Add terraform state registry (#36710)

Adds terraform/opentofu state registry with locking. Implements: https://github.com/go-gitea/gitea/issues/33644. I also checked [encrypted state](https://opentofu.org/docs/language/state/encryption), it works out of the box.

Docs PR: https://gitea.com/gitea/docs/pulls/357

---------

Co-authored-by: Andras Elso <elso.andras@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
TheFox0x7
2026-04-06 22:41:17 +02:00
committed by GitHub
parent dc197a0058
commit ff777cd2ad
30 changed files with 1379 additions and 58 deletions

View File

@@ -2790,6 +2790,8 @@ LEVEL = Info
;LIMIT_SIZE_SWIFT = -1 ;LIMIT_SIZE_SWIFT = -1
;; Maximum size of a Vagrant upload (`-1` means no limits, format `1000`, `1 MB`, `1 GiB`) ;; Maximum size of a Vagrant upload (`-1` means no limits, format `1000`, `1 MB`, `1 GiB`)
;LIMIT_SIZE_VAGRANT = -1 ;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) ;; 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 ;DEFAULT_RPM_SIGN_ENABLED = false
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

View File

@@ -212,6 +212,8 @@ func GetPackageDescriptorWithCache(ctx context.Context, pv *PackageVersion, c *c
metadata = &rubygems.Metadata{} metadata = &rubygems.Metadata{}
case TypeSwift: case TypeSwift:
metadata = &swift.Metadata{} metadata = &swift.Metadata{}
case TypeTerraformState:
// terraform packages have no metadata
case TypeVagrant: case TypeVagrant:
metadata = &vagrant.Metadata{} metadata = &vagrant.Metadata{}
default: default:

View File

@@ -30,28 +30,29 @@ type Type string
// List of supported packages // List of supported packages
const ( const (
TypeAlpine Type = "alpine" TypeAlpine Type = "alpine"
TypeArch Type = "arch" TypeArch Type = "arch"
TypeCargo Type = "cargo" TypeCargo Type = "cargo"
TypeChef Type = "chef" TypeChef Type = "chef"
TypeComposer Type = "composer" TypeComposer Type = "composer"
TypeConan Type = "conan" TypeConan Type = "conan"
TypeConda Type = "conda" TypeConda Type = "conda"
TypeContainer Type = "container" TypeContainer Type = "container"
TypeCran Type = "cran" TypeCran Type = "cran"
TypeDebian Type = "debian" TypeDebian Type = "debian"
TypeGeneric Type = "generic" TypeGeneric Type = "generic"
TypeGo Type = "go" TypeGo Type = "go"
TypeHelm Type = "helm" TypeHelm Type = "helm"
TypeMaven Type = "maven" TypeMaven Type = "maven"
TypeNpm Type = "npm" TypeNpm Type = "npm"
TypeNuGet Type = "nuget" TypeNuGet Type = "nuget"
TypePub Type = "pub" TypePub Type = "pub"
TypePyPI Type = "pypi" TypePyPI Type = "pypi"
TypeRpm Type = "rpm" TypeRpm Type = "rpm"
TypeRubyGems Type = "rubygems" TypeRubyGems Type = "rubygems"
TypeSwift Type = "swift" TypeSwift Type = "swift"
TypeVagrant Type = "vagrant" TypeTerraformState Type = "terraform"
TypeVagrant Type = "vagrant"
) )
var TypeList = []Type{ var TypeList = []Type{
@@ -76,6 +77,7 @@ var TypeList = []Type{
TypeRpm, TypeRpm,
TypeRubyGems, TypeRubyGems,
TypeSwift, TypeSwift,
TypeTerraformState,
TypeVagrant, TypeVagrant,
} }
@@ -124,6 +126,8 @@ func (pt Type) Name() string {
return "RubyGems" return "RubyGems"
case TypeSwift: case TypeSwift:
return "Swift" return "Swift"
case TypeTerraformState:
return "Terraform State"
case TypeVagrant: case TypeVagrant:
return "Vagrant" return "Vagrant"
} }
@@ -175,6 +179,8 @@ func (pt Type) SVGName() string {
return "gitea-rubygems" return "gitea-rubygems"
case TypeSwift: case TypeSwift:
return "gitea-swift" return "gitea-swift"
case TypeTerraformState:
return "gitea-terraform"
case TypeVagrant: case TypeVagrant:
return "gitea-vagrant" return "gitea-vagrant"
} }

View File

@@ -6,6 +6,7 @@
package json package json
import ( import (
"encoding/json"
"io" "io"
) )
@@ -20,3 +21,5 @@ func MarshalKeepOptionalEmpty(v any) ([]byte, error) {
func NewDecoderCaseInsensitive(reader io.Reader) Decoder { func NewDecoderCaseInsensitive(reader io.Reader) Decoder {
return DefaultJSONHandler.NewDecoder(reader) return DefaultJSONHandler.NewDecoder(reader)
} }
type Value = json.RawMessage

View File

@@ -8,6 +8,7 @@ package json
import ( import (
"bytes" "bytes"
jsonv1 "encoding/json" //nolint:depguard // this package wraps it 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 jsonv2 "encoding/json/v2" //nolint:depguard // this package wraps it
"io" "io"
) )
@@ -90,3 +91,5 @@ func (d *jsonV2Decoder) Decode(v any) error {
func NewDecoderCaseInsensitive(reader io.Reader) Decoder { func NewDecoderCaseInsensitive(reader io.Reader) Decoder {
return &jsonV2Decoder{reader: reader, opts: jsonV2.unmarshalCaseInsensitiveOptions} return &jsonV2Decoder{reader: reader, opts: jsonV2.unmarshalCaseInsensitiveOptions}
} }
type Value = jsontext.Value

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -16,30 +16,31 @@ var (
Storage *Storage Storage *Storage
Enabled bool Enabled bool
LimitTotalOwnerCount int64 LimitTotalOwnerCount int64
LimitTotalOwnerSize int64 LimitTotalOwnerSize int64
LimitSizeAlpine int64 LimitSizeAlpine int64
LimitSizeArch int64 LimitSizeArch int64
LimitSizeCargo int64 LimitSizeCargo int64
LimitSizeChef int64 LimitSizeChef int64
LimitSizeComposer int64 LimitSizeComposer int64
LimitSizeConan int64 LimitSizeConan int64
LimitSizeConda int64 LimitSizeConda int64
LimitSizeContainer int64 LimitSizeContainer int64
LimitSizeCran int64 LimitSizeCran int64
LimitSizeDebian int64 LimitSizeDebian int64
LimitSizeGeneric int64 LimitSizeGeneric int64
LimitSizeGo int64 LimitSizeGo int64
LimitSizeHelm int64 LimitSizeHelm int64
LimitSizeMaven int64 LimitSizeMaven int64
LimitSizeNpm int64 LimitSizeNpm int64
LimitSizeNuGet int64 LimitSizeNuGet int64
LimitSizePub int64 LimitSizePub int64
LimitSizePyPI int64 LimitSizePyPI int64
LimitSizeRpm int64 LimitSizeRpm int64
LimitSizeRubyGems int64 LimitSizeRubyGems int64
LimitSizeSwift int64 LimitSizeSwift int64
LimitSizeVagrant int64 LimitSizeTerraformState int64
LimitSizeVagrant int64
DefaultRPMSignEnabled bool DefaultRPMSignEnabled bool
}{ }{
@@ -86,6 +87,7 @@ func loadPackagesFrom(rootCfg ConfigProvider) (err error) {
Packages.LimitSizeRpm = mustBytes(sec, "LIMIT_SIZE_RPM") Packages.LimitSizeRpm = mustBytes(sec, "LIMIT_SIZE_RPM")
Packages.LimitSizeRubyGems = mustBytes(sec, "LIMIT_SIZE_RUBYGEMS") Packages.LimitSizeRubyGems = mustBytes(sec, "LIMIT_SIZE_RUBYGEMS")
Packages.LimitSizeSwift = mustBytes(sec, "LIMIT_SIZE_SWIFT") Packages.LimitSizeSwift = mustBytes(sec, "LIMIT_SIZE_SWIFT")
Packages.LimitSizeTerraformState = mustBytes(sec, "LIMIT_SIZE_TERRAFORM_STATE")
Packages.LimitSizeVagrant = mustBytes(sec, "LIMIT_SIZE_VAGRANT") Packages.LimitSizeVagrant = mustBytes(sec, "LIMIT_SIZE_VAGRANT")
Packages.DefaultRPMSignEnabled = sec.Key("DEFAULT_RPM_SIGN_ENABLED").MustBool(false) Packages.DefaultRPMSignEnabled = sec.Key("DEFAULT_RPM_SIGN_ENABLED").MustBool(false)
return nil return nil

View File

@@ -3611,6 +3611,18 @@
"packages.swift.registry": "Set up this registry from the command line:", "packages.swift.registry": "Set up this registry from the command line:",
"packages.swift.install": "Add the package in your <code>Package.swift</code> file:", "packages.swift.install": "Add the package in your <code>Package.swift</code> file:",
"packages.swift.install2": "and run the following command:", "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.vagrant.install": "To add a Vagrant box, run the following command:",
"packages.settings.link": "Link this package to a repository", "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.", "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.",

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" class="svg gitea-terraform" width="16" height="16" aria-hidden="true"><g fill-rule="evenodd"><path fill="#5c4ee5" d="M77.941 44.5v36.836L46.324 62.918V26.082zm0 0"/><path fill="#4040b2" d="m81.41 81.336 31.633-18.418V26.082L81.41 44.5zm0 0"/><path fill="#5c4ee5" d="M11.242 42.36 42.86 60.776V23.941L11.242 5.523zm66.699 43.015L46.324 66.957v36.82l31.617 18.418zm0 0"/></g></svg>

After

Width:  |  Height:  |  Size: 441 B

View File

@@ -32,6 +32,7 @@ import (
"code.gitea.io/gitea/routers/api/packages/rpm" "code.gitea.io/gitea/routers/api/packages/rpm"
"code.gitea.io/gitea/routers/api/packages/rubygems" "code.gitea.io/gitea/routers/api/packages/rubygems"
"code.gitea.io/gitea/routers/api/packages/swift" "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/routers/api/packages/vagrant"
"code.gitea.io/gitea/services/auth" "code.gitea.io/gitea/services/auth"
"code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/context"
@@ -514,6 +515,21 @@ func CommonRoutes() *web.Router {
r.Get("/identifiers", swift.CheckAcceptMediaType(swift.AcceptJSON), swift.LookupPackageIdentifiers) r.Get("/identifiers", swift.CheckAcceptMediaType(swift.AcceptJSON), swift.LookupPackageIdentifiers)
}, reqPackageAccess(perm.AccessModeRead)) }, 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("/vagrant", func() {
r.Group("/authenticate", func() { r.Group("/authenticate", func() {
r.Get("", vagrant.CheckAuthenticate) r.Get("", vagrant.CheckAuthenticate)

View File

@@ -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")))
}

View File

@@ -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)
}
}

View File

@@ -43,7 +43,7 @@ func ListPackages(ctx *context.APIContext) {
// in: query // in: query
// description: package type filter // description: package type filter
// type: string // 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 // - name: q
// in: query // in: query
// description: name filter // description: name filter

View File

@@ -47,6 +47,7 @@ import (
repo_migrations "code.gitea.io/gitea/services/migrations" repo_migrations "code.gitea.io/gitea/services/migrations"
mirror_service "code.gitea.io/gitea/services/mirror" mirror_service "code.gitea.io/gitea/services/mirror"
"code.gitea.io/gitea/services/oauth2_provider" "code.gitea.io/gitea/services/oauth2_provider"
packages_spec "code.gitea.io/gitea/services/packages/pkgspec"
pull_service "code.gitea.io/gitea/services/pull" pull_service "code.gitea.io/gitea/services/pull"
release_service "code.gitea.io/gitea/services/release" release_service "code.gitea.io/gitea/services/release"
repo_service "code.gitea.io/gitea/services/repository" repo_service "code.gitea.io/gitea/services/repository"
@@ -149,6 +150,7 @@ func InitWebInstalled(ctx context.Context) {
mustInitCtx(ctx, models.Init) mustInitCtx(ctx, models.Init)
mustInitCtx(ctx, authmodel.Init) mustInitCtx(ctx, authmodel.Init)
mustInitCtx(ctx, repo_service.Init) mustInitCtx(ctx, repo_service.Init)
mustInit(packages_spec.InitManager)
// Booting long running goroutines. // Booting long running goroutines.
mustInit(indexer_service.Init) mustInit(indexer_service.Init)

View File

@@ -8,6 +8,7 @@ import (
"errors" "errors"
"net/http" "net/http"
"net/url" "net/url"
"time"
"code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/db"
org_model "code.gitea.io/gitea/models/organization" org_model "code.gitea.io/gitea/models/organization"
@@ -18,13 +19,13 @@ import (
repo_model "code.gitea.io/gitea/models/repo" repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/container"
"code.gitea.io/gitea/modules/httplib" "code.gitea.io/gitea/modules/httplib"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/optional"
alpine_module "code.gitea.io/gitea/modules/packages/alpine" alpine_module "code.gitea.io/gitea/modules/packages/alpine"
arch_module "code.gitea.io/gitea/modules/packages/arch" arch_module "code.gitea.io/gitea/modules/packages/arch"
container_module "code.gitea.io/gitea/modules/packages/container" container_module "code.gitea.io/gitea/modules/packages/container"
debian_module "code.gitea.io/gitea/modules/packages/debian" debian_module "code.gitea.io/gitea/modules/packages/debian"
rpm_module "code.gitea.io/gitea/modules/packages/rpm" 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/setting"
"code.gitea.io/gitea/modules/templates" "code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/util"
@@ -35,6 +36,8 @@ import (
"code.gitea.io/gitea/services/forms" "code.gitea.io/gitea/services/forms"
packages_service "code.gitea.io/gitea/services/packages" packages_service "code.gitea.io/gitea/services/packages"
container_service "code.gitea.io/gitea/services/packages/container" container_service "code.gitea.io/gitea/services/packages/container"
"github.com/google/uuid"
) )
const ( const (
@@ -315,6 +318,11 @@ func ViewPackageVersion(ctx *context.Context) {
} }
ctx.Data["LatestVersions"] = pvs ctx.Data["LatestVersions"] = pvs
ctx.Data["TotalVersionCount"] = pvsTotal 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() ctx.Data["CanWritePackages"] = ctx.Package.AccessMode >= perm.AccessModeWrite || ctx.IsUserSiteAdmin()
@@ -498,14 +506,18 @@ func packageSettingsPostActionDelete(ctx *context.Context) {
ctx.Redirect(pd.PackageSettingsLink()) ctx.Redirect(pd.PackageSettingsLink())
return return
} }
if err := packages_service.RemovePackage(ctx, ctx.Doer, pd.Package); err != nil { if err := packages_service.RemovePackage(ctx, ctx.Doer, pd.Package); err != nil {
log.Error("Error deleting package: %v", err) errTr := util.ErrorAsTranslatable(err)
ctx.Flash.Error(ctx.Tr("packages.settings.delete.error")) if errTr == nil {
} else { ctx.ServerError("RemovePackage", err)
ctx.Flash.Success(ctx.Tr("packages.settings.delete.success")) 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") 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 { if err := packages_service.RemovePackageVersion(ctx, ctx.Doer, pd.Version); err != nil {
log.Error("Error deleting package version: %v", err) errTr := util.ErrorAsTranslatable(err)
ctx.Flash.Error(ctx.Tr("packages.settings.delete.error")) if errTr == nil {
ctx.ServerError("RemovePackageVersion", err)
return
}
ctx.Flash.Error(errTr.Translate(ctx.Locale))
} else { } else {
ctx.Flash.Success(ctx.Tr("packages.settings.delete.version.success")) 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 // 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 { if has, _ := packages_model.ExistVersion(ctx, &packages_model.PackageSearchOptions{PackageID: pd.Package.ID, IsInternal: optional.Some(false)}); has {
redirectURL = pd.PackageWebLink() redirectURL = pd.PackageWebLink()
} }
ctx.Redirect(redirectURL) ctx.Redirect(redirectURL)
} }
@@ -553,3 +568,56 @@ func DownloadPackageFile(ctx *context.Context) {
packages_helper.ServePackageFile(ctx, s, u, pf) 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())
}

View File

@@ -1073,6 +1073,10 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
m.Get("", user.ViewPackageVersion) m.Get("", user.ViewPackageVersion)
m.Post("", reqPackageAccess(perm.AccessModeWrite), user.PackageVersionDelete) m.Post("", reqPackageAccess(perm.AccessModeWrite), user.PackageVersionDelete)
m.Get("/{version_sub}", user.ViewPackageVersion) 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) m.Get("/files/{fileid}", user.DownloadPackageFile)
}) })
}) })

