From 1d4dfecff776157d32b156a4ee9eb1b57ae08a53 Mon Sep 17 00:00:00 2001 From: Gautam Date: Wed, 22 Oct 2025 22:02:08 -0700 Subject: [PATCH] Add Terraform Actions support --- pkg/tools/tfe/create_run.go | 39 ++++- pkg/tools/tfe/create_run_test.go | 282 +++++++++++++++++++++++++++++++ 2 files changed, 319 insertions(+), 2 deletions(-) diff --git a/pkg/tools/tfe/create_run.go b/pkg/tools/tfe/create_run.go index 032dc9ae..e9750475 100644 --- a/pkg/tools/tfe/create_run.go +++ b/pkg/tools/tfe/create_run.go @@ -42,6 +42,10 @@ func CreateRunSafe(logger *log.Logger) server.ServerTool { mcp.Description("Optional message for the run"), mcp.DefaultString("Triggered via Terraform MCP Server"), ), + mcp.WithArray("actions", + mcp.Description("Optional list of actions to invoke in the array of format actions.."), + mcp.DefaultArray([]string{}), + ), ), Handler: func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { return createRunSafeHandler(ctx, req, logger) @@ -65,6 +69,18 @@ func createRunSafeHandler(ctx context.Context, request mcp.CallToolRequest, logg runType := request.GetString("run_type", "plan_and_apply") message := request.GetString("message", "Triggered via Terraform MCP Server") + // Get actions array parameter + var actions []string + if actionsRaw, ok := request.GetArguments()["actions"]; ok { + if actionsArray, ok := actionsRaw.([]interface{}); ok { + for _, action := range actionsArray { + if actionStr, ok := action.(string); ok { + actions = append(actions, actionStr) + } + } + } + } + tfeClient, err := client.GetTfeClientFromContext(ctx, logger) if err != nil { return nil, utils.LogAndReturnError(logger, "getting Terraform client", err) @@ -76,7 +92,8 @@ func createRunSafeHandler(ctx context.Context, request mcp.CallToolRequest, logg } options := &tfe.RunCreateOptions{ - Workspace: workspace, + Workspace: workspace, + InvokeActionAddrs: actions, } switch runType { case "plan_and_apply": @@ -133,6 +150,11 @@ func CreateRun(logger *log.Logger) server.ServerTool { ), mcp.WithString("message", mcp.Description("Optional message for the run"), + mcp.DefaultString("Triggered via Terraform MCP Server"), + ), + mcp.WithArray("actions", + mcp.Description("Optional list of actions to invoke"), + mcp.DefaultArray([]string{}), ), ), Handler: func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { @@ -157,6 +179,18 @@ func createRunHandler(ctx context.Context, request mcp.CallToolRequest, logger * runType := request.GetString("run_type", "plan_and_apply") message := request.GetString("message", "Triggered via Terraform MCP Server") + // Get actions array parameter + var actions []string + if actionsRaw, ok := request.GetArguments()["actions"]; ok { + if actionsArray, ok := actionsRaw.([]interface{}); ok { + for _, action := range actionsArray { + if actionStr, ok := action.(string); ok { + actions = append(actions, actionStr) + } + } + } + } + tfeClient, err := client.GetTfeClientFromContext(ctx, logger) if err != nil { return nil, utils.LogAndReturnError(logger, "getting Terraform client", err) @@ -168,7 +202,8 @@ func createRunHandler(ctx context.Context, request mcp.CallToolRequest, logger * } options := &tfe.RunCreateOptions{ - Workspace: workspace, + Workspace: workspace, + InvokeActionAddrs: actions, } switch runType { case "plan_and_apply": diff --git a/pkg/tools/tfe/create_run_test.go b/pkg/tools/tfe/create_run_test.go index b1b44a4f..8e8cb442 100644 --- a/pkg/tools/tfe/create_run_test.go +++ b/pkg/tools/tfe/create_run_test.go @@ -10,6 +10,10 @@ import ( "github.com/stretchr/testify/assert" ) +func (m *MockCallToolRequest) GetArguments() map[string]interface{} { + return m.params +} + func TestCreateRunSafe(t *testing.T) { logger := log.New() logger.SetLevel(log.ErrorLevel) @@ -32,6 +36,92 @@ func TestCreateRunSafe(t *testing.T) { // Check that run_type property exists runTypeProperty := tool.Tool.InputSchema.Properties["run_type"] assert.NotNil(t, runTypeProperty) + + // Check that actions property exists + actionsProperty := tool.Tool.InputSchema.Properties["actions"] + assert.NotNil(t, actionsProperty) + }) + + t.Run("actions parameter parsing", func(t *testing.T) { + tests := []struct { + name string + params map[string]interface{} + expected []string + }{ + { + name: "no actions parameter", + params: map[string]interface{}{ + "terraform_org_name": "test-org", + "workspace_name": "test-workspace", + }, + expected: nil, + }, + { + name: "empty actions array", + params: map[string]interface{}{ + "terraform_org_name": "test-org", + "workspace_name": "test-workspace", + "actions": []interface{}{}, + }, + expected: nil, + }, + { + name: "single action", + params: map[string]interface{}{ + "terraform_org_name": "test-org", + "workspace_name": "test-workspace", + "actions": []interface{}{"actions.foo.bar"}, + }, + expected: []string{"actions.foo.bar"}, + }, + { + name: "multiple actions", + params: map[string]interface{}{ + "terraform_org_name": "test-org", + "workspace_name": "test-workspace", + "actions": []interface{}{"actions.foo.bar", "actions.baz.qux"}, + }, + expected: []string{"actions.foo.bar", "actions.baz.qux"}, + }, + { + name: "mixed types in actions array (filters non-strings)", + params: map[string]interface{}{ + "terraform_org_name": "test-org", + "workspace_name": "test-workspace", + "actions": []interface{}{"actions.foo.bar", 123, "actions.baz.qux", true}, + }, + expected: []string{"actions.foo.bar", "actions.baz.qux"}, + }, + { + name: "non-array actions parameter", + params: map[string]interface{}{ + "terraform_org_name": "test-org", + "workspace_name": "test-workspace", + "actions": "not-an-array", + }, + expected: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + request := &MockCallToolRequest{params: tt.params} + + // Test the actions parameter extraction logic + var actions []string + if actionsRaw, ok := request.GetArguments()["actions"]; ok { + if actionsArray, ok := actionsRaw.([]interface{}); ok { + for _, action := range actionsArray { + if actionStr, ok := action.(string); ok { + actions = append(actions, actionStr) + } + } + } + } + + assert.Equal(t, tt.expected, actions) + }) + } }) } @@ -57,5 +147,197 @@ func TestCreateRun(t *testing.T) { // Check that run_type property exists runTypeProperty := tool.Tool.InputSchema.Properties["run_type"] assert.NotNil(t, runTypeProperty) + + // Check that actions property exists + actionsProperty := tool.Tool.InputSchema.Properties["actions"] + assert.NotNil(t, actionsProperty) + }) + + t.Run("actions parameter parsing", func(t *testing.T) { + tests := []struct { + name string + params map[string]interface{} + expected []string + }{ + { + name: "no actions parameter", + params: map[string]interface{}{ + "terraform_org_name": "test-org", + "workspace_name": "test-workspace", + }, + expected: nil, + }, + { + name: "empty actions array", + params: map[string]interface{}{ + "terraform_org_name": "test-org", + "workspace_name": "test-workspace", + "actions": []interface{}{}, + }, + expected: nil, + }, + { + name: "single action", + params: map[string]interface{}{ + "terraform_org_name": "test-org", + "workspace_name": "test-workspace", + "actions": []interface{}{"actions.example.deploy"}, + }, + expected: []string{"actions.example.deploy"}, + }, + { + name: "multiple actions", + params: map[string]interface{}{ + "terraform_org_name": "test-org", + "workspace_name": "test-workspace", + "actions": []interface{}{"actions.deploy.app", "actions.notify.slack", "actions.rollback.plan"}, + }, + expected: []string{"actions.deploy.app", "actions.notify.slack", "actions.rollback.plan"}, + }, + { + name: "complex action names", + params: map[string]interface{}{ + "terraform_org_name": "test-org", + "workspace_name": "test-workspace", + "actions": []interface{}{"actions.providers.aws.deploy", "actions.modules.vpc.configure"}, + }, + expected: []string{"actions.providers.aws.deploy", "actions.modules.vpc.configure"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + request := &MockCallToolRequest{params: tt.params} + + // Test the actions parameter extraction logic + var actions []string + if actionsRaw, ok := request.GetArguments()["actions"]; ok { + if actionsArray, ok := actionsRaw.([]interface{}); ok { + for _, action := range actionsArray { + if actionStr, ok := action.(string); ok { + actions = append(actions, actionStr) + } + } + } + } + + assert.Equal(t, tt.expected, actions) + }) + } + }) + + t.Run("parameter validation with actions", func(t *testing.T) { + tests := []struct { + name string + params map[string]interface{} + expectError bool + errorMsg string + }{ + { + name: "valid parameters with actions", + params: map[string]interface{}{ + "terraform_org_name": "test-org", + "workspace_name": "test-workspace", + "run_type": "plan_and_apply", + "message": "Test run with actions", + "actions": []interface{}{"actions.foo.bar", "actions.baz.qux"}, + }, + expectError: false, + }, + { + name: "valid parameters without actions", + params: map[string]interface{}{ + "terraform_org_name": "test-org", + "workspace_name": "test-workspace", + "run_type": "plan_only", + "message": "Test run without actions", + }, + expectError: false, + }, + { + name: "missing required terraform_org_name", + params: map[string]interface{}{ + "workspace_name": "test-workspace", + "actions": []interface{}{"actions.foo.bar"}, + }, + expectError: true, + errorMsg: "terraform_org_name", + }, + { + name: "missing required workspace_name", + params: map[string]interface{}{ + "terraform_org_name": "test-org", + "actions": []interface{}{"actions.foo.bar"}, + }, + expectError: true, + errorMsg: "workspace_name", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + request := &MockCallToolRequest{params: tt.params} + + // Test required parameter validation + orgName, err1 := request.RequireString("terraform_org_name") + workspaceName, err2 := request.RequireString("workspace_name") + + if tt.expectError { + if tt.errorMsg == "terraform_org_name" { + assert.Error(t, err1) + } + if tt.errorMsg == "workspace_name" { + assert.Error(t, err2) + } + } else { + assert.NoError(t, err1) + assert.NoError(t, err2) + assert.Equal(t, tt.params["terraform_org_name"], orgName) + assert.Equal(t, tt.params["workspace_name"], workspaceName) + + // Test optional parameters + runType := request.GetString("run_type", "plan_and_apply") + message := request.GetString("message", "Triggered via Terraform MCP Server") + + if expectedRunType, ok := tt.params["run_type"]; ok { + assert.Equal(t, expectedRunType, runType) + } else { + assert.Equal(t, "plan_and_apply", runType) + } + + if expectedMessage, ok := tt.params["message"]; ok { + assert.Equal(t, expectedMessage, message) + } else { + assert.Equal(t, "Triggered via Terraform MCP Server", message) + } + + // Test actions parameter extraction + var actions []string + if actionsRaw, ok := request.GetArguments()["actions"]; ok { + if actionsArray, ok := actionsRaw.([]interface{}); ok { + for _, action := range actionsArray { + if actionStr, ok := action.(string); ok { + actions = append(actions, actionStr) + } + } + } + } + + if expectedActions, ok := tt.params["actions"]; ok { + expectedActionsSlice := make([]string, 0) + if actionArray, ok := expectedActions.([]interface{}); ok { + for _, action := range actionArray { + if actionStr, ok := action.(string); ok { + expectedActionsSlice = append(expectedActionsSlice, actionStr) + } + } + } + assert.Equal(t, expectedActionsSlice, actions) + } else { + assert.Nil(t, actions) + } + } + }) + } }) }