plans/planfile: Include dependency locks in saved plan files

We recently removed the legacy way we used to track the SHA256 hashes of
individual provider executables as part of a plans.Plan, because these
days we want to track the checksums of entire provider packages rather
than just the executable.

In order to achieve that new goal, we can save a copy of the dependency
lock information inside the plan file. This follows our existing precedent
of using exactly the same serialization formats we'd normally use for
this information, and thus we can reuse the existing models and
serializers and be confident we won't lose any detail in the round-trip.

As of this commit there's not yet anything actually making use of this
mechanism. In a subsequent commit we'll teach the main callers that write
and read plan files to include and expect (respectively) dependency
information, verifying that the available providers still match by the
time we're applying the plan.
This commit is contained in:
Martin Atkins 2021-09-29 17:03:38 -07:00
parent 3f85591998
commit 702413702c
3 changed files with 99 additions and 0 deletions

View File

@ -7,7 +7,10 @@ import (
"github.com/google/go-cmp/cmp"
"github.com/hashicorp/terraform/internal/addrs"
"github.com/hashicorp/terraform/internal/configs/configload"
"github.com/hashicorp/terraform/internal/depsfile"
"github.com/hashicorp/terraform/internal/getproviders"
"github.com/hashicorp/terraform/internal/plans"
"github.com/hashicorp/terraform/internal/states"
"github.com/hashicorp/terraform/internal/states/statefile"
@ -71,6 +74,16 @@ func TestRoundtrip(t *testing.T) {
PriorState: stateFileIn.State,
}
locksIn := depsfile.NewLocks()
locksIn.SetProvider(
addrs.NewDefaultProvider("boop"),
getproviders.MustParseVersion("1.0.0"),
getproviders.MustParseVersionConstraints(">= 1.0.0"),
[]getproviders.Hash{
getproviders.MustParseHash("fake:hello"),
},
)
workDir, err := ioutil.TempDir("", "tf-planfile")
if err != nil {
t.Fatal(err)
@ -82,6 +95,7 @@ func TestRoundtrip(t *testing.T) {
PreviousRunStateFile: prevStateFileIn,
StateFile: stateFileIn,
Plan: planIn,
DependencyLocks: locksIn,
})
if err != nil {
t.Fatalf("failed to create plan file: %s", err)
@ -141,4 +155,16 @@ func TestRoundtrip(t *testing.T) {
t.Errorf("when reading config: %s", diags.Err())
}
})
t.Run("ReadDependencyLocks", func(t *testing.T) {
locksOut, diags := pr.ReadDependencyLocks()
if diags.HasErrors() {
t.Fatalf("when reading config: %s", diags.Err())
}
got := locksOut.AllProviders()
want := locksIn.AllProviders()
if diff := cmp.Diff(want, got, cmp.AllowUnexported(depsfile.ProviderLock{})); diff != "" {
t.Errorf("provider locks did not survive round-trip\n%s", diff)
}
})
}

View File

@ -8,6 +8,7 @@ import (
"github.com/hashicorp/terraform/internal/configs"
"github.com/hashicorp/terraform/internal/configs/configload"
"github.com/hashicorp/terraform/internal/depsfile"
"github.com/hashicorp/terraform/internal/plans"
"github.com/hashicorp/terraform/internal/states/statefile"
"github.com/hashicorp/terraform/internal/tfdiags"
@ -15,6 +16,7 @@ import (
const tfstateFilename = "tfstate"
const tfstatePreviousFilename = "tfstate-prev"
const dependencyLocksFilename = ".terraform.lock.hcl" // matches the conventional name in an input configuration
// Reader is the main type used to read plan files. Create a Reader by calling
// Open.
@ -190,6 +192,50 @@ func (r *Reader) ReadConfig() (*configs.Config, tfdiags.Diagnostics) {
return config, diags
}
// ReadDependencyLocks reads the dependency lock information embedded in
// the plan file.
//
// Some test codepaths create plan files without dependency lock information,
// but all main codepaths should populate this. If reading a file without
// the dependency information, this will return error diagnostics.
func (r *Reader) ReadDependencyLocks() (*depsfile.Locks, tfdiags.Diagnostics) {
var diags tfdiags.Diagnostics
for _, file := range r.zip.File {
if file.Name == dependencyLocksFilename {
r, err := file.Open()
if err != nil {
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
"Failed to read dependency locks from plan file",
fmt.Sprintf("Couldn't read the dependency lock information embedded in the plan file: %s.", err),
))
return nil, diags
}
src, err := ioutil.ReadAll(r)
if err != nil {
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
"Failed to read dependency locks from plan file",
fmt.Sprintf("Couldn't read the dependency lock information embedded in the plan file: %s.", err),
))
return nil, diags
}
locks, moreDiags := depsfile.LoadLocksFromBytes(src, "<saved-plan>")
diags = diags.Append(moreDiags)
return locks, diags
}
}
// If we fall out here then this is a file without dependency information.
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
"Saved plan has no dependency lock information",
"The specified saved plan file does not include any dependency lock information. This is a bug in the previous run of Terraform that created this file.",
))
return nil, diags
}
// Close closes the file, after which no other operations may be performed.
func (r *Reader) Close() error {
return r.zip.Close()

View File

@ -7,6 +7,7 @@ import (
"time"
"github.com/hashicorp/terraform/internal/configs/configload"
"github.com/hashicorp/terraform/internal/depsfile"
"github.com/hashicorp/terraform/internal/plans"
"github.com/hashicorp/terraform/internal/states/statefile"
)
@ -33,6 +34,11 @@ type CreateArgs struct {
// Plan records the plan itself, which is the main artifact inside a
// saved plan file.
Plan *plans.Plan
// DependencyLocks records the dependency lock information that we
// checked prior to creating the plan, so we can make sure that all of the
// same dependencies are still available when applying the plan.
DependencyLocks *depsfile.Locks
}
// Create creates a new plan file with the given filename, overwriting any
@ -108,5 +114,26 @@ func Create(filename string, args CreateArgs) error {
}
}
// .terraform.lock.hcl file, containing dependency lock information
if args.DependencyLocks != nil { // (this was a later addition, so not all callers set it, but main callers should)
src, diags := depsfile.SaveLocksToBytes(args.DependencyLocks)
if diags.HasErrors() {
return fmt.Errorf("failed to write embedded dependency lock file: %s", diags.Err().Error())
}
w, err := zw.CreateHeader(&zip.FileHeader{
Name: dependencyLocksFilename,
Method: zip.Deflate,
Modified: time.Now(),
})
if err != nil {
return fmt.Errorf("failed to create embedded dependency lock file: %s", err)
}
_, err = w.Write(src)
if err != nil {
return fmt.Errorf("failed to write embedded dependency lock file: %s", err)
}
}
return nil
}