Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Terraform State Locking/Unlocking Support for Gitea #33277

Open
wants to merge 11 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions models/packages/descriptor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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:
Expand Down
6 changes: 6 additions & 0 deletions models/packages/package.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ const (
TypeRpm Type = "rpm"
TypeRubyGems Type = "rubygems"
TypeSwift Type = "swift"
TypeTerraform Type = "terraform"
TypeVagrant Type = "vagrant"
)

Expand All @@ -76,6 +77,7 @@ var TypeList = []Type{
TypeRpm,
TypeRubyGems,
TypeSwift,
TypeTerraform,
TypeVagrant,
}

Expand Down Expand Up @@ -124,6 +126,8 @@ func (pt Type) Name() string {
return "RubyGems"
case TypeSwift:
return "Swift"
case TypeTerraform:
return "Terraform"
case TypeVagrant:
return "Vagrant"
}
Expand Down Expand Up @@ -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"
}
Expand Down
88 changes: 88 additions & 0 deletions modules/packages/terraform/metadata.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
techknowlogick marked this conversation as resolved.
Show resolved Hide resolved
// 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
}
161 changes: 161 additions & 0 deletions modules/packages/terraform/metadata_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
techknowlogick marked this conversation as resolved.
Show resolved Hide resolved
// 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)
}
})
}
}
2 changes: 2 additions & 0 deletions modules/setting/packages.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ var (
LimitSizeRpm int64
LimitSizeRubyGems int64
LimitSizeSwift int64
LimitSizeTerraform int64
LimitSizeVagrant int64

DefaultRPMSignEnabled bool
Expand Down Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions routers/api/packages/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading