Skip to content
Merged
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
5 changes: 3 additions & 2 deletions providers/aws/cloudwatch/cloudwatch.go
Original file line number Diff line number Diff line change
Expand Up @@ -657,8 +657,9 @@ func toAlarmInfo(a *alarmData) driver.AlarmInfo {
Metrics: metricmath.Clone(a.Metrics),
ThresholdMetricID: a.ThresholdMetricID,

EvaluateLowSampleCountPercentile: a.EvaluateLowSampleCountPercentile,
EvaluationWindow: cloneWindow(a.EvaluationWindow),
AlarmConfigurationUpdatedTimestamp: a.ConfigUpdatedAt,
EvaluateLowSampleCountPercentile: a.EvaluateLowSampleCountPercentile,
EvaluationWindow: cloneWindow(a.EvaluationWindow),
}
}

Expand Down
18 changes: 18 additions & 0 deletions providers/aws/cloudwatch/metric_streams.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ package cloudwatch
import (
"context"
"sort"
"strings"
"time"

"github.com/stackshy/cloudemu/v2/errors"
Expand Down Expand Up @@ -111,6 +112,12 @@ func validateMetricStreamConfig(cfg *driver.MetricStreamConfig) error {
return errors.Newf(errors.InvalidArgument, "RoleArn is required")
}

for _, p := range [...]struct{ name, arn string }{{"FirehoseArn", cfg.FirehoseARN}, {"RoleArn", cfg.RoleARN}} {
if !validARN(p.arn) {
return errors.Newf(errors.InvalidArgument, "The value %s for parameter %s is not a valid ARN.", p.arn, p.name)
}
}

if !validMetricStreamOutputFormats[cfg.OutputFormat] {
return errors.Newf(errors.InvalidArgument, "invalid OutputFormat %q", cfg.OutputFormat)
}
Expand All @@ -122,6 +129,17 @@ func validateMetricStreamConfig(cfg *driver.MetricStreamConfig) error {
return nil
}

// validARN reports whether s has the arn:partition:service:region:account:resource
// shape, with the partition, service and resource set.
func validARN(s string) bool {
parts := strings.SplitN(s, ":", arnParts)

return len(parts) == arnParts && parts[0] == "arn" && parts[1] != "" && parts[2] != "" && parts[5] != ""
}

// arnParts is the number of colon-separated fields in an ARN.
const arnParts = 6

// GetMetricStream returns the named metric stream, or NotFound (the
// CloudWatch ResourceNotFoundException) when it does not exist.
func (m *Mock) GetMetricStream(_ context.Context, name string) (*driver.MetricStreamInfo, error) {
Expand Down
1 change: 1 addition & 0 deletions server/aws/authzgate.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ var jsonRPCServiceByTarget = map[string]string{
"Route53Resolver.": "route53resolver",
"AWSEvents.": "events",
"Logs_20140328.": "logs",
"GraniteServiceVersion20100801.": "cloudwatch",
"SageMaker.": "sagemaker",
"secretsmanager.": "secretsmanager",
"KeyspacesService.": "cassandra",
Expand Down
110 changes: 110 additions & 0 deletions server/aws/authzgate_cloudwatch_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
package aws

import (
"context"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"

"github.com/aws/aws-sdk-go-v2/aws"
v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4"

cloudemu "github.com/stackshy/cloudemu/v2"
iamdriver "github.com/stackshy/cloudemu/v2/services/iam/driver"
)

// cloudWatchJSONProbe answers any CloudWatch awsJson1_0 request with 200.
type cloudWatchJSONProbe struct{}

func (cloudWatchJSONProbe) Matches(r *http.Request) bool {
return strings.HasPrefix(r.Header.Get("X-Amz-Target"), "GraniteServiceVersion20100801.")
}

func (cloudWatchJSONProbe) ServeHTTP(w http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(w, "{}")
}

func signedCloudWatchJSONRequest(t *testing.T, url, op string, creds aws.Credentials) *http.Request {
t.Helper()

body := `{}`

req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, url, strings.NewReader(body))
if err != nil {
t.Fatalf("new request: %v", err)
}

req.Header.Set("X-Amz-Target", "GraniteServiceVersion20100801."+op)
req.Header.Set("Content-Type", "application/x-amz-json-1.0")

sum := sha256.Sum256([]byte(body))
if err := v4.NewSigner().SignHTTP(
context.Background(), creds, req, hex.EncodeToString(sum[:]), "monitoring", "us-east-1", time.Now(),
); err != nil {
t.Fatalf("sign: %v", err)
}

return req
}

// TestAuthzGateCloudWatchJSON checks that the authorization gate maps a
// GraniteServiceVersion20100801 target to the cloudwatch IAM service, so an
// allow on cloudwatch:* lets boto3's CloudWatch calls through and an explicit
// deny still blocks its action.
func TestAuthzGateCloudWatchJSON(t *testing.T) {
cloud := cloudemu.NewAWS()
ctx := context.Background()

if _, err := cloud.IAM.CreateUser(ctx, iamdriver.UserConfig{Name: "cw"}); err != nil {
t.Fatalf("CreateUser: %v", err)
}

doc := `{"Version":"2012-10-17","Statement":[` +
`{"Effect":"Allow","Action":"cloudwatch:*","Resource":"*"},` +
`{"Effect":"Deny","Action":"cloudwatch:DeleteAlarms","Resource":"*"}]}`

pol, err := cloud.IAM.CreatePolicy(ctx, iamdriver.PolicyConfig{Name: "cwpol", PolicyDocument: doc})
if err != nil {
t.Fatalf("CreatePolicy: %v", err)
}

if err := cloud.IAM.AttachUserPolicy(ctx, "cw", pol.ARN); err != nil {
t.Fatalf("AttachUserPolicy: %v", err)
}

ak, err := cloud.IAM.CreateAccessKey(ctx, iamdriver.AccessKeyConfig{UserName: "cw"})
if err != nil {
t.Fatalf("CreateAccessKey: %v", err)
}

srv := New(Drivers{IAM: cloud.IAM, AccountID: "123456789012", Region: "us-east-1", EnforceAuth: true})
srv.Register(cloudWatchJSONProbe{})

ts := httptest.NewServer(srv)
defer ts.Close()

creds := aws.Credentials{AccessKeyID: ak.AccessKeyID, SecretAccessKey: ak.SecretAccessKey}

for op, want := range map[string]int{
"DescribeAlarms": http.StatusOK,
"PutMetricData": http.StatusOK,
"DeleteAlarms": http.StatusForbidden,
} {
resp, err := http.DefaultClient.Do(signedCloudWatchJSONRequest(t, ts.URL+"/", op, creds))
if err != nil {
t.Fatalf("%s: %v", op, err)
}

raw, _ := io.ReadAll(resp.Body)
resp.Body.Close()

if resp.StatusCode != want {
t.Fatalf("%s: status %d, want %d: %s", op, resp.StatusCode, want, raw)
}
}
}
55 changes: 55 additions & 0 deletions server/aws/cloudwatch/core_alarm_statistics.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ func validateAlarmStatistic(cfg *mondriver.AlarmConfig) error {
return newWireError(errInvalidParameterCombo, "Exactly one of Statistic or ExtendedStatistic must be specified.")
case cfg.Stat == "" && cfg.ExtendedStatistic == "":
return newWireError(errValidation, "Exactly one of Statistic or ExtendedStatistic must be specified.")
case cfg.Stat != "" && !validStatistics[cfg.Stat]:
return newWireError(errValidation, "1 validation error detected: Value '"+cfg.Stat+
"' at 'statistic' failed to satisfy constraint: Member must satisfy enum value set: "+
"[Maximum, SampleCount, Sum, Minimum, Average]")
case cfg.ExtendedStatistic != "":
if _, err := alarmeval.ParseExtendedStatistic(cfg.ExtendedStatistic); err != nil {
return newWireError(errValidation, "The value "+cfg.ExtendedStatistic+
Expand All @@ -40,6 +44,57 @@ func validateAlarmStatistic(cfg *mondriver.AlarmConfig) error {
return nil
}

// validStatistics is the closed Statistic enum.
//
//nolint:gochecknoglobals // fixed lookup table for a closed enum.
var validStatistics = map[string]bool{
statSampleCount: true, statAverage: true, statSum: true, statMinimum: true, statMaximum: true,
}

// How far back, in seconds, EvaluationPeriods * Period may reach: one day
// for a Period under an hour, seven days for a Period of an hour or more.
const (
maxAlarmLookbackDay = 86400
maxAlarmLookbackWeek = 604800
hourPeriod = 3600
)

// validateAlarmPeriods checks Period and DatapointsToAlarm. A single-metric
// alarm's Period is 10, 20, 30 or a multiple of 60, and EvaluationPeriods *
// Period is capped at a day, or at a week when Period is an hour or more.
func validateAlarmPeriods(cfg *mondriver.AlarmConfig) error {
if cfg.DatapointsToAlarm > 0 && cfg.EvaluationPeriods > 0 && cfg.DatapointsToAlarm > cfg.EvaluationPeriods {
return newWireError(errValidation, "DatapointsToAlarm must be less than or equal to EvaluationPeriods.")
}

if len(cfg.Metrics) > 0 || cfg.Period == 0 {
return nil
}

if !validPeriod(cfg.Period) {
return newWireError(errValidation, "Period must be 10, 20, 30 or a multiple of 60")
}

return validateAlarmLookback(cfg.Period, cfg.EvaluationPeriods)
}

// validateAlarmLookback checks EvaluationPeriods * Period against the day or
// week cap that applies to period p.
func validateAlarmLookback(p, evaluationPeriods int) error {
lookback := p * max(evaluationPeriods, 1)

switch {
case p < hourPeriod && lookback > maxAlarmLookbackDay:
return newWireError(errValidation, "Metrics cannot be checked across more than a day "+
"(EvaluationPeriods * Period must be <= 86400)")
case p >= hourPeriod && lookback > maxAlarmLookbackWeek:
return newWireError(errValidation, "Metrics cannot be checked across more than a week "+
"(EvaluationPeriods * Period must be <= 604800)")
}

return nil
}

// validateLowSample checks EvaluateLowSampleCountPercentile.
func validateLowSample(v string) error {
switch v {
Expand Down
4 changes: 4 additions & 0 deletions server/aws/cloudwatch/core_alarms.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ func (h *Handler) putMetricAlarmCore(
return err
}

if err := validateAlarmPeriods(cfg); err != nil {
return err
}

if err := validateLowSample(cfg.EvaluateLowSampleCountPercentile); err != nil {
return err
}
Expand Down
8 changes: 8 additions & 0 deletions server/aws/cloudwatch/core_metrics.go
Original file line number Diff line number Diff line change
Expand Up @@ -481,6 +481,9 @@ func setStat(dp *datapoint, stat requestedStat, value float64) {
}
}

// maxDatumDimensions is the most dimensions one metric can have.
const maxDatumDimensions = 30

// putMetricDataCore validates every datum and then stores them. One bad
// datum rejects the whole request, so nothing is stored.
func (h *Handler) putMetricDataCore(ctx context.Context, in *putMetricDataInput) error {
Expand All @@ -489,6 +492,11 @@ func (h *Handler) putMetricDataCore(ctx context.Context, in *putMetricDataInput)
for i := range in.MetricData {
d := &in.MetricData[i]

if len(d.Dimensions) > maxDatumDimensions {
return newWireError(errInvalidParameterValue, "The collection MetricData.member."+strconv.Itoa(i+1)+
".Dimensions must not have a size greater than "+strconv.Itoa(maxDatumDimensions)+".")
}

if d.Unit != "" && !alarmeval.ValidUnit(d.Unit) {
return newWireError(errInvalidParameterValue, "The parameter MetricData.member."+strconv.Itoa(i+1)+
".Unit must be a value in the set ["+strings.Join(alarmeval.Units(), ", ")+"]")
Expand Down
17 changes: 16 additions & 1 deletion server/aws/cloudwatch/dashboards.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ package cloudwatch

import (
"context"
"encoding/json"
"net/http"
"time"

Expand All @@ -24,6 +25,20 @@ type dashboardStore interface {
DeleteDashboards(ctx context.Context, names []string) error
}

// errInvalidParameterInput is the query code of DashboardInvalidInputError.
const errInvalidParameterInput = "InvalidParameterInput"

// putDashboardCore stores a dashboard after checking that its body is a JSON
// object, which is what PutDashboard rejects with InvalidParameterInput.
func putDashboardCore(ctx context.Context, store dashboardStore, name, body string) error {
var doc map[string]any
if err := json.Unmarshal([]byte(body), &doc); err != nil || doc == nil {
return newWireError(errInvalidParameterInput, "The field DashboardBody must be a valid JSON object")
}

return store.PutDashboard(ctx, name, body)
}

type putDashboardInput struct {
DashboardName string `cbor:"DashboardName"`
DashboardBody string `cbor:"DashboardBody"`
Expand Down Expand Up @@ -53,7 +68,7 @@ func (h *Handler) putDashboard(w http.ResponseWriter, r *http.Request, body []by
return
}

if err := store.PutDashboard(r.Context(), in.DashboardName, in.DashboardBody); err != nil {
if err := putDashboardCore(r.Context(), store, in.DashboardName, in.DashboardBody); err != nil {
writeDriverErr(w, err)
return
}
Expand Down
36 changes: 29 additions & 7 deletions server/aws/cloudwatch/handler.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// Package cloudwatch implements AWS CloudWatch's Smithy RPC-v2-CBOR protocol
// as a server.Handler.
// Package cloudwatch implements AWS CloudWatch as a server.Handler over the
// three protocols its clients speak: Smithy RPC-v2-CBOR, awsJson1_0 and query.
//
// Modern aws-sdk-go-v2 CloudWatch clients no longer use the AWS query protocol.
// They send CBOR-encoded request bodies to URLs like
Expand All @@ -9,7 +9,9 @@
// Content-Type: application/cbor
//
// This handler matches those requests, decodes CBOR, dispatches to the
// monitoring driver, and writes CBOR responses.
// monitoring driver, and writes CBOR responses. botocore 1.43+ sends
// awsJson1_0 instead (json_protocol.go), and the AWS CLI v2 and older SDKs
// send query (query.go). All three run through the same per-op cores.
package cloudwatch

import (
Expand Down Expand Up @@ -89,19 +91,27 @@ func (h *Handler) SetIPAMMetrics(ipam netdriver.IPAMMetrics) {
h.ipam = ipam
}

// Matches returns true for Smithy rpc-v2-cbor requests, and for classic
// Matches returns true for Smithy rpc-v2-cbor requests, for awsJson1_0
// requests whose X-Amz-Target names the CloudWatch service, and for classic
// query-protocol CloudWatch requests (used by the AWS CLI and older SDKs),
// disambiguated from EC2 by the SigV4 "monitoring" credential scope.
func (*Handler) Matches(r *http.Request) bool {
if r.Header.Get(protocolHeader) == protocolValue && strings.HasPrefix(r.URL.Path, pathPrefix) {
return true
}

return isQueryRequest(r)
return isJSONRequest(r) || isQueryRequest(r)
}

// ServeHTTP parses the URL path for the operation name and dispatches.
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
decodeRequestBody(r)

if r.Header.Get(protocolHeader) != protocolValue && isJSONRequest(r) {
h.serveJSON(w, r)
return
}

if isQueryRequest(r) {
h.serveQuery(w, r)
return
Expand Down Expand Up @@ -203,8 +213,14 @@ func extractOperation(path string) string {
return path[i+len(opMarker):]
}

// writeCBORError writes an rpc-v2-cbor error response.
// writeCBORError writes an rpc-v2-cbor error response, or the awsJson1_0
// error when w is the writer of a JSON request.
func writeCBORError(w http.ResponseWriter, status int, errType, msg string) {
if jw, ok := w.(*jsonWriter); ok {
jw.writeError(status, errType, msg)
return
}

payload := map[string]any{
"__type": errType,
"message": msg,
Expand Down Expand Up @@ -232,8 +248,14 @@ func mustSmithyEncMode() cbor.EncMode {
return mode
}

// writeCBORResponse writes a successful rpc-v2-cbor response body.
// writeCBORResponse writes a successful rpc-v2-cbor response body, or the
// awsJson1_0 body when w is the writer of a JSON request.
func writeCBORResponse(w http.ResponseWriter, payload any) {
if jw, ok := w.(*jsonWriter); ok {
jw.writeResult(payload)
return
}

body, err := smithyEncMode.Marshal(payload)
if err != nil {
writeCBORError(w, http.StatusInternalServerError, "InternalError", err.Error())
Expand Down
Loading
Loading