Add providers mirror subcommand (#551)
Co-authored-by: Radek Simko <radeksimko@users.noreply.github.com>
Co-authored-by: Daniel Schmidt <danielmschmidt92@gmail.com>
Co-authored-by: Matej Risek <matej.risek@hashicorp.com>
Co-authored-by: Samsondeen Dare <samsondeen.dare@hashicorp.com>
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b035441..d439a90 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,7 @@
ENHANCEMENTS:
- tfexec: Added provider reattach support to all `terraform workspace` subcommands ([#556](https://github.com/hashicorp/terraform-exec/pull/556))
- tfexec: Add `-generate-config-out` to the `(Terraform).Plan()` method ([#563](https://github.com/hashicorp/terraform-exec/pull/563))
+- Add support for `providers mirror` subcommand ([#551](https://github.com/hashicorp/terraform-exec/pull/551))
# 0.24.0 (September 17, 2025)
diff --git a/tfexec/internal/e2etest/providers_lock_test.go b/tfexec/internal/e2etest/providers_lock_test.go
index ae8eb09..c0beec6 100644
--- a/tfexec/internal/e2etest/providers_lock_test.go
+++ b/tfexec/internal/e2etest/providers_lock_test.go
@@ -28,7 +28,7 @@
err = tf.ProvidersLock(context.Background())
if err != nil {
- t.Fatalf("error running provider lock: %s", err)
+ t.Fatalf("error running providers lock: %s", err)
}
})
diff --git a/tfexec/internal/e2etest/providers_mirror_test.go b/tfexec/internal/e2etest/providers_mirror_test.go
new file mode 100644
index 0000000..aa46945
--- /dev/null
+++ b/tfexec/internal/e2etest/providers_mirror_test.go
@@ -0,0 +1,61 @@
+// Copyright (c) HashiCorp, Inc.
+// SPDX-License-Identifier: MPL-2.0
+package e2etest
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/hashicorp/go-version"
+
+ "github.com/hashicorp/terraform-exec/tfexec"
+)
+
+var (
+ providersMirrorMinVersion = version.Must(version.NewVersion("0.13.0"))
+ providersMirrorLockFileMinVersion = version.Must(version.NewVersion("1.10.0"))
+)
+
+func TestProvidersMirror(t *testing.T) {
+ runTest(t, "basic", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) {
+ if tfv.LessThan(providersMirrorMinVersion) {
+ t.Skip("terraform providers mirror was added in Terraform 0.13, so test is not valid")
+ }
+ err := tf.Init(context.Background())
+ if err != nil {
+ t.Fatalf("error running Init in test directory: %s", err)
+ }
+
+ targetDir := t.TempDir()
+ err = tf.ProvidersMirror(context.Background(), targetDir)
+ if err != nil {
+ t.Fatalf("error running providers mirror: %s", err)
+ }
+
+ expectedMirrorPath := filepath.Join(targetDir, "registry.terraform.io", "hashicorp", "null")
+ _, err = os.Stat(expectedMirrorPath)
+ if err != nil {
+ t.Fatalf("providers mirror not found in %s", expectedMirrorPath)
+ }
+ })
+}
+
+func TestProvidersMirror_lockFileFalse(t *testing.T) {
+ runTest(t, "basic", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) {
+ if tfv.LessThan(providersMirrorLockFileMinVersion) {
+ t.Skip("terraform providers mirror -lock-file flag was added in Terraform 1.10, so test is not valid")
+ }
+ err := tf.Init(context.Background())
+ if err != nil {
+ t.Fatalf("error running Init in test directory: %s", err)
+ }
+
+ targetDir := t.TempDir()
+ err = tf.ProvidersMirror(context.Background(), targetDir, tfexec.LockFile(false))
+ if err != nil {
+ t.Fatalf("error running providers mirror: %s", err)
+ }
+ })
+}
diff --git a/tfexec/internal/e2etest/show_test.go b/tfexec/internal/e2etest/show_test.go
index 342a9a1..7c3841f 100644
--- a/tfexec/internal/e2etest/show_test.go
+++ b/tfexec/internal/e2etest/show_test.go
@@ -121,7 +121,7 @@
// no providers to download, this is unintended behaviour, as
// init is not actually necessary. This is considered a known issue in
// pre-1.2.0 versions.
- runTestWithVersions(t, []string{testutil.Latest012, testutil.Latest013, testutil.Latest014, testutil.Latest015, testutil.Latest_v1, testutil.Latest_v1_1}, "basic", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) {
+ runTestWithVersions(t, []string{testutil.Latest012, testutil.Latest013, testutil.Latest014, testutil.Latest015, testutil.Latest_v1_0, testutil.Latest_v1_1}, "basic", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) {
_, err := tf.Show(context.Background())
if err == nil {
t.Fatalf("expected error, but did not get one")
@@ -158,7 +158,7 @@
// no providers to download, this is unintended behaviour, as
// init is not actually necessary. This is considered a known issue in
// pre-1.2.0 versions.
- runTestWithVersions(t, []string{testutil.Latest012, testutil.Latest013, testutil.Latest014, testutil.Latest015, testutil.Latest_v1, testutil.Latest_v1_1}, "registry_module", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) {
+ runTestWithVersions(t, []string{testutil.Latest012, testutil.Latest013, testutil.Latest014, testutil.Latest015, testutil.Latest_v1_0, testutil.Latest_v1_1}, "registry_module", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) {
_, err := tf.Show(context.Background())
if err == nil {
t.Fatalf("expected error, but did not get one")
diff --git a/tfexec/internal/testutil/tfcache.go b/tfexec/internal/testutil/tfcache.go
index fa7d260..b8240b4 100644
--- a/tfexec/internal/testutil/tfcache.go
+++ b/tfexec/internal/testutil/tfcache.go
@@ -15,12 +15,13 @@
)
const (
- Latest011 = "0.11.15"
- Latest012 = "0.12.31"
- Latest013 = "0.13.7"
- Latest014 = "0.14.11"
- Latest015 = "0.15.5"
- Latest_v1 = "1.0.11"
+ Latest011 = "0.11.15"
+ Latest012 = "0.12.31"
+ Latest013 = "0.13.7"
+ Latest014 = "0.14.11"
+ Latest015 = "0.15.5"
+
+ Latest_v1_0 = "1.0.11"
Latest_v1_1 = "1.1.9"
Latest_v1_2 = "1.2.9"
Latest_v1_3 = "1.3.10"
@@ -37,6 +38,8 @@
Latest_v1_11 = "1.11.4"
Latest_v1_12 = "1.12.2"
Latest_Alpha_v1_14 = "1.14.0-alpha20250903"
+
+ Latest_v1 = "1.14.0"
)
const appendUserAgent = "tfexec-testutil"
diff --git a/tfexec/options.go b/tfexec/options.go
index 145f10a..191db06 100644
--- a/tfexec/options.go
+++ b/tfexec/options.go
@@ -467,3 +467,12 @@
func VerifyPlugins(verifyPlugins bool) *VerifyPluginsOption {
return &VerifyPluginsOption{verifyPlugins}
}
+
+// LockFileOption represents the -lock-file flag.
+type LockFileOption struct {
+ useLockFile bool
+}
+
+func LockFile(useLockFile bool) *LockFileOption {
+ return &LockFileOption{useLockFile: useLockFile}
+}
diff --git a/tfexec/providers_mirror.go b/tfexec/providers_mirror.go
new file mode 100644
index 0000000..02188a9
--- /dev/null
+++ b/tfexec/providers_mirror.go
@@ -0,0 +1,85 @@
+// Copyright (c) HashiCorp, Inc.
+// SPDX-License-Identifier: MPL-2.0
+
+package tfexec
+
+import (
+ "context"
+ "fmt"
+ "os/exec"
+)
+
+type providersMirrorConfig struct {
+ lockFile bool
+ platforms []string
+}
+
+var defaultProvidersMirrorOptions = providersMirrorConfig{
+ // defaults to true
+ // See https://github.com/hashicorp/terraform/blob/v1.14.0/internal/command/providers_mirror.go#L42
+ lockFile: true,
+}
+
+type ProvidersMirrorOption interface {
+ configureProvidersMirror(*providersMirrorConfig)
+}
+
+func (opt *LockFileOption) configureProvidersMirror(conf *providersMirrorConfig) {
+ conf.lockFile = opt.useLockFile
+}
+
+func (opt *PlatformOption) configureProvidersMirror(conf *providersMirrorConfig) {
+ conf.platforms = append(conf.platforms, opt.platform)
+}
+
+// ProvidersMirror represents the `terraform providers mirror` command
+func (tf *Terraform) ProvidersMirror(ctx context.Context, targetDir string, opts ...ProvidersMirrorOption) error {
+ err := tf.compatible(ctx, tf0_13_0, nil)
+ if err != nil {
+ return fmt.Errorf("terraform providers mirror was added in 0.13.0: %w", err)
+ }
+ if targetDir == "" {
+ return fmt.Errorf("targetDir argument needs to be set")
+ }
+
+ mirrorCmd, err := tf.providersMirrorCmd(ctx, targetDir, opts...)
+ if err != nil {
+ return err
+ }
+
+ err = tf.runTerraformCmd(ctx, mirrorCmd)
+ if err != nil {
+ return err
+ }
+
+ return err
+}
+
+func (tf *Terraform) providersMirrorCmd(ctx context.Context, targetDir string, opts ...ProvidersMirrorOption) (*exec.Cmd, error) {
+ c := defaultProvidersMirrorOptions
+
+ args := []string{"providers", "mirror"}
+
+ for _, o := range opts {
+ o.configureProvidersMirror(&c)
+ }
+
+ for _, p := range c.platforms {
+ args = append(args, "-platform="+p)
+ }
+
+ // lockFile is true by default, so only pass the flag if the caller has set it
+ // to false
+ if !c.lockFile {
+ err := tf.compatible(ctx, tf1_10_0, nil)
+ if err != nil {
+ return nil, fmt.Errorf("lock-file option was introduced in Terraform 1.10.0: %w", err)
+ }
+
+ args = append(args, "-lock-file=false")
+ }
+
+ args = append(args, targetDir)
+
+ return tf.buildTerraformCmd(ctx, nil, args...), nil
+}
diff --git a/tfexec/providers_mirror_test.go b/tfexec/providers_mirror_test.go
new file mode 100644
index 0000000..0bac1fa
--- /dev/null
+++ b/tfexec/providers_mirror_test.go
@@ -0,0 +1,67 @@
+// Copyright (c) HashiCorp, Inc.
+// SPDX-License-Identifier: MPL-2.0
+
+package tfexec
+
+import (
+ "context"
+ "testing"
+
+ "github.com/hashicorp/terraform-exec/tfexec/internal/testutil"
+)
+
+func TestProvidersMirrorCmd(t *testing.T) {
+ td := t.TempDir()
+
+ tf, err := NewTerraform(td, tfVersion(t, testutil.Latest_v1))
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // empty env, to avoid environ mismatch in testing
+ tf.SetEnv(map[string]string{})
+
+ t.Run("defaults", func(t *testing.T) {
+ mirrorCmd, err := tf.providersMirrorCmd(context.Background(), "path")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ assertCmd(t, []string{
+ "providers",
+ "mirror",
+ "path",
+ }, nil, mirrorCmd)
+ })
+
+ t.Run("multiple platforms", func(t *testing.T) {
+ mirrorCmd, err := tf.providersMirrorCmd(context.Background(), "path", Platform("IBM-Z"), Platform("Solaris"), Platform("Commodore64"))
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ assertCmd(t, []string{
+ "providers",
+ "mirror",
+ "-platform=IBM-Z",
+ "-platform=Solaris",
+ "-platform=Commodore64",
+ "path",
+ }, nil, mirrorCmd)
+ })
+
+ t.Run("override all defaults", func(t *testing.T) {
+ mirrorCmd, err := tf.providersMirrorCmd(context.Background(), "path", LockFile(false), Platform("IBM-Z"))
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ assertCmd(t, []string{
+ "providers",
+ "mirror",
+ "-platform=IBM-Z",
+ "-lock-file=false",
+ "path",
+ }, nil, mirrorCmd)
+ })
+}
diff --git a/tfexec/version.go b/tfexec/version.go
index 5745ea9..481fd45 100644
--- a/tfexec/version.go
+++ b/tfexec/version.go
@@ -36,6 +36,7 @@
tf1_5_0 = version.Must(version.NewVersion("1.5.0"))
tf1_6_0 = version.Must(version.NewVersion("1.6.0"))
tf1_9_0 = version.Must(version.NewVersion("1.9.0"))
+ tf1_10_0 = version.Must(version.NewVersion("1.10.0"))
tf1_13_0 = version.Must(version.NewVersion("1.13.0"))
tf1_14_0 = version.Must(version.NewVersion("1.14.0"))
)