diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini
index 4df50f5cc6c..e5d5ca669fe 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/actions/run.go b/models/actions/run.go
index 27958d6fb6f..bce356c0e2b 100644
--- a/models/actions/run.go
+++ b/models/actions/run.go
@@ -153,7 +153,11 @@ func (run *ActionRun) LoadRepo(ctx context.Context) error {
}
func (run *ActionRun) Duration() time.Duration {
- return calculateDuration(run.Started, run.Stopped, run.Status) + run.PreviousDuration
+ d := calculateDuration(run.Started, run.Stopped, run.Status, run.Updated) + run.PreviousDuration
+ if d < 0 {
+ return 0
+ }
+ return d
}
func (run *ActionRun) GetPushEventPayload() (*api.PushPayload, error) {
diff --git a/models/actions/run_job.go b/models/actions/run_job.go
index f89f4e9f876..d1e5d1e9380 100644
--- a/models/actions/run_job.go
+++ b/models/actions/run_job.go
@@ -72,7 +72,7 @@ func init() {
}
func (job *ActionRunJob) Duration() time.Duration {
- return calculateDuration(job.Started, job.Stopped, job.Status)
+ return calculateDuration(job.Started, job.Stopped, job.Status, job.Updated)
}
func (job *ActionRunJob) LoadRun(ctx context.Context) error {
diff --git a/models/actions/run_test.go b/models/actions/run_test.go
index bd2b92f4f66..e1c884518fa 100644
--- a/models/actions/run_test.go
+++ b/models/actions/run_test.go
@@ -5,10 +5,12 @@ package actions
import (
"testing"
+ "time"
"code.gitea.io/gitea/models/db"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
+ "code.gitea.io/gitea/modules/timeutil"
"github.com/stretchr/testify/assert"
)
@@ -33,3 +35,13 @@ func TestUpdateRepoRunsNumbers(t *testing.T) {
assert.Equal(t, 5, repo.NumActionRuns)
assert.Equal(t, 3, repo.NumClosedActionRuns)
}
+
+func TestActionRun_Duration_NonNegative(t *testing.T) {
+ run := &ActionRun{
+ Started: timeutil.TimeStamp(100),
+ Stopped: timeutil.TimeStamp(200),
+ Status: StatusSuccess,
+ PreviousDuration: -time.Hour,
+ }
+ assert.Equal(t, time.Duration(0), run.Duration())
+}
diff --git a/models/actions/task.go b/models/actions/task.go
index e092d6fbbd9..77139ddceae 100644
--- a/models/actions/task.go
+++ b/models/actions/task.go
@@ -77,7 +77,7 @@ func init() {
}
func (task *ActionTask) Duration() time.Duration {
- return calculateDuration(task.Started, task.Stopped, task.Status)
+ return calculateDuration(task.Started, task.Stopped, task.Status, task.Updated)
}
func (task *ActionTask) IsStopped() bool {
diff --git a/models/actions/task_step.go b/models/actions/task_step.go
index 03ffbf19316..3b477d8483c 100644
--- a/models/actions/task_step.go
+++ b/models/actions/task_step.go
@@ -28,7 +28,7 @@ type ActionTaskStep struct {
}
func (step *ActionTaskStep) Duration() time.Duration {
- return calculateDuration(step.Started, step.Stopped, step.Status)
+ return calculateDuration(step.Started, step.Stopped, step.Status, step.Updated)
}
func init() {
diff --git a/models/actions/utils.go b/models/actions/utils.go
index f6ba661ae30..1101a36cfcc 100644
--- a/models/actions/utils.go
+++ b/models/actions/utils.go
@@ -13,6 +13,7 @@ import (
"time"
auth_model "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/timeutil"
"code.gitea.io/gitea/modules/util"
)
@@ -72,13 +73,25 @@ func (indexes *LogIndexes) ToDB() ([]byte, error) {
var timeSince = time.Since
-func calculateDuration(started, stopped timeutil.TimeStamp, status Status) time.Duration {
+// calculateDuration computes wall time for a run, job, task, or step. When status is terminal
+// but stopped is missing or inconsistent with started, fallbackEnd (typically the row Updated
+// time) is used so duration still reflects approximate elapsed time instead of 0 or a negative.
+func calculateDuration(started, stopped timeutil.TimeStamp, status Status, fallbackEnd timeutil.TimeStamp) time.Duration {
if started == 0 {
return 0
}
s := started.AsTime()
if status.IsDone() {
- return stopped.AsTime().Sub(s)
+ end := stopped
+ if stopped.IsZero() || stopped < started {
+ if !fallbackEnd.IsZero() && fallbackEnd >= started {
+ end = fallbackEnd
+ } else {
+ log.Trace("actions: invalid duration timestamps (started=%d, stopped=%d, fallbackEnd=%d, status=%s)", started, stopped, fallbackEnd, status)
+ return 0
+ }
+ }
+ return end.AsTime().Sub(s)
}
return timeSince(s).Truncate(time.Second)
}
diff --git a/models/actions/utils_test.go b/models/actions/utils_test.go
index 98c048d4ef8..2f7e7da3607 100644
--- a/models/actions/utils_test.go
+++ b/models/actions/utils_test.go
@@ -45,9 +45,10 @@ func Test_calculateDuration(t *testing.T) {
return timeutil.TimeStamp(1000).AsTime().Sub(t)
}
type args struct {
- started timeutil.TimeStamp
- stopped timeutil.TimeStamp
- status Status
+ started timeutil.TimeStamp
+ stopped timeutil.TimeStamp
+ status Status
+ fallbackEnd timeutil.TimeStamp
}
tests := []struct {
name string
@@ -81,10 +82,48 @@ func Test_calculateDuration(t *testing.T) {
},
want: 100 * time.Second,
},
+ {
+ name: "done_stopped_zero_no_fallback",
+ args: args{
+ started: 500,
+ stopped: 0,
+ status: StatusSuccess,
+ },
+ want: 0,
+ },
+ {
+ name: "done_stopped_zero_uses_fallback",
+ args: args{
+ started: 500,
+ stopped: 0,
+ status: StatusSuccess,
+ fallbackEnd: 600,
+ },
+ want: 100 * time.Second,
+ },
+ {
+ name: "done_stopped_before_started_no_fallback",
+ args: args{
+ started: 600,
+ stopped: 550,
+ status: StatusSuccess,
+ },
+ want: 0,
+ },
+ {
+ name: "done_stopped_before_started_uses_fallback",
+ args: args{
+ started: 600,
+ stopped: 550,
+ status: StatusSuccess,
+ fallbackEnd: 650,
+ },
+ want: 50 * time.Second,
+ },
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- assert.Equalf(t, tt.want, calculateDuration(tt.args.started, tt.args.stopped, tt.args.status), "calculateDuration(%v, %v, %v)", tt.args.started, tt.args.stopped, tt.args.status)
+ assert.Equalf(t, tt.want, calculateDuration(tt.args.started, tt.args.stopped, tt.args.status, tt.args.fallbackEnd), "calculateDuration(%v, %v, %v, %v)", tt.args.started, tt.args.stopped, tt.args.status, tt.args.fallbackEnd)
})
}
}
diff --git a/models/packages/descriptor.go b/models/packages/descriptor.go
index 58f16c9eca8..2ef27051eea 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 38d1cdcf660..17e5d4eee35 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 156e4560418..83eabad4526 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 0bba2783bcb..c4afc9513b7 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 00000000000..3c326c04e9b
--- /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 00000000000..57631286991
--- /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 b5984240648..38ee2ad55eb 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 a3617817687..9d61e3f1d77 100644
--- a/options/locale/locale_en-US.json
+++ b/options/locale/locale_en-US.json
@@ -173,6 +173,8 @@
"search.org_kind": "Search orgs…",
"search.team_kind": "Search teams…",
"search.code_kind": "Search code…",
+ "search.code_empty": "Start a code search.",
+ "search.code_empty_description": "Enter a keyword to search across the code.",
"search.code_search_unavailable": "Code search is currently not available. Please contact the site administrator.",
"search.code_search_by_git_grep": "Current code search results are provided by \"git grep\". There might be better results if site administrator enables Repository Indexer.",
"search.package_kind": "Search packages…",
@@ -1357,10 +1359,13 @@
"repo.projects.desc": "Manage issues and pulls in projects.",
"repo.projects.description": "Description (optional)",
"repo.projects.description_placeholder": "Description",
+ "repo.projects.empty": "No projects yet.",
+ "repo.projects.empty_description": "Create a project to coordinate issues and pull requests.",
"repo.projects.create": "Create Project",
"repo.projects.title": "Title",
"repo.projects.new": "New Project",
"repo.projects.new_subheader": "Coordinate, track, and update your work in one place, so projects stay transparent and on schedule.",
+ "repo.projects.no_results": "No projects match your search.",
"repo.projects.create_success": "The project \"%s\" has been created.",
"repo.projects.deletion": "Delete Project",
"repo.projects.deletion_desc": "Deleting a project removes it from all related issues. Continue?",
@@ -2722,6 +2727,8 @@
"org.members": "Members",
"org.teams": "Teams",
"org.code": "Code",
+ "org.repos.empty": "No repositories yet.",
+ "org.repos.empty_description": "Create a repository to share code with the organization.",
"org.lower_members": "members",
"org.lower_repositories": "repositories",
"org.create_new_team": "New Team",
@@ -2858,6 +2865,8 @@
"org.worktime.date_range_end": "End date",
"org.worktime.query": "Query",
"org.worktime.time": "Time",
+ "org.worktime.empty": "No worktime data yet.",
+ "org.worktime.empty_description": "Adjust the date range to view tracked time.",
"org.worktime.by_repositories": "By repositories",
"org.worktime.by_milestones": "By milestones",
"org.worktime.by_members": "By members",
@@ -3602,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 00000000000..809b7e6fe17
--- /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 ec5326130ee..876d6aaa621 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 00000000000..8b731b7dd26
--- /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 00000000000..e4705d0ccf2
--- /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 376867ab829..c7c66f549ce 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 2ed7a57e5c8..92eab5eaf28 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 b748ead5436..1484ba2fdff 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 f85c2f75011..61d1fdc1421 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 9b6f9071647..d1a2b8587cc 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 47714add82c..03b2803297f 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 00000000000..d2f33e0d47d
--- /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 00000000000..0815bdc98d2
--- /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 00000000000..982f29fb818
--- /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 00000000000..cdd6f0c5935
--- /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/org/home.tmpl b/templates/org/home.tmpl
index ab088ce7b2d..b7d037c9e1c 100644
--- a/templates/org/home.tmpl
+++ b/templates/org/home.tmpl
@@ -9,8 +9,16 @@
{{ctx.Locale.Tr "org.repos.empty_description"}}
+{{ctx.Locale.Tr "org.worktime.empty_description"}}
+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{{ctx.Locale.Tr "repo.projects.empty_description"}}
+{{ctx.Locale.Tr "search.code_empty_description"}}
+