Files
5d9bfa1e14 feat(packages): add private Terraform module registry (#38040)
Adds a Terraform module registry package type implementing the [module
registry
protocol](https://developer.hashicorp.com/terraform/internals/module-registry-protocol),
so `terraform init` and Terragrunt `tfr://` can resolve modules from
Gitea:

```hcl
module "vpc" {
  source  = "gitea.example.com/owner/vpc/aws"
  version = "~> 1.0"
}
```

- Modules are uploaded with `PUT
/api/packages/-/terraform/modules/{owner}/{name}/{provider}/{version}`
as a `.tar.gz` with the module at its root, stored verbatim and
immutable.
- `X-Terraform-Get` is a short-lived signed URL because Terraform sends
no credentials when downloading the archive.
- The package page shows install snippets, README, the root module's
inputs, outputs and providers, and `modules/*` submodules.
- Metadata is read by a small in-tree HCL scanner instead of
`hashicorp/hcl/v2`, which would add 5 modules and ~1.4 MB to the binary.

Docs: https://gitea.com/gitea/docs/pulls/433

---------

Signed-off-by: Michael Lopez <mickael.lopez@gmail.com>
Co-authored-by: TheFox0x7 <thefox0x7@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-11-07 10:45:05 +00:00

115 lines
3.8 KiB
Go

// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package terraform_module
import (
"bytes"
"compress/gzip"
"slices"
"strings"
"testing"
packages_module "gitea.dev/modules/packages"
"gitea.dev/modules/test"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func buildArchive(files map[string]string) *bytes.Buffer {
return test.WriteTarCompression(gzip.NewWriter, files)
}
func TestParseModuleArchive(t *testing.T) {
metadata, err := ParseModuleArchive(buildArchive(map[string]string{
"./main.tf": "\xef\xbb\xbf" + `terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
configuration_aliases = [aws.alternate]
}
random = "~> 3.0"
}
}
# an unbalanced { in a comment
locals {
joined = "%{ for k, v in { a = "b" } }${k}%{ endfor }"
}
variable "region" {
type = string
description = "AWS region, not $${var.template} }"
default = "eu-west-1" // trailing comment
}
variable "tags" {
type = map(string)
description = <<EOT
Tags }
EOT
}
output "vpc_id" {
description = "VPC id"
value = aws_vpc.this.id
}
`,
"README.md": "# vpc\n",
"._main.tf": `variable "ignored" {}`,
"backup.TF": `variable "ignored" {}`,
"examples/basic/main.tf": `variable "ignored" {}`,
"modules/net/main.tf": "variable \"cidr\" {}\n",
"modules/net/README.md": "# Net submodule\n",
"modules/net/LICENSE": "must not become the readme",
"modules/net/deep/main.tf": "",
"modules/json/main.tf.json": "",
}))
require.NoError(t, err)
assert.Equal(t, &Metadata{
Readme: "# vpc\n",
Root: &Module{
Inputs: []*Input{
{Name: "region", Type: "string", Description: "AWS region, not ${var.template} }", Default: `"eu-west-1"`},
{Name: "tags", Type: "map(string)", Description: "Tags }\n", Required: true},
},
Outputs: []*Output{{Name: "vpc_id", Description: "VPC id"}},
Providers: []*Provider{
{Name: "aws", Source: "hashicorp/aws", Version: "~> 5.0"},
{Name: "random", Version: "~> 3.0"},
},
},
Submodules: map[string]*Submodule{
"json": {Module: &Module{}},
"net": {Readme: "# Net submodule\n", Module: &Module{Inputs: []*Input{{Name: "cidr", Required: true}}}},
},
}, metadata)
metadata, err = ParseModuleArchive(buildArchive(map[string]string{"modules/net/main.tf": ""}))
require.NoError(t, err)
assert.Equal(t, &Metadata{Submodules: map[string]*Submodule{"net": {Module: &Module{}}}}, metadata)
metadata, err = ParseModuleArchive(buildArchive(map[string]string{
"main.tf": "",
"README.md": strings.Repeat("#", maxReadmeSize+1),
"modules/net/main.tf": "",
"modules/net/README.md": strings.Repeat("#", maxReadmeSize+1),
}))
require.NoError(t, err)
assert.Equal(t, &Metadata{Root: &Module{}, Submodules: map[string]*Submodule{"net": {Module: &Module{}}}}, metadata)
_, err = ParseModuleArchive(buildArchive(map[string]string{"mod-1.0.0/main.tf": ""}))
assert.ErrorIs(t, err, ErrNoRootModule)
_, err = ParseModuleArchive(buildArchive(map[string]string{"main.tf": strings.Repeat("#", packages_module.MaxMetadataSize)}))
assert.ErrorIs(t, err, packages_module.ErrContentTooLarge)
for _, src := range []string{`variable "x" { description = "`, `variable "x" { description = "\`, `x = <<EOT`, `/*`, `variable {`, `"${"${`, `}}}`} {
assert.NotNil(t, parseModule(map[string][]byte{"main.tf": slices.Clip([]byte(src))}))
}
}
func TestValidateAddress(t *testing.T) {
assert.NoError(t, ValidateAddress("org", "My_vpc-2", "azurerm"))
assert.ErrorIs(t, ValidateAddress("john.doe", "vpc", "aws"), ErrInvalidNamespace)
assert.ErrorIs(t, ValidateAddress("org", "vpc-", "aws"), ErrInvalidName)
assert.ErrorIs(t, ValidateAddress("org", "vpc", "my-cloud"), ErrInvalidProvider)
}