diff --git a/models/packages/descriptor.go b/models/packages/descriptor.go index 803b73c968..bdb2361239 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 31e1277a6e..b09672663a 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, } @@ -175,6 +177,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 0000000000..14df7cf548 --- /dev/null +++ b/modules/packages/terraform/metadata.go @@ -0,0 +1,88 @@ +// Copyright 2022 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 0000000000..b2d5164f5e --- /dev/null +++ b/modules/packages/terraform/metadata_test.go @@ -0,0 +1,161 @@ +// Copyright 2023 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 3f618cfd64..6eff4f1b5c 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 41c3eb95e9..2dd53198d5 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 0000000000..f0948fbbd8 --- /dev/null +++ b/routers/api/packages/terraform/terraform.go @@ -0,0 +1,396 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package terraform + +import ( + "bytes" + "errors" + "fmt" + "io" + "net/http" + "strconv" + "sync" + + 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/routers/api/packages/helper" + "code.gitea.io/gitea/services/context" + packages_service "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"` +} + +var ( + stateStorage = make(map[string]*TFState) + stateLocks = make(map[string]string) + storeMutex sync.Mutex +) + +func apiError(ctx *context.Context, status int, obj any) { + helper.LogAndProcessError(ctx, status, obj, func(message string) { + type Error struct { + Status int `json:"status"` + Message string `json:"message"` + } + ctx.JSON(status, struct { + Errors []Error `json:"errors"` + }{ + Errors: []Error{ + {Status: status, Message: message}, + }, + }) + }) +} + +func GetState(ctx *context.Context) { + stateName := ctx.PathParam("statename") + log.Info("Function GetState called with parameters: stateName=%s", stateName) + + // Find the package version + pvs, _, err := packages_model.SearchVersions(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), + }) + if err != nil { + log.Error("Failed to search package versions for state %s: %v", stateName, err) + apiError(ctx, http.StatusInternalServerError, err) + return + } + + // If no version is found, return 204 + if len(pvs) == 0 { + log.Info("No existing state found for %s, returning 204 No Content", stateName) + ctx.Resp.WriteHeader(http.StatusNoContent) + return + } + + // Get the latest package version + stateVersion := pvs[0] + if stateVersion == nil { + log.Error("State version is nil for state %s", stateName) + apiError(ctx, http.StatusInternalServerError, "Invalid state version") + return + } + log.Info("Fetching file stream for state %s with version %s", stateName, stateVersion.Version) + + // Log the parameters of GetFileStreamByPackageNameAndVersion call + log.Info("Fetching file stream with params: Owner=%v, PackageType=%v, Name=%v, Version=%v, Filename=%v", + ctx.Package.Owner, + packages_model.TypeTerraform, + stateName, + stateVersion.Version, + stateName, + ) + + // Fetch the file stream + s, _, _, err := packages_service.GetFileStreamByPackageNameAndVersion( + ctx, + &packages_service.PackageInfo{ + Owner: ctx.Package.Owner, + PackageType: packages_model.TypeTerraform, + Name: stateName, + Version: stateVersion.Version, + }, + &packages_service.PackageFileInfo{ + Filename: stateName, + }, + ) + if err != nil { + log.Error("Error fetching file stream for state %s: %v", stateName, err) + if errors.Is(err, packages_model.ErrPackageNotExist) { + log.Error("Package does not exist: %v", err) + apiError(ctx, http.StatusNotFound, "Package not found") + return + } + if errors.Is(err, packages_model.ErrPackageFileNotExist) { + log.Error("Package file does not exist: %v", err) + apiError(ctx, http.StatusNotFound, "File not found") + return + } + apiError(ctx, http.StatusInternalServerError, "Failed to fetch file stream") + return + } + defer s.Close() + + // Read the file contents + buf := new(bytes.Buffer) + if _, err := io.Copy(buf, s); err != nil { + log.Error("Failed to read state file for %s: %v", stateName, err) + apiError(ctx, http.StatusInternalServerError, "Failed to read state file") + return + } + + // Deserialize the state + var state TFState + if err := json.Unmarshal(buf.Bytes(), &state); err != nil { + log.Error("Failed to unmarshal state file for %s: %v", stateName, err) + apiError(ctx, http.StatusInternalServerError, "Invalid state file format") + return + } + + // Ensure lineage is set + if state.Lineage == "" { + state.Lineage = uuid.NewString() + log.Info("Generated new lineage for state %s: %s", stateName, state.Lineage) + } + + // Send the state in the response + ctx.Resp.Header().Set("Content-Type", "application/json") + ctx.Resp.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", stateName)) + ctx.Resp.WriteHeader(http.StatusOK) + if _, writeErr := ctx.Resp.Write(buf.Bytes()); writeErr != nil { + log.Error("Failed to write response for state %s: %v", stateName, writeErr) + } +} + +// UpdateState updates or creates a new Terraform state and interacts with Gitea packages. +func UpdateState(ctx *context.Context) { + stateName := ctx.PathParam("statename") + log.Info("UpdateState called for stateName: %s", stateName) + + storeMutex.Lock() + defer storeMutex.Unlock() + + // Check for the presence of a lock ID + requestLockID := ctx.Req.URL.Query().Get("ID") + if requestLockID == "" { + apiError(ctx, http.StatusBadRequest, "Missing ID query parameter") + return + } + + // Check for blocking state + if lockID, locked := stateLocks[stateName]; locked && lockID != requestLockID { + apiError(ctx, http.StatusConflict, fmt.Sprintf("State %s is locked", stateName)) + return + } + + // Read the request body + 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 + } + + // Getting the current serial + pvs, _, err := packages_model.SearchVersions(ctx, &packages_model.PackageSearchOptions{ + OwnerID: ctx.Package.Owner.ID, + Type: packages_model.TypeTerraform, + Name: packages_model.SearchValue{ExactMatch: true, Value: stateName}, + IsInternal: optional.Some(false), + }) + if err != nil { + apiError(ctx, http.StatusInternalServerError, "Failed to search package versions") + return + } + + serial := uint64(1) // Start from 1 + if len(pvs) > 0 { + lastSerial, _ := strconv.ParseUint(pvs[0].Version, 10, 64) + serial = lastSerial + 1 + } + log.Info("State %s updated to serial %d", stateName, serial) + + // Create package information + packageVersion := fmt.Sprintf("%d", serial) + packageInfo := &packages_service.PackageCreationInfo{ + PackageInfo: packages_service.PackageInfo{ + Owner: ctx.Package.Owner, + PackageType: packages_model.TypeTerraform, + Name: stateName, + Version: packageVersion, + }, + SemverCompatible: true, + 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 + } + + // Create/update package + if _, _, err = packages_service.CreatePackageOrAddFileToExisting( + ctx, + packageInfo, + &packages_service.PackageFileCreationInfo{ + PackageFileInfo: packages_service.PackageFileInfo{ + Filename: stateName, + }, + Creator: ctx.Doer, + Data: buffer, + IsLead: true, + }, + ); err != nil { + apiError(ctx, http.StatusInternalServerError, "Failed to update package") + return + } + + log.Info("State %s updated successfully with version %s", stateName, packageVersion) + + ctx.JSON(http.StatusOK, map[string]string{ + "message": "State updated successfully", + "statename": stateName, + }) +} + +// LockState locks a Terraform state to prevent updates. +func LockState(ctx *context.Context) { + stateName := ctx.PathParam("statename") + log.Info("LockState called for state: %s", stateName) + + // Read the request body + body, err := io.ReadAll(ctx.Req.Body) + if err != nil { + log.Error("Failed to read request body: %v", err) + apiError(ctx, http.StatusInternalServerError, "Failed to read request body") + return + } + + // Decode JSON and check lockID + var lockRequest struct { + ID string `json:"ID"` + } + if err := json.Unmarshal(body, &lockRequest); err != nil || lockRequest.ID == "" { + log.Error("Invalid lock request body: %v", err) + apiError(ctx, http.StatusBadRequest, "Invalid or missing lock ID") + return + } + + storeMutex.Lock() + defer storeMutex.Unlock() + + // Check if the state is locked + if _, locked := stateLocks[stateName]; locked { + log.Warn("State %s is already locked", stateName) + apiError(ctx, http.StatusConflict, fmt.Sprintf("State %s is already locked", stateName)) + return + } + + // Set the lock + stateLocks[stateName] = lockRequest.ID + log.Info("State %s locked with ID %s", stateName, lockRequest.ID) + + ctx.JSON(http.StatusOK, map[string]string{ + "message": "State locked successfully", + "statename": stateName, + }) +} + +// UnlockState unlocks a Terraform state. +func UnlockState(ctx *context.Context) { + stateName := ctx.PathParam("statename") + log.Info("UnlockState called for state: %s", stateName) + + // Extract lockID from request body or parameters + var unlockRequest struct { + ID string `json:"ID"` + } + + // Trying to read the request body + body, _ := io.ReadAll(ctx.Req.Body) + if len(body) > 0 { + _ = json.Unmarshal(body, &unlockRequest) // The error can be ignored, since the ID can also be in the query + } + + // If the ID is not found in the body, look in the query parameters + if unlockRequest.ID == "" { + unlockRequest.ID = ctx.Query("ID").(string) + } + + // Check for ID presence + if unlockRequest.ID == "" { + log.Error("Missing lock ID in both query and request body") + apiError(ctx, http.StatusBadRequest, "Missing lock ID") + return + } + + log.Info("Extracted lockID: %s", unlockRequest.ID) + + storeMutex.Lock() + defer storeMutex.Unlock() + + // Check the lock status + currentLockID, locked := stateLocks[stateName] + if !locked || currentLockID != unlockRequest.ID { + log.Warn("Unlock attempt failed for state %s with lock ID %s", stateName, unlockRequest.ID) + 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("State %s unlocked successfully", stateName) + + ctx.JSON(http.StatusOK, map[string]string{ + "message": "State unlocked successfully", + "statename": stateName, + }) +} + +// DeleteState deletes the Terraform state for a given name. +func DeleteState(ctx *context.Context) { + stateName := ctx.PathParam("statename") + log.Info("Attempting to delete state: %s", stateName) + + storeMutex.Lock() + defer storeMutex.Unlock() + + // Check if a state or lock exists + _, stateExists := stateStorage[stateName] + _, lockExists := stateLocks[stateName] + + if !stateExists && !lockExists { + log.Warn("State %s does not exist or is not locked", stateName) + apiError(ctx, http.StatusNotFound, "State not found") + return + } + + // Delete the state and lock + delete(stateStorage, stateName) + delete(stateLocks, stateName) + + log.Info("State %s deleted successfully", stateName) + ctx.JSON(http.StatusOK, map[string]string{ + "message": "State deleted successfully", + "statename": stateName, + }) +} diff --git a/routers/api/v1/packages/package.go b/routers/api/v1/packages/package.go index b38aa13167..c9af7e8db0 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 9b6f907164..d1a2b8587c 100644 --- a/services/forms/package_form.go +++ b/services/forms/package_form.go @@ -15,7 +15,7 @@ import ( type PackageCleanupRuleForm struct { ID int64 Enabled bool - Type string `binding:"Required;In(alpine,arch,cargo,chef,composer,conan,conda,container,cran,debian,generic,go,helm,maven,npm,nuget,pub,pypi,rpm,rubygems,swift,vagrant)"` + Type string `binding:"Required;In(alpine,arch,cargo,chef,composer,conan,conda,container,cran,debian,generic,go,helm,maven,npm,nuget,pub,pypi,rpm,rubygems,swift,terraform,vagrant)"` KeepCount int `binding:"In(0,1,5,10,25,50,100)"` KeepPattern string `binding:"RegexPattern"` RemoveDays int `binding:"In(0,7,14,30,60,90,180)"` diff --git a/services/packages/packages.go b/services/packages/packages.go index bd1d460fd3..0736ef4b56 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 0000000000..e7e8f11d49 --- /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}} \ No newline at end of file diff --git a/templates/package/view.tmpl b/templates/package/view.tmpl index 9e92207466..5c5305cd09 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 8082fc594a..17cb8c1cc9 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 0000000000..0e7df0104d --- /dev/null +++ b/tests/integration/api_packages_terraform_test.go @@ -0,0 +1,154 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "fmt" + "net/http" + "strings" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/packages" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/json" + terraform_module "code.gitea.io/gitea/modules/packages/terraform" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func TestPackageTerraform(t *testing.T) { + defer tests.PrepareTestEnv(t)() + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + token := "Bearer " + getUserToken(t, user.Name, auth_model.AccessTokenScopeWritePackage) + + packageName := "test_module" + packageVersion := "1.0.1" + packageDescription := "Test Terraform Module" + + filename := "terraform_module.tar.gz" + + infoContent, _ := json.Marshal(map[string]string{ + "description": packageDescription, + }) + + var buf bytes.Buffer + zw := gzip.NewWriter(&buf) + archive := tar.NewWriter(zw) + archive.WriteHeader(&tar.Header{ + Name: "info.json", + Mode: 0o600, + Size: int64(len(infoContent)), + }) + archive.Write(infoContent) + archive.Close() + zw.Close() + content := buf.Bytes() + + root := fmt.Sprintf("/api/packages/%s/terraform", user.Name) + + t.Run("Authenticate", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + authenticateURL := fmt.Sprintf("%s/authenticate", root) + + req := NewRequest(t, "GET", authenticateURL) + MakeRequest(t, req, http.StatusUnauthorized) + + req = NewRequest(t, "GET", authenticateURL). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusOK) + }) + + moduleURL := fmt.Sprintf("%s/%s", root, packageName) + + t.Run("Upload", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "HEAD", moduleURL) + MakeRequest(t, req, http.StatusNotFound) + + uploadURL := fmt.Sprintf("%s/%s/%s", moduleURL, packageVersion, filename) + + req = NewRequestWithBody(t, "PUT", uploadURL, bytes.NewReader(content)) + MakeRequest(t, req, http.StatusUnauthorized) + + req = NewRequestWithBody(t, "PUT", uploadURL, bytes.NewReader(content)). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusCreated) + + req = NewRequest(t, "HEAD", moduleURL) + resp := MakeRequest(t, req, http.StatusOK) + assert.True(t, strings.HasPrefix(resp.Header().Get("Content-Type"), "application/json")) + + pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeTerraform) + assert.NoError(t, err) + assert.Len(t, pvs, 1) + + pd, err := packages.GetPackageDescriptor(db.DefaultContext, pvs[0]) + assert.NoError(t, err) + assert.NotNil(t, pd.SemVer) + assert.IsType(t, &terraform_module.Metadata{}, pd.Metadata) + assert.Equal(t, packageName, pd.Package.Name) + assert.Equal(t, packageVersion, pd.Version.Version) + + pfs, err := packages.GetFilesByVersionID(db.DefaultContext, pvs[0].ID) + assert.NoError(t, err) + assert.Len(t, pfs, 1) + assert.Equal(t, filename, pfs[0].Name) + assert.True(t, pfs[0].IsLead) + + pb, err := packages.GetBlobByID(db.DefaultContext, pfs[0].BlobID) + assert.NoError(t, err) + assert.Equal(t, int64(len(content)), pb.Size) + + req = NewRequestWithBody(t, "PUT", uploadURL, bytes.NewReader(content)). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusConflict) + }) + + t.Run("Download", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", fmt.Sprintf("%s/%s/%s", moduleURL, packageVersion, filename)) + resp := MakeRequest(t, req, http.StatusOK) + + assert.Equal(t, content, resp.Body.Bytes()) + }) + + t.Run("EnumeratePackageVersions", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", moduleURL) + resp := MakeRequest(t, req, http.StatusOK) + + type versionMetadata struct { + Version string `json:"version"` + Status string `json:"status"` + } + + type packageMetadata struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Versions []*versionMetadata `json:"versions"` + } + + var result packageMetadata + DecodeJSON(t, resp, &result) + + assert.Equal(t, packageName, result.Name) + assert.Equal(t, packageDescription, result.Description) + assert.Len(t, result.Versions, 1) + version := result.Versions[0] + assert.Equal(t, packageVersion, version.Version) + assert.Equal(t, "active", version.Status) + }) +}