View File

@@ -15,7 +15,7 @@ import (
type PackageCleanupRuleForm struct { type PackageCleanupRuleForm struct {
ID int64 ID int64
Enabled bool 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)"` KeepCount int `binding:"In(0,1,5,10,25,50,100)"`
KeepPattern string `binding:"RegexPattern"` KeepPattern string `binding:"RegexPattern"`
RemoveDays int `binding:"In(0,7,14,30,60,90,180)"` RemoveDays int `binding:"In(0,7,14,30,60,90,180)"`

View File

@@ -32,6 +32,12 @@ var (
ErrQuotaTotalCount = errors.New("maximum allowed package count exceeded") 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 // PackageInfo describes a package
type PackageInfo struct { type PackageInfo struct {
Owner *user_model.User Owner *user_model.User
@@ -394,6 +400,8 @@ func CheckSizeQuotaExceeded(ctx context.Context, doer, owner *user_model.User, p
typeSpecificSize = setting.Packages.LimitSizeRubyGems typeSpecificSize = setting.Packages.LimitSizeRubyGems
case packages_model.TypeSwift: case packages_model.TypeSwift:
typeSpecificSize = setting.Packages.LimitSizeSwift typeSpecificSize = setting.Packages.LimitSizeSwift
case packages_model.TypeTerraformState:
typeSpecificSize = setting.Packages.LimitSizeTerraformState
case packages_model.TypeVagrant: case packages_model.TypeVagrant:
typeSpecificSize = setting.Packages.LimitSizeVagrant typeSpecificSize = setting.Packages.LimitSizeVagrant
} }
@@ -473,6 +481,9 @@ func RemovePackageVersion(ctx context.Context, doer *user_model.User, pv *packag
if err != nil { if err != nil {
return err 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. // 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 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 { 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 { if err != nil {
return err 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. // 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 = db.WithTx(ctx, func(ctx context.Context) error {
err := packages_model.DeletePropertiesByPackageID(ctx, packages_model.PropertyTypePackage, p.ID) err := packages_model.DeletePropertiesByPackageID(ctx, packages_model.PropertyTypePackage, p.ID)

View File

@@ -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
}

51
services/packages/spec.go Normal file
View File

@@ -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)}
})

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -0,0 +1,26 @@
{{if eq .PackageDescriptor.Package.Type "terraform"}}
<h4 class="ui top attached header">{{ctx.Locale.Tr "packages.installation"}}</h4>
<div class="ui attached segment">
<div class="ui form">
<div class="field">
<label>{{svg "octicon-code"}} {{ctx.Locale.Tr "packages.terraform.install"}}</label>
<div class="markup"><pre class="code-block"><code>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"
}
}</code></pre></div>
</div>
<div class="field">
<label>{{svg "octicon-terminal"}} {{ctx.Locale.Tr "packages.terraform.install2"}}</label>
<div class="markup"><pre class="code-block"><code>terraform init -migrate-state</code></pre></div>
</div>
<div class="field">
<label>{{ctx.Locale.Tr "packages.registry.documentation" "Terraform" "https://docs.gitea.com/usage/packages/terraform"}}</label>
</div>
</div>
</div>
{{end}}

