Merge 0dc7c5509c0bea6a13cec98f794c71dd65f44391 into c2e23d3301b1be2b2ad667184030087f92ad2470

This commit is contained in:
Shurkys 2025-02-19 10:03:29 +01:00 committed by GitHub
commit 8f616977fc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 731 additions and 2 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,30 @@
{{if eq .PackageDescriptor.Package.Type "terraform"}}
<h4 class="ui top attached header">{{ctx.Locale.Tr "packages.installation"}}</h4>
<div class="ui attached segment">
<div class="ui form">
<div class="field">
<label>{{svg "octicon-terminal"}} {{ctx.Locale.Tr "packages.terraform.install"}}</label>
<div class="markup"><pre class="code-block"><code>
export GITEA_USER_PASSWORD=&lt;YOUR-USER-PASSWORD&gt;
export TF_STATE_NAME=your-state.tfstate
terraform init \
&ensp;-backend-config="address=<origin-url data-url="{{AppSubUrl}}/api/packages/{{.PackageDescriptor.Owner.Name}}/terraform/state/$TF_STATE_NAME"></origin-url> \
&ensp;-backend-config="lock_address=<origin-url data-url="{{AppSubUrl}}/api/packages/{{.PackageDescriptor.Owner.Name}}/terraform/state/$TF_STATE_NAME/lock"></origin-url> \
&ensp;-backend-config="unlock_address=<origin-url data-url="{{AppSubUrl}}/api/packages/{{.PackageDescriptor.Owner.Name}}/terraform/state/$TF_STATE_NAME/lock"></origin-url> \
&ensp;-backend-config="username={{.PackageDescriptor.Owner.Name}}" \
&ensp;-backend-config="password=$GITEA_USER_PASSWORD" \
&ensp;-backend-config="lock_method=POST" \
&ensp;-backend-config="unlock_method=DELETE" \
&ensp;-backend-config="retry_wait_min=5"
</code></pre></div>
</div>
<div class="field">
<label>{{ctx.Locale.Tr "packages.registry.documentation" "Terraform" "https://docs.gitea.com/usage/packages/terraform/"}}</label>
</div>
</div>
</div>
{{if .PackageDescriptor.Metadata.Description}}
<h4 class="ui top attached header">{{ctx.Locale.Tr "packages.about"}}</h4>
<div class="ui attached segment">{{.PackageDescriptor.Metadata.Description}}</div>
{{end}}
{{end}}

View File

@ -0,0 +1,5 @@
{{if eq .PackageDescriptor.Package.Type "terrafomr"}}
{{if .PackageDescriptor.Metadata.Author}}<div class="item" title="{{ctx.Locale.Tr "packages.details.author"}}">{{svg "octicon-person"}} {{.PackageDescriptor.Metadata.Author}}</div>{{end}}
{{if .PackageDescriptor.Metadata.ProjectURL}}<div class="item">{{svg "octicon-link-external"}} <a href="{{.PackageDescriptor.Metadata.ProjectURL}}" target="_blank" rel="noopener noreferrer me">{{ctx.Locale.Tr "packages.details.project_site"}}</a></div>{{end}}
{{if .PackageDescriptor.Metadata.RepositoryURL}}<div class="item">{{svg "octicon-link-external"}} <a href="{{.PackageDescriptor.Metadata.RepositoryURL}}" target="_blank" rel="noopener noreferrer me">{{ctx.Locale.Tr "packages.details.repository_site"}}</a></div>{{end}}
{{end}}

View File

@ -37,6 +37,7 @@
{{template "package/content/rpm" .}}
{{template "package/content/rubygems" .}}
{{template "package/content/swift" .}}
{{template "package/content/terraform" .}}
{{template "package/content/vagrant" .}}
</div>
<div class="issue-content-right ui segment">
@ -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)}}
<div class="item">{{svg "octicon-database"}} {{FileSize .PackageDescriptor.CalculateBlobSize}}</div>

View File

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

View File

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