diff --git a/models/packages/descriptor.go b/models/packages/descriptor.go index 803b73c968995..bdb2361239b5a 100644 --- a/models/packages/descriptor.go +++ b/models/packages/descriptor.go @@ -31,6 +31,7 @@ import ( "code.gitea.io/gitea/modules/packages/rpm" "code.gitea.io/gitea/modules/packages/rubygems" "code.gitea.io/gitea/modules/packages/swift" + "code.gitea.io/gitea/modules/packages/terraform" "code.gitea.io/gitea/modules/packages/vagrant" "code.gitea.io/gitea/modules/util" @@ -191,6 +192,8 @@ func GetPackageDescriptor(ctx context.Context, pv *PackageVersion) (*PackageDesc metadata = &rubygems.Metadata{} case TypeSwift: metadata = &swift.Metadata{} + case TypeTerraform: + metadata = &terraform.Metadata{} case TypeVagrant: metadata = &vagrant.Metadata{} default: diff --git a/models/packages/package.go b/models/packages/package.go index 31e1277a6e37b..ab5deeaec161d 100644 --- a/models/packages/package.go +++ b/models/packages/package.go @@ -51,6 +51,7 @@ const ( TypeRpm Type = "rpm" TypeRubyGems Type = "rubygems" TypeSwift Type = "swift" + TypeTerraform Type = "terraform" TypeVagrant Type = "vagrant" ) @@ -76,6 +77,7 @@ var TypeList = []Type{ TypeRpm, TypeRubyGems, TypeSwift, + TypeTerraform, TypeVagrant, } @@ -124,6 +126,8 @@ func (pt Type) Name() string { return "RubyGems" case TypeSwift: return "Swift" + case TypeTerraform: + return "Terraform" case TypeVagrant: return "Vagrant" } @@ -175,6 +179,8 @@ func (pt Type) SVGName() string { return "gitea-rubygems" case TypeSwift: return "gitea-swift" + case TypeTerraform: + return "gitea-terraform" case TypeVagrant: return "gitea-vagrant" } diff --git a/modules/packages/terraform/metadata.go b/modules/packages/terraform/metadata.go new file mode 100644 index 0000000000000..6dfc0d66cee1b --- /dev/null +++ b/modules/packages/terraform/metadata.go @@ -0,0 +1,88 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package terraform + +import ( + "archive/tar" + "compress/gzip" + "errors" + "io" + + "code.gitea.io/gitea/modules/json" +) + +const ( + PropertyTerraformState = "terraform.state" +) + +// Metadata represents the Terraform backend metadata +// Updated to align with TerraformState structure +// Includes additional metadata fields like Description, Author, and URLs +type Metadata struct { + Version int `json:"version"` + TerraformVersion string `json:"terraform_version,omitempty"` + Serial uint64 `json:"serial"` + Lineage string `json:"lineage"` + Outputs map[string]any `json:"outputs,omitempty"` + Resources []ResourceState `json:"resources,omitempty"` + Description string `json:"description,omitempty"` + Author string `json:"author,omitempty"` + ProjectURL string `json:"project_url,omitempty"` + RepositoryURL string `json:"repository_url,omitempty"` +} + +// ResourceState represents the state of a resource +type ResourceState struct { + Mode string `json:"mode"` + Type string `json:"type"` + Name string `json:"name"` + Provider string `json:"provider"` + Instances []InstanceState `json:"instances"` +} + +// InstanceState represents the state of a resource instance +type InstanceState struct { + SchemaVersion int `json:"schema_version"` + Attributes map[string]any `json:"attributes"` +} + +// ParseMetadataFromState retrieves metadata from the archive with Terraform state +func ParseMetadataFromState(r io.Reader) (*Metadata, error) { + gzr, err := gzip.NewReader(r) + if err != nil { + return nil, err + } + defer gzr.Close() + + tr := tar.NewReader(gzr) + for { + hd, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return nil, err + } + + if hd.Typeflag != tar.TypeReg { + continue + } + + // Looking for the state.json file + if hd.Name == "state.json" { + return ParseStateFile(tr) + } + } + + return nil, errors.New("state.json not found in archive") +} + +// ParseStateFile parses the state.json file and returns Terraform metadata +func ParseStateFile(r io.Reader) (*Metadata, error) { + var stateData Metadata + if err := json.NewDecoder(r).Decode(&stateData); err != nil { + return nil, err + } + return &stateData, nil +} diff --git a/modules/packages/terraform/metadata_test.go b/modules/packages/terraform/metadata_test.go new file mode 100644 index 0000000000000..657c32588d428 --- /dev/null +++ b/modules/packages/terraform/metadata_test.go @@ -0,0 +1,161 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package terraform + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestParseMetadataFromState tests the ParseMetadataFromState function +func TestParseMetadataFromState(t *testing.T) { + tests := []struct { + name string + input []byte + expectedError bool + }{ + { + name: "valid state file", + input: createValidStateArchive(), + expectedError: false, + }, + { + name: "missing state.json file", + input: createInvalidStateArchive(), + expectedError: true, + }, + { + name: "corrupt archive", + input: []byte("invalid archive data"), + expectedError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := bytes.NewReader(tt.input) + metadata, err := ParseMetadataFromState(r) + + if tt.expectedError { + assert.Error(t, err) + assert.Nil(t, metadata) + } else { + assert.NoError(t, err) + assert.NotNil(t, metadata) + // Optionally, check if certain fields are populated correctly + assert.NotEmpty(t, metadata.Lineage) + } + }) + } +} + +// createValidStateArchive creates a valid TAR.GZ archive with a sample state.json +func createValidStateArchive() []byte { + metadata := `{ + "version": 4, + "terraform_version": "1.2.0", + "serial": 1, + "lineage": "abc123", + "resources": [], + "description": "Test project", + "author": "Test Author", + "project_url": "http://example.com", + "repository_url": "http://repo.com" + }` + + // Create a gzip writer and tar writer + buf := new(bytes.Buffer) + gz := gzip.NewWriter(buf) + tw := tar.NewWriter(gz) + + // Add the state.json file to the tar + hdr := &tar.Header{ + Name: "state.json", + Size: int64(len(metadata)), + Mode: 0o600, + } + if err := tw.WriteHeader(hdr); err != nil { + panic(err) + } + if _, err := tw.Write([]byte(metadata)); err != nil { + panic(err) + } + + // Close the writers + if err := tw.Close(); err != nil { + panic(err) + } + if err := gz.Close(); err != nil { + panic(err) + } + + return buf.Bytes() +} + +// createInvalidStateArchive creates an invalid TAR.GZ archive (missing state.json) +func createInvalidStateArchive() []byte { + // Create a tar archive without the state.json file + buf := new(bytes.Buffer) + gz := gzip.NewWriter(buf) + tw := tar.NewWriter(gz) + + // Add an empty file to the tar (but not state.json) + hdr := &tar.Header{ + Name: "other_file.txt", + Size: 0, + Mode: 0o600, + } + if err := tw.WriteHeader(hdr); err != nil { + panic(err) + } + + // Close the writers + if err := tw.Close(); err != nil { + panic(err) + } + if err := gz.Close(); err != nil { + panic(err) + } + + return buf.Bytes() +} + +// TestParseStateFile tests the ParseStateFile function directly +func TestParseStateFile(t *testing.T) { + tests := []struct { + name string + input string + expectedError bool + }{ + { + name: "valid state.json", + input: `{"version":4,"terraform_version":"1.2.0","serial":1,"lineage":"abc123"}`, + expectedError: false, + }, + { + name: "invalid JSON", + input: `{"version":4,"terraform_version"}`, + expectedError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := bytes.NewReader([]byte(tt.input)) + metadata, err := ParseStateFile(r) + + if tt.expectedError { + assert.Error(t, err) + assert.Nil(t, metadata) + } else { + assert.NoError(t, err) + assert.NotNil(t, metadata) + } + }) + } +} diff --git a/modules/setting/packages.go b/modules/setting/packages.go index 3f618cfd64115..6eff4f1b5c9eb 100644 --- a/modules/setting/packages.go +++ b/modules/setting/packages.go @@ -42,6 +42,7 @@ var ( LimitSizeRpm int64 LimitSizeRubyGems int64 LimitSizeSwift int64 + LimitSizeTerraform int64 LimitSizeVagrant int64 DefaultRPMSignEnabled bool @@ -100,6 +101,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.LimitSizeTerraform = mustBytes(sec, "LIMIT_SIZE_TERRAFORM") Packages.LimitSizeVagrant = mustBytes(sec, "LIMIT_SIZE_VAGRANT") Packages.DefaultRPMSignEnabled = sec.Key("DEFAULT_RPM_SIGN_ENABLED").MustBool(false) return nil diff --git a/routers/api/packages/api.go b/routers/api/packages/api.go index 41c3eb95e9011..2dd53198d515e 100644 --- a/routers/api/packages/api.go +++ b/routers/api/packages/api.go @@ -34,6 +34,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" @@ -674,6 +675,26 @@ func CommonRoutes() *web.Router { }) }) }, reqPackageAccess(perm.AccessModeRead)) + // Define routes for Terraform HTTP backend API + r.Group("/terraform/state", func() { + // Routes for specific state identified by {statename} + r.Group("/{statename}", func() { + // Fetch the current state + r.Get("", reqPackageAccess(perm.AccessModeRead), terraform.GetState) + // Update the state (supports both POST and PUT methods) + r.Post("", reqPackageAccess(perm.AccessModeWrite), terraform.UpdateState) + r.Put("", reqPackageAccess(perm.AccessModeWrite), terraform.UpdateState) + // Delete the state + r.Delete("", reqPackageAccess(perm.AccessModeWrite), terraform.DeleteState) + // Lock and unlock operations for the state + r.Group("/lock", func() { + // Lock the state + r.Post("", reqPackageAccess(perm.AccessModeWrite), terraform.LockState) + // Unlock the state + r.Delete("", reqPackageAccess(perm.AccessModeWrite), terraform.UnlockState) + }) + }) + }, reqPackageAccess(perm.AccessModeRead)) }, context.UserAssignmentWeb(), context.PackageAssignment()) return r diff --git a/routers/api/packages/terraform/terraform.go b/routers/api/packages/terraform/terraform.go new file mode 100644 index 0000000000000..61cd4c27c39c6 --- /dev/null +++ b/routers/api/packages/terraform/terraform.go @@ -0,0 +1,278 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package terraform + +import ( + "bytes" + "errors" + "fmt" + "io" + "net/http" + "strconv" + "time" + + packages_model "code.gitea.io/gitea/models/packages" + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" + packages_module "code.gitea.io/gitea/modules/packages" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/packages" + + "github.com/google/uuid" +) + +type TFState struct { + Version int `json:"version"` + TerraformVersion string `json:"terraform_version"` + Serial uint64 `json:"serial"` + Lineage string `json:"lineage"` + Outputs map[string]any `json:"outputs"` + Resources []ResourceState `json:"resources"` +} + +type ResourceState struct { + Mode string `json:"mode"` + Type string `json:"type"` + Name string `json:"name"` + Provider string `json:"provider"` + Instances []InstanceState `json:"instances"` +} + +type InstanceState struct { + SchemaVersion int `json:"schema_version"` + Attributes map[string]any `json:"attributes"` +} + +type LockInfo struct { + ID string `json:"id"` + Created string `json:"created"` +} + +var stateLocks = make(map[string]LockInfo) + +func apiError(ctx *context.Context, status int, message string) { + log.Error("Terraform API Error: %d - %s", status, message) + ctx.JSON(status, map[string]string{"error": message}) +} + +func getLockID(ctx *context.Context) (string, error) { + var lock struct { + ID string `json:"ID"` + } + + // Read the body of the request and try to parse the JSON + body, err := io.ReadAll(ctx.Req.Body) + if err == nil && len(body) > 0 { + if err := json.Unmarshal(body, &lock); err != nil { + log.Error("Failed to unmarshal request body: %v", err) + return "", err + } + } + + // We check the presence of lock ID in the request body or request parameters + if lock.ID == "" { + lock.ID = ctx.Req.URL.Query().Get("ID") + } + + if lock.ID == "" { + apiError(ctx, http.StatusBadRequest, "Missing lock ID") + return "", fmt.Errorf("missing lock ID") + } + + log.Info("Extracted lockID: %s", lock.ID) + return lock.ID, nil +} + +func GetState(ctx *context.Context) { + stateName := ctx.PathParam("statename") + log.Info("GetState called for: %s", stateName) + + pvs, _, err := packages_model.SearchLatestVersions(ctx, &packages_model.PackageSearchOptions{ + OwnerID: ctx.Package.Owner.ID, + Type: packages_model.TypeTerraform, + Name: packages_model.SearchValue{ExactMatch: true, Value: stateName}, + HasFileWithName: stateName, + IsInternal: optional.Some(false), + Sort: packages_model.SortCreatedDesc, + }) + if err != nil { + apiError(ctx, http.StatusInternalServerError, "Failed to fetch latest versions") + return + } + + if len(pvs) == 0 { + apiError(ctx, http.StatusNoContent, "No content available") + return + } + + stream, _, _, err := packages.GetFileStreamByPackageNameAndVersion(ctx, &packages.PackageInfo{ + Owner: ctx.Package.Owner, + PackageType: packages_model.TypeTerraform, + Name: stateName, + Version: pvs[0].Version, + }, &packages.PackageFileInfo{Filename: stateName}) + if err != nil { + switch { + case errors.Is(err, packages_model.ErrPackageNotExist): + apiError(ctx, http.StatusNotFound, "Package not found") + case errors.Is(err, packages_model.ErrPackageFileNotExist): + apiError(ctx, http.StatusNotFound, "File not found") + default: + apiError(ctx, http.StatusInternalServerError, err.Error()) + } + return + } + defer stream.Close() + + var state TFState + if err := json.NewDecoder(stream).Decode(&state); err != nil { + apiError(ctx, http.StatusInternalServerError, "Failed to parse state file") + return + } + + if state.Lineage == "" { + state.Lineage = uuid.NewString() + log.Info("Generated new lineage for state: %s", state.Lineage) + } + + ctx.Resp.Header().Set("Content-Type", "application/json") + ctx.Resp.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", stateName)) + ctx.JSON(http.StatusOK, state) +} + +func UpdateState(ctx *context.Context) { + stateName := ctx.PathParam("statename") + body, err := io.ReadAll(ctx.Req.Body) + if err != nil { + apiError(ctx, http.StatusInternalServerError, "Failed to read request body") + return + } + + var newState TFState + if err := json.Unmarshal(body, &newState); err != nil { + apiError(ctx, http.StatusBadRequest, "Invalid JSON") + return + } + + pvs, _, err := packages_model.SearchLatestVersions(ctx, &packages_model.PackageSearchOptions{ + OwnerID: ctx.Package.Owner.ID, + Type: packages_model.TypeTerraform, + Name: packages_model.SearchValue{ExactMatch: true, Value: stateName}, + HasFileWithName: stateName, + IsInternal: optional.Some(false), + Sort: packages_model.SortCreatedDesc, + }) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err.Error()) + return + } + serial := uint64(0) + if len(pvs) > 0 { + if lastSerial, err := strconv.ParseUint(pvs[0].Version, 10, 64); err == nil { + serial = lastSerial + 1 + } + } + + packageVersion := fmt.Sprintf("%d", serial) + + packageInfo := &packages.PackageCreationInfo{ + PackageInfo: packages.PackageInfo{ + Owner: ctx.Package.Owner, + PackageType: packages_model.TypeTerraform, + Name: stateName, + Version: packageVersion, + }, + Creator: ctx.Doer, + Metadata: newState, + } + + buffer, err := packages_module.CreateHashedBufferFromReader(bytes.NewReader(body)) + if err != nil { + apiError(ctx, http.StatusInternalServerError, "Failed to create buffer") + return + } + _, _, err = packages.CreatePackageOrAddFileToExisting(ctx, packageInfo, &packages.PackageFileCreationInfo{ + PackageFileInfo: packages.PackageFileInfo{Filename: stateName}, + Creator: ctx.Doer, + Data: buffer, + IsLead: true, + }) + if err != nil { + apiError(ctx, http.StatusInternalServerError, "Failed to update package") + return + } + + ctx.JSON(http.StatusOK, map[string]string{"message": "State updated successfully", "statename": stateName}) +} + +func LockState(ctx *context.Context) { + stateName := ctx.PathParam("statename") + lockID, err := getLockID(ctx) + if err != nil { + apiError(ctx, http.StatusBadRequest, err.Error()) + return + } + + // Check if the state is locked + if lockInfo, locked := stateLocks[stateName]; locked { + log.Warn("State %s is already locked", stateName) + + // Generate a response for the conflict with information about the current lock + response := lockInfo // Return full information about the lock + ctx.JSON(http.StatusConflict, response) + return + } + + // Set the lock + stateLocks[stateName] = LockInfo{ + ID: lockID, + Created: time.Now().UTC().Format(time.RFC3339), + } + + log.Info("Locked state: %s with ID: %s", stateName, lockID) + ctx.JSON(http.StatusOK, map[string]string{"message": "State locked successfully", "statename": stateName}) +} + +func UnlockState(ctx *context.Context) { + stateName := ctx.PathParam("statename") + lockID, err := getLockID(ctx) + if err != nil { + apiError(ctx, http.StatusBadRequest, err.Error()) + return + } + + // Check the lock status + currentLockInfo, locked := stateLocks[stateName] + if !locked || currentLockInfo.ID != lockID { + log.Warn("Unlock attempt failed for state %s with lock ID %s", stateName, lockID) + apiError(ctx, http.StatusConflict, fmt.Sprintf("State %s is not locked or lock ID mismatch", stateName)) + return + } + + // Remove the lock + delete(stateLocks, stateName) + log.Info("Unlocked state: %s with ID: %s", stateName, lockID) + ctx.JSON(http.StatusOK, map[string]string{"message": "State unlocked successfully"}) +} + +func DeleteState(ctx *context.Context) { + stateName := ctx.PathParam("statename") + pvs, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypeTerraform, stateName) + if err != nil { + apiError(ctx, http.StatusInternalServerError, "Failed to fetch package versions") + return + } + if len(pvs) == 0 { + ctx.Status(http.StatusNoContent) + return + } + for _, pv := range pvs { + if err := packages.RemovePackageVersion(ctx, ctx.Doer, pv); err != nil { + apiError(ctx, http.StatusInternalServerError, "Failed to delete package version") + return + } + } + ctx.JSON(http.StatusOK, map[string]string{"message": "State deleted successfully"}) +} diff --git a/routers/api/v1/packages/package.go b/routers/api/v1/packages/package.go index b38aa131676e1..c9af7e8db0aad 100644 --- a/routers/api/v1/packages/package.go +++ b/routers/api/v1/packages/package.go @@ -40,7 +40,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/services/forms/package_form.go b/services/forms/package_form.go index 9b6f9071647bc..d1a2b8587ccf5 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 bd1d460fd3ba8..0736ef4b56e2d 100644 --- a/services/packages/packages.go +++ b/services/packages/packages.go @@ -393,6 +393,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.TypeTerraform: + typeSpecificSize = setting.Packages.LimitSizeTerraform case packages_model.TypeVagrant: typeSpecificSize = setting.Packages.LimitSizeVagrant } diff --git a/templates/package/content/terraform.tmpl b/templates/package/content/terraform.tmpl new file mode 100644 index 0000000000000..c59713c0dbc0c --- /dev/null +++ b/templates/package/content/terraform.tmpl @@ -0,0 +1,30 @@ +{{if eq .PackageDescriptor.Package.Type "terraform"}} +

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

+
+
+
+ +

+export GITEA_USER_PASSWORD=<YOUR-USER-PASSWORD>
+export TF_STATE_NAME=your-state.tfstate
+terraform init \
+ -backend-config="address= \
+ -backend-config="lock_address= \
+ -backend-config="unlock_address= \
+ -backend-config="username={{.PackageDescriptor.Owner.Name}}" \
+ -backend-config="password=$GITEA_USER_PASSWORD" \
+ -backend-config="lock_method=POST" \
+ -backend-config="unlock_method=DELETE" \
+ -backend-config="retry_wait_min=5"
+
+
+
+ +
+
+
+ {{if .PackageDescriptor.Metadata.Description}} +

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

+
{{.PackageDescriptor.Metadata.Description}}
+ {{end}} +{{end}} diff --git a/templates/package/metadata/terraform.tmpl b/templates/package/metadata/terraform.tmpl new file mode 100644 index 0000000000000..87fdf2c2f9404 --- /dev/null +++ b/templates/package/metadata/terraform.tmpl @@ -0,0 +1,5 @@ +{{if eq .PackageDescriptor.Package.Type "terrafomr"}} + {{if .PackageDescriptor.Metadata.Author}}
{{svg "octicon-person"}} {{.PackageDescriptor.Metadata.Author}}
{{end}} + {{if .PackageDescriptor.Metadata.ProjectURL}}
{{svg "octicon-link-external"}} {{ctx.Locale.Tr "packages.details.project_site"}}
{{end}} + {{if .PackageDescriptor.Metadata.RepositoryURL}}
{{svg "octicon-link-external"}} {{ctx.Locale.Tr "packages.details.repository_site"}}
{{end}} +{{end}} diff --git a/templates/package/view.tmpl b/templates/package/view.tmpl index 9e92207466d96..5c5305cd09d8a 100644 --- a/templates/package/view.tmpl +++ b/templates/package/view.tmpl @@ -37,6 +37,7 @@ {{template "package/content/rpm" .}} {{template "package/content/rubygems" .}} {{template "package/content/swift" .}} + {{template "package/content/terraform" .}} {{template "package/content/vagrant" .}}
@@ -68,6 +69,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/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 8082fc594ac02..17cb8c1cc9f20 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -3275,6 +3275,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 0000000000000..424b4034617ca --- /dev/null +++ b/tests/integration/api_packages_terraform_test.go @@ -0,0 +1,130 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "bytes" + "fmt" + "io" + "net/http" + "strings" + "testing" + + "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/tests" + + gouuid "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}) + + // Get token for the user + token := "Bearer " + getUserToken(t, user.Name, auth.AccessTokenScopeWritePackage) + + // Define important values + lineage := "bca3c5f6-01dc-cdad-5310-d1b12e02e430" + terraformVersion := "1.10.4" + serial := float64(1) + resourceName := "hello" + resourceType := "null_resource" + id := gouuid.New().String() // Generate a unique ID + + // Build the state JSON + buildState := func() string { + return `{ + "version": 4, + "terraform_version": "` + terraformVersion + `", + "serial": ` + fmt.Sprintf("%.0f", serial) + `, + "lineage": "` + lineage + `", + "outputs": {}, + "resources": [{ + "mode": "managed", + "type": "` + resourceType + `", + "name": "` + resourceName + `", + "provider": "provider[\"registry.terraform.io/hashicorp/null\"]", + "instances": [{ + "schema_version": 0, + "attributes": { + "id": "3832416504545530133", + "triggers": null + }, + "sensitive_attributes": [] + }] + }], + "check_results": null + }` + } + state := buildState() + content := []byte(state) + root := fmt.Sprintf("/api/packages/%s/terraform/state", user.Name) + stateURL := fmt.Sprintf("%s/providers-gitea.tfstate", root) + + // Upload test + t.Run("Upload", func(t *testing.T) { + uploadURL := fmt.Sprintf("%s?ID=%s", stateURL, id) + req := NewRequestWithBody(t, "POST", uploadURL, bytes.NewReader(content)).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) // Expecting 200 OK + assert.Equal(t, http.StatusOK, resp.Code) + assert.Contains(t, resp.Header().Get("Content-Type"), "application/json") + bodyBytes, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.NotEmpty(t, bodyBytes) + }) + + // Download test + t.Run("Download", func(t *testing.T) { + downloadURL := fmt.Sprintf("%s?ID=%s", stateURL, id) + req := NewRequest(t, "GET", downloadURL) + resp := MakeRequest(t, req, http.StatusOK) + assert.True(t, strings.HasPrefix(resp.Header().Get("Content-Type"), "application/json")) + + bodyBytes, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.NotEmpty(t, bodyBytes) + + var jsonResponse map[string]any + err = json.Unmarshal(bodyBytes, &jsonResponse) + require.NoError(t, err) + + // Validate the response + assert.Equal(t, lineage, jsonResponse["lineage"]) + assert.Equal(t, terraformVersion, jsonResponse["terraform_version"]) + assert.InEpsilon(t, serial, jsonResponse["serial"].(float64), 0.0001) + resource := jsonResponse["resources"].([]any)[0].(map[string]any) + assert.Equal(t, resourceName, resource["name"]) + assert.Equal(t, resourceType, resource["type"]) + assert.NotContains(t, resource, "sensitive_attributes") + }) + + // Lock state test + t.Run("LockState", func(t *testing.T) { + lockURL := fmt.Sprintf("%s/lock?ID=%s", stateURL, id) + req := NewRequestWithBody(t, "POST", lockURL, bytes.NewReader(content)).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) // Expecting 200 OK + assert.Equal(t, http.StatusOK, resp.Code) + }) + + // Unlock state test + t.Run("UnlockState", func(t *testing.T) { + unlockURL := fmt.Sprintf("%s/lock?ID=%s", stateURL, id) + req := NewRequestWithBody(t, "DELETE", unlockURL, bytes.NewReader(content)).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) // Expecting 200 OK + assert.Equal(t, http.StatusOK, resp.Code) + }) + + // Download not found test + t.Run("DownloadNotFound", func(t *testing.T) { + invalidStateURL := fmt.Sprintf("%s/invalid-state.tfstate?ID=%s", root, id) + req := NewRequest(t, "GET", invalidStateURL) + resp := MakeRequest(t, req, http.StatusNoContent) // Expecting 204 No Content + assert.Equal(t, http.StatusNoContent, resp.Code) + }) +}