View File

@@ -0,0 +1,41 @@
{{if eq .PackageDescriptor.Package.Type "terraform"}}
{{$data := $.PackageVersionViewData}}
{{if $data.IsLatestVersion}}
<div class="divider"></div>
<div class="item tw-flex tw-flex-col tw-gap-2">
<div>
<strong>{{ctx.Locale.Tr "packages.terraform.lock_status"}}</strong>
</div>
<div>
{{if $data.TerraformLock}}
<div class="flex-text-block">
{{svg "octicon-lock" 16 "tw-text-red"}}
<span>{{ctx.Locale.Tr "packages.terraform.locked_by" $data.TerraformLock.Who}}</span>
</div>
<div class="tw-text-xs tw-ml-6 tw-break-anywhere">
{{DateUtils.TimeSince $data.TerraformLock.Created}} ({{$data.TerraformLock.Operation}})
</div>
{{if .CanWritePackages}}
<div>
<form action="{{.PackageDescriptor.VersionWebLink}}/terraform/unlock" method="post">
<button class="ui tiny button tw-w-full">{{ctx.Locale.Tr "packages.terraform.unlock"}}</button>
</form>
</div>
{{end}}
{{else}}
<div class="flex-text-block">
{{svg "octicon-unlock" 16 "tw-text-green"}}
<span>{{ctx.Locale.Tr "packages.terraform.unlocked"}}</span>
</div>
{{if .CanWritePackages}}
<div>
<form action="{{.PackageDescriptor.VersionWebLink}}/terraform/lock" method="post">
<button class="ui tiny button tw-w-full">{{ctx.Locale.Tr "packages.terraform.lock"}}</button>
</form>
</div>
{{end}}
{{end}}
</div>
</div>
{{end}}
{{end}}

