Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions internal/command/arguments/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,13 @@ type Init struct {
// CreateDefaultWorkspace indicates whether the default workspace should be created by
// Terraform when initializing a state store for the first time.
CreateDefaultWorkspace bool

// SafeInitWithPluggableStateStore indicates whether the user has opted into the process of downloading and approving
// a new provider binary to use for pluggable state storage.
// When false and `init` detects that a provider for PSS needs to be downloaded, `init` will return early and prompt the user to re-run with `-safe init`.
// When true and `init` detects that a provider for PSS needs to be downloaded then the user will experience a new UX.
// Details of the new UX depending on whether Terraform is being run in automation or not.
SafeInitWithPluggableStateStore bool
}

// ParseInit processes CLI arguments, returning an Init value and errors.
Expand Down Expand Up @@ -117,6 +124,7 @@ func ParseInit(args []string, experimentsEnabled bool) (*Init, tfdiags.Diagnosti
cmdFlags.Var(&init.BackendConfig, "backend-config", "")
cmdFlags.Var(&init.PluginPath, "plugin-dir", "plugin directory")
cmdFlags.BoolVar(&init.CreateDefaultWorkspace, "create-default-workspace", true, "when -input=false, use this flag to block creation of the default workspace")
cmdFlags.BoolVar(&init.SafeInitWithPluggableStateStore, "safe-init", false, `Enable the "safe init" workflow when downloading a provider binary for use with pluggable state storage.`)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know it's been really difficult to come up with a good name for this so I'll just leave a few thoughts here:

  1. I'd generally try to avoid the word "safe" as some people might misinterpret it as some sort of ultimate "turbo button" of safety. It also doesn't really communicate what it does.
  2. This may be too verbose but the direction I think we should be going is e.g. download-state-store-plugin, state-store-plugin, state-store, install-state-store-plugin, allow-state-store-plugin or something along these lines.
  3. This should ideally "work well" (naming wise) alongside some other flags we expect it to be combined with. e.g. -upgrade and the future flag for automation that implies approval of the interactive prompt.

I do recognise though we'll need to be a bit more creative with the naming inside the implementation if we want to avoid "state store". I don't think the two names (user-facing vs internal) need to align necessarily.

Copy link
Copy Markdown
Member Author

@SarahFrench SarahFrench Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah this is a decision that I've been putting off a bit 😅 Writing this PR using -safe-init was a bit of an exercise in Cunningham's Law but also just unblocking myself.

I like the distinction of "safe" versus "trust" in another review comment you left, so a name that includes that might be good?

  • -prompt-provider-approval
  • -prompt-provider-trust
  • -prompt-state-provider
  • -trust-state-provider-after-approval
  • -interactive-provider-approval

👉🏻 Another option could be to revisit the idea of users needing to opt into the security features at all when using Terraform in interactive mode; when we first got that feedback it was in the context of the 'Exit Early' UX being used in both interactive mode and automation. Maybe the prompt for input is sufficient friction for people using Terraform interactively, and a flag is only necessary in the context of automation?


// Used for enabling experimental code that's invoked before configuration is parsed.
cmdFlags.BoolVar(&init.EnablePssExperiment, "enable-pluggable-state-storage-experiment", false, "Enable the pluggable state storage experiment")
Expand Down Expand Up @@ -158,6 +166,13 @@ func ParseInit(args []string, experimentsEnabled bool) (*Init, tfdiags.Diagnosti
"Terraform cannot use the -create-default-workspace flag (or TF_SKIP_CREATE_DEFAULT_WORKSPACE environment variable) unless experiments are enabled.",
))
}
if init.SafeInitWithPluggableStateStore {
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
"Cannot use -safe-init flag without experiments enabled",
"Terraform cannot use the -safe-init flag unless experiments are enabled.",
))
}
} else {
// Errors using flags despite experiments being enabled.
if !init.CreateDefaultWorkspace && !init.EnablePssExperiment {
Expand All @@ -167,6 +182,38 @@ func ParseInit(args []string, experimentsEnabled bool) (*Init, tfdiags.Diagnosti
"Terraform cannot use the -create-default-workspace=false flag (or TF_SKIP_CREATE_DEFAULT_WORKSPACE environment variable) unless you also supply the -enable-pluggable-state-storage-experiment flag (or set the TF_ENABLE_PLUGGABLE_STATE_STORAGE environment variable).",
))
}
if init.SafeInitWithPluggableStateStore && !init.EnablePssExperiment {
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
"Cannot use -safe-init flag unless the pluggable state storage experiment is enabled",
"Terraform cannot use the -safe-init flag unless you also supply the -enable-pluggable-state-storage-experiment flag (or set the TF_ENABLE_PLUGGABLE_STATE_STORAGE environment variable).",
))
}
}

// Manage all flag interactions with -safe-init
if init.SafeInitWithPluggableStateStore {
if !init.Backend {
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
"The -safe-init and -backend=false options are mutually-exclusive",
"When -backend=false is set Terraform uses information from the last successful init to launch a backend or state store. Any providers used for pluggable state storage should already be downloaded, so -safe-init is unnecessary.",
))
}
if len(init.PluginPath) > 0 {
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
"The -safe-init and -plugin-dir options are mutually-exclusive",
"Providers sourced through -plugin-dir have already been vetted by the user, so -safe-init is unnecessary. Please re-run the command without the -safe-init flag.",
))
}
if init.Lockfile == "readonly" {
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
`The -safe-init and -lockfile=readonly options are mutually-exclusive`,
"The -safe-init flag is intended to help when first downloading or upgrading a provider to use for state storage, and in those scenarios the lockfile cannot be treated as read-only.",
))
}
Comment on lines +210 to +216
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it not valid if the user wants to check for an upgrade of the PSS provider but not necessarily perform the upgrade?

