diff --git a/.github/actions/test-go-tfe/action.yml b/.github/actions/test-go-tfe/action.yml index bb226954a..6c5bbeabd 100644 --- a/.github/actions/test-go-tfe/action.yml +++ b/.github/actions/test-go-tfe/action.yml @@ -97,6 +97,14 @@ runs: GITHUB_REGISTRY_MODULE_IDENTIFIER: "hashicorp/terraform-random-module" GITHUB_REGISTRY_NO_CODE_MODULE_IDENTIFIER: "hashicorp/terraform-random-no-code-module" OAUTH_CLIENT_GITHUB_TOKEN: "${{ inputs.oauth-client-github-token }}" + SKIP_HYOK_INTEGRATION_TESTS: "${{ inputs.skip-hyok-integration-tests }}" + HYOK_ORGANIZATION_NAME: "${{ inputs.hyok-organization-name }}" + HYOK_WORKSPACE_NAME: "${{ inputs.hyok-workspace-name }}" + HYOK_POOL_ID: "${{ inputs.hyok-pool-id }}" + HYOK_PLAN_ID: "${{ inputs.hyok-plan-id }}" + HYOK_STATE_VERSION_ID: "${{ inputs.hyok-state-version-id }}" + HYOK_CUSTOMER_KEY_VERSION_ID: "${{ inputs.hyok-customer-key-version-id }}" + HYOK_ENCRYPTED_DATA_KEY_ID: "${{ inputs.hyok-encrypted-data-key-id }}" GO111MODULE: "on" ENABLE_TFE: ${{ inputs.enterprise }} run: | diff --git a/agent_pool.go b/agent_pool.go index 47778820b..adf66145d 100644 --- a/agent_pool.go +++ b/agent_pool.go @@ -66,11 +66,12 @@ type AgentPool struct { CreatedAt time.Time `jsonapi:"attr,created-at,iso8601"` // Relations - Organization *Organization `jsonapi:"relation,organization"` - Workspaces []*Workspace `jsonapi:"relation,workspaces"` - AllowedWorkspaces []*Workspace `jsonapi:"relation,allowed-workspaces"` - AllowedProjects []*Project `jsonapi:"relation,allowed-projects"` - ExcludedWorkspaces []*Workspace `jsonapi:"relation,excluded-workspaces"` + Organization *Organization `jsonapi:"relation,organization"` + HYOKConfigurations []*HYOKConfiguration `jsonapi:"relation,hyok-configurations"` + Workspaces []*Workspace `jsonapi:"relation,workspaces"` + AllowedWorkspaces []*Workspace `jsonapi:"relation,allowed-workspaces"` + AllowedProjects []*Project `jsonapi:"relation,allowed-projects"` + ExcludedWorkspaces []*Workspace `jsonapi:"relation,excluded-workspaces"` } // A list of relations to include @@ -78,6 +79,7 @@ type AgentPool struct { type AgentPoolIncludeOpt string const AgentPoolWorkspaces AgentPoolIncludeOpt = "workspaces" +const AgentPoolHYOKConfigurations AgentPoolIncludeOpt = "hyok-configurations" type AgentPoolReadOptions struct { Include []AgentPoolIncludeOpt `url:"include,omitempty"` @@ -188,7 +190,7 @@ func (s *agentPools) ReadWithOptions(ctx context.Context, agentpoolID string, op } u := fmt.Sprintf("agent-pools/%s", url.PathEscape(agentpoolID)) - req, err := s.client.NewRequest("GET", u, nil) + req, err := s.client.NewRequest("GET", u, &options) if err != nil { return nil, err } diff --git a/agent_pool_integration_test.go b/agent_pool_integration_test.go index 2bad0c15e..3e06cc554 100644 --- a/agent_pool_integration_test.go +++ b/agent_pool_integration_test.go @@ -5,6 +5,7 @@ package tfe import ( "context" + "os" "testing" "github.com/stretchr/testify/assert" @@ -343,6 +344,22 @@ func TestAgentPoolsRead(t *testing.T) { require.NoError(t, err) assert.NotEmpty(t, k.Workspaces[0]) }) + + t.Run("read hyok configurations of an agent pool", func(t *testing.T) { + skipHYOKIntegrationTests(t) + + // replace the environment variable with a valid agent pool ID that has HYOK configurations + hyokPoolID := os.Getenv("HYOK_POOL_ID") + if hyokPoolID == "" { + t.Fatal("Export a valid HYOK_POOL_ID before running this test!") + } + + k, err := client.AgentPools.ReadWithOptions(ctx, hyokPoolID, &AgentPoolReadOptions{ + Include: []AgentPoolIncludeOpt{AgentPoolHYOKConfigurations}, + }) + require.NoError(t, err) + assert.NotEmpty(t, k.HYOKConfigurations) + }) } func TestAgentPoolsReadCreatedAt(t *testing.T) { diff --git a/aws_oidc_configuration_integration_test.go b/aws_oidc_configuration_integration_test.go index 5799dc88d..55b12717e 100644 --- a/aws_oidc_configuration_integration_test.go +++ b/aws_oidc_configuration_integration_test.go @@ -2,6 +2,7 @@ package tfe import ( "context" + "os" "testing" "github.com/stretchr/testify/assert" @@ -12,13 +13,17 @@ import ( // To run them locally, follow the instructions outlined in hyok_configuration_integration_test.go func TestAWSOIDCConfigurationCreateDelete(t *testing.T) { - if skipHYOKIntegrationTests { - t.Skip() - } + skipHYOKIntegrationTests(t) client := testClient(t) ctx := context.Background() + // replace the environment variable with a valid organization name that has AWS OIDC HYOK configurations + hyokOrganizationName := os.Getenv("HYOK_ORGANIZATION_NAME") + if hyokOrganizationName == "" { + t.Fatal("Export a valid HYOK_ORGANIZATION_NAME before running this test!") + } + orgTest, err := client.Organizations.Read(ctx, hyokOrganizationName) if err != nil { t.Fatal(err) @@ -48,13 +53,17 @@ func TestAWSOIDCConfigurationCreateDelete(t *testing.T) { } func TestAWSOIDCConfigurationRead(t *testing.T) { - if skipHYOKIntegrationTests { - t.Skip() - } + skipHYOKIntegrationTests(t) client := testClient(t) ctx := context.Background() + // replace the environment variable with a valid organization name that has AWS OIDC HYOK configurations + hyokOrganizationName := os.Getenv("HYOK_ORGANIZATION_NAME") + if hyokOrganizationName == "" { + t.Fatal("Export a valid HYOK_ORGANIZATION_NAME before running this test!") + } + orgTest, err := client.Organizations.Read(ctx, hyokOrganizationName) if err != nil { t.Fatal(err) @@ -76,13 +85,17 @@ func TestAWSOIDCConfigurationRead(t *testing.T) { } func TestAWSOIDCConfigurationsUpdate(t *testing.T) { - if skipHYOKIntegrationTests { - t.Skip() - } + skipHYOKIntegrationTests(t) client := testClient(t) ctx := context.Background() + // replace the environment variable with a valid organization name that has AWS OIDC HYOK configurations + hyokOrganizationName := os.Getenv("HYOK_ORGANIZATION_NAME") + if hyokOrganizationName == "" { + t.Fatal("Export a valid HYOK_ORGANIZATION_NAME before running this test!") + } + orgTest, err := client.Organizations.Read(ctx, hyokOrganizationName) if err != nil { t.Fatal(err) diff --git a/azure_oidc_configuration_integration_test.go b/azure_oidc_configuration_integration_test.go index f3a9fa67f..9b0c640db 100644 --- a/azure_oidc_configuration_integration_test.go +++ b/azure_oidc_configuration_integration_test.go @@ -2,6 +2,7 @@ package tfe import ( "context" + "os" "testing" "github.com/stretchr/testify/assert" @@ -12,13 +13,17 @@ import ( // To run them locally, follow the instructions outlined in hyok_configuration_integration_test.go func TestAzureOIDCConfigurationCreateDelete(t *testing.T) { - if skipHYOKIntegrationTests { - t.Skip() - } + skipHYOKIntegrationTests(t) client := testClient(t) ctx := context.Background() + // replace the environment variable with a valid organization name that has Azure OIDC HYOK configurations + hyokOrganizationName := os.Getenv("HYOK_ORGANIZATION_NAME") + if hyokOrganizationName == "" { + t.Fatal("Export a valid HYOK_ORGANIZATION_NAME before running this test!") + } + orgTest, err := client.Organizations.Read(ctx, hyokOrganizationName) if err != nil { t.Fatal(err) @@ -75,13 +80,17 @@ func TestAzureOIDCConfigurationCreateDelete(t *testing.T) { } func TestAzureOIDCConfigurationRead(t *testing.T) { - if skipHYOKIntegrationTests { - t.Skip() - } + skipHYOKIntegrationTests(t) client := testClient(t) ctx := context.Background() + // replace the environment variable with a valid organization name that has Azure OIDC HYOK configurations + hyokOrganizationName := os.Getenv("HYOK_ORGANIZATION_NAME") + if hyokOrganizationName == "" { + t.Fatal("Export a valid HYOK_ORGANIZATION_NAME before running this test!") + } + orgTest, err := client.Organizations.Read(ctx, hyokOrganizationName) if err != nil { t.Fatal(err) @@ -103,13 +112,17 @@ func TestAzureOIDCConfigurationRead(t *testing.T) { } func TestAzureOIDCConfigurationUpdate(t *testing.T) { - if skipHYOKIntegrationTests { - t.Skip() - } + skipHYOKIntegrationTests(t) client := testClient(t) ctx := context.Background() + // replace the environment variable with a valid organization name that has Azure OIDC HYOK configurations + hyokOrganizationName := os.Getenv("HYOK_ORGANIZATION_NAME") + if hyokOrganizationName == "" { + t.Fatal("Export a valid HYOK_ORGANIZATION_NAME before running this test!") + } + orgTest, err := client.Organizations.Read(ctx, hyokOrganizationName) if err != nil { t.Fatal(err) diff --git a/errors.go b/errors.go index 1030ba9a8..97530df40 100644 --- a/errors.go +++ b/errors.go @@ -89,6 +89,9 @@ var ( // it is locked. "conflict" followed by newline is used to preserve go-tfe version // compatibility with the error constructed at runtime before it was defined here. ErrWorkspaceLockedCannotDelete = errors.New("conflict\nWorkspace is currently locked. Workspace must be unlocked before it can be safely deleted") + + // ErrHYOKCannotBeDisabled is returned when attempting to disable HYOK on a workspace that already has it enabled. + ErrHYOKCannotBeDisabled = errors.New("bad request\n\nhyok may not be disabled once it has been turned on for a workspace") ) // Invalid values for resources/struct fields @@ -410,6 +413,8 @@ var ( ErrStateVersionUploadNotSupported = errors.New("upload not supported by this version of Terraform Enterprise") + ErrSanitizedStateUploadURLMissing = errors.New("sanitized state upload URL is missing") + ErrRequiredRoleARN = errors.New("role-arn is required for AWS OIDC configuration") ErrRequiredServiceAccountEmail = errors.New("service-account-email is required for GCP OIDC configuration") diff --git a/gcp_oidc_configuration_integration_test.go b/gcp_oidc_configuration_integration_test.go index 9355a79b1..9d28d2537 100644 --- a/gcp_oidc_configuration_integration_test.go +++ b/gcp_oidc_configuration_integration_test.go @@ -2,6 +2,7 @@ package tfe import ( "context" + "os" "testing" "github.com/stretchr/testify/assert" @@ -12,13 +13,17 @@ import ( // To run them locally, follow the instructions outlined in hyok_configuration_integration_test.go func TestGCPOIDCConfigurationCreateDelete(t *testing.T) { - if skipHYOKIntegrationTests { - t.Skip() - } + skipHYOKIntegrationTests(t) client := testClient(t) ctx := context.Background() + // replace the environment variable with a valid organization name that has GCP OIDC HYOK configurations + hyokOrganizationName := os.Getenv("HYOK_ORGANIZATION_NAME") + if hyokOrganizationName == "" { + t.Fatal("Export a valid HYOK_ORGANIZATION_NAME before running this test!") + } + orgTest, err := client.Organizations.Read(ctx, hyokOrganizationName) if err != nil { t.Fatal(err) @@ -75,13 +80,17 @@ func TestGCPOIDCConfigurationCreateDelete(t *testing.T) { } func TestGCPOIDCConfigurationRead(t *testing.T) { - if skipHYOKIntegrationTests { - t.Skip() - } + skipHYOKIntegrationTests(t) client := testClient(t) ctx := context.Background() + // replace the environment variable with a valid organization name that has GCP OIDC HYOK configurations + hyokOrganizationName := os.Getenv("HYOK_ORGANIZATION_NAME") + if hyokOrganizationName == "" { + t.Fatal("Export a valid HYOK_ORGANIZATION_NAME before running this test!") + } + orgTest, err := client.Organizations.Read(ctx, hyokOrganizationName) if err != nil { t.Fatal(err) @@ -103,13 +112,17 @@ func TestGCPOIDCConfigurationRead(t *testing.T) { } func TestGCPOIDCConfigurationUpdate(t *testing.T) { - if skipHYOKIntegrationTests { - t.Skip() - } + skipHYOKIntegrationTests(t) client := testClient(t) ctx := context.Background() + // replace the environment variable with a valid organization name that has GCP OIDC HYOK configurations + hyokOrganizationName := os.Getenv("HYOK_ORGANIZATION_NAME") + if hyokOrganizationName == "" { + t.Fatal("Export a valid HYOK_ORGANIZATION_NAME before running this test!") + } + orgTest, err := client.Organizations.Read(ctx, hyokOrganizationName) if err != nil { t.Fatal(err) diff --git a/helper_test.go b/helper_test.go index ad98def3c..0b203de8b 100644 --- a/helper_test.go +++ b/helper_test.go @@ -3221,6 +3221,13 @@ func skipIfEnterprise(t *testing.T) { } } +func skipHYOKIntegrationTests(t *testing.T) { + t.Helper() + if !hyokIntegrationTestsEnabled() { + t.Skip("Skipping test related to HYOK features. Set ENABLE_HYOK_INTEGRATION_TESTS=1 to run.") + } +} + // skips a test if the underlying beta feature is not available. // **Note: ENABLE_BETA is always disabled in CI, so ensure you: // @@ -3269,6 +3276,11 @@ func betaFeaturesEnabled() bool { return os.Getenv("ENABLE_BETA") == "1" } +// Checks to see if HYOK_INTEGRATION_TESTS is set to 1, thereby enabling tests for HYOK features. +func hyokIntegrationTestsEnabled() bool { + return os.Getenv("ENABLE_HYOK_INTEGRATION_TESTS") == "1" +} + // isEmpty gets whether the specified object is considered empty or not. func isEmpty(object interface{}) bool { // get nil case out of the way diff --git a/hyok_configuration_integration_test.go b/hyok_configuration_integration_test.go index 72bfc350f..513034ecf 100644 --- a/hyok_configuration_integration_test.go +++ b/hyok_configuration_integration_test.go @@ -2,27 +2,25 @@ package tfe import ( "context" + "os" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -// These tests are intended for local execution only, as HYOK requires specific conditions. -// To test locally: -// 1. set skipHYOKIntegrationTests to false. The default value is true. -// 2. set hyokOrganizationName to the name of an organization that can use HYOK. -const skipHYOKIntegrationTests = true -const hyokOrganizationName = "" - func TestHYOKConfigurationCreateRevokeDelete(t *testing.T) { - if skipHYOKIntegrationTests { - t.Skip() - } + skipHYOKIntegrationTests(t) client := testClient(t) ctx := context.Background() + // replace the environment variable with a valid organization name that has HYOK configurations + hyokOrganizationName := os.Getenv("HYOK_ORGANIZATION_NAME") + if hyokOrganizationName == "" { + t.Fatal("Export a valid HYOK_ORGANIZATION_NAME before running this test!") + } + orgTest, err := client.Organizations.Read(ctx, hyokOrganizationName) if err != nil { t.Fatal(err) @@ -306,13 +304,17 @@ func TestHYOKConfigurationCreateRevokeDelete(t *testing.T) { } func TestHyokConfigurationList(t *testing.T) { - if skipHYOKIntegrationTests { - t.Skip() - } + skipHYOKIntegrationTests(t) client := testClient(t) ctx := context.Background() + // replace the environment variable with a valid organization name that has HYOK configurations + hyokOrganizationName := os.Getenv("HYOK_ORGANIZATION_NAME") + if hyokOrganizationName == "" { + t.Fatal("Export a valid HYOK_ORGANIZATION_NAME before running this test!") + } + orgTest, err := client.Organizations.Read(ctx, hyokOrganizationName) if err != nil { t.Fatal(err) @@ -357,13 +359,17 @@ func TestHyokConfigurationList(t *testing.T) { } func TestHyokConfigurationRead(t *testing.T) { - if skipHYOKIntegrationTests { - t.Skip() - } + skipHYOKIntegrationTests(t) client := testClient(t) ctx := context.Background() + // replace the environment variable with a valid organization name that has HYOK configurations + hyokOrganizationName := os.Getenv("HYOK_ORGANIZATION_NAME") + if hyokOrganizationName == "" { + t.Fatal("Export a valid HYOK_ORGANIZATION_NAME before running this test!") + } + orgTest, err := client.Organizations.Read(ctx, hyokOrganizationName) if err != nil { t.Fatal(err) @@ -448,13 +454,17 @@ func TestHyokConfigurationRead(t *testing.T) { } func TestHYOKConfigurationUpdate(t *testing.T) { - if skipHYOKIntegrationTests { - t.Skip() - } + skipHYOKIntegrationTests(t) client := testClient(t) ctx := context.Background() + // replace the environment variable with a valid organization name that has HYOK configurations + hyokOrganizationName := os.Getenv("HYOK_ORGANIZATION_NAME") + if hyokOrganizationName == "" { + t.Fatal("Export a valid HYOK_ORGANIZATION_NAME before running this test!") + } + orgTest, err := client.Organizations.Read(ctx, hyokOrganizationName) if err != nil { t.Fatal(err) diff --git a/hyok_customer_key_version_integration_test.go b/hyok_customer_key_version_integration_test.go index bf817338f..8c2904ef2 100644 --- a/hyok_customer_key_version_integration_test.go +++ b/hyok_customer_key_version_integration_test.go @@ -2,6 +2,7 @@ package tfe import ( "context" + "os" "testing" "github.com/stretchr/testify/require" @@ -13,13 +14,17 @@ import ( // 2. Set hyokCustomerKeyVersionID to the ID of an existing HYOK customer key version func TestHYOKCustomerKeyVersionsList(t *testing.T) { - if skipHYOKIntegrationTests { - t.Skip() - } + skipHYOKIntegrationTests(t) client := testClient(t) ctx := context.Background() + // replace the environment variable with a valid organization name that has HYOK Customer Key Versions configurations + hyokOrganizationName := os.Getenv("HYOK_ORGANIZATION_NAME") + if hyokOrganizationName == "" { + t.Fatal("Export a valid HYOK_ORGANIZATION_NAME before running this test!") + } + orgTest, err := client.Organizations.Read(ctx, hyokOrganizationName) if err != nil { t.Fatal(err) @@ -40,15 +45,17 @@ func TestHYOKCustomerKeyVersionsList(t *testing.T) { } func TestHYOKCustomerKeyVersionsRead(t *testing.T) { - if skipHYOKIntegrationTests { - t.Skip() - } + skipHYOKIntegrationTests(t) client := testClient(t) ctx := context.Background() t.Run("read an existing key version", func(t *testing.T) { - hyokCustomerKeyVersionID := "" + hyokCustomerKeyVersionID := os.Getenv("HYOK_CUSTOMER_KEY_VERSION_ID") + if hyokCustomerKeyVersionID == "" { + t.Fatal("Export a valid HYOK_CUSTOMER_KEY_VERSION_ID before running this test!") + } + _, err := client.HYOKCustomerKeyVersions.Read(ctx, hyokCustomerKeyVersionID) require.NoError(t, err) }) diff --git a/hyok_encrypted_data_key_integration_test.go b/hyok_encrypted_data_key_integration_test.go index 9459d4d33..0ab7a4402 100644 --- a/hyok_encrypted_data_key_integration_test.go +++ b/hyok_encrypted_data_key_integration_test.go @@ -2,6 +2,7 @@ package tfe import ( "context" + "os" "testing" "github.com/stretchr/testify/require" @@ -13,15 +14,17 @@ import ( // 2. Set hyokEncryptedDataKeyID to the ID of an existing data encryption key func TestHYOKEncryptedDataKeyRead(t *testing.T) { - if skipHYOKIntegrationTests { - t.Skip() - } + skipHYOKIntegrationTests(t) client := testClient(t) ctx := context.Background() t.Run("read an existing encrypted data key", func(t *testing.T) { - hyokEncryptedDataKeyID := "" + hyokEncryptedDataKeyID := os.Getenv("HYOK_ENCRYPTED_DATA_KEY_ID") + if hyokEncryptedDataKeyID == "" { + t.Fatal("Export a valid HYOK_ENCRYPTED_DATA_KEY_ID before running this test!") + } + _, err := client.HYOKEncryptedDataKeys.Read(ctx, hyokEncryptedDataKeyID) require.NoError(t, err) }) diff --git a/mocks/state_version_mocks.go b/mocks/state_version_mocks.go index cb8073cfd..82d724441 100644 --- a/mocks/state_version_mocks.go +++ b/mocks/state_version_mocks.go @@ -216,3 +216,17 @@ func (mr *MockStateVersionsMockRecorder) Upload(ctx, workspaceID, options any) * mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Upload", reflect.TypeOf((*MockStateVersions)(nil).Upload), ctx, workspaceID, options) } + +// UploadSanitizedState mocks base method. +func (m *MockStateVersions) UploadSanitizedState(ctx context.Context, sanitizedStateUploadURL string, sanitizedState []byte) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UploadSanitizedState", ctx, sanitizedStateUploadURL, sanitizedState) + ret0, _ := ret[0].(error) + return ret0 +} + +// UploadSanitizedState indicates an expected call of UploadSanitizedState. +func (mr *MockStateVersionsMockRecorder) UploadSanitizedState(ctx, sanitizedStateUploadURL, sanitizedState any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UploadSanitizedState", reflect.TypeOf((*MockStateVersions)(nil).UploadSanitizedState), ctx, sanitizedStateUploadURL, sanitizedState) +} diff --git a/organization.go b/organization.go index b69c06457..db62049f8 100644 --- a/organization.go +++ b/organization.go @@ -116,6 +116,7 @@ type Organization struct { SendPassingStatusesForUntriggeredSpeculativePlans bool `jsonapi:"attr,send-passing-statuses-for-untriggered-speculative-plans"` RemainingTestableCount int `jsonapi:"attr,remaining-testable-count"` SpeculativePlanManagementEnabled bool `jsonapi:"attr,speculative-plan-management-enabled"` + EnforceHYOK bool `jsonapi:"attr,enforce-hyok"` // Optional: If enabled, SendPassingStatusesForUntriggeredSpeculativePlans needs to be false. AggregatedCommitStatusEnabled bool `jsonapi:"attr,aggregated-commit-status-enabled,omitempty"` // Note: This will be false for TFE versions older than v202211, where the setting was introduced. @@ -123,8 +124,9 @@ type Organization struct { AllowForceDeleteWorkspaces bool `jsonapi:"attr,allow-force-delete-workspaces"` // Relations - DefaultProject *Project `jsonapi:"relation,default-project"` - DefaultAgentPool *AgentPool `jsonapi:"relation,default-agent-pool"` + DefaultProject *Project `jsonapi:"relation,default-project"` + DefaultAgentPool *AgentPool `jsonapi:"relation,default-agent-pool"` + PrimaryHYOKConfiguration *HYOKConfiguration `jsonapi:"relation,primary-hyok-configuration,omitempty"` // Deprecated: Use DataRetentionPolicyChoice instead. DataRetentionPolicy *DataRetentionPolicy @@ -197,6 +199,8 @@ type OrganizationPermissions struct { CanUpdateAPIToken bool `jsonapi:"attr,can-update-api-token"` CanUpdateOAuth bool `jsonapi:"attr,can-update-oauth"` CanUpdateSentinel bool `jsonapi:"attr,can-update-sentinel"` + CanUpdateHYOKConfiguration bool `jsonapi:"attr,can-update-hyok-configuration"` + CanViewHYOKFeatureInfo bool `jsonapi:"attr,can-view-hyok-feature-info"` } // OrganizationListOptions represents the options for listing organizations. @@ -255,6 +259,9 @@ type OrganizationCreateOptions struct { // Optional: DefaultExecutionMode the default execution mode for workspaces DefaultExecutionMode *string `jsonapi:"attr,default-execution-mode,omitempty"` + // Optional: EnforceHYOK if HYOK is enforced for the organization. + EnforceHYOK *bool `jsonapi:"attr,enforce-hyok,omitempty"` + // Optional: StacksEnabled toggles whether stacks are enabled for the organization. This setting // is considered BETA, SUBJECT TO CHANGE, and likely unavailable to most users. StacksEnabled *bool `jsonapi:"attr,stacks-enabled,omitempty"` @@ -313,6 +320,9 @@ type OrganizationUpdateOptions struct { // Optional: DefaultAgentPoolId default agent pool for workspaces, requires DefaultExecutionMode to be set to `agent` DefaultAgentPool *AgentPool `jsonapi:"relation,default-agent-pool,omitempty"` + // Optional: EnforceHYOK if HYOK is enforced for the organization. + EnforceHYOK *bool `jsonapi:"attr,enforce-hyok,omitempty"` + // Optional: StacksEnabled toggles whether stacks are enabled for the organization. This setting // is considered BETA, SUBJECT TO CHANGE, and likely unavailable to most users. StacksEnabled *bool `jsonapi:"attr,stacks-enabled,omitempty"` diff --git a/organization_integration_test.go b/organization_integration_test.go index 0883cb2ed..0e4a076d0 100644 --- a/organization_integration_test.go +++ b/organization_integration_test.go @@ -7,6 +7,7 @@ import ( "bytes" "context" "encoding/json" + "os" "testing" "time" @@ -233,6 +234,34 @@ func TestOrganizationsRead(t *testing.T) { assert.Equal(t, org.DefaultAgentPool.ID, orgAgentTest.DefaultAgentPool.ID) }) }) + + t.Run("read primary hyok configuration of an organization", func(t *testing.T) { + skipHYOKIntegrationTests(t) + + // replace the environment variable with a valid organization name that has primary hyok configuration + hyokOrganizationName := os.Getenv("HYOK_ORGANIZATION_NAME") + if hyokOrganizationName == "" { + t.Fatal("Export a valid HYOK_ORGANIZATION_NAME before running this test!") + } + + org, err := client.Organizations.Read(ctx, hyokOrganizationName) + require.NoError(t, err) + assert.NotEmpty(t, org.PrimaryHYOKConfiguration) + }) + + t.Run("read enforce hyok of an organization", func(t *testing.T) { + skipHYOKIntegrationTests(t) + + // replace the environment variable with a valid organization name that has enforce hyok set to true or false + hyokOrganizationName := os.Getenv("HYOK_ORGANIZATION_NAME") + if hyokOrganizationName == "" { + t.Fatal("Export a valid HYOK_ORGANIZATION_NAME before running this test!") + } + + org, err := client.Organizations.Read(ctx, hyokOrganizationName) + require.NoError(t, err) + assert.True(t, org.EnforceHYOK || !org.EnforceHYOK) + }) } func TestOrganizationsUpdate(t *testing.T) { @@ -388,6 +417,38 @@ func TestOrganizationsUpdate(t *testing.T) { t.Cleanup(orgAgentTestCleanup) }) + + t.Run("update enforce hyok of an organization to true", func(t *testing.T) { + skipHYOKIntegrationTests(t) + + // replace the environment variable with a valid organization name with hyok permissions + hyokOrganizationName := os.Getenv("HYOK_ORGANIZATION_NAME") + if hyokOrganizationName == "" { + t.Fatal("Export a valid HYOK_ORGANIZATION_NAME before running this test!") + } + + org, err := client.Organizations.Update(ctx, hyokOrganizationName, OrganizationUpdateOptions{ + EnforceHYOK: Bool(true), + }) + require.NoError(t, err) + assert.True(t, org.EnforceHYOK) + }) + + t.Run("update enforce hyok of an organization to false", func(t *testing.T) { + skipHYOKIntegrationTests(t) + + // replace the environment variable with a valid organization name with hyok permissions + hyokOrganizationName := os.Getenv("HYOK_ORGANIZATION_NAME") + if hyokOrganizationName == "" { + t.Fatal("Export a valid HYOK_ORGANIZATION_NAME before running this test!") + } + + org, err := client.Organizations.Update(ctx, hyokOrganizationName, OrganizationUpdateOptions{ + EnforceHYOK: Bool(false), + }) + require.NoError(t, err) + assert.False(t, org.EnforceHYOK) + }) } func TestOrganizationsDelete(t *testing.T) { diff --git a/plan.go b/plan.go index d603fc58f..9ee2a86cd 100644 --- a/plan.go +++ b/plan.go @@ -65,7 +65,11 @@ type Plan struct { StatusTimestamps *PlanStatusTimestamps `jsonapi:"attr,status-timestamps"` // Relations - Exports []*PlanExport `jsonapi:"relation,exports"` + Exports []*PlanExport `jsonapi:"relation,exports"` + HYOKEncryptedDataKey *HYOKEncryptedDataKey `jsonapi:"relation,hyok-encrypted-data-key,omitempty"` + + // Links + Links map[string]interface{} `jsonapi:"links,omitempty"` } // PlanStatusTimestamps holds the timestamps for individual plan statuses. diff --git a/plan_integration_test.go b/plan_integration_test.go index d07f27ed5..c7a3a143a 100644 --- a/plan_integration_test.go +++ b/plan_integration_test.go @@ -8,6 +8,7 @@ import ( "context" "encoding/json" "io" + "os" "testing" "time" @@ -43,6 +44,34 @@ func TestPlansRead(t *testing.T) { assert.Nil(t, p) assert.Equal(t, err, ErrInvalidPlanID) }) + + t.Run("read hyok encrypted data key of a plan", func(t *testing.T) { + skipHYOKIntegrationTests(t) + + // replace the environment variable with a valid plan ID that has a hyok encrypted data key + hyokPlanID := os.Getenv("HYOK_PLAN_ID") + if hyokPlanID == "" { + t.Fatal("Export a valid HYOK_PLAN_ID before running this test!") + } + + p, err := client.Plans.Read(ctx, hyokPlanID) + require.NoError(t, err) + assert.NotNil(t, p.HYOKEncryptedDataKey) + }) + + t.Run("read sanitized plan of a plan", func(t *testing.T) { + skipHYOKIntegrationTests(t) + + // replace the environment variable with a valid plan ID that has a sanitized plan link + hyokPlanID := os.Getenv("HYOK_PLAN_ID") + if hyokPlanID == "" { + t.Fatal("Export a valid HYOK_PLAN_ID before running this test!") + } + + p, err := client.Plans.Read(ctx, hyokPlanID) + require.NoError(t, err) + assert.NotEmpty(t, p.Links["sanitized-plan"]) + }) } func TestPlansLogs(t *testing.T) { diff --git a/scripts/hyok-testing.sh b/scripts/hyok-testing.sh new file mode 100755 index 000000000..0cdad9e0c --- /dev/null +++ b/scripts/hyok-testing.sh @@ -0,0 +1,119 @@ +#!/bin/bash + +env="STAGING_ENVCHAIN" +pairs=( + # HYOK Attributes testing + # -- Agent Pools + "TestAgentPoolsRead:read_hyok_configurations_of_an_agent_pool" + # -- Plans + "TestPlansRead:read_hyok_encrypted_data_key_of_a_plan" + "TestPlansRead:read_sanitized_plan_of_a_plan" + # -- Workspaces + "TestWorkspacesCreate:create_workspace_with_hyok_enabled_set_to_false" + "TestWorkspacesCreate:create_workspace_with_hyok_enabled_set_to_true" + "TestWorkspacesRead:read_hyok_enabled_of_a_workspace" + "TestWorkspacesRead:read_hyok_encrypted_data_key_of_a_workspace" + "TestWorkspacesUpdate:update_hyok_enabled_of_a_workspace_from_false_to_false" + "TestWorkspacesUpdate:update_hyok_enabled_of_a_workspace_from_false_to_true" + "TestWorkspacesUpdate:update_hyok_enabled_of_a_workspace_from_true_to_true" + "TestWorkspacesUpdate:update_hyok_enabled_of_a_workspace_from_true_to_false" + # -- Organizations + "TestOrganizationsRead:read_primary_hyok_configuration_of_an_organization" + "TestOrganizationsRead:read_enforce_hyok_of_an_organization" + "TestOrganizationsUpdate:update_enforce_hyok_of_an_organization_to_true" + "TestOrganizationsUpdate:update_enforce_hyok_of_an_organization_to_false" + # -- State Versions + "TestStateVersionsRead:read_encrypted_state_download_url_of_a_state_version" + "TestStateVersionsRead:read_sanitized_state_download_url_of_a_state_version" + "TestStateVersionsRead:read_hyok_encrypted_data_key_of_a_state_version" + "TestStateVersionsUpload:uploading_state_using_SanitizedStateUploadURL_and_verifying_SanitizedStateDownloadURL_exists" + "TestStateVersionsUpload:SanitizedStateUploadURL_is_required_when_uploading_sanitized_state" + + # AWS OIDC Configuration testing + "TestAWSOIDCConfigurationCreateDelete:with_valid_options" + "TestAWSOIDCConfigurationCreateDelete:missing_role_ARN" + "TestAWSOIDCConfigurationRead:fetch_existing_configuration" + "TestAWSOIDCConfigurationRead:fetching_non-existing_configuration" + "TestAWSOIDCConfigurationsUpdate:with_valid_options" + "TestAWSOIDCConfigurationsUpdate:missing_role_ARN" + + # Azure OIDC Configuration testing + "TestAzureOIDCConfigurationCreateDelete:with_valid_options" + "TestAzureOIDCConfigurationCreateDelete:missing_client_ID" + "TestAzureOIDCConfigurationCreateDelete:missing_subscription_ID" + "TestAzureOIDCConfigurationCreateDelete:missing_tenant_ID" + "TestAzureOIDCConfigurationRead:fetch_existing_configuration" + "TestAzureOIDCConfigurationRead:fetching_non-existing_configuration" + "TestAzureOIDCConfigurationUpdate:update_all_fields" + "TestAzureOIDCConfigurationUpdate:client_ID_not_provided" + "TestAzureOIDCConfigurationUpdate:subscription_ID_not_provided" + "TestAzureOIDCConfigurationUpdate:tenant_ID_not_provided" + + # GCP OIDC Configuration testing + "TestGCPOIDCConfigurationCreateDelete:with_valid_options" + "TestGCPOIDCConfigurationCreateDelete:missing_workload_provider_name" + "TestGCPOIDCConfigurationCreateDelete:missing_service_account_email" + "TestGCPOIDCConfigurationCreateDelete:missing_project_number" + "TestGCPOIDCConfigurationRead:fetch_existing_configuration" + "TestGCPOIDCConfigurationRead:fetching_non-existing_configuration" + "TestGCPOIDCConfigurationUpdate:update_all_fields" + "TestGCPOIDCConfigurationUpdate:workload_provider_name_not_provided" + "TestGCPOIDCConfigurationUpdate:service_account_email_not_provided" + "TestGCPOIDCConfigurationUpdate:project_number_not_provided" + + # Vault OIDC Configuration testing + "TestVaultOIDCConfigurationCreateDelete:with_valid_options" + "TestVaultOIDCConfigurationCreateDelete:missing_address" + "TestVaultOIDCConfigurationCreateDelete:missing_role_name" + "TestVaultOIDCConfigurationRead:fetch_existing_configuration" + "TestVaultOIDCConfigurationRead:fetching_non-existing_configuration" + "TestVaultOIDCConfigurationUpdate:update_all_fields" + "TestVaultOIDCConfigurationUpdate:address_not_provided" + "TestVaultOIDCConfigurationUpdate:role_name_not_provided" + "TestVaultOIDCConfigurationUpdate:namespace_not_provided" + "TestVaultOIDCConfigurationUpdate:JWTAuthPath_not_provided" + "TestVaultOIDCConfigurationUpdate:TLSCACertificate_not_provided" + + # HYOK Customer Key Version testing + "TestHYOKCustomerKeyVersionsList:with_no_list_options" + "TestHYOKCustomerKeyVersionsRead:read_an_existing_key_version" + + # HYOK Encrypted Data Key testing + "TestHYOKEncryptedDataKeyRead:read_an_existing_encrypted_data_key" + + # HYOK Configurations testing + "TestHYOKConfigurationCreateRevokeDelete:AWS_with_valid_options" + "TestHYOKConfigurationCreateRevokeDelete:AWS_with_missing_key_region" + "TestHYOKConfigurationCreateRevokeDelete:GCP_with_valid_options" + "TestHYOKConfigurationCreateRevokeDelete:GCP_with_missing_key_location" + "TestHYOKConfigurationCreateRevokeDelete:GCP_with_missing_key_ring_ID" + "TestHYOKConfigurationCreateRevokeDelete:Vault_with_valid_options" + "TestHYOKConfigurationCreateRevokeDelete:Azure_with_valid_options" + "TestHYOKConfigurationCreateRevokeDelete:with_missing_KEK_ID" + "TestHYOKConfigurationCreateRevokeDelete:with_missing_agent_pool" + "TestHYOKConfigurationCreateRevokeDelete:with_missing_OIDC_config" + "TestHyokConfigurationList:without_list_options" + "TestHyokConfigurationRead:AWS" + "TestHyokConfigurationRead:Azure" + "TestHyokConfigurationRead:GCP" + "TestHyokConfigurationRead:Vault" + "TestHyokConfigurationRead:fetching_non-existing_configuration" + "TestHYOKConfigurationUpdate:AWS_with_valid_options" + "TestHYOKConfigurationUpdate:GCP_with_valid_options" + "TestHYOKConfigurationUpdate:Vault_with_valid_options" + "TestHYOKConfigurationUpdate:Azure_with_valid_options" +) + +for pair in "${pairs[@]}"; do + IFS=':' read -r parent child <<< "$pair" + result=$(envchain ${env} go test -run "^${parent}$/^${child}$" -v ./...) + status="\033[33mUNKNOWN\033[0m" # yellow by default + if echo "$result" | grep -q "^ --- SKIP: ${parent}/${child}"; then + status="\033[33mSKIP\033[0m" # yellow + elif echo "$result" | grep -q "^--- PASS: ${parent}"; then + status="\033[32mPASS\033[0m" # green + elif echo "$result" | grep -q "^--- FAIL: ${parent}"; then + status="\033[31mFAIL\033[0m" # red + fi + echo -e "\033[34m${parent}/${child}\033[0m: ${status}" +done \ No newline at end of file diff --git a/state_version.go b/state_version.go index a90f8a0a0..fdd8c9b57 100644 --- a/state_version.go +++ b/state_version.go @@ -43,6 +43,10 @@ type StateVersions interface { // This is a more resilient form of Create and is the recommended approach to creating state versions. Upload(ctx context.Context, workspaceID string, options StateVersionUploadOptions) (*StateVersion, error) + // UploadSanitizedState uploads a sanitized version of the state to the provided sanitized state upload url. + // The SanitizedStateUploadURL cannot be empty. + UploadSanitizedState(ctx context.Context, sanitizedStateUploadURL string, sanitizedState []byte) error + // Read a state version by its ID. Read(ctx context.Context, svID string) (*StateVersion, error) @@ -89,17 +93,21 @@ 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"` - 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"` - BillableRUMCount *uint32 `jsonapi:"attr,billable-rum-count"` + 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"` + BillableRUMCount *uint32 `jsonapi:"attr,billable-rum-count"` + EncryptedStateDownloadURL string `jsonapi:"attr,encrypted-state-download-url,omitempty"` + SanitizedStateUploadURL string `jsonapi:"attr,sanitized-state-upload-url,omitempty"` + SanitizedStateDownloadURL string `jsonapi:"attr,sanitized-state-download-url,omitempty"` + // Whether HCP Terraform 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. @@ -115,8 +123,9 @@ type StateVersion struct { Resources []*StateVersionResources `jsonapi:"attr,resources"` // Relations - Run *Run `jsonapi:"relation,run"` - Outputs []*StateVersionOutput `jsonapi:"relation,outputs"` + Run *Run `jsonapi:"relation,run"` + Outputs []*StateVersionOutput `jsonapi:"relation,outputs"` + HYOKEncryptedDataKey *HYOKEncryptedDataKey `jsonapi:"relation,hyok-encrypted-data-key,omitempty"` } // StateVersionOutputsList represents a list of StateVersionOutput items. @@ -313,6 +322,16 @@ func (s *stateVersions) Upload(ctx context.Context, workspaceID string, options return s.Read(ctx, sv.ID) } +// UploadSanitizedState uploads a sanitized version of the state to the provided sanitized state upload url. +// The SanitizedStateUploadURL cannot be empty. +func (s *stateVersions) UploadSanitizedState(ctx context.Context, sanitizedStateUploadURL string, sanitizedState []byte) error { + if sanitizedStateUploadURL == "" { + return ErrSanitizedStateUploadURLMissing + } + + return s.client.doForeignPUTRequest(ctx, sanitizedStateUploadURL, bytes.NewReader(sanitizedState)) +} + // Read a state version by its ID. func (s *stateVersions) ReadWithOptions(ctx context.Context, svID string, options *StateVersionReadOptions) (*StateVersion, error) { if !validStringID(&svID) { diff --git a/state_version_integration_test.go b/state_version_integration_test.go index d740abe9d..a56f077da 100644 --- a/state_version_integration_test.go +++ b/state_version_integration_test.go @@ -187,6 +187,79 @@ func TestStateVersionsUpload(t *testing.T) { }) require.ErrorIs(t, err, ErrRequiredRawState) }) + + t.Run("uploading state using SanitizedStateUploadURL and verifying SanitizedStateDownloadURL exists", func(t *testing.T) { + skipHYOKIntegrationTests(t) + + hyokOrganizationName := os.Getenv("HYOK_ORGANIZATION_NAME") + if hyokOrganizationName == "" { + t.Fatal("Export a valid HYOK_ORGANIZATION_NAME before running this test!") + } + + hyokWorkspaceName := os.Getenv("HYOK_WORKSPACE_NAME") + if hyokWorkspaceName == "" { + t.Fatal("Export a valid HYOK_WORKSPACE_NAME before running this test!") + } + + w, err := client.Workspaces.Read(context.Background(), hyokOrganizationName, hyokWorkspaceName) + if err != nil { + t.Fatal(err) + } + + ctx := context.Background() + _, err = client.Workspaces.Lock(ctx, w.ID, WorkspaceLockOptions{}) + if err != nil { + t.Fatal(err) + } + + sv, err := client.StateVersions.Create(ctx, w.ID, StateVersionCreateOptions{ + Lineage: String("741c4949-60b9-5bb1-5bf8-b14f4bb14af3"), + MD5: String(fmt.Sprintf("%x", md5.Sum(state))), + Serial: Int64(1), + }) + require.NoError(t, err) + + err = client.StateVersions.UploadSanitizedState(ctx, sv.SanitizedStateUploadURL, jsonState) + require.NoError(t, err) + + // Get a refreshed view of the configuration version. + sv, err = client.StateVersions.Read(ctx, sv.ID) + require.NoError(t, err) + + assert.NotEmpty(t, sv.SanitizedStateDownloadURL) + assert.Empty(t, sv.SanitizedStateUploadURL) + + _, err = client.Workspaces.ForceUnlock(ctx, w.ID) + if err != nil { + t.Fatal(err) + } + }) + + t.Run("SanitizedStateUploadURL is required when uploading sanitized state", func(t *testing.T) { + skipHYOKIntegrationTests(t) + + ctx := context.Background() + _, err := client.Workspaces.Lock(ctx, wTest.ID, WorkspaceLockOptions{}) + if err != nil { + t.Fatal(err) + } + + sv, 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) + + err = client.StateVersions.UploadSanitizedState(ctx, sv.SanitizedStateUploadURL, state) + require.Error(t, err, ErrSanitizedStateUploadURLMissing) + + // 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) + } + }) } func TestStateVersionsCreate(t *testing.T) { @@ -465,6 +538,45 @@ func TestStateVersionsRead(t *testing.T) { assert.Nil(t, sv) assert.Equal(t, err, ErrInvalidStateVerID) }) + + t.Run("read encrypted state download url of a state version", func(t *testing.T) { + skipHYOKIntegrationTests(t) + + hyokStateVersionID := os.Getenv("HYOK_STATE_VERSION_ID") + if hyokStateVersionID == "" { + t.Fatal("Export a valid HYOK_STATE_VERSION_ID before running this test!") + } + + sv, err := client.StateVersions.Read(ctx, hyokStateVersionID) + require.NoError(t, err) + assert.NotEmpty(t, sv.EncryptedStateDownloadURL) + }) + + t.Run("read sanitized state download url of a state version", func(t *testing.T) { + skipHYOKIntegrationTests(t) + + hyokStateVersionID := os.Getenv("HYOK_STATE_VERSION_ID") + if hyokStateVersionID == "" { + t.Fatal("Export a valid HYOK_STATE_VERSION_ID before running this test!") + } + + sv, err := client.StateVersions.Read(ctx, hyokStateVersionID) + require.NoError(t, err) + assert.NotEmpty(t, sv.SanitizedStateDownloadURL) + }) + + t.Run("read hyok encrypted data key of a state version", func(t *testing.T) { + skipHYOKIntegrationTests(t) + + hyokStateVersionID := os.Getenv("HYOK_STATE_VERSION_ID") + if hyokStateVersionID == "" { + t.Fatal("Export a valid HYOK_STATE_VERSION_ID before running this test!") + } + + sv, err := client.StateVersions.Read(ctx, hyokStateVersionID) + require.NoError(t, err) + assert.NotEmpty(t, sv.HYOKEncryptedDataKey) + }) } func TestStateVersionsReadWithOptions(t *testing.T) { diff --git a/vault_oidc_configuration_integration_test.go b/vault_oidc_configuration_integration_test.go index 700c122d4..9e8d061bb 100644 --- a/vault_oidc_configuration_integration_test.go +++ b/vault_oidc_configuration_integration_test.go @@ -2,6 +2,7 @@ package tfe import ( "context" + "os" "testing" "github.com/stretchr/testify/assert" @@ -12,13 +13,17 @@ import ( // To run them locally, follow the instructions outlined in hyok_configuration_integration_test.go func TestVaultOIDCConfigurationCreateDelete(t *testing.T) { - if skipHYOKIntegrationTests { - t.Skip() - } + skipHYOKIntegrationTests(t) client := testClient(t) ctx := context.Background() + // replace the environment variable with a valid organization name that has Vault OIDC HYOK configurations + hyokOrganizationName := os.Getenv("HYOK_ORGANIZATION_NAME") + if hyokOrganizationName == "" { + t.Fatal("Export a valid HYOK_ORGANIZATION_NAME before running this test!") + } + orgTest, err := client.Organizations.Read(ctx, hyokOrganizationName) if err != nil { t.Fatal(err) @@ -72,13 +77,17 @@ func TestVaultOIDCConfigurationCreateDelete(t *testing.T) { } func TestVaultOIDCConfigurationRead(t *testing.T) { - if skipHYOKIntegrationTests { - t.Skip() - } + skipHYOKIntegrationTests(t) client := testClient(t) ctx := context.Background() + // replace the environment variable with a valid organization name that has Vault OIDC HYOK configurations + hyokOrganizationName := os.Getenv("HYOK_ORGANIZATION_NAME") + if hyokOrganizationName == "" { + t.Fatal("Export a valid HYOK_ORGANIZATION_NAME before running this test!") + } + orgTest, err := client.Organizations.Read(ctx, hyokOrganizationName) if err != nil { t.Fatal(err) @@ -100,13 +109,17 @@ func TestVaultOIDCConfigurationRead(t *testing.T) { } func TestVaultOIDCConfigurationUpdate(t *testing.T) { - if skipHYOKIntegrationTests { - t.Skip() - } + skipHYOKIntegrationTests(t) client := testClient(t) ctx := context.Background() + // replace the environment variable with a valid organization name that has Vault OIDC HYOK configurations + hyokOrganizationName := os.Getenv("HYOK_ORGANIZATION_NAME") + if hyokOrganizationName == "" { + t.Fatal("Export a valid HYOK_ORGANIZATION_NAME before running this test!") + } + orgTest, err := client.Organizations.Read(ctx, hyokOrganizationName) if err != nil { t.Fatal(err) diff --git a/workspace.go b/workspace.go index 7d0990bb9..7e0629e13 100644 --- a/workspace.go +++ b/workspace.go @@ -226,6 +226,7 @@ type Workspace struct { RunsCount int `jsonapi:"attr,workspace-kpis-runs-count"` TagNames []string `jsonapi:"attr,tag-names"` SettingOverwrites *WorkspaceSettingOverwrites `jsonapi:"attr,setting-overwrites"` + HYOKEnabled *bool `jsonapi:"attr,hyok-enabled"` // Relations AgentPool *AgentPool `jsonapi:"relation,agent-pool"` @@ -241,6 +242,7 @@ type Workspace struct { Variables []*Variable `jsonapi:"relation,vars"` TagBindings []*TagBinding `jsonapi:"relation,tag-bindings"` EffectiveTagBindings []*EffectiveTagBinding `jsonapi:"relation,effective-tag-bindings"` + HYOKEncryptedDataKey *HYOKEncryptedDataKey `jsonapi:"relation,hyok-data-key-for-encryption,omitempty"` // Deprecated: Use DataRetentionPolicyChoice instead. DataRetentionPolicy *DataRetentionPolicy @@ -306,6 +308,7 @@ type WorkspacePermissions struct { CanForceUnlock bool `jsonapi:"attr,can-force-unlock"` CanLock bool `jsonapi:"attr,can-lock"` CanManageRunTasks bool `jsonapi:"attr,can-manage-run-tasks"` + CanManageHYOK bool `jsonapi:"attr,can-manage-hyok"` CanQueueApply bool `jsonapi:"attr,can-queue-apply"` CanQueueDestroy bool `jsonapi:"attr,can-queue-destroy"` CanQueueRun bool `jsonapi:"attr,can-queue-run"` @@ -494,6 +497,13 @@ type WorkspaceCreateOptions struct { // environment when multiple environments exist within the same repository. WorkingDirectory *string `jsonapi:"attr,working-directory,omitempty"` + // Optional: Enables HYOK in the workspace. + // If set to true, the workspace will be created with HYOK enabled. + // If set to false, the workspace will be created with HYOK disabled. + // If not specified, the workspace will be created with HYOK disabled. + // Note: HYOK is only available in HCP Terraform. + HYOKEnabled *bool `jsonapi:"attr,hyok-enabled,omitempty"` + // A list of tags to attach to the workspace. If the tag does not already // exist, it is created and added to the workspace. Tags []*Tag `jsonapi:"relation,tags,omitempty"` @@ -656,6 +666,11 @@ type WorkspaceUpdateOptions struct { // setting at the same time. SettingOverwrites *WorkspaceSettingOverwritesOptions `jsonapi:"attr,setting-overwrites,omitempty"` + // Optional: Enables HYOK in the workspace. + // If set to true, the workspace will be updated with HYOK enabled. + // This can't be set to false, as HYOK is a one-way operation. + HYOKEnabled *bool `jsonapi:"attr,hyok-enabled,omitempty"` + // Associated Project with the workspace. If not provided, default project // of the organization will be assigned to the workspace Project *Project `jsonapi:"relation,project,omitempty"` diff --git a/workspace_integration_test.go b/workspace_integration_test.go index ad18518f0..08a35e078 100644 --- a/workspace_integration_test.go +++ b/workspace_integration_test.go @@ -977,6 +977,51 @@ func TestWorkspacesCreate(t *testing.T) { assert.Equal(t, "remote", w.ExecutionMode) }) }) + + t.Run("create workspace with hyok enabled set to false", func(t *testing.T) { + skipHYOKIntegrationTests(t) + + // replace the environment variable with a valid organization name that has HYOK permissions + hyokOrganizationName := os.Getenv("HYOK_ORGANIZATION_NAME") + if hyokOrganizationName == "" { + t.Fatal("Export a valid HYOK_ORGANIZATION_NAME before running this test!") + } + workspaceCreateOptions := WorkspaceCreateOptions{ + Name: String("go-tfe-test-hyok-enabled-false"), + HYOKEnabled: Bool(false), + } + + w, err := client.Workspaces.Create(ctx, hyokOrganizationName, workspaceCreateOptions) + require.NoError(t, err) + assert.NotNil(t, w.HYOKEnabled) + assert.False(t, *w.HYOKEnabled) + + err = client.Workspaces.Delete(ctx, hyokOrganizationName, *workspaceCreateOptions.Name) + require.NoError(t, err) + }) + + t.Run("create workspace with hyok enabled set to true", func(t *testing.T) { + skipHYOKIntegrationTests(t) + + // replace the environment variable with a valid organization name that has HYOK permissions + hyokOrganizationName := os.Getenv("HYOK_ORGANIZATION_NAME") + if hyokOrganizationName == "" { + t.Fatal("Export a valid HYOK_ORGANIZATION_NAME before running this test!") + } + + workspaceCreateOptions := WorkspaceCreateOptions{ + Name: String("go-tfe-test-hyok-enabled-true"), + HYOKEnabled: Bool(true), + } + + w, err := client.Workspaces.Create(ctx, hyokOrganizationName, workspaceCreateOptions) + require.NoError(t, err) + assert.NotNil(t, w.HYOKEnabled) + assert.True(t, *w.HYOKEnabled) + + err = client.Workspaces.Delete(ctx, hyokOrganizationName, *workspaceCreateOptions.Name) + require.NoError(t, err) + }) } func TestWorkspacesRead(t *testing.T) { @@ -1062,6 +1107,46 @@ func TestWorkspacesRead(t *testing.T) { assert.Equal(t, false, *w.SettingOverwrites.ExecutionMode) }) }) + + t.Run("read hyok enabled of a workspace", func(t *testing.T) { + skipHYOKIntegrationTests(t) + + // replace the environment variable with a valid organization name that has HYOK permissions + hyokOrganizationName := os.Getenv("HYOK_ORGANIZATION_NAME") + if hyokOrganizationName == "" { + t.Fatal("Export a valid HYOK_ORGANIZATION_NAME before running this test!") + } + + // replace the environment variable with a valid workspace name that has hyok enabled set to true or false + hyokWorkspaceName := os.Getenv("HYOK_WORKSPACE_NAME") + if hyokWorkspaceName == "" { + t.Fatal("Export a valid HYOK_WORKSPACE_NAME before running this test!") + } + + w, err := client.Workspaces.Read(ctx, hyokOrganizationName, hyokWorkspaceName) + require.NoError(t, err) + assert.NotNil(t, w.HYOKEnabled) + }) + + t.Run("read hyok encrypted data key of a workspace", func(t *testing.T) { + skipHYOKIntegrationTests(t) + + // replace the environment variable with a valid organization name that has HYOK permissions + hyokOrganizationName := os.Getenv("HYOK_ORGANIZATION_NAME") + if hyokOrganizationName == "" { + t.Fatal("Export a valid HYOK_ORGANIZATION_NAME before running this test!") + } + + // replace the environment variable with a valid workspace name that has hyok encrypted data key + hyokWorkspaceName := os.Getenv("HYOK_WORKSPACE_NAME") + if hyokWorkspaceName == "" { + t.Fatal("Export a valid HYOK_WORKSPACE_NAME before running this test!") + } + + w, err := client.Workspaces.Read(ctx, hyokOrganizationName, hyokWorkspaceName) + require.NoError(t, err) + assert.NotEmpty(t, w.HYOKEncryptedDataKey) + }) } func TestWorkspacesReadSource(t *testing.T) { @@ -1601,6 +1686,133 @@ func TestWorkspacesUpdate(t *testing.T) { assert.Equal(t, options.TriggerPatterns, item.TriggerPatterns) } }) + + t.Run("update hyok enabled of a workspace from false to false", func(t *testing.T) { + skipHYOKIntegrationTests(t) + + // replace the environment variable with a valid organization name that has HYOK permissions + hyokOrganizationName := os.Getenv("HYOK_ORGANIZATION_NAME") + if hyokOrganizationName == "" { + t.Fatal("Export a valid HYOK_ORGANIZATION_NAME before running this test!") + } + + workspaceCreateOptions := WorkspaceCreateOptions{ + Name: String("go-tfe-test-hyok-enabled-false"), + HYOKEnabled: Bool(false), + } + + w, err := client.Workspaces.Create(ctx, hyokOrganizationName, workspaceCreateOptions) + require.NoError(t, err) + assert.NotNil(t, w.HYOKEnabled) + assert.False(t, *w.HYOKEnabled) + + workspaceUpdateOptions := WorkspaceUpdateOptions{ + HYOKEnabled: Bool(false), + } + + w, err = client.Workspaces.Update(ctx, hyokOrganizationName, w.Name, workspaceUpdateOptions) + require.NoError(t, err) + assert.NotNil(t, w.HYOKEnabled) + assert.False(t, *w.HYOKEnabled) + + err = client.Workspaces.Delete(ctx, hyokOrganizationName, *workspaceCreateOptions.Name) + require.NoError(t, err) + }) + + t.Run("update hyok enabled of a workspace from false to true", func(t *testing.T) { + skipHYOKIntegrationTests(t) + + // replace the environment variable with a valid organization name that has HYOK permissions + hyokOrganizationName := os.Getenv("HYOK_ORGANIZATION_NAME") + if hyokOrganizationName == "" { + t.Fatal("Export a valid HYOK_ORGANIZATION_NAME before running this test!") + } + + workspaceCreateOptions := WorkspaceCreateOptions{ + Name: String("go-tfe-test-hyok-enabled-false"), + HYOKEnabled: Bool(false), + } + + w, err := client.Workspaces.Create(ctx, hyokOrganizationName, workspaceCreateOptions) + require.NoError(t, err) + assert.NotNil(t, w.HYOKEnabled) + assert.False(t, *w.HYOKEnabled) + + workspaceUpdateOptions := WorkspaceUpdateOptions{ + HYOKEnabled: Bool(true), + } + + w, err = client.Workspaces.Update(ctx, hyokOrganizationName, w.Name, workspaceUpdateOptions) + require.NoError(t, err) + assert.NotNil(t, w.HYOKEnabled) + assert.True(t, *w.HYOKEnabled) + + err = client.Workspaces.Delete(ctx, hyokOrganizationName, *workspaceCreateOptions.Name) + require.NoError(t, err) + }) + + t.Run("update hyok enabled of a workspace from true to true", func(t *testing.T) { + skipHYOKIntegrationTests(t) + + // replace the environment variable with a valid organization name that has HYOK permissions + hyokOrganizationName := os.Getenv("HYOK_ORGANIZATION_NAME") + if hyokOrganizationName == "" { + t.Fatal("Export a valid HYOK_ORGANIZATION_NAME before running this test!") + } + + workspaceCreateOptions := WorkspaceCreateOptions{ + Name: String("go-tfe-test-hyok-enabled-true"), + HYOKEnabled: Bool(true), + } + + w, err := client.Workspaces.Create(ctx, hyokOrganizationName, workspaceCreateOptions) + require.NoError(t, err) + assert.NotNil(t, w.HYOKEnabled) + assert.True(t, *w.HYOKEnabled) + + workspaceUpdateOptions := WorkspaceUpdateOptions{ + HYOKEnabled: Bool(true), + } + + w, err = client.Workspaces.Update(ctx, hyokOrganizationName, w.Name, workspaceUpdateOptions) + require.NoError(t, err) + assert.NotNil(t, w.HYOKEnabled) + assert.True(t, *w.HYOKEnabled) + + err = client.Workspaces.Delete(ctx, hyokOrganizationName, *workspaceCreateOptions.Name) + require.NoError(t, err) + }) + + t.Run("update hyok enabled of a workspace from true to false", func(t *testing.T) { + skipHYOKIntegrationTests(t) + + // replace the environment variable with a valid organization name that has HYOK permissions + hyokOrganizationName := os.Getenv("HYOK_ORGANIZATION_NAME") + if hyokOrganizationName == "" { + t.Fatal("Export a valid HYOK_ORGANIZATION_NAME before running this test!") + } + + workspaceCreateOptions := WorkspaceCreateOptions{ + Name: String("go-tfe-test-hyok-enabled-true"), + HYOKEnabled: Bool(true), + } + + w, err := client.Workspaces.Create(ctx, hyokOrganizationName, workspaceCreateOptions) + require.NoError(t, err) + assert.NotNil(t, w.HYOKEnabled) + assert.True(t, *w.HYOKEnabled) + + workspaceUpdateOptions := WorkspaceUpdateOptions{ + HYOKEnabled: Bool(false), + } + + _, err = client.Workspaces.Update(ctx, hyokOrganizationName, w.Name, workspaceUpdateOptions) + require.Error(t, err) + assert.EqualError(t, err, ErrHYOKCannotBeDisabled.Error()) + + err = client.Workspaces.Delete(ctx, hyokOrganizationName, *workspaceCreateOptions.Name) + require.NoError(t, err) + }) } func TestWorkspacesUpdateTableDriven(t *testing.T) {