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:
@@ -2790,6 +2790,8 @@ LEVEL = Info
|
|||||||
;LIMIT_SIZE_SWIFT = -1
|
;LIMIT_SIZE_SWIFT = -1
|
||||||
;; Maximum size of a Vagrant upload (`-1` means no limits, format `1000`, `1 MB`, `1 GiB`)
|
;; Maximum size of a Vagrant upload (`-1` means no limits, format `1000`, `1 MB`, `1 GiB`)
|
||||||
;LIMIT_SIZE_VAGRANT = -1
|
;LIMIT_SIZE_VAGRANT = -1
|
||||||
|
;; Maximum size of a Terraform state upload (`-1` means no limits, format `1000`, `1 MB`, `1 GiB`)
|
||||||
|
;LIMIT_SIZE_TERRAFORM_STATE = -1
|
||||||
;; Enable RPM re-signing by default. (It will overwrite the old signature ,using v4 format, not compatible with CentOS 6 or older)
|
;; Enable RPM re-signing by default. (It will overwrite the old signature ,using v4 format, not compatible with CentOS 6 or older)
|
||||||
;DEFAULT_RPM_SIGN_ENABLED = false
|
;DEFAULT_RPM_SIGN_ENABLED = false
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
|||||||
@@ -212,6 +212,8 @@ func GetPackageDescriptorWithCache(ctx context.Context, pv *PackageVersion, c *c
|
|||||||
metadata = &rubygems.Metadata{}
|
metadata = &rubygems.Metadata{}
|
||||||
case TypeSwift:
|
case TypeSwift:
|
||||||
metadata = &swift.Metadata{}
|
metadata = &swift.Metadata{}
|
||||||
|
case TypeTerraformState:
|
||||||
|
// terraform packages have no metadata
|
||||||
case TypeVagrant:
|
case TypeVagrant:
|
||||||
metadata = &vagrant.Metadata{}
|
metadata = &vagrant.Metadata{}
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -30,28 +30,29 @@ type Type string
|
|||||||
|
|
||||||
// List of supported packages
|
// List of supported packages
|
||||||
const (
|
const (
|
||||||
TypeAlpine Type = "alpine"
|
TypeAlpine Type = "alpine"
|
||||||
TypeArch Type = "arch"
|
TypeArch Type = "arch"
|
||||||
TypeCargo Type = "cargo"
|
TypeCargo Type = "cargo"
|
||||||
TypeChef Type = "chef"
|
TypeChef Type = "chef"
|
||||||
TypeComposer Type = "composer"
|
TypeComposer Type = "composer"
|
||||||
TypeConan Type = "conan"
|
TypeConan Type = "conan"
|
||||||
TypeConda Type = "conda"
|
TypeConda Type = "conda"
|
||||||
TypeContainer Type = "container"
|
TypeContainer Type = "container"
|
||||||
TypeCran Type = "cran"
|
TypeCran Type = "cran"
|
||||||
TypeDebian Type = "debian"
|
TypeDebian Type = "debian"
|
||||||
TypeGeneric Type = "generic"
|
TypeGeneric Type = "generic"
|
||||||
TypeGo Type = "go"
|
TypeGo Type = "go"
|
||||||
TypeHelm Type = "helm"
|
TypeHelm Type = "helm"
|
||||||
TypeMaven Type = "maven"
|
TypeMaven Type = "maven"
|
||||||
TypeNpm Type = "npm"
|
TypeNpm Type = "npm"
|
||||||
TypeNuGet Type = "nuget"
|
TypeNuGet Type = "nuget"
|
||||||
TypePub Type = "pub"
|
TypePub Type = "pub"
|
||||||
TypePyPI Type = "pypi"
|
TypePyPI Type = "pypi"
|
||||||
TypeRpm Type = "rpm"
|
TypeRpm Type = "rpm"
|
||||||
TypeRubyGems Type = "rubygems"
|
TypeRubyGems Type = "rubygems"
|
||||||
TypeSwift Type = "swift"
|
TypeSwift Type = "swift"
|
||||||
TypeVagrant Type = "vagrant"
|
TypeTerraformState Type = "terraform"
|
||||||
|
TypeVagrant Type = "vagrant"
|
||||||
)
|
)
|
||||||
|
|
||||||
var TypeList = []Type{
|
var TypeList = []Type{
|
||||||
@@ -76,6 +77,7 @@ var TypeList = []Type{
|
|||||||
TypeRpm,
|
TypeRpm,
|
||||||
TypeRubyGems,
|
TypeRubyGems,
|
||||||
TypeSwift,
|
TypeSwift,
|
||||||
|
TypeTerraformState,
|
||||||
TypeVagrant,
|
TypeVagrant,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,6 +126,8 @@ func (pt Type) Name() string {
|
|||||||
return "RubyGems"
|
return "RubyGems"
|
||||||
case TypeSwift:
|
case TypeSwift:
|
||||||
return "Swift"
|
return "Swift"
|
||||||
|
case TypeTerraformState:
|
||||||
|
return "Terraform State"
|
||||||
case TypeVagrant:
|
case TypeVagrant:
|
||||||
return "Vagrant"
|
return "Vagrant"
|
||||||
}
|
}
|
||||||
@@ -175,6 +179,8 @@ func (pt Type) SVGName() string {
|
|||||||
return "gitea-rubygems"
|
return "gitea-rubygems"
|
||||||
case TypeSwift:
|
case TypeSwift:
|
||||||
return "gitea-swift"
|
return "gitea-swift"
|
||||||
|
case TypeTerraformState:
|
||||||
|
return "gitea-terraform"
|
||||||
case TypeVagrant:
|
case TypeVagrant:
|
||||||
return "gitea-vagrant"
|
return "gitea-vagrant"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
package json
|
package json
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -20,3 +21,5 @@ func MarshalKeepOptionalEmpty(v any) ([]byte, error) {
|
|||||||
func NewDecoderCaseInsensitive(reader io.Reader) Decoder {
|
func NewDecoderCaseInsensitive(reader io.Reader) Decoder {
|
||||||
return DefaultJSONHandler.NewDecoder(reader)
|
return DefaultJSONHandler.NewDecoder(reader)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Value = json.RawMessage
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ package json
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
jsonv1 "encoding/json" //nolint:depguard // this package wraps it
|
jsonv1 "encoding/json" //nolint:depguard // this package wraps it
|
||||||
|
"encoding/json/jsontext" //nolint:depguard // this package wraps it
|
||||||
jsonv2 "encoding/json/v2" //nolint:depguard // this package wraps it
|
jsonv2 "encoding/json/v2" //nolint:depguard // this package wraps it
|
||||||
"io"
|
"io"
|
||||||
)
|
)
|
||||||
@@ -90,3 +91,5 @@ func (d *jsonV2Decoder) Decode(v any) error {
|
|||||||
func NewDecoderCaseInsensitive(reader io.Reader) Decoder {
|
func NewDecoderCaseInsensitive(reader io.Reader) Decoder {
|
||||||
return &jsonV2Decoder{reader: reader, opts: jsonV2.unmarshalCaseInsensitiveOptions}
|
return &jsonV2Decoder{reader: reader, opts: jsonV2.unmarshalCaseInsensitiveOptions}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Value = jsontext.Value
|
||||||
|
|||||||
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
|
Storage *Storage
|
||||||
Enabled bool
|
Enabled bool
|
||||||
|
|
||||||
LimitTotalOwnerCount int64
|
LimitTotalOwnerCount int64
|
||||||
LimitTotalOwnerSize int64
|
LimitTotalOwnerSize int64
|
||||||
LimitSizeAlpine int64
|
LimitSizeAlpine int64
|
||||||
LimitSizeArch int64
|
LimitSizeArch int64
|
||||||
LimitSizeCargo int64
|
LimitSizeCargo int64
|
||||||
LimitSizeChef int64
|
LimitSizeChef int64
|
||||||
LimitSizeComposer int64
|
LimitSizeComposer int64
|
||||||
LimitSizeConan int64
|
LimitSizeConan int64
|
||||||
LimitSizeConda int64
|
LimitSizeConda int64
|
||||||
LimitSizeContainer int64
|
LimitSizeContainer int64
|
||||||
LimitSizeCran int64
|
LimitSizeCran int64
|
||||||
LimitSizeDebian int64
|
LimitSizeDebian int64
|
||||||
LimitSizeGeneric int64
|
LimitSizeGeneric int64
|
||||||
LimitSizeGo int64
|
LimitSizeGo int64
|
||||||
LimitSizeHelm int64
|
LimitSizeHelm int64
|
||||||
LimitSizeMaven int64
|
LimitSizeMaven int64
|
||||||
LimitSizeNpm int64
|
LimitSizeNpm int64
|
||||||
LimitSizeNuGet int64
|
LimitSizeNuGet int64
|
||||||
LimitSizePub int64
|
LimitSizePub int64
|
||||||
LimitSizePyPI int64
|
LimitSizePyPI int64
|
||||||
LimitSizeRpm int64
|
LimitSizeRpm int64
|
||||||
LimitSizeRubyGems int64
|
LimitSizeRubyGems int64
|
||||||
LimitSizeSwift int64
|
LimitSizeSwift int64
|
||||||
LimitSizeVagrant int64
|
LimitSizeTerraformState int64
|
||||||
|
LimitSizeVagrant int64
|
||||||
|
|
||||||
DefaultRPMSignEnabled bool
|
DefaultRPMSignEnabled bool
|
||||||
}{
|
}{
|
||||||
@@ -86,6 +87,7 @@ func loadPackagesFrom(rootCfg ConfigProvider) (err error) {
|
|||||||
Packages.LimitSizeRpm = mustBytes(sec, "LIMIT_SIZE_RPM")
|
Packages.LimitSizeRpm = mustBytes(sec, "LIMIT_SIZE_RPM")
|
||||||
Packages.LimitSizeRubyGems = mustBytes(sec, "LIMIT_SIZE_RUBYGEMS")
|
Packages.LimitSizeRubyGems = mustBytes(sec, "LIMIT_SIZE_RUBYGEMS")
|
||||||
Packages.LimitSizeSwift = mustBytes(sec, "LIMIT_SIZE_SWIFT")
|
Packages.LimitSizeSwift = mustBytes(sec, "LIMIT_SIZE_SWIFT")
|
||||||
|
Packages.LimitSizeTerraformState = mustBytes(sec, "LIMIT_SIZE_TERRAFORM_STATE")
|
||||||
Packages.LimitSizeVagrant = mustBytes(sec, "LIMIT_SIZE_VAGRANT")
|
Packages.LimitSizeVagrant = mustBytes(sec, "LIMIT_SIZE_VAGRANT")
|
||||||
Packages.DefaultRPMSignEnabled = sec.Key("DEFAULT_RPM_SIGN_ENABLED").MustBool(false)
|
Packages.DefaultRPMSignEnabled = sec.Key("DEFAULT_RPM_SIGN_ENABLED").MustBool(false)
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -3611,6 +3611,18 @@
|
|||||||
"packages.swift.registry": "Set up this registry from the command line:",
|
"packages.swift.registry": "Set up this registry from the command line:",
|
||||||
"packages.swift.install": "Add the package in your <code>Package.swift</code> file:",
|
"packages.swift.install": "Add the package in your <code>Package.swift</code> file:",
|
||||||
"packages.swift.install2": "and run the following command:",
|
"packages.swift.install2": "and run the following command:",
|
||||||
|
"packages.terraform.install": "Set your state to use the HTTP backend",
|
||||||
|
"packages.terraform.install2": "and run the following command:",
|
||||||
|
"packages.terraform.lock_status": "Lock Status",
|
||||||
|
"packages.terraform.locked_by": "Locked by %s",
|
||||||
|
"packages.terraform.unlocked": "Unlocked",
|
||||||
|
"packages.terraform.lock": "Lock",
|
||||||
|
"packages.terraform.unlock": "Unlock",
|
||||||
|
"packages.terraform.lock.success": "Terraform state was successfully locked.",
|
||||||
|
"packages.terraform.unlock.success": "Terraform state was successfully unlocked.",
|
||||||
|
"packages.terraform.lock.error.already_locked": "Terraform state is already locked.",
|
||||||
|
"packages.terraform.delete.locked": "Terraform state is locked and cannot be deleted.",
|
||||||
|
"packages.terraform.delete.latest": "The latest version of a Terraform state cannot be deleted.",
|
||||||
"packages.vagrant.install": "To add a Vagrant box, run the following command:",
|
"packages.vagrant.install": "To add a Vagrant box, run the following command:",
|
||||||
"packages.settings.link": "Link this package to a repository",
|
"packages.settings.link": "Link this package to a repository",
|
||||||
"packages.settings.link.description": "If you link a package with a repository, the package will appear in the repository's package list. Only repositories under the same owner can be linked. Leaving the field empty will remove the link.",
|
"packages.settings.link.description": "If you link a package with a repository, the package will appear in the repository's package list. Only repositories under the same owner can be linked. Leaving the field empty will remove the link.",
|
||||||
|
|||||||
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/rpm"
|
||||||
"code.gitea.io/gitea/routers/api/packages/rubygems"
|
"code.gitea.io/gitea/routers/api/packages/rubygems"
|
||||||
"code.gitea.io/gitea/routers/api/packages/swift"
|
"code.gitea.io/gitea/routers/api/packages/swift"
|
||||||
|
"code.gitea.io/gitea/routers/api/packages/terraform"
|
||||||
"code.gitea.io/gitea/routers/api/packages/vagrant"
|
"code.gitea.io/gitea/routers/api/packages/vagrant"
|
||||||
"code.gitea.io/gitea/services/auth"
|
"code.gitea.io/gitea/services/auth"
|
||||||
"code.gitea.io/gitea/services/context"
|
"code.gitea.io/gitea/services/context"
|
||||||
@@ -514,6 +515,21 @@ func CommonRoutes() *web.Router {
|
|||||||
r.Get("/identifiers", swift.CheckAcceptMediaType(swift.AcceptJSON), swift.LookupPackageIdentifiers)
|
r.Get("/identifiers", swift.CheckAcceptMediaType(swift.AcceptJSON), swift.LookupPackageIdentifiers)
|
||||||
}, reqPackageAccess(perm.AccessModeRead))
|
}, reqPackageAccess(perm.AccessModeRead))
|
||||||
})
|
})
|
||||||
|
// See https://docs.gitlab.com/ci/jobs/fine_grained_permissions/#terraform-state-endpoints
|
||||||
|
// For endpoint and permission reference
|
||||||
|
r.Group("/terraform/state/{name}", func() {
|
||||||
|
r.Get("", terraform.GetTerraformState)
|
||||||
|
r.Get("/versions/{serial}", terraform.GetTerraformStateBySerial)
|
||||||
|
r.Group("", func() {
|
||||||
|
r.Post("", terraform.UploadState)
|
||||||
|
r.Delete("", terraform.DeleteState)
|
||||||
|
r.Delete("/versions/{serial}", terraform.DeleteStateBySerial)
|
||||||
|
}, reqPackageAccess(perm.AccessModeWrite))
|
||||||
|
r.Group("/lock", func() {
|
||||||
|
r.Post("", terraform.LockState)
|
||||||
|
r.Delete("", terraform.UnlockState)
|
||||||
|
}, reqPackageAccess(perm.AccessModeWrite))
|
||||||
|
}, reqPackageAccess(perm.AccessModeRead))
|
||||||
r.Group("/vagrant", func() {
|
r.Group("/vagrant", func() {
|
||||||
r.Group("/authenticate", func() {
|
r.Group("/authenticate", func() {
|
||||||
r.Get("", vagrant.CheckAuthenticate)
|
r.Get("", vagrant.CheckAuthenticate)
|
||||||
|
|||||||
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
|
// in: query
|
||||||
// description: package type filter
|
// description: package type filter
|
||||||
// type: string
|
// type: string
|
||||||
// enum: [alpine, cargo, chef, composer, conan, conda, container, cran, debian, generic, go, helm, maven, npm, nuget, pub, pypi, rpm, rubygems, swift, vagrant]
|
// enum: [alpine, cargo, chef, composer, conan, conda, container, cran, debian, generic, go, helm, maven, npm, nuget, pub, pypi, rpm, rubygems, swift, terraform, vagrant]
|
||||||
// - name: q
|
// - name: q
|
||||||
// in: query
|
// in: query
|
||||||
// description: name filter
|
// description: name filter
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ import (
|
|||||||
repo_migrations "code.gitea.io/gitea/services/migrations"
|
repo_migrations "code.gitea.io/gitea/services/migrations"
|
||||||
mirror_service "code.gitea.io/gitea/services/mirror"
|
mirror_service "code.gitea.io/gitea/services/mirror"
|
||||||
"code.gitea.io/gitea/services/oauth2_provider"
|
"code.gitea.io/gitea/services/oauth2_provider"
|
||||||
|
packages_spec "code.gitea.io/gitea/services/packages/pkgspec"
|
||||||
pull_service "code.gitea.io/gitea/services/pull"
|
pull_service "code.gitea.io/gitea/services/pull"
|
||||||
release_service "code.gitea.io/gitea/services/release"
|
release_service "code.gitea.io/gitea/services/release"
|
||||||
repo_service "code.gitea.io/gitea/services/repository"
|
repo_service "code.gitea.io/gitea/services/repository"
|
||||||
@@ -149,6 +150,7 @@ func InitWebInstalled(ctx context.Context) {
|
|||||||
mustInitCtx(ctx, models.Init)
|
mustInitCtx(ctx, models.Init)
|
||||||
mustInitCtx(ctx, authmodel.Init)
|
mustInitCtx(ctx, authmodel.Init)
|
||||||
mustInitCtx(ctx, repo_service.Init)
|
mustInitCtx(ctx, repo_service.Init)
|
||||||
|
mustInit(packages_spec.InitManager)
|
||||||
|
|
||||||
// Booting long running goroutines.
|
// Booting long running goroutines.
|
||||||
mustInit(indexer_service.Init)
|
mustInit(indexer_service.Init)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"time"
|
||||||
|
|
||||||
"code.gitea.io/gitea/models/db"
|
"code.gitea.io/gitea/models/db"
|
||||||
org_model "code.gitea.io/gitea/models/organization"
|
org_model "code.gitea.io/gitea/models/organization"
|
||||||
@@ -18,13 +19,13 @@ import (
|
|||||||
repo_model "code.gitea.io/gitea/models/repo"
|
repo_model "code.gitea.io/gitea/models/repo"
|
||||||
"code.gitea.io/gitea/modules/container"
|
"code.gitea.io/gitea/modules/container"
|
||||||
"code.gitea.io/gitea/modules/httplib"
|
"code.gitea.io/gitea/modules/httplib"
|
||||||
"code.gitea.io/gitea/modules/log"
|
|
||||||
"code.gitea.io/gitea/modules/optional"
|
"code.gitea.io/gitea/modules/optional"
|
||||||
alpine_module "code.gitea.io/gitea/modules/packages/alpine"
|
alpine_module "code.gitea.io/gitea/modules/packages/alpine"
|
||||||
arch_module "code.gitea.io/gitea/modules/packages/arch"
|
arch_module "code.gitea.io/gitea/modules/packages/arch"
|
||||||
container_module "code.gitea.io/gitea/modules/packages/container"
|
container_module "code.gitea.io/gitea/modules/packages/container"
|
||||||
debian_module "code.gitea.io/gitea/modules/packages/debian"
|
debian_module "code.gitea.io/gitea/modules/packages/debian"
|
||||||
rpm_module "code.gitea.io/gitea/modules/packages/rpm"
|
rpm_module "code.gitea.io/gitea/modules/packages/rpm"
|
||||||
|
terraform_module "code.gitea.io/gitea/modules/packages/terraform"
|
||||||
"code.gitea.io/gitea/modules/setting"
|
"code.gitea.io/gitea/modules/setting"
|
||||||
"code.gitea.io/gitea/modules/templates"
|
"code.gitea.io/gitea/modules/templates"
|
||||||
"code.gitea.io/gitea/modules/util"
|
"code.gitea.io/gitea/modules/util"
|
||||||
@@ -35,6 +36,8 @@ import (
|
|||||||
"code.gitea.io/gitea/services/forms"
|
"code.gitea.io/gitea/services/forms"
|
||||||
packages_service "code.gitea.io/gitea/services/packages"
|
packages_service "code.gitea.io/gitea/services/packages"
|
||||||
container_service "code.gitea.io/gitea/services/packages/container"
|
container_service "code.gitea.io/gitea/services/packages/container"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -315,6 +318,11 @@ func ViewPackageVersion(ctx *context.Context) {
|
|||||||
}
|
}
|
||||||
ctx.Data["LatestVersions"] = pvs
|
ctx.Data["LatestVersions"] = pvs
|
||||||
ctx.Data["TotalVersionCount"] = pvsTotal
|
ctx.Data["TotalVersionCount"] = pvsTotal
|
||||||
|
ctx.Data["PackageVersionViewData"], err = packages_service.GetSpecManager().Get(pd.Package.Type).GetViewPackageVersionData(ctx, pd)
|
||||||
|
if err != nil {
|
||||||
|
ctx.ServerError("GetViewPackageVersionData", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
ctx.Data["CanWritePackages"] = ctx.Package.AccessMode >= perm.AccessModeWrite || ctx.IsUserSiteAdmin()
|
ctx.Data["CanWritePackages"] = ctx.Package.AccessMode >= perm.AccessModeWrite || ctx.IsUserSiteAdmin()
|
||||||
|
|
||||||
@@ -498,14 +506,18 @@ func packageSettingsPostActionDelete(ctx *context.Context) {
|
|||||||
ctx.Redirect(pd.PackageSettingsLink())
|
ctx.Redirect(pd.PackageSettingsLink())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := packages_service.RemovePackage(ctx, ctx.Doer, pd.Package); err != nil {
|
if err := packages_service.RemovePackage(ctx, ctx.Doer, pd.Package); err != nil {
|
||||||
log.Error("Error deleting package: %v", err)
|
errTr := util.ErrorAsTranslatable(err)
|
||||||
ctx.Flash.Error(ctx.Tr("packages.settings.delete.error"))
|
if errTr == nil {
|
||||||
} else {
|
ctx.ServerError("RemovePackage", err)
|
||||||
ctx.Flash.Success(ctx.Tr("packages.settings.delete.success"))
|
return
|
||||||
|
}
|
||||||
|
ctx.Flash.Error(errTr.Translate(ctx.Locale))
|
||||||
|
ctx.Redirect(pd.PackageSettingsLink())
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ctx.Flash.Success(ctx.Tr("packages.settings.delete.success"))
|
||||||
ctx.Redirect(ctx.Package.Owner.HomeLink() + "/-/packages")
|
ctx.Redirect(ctx.Package.Owner.HomeLink() + "/-/packages")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -518,18 +530,21 @@ func PackageVersionDelete(ctx *context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := packages_service.RemovePackageVersion(ctx, ctx.Doer, pd.Version); err != nil {
|
if err := packages_service.RemovePackageVersion(ctx, ctx.Doer, pd.Version); err != nil {
|
||||||
log.Error("Error deleting package version: %v", err)
|
errTr := util.ErrorAsTranslatable(err)
|
||||||
ctx.Flash.Error(ctx.Tr("packages.settings.delete.error"))
|
if errTr == nil {
|
||||||
|
ctx.ServerError("RemovePackageVersion", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.Flash.Error(errTr.Translate(ctx.Locale))
|
||||||
} else {
|
} else {
|
||||||
ctx.Flash.Success(ctx.Tr("packages.settings.delete.version.success"))
|
ctx.Flash.Success(ctx.Tr("packages.settings.delete.version.success"))
|
||||||
}
|
}
|
||||||
|
|
||||||
redirectURL := ctx.Package.Owner.HomeLink() + "/-/packages"
|
|
||||||
// redirect to the package if there are still versions available
|
// redirect to the package if there are still versions available
|
||||||
|
redirectURL := ctx.Package.Owner.HomeLink() + "/-/packages"
|
||||||
if has, _ := packages_model.ExistVersion(ctx, &packages_model.PackageSearchOptions{PackageID: pd.Package.ID, IsInternal: optional.Some(false)}); has {
|
if has, _ := packages_model.ExistVersion(ctx, &packages_model.PackageSearchOptions{PackageID: pd.Package.ID, IsInternal: optional.Some(false)}); has {
|
||||||
redirectURL = pd.PackageWebLink()
|
redirectURL = pd.PackageWebLink()
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.Redirect(redirectURL)
|
ctx.Redirect(redirectURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -553,3 +568,56 @@ func DownloadPackageFile(ctx *context.Context) {
|
|||||||
|
|
||||||
packages_helper.ServePackageFile(ctx, s, u, pf)
|
packages_helper.ServePackageFile(ctx, s, u, pf)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ActionPackageTerraformLock locks a terraform state
|
||||||
|
func ActionPackageTerraformLock(ctx *context.Context) {
|
||||||
|
pd := ctx.Package.Descriptor
|
||||||
|
if pd.Package.Type != packages_model.TypeTerraformState {
|
||||||
|
ctx.NotFound(nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
existingLock, err := terraform_module.GetLock(ctx, pd.Package.ID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.ServerError("GetLock", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if existingLock.IsLocked() {
|
||||||
|
ctx.Flash.Error(ctx.Tr("packages.terraform.lock.error.already_locked"))
|
||||||
|
ctx.Redirect(pd.VersionWebLink())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
lockID := uuid.New().String()
|
||||||
|
lockInfo := &terraform_module.LockInfo{
|
||||||
|
ID: lockID,
|
||||||
|
Operation: "Manual UI Lock",
|
||||||
|
Who: ctx.Doer.Name,
|
||||||
|
Created: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := terraform_module.SetLock(ctx, pd.Package.ID, lockInfo); err != nil {
|
||||||
|
ctx.ServerError("SetLock", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Flash.Success(ctx.Tr("packages.terraform.lock.success"))
|
||||||
|
ctx.Redirect(pd.VersionWebLink())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActionPackageTerraformUnlock unlocks a terraform state
|
||||||
|
func ActionPackageTerraformUnlock(ctx *context.Context) {
|
||||||
|
pd := ctx.Package.Descriptor
|
||||||
|
if pd.Package.Type != packages_model.TypeTerraformState {
|
||||||
|
ctx.NotFound(nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := terraform_module.RemoveLock(ctx, pd.Package.ID); err != nil {
|
||||||
|
ctx.ServerError("RemoveLock", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Flash.Success(ctx.Tr("packages.terraform.unlock.success"))
|
||||||
|
ctx.Redirect(pd.VersionWebLink())
|
||||||
|
}
|
||||||
|
|||||||
@@ -1073,6 +1073,10 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
|
|||||||
m.Get("", user.ViewPackageVersion)
|
m.Get("", user.ViewPackageVersion)
|
||||||
m.Post("", reqPackageAccess(perm.AccessModeWrite), user.PackageVersionDelete)
|
m.Post("", reqPackageAccess(perm.AccessModeWrite), user.PackageVersionDelete)
|
||||||
m.Get("/{version_sub}", user.ViewPackageVersion)
|
m.Get("/{version_sub}", user.ViewPackageVersion)
|
||||||
|
m.Group("/terraform", func() {
|
||||||
|
m.Post("/lock", user.ActionPackageTerraformLock)
|
||||||
|
m.Post("/unlock", user.ActionPackageTerraformUnlock)
|
||||||
|
}, reqPackageAccess(perm.AccessModeWrite))
|
||||||
m.Get("/files/{fileid}", user.DownloadPackageFile)
|
m.Get("/files/{fileid}", user.DownloadPackageFile)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import (
|
|||||||
type PackageCleanupRuleForm struct {
|
type PackageCleanupRuleForm struct {
|
||||||
ID int64
|
ID int64
|
||||||
Enabled bool
|
Enabled bool
|
||||||
Type string `binding:"Required;In(alpine,arch,cargo,chef,composer,conan,conda,container,cran,debian,generic,go,helm,maven,npm,nuget,pub,pypi,rpm,rubygems,swift,vagrant)"`
|
Type string `binding:"Required;In(alpine,arch,cargo,chef,composer,conan,conda,container,cran,debian,generic,go,helm,maven,npm,nuget,pub,pypi,rpm,rubygems,swift,terraform,vagrant)"`
|
||||||
KeepCount int `binding:"In(0,1,5,10,25,50,100)"`
|
KeepCount int `binding:"In(0,1,5,10,25,50,100)"`
|
||||||
KeepPattern string `binding:"RegexPattern"`
|
KeepPattern string `binding:"RegexPattern"`
|
||||||
RemoveDays int `binding:"In(0,7,14,30,60,90,180)"`
|
RemoveDays int `binding:"In(0,7,14,30,60,90,180)"`
|
||||||
|
|||||||
@@ -32,6 +32,12 @@ var (
|
|||||||
ErrQuotaTotalCount = errors.New("maximum allowed package count exceeded")
|
ErrQuotaTotalCount = errors.New("maximum allowed package count exceeded")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type Specialization interface {
|
||||||
|
OnBeforeRemovePackageAll(ctx context.Context, doer *user_model.User, pkg *packages_model.Package, pds []*packages_model.PackageDescriptor) error
|
||||||
|
OnBeforeRemovePackageVersion(ctx context.Context, doer *user_model.User, pd *packages_model.PackageDescriptor) error
|
||||||
|
GetViewPackageVersionData(ctx context.Context, pd *packages_model.PackageDescriptor) (any, error)
|
||||||
|
}
|
||||||
|
|
||||||
// PackageInfo describes a package
|
// PackageInfo describes a package
|
||||||
type PackageInfo struct {
|
type PackageInfo struct {
|
||||||
Owner *user_model.User
|
Owner *user_model.User
|
||||||
@@ -394,6 +400,8 @@ func CheckSizeQuotaExceeded(ctx context.Context, doer, owner *user_model.User, p
|
|||||||
typeSpecificSize = setting.Packages.LimitSizeRubyGems
|
typeSpecificSize = setting.Packages.LimitSizeRubyGems
|
||||||
case packages_model.TypeSwift:
|
case packages_model.TypeSwift:
|
||||||
typeSpecificSize = setting.Packages.LimitSizeSwift
|
typeSpecificSize = setting.Packages.LimitSizeSwift
|
||||||
|
case packages_model.TypeTerraformState:
|
||||||
|
typeSpecificSize = setting.Packages.LimitSizeTerraformState
|
||||||
case packages_model.TypeVagrant:
|
case packages_model.TypeVagrant:
|
||||||
typeSpecificSize = setting.Packages.LimitSizeVagrant
|
typeSpecificSize = setting.Packages.LimitSizeVagrant
|
||||||
}
|
}
|
||||||
@@ -473,6 +481,9 @@ func RemovePackageVersion(ctx context.Context, doer *user_model.User, pv *packag
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := GetSpecManager().Get(pd.Package.Type).OnBeforeRemovePackageVersion(ctx, doer, pd); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
// HINT: PACKAGE-DEFER-STORAGE-DELETE: Blobs are not deleted immediately, instead they are deleted by the cleanup_packages cron task.
|
// HINT: PACKAGE-DEFER-STORAGE-DELETE: Blobs are not deleted immediately, instead they are deleted by the cleanup_packages cron task.
|
||||||
// If there are no more versions for the package, the same task removes that as well.
|
// If there are no more versions for the package, the same task removes that as well.
|
||||||
if err := db.WithTx(ctx, func(ctx context.Context) error {
|
if err := db.WithTx(ctx, func(ctx context.Context) error {
|
||||||
@@ -631,6 +642,10 @@ func RemovePackage(ctx context.Context, doer *user_model.User, p *packages_model
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := GetSpecManager().Get(p.Type).OnBeforeRemovePackageAll(ctx, doer, p, pds); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// HINT: PACKAGE-DEFER-STORAGE-DELETE: Blobs are not deleted immediately, instead they are deleted by cleanup_packages cron task.
|
// HINT: PACKAGE-DEFER-STORAGE-DELETE: Blobs are not deleted immediately, instead they are deleted by cleanup_packages cron task.
|
||||||
err = db.WithTx(ctx, func(ctx context.Context) error {
|
err = db.WithTx(ctx, func(ctx context.Context) error {
|
||||||
err := packages_model.DeletePropertiesByPackageID(ctx, packages_model.PropertyTypePackage, p.ID)
|
err := packages_model.DeletePropertiesByPackageID(ctx, packages_model.PropertyTypePackage, p.ID)
|
||||||
|
|||||||
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/rpm" .}}
|
||||||
{{template "package/content/rubygems" .}}
|
{{template "package/content/rubygems" .}}
|
||||||
{{template "package/content/swift" .}}
|
{{template "package/content/swift" .}}
|
||||||
|
{{template "package/content/terraform" .}}
|
||||||
{{template "package/content/vagrant" .}}
|
{{template "package/content/vagrant" .}}
|
||||||
</div>
|
</div>
|
||||||
<div class="ui segment packages-content-right">
|
<div class="ui segment packages-content-right">
|
||||||
@@ -64,6 +65,7 @@
|
|||||||
{{template "package/metadata/rpm" .}}
|
{{template "package/metadata/rpm" .}}
|
||||||
{{template "package/metadata/rubygems" .}}
|
{{template "package/metadata/rubygems" .}}
|
||||||
{{template "package/metadata/swift" .}}
|
{{template "package/metadata/swift" .}}
|
||||||
|
{{template "package/metadata/terraform" .}}
|
||||||
{{template "package/metadata/vagrant" .}}
|
{{template "package/metadata/vagrant" .}}
|
||||||
{{if not (and (eq .PackageDescriptor.Package.Type "container") .PackageDescriptor.Metadata.Manifests)}}
|
{{if not (and (eq .PackageDescriptor.Package.Type "container") .PackageDescriptor.Metadata.Manifests)}}
|
||||||
<div class="item">{{svg "octicon-database"}} {{FileSize .PackageDescriptor.CalculateBlobSize}}</div>
|
<div class="item">{{svg "octicon-database"}} {{FileSize .PackageDescriptor.CalculateBlobSize}}</div>
|
||||||
|
|||||||
@@ -3,12 +3,14 @@
|
|||||||
<div role="main" aria-label="{{.Title}}" class="page-content organization packages">
|
<div role="main" aria-label="{{.Title}}" class="page-content organization packages">
|
||||||
{{template "org/header" .}}
|
{{template "org/header" .}}
|
||||||
<div class="ui container">
|
<div class="ui container">
|
||||||
|
{{template "base/alert" .}}
|
||||||
{{template "package/shared/view" .}}
|
{{template "package/shared/view" .}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{else}}
|
{{else}}
|
||||||
<div role="main" aria-label="{{.Title}}" class="page-content user profile packages">
|
<div role="main" aria-label="{{.Title}}" class="page-content user profile packages">
|
||||||
<div class="ui container">
|
<div class="ui container">
|
||||||
|
{{template "base/alert" .}}
|
||||||
<div class="ui stackable grid">
|
<div class="ui stackable grid">
|
||||||
<div class="ui four wide column">
|
<div class="ui four wide column">
|
||||||
{{template "shared/user/profile_big_avatar" .}}
|
{{template "shared/user/profile_big_avatar" .}}
|
||||||
|
|||||||
1
templates/swagger/v1_json.tmpl
generated
1
templates/swagger/v1_json.tmpl
generated
@@ -3835,6 +3835,7 @@
|
|||||||
"rpm",
|
"rpm",
|
||||||
"rubygems",
|
"rubygems",
|
||||||
"swift",
|
"swift",
|
||||||
|
"terraform",
|
||||||
"vagrant"
|
"vagrant"
|
||||||
],
|
],
|
||||||
"type": "string",
|
"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 |
Reference in New Issue
Block a user