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
;; 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
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

View File

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

View File

@@ -51,6 +51,7 @@ const (
TypeRpm Type = "rpm"
TypeRubyGems Type = "rubygems"
TypeSwift Type = "swift"
TypeTerraformState Type = "terraform"
TypeVagrant Type = "vagrant"
)
@@ -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"
}

View File

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

View File

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

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

@@ -39,6 +39,7 @@ var (
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

View File

@@ -3611,6 +3611,18 @@
"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.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.",

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/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)

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
// 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

View File

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

View File

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

View File

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

View File

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

View File

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

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/rubygems" .}}
{{template "package/content/swift" .}}
{{template "package/content/terraform" .}}
{{template "package/content/vagrant" .}}
</div>
<div class="ui segment packages-content-right">
@@ -64,6 +65,7 @@
{{template "package/metadata/rpm" .}}
{{template "package/metadata/rubygems" .}}
{{template "package/metadata/swift" .}}
{{template "package/metadata/terraform" .}}
{{template "package/metadata/vagrant" .}}
{{if not (and (eq .PackageDescriptor.Package.Type "container") .PackageDescriptor.Metadata.Manifests)}}
<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">
{{template "org/header" .}}
<div class="ui container">
{{template "base/alert" .}}
{{template "package/shared/view" .}}
</div>
</div>
{{else}}
<div role="main" aria-label="{{.Title}}" class="page-content user profile packages">
<div class="ui container">
{{template "base/alert" .}}
<div class="ui stackable grid">
<div class="ui four wide column">
{{template "shared/user/profile_big_avatar" .}}

View File

@@ -3835,6 +3835,7 @@
"rpm",
"rubygems",
"swift",
"terraform",
"vagrant"
],
"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