It's true that first-time installation will always modify the lockfile but upgrade may not necessarily do so.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently the -upgrade and -lockfile=readonly flags are mutually exclusive (this dates from when the flag was first added to the original getProviders method), so prior to this PR it's not valid to check for any available upgrades that way.


Rather than just taking the above as unquestionable gospel I dug around into the original motivations for adding -lockfile=readonly to figure out what the intended use case was.

Brief summary of what I learned

In the original issue #27264 the author experiences a problem where terraform init is editing dependency lock files despite no changes to the providers in use (no new downloaded, no upgrades). The root cause is due to the different types of hashes that are used by Terraform (zh and h1) to handle providers supplied as zip files versus unpacked directories. This comment from Martin has loads of useful info.

If a filesystem mirror is made via providers mirror and then a matching dependency lock file is made using providers lock that lock file will contain only h1 hashes. There are no zh hashes as those are used for zip files, and the providers lock command creates nested directories in the 'unpacked' structure, not a zip file. However when you run a subsequent init command Terraform will update the dep lock file to include zh hashes using data from the Registry.

tl;dr: the new flag was a way to navigate around an issue that could have been solved by updating the Registry protocol, but understandably that wasn't an option in the past. The -lockfile=readonly flag is only intended to let people use lockfiles for filesystem mirrors that are sufficient but don't match what would be created by downloading from the Registry. It's not meant to enable something like a 'dry-run' provider download experience.

Copy link
Copy Markdown
Member Author

@SarahFrench SarahFrench Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With init -lockfile=readonly users would currently be able to 'test out' downloading a new provider because they'd hit this error after Terraform has already made tons of UI output describing which provider s were downloaded. Unfortunately I can see how users might have come to rely on that behaviour to get something resembling a 'dry-run' experience of downloading new providers, even though it wasn't intentional when the flag was implemented. But doing the same for upgrades is definitely not possible.

}

if init.MigrateState && init.Json {
Expand Down
42 changes: 35 additions & 7 deletions internal/command/arguments/init_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,19 +40,22 @@ func TestParseInit_basicValid(t *testing.T) {
FlagName: "-backend-config",
Items: &flagNameValue,
},
Vars: &Vars{},
InputEnabled: true,
CompactWarnings: false,
TargetFlags: nil,
CreateDefaultWorkspace: true,
Vars: &Vars{},
InputEnabled: true,
CompactWarnings: false,
TargetFlags: nil,
CreateDefaultWorkspace: true,
SafeInitWithPluggableStateStore: false,
},
},
"setting multiple options": {
[]string{"-backend=false", "-force-copy=true",
[]string{
"-backend=false", "-force-copy=true",
"-from-module=./main-dir", "-json", "-get=false",
"-lock=false", "-lock-timeout=10s", "-reconfigure=true",
"-upgrade=true", "-lockfile=readonly", "-compact-warnings=true",
"-ignore-remote-version=true", "-test-directory=./test-dir"},
"-ignore-remote-version=true", "-test-directory=./test-dir",
},
&Init{
FromModule: "./main-dir",
Lockfile: "readonly",
Expand Down Expand Up @@ -156,6 +159,21 @@ func TestParseInit_invalid(t *testing.T) {
wantErr: "The -migrate-state and -reconfigure options are mutually-exclusive.",
wantViewType: ViewHuman,
},
"with both -safe-init and -backend=false options set": {
args: []string{"-safe-init", "-backend=false"},
wantErr: "The -safe-init and -backend=false options are mutually-exclusive",
wantViewType: ViewHuman,
},
"with both -safe-init and -plugin-dir options set": {
args: []string{"-safe-init", "-plugin-dir=./my/path/to/dir"},
wantErr: "The -safe-init and -plugin-dir options are mutually-exclusive",
wantViewType: ViewHuman,
},
"with both -safe-init and -lockfile=readonly options set": {
args: []string{"-safe-init", `-lockfile=readonly`},
wantErr: "The -safe-init and -lockfile=readonly options are mutually-exclusive",
wantViewType: ViewHuman,
},
}

for name, tc := range testCases {
Expand Down Expand Up @@ -218,6 +236,16 @@ func TestParseInit_experimentalFlags(t *testing.T) {
experimentsEnabled: true,
wantErr: "Cannot use -create-default-workspace=false flag unless the pluggable state storage experiment is enabled",
},
"error: -safe-init and experiments are disabled": {
args: []string{"-safe-init"},
experimentsEnabled: false,
wantErr: "Cannot use -safe-init flag without experiments enabled: Terraform cannot use the -safe-init flag unless experiments are enabled.",
},
"error: -safe-init used without -enable-pluggable-state-storage-experiment": {
args: []string{"-safe-init"},
experimentsEnabled: true,
wantErr: "Cannot use -safe-init flag unless the pluggable state storage experiment is enabled",
},
}

for name, tc := range testCases {
Expand Down
Loading