Skip to content

Commit 669a681

Browse files
committed
Adds BETA Upload method to StateVersions
1 parent 4285c46 commit 669a681

6 files changed

+206
-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
)

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=

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
}

state_version_integration_test.go

+107-10
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,84 @@ func TestStateVersionsList(t *testing.T) {
103103
})
104104
}
105105

106+
func TestStateVersionsUpload(t *testing.T) {
107+
skipUnlessBeta(t)
108+
109+
client := testClient(t)
110+
111+
wTest, wTestCleanup := createWorkspace(t, client, nil)
112+
t.Cleanup(wTestCleanup)
113+
114+
state, err := os.ReadFile("test-fixtures/state-version/terraform.tfstate")
115+
if err != nil {
116+
t.Fatal(err)
117+
}
118+
119+
jsonState, err := os.ReadFile("test-fixtures/json-state/state.json")
120+
if err != nil {
121+
t.Fatal(err)
122+
}
123+
124+
jsonStateOutputs, err := os.ReadFile("test-fixtures/json-state-outputs/everything.json")
125+
if err != nil {
126+
t.Fatal(err)
127+
}
128+
129+
t.Run("can create finalized state versions", func(t *testing.T) {
130+
ctx := context.Background()
131+
_, err := client.Workspaces.Lock(ctx, wTest.ID, WorkspaceLockOptions{})
132+
require.NoError(t, err)
133+
134+
sv, err := client.StateVersions.Upload(ctx, wTest.ID, StateVersionUploadOptions{
135+
StateVersionCreateOptions: StateVersionCreateOptions{
136+
Lineage: String("741c4949-60b9-5bb1-5bf8-b14f4bb14af3"),
137+
MD5: String(fmt.Sprintf("%x", md5.Sum(state))),
138+
Serial: Int64(1),
139+
JSONStateOutputs: String(base64.StdEncoding.EncodeToString(jsonStateOutputs)),
140+
},
141+
RawState: state,
142+
RawJSONState: jsonState,
143+
})
144+
require.NoError(t, err)
145+
146+
_, err = client.Workspaces.Unlock(ctx, wTest.ID)
147+
require.NoError(t, err)
148+
149+
assert.NotEmpty(t, sv.DownloadURL)
150+
assert.Equal(t, sv.Status, StateVersionFinalized)
151+
})
152+
153+
t.Run("cannot provide base64 state parameter when uploading", func(t *testing.T) {
154+
ctx := context.Background()
155+
_, err = client.StateVersions.Upload(ctx, wTest.ID, StateVersionUploadOptions{
156+
StateVersionCreateOptions: StateVersionCreateOptions{
157+
Lineage: String("741c4949-60b9-5bb1-5bf8-b14f4bb14af3"),
158+
MD5: String(fmt.Sprintf("%x", md5.Sum(state))),
159+
Serial: Int64(1),
160+
State: String(base64.StdEncoding.EncodeToString(state)),
161+
JSONStateOutputs: String(base64.StdEncoding.EncodeToString(jsonStateOutputs)),
162+
},
163+
RawState: state,
164+
RawJSONState: jsonState,
165+
})
166+
require.ErrorIs(t, err, ErrStateMustBeOmitted)
167+
})
168+
169+
t.Run("RawState parameter is required when uploading", func(t *testing.T) {
170+
ctx := context.Background()
171+
_, err = client.StateVersions.Upload(ctx, wTest.ID, StateVersionUploadOptions{
172+
StateVersionCreateOptions: StateVersionCreateOptions{
173+
Lineage: String("741c4949-60b9-5bb1-5bf8-b14f4bb14af3"),
174+
MD5: String(fmt.Sprintf("%x", md5.Sum(state))),
175+
Serial: Int64(1),
176+
JSONStateOutputs: String(base64.StdEncoding.EncodeToString(jsonStateOutputs)),
177+
},
178+
RawJSONState: jsonState,
179+
})
180+
require.ErrorIs(t, err, ErrRequiredRawState)
181+
})
182+
}
183+
106184
func TestStateVersionsCreate(t *testing.T) {
107185
client := testClient(t)
108186
ctx := context.Background()
@@ -125,6 +203,34 @@ func TestStateVersionsCreate(t *testing.T) {
125203
t.Fatal(err)
126204
}
127205

206+
t.Run("can create pending state versions", func(t *testing.T) {
207+
skipUnlessBeta(t)
208+
209+
ctx := context.Background()
210+
_, err := client.Workspaces.Lock(ctx, wTest.ID, WorkspaceLockOptions{})
211+
if err != nil {
212+
t.Fatal(err)
213+
}
214+
215+
sv, err := client.StateVersions.Create(ctx, wTest.ID, StateVersionCreateOptions{
216+
Lineage: String("741c4949-60b9-5bb1-5bf8-b14f4bb14af3"),
217+
MD5: String(fmt.Sprintf("%x", md5.Sum(state))),
218+
Serial: Int64(1),
219+
})
220+
require.NoError(t, err)
221+
222+
// Workspaces must be force-unlocked when there is a pending state version
223+
_, err = client.Workspaces.ForceUnlock(ctx, wTest.ID)
224+
if err != nil {
225+
t.Fatal(err)
226+
}
227+
228+
sv, err = client.StateVersions.Read(ctx, sv.ID)
229+
assert.ErrorIs(t, err, ErrResourceNotFound)
230+
231+
assert.Equal(t, StateVersionPending, sv.Status)
232+
})
233+
128234
t.Run("with valid options", func(t *testing.T) {
129235
ctx := context.Background()
130236
_, err := client.Workspaces.Lock(ctx, wTest.ID, WorkspaceLockOptions{})
@@ -285,7 +391,7 @@ func TestStateVersionsCreate(t *testing.T) {
285391
assert.Equal(t, err, ErrRequiredM5)
286392
})
287393

288-
t.Run("withous serial", func(t *testing.T) {
394+
t.Run("without serial", func(t *testing.T) {
289395
sv, err := client.StateVersions.Create(ctx, wTest.ID, StateVersionCreateOptions{
290396
MD5: String(fmt.Sprintf("%x", md5.Sum(state))),
291397
State: String(base64.StdEncoding.EncodeToString(state)),
@@ -294,15 +400,6 @@ func TestStateVersionsCreate(t *testing.T) {
294400
assert.Equal(t, err, ErrRequiredSerial)
295401
})
296402

297-
t.Run("without state", func(t *testing.T) {
298-
sv, err := client.StateVersions.Create(ctx, wTest.ID, StateVersionCreateOptions{
299-
MD5: String(fmt.Sprintf("%x", md5.Sum(state))),
300-
Serial: Int64(0),
301-
})
302-
assert.Nil(t, sv)
303-
assert.Equal(t, err, ErrRequiredState)
304-
})
305-
306403
t.Run("with invalid workspace id", func(t *testing.T) {
307404
sv, err := client.StateVersions.Create(ctx, badIdentifier, StateVersionCreateOptions{})
308405
assert.Nil(t, sv)

0 commit comments

Comments
 (0)