View File

@@ -33,6 +33,7 @@
{{template "package/content/rpm" .}} {{template "package/content/rpm" .}}
{{template "package/content/rubygems" .}} {{template "package/content/rubygems" .}}
{{template "package/content/swift" .}} {{template "package/content/swift" .}}
{{template "package/content/terraform" .}}
{{template "package/content/vagrant" .}} {{template "package/content/vagrant" .}}
</div> </div>
<div class="ui segment packages-content-right"> <div class="ui segment packages-content-right">
@@ -64,6 +65,7 @@
{{template "package/metadata/rpm" .}} {{template "package/metadata/rpm" .}}
{{template "package/metadata/rubygems" .}} {{template "package/metadata/rubygems" .}}
{{template "package/metadata/swift" .}} {{template "package/metadata/swift" .}}
{{template "package/metadata/terraform" .}}
{{template "package/metadata/vagrant" .}} {{template "package/metadata/vagrant" .}}
{{if not (and (eq .PackageDescriptor.Package.Type "container") .PackageDescriptor.Metadata.Manifests)}} {{if not (and (eq .PackageDescriptor.Package.Type "container") .PackageDescriptor.Metadata.Manifests)}}
<div class="item">{{svg "octicon-database"}} {{FileSize .PackageDescriptor.CalculateBlobSize}}</div> <div class="item">{{svg "octicon-database"}} {{FileSize .PackageDescriptor.CalculateBlobSize}}</div>

