From 968438991a69fd1de1ad809f9c805bba30d80a0a Mon Sep 17 00:00:00 2001 From: Benji Date: Fri, 10 Jul 2026 12:18:35 +0000 Subject: [PATCH 1/3] refactor: centralise Platform API HTTP client Introduce internal/client.Client as a single shared implementation of SigV4-signed HTTP requests to the Platform API. Previously, signing logic was duplicated across three locations: - internal/commands/cluster/api.go (GET only, 15s timeout) - internal/services/cluster/service.go (POST only, inline, fresh http.Client per call) Each had inconsistencies: different timeouts, different return signatures (some omitted the status code), and service.go created a new http.Client on every call rather than reusing one. client.New(ctx) resolves the Platform API URL and AWS credentials once. The Client exposes Get, Post and Delete, each returning ([]byte, int, error) so callers can inspect the status code without the client second-guessing what counts as an error. All callers in commands/cluster and services/cluster are updated to use the new client. The SubmitClusterRequest.PlatformAPIURL and AWSConfig fields are replaced with a single Client field. --- internal/client/client.go | 138 ++++++++++++++++++++++++ internal/commands/cluster/api.go | 67 +++--------- internal/commands/cluster/create.go | 35 ++---- internal/commands/cluster/kubeconfig.go | 26 +---- internal/commands/cluster/list.go | 35 ++---- internal/services/cluster/service.go | 67 ++---------- 6 files changed, 181 insertions(+), 187 deletions(-) create mode 100644 internal/client/client.go diff --git a/internal/client/client.go b/internal/client/client.go new file mode 100644 index 0000000..f7947eb --- /dev/null +++ b/internal/client/client.go @@ -0,0 +1,138 @@ +// Package client provides a SigV4-signed HTTP client for the Platform API. +// All commands that talk to the Platform API should use this package rather +// than inlining signing logic directly. +package client + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "net/http" + "time" + + awssdk "github.com/aws/aws-sdk-go-v2/aws" + v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4" + "github.com/openshift-online/rosa-regional-platform-cli/internal/aws" + "github.com/openshift-online/rosa-regional-platform-cli/internal/config" +) + +const ( + defaultRegion = "us-east-1" + defaultTimeout = 30 * time.Second +) + +// Client is a SigV4-signed HTTP client for the Platform API. Construct one +// with New and reuse it across calls — it holds a single *http.Client and +// resolved AWS credentials. +type Client struct { + baseURL string + creds awssdk.Credentials + region string + httpClient *http.Client +} + +// New resolves the Platform API URL and AWS credentials once and returns a +// ready-to-use Client. All commands should call New at the start of their +// RunE handler and pass the Client down to any helpers that need it. +func New(ctx context.Context) (*Client, error) { + baseURL, err := config.GetPlatformAPIURL() + if err != nil { + return nil, err + } + + cfg, err := aws.NewConfig(ctx) + if err != nil { + return nil, fmt.Errorf("failed to load AWS config: %w", err) + } + + creds, err := cfg.Credentials.Retrieve(ctx) + if err != nil { + return nil, fmt.Errorf("failed to retrieve AWS credentials: %w", err) + } + + region := cfg.Region + if region == "" { + region = defaultRegion + } + + return &Client{ + baseURL: baseURL, + creds: creds, + region: region, + httpClient: &http.Client{ + Timeout: defaultTimeout, + }, + }, nil +} + +// BaseURL returns the Platform API base URL. +func (c *Client) BaseURL() string { + return c.baseURL +} + +// Region returns the AWS region the client is configured for. +func (c *Client) Region() string { + return c.region +} + +// Get performs a SigV4-signed GET request and returns the response body and +// status code. The caller is responsible for checking the status code. +func (c *Client) Get(ctx context.Context, path string) ([]byte, int, error) { + return c.do(ctx, http.MethodGet, path, nil) +} + +// Post performs a SigV4-signed POST request with a JSON body and returns the +// response body and status code. +func (c *Client) Post(ctx context.Context, path string, body []byte) ([]byte, int, error) { + return c.do(ctx, http.MethodPost, path, body) +} + +// Delete performs a SigV4-signed DELETE request and returns the response body +// and status code. +func (c *Client) Delete(ctx context.Context, path string) ([]byte, int, error) { + return c.do(ctx, http.MethodDelete, path, nil) +} + +// do is the single implementation of a signed HTTP request. All exported +// methods delegate here. +func (c *Client) do(ctx context.Context, method, path string, body []byte) ([]byte, int, error) { + url := c.baseURL + path + + var bodyReader io.Reader + if len(body) > 0 { + bodyReader = bytes.NewReader(body) + } + + req, err := http.NewRequestWithContext(ctx, method, url, bodyReader) + if err != nil { + return nil, 0, fmt.Errorf("failed to create request: %w", err) + } + + if len(body) > 0 { + req.Header.Set("Content-Type", "application/json") + } + + hash := sha256.Sum256(body) + payloadHash := hex.EncodeToString(hash[:]) + + signer := v4.NewSigner() + if err := signer.SignHTTP(ctx, c.creds, req, payloadHash, "execute-api", c.region, time.Now()); err != nil { + return nil, 0, fmt.Errorf("failed to sign request: %w", err) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, 0, fmt.Errorf("failed to execute request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, resp.StatusCode, fmt.Errorf("failed to read response: %w", err) + } + + return respBody, resp.StatusCode, nil +} diff --git a/internal/commands/cluster/api.go b/internal/commands/cluster/api.go index b3c8d0a..f61642b 100644 --- a/internal/commands/cluster/api.go +++ b/internal/commands/cluster/api.go @@ -2,58 +2,22 @@ package cluster import ( "context" - "crypto/sha256" - "encoding/hex" "encoding/json" "fmt" - "io" - "net/http" - "time" + "net/url" - awssdk "github.com/aws/aws-sdk-go-v2/aws" - v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4" + "github.com/openshift-online/rosa-regional-platform-cli/internal/client" ) -var httpClient = &http.Client{Timeout: 15 * time.Second} - -func signedGet(ctx context.Context, url string, creds awssdk.Credentials, region string) ([]byte, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - payloadHash := sha256.Sum256([]byte("")) - payloadHashStr := hex.EncodeToString(payloadHash[:]) - - signer := v4.NewSigner() - if err := signer.SignHTTP(ctx, creds, req, payloadHashStr, "execute-api", region, time.Now()); err != nil { - return nil, fmt.Errorf("failed to sign request: %w", err) - } - - resp, err := httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to execute request: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response: %w", err) - } - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body)) - } - - return body, nil -} - -func fetchAPIURL(ctx context.Context, baseURL, clusterID string, creds awssdk.Credentials, region string) (string, error) { - endpoint := fmt.Sprintf("%s/api/v0/clusters/%s/statuses", baseURL, clusterID) - body, err := signedGet(ctx, endpoint, creds, region) +func fetchAPIURL(ctx context.Context, c *client.Client, clusterID string) (string, error) { + path := fmt.Sprintf("/api/v0/clusters/%s/statuses", url.PathEscape(clusterID)) + body, statusCode, err := c.Get(ctx, path) if err != nil { return "", fmt.Errorf("failed to fetch cluster statuses: %w", err) } + if statusCode != 200 { + return "", fmt.Errorf("failed to fetch cluster statuses: status %d: %s", statusCode, string(body)) + } var envelope struct { ControllerStatuses []struct { @@ -74,23 +38,26 @@ func fetchAPIURL(ctx context.Context, baseURL, clusterID string, creds awssdk.Cr return "", nil } -func fetchClusterByName(ctx context.Context, baseURL, name string, creds awssdk.Credentials, region string) (*clusterItem, error) { +func fetchClusterByName(ctx context.Context, c *client.Client, name string) (*clusterItem, error) { const pageSize = 100 for offset := 0; ; offset += pageSize { - endpoint := fmt.Sprintf("%s/api/v0/clusters?limit=%d&offset=%d", baseURL, pageSize, offset) - body, err := signedGet(ctx, endpoint, creds, region) + path := fmt.Sprintf("/api/v0/clusters?limit=%d&offset=%d", pageSize, offset) + body, statusCode, err := c.Get(ctx, path) if err != nil { return nil, fmt.Errorf("failed to list clusters: %w", err) } + if statusCode != 200 { + return nil, fmt.Errorf("failed to list clusters: status %d: %s", statusCode, string(body)) + } var resp listResponse if err := json.Unmarshal(body, &resp); err != nil { return nil, fmt.Errorf("failed to parse cluster list: %w", err) } - for _, c := range resp.Items { - if c.Name == name || c.ID == name { - return &c, nil + for _, item := range resp.Items { + if item.Name == name || item.ID == name { + return &item, nil } } diff --git a/internal/commands/cluster/create.go b/internal/commands/cluster/create.go index 0e0472d..01c3b16 100644 --- a/internal/commands/cluster/create.go +++ b/internal/commands/cluster/create.go @@ -7,7 +7,7 @@ import ( "os" awsconfig "github.com/aws/aws-sdk-go-v2/config" - pkgconfig "github.com/openshift-online/rosa-regional-platform-cli/internal/config" + "github.com/openshift-online/rosa-regional-platform-cli/internal/client" clusterservice "github.com/openshift-online/rosa-regional-platform-cli/internal/services/cluster" "github.com/spf13/cobra" ) @@ -205,7 +205,7 @@ func runCreateDryRun(ctx context.Context, opts *createOptions) error { } func runCreateAndSubmit(ctx context.Context, opts *createOptions) error { - // Load AWS config + // Load AWS config (needed for CloudFormation calls in GenerateClusterConfig) cfg, err := awsconfig.LoadDefaultConfig(ctx, awsconfig.WithRegion(opts.region)) if err != nil { return fmt.Errorf("failed to load AWS config: %w", err) @@ -247,17 +247,14 @@ func runCreateAndSubmit(ctx context.Context, opts *createOptions) error { fmt.Fprintf(os.Stderr, "✓ Cluster configuration saved to: %s\n", opts.outputFile) } - // Get the platform API URL from config - baseURL, err := pkgconfig.GetPlatformAPIURL() + c, err := client.New(ctx) if err != nil { return err } - // Submit cluster to platform API submitReq := &clusterservice.SubmitClusterRequest{ - Payload: genResp.ClusterConfig, - PlatformAPIURL: baseURL, - AWSConfig: cfg, + Payload: genResp.ClusterConfig, + Client: c, } if opts.output != "json" { @@ -269,7 +266,6 @@ func runCreateAndSubmit(ctx context.Context, opts *createOptions) error { return err } - // Output response based on format if opts.output == "json" { jsonBytes, err := json.MarshalIndent(submitResp.Response, "", " ") if err != nil { @@ -277,7 +273,6 @@ func runCreateAndSubmit(ctx context.Context, opts *createOptions) error { } fmt.Println(string(jsonBytes)) } else { - // Extract and display key fields from response printClusterSummary(submitResp.Response) } @@ -285,37 +280,26 @@ func runCreateAndSubmit(ctx context.Context, opts *createOptions) error { } func runCreateWithPayload(ctx context.Context, opts *createOptions) error { - // Read the payload file payloadBytes, err := os.ReadFile(opts.payloadFile) if err != nil { return fmt.Errorf("failed to read payload file: %w", err) } - // Validate JSON var payload map[string]interface{} if err := json.Unmarshal(payloadBytes, &payload); err != nil { return fmt.Errorf("invalid JSON in payload file: %w", err) } - // Override cluster name with CLI argument (CLI arg takes precedence) if currentName, ok := payload["name"].(string); ok && currentName != opts.clusterName { fmt.Fprintf(os.Stderr, "Overriding cluster name: %s → %s\n", currentName, opts.clusterName) } payload["name"] = opts.clusterName - // Get the platform API URL from config - baseURL, err := pkgconfig.GetPlatformAPIURL() + c, err := client.New(ctx) if err != nil { return err } - // Load AWS config for SigV4 signing - cfg, err := awsconfig.LoadDefaultConfig(ctx, awsconfig.WithRegion(opts.region)) - if err != nil { - return fmt.Errorf("failed to load AWS config: %w", err) - } - - // Check if placement is being overridden var placementOverride string if opts.placementCluster != "" { if spec, ok := payload["spec"].(map[string]interface{}); ok { @@ -327,21 +311,17 @@ func runCreateWithPayload(ctx context.Context, opts *createOptions) error { } } - // Build service request req := &clusterservice.SubmitClusterRequest{ Payload: payload, - PlatformAPIURL: baseURL, + Client: c, PlacementOverride: placementOverride, - AWSConfig: cfg, } - // Submit cluster to platform API resp, err := clusterservice.SubmitCluster(ctx, req) if err != nil { return err } - // Output response based on format if opts.output == "json" { jsonBytes, err := json.MarshalIndent(resp.Response, "", " ") if err != nil { @@ -349,7 +329,6 @@ func runCreateWithPayload(ctx context.Context, opts *createOptions) error { } fmt.Println(string(jsonBytes)) } else { - // Extract and display key fields from response printClusterSummary(resp.Response) } diff --git a/internal/commands/cluster/kubeconfig.go b/internal/commands/cluster/kubeconfig.go index 64ecbf8..3b1f83c 100644 --- a/internal/commands/cluster/kubeconfig.go +++ b/internal/commands/cluster/kubeconfig.go @@ -9,8 +9,7 @@ import ( "path/filepath" "text/template" - "github.com/openshift-online/rosa-regional-platform-cli/internal/aws" - "github.com/openshift-online/rosa-regional-platform-cli/internal/config" + "github.com/openshift-online/rosa-regional-platform-cli/internal/client" "github.com/spf13/cobra" ) @@ -46,32 +45,17 @@ for AWS IAM authentication. Pipe the output to a file and use with kubectl: } func runKubeconfig(ctx context.Context, nameOrID string) error { - baseURL, err := config.GetPlatformAPIURL() + c, err := client.New(ctx) if err != nil { return err } - cfg, err := aws.NewConfig(ctx) - if err != nil { - return fmt.Errorf("failed to load AWS config: %w", err) - } - - creds, err := cfg.Credentials.Retrieve(ctx) - if err != nil { - return fmt.Errorf("failed to retrieve AWS credentials: %w", err) - } - - region := cfg.Region - if region == "" { - region = "us-east-1" - } - - cluster, err := fetchClusterByName(ctx, baseURL, nameOrID, creds, region) + cluster, err := fetchClusterByName(ctx, c, nameOrID) if err != nil { return err } - apiEndpoint, err := fetchAPIURL(ctx, baseURL, cluster.ID, creds, region) + apiEndpoint, err := fetchAPIURL(ctx, c, cluster.ID) if err != nil { return err } @@ -92,7 +76,7 @@ func runKubeconfig(ctx context.Context, nameOrID string) error { ClusterName: cluster.Name, RosactlPath: rosactlPath, ClusterID: cluster.ID, - Region: region, + Region: c.Region(), }); err != nil { return fmt.Errorf("failed to render kubeconfig: %w", err) } diff --git a/internal/commands/cluster/list.go b/internal/commands/cluster/list.go index 47f8f1c..5b31531 100644 --- a/internal/commands/cluster/list.go +++ b/internal/commands/cluster/list.go @@ -8,8 +8,7 @@ import ( "os" "text/tabwriter" - "github.com/openshift-online/rosa-regional-platform-cli/internal/aws" - "github.com/openshift-online/rosa-regional-platform-cli/internal/config" + "github.com/openshift-online/rosa-regional-platform-cli/internal/client" "github.com/spf13/cobra" ) @@ -82,35 +81,23 @@ Example: } func runList(ctx context.Context, opts *listOptions) error { - baseURL, err := config.GetPlatformAPIURL() + c, err := client.New(ctx) if err != nil { return err } - cfg, err := aws.NewConfig(ctx) - if err != nil { - return fmt.Errorf("failed to load AWS config: %w", err) - } - - creds, err := cfg.Credentials.Retrieve(ctx) - if err != nil { - return fmt.Errorf("failed to retrieve AWS credentials: %w", err) - } - - region := cfg.Region - if region == "" { - region = "us-east-1" - } - - endpoint := fmt.Sprintf("%s/api/v0/clusters?limit=%d&offset=%d", baseURL, opts.limit, opts.offset) + path := fmt.Sprintf("/api/v0/clusters?limit=%d&offset=%d", opts.limit, opts.offset) if opts.status != "" { - endpoint = fmt.Sprintf("%s&status=%s", endpoint, url.QueryEscape(opts.status)) + path = fmt.Sprintf("%s&status=%s", path, url.QueryEscape(opts.status)) } - body, err := signedGet(ctx, endpoint, creds, region) + body, statusCode, err := c.Get(ctx, path) if err != nil { return err } + if statusCode != 200 { + return fmt.Errorf("API request failed with status %d: %s", statusCode, string(body)) + } if opts.output == "json" { var result map[string]interface{} @@ -134,14 +121,11 @@ func runList(ctx context.Context, opts *listOptions) error { func displayTable(clusters []clusterItem) error { w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0) - // Print header if _, err := fmt.Fprintln(w, "ID\tNAME\tVERSION\tAVAILABLE\tREADY\tMESSAGE"); err != nil { return err } - // Print each cluster for _, cluster := range clusters { - // Extract status and message from conditions available := getConditionStatus(cluster.Status.Conditions, "Available") ready := getConditionStatus(cluster.Status.Conditions, "Ready") message := getConditionMessage(cluster.Status.Conditions, "Ready") @@ -171,19 +155,16 @@ func getConditionStatus(conditions []condition, condType string) string { } func getConditionMessage(conditions []condition, condType string) string { - // First try the specified condition type for _, cond := range conditions { if cond.Type == condType && cond.Message != "" { return cond.Message } } - // Fall back to Adapter1Successful which typically has the main status message for _, cond := range conditions { if cond.Type == "Adapter1Successful" && cond.Message != "" { return cond.Message } } - // Finally return any condition with a message for _, cond := range conditions { if cond.Message != "" { return cond.Message diff --git a/internal/services/cluster/service.go b/internal/services/cluster/service.go index 50a8aaa..69569de 100644 --- a/internal/services/cluster/service.go +++ b/internal/services/cluster/service.go @@ -1,23 +1,17 @@ package cluster import ( - "bytes" "context" - "crypto/sha256" - "encoding/hex" "encoding/json" "errors" "fmt" - "io" - "net/http" "strings" - "time" "unicode" "github.com/aws/aws-sdk-go-v2/aws" - v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4" "github.com/aws/aws-sdk-go-v2/service/sts" "github.com/openshift-online/rosa-regional-platform-cli/internal/aws/cloudformation" + "github.com/openshift-online/rosa-regional-platform-cli/internal/client" ) // GenerateClusterConfigRequest contains parameters for generating cluster configuration @@ -44,7 +38,7 @@ type GenerateClusterConfigResponse struct { // SubmitClusterRequest contains parameters for submitting cluster to platform API type SubmitClusterRequest struct { Payload map[string]interface{} - PlatformAPIURL string + Client *client.Client PlacementOverride string // Optional - overrides placement in payload if set AWSConfig aws.Config } @@ -196,68 +190,19 @@ func SubmitCluster(ctx context.Context, req *SubmitClusterRequest) (*SubmitClust } } - // Marshal payload to JSON payloadBytes, err := json.Marshal(payload) if err != nil { return nil, fmt.Errorf("failed to marshal payload: %w", err) } - // Build the API endpoint URL - endpoint := fmt.Sprintf("%s/api/v0/clusters", req.PlatformAPIURL) - - // Create HTTP request - httpReq, err := http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewReader(payloadBytes)) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - httpReq.Header.Set("Content-Type", "application/json") - - // Calculate SHA256 hash of the request body - hash := sha256.Sum256(payloadBytes) - payloadHash := hex.EncodeToString(hash[:]) - - // Sign the request with AWS SigV4 - signer := v4.NewSigner() - creds, err := req.AWSConfig.Credentials.Retrieve(ctx) - if err != nil { - return nil, fmt.Errorf("failed to retrieve AWS credentials: %w", err) - } - - // Determine the region from AWS config - region := req.AWSConfig.Region - if region == "" { - region = "us-east-1" // Default region - } - - err = signer.SignHTTP(ctx, creds, httpReq, payloadHash, "execute-api", region, time.Now()) + body, statusCode, err := req.Client.Post(ctx, "/api/v0/clusters", payloadBytes) if err != nil { - return nil, fmt.Errorf("failed to sign request: %w", err) - } - - // Execute the request - client := &http.Client{ - Timeout: 30 * time.Second, - } - - resp, err := client.Do(httpReq) - if err != nil { - return nil, fmt.Errorf("failed to execute request: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - // Read the response body - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response: %w", err) + return nil, err } - - // Check for error responses - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return nil, fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body)) + if statusCode < 200 || statusCode >= 300 { + return nil, fmt.Errorf("API request failed with status %d: %s", statusCode, string(body)) } - // Parse the JSON response var result map[string]interface{} if err := json.Unmarshal(body, &result); err != nil { return nil, fmt.Errorf("failed to parse response: %w", err) From d4ab6e252b1c863f21bd9e8ea467caabd7de2d16 Mon Sep 17 00:00:00 2001 From: Benji Date: Fri, 10 Jul 2026 14:20:14 +0000 Subject: [PATCH 2/3] fix: retain credentials provider instead of snapshot in client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Storing the resolved awssdk.Credentials value meant a reused Client would sign with an expired credential snapshot after the STS session expires. Store the CredentialsProvider from the AWS config instead and call Retrieve(ctx) on each request — the SDK's CredentialsCache handles thread-safe caching and refresh automatically. --- internal/client/client.go | 37 ++++++++++++++++++++----------------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/internal/client/client.go b/internal/client/client.go index f7947eb..1c1e1b6 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -26,17 +26,20 @@ const ( // Client is a SigV4-signed HTTP client for the Platform API. Construct one // with New and reuse it across calls — it holds a single *http.Client and -// resolved AWS credentials. +// a credentials provider that is consulted on every request so credentials +// are always fresh. type Client struct { - baseURL string - creds awssdk.Credentials - region string - httpClient *http.Client + baseURL string + credsProvider awssdk.CredentialsProvider + region string + httpClient *http.Client } -// New resolves the Platform API URL and AWS credentials once and returns a -// ready-to-use Client. All commands should call New at the start of their -// RunE handler and pass the Client down to any helpers that need it. +// New resolves the Platform API URL and AWS config and returns a ready-to-use +// Client. The credentials provider from the AWS config is retained so that +// credentials are refreshed automatically on each request — callers that reuse +// the Client (e.g. Lambda handlers or commands that fan out many calls) will +// never sign with expired credentials. func New(ctx context.Context) (*Client, error) { baseURL, err := config.GetPlatformAPIURL() if err != nil { @@ -48,20 +51,15 @@ func New(ctx context.Context) (*Client, error) { return nil, fmt.Errorf("failed to load AWS config: %w", err) } - creds, err := cfg.Credentials.Retrieve(ctx) - if err != nil { - return nil, fmt.Errorf("failed to retrieve AWS credentials: %w", err) - } - region := cfg.Region if region == "" { region = defaultRegion } return &Client{ - baseURL: baseURL, - creds: creds, - region: region, + baseURL: baseURL, + credsProvider: cfg.Credentials, + region: region, httpClient: &http.Client{ Timeout: defaultTimeout, }, @@ -118,8 +116,13 @@ func (c *Client) do(ctx context.Context, method, path string, body []byte) ([]by hash := sha256.Sum256(body) payloadHash := hex.EncodeToString(hash[:]) + creds, err := c.credsProvider.Retrieve(ctx) + if err != nil { + return nil, 0, fmt.Errorf("failed to retrieve AWS credentials: %w", err) + } + signer := v4.NewSigner() - if err := signer.SignHTTP(ctx, c.creds, req, payloadHash, "execute-api", c.region, time.Now()); err != nil { + if err := signer.SignHTTP(ctx, creds, req, payloadHash, "execute-api", c.region, time.Now()); err != nil { return nil, 0, fmt.Errorf("failed to sign request: %w", err) } From e36eb1a663ec2ead73b2d6f25599ccdcc3974abc Mon Sep 17 00:00:00 2001 From: Pete Savage Date: Fri, 10 Jul 2026 16:12:36 +0100 Subject: [PATCH 3/3] Format the client --- internal/client/client.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/client/client.go b/internal/client/client.go index 1c1e1b6..3c5fcd1 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -29,10 +29,10 @@ const ( // a credentials provider that is consulted on every request so credentials // are always fresh. type Client struct { - baseURL string + baseURL string credsProvider awssdk.CredentialsProvider - region string - httpClient *http.Client + region string + httpClient *http.Client } // New resolves the Platform API URL and AWS config and returns a ready-to-use