From db0936d170aac0ddd12313102a02541a8b752c22 Mon Sep 17 00:00:00 2001 From: Gajendra Malviya Date: Tue, 4 Aug 2026 14:48:27 +0530 Subject: [PATCH 01/45] fix(cloudwatch): serve the query protocol so the AWS CLI works (#319) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CloudWatch handler only matched rpc-v2-cbor requests, so the AWS CLI — which sends CloudWatch as the classic query protocol (form-encoded POST, Action=..., XML responses) — was stolen by the EC2 handler and every op returned InvalidAction. Add a query-protocol path to the CloudWatch handler, disambiguated from EC2 by the SigV4 credential scope service ("monitoring"). Implements PutMetricData, ListMetrics, GetMetricStatistics, PutMetricAlarm, DescribeAlarms, DeleteAlarms, and SetAlarmState (the last previously a gap). PutMetricData now defaults an absent timestamp to now (so GetMetricStatistics returns datapoints). Verified end-to-end with the real aws CLI; EC2 form-POST routing unaffected. Adds TestQueryProtocol regression test. --- server/aws/cloudwatch/handler.go | 15 +- server/aws/cloudwatch/query.go | 387 ++++++++++++++++++++++++++++ server/aws/cloudwatch/query_test.go | 100 +++++++ 3 files changed, 498 insertions(+), 4 deletions(-) create mode 100644 server/aws/cloudwatch/query.go create mode 100644 server/aws/cloudwatch/query_test.go diff --git a/server/aws/cloudwatch/handler.go b/server/aws/cloudwatch/handler.go index 5943fcff..522b7cf8 100644 --- a/server/aws/cloudwatch/handler.go +++ b/server/aws/cloudwatch/handler.go @@ -42,17 +42,24 @@ func New(m mondriver.Monitoring) *Handler { return &Handler{monitoring: m} } -// Matches returns true for Smithy rpc-v2-cbor requests. +// Matches returns true for Smithy rpc-v2-cbor requests, 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 { - return false + if r.Header.Get(protocolHeader) == protocolValue && strings.HasPrefix(r.URL.Path, pathPrefix) { + return true } - return strings.HasPrefix(r.URL.Path, pathPrefix) + return isQueryRequest(r) } // ServeHTTP parses the URL path for the operation name and dispatches. func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if isQueryRequest(r) { + h.serveQuery(w, r) + return + } + op := extractOperation(r.URL.Path) if op == "" { writeCBORError(w, http.StatusBadRequest, "InvalidRequest", "missing operation in path") diff --git a/server/aws/cloudwatch/query.go b/server/aws/cloudwatch/query.go new file mode 100644 index 00000000..a8642371 --- /dev/null +++ b/server/aws/cloudwatch/query.go @@ -0,0 +1,387 @@ +package cloudwatch + +// CloudWatch's AWS CLI (and older SDKs) use the classic AWS **query protocol** +// (form-encoded POST, `Action=...`, XML responses) rather than rpc-v2-cbor. +// This file adds that path so `aws cloudwatch ...` works against the emulator. +// Query requests are disambiguated from EC2 (which also claims form POSTs) by +// the SigV4 credential scope service, which is "monitoring" for CloudWatch. + +import ( + "encoding/xml" + "net/http" + "strconv" + "strings" + "time" + + cerrors "github.com/stackshy/cloudemu/v2/errors" + mondriver "github.com/stackshy/cloudemu/v2/services/monitoring/driver" +) + +const ( + queryNamespace = "http://monitoring.amazonaws.com/doc/2010-08-01/" + queryRequestID = "00000000-0000-0000-0000-000000000000" + sigV4Service = "monitoring" +) + +// isQueryRequest reports whether r is a CloudWatch query-protocol request: +// a form-encoded POST (or GET with Action) whose SigV4 credential scope names +// the "monitoring" service. +func isQueryRequest(r *http.Request) bool { + if r.Header.Get(protocolHeader) == protocolValue { + return false // rpc-v2-cbor, handled elsewhere + } + + if r.URL.Query().Get("Action") == "" && + !(r.Method == http.MethodPost && strings.HasPrefix(r.Header.Get("Content-Type"), "application/x-www-form-urlencoded")) { + return false + } + + return sigV4ScopeService(r.Header.Get("Authorization")) == sigV4Service +} + +// sigV4ScopeService extracts the service from a SigV4 Authorization header's +// credential scope: "Credential=AKID/20260101/us-east-1//aws4_request". +func sigV4ScopeService(auth string) string { + i := strings.Index(auth, "Credential=") + if i < 0 { + return "" + } + + scope := auth[i+len("Credential="):] + if j := strings.IndexByte(scope, ','); j >= 0 { + scope = scope[:j] + } + + parts := strings.Split(scope, "/") + if len(parts) < 5 { + return "" + } + + return parts[3] +} + +// serveQuery handles a CloudWatch query-protocol request. +func (h *Handler) serveQuery(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + writeQueryError(w, http.StatusBadRequest, "MalformedQueryString", err.Error()) + return + } + + switch r.Form.Get("Action") { + case "PutMetricData": + h.queryPutMetricData(w, r) + case "ListMetrics": + h.queryListMetrics(w, r) + case "GetMetricStatistics": + h.queryGetMetricStatistics(w, r) + case "PutMetricAlarm": + h.queryPutMetricAlarm(w, r) + case "DescribeAlarms": + h.queryDescribeAlarms(w, r) + case "DeleteAlarms": + h.queryDeleteAlarms(w, r) + case "SetAlarmState": + h.querySetAlarmState(w, r) + default: + writeQueryError(w, http.StatusBadRequest, "InvalidAction", "unsupported CloudWatch action: "+r.Form.Get("Action")) + } +} + +func (h *Handler) queryPutMetricData(w http.ResponseWriter, r *http.Request) { + ns := r.Form.Get("Namespace") + + var data []mondriver.MetricDatum + + for i := 1; ; i++ { + p := "MetricData.member." + strconv.Itoa(i) + "." + name := r.Form.Get(p + "MetricName") + if name == "" { + break + } + + val, _ := strconv.ParseFloat(r.Form.Get(p+"Value"), 64) + + ts := time.Now().UTC() + if raw := r.Form.Get(p + "Timestamp"); raw != "" { + if parsed, err := time.Parse(time.RFC3339, raw); err == nil { + ts = parsed + } + } + + data = append(data, mondriver.MetricDatum{ + Namespace: ns, MetricName: name, Value: val, Unit: r.Form.Get(p + "Unit"), + Dimensions: queryDimensions(r, p+"Dimensions.member."), Timestamp: ts, + }) + } + + if err := h.monitoring.PutMetricData(r.Context(), data); err != nil { + writeQueryDriverErr(w, err) + return + } + + writeQueryResponse(w, "PutMetricDataResponse", nil) +} + +func (h *Handler) queryListMetrics(w http.ResponseWriter, r *http.Request) { + names, err := h.monitoring.ListMetrics(r.Context(), r.Form.Get("Namespace")) + if err != nil { + writeQueryDriverErr(w, err) + return + } + + ns := r.Form.Get("Namespace") + members := make([]metricMemberXML, 0, len(names)) + + for _, n := range names { + members = append(members, metricMemberXML{Namespace: ns, MetricName: n}) + } + + writeQueryResponse(w, "ListMetricsResponse", listMetricsResultXML{Metrics: members}) +} + +func (h *Handler) queryGetMetricStatistics(w http.ResponseWriter, r *http.Request) { + stat := r.Form.Get("Statistics.member.1") + if stat == "" { + stat = "Average" + } + + start, _ := time.Parse(time.RFC3339, r.Form.Get("StartTime")) + end, _ := time.Parse(time.RFC3339, r.Form.Get("EndTime")) + period, _ := strconv.Atoi(r.Form.Get("Period")) + + res, err := h.monitoring.GetMetricData(r.Context(), mondriver.GetMetricInput{ + Namespace: r.Form.Get("Namespace"), MetricName: r.Form.Get("MetricName"), + Dimensions: queryDimensions(r, "Dimensions.member."), StartTime: start, EndTime: end, + Period: period, Stat: stat, + }) + if err != nil { + writeQueryDriverErr(w, err) + return + } + + var dps []datapointXML + + if res != nil { + for i := range res.Timestamps { + dp := datapointXML{Timestamp: res.Timestamps[i].UTC().Format(time.RFC3339), Unit: "Count"} + setQueryStat(&dp, stat, res.Values[i]) + dps = append(dps, dp) + } + } + + writeQueryResponse(w, "GetMetricStatisticsResponse", getStatsResultXML{Label: r.Form.Get("MetricName"), Datapoints: dps}) +} + +func (h *Handler) queryPutMetricAlarm(w http.ResponseWriter, r *http.Request) { + threshold, _ := strconv.ParseFloat(r.Form.Get("Threshold"), 64) + period, _ := strconv.Atoi(r.Form.Get("Period")) + evalPeriods, _ := strconv.Atoi(r.Form.Get("EvaluationPeriods")) + + err := h.monitoring.CreateAlarm(r.Context(), mondriver.AlarmConfig{ + Name: r.Form.Get("AlarmName"), Namespace: r.Form.Get("Namespace"), MetricName: r.Form.Get("MetricName"), + Dimensions: queryDimensions(r, "Dimensions.member."), ComparisonOperator: r.Form.Get("ComparisonOperator"), + Threshold: threshold, Period: period, EvaluationPeriods: evalPeriods, Stat: r.Form.Get("Statistic"), + AlarmActions: queryStringList(r, "AlarmActions.member."), OKActions: queryStringList(r, "OKActions.member."), + }) + if err != nil { + writeQueryDriverErr(w, err) + return + } + + writeQueryResponse(w, "PutMetricAlarmResponse", nil) +} + +func (h *Handler) queryDescribeAlarms(w http.ResponseWriter, r *http.Request) { + alarms, err := h.monitoring.DescribeAlarms(r.Context(), queryStringList(r, "AlarmNames.member.")) + if err != nil { + writeQueryDriverErr(w, err) + return + } + + members := make([]alarmMemberXML, 0, len(alarms)) + for i := range alarms { + members = append(members, alarmMemberXML{ + AlarmName: alarms[i].Name, Namespace: alarms[i].Namespace, MetricName: alarms[i].MetricName, + StateValue: alarms[i].State, ComparisonOperator: alarms[i].ComparisonOperator, Threshold: alarms[i].Threshold, + }) + } + + writeQueryResponse(w, "DescribeAlarmsResponse", describeAlarmsResultXML{MetricAlarms: members}) +} + +func (h *Handler) queryDeleteAlarms(w http.ResponseWriter, r *http.Request) { + for _, name := range queryStringList(r, "AlarmNames.member.") { + if err := h.monitoring.DeleteAlarm(r.Context(), name); err != nil { + writeQueryDriverErr(w, err) + return + } + } + + writeQueryResponse(w, "DeleteAlarmsResponse", nil) +} + +func (h *Handler) querySetAlarmState(w http.ResponseWriter, r *http.Request) { + err := h.monitoring.SetAlarmState(r.Context(), r.Form.Get("AlarmName"), r.Form.Get("StateValue"), r.Form.Get("StateReason")) + if err != nil { + writeQueryDriverErr(w, err) + return + } + + writeQueryResponse(w, "SetAlarmStateResponse", nil) +} + +// ---- form list helpers ---- + +func queryDimensions(r *http.Request, prefix string) map[string]string { + var out map[string]string + + for i := 1; ; i++ { + name := r.Form.Get(prefix + strconv.Itoa(i) + ".Name") + if name == "" { + break + } + + if out == nil { + out = map[string]string{} + } + + out[name] = r.Form.Get(prefix + strconv.Itoa(i) + ".Value") + } + + return out +} + +func queryStringList(r *http.Request, prefix string) []string { + var out []string + + for i := 1; ; i++ { + v := r.Form.Get(prefix + strconv.Itoa(i)) + if v == "" { + break + } + + out = append(out, v) + } + + return out +} + +func setQueryStat(dp *datapointXML, stat string, v float64) { + switch stat { + case "Sum": + dp.Sum = v + case "Minimum": + dp.Minimum = v + case "Maximum": + dp.Maximum = v + case "SampleCount": + dp.SampleCount = v + default: + dp.Average = v + } +} + +// ---- XML response shapes (query protocol, 2010-08-01) ---- + +type metricMemberXML struct { + Namespace string `xml:"Namespace"` + MetricName string `xml:"MetricName"` +} + +type listMetricsResultXML struct { + XMLName xml.Name `xml:"ListMetricsResult"` + Metrics []metricMemberXML `xml:"Metrics>member"` +} + +type datapointXML struct { + Timestamp string `xml:"Timestamp"` + SampleCount float64 `xml:"SampleCount,omitempty"` + Average float64 `xml:"Average,omitempty"` + Sum float64 `xml:"Sum,omitempty"` + Minimum float64 `xml:"Minimum,omitempty"` + Maximum float64 `xml:"Maximum,omitempty"` + Unit string `xml:"Unit,omitempty"` +} + +type getStatsResultXML struct { + XMLName xml.Name `xml:"GetMetricStatisticsResult"` + Label string `xml:"Label"` + Datapoints []datapointXML `xml:"Datapoints>member"` +} + +type alarmMemberXML struct { + AlarmName string `xml:"AlarmName"` + Namespace string `xml:"Namespace"` + MetricName string `xml:"MetricName"` + StateValue string `xml:"StateValue"` + ComparisonOperator string `xml:"ComparisonOperator"` + Threshold float64 `xml:"Threshold"` +} + +type describeAlarmsResultXML struct { + XMLName xml.Name `xml:"DescribeAlarmsResult"` + MetricAlarms []alarmMemberXML `xml:"MetricAlarms>member"` +} + +// writeQueryResponse writes an AWS query-protocol XML envelope. result may be +// nil for actions that return only ResponseMetadata. +func writeQueryResponse(w http.ResponseWriter, root string, result any) { + type meta struct { + RequestID string `xml:"RequestId"` + } + + var buf strings.Builder + + buf.WriteString(``) + buf.WriteString(`<` + root + ` xmlns="` + queryNamespace + `">`) + + if result != nil { + // result structs carry their own XMLName. + inner, err := xml.Marshal(result) + if err != nil { + writeQueryError(w, http.StatusInternalServerError, "InternalFailure", err.Error()) + return + } + + buf.Write(inner) + } + + m, _ := xml.Marshal(struct { + meta `xml:"ResponseMetadata"` + }{meta{RequestID: queryRequestID}}) + buf.Write(m) + buf.WriteString(``) + + w.Header().Set("Content-Type", "text/xml") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(buf.String())) +} + +func writeQueryError(w http.ResponseWriter, status int, code, msg string) { + w.Header().Set("Content-Type", "text/xml") + w.WriteHeader(status) + _, _ = w.Write([]byte(`Sender` + code + `` + xmlEscape(msg) + + `` + queryRequestID + ``)) +} + +func writeQueryDriverErr(w http.ResponseWriter, err error) { + code, status := "InternalFailure", http.StatusInternalServerError + + switch { + case cerrors.IsNotFound(err): + code, status = "ResourceNotFound", http.StatusNotFound + case cerrors.IsInvalidArgument(err): + code, status = "InvalidParameterValue", http.StatusBadRequest + } + + writeQueryError(w, status, code, err.Error()) +} + +func xmlEscape(s string) string { + var b strings.Builder + + _ = xml.EscapeText(&b, []byte(s)) + + return b.String() +} diff --git a/server/aws/cloudwatch/query_test.go b/server/aws/cloudwatch/query_test.go new file mode 100644 index 00000000..d76f736e --- /dev/null +++ b/server/aws/cloudwatch/query_test.go @@ -0,0 +1,100 @@ +package cloudwatch_test + +import ( + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/stackshy/cloudemu/v2/config" + cwprovider "github.com/stackshy/cloudemu/v2/providers/aws/cloudwatch" + cwserver "github.com/stackshy/cloudemu/v2/server/aws/cloudwatch" +) + +// monitoringAuth is a SigV4 Authorization header whose credential scope names +// the "monitoring" service — exactly what the AWS CLI sends for CloudWatch. +const monitoringAuth = "AWS4-HMAC-SHA256 Credential=test/20260804/us-east-1/monitoring/aws4_request, SignedHeaders=host, Signature=x" + +// TestQueryProtocol verifies the CloudWatch handler serves the classic query +// protocol (form-encoded POST + XML) that the AWS CLI uses — regression guard +// for issue #319 (CloudWatch was previously stolen by the EC2 handler). +func TestQueryProtocol(t *testing.T) { + h := cwserver.New(cwprovider.New(config.NewOptions())) + ts := httptest.NewServer(h) + + t.Cleanup(ts.Close) + + post := func(form url.Values) (int, string) { + req, _ := http.NewRequest(http.MethodPost, ts.URL+"/", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Authorization", monitoringAuth) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("post: %v", err) + } + defer resp.Body.Close() + + b, _ := io.ReadAll(resp.Body) + + return resp.StatusCode, string(b) + } + + // The handler must claim monitoring-scoped form POSTs. + if !h.Matches(mustReq(monitoringAuth)) { + t.Fatal("Matches should be true for a monitoring-scoped form POST") + } + // ...and must NOT claim ec2-scoped ones (those belong to the EC2 handler). + if h.Matches(mustReq(strings.Replace(monitoringAuth, "monitoring", "ec2", 1))) { + t.Fatal("Matches must be false for an ec2-scoped request") + } + + // PutMetricData → 200 + PutMetricDataResponse. + if code, body := post(url.Values{ + "Action": {"PutMetricData"}, "Namespace": {"MyApp"}, + "MetricData.member.1.MetricName": {"Requests"}, "MetricData.member.1.Value": {"42"}, + }); code != 200 || !strings.Contains(body, "PutMetricDataResponse") { + t.Fatalf("PutMetricData: code=%d body=%s", code, body) + } + + // ListMetrics → the metric we just put is present. + code, body := post(url.Values{"Action": {"ListMetrics"}, "Namespace": {"MyApp"}}) + if code != 200 || !strings.Contains(body, "Requests") { + t.Fatalf("ListMetrics: code=%d body=%s", code, body) + } + if !strings.Contains(body, "ListMetricsResult") { + t.Fatalf("ListMetrics missing result wrapper: %s", body) + } + + // PutMetricAlarm + DescribeAlarms round-trip. + if code, body := post(url.Values{ + "Action": {"PutMetricAlarm"}, "AlarmName": {"a1"}, "Namespace": {"MyApp"}, "MetricName": {"Requests"}, + "ComparisonOperator": {"GreaterThanThreshold"}, "EvaluationPeriods": {"1"}, "Period": {"60"}, + "Threshold": {"10"}, "Statistic": {"Average"}, + }); code != 200 { + t.Fatalf("PutMetricAlarm: code=%d body=%s", code, body) + } + + if code, body := post(url.Values{"Action": {"DescribeAlarms"}}); code != 200 || !strings.Contains(body, "a1") { + t.Fatalf("DescribeAlarms: code=%d body=%s", code, body) + } + + // SetAlarmState + DeleteAlarms. + if code, _ := post(url.Values{"Action": {"SetAlarmState"}, "AlarmName": {"a1"}, "StateValue": {"ALARM"}, "StateReason": {"t"}}); code != 200 { + t.Fatalf("SetAlarmState: code=%d", code) + } + + if code, _ := post(url.Values{"Action": {"DeleteAlarms"}, "AlarmNames.member.1": {"a1"}}); code != 200 { + t.Fatalf("DeleteAlarms: code=%d", code) + } +} + +func mustReq(auth string) *http.Request { + r, _ := http.NewRequest(http.MethodPost, "http://x/", strings.NewReader("Action=ListMetrics")) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + r.Header.Set("Authorization", auth) + + return r +} From ab8c259f05fc423f06f2af0a998d887c8755065a Mon Sep 17 00:00:00 2001 From: Gajendra Malviya Date: Tue, 4 Aug 2026 14:53:28 +0530 Subject: [PATCH 02/45] fix(sns): deliver published messages to SQS subscriptions (#319) SNS Publish stored the message but never delivered it to subscribers, so the SNS->SQS fan-out pattern silently dropped messages. Add an SQSDeliverer hook (satisfied by the SQS mock's new DeliverExternal, which enqueues by queue ARN) and fan out on Publish to every sqs-protocol subscription, wrapping the payload in the standard SNS Notification envelope. Wired via SetSQSDeliverer in aws.go. Verified end-to-end with the real aws CLI (subscribe + publish + receive). Adds TestSNSToSQSDelivery. --- providers/aws/aws.go | 2 + providers/aws/sns/delivery_test.go | 65 ++++++++++++++++++++++++++++++ providers/aws/sns/sns.go | 46 ++++++++++++++++++++- providers/aws/sqs/sqs.go | 23 +++++++++++ 4 files changed, 135 insertions(+), 1 deletion(-) create mode 100644 providers/aws/sns/delivery_test.go diff --git a/providers/aws/aws.go b/providers/aws/aws.go index 0adc960b..6cd6d0d8 100644 --- a/providers/aws/aws.go +++ b/providers/aws/aws.go @@ -205,6 +205,8 @@ func New(opts ...config.Option) *Provider { p.Redshift.SetMonitoring(p.CloudWatch) p.EKS.SetMonitoring(p.CloudWatch) p.SageMaker.SetMonitoring(p.CloudWatch) + // SNS -> SQS fan-out: publishes deliver to SQS-protocol subscriptions. + p.SNS.SetSQSDeliverer(p.SQS) p.ResourceDiscovery = resourcediscovery.New( resourcediscovery.ProviderAWS, o.AccountID, o.Region, diff --git a/providers/aws/sns/delivery_test.go b/providers/aws/sns/delivery_test.go new file mode 100644 index 00000000..fb510189 --- /dev/null +++ b/providers/aws/sns/delivery_test.go @@ -0,0 +1,65 @@ +package sns_test + +import ( + "context" + "encoding/json" + "testing" + + "github.com/stackshy/cloudemu/v2/config" + snsprovider "github.com/stackshy/cloudemu/v2/providers/aws/sns" + sqsprovider "github.com/stackshy/cloudemu/v2/providers/aws/sqs" + mqdriver "github.com/stackshy/cloudemu/v2/services/messagequeue/driver" + sndriver "github.com/stackshy/cloudemu/v2/services/notification/driver" +) + +// TestSNSToSQSDelivery is a regression guard for issue #319: publishing to an +// SNS topic with an SQS subscription must deliver the message to the queue +// (wrapped in the SNS notification envelope). +func TestSNSToSQSDelivery(t *testing.T) { + ctx := context.Background() + opts := config.NewOptions() + + sqs := sqsprovider.New(opts) + sns := snsprovider.New(opts) + sns.SetSQSDeliverer(sqs) + + q, err := sqs.CreateQueue(ctx, mqdriver.QueueConfig{Name: "inbox"}) + if err != nil { + t.Fatalf("CreateQueue: %v", err) + } + + topic, err := sns.CreateTopic(ctx, sndriver.TopicConfig{Name: "feed"}) + if err != nil { + t.Fatalf("CreateTopic: %v", err) + } + + if _, err := sns.Subscribe(ctx, sndriver.SubscriptionConfig{ + TopicID: topic.Name, Protocol: "sqs", Endpoint: q.ARN, + }); err != nil { + t.Fatalf("Subscribe: %v", err) + } + + if _, err := sns.Publish(ctx, sndriver.PublishInput{ + TopicID: topic.Name, Message: "hello", Subject: "hi", + }); err != nil { + t.Fatalf("Publish: %v", err) + } + + msgs, err := sqs.ReceiveMessages(ctx, mqdriver.ReceiveMessageInput{QueueURL: q.URL, MaxMessages: 10}) + if err != nil { + t.Fatalf("ReceiveMessages: %v", err) + } + + if len(msgs) != 1 { + t.Fatalf("expected 1 delivered message, got %d", len(msgs)) + } + + var envelope map[string]string + if err := json.Unmarshal([]byte(msgs[0].Body), &envelope); err != nil { + t.Fatalf("delivered body is not the SNS envelope JSON: %v (%s)", err, msgs[0].Body) + } + + if envelope["Type"] != "Notification" || envelope["Message"] != "hello" || envelope["TopicArn"] != topic.ResourceID { + t.Fatalf("unexpected envelope: %+v", envelope) + } +} diff --git a/providers/aws/sns/sns.go b/providers/aws/sns/sns.go index 95348a65..ddcd7dbf 100644 --- a/providers/aws/sns/sns.go +++ b/providers/aws/sns/sns.go @@ -3,8 +3,10 @@ package sns import ( "context" + "encoding/json" "maps" "sync" + "time" "github.com/stackshy/cloudemu/v2/config" "github.com/stackshy/cloudemu/v2/errors" @@ -33,11 +35,18 @@ type topicData struct { mu sync.RWMutex } +// SQSDeliverer delivers an SNS notification into an SQS queue identified by +// its ARN. The SQS mock satisfies this, enabling real SNS -> SQS fan-out. +type SQSDeliverer interface { + DeliverExternal(ctx context.Context, queueARN, body string) error +} + // Mock is an in-memory mock implementation of the AWS SNS service. type Mock struct { topics *memstore.Store[*topicData] opts *config.Options monitoring mondriver.Monitoring + sqs SQSDeliverer } // SetMonitoring sets the monitoring backend for auto-metric generation. @@ -45,6 +54,11 @@ func (m *Mock) SetMonitoring(mon mondriver.Monitoring) { m.monitoring = mon } +// SetSQSDeliverer wires the SQS backend so publishes fan out to SQS subscriptions. +func (m *Mock) SetSQSDeliverer(d SQSDeliverer) { + m.sqs = d +} + func (m *Mock) emitMetric(metricName string, value float64, unit string, dims map[string]string) { if m.monitoring == nil { return @@ -235,7 +249,7 @@ func (m *Mock) ListSubscriptions(_ context.Context, topicID string) ([]driver.Su } // Publish publishes a message to an SNS topic. -func (m *Mock) Publish(_ context.Context, input driver.PublishInput) (*driver.PublishOutput, error) { +func (m *Mock) Publish(ctx context.Context, input driver.PublishInput) (*driver.PublishOutput, error) { td, ok := m.topics.Get(input.TopicID) if !ok { return nil, errors.Newf(errors.NotFound, "topic %q not found", input.TopicID) @@ -262,9 +276,39 @@ func (m *Mock) Publish(_ context.Context, input driver.PublishInput) (*driver.Pu }) td.mu.Unlock() + m.fanOutToSQS(ctx, td, msgID, input) + dims := map[string]string{"TopicName": input.TopicID} m.emitMetric("NumberOfMessagesPublished", 1, "Count", dims) m.emitMetric("PublishSize", float64(len(input.Message)), "Bytes", dims) return &driver.PublishOutput{MessageID: msgID}, nil } + +// fanOutToSQS delivers a published message to every SQS-protocol subscription +// on the topic, wrapping it in the SNS notification envelope real SNS uses. +func (m *Mock) fanOutToSQS(ctx context.Context, td *topicData, msgID string, input driver.PublishInput) { + if m.sqs == nil { + return + } + + for _, sub := range td.subscriptions.All() { + if sub.Protocol != "sqs" || sub.Endpoint == "" { + continue + } + + envelope, err := json.Marshal(map[string]string{ + "Type": "Notification", + "MessageId": msgID, + "TopicArn": td.info.ResourceID, + "Subject": input.Subject, + "Message": input.Message, + "Timestamp": m.opts.Clock.Now().UTC().Format(time.RFC3339), + }) + if err != nil { + continue + } + + _ = m.sqs.DeliverExternal(ctx, sub.Endpoint, string(envelope)) + } +} diff --git a/providers/aws/sqs/sqs.go b/providers/aws/sqs/sqs.go index 4fb7b920..4797383e 100644 --- a/providers/aws/sqs/sqs.go +++ b/providers/aws/sqs/sqs.go @@ -108,6 +108,29 @@ func (m *Mock) RemoveTrigger(queueURL string) { delete(m.triggers, queueURL) } +// DeliverExternal enqueues body into the queue identified by ARN. It is used +// for cross-service delivery such as SNS -> SQS and EventBridge -> SQS, where +// the source only knows the target queue's ARN. Returns NotFound if no queue +// matches the ARN. +func (m *Mock) DeliverExternal(ctx context.Context, queueARN, body string) error { + var url string + + for _, qd := range m.queues.SortedValues() { + if qd.info.ARN == queueARN { + url = qd.info.URL + break + } + } + + if url == "" { + return errors.Newf(errors.NotFound, "no queue found for arn %q", queueARN) + } + + _, err := m.SendMessage(ctx, driver.SendMessageInput{QueueURL: url, Body: body}) + + return err +} + // CreateQueue creates a new SQS queue. func (m *Mock) CreateQueue(_ context.Context, cfg driver.QueueConfig) (*driver.QueueInfo, error) { if cfg.Name == "" { From fcf2aeaea51d618184baad2cf0d9ea2571382be3 Mon Sep 17 00:00:00 2001 From: Gajendra Malviya Date: Tue, 4 Aug 2026 14:59:13 +0530 Subject: [PATCH 03/45] fix(eventbridge): deliver matched-rule events to SQS targets (#319) PutEvents matched rules against patterns but never propagated events to their targets, so SQS-target subscribers received nothing. Wire an injected SQSDeliverer and deliver the standard EventBridge event envelope to every matched target whose ARN is an SQS queue. --- providers/aws/aws.go | 2 + providers/aws/eventbridge/delivery_test.go | 63 ++++++++++++++++++++++ providers/aws/eventbridge/eventbridge.go | 53 +++++++++++++++++- 3 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 providers/aws/eventbridge/delivery_test.go diff --git a/providers/aws/aws.go b/providers/aws/aws.go index 6cd6d0d8..f9f8d5bd 100644 --- a/providers/aws/aws.go +++ b/providers/aws/aws.go @@ -207,6 +207,8 @@ func New(opts ...config.Option) *Provider { p.SageMaker.SetMonitoring(p.CloudWatch) // SNS -> SQS fan-out: publishes deliver to SQS-protocol subscriptions. p.SNS.SetSQSDeliverer(p.SQS) + // EventBridge -> SQS: matched rules deliver events to SQS targets. + p.EventBridge.SetSQSDeliverer(p.SQS) p.ResourceDiscovery = resourcediscovery.New( resourcediscovery.ProviderAWS, o.AccountID, o.Region, diff --git a/providers/aws/eventbridge/delivery_test.go b/providers/aws/eventbridge/delivery_test.go new file mode 100644 index 00000000..f8d7ed36 --- /dev/null +++ b/providers/aws/eventbridge/delivery_test.go @@ -0,0 +1,63 @@ +package eventbridge_test + +import ( + "context" + "encoding/json" + "testing" + + "github.com/stackshy/cloudemu/v2/config" + ebprovider "github.com/stackshy/cloudemu/v2/providers/aws/eventbridge" + sqsprovider "github.com/stackshy/cloudemu/v2/providers/aws/sqs" + ebdriver "github.com/stackshy/cloudemu/v2/services/eventbus/driver" + mqdriver "github.com/stackshy/cloudemu/v2/services/messagequeue/driver" +) + +func TestEventBridgeToSQSDelivery(t *testing.T) { + ctx := context.Background() + opts := config.NewOptions() + + sqs := sqsprovider.New(opts) + eb := ebprovider.New(opts) + eb.SetSQSDeliverer(sqs) + + q, err := sqs.CreateQueue(ctx, mqdriver.QueueConfig{Name: "eb-target"}) + if err != nil { + t.Fatalf("CreateQueue: %v", err) + } + + if _, err := eb.PutRule(ctx, &ebdriver.RuleConfig{ + Name: "r-all", EventPattern: `{"source":["myapp"]}`, + }); err != nil { + t.Fatalf("PutRule: %v", err) + } + + if err := eb.PutTargets(ctx, "", "r-all", []ebdriver.Target{ + {ID: "1", ARN: q.ARN}, + }); err != nil { + t.Fatalf("PutTargets: %v", err) + } + + if _, err := eb.PutEvents(ctx, []ebdriver.Event{ + {Source: "myapp", DetailType: "order.created", Detail: `{"orderId":"42"}`}, + }); err != nil { + t.Fatalf("PutEvents: %v", err) + } + + msgs, err := sqs.ReceiveMessages(ctx, mqdriver.ReceiveMessageInput{QueueURL: q.URL, MaxMessages: 10}) + if err != nil { + t.Fatalf("ReceiveMessages: %v", err) + } + + if len(msgs) != 1 { + t.Fatalf("expected 1 delivered event, got %d", len(msgs)) + } + + var env map[string]any + if err := json.Unmarshal([]byte(msgs[0].Body), &env); err != nil { + t.Fatalf("body not JSON: %v (%s)", err, msgs[0].Body) + } + + if env["detail-type"] != "order.created" || env["source"] != "myapp" { + t.Fatalf("unexpected envelope: %+v", env) + } +} diff --git a/providers/aws/eventbridge/eventbridge.go b/providers/aws/eventbridge/eventbridge.go index b4ee2b0a..13eab5ab 100644 --- a/providers/aws/eventbridge/eventbridge.go +++ b/providers/aws/eventbridge/eventbridge.go @@ -7,6 +7,7 @@ import ( "encoding/json" "fmt" "maps" + "strings" "sync" "time" @@ -41,11 +42,17 @@ type busData struct { events []driver.Event } +// SQSDeliverer delivers an event to an SQS queue identified by its ARN. +type SQSDeliverer interface { + DeliverExternal(ctx context.Context, queueARN, body string) error +} + // Mock is an in-memory mock implementation of AWS EventBridge. type Mock struct { buses *memstore.Store[*busData] opts *config.Options monitoring mondriver.Monitoring + sqs SQSDeliverer } // SetMonitoring sets the monitoring backend for auto-metric generation. @@ -53,6 +60,11 @@ func (m *Mock) SetMonitoring(mon mondriver.Monitoring) { m.monitoring = mon } +// SetSQSDeliverer wires the SQS backend so PutEvents delivers to SQS targets. +func (m *Mock) SetSQSDeliverer(d SQSDeliverer) { + m.sqs = d +} + func (m *Mock) emitMetric(metricName string, value float64, dims map[string]string) { if m.monitoring == nil { return @@ -391,7 +403,7 @@ func (m *Mock) ListTargets(_ context.Context, eventBus, ruleName string) ([]driv } // PutEvents publishes events to the event bus. -func (m *Mock) PutEvents(_ context.Context, events []driver.Event) (*driver.PublishResult, error) { +func (m *Mock) PutEvents(ctx context.Context, events []driver.Event) (*driver.PublishResult, error) { result := &driver.PublishResult{ EventIDs: make([]string, 0, len(events)), } @@ -418,6 +430,7 @@ func (m *Mock) PutEvents(_ context.Context, events []driver.Event) (*driver.Publ m.storeEvent(bd, &events[i]) matched := m.MatchedRules(&events[i]) + m.deliverToTargets(ctx, matched, &events[i]) dims := map[string]string{"EventBusName": busName} m.emitMetric("PutEventsRequestCount", 1, dims) @@ -430,6 +443,44 @@ func (m *Mock) PutEvents(_ context.Context, events []driver.Event) (*driver.Publ return result, nil } +// deliverToTargets delivers an event to the SQS targets of matched rules, +// wrapping it in the standard EventBridge event envelope. +func (m *Mock) deliverToTargets(ctx context.Context, matched []driver.Rule, event *driver.Event) { + if m.sqs == nil { + return + } + + for i := range matched { + for _, t := range matched[i].Targets { + if t.ARN == "" || !strings.Contains(t.ARN, ":sqs:") { + continue + } + + detail := json.RawMessage(event.Detail) + if len(detail) == 0 { + detail = json.RawMessage("{}") + } + + body, err := json.Marshal(map[string]any{ + "version": "0", + "id": event.ID, + "detail-type": event.DetailType, + "source": event.Source, + "account": m.opts.AccountID, + "time": event.Time.UTC().Format(time.RFC3339), + "region": m.opts.Region, + "resources": event.Resources, + "detail": detail, + }) + if err != nil { + continue + } + + _ = m.sqs.DeliverExternal(ctx, t.ARN, string(body)) + } + } +} + // GetEventHistory retrieves event history for an event bus. func (m *Mock) GetEventHistory(_ context.Context, eventBus string, limit int) ([]driver.Event, error) { busName := eventBus From 2eb4dc5b137077c5f1e6d940cda47ef258c02df1 Mon Sep 17 00:00:00 2001 From: Gajendra Malviya Date: Tue, 4 Aug 2026 15:03:50 +0530 Subject: [PATCH 04/45] fix(ec2): return *.NotFound for Describe by nonexistent ID (#319) Describe{Vpcs,Instances,Volumes,SecurityGroups} with an explicit ID that does not exist returned an empty success instead of the resource-specific Invalid*.NotFound error real EC2 emits. That silently broke existence checks, Terraform refresh/drift detection, and wait-until-deleted polls. Providers now return NotFound for a missing explicit ID; the server handlers already map it to InvalidVpcID/InvalidInstanceID/InvalidVolume/ InvalidGroup.NotFound. --- providers/aws/ec2/ec2.go | 12 ++++++++++-- providers/aws/ec2/ec2_test.go | 8 ++++---- providers/aws/vpc/vpc.go | 12 ++++++++++++ providers/aws/vpc/vpc_test.go | 7 ++++--- 4 files changed, 30 insertions(+), 9 deletions(-) diff --git a/providers/aws/ec2/ec2.go b/providers/aws/ec2/ec2.go index 233b2b07..c6b6d9d6 100644 --- a/providers/aws/ec2/ec2.go +++ b/providers/aws/ec2/ec2.go @@ -404,7 +404,7 @@ func (m *Mock) describeCandidates(instanceIDs []string, hidden, includeManaged b for _, id := range instanceIDs { inst, ok := m.instances.Get(id) if !ok { - continue + return nil, cerrors.Newf(cerrors.NotFound, "instance %q not found", id) } if hiddenManaged(inst, hidden, includeManaged) { @@ -607,8 +607,16 @@ func (m *Mock) DeleteVolume(_ context.Context, id string) error { return nil } -// DescribeVolumes returns volumes matching the given IDs. +// DescribeVolumes returns volumes matching the given IDs. An explicit ID that +// does not exist yields InvalidVolume.NotFound, matching real EC2 (an empty +// success would break existence checks and Terraform drift detection). func (m *Mock) DescribeVolumes(_ context.Context, ids []string) ([]driver.VolumeInfo, error) { + for _, id := range ids { + if !m.volumes.Has(id) { + return nil, cerrors.Newf(cerrors.NotFound, "volume %q not found", id) + } + } + return describeResources(m.volumes, ids), nil } diff --git a/providers/aws/ec2/ec2_test.go b/providers/aws/ec2/ec2_test.go index e4231113..34a02fe3 100644 --- a/providers/aws/ec2/ec2_test.go +++ b/providers/aws/ec2/ec2_test.go @@ -376,10 +376,10 @@ func TestDeleteVolume(t *testing.T) { err = m.DeleteVolume(ctx, vol.ID) requireNoError(t, err) - // Should be gone - vols, err := m.DescribeVolumes(ctx, []string{vol.ID}) - requireNoError(t, err) - assertEqual(t, 0, len(vols)) + // Should be gone: describing the deleted ID now yields NotFound, + // matching real EC2 (InvalidVolume.NotFound) rather than empty success. + _, err = m.DescribeVolumes(ctx, []string{vol.ID}) + assertError(t, err, true) }) t.Run("not found", func(t *testing.T) { diff --git a/providers/aws/vpc/vpc.go b/providers/aws/vpc/vpc.go index 4d2865ba..7302c285 100644 --- a/providers/aws/vpc/vpc.go +++ b/providers/aws/vpc/vpc.go @@ -251,6 +251,12 @@ func (m *Mock) DescribeVPCs(_ context.Context, ids []string) ([]driver.VPCInfo, m.mu.RLock() defer m.mu.RUnlock() + for _, id := range ids { + if !m.vpcs.Has(id) { + return nil, errors.Newf(errors.NotFound, "vpc %q not found", id) + } + } + return describeResources(m.vpcs, ids, toVPCInfo), nil } @@ -376,6 +382,12 @@ func (m *Mock) DeleteSecurityGroup(_ context.Context, id string) error { // DescribeSecurityGroups returns security groups matching the given IDs, or all if ids is empty. func (m *Mock) DescribeSecurityGroups(_ context.Context, ids []string) ([]driver.SecurityGroupInfo, error) { + for _, id := range ids { + if !m.securityGroups.Has(id) { + return nil, errors.Newf(errors.NotFound, "security group %q not found", id) + } + } + return describeResources(m.securityGroups, ids, toSGInfo), nil } diff --git a/providers/aws/vpc/vpc_test.go b/providers/aws/vpc/vpc_test.go index 9616a350..0431c414 100644 --- a/providers/aws/vpc/vpc_test.go +++ b/providers/aws/vpc/vpc_test.go @@ -78,9 +78,10 @@ func TestDescribeVPCs(t *testing.T) { }) t.Run("nonexistent ID", func(t *testing.T) { - vpcs, err := m.DescribeVPCs(ctx, []string{"vpc-nope"}) - requireNoError(t, err) - assertEqual(t, 0, len(vpcs)) + // Real EC2 returns InvalidVpcID.NotFound for an explicit missing ID, + // not an empty success — existence checks and Terraform drift rely on it. + _, err := m.DescribeVPCs(ctx, []string{"vpc-nope"}) + assertError(t, err, true) }) _ = v2 // used to create second VPC From 070116ba4570b6363f6ea3feba40cec252f58e4f Mon Sep 17 00:00:00 2001 From: Gajendra Malviya Date: Tue, 4 Aug 2026 15:06:21 +0530 Subject: [PATCH 05/45] feat(sqs): serve GetQueueAttributes/SetQueueAttributes/PurgeQueue (#319) The provider already implemented these, but the JSON-RPC handler didn't dispatch them, so callers couldn't read a queue's ARN (required for DLQ wiring, Lambda event-source mappings, and S3->SQS notifications), resize a queue, or drain it. Wire the three operations through to the existing driver methods. --- server/aws/sqs/handler.go | 128 ++++++++++++++++++++++++++++++++++++- server/aws/sqs/sqs_test.go | 43 +++++++++++++ 2 files changed, 168 insertions(+), 3 deletions(-) diff --git a/server/aws/sqs/handler.go b/server/aws/sqs/handler.go index c22671d7..ba422dc6 100644 --- a/server/aws/sqs/handler.go +++ b/server/aws/sqs/handler.go @@ -2,14 +2,16 @@ // Modern aws-sdk-go-v2 SQS uses AwsJson1_0 with X-Amz-Target headers (since // SQS migrated off the legacy Query protocol in 2023). // -// MVP coverage: queue lifecycle + the synchronous send/receive/delete loop -// every consumer needs. Batch ops, ChangeMessageVisibility, attributes, and -// PurgeQueue are deferred to a follow-up — the portable +// Coverage: queue lifecycle, the synchronous send/receive/delete loop, +// queue attributes (GetQueueAttributes exposes the QueueArn that event-source +// mappings, DLQ wiring, and S3->SQS notifications depend on), and PurgeQueue. +// Batch ops and ChangeMessageVisibility remain deferred — the portable // messagequeue.MessageQueue driver supports them. package sqs import ( "net/http" + "strconv" "strings" cerrors "github.com/stackshy/cloudemu/v2/errors" @@ -56,6 +58,12 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.receiveMessage(w, r) case "DeleteMessage": h.deleteMessage(w, r) + case "GetQueueAttributes": + h.getQueueAttributes(w, r) + case "SetQueueAttributes": + h.setQueueAttributes(w, r) + case "PurgeQueue": + h.purgeQueue(w, r) default: wire.WriteJSONError(w, http.StatusBadRequest, "UnknownOperationException", "unknown operation: "+op) @@ -240,6 +248,120 @@ func (h *Handler) deleteMessage(w http.ResponseWriter, r *http.Request) { wire.WriteJSON(w, map[string]any{}) } +// numericAttrKeys are the SetQueueAttributes attributes the provider applies. +var numericAttrKeys = []string{ + "DelaySeconds", "VisibilityTimeout", "MaximumMessageSize", + "MessageRetentionPeriod", "ReceiveMessageWaitTimeSeconds", +} + +func (h *Handler) getQueueAttributes(w http.ResponseWriter, r *http.Request) { + var req struct { + QueueURL string `json:"QueueUrl"` + AttributeNames []string `json:"AttributeNames"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + attrs, err := h.mq.GetQueueAttributes(r.Context(), req.QueueURL) + if err != nil { + writeErr(w, err) + return + } + + info, err := h.mq.GetQueueInfo(r.Context(), req.QueueURL) + if err != nil { + writeErr(w, err) + return + } + + all := map[string]string{ + "QueueArn": info.ARN, + "ApproximateNumberOfMessages": strconv.Itoa(attrs.ApproximateMessageCount), + "ApproximateNumberOfMessagesNotVisible": strconv.Itoa(attrs.ApproximateNotVisibleCount), + "VisibilityTimeout": strconv.Itoa(attrs.VisibilityTimeout), + "DelaySeconds": strconv.Itoa(attrs.DelaySeconds), + "MaximumMessageSize": strconv.Itoa(attrs.MaximumMessageSize), + "MessageRetentionPeriod": strconv.Itoa(attrs.MessageRetentionPeriod), + "CreatedTimestamp": strconv.FormatInt(attrs.CreatedAt.Unix(), 10), + "LastModifiedTimestamp": strconv.FormatInt(attrs.LastModifiedAt.Unix(), 10), + "FifoQueue": strconv.FormatBool(attrs.FifoQueue), + } + if attrs.RedrivePolicy != "" { + all["RedrivePolicy"] = attrs.RedrivePolicy + } + + wire.WriteJSON(w, map[string]any{"Attributes": selectAttributes(all, req.AttributeNames)}) +} + +// selectAttributes returns the requested subset, or all when the caller asks +// for "All" or names nothing (real SQS semantics). +func selectAttributes(all map[string]string, names []string) map[string]string { + if len(names) == 0 { + return all + } + + for _, n := range names { + if n == "All" { + return all + } + } + + out := make(map[string]string, len(names)) + for _, n := range names { + if v, ok := all[n]; ok { + out[n] = v + } + } + + return out +} + +func (h *Handler) setQueueAttributes(w http.ResponseWriter, r *http.Request) { + var req struct { + QueueURL string `json:"QueueUrl"` + Attributes map[string]string `json:"Attributes"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + attrs := make(map[string]int, len(numericAttrKeys)) + for _, k := range numericAttrKeys { + if v, ok := req.Attributes[k]; ok { + if n, err := strconv.Atoi(v); err == nil { + attrs[k] = n + } + } + } + + if err := h.mq.SetQueueAttributes(r.Context(), req.QueueURL, attrs); err != nil { + writeErr(w, err) + return + } + + wire.WriteJSON(w, map[string]any{}) +} + +func (h *Handler) purgeQueue(w http.ResponseWriter, r *http.Request) { + var req struct { + QueueURL string `json:"QueueUrl"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + if err := h.mq.PurgeQueue(r.Context(), req.QueueURL); err != nil { + writeErr(w, err) + return + } + + wire.WriteJSON(w, map[string]any{}) +} + // writeErr maps CloudEmu canonical errors to SQS-shaped HTTP error responses. func writeErr(w http.ResponseWriter, err error) { switch { diff --git a/server/aws/sqs/sqs_test.go b/server/aws/sqs/sqs_test.go index 3e05641f..65881848 100644 --- a/server/aws/sqs/sqs_test.go +++ b/server/aws/sqs/sqs_test.go @@ -179,6 +179,49 @@ func TestUnknownOperation(t *testing.T) { // helpers -------------------------------------------------------------------- +// TestQueueAttributesAndPurge is a regression guard for issue #319: the SQS +// handler previously did not dispatch GetQueueAttributes/SetQueueAttributes/ +// PurgeQueue, so callers couldn't read a queue's ARN (needed for DLQ wiring, +// event-source mappings, and S3->SQS notifications) or resize/drain a queue. +func TestQueueAttributesAndPurge(t *testing.T) { + srv, _ := newServer(t) + + create := postJSON(t, srv, "AmazonSQS.CreateQueue", `{"QueueName":"attrq"}`) + qurl := extractQueueURL(t, create) + + // GetQueueAttributes(All) must surface QueueArn. + got := readBody(t, postJSON(t, srv, "AmazonSQS.GetQueueAttributes", + `{"QueueUrl":"`+qurl+`","AttributeNames":["All"]}`)) + if !strings.Contains(got, `"QueueArn":"arn:aws:sqs:`) { + t.Fatalf("GetQueueAttributes missing QueueArn: %s", got) + } + + // SetQueueAttributes persists a numeric attribute. + if resp := postJSON(t, srv, "AmazonSQS.SetQueueAttributes", + `{"QueueUrl":"`+qurl+`","Attributes":{"VisibilityTimeout":"45"}}`); resp.StatusCode != http.StatusOK { + t.Fatalf("SetQueueAttributes status = %d", resp.StatusCode) + } + + got = readBody(t, postJSON(t, srv, "AmazonSQS.GetQueueAttributes", + `{"QueueUrl":"`+qurl+`","AttributeNames":["VisibilityTimeout"]}`)) + if !strings.Contains(got, `"VisibilityTimeout":"45"`) { + t.Fatalf("SetQueueAttributes not applied: %s", got) + } + + // PurgeQueue drains messages. + postJSON(t, srv, "AmazonSQS.SendMessage", `{"QueueUrl":"`+qurl+`","MessageBody":"x"}`) + if resp := postJSON(t, srv, "AmazonSQS.PurgeQueue", + `{"QueueUrl":"`+qurl+`"}`); resp.StatusCode != http.StatusOK { + t.Fatalf("PurgeQueue status = %d", resp.StatusCode) + } + + got = readBody(t, postJSON(t, srv, "AmazonSQS.GetQueueAttributes", + `{"QueueUrl":"`+qurl+`","AttributeNames":["ApproximateNumberOfMessages"]}`)) + if !strings.Contains(got, `"ApproximateNumberOfMessages":"0"`) { + t.Fatalf("PurgeQueue left messages: %s", got) + } +} + func postJSON(t *testing.T, srv *httptest.Server, target, body string) *http.Response { t.Helper() From 6496d941373066a9d248098c590e6f31d39c9d9d Mon Sep 17 00:00:00 2001 From: Gajendra Malviya Date: Tue, 4 Aug 2026 15:09:55 +0530 Subject: [PATCH 06/45] feat(lambda): route configuration, version, and alias sub-paths (#319) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UpdateFunctionConfiguration, PublishVersion/ListVersionsByFunction, and the alias lifecycle (create/get/list/update/delete) returned 404 "unsupported Lambda path" — the driver implemented them but the REST handler only dispatched the collection, resource, and invoke shapes. Add routing for /{name}/configuration, /{name}/versions, /{name}/aliases, and /{name}/aliases/{alias}. Resource policies (AddPermission), tagging, and event-source mappings remain follow-ups. --- server/aws/lambda/handler.go | 211 +++++++++++++++++++++++++++++-- server/aws/lambda/lambda_test.go | 93 ++++++++++++++ server/aws/lambda/types.go | 43 +++++++ 3 files changed, 338 insertions(+), 9 deletions(-) diff --git a/server/aws/lambda/handler.go b/server/aws/lambda/handler.go index bce82ef0..1a6f61d6 100644 --- a/server/aws/lambda/handler.go +++ b/server/aws/lambda/handler.go @@ -3,10 +3,12 @@ // registered with this handler and operations work against an in-memory // serverless driver. // -// MVP coverage: CreateFunction, GetFunction, ListFunctions, DeleteFunction, -// Invoke (synchronous). Versions, aliases, layers, concurrency configs, and -// event source mappings are not yet wired through — the driver supports them -// but the wire surface is deferred to a follow-up. +// Coverage: CreateFunction, GetFunction, ListFunctions, DeleteFunction, +// Invoke (synchronous), UpdateFunctionConfiguration, PublishVersion / +// ListVersionsByFunction, and the alias lifecycle (create/get/list/update/ +// delete). Layers, concurrency configs, resource policies (AddPermission), +// tagging, and event source mappings remain deferred — the driver supports +// some of them but the wire surface is not yet wired through. package lambda import ( @@ -64,16 +66,19 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { name := parts[0] const ( - partsResource = 1 // /functions/{name} - partsInvoke = 2 // /functions/{name}/invocations + partsResource = 1 // /functions/{name} + partsSubresource = 2 // /functions/{name}/{sub} + partsSubItem = 3 // /functions/{name}/{sub}/{id} ) switch len(parts) { case partsResource: h.serveResource(w, r, name) - case partsInvoke: - if parts[1] == "invocations" { - h.serveInvoke(w, r, name) + case partsSubresource: + h.serveSubresource(w, r, name, parts[1]) + case partsSubItem: + if parts[1] == "aliases" { + h.serveAlias(w, r, name, parts[2]) return } @@ -83,6 +88,194 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } } +// serveSubresource dispatches /functions/{name}/{sub} paths. +func (h *Handler) serveSubresource(w http.ResponseWriter, r *http.Request, name, sub string) { + switch sub { + case "invocations": + h.serveInvoke(w, r, name) + case "configuration": + h.serveConfiguration(w, r, name) + case "versions": + h.serveVersions(w, r, name) + case "aliases": + h.serveAliases(w, r, name) + default: + writeError(w, http.StatusNotFound, "ResourceNotFoundException", "unsupported Lambda path") + } +} + +// serveConfiguration handles PUT .../{name}/configuration +// (UpdateFunctionConfiguration). +func (h *Handler) serveConfiguration(w http.ResponseWriter, r *http.Request, name string) { + if r.Method != http.MethodPut { + writeError(w, http.StatusMethodNotAllowed, "InvalidRequestException", "method not allowed") + return + } + + var req updateFunctionConfigurationRequest + if !decodeJSON(w, r, &req) { + return + } + + cfg := sdrv.FunctionConfig{ + Name: name, + Runtime: req.Runtime, + Handler: req.Handler, + Memory: req.MemorySize, + Timeout: req.Timeout, + } + if req.Environment != nil { + cfg.Environment = req.Environment.Variables + } + + info, err := h.fn.UpdateFunction(r.Context(), name, cfg) + if err != nil { + writeErr(w, err) + return + } + + writeJSON(w, http.StatusOK, toConfiguration(info)) +} + +// serveVersions handles POST (PublishVersion) and GET (ListVersionsByFunction) +// on .../{name}/versions. +func (h *Handler) serveVersions(w http.ResponseWriter, r *http.Request, name string) { + switch r.Method { + case http.MethodPost: + var req publishVersionRequest + if !decodeJSON(w, r, &req) { + return + } + + ver, err := h.fn.PublishVersion(r.Context(), name, req.Description) + if err != nil { + writeErr(w, err) + return + } + + info, err := h.fn.GetFunction(r.Context(), name) + if err != nil { + writeErr(w, err) + return + } + + cfg := toConfiguration(info) + cfg.Version = ver.Version + cfg.Description = ver.Description + writeJSON(w, http.StatusCreated, cfg) + case http.MethodGet: + vers, err := h.fn.ListVersions(r.Context(), name) + if err != nil { + writeErr(w, err) + return + } + + info, err := h.fn.GetFunction(r.Context(), name) + if err != nil { + writeErr(w, err) + return + } + + out := listVersionsResponse{Versions: make([]functionConfiguration, 0, len(vers))} + for i := range vers { + cfg := toConfiguration(info) + cfg.Version = vers[i].Version + cfg.Description = vers[i].Description + out.Versions = append(out.Versions, cfg) + } + + writeJSON(w, http.StatusOK, out) + default: + writeError(w, http.StatusMethodNotAllowed, "InvalidRequestException", "method not allowed") + } +} + +// serveAliases handles POST (CreateAlias) and GET (ListAliases) on +// .../{name}/aliases. +func (h *Handler) serveAliases(w http.ResponseWriter, r *http.Request, name string) { + switch r.Method { + case http.MethodPost: + var req aliasRequest + if !decodeJSON(w, r, &req) { + return + } + + a, err := h.fn.CreateAlias(r.Context(), sdrv.AliasConfig{ + FunctionName: name, Name: req.Name, + FunctionVersion: req.FunctionVersion, Description: req.Description, + }) + if err != nil { + writeErr(w, err) + return + } + + writeJSON(w, http.StatusCreated, toAliasResponse(a)) + case http.MethodGet: + aliases, err := h.fn.ListAliases(r.Context(), name) + if err != nil { + writeErr(w, err) + return + } + + out := listAliasesResponse{Aliases: make([]aliasResponse, 0, len(aliases))} + for i := range aliases { + out.Aliases = append(out.Aliases, toAliasResponse(&aliases[i])) + } + + writeJSON(w, http.StatusOK, out) + default: + writeError(w, http.StatusMethodNotAllowed, "InvalidRequestException", "method not allowed") + } +} + +// serveAlias handles GET/PUT/DELETE on .../{name}/aliases/{aliasName}. +func (h *Handler) serveAlias(w http.ResponseWriter, r *http.Request, name, aliasName string) { + switch r.Method { + case http.MethodGet: + a, err := h.fn.GetAlias(r.Context(), name, aliasName) + if err != nil { + writeErr(w, err) + return + } + + writeJSON(w, http.StatusOK, toAliasResponse(a)) + case http.MethodPut: + var req aliasRequest + if !decodeJSON(w, r, &req) { + return + } + + a, err := h.fn.UpdateAlias(r.Context(), sdrv.AliasConfig{ + FunctionName: name, Name: aliasName, + FunctionVersion: req.FunctionVersion, Description: req.Description, + }) + if err != nil { + writeErr(w, err) + return + } + + writeJSON(w, http.StatusOK, toAliasResponse(a)) + case http.MethodDelete: + if err := h.fn.DeleteAlias(r.Context(), name, aliasName); err != nil { + writeErr(w, err) + return + } + + w.WriteHeader(http.StatusNoContent) + default: + writeError(w, http.StatusMethodNotAllowed, "InvalidRequestException", "method not allowed") + } +} + +func toAliasResponse(a *sdrv.Alias) aliasResponse { + return aliasResponse{ + AliasArn: a.AliasARN, + Name: a.Name, + FunctionVersion: a.FunctionVersion, + Description: a.Description, + } +} + func (h *Handler) serveCollection(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: diff --git a/server/aws/lambda/lambda_test.go b/server/aws/lambda/lambda_test.go index 8003e096..59cdde6a 100644 --- a/server/aws/lambda/lambda_test.go +++ b/server/aws/lambda/lambda_test.go @@ -325,6 +325,8 @@ type functionShape struct { FunctionArn string `json:"FunctionArn"` Runtime string `json:"Runtime"` Handler string `json:"Handler"` + Timeout int `json:"Timeout"` + Version string `json:"Version"` Environment *envShape `json:"Environment"` } @@ -338,3 +340,94 @@ func postJSON(t *testing.T, url, body string) *http.Response { return resp } + +func doJSON(t *testing.T, method, url, body string) *http.Response { + t.Helper() + + req, err := http.NewRequest(method, url, strings.NewReader(body)) + if err != nil { + t.Fatalf("new %s %s: %v", method, url, err) + } + + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("%s %s: %v", method, url, err) + } + + return resp +} + +// TestConfigurationVersionsAliases is a regression guard for issue #319: the +// Lambda handler previously returned 404 "unsupported Lambda path" for +// UpdateFunctionConfiguration, PublishVersion, and the alias sub-resources. +func TestConfigurationVersionsAliases(t *testing.T) { + srv, _ := newServer(t) + base := srv.URL + "/2015-03-31/functions" + + if resp := postJSON(t, base, + `{"FunctionName":"fn","Runtime":"go1.x","Handler":"main","Timeout":10}`); resp.StatusCode != http.StatusCreated { + t.Fatalf("create status = %d", resp.StatusCode) + } + + // UpdateFunctionConfiguration. + resp := doJSON(t, http.MethodPut, base+"/fn/configuration", `{"Timeout":60,"MemorySize":256}`) + if resp.StatusCode != http.StatusOK { + t.Fatalf("update-configuration status = %d", resp.StatusCode) + } + + var cfg functionShape + decode(t, resp, &cfg) + + if cfg.Timeout != 60 { + t.Fatalf("Timeout = %d, want 60", cfg.Timeout) + } + + // PublishVersion. + resp = postJSON(t, base+"/fn/versions", `{"Description":"v1"}`) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("publish-version status = %d", resp.StatusCode) + } + + var ver functionShape + decode(t, resp, &ver) + + if ver.Version != "1" { + t.Fatalf("Version = %q, want 1", ver.Version) + } + + // CreateAlias + GetAlias. + resp = postJSON(t, base+"/fn/aliases", `{"Name":"prod","FunctionVersion":"1"}`) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create-alias status = %d", resp.StatusCode) + } + + resp = doJSON(t, http.MethodGet, base+"/fn/aliases/prod", "") + + var alias struct { + Name string `json:"Name"` + FunctionVersion string `json:"FunctionVersion"` + AliasArn string `json:"AliasArn"` + } + + decode(t, resp, &alias) + + if alias.Name != "prod" || alias.FunctionVersion != "1" { + t.Fatalf("get-alias = %+v", alias) + } + + if !strings.Contains(alias.AliasArn, ":function:fn:prod") { + t.Fatalf("AliasArn = %q", alias.AliasArn) + } +} + +func decode(t *testing.T, resp *http.Response, v any) { + t.Helper() + + defer resp.Body.Close() + + if err := json.NewDecoder(resp.Body).Decode(v); err != nil { + t.Fatalf("decode: %v", err) + } +} diff --git a/server/aws/lambda/types.go b/server/aws/lambda/types.go index 0b28a03b..da358270 100644 --- a/server/aws/lambda/types.go +++ b/server/aws/lambda/types.go @@ -21,6 +21,49 @@ type functionConfiguration struct { CodeSha256 string `json:"CodeSha256,omitempty"` Environment *envEnvelope `json:"Environment,omitempty"` PackageType string `json:"PackageType,omitempty"` + Version string `json:"Version,omitempty"` +} + +// updateFunctionConfigurationRequest captures the mutable fields of +// UpdateFunctionConfiguration (PUT .../{name}/configuration). +type updateFunctionConfigurationRequest struct { + Runtime string `json:"Runtime"` + Role string `json:"Role"` + Handler string `json:"Handler"` + Description string `json:"Description"` + MemorySize int `json:"MemorySize"` + Timeout int `json:"Timeout"` + Environment *envEnvelope `json:"Environment"` +} + +// publishVersionRequest is the body of PublishVersion (POST .../{name}/versions). +type publishVersionRequest struct { + Description string `json:"Description"` +} + +// listVersionsResponse is the ListVersionsByFunction envelope. +type listVersionsResponse struct { + Versions []functionConfiguration `json:"Versions"` +} + +// aliasRequest is the body of Create/UpdateAlias. +type aliasRequest struct { + Name string `json:"Name"` + FunctionVersion string `json:"FunctionVersion"` + Description string `json:"Description"` +} + +// aliasResponse is the AWS AliasConfiguration shape. +type aliasResponse struct { + AliasArn string `json:"AliasArn"` + Name string `json:"Name"` + FunctionVersion string `json:"FunctionVersion"` + Description string `json:"Description,omitempty"` +} + +// listAliasesResponse is the ListAliases envelope. +type listAliasesResponse struct { + Aliases []aliasResponse `json:"Aliases"` } // functionResource is the shape returned by GetFunction: From 85a08dd501dc106cf29d0924d256db76b9c2a11e Mon Sep 17 00:00:00 2001 From: Gajendra Malviya Date: Tue, 4 Aug 2026 15:13:37 +0530 Subject: [PATCH 07/45] feat(lambda): support AddPermission/GetPolicy/RemovePermission (#319) Terraform's aws_lambda_permission and the invoke grants S3/SNS/ EventBridge create were unreachable (404). Add a per-function resource- policy store in the AWS provider and route /{name}/policy(/{sid}). Resource policies are Lambda-specific, so the handler type-asserts an AWS-local policyManager rather than widening the portable Serverless driver (which Azure Functions and GCP Cloud Functions also implement). The emulator stores statements without evaluating them. --- providers/aws/lambda/lambda.go | 1 + providers/aws/lambda/policy.go | 106 +++++++++++++++++++++++++++ server/aws/lambda/handler.go | 103 ++++++++++++++++++++++++-- server/aws/lambda/lambda_test.go | 42 +++++++++++ server/aws/lambda/types.go | 8 ++ services/serverless/driver/driver.go | 9 +++ 6 files changed, 261 insertions(+), 8 deletions(-) create mode 100644 providers/aws/lambda/policy.go diff --git a/providers/aws/lambda/lambda.go b/providers/aws/lambda/lambda.go index d077f573..1f6942ce 100644 --- a/providers/aws/lambda/lambda.go +++ b/providers/aws/lambda/lambda.go @@ -51,6 +51,7 @@ type funcData struct { nextVersion int aliases *memstore.Store[*aliasData] concurrency *driver.ConcurrencyConfig + policy map[string]driver.PermissionStatement } // Mock is an in-memory mock implementation of AWS Lambda. diff --git a/providers/aws/lambda/policy.go b/providers/aws/lambda/policy.go new file mode 100644 index 00000000..7579f3a7 --- /dev/null +++ b/providers/aws/lambda/policy.go @@ -0,0 +1,106 @@ +package lambda + +import ( + "context" + "encoding/json" + + cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/services/serverless/driver" +) + +// AddPermission adds a statement to a function's resource-based policy. This +// backs Terraform's aws_lambda_permission and the grants S3/SNS/EventBridge +// create to invoke a function. The emulator stores statements without +// evaluating them — invocation is never actually denied. +func (m *Mock) AddPermission(_ context.Context, functionName string, stmt driver.PermissionStatement) error { + if stmt.StatementID == "" { + return cerrors.New(cerrors.InvalidArgument, "StatementId is required") + } + + m.mu.Lock() + defer m.mu.Unlock() + + fd, ok := m.funcs.Get(functionName) + if !ok { + return cerrors.Newf(cerrors.NotFound, "function %s not found", functionName) + } + + if fd.policy == nil { + fd.policy = make(map[string]driver.PermissionStatement) + } + + if _, exists := fd.policy[stmt.StatementID]; exists { + return cerrors.Newf(cerrors.AlreadyExists, "statement %s already exists", stmt.StatementID) + } + + fd.policy[stmt.StatementID] = stmt + m.funcs.Set(functionName, fd) + + return nil +} + +// RemovePermission drops a statement from a function's resource-based policy. +func (m *Mock) RemovePermission(_ context.Context, functionName, statementID string) error { + m.mu.Lock() + defer m.mu.Unlock() + + fd, ok := m.funcs.Get(functionName) + if !ok { + return cerrors.Newf(cerrors.NotFound, "function %s not found", functionName) + } + + if _, exists := fd.policy[statementID]; !exists { + return cerrors.Newf(cerrors.NotFound, "statement %s not found", statementID) + } + + delete(fd.policy, statementID) + m.funcs.Set(functionName, fd) + + return nil +} + +// GetPolicy returns the function's resource-based policy as a JSON document, +// matching the shape the AWS SDK expects (IAM policy with Sid/Principal/ +// Action/Resource per statement). Returns NotFound when no policy exists. +func (m *Mock) GetPolicy(_ context.Context, functionName string) (string, error) { + m.mu.Lock() + defer m.mu.Unlock() + + fd, ok := m.funcs.Get(functionName) + if !ok { + return "", cerrors.Newf(cerrors.NotFound, "function %s not found", functionName) + } + + if len(fd.policy) == 0 { + return "", cerrors.Newf(cerrors.NotFound, "no policy for function %s", functionName) + } + + statements := make([]map[string]any, 0, len(fd.policy)) + for _, s := range fd.policy { + stmt := map[string]any{ + "Sid": s.StatementID, + "Effect": "Allow", + "Principal": map[string]string{"Service": s.Principal}, + "Action": s.Action, + "Resource": fd.info.ARN, + } + if s.SourceARN != "" { + stmt["Condition"] = map[string]any{ + "ArnLike": map[string]string{"AWS:SourceArn": s.SourceARN}, + } + } + + statements = append(statements, stmt) + } + + doc, err := json.Marshal(map[string]any{ + "Version": "2012-10-17", + "Id": "default", + "Statement": statements, + }) + if err != nil { + return "", err + } + + return string(doc), nil +} diff --git a/server/aws/lambda/handler.go b/server/aws/lambda/handler.go index 1a6f61d6..22ff596d 100644 --- a/server/aws/lambda/handler.go +++ b/server/aws/lambda/handler.go @@ -5,13 +5,15 @@ // // Coverage: CreateFunction, GetFunction, ListFunctions, DeleteFunction, // Invoke (synchronous), UpdateFunctionConfiguration, PublishVersion / -// ListVersionsByFunction, and the alias lifecycle (create/get/list/update/ -// delete). Layers, concurrency configs, resource policies (AddPermission), -// tagging, and event source mappings remain deferred — the driver supports -// some of them but the wire surface is not yet wired through. +// ListVersionsByFunction, the alias lifecycle (create/get/list/update/ +// delete), and resource policies (AddPermission / GetPolicy / +// RemovePermission). Layers, concurrency configs, tagging, and event source +// mappings remain deferred — the driver supports some of them but the wire +// surface is not yet wired through. package lambda import ( + "context" "encoding/json" "io" "net/http" @@ -31,6 +33,16 @@ const ( maxBodyBytes = 6 << 20 // 6 MiB — Lambda's sync invocation payload limit. ) +// policyManager is the AWS-specific resource-policy surface (AddPermission / +// GetPolicy / RemovePermission). It's not part of the portable Serverless +// driver — resource policies are a Lambda concept — so the handler type-asserts +// for it rather than requiring every cloud's function provider to implement it. +type policyManager interface { + AddPermission(ctx context.Context, functionName string, stmt sdrv.PermissionStatement) error + RemovePermission(ctx context.Context, functionName, statementID string) error + GetPolicy(ctx context.Context, functionName string) (string, error) +} + // Handler serves AWS Lambda REST requests against a serverless.Serverless // driver. type Handler struct { @@ -77,12 +89,14 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { case partsSubresource: h.serveSubresource(w, r, name, parts[1]) case partsSubItem: - if parts[1] == "aliases" { + switch parts[1] { + case "aliases": h.serveAlias(w, r, name, parts[2]) - return + case "policy": + h.serveRemovePermission(w, r, name, parts[2]) + default: + writeError(w, http.StatusNotFound, "ResourceNotFoundException", "unsupported Lambda path") } - - writeError(w, http.StatusNotFound, "ResourceNotFoundException", "unsupported Lambda path") default: writeError(w, http.StatusNotFound, "ResourceNotFoundException", "unsupported Lambda path") } @@ -99,11 +113,84 @@ func (h *Handler) serveSubresource(w http.ResponseWriter, r *http.Request, name, h.serveVersions(w, r, name) case "aliases": h.serveAliases(w, r, name) + case "policy": + h.servePolicy(w, r, name) default: writeError(w, http.StatusNotFound, "ResourceNotFoundException", "unsupported Lambda path") } } +// servePolicy handles POST (AddPermission) and GET (GetPolicy) on +// .../{name}/policy. +func (h *Handler) servePolicy(w http.ResponseWriter, r *http.Request, name string) { + pm, ok := h.fn.(policyManager) + if !ok { + writeError(w, http.StatusNotImplemented, "InvalidRequestException", "resource policies not supported") + return + } + + switch r.Method { + case http.MethodPost: + var req addPermissionRequest + if !decodeJSON(w, r, &req) { + return + } + + err := pm.AddPermission(r.Context(), name, sdrv.PermissionStatement{ + StatementID: req.StatementID, Action: req.Action, + Principal: req.Principal, SourceARN: req.SourceArn, + }) + if err != nil { + writeErr(w, err) + return + } + + stmt, jerr := json.Marshal(map[string]any{ + "Sid": req.StatementID, + "Effect": "Allow", + "Principal": map[string]string{"Service": req.Principal}, + "Action": req.Action, + }) + if jerr != nil { + writeErr(w, jerr) + return + } + + writeJSON(w, http.StatusCreated, map[string]string{"Statement": string(stmt)}) + case http.MethodGet: + policy, err := pm.GetPolicy(r.Context(), name) + if err != nil { + writeErr(w, err) + return + } + + writeJSON(w, http.StatusOK, map[string]string{"Policy": policy, "RevisionId": "1"}) + default: + writeError(w, http.StatusMethodNotAllowed, "InvalidRequestException", "method not allowed") + } +} + +// serveRemovePermission handles DELETE .../{name}/policy/{statementId}. +func (h *Handler) serveRemovePermission(w http.ResponseWriter, r *http.Request, name, statementID string) { + if r.Method != http.MethodDelete { + writeError(w, http.StatusMethodNotAllowed, "InvalidRequestException", "method not allowed") + return + } + + pm, ok := h.fn.(policyManager) + if !ok { + writeError(w, http.StatusNotImplemented, "InvalidRequestException", "resource policies not supported") + return + } + + if err := pm.RemovePermission(r.Context(), name, statementID); err != nil { + writeErr(w, err) + return + } + + w.WriteHeader(http.StatusNoContent) +} + // serveConfiguration handles PUT .../{name}/configuration // (UpdateFunctionConfiguration). func (h *Handler) serveConfiguration(w http.ResponseWriter, r *http.Request, name string) { diff --git a/server/aws/lambda/lambda_test.go b/server/aws/lambda/lambda_test.go index 59cdde6a..d15ac750 100644 --- a/server/aws/lambda/lambda_test.go +++ b/server/aws/lambda/lambda_test.go @@ -422,6 +422,48 @@ func TestConfigurationVersionsAliases(t *testing.T) { } } +// TestResourcePolicy is a regression guard for issue #319: AddPermission, +// GetPolicy, and RemovePermission (Terraform's aws_lambda_permission) were +// unreachable. It also verifies the AWS-local policyManager assertion path. +func TestResourcePolicy(t *testing.T) { + srv, _ := newServer(t) + base := srv.URL + "/2015-03-31/functions" + + if resp := postJSON(t, base, + `{"FunctionName":"pf","Runtime":"go1.x","Handler":"main"}`); resp.StatusCode != http.StatusCreated { + t.Fatalf("create status = %d", resp.StatusCode) + } + + // AddPermission. + resp := postJSON(t, base+"/pf/policy", + `{"StatementId":"s3invoke","Action":"lambda:InvokeFunction","Principal":"s3.amazonaws.com","SourceArn":"arn:aws:s3:::b"}`) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("add-permission status = %d", resp.StatusCode) + } + + // GetPolicy surfaces the statement. + resp = doJSON(t, http.MethodGet, base+"/pf/policy", "") + + var got struct { + Policy string `json:"Policy"` + } + + decode(t, resp, &got) + + if !strings.Contains(got.Policy, `"Sid":"s3invoke"`) { + t.Fatalf("policy missing statement: %s", got.Policy) + } + + // RemovePermission, then GetPolicy must 404. + if resp := doJSON(t, http.MethodDelete, base+"/pf/policy/s3invoke", ""); resp.StatusCode != http.StatusNoContent { + t.Fatalf("remove-permission status = %d", resp.StatusCode) + } + + if resp := doJSON(t, http.MethodGet, base+"/pf/policy", ""); resp.StatusCode != http.StatusNotFound { + t.Fatalf("get-policy after remove status = %d, want 404", resp.StatusCode) + } +} + func decode(t *testing.T, resp *http.Response, v any) { t.Helper() diff --git a/server/aws/lambda/types.go b/server/aws/lambda/types.go index da358270..2b55cd86 100644 --- a/server/aws/lambda/types.go +++ b/server/aws/lambda/types.go @@ -66,6 +66,14 @@ type listAliasesResponse struct { Aliases []aliasResponse `json:"Aliases"` } +// addPermissionRequest is the body of AddPermission (POST .../{name}/policy). +type addPermissionRequest struct { + StatementID string `json:"StatementId"` + Action string `json:"Action"` + Principal string `json:"Principal"` + SourceArn string `json:"SourceArn"` +} + // functionResource is the shape returned by GetFunction: // {Configuration, Code, Tags}. Code is a placeholder since the driver // doesn't persist deployment artifacts. diff --git a/services/serverless/driver/driver.go b/services/serverless/driver/driver.go index 60b5d292..06776a79 100644 --- a/services/serverless/driver/driver.go +++ b/services/serverless/driver/driver.go @@ -12,6 +12,15 @@ type FunctionVersion struct { CreatedAt string } +// PermissionStatement is one statement of a function's resource-based policy, +// added via AddPermission (Terraform's aws_lambda_permission). +type PermissionStatement struct { + StatementID string + Action string + Principal string + SourceARN string +} + // AliasConfig configures a function alias. type AliasConfig struct { FunctionName string From 6e53fd240f32acc48d69e6580661899789cc57e2 Mon Sep 17 00:00:00 2001 From: Gajendra Malviya Date: Tue, 4 Aug 2026 15:15:40 +0530 Subject: [PATCH 08/45] feat(ecr): implement GetAuthorizationToken (#319) docker login and image push/pull authenticate via GetAuthorizationToken, which was unimplemented. The AWS ECR provider now returns a base64 "AWS:" credential, the registry proxy endpoint, and a 12h expiry. Registry auth is ECR-specific, so the handler type-asserts an AWS-local authTokenProvider rather than widening the shared ContainerRegistry driver (Azure ACR and GCP Artifact Registry also implement it). --- providers/aws/ecr/authtoken.go | 24 +++++++++++++++++ server/aws/ecr/handler.go | 38 +++++++++++++++++++++++++++ server/aws/ecr/sdk_roundtrip_test.go | 39 ++++++++++++++++++++++++++++ 3 files changed, 101 insertions(+) create mode 100644 providers/aws/ecr/authtoken.go diff --git a/providers/aws/ecr/authtoken.go b/providers/aws/ecr/authtoken.go new file mode 100644 index 00000000..55556406 --- /dev/null +++ b/providers/aws/ecr/authtoken.go @@ -0,0 +1,24 @@ +package ecr + +import ( + "context" + "encoding/base64" + "fmt" + "time" +) + +// authTokenTTL is how long a GetAuthorizationToken response stays valid. Real +// ECR tokens last 12 hours. +const authTokenTTL = 12 * time.Hour + +// GetAuthorizationToken returns a base64 "AWS:" credential, the +// registry proxy endpoint, and an expiry — everything `docker login` and +// image push/pull need. The emulator does not validate the token on later +// requests; it exists so auth flows succeed. +func (m *Mock) GetAuthorizationToken(_ context.Context) (token, proxyEndpoint string, expiresAt time.Time, err error) { + token = base64.StdEncoding.EncodeToString([]byte("AWS:cloudemu")) + proxyEndpoint = fmt.Sprintf("https://%s.dkr.ecr.%s.amazonaws.com", m.opts.AccountID, m.opts.Region) + expiresAt = m.opts.Clock.Now().Add(authTokenTTL).UTC() + + return token, proxyEndpoint, expiresAt, nil +} diff --git a/server/aws/ecr/handler.go b/server/aws/ecr/handler.go index cdbded03..75b6039a 100644 --- a/server/aws/ecr/handler.go +++ b/server/aws/ecr/handler.go @@ -8,8 +8,10 @@ package ecr import ( + "context" "net/http" "strings" + "time" cerrors "github.com/stackshy/cloudemu/v2/errors" "github.com/stackshy/cloudemu/v2/server/wire" @@ -18,6 +20,14 @@ import ( const targetPrefix = "AmazonEC2ContainerRegistry_V20150921." +// authTokenProvider is the AWS-specific GetAuthorizationToken surface. ECR +// registry auth is not part of the portable ContainerRegistry driver (Azure +// ACR and GCP Artifact Registry authenticate differently), so the handler +// type-asserts for it rather than widening the shared interface. +type authTokenProvider interface { + GetAuthorizationToken(ctx context.Context) (token, proxyEndpoint string, expiresAt time.Time, err error) +} + // Handler serves ECR JSON-RPC requests against a ContainerRegistry driver. type Handler struct { registry crdriver.ContainerRegistry @@ -51,6 +61,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.describeImages(w, r) case "BatchDeleteImage": h.batchDeleteImage(w, r) + case "GetAuthorizationToken": + h.getAuthorizationToken(w, r) default: op := strings.TrimPrefix(r.Header.Get("X-Amz-Target"), targetPrefix) wire.WriteJSONError(w, http.StatusBadRequest, @@ -58,6 +70,32 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } } +// getAuthorizationToken returns a docker-login credential. The response shape +// matches the AWS SDK's AuthorizationData: a base64 token, an expiry, and the +// registry proxy endpoint. +func (h *Handler) getAuthorizationToken(w http.ResponseWriter, r *http.Request) { + auth, ok := h.registry.(authTokenProvider) + if !ok { + wire.WriteJSONError(w, http.StatusBadRequest, + "ServerException", "authorization token not supported") + return + } + + token, endpoint, expiresAt, err := auth.GetAuthorizationToken(r.Context()) + if err != nil { + writeErr(w, err) + return + } + + wire.WriteJSON(w, map[string]any{ + "authorizationData": []map[string]any{{ + "authorizationToken": token, + "proxyEndpoint": endpoint, + "expiresAt": expiresAt.Unix(), + }}, + }) +} + // writeErr maps canonical cloudemu errors to ECR JSON error responses. ECR // returns errors as HTTP 400 with a "__type" body the SDK maps to a typed // exception. diff --git a/server/aws/ecr/sdk_roundtrip_test.go b/server/aws/ecr/sdk_roundtrip_test.go index dfa625f4..8816a808 100644 --- a/server/aws/ecr/sdk_roundtrip_test.go +++ b/server/aws/ecr/sdk_roundtrip_test.go @@ -2,9 +2,12 @@ package ecr_test import ( "context" + "encoding/base64" "errors" "net/http/httptest" + "strings" "testing" + "time" "github.com/aws/aws-sdk-go-v2/aws" awsconfig "github.com/aws/aws-sdk-go-v2/config" @@ -92,6 +95,42 @@ func TestSDKECRRepositoryLifecycle(t *testing.T) { } } +// TestSDKECRGetAuthorizationToken is a regression guard for issue #319: +// GetAuthorizationToken (required for `docker login` / image push+pull) was +// unimplemented. The SDK must decode a base64 "AWS:" token, a proxy +// endpoint, and an expiry. +func TestSDKECRGetAuthorizationToken(t *testing.T) { + client := newECRClient(t) + + out, err := client.GetAuthorizationToken(context.Background(), &awsecr.GetAuthorizationTokenInput{}) + if err != nil { + t.Fatalf("GetAuthorizationToken: %v", err) + } + + if len(out.AuthorizationData) != 1 { + t.Fatalf("got %d authorization entries, want 1", len(out.AuthorizationData)) + } + + data := out.AuthorizationData[0] + + decoded, err := base64.StdEncoding.DecodeString(aws.ToString(data.AuthorizationToken)) + if err != nil { + t.Fatalf("token not base64: %v", err) + } + + if !strings.HasPrefix(string(decoded), "AWS:") { + t.Fatalf("decoded token = %q, want AWS:", string(decoded)) + } + + if !strings.Contains(aws.ToString(data.ProxyEndpoint), ".dkr.ecr.") { + t.Fatalf("proxy endpoint = %q", aws.ToString(data.ProxyEndpoint)) + } + + if data.ExpiresAt == nil || !data.ExpiresAt.After(time.Now()) { + t.Fatalf("expiresAt = %v, want a future time", data.ExpiresAt) + } +} + func TestSDKECRImageLifecycle(t *testing.T) { client := newECRClient(t) ctx := context.Background() From d4769e3152521e37f039ef81db7bd3278032ec11 Mon Sep 17 00:00:00 2001 From: Gajendra Malviya Date: Tue, 4 Aug 2026 15:18:29 +0530 Subject: [PATCH 09/45] feat(secretsmanager): support UpdateSecret, TagResource, UntagResource (#319) UpdateSecret (routine metadata/value change) and secret tagging were unimplemented. Add UpdateSecret (description + optional new value version) plus Tag/Untag to the AWS provider, routed via an AWS-local secretMutator assertion so the shared Secrets driver (also implemented by Azure Key Vault and GCP Secret Manager) stays untouched. --- providers/aws/secretsmanager/update.go | 94 +++++++++++++++++++ server/aws/secretsmanager/handler.go | 21 +++++ server/aws/secretsmanager/operations.go | 62 ++++++++++++ .../aws/secretsmanager/sdk_roundtrip_test.go | 60 ++++++++++++ server/aws/secretsmanager/types.go | 22 +++++ 5 files changed, 259 insertions(+) create mode 100644 providers/aws/secretsmanager/update.go diff --git a/providers/aws/secretsmanager/update.go b/providers/aws/secretsmanager/update.go new file mode 100644 index 00000000..7b9855cb --- /dev/null +++ b/providers/aws/secretsmanager/update.go @@ -0,0 +1,94 @@ +package secretsmanager + +import ( + "context" + "time" + + "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/internal/idgen" + "github.com/stackshy/cloudemu/v2/services/secrets/driver" +) + +// UpdateSecret updates a secret's description and, when value is non-nil, +// stores it as a new current version (SecretsManager UpdateSecret semantics: +// SecretString/SecretBinary are optional). An empty description leaves the +// existing one unchanged. +func (m *Mock) UpdateSecret(_ context.Context, name, description string, value []byte) (*driver.SecretInfo, error) { + sd, ok := m.secrets.Get(name) + if !ok { + return nil, errors.Newf(errors.NotFound, "secret %q not found", name) + } + + sd.mu.Lock() + defer sd.mu.Unlock() + + if !sd.deletedAt.IsZero() { + return nil, errors.Newf(errors.NotFound, "secret %q is scheduled for deletion", name) + } + + now := m.opts.Clock.Now().UTC().Format(time.RFC3339) + + if description != "" { + sd.info.Description = description + } + + if value != nil { + for i := range sd.versions { + sd.versions[i].Current = false + } + + data := make([]byte, len(value)) + copy(data, value) + + sd.versions = append(sd.versions, driver.SecretVersion{ + VersionID: idgen.GenerateID("ver-"), + Value: data, + CreatedAt: now, + Current: true, + }) + } + + sd.info.UpdatedAt = now + + result := sd.info + + return &result, nil +} + +// TagSecret adds or overwrites tags on a secret (SecretsManager TagResource). +func (m *Mock) TagSecret(_ context.Context, name string, tags map[string]string) error { + sd, ok := m.secrets.Get(name) + if !ok { + return errors.Newf(errors.NotFound, "secret %q not found", name) + } + + sd.mu.Lock() + defer sd.mu.Unlock() + + if sd.info.Tags == nil { + sd.info.Tags = make(map[string]string, len(tags)) + } + + for k, v := range tags { + sd.info.Tags[k] = v + } + + return nil +} + +// UntagSecret removes tags by key from a secret (SecretsManager UntagResource). +func (m *Mock) UntagSecret(_ context.Context, name string, keys []string) error { + sd, ok := m.secrets.Get(name) + if !ok { + return errors.Newf(errors.NotFound, "secret %q not found", name) + } + + sd.mu.Lock() + defer sd.mu.Unlock() + + for _, k := range keys { + delete(sd.info.Tags, k) + } + + return nil +} diff --git a/server/aws/secretsmanager/handler.go b/server/aws/secretsmanager/handler.go index 4573ff30..ac508b05 100644 --- a/server/aws/secretsmanager/handler.go +++ b/server/aws/secretsmanager/handler.go @@ -8,6 +8,7 @@ package secretsmanager import ( + "context" "net/http" "strings" @@ -18,6 +19,16 @@ import ( const targetPrefix = "secretsmanager." +// secretMutator is the AWS-specific UpdateSecret + tagging surface. These are +// not part of the portable Secrets driver (Azure Key Vault and GCP Secret +// Manager also implement it), so the handler type-asserts for them rather than +// widening the shared interface. +type secretMutator interface { + UpdateSecret(ctx context.Context, name, description string, value []byte) (*secretsdriver.SecretInfo, error) + TagSecret(ctx context.Context, name string, tags map[string]string) error + UntagSecret(ctx context.Context, name string, keys []string) error +} + // Handler serves Secrets Manager JSON-RPC requests against a Secrets driver. type Handler struct { secrets secretsdriver.Secrets @@ -51,6 +62,12 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.putSecretValue(w, r) case "ListSecretVersionIds": h.listSecretVersionIDs(w, r) + case "UpdateSecret": + h.updateSecret(w, r) + case "TagResource": + h.tagResource(w, r) + case "UntagResource": + h.untagResource(w, r) default: op := strings.TrimPrefix(r.Header.Get("X-Amz-Target"), targetPrefix) wire.WriteJSONError(w, http.StatusBadRequest, @@ -58,6 +75,10 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } } +// errNotSupported is returned when the backing driver doesn't implement the +// AWS-specific secretMutator surface. Real deployments always do. +var errNotSupported = cerrors.New(cerrors.Unimplemented, "operation not supported by this backend") + // writeErr maps canonical cloudemu errors to Secrets Manager JSON error // responses. Secrets Manager returns errors as HTTP 400 with a "__type" body // the SDK maps to a typed exception. diff --git a/server/aws/secretsmanager/operations.go b/server/aws/secretsmanager/operations.go index 2e95b3a1..1fc977ab 100644 --- a/server/aws/secretsmanager/operations.go +++ b/server/aws/secretsmanager/operations.go @@ -176,3 +176,65 @@ func (h *Handler) listSecretVersionIDs(w http.ResponseWriter, r *http.Request) { wire.WriteJSON(w, listSecretVersionIDsResponse{ARN: info.ResourceID, Name: info.Name, Versions: out}) } + +func (h *Handler) updateSecret(w http.ResponseWriter, r *http.Request) { + mut, ok := h.secrets.(secretMutator) + if !ok { + writeErr(w, errNotSupported) + return + } + + var req updateSecretRequest + if !wire.DecodeJSON(w, r, &req) { + return + } + + info, err := mut.UpdateSecret(r.Context(), resolveSecretID(req.SecretID), + req.Description, secretValue(req.SecretString, req.SecretBinary)) + if err != nil { + writeErr(w, err) + return + } + + wire.WriteJSON(w, updateSecretResponse{ARN: info.ResourceID, Name: info.Name}) +} + +func (h *Handler) tagResource(w http.ResponseWriter, r *http.Request) { + mut, ok := h.secrets.(secretMutator) + if !ok { + writeErr(w, errNotSupported) + return + } + + var req tagResourceRequest + if !wire.DecodeJSON(w, r, &req) { + return + } + + if err := mut.TagSecret(r.Context(), resolveSecretID(req.SecretID), tagsToMap(req.Tags)); err != nil { + writeErr(w, err) + return + } + + wire.WriteJSON(w, struct{}{}) +} + +func (h *Handler) untagResource(w http.ResponseWriter, r *http.Request) { + mut, ok := h.secrets.(secretMutator) + if !ok { + writeErr(w, errNotSupported) + return + } + + var req untagResourceRequest + if !wire.DecodeJSON(w, r, &req) { + return + } + + if err := mut.UntagSecret(r.Context(), resolveSecretID(req.SecretID), req.TagKeys); err != nil { + writeErr(w, err) + return + } + + wire.WriteJSON(w, struct{}{}) +} diff --git a/server/aws/secretsmanager/sdk_roundtrip_test.go b/server/aws/secretsmanager/sdk_roundtrip_test.go index d0659e78..79da767d 100644 --- a/server/aws/secretsmanager/sdk_roundtrip_test.go +++ b/server/aws/secretsmanager/sdk_roundtrip_test.go @@ -96,6 +96,66 @@ func TestSDKSecretLifecycle(t *testing.T) { } } +// TestSDKUpdateSecretAndTagging is a regression guard for issue #319: +// UpdateSecret, TagResource, and UntagResource were unimplemented. +func TestSDKUpdateSecretAndTagging(t *testing.T) { + client := newSecretsClient(t) + ctx := context.Background() + + if _, err := client.CreateSecret(ctx, &awssm.CreateSecretInput{ + Name: aws.String("s"), Description: aws.String("d1"), SecretString: aws.String("v1"), + }); err != nil { + t.Fatalf("CreateSecret: %v", err) + } + + // UpdateSecret changes description and value. + if _, err := client.UpdateSecret(ctx, &awssm.UpdateSecretInput{ + SecretId: aws.String("s"), Description: aws.String("d2"), SecretString: aws.String("v2"), + }); err != nil { + t.Fatalf("UpdateSecret: %v", err) + } + + val, err := client.GetSecretValue(ctx, &awssm.GetSecretValueInput{SecretId: aws.String("s")}) + if err != nil { + t.Fatalf("GetSecretValue: %v", err) + } + + if aws.ToString(val.SecretString) != "v2" { + t.Fatalf("value = %q, want v2", aws.ToString(val.SecretString)) + } + + // TagResource then UntagResource. + if _, err := client.TagResource(ctx, &awssm.TagResourceInput{ + SecretId: aws.String("s"), Tags: []smtypes.Tag{{Key: aws.String("env"), Value: aws.String("prod")}}, + }); err != nil { + t.Fatalf("TagResource: %v", err) + } + + desc, err := client.DescribeSecret(ctx, &awssm.DescribeSecretInput{SecretId: aws.String("s")}) + if err != nil { + t.Fatalf("DescribeSecret: %v", err) + } + + if aws.ToString(desc.Description) != "d2" || len(desc.Tags) != 1 { + t.Fatalf("after update+tag: description=%q tags=%+v", aws.ToString(desc.Description), desc.Tags) + } + + if _, err := client.UntagResource(ctx, &awssm.UntagResourceInput{ + SecretId: aws.String("s"), TagKeys: []string{"env"}, + }); err != nil { + t.Fatalf("UntagResource: %v", err) + } + + desc, err = client.DescribeSecret(ctx, &awssm.DescribeSecretInput{SecretId: aws.String("s")}) + if err != nil { + t.Fatalf("DescribeSecret after untag: %v", err) + } + + if len(desc.Tags) != 0 { + t.Fatalf("tags after untag = %+v, want none", desc.Tags) + } +} + func TestSDKSecretValueVersioning(t *testing.T) { client := newSecretsClient(t) ctx := context.Background() diff --git a/server/aws/secretsmanager/types.go b/server/aws/secretsmanager/types.go index fc94bc81..ffa522f0 100644 --- a/server/aws/secretsmanager/types.go +++ b/server/aws/secretsmanager/types.go @@ -59,6 +59,28 @@ type putSecretValueRequest struct { SecretBinary []byte `json:"SecretBinary"` } +type updateSecretRequest struct { + SecretID string `json:"SecretId"` + Description string `json:"Description"` + SecretString string `json:"SecretString"` + SecretBinary []byte `json:"SecretBinary"` +} + +type tagResourceRequest struct { + SecretID string `json:"SecretId"` + Tags []tagJSON `json:"Tags"` +} + +type untagResourceRequest struct { + SecretID string `json:"SecretId"` + TagKeys []string `json:"TagKeys"` +} + +type updateSecretResponse struct { + ARN string `json:"ARN"` + Name string `json:"Name"` +} + // --- response envelopes --- type createSecretResponse struct { From 7b42b432a68f92c70219e58d3771c448c49d608b Mon Sep 17 00:00:00 2001 From: Gajendra Malviya Date: Tue, 4 Aug 2026 15:27:09 +0530 Subject: [PATCH 10/45] feat(ec2): implement CreateTags/DeleteTags (#319) CreateTags/DeleteTags returned InvalidAction, so every IaC tool that tags EC2 resources (nearly all of them) failed. Route the calls by resource-ID prefix: VPC-family IDs to the networking provider's existing tag methods, and instance/volume/snapshot/image IDs to a new AWS-local compute tagger. Unknown IDs return InvalidID.NotFound. --- providers/aws/ec2/tags.go | 94 ++++++++++++++++++++++++ server/aws/ec2/ec2_phase2_test.go | 57 +++++++++++++++ server/aws/ec2/handler.go | 1 + server/aws/ec2/tags.go | 114 ++++++++++++++++++++++++++++++ 4 files changed, 266 insertions(+) create mode 100644 providers/aws/ec2/tags.go create mode 100644 server/aws/ec2/tags.go diff --git a/providers/aws/ec2/tags.go b/providers/aws/ec2/tags.go new file mode 100644 index 00000000..07e099b9 --- /dev/null +++ b/providers/aws/ec2/tags.go @@ -0,0 +1,94 @@ +package ec2 + +import ( + "context" + "strings" + + cerrors "github.com/stackshy/cloudemu/v2/errors" +) + +// tagsOf resolves an EC2 resource ID (by prefix) to its mutable tag map. +// Returns false when the ID is unknown or the resource does not exist. +func (m *Mock) tagsOf(id string) (map[string]string, bool) { + switch { + case strings.HasPrefix(id, "i-"): + if d, ok := m.instances.Get(id); ok { + if d.Tags == nil { + d.Tags = map[string]string{} + } + + return d.Tags, true + } + case strings.HasPrefix(id, "vol-"): + if d, ok := m.volumes.Get(id); ok { + if d.Tags == nil { + d.Tags = map[string]string{} + } + + return d.Tags, true + } + case strings.HasPrefix(id, "snap-"): + if d, ok := m.snapshots.Get(id); ok { + if d.Tags == nil { + d.Tags = map[string]string{} + } + + return d.Tags, true + } + case strings.HasPrefix(id, "ami-"): + if d, ok := m.images.Get(id); ok { + if d.Tags == nil { + d.Tags = map[string]string{} + } + + return d.Tags, true + } + } + + return nil, false +} + +// TagResource applies tags to an EC2 instance, volume, snapshot, or image by +// ID. This backs the EC2 CreateTags API for compute resources (VPC-family IDs +// are handled by the networking provider). Returns NotFound for an unknown ID. +func (m *Mock) TagResource(_ context.Context, id string, tags map[string]string) error { + m.mu.Lock() + defer m.mu.Unlock() + + dst, ok := m.tagsOf(id) + if !ok { + return cerrors.Newf(cerrors.NotFound, "resource %q not found", id) + } + + for k, v := range tags { + dst[k] = v + } + + return nil +} + +// UntagResource removes tags by key from an EC2 resource. An empty key list +// clears all tags, matching EC2 DeleteTags semantics. +func (m *Mock) UntagResource(_ context.Context, id string, keys []string) error { + m.mu.Lock() + defer m.mu.Unlock() + + dst, ok := m.tagsOf(id) + if !ok { + return cerrors.Newf(cerrors.NotFound, "resource %q not found", id) + } + + if len(keys) == 0 { + for k := range dst { + delete(dst, k) + } + + return nil + } + + for _, k := range keys { + delete(dst, k) + } + + return nil +} diff --git a/server/aws/ec2/ec2_phase2_test.go b/server/aws/ec2/ec2_phase2_test.go index b5be232f..d3c3e4bf 100644 --- a/server/aws/ec2/ec2_phase2_test.go +++ b/server/aws/ec2/ec2_phase2_test.go @@ -480,6 +480,63 @@ func TestToIPPermissionXMLsEmpty(t *testing.T) { } // between returns the substring between open and close markers, or empty. +// TestCreateAndDeleteTags is a regression guard for issue #319: EC2 +// CreateTags/DeleteTags returned InvalidAction. Tags must apply to VPC-family +// resources (networking provider) and compute resources (compute tagger), and +// an unknown ID must yield InvalidID.NotFound. +func TestCreateAndDeleteTags(t *testing.T) { + h := newFullHandler() + + vpc := do(t, h, http.MethodPost, "/", url.Values{ + "Action": {"CreateVpc"}, "CidrBlock": {"10.0.0.0/16"}, + }) + vpcID := between(vpc.Body.String(), "", "") + + if vpcID == "" { + t.Fatalf("CreateVpc returned no id: %s", vpc.Body.String()) + } + + // CreateTags on the VPC. + ct := do(t, h, http.MethodPost, "/", url.Values{ + "Action": {"CreateTags"}, "ResourceId.1": {vpcID}, + "Tag.1.Key": {"env"}, "Tag.1.Value": {"prod"}, + "Tag.2.Key": {"team"}, "Tag.2.Value": {"platform"}, + }) + if ct.Code != http.StatusOK { + t.Fatalf("CreateTags status = %d: %s", ct.Code, ct.Body.String()) + } + + desc := do(t, h, http.MethodPost, "/", url.Values{ + "Action": {"DescribeVpcs"}, "VpcId.1": {vpcID}, + }).Body.String() + if !strings.Contains(desc, "env") || !strings.Contains(desc, "team") { + t.Fatalf("tags missing after CreateTags: %s", desc) + } + + // DeleteTags removes one key. + if dt := do(t, h, http.MethodPost, "/", url.Values{ + "Action": {"DeleteTags"}, "ResourceId.1": {vpcID}, "Tag.1.Key": {"env"}, + }); dt.Code != http.StatusOK { + t.Fatalf("DeleteTags status = %d", dt.Code) + } + + desc = do(t, h, http.MethodPost, "/", url.Values{ + "Action": {"DescribeVpcs"}, "VpcId.1": {vpcID}, + }).Body.String() + if strings.Contains(desc, "env") || !strings.Contains(desc, "team") { + t.Fatalf("DeleteTags result wrong: %s", desc) + } + + // Unknown ID -> InvalidID.NotFound. + bad := do(t, h, http.MethodPost, "/", url.Values{ + "Action": {"CreateTags"}, "ResourceId.1": {"vpc-deadbeef"}, + "Tag.1.Key": {"a"}, "Tag.1.Value": {"b"}, + }) + if !strings.Contains(bad.Body.String(), "InvalidID.NotFound") { + t.Fatalf("want InvalidID.NotFound, got: %s", bad.Body.String()) + } +} + func between(s, open, close string) string { i := strings.Index(s, open) if i < 0 { diff --git a/server/aws/ec2/handler.go b/server/aws/ec2/handler.go index 3a9a7ccd..9faa9715 100644 --- a/server/aws/ec2/handler.go +++ b/server/aws/ec2/handler.go @@ -95,6 +95,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.routeEndpointServices, h.routeClientVPN, h.routeVPC, + h.routeTags, } for _, route := range routes { if route(w, r, action) { diff --git a/server/aws/ec2/tags.go b/server/aws/ec2/tags.go new file mode 100644 index 00000000..6d7fbda6 --- /dev/null +++ b/server/aws/ec2/tags.go @@ -0,0 +1,114 @@ +package ec2 + +import ( + "context" + "encoding/xml" + "net/http" + "strings" + + "github.com/stackshy/cloudemu/v2/server/wire/awsquery" +) + +// computeTagger is the AWS-specific compute-resource tagging surface +// (instances/volumes/snapshots/images). It's not part of the portable Compute +// driver (Azure/GCP also implement it), so the handler type-asserts for it. +type computeTagger interface { + TagResource(ctx context.Context, id string, tags map[string]string) error + UntagResource(ctx context.Context, id string, keys []string) error +} + +type tagsResponseXML struct { + XMLName xml.Name `xml:"CreateTagsResponse"` + Return bool `xml:"return"` + RequestID string `xml:"requestId"` +} + +type deleteTagsResponseXML struct { + XMLName xml.Name `xml:"DeleteTagsResponse"` + Return bool `xml:"return"` + RequestID string `xml:"requestId"` +} + +func (h *Handler) routeTags(w http.ResponseWriter, r *http.Request, action string) bool { + switch action { + case "CreateTags": + h.createTags(w, r) + case "DeleteTags": + h.deleteTags(w, r) + default: + return false + } + + return true +} + +// createTags applies tags to one or more resources, dispatching each resource +// ID by prefix to the owning provider (VPC-family IDs to the networking +// provider, compute IDs to the compute tagger). +func (h *Handler) createTags(w http.ResponseWriter, r *http.Request) { + ids := awsquery.ListStrings(r.Form, "ResourceId") + tags := awsquery.FlatTags(r.Form, "Tag") + + for _, id := range ids { + if err := h.tagResource(r.Context(), id, tags); err != nil { + writeErrWithNotFound(w, err, "InvalidID.NotFound", "IncorrectState") + return + } + } + + awsquery.WriteXMLResponse(w, tagsResponseXML{Return: true, RequestID: "cloudemu"}) +} + +// deleteTags removes tags (by key) from one or more resources. +func (h *Handler) deleteTags(w http.ResponseWriter, r *http.Request) { + ids := awsquery.ListStrings(r.Form, "ResourceId") + tags := awsquery.FlatTags(r.Form, "Tag") + + keys := make([]string, 0, len(tags)) + for k := range tags { + keys = append(keys, k) + } + + for _, id := range ids { + if err := h.untagResource(r.Context(), id, keys); err != nil { + writeErrWithNotFound(w, err, "InvalidID.NotFound", "IncorrectState") + return + } + } + + awsquery.WriteXMLResponse(w, deleteTagsResponseXML{Return: true, RequestID: "cloudemu"}) +} + +func (h *Handler) tagResource(ctx context.Context, id string, tags map[string]string) error { + switch { + case strings.HasPrefix(id, "vpc-"): + return h.vpc.UpdateVPCTags(ctx, id, tags) + case strings.HasPrefix(id, "subnet-"): + return h.vpc.UpdateSubnetTags(ctx, id, tags) + case strings.HasPrefix(id, "sg-"): + return h.vpc.UpdateSecurityGroupTags(ctx, id, tags) + default: + if tagger, ok := h.compute.(computeTagger); ok { + return tagger.TagResource(ctx, id, tags) + } + + return nil + } +} + +func (h *Handler) untagResource(ctx context.Context, id string, keys []string) error { + switch { + case strings.HasPrefix(id, "vpc-"): + return h.vpc.RemoveVPCTags(ctx, id, keys) + case strings.HasPrefix(id, "subnet-"): + return h.vpc.RemoveSubnetTags(ctx, id, keys) + case strings.HasPrefix(id, "sg-"): + return h.vpc.RemoveSecurityGroupTags(ctx, id, keys) + default: + if tagger, ok := h.compute.(computeTagger); ok { + return tagger.UntagResource(ctx, id, keys) + } + + return nil + } +} From 0a886a8e8d54c6bc8b03bef2df7e31dfd0347b79 Mon Sep 17 00:00:00 2001 From: Gajendra Malviya Date: Tue, 4 Aug 2026 15:32:01 +0530 Subject: [PATCH 11/45] feat(sns): support TagResource and UntagResource (#319) SNS TagResource returned InvalidAction, blocking IaC tag-on-create flows. Add TagTopic/UntagTopic to the AWS provider, routed via an AWS-local topicTagger assertion (the shared Notification driver is also implemented by Azure Notification Hubs and GCP FCM). ListTagsForResource is deferred: its action name collides with RDS in the query protocol and needs SigV4 credential-scope routing to disambiguate. --- providers/aws/sns/sns_test.go | 28 ++++++++++++++++ providers/aws/sns/tags.go | 63 +++++++++++++++++++++++++++++++++++ server/aws/sns/handler.go | 20 +++++++++++ server/aws/sns/operations.go | 38 +++++++++++++++++++++ server/aws/sns/types.go | 19 +++++++++++ 5 files changed, 168 insertions(+) create mode 100644 providers/aws/sns/tags.go diff --git a/providers/aws/sns/sns_test.go b/providers/aws/sns/sns_test.go index b19f4cba..81670e90 100644 --- a/providers/aws/sns/sns_test.go +++ b/providers/aws/sns/sns_test.go @@ -107,6 +107,34 @@ func TestCreateTopicWithTags(t *testing.T) { assert.Equal(t, "staging", info.Tags["env"]) } +// TestTagUntagTopic is a regression guard for issue #319: SNS TagResource / +// UntagResource were unimplemented. +func TestTagUntagTopic(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + if _, err := m.CreateTopic(ctx, driver.TopicConfig{Name: "t"}); err != nil { + t.Fatalf("CreateTopic: %v", err) + } + + require.NoError(t, m.TagTopic(ctx, "t", map[string]string{"env": "prod", "team": "infra"})) + + got, err := m.ListTopicTags(ctx, "t") + require.NoError(t, err) + assert.Equal(t, "prod", got["env"]) + assert.Equal(t, "infra", got["team"]) + + require.NoError(t, m.UntagTopic(ctx, "t", []string{"env"})) + + got, err = m.ListTopicTags(ctx, "t") + require.NoError(t, err) + _, has := got["env"] + assert.False(t, has) + assert.Equal(t, "infra", got["team"]) + + assert.Error(t, m.TagTopic(ctx, "missing", map[string]string{"a": "b"})) +} + func TestDeleteTopic(t *testing.T) { tests := []struct { name string diff --git a/providers/aws/sns/tags.go b/providers/aws/sns/tags.go new file mode 100644 index 00000000..42d23da8 --- /dev/null +++ b/providers/aws/sns/tags.go @@ -0,0 +1,63 @@ +package sns + +import ( + "context" + + "github.com/stackshy/cloudemu/v2/errors" +) + +// TagTopic adds or overwrites tags on a topic (SNS TagResource). +func (m *Mock) TagTopic(_ context.Context, topicName string, tags map[string]string) error { + td, ok := m.topics.Get(topicName) + if !ok { + return errors.Newf(errors.NotFound, "topic %q not found", topicName) + } + + td.mu.Lock() + defer td.mu.Unlock() + + if td.info.Tags == nil { + td.info.Tags = make(map[string]string, len(tags)) + } + + for k, v := range tags { + td.info.Tags[k] = v + } + + return nil +} + +// UntagTopic removes tags by key from a topic (SNS UntagResource). +func (m *Mock) UntagTopic(_ context.Context, topicName string, keys []string) error { + td, ok := m.topics.Get(topicName) + if !ok { + return errors.Newf(errors.NotFound, "topic %q not found", topicName) + } + + td.mu.Lock() + defer td.mu.Unlock() + + for _, k := range keys { + delete(td.info.Tags, k) + } + + return nil +} + +// ListTopicTags returns a topic's tags (SNS ListTagsForResource). +func (m *Mock) ListTopicTags(_ context.Context, topicName string) (map[string]string, error) { + td, ok := m.topics.Get(topicName) + if !ok { + return nil, errors.Newf(errors.NotFound, "topic %q not found", topicName) + } + + td.mu.RLock() + defer td.mu.RUnlock() + + out := make(map[string]string, len(td.info.Tags)) + for k, v := range td.info.Tags { + out[k] = v + } + + return out, nil +} diff --git a/server/aws/sns/handler.go b/server/aws/sns/handler.go index 8012def3..de6c1931 100644 --- a/server/aws/sns/handler.go +++ b/server/aws/sns/handler.go @@ -24,6 +24,7 @@ package sns import ( + "context" "net/http" "strings" @@ -53,6 +54,21 @@ var snsActions = map[string]struct{}{ //nolint:gochecknoglobals // static lookup "ListSubscriptions": {}, "ListSubscriptionsByTopic": {}, "Publish": {}, + "TagResource": {}, + "UntagResource": {}, +} + +// topicTagger is the AWS-specific topic-tagging surface. It's not part of the +// portable Notification driver (Azure Notification Hubs and GCP FCM also +// implement it), so the handler type-asserts for it. +// +// ListTagsForResource is intentionally omitted: that action name collides with +// RDS in the shared query protocol (RDS registers first and claims it), and +// disambiguating would require SigV4 credential-scope routing. SNS tag writes +// (the flagged gap) work; tag read-back is a follow-up. +type topicTagger interface { + TagTopic(ctx context.Context, topicName string, tags map[string]string) error + UntagTopic(ctx context.Context, topicName string, keys []string) error } // Handler serves SNS query-protocol requests against a notification driver. @@ -115,6 +131,10 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.listSubscriptionsByTopic(w, r) case "Publish": h.publish(w, r) + case "TagResource": + h.tagResource(w, r) + case "UntagResource": + h.untagResource(w, r) default: awsquery.WriteXMLError(w, http.StatusBadRequest, "InvalidAction", "unknown SNS action: "+action) diff --git a/server/aws/sns/operations.go b/server/aws/sns/operations.go index 2f46bde5..90013bf2 100644 --- a/server/aws/sns/operations.go +++ b/server/aws/sns/operations.go @@ -11,6 +11,44 @@ import ( "github.com/stackshy/cloudemu/v2/services/scope" ) +func (h *Handler) tagResource(w http.ResponseWriter, r *http.Request) { + tagger, ok := h.notif.(topicTagger) + if !ok { + writeErr(w, cerrors.New(cerrors.Unimplemented, "tagging not supported")) + return + } + + name := topicNameFromARN(r.Form.Get("ResourceArn")) + + if err := tagger.TagTopic(r.Context(), name, awsquery.FlatTags(r.Form, "Tags.member")); err != nil { + writeErr(w, err) + return + } + + awsquery.WriteXMLResponse(w, tagResourceResponse{ + Xmlns: Namespace, Metadata: responseMetadata{RequestID: awsquery.RequestID}, + }) +} + +func (h *Handler) untagResource(w http.ResponseWriter, r *http.Request) { + tagger, ok := h.notif.(topicTagger) + if !ok { + writeErr(w, cerrors.New(cerrors.Unimplemented, "tagging not supported")) + return + } + + name := topicNameFromARN(r.Form.Get("ResourceArn")) + + if err := tagger.UntagTopic(r.Context(), name, awsquery.ListStrings(r.Form, "TagKeys.member")); err != nil { + writeErr(w, err) + return + } + + awsquery.WriteXMLResponse(w, untagResourceResponse{ + Xmlns: Namespace, Metadata: responseMetadata{RequestID: awsquery.RequestID}, + }) +} + // createTopic maps CreateTopic to Notification.CreateTopic. SNS CreateTopic is // idempotent: creating a topic that already exists returns the existing ARN // rather than an error, so we translate the driver's AlreadyExists into a diff --git a/server/aws/sns/types.go b/server/aws/sns/types.go index 242677fd..834b53f3 100644 --- a/server/aws/sns/types.go +++ b/server/aws/sns/types.go @@ -38,6 +38,25 @@ type unsubscribeResponse struct { Metadata responseMetadata `xml:"ResponseMetadata"` } +// --- TagResource / UntagResource (empty results) --- +// +// The SDK's SNS unmarshaler expects the empty wrapper element, so +// it's included even though it carries no data. + +type tagResourceResponse struct { + XMLName xml.Name `xml:"TagResourceResponse"` + Xmlns string `xml:"xmlns,attr"` + Result struct{} `xml:"TagResourceResult"` + Metadata responseMetadata `xml:"ResponseMetadata"` +} + +type untagResourceResponse struct { + XMLName xml.Name `xml:"UntagResourceResponse"` + Xmlns string `xml:"xmlns,attr"` + Result struct{} `xml:"UntagResourceResult"` + Metadata responseMetadata `xml:"ResponseMetadata"` +} + // --- GetTopicAttributes --- type attributeEntry struct { From c8a5443467542607eaf58786d06be7ea34df4369 Mon Sep 17 00:00:00 2001 From: Gajendra Malviya Date: Tue, 4 Aug 2026 15:34:29 +0530 Subject: [PATCH 12/45] feat(dynamodb): serve TagResource/UntagResource/ListTagsOfResource (#319) The database driver already implemented table tagging, but the DynamoDB JSON-RPC handler didn't dispatch the three tag operations, so they returned UnknownOperationException. Wire them through, resolving the ResourceArn to the table name. --- .../aws/dynamodb/dynamodb_lifecycle_test.go | 36 ++++++ server/aws/dynamodb/handler.go | 2 +- server/aws/dynamodb/tags.go | 112 ++++++++++++++++++ 3 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 server/aws/dynamodb/tags.go diff --git a/server/aws/dynamodb/dynamodb_lifecycle_test.go b/server/aws/dynamodb/dynamodb_lifecycle_test.go index f20cbffc..cf8b6fb8 100644 --- a/server/aws/dynamodb/dynamodb_lifecycle_test.go +++ b/server/aws/dynamodb/dynamodb_lifecycle_test.go @@ -199,6 +199,42 @@ func TestDDBTableLifecycle(t *testing.T) { require.ErrorAs(t, err, &rnf, "DescribeTable on a deleted table should be ResourceNotFoundException") } +// TestDDBTagging is a regression guard for issue #319: TagResource / +// UntagResource / ListTagsOfResource returned UnknownOperationException. +func TestDDBTagging(t *testing.T) { + client, _ := newSuiteDDBEnv(t) + ctx := context.Background() + + suiteDDBCreateTable(t, client, "tagged", "pk", "sk") + + arn := "arn:aws:dynamodb:us-east-1:000000000000:table/tagged" + + if _, err := client.TagResource(ctx, &dynamodb.TagResourceInput{ + ResourceArn: aws.String(arn), + Tags: []ddbtypes.Tag{ + {Key: aws.String("env"), Value: aws.String("prod")}, + {Key: aws.String("team"), Value: aws.String("data")}, + }, + }); err != nil { + t.Fatalf("TagResource: %v", err) + } + + list, err := client.ListTagsOfResource(ctx, &dynamodb.ListTagsOfResourceInput{ResourceArn: aws.String(arn)}) + require.NoError(t, err) + require.Len(t, list.Tags, 2) + + if _, err := client.UntagResource(ctx, &dynamodb.UntagResourceInput{ + ResourceArn: aws.String(arn), TagKeys: []string{"env"}, + }); err != nil { + t.Fatalf("UntagResource: %v", err) + } + + list, err = client.ListTagsOfResource(ctx, &dynamodb.ListTagsOfResourceInput{ResourceArn: aws.String(arn)}) + require.NoError(t, err) + require.Len(t, list.Tags, 1) + assert.Equal(t, "team", aws.ToString(list.Tags[0].Key)) +} + // TestDDBItemJourney: put an item with varied attribute types // (S, N incl. negative decimal, BOOL, NULL, L, M, empty string, ~100KB blob), // read it back through the SDK, update with SET+REMOVE (ReturnValues ALL_NEW), diff --git a/server/aws/dynamodb/handler.go b/server/aws/dynamodb/handler.go index 4cd56dfc..9633aa77 100644 --- a/server/aws/dynamodb/handler.go +++ b/server/aws/dynamodb/handler.go @@ -36,7 +36,7 @@ func (*Handler) Matches(r *http.Request) bool { func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { op := strings.TrimPrefix(r.Header.Get("X-Amz-Target"), targetPrefix) - if h.routeTables(w, r, op) || h.routeItems(w, r, op) || h.routeBatch(w, r, op) { + if h.routeTables(w, r, op) || h.routeItems(w, r, op) || h.routeBatch(w, r, op) || h.routeTags(w, r, op) { return } diff --git a/server/aws/dynamodb/tags.go b/server/aws/dynamodb/tags.go new file mode 100644 index 00000000..dd87915a --- /dev/null +++ b/server/aws/dynamodb/tags.go @@ -0,0 +1,112 @@ +package dynamodb + +import ( + "net/http" + "strings" + + "github.com/stackshy/cloudemu/v2/server/wire" +) + +type tagJSON struct { + Key string `json:"Key"` + Value string `json:"Value"` +} + +// tableFromARN resolves a DynamoDB ResourceArn ("arn:aws:dynamodb:: +// :table/") to the bare table name the driver keys on. A value +// that isn't an ARN is returned unchanged, so a plain name also works. +func tableFromARN(arn string) string { + const marker = ":table/" + + if i := strings.LastIndex(arn, marker); i >= 0 { + name := arn[i+len(marker):] + // A table ARN may carry a sub-resource suffix (.../index/...); keep only + // the table segment. + if j := strings.IndexByte(name, '/'); j >= 0 { + name = name[:j] + } + + return name + } + + return arn +} + +func (h *Handler) routeTags(w http.ResponseWriter, r *http.Request, op string) bool { + switch op { + case "TagResource": + h.tagResource(w, r) + case "UntagResource": + h.untagResource(w, r) + case "ListTagsOfResource": + h.listTagsOfResource(w, r) + default: + return false + } + + return true +} + +func (h *Handler) tagResource(w http.ResponseWriter, r *http.Request) { + var req struct { + ResourceArn string `json:"ResourceArn"` + Tags []tagJSON `json:"Tags"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + tags := make(map[string]string, len(req.Tags)) + for _, t := range req.Tags { + tags[t.Key] = t.Value + } + + if err := h.db.TagResource(r.Context(), tableFromARN(req.ResourceArn), tags); err != nil { + writeErr(w, err) + return + } + + wire.WriteJSON(w, struct{}{}) +} + +func (h *Handler) untagResource(w http.ResponseWriter, r *http.Request) { + var req struct { + ResourceArn string `json:"ResourceArn"` + TagKeys []string `json:"TagKeys"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + if err := h.db.UntagResource(r.Context(), tableFromARN(req.ResourceArn), req.TagKeys); err != nil { + writeErr(w, err) + return + } + + wire.WriteJSON(w, struct{}{}) +} + +func (h *Handler) listTagsOfResource(w http.ResponseWriter, r *http.Request) { + var req struct { + ResourceArn string `json:"ResourceArn"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + tags, err := h.db.ListTagsOfResource(r.Context(), tableFromARN(req.ResourceArn)) + if err != nil { + writeErr(w, err) + return + } + + out := make([]tagJSON, 0, len(tags)) + for k, v := range tags { + out = append(out, tagJSON{Key: k, Value: v}) + } + + wire.WriteJSON(w, map[string]any{"Tags": out}) +} From 918814314f1f8eef9637b2e3115220452c4bc8ce Mon Sep 17 00:00:00 2001 From: Gajendra Malviya Date: Tue, 4 Aug 2026 15:40:20 +0530 Subject: [PATCH 13/45] fix(lambda): serve the tagging API (TagResource/UntagResource/ListTags) (#319) The Lambda tagging API lives at the /2017-03-31/tags prefix, which the handler didn't match, so requests fell through to the S3 catch-all and returned a 405 + HTML body the SDK couldn't deserialize. Match that prefix and route POST/DELETE/GET to new AWS-local function-tagger methods. --- providers/aws/lambda/tags.go | 60 ++++++++++++++++++++++ server/aws/lambda/handler.go | 86 +++++++++++++++++++++++++++++++- server/aws/lambda/lambda_test.go | 51 +++++++++++++++++++ 3 files changed, 195 insertions(+), 2 deletions(-) create mode 100644 providers/aws/lambda/tags.go diff --git a/providers/aws/lambda/tags.go b/providers/aws/lambda/tags.go new file mode 100644 index 00000000..2e38c35d --- /dev/null +++ b/providers/aws/lambda/tags.go @@ -0,0 +1,60 @@ +package lambda + +import ( + "context" + "maps" + + cerrors "github.com/stackshy/cloudemu/v2/errors" +) + +// TagFunction adds or overwrites tags on a function (Lambda TagResource). +func (m *Mock) TagFunction(_ context.Context, name string, tags map[string]string) error { + m.mu.Lock() + defer m.mu.Unlock() + + fd, ok := m.funcs.Get(name) + if !ok { + return cerrors.Newf(cerrors.NotFound, "function %s not found", name) + } + + if fd.info.Tags == nil { + fd.info.Tags = make(map[string]string, len(tags)) + } + + for k, v := range tags { + fd.info.Tags[k] = v + } + + m.funcs.Set(name, fd) + + return nil +} + +// UntagFunction removes tags by key from a function (Lambda UntagResource). +func (m *Mock) UntagFunction(_ context.Context, name string, keys []string) error { + m.mu.Lock() + defer m.mu.Unlock() + + fd, ok := m.funcs.Get(name) + if !ok { + return cerrors.Newf(cerrors.NotFound, "function %s not found", name) + } + + for _, k := range keys { + delete(fd.info.Tags, k) + } + + m.funcs.Set(name, fd) + + return nil +} + +// ListFunctionTags returns a function's tags (Lambda ListTags). +func (m *Mock) ListFunctionTags(_ context.Context, name string) (map[string]string, error) { + fd, ok := m.funcs.Get(name) + if !ok { + return nil, cerrors.Newf(cerrors.NotFound, "function %s not found", name) + } + + return maps.Clone(fd.info.Tags), nil +} diff --git a/server/aws/lambda/handler.go b/server/aws/lambda/handler.go index 22ff596d..9f520d18 100644 --- a/server/aws/lambda/handler.go +++ b/server/aws/lambda/handler.go @@ -7,7 +7,8 @@ // Invoke (synchronous), UpdateFunctionConfiguration, PublishVersion / // ListVersionsByFunction, the alias lifecycle (create/get/list/update/ // delete), and resource policies (AddPermission / GetPolicy / -// RemovePermission). Layers, concurrency configs, tagging, and event source +// RemovePermission), and tagging (TagResource / UntagResource / ListTags at +// the /2017-03-31/tags prefix). Layers, concurrency configs, and event source // mappings remain deferred — the driver supports some of them but the wire // surface is not yet wired through. package lambda @@ -28,6 +29,12 @@ import ( // REST traffic that should fall through to the S3 catch-all. const pathPrefix = "/2015-03-31/functions" +// tagsPrefix is the Lambda tagging API prefix (TagResource / UntagResource / +// ListTags). It's a different version prefix than the function control plane, +// so it needs its own Matches clause — otherwise tag requests fall through to +// the S3 catch-all and return a 405 HTML body the SDK can't deserialize. +const tagsPrefix = "/2017-03-31/tags" + const ( contentTypeJSON = "application/json" maxBodyBytes = 6 << 20 // 6 MiB — Lambda's sync invocation payload limit. @@ -43,6 +50,14 @@ type policyManager interface { GetPolicy(ctx context.Context, functionName string) (string, error) } +// functionTagger is the AWS-specific Lambda tagging surface (not part of the +// portable Serverless driver), asserted the same way as policyManager. +type functionTagger interface { + TagFunction(ctx context.Context, name string, tags map[string]string) error + UntagFunction(ctx context.Context, name string, keys []string) error + ListFunctionTags(ctx context.Context, name string) (map[string]string, error) +} + // Handler serves AWS Lambda REST requests against a serverless.Serverless // driver. type Handler struct { @@ -57,7 +72,7 @@ func New(fn sdrv.Serverless) *Handler { // Matches returns true for any URL under /2015-03-31/functions — that's the // Lambda control-plane prefix the SDK uses for every operation in our MVP. func (*Handler) Matches(r *http.Request) bool { - return strings.HasPrefix(r.URL.Path, pathPrefix) + return strings.HasPrefix(r.URL.Path, pathPrefix) || strings.HasPrefix(r.URL.Path, tagsPrefix) } // ServeHTTP dispatches Lambda operations based on path shape and method. @@ -66,6 +81,13 @@ func (*Handler) Matches(r *http.Request) bool { // /2015-03-31/functions/{name} GET=get, DELETE=delete // /2015-03-31/functions/{name}/invocations POST=invoke func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, tagsPrefix) { + arn := strings.TrimPrefix(strings.TrimPrefix(r.URL.Path, tagsPrefix), "/") + h.serveTags(w, r, arn) + + return + } + rest := strings.TrimPrefix(r.URL.Path, pathPrefix) rest = strings.TrimPrefix(rest, "/") @@ -120,6 +142,66 @@ func (h *Handler) serveSubresource(w http.ResponseWriter, r *http.Request, name, } } +// serveTags handles the Lambda tagging API at /2017-03-31/tags/{arn}: +// POST=TagResource, DELETE=UntagResource (?tagKeys=...), GET=ListTags. +func (h *Handler) serveTags(w http.ResponseWriter, r *http.Request, arn string) { + tagger, ok := h.fn.(functionTagger) + if !ok { + writeError(w, http.StatusNotImplemented, "InvalidRequestException", "tagging not supported") + return + } + + name := functionNameFromARN(arn) + + switch r.Method { + case http.MethodPost: + var req struct { + Tags map[string]string `json:"Tags"` + } + + if !decodeJSON(w, r, &req) { + return + } + + if err := tagger.TagFunction(r.Context(), name, req.Tags); err != nil { + writeErr(w, err) + return + } + + w.WriteHeader(http.StatusNoContent) + case http.MethodDelete: + if err := tagger.UntagFunction(r.Context(), name, r.URL.Query()["tagKeys"]); err != nil { + writeErr(w, err) + return + } + + w.WriteHeader(http.StatusNoContent) + case http.MethodGet: + tags, err := tagger.ListFunctionTags(r.Context(), name) + if err != nil { + writeErr(w, err) + return + } + + writeJSON(w, http.StatusOK, map[string]any{"Tags": tags}) + default: + writeError(w, http.StatusMethodNotAllowed, "InvalidRequestException", "method not allowed") + } +} + +// functionNameFromARN extracts the function name from a Lambda ARN +// (arn:aws:lambda:::function:). A value that isn't an +// ARN is returned unchanged. +func functionNameFromARN(arn string) string { + const marker = ":function:" + + if i := strings.LastIndex(arn, marker); i >= 0 { + return arn[i+len(marker):] + } + + return arn +} + // servePolicy handles POST (AddPermission) and GET (GetPolicy) on // .../{name}/policy. func (h *Handler) servePolicy(w http.ResponseWriter, r *http.Request, name string) { diff --git a/server/aws/lambda/lambda_test.go b/server/aws/lambda/lambda_test.go index d15ac750..898deaf8 100644 --- a/server/aws/lambda/lambda_test.go +++ b/server/aws/lambda/lambda_test.go @@ -7,6 +7,7 @@ import ( "io" "net/http" "net/http/httptest" + "net/url" "strings" "testing" @@ -464,6 +465,56 @@ func TestResourcePolicy(t *testing.T) { } } +// TestTagging is a regression guard for issue #319: the Lambda tagging API +// (/2017-03-31/tags/{arn}) was unmatched, so it fell through to the S3 +// catch-all and returned a 405 + HTML body the SDK couldn't deserialize. +func TestTagging(t *testing.T) { + srv, _ := newServer(t) + + if resp := postJSON(t, srv.URL+"/2015-03-31/functions", + `{"FunctionName":"tf","Runtime":"go1.x","Handler":"main"}`); resp.StatusCode != http.StatusCreated { + t.Fatalf("create status = %d", resp.StatusCode) + } + + // The SDK percent-encodes the ARN in the path; mirror that here so the + // server's URL parser keeps the query string separate. + tagsURL := srv.URL + "/2017-03-31/tags/" + + url.PathEscape("arn:aws:lambda:us-east-1:000000000000:function:tf") + + // TagResource. + if resp := postJSON(t, tagsURL, `{"Tags":{"env":"prod","team":"sls"}}`); resp.StatusCode != http.StatusNoContent { + t.Fatalf("tag-resource status = %d", resp.StatusCode) + } + + // ListTags. + resp := doJSON(t, http.MethodGet, tagsURL, "") + + var got struct { + Tags map[string]string `json:"Tags"` + } + + decode(t, resp, &got) + + if got.Tags["env"] != "prod" || got.Tags["team"] != "sls" { + t.Fatalf("ListTags = %+v", got.Tags) + } + + // UntagResource. + if resp := doJSON(t, http.MethodDelete, tagsURL+"?tagKeys=env", ""); resp.StatusCode != http.StatusNoContent { + t.Fatalf("untag-resource status = %d", resp.StatusCode) + } + + var after struct { + Tags map[string]string `json:"Tags"` + } + + decode(t, doJSON(t, http.MethodGet, tagsURL, ""), &after) + + if _, has := after.Tags["env"]; has || after.Tags["team"] != "sls" { + t.Fatalf("after untag = %+v", after.Tags) + } +} + func decode(t *testing.T, resp *http.Response, v any) { t.Helper() From 80809449e0bc2e33bb27d37af63dca3091735087 Mon Sep 17 00:00:00 2001 From: Gajendra Malviya Date: Tue, 4 Aug 2026 15:41:53 +0530 Subject: [PATCH 14/45] feat(sqs): support TagQueue/UntagQueue/ListQueueTags (#319) SQS tag operations returned UnknownOperationException. Add queue tagging to the AWS provider (stored on QueueInfo.Tags), routed via an AWS-local queueTagger assertion so the portable MessageQueue driver stays untouched. --- providers/aws/sqs/tags.go | 63 +++++++++++++++++++++++++++ server/aws/sqs/handler.go | 87 ++++++++++++++++++++++++++++++++++++++ server/aws/sqs/sqs_test.go | 29 +++++++++++++ 3 files changed, 179 insertions(+) create mode 100644 providers/aws/sqs/tags.go diff --git a/providers/aws/sqs/tags.go b/providers/aws/sqs/tags.go new file mode 100644 index 00000000..2d3c0f54 --- /dev/null +++ b/providers/aws/sqs/tags.go @@ -0,0 +1,63 @@ +package sqs + +import ( + "context" + + "github.com/stackshy/cloudemu/v2/errors" +) + +// TagQueue adds or overwrites tags on a queue (SQS TagQueue). +func (m *Mock) TagQueue(_ context.Context, queueURL string, tags map[string]string) error { + qd, ok := m.queues.Get(queueURL) + if !ok { + return errors.Newf(errors.NotFound, "queue %q not found", queueURL) + } + + qd.mu.Lock() + defer qd.mu.Unlock() + + if qd.info.Tags == nil { + qd.info.Tags = make(map[string]string, len(tags)) + } + + for k, v := range tags { + qd.info.Tags[k] = v + } + + return nil +} + +// UntagQueue removes tags by key from a queue (SQS UntagQueue). +func (m *Mock) UntagQueue(_ context.Context, queueURL string, keys []string) error { + qd, ok := m.queues.Get(queueURL) + if !ok { + return errors.Newf(errors.NotFound, "queue %q not found", queueURL) + } + + qd.mu.Lock() + defer qd.mu.Unlock() + + for _, k := range keys { + delete(qd.info.Tags, k) + } + + return nil +} + +// ListQueueTags returns a queue's tags (SQS ListQueueTags). +func (m *Mock) ListQueueTags(_ context.Context, queueURL string) (map[string]string, error) { + qd, ok := m.queues.Get(queueURL) + if !ok { + return nil, errors.Newf(errors.NotFound, "queue %q not found", queueURL) + } + + qd.mu.Lock() + defer qd.mu.Unlock() + + out := make(map[string]string, len(qd.info.Tags)) + for k, v := range qd.info.Tags { + out[k] = v + } + + return out, nil +} diff --git a/server/aws/sqs/handler.go b/server/aws/sqs/handler.go index ba422dc6..a9680b55 100644 --- a/server/aws/sqs/handler.go +++ b/server/aws/sqs/handler.go @@ -10,6 +10,7 @@ package sqs import ( + "context" "net/http" "strconv" "strings" @@ -64,6 +65,12 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.setQueueAttributes(w, r) case "PurgeQueue": h.purgeQueue(w, r) + case "TagQueue": + h.tagQueue(w, r) + case "UntagQueue": + h.untagQueue(w, r) + case "ListQueueTags": + h.listQueueTags(w, r) default: wire.WriteJSONError(w, http.StatusBadRequest, "UnknownOperationException", "unknown operation: "+op) @@ -248,6 +255,86 @@ func (h *Handler) deleteMessage(w http.ResponseWriter, r *http.Request) { wire.WriteJSON(w, map[string]any{}) } +// queueTagger is the AWS-specific SQS tagging surface. It's not part of the +// portable MessageQueue driver, so the handler type-asserts for it. +type queueTagger interface { + TagQueue(ctx context.Context, queueURL string, tags map[string]string) error + UntagQueue(ctx context.Context, queueURL string, keys []string) error + ListQueueTags(ctx context.Context, queueURL string) (map[string]string, error) +} + +func (h *Handler) tagQueue(w http.ResponseWriter, r *http.Request) { + tagger, ok := h.mq.(queueTagger) + if !ok { + writeErr(w, cerrors.New(cerrors.Unimplemented, "tagging not supported")) + return + } + + var req struct { + QueueURL string `json:"QueueUrl"` + Tags map[string]string `json:"Tags"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + if err := tagger.TagQueue(r.Context(), req.QueueURL, req.Tags); err != nil { + writeErr(w, err) + return + } + + wire.WriteJSON(w, map[string]any{}) +} + +func (h *Handler) untagQueue(w http.ResponseWriter, r *http.Request) { + tagger, ok := h.mq.(queueTagger) + if !ok { + writeErr(w, cerrors.New(cerrors.Unimplemented, "tagging not supported")) + return + } + + var req struct { + QueueURL string `json:"QueueUrl"` + TagKeys []string `json:"TagKeys"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + if err := tagger.UntagQueue(r.Context(), req.QueueURL, req.TagKeys); err != nil { + writeErr(w, err) + return + } + + wire.WriteJSON(w, map[string]any{}) +} + +func (h *Handler) listQueueTags(w http.ResponseWriter, r *http.Request) { + tagger, ok := h.mq.(queueTagger) + if !ok { + writeErr(w, cerrors.New(cerrors.Unimplemented, "tagging not supported")) + return + } + + var req struct { + QueueURL string `json:"QueueUrl"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + tags, err := tagger.ListQueueTags(r.Context(), req.QueueURL) + if err != nil { + writeErr(w, err) + return + } + + wire.WriteJSON(w, map[string]any{"Tags": tags}) +} + // numericAttrKeys are the SetQueueAttributes attributes the provider applies. var numericAttrKeys = []string{ "DelaySeconds", "VisibilityTimeout", "MaximumMessageSize", diff --git a/server/aws/sqs/sqs_test.go b/server/aws/sqs/sqs_test.go index 65881848..e12c947e 100644 --- a/server/aws/sqs/sqs_test.go +++ b/server/aws/sqs/sqs_test.go @@ -222,6 +222,35 @@ func TestQueueAttributesAndPurge(t *testing.T) { } } +// TestQueueTagging is a regression guard for issue #319: TagQueue / +// UntagQueue / ListQueueTags were unimplemented (UnknownOperationException). +func TestQueueTagging(t *testing.T) { + srv, _ := newServer(t) + + create := postJSON(t, srv, "AmazonSQS.CreateQueue", `{"QueueName":"tq"}`) + qurl := extractQueueURL(t, create) + + if resp := postJSON(t, srv, "AmazonSQS.TagQueue", + `{"QueueUrl":"`+qurl+`","Tags":{"env":"prod","team":"msg"}}`); resp.StatusCode != http.StatusOK { + t.Fatalf("TagQueue status = %d", resp.StatusCode) + } + + got := readBody(t, postJSON(t, srv, "AmazonSQS.ListQueueTags", `{"QueueUrl":"`+qurl+`"}`)) + if !strings.Contains(got, `"env":"prod"`) || !strings.Contains(got, `"team":"msg"`) { + t.Fatalf("ListQueueTags = %s", got) + } + + if resp := postJSON(t, srv, "AmazonSQS.UntagQueue", + `{"QueueUrl":"`+qurl+`","TagKeys":["env"]}`); resp.StatusCode != http.StatusOK { + t.Fatalf("UntagQueue status = %d", resp.StatusCode) + } + + got = readBody(t, postJSON(t, srv, "AmazonSQS.ListQueueTags", `{"QueueUrl":"`+qurl+`"}`)) + if strings.Contains(got, `"env"`) || !strings.Contains(got, `"team":"msg"`) { + t.Fatalf("after untag = %s", got) + } +} + func postJSON(t *testing.T, srv *httptest.Server, target, body string) *http.Response { t.Helper() From fa8f1f99357cb9b31460615012f02a7473eff1e4 Mon Sep 17 00:00:00 2001 From: Gajendra Malviya Date: Tue, 4 Aug 2026 15:44:02 +0530 Subject: [PATCH 15/45] feat(ssm): support parameter tagging (Add/Remove/ListTagsForResource) (#319) SSM AddTagsToResource returned UnknownOperationException. Add a tags map to parameter storage plus Tag/Untag/List provider methods, routed via an AWS-local parameterTagger assertion. --- providers/aws/ssm/ssm.go | 1 + providers/aws/ssm/tags.go | 65 ++++++++++++++++ server/aws/ssm/handler.go | 6 ++ server/aws/ssm/sdk_roundtrip_test.go | 51 +++++++++++++ server/aws/ssm/tags.go | 107 +++++++++++++++++++++++++++ 5 files changed, 230 insertions(+) create mode 100644 providers/aws/ssm/tags.go create mode 100644 server/aws/ssm/tags.go diff --git a/providers/aws/ssm/ssm.go b/providers/aws/ssm/ssm.go index 4e7cd152..42810882 100644 --- a/providers/aws/ssm/ssm.go +++ b/providers/aws/ssm/ssm.go @@ -45,6 +45,7 @@ type paramData struct { tier string versions []*version latest int64 + tags map[string]string mu sync.RWMutex } diff --git a/providers/aws/ssm/tags.go b/providers/aws/ssm/tags.go new file mode 100644 index 00000000..b1056527 --- /dev/null +++ b/providers/aws/ssm/tags.go @@ -0,0 +1,65 @@ +package ssm + +import ( + "context" + + "github.com/stackshy/cloudemu/v2/errors" +) + +// TagParameter adds or overwrites tags on a parameter (SSM AddTagsToResource +// with ResourceType=Parameter). +func (m *Mock) TagParameter(_ context.Context, name string, tags map[string]string) error { + pd, ok := m.params.Get(name) + if !ok { + return errors.Newf(errors.NotFound, "parameter %q not found", name) + } + + pd.mu.Lock() + defer pd.mu.Unlock() + + if pd.tags == nil { + pd.tags = make(map[string]string, len(tags)) + } + + for k, v := range tags { + pd.tags[k] = v + } + + return nil +} + +// UntagParameter removes tags by key from a parameter (SSM +// RemoveTagsFromResource). +func (m *Mock) UntagParameter(_ context.Context, name string, keys []string) error { + pd, ok := m.params.Get(name) + if !ok { + return errors.Newf(errors.NotFound, "parameter %q not found", name) + } + + pd.mu.Lock() + defer pd.mu.Unlock() + + for _, k := range keys { + delete(pd.tags, k) + } + + return nil +} + +// ListParameterTags returns a parameter's tags (SSM ListTagsForResource). +func (m *Mock) ListParameterTags(_ context.Context, name string) (map[string]string, error) { + pd, ok := m.params.Get(name) + if !ok { + return nil, errors.Newf(errors.NotFound, "parameter %q not found", name) + } + + pd.mu.RLock() + defer pd.mu.RUnlock() + + out := make(map[string]string, len(pd.tags)) + for k, v := range pd.tags { + out[k] = v + } + + return out, nil +} diff --git a/server/aws/ssm/handler.go b/server/aws/ssm/handler.go index 8866d64c..8ea1f3b3 100644 --- a/server/aws/ssm/handler.go +++ b/server/aws/ssm/handler.go @@ -64,6 +64,12 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.sendCommand(w, r) case "GetCommandInvocation": h.getCommandInvocation(w, r) + case "AddTagsToResource": + h.addTagsToResource(w, r) + case "RemoveTagsFromResource": + h.removeTagsFromResource(w, r) + case "ListTagsForResource": + h.listTagsForResource(w, r) default: wire.WriteJSONError(w, http.StatusBadRequest, "UnknownOperationException", "unknown SSM operation: "+op) diff --git a/server/aws/ssm/sdk_roundtrip_test.go b/server/aws/ssm/sdk_roundtrip_test.go index 45bc6413..e8e95003 100644 --- a/server/aws/ssm/sdk_roundtrip_test.go +++ b/server/aws/ssm/sdk_roundtrip_test.go @@ -78,6 +78,57 @@ func TestSDKPutGetParameter(t *testing.T) { } } +// TestSDKParameterTagging is a regression guard for issue #319: +// AddTagsToResource / RemoveTagsFromResource / ListTagsForResource were +// unimplemented (UnknownOperationException). +func TestSDKParameterTagging(t *testing.T) { + client := newSSMClient(t) + ctx := context.Background() + + if _, err := client.PutParameter(ctx, &awsssm.PutParameterInput{ + Name: aws.String("/app/db"), Value: aws.String("v"), Type: ssmtypes.ParameterTypeString, + }); err != nil { + t.Fatalf("PutParameter: %v", err) + } + + if _, err := client.AddTagsToResource(ctx, &awsssm.AddTagsToResourceInput{ + ResourceType: ssmtypes.ResourceTypeForTaggingParameter, + ResourceId: aws.String("/app/db"), + Tags: []ssmtypes.Tag{{Key: aws.String("env"), Value: aws.String("prod")}}, + }); err != nil { + t.Fatalf("AddTagsToResource: %v", err) + } + + list, err := client.ListTagsForResource(ctx, &awsssm.ListTagsForResourceInput{ + ResourceType: ssmtypes.ResourceTypeForTaggingParameter, ResourceId: aws.String("/app/db"), + }) + if err != nil { + t.Fatalf("ListTagsForResource: %v", err) + } + + if len(list.TagList) != 1 || aws.ToString(list.TagList[0].Key) != "env" { + t.Fatalf("TagList = %+v", list.TagList) + } + + if _, err := client.RemoveTagsFromResource(ctx, &awsssm.RemoveTagsFromResourceInput{ + ResourceType: ssmtypes.ResourceTypeForTaggingParameter, + ResourceId: aws.String("/app/db"), TagKeys: []string{"env"}, + }); err != nil { + t.Fatalf("RemoveTagsFromResource: %v", err) + } + + list, err = client.ListTagsForResource(ctx, &awsssm.ListTagsForResourceInput{ + ResourceType: ssmtypes.ResourceTypeForTaggingParameter, ResourceId: aws.String("/app/db"), + }) + if err != nil { + t.Fatalf("ListTagsForResource after remove: %v", err) + } + + if len(list.TagList) != 0 { + t.Fatalf("TagList after remove = %+v, want empty", list.TagList) + } +} + func TestSDKPutOverwriteVersioning(t *testing.T) { client := newSSMClient(t) ctx := context.Background() diff --git a/server/aws/ssm/tags.go b/server/aws/ssm/tags.go new file mode 100644 index 00000000..fd062ff9 --- /dev/null +++ b/server/aws/ssm/tags.go @@ -0,0 +1,107 @@ +package ssm + +import ( + "context" + "net/http" + + cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/server/wire" +) + +// parameterTagger is the AWS-specific parameter-tagging surface. It's not part +// of the portable ParameterStore driver, so the handler type-asserts for it. +type parameterTagger interface { + TagParameter(ctx context.Context, name string, tags map[string]string) error + UntagParameter(ctx context.Context, name string, keys []string) error + ListParameterTags(ctx context.Context, name string) (map[string]string, error) +} + +type ssmTag struct { + Key string `json:"Key"` + Value string `json:"Value"` +} + +func (h *Handler) addTagsToResource(w http.ResponseWriter, r *http.Request) { + tagger, ok := h.store.(parameterTagger) + if !ok { + writeErr(w, cerrors.New(cerrors.Unimplemented, "tagging not supported")) + return + } + + var req struct { + ResourceType string `json:"ResourceType"` + ResourceID string `json:"ResourceId"` + Tags []ssmTag `json:"Tags"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + tags := make(map[string]string, len(req.Tags)) + for _, t := range req.Tags { + tags[t.Key] = t.Value + } + + if err := tagger.TagParameter(r.Context(), req.ResourceID, tags); err != nil { + writeErr(w, err) + return + } + + wire.WriteJSON(w, struct{}{}) +} + +func (h *Handler) removeTagsFromResource(w http.ResponseWriter, r *http.Request) { + tagger, ok := h.store.(parameterTagger) + if !ok { + writeErr(w, cerrors.New(cerrors.Unimplemented, "tagging not supported")) + return + } + + var req struct { + ResourceType string `json:"ResourceType"` + ResourceID string `json:"ResourceId"` + TagKeys []string `json:"TagKeys"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + if err := tagger.UntagParameter(r.Context(), req.ResourceID, req.TagKeys); err != nil { + writeErr(w, err) + return + } + + wire.WriteJSON(w, struct{}{}) +} + +func (h *Handler) listTagsForResource(w http.ResponseWriter, r *http.Request) { + tagger, ok := h.store.(parameterTagger) + if !ok { + writeErr(w, cerrors.New(cerrors.Unimplemented, "tagging not supported")) + return + } + + var req struct { + ResourceType string `json:"ResourceType"` + ResourceID string `json:"ResourceId"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + tags, err := tagger.ListParameterTags(r.Context(), req.ResourceID) + if err != nil { + writeErr(w, err) + return + } + + out := make([]ssmTag, 0, len(tags)) + for k, v := range tags { + out = append(out, ssmTag{Key: k, Value: v}) + } + + wire.WriteJSON(w, map[string]any{"TagList": out}) +} From d937b01854b8fbbb331dd3305c6283e0698c8d53 Mon Sep 17 00:00:00 2001 From: Gajendra Malviya Date: Tue, 4 Aug 2026 15:49:44 +0530 Subject: [PATCH 16/45] fix(s3): route HeadBucket and the bucket ?tagging sub-resource (#319) HEAD /{bucket} returned 405 (no HeadBucket), and PUT /{bucket}?tagging mis-routed to CreateBucket, failing with BucketAlreadyOwnedByYou. Add a HEAD case backed by a bucket-exists check and a ?tagging dispatch to the provider's existing Put/Get/DeleteBucketTagging methods. --- server/aws/s3/handler.go | 80 +++++++++++++++++++++++++++++ server/aws/s3/sdk_roundtrip_test.go | 45 ++++++++++++++++ 2 files changed, 125 insertions(+) diff --git a/server/aws/s3/handler.go b/server/aws/s3/handler.go index 24a28974..4bb5a8e1 100644 --- a/server/aws/s3/handler.go +++ b/server/aws/s3/handler.go @@ -126,6 +126,9 @@ func (h *Handler) bucketOp(w http.ResponseWriter, r *http.Request, bucket string q := r.URL.Query() switch { + case q.Has("tagging"): + h.bucketTaggingOp(w, r, bucket) + return case q.Has("versioning"): h.bucketVersioningOp(w, r, bucket) return @@ -156,6 +159,83 @@ func (h *Handler) bucketOp(w http.ResponseWriter, r *http.Request, bucket string h.deleteBucket(w, r, bucket) case http.MethodGet: h.listObjects(w, r, bucket) + case http.MethodHead: + h.headBucket(w, r, bucket) + default: + writeError(w, http.StatusMethodNotAllowed, "MethodNotAllowed", "method not allowed") + } +} + +// headBucket answers HEAD /{bucket}: 200 if the bucket exists, 404 otherwise. +// It backs the SDK's HeadBucket / bucket-exists waiters. +func (h *Handler) headBucket(w http.ResponseWriter, r *http.Request, bucket string) { + buckets, err := h.bucket.ListBuckets(r.Context()) + if err != nil { + writeErr(w, err) + return + } + + for _, b := range buckets { + if b.Name == bucket { + w.WriteHeader(http.StatusOK) + return + } + } + + // HEAD carries no body, so the SDK infers NoSuchBucket from the 404 status. + w.WriteHeader(http.StatusNotFound) +} + +// bucketTaggingOp dispatches PUT/GET/DELETE for the bucket ?tagging +// sub-resource. Without this, a PUT ?tagging fell through to CreateBucket and +// failed with BucketAlreadyOwnedByYou. +func (h *Handler) bucketTaggingOp(w http.ResponseWriter, r *http.Request, bucket string) { + switch r.Method { + case http.MethodPut: + var body tagging + if err := xml.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "MalformedXML", "could not parse request body") + return + } + + tags := make(map[string]string, len(body.TagSet)) + for _, t := range body.TagSet { + tags[t.Key] = t.Value + } + + if err := h.bucket.PutBucketTagging(r.Context(), bucket, tags); err != nil { + writeErr(w, err) + return + } + + w.WriteHeader(http.StatusNoContent) + case http.MethodGet: + tags, err := h.bucket.GetBucketTagging(r.Context(), bucket) + if err != nil { + writeErr(w, err) + return + } + + resp := tagging{Xmlns: xmlns} + + keys := make([]string, 0, len(tags)) + for k := range tags { + keys = append(keys, k) + } + sort.Strings(keys) + + for _, k := range keys { + resp.TagSet = append(resp.TagSet, tagXML{Key: k, Value: tags[k]}) + } + + wire.WriteXML(w, http.StatusOK, resp) + case http.MethodDelete: + if err := h.bucket.DeleteBucketTagging(r.Context(), bucket); err != nil { + writeErr(w, err) + return + } + + w.WriteHeader(http.StatusNoContent) default: writeError(w, http.StatusMethodNotAllowed, "MethodNotAllowed", "method not allowed") } diff --git a/server/aws/s3/sdk_roundtrip_test.go b/server/aws/s3/sdk_roundtrip_test.go index 0cf71db5..4413b78b 100644 --- a/server/aws/s3/sdk_roundtrip_test.go +++ b/server/aws/s3/sdk_roundtrip_test.go @@ -264,6 +264,51 @@ func TestSDKObjectTagging(t *testing.T) { } } +// TestSDKHeadBucketAndTagging is a regression guard for issue #319: HeadBucket +// (HEAD /{bucket}) returned 405, and PutBucketTagging (PUT /{bucket}?tagging) +// mis-routed to CreateBucket and failed with BucketAlreadyOwnedByYou. +func TestSDKHeadBucketAndTagging(t *testing.T) { + client := newSDKClient(t) + ctx := context.Background() + + const bucket = "hb-bucket" + + mustCreateBucket(t, client, bucket) + + // HeadBucket on an existing bucket succeeds. + if _, err := client.HeadBucket(ctx, &awss3.HeadBucketInput{Bucket: aws.String(bucket)}); err != nil { + t.Fatalf("HeadBucket(existing): %v", err) + } + + // HeadBucket on a missing bucket is an error (404). + if _, err := client.HeadBucket(ctx, &awss3.HeadBucketInput{Bucket: aws.String("ghost")}); err == nil { + t.Fatal("HeadBucket(missing): expected error, got nil") + } + + // PutBucketTagging round-trips through the ?tagging sub-resource. + if _, err := client.PutBucketTagging(ctx, &awss3.PutBucketTaggingInput{ + Bucket: aws.String(bucket), + Tagging: &types.Tagging{TagSet: []types.Tag{ + {Key: aws.String("env"), Value: aws.String("prod")}, + }}, + }); err != nil { + t.Fatalf("PutBucketTagging: %v", err) + } + + got, err := client.GetBucketTagging(ctx, &awss3.GetBucketTaggingInput{Bucket: aws.String(bucket)}) + if err != nil { + t.Fatalf("GetBucketTagging: %v", err) + } + + if len(got.TagSet) != 1 || aws.ToString(got.TagSet[0].Key) != "env" { + t.Fatalf("GetBucketTagging = %+v", got.TagSet) + } + + if _, err := client.DeleteBucketTagging(ctx, &awss3.DeleteBucketTaggingInput{Bucket: aws.String(bucket)}); err != nil { + t.Fatalf("DeleteBucketTagging: %v", err) + } +} + // TestSDKBucketVersioning verifies PutBucketVersioning(Enabled) -> // GetBucketVersioning returns Enabled. func TestSDKBucketVersioning(t *testing.T) { From 54c86bd88222da9bbf39509e99396ccabb10cbdd Mon Sep 17 00:00:00 2001 From: Gajendra Malviya Date: Tue, 4 Aug 2026 15:49:45 +0530 Subject: [PATCH 17/45] test(ec2): update integration tests for Describe NotFound behavior (#319) Two integration tests still asserted the pre-fix empty-success behavior for DescribeInstances/DescribeVolumes by a missing ID; align them with the theme-C InvalidInstanceID/InvalidVolume.NotFound contract. --- cloudemu_test.go | 11 ++++------- server/aws/ec2_test.go | 14 +++++++++----- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/cloudemu_test.go b/cloudemu_test.go index 01a46d86..d2e70377 100644 --- a/cloudemu_test.go +++ b/cloudemu_test.go @@ -7097,13 +7097,10 @@ func TestVolumeLifecycleAWS(t *testing.T) { t.Fatal(err) } - vols, err = p.EC2.DescribeVolumes(ctx, []string{vol.ID}) - if err != nil { - t.Fatal(err) - } - - if len(vols) != 0 { - t.Errorf("expected 0 volumes after delete, got %d", len(vols)) + // Describing the deleted volume by ID now yields NotFound (issue #319, + // theme C: InvalidVolume.NotFound), not an empty success. + if _, err = p.EC2.DescribeVolumes(ctx, []string{vol.ID}); err == nil { + t.Error("expected NotFound describing a deleted volume, got nil") } } diff --git a/server/aws/ec2_test.go b/server/aws/ec2_test.go index 335662cf..d24be012 100644 --- a/server/aws/ec2_test.go +++ b/server/aws/ec2_test.go @@ -713,16 +713,20 @@ func TestEC2DescribeTerminatedInstanceStillVisible(t *testing.T) { "terminated instance should still be described") } -func TestEC2DescribeInstancesByUnknownIDReturnsEmpty(t *testing.T) { - // Real AWS returns an error; our provider returns empty. Document behavior. +func TestEC2DescribeInstancesByUnknownIDReturnsNotFound(t *testing.T) { + // Real AWS returns InvalidInstanceID.NotFound for an explicit missing ID + // (issue #319, theme C); a prior version returned an empty success. client := newEC2Client(t) ctx := context.Background() - out, err := client.DescribeInstances(ctx, &ec2.DescribeInstancesInput{ + _, err := client.DescribeInstances(ctx, &ec2.DescribeInstancesInput{ InstanceIds: []string{"i-deadbeef"}, }) - require.NoError(t, err) - assert.Empty(t, collectIDs(out)) + require.Error(t, err) + + var apiErr smithy.APIError + require.ErrorAs(t, err, &apiErr) + assert.Equal(t, "InvalidInstanceID.NotFound", apiErr.ErrorCode()) } func TestEC2StopIdempotent(t *testing.T) { From cf5de0ecd6df032e05ce744a227f1a4eca3f30b9 Mon Sep 17 00:00:00 2001 From: Gajendra Malviya Date: Tue, 4 Aug 2026 15:56:02 +0530 Subject: [PATCH 18/45] fix(resourceexplorer2): serve CreateIndex and GetDefaultView (#319) Both operations were unmatched, so they fell through to the S3 catch-all and returned a 405 + HTML body the SDK couldn't deserialize (theme D). Route them: CreateIndex idempotently returns the bootstrapped local index; GetDefaultView returns the first-created view (AWS auto-associates it as the account default). --- server/aws/resourceexplorer2/handler.go | 66 +++++++++++++++++++----- server/aws/resourceexplorer2/sdk_test.go | 15 ++++++ 2 files changed, 69 insertions(+), 12 deletions(-) diff --git a/server/aws/resourceexplorer2/handler.go b/server/aws/resourceexplorer2/handler.go index 71610243..ca0e8a11 100644 --- a/server/aws/resourceexplorer2/handler.go +++ b/server/aws/resourceexplorer2/handler.go @@ -55,10 +55,11 @@ type Handler struct { accountID string region string - mu sync.RWMutex - views map[string]*view // keyed by ViewArn - viewsByName map[string]string // ViewName → ViewArn, for collision detection - indexes map[string]*index // keyed by region + mu sync.RWMutex + views map[string]*view // keyed by ViewArn + viewsByName map[string]string // ViewName → ViewArn, for collision detection + indexes map[string]*index // keyed by region + defaultViewARN string // account default view (first created), for GetDefaultView } type view struct { @@ -103,14 +104,16 @@ func New(engine *resourcediscovery.Engine, accountID, region string) *Handler { // //nolint:gochecknoglobals // immutable lookup table. var knownPaths = map[string]struct{}{ - "/CreateView": {}, - "/DeleteView": {}, - "/ListViews": {}, - "/GetView": {}, - "/Search": {}, - "/ListResources": {}, - "/ListIndexes": {}, - "/GetIndex": {}, + "/CreateView": {}, + "/DeleteView": {}, + "/ListViews": {}, + "/GetView": {}, + "/Search": {}, + "/ListResources": {}, + "/ListIndexes": {}, + "/GetIndex": {}, + "/CreateIndex": {}, + "/GetDefaultView": {}, } // Matches returns true for POST requests whose path is one of the known @@ -143,6 +146,10 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.listIndexes(w, r) case "/GetIndex": h.getIndex(w, r) + case "/CreateIndex": + h.createIndex(w, r) + case "/GetDefaultView": + h.getDefaultView(w, r) default: wire.WriteJSONError(w, http.StatusNotFound, "ResourceNotFoundException", "unknown path: "+r.URL.Path) } @@ -189,6 +196,11 @@ func (h *Handler) createView(w http.ResponseWriter, r *http.Request) { h.views[arn] = v h.viewsByName[req.ViewName] = arn + // AWS auto-associates the first view in an account as its default view. + if h.defaultViewARN == "" { + h.defaultViewARN = arn + } + wire.WriteJSON(w, map[string]any{ "View": viewToWire(v, h.accountID), }) @@ -380,6 +392,36 @@ func (h *Handler) getIndex(w http.ResponseWriter, _ *http.Request) { }) } +// createIndex creates the LOCAL index for the calling region. Real Resource +// Explorer requires this before Search; the emulator bootstraps one at New(), +// so this is idempotent — it returns the existing index rather than erroring. +func (h *Handler) createIndex(w http.ResponseWriter, _ *http.Request) { + h.mu.Lock() + defer h.mu.Unlock() + + idx, ok := h.indexes[h.region] + if !ok { + idx = &index{ARN: h.indexARN(h.region), Region: h.region, Type: "LOCAL", CreatedAt: time.Now().UTC()} + h.indexes[h.region] = idx + } + + wire.WriteJSON(w, map[string]any{ + "Arn": idx.ARN, + "State": "CREATING", + "CreatedAt": idx.CreatedAt.Format(time.RFC3339), + }) +} + +// getDefaultView returns the account's default view. The first view created +// becomes the default (mirroring AWS auto-associating it); with no views the +// ViewArn is empty, which real Resource Explorer also returns. +func (h *Handler) getDefaultView(w http.ResponseWriter, _ *http.Request) { + h.mu.RLock() + defer h.mu.RUnlock() + + wire.WriteJSON(w, map[string]any{"ViewArn": h.defaultViewARN}) +} + func (h *Handler) viewARN(name string) string { return idgen.AWSARN("resource-explorer-2", h.region, h.accountID, "view/"+name+"/"+idgen.GenerateID("")) } diff --git a/server/aws/resourceexplorer2/sdk_test.go b/server/aws/resourceexplorer2/sdk_test.go index 84a6b92e..8cb423c9 100644 --- a/server/aws/resourceexplorer2/sdk_test.go +++ b/server/aws/resourceexplorer2/sdk_test.go @@ -176,6 +176,21 @@ func TestSDKResourceExplorer2_BugFixes(t *testing.T) { client := newREXClient(t, ts.URL) + t.Run("CreateIndex is idempotent and returns the local index", func(t *testing.T) { + out, err := client.CreateIndex(ctx, &rex.CreateIndexInput{}) + require.NoError(t, err, "CreateIndex must not return a 405/HTML deserialize error (#319 theme D)") + assert.NotEmpty(t, aws.ToString(out.Arn)) + }) + + t.Run("GetDefaultView returns the first created view", func(t *testing.T) { + created, err := client.CreateView(ctx, &rex.CreateViewInput{ViewName: aws.String("default-probe")}) + require.NoError(t, err) + + got, err := client.GetDefaultView(ctx, &rex.GetDefaultViewInput{}) + require.NoError(t, err, "GetDefaultView must not return a 405/HTML deserialize error (#319 theme D)") + assert.Equal(t, aws.ToString(created.View.ViewArn), aws.ToString(got.ViewArn)) + }) + t.Run("service:ec2 matches networking (not s3)", func(t *testing.T) { out, err := client.Search(ctx, &rex.SearchInput{ QueryString: aws.String("service:ec2"), From b7983f46ad9e32203178e9ac535a5a972212e5ae Mon Sep 17 00:00:00 2001 From: Gajendra Malviya Date: Tue, 4 Aug 2026 15:57:41 +0530 Subject: [PATCH 19/45] feat(cloudwatchlogs): implement PutRetentionPolicy (#319) PutRetentionPolicy returned UnknownOperationException. Route it through the driver's existing UpdateLogGroup, which applies the retention to the log group. --- server/aws/cloudwatchlogs/handler.go | 24 +++++++++++++++ .../aws/cloudwatchlogs/sdk_roundtrip_test.go | 30 +++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/server/aws/cloudwatchlogs/handler.go b/server/aws/cloudwatchlogs/handler.go index f336a4ed..75a927fc 100644 --- a/server/aws/cloudwatchlogs/handler.go +++ b/server/aws/cloudwatchlogs/handler.go @@ -70,12 +70,36 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.getLogEvents(w, r) case "FilterLogEvents": h.filterLogEvents(w, r) + case "PutRetentionPolicy": + h.putRetentionPolicy(w, r) default: wire.WriteJSONError(w, http.StatusBadRequest, "UnknownOperationException", "unknown CloudWatch Logs operation: "+op) } } +// putRetentionPolicy sets a log group's retention (SSM PutRetentionPolicy), +// backed by the driver's UpdateLogGroup. +func (h *Handler) putRetentionPolicy(w http.ResponseWriter, r *http.Request) { + var req struct { + LogGroupName string `json:"logGroupName"` + RetentionInDays int `json:"retentionInDays"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + if _, err := h.logs.UpdateLogGroup(r.Context(), logdriver.LogGroupConfig{ + Name: req.LogGroupName, RetentionDays: req.RetentionInDays, + }); err != nil { + writeErr(w, err) + return + } + + wire.WriteJSON(w, struct{}{}) +} + // writeErr maps canonical cloudemu errors to CloudWatch Logs JSON error // responses. Like the other AWS JSON 1.1 services, errors are HTTP 400 with a // "__type" body the SDK maps to a typed exception. diff --git a/server/aws/cloudwatchlogs/sdk_roundtrip_test.go b/server/aws/cloudwatchlogs/sdk_roundtrip_test.go index 0db41438..6001becc 100644 --- a/server/aws/cloudwatchlogs/sdk_roundtrip_test.go +++ b/server/aws/cloudwatchlogs/sdk_roundtrip_test.go @@ -81,6 +81,36 @@ func TestSDKLogGroupLifecycle(t *testing.T) { } } +// TestSDKPutRetentionPolicy is a regression guard for issue #319: +// PutRetentionPolicy was unimplemented (UnknownOperationException). +func TestSDKPutRetentionPolicy(t *testing.T) { + client := newLogsClient(t) + ctx := context.Background() + + if _, err := client.CreateLogGroup(ctx, &cwl.CreateLogGroupInput{ + LogGroupName: aws.String("/app/ret"), + }); err != nil { + t.Fatalf("CreateLogGroup: %v", err) + } + + if _, err := client.PutRetentionPolicy(ctx, &cwl.PutRetentionPolicyInput{ + LogGroupName: aws.String("/app/ret"), RetentionInDays: aws.Int32(14), + }); err != nil { + t.Fatalf("PutRetentionPolicy: %v", err) + } + + desc, err := client.DescribeLogGroups(ctx, &cwl.DescribeLogGroupsInput{ + LogGroupNamePrefix: aws.String("/app/ret"), + }) + if err != nil { + t.Fatalf("DescribeLogGroups: %v", err) + } + + if len(desc.LogGroups) != 1 || aws.ToInt32(desc.LogGroups[0].RetentionInDays) != 14 { + t.Fatalf("retention not applied: %+v", desc.LogGroups) + } +} + func TestSDKPutAndGetLogEvents(t *testing.T) { client := newLogsClient(t) ctx := context.Background() From 2c74b802e6c2d99d5a712bb81bf168df405590ec Mon Sep 17 00:00:00 2001 From: Gajendra Malviya Date: Tue, 4 Aug 2026 16:00:31 +0530 Subject: [PATCH 20/45] feat(iam): support inline role policies (Put/Get/Delete/ListRolePolicies) (#319) PutRolePolicy and the inline-role-policy operations returned InvalidAction. Store inline policies on the role in the AWS IAM provider and route the four query-protocol actions via an AWS-local rolePolicyManager assertion. --- providers/aws/awsiam/iam.go | 1 + providers/aws/awsiam/rolepolicy.go | 87 ++++++++++++++++++ server/aws/iam/handler.go | 22 +++++ server/aws/iam/rolepolicy.go | 131 +++++++++++++++++++++++++++ server/aws/iam/sdk_roundtrip_test.go | 57 ++++++++++++ 5 files changed, 298 insertions(+) create mode 100644 providers/aws/awsiam/rolepolicy.go create mode 100644 server/aws/iam/rolepolicy.go diff --git a/providers/aws/awsiam/iam.go b/providers/aws/awsiam/iam.go index 33e0329c..da0853cb 100644 --- a/providers/aws/awsiam/iam.go +++ b/providers/aws/awsiam/iam.go @@ -53,6 +53,7 @@ type roleData struct { Path string AssumeRolePolicyDoc string Tags map[string]string + inlinePolicies map[string]string // policyName -> policy document JSON } type policyData struct { diff --git a/providers/aws/awsiam/rolepolicy.go b/providers/aws/awsiam/rolepolicy.go new file mode 100644 index 00000000..f7704836 --- /dev/null +++ b/providers/aws/awsiam/rolepolicy.go @@ -0,0 +1,87 @@ +package awsiam + +import ( + "context" + "sort" + + "github.com/stackshy/cloudemu/v2/errors" +) + +// PutRolePolicy adds or replaces an inline policy on a role (IAM +// PutRolePolicy). Inline policies are embedded in the role, distinct from the +// managed policies attached via AttachRolePolicy. +func (m *Mock) PutRolePolicy(_ context.Context, roleName, policyName, policyDocument string) error { + m.mu.Lock() + defer m.mu.Unlock() + + rd, ok := m.roles.Get(roleName) + if !ok { + return errors.Newf(errors.NotFound, "role %q not found", roleName) + } + + if rd.inlinePolicies == nil { + rd.inlinePolicies = make(map[string]string) + } + + rd.inlinePolicies[policyName] = policyDocument + + return nil +} + +// GetRolePolicy returns an inline policy document by name (IAM GetRolePolicy). +func (m *Mock) GetRolePolicy(_ context.Context, roleName, policyName string) (string, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + rd, ok := m.roles.Get(roleName) + if !ok { + return "", errors.Newf(errors.NotFound, "role %q not found", roleName) + } + + doc, ok := rd.inlinePolicies[policyName] + if !ok { + return "", errors.Newf(errors.NotFound, "policy %q not found on role %q", policyName, roleName) + } + + return doc, nil +} + +// DeleteRolePolicy removes an inline policy from a role (IAM DeleteRolePolicy). +func (m *Mock) DeleteRolePolicy(_ context.Context, roleName, policyName string) error { + m.mu.Lock() + defer m.mu.Unlock() + + rd, ok := m.roles.Get(roleName) + if !ok { + return errors.Newf(errors.NotFound, "role %q not found", roleName) + } + + if _, ok := rd.inlinePolicies[policyName]; !ok { + return errors.Newf(errors.NotFound, "policy %q not found on role %q", policyName, roleName) + } + + delete(rd.inlinePolicies, policyName) + + return nil +} + +// ListRolePolicies returns the names of a role's inline policies, sorted (IAM +// ListRolePolicies). +func (m *Mock) ListRolePolicies(_ context.Context, roleName string) ([]string, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + rd, ok := m.roles.Get(roleName) + if !ok { + return nil, errors.Newf(errors.NotFound, "role %q not found", roleName) + } + + names := make([]string, 0, len(rd.inlinePolicies)) + for name := range rd.inlinePolicies { + names = append(names, name) + } + + sort.Strings(names) + + return names, nil +} diff --git a/server/aws/iam/handler.go b/server/aws/iam/handler.go index 95d1b66d..1e5e12ff 100644 --- a/server/aws/iam/handler.go +++ b/server/aws/iam/handler.go @@ -11,6 +11,7 @@ package iam import ( + "context" "net/http" "strings" @@ -71,6 +72,19 @@ var iamActions = map[string]struct{}{ //nolint:gochecknoglobals // static lookup "ListInstanceProfiles": {}, "AddRoleToInstanceProfile": {}, "RemoveRoleFromInstanceProfile": {}, + "PutRolePolicy": {}, + "GetRolePolicy": {}, + "DeleteRolePolicy": {}, + "ListRolePolicies": {}, +} + +// rolePolicyManager is the AWS-specific inline-role-policy surface. It's not +// part of the portable IAM driver, so the handler type-asserts for it. +type rolePolicyManager interface { + PutRolePolicy(ctx context.Context, roleName, policyName, policyDocument string) error + GetRolePolicy(ctx context.Context, roleName, policyName string) (string, error) + DeleteRolePolicy(ctx context.Context, roleName, policyName string) error + ListRolePolicies(ctx context.Context, roleName string) ([]string, error) } // Handler serves IAM query-protocol requests. @@ -193,6 +207,14 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.addRoleToInstanceProfile(w, r) case "RemoveRoleFromInstanceProfile": h.removeRoleFromInstanceProfile(w, r) + case "PutRolePolicy": + h.putRolePolicy(w, r) + case "GetRolePolicy": + h.getRolePolicy(w, r) + case "DeleteRolePolicy": + h.deleteRolePolicy(w, r) + case "ListRolePolicies": + h.listRolePolicies(w, r) default: awsquery.WriteXMLError(w, http.StatusBadRequest, "InvalidAction", "unknown IAM action: "+r.Form.Get("Action")) diff --git a/server/aws/iam/rolepolicy.go b/server/aws/iam/rolepolicy.go new file mode 100644 index 00000000..29710ff5 --- /dev/null +++ b/server/aws/iam/rolepolicy.go @@ -0,0 +1,131 @@ +package iam + +import ( + "encoding/xml" + "net/http" + + cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/server/wire/awsquery" +) + +type putRolePolicyResponse struct { + XMLName xml.Name `xml:"PutRolePolicyResponse"` + Xmlns string `xml:"xmlns,attr"` + Metadata responseMetadata `xml:"ResponseMetadata"` +} + +type deleteRolePolicyResponse struct { + XMLName xml.Name `xml:"DeleteRolePolicyResponse"` + Xmlns string `xml:"xmlns,attr"` + Metadata responseMetadata `xml:"ResponseMetadata"` +} + +type getRolePolicyResult struct { + RoleName string `xml:"RoleName"` + PolicyName string `xml:"PolicyName"` + PolicyDocument string `xml:"PolicyDocument"` +} + +type getRolePolicyResponse struct { + XMLName xml.Name `xml:"GetRolePolicyResponse"` + Xmlns string `xml:"xmlns,attr"` + Result getRolePolicyResult `xml:"GetRolePolicyResult"` + Metadata responseMetadata `xml:"ResponseMetadata"` +} + +type listRolePoliciesResult struct { + PolicyNames []string `xml:"PolicyNames>member"` +} + +type listRolePoliciesResponse struct { + XMLName xml.Name `xml:"ListRolePoliciesResponse"` + Xmlns string `xml:"xmlns,attr"` + Result listRolePoliciesResult `xml:"ListRolePoliciesResult"` + Metadata responseMetadata `xml:"ResponseMetadata"` +} + +func (h *Handler) rolePolicies() (rolePolicyManager, bool) { + pm, ok := h.iam.(rolePolicyManager) + + return pm, ok +} + +func (h *Handler) putRolePolicy(w http.ResponseWriter, r *http.Request) { + pm, ok := h.rolePolicies() + if !ok { + writeErr(w, cerrors.New(cerrors.Unimplemented, "inline role policies not supported")) + return + } + + if err := pm.PutRolePolicy(r.Context(), + r.Form.Get("RoleName"), r.Form.Get("PolicyName"), r.Form.Get("PolicyDocument")); err != nil { + writeErr(w, err) + return + } + + awsquery.WriteXMLResponse(w, putRolePolicyResponse{ + Xmlns: Namespace, Metadata: responseMetadata{RequestID: awsquery.RequestID}, + }) +} + +func (h *Handler) getRolePolicy(w http.ResponseWriter, r *http.Request) { + pm, ok := h.rolePolicies() + if !ok { + writeErr(w, cerrors.New(cerrors.Unimplemented, "inline role policies not supported")) + return + } + + roleName := r.Form.Get("RoleName") + policyName := r.Form.Get("PolicyName") + + doc, err := pm.GetRolePolicy(r.Context(), roleName, policyName) + if err != nil { + writeErr(w, err) + return + } + + awsquery.WriteXMLResponse(w, getRolePolicyResponse{ + Xmlns: Namespace, + Result: getRolePolicyResult{ + RoleName: roleName, PolicyName: policyName, PolicyDocument: doc, + }, + Metadata: responseMetadata{RequestID: awsquery.RequestID}, + }) +} + +func (h *Handler) deleteRolePolicy(w http.ResponseWriter, r *http.Request) { + pm, ok := h.rolePolicies() + if !ok { + writeErr(w, cerrors.New(cerrors.Unimplemented, "inline role policies not supported")) + return + } + + if err := pm.DeleteRolePolicy(r.Context(), r.Form.Get("RoleName"), r.Form.Get("PolicyName")); err != nil { + writeErr(w, err) + return + } + + awsquery.WriteXMLResponse(w, deleteRolePolicyResponse{ + Xmlns: Namespace, Metadata: responseMetadata{RequestID: awsquery.RequestID}, + }) +} + +func (h *Handler) listRolePolicies(w http.ResponseWriter, r *http.Request) { + pm, ok := h.rolePolicies() + if !ok { + writeErr(w, cerrors.New(cerrors.Unimplemented, "inline role policies not supported")) + return + } + + names, err := pm.ListRolePolicies(r.Context(), r.Form.Get("RoleName")) + if err != nil { + writeErr(w, err) + return + } + + awsquery.WriteXMLResponse(w, listRolePoliciesResponse{ + Xmlns: Namespace, + Result: listRolePoliciesResult{PolicyNames: names}, + Metadata: responseMetadata{RequestID: awsquery.RequestID}, + }) +} diff --git a/server/aws/iam/sdk_roundtrip_test.go b/server/aws/iam/sdk_roundtrip_test.go index 07f98d9f..f443d1ff 100644 --- a/server/aws/iam/sdk_roundtrip_test.go +++ b/server/aws/iam/sdk_roundtrip_test.go @@ -110,6 +110,63 @@ func TestSDKIAMUserLifecycle(t *testing.T) { } } +// TestSDKInlineRolePolicy is a regression guard for issue #319: PutRolePolicy +// and the inline-role-policy operations returned InvalidAction. +func TestSDKInlineRolePolicy(t *testing.T) { + client := newSDKClient(t) + ctx := context.Background() + + if _, err := client.CreateRole(ctx, &awsiam.CreateRoleInput{ + RoleName: aws.String("inline-role"), + AssumeRolePolicyDocument: aws.String(trustPolicy), + }); err != nil { + t.Fatalf("CreateRole: %v", err) + } + + if _, err := client.PutRolePolicy(ctx, &awsiam.PutRolePolicyInput{ + RoleName: aws.String("inline-role"), + PolicyName: aws.String("s3access"), + PolicyDocument: aws.String(samplePolicy), + }); err != nil { + t.Fatalf("PutRolePolicy: %v", err) + } + + list, err := client.ListRolePolicies(ctx, &awsiam.ListRolePoliciesInput{RoleName: aws.String("inline-role")}) + if err != nil { + t.Fatalf("ListRolePolicies: %v", err) + } + + if len(list.PolicyNames) != 1 || list.PolicyNames[0] != "s3access" { + t.Fatalf("ListRolePolicies = %v", list.PolicyNames) + } + + got, err := client.GetRolePolicy(ctx, &awsiam.GetRolePolicyInput{ + RoleName: aws.String("inline-role"), PolicyName: aws.String("s3access"), + }) + if err != nil { + t.Fatalf("GetRolePolicy: %v", err) + } + + if aws.ToString(got.PolicyDocument) == "" { + t.Fatal("GetRolePolicy returned empty document") + } + + if _, err := client.DeleteRolePolicy(ctx, &awsiam.DeleteRolePolicyInput{ + RoleName: aws.String("inline-role"), PolicyName: aws.String("s3access"), + }); err != nil { + t.Fatalf("DeleteRolePolicy: %v", err) + } + + list, err = client.ListRolePolicies(ctx, &awsiam.ListRolePoliciesInput{RoleName: aws.String("inline-role")}) + if err != nil { + t.Fatalf("ListRolePolicies after delete: %v", err) + } + + if len(list.PolicyNames) != 0 { + t.Fatalf("ListRolePolicies after delete = %v, want empty", list.PolicyNames) + } +} + func TestSDKIAMRoleAndPolicy(t *testing.T) { client := newSDKClient(t) ctx := context.Background() From e29e7194b26edaa4376b5b47cc0eea4d68d1ab9e Mon Sep 17 00:00:00 2001 From: Gajendra Malviya Date: Tue, 4 Aug 2026 16:03:17 +0530 Subject: [PATCH 21/45] feat(elasticache): implement ModifyCacheCluster (#319) ModifyCacheCluster returned InvalidAction. Add ModifyCache to the AWS provider (updates node type/engine) routed via an AWS-local cacheModifier assertion so the shared Cache driver (Azure Cache, GCP Memorystore) stays untouched. --- providers/aws/elasticache/elasticache.go | 23 +++++++++++++ server/aws/elasticache/handler.go | 3 ++ server/aws/elasticache/operations.go | 30 ++++++++++++++++ server/aws/elasticache/sdk_roundtrip_test.go | 36 ++++++++++++++++++++ server/aws/elasticache/types.go | 7 ++++ 5 files changed, 99 insertions(+) diff --git a/providers/aws/elasticache/elasticache.go b/providers/aws/elasticache/elasticache.go index 33ce9007..9adc32f6 100644 --- a/providers/aws/elasticache/elasticache.go +++ b/providers/aws/elasticache/elasticache.go @@ -128,6 +128,29 @@ func (m *Mock) CreateCache(_ context.Context, cfg driver.CacheConfig) (*driver.C return &result, nil } +// ModifyCache updates the mutable fields (node type, engine) of an existing +// cache cluster (ElastiCache ModifyCacheCluster). Empty arguments leave the +// corresponding field unchanged. +func (m *Mock) ModifyCache(_ context.Context, name, nodeType, engine string) (*driver.CacheInfo, error) { + cd, ok := m.caches.Get(name) + if !ok { + return nil, errors.Newf(errors.NotFound, "cache %q not found", name) + } + + if nodeType != "" { + cd.info.NodeType = nodeType + } + if engine != "" { + cd.info.Engine = engine + } + + m.caches.Set(name, cd) + + result := cd.info + + return &result, nil +} + // DeleteCache deletes an ElastiCache cluster by name. func (m *Mock) DeleteCache(_ context.Context, name string) error { if !m.caches.Delete(name) { diff --git a/server/aws/elasticache/handler.go b/server/aws/elasticache/handler.go index e711e2cd..119f4b32 100644 --- a/server/aws/elasticache/handler.go +++ b/server/aws/elasticache/handler.go @@ -44,6 +44,7 @@ var elastiCacheActions = map[string]struct{}{ //nolint:gochecknoglobals // stati "DeleteCacheSubnetGroup": {}, "CreateCacheCluster": {}, "DescribeCacheClusters": {}, + "ModifyCacheCluster": {}, "DeleteCacheCluster": {}, "CreateReplicationGroup": {}, "DescribeReplicationGroups": {}, @@ -99,6 +100,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.deleteCacheSubnetGroup(w, r) case "CreateCacheCluster": h.createCacheCluster(w, r) + case "ModifyCacheCluster": + h.modifyCacheCluster(w, r) case "DescribeCacheClusters": h.describeCacheClusters(w, r) case "CreateReplicationGroup": diff --git a/server/aws/elasticache/operations.go b/server/aws/elasticache/operations.go index 27dc3274..8d7e4bac 100644 --- a/server/aws/elasticache/operations.go +++ b/server/aws/elasticache/operations.go @@ -1,10 +1,12 @@ package elasticache import ( + "context" "net/http" "net/url" "strconv" + cerrors "github.com/stackshy/cloudemu/v2/errors" "github.com/stackshy/cloudemu/v2/server/wire/awsquery" cachedriver "github.com/stackshy/cloudemu/v2/services/cache/driver" "github.com/stackshy/cloudemu/v2/services/scope" @@ -52,6 +54,34 @@ func (h *Handler) createCacheCluster(w http.ResponseWriter, r *http.Request) { }) } +// cacheModifier is the AWS-specific ModifyCacheCluster surface. It's not part +// of the portable Cache driver (Azure Cache and GCP Memorystore also implement +// it), so the handler type-asserts for it. +type cacheModifier interface { + ModifyCache(ctx context.Context, name, nodeType, engine string) (*cachedriver.CacheInfo, error) +} + +func (h *Handler) modifyCacheCluster(w http.ResponseWriter, r *http.Request) { + mod, ok := h.cache.(cacheModifier) + if !ok { + writeErr(w, cerrors.New(cerrors.Unimplemented, "ModifyCacheCluster not supported")) + return + } + + info, err := mod.ModifyCache(r.Context(), + r.Form.Get("CacheClusterId"), r.Form.Get("CacheNodeType"), r.Form.Get("Engine")) + if err != nil { + writeErr(w, err) + return + } + + awsquery.WriteXMLResponse(w, modifyCacheClusterResponse{ + Xmlns: Namespace, + Result: cacheClusterResult{CacheCluster: toCacheClusterXML(info)}, + Metadata: responseMetadata{RequestID: awsquery.RequestID}, + }) +} + func (h *Handler) describeCacheClusters(w http.ResponseWriter, r *http.Request) { id := r.Form.Get("CacheClusterId") diff --git a/server/aws/elasticache/sdk_roundtrip_test.go b/server/aws/elasticache/sdk_roundtrip_test.go index 1179b29e..e0ed93f2 100644 --- a/server/aws/elasticache/sdk_roundtrip_test.go +++ b/server/aws/elasticache/sdk_roundtrip_test.go @@ -47,6 +47,42 @@ func newSDKClient(t *testing.T) *awselasticache.Client { }) } +// TestSDKModifyCacheCluster is a regression guard for issue #319: +// ModifyCacheCluster was unimplemented (InvalidAction). +func TestSDKModifyCacheCluster(t *testing.T) { + client := newSDKClient(t) + ctx := context.Background() + + if _, err := client.CreateCacheCluster(ctx, &awselasticache.CreateCacheClusterInput{ + CacheClusterId: aws.String("mc"), Engine: aws.String("redis"), + CacheNodeType: aws.String("cache.t3.micro"), NumCacheNodes: aws.Int32(1), + }); err != nil { + t.Fatalf("CreateCacheCluster: %v", err) + } + + out, err := client.ModifyCacheCluster(ctx, &awselasticache.ModifyCacheClusterInput{ + CacheClusterId: aws.String("mc"), CacheNodeType: aws.String("cache.t3.medium"), + }) + if err != nil { + t.Fatalf("ModifyCacheCluster: %v", err) + } + + if aws.ToString(out.CacheCluster.CacheNodeType) != "cache.t3.medium" { + t.Fatalf("node type = %q, want cache.t3.medium", aws.ToString(out.CacheCluster.CacheNodeType)) + } + + got, err := client.DescribeCacheClusters(ctx, &awselasticache.DescribeCacheClustersInput{ + CacheClusterId: aws.String("mc"), + }) + if err != nil { + t.Fatalf("DescribeCacheClusters: %v", err) + } + + if aws.ToString(got.CacheClusters[0].CacheNodeType) != "cache.t3.medium" { + t.Fatalf("persisted node type = %q", aws.ToString(got.CacheClusters[0].CacheNodeType)) + } +} + func TestSDKElastiCacheLifecycle(t *testing.T) { client := newSDKClient(t) ctx := context.Background() diff --git a/server/aws/elasticache/types.go b/server/aws/elasticache/types.go index 2b2cc887..054d0929 100644 --- a/server/aws/elasticache/types.go +++ b/server/aws/elasticache/types.go @@ -64,6 +64,13 @@ type createCacheClusterResponse struct { Metadata responseMetadata `xml:"ResponseMetadata"` } +type modifyCacheClusterResponse struct { + XMLName xml.Name `xml:"ModifyCacheClusterResponse"` + Xmlns string `xml:"xmlns,attr"` + Result cacheClusterResult `xml:"ModifyCacheClusterResult"` + Metadata responseMetadata `xml:"ResponseMetadata"` +} + type deleteCacheClusterResponse struct { XMLName xml.Name `xml:"DeleteCacheClusterResponse"` Xmlns string `xml:"xmlns,attr"` From ede932437f1be192e593e0a8be3d0415fc51240b Mon Sep 17 00:00:00 2001 From: Gajendra Malviya Date: Tue, 4 Aug 2026 17:11:41 +0530 Subject: [PATCH 22/45] fix(dynamodb): apply FilterExpression in Query (#319) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Query applied only the key condition and ignored FilterExpression, so it returned the full key-matched set — silently wrong data (Scan applied the same filter correctly). Thread a Filters field through QueryInput, parse FilterExpression in the query handler, and apply it after the key match. --- providers/aws/dynamodb/dynamodb.go | 6 ++++ .../aws/dynamodb/dynamodb_lifecycle_test.go | 35 +++++++++++++++++++ server/aws/dynamodb/handler.go | 3 ++ services/database/driver/driver.go | 7 ++-- 4 files changed, 49 insertions(+), 2 deletions(-) diff --git a/providers/aws/dynamodb/dynamodb.go b/providers/aws/dynamodb/dynamodb.go index bb4abcd7..25022a4f 100644 --- a/providers/aws/dynamodb/dynamodb.go +++ b/providers/aws/dynamodb/dynamodb.go @@ -347,6 +347,12 @@ func (m *Mock) matchQueryItems( } } + // Apply the FilterExpression (post key-condition), matching real + // DynamoDB: Query filters the key-matched set the same way Scan does. + if !matchesFilters(item, input.Filters) { + continue + } + matched = append(matched, item) } diff --git a/server/aws/dynamodb/dynamodb_lifecycle_test.go b/server/aws/dynamodb/dynamodb_lifecycle_test.go index cf8b6fb8..a7c36bf2 100644 --- a/server/aws/dynamodb/dynamodb_lifecycle_test.go +++ b/server/aws/dynamodb/dynamodb_lifecycle_test.go @@ -401,6 +401,41 @@ func TestDDBQueryPartitionAndSort(t *testing.T) { assert.Equal(t, "99", attrN(t, out.Items[0], "total")) } +// TestDDBQueryWithFilterExpression is a regression guard for issue #319: Query +// ignored FilterExpression and returned the full key-matched set (silent wrong +// data), while Scan applied it correctly. +func TestDDBQueryWithFilterExpression(t *testing.T) { + client, _ := newSuiteDDBEnv(t) + ctx := context.Background() + + suiteDDBCreateTable(t, client, "orders", "customer", "orderDate") + + for _, o := range []struct{ date, total string }{ + {"2024-01-01", "10"}, + {"2024-02-15", "70"}, + {"2024-03-10", "40"}, + } { + suiteDDBPut(t, client, "orders", map[string]ddbtypes.AttributeValue{ + "customer": sAttr("alice"), + "orderDate": sAttr(o.date), + "total": nAttr(o.total), + }) + } + + // Key matches 3 rows; the filter (total > 50) should leave only 1. + out, err := client.Query(ctx, &dynamodb.QueryInput{ + TableName: aws.String("orders"), + KeyConditionExpression: aws.String("customer = :c"), + FilterExpression: aws.String("total > :m"), + ExpressionAttributeValues: map[string]ddbtypes.AttributeValue{ + ":c": sAttr("alice"), ":m": nAttr("50"), + }, + }) + require.NoError(t, err) + require.Equal(t, int32(1), out.Count, "FilterExpression must prune the key-matched set") + assert.Equal(t, "70", attrN(t, out.Items[0], "total")) +} + // TestDDBQueryEdges: query on an empty table returns zero items; // query against a missing table or unknown index yields the typed error. func TestDDBQueryEdges(t *testing.T) { diff --git a/server/aws/dynamodb/handler.go b/server/aws/dynamodb/handler.go index 9633aa77..be424054 100644 --- a/server/aws/dynamodb/handler.go +++ b/server/aws/dynamodb/handler.go @@ -363,6 +363,7 @@ func (h *Handler) query(w http.ResponseWriter, r *http.Request) { var req struct { TableName string `json:"TableName"` KeyConditionExpression string `json:"KeyConditionExpression"` + FilterExpression string `json:"FilterExpression"` ExpressionAttributeValues map[string]any `json:"ExpressionAttributeValues"` ExpressionAttributeNames map[string]string `json:"ExpressionAttributeNames"` Limit int `json:"Limit"` @@ -377,6 +378,7 @@ func (h *Handler) query(w http.ResponseWriter, r *http.Request) { vals := fromWireItem(req.ExpressionAttributeValues) kc := parseKeyCondition(req.KeyConditionExpression, vals, req.ExpressionAttributeNames) + filters := parseFilterExpression(req.FilterExpression, vals, req.ExpressionAttributeNames) forward := true if req.ScanIndexForward != nil { @@ -387,6 +389,7 @@ func (h *Handler) query(w http.ResponseWriter, r *http.Request) { Table: req.TableName, IndexName: req.IndexName, KeyCondition: kc, + Filters: filters, Limit: req.Limit, SortDescending: !forward, ExclusiveStartKey: fromWireItem(req.ExclusiveStartKey), diff --git a/services/database/driver/driver.go b/services/database/driver/driver.go index e96e37fe..4fe9f033 100644 --- a/services/database/driver/driver.go +++ b/services/database/driver/driver.go @@ -94,8 +94,11 @@ type QueryInput struct { Table string IndexName string KeyCondition KeyCondition - Limit int - PageToken string + // Filters is the post-key-condition FilterExpression, applied to items + // that already match the key condition (same semantics as Scan.Filters). + Filters []ScanFilter + Limit int + PageToken string // ExclusiveStartKey selects key-based continuation (DynamoDB-style): // the page starts after the item with these key attributes. Mutually From 3f725f4431b8cf45575f2822d4ae3b22768cf7b4 Mon Sep 17 00:00:00 2001 From: Gajendra Malviya Date: Tue, 4 Aug 2026 17:13:39 +0530 Subject: [PATCH 23/45] fix(s3): honor max-keys and pagination in ListObjects (#319) The list handler never read the max-keys query param and hardcoded MaxKeys in the response, so ListObjectsV2 returned every key with IsTruncated=false and no continuation token. Parse max-keys (and the v1 marker) and pass them to the driver, which already paginated correctly. --- server/aws/s3/handler.go | 27 +++++++++++++--- server/aws/s3/sdk_roundtrip_test.go | 48 +++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 4 deletions(-) diff --git a/server/aws/s3/handler.go b/server/aws/s3/handler.go index 4bb5a8e1..8f447a5b 100644 --- a/server/aws/s3/handler.go +++ b/server/aws/s3/handler.go @@ -261,10 +261,29 @@ func (h *Handler) deleteBucket(w http.ResponseWriter, r *http.Request, bucket st } func (h *Handler) listObjects(w http.ResponseWriter, r *http.Request, bucket string) { + q := r.URL.Query() + + // A client-supplied continuation-token (ListObjectsV2) or marker + // (ListObjects v1) both resume paging; accept either. + pageToken := q.Get("continuation-token") + if pageToken == "" { + pageToken = q.Get("marker") + } + opts := driver.ListOptions{ - Prefix: r.URL.Query().Get("prefix"), - Delimiter: r.URL.Query().Get("delimiter"), - PageToken: r.URL.Query().Get("continuation-token"), + Prefix: q.Get("prefix"), + Delimiter: q.Get("delimiter"), + PageToken: pageToken, + } + + // max-keys caps the page; an absent or unparseable value leaves the driver + // default in place. Previously ignored, so large buckets never truncated. + maxKeys := defaultMaxKeys + if v := q.Get("max-keys"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n >= 0 { + opts.MaxKeys = n + maxKeys = n + } } result, err := h.bucket.ListObjects(r.Context(), bucket, opts) @@ -278,7 +297,7 @@ func (h *Handler) listObjects(w http.ResponseWriter, r *http.Request, bucket str Name: bucket, Prefix: opts.Prefix, Delimiter: opts.Delimiter, - MaxKeys: defaultMaxKeys, + MaxKeys: maxKeys, IsTruncated: result.IsTruncated, KeyCount: len(result.Objects), } diff --git a/server/aws/s3/sdk_roundtrip_test.go b/server/aws/s3/sdk_roundtrip_test.go index 4413b78b..81156d7e 100644 --- a/server/aws/s3/sdk_roundtrip_test.go +++ b/server/aws/s3/sdk_roundtrip_test.go @@ -309,6 +309,54 @@ func TestSDKHeadBucketAndTagging(t *testing.T) { } } +// TestSDKListObjectsV2MaxKeys is a regression guard for issue #319: +// ListObjectsV2 ignored MaxKeys, returned every key with IsTruncated=false and +// no continuation token, breaking any client that pages large buckets. +func TestSDKListObjectsV2MaxKeys(t *testing.T) { + client := newSDKClient(t) + ctx := context.Background() + + const bucket = "paged-bucket" + + mustCreateBucket(t, client, bucket) + + for _, k := range []string{"k1", "k2", "k3", "k4", "k5"} { + if _, err := client.PutObject(ctx, &awss3.PutObjectInput{ + Bucket: aws.String(bucket), Key: aws.String(k), Body: bytes.NewReader([]byte("x")), + }); err != nil { + t.Fatalf("PutObject %s: %v", k, err) + } + } + + first, err := client.ListObjectsV2(ctx, &awss3.ListObjectsV2Input{ + Bucket: aws.String(bucket), MaxKeys: aws.Int32(2), + }) + if err != nil { + t.Fatalf("ListObjectsV2 page 1: %v", err) + } + + if len(first.Contents) != 2 || !aws.ToBool(first.IsTruncated) || aws.ToString(first.NextContinuationToken) == "" { + t.Fatalf("page 1: got %d keys, truncated=%v, token=%q", + len(first.Contents), aws.ToBool(first.IsTruncated), aws.ToString(first.NextContinuationToken)) + } + + second, err := client.ListObjectsV2(ctx, &awss3.ListObjectsV2Input{ + Bucket: aws.String(bucket), MaxKeys: aws.Int32(2), + ContinuationToken: first.NextContinuationToken, + }) + if err != nil { + t.Fatalf("ListObjectsV2 page 2: %v", err) + } + + if len(second.Contents) != 2 { + t.Fatalf("page 2: got %d keys, want 2", len(second.Contents)) + } + + if aws.ToString(first.Contents[0].Key) == aws.ToString(second.Contents[0].Key) { + t.Fatal("page 2 returned the same first key as page 1 — pagination not advancing") + } +} + // TestSDKBucketVersioning verifies PutBucketVersioning(Enabled) -> // GetBucketVersioning returns Enabled. func TestSDKBucketVersioning(t *testing.T) { From 745d9feb301f0a594d17d12e57dbd20f5b131f82 Mon Sep 17 00:00:00 2001 From: Gajendra Malviya Date: Tue, 4 Aug 2026 17:17:57 +0530 Subject: [PATCH 24/45] test(bedrock): stop ConverseStream flaking on connection teardown (#319) The streaming (eventstream) runtime client reused pooled connections against the httptest server; once a stream was fully consumed, the reader could observe 'use of closed network connection' instead of a clean EOF, flaking the Test CI job under load. Disable keep-alives on the streaming test client so each request gets a fresh, server-closed connection and the stream ends deterministically. --- server/aws/bedrock/sdk_roundtrip_test.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/server/aws/bedrock/sdk_roundtrip_test.go b/server/aws/bedrock/sdk_roundtrip_test.go index 08f0a76f..9b39b2d2 100644 --- a/server/aws/bedrock/sdk_roundtrip_test.go +++ b/server/aws/bedrock/sdk_roundtrip_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "net/http" "net/http/httptest" "testing" @@ -212,6 +213,13 @@ func newRuntimeClient(t *testing.T) *awsruntime.Client { return awsruntime.NewFromConfig(cfg, func(o *awsruntime.Options) { o.BaseEndpoint = aws.String(newServer(t)) + // Disable HTTP keep-alives for the streaming (eventstream) client. + // Reusing a pooled connection races the httptest server's teardown: + // the eventstream reader can observe "use of closed network + // connection" instead of a clean EOF once the stream is fully + // consumed, flaking under CI load. A fresh, server-closed connection + // per request makes the stream end deterministically. + o.HTTPClient = &http.Client{Transport: &http.Transport{DisableKeepAlives: true}} }) } From acb7fe837807455ce76a033fd52acc522ba2efeb Mon Sep 17 00:00:00 2001 From: Gajendra Malviya Date: Tue, 4 Aug 2026 17:18:57 +0530 Subject: [PATCH 25/45] feat(ec2): implement DescribeRegions and DescribeInstanceTypes (#319) Both returned InvalidAction, breaking region and instance-type validation calls that bootstrap tooling makes. Serve DescribeRegions from a common region set (or the requested subset) and DescribeInstanceTypes with vCPU/ memory specs for the common types (defaulting for unknown types). --- server/aws/ec2/ec2_test.go | 25 +++++++ server/aws/ec2/handler.go | 1 + server/aws/ec2/metadata.go | 141 +++++++++++++++++++++++++++++++++++++ 3 files changed, 167 insertions(+) create mode 100644 server/aws/ec2/metadata.go diff --git a/server/aws/ec2/ec2_test.go b/server/aws/ec2/ec2_test.go index 0ded106f..4aac9279 100644 --- a/server/aws/ec2/ec2_test.go +++ b/server/aws/ec2/ec2_test.go @@ -99,6 +99,31 @@ func TestMatchesRejectsJSONPost(t *testing.T) { } } +// TestDescribeRegionsAndInstanceTypes is a regression guard for issue #319: +// DescribeRegions / DescribeInstanceTypes returned InvalidAction, breaking +// region/instance-type validation calls. +func TestDescribeRegionsAndInstanceTypes(t *testing.T) { + h := newHandler() + + regions := do(t, h, http.MethodPost, "/", url.Values{"Action": {"DescribeRegions"}}) + if regions.Code != http.StatusOK { + t.Fatalf("DescribeRegions status = %d", regions.Code) + } + if !strings.Contains(regions.Body.String(), "us-east-1") { + t.Fatalf("DescribeRegions missing us-east-1: %s", regions.Body.String()) + } + + types := do(t, h, http.MethodPost, "/", url.Values{ + "Action": {"DescribeInstanceTypes"}, "InstanceType.1": {"t3.micro"}, + }) + if types.Code != http.StatusOK { + t.Fatalf("DescribeInstanceTypes status = %d", types.Code) + } + if !strings.Contains(types.Body.String(), "t3.micro") { + t.Fatalf("DescribeInstanceTypes missing t3.micro: %s", types.Body.String()) + } +} + func TestServeHTTPUnknownActionReturns400(t *testing.T) { h := newHandler() diff --git a/server/aws/ec2/handler.go b/server/aws/ec2/handler.go index 9faa9715..b7098287 100644 --- a/server/aws/ec2/handler.go +++ b/server/aws/ec2/handler.go @@ -96,6 +96,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.routeClientVPN, h.routeVPC, h.routeTags, + h.routeMetadata, } for _, route := range routes { if route(w, r, action) { diff --git a/server/aws/ec2/metadata.go b/server/aws/ec2/metadata.go new file mode 100644 index 00000000..09f3604c --- /dev/null +++ b/server/aws/ec2/metadata.go @@ -0,0 +1,141 @@ +package ec2 + +import ( + "encoding/xml" + "net/http" + + "github.com/stackshy/cloudemu/v2/server/wire/awsquery" +) + +// commonRegions is the representative region set DescribeRegions reports. Tools +// call DescribeRegions to validate a region exists before provisioning; a fixed +// common set satisfies that without pretending to enumerate every AWS region. +var commonRegions = []string{ //nolint:gochecknoglobals // static lookup table + "us-east-1", "us-east-2", "us-west-1", "us-west-2", + "eu-west-1", "eu-west-2", "eu-central-1", + "ap-south-1", "ap-southeast-1", "ap-southeast-2", "ap-northeast-1", +} + +type regionXML struct { + RegionName string `xml:"regionName"` + Endpoint string `xml:"regionEndpoint"` + OptInStatus string `xml:"optInStatus"` +} + +type describeRegionsResponseXML struct { + XMLName xml.Name `xml:"DescribeRegionsResponse"` + Xmlns string `xml:"xmlns,attr"` + RequestID string `xml:"requestId"` + Regions []regionXML `xml:"regionInfo>item"` +} + +func (h *Handler) routeMetadata(w http.ResponseWriter, r *http.Request, action string) bool { + switch action { + case "DescribeRegions": + h.describeRegions(w, r) + case "DescribeInstanceTypes": + h.describeInstanceTypes(w, r) + default: + return false + } + + return true +} + +// describeRegions answers ec2:DescribeRegions. If explicit RegionName.N filters +// are supplied, only those are returned; otherwise the common set is reported. +func (h *Handler) describeRegions(w http.ResponseWriter, r *http.Request) { + requested := awsquery.ListStrings(r.Form, "RegionName") + + names := commonRegions + if len(requested) > 0 { + names = requested + } + + out := make([]regionXML, 0, len(names)) + for _, name := range names { + out = append(out, regionXML{ + RegionName: name, + Endpoint: "ec2." + name + ".amazonaws.com", + OptInStatus: "opt-in-not-required", + }) + } + + awsquery.WriteXMLResponse(w, describeRegionsResponseXML{ + Xmlns: awsquery.Namespace, RequestID: awsquery.RequestID, Regions: out, + }) +} + +// instanceTypeSpec is the vCPU/memory profile reported for an instance type. +type instanceTypeSpec struct { + vcpus int + memoryMiB int +} + +// knownInstanceTypes maps the common instance types to their specs. An +// unrecognized type still gets a response (small default) so validation calls +// don't fail on a type the emulator hasn't enumerated. +var knownInstanceTypes = map[string]instanceTypeSpec{ //nolint:gochecknoglobals // static lookup table + "t2.micro": {1, 1024}, + "t2.small": {1, 2048}, + "t3.micro": {2, 1024}, + "t3.small": {2, 2048}, + "t3.medium": {2, 4096}, + "m5.large": {2, 8192}, + "m5.xlarge": {4, 16384}, + "c5.large": {2, 4096}, + "r5.large": {2, 16384}, +} + +type vCPUInfoXML struct { + DefaultVCpus int `xml:"defaultVCpus"` +} + +type memoryInfoXML struct { + SizeInMiB int `xml:"sizeInMiB"` +} + +type instanceTypeInfoXML struct { + InstanceType string `xml:"instanceType"` + VCPUInfo vCPUInfoXML `xml:"vCpuInfo"` + MemoryInfo memoryInfoXML `xml:"memoryInfo"` +} + +type describeInstanceTypesResponseXML struct { + XMLName xml.Name `xml:"DescribeInstanceTypesResponse"` + Xmlns string `xml:"xmlns,attr"` + RequestID string `xml:"requestId"` + InstanceTypes []instanceTypeInfoXML `xml:"instanceTypeSet>item"` +} + +// describeInstanceTypes answers ec2:DescribeInstanceTypes. Explicit +// InstanceType.N values are echoed with their (or a default) spec; with none +// supplied, the known set is reported. +func (h *Handler) describeInstanceTypes(w http.ResponseWriter, r *http.Request) { + requested := awsquery.ListStrings(r.Form, "InstanceType") + + names := requested + if len(names) == 0 { + for name := range knownInstanceTypes { + names = append(names, name) + } + } + + out := make([]instanceTypeInfoXML, 0, len(names)) + for _, name := range names { + spec, ok := knownInstanceTypes[name] + if !ok { + spec = instanceTypeSpec{vcpus: 2, memoryMiB: 4096} + } + + out = append(out, instanceTypeInfoXML{ + InstanceType: name, + VCPUInfo: vCPUInfoXML{DefaultVCpus: spec.vcpus}, + MemoryInfo: memoryInfoXML{SizeInMiB: spec.memoryMiB}, + }) + } + + awsquery.WriteXMLResponse(w, describeInstanceTypesResponseXML{ + Xmlns: awsquery.Namespace, RequestID: awsquery.RequestID, InstanceTypes: out, + }) +} From 72178889d0be016dccbfe55fed50039d0405bfe6 Mon Sep 17 00:00:00 2001 From: Gajendra Malviya Date: Tue, 4 Aug 2026 17:22:34 +0530 Subject: [PATCH 26/45] feat(ec2): add CreateNetworkInterface, MonitorInstances, DescribeInstanceStatus (#319) All three returned InvalidAction. CreateNetworkInterface makes a standalone available ENI in a subnet (VPC resolved from the subnet); Monitor/UnmonitorInstances validate the instances exist and echo the monitoring state; DescribeInstanceStatus reports running instances with passing system/instance checks (IncludeAllInstances covers other states). --- providers/aws/vpc/eni.go | 30 +++++++ server/aws/ec2/ec2_phase2_test.go | 43 +++++++++ server/aws/ec2/handler.go | 3 + server/aws/ec2/instance_status.go | 126 +++++++++++++++++++++++++++ server/aws/ec2/network_interface.go | 34 ++++++++ services/networking/driver/driver.go | 1 + 6 files changed, 237 insertions(+) create mode 100644 server/aws/ec2/instance_status.go diff --git a/providers/aws/vpc/eni.go b/providers/aws/vpc/eni.go index 312ee7a1..cc16dbaf 100644 --- a/providers/aws/vpc/eni.go +++ b/providers/aws/vpc/eni.go @@ -24,6 +24,36 @@ type eniData struct { Tags map[string]string } +// CreateNetworkInterface creates a standalone, unattached ENI in the given +// subnet (ec2:CreateNetworkInterface). The VPC is resolved from the subnet, so +// an unknown subnet is NotFound. +func (m *Mock) CreateNetworkInterface( + _ context.Context, subnetID, description string, tags map[string]string, +) (*driver.NetworkInterface, error) { + m.mu.Lock() + defer m.mu.Unlock() + + sub, ok := m.subnets.Get(subnetID) + if !ok { + return nil, errors.Newf(errors.NotFound, "InvalidSubnetID.NotFound: subnet %q not found", subnetID) + } + + id := idgen.GenerateID("eni-") + eni := &eniData{ + ID: id, + VPCID: sub.VPCID, + SubnetID: subnetID, + Status: ENIStatusAvailable, + Description: description, + Tags: copyTags(tags), + } + m.enis.Set(id, eni) + + info := toENIInfo(eni) + + return &info, nil +} + // DescribeNetworkInterfaces returns ENIs matching the given IDs, or all if empty. // // An explicitly named ID that does not exist is NotFound rather than an empty diff --git a/server/aws/ec2/ec2_phase2_test.go b/server/aws/ec2/ec2_phase2_test.go index d3c3e4bf..0813ebec 100644 --- a/server/aws/ec2/ec2_phase2_test.go +++ b/server/aws/ec2/ec2_phase2_test.go @@ -537,6 +537,49 @@ func TestCreateAndDeleteTags(t *testing.T) { } } +// TestCreateNetworkInterfaceAndInstanceStatus is a regression guard for issue +// #319: CreateNetworkInterface, MonitorInstances, and DescribeInstanceStatus +// returned InvalidAction. +func TestCreateNetworkInterfaceAndInstanceStatus(t *testing.T) { + h := newFullHandler() + + vpcID := between(do(t, h, http.MethodPost, "/", url.Values{ + "Action": {"CreateVpc"}, "CidrBlock": {"10.0.0.0/16"}, + }).Body.String(), "", "") + + subnetID := between(do(t, h, http.MethodPost, "/", url.Values{ + "Action": {"CreateSubnet"}, "VpcId": {vpcID}, "CidrBlock": {"10.0.1.0/24"}, + }).Body.String(), "", "") + + // CreateNetworkInterface in the subnet. + eni := do(t, h, http.MethodPost, "/", url.Values{ + "Action": {"CreateNetworkInterface"}, "SubnetId": {subnetID}, "Description": {"eni-x"}, + }) + if eni.Code != http.StatusOK || !strings.Contains(eni.Body.String(), "eni-") { + t.Fatalf("CreateNetworkInterface: code=%d body=%s", eni.Code, eni.Body.String()) + } + + // Run an instance, then monitor + status it. + instID := between(do(t, h, http.MethodPost, "/", url.Values{ + "Action": {"RunInstances"}, "ImageId": {"ami-1"}, "InstanceType": {"t3.micro"}, + "MinCount": {"1"}, "MaxCount": {"1"}, + }).Body.String(), "", "") + + mon := do(t, h, http.MethodPost, "/", url.Values{ + "Action": {"MonitorInstances"}, "InstanceId.1": {instID}, + }) + if mon.Code != http.StatusOK || !strings.Contains(mon.Body.String(), "enabled") { + t.Fatalf("MonitorInstances: code=%d body=%s", mon.Code, mon.Body.String()) + } + + status := do(t, h, http.MethodPost, "/", url.Values{ + "Action": {"DescribeInstanceStatus"}, "InstanceId.1": {instID}, + }) + if status.Code != http.StatusOK || !strings.Contains(status.Body.String(), ""+instID+"") { + t.Fatalf("DescribeInstanceStatus: code=%d body=%s", status.Code, status.Body.String()) + } +} + func between(s, open, close string) string { i := strings.Index(s, open) if i < 0 { diff --git a/server/aws/ec2/handler.go b/server/aws/ec2/handler.go index b7098287..4c7c3b0b 100644 --- a/server/aws/ec2/handler.go +++ b/server/aws/ec2/handler.go @@ -97,6 +97,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.routeVPC, h.routeTags, h.routeMetadata, + h.routeInstanceStatus, } for _, route := range routes { if route(w, r, action) { @@ -444,6 +445,8 @@ func (h *Handler) routeVPCRouteTable(w http.ResponseWriter, r *http.Request, act h.associateRouteTable(w, r) case "DisassociateRouteTable": h.disassociateRouteTable(w, r) + case "CreateNetworkInterface": + h.createNetworkInterface(w, r) case "DescribeNetworkInterfaces": h.describeNetworkInterfaces(w, r) case "DetachNetworkInterface": diff --git a/server/aws/ec2/instance_status.go b/server/aws/ec2/instance_status.go new file mode 100644 index 00000000..95c744eb --- /dev/null +++ b/server/aws/ec2/instance_status.go @@ -0,0 +1,126 @@ +package ec2 + +import ( + "encoding/xml" + "net/http" + + "github.com/stackshy/cloudemu/v2/server/wire/awsquery" + computedriver "github.com/stackshy/cloudemu/v2/services/compute/driver" +) + +func (h *Handler) routeInstanceStatus(w http.ResponseWriter, r *http.Request, action string) bool { + switch action { + case "MonitorInstances": + h.monitorInstances(w, r, "enabled") + case "UnmonitorInstances": + h.monitorInstances(w, r, "disabled") + case "DescribeInstanceStatus": + h.describeInstanceStatus(w, r) + default: + return false + } + + return true +} + +type monitorItemXML struct { + InstanceID string `xml:"instanceId"` + State string `xml:"monitoring>state"` +} + +type monitorInstancesResponseXML struct { + XMLName xml.Name `xml:"MonitorInstancesResponse"` + Xmlns string `xml:"xmlns,attr"` + RequestID string `xml:"requestId"` + Instances []monitorItemXML `xml:"instancesSet>item"` +} + +// monitorInstances answers Monitor/UnmonitorInstances. It validates each +// requested instance exists (InvalidInstanceID.NotFound otherwise) and echoes +// the resulting monitoring state. +func (h *Handler) monitorInstances(w http.ResponseWriter, r *http.Request, state string) { + ids := awsquery.ListStrings(r.Form, "InstanceId") + + if _, err := h.compute.DescribeInstances(r.Context(), ids, nil); err != nil { + writeErr(w, err) + return + } + + items := make([]monitorItemXML, 0, len(ids)) + for _, id := range ids { + items = append(items, monitorItemXML{InstanceID: id, State: state}) + } + + awsquery.WriteXMLResponse(w, monitorInstancesResponseXML{ + Xmlns: awsquery.Namespace, RequestID: awsquery.RequestID, Instances: items, + }) +} + +type statusDetailXML struct { + Status string `xml:"status"` +} + +type instanceStatusItemXML struct { + InstanceID string `xml:"instanceId"` + AvailZone string `xml:"availabilityZone,omitempty"` + InstanceState instanceState `xml:"instanceState"` + SystemStatus statusDetailXML `xml:"systemStatus"` + InstanceStatus statusDetailXML `xml:"instanceStatus"` +} + +type describeInstanceStatusResponseXML struct { + XMLName xml.Name `xml:"DescribeInstanceStatusResponse"` + Xmlns string `xml:"xmlns,attr"` + RequestID string `xml:"requestId"` + Statuses []instanceStatusItemXML `xml:"instanceStatusSet>item"` +} + +// describeInstanceStatus answers DescribeInstanceStatus. By default only +// running instances are reported (matching real EC2); IncludeAllInstances=true +// reports every state. Running instances report passing system/instance checks. +func (h *Handler) describeInstanceStatus(w http.ResponseWriter, r *http.Request) { + ids := awsquery.ListStrings(r.Form, "InstanceId") + includeAll := r.Form.Get("IncludeAllInstances") == formTrue + + instances, err := h.compute.DescribeInstances(r.Context(), ids, nil) + if err != nil { + writeErr(w, err) + return + } + + out := make([]instanceStatusItemXML, 0, len(instances)) + for i := range instances { + inst := &instances[i] + if !includeAll && inst.State != "running" { + continue + } + + out = append(out, statusItem(inst)) + } + + awsquery.WriteXMLResponse(w, describeInstanceStatusResponseXML{ + Xmlns: awsquery.Namespace, RequestID: awsquery.RequestID, Statuses: out, + }) +} + +func statusItem(inst *computedriver.Instance) instanceStatusItemXML { + // Checks are "ok" only once the instance is running; otherwise + // "not-applicable", matching real EC2's status-check semantics. + check := "not-applicable" + if inst.State == "running" { + check = "ok" + } + + az := "" + if len(inst.Zones) > 0 { + az = inst.Zones[0] + } + + return instanceStatusItemXML{ + InstanceID: inst.ID, + AvailZone: az, + InstanceState: instanceState{Code: stateCode(inst.State), Name: inst.State}, + SystemStatus: statusDetailXML{Status: check}, + InstanceStatus: statusDetailXML{Status: check}, + } +} diff --git a/server/aws/ec2/network_interface.go b/server/aws/ec2/network_interface.go index 9922d16c..86bb40fe 100644 --- a/server/aws/ec2/network_interface.go +++ b/server/aws/ec2/network_interface.go @@ -31,6 +31,13 @@ type describeNetworkInterfacesResponseXML struct { NetworkInterfaceSet []networkInterfaceXML `xml:"networkInterfaceSet>item"` } +type createNetworkInterfaceResponseXML struct { + XMLName xml.Name `xml:"CreateNetworkInterfaceResponse"` + Xmlns string `xml:"xmlns,attr"` + RequestID string `xml:"requestId"` + NetworkInterface networkInterfaceXML `xml:"networkInterface"` +} + type detachNetworkInterfaceResponseXML struct { XMLName xml.Name `xml:"DetachNetworkInterfaceResponse"` Xmlns string `xml:"xmlns,attr"` @@ -150,6 +157,33 @@ func containsString(values []string, want string) bool { return false } +func (h *Handler) createNetworkInterface(w http.ResponseWriter, r *http.Request) { + store, ok := h.networkInterfaces() + if !ok { + writeUnsupportedENI(w) + return + } + + subnetID := r.Form.Get("SubnetId") + if subnetID == "" { + writeENIErr(w, cerrors.New(cerrors.InvalidArgument, "SubnetId is required")) + return + } + + eni, err := store.CreateNetworkInterface(r.Context(), subnetID, r.Form.Get("Description"), + mergeTagSpecs(awsquery.TagSpecs(r.Form), "network-interface")) + if err != nil { + writeENIErr(w, err) + return + } + + awsquery.WriteXMLResponse(w, createNetworkInterfaceResponseXML{ + Xmlns: awsquery.Namespace, + RequestID: awsquery.RequestID, + NetworkInterface: toNetworkInterfaceXML(eni), + }) +} + func (h *Handler) detachNetworkInterface(w http.ResponseWriter, r *http.Request) { force := r.Form.Get("Force") == formTrue diff --git a/services/networking/driver/driver.go b/services/networking/driver/driver.go index 46500639..e2fa2c1b 100644 --- a/services/networking/driver/driver.go +++ b/services/networking/driver/driver.go @@ -375,6 +375,7 @@ type VPCAttributes interface { // that do not model interfaces would carry identical copies of one that does // nothing for them. type NetworkInterfaces interface { + CreateNetworkInterface(ctx context.Context, subnetID, description string, tags map[string]string) (*NetworkInterface, error) DescribeNetworkInterfaces(ctx context.Context, ids []string) ([]NetworkInterface, error) DetachNetworkInterface(ctx context.Context, attachmentID string, force bool) error DeleteNetworkInterface(ctx context.Context, id string) error From 3fde28cc4d28df90b35f32653fcd23ab07421860 Mon Sep 17 00:00:00 2001 From: Gajendra Malviya Date: Tue, 4 Aug 2026 17:25:37 +0530 Subject: [PATCH 27/45] feat(redshift): implement CreateClusterParameterGroup and CreateClusterSubnetGroup (#319) Both returned InvalidAction, blocking IaC that provisions a warehouse with a custom parameter or subnet group. Store the groups in the AWS Redshift provider and route the two actions via an AWS-local clusterGroupManager assertion (they're not part of the shared relationaldb driver). --- providers/aws/redshift/redshift.go | 51 +++++++++++++ server/aws/redshift/handler.go | 33 +++++--- server/aws/redshift/parametergroup.go | 92 +++++++++++++++++++++++ server/aws/redshift/sdk_roundtrip_test.go | 35 +++++++++ 4 files changed, 202 insertions(+), 9 deletions(-) create mode 100644 server/aws/redshift/parametergroup.go diff --git a/providers/aws/redshift/redshift.go b/providers/aws/redshift/redshift.go index fb064227..2f73d21c 100644 --- a/providers/aws/redshift/redshift.go +++ b/providers/aws/redshift/redshift.go @@ -39,12 +39,29 @@ var errInstanceOpsUnsupported = cerrors.New(cerrors.InvalidArgument, var _ rdbdriver.RelationalDB = (*Mock)(nil) +// ParameterGroup and SubnetGroup are lightweight redshift-specific resources +// (not part of the shared relationaldb driver). The emulator stores their +// identity so IaC that creates and references them succeeds. +type ParameterGroup struct { + Name string + Family string + Description string +} + +type SubnetGroup struct { + Name string + Description string + SubnetIDs []string +} + // Mock is the in-memory AWS Redshift implementation. type Mock struct { mu sync.RWMutex clusters *memstore.Store[rdbdriver.Cluster] clusterSnapshots *memstore.Store[rdbdriver.ClusterSnapshot] + parameterGroups *memstore.Store[ParameterGroup] + subnetGroups *memstore.Store[SubnetGroup] opts *config.Options monitoring mondriver.Monitoring @@ -55,10 +72,44 @@ func New(opts *config.Options) *Mock { return &Mock{ clusters: memstore.New[rdbdriver.Cluster](), clusterSnapshots: memstore.New[rdbdriver.ClusterSnapshot](), + parameterGroups: memstore.New[ParameterGroup](), + subnetGroups: memstore.New[SubnetGroup](), opts: opts, } } +// CreateClusterParameterGroup registers a redshift cluster parameter group. +func (m *Mock) CreateClusterParameterGroup(_ context.Context, name, family, description string) (*ParameterGroup, error) { + if name == "" { + return nil, cerrors.New(cerrors.InvalidArgument, "parameter group name is required") + } + + if m.parameterGroups.Has(name) { + return nil, cerrors.Newf(cerrors.AlreadyExists, "parameter group %q already exists", name) + } + + pg := ParameterGroup{Name: name, Family: family, Description: description} + m.parameterGroups.Set(name, pg) + + return &pg, nil +} + +// CreateClusterSubnetGroup registers a redshift cluster subnet group. +func (m *Mock) CreateClusterSubnetGroup(_ context.Context, name, description string, subnetIDs []string) (*SubnetGroup, error) { + if name == "" { + return nil, cerrors.New(cerrors.InvalidArgument, "subnet group name is required") + } + + if m.subnetGroups.Has(name) { + return nil, cerrors.Newf(cerrors.AlreadyExists, "subnet group %q already exists", name) + } + + sg := SubnetGroup{Name: name, Description: description, SubnetIDs: subnetIDs} + m.subnetGroups.Set(name, sg) + + return &sg, nil +} + // SetMonitoring wires a CloudWatch-style backend for auto-metric emission. func (m *Mock) SetMonitoring(mon mondriver.Monitoring) { m.monitoring = mon diff --git a/server/aws/redshift/handler.go b/server/aws/redshift/handler.go index 806ff92c..808cc0bb 100644 --- a/server/aws/redshift/handler.go +++ b/server/aws/redshift/handler.go @@ -12,10 +12,12 @@ package redshift import ( + "context" "net/http" "strings" cerrors "github.com/stackshy/cloudemu/v2/errors" + redshiftprovider "github.com/stackshy/cloudemu/v2/providers/aws/redshift" "github.com/stackshy/cloudemu/v2/server/wire/awsquery" rdbdriver "github.com/stackshy/cloudemu/v2/services/relationaldb/driver" ) @@ -31,15 +33,24 @@ const ( // redshiftActions is the set of Action values this handler recognizes. Matches // uses it to decide whether to claim a request. var redshiftActions = map[string]struct{}{ //nolint:gochecknoglobals // static lookup table - "CreateCluster": {}, - "DescribeClusters": {}, - "ModifyCluster": {}, - "DeleteCluster": {}, - "RebootCluster": {}, - "CreateClusterSnapshot": {}, - "DescribeClusterSnapshots": {}, - "DeleteClusterSnapshot": {}, - "RestoreFromClusterSnapshot": {}, + "CreateCluster": {}, + "DescribeClusters": {}, + "ModifyCluster": {}, + "DeleteCluster": {}, + "RebootCluster": {}, + "CreateClusterSnapshot": {}, + "DescribeClusterSnapshots": {}, + "DeleteClusterSnapshot": {}, + "RestoreFromClusterSnapshot": {}, + "CreateClusterParameterGroup": {}, + "CreateClusterSubnetGroup": {}, +} + +// clusterGroupManager is the AWS-specific parameter/subnet-group surface, not +// part of the shared relationaldb driver; the handler type-asserts for it. +type clusterGroupManager interface { + CreateClusterParameterGroup(ctx context.Context, name, family, description string) (*redshiftprovider.ParameterGroup, error) + CreateClusterSubnetGroup(ctx context.Context, name, description string, subnetIDs []string) (*redshiftprovider.SubnetGroup, error) } // Handler serves Redshift query-protocol requests. @@ -102,6 +113,10 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.deleteClusterSnapshot(w, r) case "RestoreFromClusterSnapshot": h.restoreFromClusterSnapshot(w, r) + case "CreateClusterParameterGroup": + h.createClusterParameterGroup(w, r) + case "CreateClusterSubnetGroup": + h.createClusterSubnetGroup(w, r) default: awsquery.WriteXMLError(w, http.StatusBadRequest, "InvalidAction", "unknown Redshift action: "+action) diff --git a/server/aws/redshift/parametergroup.go b/server/aws/redshift/parametergroup.go new file mode 100644 index 00000000..d47aaefe --- /dev/null +++ b/server/aws/redshift/parametergroup.go @@ -0,0 +1,92 @@ +package redshift + +import ( + "encoding/xml" + "net/http" + + cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/server/wire/awsquery" +) + +type clusterParameterGroupXML struct { + ParameterGroupName string `xml:"ParameterGroupName"` + ParameterGroupFamily string `xml:"ParameterGroupFamily"` + Description string `xml:"Description"` +} + +type createClusterParameterGroupResponse struct { + XMLName xml.Name `xml:"CreateClusterParameterGroupResponse"` + Xmlns string `xml:"xmlns,attr"` + Group clusterParameterGroupXML `xml:"CreateClusterParameterGroupResult>ClusterParameterGroup"` + Metadata responseMetadata `xml:"ResponseMetadata"` +} + +type clusterSubnetGroupXML struct { + ClusterSubnetGroupName string `xml:"ClusterSubnetGroupName"` + Description string `xml:"Description"` + SubnetGroupStatus string `xml:"SubnetGroupStatus"` +} + +type createClusterSubnetGroupResponse struct { + XMLName xml.Name `xml:"CreateClusterSubnetGroupResponse"` + Xmlns string `xml:"xmlns,attr"` + Group clusterSubnetGroupXML `xml:"CreateClusterSubnetGroupResult>ClusterSubnetGroup"` + Metadata responseMetadata `xml:"ResponseMetadata"` +} + +func (h *Handler) clusterGroups() (clusterGroupManager, bool) { + m, ok := h.db.(clusterGroupManager) + + return m, ok +} + +func (h *Handler) createClusterParameterGroup(w http.ResponseWriter, r *http.Request) { + mgr, ok := h.clusterGroups() + if !ok { + writeErr(w, cerrors.New(cerrors.Unimplemented, "parameter groups not supported")) + return + } + + pg, err := mgr.CreateClusterParameterGroup(r.Context(), + r.Form.Get("ParameterGroupName"), r.Form.Get("ParameterGroupFamily"), r.Form.Get("Description")) + if err != nil { + writeErr(w, err) + return + } + + awsquery.WriteXMLResponse(w, createClusterParameterGroupResponse{ + Xmlns: Namespace, + Group: clusterParameterGroupXML{ + ParameterGroupName: pg.Name, + ParameterGroupFamily: pg.Family, + Description: pg.Description, + }, + Metadata: responseMetadata{RequestID: awsquery.RequestID}, + }) +} + +func (h *Handler) createClusterSubnetGroup(w http.ResponseWriter, r *http.Request) { + mgr, ok := h.clusterGroups() + if !ok { + writeErr(w, cerrors.New(cerrors.Unimplemented, "subnet groups not supported")) + return + } + + sg, err := mgr.CreateClusterSubnetGroup(r.Context(), + r.Form.Get("ClusterSubnetGroupName"), r.Form.Get("Description"), + awsquery.ListStrings(r.Form, "SubnetIds.SubnetIdentifier")) + if err != nil { + writeErr(w, err) + return + } + + awsquery.WriteXMLResponse(w, createClusterSubnetGroupResponse{ + Xmlns: Namespace, + Group: clusterSubnetGroupXML{ + ClusterSubnetGroupName: sg.Name, + Description: sg.Description, + SubnetGroupStatus: "Complete", + }, + Metadata: responseMetadata{RequestID: awsquery.RequestID}, + }) +} diff --git a/server/aws/redshift/sdk_roundtrip_test.go b/server/aws/redshift/sdk_roundtrip_test.go index 2ff35813..911c5889 100644 --- a/server/aws/redshift/sdk_roundtrip_test.go +++ b/server/aws/redshift/sdk_roundtrip_test.go @@ -86,6 +86,41 @@ func TestSDKRedshiftCreateDescribeCluster(t *testing.T) { } } +// TestSDKRedshiftParameterAndSubnetGroups is a regression guard for issue +// #319: CreateClusterParameterGroup / CreateClusterSubnetGroup returned +// InvalidAction, blocking IaC that provisions a warehouse with a custom group. +func TestSDKRedshiftParameterAndSubnetGroups(t *testing.T) { + client := newSDKClient(t) + ctx := context.Background() + + pg, err := client.CreateClusterParameterGroup(ctx, &awsredshift.CreateClusterParameterGroupInput{ + ParameterGroupName: aws.String("pg1"), + ParameterGroupFamily: aws.String("redshift-1.0"), + Description: aws.String("my pg"), + }) + if err != nil { + t.Fatalf("CreateClusterParameterGroup: %v", err) + } + + if aws.ToString(pg.ClusterParameterGroup.ParameterGroupName) != "pg1" || + aws.ToString(pg.ClusterParameterGroup.ParameterGroupFamily) != "redshift-1.0" { + t.Fatalf("parameter group = %+v", pg.ClusterParameterGroup) + } + + sg, err := client.CreateClusterSubnetGroup(ctx, &awsredshift.CreateClusterSubnetGroupInput{ + ClusterSubnetGroupName: aws.String("sg1"), + Description: aws.String("my sg"), + SubnetIds: []string{"subnet-1", "subnet-2"}, + }) + if err != nil { + t.Fatalf("CreateClusterSubnetGroup: %v", err) + } + + if aws.ToString(sg.ClusterSubnetGroup.ClusterSubnetGroupName) != "sg1" { + t.Fatalf("subnet group = %+v", sg.ClusterSubnetGroup) + } +} + func TestSDKRedshiftClusterLifecycle(t *testing.T) { client := newSDKClient(t) ctx := context.Background() From 3aab1bd4c6bbe2ef81db3b0f6463c54f7a25ff66 Mon Sep 17 00:00:00 2001 From: Gajendra Malviya Date: Tue, 4 Aug 2026 17:27:16 +0530 Subject: [PATCH 28/45] feat(dynamodb): serve DescribeTimeToLive and UpdateTimeToLive (#319) The provider already implemented TTL, but the handler didn't dispatch the two TTL operations, so they returned UnknownOperationException. Wire them through to the driver's UpdateTTL/DescribeTTL. --- .../aws/dynamodb/dynamodb_lifecycle_test.go | 31 ++++++++ server/aws/dynamodb/handler.go | 3 +- server/aws/dynamodb/ttl.go | 78 +++++++++++++++++++ 3 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 server/aws/dynamodb/ttl.go diff --git a/server/aws/dynamodb/dynamodb_lifecycle_test.go b/server/aws/dynamodb/dynamodb_lifecycle_test.go index a7c36bf2..25333ae2 100644 --- a/server/aws/dynamodb/dynamodb_lifecycle_test.go +++ b/server/aws/dynamodb/dynamodb_lifecycle_test.go @@ -401,6 +401,37 @@ func TestDDBQueryPartitionAndSort(t *testing.T) { assert.Equal(t, "99", attrN(t, out.Items[0], "total")) } +// TestDDBTimeToLive is a regression guard for issue #319: +// DescribeTimeToLive / UpdateTimeToLive returned UnknownOperationException. +func TestDDBTimeToLive(t *testing.T) { + client, _ := newSuiteDDBEnv(t) + ctx := context.Background() + + suiteDDBCreateTable(t, client, "ttl-table", "pk", "") + + desc, err := client.DescribeTimeToLive(ctx, &dynamodb.DescribeTimeToLiveInput{ + TableName: aws.String("ttl-table"), + }) + require.NoError(t, err) + assert.Equal(t, ddbtypes.TimeToLiveStatusDisabled, desc.TimeToLiveDescription.TimeToLiveStatus) + + if _, err := client.UpdateTimeToLive(ctx, &dynamodb.UpdateTimeToLiveInput{ + TableName: aws.String("ttl-table"), + TimeToLiveSpecification: &ddbtypes.TimeToLiveSpecification{ + Enabled: aws.Bool(true), AttributeName: aws.String("expiresAt"), + }, + }); err != nil { + t.Fatalf("UpdateTimeToLive: %v", err) + } + + desc, err = client.DescribeTimeToLive(ctx, &dynamodb.DescribeTimeToLiveInput{ + TableName: aws.String("ttl-table"), + }) + require.NoError(t, err) + assert.Equal(t, ddbtypes.TimeToLiveStatusEnabled, desc.TimeToLiveDescription.TimeToLiveStatus) + assert.Equal(t, "expiresAt", aws.ToString(desc.TimeToLiveDescription.AttributeName)) +} + // TestDDBQueryWithFilterExpression is a regression guard for issue #319: Query // ignored FilterExpression and returned the full key-matched set (silent wrong // data), while Scan applied it correctly. diff --git a/server/aws/dynamodb/handler.go b/server/aws/dynamodb/handler.go index be424054..a704140d 100644 --- a/server/aws/dynamodb/handler.go +++ b/server/aws/dynamodb/handler.go @@ -36,7 +36,8 @@ func (*Handler) Matches(r *http.Request) bool { func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { op := strings.TrimPrefix(r.Header.Get("X-Amz-Target"), targetPrefix) - if h.routeTables(w, r, op) || h.routeItems(w, r, op) || h.routeBatch(w, r, op) || h.routeTags(w, r, op) { + if h.routeTables(w, r, op) || h.routeItems(w, r, op) || h.routeBatch(w, r, op) || + h.routeTags(w, r, op) || h.routeTTL(w, r, op) { return } diff --git a/server/aws/dynamodb/ttl.go b/server/aws/dynamodb/ttl.go new file mode 100644 index 00000000..2c61c24c --- /dev/null +++ b/server/aws/dynamodb/ttl.go @@ -0,0 +1,78 @@ +package dynamodb + +import ( + "net/http" + + "github.com/stackshy/cloudemu/v2/server/wire" + dbdriver "github.com/stackshy/cloudemu/v2/services/database/driver" +) + +func (h *Handler) routeTTL(w http.ResponseWriter, r *http.Request, op string) bool { + switch op { + case "UpdateTimeToLive": + h.updateTimeToLive(w, r) + case "DescribeTimeToLive": + h.describeTimeToLive(w, r) + default: + return false + } + + return true +} + +func (h *Handler) updateTimeToLive(w http.ResponseWriter, r *http.Request) { + var req struct { + TableName string `json:"TableName"` + TimeToLiveSpecification struct { + Enabled bool `json:"Enabled"` + AttributeName string `json:"AttributeName"` + } `json:"TimeToLiveSpecification"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + spec := req.TimeToLiveSpecification + + if err := h.db.UpdateTTL(r.Context(), req.TableName, dbdriver.TTLConfig{ + Enabled: spec.Enabled, AttributeName: spec.AttributeName, + }); err != nil { + writeErr(w, err) + return + } + + wire.WriteJSON(w, map[string]any{ + "TimeToLiveSpecification": map[string]any{ + "Enabled": spec.Enabled, "AttributeName": spec.AttributeName, + }, + }) +} + +func (h *Handler) describeTimeToLive(w http.ResponseWriter, r *http.Request) { + var req struct { + TableName string `json:"TableName"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + cfg, err := h.db.DescribeTTL(r.Context(), req.TableName) + if err != nil { + writeErr(w, err) + return + } + + status := "DISABLED" + if cfg.Enabled { + status = "ENABLED" + } + + wire.WriteJSON(w, map[string]any{ + "TimeToLiveDescription": map[string]any{ + "TimeToLiveStatus": status, + "AttributeName": cfg.AttributeName, + }, + }) +} From dfc43ff57ff195a882638638f426dcf24edaff9f Mon Sep 17 00:00:00 2001 From: Gajendra Malviya Date: Tue, 4 Aug 2026 17:28:33 +0530 Subject: [PATCH 29/45] test(dynamodb): update unrouted-op assertion after TTL routing (#319) TestDDBTypedErrors asserted UpdateTimeToLive returns UnknownOperation, which no longer holds now that TTL is routed. Point the assertion at DescribeContinuousBackups, which remains unimplemented. --- server/aws/dynamodb/dynamodb_lifecycle_test.go | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/server/aws/dynamodb/dynamodb_lifecycle_test.go b/server/aws/dynamodb/dynamodb_lifecycle_test.go index 25333ae2..59e17b13 100644 --- a/server/aws/dynamodb/dynamodb_lifecycle_test.go +++ b/server/aws/dynamodb/dynamodb_lifecycle_test.go @@ -883,13 +883,9 @@ func TestDDBTypedErrors(t *testing.T) { }) t.Run("unrouted operation is UnknownOperationException", func(t *testing.T) { - // UpdateTimeToLive has no HTTP surface in the emulator. - _, err := client.UpdateTimeToLive(ctx, &dynamodb.UpdateTimeToLiveInput{ + // DescribeContinuousBackups has no HTTP surface in the emulator. + _, err := client.DescribeContinuousBackups(ctx, &dynamodb.DescribeContinuousBackupsInput{ TableName: aws.String("errs"), - TimeToLiveSpecification: &ddbtypes.TimeToLiveSpecification{ - AttributeName: aws.String("ttl"), - Enabled: aws.Bool(true), - }, }) require.Error(t, err) From 4ea055c090a2f4bf8ac59aafc6cefba851a490a9 Mon Sep 17 00:00:00 2001 From: Gajendra Malviya Date: Tue, 4 Aug 2026 17:30:22 +0530 Subject: [PATCH 30/45] feat(sns): implement SetTopicAttributes (#319) SetTopicAttributes returned InvalidAction. Route the DisplayName attribute through the driver's UpdateTopic; other attribute names (Policy, DeliveryPolicy) are accepted but not modeled, since the emulator doesn't evaluate topic policies. --- providers/aws/sns/sns_test.go | 19 +++++++++++++++++++ server/aws/sns/handler.go | 3 +++ server/aws/sns/operations.go | 21 +++++++++++++++++++++ server/aws/sns/types.go | 6 ++++++ 4 files changed, 49 insertions(+) diff --git a/providers/aws/sns/sns_test.go b/providers/aws/sns/sns_test.go index 81670e90..318ba8d9 100644 --- a/providers/aws/sns/sns_test.go +++ b/providers/aws/sns/sns_test.go @@ -107,6 +107,25 @@ func TestCreateTopicWithTags(t *testing.T) { assert.Equal(t, "staging", info.Tags["env"]) } +// TestUpdateTopicDisplayName guards the DisplayName path SetTopicAttributes +// uses (issue #319): UpdateTopic must change the display name in place. +func TestUpdateTopicDisplayName(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + if _, err := m.CreateTopic(ctx, driver.TopicConfig{Name: "t"}); err != nil { + t.Fatalf("CreateTopic: %v", err) + } + + if _, err := m.UpdateTopic(ctx, driver.TopicConfig{Name: "t", DisplayName: "My Topic"}); err != nil { + t.Fatalf("UpdateTopic: %v", err) + } + + info, err := m.GetTopic(ctx, "t") + require.NoError(t, err) + assert.Equal(t, "My Topic", info.DisplayName) +} + // TestTagUntagTopic is a regression guard for issue #319: SNS TagResource / // UntagResource were unimplemented. func TestTagUntagTopic(t *testing.T) { diff --git a/server/aws/sns/handler.go b/server/aws/sns/handler.go index de6c1931..86e62c58 100644 --- a/server/aws/sns/handler.go +++ b/server/aws/sns/handler.go @@ -48,6 +48,7 @@ var snsActions = map[string]struct{}{ //nolint:gochecknoglobals // static lookup "CreateTopic": {}, "DeleteTopic": {}, "GetTopicAttributes": {}, + "SetTopicAttributes": {}, "ListTopics": {}, "Subscribe": {}, "Unsubscribe": {}, @@ -119,6 +120,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.deleteTopic(w, r) case "GetTopicAttributes": h.getTopicAttributes(w, r) + case "SetTopicAttributes": + h.setTopicAttributes(w, r) case "ListTopics": h.listTopics(w, r) case "Subscribe": diff --git a/server/aws/sns/operations.go b/server/aws/sns/operations.go index 90013bf2..d59258fb 100644 --- a/server/aws/sns/operations.go +++ b/server/aws/sns/operations.go @@ -101,6 +101,27 @@ func (h *Handler) deleteTopic(w http.ResponseWriter, r *http.Request) { // getTopicAttributes maps GetTopicAttributes to Notification.GetTopic and // exposes the topic's ARN, display name, and subscription count as the standard // SNS attribute map. +// setTopicAttributes maps SetTopicAttributes to Notification.UpdateTopic for +// the DisplayName attribute. Other attribute names (Policy, DeliveryPolicy) are +// accepted but not modeled — the emulator doesn't evaluate topic policies, so +// storing them would have no observable effect. +func (h *Handler) setTopicAttributes(w http.ResponseWriter, r *http.Request) { + name := topicNameFromARN(r.Form.Get("TopicArn")) + + if r.Form.Get("AttributeName") == "DisplayName" { + if _, err := h.notif.UpdateTopic(r.Context(), notifdriver.TopicConfig{ + Name: name, DisplayName: r.Form.Get("AttributeValue"), + }); err != nil { + writeErr(w, err) + return + } + } + + awsquery.WriteXMLResponse(w, setTopicAttributesResponse{ + Xmlns: Namespace, Metadata: responseMetadata{RequestID: awsquery.RequestID}, + }) +} + func (h *Handler) getTopicAttributes(w http.ResponseWriter, r *http.Request) { name := topicNameFromARN(r.Form.Get("TopicArn")) diff --git a/server/aws/sns/types.go b/server/aws/sns/types.go index 834b53f3..0b7be42e 100644 --- a/server/aws/sns/types.go +++ b/server/aws/sns/types.go @@ -38,6 +38,12 @@ type unsubscribeResponse struct { Metadata responseMetadata `xml:"ResponseMetadata"` } +type setTopicAttributesResponse struct { + XMLName xml.Name `xml:"SetTopicAttributesResponse"` + Xmlns string `xml:"xmlns,attr"` + Metadata responseMetadata `xml:"ResponseMetadata"` +} + // --- TagResource / UntagResource (empty results) --- // // The SDK's SNS unmarshaler expects the empty wrapper element, so From 45aaf11696efc6e27c9111993961c3a8c7df351e Mon Sep 17 00:00:00 2001 From: Gajendra Malviya Date: Tue, 4 Aug 2026 17:34:21 +0530 Subject: [PATCH 31/45] feat(s3): bucket notifications with S3 -> SQS delivery (#319) PUT /{bucket}?notification mis-routed to CreateBucket, and object-create events never propagated. Route the ?notification sub-resource (Put/Get BucketNotificationConfiguration), store QueueConfigurations on the bucket, and deliver an S3 ObjectCreated:Put event to matching SQS targets on upload via an injected SQSDeliverer (same pattern as SNS/EventBridge). Event-name selectors support exact and ':*' wildcard matches. --- providers/aws/aws.go | 2 + providers/aws/s3/notification.go | 95 ++++++++++++++++++++++++++++++++ providers/aws/s3/s3.go | 24 ++++++++ providers/aws/s3/s3_test.go | 49 ++++++++++++++++ server/aws/s3/handler.go | 3 + server/aws/s3/notification.go | 81 +++++++++++++++++++++++++++ 6 files changed, 254 insertions(+) create mode 100644 providers/aws/s3/notification.go create mode 100644 server/aws/s3/notification.go diff --git a/providers/aws/aws.go b/providers/aws/aws.go index f9f8d5bd..3ae0a79c 100644 --- a/providers/aws/aws.go +++ b/providers/aws/aws.go @@ -209,6 +209,8 @@ func New(opts ...config.Option) *Provider { p.SNS.SetSQSDeliverer(p.SQS) // EventBridge -> SQS: matched rules deliver events to SQS targets. p.EventBridge.SetSQSDeliverer(p.SQS) + // S3 -> SQS: object-create events deliver to bucket notification targets. + p.S3.SetSQSDeliverer(p.SQS) p.ResourceDiscovery = resourcediscovery.New( resourcediscovery.ProviderAWS, o.AccountID, o.Region, diff --git a/providers/aws/s3/notification.go b/providers/aws/s3/notification.go new file mode 100644 index 00000000..02959545 --- /dev/null +++ b/providers/aws/s3/notification.go @@ -0,0 +1,95 @@ +package s3 + +import ( + "context" + "encoding/json" + "strings" + + cerrors "github.com/stackshy/cloudemu/v2/errors" +) + +// PutBucketNotification replaces a bucket's SQS notification configuration. +func (m *Mock) PutBucketNotification(_ context.Context, bucket string, configs []QueueNotification) error { + bkt, ok := m.buckets.Get(bucket) + if !ok { + return cerrors.Newf(cerrors.NotFound, "bucket %q not found", bucket) + } + + bkt.notifications = configs + + return nil +} + +// GetBucketNotification returns a bucket's SQS notification configuration. +func (m *Mock) GetBucketNotification(_ context.Context, bucket string) ([]QueueNotification, error) { + bkt, ok := m.buckets.Get(bucket) + if !ok { + return nil, cerrors.Newf(cerrors.NotFound, "bucket %q not found", bucket) + } + + return bkt.notifications, nil +} + +// notifyObjectCreated delivers an s3:ObjectCreated:Put event to every SQS +// target configured on the bucket whose event filter matches. Best-effort: +// delivery errors are swallowed so a missing/failed queue never fails the +// upload (mirroring S3's asynchronous, decoupled notification behavior). +func (m *Mock) notifyObjectCreated(bkt *bucketMeta, bucket, key string, size int64) { + if m.sqs == nil || len(bkt.notifications) == 0 { + return + } + + const eventName = "ObjectCreated:Put" + + body := m.objectEventJSON(bucket, key, size, eventName) + + for i := range bkt.notifications { + n := &bkt.notifications[i] + if !eventMatches(n.Events, eventName) { + continue + } + + _ = m.sqs.DeliverExternal(context.Background(), n.QueueARN, body) + } +} + +// eventMatches reports whether an event name satisfies one of the configured +// event selectors. "s3:ObjectCreated:*" matches any ObjectCreated:* event; +// "s3:ObjectCreated:Put" matches exactly. +func eventMatches(selectors []string, eventName string) bool { + full := "s3:" + eventName + + for _, sel := range selectors { + switch { + case sel == full: + return true + case strings.HasSuffix(sel, ":*"): + prefix := strings.TrimSuffix(sel, "*") // "s3:ObjectCreated:" + if strings.HasPrefix(full, prefix) { + return true + } + } + } + + return false +} + +func (m *Mock) objectEventJSON(bucket, key string, size int64, eventName string) string { + record := map[string]any{ + "eventSource": "aws:s3", + "eventName": eventName, + "awsRegion": m.opts.Region, + "eventTime": m.opts.Clock.Now().UTC().Format(s3TimeFormat), + "s3": map[string]any{ + "bucket": map[string]any{"name": bucket}, + "object": map[string]any{"key": key, "size": size}, + }, + } + + b, err := json.Marshal(map[string]any{"Records": []any{record}}) + if err != nil { + return "{}" + } + + return string(b) +} diff --git a/providers/aws/s3/s3.go b/providers/aws/s3/s3.go index 8d4622bd..38cf1f20 100644 --- a/providers/aws/s3/s3.go +++ b/providers/aws/s3/s3.go @@ -86,6 +86,21 @@ type bucketMeta struct { corsConfig *driver.CORSConfig encryption *driver.EncryptionConfig tags map[string]string + notifications []QueueNotification +} + +// QueueNotification is one S3 bucket-notification target: an SQS queue that +// receives events whose names match one of Events (e.g. "s3:ObjectCreated:*"). +type QueueNotification struct { + ID string + QueueARN string + Events []string +} + +// SQSDeliverer delivers an S3 event notification into an SQS queue by ARN. The +// SQS mock satisfies this, enabling real S3 -> SQS event delivery. +type SQSDeliverer interface { + DeliverExternal(ctx context.Context, queueARN, body string) error } // Mock is an in-memory mock implementation of the AWS S3 service. @@ -93,6 +108,13 @@ type Mock struct { buckets *memstore.Store[*bucketMeta] opts *config.Options monitoring mondriver.Monitoring + sqs SQSDeliverer +} + +// SetSQSDeliverer wires the SQS backend so object-create events deliver to +// buckets' SQS notification targets. +func (m *Mock) SetSQSDeliverer(d SQSDeliverer) { + m.sqs = d } // SetMonitoring sets the monitoring backend for auto-metric generation. @@ -198,6 +220,8 @@ func (m *Mock) PutObject(_ context.Context, bucket, key string, data []byte, con m.emitMetric("PutRequests", 1, "Count", dims) m.emitMetric("BytesUploaded", float64(len(data)), "Bytes", dims) + m.notifyObjectCreated(bkt, bucket, key, int64(len(data))) + return nil } diff --git a/providers/aws/s3/s3_test.go b/providers/aws/s3/s3_test.go index 2023da56..9b41e580 100644 --- a/providers/aws/s3/s3_test.go +++ b/providers/aws/s3/s3_test.go @@ -19,6 +19,55 @@ func newTestMock() *Mock { return New(opts) } +// recordingDeliverer captures S3 -> SQS deliveries for assertion. +type recordingDeliverer struct { + arns []string + bodies []string +} + +func (d *recordingDeliverer) DeliverExternal(_ context.Context, queueARN, body string) error { + d.arns = append(d.arns, queueARN) + d.bodies = append(d.bodies, body) + + return nil +} + +// TestBucketNotificationDelivery is a regression guard for issue #319: a bucket +// with an SQS notification config must deliver an S3 event on object create, +// and only to targets whose event filter matches. +func TestBucketNotificationDelivery(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + rec := &recordingDeliverer{} + m.SetSQSDeliverer(rec) + + if err := m.CreateBucket(ctx, "nb"); err != nil { + t.Fatalf("CreateBucket: %v", err) + } + + if err := m.PutBucketNotification(ctx, "nb", []QueueNotification{ + {QueueARN: "arn:aws:sqs:us-east-1:000000000000:s3events", Events: []string{"s3:ObjectCreated:*"}}, + {QueueARN: "arn:aws:sqs:us-east-1:000000000000:deletes", Events: []string{"s3:ObjectRemoved:*"}}, + }); err != nil { + t.Fatalf("PutBucketNotification: %v", err) + } + + if err := m.PutObject(ctx, "nb", "file1.txt", []byte("hi"), "text/plain", nil); err != nil { + t.Fatalf("PutObject: %v", err) + } + + // Only the ObjectCreated:* target should have received the event. + if len(rec.arns) != 1 || !strings.HasSuffix(rec.arns[0], ":s3events") { + t.Fatalf("deliveries = %v, want one to :s3events", rec.arns) + } + + if !strings.Contains(rec.bodies[0], `"eventName":"ObjectCreated:Put"`) || + !strings.Contains(rec.bodies[0], `"key":"file1.txt"`) { + t.Fatalf("event body = %s", rec.bodies[0]) + } +} + func TestCreateBucket(t *testing.T) { tests := []struct { name string diff --git a/server/aws/s3/handler.go b/server/aws/s3/handler.go index 8f447a5b..9ca07a34 100644 --- a/server/aws/s3/handler.go +++ b/server/aws/s3/handler.go @@ -129,6 +129,9 @@ func (h *Handler) bucketOp(w http.ResponseWriter, r *http.Request, bucket string case q.Has("tagging"): h.bucketTaggingOp(w, r, bucket) return + case q.Has("notification"): + h.bucketNotificationOp(w, r, bucket) + return case q.Has("versioning"): h.bucketVersioningOp(w, r, bucket) return diff --git a/server/aws/s3/notification.go b/server/aws/s3/notification.go new file mode 100644 index 00000000..0c4125b7 --- /dev/null +++ b/server/aws/s3/notification.go @@ -0,0 +1,81 @@ +package s3 + +import ( + "context" + "encoding/xml" + "net/http" + + s3provider "github.com/stackshy/cloudemu/v2/providers/aws/s3" + "github.com/stackshy/cloudemu/v2/server/wire" +) + +// bucketNotifier is the AWS-specific bucket-notification surface. It's not part +// of the portable Bucket driver (Azure Blob / GCS notify differently), so the +// handler type-asserts for it. +type bucketNotifier interface { + PutBucketNotification(ctx context.Context, bucket string, configs []s3provider.QueueNotification) error + GetBucketNotification(ctx context.Context, bucket string) ([]s3provider.QueueNotification, error) +} + +type queueConfigurationXML struct { + ID string `xml:"Id,omitempty"` + Queue string `xml:"Queue"` + Events []string `xml:"Event"` +} + +type notificationConfigurationXML struct { + XMLName xml.Name `xml:"NotificationConfiguration"` + Xmlns string `xml:"xmlns,attr,omitempty"` + QueueConfigurations []queueConfigurationXML `xml:"QueueConfiguration"` +} + +// bucketNotificationOp dispatches PUT/GET for the bucket ?notification +// sub-resource. Without this a PUT ?notification fell through to CreateBucket +// (BucketAlreadyOwnedByYou), and S3 -> SQS event pipelines could not be wired. +func (h *Handler) bucketNotificationOp(w http.ResponseWriter, r *http.Request, bucket string) { + notifier, ok := h.bucket.(bucketNotifier) + if !ok { + writeError(w, http.StatusNotImplemented, "NotImplemented", "notifications not supported") + return + } + + switch r.Method { + case http.MethodPut: + var body notificationConfigurationXML + if err := xml.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "MalformedXML", "could not parse request body") + return + } + + configs := make([]s3provider.QueueNotification, 0, len(body.QueueConfigurations)) + for _, qc := range body.QueueConfigurations { + configs = append(configs, s3provider.QueueNotification{ + ID: qc.ID, QueueARN: qc.Queue, Events: qc.Events, + }) + } + + if err := notifier.PutBucketNotification(r.Context(), bucket, configs); err != nil { + writeErr(w, err) + return + } + + w.WriteHeader(http.StatusOK) + case http.MethodGet: + configs, err := notifier.GetBucketNotification(r.Context(), bucket) + if err != nil { + writeErr(w, err) + return + } + + resp := notificationConfigurationXML{Xmlns: xmlns} + for _, c := range configs { + resp.QueueConfigurations = append(resp.QueueConfigurations, queueConfigurationXML{ + ID: c.ID, Queue: c.QueueARN, Events: c.Events, + }) + } + + wire.WriteXML(w, http.StatusOK, resp) + default: + writeError(w, http.StatusMethodNotAllowed, "MethodNotAllowed", "method not allowed") + } +} From cb29deee0ecb68b12106f8dbfa06a64ee7a45b9f Mon Sep 17 00:00:00 2001 From: Gajendra Malviya Date: Tue, 4 Aug 2026 17:37:39 +0530 Subject: [PATCH 32/45] feat(lambda): stub invoke without a handler + event source mappings (#319) Two gaps: invoke returned a FunctionError ('no handler registered') for every uploaded function since the emulator can't run arbitrary zip code; and CreateEventSourceMapping (SQS/DDB-stream -> Lambda) returned 405. Invoke now returns a 200 stub echoing the payload when no Go handler is registered, so invoke control flow is testable. Route the /2015-03-31/event-source-mappings collection and per-UUID paths through the driver's existing ESM methods. --- providers/aws/lambda/lambda.go | 15 +++- providers/aws/lambda/lambda_test.go | 12 ++- server/aws/lambda/esm.go | 115 ++++++++++++++++++++++++++++ server/aws/lambda/handler.go | 15 +++- server/aws/lambda/lambda_test.go | 63 ++++++++++++++- 5 files changed, 209 insertions(+), 11 deletions(-) create mode 100644 server/aws/lambda/esm.go diff --git a/providers/aws/lambda/lambda.go b/providers/aws/lambda/lambda.go index 1f6942ce..ba254518 100644 --- a/providers/aws/lambda/lambda.go +++ b/providers/aws/lambda/lambda.go @@ -191,10 +191,21 @@ func (m *Mock) Invoke(ctx context.Context, input driver.InvokeInput) (*driver.In } if h == nil { + // The emulator can't execute an uploaded zip (arbitrary Python/Node/ + // etc.), so with no Go handler registered we return a successful stub + // that echoes the request payload rather than a FunctionError. This + // lets users exercise invoke control flow (wiring, permissions, + // event-source mappings) without a real runtime. Register a handler via + // RegisterHandler to run real logic. m.emitMetric(ctx, "Invocations", 1, dims) - m.emitMetric(ctx, "Errors", 1, dims) + m.emitMetric(ctx, "Duration", 1.0, dims) + + payload := input.Payload + if len(payload) == 0 { + payload = []byte("{}") + } - return &driver.InvokeOutput{StatusCode: 500, Error: "no handler registered"}, nil + return &driver.InvokeOutput{StatusCode: 200, Payload: payload}, nil } payload, err := h(ctx, input.Payload) diff --git a/providers/aws/lambda/lambda_test.go b/providers/aws/lambda/lambda_test.go index b3308a9b..5ef18cd1 100644 --- a/providers/aws/lambda/lambda_test.go +++ b/providers/aws/lambda/lambda_test.go @@ -164,14 +164,18 @@ func TestInvokeFunction(t *testing.T) { ctx := context.Background() _, _ = m.CreateFunction(ctx, defaultFuncConfig()) - t.Run("no handler returns error", func(t *testing.T) { + t.Run("no handler echoes a success stub", func(t *testing.T) { + // The emulator can't run an uploaded zip, so with no Go handler it + // returns a 200 stub echoing the payload rather than a FunctionError + // (issue #319) — invoke stays testable. out, err := m.Invoke(ctx, driver.InvokeInput{ FunctionName: "my-func", - Payload: []byte("test"), + Payload: []byte(`{"k":1}`), }) requireNoError(t, err) - assertEqual(t, 500, out.StatusCode) - assertEqual(t, "no handler registered", out.Error) + assertEqual(t, 200, out.StatusCode) + assertEqual(t, "", out.Error) + assertEqual(t, `{"k":1}`, string(out.Payload)) }) t.Run("with handler success", func(t *testing.T) { diff --git a/server/aws/lambda/esm.go b/server/aws/lambda/esm.go new file mode 100644 index 00000000..e711825a --- /dev/null +++ b/server/aws/lambda/esm.go @@ -0,0 +1,115 @@ +package lambda + +import ( + "net/http" + + sdrv "github.com/stackshy/cloudemu/v2/services/serverless/driver" +) + +type eventSourceMappingJSON struct { + UUID string `json:"UUID"` + EventSourceArn string `json:"EventSourceArn"` + FunctionArn string `json:"FunctionArn,omitempty"` + BatchSize int `json:"BatchSize,omitempty"` + State string `json:"State,omitempty"` + StartingPosition string `json:"StartingPosition,omitempty"` + LastModified string `json:"LastModified,omitempty"` +} + +func toESMJSON(info *sdrv.EventSourceMappingInfo) eventSourceMappingJSON { + return eventSourceMappingJSON{ + UUID: info.UUID, + EventSourceArn: info.EventSourceArn, + FunctionArn: info.FunctionName, + BatchSize: info.BatchSize, + State: info.State, + StartingPosition: info.StartingPosition, + LastModified: info.CreatedAt, + } +} + +// serveEventSourceMappings dispatches the /2015-03-31/event-source-mappings +// paths: collection (POST create, GET list) and per-UUID (GET/DELETE). +func (h *Handler) serveEventSourceMappings(w http.ResponseWriter, r *http.Request, uuid string) { + if uuid == "" { + switch r.Method { + case http.MethodPost: + h.createEventSourceMapping(w, r) + case http.MethodGet: + h.listEventSourceMappings(w, r) + default: + writeError(w, http.StatusMethodNotAllowed, "InvalidRequestException", "method not allowed") + } + + return + } + + switch r.Method { + case http.MethodGet: + info, err := h.fn.GetEventSourceMapping(r.Context(), uuid) + if err != nil { + writeErr(w, err) + return + } + + writeJSON(w, http.StatusOK, toESMJSON(info)) + case http.MethodDelete: + if err := h.fn.DeleteEventSourceMapping(r.Context(), uuid); err != nil { + writeErr(w, err) + return + } + + w.WriteHeader(http.StatusNoContent) + default: + writeError(w, http.StatusMethodNotAllowed, "InvalidRequestException", "method not allowed") + } +} + +func (h *Handler) createEventSourceMapping(w http.ResponseWriter, r *http.Request) { + var req struct { + EventSourceArn string `json:"EventSourceArn"` + FunctionName string `json:"FunctionName"` + BatchSize int `json:"BatchSize"` + Enabled *bool `json:"Enabled"` + StartingPosition string `json:"StartingPosition"` + } + + if !decodeJSON(w, r, &req) { + return + } + + enabled := true + if req.Enabled != nil { + enabled = *req.Enabled + } + + info, err := h.fn.CreateEventSourceMapping(r.Context(), sdrv.EventSourceMappingConfig{ + EventSourceArn: req.EventSourceArn, + FunctionName: req.FunctionName, + BatchSize: req.BatchSize, + Enabled: enabled, + StartingPosition: req.StartingPosition, + }) + if err != nil { + writeErr(w, err) + return + } + + writeJSON(w, http.StatusCreated, toESMJSON(info)) +} + +func (h *Handler) listEventSourceMappings(w http.ResponseWriter, r *http.Request) { + // FunctionName is an optional filter carried as a query parameter. + infos, err := h.fn.ListEventSourceMappings(r.Context(), r.URL.Query().Get("FunctionName")) + if err != nil { + writeErr(w, err) + return + } + + out := make([]eventSourceMappingJSON, 0, len(infos)) + for i := range infos { + out = append(out, toESMJSON(&infos[i])) + } + + writeJSON(w, http.StatusOK, map[string]any{"EventSourceMappings": out}) +} diff --git a/server/aws/lambda/handler.go b/server/aws/lambda/handler.go index 9f520d18..74dacae3 100644 --- a/server/aws/lambda/handler.go +++ b/server/aws/lambda/handler.go @@ -35,6 +35,10 @@ const pathPrefix = "/2015-03-31/functions" // the S3 catch-all and return a 405 HTML body the SDK can't deserialize. const tagsPrefix = "/2017-03-31/tags" +// esmPrefix is the Lambda event-source-mapping API prefix (SQS/DynamoDB-stream +// -> Lambda triggers). Its own version prefix, so it needs a Matches clause. +const esmPrefix = "/2015-03-31/event-source-mappings" + const ( contentTypeJSON = "application/json" maxBodyBytes = 6 << 20 // 6 MiB — Lambda's sync invocation payload limit. @@ -72,7 +76,9 @@ func New(fn sdrv.Serverless) *Handler { // Matches returns true for any URL under /2015-03-31/functions — that's the // Lambda control-plane prefix the SDK uses for every operation in our MVP. func (*Handler) Matches(r *http.Request) bool { - return strings.HasPrefix(r.URL.Path, pathPrefix) || strings.HasPrefix(r.URL.Path, tagsPrefix) + return strings.HasPrefix(r.URL.Path, pathPrefix) || + strings.HasPrefix(r.URL.Path, tagsPrefix) || + strings.HasPrefix(r.URL.Path, esmPrefix) } // ServeHTTP dispatches Lambda operations based on path shape and method. @@ -88,6 +94,13 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } + if strings.HasPrefix(r.URL.Path, esmPrefix) { + uuid := strings.TrimPrefix(strings.TrimPrefix(r.URL.Path, esmPrefix), "/") + h.serveEventSourceMappings(w, r, uuid) + + return + } + rest := strings.TrimPrefix(r.URL.Path, pathPrefix) rest = strings.TrimPrefix(rest, "/") diff --git a/server/aws/lambda/lambda_test.go b/server/aws/lambda/lambda_test.go index 898deaf8..1a6a8176 100644 --- a/server/aws/lambda/lambda_test.go +++ b/server/aws/lambda/lambda_test.go @@ -235,7 +235,11 @@ func TestInvokeReturnsHandlerPayload(t *testing.T) { } } -func TestInvokeMissingHandlerSignalsError(t *testing.T) { +// TestInvokeNoHandlerEchoesStub is a regression guard for issue #319: with no +// Go handler registered, invoke used to return a FunctionError ("no handler +// registered"). The emulator can't run an uploaded zip, so it now returns a +// successful stub that echoes the request payload — invoke is testable. +func TestInvokeNoHandlerEchoesStub(t *testing.T) { srv, _ := newServer(t) if r := postJSON(t, srv.URL+"/2015-03-31/functions", @@ -244,18 +248,69 @@ func TestInvokeMissingHandlerSignalsError(t *testing.T) { } resp, err := http.Post(srv.URL+"/2015-03-31/functions/nohandler/invocations", - "application/json", bytes.NewReader([]byte(`{}`))) + "application/json", bytes.NewReader([]byte(`{"hi":1}`))) if err != nil { t.Fatalf("invoke: %v", err) } defer resp.Body.Close() - if resp.Header.Get("X-Amz-Function-Error") == "" { - t.Fatal("expected X-Amz-Function-Error on no-handler invoke") + if resp.StatusCode != http.StatusOK { + t.Fatalf("invoke status = %d, want 200", resp.StatusCode) + } + + if resp.Header.Get("X-Amz-Function-Error") != "" { + t.Fatal("no-handler invoke must not signal a FunctionError") + } + + body, _ := io.ReadAll(resp.Body) + if string(body) != `{"hi":1}` { + t.Fatalf("stub invoke body = %q, want the echoed payload", string(body)) } } +// TestEventSourceMappings is a regression guard for issue #319: +// CreateEventSourceMapping (and the ESM lifecycle) returned 405. +func TestEventSourceMappings(t *testing.T) { + srv, _ := newServer(t) + + if r := postJSON(t, srv.URL+"/2015-03-31/functions", + `{"FunctionName":"fx","Runtime":"go1.x"}`); r.StatusCode != http.StatusCreated { + t.Fatalf("create fn: %d", r.StatusCode) + } + + esmURL := srv.URL + esmBasePath + create := postJSON(t, esmURL, + `{"FunctionName":"fx","EventSourceArn":"arn:aws:sqs:us-east-1:000000000000:q","BatchSize":5}`) + if create.StatusCode != http.StatusCreated { + t.Fatalf("create ESM status = %d", create.StatusCode) + } + + var esm struct { + UUID string `json:"UUID"` + State string `json:"State"` + } + + decode(t, create, &esm) + + if esm.UUID == "" { + t.Fatal("CreateEventSourceMapping returned empty UUID") + } + + // GET by UUID. + got := doJSON(t, http.MethodGet, esmURL+"/"+esm.UUID, "") + if got.StatusCode != http.StatusOK { + t.Fatalf("get ESM status = %d", got.StatusCode) + } + + // DELETE by UUID. + if del := doJSON(t, http.MethodDelete, esmURL+"/"+esm.UUID, ""); del.StatusCode != http.StatusNoContent { + t.Fatalf("delete ESM status = %d", del.StatusCode) + } +} + +const esmBasePath = "/2015-03-31/event-source-mappings" + func TestInvokeOnMissingFunctionReturns404(t *testing.T) { srv, _ := newServer(t) From 14665d4835a81ba3753de194b5447f5f8522bdce Mon Sep 17 00:00:00 2001 From: Gajendra Malviya Date: Tue, 4 Aug 2026 17:41:13 +0530 Subject: [PATCH 33/45] feat(cloudwatchlogs): support log-group tagging (#319) TagResource/UntagResource/ListTagsForResource (and their legacy TagLogGroup/UntagLogGroup/ListTagsLogGroup aliases) were unimplemented. Store tags on the log group in the provider and route both the modern ARN-based and legacy name-based operations via an AWS-local logGroupTagger assertion. --- .../aws/cloudwatchlogs/cloudwatchlogs_test.go | 27 ++++ providers/aws/cloudwatchlogs/tags.go | 55 ++++++++ server/aws/cloudwatchlogs/handler.go | 6 + server/aws/cloudwatchlogs/tags.go | 127 ++++++++++++++++++ 4 files changed, 215 insertions(+) create mode 100644 providers/aws/cloudwatchlogs/tags.go create mode 100644 server/aws/cloudwatchlogs/tags.go diff --git a/providers/aws/cloudwatchlogs/cloudwatchlogs_test.go b/providers/aws/cloudwatchlogs/cloudwatchlogs_test.go index 413a9d8b..d80e63b8 100644 --- a/providers/aws/cloudwatchlogs/cloudwatchlogs_test.go +++ b/providers/aws/cloudwatchlogs/cloudwatchlogs_test.go @@ -815,3 +815,30 @@ func TestDescribeMetricFilters(t *testing.T) { require.Error(t, err) }) } + +// TestLogGroupTagging is a regression guard for issue #319: CloudWatch Logs +// TagResource/UntagResource/ListTagsForResource were unimplemented. +func TestLogGroupTagging(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + _, err := m.CreateLogGroup(ctx, driver.LogGroupConfig{Name: "g"}) + require.NoError(t, err) + + require.NoError(t, m.TagLogGroup(ctx, "g", map[string]string{"env": "prod", "team": "ops"})) + + tags, err := m.ListLogGroupTags(ctx, "g") + require.NoError(t, err) + assert.Equal(t, "prod", tags["env"]) + assert.Equal(t, "ops", tags["team"]) + + require.NoError(t, m.UntagLogGroup(ctx, "g", []string{"env"})) + + tags, err = m.ListLogGroupTags(ctx, "g") + require.NoError(t, err) + _, has := tags["env"] + assert.False(t, has) + assert.Equal(t, "ops", tags["team"]) + + assert.Error(t, m.TagLogGroup(ctx, "missing", map[string]string{"a": "b"})) +} diff --git a/providers/aws/cloudwatchlogs/tags.go b/providers/aws/cloudwatchlogs/tags.go new file mode 100644 index 00000000..2e4b8162 --- /dev/null +++ b/providers/aws/cloudwatchlogs/tags.go @@ -0,0 +1,55 @@ +package cloudwatchlogs + +import ( + "context" + + "github.com/stackshy/cloudemu/v2/errors" +) + +// TagLogGroup adds or overwrites tags on a log group (CloudWatch Logs +// TagResource / TagLogGroup). +func (m *Mock) TagLogGroup(_ context.Context, name string, tags map[string]string) error { + g, ok := m.groups.Get(name) + if !ok { + return errors.Newf(errors.NotFound, "log group %q not found", name) + } + + if g.info.Tags == nil { + g.info.Tags = make(map[string]string, len(tags)) + } + + for k, v := range tags { + g.info.Tags[k] = v + } + + return nil +} + +// UntagLogGroup removes tags by key from a log group. +func (m *Mock) UntagLogGroup(_ context.Context, name string, keys []string) error { + g, ok := m.groups.Get(name) + if !ok { + return errors.Newf(errors.NotFound, "log group %q not found", name) + } + + for _, k := range keys { + delete(g.info.Tags, k) + } + + return nil +} + +// ListLogGroupTags returns a log group's tags. +func (m *Mock) ListLogGroupTags(_ context.Context, name string) (map[string]string, error) { + g, ok := m.groups.Get(name) + if !ok { + return nil, errors.Newf(errors.NotFound, "log group %q not found", name) + } + + out := make(map[string]string, len(g.info.Tags)) + for k, v := range g.info.Tags { + out[k] = v + } + + return out, nil +} diff --git a/server/aws/cloudwatchlogs/handler.go b/server/aws/cloudwatchlogs/handler.go index 75a927fc..d5022fe4 100644 --- a/server/aws/cloudwatchlogs/handler.go +++ b/server/aws/cloudwatchlogs/handler.go @@ -72,6 +72,12 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.filterLogEvents(w, r) case "PutRetentionPolicy": h.putRetentionPolicy(w, r) + case "TagResource", "TagLogGroup": + h.tagLogGroup(w, r) + case "UntagResource", "UntagLogGroup": + h.untagLogGroup(w, r) + case "ListTagsForResource", "ListTagsLogGroup": + h.listTagsForResource(w, r) default: wire.WriteJSONError(w, http.StatusBadRequest, "UnknownOperationException", "unknown CloudWatch Logs operation: "+op) diff --git a/server/aws/cloudwatchlogs/tags.go b/server/aws/cloudwatchlogs/tags.go new file mode 100644 index 00000000..72f3a363 --- /dev/null +++ b/server/aws/cloudwatchlogs/tags.go @@ -0,0 +1,127 @@ +package cloudwatchlogs + +import ( + "context" + "net/http" + "strings" + + cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/server/wire" +) + +// logGroupTagger is the AWS-specific log-group tagging surface, asserted +// against the provider (not part of the portable Logging driver). +type logGroupTagger interface { + TagLogGroup(ctx context.Context, name string, tags map[string]string) error + UntagLogGroup(ctx context.Context, name string, keys []string) error + ListLogGroupTags(ctx context.Context, name string) (map[string]string, error) +} + +// logGroupName resolves either a log-group ARN (modern TagResource) or a bare +// name (legacy TagLogGroup) to the name the driver keys on. +func logGroupName(resourceArn, name string) string { + if name != "" { + return name + } + + const marker = ":log-group:" + + if i := strings.LastIndex(resourceArn, marker); i >= 0 { + return strings.TrimSuffix(resourceArn[i+len(marker):], ":*") + } + + return resourceArn +} + +func (h *Handler) logGroupTags() (logGroupTagger, bool) { + t, ok := h.logs.(logGroupTagger) + + return t, ok +} + +func (h *Handler) tagLogGroup(w http.ResponseWriter, r *http.Request) { + tagger, ok := h.logGroupTags() + if !ok { + writeErr(w, cerrors.New(cerrors.Unimplemented, "tagging not supported")) + return + } + + var req struct { + ResourceArn string `json:"resourceArn"` + LogGroupName string `json:"logGroupName"` + Tags map[string]string `json:"tags"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + name := logGroupName(req.ResourceArn, req.LogGroupName) + + if err := tagger.TagLogGroup(r.Context(), name, req.Tags); err != nil { + writeErr(w, err) + return + } + + wire.WriteJSON(w, struct{}{}) +} + +func (h *Handler) untagLogGroup(w http.ResponseWriter, r *http.Request) { + tagger, ok := h.logGroupTags() + if !ok { + writeErr(w, cerrors.New(cerrors.Unimplemented, "tagging not supported")) + return + } + + var req struct { + ResourceArn string `json:"resourceArn"` + LogGroupName string `json:"logGroupName"` + TagKeys []string `json:"tagKeys"` + Tags []string `json:"tags"` // legacy UntagLogGroup uses "tags" + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + name := logGroupName(req.ResourceArn, req.LogGroupName) + + keys := req.TagKeys + if len(keys) == 0 { + keys = req.Tags + } + + if err := tagger.UntagLogGroup(r.Context(), name, keys); err != nil { + writeErr(w, err) + return + } + + wire.WriteJSON(w, struct{}{}) +} + +func (h *Handler) listTagsForResource(w http.ResponseWriter, r *http.Request) { + tagger, ok := h.logGroupTags() + if !ok { + writeErr(w, cerrors.New(cerrors.Unimplemented, "tagging not supported")) + return + } + + var req struct { + ResourceArn string `json:"resourceArn"` + LogGroupName string `json:"logGroupName"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + name := logGroupName(req.ResourceArn, req.LogGroupName) + + tags, err := tagger.ListLogGroupTags(r.Context(), name) + if err != nil { + writeErr(w, err) + return + } + + wire.WriteJSON(w, map[string]any{"tags": tags}) +} From b8ba473217335207b4a17881b9674ba2c357b0bf Mon Sep 17 00:00:00 2001 From: Gajendra Malviya Date: Tue, 4 Aug 2026 17:42:56 +0530 Subject: [PATCH 34/45] feat(ecr): support repository tagging (Tag/Untag/ListTagsForResource) (#319) ECR tag operations returned UnknownOperation. Store tags on the repository in the provider and route the three ARN-based tag operations via an AWS-local repositoryTagger assertion. --- providers/aws/ecr/ecr_test.go | 25 +++++++ providers/aws/ecr/tags.go | 63 +++++++++++++++++ server/aws/ecr/handler.go | 6 ++ server/aws/ecr/tags.go | 124 ++++++++++++++++++++++++++++++++++ 4 files changed, 218 insertions(+) create mode 100644 providers/aws/ecr/tags.go create mode 100644 server/aws/ecr/tags.go diff --git a/providers/aws/ecr/ecr_test.go b/providers/aws/ecr/ecr_test.go index 96927d3a..03aeb24a 100644 --- a/providers/aws/ecr/ecr_test.go +++ b/providers/aws/ecr/ecr_test.go @@ -801,3 +801,28 @@ func TestMetricsEmission(t *testing.T) { assert.Contains(t, metrics, "ImagePullCount") }) } + +// TestRepositoryTagging is a regression guard for issue #319: ECR +// TagResource/UntagResource/ListTagsForResource were unimplemented. +func TestRepositoryTagging(t *testing.T) { + m, _ := newTestMock() + ctx := context.Background() + + createTestRepo(t, m, "r1") + + require.NoError(t, m.TagRepository(ctx, "r1", map[string]string{"env": "prod", "team": "img"})) + + tags, err := m.ListRepositoryTags(ctx, "r1") + require.NoError(t, err) + assert.Equal(t, "prod", tags["env"]) + assert.Equal(t, "img", tags["team"]) + + require.NoError(t, m.UntagRepository(ctx, "r1", []string{"env"})) + + tags, err = m.ListRepositoryTags(ctx, "r1") + require.NoError(t, err) + _, has := tags["env"] + assert.False(t, has) + + assert.Error(t, m.TagRepository(ctx, "missing", map[string]string{"a": "b"})) +} diff --git a/providers/aws/ecr/tags.go b/providers/aws/ecr/tags.go new file mode 100644 index 00000000..bfeb6703 --- /dev/null +++ b/providers/aws/ecr/tags.go @@ -0,0 +1,63 @@ +package ecr + +import ( + "context" + + "github.com/stackshy/cloudemu/v2/errors" +) + +// TagRepository adds or overwrites tags on a repository (ECR TagResource). +func (m *Mock) TagRepository(_ context.Context, name string, tags map[string]string) error { + m.mu.Lock() + defer m.mu.Unlock() + + rd, ok := m.repos.Get(name) + if !ok { + return errors.Newf(errors.NotFound, "repository %q not found", name) + } + + if rd.info.Tags == nil { + rd.info.Tags = make(map[string]string, len(tags)) + } + + for k, v := range tags { + rd.info.Tags[k] = v + } + + return nil +} + +// UntagRepository removes tags by key from a repository (ECR UntagResource). +func (m *Mock) UntagRepository(_ context.Context, name string, keys []string) error { + m.mu.Lock() + defer m.mu.Unlock() + + rd, ok := m.repos.Get(name) + if !ok { + return errors.Newf(errors.NotFound, "repository %q not found", name) + } + + for _, k := range keys { + delete(rd.info.Tags, k) + } + + return nil +} + +// ListRepositoryTags returns a repository's tags (ECR ListTagsForResource). +func (m *Mock) ListRepositoryTags(_ context.Context, name string) (map[string]string, error) { + m.mu.Lock() + defer m.mu.Unlock() + + rd, ok := m.repos.Get(name) + if !ok { + return nil, errors.Newf(errors.NotFound, "repository %q not found", name) + } + + out := make(map[string]string, len(rd.info.Tags)) + for k, v := range rd.info.Tags { + out[k] = v + } + + return out, nil +} diff --git a/server/aws/ecr/handler.go b/server/aws/ecr/handler.go index 75b6039a..de14896d 100644 --- a/server/aws/ecr/handler.go +++ b/server/aws/ecr/handler.go @@ -63,6 +63,12 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.batchDeleteImage(w, r) case "GetAuthorizationToken": h.getAuthorizationToken(w, r) + case "TagResource": + h.tagResource(w, r) + case "UntagResource": + h.untagResource(w, r) + case "ListTagsForResource": + h.listTagsForResource(w, r) default: op := strings.TrimPrefix(r.Header.Get("X-Amz-Target"), targetPrefix) wire.WriteJSONError(w, http.StatusBadRequest, diff --git a/server/aws/ecr/tags.go b/server/aws/ecr/tags.go new file mode 100644 index 00000000..fcb91216 --- /dev/null +++ b/server/aws/ecr/tags.go @@ -0,0 +1,124 @@ +package ecr + +import ( + "context" + "net/http" + "strings" + + cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/server/wire" +) + +// repositoryTagger is the AWS-specific ECR tagging surface, asserted against +// the provider (not part of the portable ContainerRegistry driver). +type repositoryTagger interface { + TagRepository(ctx context.Context, name string, tags map[string]string) error + UntagRepository(ctx context.Context, name string, keys []string) error + ListRepositoryTags(ctx context.Context, name string) (map[string]string, error) +} + +type ecrTag struct { + Key string `json:"Key"` + Value string `json:"Value"` +} + +// repoFromARN resolves an ECR ResourceArn +// ("arn:aws:ecr:::repository/") to the bare repository +// name. A non-ARN value is returned unchanged. +func repoFromARN(arn string) string { + const marker = ":repository/" + + if i := strings.LastIndex(arn, marker); i >= 0 { + return arn[i+len(marker):] + } + + return arn +} + +func (h *Handler) repoTagger() (repositoryTagger, bool) { + t, ok := h.registry.(repositoryTagger) + + return t, ok +} + +func (h *Handler) tagResource(w http.ResponseWriter, r *http.Request) { + tagger, ok := h.repoTagger() + if !ok { + writeErr(w, cerrors.New(cerrors.Unimplemented, "tagging not supported")) + return + } + + var req struct { + ResourceArn string `json:"resourceArn"` + Tags []ecrTag `json:"tags"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + tags := make(map[string]string, len(req.Tags)) + for _, t := range req.Tags { + tags[t.Key] = t.Value + } + + if err := tagger.TagRepository(r.Context(), repoFromARN(req.ResourceArn), tags); err != nil { + writeErr(w, err) + return + } + + wire.WriteJSON(w, struct{}{}) +} + +func (h *Handler) untagResource(w http.ResponseWriter, r *http.Request) { + tagger, ok := h.repoTagger() + if !ok { + writeErr(w, cerrors.New(cerrors.Unimplemented, "tagging not supported")) + return + } + + var req struct { + ResourceArn string `json:"resourceArn"` + TagKeys []string `json:"tagKeys"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + if err := tagger.UntagRepository(r.Context(), repoFromARN(req.ResourceArn), req.TagKeys); err != nil { + writeErr(w, err) + return + } + + wire.WriteJSON(w, struct{}{}) +} + +func (h *Handler) listTagsForResource(w http.ResponseWriter, r *http.Request) { + tagger, ok := h.repoTagger() + if !ok { + writeErr(w, cerrors.New(cerrors.Unimplemented, "tagging not supported")) + return + } + + var req struct { + ResourceArn string `json:"resourceArn"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + tags, err := tagger.ListRepositoryTags(r.Context(), repoFromARN(req.ResourceArn)) + if err != nil { + writeErr(w, err) + return + } + + out := make([]ecrTag, 0, len(tags)) + for k, v := range tags { + out = append(out, ecrTag{Key: k, Value: v}) + } + + wire.WriteJSON(w, map[string]any{"tags": out}) +} From 6796d698a10770479dba0151caac00a30da0de59 Mon Sep 17 00:00:00 2001 From: Gajendra Malviya Date: Tue, 4 Aug 2026 17:45:06 +0530 Subject: [PATCH 35/45] feat(eventbridge): support TagResource/UntagResource/ListTagsForResource (#319) EventBridge tag operations returned UnknownOperation. Rules carry no tag field, so back tagging with a generic ARN-keyed store on the provider and route the three operations via an AWS-local resourceTagger assertion. --- providers/aws/eventbridge/eventbridge.go | 1 + providers/aws/eventbridge/eventbridge_test.go | 31 +++++ providers/aws/eventbridge/tags.go | 71 +++++++++++ server/aws/eventbridge/handler.go | 6 + server/aws/eventbridge/tags.go | 110 ++++++++++++++++++ 5 files changed, 219 insertions(+) create mode 100644 providers/aws/eventbridge/tags.go create mode 100644 server/aws/eventbridge/tags.go diff --git a/providers/aws/eventbridge/eventbridge.go b/providers/aws/eventbridge/eventbridge.go index 13eab5ab..4547002d 100644 --- a/providers/aws/eventbridge/eventbridge.go +++ b/providers/aws/eventbridge/eventbridge.go @@ -53,6 +53,7 @@ type Mock struct { opts *config.Options monitoring mondriver.Monitoring sqs SQSDeliverer + tagsByARN tagStore } // SetMonitoring sets the monitoring backend for auto-metric generation. diff --git a/providers/aws/eventbridge/eventbridge_test.go b/providers/aws/eventbridge/eventbridge_test.go index 51f47d93..df3396ac 100644 --- a/providers/aws/eventbridge/eventbridge_test.go +++ b/providers/aws/eventbridge/eventbridge_test.go @@ -771,3 +771,34 @@ func TestMetricsEmission(t *testing.T) { assert.Contains(t, metrics, "MatchedEvents") }) } + +// TestResourceTagging is a regression guard for issue #319: EventBridge +// TagResource/UntagResource/ListTagsForResource were unimplemented. +func TestResourceTagging(t *testing.T) { + m, _ := newTestMock() + ctx := context.Background() + + arn := "arn:aws:events:us-east-1:000000000000:rule/r1" + + if err := m.TagResource(ctx, arn, map[string]string{"env": "prod", "team": "evt"}); err != nil { + t.Fatalf("TagResource: %v", err) + } + + tags, err := m.ListResourceTags(ctx, arn) + if err != nil { + t.Fatalf("ListResourceTags: %v", err) + } + + if tags["env"] != "prod" || tags["team"] != "evt" { + t.Fatalf("tags = %v", tags) + } + + if err := m.UntagResource(ctx, arn, []string{"env"}); err != nil { + t.Fatalf("UntagResource: %v", err) + } + + tags, _ = m.ListResourceTags(ctx, arn) + if _, has := tags["env"]; has || tags["team"] != "evt" { + t.Fatalf("after untag = %v", tags) + } +} diff --git a/providers/aws/eventbridge/tags.go b/providers/aws/eventbridge/tags.go new file mode 100644 index 00000000..98b2ab73 --- /dev/null +++ b/providers/aws/eventbridge/tags.go @@ -0,0 +1,71 @@ +package eventbridge + +import ( + "context" + "sync" +) + +// tagStore is a generic ARN-keyed tag store. EventBridge tags rules and event +// buses by ARN; rules carry no tag field of their own, so a shared store keyed +// by ARN backs TagResource/UntagResource/ListTagsForResource uniformly. +type tagStore struct { + mu sync.RWMutex + tags map[string]map[string]string // ARN -> tags +} + +func (t *tagStore) tag(arn string, tags map[string]string) { + t.mu.Lock() + defer t.mu.Unlock() + + if t.tags == nil { + t.tags = map[string]map[string]string{} + } + + if t.tags[arn] == nil { + t.tags[arn] = map[string]string{} + } + + for k, v := range tags { + t.tags[arn][k] = v + } +} + +func (t *tagStore) untag(arn string, keys []string) { + t.mu.Lock() + defer t.mu.Unlock() + + for _, k := range keys { + delete(t.tags[arn], k) + } +} + +func (t *tagStore) list(arn string) map[string]string { + t.mu.RLock() + defer t.mu.RUnlock() + + out := make(map[string]string, len(t.tags[arn])) + for k, v := range t.tags[arn] { + out[k] = v + } + + return out +} + +// TagResource tags an EventBridge resource (rule or event bus) by ARN. +func (m *Mock) TagResource(_ context.Context, arn string, tags map[string]string) error { + m.tagsByARN.tag(arn, tags) + + return nil +} + +// UntagResource removes tags by key from an EventBridge resource by ARN. +func (m *Mock) UntagResource(_ context.Context, arn string, keys []string) error { + m.tagsByARN.untag(arn, keys) + + return nil +} + +// ListResourceTags returns the tags on an EventBridge resource by ARN. +func (m *Mock) ListResourceTags(_ context.Context, arn string) (map[string]string, error) { + return m.tagsByARN.list(arn), nil +} diff --git a/server/aws/eventbridge/handler.go b/server/aws/eventbridge/handler.go index dcee2cf9..34d6546f 100644 --- a/server/aws/eventbridge/handler.go +++ b/server/aws/eventbridge/handler.go @@ -69,6 +69,12 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.listTargetsByRule(w, r) case "PutEvents": h.putEvents(w, r) + case "TagResource": + h.tagResource(w, r) + case "UntagResource": + h.untagResource(w, r) + case "ListTagsForResource": + h.listTagsForResource(w, r) default: wire.WriteJSONError(w, http.StatusBadRequest, "UnknownOperationException", "unknown EventBridge operation: "+op) diff --git a/server/aws/eventbridge/tags.go b/server/aws/eventbridge/tags.go new file mode 100644 index 00000000..c05c6faa --- /dev/null +++ b/server/aws/eventbridge/tags.go @@ -0,0 +1,110 @@ +package eventbridge + +import ( + "context" + "net/http" + + cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/server/wire" +) + +// resourceTagger is the AWS-specific EventBridge tagging surface, asserted +// against the provider (not part of the portable EventBus driver). +type resourceTagger interface { + TagResource(ctx context.Context, arn string, tags map[string]string) error + UntagResource(ctx context.Context, arn string, keys []string) error + ListResourceTags(ctx context.Context, arn string) (map[string]string, error) +} + +type ebTag struct { + Key string `json:"Key"` + Value string `json:"Value"` +} + +func (h *Handler) tagger() (resourceTagger, bool) { + t, ok := h.bus.(resourceTagger) + + return t, ok +} + +func (h *Handler) tagResource(w http.ResponseWriter, r *http.Request) { + tagger, ok := h.tagger() + if !ok { + writeErr(w, cerrors.New(cerrors.Unimplemented, "tagging not supported")) + return + } + + var req struct { + ResourceARN string `json:"ResourceARN"` + Tags []ebTag `json:"Tags"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + tags := make(map[string]string, len(req.Tags)) + for _, t := range req.Tags { + tags[t.Key] = t.Value + } + + if err := tagger.TagResource(r.Context(), req.ResourceARN, tags); err != nil { + writeErr(w, err) + return + } + + wire.WriteJSON(w, struct{}{}) +} + +func (h *Handler) untagResource(w http.ResponseWriter, r *http.Request) { + tagger, ok := h.tagger() + if !ok { + writeErr(w, cerrors.New(cerrors.Unimplemented, "tagging not supported")) + return + } + + var req struct { + ResourceARN string `json:"ResourceARN"` + TagKeys []string `json:"TagKeys"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + if err := tagger.UntagResource(r.Context(), req.ResourceARN, req.TagKeys); err != nil { + writeErr(w, err) + return + } + + wire.WriteJSON(w, struct{}{}) +} + +func (h *Handler) listTagsForResource(w http.ResponseWriter, r *http.Request) { + tagger, ok := h.tagger() + if !ok { + writeErr(w, cerrors.New(cerrors.Unimplemented, "tagging not supported")) + return + } + + var req struct { + ResourceARN string `json:"ResourceARN"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + tags, err := tagger.ListResourceTags(r.Context(), req.ResourceARN) + if err != nil { + writeErr(w, err) + return + } + + out := make([]ebTag, 0, len(tags)) + for k, v := range tags { + out = append(out, ebTag{Key: k, Value: v}) + } + + wire.WriteJSON(w, map[string]any{"Tags": out}) +} From 1af63587306f49acd983fd6ea11ea40ccc186165 Mon Sep 17 00:00:00 2001 From: Gajendra Malviya Date: Tue, 4 Aug 2026 17:46:36 +0530 Subject: [PATCH 36/45] feat(iam): support role tagging (TagRole/UntagRole/ListRoleTags) (#319) IAM role tagging returned InvalidAction. Store tags on the role in the provider (the field already existed for tag-on-create) and route the three query-protocol actions via an AWS-local roleTagManager assertion. --- providers/aws/awsiam/roletags.go | 63 +++++++++++++++++++ server/aws/iam/handler.go | 17 +++++ server/aws/iam/roletags.go | 103 +++++++++++++++++++++++++++++++ 3 files changed, 183 insertions(+) create mode 100644 providers/aws/awsiam/roletags.go create mode 100644 server/aws/iam/roletags.go diff --git a/providers/aws/awsiam/roletags.go b/providers/aws/awsiam/roletags.go new file mode 100644 index 00000000..ab079dba --- /dev/null +++ b/providers/aws/awsiam/roletags.go @@ -0,0 +1,63 @@ +package awsiam + +import ( + "context" + + "github.com/stackshy/cloudemu/v2/errors" +) + +// TagRole adds or overwrites tags on a role (IAM TagRole). +func (m *Mock) TagRole(_ context.Context, roleName string, tags map[string]string) error { + m.mu.Lock() + defer m.mu.Unlock() + + rd, ok := m.roles.Get(roleName) + if !ok { + return errors.Newf(errors.NotFound, "role %q not found", roleName) + } + + if rd.Tags == nil { + rd.Tags = make(map[string]string, len(tags)) + } + + for k, v := range tags { + rd.Tags[k] = v + } + + return nil +} + +// UntagRole removes tags by key from a role (IAM UntagRole). +func (m *Mock) UntagRole(_ context.Context, roleName string, keys []string) error { + m.mu.Lock() + defer m.mu.Unlock() + + rd, ok := m.roles.Get(roleName) + if !ok { + return errors.Newf(errors.NotFound, "role %q not found", roleName) + } + + for _, k := range keys { + delete(rd.Tags, k) + } + + return nil +} + +// ListRoleTags returns a role's tags (IAM ListRoleTags). +func (m *Mock) ListRoleTags(_ context.Context, roleName string) (map[string]string, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + rd, ok := m.roles.Get(roleName) + if !ok { + return nil, errors.Newf(errors.NotFound, "role %q not found", roleName) + } + + out := make(map[string]string, len(rd.Tags)) + for k, v := range rd.Tags { + out[k] = v + } + + return out, nil +} diff --git a/server/aws/iam/handler.go b/server/aws/iam/handler.go index 1e5e12ff..259794a3 100644 --- a/server/aws/iam/handler.go +++ b/server/aws/iam/handler.go @@ -76,6 +76,17 @@ var iamActions = map[string]struct{}{ //nolint:gochecknoglobals // static lookup "GetRolePolicy": {}, "DeleteRolePolicy": {}, "ListRolePolicies": {}, + "TagRole": {}, + "UntagRole": {}, + "ListRoleTags": {}, +} + +// roleTagManager is the AWS-specific role-tagging surface, asserted against the +// provider (not part of the portable IAM driver). +type roleTagManager interface { + TagRole(ctx context.Context, roleName string, tags map[string]string) error + UntagRole(ctx context.Context, roleName string, keys []string) error + ListRoleTags(ctx context.Context, roleName string) (map[string]string, error) } // rolePolicyManager is the AWS-specific inline-role-policy surface. It's not @@ -215,6 +226,12 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.deleteRolePolicy(w, r) case "ListRolePolicies": h.listRolePolicies(w, r) + case "TagRole": + h.tagRole(w, r) + case "UntagRole": + h.untagRole(w, r) + case "ListRoleTags": + h.listRoleTags(w, r) default: awsquery.WriteXMLError(w, http.StatusBadRequest, "InvalidAction", "unknown IAM action: "+r.Form.Get("Action")) diff --git a/server/aws/iam/roletags.go b/server/aws/iam/roletags.go new file mode 100644 index 00000000..0c9f417a --- /dev/null +++ b/server/aws/iam/roletags.go @@ -0,0 +1,103 @@ +package iam + +import ( + "encoding/xml" + "net/http" + "sort" + + cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/server/wire/awsquery" +) + +type tagMemberXML struct { + Key string `xml:"Key"` + Value string `xml:"Value"` +} + +type tagRoleResponse struct { + XMLName xml.Name `xml:"TagRoleResponse"` + Xmlns string `xml:"xmlns,attr"` + Metadata responseMetadata `xml:"ResponseMetadata"` +} + +type untagRoleResponse struct { + XMLName xml.Name `xml:"UntagRoleResponse"` + Xmlns string `xml:"xmlns,attr"` + Metadata responseMetadata `xml:"ResponseMetadata"` +} + +type listRoleTagsResponse struct { + XMLName xml.Name `xml:"ListRoleTagsResponse"` + Xmlns string `xml:"xmlns,attr"` + Tags []tagMemberXML `xml:"ListRoleTagsResult>Tags>member"` + Metadata responseMetadata `xml:"ResponseMetadata"` +} + +func (h *Handler) roleTags() (roleTagManager, bool) { + m, ok := h.iam.(roleTagManager) + + return m, ok +} + +func (h *Handler) tagRole(w http.ResponseWriter, r *http.Request) { + mgr, ok := h.roleTags() + if !ok { + writeErr(w, cerrors.New(cerrors.Unimplemented, "role tagging not supported")) + return + } + + if err := mgr.TagRole(r.Context(), r.Form.Get("RoleName"), awsquery.FlatTags(r.Form, "Tags.member")); err != nil { + writeErr(w, err) + return + } + + awsquery.WriteXMLResponse(w, tagRoleResponse{ + Xmlns: Namespace, Metadata: responseMetadata{RequestID: awsquery.RequestID}, + }) +} + +func (h *Handler) untagRole(w http.ResponseWriter, r *http.Request) { + mgr, ok := h.roleTags() + if !ok { + writeErr(w, cerrors.New(cerrors.Unimplemented, "role tagging not supported")) + return + } + + if err := mgr.UntagRole(r.Context(), r.Form.Get("RoleName"), awsquery.ListStrings(r.Form, "TagKeys.member")); err != nil { + writeErr(w, err) + return + } + + awsquery.WriteXMLResponse(w, untagRoleResponse{ + Xmlns: Namespace, Metadata: responseMetadata{RequestID: awsquery.RequestID}, + }) +} + +func (h *Handler) listRoleTags(w http.ResponseWriter, r *http.Request) { + mgr, ok := h.roleTags() + if !ok { + writeErr(w, cerrors.New(cerrors.Unimplemented, "role tagging not supported")) + return + } + + tags, err := mgr.ListRoleTags(r.Context(), r.Form.Get("RoleName")) + if err != nil { + writeErr(w, err) + return + } + + keys := make([]string, 0, len(tags)) + for k := range tags { + keys = append(keys, k) + } + sort.Strings(keys) + + members := make([]tagMemberXML, 0, len(keys)) + for _, k := range keys { + members = append(members, tagMemberXML{Key: k, Value: tags[k]}) + } + + awsquery.WriteXMLResponse(w, listRoleTagsResponse{ + Xmlns: Namespace, Tags: members, Metadata: responseMetadata{RequestID: awsquery.RequestID}, + }) +} From dcb5fc69b73bb99f54957ece8d51b8ccd1545f0d Mon Sep 17 00:00:00 2001 From: Gajendra Malviya Date: Tue, 4 Aug 2026 17:48:28 +0530 Subject: [PATCH 37/45] feat(redshift): support CreateTags/DeleteTags/DescribeTags (#319) Redshift resource tagging returned InvalidAction. Back it with an ARN-keyed store on the provider and route the three query-protocol actions via an AWS-local resourceTagger assertion. --- providers/aws/redshift/redshift.go | 1 + providers/aws/redshift/tags.go | 50 ++++++++++++++ server/aws/redshift/handler.go | 16 +++++ server/aws/redshift/tags.go | 106 +++++++++++++++++++++++++++++ 4 files changed, 173 insertions(+) create mode 100644 providers/aws/redshift/tags.go create mode 100644 server/aws/redshift/tags.go diff --git a/providers/aws/redshift/redshift.go b/providers/aws/redshift/redshift.go index 2f73d21c..f247e61c 100644 --- a/providers/aws/redshift/redshift.go +++ b/providers/aws/redshift/redshift.go @@ -62,6 +62,7 @@ type Mock struct { clusterSnapshots *memstore.Store[rdbdriver.ClusterSnapshot] parameterGroups *memstore.Store[ParameterGroup] subnetGroups *memstore.Store[SubnetGroup] + tagsByARN map[string]map[string]string // ResourceName (ARN) -> tags opts *config.Options monitoring mondriver.Monitoring diff --git a/providers/aws/redshift/tags.go b/providers/aws/redshift/tags.go new file mode 100644 index 00000000..4753af01 --- /dev/null +++ b/providers/aws/redshift/tags.go @@ -0,0 +1,50 @@ +package redshift + +import "context" + +// CreateTags tags a Redshift resource by ARN (ResourceName). Redshift resources +// don't carry a tag field in the shared cluster model, so tags live in an +// ARN-keyed store on the provider. +func (m *Mock) CreateTags(_ context.Context, resourceName string, tags map[string]string) error { + m.mu.Lock() + defer m.mu.Unlock() + + if m.tagsByARN == nil { + m.tagsByARN = map[string]map[string]string{} + } + + if m.tagsByARN[resourceName] == nil { + m.tagsByARN[resourceName] = map[string]string{} + } + + for k, v := range tags { + m.tagsByARN[resourceName][k] = v + } + + return nil +} + +// DeleteTags removes tags by key from a Redshift resource by ARN. +func (m *Mock) DeleteTags(_ context.Context, resourceName string, keys []string) error { + m.mu.Lock() + defer m.mu.Unlock() + + for _, k := range keys { + delete(m.tagsByARN[resourceName], k) + } + + return nil +} + +// DescribeTags returns the tags on a Redshift resource by ARN. +func (m *Mock) DescribeTags(_ context.Context, resourceName string) (map[string]string, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + out := make(map[string]string, len(m.tagsByARN[resourceName])) + for k, v := range m.tagsByARN[resourceName] { + out[k] = v + } + + return out, nil +} diff --git a/server/aws/redshift/handler.go b/server/aws/redshift/handler.go index 808cc0bb..c7df8fd5 100644 --- a/server/aws/redshift/handler.go +++ b/server/aws/redshift/handler.go @@ -44,6 +44,9 @@ var redshiftActions = map[string]struct{}{ //nolint:gochecknoglobals // static l "RestoreFromClusterSnapshot": {}, "CreateClusterParameterGroup": {}, "CreateClusterSubnetGroup": {}, + "CreateTags": {}, + "DeleteTags": {}, + "DescribeTags": {}, } // clusterGroupManager is the AWS-specific parameter/subnet-group surface, not @@ -53,6 +56,13 @@ type clusterGroupManager interface { CreateClusterSubnetGroup(ctx context.Context, name, description string, subnetIDs []string) (*redshiftprovider.SubnetGroup, error) } +// resourceTagger is the AWS-specific Redshift tagging surface. +type resourceTagger interface { + CreateTags(ctx context.Context, resourceName string, tags map[string]string) error + DeleteTags(ctx context.Context, resourceName string, keys []string) error + DescribeTags(ctx context.Context, resourceName string) (map[string]string, error) +} + // Handler serves Redshift query-protocol requests. type Handler struct { db rdbdriver.RelationalDB @@ -117,6 +127,12 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.createClusterParameterGroup(w, r) case "CreateClusterSubnetGroup": h.createClusterSubnetGroup(w, r) + case "CreateTags": + h.createTags(w, r) + case "DeleteTags": + h.deleteTags(w, r) + case "DescribeTags": + h.describeTags(w, r) default: awsquery.WriteXMLError(w, http.StatusBadRequest, "InvalidAction", "unknown Redshift action: "+action) diff --git a/server/aws/redshift/tags.go b/server/aws/redshift/tags.go new file mode 100644 index 00000000..89429f57 --- /dev/null +++ b/server/aws/redshift/tags.go @@ -0,0 +1,106 @@ +package redshift + +import ( + "encoding/xml" + "net/http" + "sort" + + cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/server/wire/awsquery" +) + +type taggedResourceXML struct { + ResourceName string `xml:"ResourceName"` + Key string `xml:"Tag>Key"` + Value string `xml:"Tag>Value"` +} + +type createTagsResponse struct { + XMLName xml.Name `xml:"CreateTagsResponse"` + Xmlns string `xml:"xmlns,attr"` + Metadata responseMetadata `xml:"ResponseMetadata"` +} + +type deleteTagsResponse struct { + XMLName xml.Name `xml:"DeleteTagsResponse"` + Xmlns string `xml:"xmlns,attr"` + Metadata responseMetadata `xml:"ResponseMetadata"` +} + +type describeTagsResponse struct { + XMLName xml.Name `xml:"DescribeTagsResponse"` + Xmlns string `xml:"xmlns,attr"` + Resources []taggedResourceXML `xml:"DescribeTagsResult>TaggedResources>TaggedResource"` + Metadata responseMetadata `xml:"ResponseMetadata"` +} + +func (h *Handler) resourceTagger() (resourceTagger, bool) { + t, ok := h.db.(resourceTagger) + + return t, ok +} + +func (h *Handler) createTags(w http.ResponseWriter, r *http.Request) { + tagger, ok := h.resourceTagger() + if !ok { + writeErr(w, cerrors.New(cerrors.Unimplemented, "tagging not supported")) + return + } + + if err := tagger.CreateTags(r.Context(), r.Form.Get("ResourceName"), awsquery.FlatTags(r.Form, "Tags.Tag")); err != nil { + writeErr(w, err) + return + } + + awsquery.WriteXMLResponse(w, createTagsResponse{ + Xmlns: Namespace, Metadata: responseMetadata{RequestID: awsquery.RequestID}, + }) +} + +func (h *Handler) deleteTags(w http.ResponseWriter, r *http.Request) { + tagger, ok := h.resourceTagger() + if !ok { + writeErr(w, cerrors.New(cerrors.Unimplemented, "tagging not supported")) + return + } + + if err := tagger.DeleteTags(r.Context(), r.Form.Get("ResourceName"), awsquery.ListStrings(r.Form, "TagKeys.TagKey")); err != nil { + writeErr(w, err) + return + } + + awsquery.WriteXMLResponse(w, deleteTagsResponse{ + Xmlns: Namespace, Metadata: responseMetadata{RequestID: awsquery.RequestID}, + }) +} + +func (h *Handler) describeTags(w http.ResponseWriter, r *http.Request) { + tagger, ok := h.resourceTagger() + if !ok { + writeErr(w, cerrors.New(cerrors.Unimplemented, "tagging not supported")) + return + } + + resourceName := r.Form.Get("ResourceName") + + tags, err := tagger.DescribeTags(r.Context(), resourceName) + if err != nil { + writeErr(w, err) + return + } + + keys := make([]string, 0, len(tags)) + for k := range tags { + keys = append(keys, k) + } + sort.Strings(keys) + + out := make([]taggedResourceXML, 0, len(keys)) + for _, k := range keys { + out = append(out, taggedResourceXML{ResourceName: resourceName, Key: k, Value: tags[k]}) + } + + awsquery.WriteXMLResponse(w, describeTagsResponse{ + Xmlns: Namespace, Resources: out, Metadata: responseMetadata{RequestID: awsquery.RequestID}, + }) +} From 8dacc6eaa46c57fbd30f483f03f159998b4650d1 Mon Sep 17 00:00:00 2001 From: Gajendra Malviya Date: Tue, 4 Aug 2026 18:10:07 +0530 Subject: [PATCH 38/45] fix(ec2): move CreateNetworkInterface off the shared NetworkInterfaces interface (#319) Adding CreateNetworkInterface to the NetworkInterfaces driver interface broke resourcediscovery's read-only ENI walker: its subset type-assertion (and the test's failingInterfaces fake) no longer satisfied the widened interface, so interface-listing errors were silently swallowed. Split the creation method into a separate NetworkInterfaceCreator interface and type-assert it in the EC2 handler instead. --- server/aws/ec2/network_interface.go | 4 ++-- services/networking/driver/driver.go | 8 +++++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/server/aws/ec2/network_interface.go b/server/aws/ec2/network_interface.go index 86bb40fe..dcea9e31 100644 --- a/server/aws/ec2/network_interface.go +++ b/server/aws/ec2/network_interface.go @@ -158,7 +158,7 @@ func containsString(values []string, want string) bool { } func (h *Handler) createNetworkInterface(w http.ResponseWriter, r *http.Request) { - store, ok := h.networkInterfaces() + creator, ok := h.vpc.(netdriver.NetworkInterfaceCreator) if !ok { writeUnsupportedENI(w) return @@ -170,7 +170,7 @@ func (h *Handler) createNetworkInterface(w http.ResponseWriter, r *http.Request) return } - eni, err := store.CreateNetworkInterface(r.Context(), subnetID, r.Form.Get("Description"), + eni, err := creator.CreateNetworkInterface(r.Context(), subnetID, r.Form.Get("Description"), mergeTagSpecs(awsquery.TagSpecs(r.Form), "network-interface")) if err != nil { writeENIErr(w, err) diff --git a/services/networking/driver/driver.go b/services/networking/driver/driver.go index e2fa2c1b..c2b11c10 100644 --- a/services/networking/driver/driver.go +++ b/services/networking/driver/driver.go @@ -375,8 +375,14 @@ type VPCAttributes interface { // that do not model interfaces would carry identical copies of one that does // nothing for them. type NetworkInterfaces interface { - CreateNetworkInterface(ctx context.Context, subnetID, description string, tags map[string]string) (*NetworkInterface, error) DescribeNetworkInterfaces(ctx context.Context, ids []string) ([]NetworkInterface, error) DetachNetworkInterface(ctx context.Context, attachmentID string, force bool) error DeleteNetworkInterface(ctx context.Context, id string) error } + +// NetworkInterfaceCreator is the AWS-specific ENI-creation surface. It's kept +// out of NetworkInterfaces so that adding it doesn't break subset assertions +// (e.g. resourcediscovery's read-only walker, which only needs Describe). +type NetworkInterfaceCreator interface { + CreateNetworkInterface(ctx context.Context, subnetID, description string, tags map[string]string) (*NetworkInterface, error) +} From 513012e37d40926aeba8ff8b374f80e089ac071c Mon Sep 17 00:00:00 2001 From: Gajendra Malviya Date: Tue, 4 Aug 2026 18:10:07 +0530 Subject: [PATCH 39/45] fix(redshift): gate generic tag verbs by SigV4 scope to avoid collisions (#319) Adding CreateTags/DeleteTags/DescribeTags to redshiftActions made the Redshift handler (registered before EC2 and ELBv2) steal those verbs from EC2 (CreateTags/DeleteTags) and ELBv2 (DescribeTags) on the shared query protocol. Claim the ambiguous verbs only when the SigV4 credential scope names 'redshift'; otherwise fall through to the owning handler. --- server/aws/redshift/handler.go | 41 ++++++++++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/server/aws/redshift/handler.go b/server/aws/redshift/handler.go index c7df8fd5..833e1812 100644 --- a/server/aws/redshift/handler.go +++ b/server/aws/redshift/handler.go @@ -95,9 +95,46 @@ func (*Handler) Matches(r *http.Request) bool { return false } - _, ok := redshiftActions[r.Form.Get("Action")] + action := r.Form.Get("Action") + if _, ok := redshiftActions[action]; !ok { + return false + } + + // CreateTags/DeleteTags/DescribeTags are generic tag verbs shared with EC2 + // and ELBv2 on the same query protocol. Redshift registers before both, so + // claim these only when the SigV4 credential scope names "redshift"; + // otherwise let them fall through to the owning handler. + if _, ambiguous := ambiguousTagActions[action]; ambiguous { + return sigV4ScopeService(r.Header.Get("Authorization")) == "redshift" + } + + return true +} + +// ambiguousTagActions are the tag verbs Redshift shares with other +// query-protocol services (EC2 CreateTags/DeleteTags, ELBv2 DescribeTags). +var ambiguousTagActions = map[string]struct{}{ //nolint:gochecknoglobals // static lookup table + "CreateTags": {}, + "DeleteTags": {}, + "DescribeTags": {}, +} + +// sigV4ScopeService extracts the service from a SigV4 Authorization credential +// scope: "Credential=AKID/20260101/us-east-1//aws4_request". +func sigV4ScopeService(auth string) string { + i := strings.Index(auth, "Credential=") + if i < 0 { + return "" + } + + parts := strings.Split(auth[i+len("Credential="):], "/") + + const serviceField = 3 + if len(parts) <= serviceField { + return "" + } - return ok + return parts[serviceField] } // ServeHTTP dispatches on Action. The form has already been parsed by Matches. From fcf2fc6912b82c0ea7b667b5c11dbda418b94db0 Mon Sep 17 00:00:00 2001 From: Gajendra Malviya Date: Tue, 4 Aug 2026 18:10:22 +0530 Subject: [PATCH 40/45] feat(elbv2): support AddTags and RemoveTags (#319) Tags could only be set at create time; AddTags/RemoveTags returned InvalidAction. Add tag-mutation methods to the ELB provider (updating the load balancer or target group by ARN) routed via an AWS-local tagMutator assertion. The empty wrapper is included so the SDK deserializes the response. --- providers/aws/elb/tags.go | 60 ++++++++++++++++ .../elbv2/attributes_sdk_roundtrip_test.go | 41 +++++++++++ server/aws/elbv2/handler.go | 6 ++ server/aws/elbv2/tags.go | 70 +++++++++++++++++++ 4 files changed, 177 insertions(+) create mode 100644 providers/aws/elb/tags.go diff --git a/providers/aws/elb/tags.go b/providers/aws/elb/tags.go new file mode 100644 index 00000000..1e9f38ed --- /dev/null +++ b/providers/aws/elb/tags.go @@ -0,0 +1,60 @@ +package elb + +import "context" + +// AddResourceTags adds or overwrites tags on a load balancer or target group +// identified by ARN (ELBv2 AddTags). Unknown ARNs are ignored, matching AWS's +// tolerance for a mixed multi-resource AddTags call. +func (m *Mock) AddResourceTags(_ context.Context, arn string, tags map[string]string) error { + if lb, ok := m.lbs.Get(arn); ok { + if lb.Tags == nil { + lb.Tags = map[string]string{} + } + + for k, v := range tags { + lb.Tags[k] = v + } + + m.lbs.Set(arn, lb) + + return nil + } + + if tg, ok := m.tgs.Get(arn); ok { + if tg.Tags == nil { + tg.Tags = map[string]string{} + } + + for k, v := range tags { + tg.Tags[k] = v + } + + m.tgs.Set(arn, tg) + } + + return nil +} + +// RemoveResourceTags removes tags by key from a load balancer or target group +// identified by ARN (ELBv2 RemoveTags). +func (m *Mock) RemoveResourceTags(_ context.Context, arn string, keys []string) error { + if lb, ok := m.lbs.Get(arn); ok { + for _, k := range keys { + delete(lb.Tags, k) + } + + m.lbs.Set(arn, lb) + + return nil + } + + if tg, ok := m.tgs.Get(arn); ok { + for _, k := range keys { + delete(tg.Tags, k) + } + + m.tgs.Set(arn, tg) + } + + return nil +} diff --git a/server/aws/elbv2/attributes_sdk_roundtrip_test.go b/server/aws/elbv2/attributes_sdk_roundtrip_test.go index 874a8e70..aeaf4f7d 100644 --- a/server/aws/elbv2/attributes_sdk_roundtrip_test.go +++ b/server/aws/elbv2/attributes_sdk_roundtrip_test.go @@ -137,6 +137,47 @@ func TestModifyLoadBalancerAttributesMerges(t *testing.T) { // A sweep for orphaned infrastructure identifies its own load balancers by // tag; an empty answer reads as "not mine" and leaves the orphan standing. +// TestAddAndRemoveTags is a regression guard for issue #319: AddTags/RemoveTags +// were unimplemented, so tags could only be set at create time. +func TestAddAndRemoveTags(t *testing.T) { + ctx := context.Background() + c := newELBClient(t) + + arn := mkLB(t, c, "nlb-mut", nil) + + if _, err := c.AddTags(ctx, &awselbv2.AddTagsInput{ + ResourceArns: []string{arn}, + Tags: []elbv2types.Tag{{Key: aws.String("env"), Value: aws.String("prod")}}, + }); err != nil { + t.Fatalf("AddTags: %v", err) + } + + got, err := c.DescribeTags(ctx, &awselbv2.DescribeTagsInput{ResourceArns: []string{arn}}) + if err != nil { + t.Fatalf("DescribeTags: %v", err) + } + + if len(got.TagDescriptions) != 1 || len(got.TagDescriptions[0].Tags) != 1 || + aws.ToString(got.TagDescriptions[0].Tags[0].Key) != "env" { + t.Fatalf("after AddTags: %+v", got.TagDescriptions) + } + + if _, err := c.RemoveTags(ctx, &awselbv2.RemoveTagsInput{ + ResourceArns: []string{arn}, TagKeys: []string{"env"}, + }); err != nil { + t.Fatalf("RemoveTags: %v", err) + } + + got, err = c.DescribeTags(ctx, &awselbv2.DescribeTagsInput{ResourceArns: []string{arn}}) + if err != nil { + t.Fatalf("DescribeTags after remove: %v", err) + } + + if len(got.TagDescriptions) == 1 && len(got.TagDescriptions[0].Tags) != 0 { + t.Fatalf("tags remained after RemoveTags: %+v", got.TagDescriptions[0].Tags) + } +} + func TestDescribeTagsReturnsLoadBalancerTags(t *testing.T) { ctx := context.Background() c := newELBClient(t) diff --git a/server/aws/elbv2/handler.go b/server/aws/elbv2/handler.go index da76e0fe..969011fe 100644 --- a/server/aws/elbv2/handler.go +++ b/server/aws/elbv2/handler.go @@ -37,6 +37,8 @@ var elbActions = map[string]struct{}{ //nolint:gochecknoglobals // static lookup "ModifyLoadBalancerAttributes": {}, "DescribeLoadBalancerAttributes": {}, "DescribeTags": {}, + "AddTags": {}, + "RemoveTags": {}, "DescribeLoadBalancers": {}, "DeleteLoadBalancer": {}, "CreateTargetGroup": {}, @@ -107,6 +109,10 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.describeLoadBalancerAttributes(w, r) case "DescribeTags": h.describeTags(w, r) + case "AddTags": + h.addTags(w, r) + case "RemoveTags": + h.removeTags(w, r) case "DeleteLoadBalancer": h.deleteLoadBalancer(w, r) case "CreateTargetGroup": diff --git a/server/aws/elbv2/tags.go b/server/aws/elbv2/tags.go index 76503cd5..076ea88d 100644 --- a/server/aws/elbv2/tags.go +++ b/server/aws/elbv2/tags.go @@ -1,12 +1,82 @@ package elbv2 import ( + "context" "encoding/xml" "net/http" + cerrors "github.com/stackshy/cloudemu/v2/errors" "github.com/stackshy/cloudemu/v2/server/wire/awsquery" ) +// tagMutator is the AWS-specific ELBv2 tag-write surface, asserted against the +// provider (not part of the portable LoadBalancer driver). +type tagMutator interface { + AddResourceTags(ctx context.Context, arn string, tags map[string]string) error + RemoveResourceTags(ctx context.Context, arn string, keys []string) error +} + +// The ELBv2 SDK unmarshaler expects the empty wrapper element. +type addTagsResponse struct { + XMLName xml.Name `xml:"AddTagsResponse"` + Xmlns string `xml:"xmlns,attr"` + Result struct{} `xml:"AddTagsResult"` + Metadata responseMetadata `xml:"ResponseMetadata"` +} + +type removeTagsResponse struct { + XMLName xml.Name `xml:"RemoveTagsResponse"` + Xmlns string `xml:"xmlns,attr"` + Result struct{} `xml:"RemoveTagsResult"` + Metadata responseMetadata `xml:"ResponseMetadata"` +} + +func (h *Handler) tagMutator() (tagMutator, bool) { + m, ok := h.lb.(tagMutator) + + return m, ok +} + +func (h *Handler) addTags(w http.ResponseWriter, r *http.Request) { + mut, ok := h.tagMutator() + if !ok { + writeErr(w, cerrors.New(cerrors.Unimplemented, "tagging not supported")) + return + } + + tags := awsquery.FlatTags(r.Form, "Tags.member") + for _, arn := range awsquery.ListStrings(r.Form, "ResourceArns.member") { + if err := mut.AddResourceTags(r.Context(), arn, tags); err != nil { + writeErr(w, err) + return + } + } + + awsquery.WriteXMLResponse(w, addTagsResponse{ + Xmlns: Namespace, Metadata: responseMetadata{RequestID: awsquery.RequestID}, + }) +} + +func (h *Handler) removeTags(w http.ResponseWriter, r *http.Request) { + mut, ok := h.tagMutator() + if !ok { + writeErr(w, cerrors.New(cerrors.Unimplemented, "tagging not supported")) + return + } + + keys := awsquery.ListStrings(r.Form, "TagKeys.member") + for _, arn := range awsquery.ListStrings(r.Form, "ResourceArns.member") { + if err := mut.RemoveResourceTags(r.Context(), arn, keys); err != nil { + writeErr(w, err) + return + } + } + + awsquery.WriteXMLResponse(w, removeTagsResponse{ + Xmlns: Namespace, Metadata: responseMetadata{RequestID: awsquery.RequestID}, + }) +} + type tagMemberXML struct { Key string `xml:"Key"` Value string `xml:"Value"` From 37f284201bc35a6fb1b69e914a1345c39e3ddcb5 Mon Sep 17 00:00:00 2001 From: Gajendra Malviya Date: Tue, 4 Aug 2026 18:14:52 +0530 Subject: [PATCH 41/45] feat(eks): support cluster tagging (Tag/Untag/ListTagsForResource) (#319) EKS tag operations at /tags/{arn} fell through to the S3 catch-all. Match the /tags/ prefix and route the three operations to new provider methods (resolving the cluster from the ARN) via an AWS-local clusterTagger assertion. --- providers/aws/eks/tags.go | 79 +++++++++++++++++++++++++++++++++++++++ server/aws/eks/handler.go | 11 +++++- server/aws/eks/tags.go | 59 +++++++++++++++++++++++++++++ 3 files changed, 148 insertions(+), 1 deletion(-) create mode 100644 providers/aws/eks/tags.go create mode 100644 server/aws/eks/tags.go diff --git a/providers/aws/eks/tags.go b/providers/aws/eks/tags.go new file mode 100644 index 00000000..586313ea --- /dev/null +++ b/providers/aws/eks/tags.go @@ -0,0 +1,79 @@ +package eks + +import ( + "context" + "strings" + + cerrors "github.com/stackshy/cloudemu/v2/errors" +) + +// clusterNameFromARN resolves an EKS cluster ARN +// ("arn:aws:eks:::cluster/") to the bare cluster name. +// A non-ARN value is returned unchanged. +func clusterNameFromARN(arn string) string { + const marker = ":cluster/" + + if i := strings.LastIndex(arn, marker); i >= 0 { + return arn[i+len(marker):] + } + + return arn +} + +// TagResource adds or overwrites tags on a cluster identified by ARN (EKS +// TagResource). +func (m *Mock) TagResource(_ context.Context, arn string, tags map[string]string) error { + name := clusterNameFromARN(arn) + + c, ok := m.clusters.Get(name) + if !ok { + return cerrors.Newf(cerrors.NotFound, "cluster %q not found", name) + } + + if c.Tags == nil { + c.Tags = make(map[string]string, len(tags)) + } + + for k, v := range tags { + c.Tags[k] = v + } + + m.clusters.Set(name, c) + + return nil +} + +// UntagResource removes tags by key from a cluster identified by ARN. +func (m *Mock) UntagResource(_ context.Context, arn string, keys []string) error { + name := clusterNameFromARN(arn) + + c, ok := m.clusters.Get(name) + if !ok { + return cerrors.Newf(cerrors.NotFound, "cluster %q not found", name) + } + + for _, k := range keys { + delete(c.Tags, k) + } + + m.clusters.Set(name, c) + + return nil +} + +// ListResourceTags returns the tags on a cluster identified by ARN. +func (m *Mock) ListResourceTags(_ context.Context, arn string) (map[string]string, error) { + name := clusterNameFromARN(arn) + + c, ok := m.clusters.Get(name) + if !ok { + return nil, cerrors.Newf(cerrors.NotFound, "cluster %q not found", name) + } + + out := make(map[string]string, len(c.Tags)) + for k, v := range c.Tags { + out[k] = v + } + + return out, nil +} diff --git a/server/aws/eks/handler.go b/server/aws/eks/handler.go index a9189105..a7f6d388 100644 --- a/server/aws/eks/handler.go +++ b/server/aws/eks/handler.go @@ -31,6 +31,9 @@ const ( pathPrefix = "/clusters" + // tagsPrefix is the EKS tagging API root: /tags/{resourceArn}. + tagsPrefix = "/tags/" + // segNodeGroups, segFargateProfiles, segAddons are the EKS sub-resource // path segments. Real SDK kebab-cases them (note "node-groups" with a // hyphen; the JSON body field is camelCase "nodegroupName"). @@ -71,11 +74,17 @@ func (*Handler) Matches(r *http.Request) bool { return true } - return strings.HasPrefix(r.URL.Path, pathPrefix+"/") + return strings.HasPrefix(r.URL.Path, pathPrefix+"/") || + strings.HasPrefix(r.URL.Path, tagsPrefix) } // ServeHTTP routes EKS requests by URL shape. func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, tagsPrefix) { + h.serveTags(w, r, strings.TrimPrefix(r.URL.Path, tagsPrefix)) + return + } + parts := splitPath(r.URL.Path) switch len(parts) { diff --git a/server/aws/eks/tags.go b/server/aws/eks/tags.go new file mode 100644 index 00000000..fdd2d216 --- /dev/null +++ b/server/aws/eks/tags.go @@ -0,0 +1,59 @@ +package eks + +import ( + "context" + "net/http" +) + +// clusterTagger is the AWS-specific EKS tagging surface, asserted against the +// provider (not part of the portable EKS driver). +type clusterTagger interface { + TagResource(ctx context.Context, arn string, tags map[string]string) error + UntagResource(ctx context.Context, arn string, keys []string) error + ListResourceTags(ctx context.Context, arn string) (map[string]string, error) +} + +// serveTags handles the EKS tagging API at /tags/{resourceArn}: +// POST=TagResource, DELETE=UntagResource (?tagKeys=...), GET=ListTagsForResource. +func (h *Handler) serveTags(w http.ResponseWriter, r *http.Request, arn string) { + tagger, ok := h.eks.(clusterTagger) + if !ok { + writeError(w, http.StatusNotImplemented, "InvalidRequestException", "tagging not supported") + return + } + + switch r.Method { + case http.MethodPost: + var req struct { + Tags map[string]string `json:"tags"` + } + + if !decodeJSON(w, r, &req) { + return + } + + if err := tagger.TagResource(r.Context(), arn, req.Tags); err != nil { + writeErr(w, err) + return + } + + writeJSON(w, struct{}{}) + case http.MethodDelete: + if err := tagger.UntagResource(r.Context(), arn, r.URL.Query()["tagKeys"]); err != nil { + writeErr(w, err) + return + } + + writeJSON(w, struct{}{}) + case http.MethodGet: + tags, err := tagger.ListResourceTags(r.Context(), arn) + if err != nil { + writeErr(w, err) + return + } + + writeJSON(w, map[string]any{"tags": tags}) + default: + writeError(w, http.StatusMethodNotAllowed, "InvalidRequestException", "method not allowed") + } +} From 697e70983b21fe3c36d857935f4a5f602ffd521c Mon Sep 17 00:00:00 2001 From: Gajendra Malviya Date: Tue, 4 Aug 2026 18:17:45 +0530 Subject: [PATCH 42/45] feat(route53): support ChangeTagsForResource and ListTagsForResource (#319) Route 53 tagging at /2013-04-01/tags/{type}/{id} fell through to the S3 catch-all. Match that prefix and route the two operations to a generic ID-keyed tag store on the provider via an AWS-local resourceTagger assertion. --- providers/aws/route53/route53.go | 39 +++++++++++++ server/aws/route53/handler.go | 12 +++- server/aws/route53/tags.go | 94 ++++++++++++++++++++++++++++++++ 3 files changed, 144 insertions(+), 1 deletion(-) create mode 100644 server/aws/route53/tags.go diff --git a/providers/aws/route53/route53.go b/providers/aws/route53/route53.go index 861e5780..84b4c703 100644 --- a/providers/aws/route53/route53.go +++ b/providers/aws/route53/route53.go @@ -5,6 +5,7 @@ import ( "context" "maps" "strings" + "sync" "github.com/stackshy/cloudemu/v2/config" "github.com/stackshy/cloudemu/v2/errors" @@ -23,6 +24,9 @@ type Mock struct { records *memstore.Store[driver.RecordInfo] healthChecks *memstore.Store[driver.HealthCheckInfo] opts *config.Options + + tagsMu sync.Mutex + tagsByID map[string]map[string]string // ResourceId -> tags } // New creates a new Route 53 mock with the given configuration options. @@ -32,7 +36,42 @@ func New(opts *config.Options) *Mock { records: memstore.New[driver.RecordInfo](), healthChecks: memstore.New[driver.HealthCheckInfo](), opts: opts, + tagsByID: map[string]map[string]string{}, + } +} + +// ChangeResourceTags applies tag additions and key removals to a Route 53 +// resource (hosted zone or health check) identified by ID. +func (m *Mock) ChangeResourceTags(_ context.Context, resourceID string, add map[string]string, remove []string) error { + m.tagsMu.Lock() + defer m.tagsMu.Unlock() + + if m.tagsByID[resourceID] == nil { + m.tagsByID[resourceID] = map[string]string{} + } + + for k, v := range add { + m.tagsByID[resourceID][k] = v + } + + for _, k := range remove { + delete(m.tagsByID[resourceID], k) + } + + return nil +} + +// ListResourceTags returns the tags on a Route 53 resource by ID. +func (m *Mock) ListResourceTags(_ context.Context, resourceID string) (map[string]string, error) { + m.tagsMu.Lock() + defer m.tagsMu.Unlock() + + out := make(map[string]string, len(m.tagsByID[resourceID])) + for k, v := range m.tagsByID[resourceID] { + out[k] = v } + + return out, nil } // recordKey builds the key used to store a record in the memstore. diff --git a/server/aws/route53/handler.go b/server/aws/route53/handler.go index 80285851..4d51877a 100644 --- a/server/aws/route53/handler.go +++ b/server/aws/route53/handler.go @@ -28,6 +28,9 @@ import ( // pathPrefix roots every Route 53 REST URL. The version segment is fixed. const pathPrefix = "/2013-04-01/hostedzone" +// tagsPrefix roots the Route 53 tagging API: /2013-04-01/tags/{type}/{id}. +const tagsPrefix = "/2013-04-01/tags/" + const rrsetSeg = "rrset" // Handler serves Route 53 REST requests against a dns driver. @@ -44,11 +47,18 @@ func New(d dnsdriver.DNS) *Handler { // path space, disjoint from every other AWS handler. Registered before the S3 // REST fallback so those paths aren't swallowed by the catch-all. func (*Handler) Matches(r *http.Request) bool { - return r.URL.Path == pathPrefix || strings.HasPrefix(r.URL.Path, pathPrefix+"/") + return r.URL.Path == pathPrefix || + strings.HasPrefix(r.URL.Path, pathPrefix+"/") || + strings.HasPrefix(r.URL.Path, tagsPrefix) } // ServeHTTP routes on the path tail and method. func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, tagsPrefix) { + h.serveTags(w, r, strings.TrimPrefix(r.URL.Path, tagsPrefix)) + return + } + tail := strings.Trim(strings.TrimPrefix(r.URL.Path, pathPrefix), "/") if tail == "" { h.serveZoneCollection(w, r) diff --git a/server/aws/route53/tags.go b/server/aws/route53/tags.go new file mode 100644 index 00000000..5ad0865a --- /dev/null +++ b/server/aws/route53/tags.go @@ -0,0 +1,94 @@ +package route53 + +import ( + "context" + "encoding/xml" + "net/http" + "strings" + + "github.com/stackshy/cloudemu/v2/server/wire" +) + +// resourceTagger is the AWS-specific Route 53 tagging surface, asserted against +// the provider (not part of the portable DNS driver). +type resourceTagger interface { + ChangeResourceTags(ctx context.Context, resourceID string, add map[string]string, remove []string) error + ListResourceTags(ctx context.Context, resourceID string) (map[string]string, error) +} + +type r53Tag struct { + Key string `xml:"Key"` + Value string `xml:"Value"` +} + +type changeTagsRequest struct { + XMLName xml.Name `xml:"ChangeTagsForResourceRequest"` + AddTags []r53Tag `xml:"AddTags>Tag"` + RemoveTagKeys []string `xml:"RemoveTagKeys>Key"` +} + +type resourceTagSetXML struct { + ResourceType string `xml:"ResourceType"` + ResourceID string `xml:"ResourceId"` + Tags []r53Tag `xml:"Tags>Tag"` +} + +type listTagsForResourceResponse struct { + XMLName xml.Name `xml:"ListTagsForResourceResponse"` + ResourceTagSet resourceTagSetXML `xml:"ResourceTagSet"` +} + +type changeTagsForResourceResponse struct { + XMLName xml.Name `xml:"ChangeTagsForResourceResponse"` +} + +// serveTags handles /2013-04-01/tags/{ResourceType}/{ResourceId}: +// POST=ChangeTagsForResource, GET=ListTagsForResource. +func (h *Handler) serveTags(w http.ResponseWriter, r *http.Request, tail string) { + tagger, ok := h.dns.(resourceTagger) + if !ok { + writeError(w, http.StatusNotImplemented, "InvalidInput", "tagging not supported") + return + } + + resourceType, resourceID, _ := strings.Cut(tail, "/") + if resourceID == "" { + writeError(w, http.StatusBadRequest, "InvalidInput", "resource id is required") + return + } + + switch r.Method { + case http.MethodPost: + var req changeTagsRequest + if !decodeXML(w, r, &req) { + return + } + + add := make(map[string]string, len(req.AddTags)) + for _, t := range req.AddTags { + add[t.Key] = t.Value + } + + if err := tagger.ChangeResourceTags(r.Context(), resourceID, add, req.RemoveTagKeys); err != nil { + writeErr(w, err) + return + } + + wire.WriteXML(w, http.StatusOK, changeTagsForResourceResponse{}) + case http.MethodGet: + tags, err := tagger.ListResourceTags(r.Context(), resourceID) + if err != nil { + writeErr(w, err) + return + } + + set := resourceTagSetXML{ResourceType: resourceType, ResourceID: resourceID} + for k, v := range tags { + set.Tags = append(set.Tags, r53Tag{Key: k, Value: v}) + } + + wire.WriteXML(w, http.StatusOK, listTagsForResourceResponse{ResourceTagSet: set}) + default: + writeMethodNotAllowed(w) + } +} From 842135406bd7a7e58397c2546e3ebc53a19d41c0 Mon Sep 17 00:00:00 2001 From: Gajendra Malviya Date: Tue, 4 Aug 2026 18:20:29 +0530 Subject: [PATCH 43/45] fix(eventbridge): emit well-formed rule ARNs with region and account (#319) PutRule/DescribeRule/ListRules returned 'arn:aws:events:::rule//' with empty region and account. Thread accountID/region into the handler so rule ARNs are complete (arn:aws:events:::rule/...). --- server/aws/aws.go | 2 +- server/aws/eventbridge/handler.go | 11 +++++++---- server/aws/eventbridge/operations.go | 6 +++--- server/aws/eventbridge/types.go | 4 ++-- 4 files changed, 13 insertions(+), 10 deletions(-) diff --git a/server/aws/aws.go b/server/aws/aws.go index b26df2d5..99aded8d 100644 --- a/server/aws/aws.go +++ b/server/aws/aws.go @@ -278,7 +278,7 @@ func New(d Drivers) *server.Server { // EventBridge matches the X-Amz-Target prefix "AWSEvents." — disjoint from // DynamoDB, SQS, ECR, SageMaker, Secrets Manager, and the tagging API. if d.EventBridge != nil { - srv.Register(eventbridge.New(d.EventBridge)) + srv.Register(eventbridge.New(d.EventBridge, d.AccountID, d.Region)) } // CloudWatch Logs matches the X-Amz-Target prefix "Logs_20140328." — diff --git a/server/aws/eventbridge/handler.go b/server/aws/eventbridge/handler.go index 34d6546f..1f500f60 100644 --- a/server/aws/eventbridge/handler.go +++ b/server/aws/eventbridge/handler.go @@ -22,12 +22,15 @@ const targetPrefix = "AWSEvents." // Handler serves EventBridge JSON-RPC requests against an EventBus driver. type Handler struct { - bus ebdriver.EventBus + bus ebdriver.EventBus + accountID string + region string } -// New returns an EventBridge handler backed by b. -func New(b ebdriver.EventBus) *Handler { - return &Handler{bus: b} +// New returns an EventBridge handler backed by b. accountID and region are used +// to synthesize well-formed rule ARNs. +func New(b ebdriver.EventBus, accountID, region string) *Handler { + return &Handler{bus: b, accountID: accountID, region: region} } // Matches returns true for EventBridge-shaped requests, identified by an diff --git a/server/aws/eventbridge/operations.go b/server/aws/eventbridge/operations.go index b31300dd..32b8e70e 100644 --- a/server/aws/eventbridge/operations.go +++ b/server/aws/eventbridge/operations.go @@ -103,7 +103,7 @@ func (h *Handler) putRule(w http.ResponseWriter, r *http.Request) { return } - wire.WriteJSON(w, putRuleResponse{RuleArn: ruleARN(rule.EventBus, rule.Name)}) + wire.WriteJSON(w, putRuleResponse{RuleArn: h.ruleARN(rule.EventBus, rule.Name)}) } func (h *Handler) describeRule(w http.ResponseWriter, r *http.Request) { @@ -119,7 +119,7 @@ func (h *Handler) describeRule(w http.ResponseWriter, r *http.Request) { } wire.WriteJSON(w, describeRuleResponse{ - Arn: ruleARN(rule.EventBus, rule.Name), + Arn: h.ruleARN(rule.EventBus, rule.Name), Name: rule.Name, EventBusName: rule.EventBus, Description: rule.Description, @@ -143,7 +143,7 @@ func (h *Handler) listRules(w http.ResponseWriter, r *http.Request) { entries := make([]ruleEntry, 0, len(rules)) for i := range rules { entries = append(entries, ruleEntry{ - Arn: ruleARN(rules[i].EventBus, rules[i].Name), + Arn: h.ruleARN(rules[i].EventBus, rules[i].Name), Name: rules[i].Name, EventBusName: rules[i].EventBus, Description: rules[i].Description, diff --git a/server/aws/eventbridge/types.go b/server/aws/eventbridge/types.go index a7563dcc..b6661f2b 100644 --- a/server/aws/eventbridge/types.go +++ b/server/aws/eventbridge/types.go @@ -190,12 +190,12 @@ func epochSeconds(iso string) float64 { // EventBridge rule ARNs are "arn:aws:events:::rule//"; // region/account aren't threaded into this handler, so they're left as // placeholders that keep the ARN shape recognizable. -func ruleARN(bus, rule string) string { +func (h *Handler) ruleARN(bus, rule string) string { if bus == "" { bus = defaultBusName } - return "arn:aws:events:::rule/" + bus + "/" + rule + return "arn:aws:events:" + h.region + ":" + h.accountID + ":rule/" + bus + "/" + rule } func toTargetJSON(t *ebdriver.Target) targetJSON { From 192b938a6eca10b90b8bf779474bbf784a9f2fab Mon Sep 17 00:00:00 2001 From: Gajendra Malviya Date: Tue, 4 Aug 2026 18:22:12 +0530 Subject: [PATCH 44/45] fix(eks): default cluster Kubernetes version instead of null (#319) CreateCluster left Version empty when the caller omitted it, so Create/DescribeCluster returned a null version. Default to the latest supported version (1.29), matching real EKS. --- providers/aws/eks/eks.go | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/providers/aws/eks/eks.go b/providers/aws/eks/eks.go index 96c473fc..7200eea8 100644 --- a/providers/aws/eks/eks.go +++ b/providers/aws/eks/eks.go @@ -30,9 +30,10 @@ import ( // Wave 1 placeholder for the cluster API server endpoint. Wave 2 will swap // in a real per-cluster apiserver address. const ( - wavePlaceholderEndpoint = "https://EKS-DATAPLANE-NOT-IMPLEMENTED.cloudemu.local" - defaultPlatformVersion = "eks.1" - namespaceEKS = "AWS/EKS" + wavePlaceholderEndpoint = "https://EKS-DATAPLANE-NOT-IMPLEMENTED.cloudemu.local" + defaultPlatformVersion = "eks.1" + defaultKubernetesVersion = "1.29" + namespaceEKS = "AWS/EKS" ) // CloudWatch-style metric values emitted on cluster create. The numbers are @@ -243,10 +244,17 @@ func (m *Mock) CreateCluster(_ context.Context, cfg eksdriver.ClusterConfig) (*e return nil, cerrors.Newf(cerrors.AlreadyExists, "cluster %q already exists", cfg.Name) } + version := cfg.Version + if version == "" { + // Real EKS defaults to the latest supported Kubernetes version when the + // caller omits it, rather than returning a null version. + version = defaultKubernetesVersion + } + cluster := eksdriver.Cluster{ Name: cfg.Name, ARN: m.clusterARN(cfg.Name), - Version: cfg.Version, + Version: version, PlatformVersion: defaultPlatformVersion, RoleArn: cfg.RoleArn, Endpoint: wavePlaceholderEndpoint, From ead94bbcc641b4f954c3b178bfefe2a843d4e2a4 Mon Sep 17 00:00:00 2001 From: Gajendra Malviya Date: Tue, 4 Aug 2026 21:26:41 +0530 Subject: [PATCH 45/45] =?UTF-8?q?fix(aws):=20address=20#320=20review=20?= =?UTF-8?q?=E2=80=94=20event/notification=20fidelity=20+=20parity=20gaps?= =?UTF-8?q?=20(#319)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - eventbridge: fold PutEvents index into generateEventID so byte-identical events in one call get unique ids under FakeClock - s3: notify on CompleteMultipartUpload/CopyObject and emit ObjectRemoved:Delete on DeleteObject; make the ObjectRemoved test actually delete and assert - lambda parity: mirror the no-handler 200+echo invoke stub to Azure Functions and GCP Cloud Functions - rds: scope-gate AddTags/RemoveTags/ListTags Matches to the rds SigV4 scope - sns: carry MessageAttributes through Publish -> SQS envelope - ecr: implement Set/Get/DeleteRepositoryPolicy - ec2: lint hygiene (stateRunning const, receiver/wsl cleanups) and a CreateNetworkInterface unknown-SubnetId negative test --- providers/aws/ecr/ecr.go | 1 + providers/aws/ecr/repopolicy.go | 60 ++++++++++++ providers/aws/eventbridge/eventbridge.go | 12 ++- providers/aws/eventbridge/eventbridge_test.go | 13 +++ providers/aws/s3/notification.go | 22 +++-- providers/aws/s3/s3.go | 6 ++ providers/aws/s3/s3_test.go | 23 +++++ providers/aws/sns/delivery_test.go | 14 ++- providers/aws/sns/sns.go | 17 +++- providers/azure/functions/functions.go | 15 ++- providers/azure/functions/functions_test.go | 11 ++- providers/gcp/cloudfunctions/functions.go | 15 ++- .../gcp/cloudfunctions/functions_test.go | 3 +- server/aws/ec2/ec2_phase2_test.go | 18 ++++ server/aws/ec2/instance_status.go | 5 +- server/aws/ec2/metadata.go | 5 +- server/aws/ec2/operations.go | 4 +- server/aws/ec2/xml.go | 5 +- server/aws/ecr/handler.go | 6 ++ server/aws/ecr/repopolicy.go | 96 +++++++++++++++++++ server/aws/ecr/sdk_roundtrip_test.go | 43 +++++++++ server/aws/rds/handler.go | 42 +++++++- server/aws/sns/operations.go | 32 ++++++- 23 files changed, 437 insertions(+), 31 deletions(-) create mode 100644 providers/aws/ecr/repopolicy.go create mode 100644 server/aws/ecr/repopolicy.go diff --git a/providers/aws/ecr/ecr.go b/providers/aws/ecr/ecr.go index 84514e0e..8f76634f 100644 --- a/providers/aws/ecr/ecr.go +++ b/providers/aws/ecr/ecr.go @@ -38,6 +38,7 @@ type repoData struct { images *memstore.Store[*imageData] scans *memstore.Store[*driver.ScanResult] policy *driver.LifecyclePolicy + repoPolicy string // resource (permissions) policy JSON, set via SetRepositoryPolicy scanOnPush bool tagMutability string } diff --git a/providers/aws/ecr/repopolicy.go b/providers/aws/ecr/repopolicy.go new file mode 100644 index 00000000..62b6559f --- /dev/null +++ b/providers/aws/ecr/repopolicy.go @@ -0,0 +1,60 @@ +package ecr + +import ( + "context" + + "github.com/stackshy/cloudemu/v2/errors" +) + +// SetRepositoryPolicy stores a repository's resource (permissions) policy. +func (m *Mock) SetRepositoryPolicy(_ context.Context, repository, policyText string) (string, error) { + m.mu.Lock() + defer m.mu.Unlock() + + rd, ok := m.repos.Get(repository) + if !ok { + return "", errors.Newf(errors.NotFound, "repository %q not found", repository) + } + + rd.repoPolicy = policyText + + return rd.repoPolicy, nil +} + +// GetRepositoryPolicy returns a repository's resource policy, or NotFound if +// none is set. +func (m *Mock) GetRepositoryPolicy(_ context.Context, repository string) (string, error) { + m.mu.Lock() + defer m.mu.Unlock() + + rd, ok := m.repos.Get(repository) + if !ok { + return "", errors.Newf(errors.NotFound, "repository %q not found", repository) + } + + if rd.repoPolicy == "" { + return "", errors.Newf(errors.NotFound, "no repository policy for %q", repository) + } + + return rd.repoPolicy, nil +} + +// DeleteRepositoryPolicy removes a repository's resource policy. +func (m *Mock) DeleteRepositoryPolicy(_ context.Context, repository string) (string, error) { + m.mu.Lock() + defer m.mu.Unlock() + + rd, ok := m.repos.Get(repository) + if !ok { + return "", errors.Newf(errors.NotFound, "repository %q not found", repository) + } + + if rd.repoPolicy == "" { + return "", errors.Newf(errors.NotFound, "no repository policy for %q", repository) + } + + policy := rd.repoPolicy + rd.repoPolicy = "" + + return policy, nil +} diff --git a/providers/aws/eventbridge/eventbridge.go b/providers/aws/eventbridge/eventbridge.go index 4547002d..f6958921 100644 --- a/providers/aws/eventbridge/eventbridge.go +++ b/providers/aws/eventbridge/eventbridge.go @@ -410,7 +410,7 @@ func (m *Mock) PutEvents(ctx context.Context, events []driver.Event) (*driver.Pu } for i := range events { - eventID := generateEventID(&events[i], m.opts.Clock.Now()) + eventID := generateEventID(&events[i], m.opts.Clock.Now(), i) events[i].ID = eventID if events[i].Time.IsZero() { @@ -529,8 +529,14 @@ func targetsFromStore(store *memstore.Store[driver.Target]) []driver.Target { return targets } -func generateEventID(event *driver.Event, now time.Time) string { - data := fmt.Sprintf("%s:%s:%s:%s:%d", event.Source, event.DetailType, event.Detail, event.EventBus, now.UnixNano()) +// generateEventID hashes the event's identity plus the clock and its position +// within the PutEvents batch. The batch index is included because real +// EventBridge always issues unique IDs, and under a deterministic (fake) clock +// two byte-identical events in one call would otherwise collide — breaking any +// consumer that uses EventId as an idempotency/history key. +func generateEventID(event *driver.Event, now time.Time, index int) string { + data := fmt.Sprintf("%s:%s:%s:%s:%d:%d", + event.Source, event.DetailType, event.Detail, event.EventBus, now.UnixNano(), index) hash := sha256.Sum256([]byte(data)) return fmt.Sprintf("%x", hash[:16]) diff --git a/providers/aws/eventbridge/eventbridge_test.go b/providers/aws/eventbridge/eventbridge_test.go index df3396ac..d67e6001 100644 --- a/providers/aws/eventbridge/eventbridge_test.go +++ b/providers/aws/eventbridge/eventbridge_test.go @@ -559,6 +559,19 @@ func TestPutEvents(t *testing.T) { assert.Equal(t, 1, result.SuccessCount) assert.Equal(t, 1, result.FailCount) }) + + t.Run("byte-identical events in one call get unique ids", func(t *testing.T) { + // Under the deterministic FakeClock the timestamp is identical, so the + // batch index must keep the ids distinct — real EventBridge never + // repeats an EventId, and consumers use it as an idempotency key. + result, err := m.PutEvents(ctx, []driver.Event{ + {Source: "dup.app", DetailType: "Same", Detail: `{"k":"v"}`}, + {Source: "dup.app", DetailType: "Same", Detail: `{"k":"v"}`}, + }) + require.NoError(t, err) + require.Len(t, result.EventIDs, 2) + assert.NotEqual(t, result.EventIDs[0], result.EventIDs[1], "identical events must get distinct EventIds") + }) } func TestEventPatternMatching(t *testing.T) { diff --git a/providers/aws/s3/notification.go b/providers/aws/s3/notification.go index 02959545..ebcc4995 100644 --- a/providers/aws/s3/notification.go +++ b/providers/aws/s3/notification.go @@ -30,17 +30,27 @@ func (m *Mock) GetBucketNotification(_ context.Context, bucket string) ([]QueueN return bkt.notifications, nil } -// notifyObjectCreated delivers an s3:ObjectCreated:Put event to every SQS -// target configured on the bucket whose event filter matches. Best-effort: -// delivery errors are swallowed so a missing/failed queue never fails the -// upload (mirroring S3's asynchronous, decoupled notification behavior). +// notifyObjectCreated delivers an s3:ObjectCreated:Put event (PutObject, copy, +// or completed multipart upload) to matching SQS targets. func (m *Mock) notifyObjectCreated(bkt *bucketMeta, bucket, key string, size int64) { + m.notify(bkt, bucket, key, size, "ObjectCreated:Put") +} + +// notifyObjectRemoved delivers an s3:ObjectRemoved:Delete event to matching SQS +// targets. +func (m *Mock) notifyObjectRemoved(bkt *bucketMeta, bucket, key string) { + m.notify(bkt, bucket, key, 0, "ObjectRemoved:Delete") +} + +// notify delivers an S3 event to every SQS target configured on the bucket +// whose event filter matches. Best-effort: delivery errors are swallowed so a +// missing/failed queue never fails the object operation (mirroring S3's +// asynchronous, decoupled notification behavior). +func (m *Mock) notify(bkt *bucketMeta, bucket, key string, size int64, eventName string) { if m.sqs == nil || len(bkt.notifications) == 0 { return } - const eventName = "ObjectCreated:Put" - body := m.objectEventJSON(bucket, key, size, eventName) for i := range bkt.notifications { diff --git a/providers/aws/s3/s3.go b/providers/aws/s3/s3.go index 38cf1f20..e083833c 100644 --- a/providers/aws/s3/s3.go +++ b/providers/aws/s3/s3.go @@ -338,6 +338,8 @@ func (m *Mock) DeleteObject(_ context.Context, bucket, key string) error { m.emitMetric("AllRequests", 1, "Count", dims) m.emitMetric("DeleteRequests", 1, "Count", dims) + m.notifyObjectRemoved(bkt, bucket, key) + return nil } @@ -495,6 +497,8 @@ func (m *Mock) CopyObject(_ context.Context, dstBucket, dstKey string, src drive m.emitMetric("AllRequests", 1, "Count", dims) m.emitMetric("CopyRequests", 1, "Count", dims) + m.notifyObjectCreated(dstBkt, dstBucket, dstKey, int64(len(dataCopy))) + return nil } @@ -738,6 +742,8 @@ func (m *Mock) CompleteMultipartUpload(_ context.Context, bucket, key, uploadID m.emitMetric("PutRequests", 1, "Count", dims) m.emitMetric("BytesUploaded", float64(len(data)), "Bytes", dims) + m.notifyObjectCreated(bkt, bucket, key, int64(len(data))) + return nil } diff --git a/providers/aws/s3/s3_test.go b/providers/aws/s3/s3_test.go index 9b41e580..b1de1547 100644 --- a/providers/aws/s3/s3_test.go +++ b/providers/aws/s3/s3_test.go @@ -66,6 +66,29 @@ func TestBucketNotificationDelivery(t *testing.T) { !strings.Contains(rec.bodies[0], `"key":"file1.txt"`) { t.Fatalf("event body = %s", rec.bodies[0]) } + + // CopyObject also fires ObjectCreated (regression: multipart/copy used to + // notify nothing). + if err := m.CopyObject(ctx, "nb", "file2.txt", driver.CopySource{Bucket: "nb", Key: "file1.txt"}); err != nil { + t.Fatalf("CopyObject: %v", err) + } + + if len(rec.arns) != 2 || !strings.HasSuffix(rec.arns[1], ":s3events") || + !strings.Contains(rec.bodies[1], `"key":"file2.txt"`) { + t.Fatalf("copy delivery = arns %v, body %q", rec.arns, rec.bodies[len(rec.bodies)-1]) + } + + // DeleteObject fires ObjectRemoved:* to the deletes queue (regression: the + // ObjectRemoved selector previously could never receive anything). + if err := m.DeleteObject(ctx, "nb", "file1.txt"); err != nil { + t.Fatalf("DeleteObject: %v", err) + } + + if len(rec.arns) != 3 || !strings.HasSuffix(rec.arns[2], ":deletes") || + !strings.Contains(rec.bodies[2], `"eventName":"ObjectRemoved:Delete"`) || + !strings.Contains(rec.bodies[2], `"key":"file1.txt"`) { + t.Fatalf("delete delivery = arns %v, body %q", rec.arns, rec.bodies[len(rec.bodies)-1]) + } } func TestCreateBucket(t *testing.T) { diff --git a/providers/aws/sns/delivery_test.go b/providers/aws/sns/delivery_test.go index fb510189..69cb2736 100644 --- a/providers/aws/sns/delivery_test.go +++ b/providers/aws/sns/delivery_test.go @@ -41,6 +41,7 @@ func TestSNSToSQSDelivery(t *testing.T) { if _, err := sns.Publish(ctx, sndriver.PublishInput{ TopicID: topic.Name, Message: "hello", Subject: "hi", + Attributes: map[string]string{"env": "prod"}, }); err != nil { t.Fatalf("Publish: %v", err) } @@ -54,7 +55,7 @@ func TestSNSToSQSDelivery(t *testing.T) { t.Fatalf("expected 1 delivered message, got %d", len(msgs)) } - var envelope map[string]string + var envelope map[string]any if err := json.Unmarshal([]byte(msgs[0].Body), &envelope); err != nil { t.Fatalf("delivered body is not the SNS envelope JSON: %v (%s)", err, msgs[0].Body) } @@ -62,4 +63,15 @@ func TestSNSToSQSDelivery(t *testing.T) { if envelope["Type"] != "Notification" || envelope["Message"] != "hello" || envelope["TopicArn"] != topic.ResourceID { t.Fatalf("unexpected envelope: %+v", envelope) } + + // MessageAttributes must survive the SNS -> SQS hop (#320 review). + attrs, ok := envelope["MessageAttributes"].(map[string]any) + if !ok { + t.Fatalf("envelope missing MessageAttributes: %+v", envelope) + } + + env, ok := attrs["env"].(map[string]any) + if !ok || env["Type"] != "String" || env["Value"] != "prod" { + t.Fatalf("unexpected MessageAttributes: %+v", attrs) + } } diff --git a/providers/aws/sns/sns.go b/providers/aws/sns/sns.go index ddcd7dbf..16c11b69 100644 --- a/providers/aws/sns/sns.go +++ b/providers/aws/sns/sns.go @@ -297,14 +297,27 @@ func (m *Mock) fanOutToSQS(ctx context.Context, td *topicData, msgID string, inp continue } - envelope, err := json.Marshal(map[string]string{ + env := map[string]any{ "Type": "Notification", "MessageId": msgID, "TopicArn": td.info.ResourceID, "Subject": input.Subject, "Message": input.Message, "Timestamp": m.opts.Clock.Now().UTC().Format(time.RFC3339), - }) + } + + // Real SNS carries publish MessageAttributes into the SQS envelope as + // {name: {"Type": "String", "Value": v}}; preserve them end-to-end. + if len(input.Attributes) > 0 { + attrs := make(map[string]any, len(input.Attributes)) + for k, v := range input.Attributes { + attrs[k] = map[string]string{"Type": "String", "Value": v} + } + + env["MessageAttributes"] = attrs + } + + envelope, err := json.Marshal(env) if err != nil { continue } diff --git a/providers/azure/functions/functions.go b/providers/azure/functions/functions.go index 95b87e34..aac8c5d4 100644 --- a/providers/azure/functions/functions.go +++ b/providers/azure/functions/functions.go @@ -208,7 +208,20 @@ func (m *Mock) Invoke(ctx context.Context, input driver.InvokeInput) (*driver.In } if h == nil { - return &driver.InvokeOutput{StatusCode: 500, Error: "no handler registered"}, nil + // The emulator can't execute uploaded function code, so with no Go + // handler registered we return a successful stub echoing the request + // payload rather than a FunctionError — mirroring the AWS Lambda + // provider so identical cross-provider tests behave the same. + m.emitMetric(input.FunctionName, map[string]float64{ + "FunctionExecutionCount": 1, "FunctionExecutionUnits": 1, + }) + + payload := input.Payload + if len(payload) == 0 { + payload = []byte("{}") + } + + return &driver.InvokeOutput{StatusCode: 200, Payload: payload}, nil } payload, err := h(ctx, input.Payload) diff --git a/providers/azure/functions/functions_test.go b/providers/azure/functions/functions_test.go index fd1147c7..e6f362ce 100644 --- a/providers/azure/functions/functions_test.go +++ b/providers/azure/functions/functions_test.go @@ -175,11 +175,14 @@ func TestInvokeFunction(t *testing.T) { _, err := m.CreateFunction(ctx, driver.FunctionConfig{Name: "fn1", Runtime: "go1.x"}) require.NoError(t, err) - t.Run("no handler registered", func(t *testing.T) { - out, err := m.Invoke(ctx, driver.InvokeInput{FunctionName: "fn1", Payload: []byte("test")}) + t.Run("no handler echoes a success stub", func(t *testing.T) { + // Mirrors AWS Lambda (#319 review): no Go handler → 200 + echoed + // payload, not a FunctionError, so cross-provider tests match. + out, err := m.Invoke(ctx, driver.InvokeInput{FunctionName: "fn1", Payload: []byte(`{"k":1}`)}) require.NoError(t, err) - assert.Equal(t, 500, out.StatusCode) - assert.Equal(t, "no handler registered", out.Error) + assert.Equal(t, 200, out.StatusCode) + assert.Equal(t, "", out.Error) + assert.Equal(t, `{"k":1}`, string(out.Payload)) }) t.Run("with handler success", func(t *testing.T) { diff --git a/providers/gcp/cloudfunctions/functions.go b/providers/gcp/cloudfunctions/functions.go index bac14a41..24759981 100644 --- a/providers/gcp/cloudfunctions/functions.go +++ b/providers/gcp/cloudfunctions/functions.go @@ -207,7 +207,20 @@ func (m *Mock) Invoke(ctx context.Context, input driver.InvokeInput) (*driver.In } if h == nil { - return &driver.InvokeOutput{StatusCode: 500, Error: "no handler registered"}, nil + // The emulator can't execute uploaded function code, so with no Go + // handler registered we return a successful stub echoing the request + // payload rather than a FunctionError — mirroring the AWS Lambda + // provider so identical cross-provider tests behave the same. + noHandlerDims := map[string]string{"function_name": input.FunctionName} + m.emitMetric(ctx, "function/execution_count", 1, noHandlerDims) + m.emitMetric(ctx, "function/execution_times", 1, noHandlerDims) + + payload := input.Payload + if len(payload) == 0 { + payload = []byte("{}") + } + + return &driver.InvokeOutput{StatusCode: 200, Payload: payload}, nil } dims := map[string]string{"function_name": input.FunctionName} diff --git a/providers/gcp/cloudfunctions/functions_test.go b/providers/gcp/cloudfunctions/functions_test.go index 7e642890..8680b0cf 100644 --- a/providers/gcp/cloudfunctions/functions_test.go +++ b/providers/gcp/cloudfunctions/functions_test.go @@ -160,7 +160,8 @@ func TestInvokeFunction(t *testing.T) { wantErr bool errSubstr string }{ - {name: "no handler", funcName: "echo", wantStatus: 500}, + // No Go handler → 200 stub echo (mirrors AWS Lambda, #319 review). + {name: "no handler", funcName: "echo", wantStatus: 200}, {name: "with handler", funcName: "echo", handler: func(_ context.Context, p []byte) ([]byte, error) { return append([]byte("echo:"), p...), nil }, payload: []byte("hi"), wantStatus: 200}, diff --git a/server/aws/ec2/ec2_phase2_test.go b/server/aws/ec2/ec2_phase2_test.go index 0813ebec..1b205300 100644 --- a/server/aws/ec2/ec2_phase2_test.go +++ b/server/aws/ec2/ec2_phase2_test.go @@ -580,6 +580,24 @@ func TestCreateNetworkInterfaceAndInstanceStatus(t *testing.T) { } } +// TestCreateNetworkInterfaceUnknownSubnet guards the resolve-from-subnet path: +// an ENI create against a subnet that does not exist must fail (NotFound), not +// silently create an interface with a dangling subnet reference. +func TestCreateNetworkInterfaceUnknownSubnet(t *testing.T) { + h := newFullHandler() + + resp := do(t, h, http.MethodPost, "/", url.Values{ + "Action": {"CreateNetworkInterface"}, "SubnetId": {"subnet-does-not-exist"}, + }) + if resp.Code == http.StatusOK { + t.Fatalf("want error for unknown subnet, got 200: %s", resp.Body.String()) + } + + if !strings.Contains(resp.Body.String(), "InvalidSubnetID.NotFound") { + t.Fatalf("want InvalidSubnetID.NotFound, got code=%d body=%s", resp.Code, resp.Body.String()) + } +} + func between(s, open, close string) string { i := strings.Index(s, open) if i < 0 { diff --git a/server/aws/ec2/instance_status.go b/server/aws/ec2/instance_status.go index 95c744eb..073fb94e 100644 --- a/server/aws/ec2/instance_status.go +++ b/server/aws/ec2/instance_status.go @@ -89,9 +89,10 @@ func (h *Handler) describeInstanceStatus(w http.ResponseWriter, r *http.Request) } out := make([]instanceStatusItemXML, 0, len(instances)) + for i := range instances { inst := &instances[i] - if !includeAll && inst.State != "running" { + if !includeAll && inst.State != stateRunning { continue } @@ -107,7 +108,7 @@ func statusItem(inst *computedriver.Instance) instanceStatusItemXML { // Checks are "ok" only once the instance is running; otherwise // "not-applicable", matching real EC2's status-check semantics. check := "not-applicable" - if inst.State == "running" { + if inst.State == stateRunning { check = "ok" } diff --git a/server/aws/ec2/metadata.go b/server/aws/ec2/metadata.go index 09f3604c..3c53e948 100644 --- a/server/aws/ec2/metadata.go +++ b/server/aws/ec2/metadata.go @@ -44,7 +44,7 @@ func (h *Handler) routeMetadata(w http.ResponseWriter, r *http.Request, action s // describeRegions answers ec2:DescribeRegions. If explicit RegionName.N filters // are supplied, only those are returned; otherwise the common set is reported. -func (h *Handler) describeRegions(w http.ResponseWriter, r *http.Request) { +func (*Handler) describeRegions(w http.ResponseWriter, r *http.Request) { requested := awsquery.ListStrings(r.Form, "RegionName") names := commonRegions @@ -111,7 +111,7 @@ type describeInstanceTypesResponseXML struct { // describeInstanceTypes answers ec2:DescribeInstanceTypes. Explicit // InstanceType.N values are echoed with their (or a default) spec; with none // supplied, the known set is reported. -func (h *Handler) describeInstanceTypes(w http.ResponseWriter, r *http.Request) { +func (*Handler) describeInstanceTypes(w http.ResponseWriter, r *http.Request) { requested := awsquery.ListStrings(r.Form, "InstanceType") names := requested @@ -122,6 +122,7 @@ func (h *Handler) describeInstanceTypes(w http.ResponseWriter, r *http.Request) } out := make([]instanceTypeInfoXML, 0, len(names)) + for _, name := range names { spec, ok := knownInstanceTypes[name] if !ok { diff --git a/server/aws/ec2/operations.go b/server/aws/ec2/operations.go index c8152819..dc0b6ae0 100644 --- a/server/aws/ec2/operations.go +++ b/server/aws/ec2/operations.go @@ -116,7 +116,7 @@ func (h *Handler) stopInstances(w http.ResponseWriter, r *http.Request) { RequestID: awsquery.RequestID, Changes: stateChanges(ids, instanceState{Code: stateCodeStopping, Name: "stopping"}, - instanceState{Code: stateCodeRunning, Name: "running"}), + instanceState{Code: stateCodeRunning, Name: stateRunning}), }) } @@ -150,7 +150,7 @@ func (h *Handler) terminateInstances(w http.ResponseWriter, r *http.Request) { RequestID: awsquery.RequestID, Changes: stateChanges(ids, instanceState{Code: stateCodeShuttingDown, Name: "shutting-down"}, - instanceState{Code: stateCodeRunning, Name: "running"}), + instanceState{Code: stateCodeRunning, Name: stateRunning}), }) } diff --git a/server/aws/ec2/xml.go b/server/aws/ec2/xml.go index 900875b6..76e9c3c7 100644 --- a/server/aws/ec2/xml.go +++ b/server/aws/ec2/xml.go @@ -15,6 +15,9 @@ const ( // Canonical "owner" returned in responses. SDK clients don't validate it; // any 12-digit account id works. ownerID = "123456789012" + + // stateRunning is the driver's string name for a running instance. + stateRunning = "running" ) // stateCode maps the driver's string state to AWS's numeric code. @@ -22,7 +25,7 @@ func stateCode(name string) int { switch name { case "pending": return stateCodePending - case "running": + case stateRunning: return stateCodeRunning case "shutting-down": return stateCodeShuttingDown diff --git a/server/aws/ecr/handler.go b/server/aws/ecr/handler.go index de14896d..62923b1a 100644 --- a/server/aws/ecr/handler.go +++ b/server/aws/ecr/handler.go @@ -69,6 +69,12 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.untagResource(w, r) case "ListTagsForResource": h.listTagsForResource(w, r) + case "SetRepositoryPolicy": + h.setRepositoryPolicy(w, r) + case "GetRepositoryPolicy": + h.getRepositoryPolicy(w, r) + case "DeleteRepositoryPolicy": + h.deleteRepositoryPolicy(w, r) default: op := strings.TrimPrefix(r.Header.Get("X-Amz-Target"), targetPrefix) wire.WriteJSONError(w, http.StatusBadRequest, diff --git a/server/aws/ecr/repopolicy.go b/server/aws/ecr/repopolicy.go new file mode 100644 index 00000000..3eee304c --- /dev/null +++ b/server/aws/ecr/repopolicy.go @@ -0,0 +1,96 @@ +package ecr + +import ( + "context" + "net/http" + + cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/server/wire" +) + +// repoPolicyManager is the AWS-specific ECR repository-policy surface, asserted +// against the provider (not part of the portable ContainerRegistry driver). +type repoPolicyManager interface { + SetRepositoryPolicy(ctx context.Context, repository, policyText string) (string, error) + GetRepositoryPolicy(ctx context.Context, repository string) (string, error) + DeleteRepositoryPolicy(ctx context.Context, repository string) (string, error) +} + +func (h *Handler) repoPolicyMgr() (repoPolicyManager, bool) { + m, ok := h.registry.(repoPolicyManager) + + return m, ok +} + +func (h *Handler) setRepositoryPolicy(w http.ResponseWriter, r *http.Request) { + mgr, ok := h.repoPolicyMgr() + if !ok { + writeErr(w, cerrors.New(cerrors.Unimplemented, "repository policies not supported")) + return + } + + var req struct { + RepositoryName string `json:"repositoryName"` + PolicyText string `json:"policyText"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + policy, err := mgr.SetRepositoryPolicy(r.Context(), req.RepositoryName, req.PolicyText) + if err != nil { + writeErr(w, err) + return + } + + wire.WriteJSON(w, map[string]any{"repositoryName": req.RepositoryName, "policyText": policy}) +} + +func (h *Handler) getRepositoryPolicy(w http.ResponseWriter, r *http.Request) { + mgr, ok := h.repoPolicyMgr() + if !ok { + writeErr(w, cerrors.New(cerrors.Unimplemented, "repository policies not supported")) + return + } + + var req struct { + RepositoryName string `json:"repositoryName"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + policy, err := mgr.GetRepositoryPolicy(r.Context(), req.RepositoryName) + if err != nil { + writeErr(w, err) + return + } + + wire.WriteJSON(w, map[string]any{"repositoryName": req.RepositoryName, "policyText": policy}) +} + +func (h *Handler) deleteRepositoryPolicy(w http.ResponseWriter, r *http.Request) { + mgr, ok := h.repoPolicyMgr() + if !ok { + writeErr(w, cerrors.New(cerrors.Unimplemented, "repository policies not supported")) + return + } + + var req struct { + RepositoryName string `json:"repositoryName"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + policy, err := mgr.DeleteRepositoryPolicy(r.Context(), req.RepositoryName) + if err != nil { + writeErr(w, err) + return + } + + wire.WriteJSON(w, map[string]any{"repositoryName": req.RepositoryName, "policyText": policy}) +} diff --git a/server/aws/ecr/sdk_roundtrip_test.go b/server/aws/ecr/sdk_roundtrip_test.go index 8816a808..cf96fc40 100644 --- a/server/aws/ecr/sdk_roundtrip_test.go +++ b/server/aws/ecr/sdk_roundtrip_test.go @@ -43,6 +43,49 @@ func newECRClient(t *testing.T) *awsecr.Client { }) } +// TestSDKECRRepositoryPolicy is a regression guard for the #320 review +// follow-up: Set/Get/DeleteRepositoryPolicy round-trip a resource policy. +func TestSDKECRRepositoryPolicy(t *testing.T) { + client := newECRClient(t) + ctx := context.Background() + + if _, err := client.CreateRepository(ctx, &awsecr.CreateRepositoryInput{ + RepositoryName: aws.String("policy-repo"), + }); err != nil { + t.Fatalf("CreateRepository: %v", err) + } + + const policy = `{"Version":"2008-10-17","Statement":[{"Sid":"a","Effect":"Allow","Principal":"*","Action":"ecr:GetDownloadUrlForLayer"}]}` + + set, err := client.SetRepositoryPolicy(ctx, &awsecr.SetRepositoryPolicyInput{ + RepositoryName: aws.String("policy-repo"), PolicyText: aws.String(policy), + }) + if err != nil { + t.Fatalf("SetRepositoryPolicy: %v", err) + } + + if aws.ToString(set.PolicyText) != policy { + t.Fatalf("SetRepositoryPolicy echoed %q", aws.ToString(set.PolicyText)) + } + + got, err := client.GetRepositoryPolicy(ctx, &awsecr.GetRepositoryPolicyInput{ + RepositoryName: aws.String("policy-repo"), + }) + if err != nil { + t.Fatalf("GetRepositoryPolicy: %v", err) + } + + if aws.ToString(got.PolicyText) != policy { + t.Fatalf("GetRepositoryPolicy = %q", aws.ToString(got.PolicyText)) + } + + if _, err := client.DeleteRepositoryPolicy(ctx, &awsecr.DeleteRepositoryPolicyInput{ + RepositoryName: aws.String("policy-repo"), + }); err != nil { + t.Fatalf("DeleteRepositoryPolicy: %v", err) + } +} + func TestSDKECRRepositoryLifecycle(t *testing.T) { client := newECRClient(t) ctx := context.Background() diff --git a/server/aws/rds/handler.go b/server/aws/rds/handler.go index 413ad831..a96a954b 100644 --- a/server/aws/rds/handler.go +++ b/server/aws/rds/handler.go @@ -143,9 +143,47 @@ func (*Handler) Matches(r *http.Request) bool { return false } - _, ok := rdsActions[r.Form.Get("Action")] + action := r.Form.Get("Action") + if _, ok := rdsActions[action]; !ok { + return false + } + + // AddTagsToResource/RemoveTagsFromResource/ListTagsForResource are generic + // tag verbs RDS shares with other query-protocol services (e.g. ElastiCache + // on the same wire). RDS registers before them, so claim these only when + // the SigV4 credential scope names "rds"; otherwise let them fall through + // to the owning handler. + if _, ambiguous := rdsAmbiguousTagActions[action]; ambiguous { + return sigV4ScopeService(r.Header.Get("Authorization")) == "rds" + } + + return true +} + +// rdsAmbiguousTagActions are the tag verbs RDS shares with other +// query-protocol services on the same wire. +var rdsAmbiguousTagActions = map[string]struct{}{ //nolint:gochecknoglobals // static lookup table + "AddTagsToResource": {}, + "RemoveTagsFromResource": {}, + "ListTagsForResource": {}, +} + +// sigV4ScopeService extracts the service from a SigV4 Authorization credential +// scope: "Credential=AKID/20260101/us-east-1//aws4_request". +func sigV4ScopeService(auth string) string { + i := strings.Index(auth, "Credential=") + if i < 0 { + return "" + } + + parts := strings.Split(auth[i+len("Credential="):], "/") + + const serviceField = 3 + if len(parts) <= serviceField { + return "" + } - return ok + return parts[serviceField] } // ServeHTTP dispatches on Action. The form has already been parsed by Matches. diff --git a/server/aws/sns/operations.go b/server/aws/sns/operations.go index d59258fb..fb4e9012 100644 --- a/server/aws/sns/operations.go +++ b/server/aws/sns/operations.go @@ -49,6 +49,31 @@ func (h *Handler) untagResource(w http.ResponseWriter, r *http.Request) { }) } +// parseMessageAttributes reads SNS Publish MessageAttributes.entry.N.Name / +// .Value.StringValue form parameters into a flat name->value map. Only string +// values are modeled (the common case); binary values are ignored. +func parseMessageAttributes(form url.Values) map[string]string { + idx := awsquery.CollectIndices(form, "MessageAttributes.entry") + if len(idx) == 0 { + return nil + } + + out := make(map[string]string, len(idx)) + + for _, i := range idx { + base := "MessageAttributes.entry." + strconv.Itoa(i) + + name := form.Get(base + ".Name") + if name == "" { + continue + } + + out[name] = form.Get(base + ".Value.StringValue") + } + + return out +} + // createTopic maps CreateTopic to Notification.CreateTopic. SNS CreateTopic is // idempotent: creating a topic that already exists returns the existing ARN // rather than an error, so we translate the driver's AlreadyExists into a @@ -256,9 +281,10 @@ func (h *Handler) publish(w http.ResponseWriter, r *http.Request) { } out, err := h.notif.Publish(r.Context(), notifdriver.PublishInput{ - TopicID: topicNameFromARN(arn), - Subject: r.Form.Get("Subject"), - Message: r.Form.Get("Message"), + TopicID: topicNameFromARN(arn), + Subject: r.Form.Get("Subject"), + Message: r.Form.Get("Message"), + Attributes: parseMessageAttributes(r.Form), }) if err != nil { writeErr(w, err)