From a3180f3cc94f0ce34ba9fa3688a8d56c4dc9b4d6 Mon Sep 17 00:00:00 2001 From: Nitin Kumar Patel Date: Sun, 12 Jul 2026 10:10:30 +0530 Subject: [PATCH 1/2] feat(ssm): add AWS SSM Parameter Store SDK-compat service (#239) Emulate AWS Systems Manager Parameter Store so the real aws-sdk-go-v2/service/ssm client works against cloudemu. - parameterstore/driver: portable ParameterStore driver interface - parameterstore: portable wrapper (recorder/metrics/ratelimit/inject/latency) - providers/aws/ssm: in-memory memstore-backed provider (versioning, labels, hierarchical paths, history) - server/aws/ssm: AWS JSON 1.1 handler dispatched on X-Amz-Target AmazonSSM.* - wired SSM onto providers/aws.Provider and server/aws Drivers/Register Operations: PutParameter (Overwrite + versioning), GetParameter, GetParameters, GetParametersByPath (Recursive), DeleteParameter, DeleteParameters, DescribeParameters, GetParameterHistory, LabelParameterVersion. Version/label selectors via ':' suffix. SecureString is stored as-is (no KMS); WithDecryption is a no-op. Tests: sdk_roundtrip_test.go drives the real SDK client against httptest, asserting typed responses and a typed ParameterNotFound. --- go.mod | 1 + go.sum | 2 + parameterstore/driver/driver.go | 77 +++++ parameterstore/parameterstore.go | 236 +++++++++++++ providers/aws/aws.go | 3 + providers/aws/ssm/ssm.go | 478 +++++++++++++++++++++++++++ providers/aws/ssm/ssm_test.go | 146 ++++++++ server/aws/aws.go | 12 + server/aws/ssm/handler.go | 85 +++++ server/aws/ssm/operations.go | 187 +++++++++++ server/aws/ssm/sdk_roundtrip_test.go | 275 +++++++++++++++ server/aws/ssm/types.go | 148 +++++++++ 12 files changed, 1650 insertions(+) create mode 100644 parameterstore/driver/driver.go create mode 100644 parameterstore/parameterstore.go create mode 100644 providers/aws/ssm/ssm.go create mode 100644 providers/aws/ssm/ssm_test.go create mode 100644 server/aws/ssm/handler.go create mode 100644 server/aws/ssm/operations.go create mode 100644 server/aws/ssm/sdk_roundtrip_test.go create mode 100644 server/aws/ssm/types.go diff --git a/go.mod b/go.mod index 427d67c..99c73b4 100644 --- a/go.mod +++ b/go.mod @@ -64,6 +64,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.42.5 github.com/aws/aws-sdk-go-v2/service/sns v1.41.0 github.com/aws/aws-sdk-go-v2/service/sqs v1.42.27 + github.com/aws/aws-sdk-go-v2/service/ssm v1.71.0 github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 github.com/aws/smithy-go v1.27.3 github.com/databricks/databricks-sdk-go v0.144.0 diff --git a/go.sum b/go.sum index f943c0e..ece8da1 100644 --- a/go.sum +++ b/go.sum @@ -194,6 +194,8 @@ github.com/aws/aws-sdk-go-v2/service/sns v1.41.0 h1:GT6QdvVfByxl1/AJQe7PNbLtQDj0 github.com/aws/aws-sdk-go-v2/service/sns v1.41.0/go.mod h1:5EnTxMpMVeiY0vcjjN/a958FFaHrS6XfXcyRBzDKDCE= github.com/aws/aws-sdk-go-v2/service/sqs v1.42.27 h1:QgaWXVmNDxv/U/3UIHfGb7ohvtFgerf/bYcYylj4i8E= github.com/aws/aws-sdk-go-v2/service/sqs v1.42.27/go.mod h1:8S6ExnLprS0oIeA8ZlHkJUJ0BMpKqnRPws/S0jegTqQ= +github.com/aws/aws-sdk-go-v2/service/ssm v1.71.0 h1:Z5oWeBPXlKfYMctLgbnD0bXbhuqpxsV/KnZpsLLEMLQ= +github.com/aws/aws-sdk-go-v2/service/ssm v1.71.0/go.mod h1:xabzRvdbMs3FG9kU5M6RUOuCW6wXDkpdIqoXXNzA1nQ= github.com/aws/aws-sdk-go-v2/service/sso v1.30.15 h1:lFd1+ZSEYJZYvv9d6kXzhkZu07si3f+GQ1AaYwa2LUM= github.com/aws/aws-sdk-go-v2/service/sso v1.30.15/go.mod h1:WSvS1NLr7JaPunCXqpJnWk1Bjo7IxzZXrZi1QQCkuqM= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 h1:dzztQ1YmfPrxdrOiuZRMF6fuOwWlWpD2StNLTceKpys= diff --git a/parameterstore/driver/driver.go b/parameterstore/driver/driver.go new file mode 100644 index 0000000..ecdd237 --- /dev/null +++ b/parameterstore/driver/driver.go @@ -0,0 +1,77 @@ +// Package driver defines the interface for SSM Parameter Store service implementations. +package driver + +import "context" + +// Parameter types, matching AWS SSM Parameter Store. +const ( + // TypeString is a plain single-value string parameter. + TypeString = "String" + // TypeStringList is a comma-separated list parameter. + TypeStringList = "StringList" + // TypeSecureString is an (nominally) encrypted parameter. cloudemu stores + // the value as-is: there is no real KMS integration. + TypeSecureString = "SecureString" +) + +// PutConfig describes a PutParameter request. +type PutConfig struct { + Name string + Value string + Type string + Description string + Overwrite bool + Tier string + DataType string +} + +// Parameter is a single version of a stored parameter. +type Parameter struct { + Name string + Type string + Value string + Version int64 + ARN string + DataType string + LastModified string + // Selector records how this value was addressed (e.g. ":3" for a version + // or ":prod" for a label), for the SDK's Selector response field. + Selector string +} + +// ParameterMetadata describes a parameter without its value. +type ParameterMetadata struct { + Name string + Type string + Description string + Version int64 + ARN string + Tier string + DataType string + LastModified string + LastModifiedUser string +} + +// GetByPathInput describes a GetParametersByPath request. +type GetByPathInput struct { + Path string + Recursive bool + WithDecryption bool +} + +// ParameterStore is the interface SSM Parameter Store provider implementations +// must satisfy. +type ParameterStore interface { + PutParameter(ctx context.Context, cfg PutConfig) (version int64, tier string, err error) + GetParameter(ctx context.Context, name string, withDecryption bool) (*Parameter, error) + GetParameters(ctx context.Context, names []string, withDecryption bool) (found []Parameter, invalid []string, err error) + GetParametersByPath(ctx context.Context, in GetByPathInput) ([]Parameter, error) + DeleteParameter(ctx context.Context, name string) error + DeleteParameters(ctx context.Context, names []string) (deleted, invalid []string, err error) + DescribeParameters(ctx context.Context) ([]ParameterMetadata, error) + + // GetParameterHistory returns every version of a parameter, oldest first. + GetParameterHistory(ctx context.Context, name string) ([]Parameter, error) + // LabelParameterVersion attaches labels to a specific version (0 = latest). + LabelParameterVersion(ctx context.Context, name string, version int64, labels []string) (appliedVersion int64, invalid []string, err error) +} diff --git a/parameterstore/parameterstore.go b/parameterstore/parameterstore.go new file mode 100644 index 0000000..1d772b0 --- /dev/null +++ b/parameterstore/parameterstore.go @@ -0,0 +1,236 @@ +// Package parameterstore provides a portable SSM Parameter Store API with +// cross-cutting concerns (recorder, metrics, rate limiting, error injection, +// simulated latency) wrapping a driver. +package parameterstore + +import ( + "context" + "time" + + "github.com/stackshy/cloudemu/inject" + "github.com/stackshy/cloudemu/metrics" + "github.com/stackshy/cloudemu/parameterstore/driver" + "github.com/stackshy/cloudemu/ratelimit" + "github.com/stackshy/cloudemu/recorder" +) + +// ParameterStore is the portable Parameter Store type wrapping a driver with +// cross-cutting concerns. +type ParameterStore struct { + driver driver.ParameterStore + recorder *recorder.Recorder + metrics *metrics.Collector + limiter *ratelimit.Limiter + injector *inject.Injector + latency time.Duration +} + +// New creates a new portable ParameterStore wrapping the given driver. +func New(d driver.ParameterStore, opts ...Option) *ParameterStore { + p := &ParameterStore{driver: d} + for _, opt := range opts { + opt(p) + } + + return p +} + +// Option configures a portable ParameterStore. +type Option func(*ParameterStore) + +// WithRecorder sets the recorder. +func WithRecorder(r *recorder.Recorder) Option { return func(p *ParameterStore) { p.recorder = r } } + +// WithMetrics sets the metrics collector. +func WithMetrics(m *metrics.Collector) Option { return func(p *ParameterStore) { p.metrics = m } } + +// WithRateLimiter sets the rate limiter. +func WithRateLimiter(l *ratelimit.Limiter) Option { return func(p *ParameterStore) { p.limiter = l } } + +// WithErrorInjection sets the error injector. +func WithErrorInjection(i *inject.Injector) Option { return func(p *ParameterStore) { p.injector = i } } + +// WithLatency sets simulated latency. +func WithLatency(d time.Duration) Option { return func(p *ParameterStore) { p.latency = d } } + +func (p *ParameterStore) do(_ context.Context, op string, input any, fn func() (any, error)) (any, error) { + start := time.Now() + + if p.injector != nil { + if err := p.injector.Check("ssm", op); err != nil { + p.rec(op, input, nil, err, time.Since(start)) + return nil, err + } + } + + if p.limiter != nil { + if err := p.limiter.Allow(); err != nil { + p.rec(op, input, nil, err, time.Since(start)) + return nil, err + } + } + + if p.latency > 0 { + time.Sleep(p.latency) + } + + out, err := fn() + dur := time.Since(start) + + if p.metrics != nil { + labels := map[string]string{"service": "ssm", "operation": op} + p.metrics.Counter("calls_total", 1, labels) + p.metrics.Histogram("call_duration", dur, labels) + + if err != nil { + p.metrics.Counter("errors_total", 1, labels) + } + } + + p.rec(op, input, out, err, dur) + + return out, err +} + +func (p *ParameterStore) rec(op string, input, output any, err error, dur time.Duration) { + if p.recorder != nil { + p.recorder.Record("ssm", op, input, output, err, dur) + } +} + +// PutParameter creates or updates a parameter, returning the new version and tier. +func (p *ParameterStore) PutParameter(ctx context.Context, cfg driver.PutConfig) (int64, string, error) { + type result struct { + version int64 + tier string + } + + out, err := p.do(ctx, "PutParameter", cfg, func() (any, error) { + v, tier, err := p.driver.PutParameter(ctx, cfg) + return result{v, tier}, err + }) + if err != nil { + return 0, "", err + } + + r := out.(result) + + return r.version, r.tier, nil +} + +// GetParameter retrieves a parameter by name (optionally with a version or label selector). +func (p *ParameterStore) GetParameter(ctx context.Context, name string, withDecryption bool) (*driver.Parameter, error) { + out, err := p.do(ctx, "GetParameter", name, func() (any, error) { + return p.driver.GetParameter(ctx, name, withDecryption) + }) + if err != nil { + return nil, err + } + + return out.(*driver.Parameter), nil +} + +// GetParameters retrieves multiple parameters by name. +func (p *ParameterStore) GetParameters(ctx context.Context, names []string, withDecryption bool) ([]driver.Parameter, []string, error) { + type result struct { + found []driver.Parameter + invalid []string + } + + out, err := p.do(ctx, "GetParameters", names, func() (any, error) { + found, invalid, err := p.driver.GetParameters(ctx, names, withDecryption) + return result{found, invalid}, err + }) + if err != nil { + return nil, nil, err + } + + r := out.(result) + + return r.found, r.invalid, nil +} + +// GetParametersByPath retrieves parameters under a hierarchical path. +func (p *ParameterStore) GetParametersByPath(ctx context.Context, in driver.GetByPathInput) ([]driver.Parameter, error) { + out, err := p.do(ctx, "GetParametersByPath", in, func() (any, error) { + return p.driver.GetParametersByPath(ctx, in) + }) + if err != nil { + return nil, err + } + + return out.([]driver.Parameter), nil +} + +// DeleteParameter deletes a parameter by name. +func (p *ParameterStore) DeleteParameter(ctx context.Context, name string) error { + _, err := p.do(ctx, "DeleteParameter", name, func() (any, error) { + return nil, p.driver.DeleteParameter(ctx, name) + }) + + return err +} + +// DeleteParameters deletes multiple parameters, returning deleted and invalid names. +func (p *ParameterStore) DeleteParameters(ctx context.Context, names []string) ([]string, []string, error) { + type result struct { + deleted []string + invalid []string + } + + out, err := p.do(ctx, "DeleteParameters", names, func() (any, error) { + deleted, invalid, err := p.driver.DeleteParameters(ctx, names) + return result{deleted, invalid}, err + }) + if err != nil { + return nil, nil, err + } + + r := out.(result) + + return r.deleted, r.invalid, nil +} + +// DescribeParameters lists parameter metadata. +func (p *ParameterStore) DescribeParameters(ctx context.Context) ([]driver.ParameterMetadata, error) { + out, err := p.do(ctx, "DescribeParameters", nil, func() (any, error) { + return p.driver.DescribeParameters(ctx) + }) + if err != nil { + return nil, err + } + + return out.([]driver.ParameterMetadata), nil +} + +// GetParameterHistory returns every version of a parameter, oldest first. +func (p *ParameterStore) GetParameterHistory(ctx context.Context, name string) ([]driver.Parameter, error) { + out, err := p.do(ctx, "GetParameterHistory", name, func() (any, error) { + return p.driver.GetParameterHistory(ctx, name) + }) + if err != nil { + return nil, err + } + + return out.([]driver.Parameter), nil +} + +// LabelParameterVersion attaches labels to a specific version (0 = latest). +func (p *ParameterStore) LabelParameterVersion(ctx context.Context, name string, version int64, labels []string) (int64, []string, error) { + type result struct { + applied int64 + invalid []string + } + + out, err := p.do(ctx, "LabelParameterVersion", map[string]any{"name": name, "version": version}, func() (any, error) { + applied, invalid, err := p.driver.LabelParameterVersion(ctx, name, version, labels) + return result{applied, invalid}, err + }) + if err != nil { + return 0, nil, err + } + + r := out.(result) + + return r.applied, r.invalid, nil +} diff --git a/providers/aws/aws.go b/providers/aws/aws.go index d4153e7..2c45df8 100644 --- a/providers/aws/aws.go +++ b/providers/aws/aws.go @@ -23,6 +23,7 @@ import ( "github.com/stackshy/cloudemu/providers/aws/secretsmanager" "github.com/stackshy/cloudemu/providers/aws/sns" "github.com/stackshy/cloudemu/providers/aws/sqs" + "github.com/stackshy/cloudemu/providers/aws/ssm" "github.com/stackshy/cloudemu/providers/aws/vpc" "github.com/stackshy/cloudemu/resourcediscovery" ) @@ -50,6 +51,7 @@ type Provider struct { EKS *eks.Mock Bedrock *bedrock.Mock SageMaker *sagemaker.Mock + SSM *ssm.Mock ResourceDiscovery *resourcediscovery.Engine } @@ -78,6 +80,7 @@ func New(opts ...config.Option) *Provider { EKS: eks.New(o), Bedrock: bedrock.New(o), SageMaker: sagemaker.New(o), + SSM: ssm.New(o), } p.EC2.SetMonitoring(p.CloudWatch) p.S3.SetMonitoring(p.CloudWatch) diff --git a/providers/aws/ssm/ssm.go b/providers/aws/ssm/ssm.go new file mode 100644 index 0000000..0ae2955 --- /dev/null +++ b/providers/aws/ssm/ssm.go @@ -0,0 +1,478 @@ +// Package ssm provides an in-memory mock implementation of AWS Systems Manager +// (SSM) Parameter Store. +// +// This is the Layer-3 driver implementation. The portable Layer-1 wrapper that +// adds recording/metrics/rate-limiting/error-injection/latency lives in the +// module-root parameterstore package (parameterstore/parameterstore.go). +// +// Values are stored verbatim regardless of Type; SecureString parameters are +// NOT encrypted (there is no real KMS integration), so WithDecryption is a +// no-op and the raw value is always returned. +package ssm + +import ( + "context" + "sort" + "strconv" + "strings" + "sync" + "time" + + "github.com/stackshy/cloudemu/config" + "github.com/stackshy/cloudemu/errors" + "github.com/stackshy/cloudemu/internal/idgen" + "github.com/stackshy/cloudemu/internal/memstore" + "github.com/stackshy/cloudemu/parameterstore/driver" +) + +// Compile-time check that Mock implements driver.ParameterStore. +var _ driver.ParameterStore = (*Mock)(nil) + +// version is a single stored revision of a parameter. +type version struct { + value string + typ string + dataType string + version int64 + lastModified string + labels []string +} + +// paramData holds all versions and current metadata for a parameter name. +type paramData struct { + name string + description string + tier string + versions []*version + latest int64 + mu sync.RWMutex +} + +// Mock is an in-memory mock implementation of SSM Parameter Store. +type Mock struct { + params *memstore.Store[*paramData] + opts *config.Options +} + +// New creates a new SSM Parameter Store mock. +func New(opts *config.Options) *Mock { + return &Mock{ + params: memstore.New[*paramData](), + opts: opts, + } +} + +func (m *Mock) now() string { + return m.opts.Clock.Now().UTC().Format(time.RFC3339) +} + +// arn builds the ARN for a parameter. AWS uses "parameter" + the name (which +// begins with "/" for hierarchical names), joined without a separating slash. +func (m *Mock) arn(name string) string { + return idgen.AWSARN("ssm", m.opts.Region, m.opts.AccountID, "parameter"+ensureLeadingSlash(name)) +} + +// ensureLeadingSlash normalizes a name so hierarchical ARNs render like +// "parameter/a/b" while flat names render like "parameter/flat". +func ensureLeadingSlash(name string) string { + if strings.HasPrefix(name, "/") { + return name + } + + return "/" + name +} + +func defaultType(t string) string { + switch t { + case driver.TypeString, driver.TypeStringList, driver.TypeSecureString: + return t + default: + return driver.TypeString + } +} + +// PutParameter creates a new parameter or, when Overwrite is set, appends a new +// version to an existing one. +func (m *Mock) PutParameter(_ context.Context, cfg driver.PutConfig) (int64, string, error) { + if cfg.Name == "" { + return 0, "", errors.New(errors.InvalidArgument, "parameter name is required") + } + + tier := cfg.Tier + if tier == "" { + tier = "Standard" + } + + dataType := cfg.DataType + if dataType == "" { + dataType = "text" + } + + now := m.now() + + if existing, ok := m.params.Get(cfg.Name); ok { + existing.mu.Lock() + defer existing.mu.Unlock() + + if !cfg.Overwrite { + return 0, "", errors.Newf(errors.AlreadyExists, + "parameter %q already exists; set Overwrite to update it", cfg.Name) + } + + next := existing.latest + 1 + existing.versions = append(existing.versions, &version{ + value: cfg.Value, + typ: defaultType(cfg.Type), + dataType: dataType, + version: next, + lastModified: now, + }) + existing.latest = next + existing.description = cfg.Description + existing.tier = tier + + return next, tier, nil + } + + pd := ¶mData{ + name: cfg.Name, + description: cfg.Description, + tier: tier, + latest: 1, + versions: []*version{{ + value: cfg.Value, + typ: defaultType(cfg.Type), + dataType: dataType, + version: 1, + lastModified: now, + }}, + } + + // SetIfAbsent guards against a concurrent create racing between Get and Set. + if !m.params.SetIfAbsent(cfg.Name, pd) { + // Lost the race: retry as an overwrite path only if allowed. + if !cfg.Overwrite { + return 0, "", errors.Newf(errors.AlreadyExists, + "parameter %q already exists; set Overwrite to update it", cfg.Name) + } + + cfg.Overwrite = true + + return m.PutParameter(context.Background(), cfg) + } + + return 1, tier, nil +} + +// resolveSelector splits a name of the form "name:selector" into its base name +// and selector (a version number or a label). An empty selector means latest. +func resolveSelector(name string) (base, selector string) { + // Hierarchical names contain slashes but never a colon in the path itself, + // so the last colon (if any) introduces a version/label selector. + if i := strings.LastIndex(name, ":"); i >= 0 { + return name[:i], name[i+1:] + } + + return name, "" +} + +// pick returns the version matching the selector (empty = latest, numeric = +// version, otherwise a label). Callers must hold pd.mu. +func (pd *paramData) pick(selector string) (*version, bool) { + if selector == "" { + return pd.versionByNumber(pd.latest) + } + + if n, err := strconv.ParseInt(selector, 10, 64); err == nil { + return pd.versionByNumber(n) + } + + for _, v := range pd.versions { + for _, l := range v.labels { + if l == selector { + return v, true + } + } + } + + return nil, false +} + +func (pd *paramData) versionByNumber(n int64) (*version, bool) { + for _, v := range pd.versions { + if v.version == n { + return v, true + } + } + + return nil, false +} + +func (m *Mock) toParameter(pd *paramData, v *version, selector string) driver.Parameter { + name := pd.name + if selector != "" { + name = pd.name + ":" + selector + } + + return driver.Parameter{ + Name: pd.name, + Type: v.typ, + Value: v.value, + Version: v.version, + ARN: m.arn(pd.name), + DataType: v.dataType, + LastModified: v.lastModified, + Selector: selectorFor(name, pd.name), + } +} + +func selectorFor(requested, base string) string { + if requested == base { + return "" + } + + return strings.TrimPrefix(requested, base+":") +} + +// GetParameter retrieves a single parameter by name, honoring an optional +// ":version" or ":label" selector suffix. withDecryption is accepted but has no +// effect: SecureString values are stored and returned in the clear. +func (m *Mock) GetParameter(_ context.Context, name string, _ bool) (*driver.Parameter, error) { + base, selector := resolveSelector(name) + + pd, ok := m.params.Get(base) + if !ok { + return nil, errors.Newf(errors.NotFound, "parameter %q not found", base) + } + + pd.mu.RLock() + defer pd.mu.RUnlock() + + v, ok := pd.pick(selector) + if !ok { + return nil, errors.Newf(errors.NotFound, "parameter %q version/label %q not found", base, selector) + } + + p := m.toParameter(pd, v, selector) + + return &p, nil +} + +// GetParameters retrieves multiple parameters, reporting names that were not +// found (or whose selector did not resolve) as invalid rather than erroring. +func (m *Mock) GetParameters(_ context.Context, names []string, _ bool) ([]driver.Parameter, []string, error) { + found := make([]driver.Parameter, 0, len(names)) + + var invalid []string + + for _, name := range names { + base, selector := resolveSelector(name) + + pd, ok := m.params.Get(base) + if !ok { + invalid = append(invalid, name) + continue + } + + pd.mu.RLock() + v, ok := pd.pick(selector) + if !ok { + pd.mu.RUnlock() + + invalid = append(invalid, name) + + continue + } + + found = append(found, m.toParameter(pd, v, selector)) + pd.mu.RUnlock() + } + + return found, invalid, nil +} + +// GetParametersByPath returns the latest version of every parameter under a +// hierarchical path. With Recursive false, only direct children are returned; +// with Recursive true, the whole subtree is returned. +func (m *Mock) GetParametersByPath(_ context.Context, in driver.GetByPathInput) ([]driver.Parameter, error) { + path := in.Path + if path == "" { + path = "/" + } + + if !strings.HasPrefix(path, "/") { + return nil, errors.New(errors.InvalidArgument, "path must begin with '/'") + } + + prefix := path + if !strings.HasSuffix(prefix, "/") { + prefix += "/" + } + + var out []driver.Parameter + + for _, pd := range m.params.All() { + name := pd.name + if !strings.HasPrefix(name, prefix) { + continue + } + + rest := strings.TrimPrefix(name, prefix) + // Non-recursive: only direct children (no further "/" in the remainder). + if !in.Recursive && strings.Contains(rest, "/") { + continue + } + + pd.mu.RLock() + if v, ok := pd.versionByNumber(pd.latest); ok { + out = append(out, m.toParameter(pd, v, "")) + } + pd.mu.RUnlock() + } + + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + + return out, nil +} + +// DeleteParameter removes a parameter and all its versions. +func (m *Mock) DeleteParameter(_ context.Context, name string) error { + if !m.params.Delete(name) { + return errors.Newf(errors.NotFound, "parameter %q not found", name) + } + + return nil +} + +// DeleteParameters removes multiple parameters, returning the names deleted and +// the names that did not exist. +func (m *Mock) DeleteParameters(_ context.Context, names []string) ([]string, []string, error) { + var deleted, invalid []string + + for _, name := range names { + if m.params.Delete(name) { + deleted = append(deleted, name) + } else { + invalid = append(invalid, name) + } + } + + return deleted, invalid, nil +} + +// DescribeParameters lists metadata (no values) for all parameters. +func (m *Mock) DescribeParameters(_ context.Context) ([]driver.ParameterMetadata, error) { + all := m.params.All() + + out := make([]driver.ParameterMetadata, 0, len(all)) + + for _, pd := range all { + pd.mu.RLock() + if v, ok := pd.versionByNumber(pd.latest); ok { + out = append(out, driver.ParameterMetadata{ + Name: pd.name, + Type: v.typ, + Description: pd.description, + Version: pd.latest, + ARN: m.arn(pd.name), + Tier: pd.tier, + DataType: v.dataType, + LastModified: v.lastModified, + LastModifiedUser: idgen.AWSARN("iam", "", m.opts.AccountID, "user/cloudemu"), + }) + } + pd.mu.RUnlock() + } + + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + + return out, nil +} + +// GetParameterHistory returns every version of a parameter, oldest first. +func (m *Mock) GetParameterHistory(_ context.Context, name string) ([]driver.Parameter, 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([]driver.Parameter, 0, len(pd.versions)) + for _, v := range pd.versions { + out = append(out, driver.Parameter{ + Name: pd.name, + Type: v.typ, + Value: v.value, + Version: v.version, + ARN: m.arn(pd.name), + DataType: v.dataType, + LastModified: v.lastModified, + }) + } + + return out, nil +} + +// LabelParameterVersion attaches labels to a specific version (0 = latest), +// returning the version the labels were applied to and any labels rejected. +// A label attached to a new version is removed from any older version that held +// it, matching real SSM semantics. +func (m *Mock) LabelParameterVersion(_ context.Context, name string, ver int64, labels []string) (int64, []string, error) { + pd, ok := m.params.Get(name) + if !ok { + return 0, nil, errors.Newf(errors.NotFound, "parameter %q not found", name) + } + + pd.mu.Lock() + defer pd.mu.Unlock() + + if ver == 0 { + ver = pd.latest + } + + target, ok := pd.versionByNumber(ver) + if !ok { + return 0, nil, errors.Newf(errors.NotFound, "parameter %q version %d not found", name, ver) + } + + for _, label := range labels { + // Detach the label from any other version first. + for _, v := range pd.versions { + if v == target { + continue + } + + v.labels = removeString(v.labels, label) + } + + if !containsString(target.labels, label) { + target.labels = append(target.labels, label) + } + } + + return ver, nil, nil +} + +func containsString(ss []string, s string) bool { + for _, x := range ss { + if x == s { + return true + } + } + + return false +} + +func removeString(ss []string, s string) []string { + out := ss[:0] + + for _, x := range ss { + if x != s { + out = append(out, x) + } + } + + return out +} diff --git a/providers/aws/ssm/ssm_test.go b/providers/aws/ssm/ssm_test.go new file mode 100644 index 0000000..d807f8f --- /dev/null +++ b/providers/aws/ssm/ssm_test.go @@ -0,0 +1,146 @@ +package ssm_test + +import ( + "context" + "testing" + + "github.com/stackshy/cloudemu/config" + cerrors "github.com/stackshy/cloudemu/errors" + "github.com/stackshy/cloudemu/parameterstore/driver" + "github.com/stackshy/cloudemu/providers/aws/ssm" +) + +func newMock() *ssm.Mock { + return ssm.New(config.NewOptions()) +} + +func TestPutOverwriteAndHistory(t *testing.T) { + m := newMock() + ctx := context.Background() + + v, _, err := m.PutParameter(ctx, driver.PutConfig{Name: "/a", Value: "1", Type: driver.TypeString}) + if err != nil { + t.Fatalf("PutParameter v1: %v", err) + } + + if v != 1 { + t.Fatalf("first version = %d, want 1", v) + } + + if _, _, err := m.PutParameter(ctx, driver.PutConfig{Name: "/a", Value: "2", Type: driver.TypeString}); err == nil { + t.Fatal("PutParameter without Overwrite: want AlreadyExists, got nil") + } else if !cerrors.IsAlreadyExists(err) { + t.Fatalf("want AlreadyExists, got %v", err) + } + + v2, _, err := m.PutParameter(ctx, driver.PutConfig{Name: "/a", Value: "2", Type: driver.TypeString, Overwrite: true}) + if err != nil { + t.Fatalf("PutParameter overwrite: %v", err) + } + + if v2 != 2 { + t.Fatalf("overwrite version = %d, want 2", v2) + } + + hist, err := m.GetParameterHistory(ctx, "/a") + if err != nil { + t.Fatalf("GetParameterHistory: %v", err) + } + + if len(hist) != 2 || hist[0].Value != "1" || hist[1].Value != "2" { + t.Fatalf("history = %+v, want [1 2]", hist) + } +} + +func TestLabelParameterVersionAndSelector(t *testing.T) { + m := newMock() + ctx := context.Background() + + if _, _, err := m.PutParameter(ctx, driver.PutConfig{Name: "/svc/cfg", Value: "old", Type: driver.TypeString}); err != nil { + t.Fatalf("Put v1: %v", err) + } + + if _, _, err := m.PutParameter(ctx, driver.PutConfig{Name: "/svc/cfg", Value: "new", Type: driver.TypeString, Overwrite: true}); err != nil { + t.Fatalf("Put v2: %v", err) + } + + // Label v1 "prod". + applied, invalid, err := m.LabelParameterVersion(ctx, "/svc/cfg", 1, []string{"prod"}) + if err != nil { + t.Fatalf("LabelParameterVersion: %v", err) + } + + if applied != 1 || len(invalid) != 0 { + t.Fatalf("applied=%d invalid=%v, want 1 []", applied, invalid) + } + + got, err := m.GetParameter(ctx, "/svc/cfg:prod", false) + if err != nil { + t.Fatalf("GetParameter(:prod): %v", err) + } + + if got.Value != "old" || got.Version != 1 { + t.Fatalf("label selector got %q v%d, want old v1", got.Value, got.Version) + } + + // Latest (no selector) is still v2. + latest, err := m.GetParameter(ctx, "/svc/cfg", false) + if err != nil { + t.Fatalf("GetParameter(latest): %v", err) + } + + if latest.Value != "new" || latest.Version != 2 { + t.Fatalf("latest got %q v%d, want new v2", latest.Value, latest.Version) + } +} + +func TestGetParametersByPathRecursive(t *testing.T) { + m := newMock() + ctx := context.Background() + + for _, n := range []string{"/p/a", "/p/b", "/p/sub/c"} { + if _, _, err := m.PutParameter(ctx, driver.PutConfig{Name: n, Value: "x", Type: driver.TypeString}); err != nil { + t.Fatalf("Put %s: %v", n, err) + } + } + + shallow, err := m.GetParametersByPath(ctx, driver.GetByPathInput{Path: "/p"}) + if err != nil { + t.Fatalf("GetParametersByPath: %v", err) + } + + if len(shallow) != 2 { + t.Fatalf("non-recursive returned %d, want 2", len(shallow)) + } + + deep, err := m.GetParametersByPath(ctx, driver.GetByPathInput{Path: "/p", Recursive: true}) + if err != nil { + t.Fatalf("GetParametersByPath recursive: %v", err) + } + + if len(deep) != 3 { + t.Fatalf("recursive returned %d, want 3", len(deep)) + } +} + +func TestDeleteParameters(t *testing.T) { + m := newMock() + ctx := context.Background() + + if _, _, err := m.PutParameter(ctx, driver.PutConfig{Name: "/d/1", Value: "x", Type: driver.TypeString}); err != nil { + t.Fatalf("Put: %v", err) + } + + deleted, invalid, err := m.DeleteParameters(ctx, []string{"/d/1", "/d/missing"}) + if err != nil { + t.Fatalf("DeleteParameters: %v", err) + } + + if len(deleted) != 1 || deleted[0] != "/d/1" { + t.Fatalf("deleted = %v, want [/d/1]", deleted) + } + + if len(invalid) != 1 || invalid[0] != "/d/missing" { + t.Fatalf("invalid = %v, want [/d/missing]", invalid) + } +} diff --git a/server/aws/aws.go b/server/aws/aws.go index 652c376..18a3d2c 100644 --- a/server/aws/aws.go +++ b/server/aws/aws.go @@ -22,6 +22,7 @@ import ( mondriver "github.com/stackshy/cloudemu/monitoring/driver" netdriver "github.com/stackshy/cloudemu/networking/driver" notifdriver "github.com/stackshy/cloudemu/notification/driver" + ssmdriver "github.com/stackshy/cloudemu/parameterstore/driver" eksdriver "github.com/stackshy/cloudemu/providers/aws/eks/driver" rdbdriver "github.com/stackshy/cloudemu/relationaldb/driver" "github.com/stackshy/cloudemu/resourcediscovery" @@ -50,6 +51,7 @@ import ( secretsmanagersrv "github.com/stackshy/cloudemu/server/aws/secretsmanager" "github.com/stackshy/cloudemu/server/aws/sns" "github.com/stackshy/cloudemu/server/aws/sqs" + ssmsrv "github.com/stackshy/cloudemu/server/aws/ssm" stssrv "github.com/stackshy/cloudemu/server/aws/sts" sdrv "github.com/stackshy/cloudemu/serverless/driver" storagedriver "github.com/stackshy/cloudemu/storage/driver" @@ -76,6 +78,9 @@ type Drivers struct { // SecretsManager serves the Secrets Manager JSON 1.1 protocol against // the secrets driver. SecretsManager secretsdriver.Secrets + // SSM serves the Systems Manager Parameter Store JSON 1.1 protocol against + // the parameterstore driver. + SSM ssmdriver.ParameterStore // CloudWatchLogs serves the CloudWatch Logs JSON 1.1 protocol against the // logging driver. CloudWatchLogs logdriver.Logging @@ -180,6 +185,13 @@ func New(d Drivers) *server.Server { srv.Register(secretsmanagersrv.New(d.SecretsManager)) } + // SSM Parameter Store matches the X-Amz-Target prefix "AmazonSSM." — + // disjoint from DynamoDB, SQS, ECR, SageMaker, Secrets Manager, EventBridge, + // CloudWatch Logs, and the tagging API. + if d.SSM != nil { + srv.Register(ssmsrv.New(d.SSM)) + } + // EventBridge matches the X-Amz-Target prefix "AWSEvents." — disjoint from // DynamoDB, SQS, ECR, SageMaker, Secrets Manager, and the tagging API. if d.EventBridge != nil { diff --git a/server/aws/ssm/handler.go b/server/aws/ssm/handler.go new file mode 100644 index 0000000..5c1a71d --- /dev/null +++ b/server/aws/ssm/handler.go @@ -0,0 +1,85 @@ +// Package ssm implements the AWS Systems Manager (SSM) Parameter Store +// JSON-RPC protocol as a server.Handler. Point the real aws-sdk-go-v2 SSM +// client at a Server registered with this handler and Parameter Store +// operations work against an in-memory parameterstore driver. +// +// SSM uses the AWS JSON 1.1 wire shape (POST + JSON body, dispatched on the +// X-Amz-Target header "AmazonSSM."), the same family as DynamoDB, +// SQS, EventBridge, CloudWatch Logs, ECR, SageMaker, and Secrets Manager. The +// "AmazonSSM." target prefix is disjoint from all of those, so registration +// order relative to them is unconstrained. +package ssm + +import ( + "net/http" + "strings" + + cerrors "github.com/stackshy/cloudemu/errors" + ssmdriver "github.com/stackshy/cloudemu/parameterstore/driver" + "github.com/stackshy/cloudemu/server/wire" +) + +const targetPrefix = "AmazonSSM." + +// Handler serves Parameter Store JSON-RPC requests against a ParameterStore driver. +type Handler struct { + store ssmdriver.ParameterStore +} + +// New returns a Parameter Store handler backed by s. +func New(s ssmdriver.ParameterStore) *Handler { + return &Handler{store: s} +} + +// Matches returns true for SSM-shaped requests, identified by an X-Amz-Target +// header of "AmazonSSM.". +func (*Handler) Matches(r *http.Request) bool { + return strings.HasPrefix(r.Header.Get("X-Amz-Target"), targetPrefix) +} + +// ServeHTTP dispatches Parameter Store operations based on X-Amz-Target. +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + op := strings.TrimPrefix(r.Header.Get("X-Amz-Target"), targetPrefix) + + switch op { + case "PutParameter": + h.putParameter(w, r) + case "GetParameter": + h.getParameter(w, r) + case "GetParameters": + h.getParameters(w, r) + case "GetParametersByPath": + h.getParametersByPath(w, r) + case "DeleteParameter": + h.deleteParameter(w, r) + case "DeleteParameters": + h.deleteParameters(w, r) + case "DescribeParameters": + h.describeParameters(w, r) + case "GetParameterHistory": + h.getParameterHistory(w, r) + case "LabelParameterVersion": + h.labelParameterVersion(w, r) + default: + wire.WriteJSONError(w, http.StatusBadRequest, + "UnknownOperationException", "unknown SSM operation: "+op) + } +} + +// writeErr maps canonical cloudemu errors to SSM JSON error responses. SSM +// returns errors as HTTP 400 with a "__type" body the SDK maps to a typed +// exception. +func writeErr(w http.ResponseWriter, err error) { + switch { + case cerrors.IsNotFound(err): + wire.WriteJSONError(w, http.StatusBadRequest, "ParameterNotFound", err.Error()) + case cerrors.IsAlreadyExists(err): + wire.WriteJSONError(w, http.StatusBadRequest, "ParameterAlreadyExists", err.Error()) + case cerrors.IsInvalidArgument(err): + wire.WriteJSONError(w, http.StatusBadRequest, "ValidationException", err.Error()) + case cerrors.GetCode(err) == cerrors.ResourceExhausted: + wire.WriteJSONError(w, http.StatusBadRequest, "ParameterLimitExceeded", err.Error()) + default: + wire.WriteJSONError(w, http.StatusInternalServerError, "InternalServerError", err.Error()) + } +} diff --git a/server/aws/ssm/operations.go b/server/aws/ssm/operations.go new file mode 100644 index 0000000..53e7ab3 --- /dev/null +++ b/server/aws/ssm/operations.go @@ -0,0 +1,187 @@ +package ssm + +import ( + "net/http" + + ssmdriver "github.com/stackshy/cloudemu/parameterstore/driver" + "github.com/stackshy/cloudemu/server/wire" +) + +func (h *Handler) putParameter(w http.ResponseWriter, r *http.Request) { + var req putParameterRequest + if !wire.DecodeJSON(w, r, &req) { + return + } + + version, tier, err := h.store.PutParameter(r.Context(), ssmdriver.PutConfig{ + Name: req.Name, + Value: req.Value, + Type: req.Type, + Description: req.Description, + Overwrite: req.Overwrite, + Tier: req.Tier, + DataType: req.DataType, + }) + if err != nil { + writeErr(w, err) + return + } + + wire.WriteJSON(w, putParameterResponse{Tier: tier, Version: version}) +} + +func (h *Handler) getParameter(w http.ResponseWriter, r *http.Request) { + var req getParameterRequest + if !wire.DecodeJSON(w, r, &req) { + return + } + + p, err := h.store.GetParameter(r.Context(), req.Name, req.WithDecryption) + if err != nil { + writeErr(w, err) + return + } + + wire.WriteJSON(w, getParameterResponse{Parameter: toParameterJSON(*p)}) +} + +func (h *Handler) getParameters(w http.ResponseWriter, r *http.Request) { + var req getParametersRequest + if !wire.DecodeJSON(w, r, &req) { + return + } + + found, invalid, err := h.store.GetParameters(r.Context(), req.Names, req.WithDecryption) + if err != nil { + writeErr(w, err) + return + } + + params := make([]parameterJSON, 0, len(found)) + for _, p := range found { + params = append(params, toParameterJSON(p)) + } + + wire.WriteJSON(w, getParametersResponse{Parameters: params, InvalidParameters: invalid}) +} + +func (h *Handler) getParametersByPath(w http.ResponseWriter, r *http.Request) { + var req getParametersByPathRequest + if !wire.DecodeJSON(w, r, &req) { + return + } + + found, err := h.store.GetParametersByPath(r.Context(), ssmdriver.GetByPathInput{ + Path: req.Path, + Recursive: req.Recursive, + WithDecryption: req.WithDecryption, + }) + if err != nil { + writeErr(w, err) + return + } + + params := make([]parameterJSON, 0, len(found)) + for _, p := range found { + params = append(params, toParameterJSON(p)) + } + + wire.WriteJSON(w, getParametersByPathResponse{Parameters: params}) +} + +func (h *Handler) deleteParameter(w http.ResponseWriter, r *http.Request) { + var req nameRequest + if !wire.DecodeJSON(w, r, &req) { + return + } + + if err := h.store.DeleteParameter(r.Context(), req.Name); err != nil { + writeErr(w, err) + return + } + + wire.WriteJSON(w, struct{}{}) +} + +func (h *Handler) deleteParameters(w http.ResponseWriter, r *http.Request) { + var req namesRequest + if !wire.DecodeJSON(w, r, &req) { + return + } + + deleted, invalid, err := h.store.DeleteParameters(r.Context(), req.Names) + if err != nil { + writeErr(w, err) + return + } + + wire.WriteJSON(w, deleteParametersResponse{DeletedParameters: deleted, InvalidParameters: invalid}) +} + +func (h *Handler) describeParameters(w http.ResponseWriter, r *http.Request) { + metas, err := h.store.DescribeParameters(r.Context()) + if err != nil { + writeErr(w, err) + return + } + + out := make([]parameterMetadataJSON, 0, len(metas)) + for _, md := range metas { + out = append(out, parameterMetadataJSON{ + ARN: md.ARN, + DataType: md.DataType, + Description: md.Description, + LastModifiedDate: epochSeconds(md.LastModified), + LastModifiedUser: md.LastModifiedUser, + Name: md.Name, + Tier: md.Tier, + Type: md.Type, + Version: md.Version, + }) + } + + wire.WriteJSON(w, describeParametersResponse{Parameters: out}) +} + +func (h *Handler) getParameterHistory(w http.ResponseWriter, r *http.Request) { + var req nameRequest + if !wire.DecodeJSON(w, r, &req) { + return + } + + history, err := h.store.GetParameterHistory(r.Context(), req.Name) + if err != nil { + writeErr(w, err) + return + } + + out := make([]parameterHistoryJSON, 0, len(history)) + for _, p := range history { + out = append(out, parameterHistoryJSON{ + ARN: p.ARN, + DataType: p.DataType, + LastModifiedDate: epochSeconds(p.LastModified), + Name: p.Name, + Type: p.Type, + Value: p.Value, + Version: p.Version, + }) + } + + wire.WriteJSON(w, getParameterHistoryResponse{Parameters: out}) +} + +func (h *Handler) labelParameterVersion(w http.ResponseWriter, r *http.Request) { + var req labelParameterVersionRequest + if !wire.DecodeJSON(w, r, &req) { + return + } + + applied, invalid, err := h.store.LabelParameterVersion(r.Context(), req.Name, req.ParameterVersion, req.Labels) + if err != nil { + writeErr(w, err) + return + } + + wire.WriteJSON(w, labelParameterVersionResponse{InvalidLabels: invalid, ParameterVersion: applied}) +} diff --git a/server/aws/ssm/sdk_roundtrip_test.go b/server/aws/ssm/sdk_roundtrip_test.go new file mode 100644 index 0000000..59b9a10 --- /dev/null +++ b/server/aws/ssm/sdk_roundtrip_test.go @@ -0,0 +1,275 @@ +package ssm_test + +import ( + "context" + "errors" + "net/http/httptest" + "sort" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + awsconfig "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + awsssm "github.com/aws/aws-sdk-go-v2/service/ssm" + ssmtypes "github.com/aws/aws-sdk-go-v2/service/ssm/types" + + "github.com/stackshy/cloudemu" + awsserver "github.com/stackshy/cloudemu/server/aws" +) + +func newSSMClient(t *testing.T) *awsssm.Client { + t.Helper() + + cloud := cloudemu.NewAWS() + srv := awsserver.New(awsserver.Drivers{SSM: cloud.SSM}) + + ts := httptest.NewServer(srv) + t.Cleanup(ts.Close) + + cfg, err := awsconfig.LoadDefaultConfig(context.Background(), + awsconfig.WithRegion("us-east-1"), + awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider("test", "test", "")), + ) + if err != nil { + t.Fatalf("aws config: %v", err) + } + + return awsssm.NewFromConfig(cfg, func(o *awsssm.Options) { + o.BaseEndpoint = aws.String(ts.URL) + }) +} + +func TestSDKPutGetParameter(t *testing.T) { + client := newSSMClient(t) + ctx := context.Background() + + put, err := client.PutParameter(ctx, &awsssm.PutParameterInput{ + Name: aws.String("/app/db/host"), + Value: aws.String("db.internal"), + Type: ssmtypes.ParameterTypeString, + }) + if err != nil { + t.Fatalf("PutParameter: %v", err) + } + + if put.Version != 1 { + t.Fatalf("PutParameter version = %d, want 1", put.Version) + } + + got, err := client.GetParameter(ctx, &awsssm.GetParameterInput{Name: aws.String("/app/db/host")}) + if err != nil { + t.Fatalf("GetParameter: %v", err) + } + + if aws.ToString(got.Parameter.Value) != "db.internal" { + t.Fatalf("value = %q, want db.internal", aws.ToString(got.Parameter.Value)) + } + + if got.Parameter.Version != 1 { + t.Fatalf("version = %d, want 1", got.Parameter.Version) + } + + if got.Parameter.Type != ssmtypes.ParameterTypeString { + t.Fatalf("type = %q, want String", got.Parameter.Type) + } + + if aws.ToString(got.Parameter.ARN) == "" { + t.Fatal("GetParameter returned empty ARN") + } +} + +func TestSDKPutOverwriteVersioning(t *testing.T) { + client := newSSMClient(t) + ctx := context.Background() + + if _, err := client.PutParameter(ctx, &awsssm.PutParameterInput{ + Name: aws.String("/app/key"), + Value: aws.String("v1"), + Type: ssmtypes.ParameterTypeString, + }); err != nil { + t.Fatalf("PutParameter v1: %v", err) + } + + // Overwrite without the flag must fail. + _, err := client.PutParameter(ctx, &awsssm.PutParameterInput{ + Name: aws.String("/app/key"), + Value: aws.String("v2"), + Type: ssmtypes.ParameterTypeString, + }) + + var exists *ssmtypes.ParameterAlreadyExists + if !errors.As(err, &exists) { + t.Fatalf("Put without Overwrite: got %v, want ParameterAlreadyExists", err) + } + + put2, err := client.PutParameter(ctx, &awsssm.PutParameterInput{ + Name: aws.String("/app/key"), + Value: aws.String("v2"), + Type: ssmtypes.ParameterTypeString, + Overwrite: aws.Bool(true), + }) + if err != nil { + t.Fatalf("PutParameter overwrite: %v", err) + } + + if put2.Version != 2 { + t.Fatalf("overwrite version = %d, want 2", put2.Version) + } + + got, err := client.GetParameter(ctx, &awsssm.GetParameterInput{Name: aws.String("/app/key")}) + if err != nil { + t.Fatalf("GetParameter: %v", err) + } + + if aws.ToString(got.Parameter.Value) != "v2" || got.Parameter.Version != 2 { + t.Fatalf("got value %q version %d, want v2 version 2", + aws.ToString(got.Parameter.Value), got.Parameter.Version) + } + + // Fetch a specific historic version via the ":version" selector. + old, err := client.GetParameter(ctx, &awsssm.GetParameterInput{Name: aws.String("/app/key:1")}) + if err != nil { + t.Fatalf("GetParameter(:1): %v", err) + } + + if aws.ToString(old.Parameter.Value) != "v1" { + t.Fatalf("v1 selector value = %q, want v1", aws.ToString(old.Parameter.Value)) + } +} + +func TestSDKGetParametersByPath(t *testing.T) { + client := newSSMClient(t) + ctx := context.Background() + + seed := map[string]string{ + "/svc/a": "1", + "/svc/b": "2", + "/svc/deep/c": "3", + "/other/x": "9", + } + for name, val := range seed { + if _, err := client.PutParameter(ctx, &awsssm.PutParameterInput{ + Name: aws.String(name), + Value: aws.String(val), + Type: ssmtypes.ParameterTypeString, + }); err != nil { + t.Fatalf("PutParameter %s: %v", name, err) + } + } + + // Non-recursive: only direct children of /svc. + shallow, err := client.GetParametersByPath(ctx, &awsssm.GetParametersByPathInput{ + Path: aws.String("/svc"), + }) + if err != nil { + t.Fatalf("GetParametersByPath(non-recursive): %v", err) + } + + if names := paramNames(shallow.Parameters); !equalStrings(names, []string{"/svc/a", "/svc/b"}) { + t.Fatalf("non-recursive names = %v, want [/svc/a /svc/b]", names) + } + + // Recursive: whole subtree. + deep, err := client.GetParametersByPath(ctx, &awsssm.GetParametersByPathInput{ + Path: aws.String("/svc"), + Recursive: aws.Bool(true), + }) + if err != nil { + t.Fatalf("GetParametersByPath(recursive): %v", err) + } + + if names := paramNames(deep.Parameters); !equalStrings(names, []string{"/svc/a", "/svc/b", "/svc/deep/c"}) { + t.Fatalf("recursive names = %v, want [/svc/a /svc/b /svc/deep/c]", names) + } +} + +func TestSDKGetParameters(t *testing.T) { + client := newSSMClient(t) + ctx := context.Background() + + for _, name := range []string{"/multi/one", "/multi/two"} { + if _, err := client.PutParameter(ctx, &awsssm.PutParameterInput{ + Name: aws.String(name), + Value: aws.String("val" + name), + Type: ssmtypes.ParameterTypeString, + }); err != nil { + t.Fatalf("PutParameter %s: %v", name, err) + } + } + + got, err := client.GetParameters(ctx, &awsssm.GetParametersInput{ + Names: []string{"/multi/one", "/multi/two", "/multi/missing"}, + }) + if err != nil { + t.Fatalf("GetParameters: %v", err) + } + + if len(got.Parameters) != 2 { + t.Fatalf("got %d parameters, want 2", len(got.Parameters)) + } + + if len(got.InvalidParameters) != 1 || got.InvalidParameters[0] != "/multi/missing" { + t.Fatalf("InvalidParameters = %v, want [/multi/missing]", got.InvalidParameters) + } +} + +func TestSDKDeleteParameter(t *testing.T) { + client := newSSMClient(t) + ctx := context.Background() + + if _, err := client.PutParameter(ctx, &awsssm.PutParameterInput{ + Name: aws.String("/del/me"), + Value: aws.String("x"), + Type: ssmtypes.ParameterTypeString, + }); err != nil { + t.Fatalf("PutParameter: %v", err) + } + + if _, err := client.DeleteParameter(ctx, &awsssm.DeleteParameterInput{Name: aws.String("/del/me")}); err != nil { + t.Fatalf("DeleteParameter: %v", err) + } + + _, err := client.GetParameter(ctx, &awsssm.GetParameterInput{Name: aws.String("/del/me")}) + + var notFound *ssmtypes.ParameterNotFound + if !errors.As(err, ¬Found) { + t.Fatalf("GetParameter after delete: got %v, want ParameterNotFound", err) + } +} + +func TestSDKGetParameterNotFound(t *testing.T) { + client := newSSMClient(t) + ctx := context.Background() + + _, err := client.GetParameter(ctx, &awsssm.GetParameterInput{Name: aws.String("/does/not/exist")}) + + var notFound *ssmtypes.ParameterNotFound + if !errors.As(err, ¬Found) { + t.Fatalf("GetParameter(missing): got %v, want ParameterNotFound", err) + } +} + +func paramNames(ps []ssmtypes.Parameter) []string { + out := make([]string, 0, len(ps)) + for _, p := range ps { + out = append(out, aws.ToString(p.Name)) + } + + sort.Strings(out) + + return out +} + +func equalStrings(a, b []string) bool { + if len(a) != len(b) { + return false + } + + for i := range a { + if a[i] != b[i] { + return false + } + } + + return true +} diff --git a/server/aws/ssm/types.go b/server/aws/ssm/types.go new file mode 100644 index 0000000..d7a0a3f --- /dev/null +++ b/server/aws/ssm/types.go @@ -0,0 +1,148 @@ +package ssm + +import ( + "time" + + ssmdriver "github.com/stackshy/cloudemu/parameterstore/driver" +) + +// parameterJSON is the wire shape for a Parameter, matching the fields the AWS +// SDK deserializes. LastModifiedDate is epoch seconds (AWS JSON timestamp form). +type parameterJSON struct { + ARN string `json:"ARN,omitempty"` + DataType string `json:"DataType,omitempty"` + LastModifiedDate float64 `json:"LastModifiedDate,omitempty"` + Name string `json:"Name"` + Selector string `json:"Selector,omitempty"` + Type string `json:"Type,omitempty"` + Value string `json:"Value,omitempty"` + Version int64 `json:"Version"` +} + +// parameterMetadataJSON is the wire shape for ParameterMetadata (DescribeParameters). +type parameterMetadataJSON struct { + ARN string `json:"ARN,omitempty"` + DataType string `json:"DataType,omitempty"` + Description string `json:"Description,omitempty"` + LastModifiedDate float64 `json:"LastModifiedDate,omitempty"` + LastModifiedUser string `json:"LastModifiedUser,omitempty"` + Name string `json:"Name"` + Tier string `json:"Tier,omitempty"` + Type string `json:"Type,omitempty"` + Version int64 `json:"Version"` +} + +// --- request envelopes --- + +type putParameterRequest struct { + Name string `json:"Name"` + Value string `json:"Value"` + Type string `json:"Type"` + Description string `json:"Description"` + Overwrite bool `json:"Overwrite"` + Tier string `json:"Tier"` + DataType string `json:"DataType"` +} + +type getParameterRequest struct { + Name string `json:"Name"` + WithDecryption bool `json:"WithDecryption"` +} + +type getParametersRequest struct { + Names []string `json:"Names"` + WithDecryption bool `json:"WithDecryption"` +} + +type getParametersByPathRequest struct { + Path string `json:"Path"` + Recursive bool `json:"Recursive"` + WithDecryption bool `json:"WithDecryption"` +} + +type nameRequest struct { + Name string `json:"Name"` +} + +type namesRequest struct { + Names []string `json:"Names"` +} + +type labelParameterVersionRequest struct { + Name string `json:"Name"` + ParameterVersion int64 `json:"ParameterVersion"` + Labels []string `json:"Labels"` +} + +// --- response envelopes --- + +type putParameterResponse struct { + Tier string `json:"Tier,omitempty"` + Version int64 `json:"Version"` +} + +type getParameterResponse struct { + Parameter parameterJSON `json:"Parameter"` +} + +type getParametersResponse struct { + Parameters []parameterJSON `json:"Parameters"` + InvalidParameters []string `json:"InvalidParameters,omitempty"` +} + +type getParametersByPathResponse struct { + Parameters []parameterJSON `json:"Parameters"` +} + +type deleteParametersResponse struct { + DeletedParameters []string `json:"DeletedParameters,omitempty"` + InvalidParameters []string `json:"InvalidParameters,omitempty"` +} + +type describeParametersResponse struct { + Parameters []parameterMetadataJSON `json:"Parameters"` +} + +type labelParameterVersionResponse struct { + InvalidLabels []string `json:"InvalidLabels,omitempty"` + ParameterVersion int64 `json:"ParameterVersion"` +} + +type getParameterHistoryResponse struct { + Parameters []parameterHistoryJSON `json:"Parameters"` +} + +// parameterHistoryJSON is the wire shape for a ParameterHistory entry. +type parameterHistoryJSON struct { + ARN string `json:"ARN,omitempty"` + DataType string `json:"DataType,omitempty"` + LastModifiedDate float64 `json:"LastModifiedDate,omitempty"` + Name string `json:"Name"` + Type string `json:"Type,omitempty"` + Value string `json:"Value,omitempty"` + Version int64 `json:"Version"` +} + +// epochSeconds converts an RFC3339 timestamp to Unix epoch seconds, the form +// the AWS JSON protocol uses for timestamp fields. Returns 0 on parse failure. +func epochSeconds(iso string) float64 { + t, err := time.Parse(time.RFC3339, iso) + if err != nil { + return 0 + } + + return float64(t.Unix()) +} + +func toParameterJSON(p ssmdriver.Parameter) parameterJSON { + return parameterJSON{ + ARN: p.ARN, + DataType: p.DataType, + LastModifiedDate: epochSeconds(p.LastModified), + Name: p.Name, + Selector: p.Selector, + Type: p.Type, + Value: p.Value, + Version: p.Version, + } +} From bdd3466d9c4159c7af66da6b482257a1eb950403 Mon Sep 17 00:00:00 2001 From: Nitin Kumar Patel Date: Sun, 12 Jul 2026 10:40:39 +0530 Subject: [PATCH 2/2] fix(ssm): validate parameter labels + thread context in Put retry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review of #239: - LabelParameterVersion now rejects labels that start with a digit or with aws/ssm (case-insensitive), returning them in InvalidLabels instead of attaching them — which also stops a numeric label (e.g. "5") from being attached and then made unreachable by version-number selector resolution. - PutParameter's Overwrite-race retry now uses the caller's context instead of context.Background(). --- providers/aws/ssm/ssm.go | 34 +++++++++++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/providers/aws/ssm/ssm.go b/providers/aws/ssm/ssm.go index 0ae2955..ae24c26 100644 --- a/providers/aws/ssm/ssm.go +++ b/providers/aws/ssm/ssm.go @@ -93,7 +93,7 @@ func defaultType(t string) string { // PutParameter creates a new parameter or, when Overwrite is set, appends a new // version to an existing one. -func (m *Mock) PutParameter(_ context.Context, cfg driver.PutConfig) (int64, string, error) { +func (m *Mock) PutParameter(ctx context.Context, cfg driver.PutConfig) (int64, string, error) { if cfg.Name == "" { return 0, "", errors.New(errors.InvalidArgument, "parameter name is required") } @@ -158,7 +158,7 @@ func (m *Mock) PutParameter(_ context.Context, cfg driver.PutConfig) (int64, str cfg.Overwrite = true - return m.PutParameter(context.Background(), cfg) + return m.PutParameter(ctx, cfg) } return 1, tier, nil @@ -437,7 +437,18 @@ func (m *Mock) LabelParameterVersion(_ context.Context, name string, ver int64, return 0, nil, errors.Newf(errors.NotFound, "parameter %q version %d not found", name, ver) } + var invalid []string + for _, label := range labels { + // Real SSM rejects labels that begin with a digit or with "aws"/"ssm" + // (case-insensitive). Rejecting them here also prevents a numeric label + // like "5" from being attached and then shadowed by version-number + // resolution in pick(). + if !validLabel(label) { + invalid = append(invalid, label) + continue + } + // Detach the label from any other version first. for _, v := range pd.versions { if v == target { @@ -452,7 +463,24 @@ func (m *Mock) LabelParameterVersion(_ context.Context, name string, ver int64, } } - return ver, nil, nil + return ver, invalid, nil +} + +// validLabel reports whether a parameter label is acceptable to real SSM: it +// must be non-empty, must not start with a digit, and must not start with +// "aws" or "ssm" (case-insensitive). +func validLabel(label string) bool { + if label == "" { + return false + } + + if label[0] >= '0' && label[0] <= '9' { + return false + } + + lower := strings.ToLower(label) + + return !strings.HasPrefix(lower, "aws") && !strings.HasPrefix(lower, "ssm") } func containsString(ss []string, s string) bool {