diff --git a/CHANGELOG.md b/CHANGELOG.md index c8bce1329..8855ba75a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,9 +5,12 @@ ## Enhancements * Adds `RunPreApplyRunning` and `RunQueuingApply` run statuses by @uk1288 [#712](https://github.com/hashicorp/go-tfe/pull/712) * Update `Workspaces` to include associated `project` resource by @glennsarti [#714](https://github.com/hashicorp/go-tfe/pull/714) +* Adds BETA method `Upload` method to `StateVersions` and support for pending state versions by @brandonc [#717](https://github.com/hashicorp/go-tfe/pull/717) +* Added ContextWithResponseHeaderHook support to `IPRanges` by @brandonc [#717](https://github.com/hashicorp/go-tfe/pull/717) ## Bug Fixes * 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) +* `ConfigurationVersions`, `PolicySetVersions`, and `RegistryModules` `Upload` methods were sending API credentials to the specified upload URL, which was unnecessary by @brandonc [#717](https://github.com/hashicorp/go-tfe/pull/717) # v1.26.0 diff --git a/configuration_version.go b/configuration_version.go index 8f19550b6..5cb67d344 100644 --- a/configuration_version.go +++ b/configuration_version.go @@ -275,12 +275,7 @@ func (s *configurationVersions) Upload(ctx context.Context, uploadURL, path stri // **Note**: This method does not validate the content being uploaded and is therefore the caller's // responsibility to ensure the raw content is a valid Terraform configuration. func (s *configurationVersions) UploadTarGzip(ctx context.Context, uploadURL string, archive io.Reader) error { - req, err := s.client.NewRequest("PUT", uploadURL, archive) - if err != nil { - return err - } - - return req.Do(ctx, nil) + return s.client.doForeignPUTRequest(ctx, uploadURL, archive) } // Archive a configuration version. This can only be done on configuration versions that diff --git a/errors.go b/errors.go index 95e8932cd..cc8c07a06 100644 --- a/errors.go +++ b/errors.go @@ -353,4 +353,10 @@ var ( ErrRequiredRegistryModule = errors.New("registry module is required") ErrTerraformVersionValidForPlanOnly = errors.New("setting terraform-version is only valid when plan-only is set to true") + + ErrStateMustBeOmitted = errors.New("when uploading state, the State and JSONState strings must be omitted from options") + + ErrRequiredRawState = errors.New("RawState is required") + + ErrStateVersionUploadNotSupported = errors.New("upload not supported by this version of Terraform Enterprise") ) diff --git a/example_test.go b/example_test.go index 132b38f55..863e65c4d 100644 --- a/example_test.go +++ b/example_test.go @@ -6,7 +6,10 @@ package tfe import ( "bytes" "context" + "crypto/md5" + "fmt" "log" + "os" slug "github.com/hashicorp/go-slug" ) @@ -177,3 +180,45 @@ func ExampleRegistryModules_UploadTarGzip() { log.Fatal(err) } } + +func ExampleStateVersions_Upload() { + ctx := context.Background() + client, err := NewClient(&Config{ + Token: "insert-your-token-here", + RetryServerErrors: true, + }) + if err != nil { + log.Fatal(err) + } + + // Lock the workspace + if _, err = client.Workspaces.Lock(ctx, "ws-12345678", WorkspaceLockOptions{}); err != nil { + log.Fatal(err) + } + + state, err := os.ReadFile("state.json") + if err != nil { + log.Fatal(err) + } + + // Create upload options that does not contain a State attribute within the create options + options := StateVersionUploadOptions{ + StateVersionCreateOptions: StateVersionCreateOptions{ + Lineage: String("493f7758-da5e-229e-7872-ea1f78ebe50a"), + Serial: Int64(int64(2)), + MD5: String(fmt.Sprintf("%x", md5.Sum(state))), + Force: Bool(false), + }, + RawState: state, + } + + // Upload a state version + if _, err = client.StateVersions.Upload(ctx, "ws-12345678", options); err != nil { + log.Fatal(err) + } + + // Unlock the workspace + if _, err = client.Workspaces.Unlock(ctx, "ws-12345678"); err != nil { + log.Fatal(err) + } +} diff --git a/examples/state_versions/main.go b/examples/state_versions/main.go new file mode 100644 index 000000000..48bcd4cb4 --- /dev/null +++ b/examples/state_versions/main.go @@ -0,0 +1,55 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package main + +import ( + "context" + "crypto/md5" + "fmt" + "log" + "os" + + tfe "github.com/hashicorp/go-tfe" +) + +func main() { + ctx := context.Background() + client, err := tfe.NewClient(&tfe.Config{ + RetryServerErrors: true, + }) + if err != nil { + log.Fatal(err) + } + + // Lock the workspace + if _, err = client.Workspaces.Lock(ctx, "ws-12345678", tfe.WorkspaceLockOptions{}); err != nil { + log.Fatal(err) + } + + state, err := os.ReadFile("state.json") + if err != nil { + log.Fatal(err) + } + + // Create upload options that does not contain a State attribute within the create options + options := tfe.StateVersionUploadOptions{ + StateVersionCreateOptions: tfe.StateVersionCreateOptions{ + Lineage: tfe.String("493f7758-da5e-229e-7872-ea1f78ebe50a"), + Serial: tfe.Int64(int64(2)), + MD5: tfe.String(fmt.Sprintf("%x", md5.Sum(state))), + Force: tfe.Bool(false), + }, + RawState: state, + } + + // Upload a state version + if _, err = client.StateVersions.Upload(ctx, "ws-12345678", options); err != nil { + log.Fatal(err) + } + + // Unlock the workspace + if _, err = client.Workspaces.Unlock(ctx, "ws-12345678"); err != nil { + log.Fatal(err) + } +} diff --git a/examples/state_versions/state.json b/examples/state_versions/state.json new file mode 100644 index 000000000..aab7a8b17 --- /dev/null +++ b/examples/state_versions/state.json @@ -0,0 +1,45 @@ +{ + "version": 4, + "terraform_version": "1.3.9", + "serial": 2, + "lineage": "493f7758-da5e-229e-7872-ea1f78ebe50a", + "outputs": { + "name": { + "value": "", + "type": "string" + } + }, + "resources": [ + { + "mode": "managed", + "type": "null_resource", + "name": "null", + "provider": "provider[\"registry.terraform.io/hashicorp/null\"]", + "instances": [ + { + "schema_version": 0, + "attributes": { + "id": "6593301963468675161", + "triggers": { + "creating": "ai-generated content", + "critical": "code", + "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]", + "happy": "gilmore", + "key": "value2", + "napkin": "piano", + "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]", + "product": "type", + "responsible": "damages to your mainframe", + "slam": "dunk", + "super": "heroes", + "system": "or otherwise", + "warning": "do not operate" + } + }, + "sensitive_attributes": [] + } + ] + } + ], + "check_results": null +} diff --git a/go.mod b/go.mod index 5b05fc53b..3a0d538f1 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/hashicorp/go-version v1.6.0 github.com/hashicorp/jsonapi v0.0.0-20210826224640-ee7dae0fb22d github.com/stretchr/testify v1.8.4 + golang.org/x/sync v0.0.0-20210220032951-036812b2e83c golang.org/x/time v0.3.0 ) diff --git a/go.sum b/go.sum index 3ebefb569..5152cdead 100644 --- a/go.sum +++ b/go.sum @@ -33,6 +33,7 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= diff --git a/ip_ranges.go b/ip_ranges.go index 07038877b..68a0352aa 100644 --- a/ip_ranges.go +++ b/ip_ranges.go @@ -50,7 +50,7 @@ func (i *ipRanges) Read(ctx context.Context, modifiedSince string) (*IPRange, er } ir := &IPRange{} - err = req.doIPRanges(ctx, ir) + err = req.DoJSON(ctx, ir) if err != nil { return nil, err } diff --git a/mocks/.DS_Store b/mocks/.DS_Store deleted file mode 100644 index 6b63b8ac6..000000000 Binary files a/mocks/.DS_Store and /dev/null differ diff --git a/mocks/state_version_mocks.go b/mocks/state_version_mocks.go index fca56ef43..ce9b04c75 100644 --- a/mocks/state_version_mocks.go +++ b/mocks/state_version_mocks.go @@ -154,3 +154,18 @@ func (mr *MockStateVersionsMockRecorder) ReadWithOptions(ctx, svID, options inte mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReadWithOptions", reflect.TypeOf((*MockStateVersions)(nil).ReadWithOptions), ctx, svID, options) } + +// Upload mocks base method. +func (m *MockStateVersions) Upload(ctx context.Context, workspaceID string, options tfe.StateVersionUploadOptions) (*tfe.StateVersion, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Upload", ctx, workspaceID, options) + ret0, _ := ret[0].(*tfe.StateVersion) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Upload indicates an expected call of Upload. +func (mr *MockStateVersionsMockRecorder) Upload(ctx, workspaceID, options interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Upload", reflect.TypeOf((*MockStateVersions)(nil).Upload), ctx, workspaceID, options) +} diff --git a/policy_set_version.go b/policy_set_version.go index 72bbd6e8f..c4fdf00ea 100644 --- a/policy_set_version.go +++ b/policy_set_version.go @@ -154,10 +154,5 @@ func (p *policySetVersions) Upload(ctx context.Context, psv PolicySetVersion, pa return err } - req, err := p.client.NewRequest("PUT", uploadURL, body) - if err != nil { - return err - } - - return req.Do(ctx, nil) + return p.client.doForeignPUTRequest(ctx, uploadURL, body) } diff --git a/registry_module.go b/registry_module.go index 60c630353..0a308db04 100644 --- a/registry_module.go +++ b/registry_module.go @@ -270,12 +270,7 @@ func (r *registryModules) Upload(ctx context.Context, rmv RegistryModuleVersion, // **Note**: This method does not validate the content being uploaded and is therefore the caller's // responsibility to ensure the raw content is a valid Terraform configuration. func (r *registryModules) UploadTarGzip(ctx context.Context, uploadURL string, archive io.Reader) error { - req, err := r.client.NewRequest("PUT", uploadURL, archive) - if err != nil { - return err - } - - return req.Do(ctx, nil) + return r.client.doForeignPUTRequest(ctx, uploadURL, archive) } // Create a new registry module without a VCS repo diff --git a/request.go b/request.go index ce5fb83a8..ecc33640c 100644 --- a/request.go +++ b/request.go @@ -27,8 +27,10 @@ type ClientRequest struct { func (r ClientRequest) Do(ctx context.Context, model interface{}) error { // Wait will block until the limiter can obtain a new token // or returns an error if the given context is canceled. - if err := r.limiter.Wait(ctx); err != nil { - return err + if r.limiter != nil { + if err := r.limiter.Wait(ctx); err != nil { + return err + } } // If the caller provided a response header hook then we'll call it @@ -76,20 +78,32 @@ func (r ClientRequest) Do(ctx context.Context, model interface{}) error { return unmarshalResponse(resp.Body, model) } -// doIPRanges is similar to Do except that The IP ranges API is not returning jsonapi -// like every other endpoint which means we need to handle it differently. -func (r *ClientRequest) doIPRanges(ctx context.Context, ir *IPRange) error { +// DoJSON is similar to Do except that it should be used when a plain JSON response is expected +// as opposed to json-api. +func (r *ClientRequest) DoJSON(ctx context.Context, model any) error { // Wait will block until the limiter can obtain a new token // or returns an error if the given context is canceled. - if err := r.limiter.Wait(ctx); err != nil { - return err + if r.limiter != nil { + if err := r.limiter.Wait(ctx); err != nil { + return err + } } // Add the context to the request. contextReq := r.retryableRequest.WithContext(ctx) + // If the caller provided a response header hook then we'll call it + // once we have a response. + respHeaderHook := contextResponseHeaderHook(ctx) + // Execute the request and check the response. resp, err := r.http.Do(contextReq) + if resp != nil { + // We call the callback whenever there's any sort of response, + // even if it's returned in conjunction with an error. + respHeaderHook(resp.StatusCode, resp.Header) + } + defer resp.Body.Close() if err != nil { // If we got an error, and the context has been canceled, // the context's error is probably more useful. @@ -100,17 +114,27 @@ func (r *ClientRequest) doIPRanges(ctx context.Context, ir *IPRange) error { return err } } - defer resp.Body.Close() - if resp.StatusCode < 200 && resp.StatusCode >= 400 { - return fmt.Errorf("error HTTP response while retrieving IP ranges: %d", resp.StatusCode) + if resp.StatusCode < 200 || resp.StatusCode >= 400 { + return fmt.Errorf("error HTTP response: %d", resp.StatusCode) } else if resp.StatusCode == 304 { + // Got a "Not Modified" response, but we can't return a model because there is no response body. + // This is necessary to support the IPRanges endpoint, which has the peculiar behavior + // of not returning content but allowing a 304 response by optionally sending an + // If-Modified-Since header. return nil } - err = json.NewDecoder(resp.Body).Decode(ir) - if err != nil { + // Return here if decoding the response isn't needed. + if model == nil { + return nil + } + + // If v implements io.Writer, write the raw response body. + if w, ok := model.(io.Writer); ok { + _, err := io.Copy(w, resp.Body) return err } - return nil + + return json.NewDecoder(resp.Body).Decode(model) } diff --git a/state_version.go b/state_version.go index 357edf76e..9b399c997 100644 --- a/state_version.go +++ b/state_version.go @@ -8,12 +8,25 @@ import ( "context" "fmt" "net/url" + "strings" "time" + + "golang.org/x/sync/errgroup" ) // Compile-time proof of interface implementation. var _ StateVersions = (*stateVersions)(nil) +// StateVersionStatus are available state version status values +type StateVersionStatus string + +// Available state version statuses. +const ( + StateVersionPending StateVersionStatus = "pending" + StateVersionFinalized StateVersionStatus = "finalized" + StateVersionDiscarded StateVersionStatus = "discarded" +) + // StateVersions describes all the state version related methods that // the Terraform Enterprise API supports. // @@ -26,6 +39,12 @@ type StateVersions interface { // Create a new state version for the given workspace. Create(ctx context.Context, workspaceID string, options StateVersionCreateOptions) (*StateVersion, error) + // Upload creates a new state version but uploads the state content directly to the object store. + // This is a more resilient form of Create and is the recommended approach to creating state versions. + // + // **Note: This method is still in BETA and subject to change.** + Upload(ctx context.Context, workspaceID string, options StateVersionUploadOptions) (*StateVersion, error) + // Read a state version by its ID. Read(ctx context.Context, svID string) (*StateVersion, error) @@ -60,12 +79,16 @@ type StateVersionList struct { // StateVersion represents a Terraform Enterprise state version. type StateVersion struct { - ID string `jsonapi:"primary,state-versions"` - CreatedAt time.Time `jsonapi:"attr,created-at,iso8601"` - DownloadURL string `jsonapi:"attr,hosted-state-download-url"` - Serial int64 `jsonapi:"attr,serial"` - VCSCommitSHA string `jsonapi:"attr,vcs-commit-sha"` - VCSCommitURL string `jsonapi:"attr,vcs-commit-url"` + ID string `jsonapi:"primary,state-versions"` + CreatedAt time.Time `jsonapi:"attr,created-at,iso8601"` + DownloadURL string `jsonapi:"attr,hosted-state-download-url"` + UploadURL string `jsonapi:"attr,hosted-state-upload-url"` + Status StateVersionStatus `jsonapi:"attr,status"` + JSONUploadURL string `jsonapi:"attr,hosted-json-state-upload-url"` + JSONDownloadURL string `jsonapi:"attr,hosted-json-state-download-url"` + Serial int64 `jsonapi:"attr,serial"` + VCSCommitSHA string `jsonapi:"attr,vcs-commit-sha"` + VCSCommitURL string `jsonapi:"attr,vcs-commit-url"` // Whether Terraform Cloud has finished populating any StateVersion fields that required async processing. // If `false`, some fields may appear empty even if they should actually contain data; see comments on // individual fields for details. @@ -147,8 +170,8 @@ type StateVersionCreateOptions struct { // Required: The serial of the state. Serial *int64 `jsonapi:"attr,serial"` - // Required: The base64 encoded state. - State *string `jsonapi:"attr,state"` + // Optional: The base64 encoded state. + State *string `jsonapi:"attr,state,omitempty"` // Optional: Force can be set to skip certain validations. Wrong use // of this flag can cause data loss, so USE WITH CAUTION! @@ -173,6 +196,13 @@ type StateVersionCreateOptions struct { JSONStateOutputs *string `jsonapi:"attr,json-state-outputs,omitempty"` } +type StateVersionUploadOptions struct { + StateVersionCreateOptions + + RawState []byte + RawJSONState []byte +} + type StateVersionModules struct { Root StateVersionModuleRoot `jsonapi:"attr,root"` } @@ -243,6 +273,40 @@ func (s *stateVersions) Create(ctx context.Context, workspaceID string, options return sv, nil } +// Upload creates a new state version but uploads the state content directly to the object store. +// This is a more resilient form of Create and is the recommended approach to creating state versions. +// +// **Note: This method is still in BETA and subject to change.** +func (s *stateVersions) Upload(ctx context.Context, workspaceID string, options StateVersionUploadOptions) (*StateVersion, error) { + if err := options.valid(); err != nil { + return nil, err + } + + sv, err := s.Create(ctx, workspaceID, options.StateVersionCreateOptions) + if err != nil { + if strings.Contains(err.Error(), "param is missing or the value is empty: state") { + return nil, ErrStateVersionUploadNotSupported + } + } + + g, _ := errgroup.WithContext(ctx) + g.Go(func() error { + return s.client.doForeignPUTRequest(ctx, sv.UploadURL, bytes.NewReader(options.RawState)) + }) + if options.RawJSONState != nil { + g.Go(func() error { + return s.client.doForeignPUTRequest(ctx, sv.JSONUploadURL, bytes.NewReader(options.RawJSONState)) + }) + } + + if err := g.Wait(); err != nil { + return nil, err + } + + // Re-read the state version to get the updated status, if available + return s.Read(ctx, sv.ID) +} + // Read a state version by its ID. func (s *stateVersions) ReadWithOptions(ctx context.Context, svID string, options *StateVersionReadOptions) (*StateVersion, error) { if !validStringID(&svID) { @@ -362,8 +426,18 @@ func (o StateVersionCreateOptions) valid() error { if o.Serial == nil { return ErrRequiredSerial } - if !validString(o.State) { - return ErrRequiredState + return nil +} + +func (o StateVersionUploadOptions) valid() error { + if err := o.StateVersionCreateOptions.valid(); err != nil { + return err + } + if o.State != nil || o.JSONState != nil { + return ErrStateMustBeOmitted + } + if o.RawState == nil { + return ErrRequiredRawState } return nil } diff --git a/state_version_integration_test.go b/state_version_integration_test.go index 4e65c26f9..0b7627a47 100644 --- a/state_version_integration_test.go +++ b/state_version_integration_test.go @@ -103,6 +103,84 @@ func TestStateVersionsList(t *testing.T) { }) } +func TestStateVersionsUpload(t *testing.T) { + skipUnlessBeta(t) + + client := testClient(t) + + wTest, wTestCleanup := createWorkspace(t, client, nil) + t.Cleanup(wTestCleanup) + + state, err := os.ReadFile("test-fixtures/state-version/terraform.tfstate") + if err != nil { + t.Fatal(err) + } + + jsonState, err := os.ReadFile("test-fixtures/json-state/state.json") + if err != nil { + t.Fatal(err) + } + + jsonStateOutputs, err := os.ReadFile("test-fixtures/json-state-outputs/everything.json") + if err != nil { + t.Fatal(err) + } + + t.Run("can create finalized state versions", func(t *testing.T) { + ctx := context.Background() + _, err := client.Workspaces.Lock(ctx, wTest.ID, WorkspaceLockOptions{}) + require.NoError(t, err) + + sv, err := client.StateVersions.Upload(ctx, wTest.ID, StateVersionUploadOptions{ + StateVersionCreateOptions: StateVersionCreateOptions{ + Lineage: String("741c4949-60b9-5bb1-5bf8-b14f4bb14af3"), + MD5: String(fmt.Sprintf("%x", md5.Sum(state))), + Serial: Int64(1), + JSONStateOutputs: String(base64.StdEncoding.EncodeToString(jsonStateOutputs)), + }, + RawState: state, + RawJSONState: jsonState, + }) + require.NoError(t, err) + + _, err = client.Workspaces.Unlock(ctx, wTest.ID) + require.NoError(t, err) + + assert.NotEmpty(t, sv.DownloadURL) + assert.Equal(t, sv.Status, StateVersionFinalized) + }) + + t.Run("cannot provide base64 state parameter when uploading", func(t *testing.T) { + ctx := context.Background() + _, err = client.StateVersions.Upload(ctx, wTest.ID, StateVersionUploadOptions{ + StateVersionCreateOptions: StateVersionCreateOptions{ + Lineage: String("741c4949-60b9-5bb1-5bf8-b14f4bb14af3"), + MD5: String(fmt.Sprintf("%x", md5.Sum(state))), + Serial: Int64(1), + State: String(base64.StdEncoding.EncodeToString(state)), + JSONStateOutputs: String(base64.StdEncoding.EncodeToString(jsonStateOutputs)), + }, + RawState: state, + RawJSONState: jsonState, + }) + require.ErrorIs(t, err, ErrStateMustBeOmitted) + }) + + t.Run("RawState parameter is required when uploading", func(t *testing.T) { + ctx := context.Background() + _, err = client.StateVersions.Upload(ctx, wTest.ID, StateVersionUploadOptions{ + StateVersionCreateOptions: StateVersionCreateOptions{ + Lineage: String("741c4949-60b9-5bb1-5bf8-b14f4bb14af3"), + MD5: String(fmt.Sprintf("%x", md5.Sum(state))), + Serial: Int64(1), + JSONStateOutputs: String(base64.StdEncoding.EncodeToString(jsonStateOutputs)), + }, + RawJSONState: jsonState, + }) + require.ErrorIs(t, err, ErrRequiredRawState) + }) +} + func TestStateVersionsCreate(t *testing.T) { client := testClient(t) ctx := context.Background() @@ -125,6 +203,29 @@ func TestStateVersionsCreate(t *testing.T) { t.Fatal(err) } + t.Run("can create pending state versions", func(t *testing.T) { + skipUnlessBeta(t) + + ctx := context.Background() + _, err := client.Workspaces.Lock(ctx, wTest.ID, WorkspaceLockOptions{}) + if err != nil { + t.Fatal(err) + } + + _, err = client.StateVersions.Create(ctx, wTest.ID, StateVersionCreateOptions{ + Lineage: String("741c4949-60b9-5bb1-5bf8-b14f4bb14af3"), + MD5: String(fmt.Sprintf("%x", md5.Sum(state))), + Serial: Int64(1), + }) + require.NoError(t, err) + + // Workspaces must be force-unlocked when there is a pending state version + _, err = client.Workspaces.ForceUnlock(ctx, wTest.ID) + if err != nil { + t.Fatal(err) + } + }) + t.Run("with valid options", func(t *testing.T) { ctx := context.Background() _, err := client.Workspaces.Lock(ctx, wTest.ID, WorkspaceLockOptions{}) @@ -285,7 +386,7 @@ func TestStateVersionsCreate(t *testing.T) { assert.Equal(t, err, ErrRequiredM5) }) - t.Run("withous serial", func(t *testing.T) { + t.Run("without serial", func(t *testing.T) { sv, err := client.StateVersions.Create(ctx, wTest.ID, StateVersionCreateOptions{ MD5: String(fmt.Sprintf("%x", md5.Sum(state))), State: String(base64.StdEncoding.EncodeToString(state)), @@ -294,15 +395,6 @@ func TestStateVersionsCreate(t *testing.T) { assert.Equal(t, err, ErrRequiredSerial) }) - t.Run("without state", func(t *testing.T) { - sv, err := client.StateVersions.Create(ctx, wTest.ID, StateVersionCreateOptions{ - MD5: String(fmt.Sprintf("%x", md5.Sum(state))), - Serial: Int64(0), - }) - assert.Nil(t, sv) - assert.Equal(t, err, ErrRequiredState) - }) - t.Run("with invalid workspace id", func(t *testing.T) { sv, err := client.StateVersions.Create(ctx, badIdentifier, StateVersionCreateOptions{}) assert.Nil(t, sv) diff --git a/tfe.go b/tfe.go index 16eb4cd3b..82d7fa819 100644 --- a/tfe.go +++ b/tfe.go @@ -198,11 +198,48 @@ type Meta struct { IPRanges IPRanges } -func (c *Client) NewRequest(method, path string, reqAttr interface{}) (*ClientRequest, error) { +// doForeignPUTRequest performs a PUT request using the specific data body. The Content-Type +// header is set to application/octet-stream but no Authentication header is sent. No response +// body is decoded. +func (c *Client) doForeignPUTRequest(ctx context.Context, foreignURL string, data io.Reader) error { + u, err := url.Parse(foreignURL) + if err != nil { + return fmt.Errorf("specified URL was not valid: %w", err) + } + + reqHeaders := make(http.Header) + reqHeaders.Set("Accept", "application/json, */*") + reqHeaders.Set("Content-Type", "application/octet-stream") + + req, err := retryablehttp.NewRequest("PUT", u.String(), data) + if err != nil { + return err + } + + // Set the default headers. + for k, v := range c.headers { + req.Header[k] = v + } + + // Set the request specific headers. + for k, v := range reqHeaders { + req.Header[k] = v + } + + request := &ClientRequest{ + retryableRequest: req, + http: c.http, + Header: req.Header, + } + + return request.DoJSON(ctx, nil) +} + +func (c *Client) NewRequest(method, path string, reqAttr any) (*ClientRequest, error) { return c.NewRequestWithAdditionalQueryParams(method, path, reqAttr, nil) } -func (c *Client) NewRequestWithAdditionalQueryParams(method, path string, reqAttr interface{}, additionalQueryParams map[string][]string) (*ClientRequest, error) { +func (c *Client) NewRequestWithAdditionalQueryParams(method, path string, reqAttr any, additionalQueryParams map[string][]string) (*ClientRequest, error) { var u *url.URL var err error if strings.Contains(path, "/api/registry/") { @@ -221,7 +258,7 @@ func (c *Client) NewRequestWithAdditionalQueryParams(method, path string, reqAtt reqHeaders := make(http.Header) reqHeaders.Set("Authorization", "Bearer "+c.token) - var body interface{} + var body any switch method { case "GET": reqHeaders.Set("Accept", ContentTypeJSONAPI)