mirror of
https://github.com/go-gitea/gitea.git
synced 2026-04-09 21:45:25 +02:00
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:
parent
dc197a0058
commit
ff777cd2ad
@ -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
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -30,28 +30,29 @@ type Type string
|
||||
|
||||
// List of supported packages
|
||||
const (
|
||||
TypeAlpine Type = "alpine"
|
||||
TypeArch Type = "arch"
|
||||
TypeCargo Type = "cargo"
|
||||
TypeChef Type = "chef"
|
||||
TypeComposer Type = "composer"
|
||||
TypeConan Type = "conan"
|
||||
TypeConda Type = "conda"
|
||||
TypeContainer Type = "container"
|
||||
TypeCran Type = "cran"
|
||||
TypeDebian Type = "debian"
|
||||
TypeGeneric Type = "generic"
|
||||
TypeGo Type = "go"
|
||||
TypeHelm Type = "helm"
|
||||
TypeMaven Type = "maven"
|
||||
TypeNpm Type = "npm"
|
||||
TypeNuGet Type = "nuget"
|
||||
TypePub Type = "pub"
|
||||
TypePyPI Type = "pypi"
|
||||
TypeRpm Type = "rpm"
|
||||
TypeRubyGems Type = "rubygems"
|
||||
TypeSwift Type = "swift"
|
||||
TypeVagrant Type = "vagrant"
|
||||
TypeAlpine Type = "alpine"
|
||||
TypeArch Type = "arch"
|
||||
TypeCargo Type = "cargo"
|
||||
TypeChef Type = "chef"
|
||||
TypeComposer Type = "composer"
|
||||
TypeConan Type = "conan"
|
||||
TypeConda Type = "conda"
|
||||
TypeContainer Type = "container"
|
||||
TypeCran Type = "cran"
|
||||
TypeDebian Type = "debian"
|
||||
TypeGeneric Type = "generic"
|
||||
TypeGo Type = "go"
|
||||
TypeHelm Type = "helm"
|
||||
TypeMaven Type = "maven"
|
||||
TypeNpm Type = "npm"
|
||||
TypeNuGet Type = "nuget"
|
||||
TypePub Type = "pub"
|
||||
TypePyPI Type = "pypi"
|
||||
TypeRpm Type = "rpm"
|
||||
TypeRubyGems Type = "rubygems"
|
||||
TypeSwift Type = "swift"
|
||||
TypeTerraformState Type = "terraform"
|
||||
TypeVagrant Type = "vagrant"
|
||||
)
|
||||
|
||||
var TypeList = []Type{
|
||||
@ -76,6 +77,7 @@ var TypeList = []Type{
|
||||
TypeRpm,
|
||||
TypeRubyGems,
|
||||
TypeSwift,
|
||||
TypeTerraformState,
|
||||
TypeVagrant,
|
||||
}
|
||||
|
||||
@ -124,6 +126,8 @@ func (pt Type) Name() string {
|
||||
return "RubyGems"
|
||||
case TypeSwift:
|
||||
return "Swift"
|
||||
case TypeTerraformState:
|
||||
return "Terraform State"
|
||||
case TypeVagrant:
|
||||
return "Vagrant"
|
||||
}
|
||||
@ -175,6 +179,8 @@ func (pt Type) SVGName() string {
|
||||
return "gitea-rubygems"
|
||||
case TypeSwift:
|
||||
return "gitea-swift"
|
||||
case TypeTerraformState:
|
||||
return "gitea-terraform"
|
||||
case TypeVagrant:
|
||||
return "gitea-vagrant"
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
100
modules/packages/terraform/lock.go
Normal file
100
modules/packages/terraform/lock.go
Normal 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
|
||||
}
|
||||
38
modules/packages/terraform/state.go
Normal file
38
modules/packages/terraform/state.go
Normal 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
|
||||
}
|
||||
@ -16,30 +16,31 @@ var (
|
||||
Storage *Storage
|
||||
Enabled bool
|
||||
|
||||
LimitTotalOwnerCount int64
|
||||
LimitTotalOwnerSize int64
|
||||
LimitSizeAlpine int64
|
||||
LimitSizeArch int64
|
||||
LimitSizeCargo int64
|
||||
LimitSizeChef int64
|
||||
LimitSizeComposer int64
|
||||
LimitSizeConan int64
|
||||
LimitSizeConda int64
|
||||
LimitSizeContainer int64
|
||||
LimitSizeCran int64
|
||||
LimitSizeDebian int64
|
||||
LimitSizeGeneric int64
|
||||
LimitSizeGo int64
|
||||
LimitSizeHelm int64
|
||||
LimitSizeMaven int64
|
||||
LimitSizeNpm int64
|
||||
LimitSizeNuGet int64
|
||||
LimitSizePub int64
|
||||
LimitSizePyPI int64
|
||||
LimitSizeRpm int64
|
||||
LimitSizeRubyGems int64
|
||||
LimitSizeSwift int64
|
||||
LimitSizeVagrant int64
|
||||
LimitTotalOwnerCount int64
|
||||
LimitTotalOwnerSize int64
|
||||
LimitSizeAlpine int64
|
||||
LimitSizeArch int64
|
||||
LimitSizeCargo int64
|
||||
LimitSizeChef int64
|
||||
LimitSizeComposer int64
|
||||
LimitSizeConan int64
|
||||
LimitSizeConda int64
|
||||
LimitSizeContainer int64
|
||||
LimitSizeCran int64
|
||||
LimitSizeDebian int64
|
||||
LimitSizeGeneric int64
|
||||
LimitSizeGo int64
|
||||
LimitSizeHelm int64
|
||||
LimitSizeMaven int64
|
||||
LimitSizeNpm int64
|
||||
LimitSizeNuGet int64
|
||||
LimitSizePub int64
|
||||
LimitSizePyPI int64
|
||||
LimitSizeRpm int64
|
||||
LimitSizeRubyGems int64
|
||||
LimitSizeSwift int64
|
||||
LimitSizeTerraformState int64
|
||||
LimitSizeVagrant int64
|
||||
|
||||
DefaultRPMSignEnabled bool
|
||||
}{
|
||||
@ -86,6 +87,7 @@ func loadPackagesFrom(rootCfg ConfigProvider) (err error) {
|
||||
Packages.LimitSizeRpm = mustBytes(sec, "LIMIT_SIZE_RPM")
|
||||
Packages.LimitSizeRubyGems = mustBytes(sec, "LIMIT_SIZE_RUBYGEMS")
|
||||
Packages.LimitSizeSwift = mustBytes(sec, "LIMIT_SIZE_SWIFT")
|
||||
Packages.LimitSizeTerraformState = mustBytes(sec, "LIMIT_SIZE_TERRAFORM_STATE")
|
||||
Packages.LimitSizeVagrant = mustBytes(sec, "LIMIT_SIZE_VAGRANT")
|
||||
Packages.DefaultRPMSignEnabled = sec.Key("DEFAULT_RPM_SIGN_ENABLED").MustBool(false)
|
||||
return nil
|
||||
|
||||
@ -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.",
|
||||
|
||||
1
public/assets/img/svg/gitea-terraform.svg
generated
Normal file
1
public/assets/img/svg/gitea-terraform.svg
generated
Normal 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 |
@ -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)
|
||||
|
||||
438
routers/api/packages/terraform/terraform.go
Normal file
438
routers/api/packages/terraform/terraform.go
Normal 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")))
|
||||
}
|
||||
36
routers/api/packages/terraform/terraform_test.go
Normal file
36
routers/api/packages/terraform/terraform_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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())
|
||||
}
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)"`
|
||||
|
||||
@ -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)
|
||||
|
||||
17
services/packages/pkgspec/manager.go
Normal file
17
services/packages/pkgspec/manager.go
Normal 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
51
services/packages/spec.go
Normal 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)}
|
||||
})
|
||||
85
services/packages/terraform/spec.go
Normal file
85
services/packages/terraform/spec.go
Normal 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
|
||||
}
|
||||
44
services/packages/terraform/state.go
Normal file
44
services/packages/terraform/state.go
Normal 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
|
||||
}
|
||||
26
templates/package/content/terraform.tmpl
Normal file
26
templates/package/content/terraform.tmpl
Normal 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}}
|
||||
41
templates/package/metadata/terraform.tmpl
Normal file
41
templates/package/metadata/terraform.tmpl
Normal 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}}
|
||||
@ -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>
|
||||
|
||||
@ -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" .}}
|
||||
|
||||
1
templates/swagger/v1_json.tmpl
generated
1
templates/swagger/v1_json.tmpl
generated
@ -3835,6 +3835,7 @@
|
||||
"rpm",
|
||||
"rubygems",
|
||||
"swift",
|
||||
"terraform",
|
||||
"vagrant"
|
||||
],
|
||||
"type": "string",
|
||||
|
||||
302
tests/integration/api_packages_terraform_test.go
Normal file
302
tests/integration/api_packages_terraform_test.go
Normal file
File diff suppressed because one or more lines are too long
2
web_src/svg/gitea-terraform.svg
Normal file
2
web_src/svg/gitea-terraform.svg
Normal 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 |
Loading…
x
Reference in New Issue
Block a user