mirror of
https://github.com/hanzoai/forge.git
synced 2026-10-30 15:00:39 +00:00
Source files keep their SPDX-License-Identifier and Hanzo copyright line; the Gitea, Gogs and LFS Test Server copyrights they used to repeat now live once in NOTICE, alongside LICENSE. The header lint accepts any copyright line above the SPDX line, so it no longer demands an upstream one. Co-authored-by: Hanzo Dev <[email protected]>
439 lines
12 KiB
Go
439 lines
12 KiB
Go
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package terraform
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"unicode"
|
|
|
|
packages_model "github.com/hanzoai/git/models/packages"
|
|
"github.com/hanzoai/git/modules/globallock"
|
|
"github.com/hanzoai/git/modules/log"
|
|
"github.com/hanzoai/git/modules/optional"
|
|
packages_module "github.com/hanzoai/git/modules/packages"
|
|
terraform_module "github.com/hanzoai/git/modules/packages/terraform"
|
|
"github.com/hanzoai/git/routers/api/packages/helper"
|
|
"github.com/hanzoai/git/services/context"
|
|
packages_service "github.com/hanzoai/git/services/packages"
|
|
)
|
|
|
|
var packageNameRegex = regexp.MustCompile(`\A[-_+.\w]+\z`)
|
|
|
|
const (
|
|
stateFilename = "tfstate"
|
|
)
|
|
|
|
func apiError(ctx *context.Context, status int, obj any) {
|
|
message := helper.ProcessErrorForUser(ctx, status, obj)
|
|
ctx.PlainText(status, message)
|
|
}
|
|
|
|
// GetTerraformState serves the latest version of the state
|
|
func GetTerraformState(ctx *context.Context) {
|
|
stateName := ctx.PathParam("name")
|
|
pv, err := getLatestVersion(ctx, stateName)
|
|
if errors.Is(err, packages_model.ErrPackageNotExist) {
|
|
apiError(ctx, http.StatusNotFound, nil)
|
|
return
|
|
} else if err != nil {
|
|
apiError(ctx, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
streamState(ctx, stateName, pv.Version)
|
|
}
|
|
|
|
// GetTerraformStateBySerial serves a specific version of terraform state.
|
|
func GetTerraformStateBySerial(ctx *context.Context) {
|
|
streamState(ctx, ctx.PathParam("name"), ctx.PathParam("serial"))
|
|
}
|
|
|
|
// streamState serves the terraform state file
|
|
func streamState(ctx *context.Context, name, serial string) {
|
|
s, u, pf, err := packages_service.OpenFileForDownloadByPackageNameAndVersion(
|
|
ctx,
|
|
&packages_service.PackageInfo{
|
|
Owner: ctx.Package.Owner,
|
|
PackageType: packages_model.TypeTerraformState,
|
|
Name: name,
|
|
Version: serial,
|
|
},
|
|
&packages_service.PackageFileInfo{
|
|
Filename: stateFilename,
|
|
},
|
|
ctx.Req.Method,
|
|
)
|
|
if err != nil {
|
|
if errors.Is(err, packages_model.ErrPackageNotExist) || errors.Is(err, packages_model.ErrPackageFileNotExist) {
|
|
apiError(ctx, http.StatusNotFound, err)
|
|
return
|
|
}
|
|
apiError(ctx, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
|
|
helper.ServePackageFile(ctx, s, u, pf)
|
|
}
|
|
|
|
func isValidPackageName(packageName string) bool {
|
|
if len(packageName) == 1 && !unicode.IsLetter(rune(packageName[0])) && !unicode.IsNumber(rune(packageName[0])) {
|
|
return false
|
|
}
|
|
return packageNameRegex.MatchString(packageName) && packageName != ".."
|
|
}
|
|
|
|
// UploadState uploads the specific terraform package.
|
|
func UploadState(ctx *context.Context) {
|
|
packageName := ctx.PathParam("name")
|
|
|
|
if !isValidPackageName(packageName) {
|
|
apiError(ctx, http.StatusBadRequest, errors.New("invalid package name"))
|
|
return
|
|
}
|
|
lockKey := getLockKey(ctx)
|
|
release, err := globallock.Lock(ctx, lockKey)
|
|
if err != nil {
|
|
apiError(ctx, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
defer release()
|
|
|
|
p, err := packages_model.GetPackageByName(ctx, ctx.Package.Owner.ID, packages_model.TypeTerraformState, packageName)
|
|
if err != nil && !errors.Is(err, packages_model.ErrPackageNotExist) {
|
|
apiError(ctx, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
if p != nil {
|
|
// Check lock
|
|
lock, err := terraform_module.GetLock(ctx, p.ID)
|
|
if err != nil {
|
|
apiError(ctx, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
|
|
// If the state is locked, enforce the lock
|
|
if lock.IsLocked() && lock.ID != ctx.FormString("ID") {
|
|
ctx.JSON(http.StatusLocked, lock)
|
|
return
|
|
}
|
|
}
|
|
|
|
upload, needToClose, err := ctx.UploadStream()
|
|
if err != nil {
|
|
apiError(ctx, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
if needToClose {
|
|
defer upload.Close()
|
|
}
|
|
|
|
buf, err := packages_module.CreateHashedBufferFromReader(upload)
|
|
if err != nil {
|
|
log.Error("Error creating hashed buffer: %v", err)
|
|
apiError(ctx, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
defer buf.Close()
|
|
|
|
state, err := terraform_module.ParseState(buf)
|
|
if err != nil {
|
|
log.Error("Error decoding state: %v", err)
|
|
apiError(ctx, http.StatusBadRequest, err)
|
|
return
|
|
}
|
|
|
|
if _, err := buf.Seek(0, io.SeekStart); err != nil {
|
|
apiError(ctx, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
_, _, err = packages_service.CreatePackageOrAddFileToExisting(
|
|
ctx,
|
|
&packages_service.PackageCreationInfo{
|
|
PackageInfo: packages_service.PackageInfo{
|
|
Owner: ctx.Package.Owner,
|
|
PackageType: packages_model.TypeTerraformState,
|
|
Name: packageName,
|
|
Version: strconv.FormatUint(state.Serial, 10),
|
|
},
|
|
Creator: ctx.Doer,
|
|
},
|
|
&packages_service.PackageFileCreationInfo{
|
|
PackageFileInfo: packages_service.PackageFileInfo{
|
|
Filename: stateFilename,
|
|
},
|
|
Creator: ctx.Doer,
|
|
Data: buf,
|
|
IsLead: true,
|
|
},
|
|
)
|
|
if err != nil {
|
|
switch {
|
|
case errors.Is(err, packages_model.ErrDuplicatePackageFile):
|
|
apiError(ctx, http.StatusConflict, err)
|
|
case errors.Is(err, packages_service.ErrQuotaTotalCount), errors.Is(err, packages_service.ErrQuotaTypeSize), errors.Is(err, packages_service.ErrQuotaTotalSize):
|
|
apiError(ctx, http.StatusForbidden, err)
|
|
default:
|
|
apiError(ctx, http.StatusInternalServerError, err)
|
|
}
|
|
return
|
|
}
|
|
|
|
ctx.Status(http.StatusCreated)
|
|
}
|
|
|
|
// DeleteStateBySerial deletes the specific serial of a terraform package as long as it's not the latest one.
|
|
func DeleteStateBySerial(ctx *context.Context) {
|
|
lockKey := getLockKey(ctx)
|
|
release, err := globallock.Lock(ctx, lockKey)
|
|
if err != nil {
|
|
apiError(ctx, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
defer release()
|
|
|
|
serial := ctx.PathParam("serial")
|
|
pv, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypeTerraformState, ctx.PathParam("name"), serial)
|
|
if errors.Is(err, packages_model.ErrPackageNotExist) {
|
|
apiError(ctx, http.StatusNotFound, err)
|
|
return
|
|
} else if err != nil {
|
|
apiError(ctx, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
|
|
pvLatest, err := getLatestVersion(ctx, ctx.PathParam("name"))
|
|
if errors.Is(err, packages_model.ErrPackageNotExist) {
|
|
apiError(ctx, http.StatusNotFound, err)
|
|
return
|
|
} else if err != nil {
|
|
apiError(ctx, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
if pvLatest.ID == pv.ID {
|
|
apiError(ctx, http.StatusForbidden, errors.New("cannot delete the latest version"))
|
|
return
|
|
}
|
|
|
|
err = packages_service.DeletePackageVersionAndReferences(ctx, pv)
|
|
if err != nil {
|
|
apiError(ctx, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
ctx.Status(http.StatusNoContent)
|
|
}
|
|
|
|
// DeleteState deletes the specific file of a terraform package.
|
|
// Fails if the state is locked
|
|
func DeleteState(ctx *context.Context) {
|
|
packageName := ctx.PathParam("name")
|
|
|
|
lockKey := getLockKey(ctx)
|
|
release, err := globallock.Lock(ctx, lockKey)
|
|
if err != nil {
|
|
apiError(ctx, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
defer release()
|
|
|
|
p, err := packages_model.GetPackageByName(ctx, ctx.Package.Owner.ID, packages_model.TypeTerraformState, packageName)
|
|
if err != nil {
|
|
if errors.Is(err, packages_model.ErrPackageNotExist) {
|
|
apiError(ctx, http.StatusNotFound, err)
|
|
return
|
|
}
|
|
apiError(ctx, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
|
|
lock, err := terraform_module.GetLock(ctx, p.ID)
|
|
if err != nil {
|
|
apiError(ctx, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
if lock.IsLocked() {
|
|
apiError(ctx, http.StatusLocked, errors.New("terraform state is locked"))
|
|
return
|
|
}
|
|
|
|
pvs, _, err := packages_model.SearchVersions(ctx, &packages_model.PackageSearchOptions{
|
|
PackageID: p.ID,
|
|
IsInternal: optional.None[bool](),
|
|
})
|
|
if err != nil {
|
|
apiError(ctx, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
|
|
err = packages_model.DeleteAllProperties(ctx, packages_model.PropertyTypePackage, p.ID)
|
|
if err != nil {
|
|
apiError(ctx, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
|
|
for _, pv := range pvs {
|
|
if err := packages_service.RemovePackageVersion(ctx, ctx.Doer, pv); err != nil {
|
|
apiError(ctx, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
}
|
|
|
|
if err := packages_model.DeletePackageByID(ctx, p.ID); err != nil {
|
|
apiError(ctx, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
|
|
ctx.Status(http.StatusOK)
|
|
}
|
|
|
|
// LockState locks the specific terraform state.
|
|
// Internally, it adds a property to the package with the lock information
|
|
// Caveat being that it allocates a package if one doesn't exist to attach the property
|
|
func LockState(ctx *context.Context) {
|
|
packageName := ctx.PathParam("name")
|
|
if !isValidPackageName(packageName) {
|
|
apiError(ctx, http.StatusBadRequest, errors.New("invalid package name"))
|
|
return
|
|
}
|
|
|
|
var reqLockInfo *terraform_module.LockInfo
|
|
reqLockInfo, err := terraform_module.ParseLockInfo(ctx.Req.Body)
|
|
if err != nil {
|
|
apiError(ctx, http.StatusBadRequest, err)
|
|
return
|
|
}
|
|
|
|
lockKey := getLockKey(ctx)
|
|
release, err := globallock.Lock(ctx, lockKey)
|
|
if err != nil {
|
|
apiError(ctx, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
defer release()
|
|
|
|
p, err := packages_model.GetPackageByName(ctx, ctx.Package.Owner.ID, packages_model.TypeTerraformState, packageName)
|
|
if err != nil {
|
|
// If the package doesn't exist, allocate it for the lock.
|
|
if errors.Is(err, packages_model.ErrPackageNotExist) {
|
|
p = &packages_model.Package{
|
|
OwnerID: ctx.Package.Owner.ID,
|
|
Type: packages_model.TypeTerraformState,
|
|
Name: packageName,
|
|
LowerName: strings.ToLower(packageName),
|
|
}
|
|
if p, err = packages_model.TryInsertPackage(ctx, p); err != nil {
|
|
apiError(ctx, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
} else {
|
|
apiError(ctx, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
}
|
|
|
|
currentLock, err := terraform_module.GetLock(ctx, p.ID)
|
|
if err != nil {
|
|
apiError(ctx, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
|
|
if currentLock.IsLocked() {
|
|
ctx.JSON(http.StatusLocked, currentLock)
|
|
return
|
|
}
|
|
|
|
err = terraform_module.SetLock(ctx, p.ID, reqLockInfo)
|
|
if err != nil {
|
|
apiError(ctx, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
|
|
ctx.Status(http.StatusOK)
|
|
}
|
|
|
|
// UnlockState unlock the specific terraform state.
|
|
// Internally, it clears the package property
|
|
func UnlockState(ctx *context.Context) {
|
|
packageName := ctx.PathParam("name")
|
|
if !isValidPackageName(packageName) {
|
|
apiError(ctx, http.StatusBadRequest, errors.New("invalid package name"))
|
|
return
|
|
}
|
|
|
|
reqLockInfo, err := terraform_module.ParseLockInfo(ctx.Req.Body)
|
|
if err != nil {
|
|
apiError(ctx, http.StatusBadRequest, err)
|
|
return
|
|
}
|
|
|
|
lockKey := getLockKey(ctx)
|
|
release, err := globallock.Lock(ctx, lockKey)
|
|
if err != nil {
|
|
apiError(ctx, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
defer release()
|
|
|
|
p, err := packages_model.GetPackageByName(ctx, ctx.Package.Owner.ID, packages_model.TypeTerraformState, packageName)
|
|
if err != nil {
|
|
if errors.Is(err, packages_model.ErrPackageNotExist) {
|
|
ctx.Status(http.StatusOK)
|
|
return
|
|
}
|
|
apiError(ctx, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
|
|
existingLock, err := terraform_module.GetLock(ctx, p.ID)
|
|
if err != nil {
|
|
apiError(ctx, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
|
|
// we can bypass messing with the lock since it's empty
|
|
if !existingLock.IsLocked() {
|
|
ctx.Status(http.StatusOK)
|
|
return
|
|
}
|
|
|
|
// Unlocking ID must be the same as locker one.
|
|
if existingLock.ID != reqLockInfo.ID {
|
|
apiError(ctx, http.StatusLocked, errors.New("lock ID mismatch"))
|
|
return
|
|
}
|
|
// We can clear the state if lock id matches
|
|
err = terraform_module.RemoveLock(ctx, p.ID)
|
|
if err != nil {
|
|
apiError(ctx, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
|
|
ctx.Status(http.StatusOK)
|
|
}
|
|
|
|
func getLatestVersion(ctx *context.Context, packageName string) (*packages_model.PackageVersion, error) {
|
|
pvs, _, err := packages_model.SearchLatestVersions(ctx, &packages_model.PackageSearchOptions{
|
|
OwnerID: ctx.Package.Owner.ID,
|
|
Type: packages_model.TypeTerraformState,
|
|
Name: packages_model.SearchValue{ExactMatch: true, Value: packageName},
|
|
IsInternal: optional.Some(false),
|
|
Sort: packages_model.SortCreatedDesc,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(pvs) == 0 {
|
|
return nil, packages_model.ErrPackageNotExist
|
|
}
|
|
return pvs[0], nil
|
|
}
|
|
|
|
func getLockKey(ctx *context.Context) string {
|
|
return fmt.Sprintf("terraform_lock_%d_%s", ctx.Package.Owner.ID, strings.ToLower(ctx.PathParam("name")))
|
|
}
|