0
0
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:
TheFox0x7 2026-04-06 22:41:17 +02:00 committed by GitHub
parent dc197a0058
commit ff777cd2ad
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 1379 additions and 58 deletions

View File

@ -2790,6 +2790,8 @@ LEVEL = Info
;LIMIT_SIZE_SWIFT = -1
;; Maximum size of a Vagrant upload (`-1` means no limits, format `1000`, `1 MB`, `1 GiB`)
;LIMIT_SIZE_VAGRANT = -1
;; Maximum size of a Terraform state upload (`-1` means no limits, format `1000`, `1 MB`, `1 GiB`)
;LIMIT_SIZE_TERRAFORM_STATE = -1
;; Enable RPM re-signing by default. (It will overwrite the old signature ,using v4 format, not compatible with CentOS 6 or older)
;DEFAULT_RPM_SIGN_ENABLED = false
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,100 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package terraform
import (
"context"
"errors"
"io"
"time"
"code.gitea.io/gitea/models/db"
packages_model "code.gitea.io/gitea/models/packages"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/util"
"xorm.io/builder"
)
const LockFile = "terraform.lock"
// LockInfo is the metadata for a terraform lock.
type LockInfo struct {
ID string `json:"ID"`
Operation string `json:"Operation"`
Info string `json:"Info"`
Who string `json:"Who"`
Version string `json:"Version"`
Created time.Time `json:"Created"`
Path string `json:"Path"`
}
func (l *LockInfo) IsLocked() bool {
return l.ID != ""
}
func ParseLockInfo(r io.Reader) (*LockInfo, error) {
var lock LockInfo
err := json.NewDecoder(r).Decode(&lock)
if err != nil {
return nil, err
}
// ID is required. Rest is less important.
if lock.ID == "" {
return nil, util.NewInvalidArgumentErrorf("terraform lock is missing an ID")
}
return &lock, nil
}
// GetLock returns the terraform lock for the given package.
// Lock is empty if no lock exists.
func GetLock(ctx context.Context, packageID int64) (LockInfo, error) {
var lock LockInfo
locks, err := packages_model.GetPropertiesByName(ctx, packages_model.PropertyTypePackage, packageID, LockFile)
if err != nil {
return lock, err
}
if len(locks) == 0 || locks[0].Value == "" {
return lock, nil
}
err = json.Unmarshal([]byte(locks[0].Value), &lock)
return lock, err
}
// SetLock sets the terraform lock for the given package.
func SetLock(ctx context.Context, packageID int64, lock *LockInfo) error {
jsonBytes, err := json.Marshal(lock)
if err != nil {
return err
}
return updateLock(ctx, packageID, string(jsonBytes), builder.Eq{"value": ""})
}
// RemoveLock removes the terraform lock for the given package.
func RemoveLock(ctx context.Context, packageID int64) error {
return updateLock(ctx, packageID, "", builder.Neq{"value": ""})
}
func updateLock(ctx context.Context, refID int64, value string, cond builder.Cond) error {
pp := packages_model.PackageProperty{RefType: packages_model.PropertyTypePackage, RefID: refID, Name: LockFile}
ok, err := db.GetEngine(ctx).Get(&pp)
if err != nil {
return err
}
if ok {
n, err := db.GetEngine(ctx).Where("ref_type=? AND ref_id=? AND name=?", packages_model.PropertyTypePackage, refID, LockFile).And(cond).Cols("value").Update(&packages_model.PackageProperty{Value: value})
if err != nil {
return err
}
if n == 0 {
return errors.New("failed to update lock state")
}
return nil
}
_, err = packages_model.InsertProperty(ctx, packages_model.PropertyTypePackage, refID, LockFile, value)
return err
}

View File

@ -0,0 +1,38 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package terraform
import (
"io"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/util"
)
// Note: this is a subset of the Terraform state file format as the full one has two forms.
// If needed, it can be expanded in the future.
type State struct {
Serial uint64 `json:"serial"`
Lineage string `json:"lineage"`
}
// ParseState parses the required parts of Terraform state file
func ParseState(r io.Reader) (*State, error) {
var state State
err := json.NewDecoder(r).Decode(&state)
if err != nil {
return nil, err
}
// Serial starts at 1; 0 means it wasn't set in the state file
if state.Serial == 0 {
return nil, util.NewInvalidArgumentErrorf("state serial is missing")
}
// Lineage should always be set
if state.Lineage == "" {
return nil, util.NewInvalidArgumentErrorf("state lineage is missing")
}
return &state, nil
}

View File

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

View File

