edb6c7c0f7
Local terraform repos already spoke the network mirror protocol, which needs
per-consumer .terraformrc config. This adds the provider registry protocol so
`terraform init` installs from a bare source address
(artifactapi.k8s.../{repo}/{type}) with no client setup.
- serve /.well-known/terraform.json service discovery and the providers.v1
versions/download endpoints under /terraform/v1/providers
- map the Terraform namespace to the artifactapi repo name and locate the
provider by type; download_url points back at the existing local file path
- generate SHA256SUMS per version and sign it with a GPG key loaded from
TF_SIGNING_KEY_PATH; advertise the public key + key id in the download
response. No key configured -> registry stays disabled (endpoints 404)
- new internal/tfsign (key loading + detached signing) and
internal/api/terraform (registry handler); export ParseProviderZip for reuse
- add TF_SIGNING_KEY_PATH/PASSPHRASE and TF_PROVIDER_PROTOCOLS config
- unit test signing + verification; dockerised test of the full flow incl.
signature verification against the advertised key
Also anchor the terraform/ gitignore to the repo root so it stops swallowing
internal/api/terraform and internal/provider/terraform test files (the latter
had gone silently untracked).
172 lines
6.1 KiB
Go
172 lines
6.1 KiB
Go
package terraform
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.unkin.net/unkin/artifactapi/internal/provider"
|
|
"git.unkin.net/unkin/artifactapi/pkg/models"
|
|
)
|
|
|
|
type fakeFileStore struct{ entries []provider.FileEntry }
|
|
|
|
func (f fakeFileStore) ListFilesByPrefix(_ context.Context, _, prefix string) ([]provider.FileEntry, error) {
|
|
var out []provider.FileEntry
|
|
for _, e := range f.entries {
|
|
if strings.HasPrefix(e.FilePath, prefix) {
|
|
out = append(out, e)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
func (f fakeFileStore) ListPackages(_ context.Context, _ string) ([]string, error) { return nil, nil }
|
|
|
|
func TestTFPureFuncs(t *testing.T) {
|
|
p := &Provider{}
|
|
if p.Classify("hashicorp/aws/versions") != provider.Mutable {
|
|
t.Error("versions should be mutable")
|
|
}
|
|
if p.Classify("hashicorp/aws/terraform-provider-aws_1.0.0_linux_amd64.zip") != provider.Immutable {
|
|
t.Error("zip should be immutable")
|
|
}
|
|
if got := p.UpstreamURL(models.Remote{BaseURL: "https://registry.terraform.io"}, "hashicorp/aws/versions"); got != "https://registry.terraform.io/v1/providers/hashicorp/aws/versions" {
|
|
t.Errorf("upstream url %q", got)
|
|
}
|
|
h, _ := p.AuthHeaders(context.Background(), models.Remote{Username: "u", Password: "p"})
|
|
if h.Get("Authorization") == "" {
|
|
t.Error("auth header")
|
|
}
|
|
_ = p.ContentType("x.json")
|
|
}
|
|
|
|
func TestTFValidateUpload(t *testing.T) {
|
|
p := &Provider{}
|
|
sp, ct, err := p.ValidateUpload("hashicorp/aws/terraform-provider-aws_1.2.3_linux_amd64.zip")
|
|
if err != nil || sp != "hashicorp/aws/terraform-provider-aws_1.2.3_linux_amd64.zip" || ct != "application/zip" {
|
|
t.Errorf("valid: sp=%q ct=%q err=%v", sp, ct, err)
|
|
}
|
|
if _, _, err := p.ValidateUpload("too/few"); err == nil {
|
|
t.Error("expected error for wrong path depth")
|
|
}
|
|
if _, _, err := p.ValidateUpload("ns/aws/not-a-provider.zip"); err == nil {
|
|
t.Error("expected error for bad filename")
|
|
}
|
|
if _, _, err := p.ValidateUpload("ns/gcp/terraform-provider-aws_1.0.0_linux_amd64.zip"); err == nil {
|
|
t.Error("expected error for type mismatch")
|
|
}
|
|
}
|
|
|
|
func TestTFUploadResponse(t *testing.T) {
|
|
p := &Provider{}
|
|
resp := p.UploadResponse("hashicorp/aws/terraform-provider-aws_1.2.3_linux_amd64.zip", "sha256:abc", 100)
|
|
if resp["namespace"] != "hashicorp" || resp["type"] != "aws" || resp["version"] != "1.2.3" || resp["os"] != "linux" || resp["arch"] != "amd64" {
|
|
t.Errorf("structured response wrong: %v", resp)
|
|
}
|
|
fallback := p.UploadResponse("weird/path", "sha256:x", 1)
|
|
if fallback["path"] != "weird/path" {
|
|
t.Errorf("fallback response wrong: %v", fallback)
|
|
}
|
|
}
|
|
|
|
func TestTFRewriteResponse(t *testing.T) {
|
|
p := &Provider{}
|
|
remote := models.Remote{Name: "tf", ReleasesRemote: "hashicorp-releases"}
|
|
|
|
if out, _ := p.RewriteResponse([]byte(`{"download_url":"x"}`), models.Remote{}, "http://proxy"); out != nil {
|
|
t.Error("no ReleasesRemote should be a no-op")
|
|
}
|
|
if out, _ := p.RewriteResponse([]byte("not json"), remote, "http://proxy"); out != nil {
|
|
t.Error("invalid json should be a no-op")
|
|
}
|
|
body := []byte(`{"download_url":"https://releases.hashicorp.com/terraform-provider-aws/1.0/aws.zip"}`)
|
|
out, err := p.RewriteResponse(body, remote, "http://proxy")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(string(out), "http://proxy/api/v1/remote/hashicorp-releases/") {
|
|
t.Errorf("download_url not rewritten: %s", out)
|
|
}
|
|
}
|
|
|
|
func TestTFServeLocalIndex(t *testing.T) {
|
|
p := &Provider{}
|
|
fs := fakeFileStore{entries: []provider.FileEntry{
|
|
{FilePath: "hashicorp/aws/terraform-provider-aws_1.0.0_linux_amd64.zip", ContentHash: "sha256:deadbeef"},
|
|
{FilePath: "hashicorp/aws/terraform-provider-aws_1.0.0_darwin_arm64.zip", ContentHash: "sha256:cafe"},
|
|
}}
|
|
|
|
serve := func(path string) *httptest.ResponseRecorder {
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest(http.MethodGet, "/"+path, nil)
|
|
p.ServeLocalIndex(w, r, fs, "repo", path)
|
|
return w
|
|
}
|
|
|
|
if w := serve("hashicorp/aws/index.json"); w.Code != 200 || !strings.Contains(w.Body.String(), "1.0.0") {
|
|
t.Errorf("index.json: code=%d body=%s", w.Code, w.Body.String())
|
|
}
|
|
if w := serve("hashicorp/aws/1.0.0.json"); w.Code != 200 || !strings.Contains(w.Body.String(), "linux_amd64") {
|
|
t.Errorf("version doc: code=%d body=%s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// Not a terraform index path.
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest(http.MethodGet, "/x", nil)
|
|
if p.ServeLocalIndex(w, r, fs, "repo", "hashicorp/aws/other.txt") {
|
|
t.Error("non-index path should return false")
|
|
}
|
|
if p.ServeLocalIndex(httptest.NewRecorder(), r, fs, "repo", "too/short") {
|
|
t.Error("short path should return false")
|
|
}
|
|
}
|
|
|
|
func TestTFContentTypeAndEmptyIndex(t *testing.T) {
|
|
p := &Provider{}
|
|
for path, want := range map[string]string{
|
|
"x.zip": "application/zip",
|
|
"x.sig": "application/octet-stream",
|
|
"index.json": "application/json",
|
|
} {
|
|
if got := p.ContentType(path); got != want {
|
|
t.Errorf("ContentType(%q)=%q want %q", path, got, want)
|
|
}
|
|
}
|
|
// index / version doc with no matching files -> 404.
|
|
empty := fakeFileStore{}
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest(http.MethodGet, "/hashicorp/aws/index.json", nil)
|
|
p.ServeLocalIndex(w, r, empty, "repo", "hashicorp/aws/index.json")
|
|
if w.Code != http.StatusNotFound {
|
|
t.Errorf("empty index should be 404, got %d", w.Code)
|
|
}
|
|
w = httptest.NewRecorder()
|
|
p.ServeLocalIndex(w, r, empty, "repo", "hashicorp/aws/1.0.0.json")
|
|
if w.Code != http.StatusNotFound {
|
|
t.Errorf("empty version doc should be 404, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestRewriteDownloadURL(t *testing.T) {
|
|
// Empty proxy base -> unchanged.
|
|
if got := rewriteDownloadURL("https://x/a.zip", "rel", ""); got != "https://x/a.zip" {
|
|
t.Errorf("empty base: %q", got)
|
|
}
|
|
// Unparseable URL -> unchanged.
|
|
if got := rewriteDownloadURL("://bad", "rel", "http://p"); got != "://bad" {
|
|
t.Errorf("bad url: %q", got)
|
|
}
|
|
// Normal rewrite.
|
|
if got := rewriteDownloadURL("https://cdn/path/a.zip", "rel", "http://p"); got != "http://p/api/v1/remote/rel/path/a.zip" {
|
|
t.Errorf("rewrite: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestTFGenerateLocalIndexUnsupported(t *testing.T) {
|
|
if _, err := (&Provider{}).GenerateLocalIndex(context.Background(), fakeFileStore{}, "r", "x"); err == nil {
|
|
t.Error("expected unsupported error")
|
|
}
|
|
}
|