Skip to content

Commit 2b188a3

Browse files
committed
Adds BETA Upload method to StateVersions
1 parent 4285c46 commit 2b188a3

10 files changed

+316
-20
lines changed

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
## Enhancements
66
* Adds `RunPreApplyRunning` and `RunQueuingApply` run statuses by @uk1288 [#712](https://github.com/hashicorp/go-tfe/pull/712)
7+
* Adds BETA method `Upload` method to `StateVersions` and support for pending state versions by @brandonc [#717](https://github.com/hashicorp/go-tfe/pull/717)
78

89
## Bug Fixes
910
* AgentPool `Update` is not able to remove all allowed workspaces from an agent pool. That operation is now handled by a separate `UpdateAllowedWorkspaces` method using `AgentPoolAllowedWorkspacesUpdateOptions` by @hs26gill [#701](https://github.com/hashicorp/go-tfe/pull/701)

errors.go

+6
Original file line numberDiff line numberDiff line change
@@ -353,4 +353,10 @@ var (
353353
ErrRequiredRegistryModule = errors.New("registry module is required")
354354

355355
ErrTerraformVersionValidForPlanOnly = errors.New("setting terraform-version is only valid when plan-only is set to true")
356+
357+
ErrStateMustBeOmitted = errors.New("when uploading state, the State and JSONState strings must be omitted from options")
358+
359+
ErrRequiredRawState = errors.New("RawState is required")
360+
361+
ErrStateVersionUploadNotSupported = errors.New("upload not supported by this version of Terraform Enterprise")
356362
)

examples/state_versions/main.go

+55
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
// Copyright (c) HashiCorp, Inc.
2+
// SPDX-License-Identifier: MPL-2.0
3+
4+
package main
5+
6+
import (
7+
"context"
8+
"crypto/md5"
9+
"fmt"
10+
"log"
11+
"os"
12+
13+
tfe "github.com/hashicorp/go-tfe"
14+
)
15+
16+
func main() {
17+
ctx := context.Background()
18+
client, err := tfe.NewClient(&tfe.Config{
19+
RetryServerErrors: true,
20+
})
21+
if err != nil {
22+
log.Fatal(err)
23+
}
24+
25+
// Lock the workspace
26+
if _, err = client.Workspaces.Lock(ctx, "ws-12345678", tfe.WorkspaceLockOptions{}); err != nil {
27+
log.Fatal(err)
28+
}
29+
30+
state, err := os.ReadFile("state.json")
31+
if err != nil {
32+
log.Fatal(err)
33+
}
34+
35+
// Create upload options that does not contain a State attribute within the create options
36+
options := tfe.StateVersionUploadOptions{
37+
StateVersionCreateOptions: tfe.StateVersionCreateOptions{
38+
Lineage: tfe.String("493f7758-da5e-229e-7872-ea1f78ebe50a"),
39+
Serial: tfe.Int64(int64(2)),
40+
MD5: tfe.String(fmt.Sprintf("%x", md5.Sum(state))),
41+
Force: tfe.Bool(false),
42+
},
43+
RawState: state,
44+
}
45+
46+
// Upload a state version
47+
if _, err = client.StateVersions.Upload(ctx, "ws-12345678", options); err != nil {
48+
log.Fatal(err)
49+
}
50+
51+
// Unlock the workspace
52+
if _, err = client.Workspaces.Unlock(ctx, "ws-12345678"); err != nil {
53+
log.Fatal(err)
54+
}
55+
}

examples/state_versions/state.json

+45
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
{
2+
"version": 4,
3+
"terraform_version": "1.3.9",
4+
"serial": 2,
5+
"lineage": "493f7758-da5e-229e-7872-ea1f78ebe50a",
6+
"outputs": {
7+
"name": {
8+
"value": "",
9+
"type": "string"
10+
}
11+
},
12+
"resources": [
13+
{
14+
"mode": "managed",
15+
"type": "null_resource",
16+
"name": "null",
17+
"provider": "provider[\"registry.terraform.io/hashicorp/null\"]",
18+
"instances": [
19+
{
20+
"schema_version": 0,
21+
"attributes": {
22+
"id": "6593301963468675161",
23+
"triggers": {
24+
"creating": "ai-generated content",
25+
"critical": "code",
26+
"even": "[2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 24, 26, 28, 30, 32, 34, 36, 38, 40, 42, 44, 46, 48, 50]",
27+
"happy": "gilmore",
28+
"key": "value2",
29+
"napkin": "piano",
30+
"odd": "[1, 3, 5, 7, 9, 11, 13, 15, 17, 19, 21, 23, 25, 27, 29, 31, 33, 35, 37, 39, 41, 43, 45, 47, 49]",
31+
"product": "type",
32+
"responsible": "damages to your mainframe",
33+
"slam": "dunk",
34+
"super": "heroes",
35+
"system": "or otherwise",
36+
"warning": "do not operate"
37+
}
38+
},
39+
"sensitive_attributes": []
40+
}
41+
]
42+
}
43+
],
44+
"check_results": null
45+
}

go.mod

+1
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ require (
1212
github.com/hashicorp/go-version v1.6.0
1313
github.com/hashicorp/jsonapi v0.0.0-20210826224640-ee7dae0fb22d
1414
github.com/stretchr/testify v1.8.4
15+
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c
1516
golang.org/x/time v0.3.0
1617
)
1718

go.sum

+1
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn
3333
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
3434
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
3535
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
36+
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ=
3637
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
3738
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
3839
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=

mocks/.DS_Store

-10 KB
Binary file not shown.

mocks/state_version_mocks.go

+15
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

state_version.go

+90-10
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,25 @@ import (
88
"context"
99
"fmt"
1010
"net/url"
11+
"strings"
1112
"time"
13+
14+
"golang.org/x/sync/errgroup"
1215
)
1316

1417
// Compile-time proof of interface implementation.
1518
var _ StateVersions = (*stateVersions)(nil)
1619

20+
// StateVersionStatus are available state version status values
21+
type StateVersionStatus string
22+
23+
// Available state version statuses.
24+
const (
25+
StateVersionPending StateVersionStatus = "pending"
26+
StateVersionFinalized StateVersionStatus = "finalized"
27+
StateVersionDiscarded StateVersionStatus = "discarded"
28+
)
29+
1730
// StateVersions describes all the state version related methods that
1831
// the Terraform Enterprise API supports.
1932
//
@@ -26,6 +39,12 @@ type StateVersions interface {
2639
// Create a new state version for the given workspace.
2740
Create(ctx context.Context, workspaceID string, options StateVersionCreateOptions) (*StateVersion, error)
2841

42+
// Upload creates a new state version but uploads the state content directly to the object store.
43+
// This is a more resilient form of Create and is the recommended approach to creating state versions.
44+
//
45+
// **Note: This method is still in BETA and subject to change.**
46+
Upload(ctx context.Context, workspaceID string, options StateVersionUploadOptions) (*StateVersion, error)
47+
2948
// Read a state version by its ID.
3049
Read(ctx context.Context, svID string) (*StateVersion, error)
3150

@@ -60,12 +79,16 @@ type StateVersionList struct {
6079

6180
// StateVersion represents a Terraform Enterprise state version.
6281
type StateVersion struct {
63-
ID string `jsonapi:"primary,state-versions"`
64-
CreatedAt time.Time `jsonapi:"attr,created-at,iso8601"`
65-
DownloadURL string `jsonapi:"attr,hosted-state-download-url"`
66-
Serial int64 `jsonapi:"attr,serial"`
67-
VCSCommitSHA string `jsonapi:"attr,vcs-commit-sha"`
68-
VCSCommitURL string `jsonapi:"attr,vcs-commit-url"`
82+
ID string `jsonapi:"primary,state-versions"`
83+
CreatedAt time.Time `jsonapi:"attr,created-at,iso8601"`
84+
DownloadURL string `jsonapi:"attr,hosted-state-download-url"`
85+
UploadURL string `jsonapi:"attr,hosted-state-upload-url"`
86+
Status StateVersionStatus `jsonapi:"attr,status"`
87+
JSONUploadURL string `jsonapi:"attr,hosted-json-state-upload-url"`
88+
JSONDownloadURL string `jsonapi:"attr,hosted-json-state-download-url"`
89+
Serial int64 `jsonapi:"attr,serial"`
90+
VCSCommitSHA string `jsonapi:"attr,vcs-commit-sha"`
91+
VCSCommitURL string `jsonapi:"attr,vcs-commit-url"`
6992
// Whether Terraform Cloud has finished populating any StateVersion fields that required async processing.
7093
// If `false`, some fields may appear empty even if they should actually contain data; see comments on
7194
// individual fields for details.
@@ -147,8 +170,8 @@ type StateVersionCreateOptions struct {
147170
// Required: The serial of the state.
148171
Serial *int64 `jsonapi:"attr,serial"`
149172

150-
// Required: The base64 encoded state.
151-
State *string `jsonapi:"attr,state"`
173+
// Optional: The base64 encoded state.
174+
State *string `jsonapi:"attr,state,omitempty"`
152175

153176
// Optional: Force can be set to skip certain validations. Wrong use
154177
// of this flag can cause data loss, so USE WITH CAUTION!
@@ -173,6 +196,13 @@ type StateVersionCreateOptions struct {
173196
JSONStateOutputs *string `jsonapi:"attr,json-state-outputs,omitempty"`
174197
}
175198

199+
type StateVersionUploadOptions struct {
200+
StateVersionCreateOptions
201+
202+
RawState []byte
203+
RawJSONState []byte
204+
}
205+
176206
type StateVersionModules struct {
177207
Root StateVersionModuleRoot `jsonapi:"attr,root"`
178208
}
@@ -243,6 +273,46 @@ func (s *stateVersions) Create(ctx context.Context, workspaceID string, options
243273
return sv, nil
244274
}
245275

276+
func (s *stateVersions) putURL(ctx context.Context, putURL string, data []byte) error {
277+
reader := bytes.NewReader(data)
278+
req, err := s.client.NewRequest("PUT", putURL, reader)
279+
if err != nil {
280+
return err
281+
}
282+
283+
return req.Do(ctx, nil)
284+
}
285+
286+
func (s *stateVersions) Upload(ctx context.Context, workspaceID string, options StateVersionUploadOptions) (*StateVersion, error) {
287+
if err := options.valid(); err != nil {
288+
return nil, err
289+
}
290+
291+
sv, err := s.Create(ctx, workspaceID, options.StateVersionCreateOptions)
292+
if err != nil {
293+
if strings.Contains(err.Error(), "param is missing or the value is empty: state") {
294+
return nil, ErrStateVersionUploadNotSupported
295+
}
296+
}
297+
298+
g, _ := errgroup.WithContext(ctx)
299+
g.Go(func() error {
300+
return s.putURL(ctx, sv.UploadURL, options.RawState)
301+
})
302+
if options.RawJSONState != nil {
303+
g.Go(func() error {
304+
return s.putURL(ctx, sv.JSONUploadURL, options.RawJSONState)
305+
})
306+
}
307+
308+
if err := g.Wait(); err != nil {
309+
return nil, err
310+
}
311+
312+
// Re-read the state version to get the updated status, if available
313+
return s.Read(ctx, sv.ID)
314+
}
315+
246316
// Read a state version by its ID.
247317
func (s *stateVersions) ReadWithOptions(ctx context.Context, svID string, options *StateVersionReadOptions) (*StateVersion, error) {
248318
if !validStringID(&svID) {
@@ -362,8 +432,18 @@ func (o StateVersionCreateOptions) valid() error {
362432
if o.Serial == nil {
363433
return ErrRequiredSerial
364434
}
365-
if !validString(o.State) {
366-
return ErrRequiredState
435+
return nil
436+
}
437+
438+
func (o StateVersionUploadOptions) valid() error {
439+
if err := o.StateVersionCreateOptions.valid(); err != nil {
440+
return err
441+
}
442+
if o.State != nil || o.JSONState != nil {
443+
return ErrStateMustBeOmitted
444+
}
445+
if o.RawState == nil {
446+
return ErrRequiredRawState
367447
}
368448
return nil
369449
}

0 commit comments

Comments
 (0)