diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini index 4df50f5cc6..e5d5ca669f 100644 --- a/custom/conf/app.example.ini +++ b/custom/conf/app.example.ini @@ -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 ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; diff --git a/models/packages/descriptor.go b/models/packages/descriptor.go index 58f16c9eca..2ef27051ee 100644 --- a/models/packages/descriptor.go +++ b/models/packages/descriptor.go @@ -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: diff --git a/models/packages/package.go b/models/packages/package.go index 38d1cdcf66..17e5d4eee3 100644 --- a/models/packages/package.go +++ b/models/packages/package.go @@ -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" } diff --git a/modules/json/jsonlegacy.go b/modules/json/jsonlegacy.go index 156e456041..83eabad452 100644 --- a/modules/json/jsonlegacy.go +++ b/modules/json/jsonlegacy.go @@ -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 diff --git a/modules/json/jsonv2.go b/modules/json/jsonv2.go index 0bba2783bc..c4afc9513b 100644 --- a/modules/json/jsonv2.go +++ b/modules/json/jsonv2.go @@ -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 diff --git a/modules/packages/terraform/lock.go b/modules/packages/terraform/lock.go new file mode 100644 index 0000000000..3c326c04e9 --- /dev/null +++ b/modules/packages/terraform/lock.go @@ -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 +} diff --git a/modules/packages/terraform/state.go b/modules/packages/terraform/state.go new file mode 100644 index 0000000000..5763128699 --- /dev/null +++ b/modules/packages/terraform/state.go @@ -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 +} diff --git a/modules/setting/packages.go b/modules/setting/packages.go index b598424064..38ee2ad55e 100644 --- a/modules/setting/packages.go +++ b/modules/setting/packages.go @@ -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 diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index d289da6e57..9d61e3f1d7 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -3611,6 +3611,18 @@ "packages.swift.registry": "Set up this registry from the command line:", "packages.swift.install": "Add the package in your Package.swift 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.", diff --git a/public/assets/img/svg/gitea-terraform.svg b/public/assets/img/svg/gitea-terraform.svg new file mode 100644 index 0000000000..809b7e6fe1 --- /dev/null +++ b/public/assets/img/svg/gitea-terraform.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/routers/api/packages/api.go b/routers/api/packages/api.go index ec5326130e..876d6aaa62 100644 --- a/routers/api/packages/api.go +++ b/routers/api/packages/api.go @@ -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) diff --git a/routers/api/packages/terraform/terraform.go b/routers/api/packages/terraform/terraform.go new file mode 100644 index 0000000000..8b731b7dd2 --- /dev/null +++ b/routers/api/packages/terraform/terraform.go @@ -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"))) +} diff --git a/routers/api/packages/terraform/terraform_test.go b/routers/api/packages/terraform/terraform_test.go new file mode 100644 index 0000000000..e4705d0ccf --- /dev/null +++ b/routers/api/packages/terraform/terraform_test.go @@ -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) + } +} diff --git a/routers/api/v1/packages/package.go b/routers/api/v1/packages/package.go index 376867ab82..c7c66f549c 100644 --- a/routers/api/v1/packages/package.go +++ b/routers/api/v1/packages/package.go @@ -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 diff --git a/routers/init.go b/routers/init.go index 2ed7a57e5c..92eab5eaf2 100644 --- a/routers/init.go +++ b/routers/init.go @@ -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) diff --git a/routers/web/user/package.go b/routers/web/user/package.go index b748ead543..1484ba2fdf 100644 --- a/routers/web/user/package.go +++ b/routers/web/user/package.go @@ -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()) +} diff --git a/routers/web/web.go b/routers/web/web.go index f85c2f7501..61d1fdc142 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -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) }) }) diff --git a/services/forms/package_form.go b/services/forms/package_form.go index 9b6f907164..d1a2b8587c 100644 --- a/services/forms/package_form.go +++ b/services/forms/package_form.go @@ -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)"` diff --git a/services/packages/packages.go b/services/packages/packages.go index 47714add82..03b2803297 100644 --- a/services/packages/packages.go +++ b/services/packages/packages.go @@ -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) diff --git a/services/packages/pkgspec/manager.go b/services/packages/pkgspec/manager.go new file mode 100644 index 0000000000..d2f33e0d47 --- /dev/null +++ b/services/packages/pkgspec/manager.go @@ -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 +} diff --git a/services/packages/spec.go b/services/packages/spec.go new file mode 100644 index 0000000000..0815bdc98d --- /dev/null +++ b/services/packages/spec.go @@ -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)} +}) diff --git a/services/packages/terraform/spec.go b/services/packages/terraform/spec.go new file mode 100644 index 0000000000..982f29fb81 --- /dev/null +++ b/services/packages/terraform/spec.go @@ -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 +} diff --git a/services/packages/terraform/state.go b/services/packages/terraform/state.go new file mode 100644 index 0000000000..cdd6f0c593 --- /dev/null +++ b/services/packages/terraform/state.go @@ -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 +} diff --git a/templates/package/content/terraform.tmpl b/templates/package/content/terraform.tmpl new file mode 100644 index 0000000000..6006bee9aa --- /dev/null +++ b/templates/package/content/terraform.tmpl @@ -0,0 +1,26 @@ +{{if eq .PackageDescriptor.Package.Type "terraform"}} +

