Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 141 additions & 0 deletions internal/client/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
// 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
// a credentials provider that is consulted on every request so credentials
// are always fresh.
type Client struct {
baseURL string
credsProvider awssdk.CredentialsProvider
region string
httpClient *http.Client
}

// 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 {
return nil, err
}

cfg, err := aws.NewConfig(ctx)
if err != nil {
return nil, fmt.Errorf("failed to load AWS config: %w", err)
}

region := cfg.Region
if region == "" {
region = defaultRegion
}

return &Client{
baseURL: baseURL,
credsProvider: cfg.Credentials,
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[:])

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, 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
}
67 changes: 17 additions & 50 deletions internal/commands/cluster/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
}
}

Expand Down
35 changes: 7 additions & 28 deletions internal/commands/cluster/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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" {
Expand All @@ -269,53 +266,40 @@ 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 {
return fmt.Errorf("failed to marshal response: %w", err)
}
fmt.Println(string(jsonBytes))
} else {
// Extract and display key fields from response
printClusterSummary(submitResp.Response)
}

return nil
}

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 {
Expand All @@ -327,29 +311,24 @@ 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 {
return fmt.Errorf("failed to marshal response: %w", err)
}
fmt.Println(string(jsonBytes))
} else {
// Extract and display key fields from response
printClusterSummary(resp.Response)
}

Expand Down
Loading