diff --git a/internal/acceptance_tests/anthropic_integration_data_source_test.go b/internal/acceptance_tests/anthropic_integration_data_source_test.go new file mode 100644 index 0000000..b1d77d5 --- /dev/null +++ b/internal/acceptance_tests/anthropic_integration_data_source_test.go @@ -0,0 +1,34 @@ +// Copyright Stacklet, Inc. 2025, 2026 + +package acceptance_tests + +import ( + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" +) + +func TestAccAnthropicIntegrationDataSource(t *testing.T) { + steps := []resource.TestStep{ + { + Config: ` + resource "stacklet_anthropic_integration" "test" { + org_id = "` + testAnthropicOrgID + `" + + admin_key_wo = "` + testAnthropicAdminKey + `" + admin_key_wo_version = "1" + } + + data "stacklet_anthropic_integration" "test" { + org_id = stacklet_anthropic_integration.test.org_id + } + `, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrPair("data.stacklet_anthropic_integration.test", "id", "stacklet_anthropic_integration.test", "id"), + resource.TestCheckResourceAttr("data.stacklet_anthropic_integration.test", "org_id", testAnthropicOrgID), + resource.TestCheckResourceAttr("data.stacklet_anthropic_integration.test", "configured", "true"), + ), + }, + } + runRecordedAccTest(t, "TestAccAnthropicIntegrationDataSource", steps) +} diff --git a/internal/acceptance_tests/anthropic_integration_resource_test.go b/internal/acceptance_tests/anthropic_integration_resource_test.go new file mode 100644 index 0000000..2ac034e --- /dev/null +++ b/internal/acceptance_tests/anthropic_integration_resource_test.go @@ -0,0 +1,163 @@ +// Copyright Stacklet, Inc. 2025, 2026 + +package acceptance_tests + +import ( + "regexp" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" +) + +// The recordings come from the platform's local development stack, whose fake +// Anthropic Admin API accepts these keys. testAnthropicAdminKey belongs to +// testAnthropicOrgID, and otherAnthropicAdminKey to another organization. +const ( + testAnthropicOrgID = "org-functest" + testAnthropicAdminKey = "sk-ant-admin01-fake-test" + otherAnthropicAdminKey = "sk-ant-admin01-fake" +) + +func TestAccAnthropicIntegrationResource(t *testing.T) { + steps := []resource.TestStep{ + // Create and Read testing + { + Config: ` + resource "stacklet_anthropic_integration" "test" { + org_id = "` + testAnthropicOrgID + `" + + admin_key_wo = "` + testAnthropicAdminKey + `" + admin_key_wo_version = "1" + } + `, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet("stacklet_anthropic_integration.test", "id"), + resource.TestCheckResourceAttr("stacklet_anthropic_integration.test", "org_id", testAnthropicOrgID), + resource.TestCheckResourceAttr("stacklet_anthropic_integration.test", "configured", "true"), + resource.TestCheckResourceAttr("stacklet_anthropic_integration.test", "admin_key_wo_version", "1"), + // The key is write-only, so it isn't in state. + resource.TestCheckNoResourceAttr("stacklet_anthropic_integration.test", "admin_key_wo"), + ), + }, + // ImportState testing + { + ResourceName: "stacklet_anthropic_integration.test", + ImportState: true, + ImportStateId: testAnthropicOrgID, + ImportStateVerify: true, + ImportStateVerifyIdentifierAttribute: "org_id", + ImportStateVerifyIgnore: []string{ + // The key is never returned, so an import can't know which + // version the stored one corresponds to. + "admin_key_wo_version", + }, + }, + } + runRecordedAccTest(t, "TestAccAnthropicIntegrationResource", steps) +} + +// TestAccAnthropicIntegrationResource_KeyRotation checks that bumping the +// version sends the key again, which the API checks with Anthropic before it +// replaces the stored one. +func TestAccAnthropicIntegrationResource_KeyRotation(t *testing.T) { + steps := []resource.TestStep{ + { + Config: ` + resource "stacklet_anthropic_integration" "test" { + org_id = "` + testAnthropicOrgID + `" + + admin_key_wo = "` + testAnthropicAdminKey + `" + admin_key_wo_version = "1" + } + `, + Check: resource.TestCheckResourceAttr("stacklet_anthropic_integration.test", "configured", "true"), + }, + { + Config: ` + resource "stacklet_anthropic_integration" "test" { + org_id = "` + testAnthropicOrgID + `" + + admin_key_wo = "` + testAnthropicAdminKey + `" + admin_key_wo_version = "2" + } + `, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("stacklet_anthropic_integration.test", "admin_key_wo_version", "2"), + resource.TestCheckResourceAttr("stacklet_anthropic_integration.test", "configured", "true"), + ), + }, + } + runRecordedAccTest(t, "TestAccAnthropicIntegrationResource_KeyRotation", steps) +} + +// TestAccAnthropicIntegrationResource_RejectedKey checks that a key the API +// rejects when it checks it with Anthropic fails the apply with the reason. +func TestAccAnthropicIntegrationResource_RejectedKey(t *testing.T) { + steps := []resource.TestStep{ + // A key that doesn't authenticate. + { + Config: ` + resource "stacklet_anthropic_integration" "test" { + org_id = "` + testAnthropicOrgID + `" + + admin_key_wo = "sk-ant-admin01-invalid" + admin_key_wo_version = "1" + } + `, + ExpectError: regexp.MustCompile(`Failed to connect to Anthropic`), + }, + // Another organization's key. + { + Config: ` + resource "stacklet_anthropic_integration" "test" { + org_id = "` + testAnthropicOrgID + `" + + admin_key_wo = "` + otherAnthropicAdminKey + `" + admin_key_wo_version = "1" + } + `, + ExpectError: regexp.MustCompile(`The admin key belongs to Anthropic organization`), + }, + { + Config: ` + resource "stacklet_anthropic_integration" "test" { + org_id = "` + testAnthropicOrgID + `" + + admin_key_wo = "` + testAnthropicAdminKey + `" + admin_key_wo_version = "1" + } + `, + Check: resource.TestCheckResourceAttr("stacklet_anthropic_integration.test", "configured", "true"), + }, + // A rotation to a bad key fails too, and leaves the stored key alone. + { + Config: ` + resource "stacklet_anthropic_integration" "test" { + org_id = "` + testAnthropicOrgID + `" + + admin_key_wo = "sk-ant-admin01-invalid" + admin_key_wo_version = "2" + } + `, + ExpectError: regexp.MustCompile(`Failed to connect to Anthropic`), + }, + // The failed rotation left state at the first version, and the API + // still holds the first key. + { + Config: ` + resource "stacklet_anthropic_integration" "test" { + org_id = "` + testAnthropicOrgID + `" + + admin_key_wo = "` + testAnthropicAdminKey + `" + admin_key_wo_version = "1" + } + `, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("stacklet_anthropic_integration.test", "org_id", testAnthropicOrgID), + resource.TestCheckResourceAttr("stacklet_anthropic_integration.test", "configured", "true"), + resource.TestCheckResourceAttr("stacklet_anthropic_integration.test", "admin_key_wo_version", "1"), + ), + }, + } + runRecordedAccTest(t, "TestAccAnthropicIntegrationResource_RejectedKey", steps) +} diff --git a/internal/acceptance_tests/recordings/TestAccAnthropicIntegrationDataSource.json b/internal/acceptance_tests/recordings/TestAccAnthropicIntegrationDataSource.json new file mode 100644 index 0000000..0199145 --- /dev/null +++ b/internal/acceptance_tests/recordings/TestAccAnthropicIntegrationDataSource.json @@ -0,0 +1,128 @@ +{ + "mutation ($input:RemoveAnthropicIntegrationInput!){removeAnthropicIntegration(input: $input){problems{__typename,message}}}:{\"input\":{\"orgId\":\"org-functest\"}}": [ + { + "request": { + "query": "mutation ($input:RemoveAnthropicIntegrationInput!){removeAnthropicIntegration(input: $input){problems{__typename,message}}}", + "variables": { + "input": { + "orgId": "org-functest" + } + } + }, + "response": { + "data": { + "removeAnthropicIntegration": { + "problems": [] + } + } + } + } + ], + "mutation ($input:UpsertAnthropicIntegrationInput!){upsertAnthropicIntegration(input: $input){anthropicIntegration{id,orgId,configured},problems{__typename,message}}}:{\"input\":{\"adminKey\":\"sk-ant-admin01-fake-test\",\"orgId\":\"org-functest\"}}": [ + { + "request": { + "query": "mutation ($input:UpsertAnthropicIntegrationInput!){upsertAnthropicIntegration(input: $input){anthropicIntegration{id,orgId,configured},problems{__typename,message}}}", + "variables": { + "input": { + "adminKey": "sk-ant-admin01-fake-test", + "orgId": "org-functest" + } + } + }, + "response": { + "data": { + "upsertAnthropicIntegration": { + "anthropicIntegration": { + "configured": true, + "id": "WyJhbnRocm9waWMtaW50ZWdyYXRpb24iLCAib3JnLWZ1bmN0ZXN0Il0=", + "orgId": "org-functest" + }, + "problems": [] + } + } + } + } + ], + "query ($orgId:String!){anthropicIntegration(orgId: $orgId){anthropicIntegration{id,orgId,configured},problems{__typename,message}}}:{\"orgId\":\"org-functest\"}": [ + { + "request": { + "query": "query ($orgId:String!){anthropicIntegration(orgId: $orgId){anthropicIntegration{id,orgId,configured},problems{__typename,message}}}", + "variables": { + "orgId": "org-functest" + } + }, + "response": { + "data": { + "anthropicIntegration": { + "anthropicIntegration": { + "configured": true, + "id": "WyJhbnRocm9waWMtaW50ZWdyYXRpb24iLCAib3JnLWZ1bmN0ZXN0Il0=", + "orgId": "org-functest" + }, + "problems": [] + } + } + } + }, + { + "request": { + "query": "query ($orgId:String!){anthropicIntegration(orgId: $orgId){anthropicIntegration{id,orgId,configured},problems{__typename,message}}}", + "variables": { + "orgId": "org-functest" + } + }, + "response": { + "data": { + "anthropicIntegration": { + "anthropicIntegration": { + "configured": true, + "id": "WyJhbnRocm9waWMtaW50ZWdyYXRpb24iLCAib3JnLWZ1bmN0ZXN0Il0=", + "orgId": "org-functest" + }, + "problems": [] + } + } + } + }, + { + "request": { + "query": "query ($orgId:String!){anthropicIntegration(orgId: $orgId){anthropicIntegration{id,orgId,configured},problems{__typename,message}}}", + "variables": { + "orgId": "org-functest" + } + }, + "response": { + "data": { + "anthropicIntegration": { + "anthropicIntegration": { + "configured": true, + "id": "WyJhbnRocm9waWMtaW50ZWdyYXRpb24iLCAib3JnLWZ1bmN0ZXN0Il0=", + "orgId": "org-functest" + }, + "problems": [] + } + } + } + }, + { + "request": { + "query": "query ($orgId:String!){anthropicIntegration(orgId: $orgId){anthropicIntegration{id,orgId,configured},problems{__typename,message}}}", + "variables": { + "orgId": "org-functest" + } + }, + "response": { + "data": { + "anthropicIntegration": { + "anthropicIntegration": { + "configured": true, + "id": "WyJhbnRocm9waWMtaW50ZWdyYXRpb24iLCAib3JnLWZ1bmN0ZXN0Il0=", + "orgId": "org-functest" + }, + "problems": [] + } + } + } + } + ] +} diff --git a/internal/acceptance_tests/recordings/TestAccAnthropicIntegrationResource.json b/internal/acceptance_tests/recordings/TestAccAnthropicIntegrationResource.json new file mode 100644 index 0000000..7bfb16f --- /dev/null +++ b/internal/acceptance_tests/recordings/TestAccAnthropicIntegrationResource.json @@ -0,0 +1,88 @@ +{ + "mutation ($input:RemoveAnthropicIntegrationInput!){removeAnthropicIntegration(input: $input){problems{__typename,message}}}:{\"input\":{\"orgId\":\"org-functest\"}}": [ + { + "request": { + "query": "mutation ($input:RemoveAnthropicIntegrationInput!){removeAnthropicIntegration(input: $input){problems{__typename,message}}}", + "variables": { + "input": { + "orgId": "org-functest" + } + } + }, + "response": { + "data": { + "removeAnthropicIntegration": { + "problems": [] + } + } + } + } + ], + "mutation ($input:UpsertAnthropicIntegrationInput!){upsertAnthropicIntegration(input: $input){anthropicIntegration{id,orgId,configured},problems{__typename,message}}}:{\"input\":{\"adminKey\":\"sk-ant-admin01-fake-test\",\"orgId\":\"org-functest\"}}": [ + { + "request": { + "query": "mutation ($input:UpsertAnthropicIntegrationInput!){upsertAnthropicIntegration(input: $input){anthropicIntegration{id,orgId,configured},problems{__typename,message}}}", + "variables": { + "input": { + "adminKey": "sk-ant-admin01-fake-test", + "orgId": "org-functest" + } + } + }, + "response": { + "data": { + "upsertAnthropicIntegration": { + "anthropicIntegration": { + "configured": true, + "id": "WyJhbnRocm9waWMtaW50ZWdyYXRpb24iLCAib3JnLWZ1bmN0ZXN0Il0=", + "orgId": "org-functest" + }, + "problems": [] + } + } + } + } + ], + "query ($orgId:String!){anthropicIntegration(orgId: $orgId){anthropicIntegration{id,orgId,configured},problems{__typename,message}}}:{\"orgId\":\"org-functest\"}": [ + { + "request": { + "query": "query ($orgId:String!){anthropicIntegration(orgId: $orgId){anthropicIntegration{id,orgId,configured},problems{__typename,message}}}", + "variables": { + "orgId": "org-functest" + } + }, + "response": { + "data": { + "anthropicIntegration": { + "anthropicIntegration": { + "configured": true, + "id": "WyJhbnRocm9waWMtaW50ZWdyYXRpb24iLCAib3JnLWZ1bmN0ZXN0Il0=", + "orgId": "org-functest" + }, + "problems": [] + } + } + } + }, + { + "request": { + "query": "query ($orgId:String!){anthropicIntegration(orgId: $orgId){anthropicIntegration{id,orgId,configured},problems{__typename,message}}}", + "variables": { + "orgId": "org-functest" + } + }, + "response": { + "data": { + "anthropicIntegration": { + "anthropicIntegration": { + "configured": true, + "id": "WyJhbnRocm9waWMtaW50ZWdyYXRpb24iLCAib3JnLWZ1bmN0ZXN0Il0=", + "orgId": "org-functest" + }, + "problems": [] + } + } + } + } + ] +} diff --git a/internal/acceptance_tests/recordings/TestAccAnthropicIntegrationResource_KeyRotation.json b/internal/acceptance_tests/recordings/TestAccAnthropicIntegrationResource_KeyRotation.json new file mode 100644 index 0000000..c76d975 --- /dev/null +++ b/internal/acceptance_tests/recordings/TestAccAnthropicIntegrationResource_KeyRotation.json @@ -0,0 +1,131 @@ +{ + "mutation ($input:RemoveAnthropicIntegrationInput!){removeAnthropicIntegration(input: $input){problems{__typename,message}}}:{\"input\":{\"orgId\":\"org-functest\"}}": [ + { + "request": { + "query": "mutation ($input:RemoveAnthropicIntegrationInput!){removeAnthropicIntegration(input: $input){problems{__typename,message}}}", + "variables": { + "input": { + "orgId": "org-functest" + } + } + }, + "response": { + "data": { + "removeAnthropicIntegration": { + "problems": [] + } + } + } + } + ], + "mutation ($input:UpsertAnthropicIntegrationInput!){upsertAnthropicIntegration(input: $input){anthropicIntegration{id,orgId,configured},problems{__typename,message}}}:{\"input\":{\"adminKey\":\"sk-ant-admin01-fake-test\",\"orgId\":\"org-functest\"}}": [ + { + "request": { + "query": "mutation ($input:UpsertAnthropicIntegrationInput!){upsertAnthropicIntegration(input: $input){anthropicIntegration{id,orgId,configured},problems{__typename,message}}}", + "variables": { + "input": { + "adminKey": "sk-ant-admin01-fake-test", + "orgId": "org-functest" + } + } + }, + "response": { + "data": { + "upsertAnthropicIntegration": { + "anthropicIntegration": { + "configured": true, + "id": "WyJhbnRocm9waWMtaW50ZWdyYXRpb24iLCAib3JnLWZ1bmN0ZXN0Il0=", + "orgId": "org-functest" + }, + "problems": [] + } + } + } + }, + { + "request": { + "query": "mutation ($input:UpsertAnthropicIntegrationInput!){upsertAnthropicIntegration(input: $input){anthropicIntegration{id,orgId,configured},problems{__typename,message}}}", + "variables": { + "input": { + "adminKey": "sk-ant-admin01-fake-test", + "orgId": "org-functest" + } + } + }, + "response": { + "data": { + "upsertAnthropicIntegration": { + "anthropicIntegration": { + "configured": true, + "id": "WyJhbnRocm9waWMtaW50ZWdyYXRpb24iLCAib3JnLWZ1bmN0ZXN0Il0=", + "orgId": "org-functest" + }, + "problems": [] + } + } + } + } + ], + "query ($orgId:String!){anthropicIntegration(orgId: $orgId){anthropicIntegration{id,orgId,configured},problems{__typename,message}}}:{\"orgId\":\"org-functest\"}": [ + { + "request": { + "query": "query ($orgId:String!){anthropicIntegration(orgId: $orgId){anthropicIntegration{id,orgId,configured},problems{__typename,message}}}", + "variables": { + "orgId": "org-functest" + } + }, + "response": { + "data": { + "anthropicIntegration": { + "anthropicIntegration": { + "configured": true, + "id": "WyJhbnRocm9waWMtaW50ZWdyYXRpb24iLCAib3JnLWZ1bmN0ZXN0Il0=", + "orgId": "org-functest" + }, + "problems": [] + } + } + } + }, + { + "request": { + "query": "query ($orgId:String!){anthropicIntegration(orgId: $orgId){anthropicIntegration{id,orgId,configured},problems{__typename,message}}}", + "variables": { + "orgId": "org-functest" + } + }, + "response": { + "data": { + "anthropicIntegration": { + "anthropicIntegration": { + "configured": true, + "id": "WyJhbnRocm9waWMtaW50ZWdyYXRpb24iLCAib3JnLWZ1bmN0ZXN0Il0=", + "orgId": "org-functest" + }, + "problems": [] + } + } + } + }, + { + "request": { + "query": "query ($orgId:String!){anthropicIntegration(orgId: $orgId){anthropicIntegration{id,orgId,configured},problems{__typename,message}}}", + "variables": { + "orgId": "org-functest" + } + }, + "response": { + "data": { + "anthropicIntegration": { + "anthropicIntegration": { + "configured": true, + "id": "WyJhbnRocm9waWMtaW50ZWdyYXRpb24iLCAib3JnLWZ1bmN0ZXN0Il0=", + "orgId": "org-functest" + }, + "problems": [] + } + } + } + } + ] +} diff --git a/internal/acceptance_tests/recordings/TestAccAnthropicIntegrationResource_RejectedKey.json b/internal/acceptance_tests/recordings/TestAccAnthropicIntegrationResource_RejectedKey.json new file mode 100644 index 0000000..3622768 --- /dev/null +++ b/internal/acceptance_tests/recordings/TestAccAnthropicIntegrationResource_RejectedKey.json @@ -0,0 +1,204 @@ +{ + "mutation ($input:RemoveAnthropicIntegrationInput!){removeAnthropicIntegration(input: $input){problems{__typename,message}}}:{\"input\":{\"orgId\":\"org-functest\"}}": [ + { + "request": { + "query": "mutation ($input:RemoveAnthropicIntegrationInput!){removeAnthropicIntegration(input: $input){problems{__typename,message}}}", + "variables": { + "input": { + "orgId": "org-functest" + } + } + }, + "response": { + "data": { + "removeAnthropicIntegration": { + "problems": [] + } + } + } + } + ], + "mutation ($input:UpsertAnthropicIntegrationInput!){upsertAnthropicIntegration(input: $input){anthropicIntegration{id,orgId,configured},problems{__typename,message}}}:{\"input\":{\"adminKey\":\"sk-ant-admin01-fake\",\"orgId\":\"org-functest\"}}": [ + { + "request": { + "query": "mutation ($input:UpsertAnthropicIntegrationInput!){upsertAnthropicIntegration(input: $input){anthropicIntegration{id,orgId,configured},problems{__typename,message}}}", + "variables": { + "input": { + "adminKey": "sk-ant-admin01-fake", + "orgId": "org-functest" + } + } + }, + "response": { + "data": { + "upsertAnthropicIntegration": { + "anthropicIntegration": null, + "problems": [ + { + "__typename": "InvalidInput", + "message": "The admin key belongs to Anthropic organization 'Localdev' (org-localdev), not org-functest" + } + ] + } + } + } + } + ], + "mutation ($input:UpsertAnthropicIntegrationInput!){upsertAnthropicIntegration(input: $input){anthropicIntegration{id,orgId,configured},problems{__typename,message}}}:{\"input\":{\"adminKey\":\"sk-ant-admin01-fake-test\",\"orgId\":\"org-functest\"}}": [ + { + "request": { + "query": "mutation ($input:UpsertAnthropicIntegrationInput!){upsertAnthropicIntegration(input: $input){anthropicIntegration{id,orgId,configured},problems{__typename,message}}}", + "variables": { + "input": { + "adminKey": "sk-ant-admin01-fake-test", + "orgId": "org-functest" + } + } + }, + "response": { + "data": { + "upsertAnthropicIntegration": { + "anthropicIntegration": { + "configured": true, + "id": "WyJhbnRocm9waWMtaW50ZWdyYXRpb24iLCAib3JnLWZ1bmN0ZXN0Il0=", + "orgId": "org-functest" + }, + "problems": [] + } + } + } + } + ], + "mutation ($input:UpsertAnthropicIntegrationInput!){upsertAnthropicIntegration(input: $input){anthropicIntegration{id,orgId,configured},problems{__typename,message}}}:{\"input\":{\"adminKey\":\"sk-ant-admin01-invalid\",\"orgId\":\"org-functest\"}}": [ + { + "request": { + "query": "mutation ($input:UpsertAnthropicIntegrationInput!){upsertAnthropicIntegration(input: $input){anthropicIntegration{id,orgId,configured},problems{__typename,message}}}", + "variables": { + "input": { + "adminKey": "sk-ant-admin01-invalid", + "orgId": "org-functest" + } + } + }, + "response": { + "data": { + "upsertAnthropicIntegration": { + "anthropicIntegration": null, + "problems": [ + { + "__typename": "InvalidInput", + "message": "Failed to connect to Anthropic: [401] 401 Client Error: Unauthorized for url: http://127.0.0.1:28783/v1/organizations/me" + } + ] + } + } + } + }, + { + "request": { + "query": "mutation ($input:UpsertAnthropicIntegrationInput!){upsertAnthropicIntegration(input: $input){anthropicIntegration{id,orgId,configured},problems{__typename,message}}}", + "variables": { + "input": { + "adminKey": "sk-ant-admin01-invalid", + "orgId": "org-functest" + } + } + }, + "response": { + "data": { + "upsertAnthropicIntegration": { + "anthropicIntegration": null, + "problems": [ + { + "__typename": "InvalidInput", + "message": "Failed to connect to Anthropic: [401] 401 Client Error: Unauthorized for url: http://127.0.0.1:28783/v1/organizations/me" + } + ] + } + } + } + } + ], + "query ($orgId:String!){anthropicIntegration(orgId: $orgId){anthropicIntegration{id,orgId,configured},problems{__typename,message}}}:{\"orgId\":\"org-functest\"}": [ + { + "request": { + "query": "query ($orgId:String!){anthropicIntegration(orgId: $orgId){anthropicIntegration{id,orgId,configured},problems{__typename,message}}}", + "variables": { + "orgId": "org-functest" + } + }, + "response": { + "data": { + "anthropicIntegration": { + "anthropicIntegration": { + "configured": true, + "id": "WyJhbnRocm9waWMtaW50ZWdyYXRpb24iLCAib3JnLWZ1bmN0ZXN0Il0=", + "orgId": "org-functest" + }, + "problems": [] + } + } + } + }, + { + "request": { + "query": "query ($orgId:String!){anthropicIntegration(orgId: $orgId){anthropicIntegration{id,orgId,configured},problems{__typename,message}}}", + "variables": { + "orgId": "org-functest" + } + }, + "response": { + "data": { + "anthropicIntegration": { + "anthropicIntegration": { + "configured": true, + "id": "WyJhbnRocm9waWMtaW50ZWdyYXRpb24iLCAib3JnLWZ1bmN0ZXN0Il0=", + "orgId": "org-functest" + }, + "problems": [] + } + } + } + }, + { + "request": { + "query": "query ($orgId:String!){anthropicIntegration(orgId: $orgId){anthropicIntegration{id,orgId,configured},problems{__typename,message}}}", + "variables": { + "orgId": "org-functest" + } + }, + "response": { + "data": { + "anthropicIntegration": { + "anthropicIntegration": { + "configured": true, + "id": "WyJhbnRocm9waWMtaW50ZWdyYXRpb24iLCAib3JnLWZ1bmN0ZXN0Il0=", + "orgId": "org-functest" + }, + "problems": [] + } + } + } + }, + { + "request": { + "query": "query ($orgId:String!){anthropicIntegration(orgId: $orgId){anthropicIntegration{id,orgId,configured},problems{__typename,message}}}", + "variables": { + "orgId": "org-functest" + } + }, + "response": { + "data": { + "anthropicIntegration": { + "anthropicIntegration": { + "configured": true, + "id": "WyJhbnRocm9waWMtaW50ZWdyYXRpb24iLCAib3JnLWZ1bmN0ZXN0Il0=", + "orgId": "org-functest" + }, + "problems": [] + } + } + } + } + ] +} diff --git a/internal/api/anthropic_integration.go b/internal/api/anthropic_integration.go new file mode 100644 index 0000000..c48f157 --- /dev/null +++ b/internal/api/anthropic_integration.go @@ -0,0 +1,104 @@ +// Copyright Stacklet, Inc. 2025, 2026 + +package api + +import ( + "context" + + "github.com/hasura/go-graphql-client" +) + +// AnthropicIntegration is the data for an Anthropic organization integration. +// +// The admin key is absent by design: it is write-only, and whether one is +// stored is reported by Configured rather than by a value. +type AnthropicIntegration struct { + ID graphql.ID `graphql:"id"` + OrgID string `graphql:"orgId"` + Configured bool `graphql:"configured"` +} + +// AnthropicIntegrationInput is the input for creating or updating an +// Anthropic integration. +// +// The admin key is omitted when unset: the API then keeps the stored one, +// which is how an existing integration is saved without resupplying a key +// that is never returned. +type AnthropicIntegrationInput struct { + OrgID string `json:"orgId"` + AdminKey *string `json:"adminKey,omitempty"` +} + +func (i AnthropicIntegrationInput) GetGraphQLType() string { + return "UpsertAnthropicIntegrationInput" +} + +type anthropicIntegrationRemoveInput struct { + OrgID string `json:"orgId"` +} + +func (i anthropicIntegrationRemoveInput) GetGraphQLType() string { + return "RemoveAnthropicIntegrationInput" +} + +type anthropicIntegrationAPI struct { + c *client +} + +// Read returns the integration for an Anthropic organization. +func (a anthropicIntegrationAPI) Read(ctx context.Context, orgID string) (*AnthropicIntegration, error) { + var query struct { + Payload struct { + AnthropicIntegration *AnthropicIntegration `graphql:"anthropicIntegration"` + Problems []problem + } `graphql:"anthropicIntegration(orgId: $orgId)"` + } + if err := a.c.Query(ctx, &query, map[string]any{"orgId": graphql.String(orgID)}); err != nil { + return nil, err + } + if err := fromProblems(ctx, query.Payload.Problems); err != nil { + return nil, err + } + if query.Payload.AnthropicIntegration == nil { + return nil, NotFound{"Anthropic integration not found"} + } + return query.Payload.AnthropicIntegration, nil +} + +// Upsert creates or updates the integration for an Anthropic organization. +// A supplied admin key is checked against Anthropic before it is stored, and +// a key that fails the check is reported as a problem. +func (a anthropicIntegrationAPI) Upsert(ctx context.Context, input AnthropicIntegrationInput) (*AnthropicIntegration, error) { + var mutation struct { + Payload struct { + AnthropicIntegration *AnthropicIntegration `graphql:"anthropicIntegration"` + Problems []problem + } `graphql:"upsertAnthropicIntegration(input: $input)"` + } + ctx = maskSecrets(ctx, input.AdminKey) + if err := a.c.Mutate(ctx, &mutation, map[string]any{"input": input}); err != nil { + return nil, err + } + if err := fromProblems(ctx, mutation.Payload.Problems); err != nil { + return nil, err + } + if mutation.Payload.AnthropicIntegration == nil { + return nil, NotFound{"Anthropic integration not found after upsert"} + } + return mutation.Payload.AnthropicIntegration, nil +} + +// Remove deletes the integration for an Anthropic organization, along with +// its stored admin key. +func (a anthropicIntegrationAPI) Remove(ctx context.Context, orgID string) error { + var mutation struct { + Payload struct { + Problems []problem + } `graphql:"removeAnthropicIntegration(input: $input)"` + } + input := anthropicIntegrationRemoveInput{OrgID: orgID} + if err := a.c.Mutate(ctx, &mutation, map[string]any{"input": input}); err != nil { + return err + } + return fromProblems(ctx, mutation.Payload.Problems) +} diff --git a/internal/api/api.go b/internal/api/api.go index 233f121..4c7434e 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -9,6 +9,7 @@ type API struct { AccountDiscovery accountDiscoveryAPI AccountGroup accountGroupAPI AccountGroupMapping accountGroupMappingAPI + AnthropicIntegration anthropicIntegrationAPI Binding bindingAPI ConfigurationProfile configurationProfileAPI DatadogIntegration datadogIntegrationAPI @@ -36,6 +37,7 @@ func New(config ClientConfig) *API { AccountDiscovery: accountDiscoveryAPI{c}, AccountGroup: accountGroupAPI{c}, AccountGroupMapping: accountGroupMappingAPI{c}, + AnthropicIntegration: anthropicIntegrationAPI{c}, Binding: bindingAPI{c}, ConfigurationProfile: configurationProfileAPI{c}, DatadogIntegration: datadogIntegrationAPI{c}, diff --git a/internal/api/error.go b/internal/api/error.go index 5198f97..b2f6ab1 100644 --- a/internal/api/error.go +++ b/internal/api/error.go @@ -50,6 +50,22 @@ func (e NotFound) Error() string { return e.Message } +// Conflict represents an error raised when a change isn't possible, or wasn't +// completed, because of the state of some other part of the system. +type Conflict struct { + Message string +} + +// Summary returns the error summary. +func (e Conflict) Summary() string { + return "Conflict" +} + +// Error returns the error message. +func (e Conflict) Error() string { + return e.Message +} + // fromProblems returns an error from a list of API problems. func fromProblems(ctx context.Context, problems []problem) error { if len(problems) == 0 { @@ -59,8 +75,11 @@ func fromProblems(ctx context.Context, problems []problem) error { info := map[string]any{"kind": problem.Kind, "message": problem.Message} tflog.Error(ctx, "discarding additional error", info) } - if problems[0].Kind == "NotFound" { + switch problems[0].Kind { + case "NotFound": return NotFound{problems[0].Message} + case "Conflict": + return Conflict{problems[0].Message} } return apiError{Kind: problems[0].Kind, Detail: problems[0].Message} } diff --git a/internal/api/error_test.go b/internal/api/error_test.go index 9d963ab..3b6deb8 100644 --- a/internal/api/error_test.go +++ b/internal/api/error_test.go @@ -3,6 +3,7 @@ package api import ( + "context" "errors" "net/url" "testing" @@ -28,3 +29,27 @@ func TestNewAPIError_PlainError(t *testing.T) { assert.Equal(t, "API Error", err.Summary()) assert.Equal(t, "something went wrong", err.Error()) } + +func TestFromProblems(t *testing.T) { + ctx := context.Background() + tests := []struct { + name string + problems []problem + want error + }{ + {"none", nil, nil}, + {"not found", []problem{{Kind: "NotFound", Message: "gone"}}, NotFound{"gone"}}, + {"conflict", []problem{{Kind: "Conflict", Message: "try again"}}, Conflict{"try again"}}, + {"other", []problem{{Kind: "InvalidInput", Message: "bad key"}}, apiError{Kind: "InvalidInput", Detail: "bad key"}}, + { + "first of several", + []problem{{Kind: "InvalidInput", Message: "first"}, {Kind: "NotFound", Message: "second"}}, + apiError{Kind: "InvalidInput", Detail: "first"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, fromProblems(ctx, tt.problems)) + }) + } +} diff --git a/internal/datasources/anthropic_integration.go b/internal/datasources/anthropic_integration.go new file mode 100644 index 0000000..cc20ec2 --- /dev/null +++ b/internal/datasources/anthropic_integration.go @@ -0,0 +1,64 @@ +// Copyright Stacklet, Inc. 2025, 2026 + +package datasources + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + + "github.com/stacklet/terraform-provider-stacklet/internal/errors" + "github.com/stacklet/terraform-provider-stacklet/internal/models" +) + +var ( + _ datasource.DataSource = &anthropicIntegrationDataSource{} + _ datasource.DataSourceWithConfigure = &anthropicIntegrationDataSource{} +) + +type anthropicIntegrationDataSource struct { + apiDataSource +} + +func (d *anthropicIntegrationDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_anthropic_integration" +} + +func (d *anthropicIntegrationDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Retrieve an Anthropic integration.\n\n" + + "The admin key is never returned by the API, and `configured` reports whether it holds one.\n", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "The GraphQL Node ID of the Anthropic integration.", + Computed: true, + }, + "org_id": schema.StringAttribute{ + Description: "The ID of the Anthropic organization.", + Required: true, + }, + "configured": schema.BoolAttribute{ + Description: "Whether the API holds an admin key.", + Computed: true, + }, + }, + } +} + +func (d *anthropicIntegrationDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + var data models.AnthropicIntegrationDataSource + resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + integration, err := d.api.AnthropicIntegration.Read(ctx, data.OrgID.ValueString()) + if err != nil { + errors.AddDiagError(&resp.Diagnostics, err) + return + } + + resp.Diagnostics.Append(data.Update(integration)...) + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} diff --git a/internal/datasources/datasources.go b/internal/datasources/datasources.go index dffafe4..67be224 100644 --- a/internal/datasources/datasources.go +++ b/internal/datasources/datasources.go @@ -53,6 +53,9 @@ var DataSources = datasources{ newFactory(&userGroupDataSource{}), newFactory(&userDataSource{}), }, + Unreleased: []func() datasource.DataSource{ + newFactory(&anthropicIntegrationDataSource{}), + }, } func newFactory[T any, R interface { diff --git a/internal/models/anthropic_integration.go b/internal/models/anthropic_integration.go new file mode 100644 index 0000000..6b2f834 --- /dev/null +++ b/internal/models/anthropic_integration.go @@ -0,0 +1,36 @@ +// Copyright Stacklet, Inc. 2025, 2026 + +package models + +import ( + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/stacklet/terraform-provider-stacklet/internal/api" + "github.com/stacklet/terraform-provider-stacklet/internal/typehelpers" +) + +// AnthropicIntegrationDataSource is the model for Anthropic integration data sources. +type AnthropicIntegrationDataSource struct { + ID types.String `tfsdk:"id"` + OrgID types.String `tfsdk:"org_id"` + Configured types.Bool `tfsdk:"configured"` +} + +func (m *AnthropicIntegrationDataSource) Update(integration *api.AnthropicIntegration) diag.Diagnostics { + m.ID = typehelpers.GraphQLIDValue(integration.ID) + m.OrgID = types.StringValue(integration.OrgID) + m.Configured = types.BoolValue(integration.Configured) + return nil +} + +// AnthropicIntegrationResource is the model for Anthropic integration resources. +// +// The admin key is write-only: the API never returns it, so nothing about it +// is kept in state beyond the version that says when to send it again. +type AnthropicIntegrationResource struct { + AnthropicIntegrationDataSource + + AdminKeyWO types.String `tfsdk:"admin_key_wo"` + AdminKeyWOVersion types.String `tfsdk:"admin_key_wo_version"` +} diff --git a/internal/resources/account.go b/internal/resources/account.go index 4026b6f..7485d30 100644 --- a/internal/resources/account.go +++ b/internal/resources/account.go @@ -80,6 +80,7 @@ func (r *accountResource) Schema(_ context.Context, _ resource.SchemaRequest, re Description: "The email contact address for the account.", Optional: true, }, + // XXX wgrant 2026-10-22: security_context_wo should require security_context_wo_version (ENG-9371). "security_context_wo": schema.StringAttribute{ Description: "The input value for the security context for the account.", Optional: true, diff --git a/internal/resources/anthropic_integration.go b/internal/resources/anthropic_integration.go new file mode 100644 index 0000000..f8b2464 --- /dev/null +++ b/internal/resources/anthropic_integration.go @@ -0,0 +1,169 @@ +// Copyright Stacklet, Inc. 2025, 2026 + +package resources + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + + "github.com/stacklet/terraform-provider-stacklet/internal/api" + "github.com/stacklet/terraform-provider-stacklet/internal/errors" + "github.com/stacklet/terraform-provider-stacklet/internal/models" +) + +var ( + _ resource.Resource = &anthropicIntegrationResource{} + _ resource.ResourceWithConfigure = &anthropicIntegrationResource{} + _ resource.ResourceWithImportState = &anthropicIntegrationResource{} +) + +type anthropicIntegrationResource struct { + apiResource +} + +func (r *anthropicIntegrationResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_anthropic_integration" +} + +func (r *anthropicIntegrationResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: `Manage an Anthropic integration, which imports the daily cost and usage of one Anthropic organization. + +The admin key is checked against Anthropic when it is saved: one that doesn't authenticate, or belongs to another organization, fails the apply with the reason, and isn't stored. + +There is one integration per organization. Creating this resource for an organization that already has an integration takes that integration over, replacing its admin key. + +The admin key is write-only: it is sent to the API and never returned by it, so it isn't kept in Terraform state, and is sent only when admin_key_wo_version changes. The configured attribute reports whether the API holds one. + +An import brings in the integration but not the key version, for the same reason. The first apply after an import therefore sends whatever admin_key_wo holds, replacing the stored key. +`, + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "The GraphQL Node ID of the Anthropic integration.", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "org_id": schema.StringAttribute{ + Description: "The ID of the Anthropic organization, as shown in the Claude Console's organization settings. The admin key must belong to it. Changing it replaces the integration.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "configured": schema.BoolAttribute{ + Description: "Whether the API holds an admin key. This is how to tell, since the key is never returned.", + Computed: true, + }, + // After this, write-only secrets and associated trigger attrs. + "admin_key_wo": schema.StringAttribute{ + Description: "An Anthropic Admin API key for the organization. Anthropic only shows an admin key when it is created, so keep it somewhere it can be read from, such as a secrets manager.", + Required: true, + Sensitive: true, + WriteOnly: true, + }, + "admin_key_wo_version": schema.StringAttribute{ + Description: "The version for the admin key. Must be changed to update admin_key_wo.", + Required: true, + }, + }, + } +} + +func (r *anthropicIntegrationResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan, config models.AnthropicIntegrationResource + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + resp.Diagnostics.Append(req.Config.Get(ctx, &config)...) + if resp.Diagnostics.HasError() { + return + } + + input := api.AnthropicIntegrationInput{ + OrgID: plan.OrgID.ValueString(), + AdminKey: config.AdminKeyWO.ValueStringPointer(), + } + integration, err := r.api.AnthropicIntegration.Upsert(ctx, input) + if err != nil { + errors.AddDiagError(&resp.Diagnostics, err) + return + } + + resp.Diagnostics.Append(plan.Update(integration)...) + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *anthropicIntegrationResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state models.AnthropicIntegrationResource + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + integration, err := r.api.AnthropicIntegration.Read(ctx, state.OrgID.ValueString()) + if err != nil { + handleAPIError(ctx, &resp.State, &resp.Diagnostics, err) + return + } + + resp.Diagnostics.Append(state.Update(integration)...) + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +func (r *anthropicIntegrationResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan, state, config models.AnthropicIntegrationResource + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + resp.Diagnostics.Append(req.Config.Get(ctx, &config)...) + if resp.Diagnostics.HasError() { + return + } + + // The organization can't change in place, so the only update is a new key, + // sent when its version says there is one. + input := api.AnthropicIntegrationInput{ + OrgID: plan.OrgID.ValueString(), + AdminKey: credentialToSend(&resp.Diagnostics, !state.AdminKeyWOVersion.Equal(plan.AdminKeyWOVersion), "admin_key_wo", config.AdminKeyWO), + } + if resp.Diagnostics.HasError() { + return + } + + integration, err := r.api.AnthropicIntegration.Upsert(ctx, input) + if err != nil { + errors.AddDiagError(&resp.Diagnostics, err) + return + } + + resp.Diagnostics.Append(plan.Update(integration)...) + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *anthropicIntegrationResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state models.AnthropicIntegrationResource + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + err := r.api.AnthropicIntegration.Remove(ctx, state.OrgID.ValueString()) + if conflict, ok := err.(api.Conflict); ok { + // The integration is gone, but its stored key outlived it. Failing the + // destroy would keep a resource the next refresh drops anyway, so + // Terraform could never retry it; the API finishes the cleanup the next + // time an integration for this organization is saved or removed. + resp.Diagnostics.AddWarning("Anthropic integration removed with its admin key pending deletion", conflict.Error()) + return + } + if err != nil { + errors.AddDiagError(&resp.Diagnostics, err) + } +} + +func (r *anthropicIntegrationResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + importState(ctx, req, resp, []string{"org_id"}) +} diff --git a/internal/resources/binding.go b/internal/resources/binding.go index a350f31..f558239 100644 --- a/internal/resources/binding.go +++ b/internal/resources/binding.go @@ -121,6 +121,7 @@ func (r *bindingResource) Schema(_ context.Context, _ resource.SchemaRequest, re Description: "The binding execution security context.", Computed: true, }, + // XXX wgrant 2026-10-22: security_context_wo should require security_context_wo_version (ENG-9371). "security_context_wo": schema.StringAttribute{ Description: "The input value for the security context for the execution configuration.", Optional: true, diff --git a/internal/resources/configuration_profile_email.go b/internal/resources/configuration_profile_email.go index 99e60fd..1117fb4 100644 --- a/internal/resources/configuration_profile_email.go +++ b/internal/resources/configuration_profile_email.go @@ -84,6 +84,7 @@ The profile is global, adding multiple resources of this kind will cause them to Description: "Authentication password (encrypted), returned by the API.", Computed: true, }, + // XXX wgrant 2026-10-22: password_wo should require password_wo_version (ENG-9371). "password_wo": schema.StringAttribute{ Description: "Input value for authentication password.", Optional: true, diff --git a/internal/resources/configuration_profile_slack.go b/internal/resources/configuration_profile_slack.go index 8384d40..ac4aa56 100644 --- a/internal/resources/configuration_profile_slack.go +++ b/internal/resources/configuration_profile_slack.go @@ -73,6 +73,7 @@ The profile is global, adding multiple resources of this kind will cause them to Description: "The encrypted value for the token, returned by the API.", Computed: true, }, + // XXX wgrant 2026-10-22: token_wo should require token_wo_version (ENG-9371). "token_wo": schema.StringAttribute{ Description: "The input value for the token.", Optional: true, diff --git a/internal/resources/datadog_integration.go b/internal/resources/datadog_integration.go index 5854cff..8e30b58 100644 --- a/internal/resources/datadog_integration.go +++ b/internal/resources/datadog_integration.go @@ -75,6 +75,7 @@ func (r *datadogIntegrationResource) Schema(_ context.Context, _ resource.Schema "What the entry describes: `-secret`, e.g. `us-east-1-secret`, is whether Secrets Manager holds the credentials in that region, which it replicates on its own schedule; `-execution` is whether the region has recorded where to find them, so a policy running there can use them. Every region the configuration is meant to reach has both from the moment that is decided, so the list doesn't grow as propagation lands.", ), // After this, write-only secrets and associated trigger attrs. + // XXX wgrant 2026-10-22: api_key_wo should require api_key_wo_version (ENG-9371). "api_key_wo": schema.StringAttribute{ Description: "The Datadog API key, which identifies the organization. Must be supplied together with `app_key_wo` the first time, since the metrics query the filter runs needs both.", Optional: true, @@ -85,6 +86,7 @@ func (r *datadogIntegrationResource) Schema(_ context.Context, _ resource.Schema Description: "Change value to update api_key_wo.", Optional: true, }, + // XXX wgrant 2026-10-22: app_key_wo should require app_key_wo_version (ENG-9371). "app_key_wo": schema.StringAttribute{ Description: "The Datadog application key, which carries the scopes authorizing the metrics query. Datadog only shows an application key when it is created, so keep it somewhere it can be read from, such as a secrets manager.", Optional: true, diff --git a/internal/resources/repository.go b/internal/resources/repository.go index 9f39509..2f688d3 100644 --- a/internal/resources/repository.go +++ b/internal/resources/repository.go @@ -105,6 +105,7 @@ func (r *repositoryResource) Schema(ctx context.Context, req resource.SchemaRequ Computed: true, }, // After this, write-only secrets and associated trigger attrs. + // XXX wgrant 2026-10-22: auth_token_wo should require auth_token_wo_version (ENG-9371). "auth_token_wo": schema.StringAttribute{ Description: "User password/token, or IAM role, to access the remote repository.", Optional: true, @@ -115,6 +116,7 @@ func (r *repositoryResource) Schema(ctx context.Context, req resource.SchemaRequ Description: "Change value to update auth_token_wo.", Optional: true, }, + // XXX wgrant 2026-10-22: ssh_private_key_wo should require ssh_private_key_wo_version (ENG-9371). "ssh_private_key_wo": schema.StringAttribute{ Description: "SSH private key for remote repository authentication.", Optional: true, @@ -125,6 +127,7 @@ func (r *repositoryResource) Schema(ctx context.Context, req resource.SchemaRequ Description: "Change value to update ssh_private_key_wo.", Optional: true, }, + // XXX wgrant 2026-10-22: ssh_passphrase_wo should require ssh_passphrase_wo_version (ENG-9371). "ssh_passphrase_wo": schema.StringAttribute{ Description: "Passphrase for the SSH private key.", Optional: true, diff --git a/internal/resources/resources.go b/internal/resources/resources.go index 0f0fcc9..0ec0262 100644 --- a/internal/resources/resources.go +++ b/internal/resources/resources.go @@ -53,6 +53,9 @@ var Resources = resources{ newFactory(&userGroupResource{}), newFactory(&userResource{}), }, + Unreleased: []func() resource.Resource{ + newFactory(&anthropicIntegrationResource{}), + }, } func newFactory[T any, R interface {