{{ctx.Locale.Tr "packages.installation"}}

+
+
+
+ +
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"
+	}
+}
+
+
+ +
terraform init -migrate-state
+
+
+ +
+
+
+{{end}} diff --git a/templates/package/metadata/terraform.tmpl b/templates/package/metadata/terraform.tmpl new file mode 100644 index 0000000000..3cecdc38ba --- /dev/null +++ b/templates/package/metadata/terraform.tmpl @@ -0,0 +1,41 @@ +{{if eq .PackageDescriptor.Package.Type "terraform"}} + {{$data := $.PackageVersionViewData}} + {{if $data.IsLatestVersion}} +
+
+
+ {{ctx.Locale.Tr "packages.terraform.lock_status"}} +
+
+ {{if $data.TerraformLock}} +
+ {{svg "octicon-lock" 16 "tw-text-red"}} + {{ctx.Locale.Tr "packages.terraform.locked_by" $data.TerraformLock.Who}} +
+
+ {{DateUtils.TimeSince $data.TerraformLock.Created}} ({{$data.TerraformLock.Operation}}) +
+ {{if .CanWritePackages}} +
+
+ +
+
+ {{end}} + {{else}} +
+ {{svg "octicon-unlock" 16 "tw-text-green"}} + {{ctx.Locale.Tr "packages.terraform.unlocked"}} +
+ {{if .CanWritePackages}} +
+
+ +
+
+ {{end}} + {{end}} +
+
+ {{end}} +{{end}} diff --git a/templates/package/shared/view.tmpl b/templates/package/shared/view.tmpl index 7c7b5b16dd..9e32d5fdc2 100644 --- a/templates/package/shared/view.tmpl +++ b/templates/package/shared/view.tmpl @@ -33,6 +33,7 @@ {{template "package/content/rpm" .}} {{template "package/content/rubygems" .}} {{template "package/content/swift" .}} + {{template "package/content/terraform" .}} {{template "package/content/vagrant" .}}
@@ -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)}}
{{svg "octicon-database"}} {{FileSize .PackageDescriptor.CalculateBlobSize}}
diff --git a/templates/package/view.tmpl b/templates/package/view.tmpl index 9067f44296..e3943fe341 100644 --- a/templates/package/view.tmpl +++ b/templates/package/view.tmpl @@ -3,12 +3,14 @@
{{template "org/header" .}}
+ {{template "base/alert" .}} {{template "package/shared/view" .}}
{{else}}
+ {{template "base/alert" .}}
{{template "shared/user/profile_big_avatar" .}} diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index d5e258cb56..e5b276f746 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -3835,6 +3835,7 @@ "rpm", "rubygems", "swift", + "terraform", "vagrant" ], "type": "string", diff --git a/tests/integration/api_packages_terraform_test.go b/tests/integration/api_packages_terraform_test.go new file mode 100644 index 0000000000..3f39aa5bfc --- /dev/null +++ b/tests/integration/api_packages_terraform_test.go @@ -0,0 +1,302 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "strings" + "testing" + + "code.gitea.io/gitea/models/packages" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/tests" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPackageTerraform(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + packageName := "te-st_pac.kage" + // generate the state json + genState := func(serial int) string { + return fmt.Sprintf(`{ + "version": 4, + "terraform_version": "1.10.4", + "serial": %d, + "lineage": "bca3c5f6-01dc-cdad-5310-d1b12e02e430", + "outputs": {}, + "resources": [{ + "mode": "managed", + "type": "hello", + "name": "null_resource", + "provider": "provider[\"registry.terraform.io/hashicorp/null\"]", + "instances": [{ + "schema_version": 0, + "attributes": { + "id": "3832416504545530133", + "triggers": null + }, + "sensitive_attributes": [] + }] + }], + "check_results": null + }`, serial) + } + genEncryptedState := func(serial int) string { + // json taken from wireshark inspection + return fmt.Sprintf(`{ + "serial": %d, + "lineage": "4dad5e35-cbd5-ca2f-3c20-97c5e13b7033", + "meta": { + "key_provider.pbkdf2.foo": "eyJzYWx0IjoiWE55NnRDZTlSQnFNWGRqWm5xc202TU1DREZNbW5FbXRHczc2UXI0NEpXST0iLCJpdGVyYXRpb25zIjo2MDAwMDAsImhhc2hfZnVuY3Rpb24iOiJzaGE1MTIiLCJrZXlfbGVuZ3RoIjozMn0=" + }, + "encrypted_data": "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", + "encryption_version": "v0" +}`, serial) + } + genLock := func(uuid string) string { + return fmt.Sprintf(`{ + "ID": "%s", + "Operation": "OperationTypePlan", + "Info": "", + "Who": "test-user@localhost", + "Version": "1.0", + "Created": "2023-01-01T00:00:00Z", + "Path": "test.tfstate" + }`, uuid) + } + + url := fmt.Sprintf("/api/packages/%s/terraform/state/%s", user.Name, packageName) + lockURL := fmt.Sprintf("/api/packages/%s/terraform/state/%s/lock", user.Name, packageName) + + // Covers non-existing package retrieval and deletion + t.Run("GetOrDeleteNonExisting", func(t *testing.T) { + // Package does not exist yet + req := NewRequest(t, "GET", url).AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusNotFound) + + // So deleting it also should not work + req = NewRequest(t, "DELETE", url).AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusNotFound) + }) + t.Run("RegularOperations", func(t *testing.T) { + cases := []struct { + name string + statefunc func(int) string + }{ + { + name: "Plain", + statefunc: genState, + }, + { + name: "Encrypted", + statefunc: genEncryptedState, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + // 1. Lock the state + lockID := uuid.New().String() + lockInfo := genLock(lockID) + req := NewRequestWithBody(t, "POST", lockURL, strings.NewReader(lockInfo)).AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusOK) + + // Verify lock property in DB + p, err := packages.GetPackageByName(t.Context(), user.ID, packages.TypeTerraformState, packageName) + require.NoError(t, err) + props, err := packages.GetPropertiesByName(t.Context(), packages.PropertyTypePackage, p.ID, "terraform.lock") + require.NoError(t, err) + require.Len(t, props, 1) + assert.Contains(t, props[0].Value, lockID) + + // Upload state with correct Lock ID + state1 := tc.statefunc(1) + req = NewRequestWithBody(t, "POST", url+"?ID="+lockID, strings.NewReader(state1)).AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusCreated) + + // Verify version created + pv, err := packages.GetVersionByNameAndVersion(t.Context(), user.ID, packages.TypeTerraformState, packageName, "1") + assert.NoError(t, err) + assert.NotNil(t, pv) + + // 3. Unlock the state + req = NewRequestWithBody(t, "DELETE", lockURL, strings.NewReader(lockInfo)).AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusOK) + + // Verify lock property is cleared + props, err = packages.GetPropertiesByName(t.Context(), packages.PropertyTypePackage, p.ID, "terraform.lock") + require.NoError(t, err) + require.Len(t, props, 1) + assert.Empty(t, props[0].Value) + + // Get latest state + req = NewRequest(t, "GET", url).AddBasicAuth(user.Name) + resp := MakeRequest(t, req, http.StatusOK) + assert.Equal(t, state1, resp.Body.String()) + + // Upload new version without lock + state2 := genState(2) + req = NewRequestWithBody(t, "POST", url, strings.NewReader(state2)).AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusCreated) + + // 6. Delete the entire package + req = NewRequest(t, "DELETE", url).AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusOK) + + // Verify package is deleted from DB + _, err = packages.GetPackageByName(t.Context(), user.ID, packages.TypeTerraformState, packageName) + assert.ErrorIs(t, err, packages.ErrPackageNotExist) + }) + } + }) + + t.Run("StateHistory", func(t *testing.T) { + // Upload 3 versions + for i := range 3 { + state := genState(i + 1) // 1-based + req := NewRequestWithBody(t, "POST", url, strings.NewReader(state)).AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusCreated) + } + + // Verify latest is 3 + req := NewRequest(t, "GET", url).AddBasicAuth(user.Name) + resp := MakeRequest(t, req, http.StatusOK) + assert.Equal(t, genState(3), resp.Body.String()) + + // Verify version 2 is accessible + req = NewRequest(t, "GET", url+"/versions/2").AddBasicAuth(user.Name) + resp = MakeRequest(t, req, http.StatusOK) + assert.Equal(t, genState(2), resp.Body.String()) + + // Delete version 2 + req = NewRequest(t, "DELETE", url+"/versions/2").AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusNoContent) + + // Verify version 2 is gone from DB + _, err := packages.GetVersionByNameAndVersion(t.Context(), user.ID, packages.TypeTerraformState, packageName, "2") + assert.ErrorIs(t, err, packages.ErrPackageNotExist) + + // Verify version 2 is gone from API + req = NewRequest(t, "GET", url+"/versions/2").AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusNotFound) + + // Deleting latest version (3) should be forbidden + req = NewRequest(t, "DELETE", url+"/versions/3").AddBasicAuth(user.Name) + resp = MakeRequest(t, req, http.StatusForbidden) + assert.Contains(t, resp.Body.String(), "cannot delete the latest version") + + // Cleanup + req = NewRequest(t, "DELETE", url).AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusOK) + }) + + t.Run("BadOperations", func(t *testing.T) { + t.Run("LockingIssues", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + lockID1 := uuid.New().String() + lockID2 := uuid.New().String() + lockInfo1 := genLock(lockID1) + lockInfo2 := genLock(lockID2) + + // Pre-create package - it's required for unlock on the non-locked package to work + req := NewRequestWithBody(t, "POST", url, strings.NewReader(genState(1))).AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusCreated) + + // Unlock non-locked state (should return 200) + req = NewRequestWithBody(t, "DELETE", lockURL, strings.NewReader(lockInfo1)).AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusOK) + + // Lock the state + req = NewRequestWithBody(t, "POST", lockURL, strings.NewReader(lockInfo1)).AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusOK) + + // Another lock attempt should fail + req = NewRequestWithBody(t, "POST", lockURL, strings.NewReader(lockInfo2)).AddBasicAuth(user.Name) + resp := MakeRequest(t, req, http.StatusLocked) + assert.JSONEq(t, lockInfo1, resp.Body.String()) + + // Unlock with wrong ID should fail + req = NewRequestWithBody(t, "DELETE", lockURL, strings.NewReader(lockInfo2)).AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusLocked) + + // Same user locking again should fail (already locked) + req = NewRequestWithBody(t, "POST", lockURL, strings.NewReader(lockInfo1)).AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusLocked) + + // Unlock with correct ID + req = NewRequestWithBody(t, "DELETE", lockURL, strings.NewReader(lockInfo1)).AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusOK) + + // Clean up + req = NewRequest(t, "DELETE", url).AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusOK) + }) + + t.Run("UploadWithoutValidLock", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + lockID := uuid.New().String() + lockInfo := genLock(lockID) + + // Lock the state + req := NewRequestWithBody(t, "POST", lockURL, strings.NewReader(lockInfo)).AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusOK) + + // Upload without ID should fail + req = NewRequestWithBody(t, "POST", url, strings.NewReader(genState(1))).AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusLocked) + + // Upload with wrong ID should fail + req = NewRequestWithBody(t, "POST", url+"?ID="+uuid.New().String(), strings.NewReader(genState(1))).AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusLocked) + + // Cleanup lock + req = NewRequestWithBody(t, "DELETE", lockURL, strings.NewReader(lockInfo)).AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusOK) + }) + + t.Run("DeleteWithLock", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Create package and lock it + req := NewRequestWithBody(t, "POST", url, strings.NewReader(genState(1))).AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusCreated) + + lockID := uuid.New().String() + lockInfo := genLock(lockID) + req = NewRequestWithBody(t, "POST", lockURL, strings.NewReader(lockInfo)).AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusOK) + + // Delete package should fail + req = NewRequest(t, "DELETE", url).AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusLocked) + + // Verify package exists + p, err := packages.GetPackageByName(t.Context(), user.ID, packages.TypeTerraformState, packageName) + require.NoError(t, err) + assert.NotNil(t, p) + + // Cleanup + req = NewRequestWithBody(t, "DELETE", lockURL, strings.NewReader(lockInfo)).AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusOK) + req = NewRequest(t, "DELETE", url).AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusOK) + }) + t.Run("PutEmpty", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // safeguard against null payload + req := NewRequestWithBody(t, "POST", url, strings.NewReader("null")).AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusBadRequest) + }) + }) +} diff --git a/web_src/svg/gitea-terraform.svg b/web_src/svg/gitea-terraform.svg new file mode 100644 index 0000000000..384ce5a921 --- /dev/null +++ b/web_src/svg/gitea-terraform.svg @@ -0,0 +1,2 @@ + +