@ -3611,6 +3611,18 @@
"packages.swift.registry": "Set up this registry from the command line:",
"packages.swift.install": "Add the package in your <code>Package.swift</code> file:",
"packages.swift.install2": "and run the following command:",
"packages.terraform.install": "Set your state to use the HTTP backend",
"packages.terraform.install2": "and run the following command:",
"packages.terraform.lock_status": "Lock Status",
"packages.terraform.locked_by": "Locked by %s",
"packages.terraform.unlocked": "Unlocked",
"packages.terraform.lock": "Lock",
"packages.terraform.unlock": "Unlock",
"packages.terraform.lock.success": "Terraform state was successfully locked.",
"packages.terraform.unlock.success": "Terraform state was successfully unlocked.",
"packages.terraform.lock.error.already_locked": "Terraform state is already locked.",
"packages.terraform.delete.locked": "Terraform state is locked and cannot be deleted.",
"packages.terraform.delete.latest": "The latest version of a Terraform state cannot be deleted.",
"packages.vagrant.install": "To add a Vagrant box, run the following command:",
"packages.settings.link": "Link this package to a repository",
"packages.settings.link.description": "If you link a package with a repository, the package will appear in the repository's package list. Only repositories under the same owner can be linked. Leaving the field empty will remove the link.",

View File

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

After

Width:  |  Height:  |  Size: 441 B

View File

@ -32,6 +32,7 @@ import (
"code.gitea.io/gitea/routers/api/packages/rpm"
"code.gitea.io/gitea/routers/api/packages/rubygems"
"code.gitea.io/gitea/routers/api/packages/swift"
"code.gitea.io/gitea/routers/api/packages/terraform"
"code.gitea.io/gitea/routers/api/packages/vagrant"
"code.gitea.io/gitea/services/auth"
"code.gitea.io/gitea/services/context"
@ -514,6 +515,21 @@ func CommonRoutes() *web.Router {
r.Get("/identifiers", swift.CheckAcceptMediaType(swift.AcceptJSON), swift.LookupPackageIdentifiers)
}, reqPackageAccess(perm.AccessModeRead))
})
// See https://docs.gitlab.com/ci/jobs/fine_grained_permissions/#terraform-state-endpoints
// For endpoint and permission reference
r.Group("/terraform/state/{name}", func() {
r.Get("", terraform.GetTerraformState)
r.Get("/versions/{serial}", terraform.GetTerraformStateBySerial)
r.Group("", func() {
r.Post("", terraform.UploadState)
r.Delete("", terraform.DeleteState)
r.Delete("/versions/{serial}", terraform.DeleteStateBySerial)
}, reqPackageAccess(perm.AccessModeWrite))
r.Group("/lock", func() {
r.Post("", terraform.LockState)
r.Delete("", terraform.UnlockState)
}, reqPackageAccess(perm.AccessModeWrite))
}, reqPackageAccess(perm.AccessModeRead))
r.Group("/vagrant", func() {
r.Group("/authenticate", func() {
r.Get("", vagrant.CheckAuthenticate)

View File

@ -0,0 +1,438 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package terraform
import (
"errors"
"fmt"
"io"
"net/http"
"regexp"
"strconv"
"strings"
"unicode"
packages_model "code.gitea.io/gitea/models/packages"
"code.gitea.io/gitea/modules/globallock"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/optional"
packages_module "code.gitea.io/gitea/modules/packages"
terraform_module "code.gitea.io/gitea/modules/packages/terraform"
"code.gitea.io/gitea/routers/api/packages/helper"
"code.gitea.io/gitea/services/context"
packages_service "code.gitea.io/gitea/services/packages"
)
var packageNameRegex = regexp.MustCompile(`\A[-_+.\w]+\z`)
const (
stateFilename = "tfstate"
)
func apiError(ctx *context.Context, status int, obj any) {
message := helper.ProcessErrorForUser(ctx, status, obj)
ctx.PlainText(status, message)
}
// GetTerraformState serves the latest version of the state
func GetTerraformState(ctx *context.Context) {
stateName := ctx.PathParam("name")
pv, err := getLatestVersion(ctx, stateName)
if errors.Is(err, packages_model.ErrPackageNotExist) {
apiError(ctx, http.StatusNotFound, nil)
return
} else if err != nil {
apiError(ctx, http.StatusInternalServerError, err)
return
}
streamState(ctx, stateName, pv.Version)
}
// GetTerraformStateBySerial serves a specific version of terraform state.
func GetTerraformStateBySerial(ctx *context.Context) {
streamState(ctx, ctx.PathParam("name"), ctx.PathParam("serial"))
}
// streamState serves the terraform state file
func streamState(ctx *context.Context, name, serial string) {
s, u, pf, err := packages_service.OpenFileForDownloadByPackageNameAndVersion(
ctx,
&packages_service.PackageInfo{
Owner: ctx.Package.Owner,
PackageType: packages_model.TypeTerraformState,
Name: name,
Version: serial,
},
&packages_service.PackageFileInfo{
Filename: stateFilename,
},
ctx.Req.Method,
)
if err != nil {
if errors.Is(err, packages_model.ErrPackageNotExist) || errors.Is(err, packages_model.ErrPackageFileNotExist) {
apiError(ctx, http.StatusNotFound, err)
return
}
apiError(ctx, http.StatusInternalServerError, err)
return
}
helper.ServePackageFile(ctx, s, u, pf)
}
func isValidPackageName(packageName string) bool {
if len(packageName) == 1 && !unicode.IsLetter(rune(packageName[0])) && !unicode.IsNumber(rune(packageName[0])) {
return false
}
return packageNameRegex.MatchString(packageName) && packageName != ".."
}
// UploadState uploads the specific terraform package.
func UploadState(ctx *context.Context) {
packageName := ctx.PathParam("name")
if !isValidPackageName(packageName) {
apiError(ctx, http.StatusBadRequest, errors.New("invalid package name"))
return
}
lockKey := getLockKey(ctx)
release, err := globallock.Lock(ctx, lockKey)
if err != nil {
apiError(ctx, http.StatusInternalServerError, err)
return
}
defer release()
p, err := packages_model.GetPackageByName(ctx, ctx.Package.Owner.ID, packages_model.TypeTerraformState, packageName)
if err != nil && !errors.Is(err, packages_model.ErrPackageNotExist) {
apiError(ctx, http.StatusInternalServerError, err)
return
}
if p != nil {
// Check lock
lock, err := terraform_module.GetLock(ctx, p.ID)
if err != nil {
apiError(ctx, http.StatusInternalServerError, err)
return
}
// If the state is locked, enforce the lock
if lock.IsLocked() && lock.ID != ctx.FormString("ID") {
ctx.JSON(http.StatusLocked, lock)
return
}
}
upload, needToClose, err := ctx.UploadStream()
if err != nil {
apiError(ctx, http.StatusInternalServerError, err)
return
}
if needToClose {
defer upload.Close()
}
buf, err := packages_module.CreateHashedBufferFromReader(upload)
if err != nil {
log.Error("Error creating hashed buffer: %v", err)
apiError(ctx, http.StatusInternalServerError, err)
return
}
defer buf.Close()
state, err := terraform_module.ParseState(buf)
if err != nil {
log.Error("Error decoding state: %v", err)
apiError(ctx, http.StatusBadRequest, err)
return
}
if _, err := buf.Seek(0, io.SeekStart); err != nil {
apiError(ctx, http.StatusInternalServerError, err)
return
}
_, _, err = packages_service.CreatePackageOrAddFileToExisting(
ctx,
&packages_service.PackageCreationInfo{
PackageInfo: packages_service.PackageInfo{
Owner: ctx.Package.Owner,
PackageType: packages_model.TypeTerraformState,
Name: packageName,
Version: strconv.FormatUint(state.Serial, 10),
},
Creator: ctx.Doer,
},
&packages_service.PackageFileCreationInfo{
PackageFileInfo: packages_service.PackageFileInfo{
Filename: stateFilename,
},
Creator: ctx.Doer,
Data: buf,
IsLead: true,
},
)
if err != nil {
switch {
case errors.Is(err, packages_model.ErrDuplicatePackageFile):
apiError(ctx, http.StatusConflict, err)
case errors.Is(err, packages_service.ErrQuotaTotalCount), errors.Is(err, packages_service.ErrQuotaTypeSize), errors.Is(err, packages_service.ErrQuotaTotalSize):
apiError(ctx, http.StatusForbidden, err)
default:
apiError(ctx, http.StatusInternalServerError, err)
}
return
}
ctx.Status(http.StatusCreated)
}
// DeleteStateBySerial deletes the specific serial of a terraform package as long as it's not the latest one.
func DeleteStateBySerial(ctx *context.Context) {
lockKey := getLockKey(ctx)
release, err := globallock.Lock(ctx, lockKey)
if err != nil {
apiError(ctx, http.StatusInternalServerError, err)
return
}
defer release()
serial := ctx.PathParam("serial")
pv, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypeTerraformState, ctx.PathParam("name"), serial)
if errors.Is(err, packages_model.ErrPackageNotExist) {
apiError(ctx, http.StatusNotFound, err)
return
} else if err != nil {
apiError(ctx, http.StatusInternalServerError, err)
return
}
pvLatest, err := getLatestVersion(ctx, ctx.PathParam("name"))
if errors.Is(err, packages_model.ErrPackageNotExist) {
apiError(ctx, http.StatusNotFound, err)
return
} else if err != nil {
apiError(ctx, http.StatusInternalServerError, err)
return
}
if pvLatest.ID == pv.ID {
apiError(ctx, http.StatusForbidden, errors.New("cannot delete the latest version"))
return
}
err = packages_service.DeletePackageVersionAndReferences(ctx, pv)
if err != nil {
apiError(ctx, http.StatusInternalServerError, err)
return
}
ctx.Status(http.StatusNoContent)
}
// DeleteState deletes the specific file of a terraform package.
// Fails if the state is locked
func DeleteState(ctx *context.Context) {
packageName := ctx.PathParam("name")
lockKey := getLockKey(ctx)
release, err := globallock.Lock(ctx, lockKey)
if err != nil {
apiError(ctx, http.StatusInternalServerError, err)
return
}
defer release()
p, err := packages_model.GetPackageByName(ctx, ctx.Package.Owner.ID, packages_model.TypeTerraformState, packageName)
if err != nil {
if errors.Is(err, packages_model.ErrPackageNotExist) {
apiError(ctx, http.StatusNotFound, err)
return
}
apiError(ctx, http.StatusInternalServerError, err)
return
}
lock, err := terraform_module.GetLock(ctx, p.ID)
if err != nil {
apiError(ctx, http.StatusInternalServerError, err)
return
}
if lock.IsLocked() {
apiError(ctx, http.StatusLocked, errors.New("terraform state is locked"))
return
}
pvs, _, err := packages_model.SearchVersions(ctx, &packages_model.PackageSearchOptions{
PackageID: p.ID,
IsInternal: optional.None[bool](),
})
if err != nil {
apiError(ctx, http.StatusInternalServerError, err)
return
}
err = packages_model.DeleteAllProperties(ctx, packages_model.PropertyTypePackage, p.ID)
if err != nil {
apiError(ctx, http.StatusInternalServerError, err)
return
}
for _, pv := range pvs {
if err := packages_service.RemovePackageVersion(ctx, ctx.Doer, pv); err != nil {
apiError(ctx, http.StatusInternalServerError, err)
return
}
}
if err := packages_model.DeletePackageByID(ctx, p.ID); err != nil {
apiError(ctx, http.StatusInternalServerError, err)
return
}
ctx.Status(http.StatusOK)
}
// LockState locks the specific terraform state.
// Internally, it adds a property to the package with the lock information
// Caveat being that it allocates a package if one doesn't exist to attach the property
func LockState(ctx *context.Context) {
packageName := ctx.PathParam("name")
if !isValidPackageName(packageName) {
apiError(ctx, http.StatusBadRequest, errors.New("invalid package name"))
return
}
var reqLockInfo *terraform_module.LockInfo
reqLockInfo, err := terraform_module.ParseLockInfo(ctx.Req.Body)
if err != nil {
apiError(ctx, http.StatusBadRequest, err)
return
}
lockKey := getLockKey(ctx)
release, err := globallock.Lock(ctx, lockKey)
if err != nil {
apiError(ctx, http.StatusInternalServerError, err)
return
}
defer release()
p, err := packages_model.GetPackageByName(ctx, ctx.Package.Owner.ID, packages_model.TypeTerraformState, packageName)
if err != nil {
// If the package doesn't exist, allocate it for the lock.
if errors.Is(err, packages_model.ErrPackageNotExist) {
p = &packages_model.Package{
OwnerID: ctx.Package.Owner.ID,
Type: packages_model.TypeTerraformState,
Name: packageName,
LowerName: strings.ToLower(packageName),
}
if p, err = packages_model.TryInsertPackage(ctx, p); err != nil {
apiError(ctx, http.StatusInternalServerError, err)
return
}
} else {
apiError(ctx, http.StatusInternalServerError, err)
return
}
}
currentLock, err := terraform_module.GetLock(ctx, p.ID)
if err != nil {
apiError(ctx, http.StatusInternalServerError, err)
return
}
if currentLock.IsLocked() {
ctx.JSON(http.StatusLocked, currentLock)
return
}
err = terraform_module.SetLock(ctx, p.ID, reqLockInfo)
if err != nil {
apiError(ctx, http.StatusInternalServerError, err)
return
}
ctx.Status(http.StatusOK)
}
// UnlockState unlock the specific terraform state.
// Internally, it clears the package property
func UnlockState(ctx *context.Context) {
packageName := ctx.PathParam("name")
if !isValidPackageName(packageName) {
apiError(ctx, http.StatusBadRequest, errors.New("invalid package name"))
return
}
reqLockInfo, err := terraform_module.ParseLockInfo(ctx.Req.Body)
if err != nil {
apiError(ctx, http.StatusBadRequest, err)
return
}
lockKey := getLockKey(ctx)
release, err := globallock.Lock(ctx, lockKey)
if err != nil {
apiError(ctx, http.StatusInternalServerError, err)
return
}
defer release()
p, err := packages_model.GetPackageByName(ctx, ctx.Package.Owner.ID, packages_model.TypeTerraformState, packageName)
if err != nil {
if errors.Is(err, packages_model.ErrPackageNotExist) {
ctx.Status(http.StatusOK)
return
}
apiError(ctx, http.StatusInternalServerError, err)
return
}
existingLock, err := terraform_module.GetLock(ctx, p.ID)
if err != nil {
apiError(ctx, http.StatusInternalServerError, err)
return
}
// we can bypass messing with the lock since it's empty
if !existingLock.IsLocked() {
ctx.Status(http.StatusOK)
return
}
// Unlocking ID must be the same as locker one.
if existingLock.ID != reqLockInfo.ID {
apiError(ctx, http.StatusLocked, errors.New("lock ID mismatch"))
return
}
// We can clear the state if lock id matches
err = terraform_module.RemoveLock(ctx, p.ID)
if err != nil {
apiError(ctx, http.StatusInternalServerError, err)
return
}
ctx.Status(http.StatusOK)
}
func getLatestVersion(ctx *context.Context, packageName string) (*packages_model.PackageVersion, error) {
pvs, _, err := packages_model.SearchLatestVersions(ctx, &packages_model.PackageSearchOptions{
OwnerID: ctx.Package.Owner.ID,
Type: packages_model.TypeTerraformState,
Name: packages_model.SearchValue{ExactMatch: true, Value: packageName},
IsInternal: optional.Some(false),
Sort: packages_model.SortCreatedDesc,
})
if err != nil {
return nil, err
}
if len(pvs) == 0 {
return nil, packages_model.ErrPackageNotExist
}
return pvs[0], nil
}
func getLockKey(ctx *context.Context) string {
return fmt.Sprintf("terraform_lock_%d_%s", ctx.Package.Owner.ID, strings.ToLower(ctx.PathParam("name")))
}

View File

@ -0,0 +1,36 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package terraform
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestValidatePackageName(t *testing.T) {
bad := []string{
"",
".",
"..",
"-",
"a?b",
"a b",
"a/b",
}
for _, name := range bad {
assert.False(t, isValidPackageName(name), "bad=%q", name)
}
good := []string{
"a",
"1",
"a-",
"a_b",
"c.d+",
}
for _, name := range good {
assert.True(t, isValidPackageName(name), "good=%q", name)
}
}

View File

@ -43,7 +43,7 @@ func ListPackages(ctx *context.APIContext) {
// in: query
// description: package type filter
// type: string
// enum: [alpine, cargo, chef, composer, conan, conda, container, cran, debian, generic, go, helm, maven, npm, nuget, pub, pypi, rpm, rubygems, swift, vagrant]
// enum: [alpine, cargo, chef, composer, conan, conda, container, cran, debian, generic, go, helm, maven, npm, nuget, pub, pypi, rpm, rubygems, swift, terraform, vagrant]
// - name: q
// in: query
// description: name filter

View File

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

View File

@ -8,6 +8,7 @@ import (
"errors"
"net/http"
"net/url"
"time"
"code.gitea.io/gitea/models/db"
org_model "code.gitea.io/gitea/models/organization"
@ -18,13 +19,13 @@ import (
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/modules/container"
"code.gitea.io/gitea/modules/httplib"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/optional"
alpine_module "code.gitea.io/gitea/modules/packages/alpine"
arch_module "code.gitea.io/gitea/modules/packages/arch"
container_module "code.gitea.io/gitea/modules/packages/container"
debian_module "code.gitea.io/gitea/modules/packages/debian"
rpm_module "code.gitea.io/gitea/modules/packages/rpm"
terraform_module "code.gitea.io/gitea/modules/packages/terraform"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/modules/util"
@ -35,6 +36,8 @@ import (
"code.gitea.io/gitea/services/forms"
packages_service "code.gitea.io/gitea/services/packages"
container_service "code.gitea.io/gitea/services/packages/container"
"github.com/google/uuid"
)
const (
@ -315,6 +318,11 @@ func ViewPackageVersion(ctx *context.Context) {
}
ctx.Data["LatestVersions"] = pvs
ctx.Data["TotalVersionCount"] = pvsTotal
ctx.Data["PackageVersionViewData"], err = packages_service.GetSpecManager().Get(pd.Package.Type).GetViewPackageVersionData(ctx, pd)
if err != nil {
ctx.ServerError("GetViewPackageVersionData", err)
return
}
ctx.Data["CanWritePackages"] = ctx.Package.AccessMode >= perm.AccessModeWrite || ctx.IsUserSiteAdmin()
@ -498,14 +506,18 @@ func packageSettingsPostActionDelete(ctx *context.Context) {
ctx.Redirect(pd.PackageSettingsLink())
return
}
if err := packages_service.RemovePackage(ctx, ctx.Doer, pd.Package); err != nil {
log.Error("Error deleting package: %v", err)
ctx.Flash.Error(ctx.Tr("packages.settings.delete.error"))
} else {
ctx.Flash.Success(ctx.Tr("packages.settings.delete.success"))
errTr := util.ErrorAsTranslatable(err)
if errTr == nil {
ctx.ServerError("RemovePackage", err)
return
}
ctx.Flash.Error(errTr.Translate(ctx.Locale))
ctx.Redirect(pd.PackageSettingsLink())
return
}
ctx.Flash.Success(ctx.Tr("packages.settings.delete.success"))
ctx.Redirect(ctx.Package.Owner.HomeLink() + "/-/packages")
}
@ -518,18 +530,21 @@ func PackageVersionDelete(ctx *context.Context) {
}
if err := packages_service.RemovePackageVersion(ctx, ctx.Doer, pd.Version); err != nil {
log.Error("Error deleting package version: %v", err)
ctx.Flash.Error(ctx.Tr("packages.settings.delete.error"))
errTr := util.ErrorAsTranslatable(err)
if errTr == nil {
ctx.ServerError("RemovePackageVersion", err)
return
}
ctx.Flash.Error(errTr.Translate(ctx.Locale))
} else {
ctx.Flash.Success(ctx.Tr("packages.settings.delete.version.success"))
}
redirectURL := ctx.Package.Owner.HomeLink() + "/-/packages"
// redirect to the package if there are still versions available
redirectURL := ctx.Package.Owner.HomeLink() + "/-/packages"
if has, _ := packages_model.ExistVersion(ctx, &packages_model.PackageSearchOptions{PackageID: pd.Package.ID, IsInternal: optional.Some(false)}); has {
redirectURL = pd.PackageWebLink()
}
ctx.Redirect(redirectURL)
}
@ -553,3 +568,56 @@ func DownloadPackageFile(ctx *context.Context) {
packages_helper.ServePackageFile(ctx, s, u, pf)
}
// ActionPackageTerraformLock locks a terraform state
func ActionPackageTerraformLock(ctx *context.Context) {
pd := ctx.Package.Descriptor
if pd.Package.Type != packages_model.TypeTerraformState {
ctx.NotFound(nil)
return
}
existingLock, err := terraform_module.GetLock(ctx, pd.Package.ID)
if err != nil {
ctx.ServerError("GetLock", err)
return
}
if existingLock.IsLocked() {
ctx.Flash.Error(ctx.Tr("packages.terraform.lock.error.already_locked"))
ctx.Redirect(pd.VersionWebLink())
return
}
lockID := uuid.New().String()
lockInfo := &terraform_module.LockInfo{
ID: lockID,
Operation: "Manual UI Lock",
Who: ctx.Doer.Name,
Created: time.Now(),
}
if err := terraform_module.SetLock(ctx, pd.Package.ID, lockInfo); err != nil {
ctx.ServerError("SetLock", err)
return
}
ctx.Flash.Success(ctx.Tr("packages.terraform.lock.success"))
ctx.Redirect(pd.VersionWebLink())
}
// ActionPackageTerraformUnlock unlocks a terraform state
func ActionPackageTerraformUnlock(ctx *context.Context) {
pd := ctx.Package.Descriptor
if pd.Package.Type != packages_model.TypeTerraformState {
ctx.NotFound(nil)
return
}
if err := terraform_module.RemoveLock(ctx, pd.Package.ID); err != nil {
ctx.ServerError("RemoveLock", err)
return
}
ctx.Flash.Success(ctx.Tr("packages.terraform.unlock.success"))
ctx.Redirect(pd.VersionWebLink())
}

View File

@ -1073,6 +1073,10 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
m.Get("", user.ViewPackageVersion)
m.Post("", reqPackageAccess(perm.AccessModeWrite), user.PackageVersionDelete)
m.Get("/{version_sub}", user.ViewPackageVersion)
m.Group("/terraform", func() {
m.Post("/lock", user.ActionPackageTerraformLock)
m.Post("/unlock", user.ActionPackageTerraformUnlock)
}, reqPackageAccess(perm.AccessModeWrite))
m.Get("/files/{fileid}", user.DownloadPackageFile)
})
})

View File

@ -15,7 +15,7 @@ import (
type PackageCleanupRuleForm struct {
ID int64
Enabled bool
Type string `binding:"Required;In(alpine,arch,cargo,chef,composer,conan,conda,container,cran,debian,generic,go,helm,maven,npm,nuget,pub,pypi,rpm,rubygems,swift,vagrant)"`
Type string `binding:"Required;In(alpine,arch,cargo,chef,composer,conan,conda,container,cran,debian,generic,go,helm,maven,npm,nuget,pub,pypi,rpm,rubygems,swift,terraform,vagrant)"`
KeepCount int `binding:"In(0,1,5,10,25,50,100)"`
KeepPattern string `binding:"RegexPattern"`
RemoveDays int `binding:"In(0,7,14,30,60,90,180)"`

View File

@ -32,6 +32,12 @@ var (
ErrQuotaTotalCount = errors.New("maximum allowed package count exceeded")
)
type Specialization interface {
OnBeforeRemovePackageAll(ctx context.Context, doer *user_model.User, pkg *packages_model.Package, pds []*packages_model.PackageDescriptor) error
OnBeforeRemovePackageVersion(ctx context.Context, doer *user_model.User, pd *packages_model.PackageDescriptor) error
GetViewPackageVersionData(ctx context.Context, pd *packages_model.PackageDescriptor) (any, error)
}
// PackageInfo describes a package
type PackageInfo struct {
Owner *user_model.User
@ -394,6 +400,8 @@ func CheckSizeQuotaExceeded(ctx context.Context, doer, owner *user_model.User, p
typeSpecificSize = setting.Packages.LimitSizeRubyGems
case packages_model.TypeSwift:
typeSpecificSize = setting.Packages.LimitSizeSwift
case packages_model.TypeTerraformState:
typeSpecificSize = setting.Packages.LimitSizeTerraformState
case packages_model.TypeVagrant:
typeSpecificSize = setting.Packages.LimitSizeVagrant
}
@ -473,6 +481,9 @@ func RemovePackageVersion(ctx context.Context, doer *user_model.User, pv *packag
if err != nil {
return err
}
if err := GetSpecManager().Get(pd.Package.Type).OnBeforeRemovePackageVersion(ctx, doer, pd); err != nil {
return err
}
// HINT: PACKAGE-DEFER-STORAGE-DELETE: Blobs are not deleted immediately, instead they are deleted by the cleanup_packages cron task.
// If there are no more versions for the package, the same task removes that as well.
if err := db.WithTx(ctx, func(ctx context.Context) error {
@ -631,6 +642,10 @@ func RemovePackage(ctx context.Context, doer *user_model.User, p *packages_model
if err != nil {
return err
}
if err := GetSpecManager().Get(p.Type).OnBeforeRemovePackageAll(ctx, doer, p, pds); err != nil {
return err
}
// HINT: PACKAGE-DEFER-STORAGE-DELETE: Blobs are not deleted immediately, instead they are deleted by cleanup_packages cron task.
err = db.WithTx(ctx, func(ctx context.Context) error {
err := packages_model.DeletePropertiesByPackageID(ctx, packages_model.PropertyTypePackage, p.ID)

View File

@ -0,0 +1,17 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package pkgspec
import (
packages_model "code.gitea.io/gitea/models/packages"
packages_service "code.gitea.io/gitea/services/packages"
"code.gitea.io/gitea/services/packages/terraform"
)
func InitManager() error {
mgr := packages_service.GetSpecManager()
mgr.Add(packages_model.TypeTerraformState, &terraform.Specialization{})
// TODO: add more in the future, refactor the existing code to use this approach
return nil
}

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

@ -0,0 +1,51 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package packages
import (
"context"
"sync"
packages_model "code.gitea.io/gitea/models/packages"
user_model "code.gitea.io/gitea/models/user"
)
type nop struct{}
func (n *nop) GetViewPackageVersionData(ctx context.Context, pd *packages_model.PackageDescriptor) (any, error) {
return nil, nil //nolint:nilnil // no data, no error
}
func (n *nop) OnBeforeRemovePackageAll(ctx context.Context, doer *user_model.User, pkg *packages_model.Package, pds []*packages_model.PackageDescriptor) error {
return nil
}
func (n *nop) OnBeforeRemovePackageVersion(ctx context.Context, doer *user_model.User, pd *packages_model.PackageDescriptor) error {
return nil
}
var _ Specialization = (*nop)(nil)
type SpecManagerType struct {
specMap map[packages_model.Type]Specialization
}
func (m *SpecManagerType) Add(t packages_model.Type, spec Specialization) {
m.specMap[t] = spec
}
func (m *SpecManagerType) Get(t packages_model.Type) Specialization {
if len(m.specMap) == 0 {
panic("specialization not initialized")
}
spec := m.specMap[t]
if spec == nil {
return &nop{}
}
return spec
}
var GetSpecManager = sync.OnceValue(func() *SpecManagerType {
return &SpecManagerType{specMap: make(map[packages_model.Type]Specialization)}
})

View File

@ -0,0 +1,85 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package terraform
import (
"context"
packages_model "code.gitea.io/gitea/models/packages"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/optional"
terraform_module "code.gitea.io/gitea/modules/packages/terraform"
"code.gitea.io/gitea/modules/util"
packages_service "code.gitea.io/gitea/services/packages"
)
type Specialization struct{}
var _ packages_service.Specialization = (*Specialization)(nil)
func (s Specialization) GetViewPackageVersionData(ctx context.Context, pd *packages_model.PackageDescriptor) (any, error) {
var ret struct {
IsLatestVersion bool
TerraformLock *terraform_module.LockInfo
}
latestPvs, _, err := packages_model.SearchLatestVersions(ctx, &packages_model.PackageSearchOptions{
PackageID: pd.Package.ID,
IsInternal: optional.Some(false),
})
if err != nil {
return ret, err
}
isLatest := len(latestPvs) > 0 && latestPvs[0].ID == pd.Version.ID
ret.IsLatestVersion = isLatest
if isLatest {
lockInfo, err := terraform_module.GetLock(ctx, pd.Package.ID)
if err != nil {
return ret, nil
}
if lockInfo.IsLocked() {
ret.TerraformLock = &lockInfo
}
}
return ret, nil
}
func (s Specialization) OnBeforeRemovePackageAll(ctx context.Context, doer *user_model.User, pkg *packages_model.Package, pds []*packages_model.PackageDescriptor) error {
locked, err := IsLocked(ctx, pkg)
if err != nil {
return err
}
if locked {
return util.ErrorWrapTranslatable(
util.ErrorWrap(util.ErrUnprocessableContent, "terraform state is locked and cannot be deleted"),
"packages.terraform.delete.locked",
)
}
return nil
}
func (s Specialization) OnBeforeRemovePackageVersion(ctx context.Context, doer *user_model.User, pd *packages_model.PackageDescriptor) error {
locked, err := IsLocked(ctx, pd.Package)
if err != nil {
return err
}
if locked {
return util.ErrorWrapTranslatable(
util.ErrorWrap(util.ErrUnprocessableContent, "terraform state is locked and cannot be deleted"),
"packages.terraform.delete.locked",
)
}
latest, err := IsLatest(ctx, pd)
if err != nil {
return err
}
if latest {
return util.ErrorWrapTranslatable(
util.ErrorWrap(util.ErrUnprocessableContent, "the latest version of a Terraform state cannot be deleted"),
"packages.terraform.delete.latest",
)
}
return nil
}

View File

@ -0,0 +1,44 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package terraform
import (
"context"
packages_model "code.gitea.io/gitea/models/packages"
"code.gitea.io/gitea/modules/optional"
terraform_module "code.gitea.io/gitea/modules/packages/terraform"
)
// IsLocked is a helper function to check if the terraform state is locked
func IsLocked(ctx context.Context, pkg *packages_model.Package) (bool, error) {
// Non terraform state packages aren't handled here
if pkg.Type == packages_model.TypeTerraformState {
return false, nil
}
lock, err := terraform_module.GetLock(ctx, pkg.ID)
if err != nil {
return false, err
}
return lock.IsLocked(), nil
}
// IsLatest is a helper function to check if the terraform state is the latest version
func IsLatest(ctx context.Context, pd *packages_model.PackageDescriptor) (bool, error) {
if pd.Package.Type == packages_model.TypeTerraformState {
return false, nil
}
latestPvs, _, err := packages_model.SearchLatestVersions(ctx, &packages_model.PackageSearchOptions{
PackageID: pd.Package.ID,
IsInternal: optional.Some(false),
})
if err != nil {
return false, err
}
if len(latestPvs) > 0 && latestPvs[0].ID == pd.Version.ID {
return true, nil
}
return false, nil
}

View File

@ -0,0 +1,26 @@
{{if eq .PackageDescriptor.Package.Type "terraform"}}
<h4 class="ui top attached header">{{ctx.Locale.Tr "packages.installation"}}</h4>
<div class="ui attached segment">
<div class="ui form">
<div class="field">
<label>{{svg "octicon-code"}} {{ctx.Locale.Tr "packages.terraform.install"}}</label>
<div class="markup"><pre class="code-block"><code>terraform {
backend "http" {
address = "{{ctx.AppFullLink}}/api/packages/{{$.PackageDescriptor.Owner.Name}}/terraform/state/{{$.PackageDescriptor.Package.Name}}""
lock_address = "{{ctx.AppFullLink}}/api/packages/{{$.PackageDescriptor.Owner.Name}}/terraform/state/{{$.PackageDescriptor.Package.Name}}/lock"
unlock_address = "{{ctx.AppFullLink}}/api/packages/{{$.PackageDescriptor.Owner.Name}}/terraform/state/{{$.PackageDescriptor.Package.Name}}/lock"
lock_method = "POST"
unlock_method = "DELETE"
}
}</code></pre></div>
</div>
<div class="field">
<label>{{svg "octicon-terminal"}} {{ctx.Locale.Tr "packages.terraform.install2"}}</label>
<div class="markup"><pre class="code-block"><code>terraform init -migrate-state</code></pre></div>
</div>
<div class="field">
<label>{{ctx.Locale.Tr "packages.registry.documentation" "Terraform" "https://docs.gitea.com/usage/packages/terraform"}}</label>
</div>
</div>
</div>
{{end}}

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

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

After

Width:  |  Height:  |  Size: 377 B