Skip to content

Commit f499b62

Browse files
sandyydkHarness
authored andcommitted
feat: [CCM-24794]: Add tool call for Estimation of Savings using Commitment Orchestrator (#118)
* Add tool call for Estimation of Savings using Commitment Orchestrator
1 parent be06d5d commit f499b62

File tree

5 files changed

+186
-8
lines changed

5 files changed

+186
-8
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,7 @@ Toolset Name: `ccm`
153153
- `get_ccm_commitment_coverage`: Get commitment coverage information for an account in Harness Cloud Cost Management
154154
- `get_ccm_commitment_savings`: Get commitment savings information for an account in Harness Cloud Cost Management
155155
- `get_ccm_commitment_utilisation`: Get commitment utilisation information for an account in Harness Cloud Cost Management broken down by Reserved Instances and Savings Plans in day wise granularity.
156+
- `get_ccm_estimated_savings`: Get estimated savings information for a cloud account in Harness Cloud Cost Management
156157

157158
#### Database Operations Toolset
158159

client/ccmcommitments.go

Lines changed: 80 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,26 +2,99 @@ package client
22

33
import (
44
"context"
5+
"encoding/json"
56
"fmt"
7+
"sync"
68

79
"github.com/harness/harness-mcp/client/dto"
810
)
911

1012
const (
11-
ccmCommitmentMasterAccountsPath = ccmCommitmentBasePath + "/accounts/%s/v1/setup/listMasterAccounts?accountIdentifier=%s"
13+
ccmCommitmentEstimatedSavingsPath = ccmCommitmentBasePath + "/accounts/%s/v2/setup/%s/estimated_savings?accountIdentifier=%s"
14+
15+
defaultTargetCoveragePercentage = 90.0
1216
)
1317

14-
func (r *CloudCostManagementService) GetCommitmentMasterAccounts(ctx context.Context, scope dto.Scope) (*dto.CCMCommitmentBaseResponse, error) {
15-
path := fmt.Sprintf(ccmCommitmentMasterAccountsPath, scope.AccountID, scope.AccountID)
18+
func (r *CloudCostManagementService) GetEstimatedSavings(ctx context.Context, scope dto.Scope, targetCoverage float64, opts *dto.CCMCommitmentOptions) (*dto.CommitmentEstimatedSavingsResponse, error) {
19+
var response dto.CommitmentEstimatedSavingsResponse
20+
var wg sync.WaitGroup
21+
var mu sync.Mutex
22+
errChan := make(chan error, len(opts.CloudAccountIDs))
23+
24+
// Parse all possible cloud account IDs and run go routines to capture responses
25+
for _, cloudAccountID := range opts.CloudAccountIDs {
26+
wg.Add(1)
27+
go func(accID string) {
28+
defer wg.Done()
29+
30+
remoteResponse, err := r.getEstimatedSavingsResponse(ctx, scope, accID, opts.Service, &targetCoverage)
31+
if err != nil {
32+
errChan <- err
33+
return
34+
}
35+
36+
mu.Lock()
37+
response.Data = append(response.Data, &dto.CommitmentEstimatedSavings{
38+
CloudAccountID: accID,
39+
AnnualizedSavings: remoteResponse.EstimatedSavings * 12, // Monthly value is extrapolated
40+
})
41+
mu.Unlock()
42+
}(cloudAccountID)
43+
}
44+
45+
// Wait for all goroutines to complete
46+
wg.Wait()
47+
close(errChan)
48+
49+
// Check if any errors occurred
50+
if len(errChan) > 0 {
51+
return nil, <-errChan // Return the first error encountered
52+
}
53+
54+
return &response, nil
55+
}
56+
57+
func (r *CloudCostManagementService) getEstimatedSavingsResponse(ctx context.Context, scope dto.Scope, cloudAccountID string, service *string, targetCoverage *float64) (*dto.EstimatedSavingsRemoteResponse, error) {
58+
path := fmt.Sprintf(ccmCommitmentEstimatedSavingsPath, scope.AccountID, cloudAccountID, scope.AccountID)
1659
params := make(map[string]string)
1760
addScope(scope, params)
1861

19-
listMasterAccountsResponse := new(dto.CCMCommitmentBaseResponse)
62+
type reqPayload struct {
63+
Service string `json:"service"`
64+
TargetCoverage float64 `json:"target_coverage"`
65+
}
66+
67+
requestPayload := reqPayload{
68+
TargetCoverage: defaultTargetCoveragePercentage,
69+
}
70+
71+
if service != nil {
72+
requestPayload.Service = *service
73+
}
74+
75+
if targetCoverage != nil {
76+
requestPayload.TargetCoverage = *targetCoverage
77+
}
78+
79+
// Temporary slice to hold the strings
80+
baseResponse := new(dto.CCMCommitmentBaseResponse)
81+
82+
err := r.Client.Post(ctx, path, params, requestPayload, baseResponse)
83+
if err != nil {
84+
return nil, fmt.Errorf("failed to get cloud cost managment estimated savings response with path %s: %w", path, err)
85+
}
86+
87+
responseBytes, err := json.Marshal(baseResponse.Response)
88+
if err != nil {
89+
return nil, fmt.Errorf("failed to marshal cloud cost managment estimated savings response with path %s: %w", path, err)
90+
}
91+
92+
var response dto.EstimatedSavingsRemoteResponse
2093

21-
err := r.Client.Post(ctx, path, params, nil, listMasterAccountsResponse)
94+
err = json.Unmarshal(responseBytes, &response)
2295
if err != nil {
23-
return nil, fmt.Errorf("failed to get cloud cost managment master accounts with path %s: %w", path, err)
96+
return nil, fmt.Errorf("failed to unmarshal cloud cost managment estimated savings response with path %s: %w", path, err)
2497
}
2598

26-
return listMasterAccountsResponse, nil
99+
return &response, nil
27100
}

client/dto/ccmcommitments.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,19 @@ type ComputeCoveragesDetail struct {
4949
Table *ComputeCoveragesDetailTable `json:"table,omitempty"`
5050
Chart []*ComputeCoverageChart `json:"chart,omitempty"`
5151
}
52+
53+
type CommitmentEstimatedSavings struct {
54+
AnnualizedSavings float64 `json:"annualized_savings,omitempty"`
55+
CloudAccountID string `json:"cloud_account_id,omitempty"`
56+
}
57+
58+
type CommitmentEstimatedSavingsResponse struct {
59+
Data []*CommitmentEstimatedSavings `json:"data,omitempty"`
60+
}
61+
62+
type EstimatedSavingsRemoteResponse struct {
63+
CurrentCoverage float64 `json:"current_coverage"`
64+
TargetCoverage float64 `json:"target_coverage"`
65+
CurrentSavings float64 `json:"current_savings"`
66+
EstimatedSavings float64 `json:"estimated_savings"`
67+
}
Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,95 @@
11
package tools
22

3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
8+
"github.com/harness/harness-mcp/client"
9+
"github.com/harness/harness-mcp/client/dto"
10+
"github.com/harness/harness-mcp/cmd/harness-mcp-server/config"
11+
"github.com/mark3labs/mcp-go/mcp"
12+
"github.com/mark3labs/mcp-go/server"
13+
)
14+
315
const (
4-
CCMCommitmentCoverageEventType = "commitment_coverage_sorted"
16+
CCMCommitmentCoverageEventType = "commitment_coverage_sorted"
17+
defaultTargetCoveragePercentage = 90.0
518

619
FollowUpGroupCommitmentCoverageByRegionsPrompt = "Help me group commitment coverage by regions"
720
FollowUpGroupCommitmentCoverageBySavingsPrompt = "Help me estimate savings opportunities"
821
)
22+
23+
func FetchEstimatedSavingsTool(config *config.Config, client *client.CloudCostManagementService) (tool mcp.Tool, handler server.ToolHandlerFunc) {
24+
return mcp.NewTool("get_ccm_commitment_estimated_savings",
25+
mcp.WithDescription("Get commitment annualized estimated savings information for provided cloud account(s) in Harness Cloud Cost Management with optionally provided target coverage or else defaults to 90% of target coverage"),
26+
mcp.WithString("service",
27+
mcp.Description("Optional service to estimate savings for"),
28+
),
29+
mcp.WithNumber("target_coverage",
30+
mcp.Description("Optional target coverage to estimate savings"),
31+
mcp.DefaultNumber(defaultTargetCoveragePercentage),
32+
mcp.Max(95),
33+
mcp.Min(10),
34+
),
35+
mcp.WithArray("cloud_account_ids",
36+
mcp.WithStringItems(),
37+
mcp.Description("cloud account IDs to estimate savings for"),
38+
mcp.Required(),
39+
),
40+
WithScope(config, false),
41+
),
42+
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
43+
accountId, err := getAccountID(config, request)
44+
if err != nil {
45+
return mcp.NewToolResultError(err.Error()), nil
46+
}
47+
48+
params := &dto.CCMCommitmentOptions{}
49+
params.AccountIdentifier = &accountId
50+
51+
// Handle service parameter
52+
service, ok, err := OptionalParamOK[string](request, "service")
53+
if err != nil {
54+
return mcp.NewToolResultError(err.Error()), nil
55+
}
56+
if ok && service != "" {
57+
params.Service = &service
58+
}
59+
60+
cloudAccountIDs, err := OptionalStringArrayParam(request, "cloud_account_ids")
61+
if err != nil {
62+
return mcp.NewToolResultError(err.Error()), nil
63+
}
64+
if len(cloudAccountIDs) == 0 {
65+
return mcp.NewToolResultError("missing required parameter: cloud_account_ids"), nil
66+
}
67+
params.CloudAccountIDs = cloudAccountIDs
68+
69+
targetCoverage, ok, err := OptionalParamOK[float64](request, "target_coverage")
70+
if err != nil {
71+
return mcp.NewToolResultError(err.Error()), nil
72+
}
73+
74+
if !ok {
75+
targetCoverage = defaultTargetCoveragePercentage
76+
}
77+
78+
scope, err := FetchScope(config, request, false)
79+
if err != nil {
80+
return mcp.NewToolResultError(err.Error()), nil
81+
}
82+
83+
data, err := client.GetEstimatedSavings(ctx, scope, targetCoverage, params)
84+
if err != nil {
85+
return mcp.NewToolResultError(fmt.Sprintf("failed to get commitment coverage: %s", err)), nil
86+
}
87+
88+
r, err := json.Marshal(data)
89+
if err != nil {
90+
return mcp.NewToolResultError(fmt.Sprintf("failed to marshal commitment coverage: %s", err)), nil
91+
}
92+
93+
return mcp.NewToolResultText(string(r)), nil
94+
}
95+
}

pkg/modules/ccm.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ func RegisterCloudCostManagement(config *config.Config, tsg *toolsets.ToolsetGro
103103
toolsets.NewServerTool(tools.FetchCommitmentCoverageTool(config, ccmClient)),
104104
toolsets.NewServerTool(tools.FetchCommitmentSavingsTool(config, ccmClient)),
105105
toolsets.NewServerTool(tools.FetchCommitmentUtilisationTool(config, ccmClient)),
106+
toolsets.NewServerTool(tools.FetchEstimatedSavingsTool(config, ccmClient)),
106107
)
107108

108109
// Add toolset to the group

0 commit comments

Comments
 (0)