View File

@@ -3,12 +3,14 @@
<div role="main" aria-label="{{.Title}}" class="page-content organization packages"> <div role="main" aria-label="{{.Title}}" class="page-content organization packages">
{{template "org/header" .}} {{template "org/header" .}}
<div class="ui container"> <div class="ui container">
{{template "base/alert" .}}
{{template "package/shared/view" .}} {{template "package/shared/view" .}}
</div> </div>
</div> </div>
{{else}} {{else}}
<div role="main" aria-label="{{.Title}}" class="page-content user profile packages"> <div role="main" aria-label="{{.Title}}" class="page-content user profile packages">
<div class="ui container"> <div class="ui container">
{{template "base/alert" .}}
<div class="ui stackable grid"> <div class="ui stackable grid">
<div class="ui four wide column"> <div class="ui four wide column">
{{template "shared/user/profile_big_avatar" .}} {{template "shared/user/profile_big_avatar" .}}

View File

@@ -3835,6 +3835,7 @@
"rpm", "rpm",
"rubygems", "rubygems",
"swift", "swift",
"terraform",
"vagrant" "vagrant"
], ],
"type": "string", "type": "string",

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,2 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><g fill-rule="evenodd"><path d="M77.941 44.5v36.836L46.324 62.918V26.082zm0 0" fill="#5c4ee5"/><path d="M81.41 81.336l31.633-18.418V26.082L81.41 44.5zm0 0" fill="#4040b2"/><path d="M11.242 42.36L42.86 60.776V23.941L11.242 5.523zm0 0M77.941 85.375L46.324 66.957v36.82l31.617 18.418zm0 0" fill="#5c4ee5"/></g></svg>

After

Width:  |  Height:  |  Size: 377 B