diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..5b65ffe --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,15 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-added-large-files + + - repo: https://github.com/dnephin/pre-commit-golang + rev: v0.5.1 + hooks: + - id: go-fmt + - id: go-vet + - id: go-mod-tidy diff --git a/.woodpecker/build.yml b/.woodpecker/build.yml new file mode 100644 index 0000000..77b69fc --- /dev/null +++ b/.woodpecker/build.yml @@ -0,0 +1,8 @@ +when: + - event: [push, pull_request] + +steps: + - name: build + image: golang:1.25 + commands: + - make build diff --git a/.woodpecker/test.yml b/.woodpecker/test.yml new file mode 100644 index 0000000..4a56955 --- /dev/null +++ b/.woodpecker/test.yml @@ -0,0 +1,13 @@ +when: + - event: [push, pull_request] + +steps: + - name: lint + image: golang:1.25 + commands: + - make lint + + - name: test + image: golang:1.25 + commands: + - make test diff --git a/README.md b/README.md new file mode 100644 index 0000000..61c3510 --- /dev/null +++ b/README.md @@ -0,0 +1,182 @@ +# terraform-provider-artifactapi + +Terraform provider for managing [ArtifactAPI](https://git.unkin.net/unkin/artifactapi) remotes and virtual repositories. + +## Requirements + +- Go >= 1.23 +- Terraform >= 1.0 + +## Building + +```sh +make build +``` + +## Installation + +Install the provider to your local Terraform plugin directory: + +```sh +make install +``` + +This places the binary at `~/.terraform.d/plugins/git.unkin.net/unkin/artifactapi///`. + +## Provider Configuration + +```hcl +terraform { + required_providers { + artifactapi = { + source = "git.unkin.net/unkin/artifactapi" + version = "0.0.1" + } + } +} + +provider "artifactapi" { + endpoint = "https://artifactapi.example.com" +} +``` + +| Attribute | Required | Description | +|------------|----------|--------------------------------------| +| `endpoint` | Yes | ArtifactAPI server endpoint URL | + +## Resources + +### Remote Resources + +Per-type remote resources manage upstream repository proxies. Each type applies its own mutability classification rules automatically (e.g., Docker classifies tag manifests as mutable and blobs as immutable; Helm classifies `index.yaml` as mutable). + +Available resource types: + +- `artifactapi_remote_generic` +- `artifactapi_remote_docker` +- `artifactapi_remote_helm` +- `artifactapi_remote_pypi` +- `artifactapi_remote_npm` +- `artifactapi_remote_rpm` +- `artifactapi_remote_alpine` +- `artifactapi_remote_puppet` +- `artifactapi_remote_terraform` +- `artifactapi_remote_goproxy` + +#### Common Attributes + +| Attribute | Required | Default | Description | +|----------------------|----------|---------|-------------------------------------------------------------------| +| `name` | Yes | | Unique name (forces replacement on change) | +| `base_url` | Yes | | Upstream repository URL | +| `description` | No | `""` | Human-readable description | +| `username` | No | `""` | Upstream auth username (sensitive) | +| `password` | No | `""` | Upstream auth password (sensitive) | +| `immutable_ttl` | No | `0` | TTL in seconds for immutable artifacts (0 = cache forever) | +| `mutable_ttl` | No | `3600` | TTL in seconds for mutable artifacts | +| `check_mutable` | No | `true` | Enable conditional revalidation for mutable artifacts | +| `patterns` | No | | Allowlist of path patterns to proxy (empty = all) | +| `blocklist` | No | | Paths to always deny (checked before patterns) | +| `mutable_patterns` | No | | Override: treat matching paths as mutable | +| `immutable_patterns` | No | | Override: treat matching paths as immutable | +| `quarantine_enabled` | No | `false` | Enable quarantine for new artifacts | +| `quarantine_days` | No | `3` | Days to quarantine new artifacts | +| `stale_on_error` | No | `true` | Serve stale cache when upstream is unreachable | + +#### Docker-specific Attributes + +| Attribute | Default | Description | +|--------------------|---------|----------------------------| +| `ban_tags_enabled` | `false` | Enable tag banning | +| `ban_tags` | | List of tags to ban | + +#### Terraform-specific Attributes + +| Attribute | Default | Description | +|-------------------|---------|----------------------------------------------------------| +| `releases_remote` | `""` | Name of a generic remote for download URL rewriting | + +#### Example + +```hcl +resource "artifactapi_remote_docker" "dockerhub" { + name = "dockerhub" + base_url = "https://registry-1.docker.io" + + immutable_ttl = 0 + mutable_ttl = 300 + ban_tags_enabled = true + ban_tags = ["latest"] + + patterns = [ + "^library/postgres", + "^library/redis", + ] +} +``` + +### Virtual Resources + +Virtual repositories merge multiple remotes of the same package type into a single endpoint. + +```hcl +resource "artifactapi_virtual" "helm" { + name = "helm" + package_type = "helm" + description = "All helm repos merged" + + members = [ + artifactapi_remote_helm.jetstack.name, + artifactapi_remote_helm.hashicorp_helm.name, + ] +} +``` + +| Attribute | Required | Description | +|----------------|----------|-------------------------------------------| +| `name` | Yes | Unique name (forces replacement on change)| +| `package_type` | Yes | Package type of member remotes | +| `description` | No | Human-readable description | +| `members` | Yes | List of remote names to include | + +## Data Sources + +### `artifactapi_remote` + +Read an existing remote's configuration. + +```hcl +data "artifactapi_remote" "dockerhub" { + name = "dockerhub" +} +``` + +### `artifactapi_virtual` + +Read an existing virtual repository's configuration. + +```hcl +data "artifactapi_virtual" "helm" { + name = "helm" +} +``` + +## Import + +Resources can be imported by name: + +```sh +terraform import artifactapi_remote_docker.dockerhub dockerhub +terraform import artifactapi_virtual.helm helm +``` + +## Development + +```sh +make build # Build the provider binary +make install # Install to local plugin directory +make test # Run tests +make lint # Run go vet +make fmt # Format code +make clean # Remove binary +``` diff --git a/examples/data-sources/artifactapi_remote/main.tf b/examples/data-sources/artifactapi_remote/main.tf new file mode 100644 index 0000000..0e5f30b --- /dev/null +++ b/examples/data-sources/artifactapi_remote/main.tf @@ -0,0 +1,27 @@ +terraform { + required_providers { + artifactapi = { + source = "git.unkin.net/unkin/artifactapi" + version = "0.0.1" + } + } +} + +provider "artifactapi" { + endpoint = "https://artifactapi.example.com" +} + +# Read the configuration of an existing remote by name. +# All fields (base_url, package_type, TTLs, patterns, etc.) are available +# as computed attributes. +data "artifactapi_remote" "dockerhub" { + name = "dockerhub" +} + +output "dockerhub_base_url" { + value = data.artifactapi_remote.dockerhub.base_url +} + +output "dockerhub_package_type" { + value = data.artifactapi_remote.dockerhub.package_type +} diff --git a/examples/data-sources/artifactapi_virtual/main.tf b/examples/data-sources/artifactapi_virtual/main.tf new file mode 100644 index 0000000..1d2c91f --- /dev/null +++ b/examples/data-sources/artifactapi_virtual/main.tf @@ -0,0 +1,26 @@ +terraform { + required_providers { + artifactapi = { + source = "git.unkin.net/unkin/artifactapi" + version = "0.0.1" + } + } +} + +provider "artifactapi" { + endpoint = "https://artifactapi.example.com" +} + +# Read the configuration of an existing virtual repository by name. +# Returns the package type, description, and ordered member list. +data "artifactapi_virtual" "helm" { + name = "helm" +} + +output "helm_members" { + value = data.artifactapi_virtual.helm.members +} + +output "helm_package_type" { + value = data.artifactapi_virtual.helm.package_type +} diff --git a/examples/main.tf b/examples/main.tf index 4443eca..678f4e7 100644 --- a/examples/main.tf +++ b/examples/main.tf @@ -11,10 +11,13 @@ provider "artifactapi" { endpoint = "https://artifactapi.k8s.syd1.au.unkin.net" } -# Generic — patterns act as allowlist, everything matching is immutable by default +# --------------------------------------------------------------------------- +# Generic remotes +# --------------------------------------------------------------------------- + resource "artifactapi_remote_generic" "github" { - name = "github" - base_url = "https://github.com" + name = "github" + base_url = "https://github.com" description = "GitHub releases" immutable_ttl = 0 @@ -26,15 +29,14 @@ resource "artifactapi_remote_generic" "github" { "neovim/neovim-releases/.*/nvim-linux-x86_64.tar.gz$", ] - # Override: branch archives are mutable mutable_patterns = [ ".*/archive/refs/heads/.*\\.tar\\.gz$", ] } resource "artifactapi_remote_generic" "hashicorp_releases" { - name = "hashicorp-releases" - base_url = "https://releases.hashicorp.com" + name = "hashicorp-releases" + base_url = "https://releases.hashicorp.com" description = "HashiCorp product releases" immutable_ttl = 0 @@ -46,11 +48,13 @@ resource "artifactapi_remote_generic" "hashicorp_releases" { ] } -# Docker — patterns restrict which images are proxied -# Provider auto-classifies: tag manifests mutable, blobs immutable +# --------------------------------------------------------------------------- +# Docker +# --------------------------------------------------------------------------- + resource "artifactapi_remote_docker" "dockerhub" { - name = "dockerhub" - base_url = "https://registry-1.docker.io" + name = "dockerhub" + base_url = "https://registry-1.docker.io" description = "Docker Hub registry" immutable_ttl = 0 @@ -66,10 +70,13 @@ resource "artifactapi_remote_docker" "dockerhub" { ] } -# Helm — no patterns needed, provider knows index.yaml is mutable +# --------------------------------------------------------------------------- +# Helm +# --------------------------------------------------------------------------- + resource "artifactapi_remote_helm" "jetstack" { - name = "jetstack" - base_url = "https://charts.jetstack.io" + name = "jetstack" + base_url = "https://charts.jetstack.io" description = "Jetstack Helm charts (cert-manager)" immutable_ttl = 0 @@ -77,25 +84,62 @@ resource "artifactapi_remote_helm" "jetstack" { } resource "artifactapi_remote_helm" "hashicorp_helm" { - name = "hashicorp-helm" - base_url = "https://helm.releases.hashicorp.com" + name = "hashicorp-helm" + base_url = "https://helm.releases.hashicorp.com" description = "HashiCorp Helm charts" immutable_ttl = 0 mutable_ttl = 3600 } -# RPM — no patterns needed, provider knows repodata/* is mutable +# --------------------------------------------------------------------------- +# Language package managers +# --------------------------------------------------------------------------- + +resource "artifactapi_remote_pypi" "pypi" { + name = "pypi" + base_url = "https://pypi.org" + description = "Python Package Index" + + immutable_ttl = 0 + mutable_ttl = 900 +} + +resource "artifactapi_remote_npm" "npmjs" { + name = "npmjs" + base_url = "https://registry.npmjs.org" + description = "npm public registry" + + immutable_ttl = 0 + mutable_ttl = 900 +} + +# --------------------------------------------------------------------------- +# OS package managers +# --------------------------------------------------------------------------- + resource "artifactapi_remote_rpm" "almalinux" { - name = "almalinux" - base_url = "https://gsl-syd.mm.fcix.net/almalinux" + name = "almalinux" + base_url = "https://gsl-syd.mm.fcix.net/almalinux" description = "AlmaLinux RPM package repository" immutable_ttl = 0 mutable_ttl = 7200 } -# Terraform registry — needs releases_remote for URL rewriting +resource "artifactapi_remote_alpine" "alpine" { + name = "alpine" + base_url = "https://dl-cdn.alpinelinux.org" + description = "Alpine Linux APK package repository" + + immutable_ttl = 0 + mutable_ttl = 7200 +} + +# --------------------------------------------------------------------------- +# Infrastructure tooling +# --------------------------------------------------------------------------- + resource "artifactapi_remote_terraform" "terraform_registry" { name = "terraform-registry" base_url = "https://registry.terraform.io" @@ -106,31 +150,32 @@ resource "artifactapi_remote_terraform" "terraform_registry" { mutable_ttl = 300 } -# Go module proxy — provider knows @v/list is mutable, .zip/.mod/.info immutable resource "artifactapi_remote_goproxy" "goproxy" { - name = "goproxy" - base_url = "https://proxy.golang.org" + name = "goproxy" + base_url = "https://proxy.golang.org" description = "Go module proxy" immutable_ttl = 0 mutable_ttl = 300 } -# Alpine — provider knows APKINDEX.tar.gz is mutable -resource "artifactapi_remote_alpine" "alpine" { - name = "alpine" - base_url = "https://dl-cdn.alpinelinux.org" - description = "Alpine Linux APK package repository" +resource "artifactapi_remote_puppet" "puppet_forge" { + name = "puppet-forge" + base_url = "https://forgeapi.puppet.com" + description = "Puppet Forge module repository" immutable_ttl = 0 - mutable_ttl = 7200 + mutable_ttl = 3600 } -# Virtual — merges multiple helm repos into one index +# --------------------------------------------------------------------------- +# Virtual repositories +# --------------------------------------------------------------------------- + resource "artifactapi_virtual" "helm" { name = "helm" package_type = "helm" - description = "All helm repos merged" + description = "All Helm repos merged" members = [ artifactapi_remote_helm.jetstack.name, @@ -138,11 +183,22 @@ resource "artifactapi_virtual" "helm" { ] } -# Data source — read a remote's config +# --------------------------------------------------------------------------- +# Data sources +# --------------------------------------------------------------------------- + data "artifactapi_remote" "dockerhub" { name = artifactapi_remote_docker.dockerhub.name } +data "artifactapi_virtual" "helm" { + name = artifactapi_virtual.helm.name +} + output "dockerhub_base_url" { value = data.artifactapi_remote.dockerhub.base_url } + +output "helm_members" { + value = data.artifactapi_virtual.helm.members +} diff --git a/examples/resources/artifactapi_remote_alpine/main.tf b/examples/resources/artifactapi_remote_alpine/main.tf new file mode 100644 index 0000000..b94e23d --- /dev/null +++ b/examples/resources/artifactapi_remote_alpine/main.tf @@ -0,0 +1,23 @@ +terraform { + required_providers { + artifactapi = { + source = "git.unkin.net/unkin/artifactapi" + version = "0.0.1" + } + } +} + +provider "artifactapi" { + endpoint = "https://artifactapi.example.com" +} + +# Alpine remote proxies an Alpine Linux APK repository. +# The provider knows APKINDEX.tar.gz is mutable; .apk packages are immutable. +resource "artifactapi_remote_alpine" "alpine" { + name = "alpine" + base_url = "https://dl-cdn.alpinelinux.org" + description = "Alpine Linux APK package repository" + + immutable_ttl = 0 + mutable_ttl = 7200 +} diff --git a/examples/resources/artifactapi_remote_docker/main.tf b/examples/resources/artifactapi_remote_docker/main.tf new file mode 100644 index 0000000..22755ba --- /dev/null +++ b/examples/resources/artifactapi_remote_docker/main.tf @@ -0,0 +1,32 @@ +terraform { + required_providers { + artifactapi = { + source = "git.unkin.net/unkin/artifactapi" + version = "0.0.1" + } + } +} + +provider "artifactapi" { + endpoint = "https://artifactapi.example.com" +} + +# Docker remote proxies a container registry. +# The provider auto-classifies: tag manifests are mutable, blobs are immutable. +resource "artifactapi_remote_docker" "dockerhub" { + name = "dockerhub" + base_url = "https://registry-1.docker.io" + description = "Docker Hub registry" + + immutable_ttl = 0 + mutable_ttl = 300 + ban_tags_enabled = true + ban_tags = ["latest"] + + patterns = [ + "^library/almalinux", + "^library/postgres", + "^library/redis", + "^bitnami/", + ] +} diff --git a/examples/resources/artifactapi_remote_generic/main.tf b/examples/resources/artifactapi_remote_generic/main.tf new file mode 100644 index 0000000..fcec252 --- /dev/null +++ b/examples/resources/artifactapi_remote_generic/main.tf @@ -0,0 +1,34 @@ +terraform { + required_providers { + artifactapi = { + source = "git.unkin.net/unkin/artifactapi" + version = "0.0.1" + } + } +} + +provider "artifactapi" { + endpoint = "https://artifactapi.example.com" +} + +# A generic remote proxies arbitrary HTTP endpoints. +# Patterns act as an allowlist; everything matching is immutable by default. +resource "artifactapi_remote_generic" "github" { + name = "github" + base_url = "https://github.com" + description = "GitHub releases" + + immutable_ttl = 0 + mutable_ttl = 7200 + + patterns = [ + "ducaale/xh/.*/xh-.*-x86_64-unknown-linux-musl.tar.gz$", + "mikefarah/yq/.*/yq_linux_amd64$", + "neovim/neovim-releases/.*/nvim-linux-x86_64.tar.gz$", + ] + + # Override: branch archives are mutable + mutable_patterns = [ + ".*/archive/refs/heads/.*\\.tar\\.gz$", + ] +} diff --git a/examples/resources/artifactapi_remote_goproxy/main.tf b/examples/resources/artifactapi_remote_goproxy/main.tf new file mode 100644 index 0000000..5dcb686 --- /dev/null +++ b/examples/resources/artifactapi_remote_goproxy/main.tf @@ -0,0 +1,23 @@ +terraform { + required_providers { + artifactapi = { + source = "git.unkin.net/unkin/artifactapi" + version = "0.0.1" + } + } +} + +provider "artifactapi" { + endpoint = "https://artifactapi.example.com" +} + +# Go module proxy remote proxies a Go module proxy. +# The provider knows @v/list is mutable; .zip, .mod, and .info files are immutable. +resource "artifactapi_remote_goproxy" "goproxy" { + name = "goproxy" + base_url = "https://proxy.golang.org" + description = "Go module proxy" + + immutable_ttl = 0 + mutable_ttl = 300 +} diff --git a/examples/resources/artifactapi_remote_helm/main.tf b/examples/resources/artifactapi_remote_helm/main.tf new file mode 100644 index 0000000..308182c --- /dev/null +++ b/examples/resources/artifactapi_remote_helm/main.tf @@ -0,0 +1,23 @@ +terraform { + required_providers { + artifactapi = { + source = "git.unkin.net/unkin/artifactapi" + version = "0.0.1" + } + } +} + +provider "artifactapi" { + endpoint = "https://artifactapi.example.com" +} + +# Helm remote proxies a Helm chart repository. +# The provider knows index.yaml is mutable; chart tarballs are immutable. +resource "artifactapi_remote_helm" "jetstack" { + name = "jetstack" + base_url = "https://charts.jetstack.io" + description = "Jetstack Helm charts (cert-manager)" + + immutable_ttl = 0 + mutable_ttl = 3600 +} diff --git a/examples/resources/artifactapi_remote_npm/main.tf b/examples/resources/artifactapi_remote_npm/main.tf new file mode 100644 index 0000000..8ed4ae1 --- /dev/null +++ b/examples/resources/artifactapi_remote_npm/main.tf @@ -0,0 +1,23 @@ +terraform { + required_providers { + artifactapi = { + source = "git.unkin.net/unkin/artifactapi" + version = "0.0.1" + } + } +} + +provider "artifactapi" { + endpoint = "https://artifactapi.example.com" +} + +# NPM remote proxies an npm registry. +# The provider knows package metadata is mutable; tarballs are immutable. +resource "artifactapi_remote_npm" "npmjs" { + name = "npmjs" + base_url = "https://registry.npmjs.org" + description = "npm public registry" + + immutable_ttl = 0 + mutable_ttl = 900 +} diff --git a/examples/resources/artifactapi_remote_puppet/main.tf b/examples/resources/artifactapi_remote_puppet/main.tf new file mode 100644 index 0000000..69e3795 --- /dev/null +++ b/examples/resources/artifactapi_remote_puppet/main.tf @@ -0,0 +1,23 @@ +terraform { + required_providers { + artifactapi = { + source = "git.unkin.net/unkin/artifactapi" + version = "0.0.1" + } + } +} + +provider "artifactapi" { + endpoint = "https://artifactapi.example.com" +} + +# Puppet remote proxies a Puppet Forge repository. +# The provider knows module metadata is mutable; release tarballs are immutable. +resource "artifactapi_remote_puppet" "puppet_forge" { + name = "puppet-forge" + base_url = "https://forgeapi.puppet.com" + description = "Puppet Forge module repository" + + immutable_ttl = 0 + mutable_ttl = 3600 +} diff --git a/examples/resources/artifactapi_remote_pypi/main.tf b/examples/resources/artifactapi_remote_pypi/main.tf new file mode 100644 index 0000000..ece1033 --- /dev/null +++ b/examples/resources/artifactapi_remote_pypi/main.tf @@ -0,0 +1,23 @@ +terraform { + required_providers { + artifactapi = { + source = "git.unkin.net/unkin/artifactapi" + version = "0.0.1" + } + } +} + +provider "artifactapi" { + endpoint = "https://artifactapi.example.com" +} + +# PyPI remote proxies a Python package index. +# The provider knows /simple/ index pages are mutable; sdists and wheels are immutable. +resource "artifactapi_remote_pypi" "pypi" { + name = "pypi" + base_url = "https://pypi.org" + description = "Python Package Index" + + immutable_ttl = 0 + mutable_ttl = 900 +} diff --git a/examples/resources/artifactapi_remote_rpm/main.tf b/examples/resources/artifactapi_remote_rpm/main.tf new file mode 100644 index 0000000..8df1c2d --- /dev/null +++ b/examples/resources/artifactapi_remote_rpm/main.tf @@ -0,0 +1,23 @@ +terraform { + required_providers { + artifactapi = { + source = "git.unkin.net/unkin/artifactapi" + version = "0.0.1" + } + } +} + +provider "artifactapi" { + endpoint = "https://artifactapi.example.com" +} + +# RPM remote proxies an RPM package repository. +# The provider knows repodata/* is mutable; RPM packages are immutable. +resource "artifactapi_remote_rpm" "almalinux" { + name = "almalinux" + base_url = "https://gsl-syd.mm.fcix.net/almalinux" + description = "AlmaLinux RPM package repository" + + immutable_ttl = 0 + mutable_ttl = 7200 +} diff --git a/examples/resources/artifactapi_remote_terraform/main.tf b/examples/resources/artifactapi_remote_terraform/main.tf new file mode 100644 index 0000000..4d085d9 --- /dev/null +++ b/examples/resources/artifactapi_remote_terraform/main.tf @@ -0,0 +1,41 @@ +terraform { + required_providers { + artifactapi = { + source = "git.unkin.net/unkin/artifactapi" + version = "0.0.1" + } + } +} + +provider "artifactapi" { + endpoint = "https://artifactapi.example.com" +} + +# A generic remote is needed for the releases CDN that the Terraform registry +# redirects provider downloads to. +resource "artifactapi_remote_generic" "hashicorp_releases" { + name = "hashicorp-releases" + base_url = "https://releases.hashicorp.com" + description = "HashiCorp product releases" + + immutable_ttl = 0 + mutable_ttl = 7200 + + patterns = [ + "terraform/.*terraform_.*_linux_amd64\\.zip$", + "vault/.*vault_.*_linux_amd64\\.zip$", + ] +} + +# Terraform registry remote proxies the Terraform provider registry. +# The releases_remote attribute points to a generic remote that serves +# the actual provider/module archives after URL rewriting. +resource "artifactapi_remote_terraform" "terraform_registry" { + name = "terraform-registry" + base_url = "https://registry.terraform.io" + description = "Terraform provider registry" + releases_remote = artifactapi_remote_generic.hashicorp_releases.name + + immutable_ttl = 0 + mutable_ttl = 300 +} diff --git a/examples/resources/artifactapi_virtual/main.tf b/examples/resources/artifactapi_virtual/main.tf new file mode 100644 index 0000000..4c68d0b --- /dev/null +++ b/examples/resources/artifactapi_virtual/main.tf @@ -0,0 +1,45 @@ +terraform { + required_providers { + artifactapi = { + source = "git.unkin.net/unkin/artifactapi" + version = "0.0.1" + } + } +} + +provider "artifactapi" { + endpoint = "https://artifactapi.example.com" +} + +# Helm remotes to be merged into a virtual repository +resource "artifactapi_remote_helm" "jetstack" { + name = "jetstack" + base_url = "https://charts.jetstack.io" + description = "Jetstack Helm charts (cert-manager)" + + immutable_ttl = 0 + mutable_ttl = 3600 +} + +resource "artifactapi_remote_helm" "hashicorp_helm" { + name = "hashicorp-helm" + base_url = "https://helm.releases.hashicorp.com" + description = "HashiCorp Helm charts" + + immutable_ttl = 0 + mutable_ttl = 3600 +} + +# Virtual repository merges multiple remotes of the same package type +# into a single endpoint. Earlier members have higher priority for +# duplicate entries. +resource "artifactapi_virtual" "helm" { + name = "helm" + package_type = "helm" + description = "All Helm repos merged" + + members = [ + artifactapi_remote_helm.jetstack.name, + artifactapi_remote_helm.hashicorp_helm.name, + ] +} diff --git a/internal/provider/client_test.go b/internal/provider/client_test.go new file mode 100644 index 0000000..98dcf32 --- /dev/null +++ b/internal/provider/client_test.go @@ -0,0 +1,318 @@ +package provider + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "testing" +) + +func TestNewAPIClient(t *testing.T) { + c := newAPIClient("http://example.com") + if c.baseURL != "http://example.com" { + t.Errorf("expected baseURL http://example.com, got %s", c.baseURL) + } + if c.httpClient == nil { + t.Error("expected non-nil httpClient") + } +} + +func TestGet_Success(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v2/remotes/test" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"name": "test"}) + })) + defer srv.Close() + + c := newAPIClient(srv.URL) + var out map[string]string + err := c.get(context.Background(), "/api/v2/remotes/test", &out) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out["name"] != "test" { + t.Errorf("expected name=test, got %s", out["name"]) + } +} + +func TestGet_NotFound(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + c := newAPIClient(srv.URL) + var out map[string]string + err := c.get(context.Background(), "/api/v2/remotes/missing", &out) + if err == nil { + t.Fatal("expected error for 404") + } + if !isNotFound(err) { + t.Errorf("expected notFoundError, got %T: %v", err, err) + } +} + +func TestGet_ServerError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprint(w, "internal server error") + })) + defer srv.Close() + + c := newAPIClient(srv.URL) + var out map[string]string + err := c.get(context.Background(), "/api/v2/remotes/fail", &out) + if err == nil { + t.Fatal("expected error for 500") + } + if isNotFound(err) { + t.Error("500 should not be notFoundError") + } + expected := "api error 500: internal server error" + if err.Error() != expected { + t.Errorf("expected error %q, got %q", expected, err.Error()) + } +} + +func TestPost_Success(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + if r.Header.Get("Content-Type") != "application/json" { + t.Error("expected Content-Type application/json") + } + // Decode the request body and echo it back + var body map[string]string + json.NewDecoder(r.Body).Decode(&body) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(body) + })) + defer srv.Close() + + c := newAPIClient(srv.URL) + input := map[string]string{"name": "new-remote"} + var out map[string]string + err := c.post(context.Background(), "/api/v2/remotes", input, &out) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out["name"] != "new-remote" { + t.Errorf("expected name=new-remote, got %s", out["name"]) + } +} + +func TestPut_Success(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + t.Errorf("expected PUT, got %s", r.Method) + } + var body map[string]string + json.NewDecoder(r.Body).Decode(&body) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(body) + })) + defer srv.Close() + + c := newAPIClient(srv.URL) + input := map[string]string{"name": "updated"} + var out map[string]string + err := c.put(context.Background(), "/api/v2/remotes/updated", input, &out) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out["name"] != "updated" { + t.Errorf("expected name=updated, got %s", out["name"]) + } +} + +func TestDel_Success(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + t.Errorf("expected DELETE, got %s", r.Method) + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + c := newAPIClient(srv.URL) + err := c.del(context.Background(), "/api/v2/remotes/test") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestDel_NotFound(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + c := newAPIClient(srv.URL) + err := c.del(context.Background(), "/api/v2/remotes/missing") + if err == nil { + t.Fatal("expected error for 404") + } + if !isNotFound(err) { + t.Errorf("expected notFoundError, got %T", err) + } +} + +func TestDo_BadRequest(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + fmt.Fprint(w, "invalid input") + })) + defer srv.Close() + + c := newAPIClient(srv.URL) + err := c.do(context.Background(), http.MethodPost, "/api/v2/remotes", map[string]string{"bad": "data"}, nil) + if err == nil { + t.Fatal("expected error for 400") + } + expected := "api error 400: invalid input" + if err.Error() != expected { + t.Errorf("expected %q, got %q", expected, err.Error()) + } +} + +func TestDo_NoContent(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + c := newAPIClient(srv.URL) + var out map[string]string + // Should not attempt to decode 204 even with out pointer + err := c.do(context.Background(), http.MethodDelete, "/test", nil, &out) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestDo_JSONEncoding(t *testing.T) { + type payload struct { + Name string `json:"name"` + Count int `json:"count"` + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + var p payload + if err := json.Unmarshal(body, &p); err != nil { + t.Errorf("failed to decode request body: %v", err) + } + if p.Name != "test" || p.Count != 42 { + t.Errorf("unexpected payload: %+v", p) + } + // Return a different payload + resp := payload{Name: "response", Count: 99} + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer srv.Close() + + c := newAPIClient(srv.URL) + input := payload{Name: "test", Count: 42} + var out payload + err := c.post(context.Background(), "/test", input, &out) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out.Name != "response" || out.Count != 99 { + t.Errorf("unexpected response: %+v", out) + } +} + +func TestNotFoundError_Error(t *testing.T) { + e := ¬FoundError{path: "/api/v2/remotes/test"} + expected := "not found: /api/v2/remotes/test" + if e.Error() != expected { + t.Errorf("expected %q, got %q", expected, e.Error()) + } +} + +func TestIsNotFound_True(t *testing.T) { + err := ¬FoundError{path: "/test"} + if !isNotFound(err) { + t.Error("expected isNotFound to return true for *notFoundError") + } +} + +func TestIsNotFound_False(t *testing.T) { + err := fmt.Errorf("some other error") + if isNotFound(err) { + t.Error("expected isNotFound to return false for non-notFoundError") + } +} + +func TestIsNotFound_Nil(t *testing.T) { + if isNotFound(nil) { + t.Error("expected isNotFound to return false for nil") + } +} + +func TestIsNotFound_WrappedError(t *testing.T) { + // isNotFound uses type assertion, not errors.As, so a wrapped notFoundError should NOT match + inner := ¬FoundError{path: "/test"} + wrapped := fmt.Errorf("wrapped: %w", inner) + // The current implementation uses type assertion, so wrapped should be false + if isNotFound(wrapped) { + t.Error("expected isNotFound to return false for wrapped notFoundError (uses type assertion)") + } + // But errors.As would find it + var nfe *notFoundError + if !errors.As(wrapped, &nfe) { + t.Error("errors.As should find wrapped notFoundError") + } +} + +func TestGet_DecodeError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, "not valid json") + })) + defer srv.Close() + + c := newAPIClient(srv.URL) + var out map[string]string + err := c.get(context.Background(), "/test", &out) + if err == nil { + t.Fatal("expected error for invalid JSON response") + } +} + +func TestDo_NilBody(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Content-Type") != "" { + t.Error("Content-Type should not be set for nil body") + } + if r.Body != nil { + body, _ := io.ReadAll(r.Body) + if len(body) > 0 { + t.Errorf("expected empty body, got %s", string(body)) + } + } + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, "{}") + })) + defer srv.Close() + + c := newAPIClient(srv.URL) + var out map[string]string + err := c.get(context.Background(), "/test", &out) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/internal/provider/helpers_test.go b/internal/provider/helpers_test.go new file mode 100644 index 0000000..619aaa7 --- /dev/null +++ b/internal/provider/helpers_test.go @@ -0,0 +1,105 @@ +package provider + +import ( + "context" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func TestListToStrings_NullList(t *testing.T) { + ctx := context.Background() + null := types.ListNull(types.StringType) + result := listToStrings(ctx, null) + if result != nil { + t.Fatalf("expected nil for null list, got %v", result) + } +} + +func TestListToStrings_UnknownList(t *testing.T) { + ctx := context.Background() + unknown := types.ListUnknown(types.StringType) + result := listToStrings(ctx, unknown) + if result != nil { + t.Fatalf("expected nil for unknown list, got %v", result) + } +} + +func TestListToStrings_EmptyList(t *testing.T) { + ctx := context.Background() + list, diags := types.ListValueFrom(ctx, types.StringType, []types.String{}) + if diags.HasError() { + t.Fatalf("unexpected diags: %v", diags) + } + result := listToStrings(ctx, list) + if len(result) != 0 { + t.Fatalf("expected empty slice, got %v", result) + } +} + +func TestListToStrings_WithValues(t *testing.T) { + ctx := context.Background() + elems := []types.String{ + types.StringValue("alpha"), + types.StringValue("beta"), + types.StringValue("gamma"), + } + list, diags := types.ListValueFrom(ctx, types.StringType, elems) + if diags.HasError() { + t.Fatalf("unexpected diags: %v", diags) + } + result := listToStrings(ctx, list) + if len(result) != 3 { + t.Fatalf("expected 3 elements, got %d", len(result)) + } + expected := []string{"alpha", "beta", "gamma"} + for i, v := range result { + if v != expected[i] { + t.Errorf("index %d: expected %q, got %q", i, expected[i], v) + } + } +} + +func TestStringsToList_EmptySlice(t *testing.T) { + ctx := context.Background() + result := stringsToList(ctx, []string{}) + if !result.IsNull() { + t.Fatalf("expected null list for empty slice, got %v", result) + } +} + +func TestStringsToList_NilSlice(t *testing.T) { + ctx := context.Background() + result := stringsToList(ctx, nil) + if !result.IsNull() { + t.Fatalf("expected null list for nil slice, got %v", result) + } +} + +func TestStringsToList_WithValues(t *testing.T) { + ctx := context.Background() + result := stringsToList(ctx, []string{"one", "two", "three"}) + if result.IsNull() || result.IsUnknown() { + t.Fatalf("expected non-null non-unknown list, got null=%v unknown=%v", result.IsNull(), result.IsUnknown()) + } + // Round-trip: convert back and verify + back := listToStrings(ctx, result) + if len(back) != 3 { + t.Fatalf("expected 3 elements after round-trip, got %d", len(back)) + } + expected := []string{"one", "two", "three"} + for i, v := range back { + if v != expected[i] { + t.Errorf("round-trip index %d: expected %q, got %q", i, expected[i], v) + } + } +} + +func TestStringsToList_SingleValue(t *testing.T) { + ctx := context.Background() + result := stringsToList(ctx, []string{"solo"}) + back := listToStrings(ctx, result) + if len(back) != 1 || back[0] != "solo" { + t.Fatalf("expected [\"solo\"], got %v", back) + } +} diff --git a/internal/provider/provider_test.go b/internal/provider/provider_test.go new file mode 100644 index 0000000..3d246ca --- /dev/null +++ b/internal/provider/provider_test.go @@ -0,0 +1,164 @@ +package provider + +import ( + "context" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + fwprovider "github.com/hashicorp/terraform-plugin-framework/provider" + "github.com/hashicorp/terraform-plugin-framework/resource" +) + +func TestNew_ReturnsFactory(t *testing.T) { + factory := New("1.0.0") + if factory == nil { + t.Fatal("expected non-nil factory") + } + p := factory() + if p == nil { + t.Fatal("expected non-nil provider") + } + _, ok := p.(*ArtifactAPIProvider) + if !ok { + t.Fatalf("expected *ArtifactAPIProvider, got %T", p) + } +} + +func TestNew_VersionPropagated(t *testing.T) { + factory := New("2.3.4") + p := factory().(*ArtifactAPIProvider) + if p.version != "2.3.4" { + t.Errorf("expected version 2.3.4, got %s", p.version) + } +} + +func TestProvider_Metadata(t *testing.T) { + p := &ArtifactAPIProvider{version: "1.2.3"} + req := fwprovider.MetadataRequest{} + var resp fwprovider.MetadataResponse + p.Metadata(context.Background(), req, &resp) + + if resp.TypeName != "artifactapi" { + t.Errorf("expected type name 'artifactapi', got %s", resp.TypeName) + } + if resp.Version != "1.2.3" { + t.Errorf("expected version '1.2.3', got %s", resp.Version) + } +} + +func TestProvider_Schema(t *testing.T) { + p := &ArtifactAPIProvider{version: "1.0.0"} + req := fwprovider.SchemaRequest{} + var resp fwprovider.SchemaResponse + p.Schema(context.Background(), req, &resp) + + if resp.Schema.Description == "" { + t.Error("expected non-empty schema description") + } + + _, ok := resp.Schema.Attributes["endpoint"] + if !ok { + t.Fatal("missing 'endpoint' attribute in schema") + } +} + +func TestProvider_Resources(t *testing.T) { + p := &ArtifactAPIProvider{version: "1.0.0"} + resources := p.Resources(context.Background()) + + // 10 remote resource types + 1 virtual = 11 + expectedCount := 11 + if len(resources) != expectedCount { + t.Fatalf("expected %d resources, got %d", expectedCount, len(resources)) + } + + // Verify each factory produces a valid resource + for i, factory := range resources { + r := factory() + if r == nil { + t.Errorf("resource factory %d returned nil", i) + } + } +} + +func TestProvider_Resources_ContainsExpectedTypes(t *testing.T) { + p := &ArtifactAPIProvider{version: "1.0.0"} + resources := p.Resources(context.Background()) + + // Collect type names by calling Metadata on each + typeNames := make(map[string]bool) + for _, factory := range resources { + r := factory() + req := resource.MetadataRequest{ProviderTypeName: "artifactapi"} + var resp resource.MetadataResponse + r.Metadata(context.Background(), req, &resp) + typeNames[resp.TypeName] = true + } + + expected := []string{ + "artifactapi_remote_generic", + "artifactapi_remote_docker", + "artifactapi_remote_helm", + "artifactapi_remote_pypi", + "artifactapi_remote_npm", + "artifactapi_remote_rpm", + "artifactapi_remote_alpine", + "artifactapi_remote_puppet", + "artifactapi_remote_terraform", + "artifactapi_remote_goproxy", + "artifactapi_virtual", + } + + for _, name := range expected { + if !typeNames[name] { + t.Errorf("missing expected resource type: %s", name) + } + } +} + +func TestProvider_DataSources(t *testing.T) { + p := &ArtifactAPIProvider{version: "1.0.0"} + dataSources := p.DataSources(context.Background()) + + expectedCount := 2 + if len(dataSources) != expectedCount { + t.Fatalf("expected %d data sources, got %d", expectedCount, len(dataSources)) + } + + // Verify each factory produces a valid data source + for i, factory := range dataSources { + ds := factory() + if ds == nil { + t.Errorf("data source factory %d returned nil", i) + } + } +} + +func TestProvider_DataSources_ContainsExpectedTypes(t *testing.T) { + p := &ArtifactAPIProvider{version: "1.0.0"} + dataSources := p.DataSources(context.Background()) + + typeNames := make(map[string]bool) + for _, factory := range dataSources { + ds := factory() + req := datasource.MetadataRequest{ProviderTypeName: "artifactapi"} + var resp datasource.MetadataResponse + ds.Metadata(context.Background(), req, &resp) + typeNames[resp.TypeName] = true + } + + expected := []string{ + "artifactapi_remote", + "artifactapi_virtual", + } + + for _, name := range expected { + if !typeNames[name] { + t.Errorf("missing expected data source type: %s", name) + } + } +} + +func TestProvider_ImplementsInterface(t *testing.T) { + var _ fwprovider.Provider = &ArtifactAPIProvider{} +} diff --git a/internal/provider/resource_remote_test.go b/internal/provider/resource_remote_test.go new file mode 100644 index 0000000..17ad353 --- /dev/null +++ b/internal/provider/resource_remote_test.go @@ -0,0 +1,447 @@ +package provider + +import ( + "context" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func TestModelToAPI_FullFields(t *testing.T) { + ctx := context.Background() + r := &remoteResource{packageType: "docker"} + + model := remoteResourceModel{ + Name: types.StringValue("my-remote"), + BaseURL: types.StringValue("https://registry.example.com"), + Description: types.StringValue("A test remote"), + Username: types.StringValue("user"), + Password: types.StringValue("pass"), + ImmutableTTL: types.Int64Value(86400), + MutableTTL: types.Int64Value(3600), + CheckMutable: types.BoolValue(true), + Patterns: stringsToList(ctx, []string{"*.tar.gz", "*.whl"}), + Blocklist: stringsToList(ctx, []string{"blocked/*"}), + MutablePatterns: stringsToList(ctx, []string{"latest"}), + ImmutablePatterns: stringsToList(ctx, []string{"v*"}), + BanTagsEnabled: types.BoolValue(true), + BanTags: stringsToList(ctx, []string{"latest", "dev"}), + QuarantineEnabled: types.BoolValue(true), + QuarantineDays: types.Int64Value(7), + StaleOnError: types.BoolValue(false), + ReleasesRemote: types.StringValue("cdn-remote"), + } + + api := r.modelToAPI(ctx, model) + + if api.Name != "my-remote" { + t.Errorf("Name: expected my-remote, got %s", api.Name) + } + if api.PackageType != "docker" { + t.Errorf("PackageType: expected docker, got %s", api.PackageType) + } + if api.BaseURL != "https://registry.example.com" { + t.Errorf("BaseURL: expected https://registry.example.com, got %s", api.BaseURL) + } + if api.Description != "A test remote" { + t.Errorf("Description: expected 'A test remote', got %s", api.Description) + } + if api.Username != "user" { + t.Errorf("Username: expected user, got %s", api.Username) + } + if api.Password != "pass" { + t.Errorf("Password: expected pass, got %s", api.Password) + } + if api.ImmutableTTL != 86400 { + t.Errorf("ImmutableTTL: expected 86400, got %d", api.ImmutableTTL) + } + if api.MutableTTL != 3600 { + t.Errorf("MutableTTL: expected 3600, got %d", api.MutableTTL) + } + if !api.CheckMutable { + t.Error("CheckMutable: expected true") + } + if len(api.Patterns) != 2 || api.Patterns[0] != "*.tar.gz" { + t.Errorf("Patterns: expected [*.tar.gz *.whl], got %v", api.Patterns) + } + if len(api.Blocklist) != 1 || api.Blocklist[0] != "blocked/*" { + t.Errorf("Blocklist: expected [blocked/*], got %v", api.Blocklist) + } + if len(api.MutablePatterns) != 1 || api.MutablePatterns[0] != "latest" { + t.Errorf("MutablePatterns: expected [latest], got %v", api.MutablePatterns) + } + if len(api.ImmutablePatterns) != 1 || api.ImmutablePatterns[0] != "v*" { + t.Errorf("ImmutablePatterns: expected [v*], got %v", api.ImmutablePatterns) + } + if !api.BanTagsEnabled { + t.Error("BanTagsEnabled: expected true") + } + if len(api.BanTags) != 2 || api.BanTags[0] != "latest" { + t.Errorf("BanTags: expected [latest dev], got %v", api.BanTags) + } + if !api.QuarantineEnabled { + t.Error("QuarantineEnabled: expected true") + } + if api.QuarantineDays != 7 { + t.Errorf("QuarantineDays: expected 7, got %d", api.QuarantineDays) + } + if api.StaleOnError { + t.Error("StaleOnError: expected false") + } + if api.ReleasesRemote != "cdn-remote" { + t.Errorf("ReleasesRemote: expected cdn-remote, got %s", api.ReleasesRemote) + } +} + +func TestModelToAPI_NullLists(t *testing.T) { + ctx := context.Background() + r := &remoteResource{packageType: "generic"} + + model := remoteResourceModel{ + Name: types.StringValue("minimal"), + BaseURL: types.StringValue("https://example.com"), + Description: types.StringValue(""), + Username: types.StringValue(""), + Password: types.StringValue(""), + ImmutableTTL: types.Int64Value(0), + MutableTTL: types.Int64Value(3600), + CheckMutable: types.BoolValue(true), + Patterns: types.ListNull(types.StringType), + Blocklist: types.ListNull(types.StringType), + MutablePatterns: types.ListNull(types.StringType), + ImmutablePatterns: types.ListNull(types.StringType), + BanTagsEnabled: types.BoolValue(false), + BanTags: types.ListNull(types.StringType), + QuarantineEnabled: types.BoolValue(false), + QuarantineDays: types.Int64Value(3), + StaleOnError: types.BoolValue(true), + ReleasesRemote: types.StringValue(""), + } + + api := r.modelToAPI(ctx, model) + + if api.Patterns != nil { + t.Errorf("Patterns: expected nil, got %v", api.Patterns) + } + if api.Blocklist != nil { + t.Errorf("Blocklist: expected nil, got %v", api.Blocklist) + } + if api.MutablePatterns != nil { + t.Errorf("MutablePatterns: expected nil, got %v", api.MutablePatterns) + } + if api.ImmutablePatterns != nil { + t.Errorf("ImmutablePatterns: expected nil, got %v", api.ImmutablePatterns) + } + if api.BanTags != nil { + t.Errorf("BanTags: expected nil, got %v", api.BanTags) + } +} + +func TestModelToAPI_PackageTypeFromResource(t *testing.T) { + ctx := context.Background() + tests := []struct { + pkgType string + }{ + {"generic"}, + {"docker"}, + {"helm"}, + {"pypi"}, + {"npm"}, + {"rpm"}, + {"alpine"}, + {"puppet"}, + {"terraform"}, + {"goproxy"}, + } + + for _, tt := range tests { + t.Run(tt.pkgType, func(t *testing.T) { + r := &remoteResource{packageType: tt.pkgType} + model := remoteResourceModel{ + Name: types.StringValue("test"), + BaseURL: types.StringValue("https://example.com"), + Description: types.StringValue(""), + Username: types.StringValue(""), + Password: types.StringValue(""), + ImmutableTTL: types.Int64Value(0), + MutableTTL: types.Int64Value(0), + CheckMutable: types.BoolValue(false), + Patterns: types.ListNull(types.StringType), + Blocklist: types.ListNull(types.StringType), + MutablePatterns: types.ListNull(types.StringType), + ImmutablePatterns: types.ListNull(types.StringType), + BanTagsEnabled: types.BoolValue(false), + BanTags: types.ListNull(types.StringType), + QuarantineEnabled: types.BoolValue(false), + QuarantineDays: types.Int64Value(0), + StaleOnError: types.BoolValue(false), + ReleasesRemote: types.StringValue(""), + } + api := r.modelToAPI(ctx, model) + if api.PackageType != tt.pkgType { + t.Errorf("expected package_type %s, got %s", tt.pkgType, api.PackageType) + } + }) + } +} + +func TestAPIToModel_FullFields(t *testing.T) { + ctx := context.Background() + r := &remoteResource{packageType: "docker"} + + api := remoteAPI{ + Name: "my-remote", + PackageType: "docker", + BaseURL: "https://registry.example.com", + Description: "A test remote", + Username: "user", + Password: "pass", + ImmutableTTL: 86400, + MutableTTL: 3600, + CheckMutable: true, + Patterns: []string{"*.tar.gz"}, + Blocklist: []string{"blocked/*"}, + MutablePatterns: []string{"latest"}, + ImmutablePatterns: []string{"v*"}, + BanTagsEnabled: true, + BanTags: []string{"latest"}, + QuarantineEnabled: true, + QuarantineDays: 7, + StaleOnError: false, + ReleasesRemote: "cdn-remote", + ManagedBy: "terraform", + } + + model := r.apiToModel(ctx, api) + + if model.Name.ValueString() != "my-remote" { + t.Errorf("Name: expected my-remote, got %s", model.Name.ValueString()) + } + if model.BaseURL.ValueString() != "https://registry.example.com" { + t.Errorf("BaseURL: expected https://registry.example.com, got %s", model.BaseURL.ValueString()) + } + if model.Description.ValueString() != "A test remote" { + t.Errorf("Description: expected 'A test remote', got %s", model.Description.ValueString()) + } + if model.Username.ValueString() != "user" { + t.Errorf("Username: expected user, got %s", model.Username.ValueString()) + } + if model.Password.ValueString() != "pass" { + t.Errorf("Password: expected pass, got %s", model.Password.ValueString()) + } + if model.ImmutableTTL.ValueInt64() != 86400 { + t.Errorf("ImmutableTTL: expected 86400, got %d", model.ImmutableTTL.ValueInt64()) + } + if model.MutableTTL.ValueInt64() != 3600 { + t.Errorf("MutableTTL: expected 3600, got %d", model.MutableTTL.ValueInt64()) + } + if !model.CheckMutable.ValueBool() { + t.Error("CheckMutable: expected true") + } + if !model.QuarantineEnabled.ValueBool() { + t.Error("QuarantineEnabled: expected true") + } + if model.QuarantineDays.ValueInt64() != 7 { + t.Errorf("QuarantineDays: expected 7, got %d", model.QuarantineDays.ValueInt64()) + } + if model.StaleOnError.ValueBool() { + t.Error("StaleOnError: expected false") + } + if !model.BanTagsEnabled.ValueBool() { + t.Error("BanTagsEnabled: expected true") + } + if model.ReleasesRemote.ValueString() != "cdn-remote" { + t.Errorf("ReleasesRemote: expected cdn-remote, got %s", model.ReleasesRemote.ValueString()) + } + + // Verify list fields via round-trip + patterns := listToStrings(ctx, model.Patterns) + if len(patterns) != 1 || patterns[0] != "*.tar.gz" { + t.Errorf("Patterns: expected [*.tar.gz], got %v", patterns) + } + blocklist := listToStrings(ctx, model.Blocklist) + if len(blocklist) != 1 || blocklist[0] != "blocked/*" { + t.Errorf("Blocklist: expected [blocked/*], got %v", blocklist) + } + banTags := listToStrings(ctx, model.BanTags) + if len(banTags) != 1 || banTags[0] != "latest" { + t.Errorf("BanTags: expected [latest], got %v", banTags) + } +} + +func TestAPIToModel_EmptyLists(t *testing.T) { + ctx := context.Background() + r := &remoteResource{packageType: "generic"} + + api := remoteAPI{ + Name: "minimal", + PackageType: "generic", + BaseURL: "https://example.com", + // All lists are nil/empty + } + + model := r.apiToModel(ctx, api) + + if !model.Patterns.IsNull() { + t.Errorf("Patterns: expected null for nil input, got %v", model.Patterns) + } + if !model.Blocklist.IsNull() { + t.Errorf("Blocklist: expected null for nil input, got %v", model.Blocklist) + } + if !model.MutablePatterns.IsNull() { + t.Errorf("MutablePatterns: expected null for nil input, got %v", model.MutablePatterns) + } + if !model.ImmutablePatterns.IsNull() { + t.Errorf("ImmutablePatterns: expected null for nil input, got %v", model.ImmutablePatterns) + } + if !model.BanTags.IsNull() { + t.Errorf("BanTags: expected null for nil input, got %v", model.BanTags) + } +} + +func TestModelToAPI_RoundTrip(t *testing.T) { + ctx := context.Background() + r := &remoteResource{packageType: "helm"} + + original := remoteAPI{ + Name: "helm-remote", + PackageType: "helm", + BaseURL: "https://charts.example.com", + Description: "Helm chart mirror", + Username: "helmuser", + Password: "helmpass", + ImmutableTTL: 172800, + MutableTTL: 7200, + CheckMutable: true, + Patterns: []string{"stable/*", "incubator/*"}, + Blocklist: []string{"deprecated/*"}, + MutablePatterns: []string{"latest"}, + ImmutablePatterns: []string{"v1.*"}, + BanTagsEnabled: false, + BanTags: nil, + QuarantineEnabled: false, + QuarantineDays: 3, + StaleOnError: true, + ReleasesRemote: "", + } + + // API -> Model -> API round-trip + model := r.apiToModel(ctx, original) + result := r.modelToAPI(ctx, model) + + if result.Name != original.Name { + t.Errorf("Name: expected %s, got %s", original.Name, result.Name) + } + if result.PackageType != original.PackageType { + t.Errorf("PackageType: expected %s, got %s", original.PackageType, result.PackageType) + } + if result.BaseURL != original.BaseURL { + t.Errorf("BaseURL: expected %s, got %s", original.BaseURL, result.BaseURL) + } + if result.Description != original.Description { + t.Errorf("Description: expected %s, got %s", original.Description, result.Description) + } + if result.ImmutableTTL != original.ImmutableTTL { + t.Errorf("ImmutableTTL: expected %d, got %d", original.ImmutableTTL, result.ImmutableTTL) + } + if result.MutableTTL != original.MutableTTL { + t.Errorf("MutableTTL: expected %d, got %d", original.MutableTTL, result.MutableTTL) + } + if result.CheckMutable != original.CheckMutable { + t.Errorf("CheckMutable: expected %v, got %v", original.CheckMutable, result.CheckMutable) + } + if len(result.Patterns) != len(original.Patterns) { + t.Errorf("Patterns length: expected %d, got %d", len(original.Patterns), len(result.Patterns)) + } + for i := range original.Patterns { + if result.Patterns[i] != original.Patterns[i] { + t.Errorf("Patterns[%d]: expected %s, got %s", i, original.Patterns[i], result.Patterns[i]) + } + } + if result.QuarantineDays != original.QuarantineDays { + t.Errorf("QuarantineDays: expected %d, got %d", original.QuarantineDays, result.QuarantineDays) + } + if result.StaleOnError != original.StaleOnError { + t.Errorf("StaleOnError: expected %v, got %v", original.StaleOnError, result.StaleOnError) + } +} + +func TestRemoteResource_Metadata(t *testing.T) { + tests := []struct { + pkgType string + expected string + }{ + {"generic", "artifactapi_remote_generic"}, + {"docker", "artifactapi_remote_docker"}, + {"helm", "artifactapi_remote_helm"}, + {"pypi", "artifactapi_remote_pypi"}, + {"npm", "artifactapi_remote_npm"}, + } + + for _, tt := range tests { + t.Run(tt.pkgType, func(t *testing.T) { + r := &remoteResource{packageType: tt.pkgType} + req := resource.MetadataRequest{ProviderTypeName: "artifactapi"} + var resp resource.MetadataResponse + r.Metadata(context.Background(), req, &resp) + if resp.TypeName != tt.expected { + t.Errorf("expected %s, got %s", tt.expected, resp.TypeName) + } + }) + } +} + +func TestRemoteResource_Schema(t *testing.T) { + r := &remoteResource{packageType: "docker"} + req := resource.SchemaRequest{} + var resp resource.SchemaResponse + r.Schema(context.Background(), req, &resp) + + expectedAttrs := []string{ + "name", "base_url", "description", "username", "password", + "immutable_ttl", "mutable_ttl", "check_mutable", + "patterns", "blocklist", "mutable_patterns", "immutable_patterns", + "ban_tags_enabled", "ban_tags", + "quarantine_enabled", "quarantine_days", + "stale_on_error", "releases_remote", + } + + for _, attr := range expectedAttrs { + if _, ok := resp.Schema.Attributes[attr]; !ok { + t.Errorf("missing expected attribute: %s", attr) + } + } +} + +func TestNewRemoteResource_Constructors(t *testing.T) { + tests := []struct { + name string + fn func() resource.Resource + expected string + }{ + {"generic", func() resource.Resource { return NewRemoteGeneric() }, "generic"}, + {"docker", func() resource.Resource { return NewRemoteDocker() }, "docker"}, + {"helm", func() resource.Resource { return NewRemoteHelm() }, "helm"}, + {"pypi", func() resource.Resource { return NewRemotePyPI() }, "pypi"}, + {"npm", func() resource.Resource { return NewRemoteNPM() }, "npm"}, + {"rpm", func() resource.Resource { return NewRemoteRPM() }, "rpm"}, + {"alpine", func() resource.Resource { return NewRemoteAlpine() }, "alpine"}, + {"puppet", func() resource.Resource { return NewRemotePuppet() }, "puppet"}, + {"terraform", func() resource.Resource { return NewRemoteTerraform() }, "terraform"}, + {"goproxy", func() resource.Resource { return NewRemoteGoProxy() }, "goproxy"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := tt.fn() + rr, ok := r.(*remoteResource) + if !ok { + t.Fatal("expected *remoteResource") + } + if rr.packageType != tt.expected { + t.Errorf("expected packageType %s, got %s", tt.expected, rr.packageType) + } + }) + } +} diff --git a/internal/provider/resource_virtual_test.go b/internal/provider/resource_virtual_test.go new file mode 100644 index 0000000..efb72c2 --- /dev/null +++ b/internal/provider/resource_virtual_test.go @@ -0,0 +1,167 @@ +package provider + +import ( + "context" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func TestVirtualModelToAPI(t *testing.T) { + ctx := context.Background() + + model := virtualResourceModel{ + Name: types.StringValue("my-virtual"), + PackageType: types.StringValue("helm"), + Description: types.StringValue("Virtual helm repo"), + Members: stringsToList(ctx, []string{"remote-a", "remote-b"}), + } + + api := virtualModelToAPI(ctx, model) + + if api.Name != "my-virtual" { + t.Errorf("Name: expected my-virtual, got %s", api.Name) + } + if api.PackageType != "helm" { + t.Errorf("PackageType: expected helm, got %s", api.PackageType) + } + if api.Description != "Virtual helm repo" { + t.Errorf("Description: expected 'Virtual helm repo', got %s", api.Description) + } + if len(api.Members) != 2 { + t.Fatalf("Members: expected 2, got %d", len(api.Members)) + } + if api.Members[0] != "remote-a" || api.Members[1] != "remote-b" { + t.Errorf("Members: expected [remote-a remote-b], got %v", api.Members) + } +} + +func TestVirtualModelToAPI_NullMembers(t *testing.T) { + ctx := context.Background() + + model := virtualResourceModel{ + Name: types.StringValue("no-members"), + PackageType: types.StringValue("pypi"), + Description: types.StringValue(""), + Members: types.ListNull(types.StringType), + } + + api := virtualModelToAPI(ctx, model) + + if api.Members != nil { + t.Errorf("Members: expected nil for null list, got %v", api.Members) + } +} + +func TestVirtualAPIToModel(t *testing.T) { + ctx := context.Background() + + api := virtualAPI{ + Name: "my-virtual", + PackageType: "helm", + Description: "Virtual helm repo", + Members: []string{"remote-a", "remote-b"}, + ManagedBy: "terraform", + } + + model := virtualAPIToModel(ctx, api) + + if model.Name.ValueString() != "my-virtual" { + t.Errorf("Name: expected my-virtual, got %s", model.Name.ValueString()) + } + if model.PackageType.ValueString() != "helm" { + t.Errorf("PackageType: expected helm, got %s", model.PackageType.ValueString()) + } + if model.Description.ValueString() != "Virtual helm repo" { + t.Errorf("Description: expected 'Virtual helm repo', got %s", model.Description.ValueString()) + } + + members := listToStrings(ctx, model.Members) + if len(members) != 2 { + t.Fatalf("Members: expected 2, got %d", len(members)) + } + if members[0] != "remote-a" || members[1] != "remote-b" { + t.Errorf("Members: expected [remote-a remote-b], got %v", members) + } +} + +func TestVirtualAPIToModel_EmptyMembers(t *testing.T) { + ctx := context.Background() + + api := virtualAPI{ + Name: "empty-members", + PackageType: "pypi", + Members: nil, + } + + model := virtualAPIToModel(ctx, api) + + if !model.Members.IsNull() { + t.Errorf("Members: expected null for nil/empty slice, got %v", model.Members) + } +} + +func TestVirtualRoundTrip(t *testing.T) { + ctx := context.Background() + + original := virtualAPI{ + Name: "roundtrip-virtual", + PackageType: "helm", + Description: "Round trip test", + Members: []string{"a", "b", "c"}, + } + + model := virtualAPIToModel(ctx, original) + result := virtualModelToAPI(ctx, model) + + if result.Name != original.Name { + t.Errorf("Name: expected %s, got %s", original.Name, result.Name) + } + if result.PackageType != original.PackageType { + t.Errorf("PackageType: expected %s, got %s", original.PackageType, result.PackageType) + } + if result.Description != original.Description { + t.Errorf("Description: expected %s, got %s", original.Description, result.Description) + } + if len(result.Members) != len(original.Members) { + t.Fatalf("Members length: expected %d, got %d", len(original.Members), len(result.Members)) + } + for i := range original.Members { + if result.Members[i] != original.Members[i] { + t.Errorf("Members[%d]: expected %s, got %s", i, original.Members[i], result.Members[i]) + } + } +} + +func TestVirtualResource_Metadata(t *testing.T) { + r := NewVirtualResource() + req := resource.MetadataRequest{ProviderTypeName: "artifactapi"} + var resp resource.MetadataResponse + r.Metadata(context.Background(), req, &resp) + if resp.TypeName != "artifactapi_virtual" { + t.Errorf("expected artifactapi_virtual, got %s", resp.TypeName) + } +} + +func TestVirtualResource_Schema(t *testing.T) { + r := NewVirtualResource() + req := resource.SchemaRequest{} + var resp resource.SchemaResponse + r.Schema(context.Background(), req, &resp) + + expectedAttrs := []string{"name", "package_type", "description", "members"} + for _, attr := range expectedAttrs { + if _, ok := resp.Schema.Attributes[attr]; !ok { + t.Errorf("missing expected attribute: %s", attr) + } + } +} + +func TestNewVirtualResource_Type(t *testing.T) { + r := NewVirtualResource() + _, ok := r.(*virtualResource) + if !ok { + t.Error("expected *virtualResource") + } +}