Skip to content

Commit ef7bf07

Browse files
authored
Support .bedrock and .gen-ai connectors (#1467)
* Support .bedrock and .gen-ai connectors * Update changelog * Acceptance test for bedrock connector * Makefile target for running acc tests against started docker images * Acc tests for bedrock and genai connectors * Change min supported version for genai connector
1 parent 45c45f5 commit ef7bf07

File tree

7 files changed

+515
-0
lines changed

7 files changed

+515
-0
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
## [Unreleased]
22

3+
- Support `.bedrock` and `.gen-ai` connectors ([#1467](https://github.com/elastic/terraform-provider-elasticstack/pull/1467))
4+
35
## [0.12.2] - 2025-11-19
46
- Fix `elasticstack_elasticsearch_snapshot_lifecycle` metadata type conversion causing terraform apply to fail ([#1409](https://github.com/elastic/terraform-provider-elasticstack/issues/1409))
57
- Add new `elasticstack_elasticsearch_ml_anomaly_detection_job` resource ([#1329](https://github.com/elastic/terraform-provider-elasticstack/pull/1329))

Makefile

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,12 @@ build-ci: ## build the terraform provider
4646
.PHONY: build
4747
build: lint build-ci ## build the terraform provider
4848

49+
# run acceptance tests against the docker container that has been started with `make docker-kibana` (or `make docker-elasticsearch`)
50+
# To run specific test (e.g. TestAccResourceActionConnector) execute `make testacc-vs-docker TESTARGS='-run ^TestAccResourceKibanaConnectorBedrock$$'`
51+
.PHONY: testacc-vs-docker
52+
testacc-vs-docker:
53+
@ ELASTICSEARCH_ENDPOINTS=http://localhost:9200 KIBANA_ENDPOINT=http://localhost:5601 ELASTICSEARCH_USERNAME=$(ELASTICSEARCH_USERNAME) ELASTICSEARCH_PASSWORD=$(ELASTICSEARCH_PASSWORD) make testacc
54+
4955
.PHONY: testacc
5056
testacc: ## Run acceptance tests
5157
TF_ACC=1 go tool gotestsum --format testname --rerun-fails=3 --packages="-v ./..." -- -count $(ACCTEST_COUNT) -parallel $(ACCTEST_PARALLELISM) $(TESTARGS) -timeout $(ACCTEST_TIMEOUT)

docs/resources/kibana_action_connector.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,44 @@ resource "elasticstack_kibana_action_connector" "slack-api-connector" {
5252
token = "<your-token>"
5353
})
5454
}
55+
56+
resource "elasticstack_kibana_action_connector" "bedrock-connector" {
57+
name = "aws-bedrock"
58+
connector_type_id = ".bedrock"
59+
config = jsonencode({
60+
apiUrl = "https://bedrock-runtime.us-east-1.amazonaws.com"
61+
defaultModel = "anthropic.claude-v2"
62+
})
63+
secrets = jsonencode({
64+
accessKey = "<your-aws-access-key>"
65+
secret = "<your-aws-secret-key>"
66+
})
67+
}
68+
69+
resource "elasticstack_kibana_action_connector" "genai-openai-connector" {
70+
name = "openai"
71+
connector_type_id = ".gen-ai"
72+
config = jsonencode({
73+
apiProvider = "OpenAI"
74+
apiUrl = "https://api.openai.com/v1"
75+
defaultModel = "gpt-4"
76+
})
77+
secrets = jsonencode({
78+
apiKey = "<your-openai-api-key>"
79+
})
80+
}
81+
82+
resource "elasticstack_kibana_action_connector" "genai-azure-connector" {
83+
name = "azure-openai"
84+
connector_type_id = ".gen-ai"
85+
config = jsonencode({
86+
apiProvider = "Azure OpenAI"
87+
apiUrl = "https://my-resource.openai.azure.com/openai/deployments/my-deployment"
88+
})
89+
secrets = jsonencode({
90+
apiKey = "<your-azure-api-key>"
91+
})
92+
}
5593
```
5694

5795
<!-- schema generated by tfplugindocs -->

examples/resources/elasticstack_kibana_action_connector/resource.tf

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,41 @@ resource "elasticstack_kibana_action_connector" "slack-api-connector" {
3737
token = "<your-token>"
3838
})
3939
}
40+
41+
resource "elasticstack_kibana_action_connector" "bedrock-connector" {
42+
name = "aws-bedrock"
43+
connector_type_id = ".bedrock"
44+
config = jsonencode({
45+
apiUrl = "https://bedrock-runtime.us-east-1.amazonaws.com"
46+
defaultModel = "anthropic.claude-v2"
47+
})
48+
secrets = jsonencode({
49+
accessKey = "<your-aws-access-key>"
50+
secret = "<your-aws-secret-key>"
51+
})
52+
}
53+
54+
resource "elasticstack_kibana_action_connector" "genai-openai-connector" {
55+
name = "openai"
56+
connector_type_id = ".gen-ai"
57+
config = jsonencode({
58+
apiProvider = "OpenAI"
59+
apiUrl = "https://api.openai.com/v1"
60+
defaultModel = "gpt-4"
61+
})
62+
secrets = jsonencode({
63+
apiKey = "<your-openai-api-key>"
64+
})
65+
}
66+
67+
resource "elasticstack_kibana_action_connector" "genai-azure-connector" {
68+
name = "azure-openai"
69+
connector_type_id = ".gen-ai"
70+
config = jsonencode({
71+
apiProvider = "Azure OpenAI"
72+
apiUrl = "https://my-resource.openai.azure.com/openai/deployments/my-deployment"
73+
})
74+
secrets = jsonencode({
75+
apiKey = "<your-azure-api-key>"
76+
})
77+
}

internal/clients/kibana_oapi/connector.go

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,14 @@ var connectorConfigHandlers = map[string]connectorConfigHandler{
194194
defaults: connectorConfigWithDefaultsEmail,
195195
remarshalConfig: remarshalConfig[kbapi.EmailConfig],
196196
},
197+
".bedrock": {
198+
defaults: connectorConfigWithDefaultsBedrock,
199+
remarshalConfig: remarshalConfig[kbapi.BedrockConfig],
200+
},
201+
".gen-ai": {
202+
defaults: connectorConfigWithDefaultsGenAi,
203+
remarshalConfig: remarshalConfigGenAi,
204+
},
197205
".gemini": {
198206
remarshalConfig: remarshalConfig[kbapi.GeminiConfig],
199207
},
@@ -275,6 +283,96 @@ func remarshalConfig[T any](plan string) (string, error) {
275283
return string(customJSON), nil
276284
}
277285

286+
// remarshalConfigGenAi handles config for .gen-ai connectors.
287+
// The .gen-ai connector type has multiple possible config structures depending on the apiProvider
288+
// (GenaiOpenaiConfig, GenaiAzureConfig, GenaiOpenaiOtherConfig). This function unmarshals to the
289+
// appropriate type based on the apiProvider field. By unmarshaling to a typed struct and marshaling
290+
// back, unknown fields are automatically filtered out.
291+
func remarshalConfigGenAi(plan string) (string, error) {
292+
// First, unmarshal to a map to check the apiProvider
293+
var configMap map[string]interface{}
294+
if err := json.Unmarshal([]byte(plan), &configMap); err != nil {
295+
return "", err
296+
}
297+
298+
apiProvider, ok := configMap["apiProvider"].(string)
299+
if !ok {
300+
// apiProvider is required for .gen-ai connectors
301+
return "", errors.New("apiProvider is required for .gen-ai connector type")
302+
}
303+
304+
// Unmarshal to the appropriate specific type based on apiProvider.
305+
// By unmarshaling (which ignores unknown fields) and then marshaling back,
306+
// we automatically filter out any fields that aren't defined in the specific config type.
307+
switch apiProvider {
308+
case "OpenAI":
309+
return remarshalConfig[kbapi.GenaiOpenaiConfig](plan)
310+
case "Azure OpenAI":
311+
return remarshalConfig[kbapi.GenaiAzureConfig](plan)
312+
case "Other":
313+
return remarshalConfig[kbapi.GenaiOpenaiOtherConfig](plan)
314+
default:
315+
return "", fmt.Errorf("unsupported apiProvider %q for .gen-ai connector type, must be one of: OpenAI, Azure OpenAI, Other", apiProvider)
316+
}
317+
}
318+
319+
func connectorConfigWithDefaultsBedrock(plan string) (string, error) {
320+
var custom kbapi.BedrockConfig
321+
if err := json.Unmarshal([]byte(plan), &custom); err != nil {
322+
return "", err
323+
}
324+
if custom.DefaultModel == nil {
325+
custom.DefaultModel = utils.Pointer("us.anthropic.claude-sonnet-4-5-20250929-v1:0")
326+
}
327+
customJSON, err := json.Marshal(custom)
328+
if err != nil {
329+
return "", err
330+
}
331+
return string(customJSON), nil
332+
}
333+
334+
func connectorConfigWithDefaultsGenAi(plan string) (string, error) {
335+
// First unmarshal to check the apiProvider
336+
var configMap map[string]interface{}
337+
if err := json.Unmarshal([]byte(plan), &configMap); err != nil {
338+
return "", err
339+
}
340+
341+
apiProvider, ok := configMap["apiProvider"].(string)
342+
if !ok {
343+
// apiProvider is required for .gen-ai connectors
344+
return "", errors.New("apiProvider is required for .gen-ai connector type")
345+
}
346+
347+
// Apply defaults and filter fields based on the specific config type.
348+
// By unmarshaling (which ignores unknown fields) and marshaling back,
349+
// unknown fields are automatically filtered out.
350+
switch apiProvider {
351+
case "OpenAI":
352+
// No defaults to apply for OpenAI
353+
return remarshalConfig[kbapi.GenaiOpenaiConfig](plan)
354+
case "Azure OpenAI":
355+
// No defaults to apply for Azure
356+
return remarshalConfig[kbapi.GenaiAzureConfig](plan)
357+
case "Other":
358+
var config kbapi.GenaiOpenaiOtherConfig
359+
if err := json.Unmarshal([]byte(plan), &config); err != nil {
360+
return "", err
361+
}
362+
// Apply verificationMode default for "Other" provider
363+
if config.VerificationMode == nil {
364+
config.VerificationMode = utils.Pointer(kbapi.GenaiOpenaiOtherConfigVerificationModeFull)
365+
}
366+
customJSON, err := json.Marshal(config)
367+
if err != nil {
368+
return "", err
369+
}
370+
return string(customJSON), nil
371+
default:
372+
return "", fmt.Errorf("unsupported apiProvider %q for .gen-ai connector type, must be one of: OpenAI, Azure OpenAI, Other", apiProvider)
373+
}
374+
}
375+
278376
func connectorConfigWithDefaultsCasesWebhook(plan string) (string, error) {
279377
var custom kbapi.CasesWebhookConfig
280378
if err := json.Unmarshal([]byte(plan), &custom); err != nil {

internal/clients/kibana_oapi/connector_test.go

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,3 +262,168 @@ func TestGetConnectorByName(t *testing.T) {
262262
require.NotNil(t, diags)
263263
require.Nil(t, fail)
264264
}
265+
266+
func TestConnectorConfigWithDefaults(t *testing.T) {
267+
tests := []struct {
268+
name string
269+
connectorTypeID string
270+
planConfig string
271+
expectedError bool
272+
errorContains string
273+
validateResult func(t *testing.T, result string)
274+
}{
275+
{
276+
name: "bedrock connector with valid config and explicit defaultModel",
277+
connectorTypeID: ".bedrock",
278+
planConfig: `{"apiUrl":"https://bedrock.us-east-1.amazonaws.com","defaultModel":"anthropic.claude-v2"}`,
279+
expectedError: false,
280+
validateResult: func(t *testing.T, result string) {
281+
expected := `{"apiUrl":"https://bedrock.us-east-1.amazonaws.com","defaultModel":"anthropic.claude-v2"}`
282+
require.JSONEq(t, expected, result)
283+
},
284+
},
285+
{
286+
name: "bedrock connector without defaultModel gets default value",
287+
connectorTypeID: ".bedrock",
288+
planConfig: `{"apiUrl":"https://bedrock.us-east-1.amazonaws.com"}`,
289+
expectedError: false,
290+
validateResult: func(t *testing.T, result string) {
291+
expected := `{"apiUrl":"https://bedrock.us-east-1.amazonaws.com","defaultModel":"us.anthropic.claude-sonnet-4-5-20250929-v1:0"}`
292+
require.JSONEq(t, expected, result)
293+
},
294+
},
295+
{
296+
name: "gen-ai connector with OpenAI provider with defaultModel",
297+
connectorTypeID: ".gen-ai",
298+
planConfig: `{"apiProvider":"OpenAI","apiUrl":"https://api.openai.com/v1","defaultModel":"gpt-4"}`,
299+
expectedError: false,
300+
validateResult: func(t *testing.T, result string) {
301+
expected := `{"apiProvider":"OpenAI","apiUrl":"https://api.openai.com/v1","defaultModel":"gpt-4"}`
302+
require.JSONEq(t, expected, result)
303+
},
304+
},
305+
{
306+
name: "gen-ai connector with Azure provider",
307+
connectorTypeID: ".gen-ai",
308+
planConfig: `{"apiProvider":"Azure OpenAI","apiUrl":"https://my-resource.openai.azure.com/openai/deployments/my-deployment"}`,
309+
expectedError: false,
310+
validateResult: func(t *testing.T, result string) {
311+
expected := `{"apiProvider":"Azure OpenAI","apiUrl":"https://my-resource.openai.azure.com/openai/deployments/my-deployment"}`
312+
require.JSONEq(t, expected, result)
313+
},
314+
},
315+
{
316+
name: "gen-ai connector with Other provider and explicit verificationMode",
317+
connectorTypeID: ".gen-ai",
318+
planConfig: `{"apiProvider":"Other","apiUrl":"https://custom-llm.example.com/v1","defaultModel":"custom-model","verificationMode":"none"}`,
319+
expectedError: false,
320+
validateResult: func(t *testing.T, result string) {
321+
expected := `{"apiProvider":"Other","apiUrl":"https://custom-llm.example.com/v1","defaultModel":"custom-model","verificationMode":"none"}`
322+
require.JSONEq(t, expected, result)
323+
},
324+
},
325+
{
326+
name: "gen-ai connector with Other provider without verificationMode gets default",
327+
connectorTypeID: ".gen-ai",
328+
planConfig: `{"apiProvider":"Other","apiUrl":"https://custom-llm.example.com/v1","defaultModel":"custom-model"}`,
329+
expectedError: false,
330+
validateResult: func(t *testing.T, result string) {
331+
expected := `{"apiProvider":"Other","apiUrl":"https://custom-llm.example.com/v1","defaultModel":"custom-model","verificationMode":"full"}`
332+
require.JSONEq(t, expected, result)
333+
},
334+
},
335+
{
336+
name: "gen-ai connector with OpenAI provider without defaultModel",
337+
connectorTypeID: ".gen-ai",
338+
planConfig: `{"apiProvider":"OpenAI","apiUrl":"https://api.openai.com/v1"}`,
339+
expectedError: false,
340+
validateResult: func(t *testing.T, result string) {
341+
// Verify no verificationMode is added (that's only for Other provider)
342+
expected := `{"apiProvider":"OpenAI","apiUrl":"https://api.openai.com/v1"}`
343+
require.JSONEq(t, expected, result)
344+
},
345+
},
346+
{
347+
name: "gemini connector with valid config",
348+
connectorTypeID: ".gemini",
349+
planConfig: `{"apiUrl":"https://us-central1-aiplatform.googleapis.com","gcpProjectId":"my-project","gcpRegion":"us-central1","defaultModel":"gemini-pro"}`,
350+
expectedError: false,
351+
validateResult: func(t *testing.T, result string) {
352+
expected := `{"apiUrl":"https://us-central1-aiplatform.googleapis.com","gcpProjectId":"my-project","gcpRegion":"us-central1","defaultModel":"gemini-pro"}`
353+
require.JSONEq(t, expected, result)
354+
},
355+
},
356+
{
357+
name: "gen-ai OpenAI connector silently filters unknown fields",
358+
connectorTypeID: ".gen-ai",
359+
planConfig: `{"apiProvider":"OpenAI","apiUrl":"https://api.openai.com/v1","defaultModel":"gpt-4","unknownField":"should-be-filtered"}`,
360+
expectedError: false,
361+
validateResult: func(t *testing.T, result string) {
362+
// Unknown field should be filtered out
363+
expected := `{"apiProvider":"OpenAI","apiUrl":"https://api.openai.com/v1","defaultModel":"gpt-4"}`
364+
require.JSONEq(t, expected, result)
365+
},
366+
},
367+
{
368+
name: "gen-ai Azure connector silently filters invalid PKI fields",
369+
connectorTypeID: ".gen-ai",
370+
planConfig: `{"apiProvider":"Azure OpenAI","apiUrl":"https://my.openai.azure.com","certificateData":"invalid-for-azure"}`,
371+
expectedError: false,
372+
validateResult: func(t *testing.T, result string) {
373+
// certificateData is not valid for Azure provider, should be filtered
374+
expected := `{"apiProvider":"Azure OpenAI","apiUrl":"https://my.openai.azure.com"}`
375+
require.JSONEq(t, expected, result)
376+
},
377+
},
378+
{
379+
name: "gen-ai Other connector allows PKI fields",
380+
connectorTypeID: ".gen-ai",
381+
planConfig: `{"apiProvider":"Other","apiUrl":"https://custom.com","defaultModel":"custom","certificateData":"pem-data","verificationMode":"full"}`,
382+
expectedError: false,
383+
validateResult: func(t *testing.T, result string) {
384+
expected := `{"apiProvider":"Other","apiUrl":"https://custom.com","defaultModel":"custom","certificateData":"pem-data","verificationMode":"full"}`
385+
require.JSONEq(t, expected, result)
386+
},
387+
},
388+
{
389+
name: "gen-ai connector without apiProvider returns error",
390+
connectorTypeID: ".gen-ai",
391+
planConfig: `{"apiUrl":"https://api.example.com"}`,
392+
expectedError: true,
393+
errorContains: "apiProvider is required",
394+
},
395+
{
396+
name: "gen-ai connector with unknown apiProvider returns error",
397+
connectorTypeID: ".gen-ai",
398+
planConfig: `{"apiProvider":"UnknownProvider","apiUrl":"https://api.example.com"}`,
399+
expectedError: true,
400+
errorContains: "unsupported apiProvider",
401+
},
402+
{
403+
name: "unknown connector type returns error",
404+
connectorTypeID: ".unknown-type",
405+
planConfig: `{"key":"value"}`,
406+
expectedError: true,
407+
errorContains: "unknown connector type ID",
408+
},
409+
}
410+
411+
for _, tt := range tests {
412+
t.Run(tt.name, func(t *testing.T) {
413+
result, err := kibana_oapi.ConnectorConfigWithDefaults(tt.connectorTypeID, tt.planConfig)
414+
415+
if tt.expectedError {
416+
require.Error(t, err)
417+
if tt.errorContains != "" {
418+
require.Contains(t, err.Error(), tt.errorContains)
419+
}
420+
} else {
421+
require.NoError(t, err)
422+
require.NotEmpty(t, result)
423+
if tt.validateResult != nil {
424+
tt.validateResult(t, result)
425+
}
426+
}
427+
})
428+
}
429+
}

0 commit comments

Comments
 (0)