diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 00000000..aa8b7f63 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,43 @@ +--- +name: Bug Report +about: Report a bug in CloudEmu +title: "[Bug] " +labels: bug +assignees: '' +--- + +## Describe the Bug + +A clear description of what the bug is. + +## To Reproduce + +Steps to reproduce the behavior: + +1. Create provider with `cloudemu.NewAWS()` +2. Call `...` +3. See error + +## Expected Behavior + +What you expected to happen. + +## Actual Behavior + +What actually happened. Include error messages if applicable. + +## Code Sample + +```go +// Minimal code to reproduce the issue +``` + +## Environment + +- Go version: [e.g., 1.25.0] +- CloudEmu version: [e.g., v0.1.0 or commit hash] +- OS: [e.g., macOS, Linux, Windows] + +## Additional Context + +Any other context about the problem. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 00000000..442f930a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,37 @@ +--- +name: Feature Request +about: Suggest a new feature or enhancement for CloudEmu +title: "[Feature] " +labels: enhancement +assignees: '' +--- + +## Feature Description + +A clear description of the feature you'd like to see. + +## Provider(s) + +Which cloud provider(s) does this apply to? + +- [ ] AWS +- [ ] Azure +- [ ] GCP + +## Service + +Which service does this relate to? (e.g., Storage, Compute, Database, etc.) + +## Use Case + +Describe the testing scenario this feature would enable. + +## Proposed API + +```go +// Example of what the API could look like +``` + +## Additional Context + +Any other context, links to cloud documentation, or examples. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..f13195d0 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,29 @@ +## Summary + + + +## Changes + + + +## Provider Coverage + +- [ ] AWS +- [ ] Azure +- [ ] GCP + +## Checklist + +- [ ] All tests pass (`go test ./...`) +- [ ] Linter passes (`golangci-lint run --timeout=9m ./...`) +- [ ] All 3 providers implement the same behavior +- [ ] Integration tests added to `cloudemu_test.go` +- [ ] Unit tests added to provider test files + +## Test Plan + + + +## Related Issues + + diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..3f703561 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,66 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity and +orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of + any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, + without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +**nitinraj7488204975@gmail.com**. + +All complaints will be reviewed and investigated promptly and fairly. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..1d5d510f --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,93 @@ +# Contributing to CloudEmu + +Thank you for your interest in contributing to CloudEmu! This guide will help you get started. + +## Getting Started + +1. Fork the repository +2. Clone your fork: + ```bash + git clone https://github.com//cloudemu.git + cd cloudemu + ``` +3. Create a feature branch from `development`: + ```bash + git checkout development + git checkout -b feature/your-feature-name + ``` + +## Development Setup + +**Requirements:** +- Go 1.25.0+ +- golangci-lint v2 + +```bash +go build ./... # compile all packages +go test ./... # run all tests +go vet ./... # static analysis +``` + +## Code Standards + +- **Max line length:** 140 characters +- **Max cyclomatic complexity:** 10 +- **Max function length:** 100 lines / 50 statements +- **No magic numbers** — use named constants +- **Import ordering:** stdlib, third-party, local module (enforced by `gci`) +- **Thread safety:** all mock implementations must use `sync.RWMutex` + +### Linting + +Run the linter before submitting: + +```bash +golangci-lint run --timeout=9m ./... +``` + +Fix all issues. If a `//nolint` directive is needed, always include an explanation. + +## Making Changes + +### Adding a New Feature to an Existing Service + +1. Add types and methods to the driver interface (`/driver/driver.go`) +2. Implement in **all 3 providers** (AWS, Azure, GCP) +3. Wire through the portable API layer (`/.go`) +4. Add integration tests to `cloudemu_test.go` +5. Add unit tests to each provider test file +6. Run linter and full test suite + +### Adding a New Service + +1. Create driver interface in `/driver/driver.go` +2. Create provider implementations in `providers/{aws,azure,gcp}//` +3. Add field to each Provider struct +4. Initialize in each `New()` factory +5. Add portable API wrapper +6. Add tests + +### Important Rules + +- All 3 providers (AWS, Azure, GCP) must implement the same behaviors +- Use `cerrors.New()` / `cerrors.Newf()` for error codes +- Use `config.FakeClock` for deterministic time in tests +- Use `memstore.Store[V]` for in-memory storage +- Use `idgen` for cloud-native ID generation + +## Submitting Changes + +1. Ensure all tests pass: `go test ./...` +2. Ensure linter passes: `golangci-lint run --timeout=9m ./...` +3. Push your branch and create a PR against `development` +4. Include a summary of what changed and why in the PR description + +## Reporting Issues + +- Use GitHub Issues to report bugs or request features +- Include steps to reproduce for bug reports +- Tag issues with appropriate labels (aws, azure, gcp, enhancement, bug) + +## License + +By contributing, you agree that your contributions will be licensed under the MIT License. diff --git a/README.md b/README.md index 5878805d..b3f6779c 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ Go Version Providers Zero Cost + Documentation

--- diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..1cf7a261 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,35 @@ +# Security Policy + +## Supported Versions + +| Version | Supported | +|---------|--------------------| +| latest | :white_check_mark: | + +## Reporting a Vulnerability + +If you discover a security vulnerability in CloudEmu, please report it responsibly. + +**Do NOT open a public GitHub issue for security vulnerabilities.** + +Instead, please email **nitinraj7488204975@gmail.com** with: + +- A description of the vulnerability +- Steps to reproduce the issue +- Any potential impact + +We will acknowledge receipt within 48 hours and aim to provide a fix within 7 days for critical issues. + +## Scope + +CloudEmu is an in-memory testing library and does not handle production traffic, secrets, or real cloud credentials. However, we still take security seriously in the following areas: + +- **Code injection** via user-provided inputs (policy documents, filter patterns) +- **Denial of service** via unbounded memory allocation +- **Dependency vulnerabilities** in Go modules + +## Best Practices for Users + +- Never use CloudEmu in production environments — it is designed for testing only +- Do not commit real cloud credentials in test files +- Keep your Go dependencies up to date with `go get -u ./...` diff --git a/cache/cache.go b/cache/cache.go index 830769ff..10814480 100644 --- a/cache/cache.go +++ b/cache/cache.go @@ -178,3 +178,81 @@ func (c *Cache) FlushAll(ctx context.Context, cacheName string) error { _, err := c.do(ctx, "FlushAll", cacheName, func() (any, error) { return nil, c.driver.FlushAll(ctx, cacheName) }) return err } + +// Expire sets a TTL on an existing key. +func (c *Cache) Expire(ctx context.Context, cacheName, key string, ttl time.Duration) error { + _, err := c.do(ctx, "Expire", map[string]string{"cache": cacheName, "key": key}, func() (any, error) { + return nil, c.driver.Expire(ctx, cacheName, key, ttl) + }) + + return err +} + +// GetTTL returns the remaining TTL for a key. Returns -1 if the key has no TTL. +func (c *Cache) GetTTL(ctx context.Context, cacheName, key string) (time.Duration, error) { + out, err := c.do(ctx, "GetTTL", map[string]string{"cache": cacheName, "key": key}, func() (any, error) { + return c.driver.GetTTL(ctx, cacheName, key) + }) + if err != nil { + return 0, err + } + + return out.(time.Duration), nil +} + +// Persist removes the TTL from a key, making it persistent. +func (c *Cache) Persist(ctx context.Context, cacheName, key string) error { + _, err := c.do(ctx, "Persist", map[string]string{"cache": cacheName, "key": key}, func() (any, error) { + return nil, c.driver.Persist(ctx, cacheName, key) + }) + + return err +} + +// Incr atomically increments the integer value of a key by 1. +func (c *Cache) Incr(ctx context.Context, cacheName, key string) (int64, error) { + out, err := c.do(ctx, "Incr", map[string]string{"cache": cacheName, "key": key}, func() (any, error) { + return c.driver.Incr(ctx, cacheName, key) + }) + if err != nil { + return 0, err + } + + return out.(int64), nil +} + +// IncrBy atomically increments the integer value of a key by delta. +func (c *Cache) IncrBy(ctx context.Context, cacheName, key string, delta int64) (int64, error) { + out, err := c.do(ctx, "IncrBy", map[string]string{"cache": cacheName, "key": key}, func() (any, error) { + return c.driver.IncrBy(ctx, cacheName, key, delta) + }) + if err != nil { + return 0, err + } + + return out.(int64), nil +} + +// Decr atomically decrements the integer value of a key by 1. +func (c *Cache) Decr(ctx context.Context, cacheName, key string) (int64, error) { + out, err := c.do(ctx, "Decr", map[string]string{"cache": cacheName, "key": key}, func() (any, error) { + return c.driver.Decr(ctx, cacheName, key) + }) + if err != nil { + return 0, err + } + + return out.(int64), nil +} + +// DecrBy atomically decrements the integer value of a key by delta. +func (c *Cache) DecrBy(ctx context.Context, cacheName, key string, delta int64) (int64, error) { + out, err := c.do(ctx, "DecrBy", map[string]string{"cache": cacheName, "key": key}, func() (any, error) { + return c.driver.DecrBy(ctx, cacheName, key, delta) + }) + if err != nil { + return 0, err + } + + return out.(int64), nil +} diff --git a/cache/driver/driver.go b/cache/driver/driver.go index 9c9bfc8d..0671087f 100644 --- a/cache/driver/driver.go +++ b/cache/driver/driver.go @@ -45,4 +45,15 @@ type Cache interface { Delete(ctx context.Context, cacheName, key string) error Keys(ctx context.Context, cacheName, pattern string) ([]string, error) FlushAll(ctx context.Context, cacheName string) error + + // TTL management + Expire(ctx context.Context, cacheName, key string, ttl time.Duration) error + GetTTL(ctx context.Context, cacheName, key string) (time.Duration, error) + Persist(ctx context.Context, cacheName, key string) error + + // Atomic counters + Incr(ctx context.Context, cacheName, key string) (int64, error) + IncrBy(ctx context.Context, cacheName, key string, delta int64) (int64, error) + Decr(ctx context.Context, cacheName, key string) (int64, error) + DecrBy(ctx context.Context, cacheName, key string, delta int64) (int64, error) } diff --git a/cloudemu_test.go b/cloudemu_test.go index 31c0efef..bbb81c88 100644 --- a/cloudemu_test.go +++ b/cloudemu_test.go @@ -26,6 +26,8 @@ import ( "github.com/stackshy/cloudemu/storage" storagedriver "github.com/stackshy/cloudemu/storage/driver" + lbdriver "github.com/stackshy/cloudemu/loadbalancer/driver" + cachedriver "github.com/stackshy/cloudemu/cache/driver" crdriver "github.com/stackshy/cloudemu/containerregistry/driver" ebdriver "github.com/stackshy/cloudemu/eventbus/driver" @@ -5570,3 +5572,1214 @@ func TestGCPMetricsEmission(t *testing.T) { } }) } + +func TestUpdateItemAWS(t *testing.T) { + ctx := context.Background() + p := NewAWS() + + if err := p.DynamoDB.CreateTable(ctx, driver.TableConfig{ + Name: "users", PartitionKey: "pk", SortKey: "sk", + }); err != nil { + t.Fatal(err) + } + + // Put initial item + if err := p.DynamoDB.PutItem(ctx, "users", map[string]any{ + "pk": "user1", "sk": "profile", "name": "Alice", "age": 30, "city": "NYC", + }); err != nil { + t.Fatal(err) + } + + // SET: update name and add new field + updated, err := p.DynamoDB.UpdateItem(ctx, driver.UpdateItemInput{ + Table: "users", + Key: map[string]any{"pk": "user1", "sk": "profile"}, + Actions: []driver.UpdateAction{ + {Action: "SET", Field: "name", Value: "Alice Smith"}, + {Action: "SET", Field: "email", Value: "alice@example.com"}, + }, + }) + if err != nil { + t.Fatal(err) + } + + if updated["name"] != "Alice Smith" { + t.Errorf("expected 'Alice Smith', got %v", updated["name"]) + } + + if updated["email"] != "alice@example.com" { + t.Errorf("expected 'alice@example.com', got %v", updated["email"]) + } + + if updated["age"] != 30 { + t.Errorf("expected age 30 preserved, got %v", updated["age"]) + } + + // REMOVE: remove city field + updated, err = p.DynamoDB.UpdateItem(ctx, driver.UpdateItemInput{ + Table: "users", + Key: map[string]any{"pk": "user1", "sk": "profile"}, + Actions: []driver.UpdateAction{ + {Action: "REMOVE", Field: "city"}, + }, + }) + if err != nil { + t.Fatal(err) + } + + if _, hasCityField := updated["city"]; hasCityField { + t.Error("expected city field to be removed") + } + + if updated["name"] != "Alice Smith" { + t.Errorf("expected name preserved as 'Alice Smith', got %v", updated["name"]) + } + + // Verify via GetItem + got, err := p.DynamoDB.GetItem(ctx, "users", map[string]any{"pk": "user1", "sk": "profile"}) + if err != nil { + t.Fatal(err) + } + + if got["name"] != "Alice Smith" { + t.Errorf("GetItem: expected 'Alice Smith', got %v", got["name"]) + } + + if got["email"] != "alice@example.com" { + t.Errorf("GetItem: expected 'alice@example.com', got %v", got["email"]) + } + + if _, hasCityField := got["city"]; hasCityField { + t.Error("GetItem: expected city field to be removed") + } +} + +func TestUpdateItemAzure(t *testing.T) { + ctx := context.Background() + p := NewAzure() + + if err := p.CosmosDB.CreateTable(ctx, driver.TableConfig{ + Name: "users", PartitionKey: "pk", SortKey: "sk", + }); err != nil { + t.Fatal(err) + } + + if err := p.CosmosDB.PutItem(ctx, "users", map[string]any{ + "pk": "user1", "sk": "profile", "name": "Alice", "age": 30, "city": "NYC", + }); err != nil { + t.Fatal(err) + } + + // SET fields + updated, err := p.CosmosDB.UpdateItem(ctx, driver.UpdateItemInput{ + Table: "users", + Key: map[string]any{"pk": "user1", "sk": "profile"}, + Actions: []driver.UpdateAction{ + {Action: "SET", Field: "name", Value: "Alice Smith"}, + {Action: "SET", Field: "email", Value: "alice@example.com"}, + }, + }) + if err != nil { + t.Fatal(err) + } + + if updated["name"] != "Alice Smith" { + t.Errorf("expected 'Alice Smith', got %v", updated["name"]) + } + + if updated["email"] != "alice@example.com" { + t.Errorf("expected 'alice@example.com', got %v", updated["email"]) + } + + if updated["age"] != 30 { + t.Errorf("expected age 30 preserved, got %v", updated["age"]) + } + + // REMOVE field + updated, err = p.CosmosDB.UpdateItem(ctx, driver.UpdateItemInput{ + Table: "users", + Key: map[string]any{"pk": "user1", "sk": "profile"}, + Actions: []driver.UpdateAction{ + {Action: "REMOVE", Field: "city"}, + }, + }) + if err != nil { + t.Fatal(err) + } + + if _, hasCityField := updated["city"]; hasCityField { + t.Error("expected city field to be removed") + } + + // Verify via GetItem + got, err := p.CosmosDB.GetItem(ctx, "users", map[string]any{"pk": "user1", "sk": "profile"}) + if err != nil { + t.Fatal(err) + } + + if got["name"] != "Alice Smith" { + t.Errorf("GetItem: expected 'Alice Smith', got %v", got["name"]) + } +} + +func TestUpdateItemGCP(t *testing.T) { + ctx := context.Background() + p := NewGCP() + + if err := p.Firestore.CreateTable(ctx, driver.TableConfig{ + Name: "users", PartitionKey: "pk", SortKey: "sk", + }); err != nil { + t.Fatal(err) + } + + if err := p.Firestore.PutItem(ctx, "users", map[string]any{ + "pk": "user1", "sk": "profile", "name": "Alice", "age": 30, "city": "NYC", + }); err != nil { + t.Fatal(err) + } + + // SET fields + updated, err := p.Firestore.UpdateItem(ctx, driver.UpdateItemInput{ + Table: "users", + Key: map[string]any{"pk": "user1", "sk": "profile"}, + Actions: []driver.UpdateAction{ + {Action: "SET", Field: "name", Value: "Alice Smith"}, + {Action: "SET", Field: "email", Value: "alice@example.com"}, + }, + }) + if err != nil { + t.Fatal(err) + } + + if updated["name"] != "Alice Smith" { + t.Errorf("expected 'Alice Smith', got %v", updated["name"]) + } + + if updated["email"] != "alice@example.com" { + t.Errorf("expected 'alice@example.com', got %v", updated["email"]) + } + + if updated["age"] != 30 { + t.Errorf("expected age 30 preserved, got %v", updated["age"]) + } + + // REMOVE field + updated, err = p.Firestore.UpdateItem(ctx, driver.UpdateItemInput{ + Table: "users", + Key: map[string]any{"pk": "user1", "sk": "profile"}, + Actions: []driver.UpdateAction{ + {Action: "REMOVE", Field: "city"}, + }, + }) + if err != nil { + t.Fatal(err) + } + + if _, hasCityField := updated["city"]; hasCityField { + t.Error("expected city field to be removed") + } + + // Verify via GetItem + got, err := p.Firestore.GetItem(ctx, "users", map[string]any{"pk": "user1", "sk": "profile"}) + if err != nil { + t.Fatal(err) + } + + if got["name"] != "Alice Smith" { + t.Errorf("GetItem: expected 'Alice Smith', got %v", got["name"]) + } +} + +func TestUpdateItemNotFound(t *testing.T) { + ctx := context.Background() + + t.Run("AWS", func(t *testing.T) { + p := NewAWS() + + if err := p.DynamoDB.CreateTable(ctx, driver.TableConfig{ + Name: "t1", PartitionKey: "pk", + }); err != nil { + t.Fatal(err) + } + + _, err := p.DynamoDB.UpdateItem(ctx, driver.UpdateItemInput{ + Table: "t1", + Key: map[string]any{"pk": "missing"}, + Actions: []driver.UpdateAction{{Action: "SET", Field: "x", Value: 1}}, + }) + if !cerrors.IsNotFound(err) { + t.Errorf("expected NotFound, got %v", err) + } + }) + + t.Run("Azure", func(t *testing.T) { + p := NewAzure() + + if err := p.CosmosDB.CreateTable(ctx, driver.TableConfig{ + Name: "t1", PartitionKey: "pk", + }); err != nil { + t.Fatal(err) + } + + _, err := p.CosmosDB.UpdateItem(ctx, driver.UpdateItemInput{ + Table: "t1", + Key: map[string]any{"pk": "missing"}, + Actions: []driver.UpdateAction{{Action: "SET", Field: "x", Value: 1}}, + }) + if !cerrors.IsNotFound(err) { + t.Errorf("expected NotFound, got %v", err) + } + }) + + t.Run("GCP", func(t *testing.T) { + p := NewGCP() + + if err := p.Firestore.CreateTable(ctx, driver.TableConfig{ + Name: "t1", PartitionKey: "pk", + }); err != nil { + t.Fatal(err) + } + + _, err := p.Firestore.UpdateItem(ctx, driver.UpdateItemInput{ + Table: "t1", + Key: map[string]any{"pk": "missing"}, + Actions: []driver.UpdateAction{{Action: "SET", Field: "x", Value: 1}}, + }) + if !cerrors.IsNotFound(err) { + t.Errorf("expected NotFound, got %v", err) + } + }) +} + +func TestUpdateItemTableNotFound(t *testing.T) { + ctx := context.Background() + + t.Run("AWS", func(t *testing.T) { + p := NewAWS() + + _, err := p.DynamoDB.UpdateItem(ctx, driver.UpdateItemInput{ + Table: "nonexistent", + Key: map[string]any{"pk": "x"}, + Actions: []driver.UpdateAction{{Action: "SET", Field: "x", Value: 1}}, + }) + if !cerrors.IsNotFound(err) { + t.Errorf("expected NotFound, got %v", err) + } + }) + + t.Run("Azure", func(t *testing.T) { + p := NewAzure() + + _, err := p.CosmosDB.UpdateItem(ctx, driver.UpdateItemInput{ + Table: "nonexistent", + Key: map[string]any{"pk": "x"}, + Actions: []driver.UpdateAction{{Action: "SET", Field: "x", Value: 1}}, + }) + if !cerrors.IsNotFound(err) { + t.Errorf("expected NotFound, got %v", err) + } + }) + + t.Run("GCP", func(t *testing.T) { + p := NewGCP() + + _, err := p.Firestore.UpdateItem(ctx, driver.UpdateItemInput{ + Table: "nonexistent", + Key: map[string]any{"pk": "x"}, + Actions: []driver.UpdateAction{{Action: "SET", Field: "x", Value: 1}}, + }) + if !cerrors.IsNotFound(err) { + t.Errorf("expected NotFound, got %v", err) + } + }) +} + +func TestUpdateItemInvalidAction(t *testing.T) { + ctx := context.Background() + p := NewAWS() + + if err := p.DynamoDB.CreateTable(ctx, driver.TableConfig{ + Name: "t1", PartitionKey: "pk", + }); err != nil { + t.Fatal(err) + } + + if err := p.DynamoDB.PutItem(ctx, "t1", map[string]any{"pk": "k1", "v": 1}); err != nil { + t.Fatal(err) + } + + _, err := p.DynamoDB.UpdateItem(ctx, driver.UpdateItemInput{ + Table: "t1", + Key: map[string]any{"pk": "k1"}, + Actions: []driver.UpdateAction{{Action: "INVALID", Field: "v", Value: 2}}, + }) + if err == nil { + t.Error("expected error for invalid action, got nil") + } +} + +func TestUpdateItemWithStreams(t *testing.T) { + ctx := context.Background() + p := NewAWS() + + if err := p.DynamoDB.CreateTable(ctx, driver.TableConfig{ + Name: "t1", PartitionKey: "pk", + }); err != nil { + t.Fatal(err) + } + + if err := p.DynamoDB.UpdateStreamConfig(ctx, "t1", driver.StreamConfig{ + Enabled: true, ViewType: "NEW_AND_OLD_IMAGES", + }); err != nil { + t.Fatal(err) + } + + if err := p.DynamoDB.PutItem(ctx, "t1", map[string]any{"pk": "k1", "val": "old"}); err != nil { + t.Fatal(err) + } + + _, err := p.DynamoDB.UpdateItem(ctx, driver.UpdateItemInput{ + Table: "t1", + Key: map[string]any{"pk": "k1"}, + Actions: []driver.UpdateAction{{Action: "SET", Field: "val", Value: "new"}}, + }) + if err != nil { + t.Fatal(err) + } + + iter, err := p.DynamoDB.GetStreamRecords(ctx, "t1", 10, "") + if err != nil { + t.Fatal(err) + } + + // Should have INSERT (from PutItem) + MODIFY (from UpdateItem) + if len(iter.Records) != 2 { + t.Fatalf("expected 2 stream records, got %d", len(iter.Records)) + } + + modifyRec := iter.Records[1] + if modifyRec.EventType != "MODIFY" { + t.Errorf("expected MODIFY event, got %s", modifyRec.EventType) + } + + if modifyRec.OldImage["val"] != "old" { + t.Errorf("expected old image val='old', got %v", modifyRec.OldImage["val"]) + } + + if modifyRec.NewImage["val"] != "new" { + t.Errorf("expected new image val='new', got %v", modifyRec.NewImage["val"]) + } +} + +func TestCacheExpireAndPersistAWS(t *testing.T) { + ctx := context.Background() + p := NewAWS() + + _, err := p.ElastiCache.CreateCache(ctx, cachedriver.CacheConfig{Name: "c1"}) + if err != nil { + t.Fatal(err) + } + + if err := p.ElastiCache.Set(ctx, "c1", "k1", []byte("val"), 0); err != nil { + t.Fatal(err) + } + + ttl, err := p.ElastiCache.GetTTL(ctx, "c1", "k1") + if err != nil { + t.Fatal(err) + } + + if ttl != -1 { + t.Errorf("expected TTL -1, got %v", ttl) + } + + if err := p.ElastiCache.Expire(ctx, "c1", "k1", 1*time.Hour); err != nil { + t.Fatal(err) + } + + ttl, err = p.ElastiCache.GetTTL(ctx, "c1", "k1") + if err != nil { + t.Fatal(err) + } + + if ttl <= 0 { + t.Errorf("expected positive TTL, got %v", ttl) + } + + if err := p.ElastiCache.Persist(ctx, "c1", "k1"); err != nil { + t.Fatal(err) + } + + ttl, err = p.ElastiCache.GetTTL(ctx, "c1", "k1") + if err != nil { + t.Fatal(err) + } + + if ttl != -1 { + t.Errorf("expected TTL -1 after Persist, got %v", ttl) + } +} + +func TestCacheExpireAndPersistAzure(t *testing.T) { + ctx := context.Background() + p := NewAzure() + + _, err := p.Cache.CreateCache(ctx, cachedriver.CacheConfig{Name: "c1"}) + if err != nil { + t.Fatal(err) + } + + if err := p.Cache.Set(ctx, "c1", "k1", []byte("val"), 0); err != nil { + t.Fatal(err) + } + + if err := p.Cache.Expire(ctx, "c1", "k1", 1*time.Hour); err != nil { + t.Fatal(err) + } + + ttl, err := p.Cache.GetTTL(ctx, "c1", "k1") + if err != nil { + t.Fatal(err) + } + + if ttl <= 0 { + t.Errorf("expected positive TTL, got %v", ttl) + } + + if err := p.Cache.Persist(ctx, "c1", "k1"); err != nil { + t.Fatal(err) + } + + ttl, err = p.Cache.GetTTL(ctx, "c1", "k1") + if err != nil { + t.Fatal(err) + } + + if ttl != -1 { + t.Errorf("expected TTL -1 after Persist, got %v", ttl) + } +} + +func TestCacheExpireAndPersistGCP(t *testing.T) { + ctx := context.Background() + p := NewGCP() + + _, err := p.Memorystore.CreateCache(ctx, cachedriver.CacheConfig{Name: "c1"}) + if err != nil { + t.Fatal(err) + } + + if err := p.Memorystore.Set(ctx, "c1", "k1", []byte("val"), 0); err != nil { + t.Fatal(err) + } + + if err := p.Memorystore.Expire(ctx, "c1", "k1", 1*time.Hour); err != nil { + t.Fatal(err) + } + + ttl, err := p.Memorystore.GetTTL(ctx, "c1", "k1") + if err != nil { + t.Fatal(err) + } + + if ttl <= 0 { + t.Errorf("expected positive TTL, got %v", ttl) + } + + if err := p.Memorystore.Persist(ctx, "c1", "k1"); err != nil { + t.Fatal(err) + } + + ttl, err = p.Memorystore.GetTTL(ctx, "c1", "k1") + if err != nil { + t.Fatal(err) + } + + if ttl != -1 { + t.Errorf("expected TTL -1 after Persist, got %v", ttl) + } +} + +func TestCacheIncrDecrAWS(t *testing.T) { + ctx := context.Background() + p := NewAWS() + + _, err := p.ElastiCache.CreateCache(ctx, cachedriver.CacheConfig{Name: "c1"}) + if err != nil { + t.Fatal(err) + } + + val, err := p.ElastiCache.Incr(ctx, "c1", "counter") + if err != nil { + t.Fatal(err) + } + + if val != 1 { + t.Errorf("expected 1, got %d", val) + } + + val, err = p.ElastiCache.IncrBy(ctx, "c1", "counter", 9) + if err != nil { + t.Fatal(err) + } + + if val != 10 { + t.Errorf("expected 10, got %d", val) + } + + val, err = p.ElastiCache.Decr(ctx, "c1", "counter") + if err != nil { + t.Fatal(err) + } + + if val != 9 { + t.Errorf("expected 9, got %d", val) + } + + val, err = p.ElastiCache.DecrBy(ctx, "c1", "counter", 4) + if err != nil { + t.Fatal(err) + } + + if val != 5 { + t.Errorf("expected 5, got %d", val) + } + + item, err := p.ElastiCache.Get(ctx, "c1", "counter") + if err != nil { + t.Fatal(err) + } + + if string(item.Value) != "5" { + t.Errorf("expected '5', got %q", string(item.Value)) + } +} + +func TestCacheIncrDecrAzure(t *testing.T) { + ctx := context.Background() + p := NewAzure() + + _, err := p.Cache.CreateCache(ctx, cachedriver.CacheConfig{Name: "c1"}) + if err != nil { + t.Fatal(err) + } + + val, err := p.Cache.Incr(ctx, "c1", "counter") + if err != nil { + t.Fatal(err) + } + + if val != 1 { + t.Errorf("expected 1, got %d", val) + } + + val, err = p.Cache.IncrBy(ctx, "c1", "counter", 9) + if err != nil { + t.Fatal(err) + } + + if val != 10 { + t.Errorf("expected 10, got %d", val) + } + + val, err = p.Cache.Decr(ctx, "c1", "counter") + if err != nil { + t.Fatal(err) + } + + if val != 9 { + t.Errorf("expected 9, got %d", val) + } + + val, err = p.Cache.DecrBy(ctx, "c1", "counter", 4) + if err != nil { + t.Fatal(err) + } + + if val != 5 { + t.Errorf("expected 5, got %d", val) + } +} + +func TestCacheIncrDecrGCP(t *testing.T) { + ctx := context.Background() + p := NewGCP() + + _, err := p.Memorystore.CreateCache(ctx, cachedriver.CacheConfig{Name: "c1"}) + if err != nil { + t.Fatal(err) + } + + val, err := p.Memorystore.Incr(ctx, "c1", "counter") + if err != nil { + t.Fatal(err) + } + + if val != 1 { + t.Errorf("expected 1, got %d", val) + } + + val, err = p.Memorystore.IncrBy(ctx, "c1", "counter", 9) + if err != nil { + t.Fatal(err) + } + + if val != 10 { + t.Errorf("expected 10, got %d", val) + } + + val, err = p.Memorystore.Decr(ctx, "c1", "counter") + if err != nil { + t.Fatal(err) + } + + if val != 9 { + t.Errorf("expected 9, got %d", val) + } + + val, err = p.Memorystore.DecrBy(ctx, "c1", "counter", 4) + if err != nil { + t.Fatal(err) + } + + if val != 5 { + t.Errorf("expected 5, got %d", val) + } +} + +func TestBucketPolicyAWS(t *testing.T) { + ctx := context.Background() + p := NewAWS() + + if err := p.S3.CreateBucket(ctx, "b1"); err != nil { + t.Fatal(err) + } + + policy := storagedriver.BucketPolicy{ + Version: "2012-10-17", + Statements: []storagedriver.PolicyStatement{ + {Effect: "Allow", Principal: "*", Actions: []string{"s3:GetObject"}, Resources: []string{"arn:aws:s3:::b1/*"}}, + }, + } + + if err := p.S3.PutBucketPolicy(ctx, "b1", policy); err != nil { + t.Fatal(err) + } + + got, err := p.S3.GetBucketPolicy(ctx, "b1") + if err != nil { + t.Fatal(err) + } + + if got.Version != "2012-10-17" { + t.Errorf("expected version '2012-10-17', got %q", got.Version) + } + + if len(got.Statements) != 1 { + t.Fatalf("expected 1 statement, got %d", len(got.Statements)) + } + + if got.Statements[0].Effect != "Allow" { + t.Errorf("expected effect 'Allow', got %q", got.Statements[0].Effect) + } + + if err := p.S3.DeleteBucketPolicy(ctx, "b1"); err != nil { + t.Fatal(err) + } + + _, err = p.S3.GetBucketPolicy(ctx, "b1") + if !cerrors.IsNotFound(err) { + t.Errorf("expected NotFound after delete, got %v", err) + } +} + +func TestBucketPolicyAzure(t *testing.T) { + ctx := context.Background() + p := NewAzure() + + if err := p.BlobStorage.CreateBucket(ctx, "c1"); err != nil { + t.Fatal(err) + } + + policy := storagedriver.BucketPolicy{ + Version: "1.0", + Statements: []storagedriver.PolicyStatement{ + {Effect: "Allow", Principal: "*", Actions: []string{"read"}, Resources: []string{"c1/*"}}, + }, + } + + if err := p.BlobStorage.PutBucketPolicy(ctx, "c1", policy); err != nil { + t.Fatal(err) + } + + got, err := p.BlobStorage.GetBucketPolicy(ctx, "c1") + if err != nil { + t.Fatal(err) + } + + if len(got.Statements) != 1 { + t.Fatalf("expected 1 statement, got %d", len(got.Statements)) + } +} + +func TestBucketPolicyGCP(t *testing.T) { + ctx := context.Background() + p := NewGCP() + + if err := p.GCS.CreateBucket(ctx, "b1"); err != nil { + t.Fatal(err) + } + + policy := storagedriver.BucketPolicy{ + Version: "1", + Statements: []storagedriver.PolicyStatement{ + {Effect: "Allow", Principal: "allUsers", Actions: []string{"storage.objects.get"}, Resources: []string{"b1/*"}}, + }, + } + + if err := p.GCS.PutBucketPolicy(ctx, "b1", policy); err != nil { + t.Fatal(err) + } + + got, err := p.GCS.GetBucketPolicy(ctx, "b1") + if err != nil { + t.Fatal(err) + } + + if len(got.Statements) != 1 { + t.Fatalf("expected 1 statement, got %d", len(got.Statements)) + } +} + +func TestCORSConfigAWS(t *testing.T) { + ctx := context.Background() + p := NewAWS() + + if err := p.S3.CreateBucket(ctx, "b1"); err != nil { + t.Fatal(err) + } + + cors := storagedriver.CORSConfig{ + Rules: []storagedriver.CORSRule{ + { + AllowedOrigins: []string{"https://example.com"}, + AllowedMethods: []string{"GET", "PUT"}, + AllowedHeaders: []string{"*"}, + MaxAgeSeconds: 3600, + }, + }, + } + + if err := p.S3.PutCORSConfig(ctx, "b1", cors); err != nil { + t.Fatal(err) + } + + got, err := p.S3.GetCORSConfig(ctx, "b1") + if err != nil { + t.Fatal(err) + } + + if len(got.Rules) != 1 { + t.Fatalf("expected 1 CORS rule, got %d", len(got.Rules)) + } + + if got.Rules[0].AllowedOrigins[0] != "https://example.com" { + t.Errorf("expected origin 'https://example.com', got %q", got.Rules[0].AllowedOrigins[0]) + } + + if err := p.S3.DeleteCORSConfig(ctx, "b1"); err != nil { + t.Fatal(err) + } + + _, err = p.S3.GetCORSConfig(ctx, "b1") + if !cerrors.IsNotFound(err) { + t.Errorf("expected NotFound after delete, got %v", err) + } +} + +func TestEncryptionConfigAWS(t *testing.T) { + ctx := context.Background() + p := NewAWS() + + if err := p.S3.CreateBucket(ctx, "b1"); err != nil { + t.Fatal(err) + } + + enc := storagedriver.EncryptionConfig{ + Enabled: true, + Algorithm: "AES256", + } + + if err := p.S3.PutEncryptionConfig(ctx, "b1", enc); err != nil { + t.Fatal(err) + } + + got, err := p.S3.GetEncryptionConfig(ctx, "b1") + if err != nil { + t.Fatal(err) + } + + if !got.Enabled { + t.Error("expected encryption enabled") + } + + if got.Algorithm != "AES256" { + t.Errorf("expected algorithm 'AES256', got %q", got.Algorithm) + } +} + +func TestEncryptionConfigAzure(t *testing.T) { + ctx := context.Background() + p := NewAzure() + + if err := p.BlobStorage.CreateBucket(ctx, "c1"); err != nil { + t.Fatal(err) + } + + enc := storagedriver.EncryptionConfig{ + Enabled: true, + Algorithm: "AES256", + } + + if err := p.BlobStorage.PutEncryptionConfig(ctx, "c1", enc); err != nil { + t.Fatal(err) + } + + got, err := p.BlobStorage.GetEncryptionConfig(ctx, "c1") + if err != nil { + t.Fatal(err) + } + + if !got.Enabled { + t.Error("expected encryption enabled") + } +} + +func TestEncryptionConfigGCP(t *testing.T) { + ctx := context.Background() + p := NewGCP() + + if err := p.GCS.CreateBucket(ctx, "b1"); err != nil { + t.Fatal(err) + } + + enc := storagedriver.EncryptionConfig{ + Enabled: true, + Algorithm: "AES256", + } + + if err := p.GCS.PutEncryptionConfig(ctx, "b1", enc); err != nil { + t.Fatal(err) + } + + got, err := p.GCS.GetEncryptionConfig(ctx, "b1") + if err != nil { + t.Fatal(err) + } + + if !got.Enabled { + t.Error("expected encryption enabled") + } +} + +func TestListenerRulesAWS(t *testing.T) { + ctx := context.Background() + p := NewAWS() + + lb, err := p.ELB.CreateLoadBalancer(ctx, lbdriver.LBConfig{ + Name: "test-lb", Type: "application", Scheme: "internet-facing", + }) + if err != nil { + t.Fatal(err) + } + + tg, err := p.ELB.CreateTargetGroup(ctx, lbdriver.TargetGroupConfig{ + Name: "test-tg", Protocol: "HTTP", Port: 80, VPCID: "vpc-1", + }) + if err != nil { + t.Fatal(err) + } + + li, err := p.ELB.CreateListener(ctx, lbdriver.ListenerConfig{ + LBARN: lb.ARN, Protocol: "HTTP", Port: 80, TargetGroupARN: tg.ARN, + }) + if err != nil { + t.Fatal(err) + } + + // Create rules with path conditions + rule1, err := p.ELB.CreateRule(ctx, lbdriver.RuleConfig{ + ListenerARN: li.ARN, + Priority: 10, + Conditions: []lbdriver.RuleCondition{{Field: "path-pattern", Values: []string{"/api/*"}}}, + Actions: []lbdriver.RuleAction{{Type: "forward", TargetGroupARN: tg.ARN}}, + }) + if err != nil { + t.Fatal(err) + } + + if rule1.ARN == "" { + t.Error("expected non-empty rule ARN") + } + + if rule1.Priority != 10 { + t.Errorf("expected priority 10, got %d", rule1.Priority) + } + + _, err = p.ELB.CreateRule(ctx, lbdriver.RuleConfig{ + ListenerARN: li.ARN, + Priority: 20, + Conditions: []lbdriver.RuleCondition{{Field: "host-header", Values: []string{"example.com"}}}, + Actions: []lbdriver.RuleAction{{Type: "forward", TargetGroupARN: tg.ARN}}, + }) + if err != nil { + t.Fatal(err) + } + + // Describe rules + rules, err := p.ELB.DescribeRules(ctx, li.ARN) + if err != nil { + t.Fatal(err) + } + + if len(rules) != 2 { + t.Errorf("expected 2 rules, got %d", len(rules)) + } + + // Delete a rule + if err := p.ELB.DeleteRule(ctx, rule1.ARN); err != nil { + t.Fatal(err) + } + + rules, err = p.ELB.DescribeRules(ctx, li.ARN) + if err != nil { + t.Fatal(err) + } + + if len(rules) != 1 { + t.Errorf("expected 1 rule after deletion, got %d", len(rules)) + } +} + +func TestModifyListenerAWS(t *testing.T) { + ctx := context.Background() + p := NewAWS() + + lb, err := p.ELB.CreateLoadBalancer(ctx, lbdriver.LBConfig{ + Name: "test-lb", Type: "application", Scheme: "internet-facing", + }) + if err != nil { + t.Fatal(err) + } + + tg, err := p.ELB.CreateTargetGroup(ctx, lbdriver.TargetGroupConfig{ + Name: "test-tg", Protocol: "HTTP", Port: 80, VPCID: "vpc-1", + }) + if err != nil { + t.Fatal(err) + } + + li, err := p.ELB.CreateListener(ctx, lbdriver.ListenerConfig{ + LBARN: lb.ARN, Protocol: "HTTP", Port: 80, TargetGroupARN: tg.ARN, + }) + if err != nil { + t.Fatal(err) + } + + // Modify port + if err := p.ELB.ModifyListener(ctx, lbdriver.ModifyListenerInput{ + ListenerARN: li.ARN, Port: 8080, + }); err != nil { + t.Fatal(err) + } + + listeners, err := p.ELB.DescribeListeners(ctx, lb.ARN) + if err != nil { + t.Fatal(err) + } + + if len(listeners) != 1 { + t.Fatalf("expected 1 listener, got %d", len(listeners)) + } + + if listeners[0].Port != 8080 { + t.Errorf("expected port 8080, got %d", listeners[0].Port) + } +} + +func TestLBAttributesAWS(t *testing.T) { + ctx := context.Background() + p := NewAWS() + + lb, err := p.ELB.CreateLoadBalancer(ctx, lbdriver.LBConfig{ + Name: "test-lb", Type: "application", Scheme: "internet-facing", + }) + if err != nil { + t.Fatal(err) + } + + // Get default attributes + attrs, err := p.ELB.GetLBAttributes(ctx, lb.ARN) + if err != nil { + t.Fatal(err) + } + + if attrs.IdleTimeout != 60 { + t.Errorf("expected default idle timeout 60, got %d", attrs.IdleTimeout) + } + + // Put custom attributes + if err := p.ELB.PutLBAttributes(ctx, lb.ARN, lbdriver.LBAttributes{ + IdleTimeout: 120, + DeletionProtection: true, + AccessLogsEnabled: true, + AccessLogsBucket: "my-access-logs", + }); err != nil { + t.Fatal(err) + } + + attrs, err = p.ELB.GetLBAttributes(ctx, lb.ARN) + if err != nil { + t.Fatal(err) + } + + if attrs.IdleTimeout != 120 { + t.Errorf("expected idle timeout 120, got %d", attrs.IdleTimeout) + } + + if !attrs.DeletionProtection { + t.Error("expected deletion protection enabled") + } + + if !attrs.AccessLogsEnabled { + t.Error("expected access logs enabled") + } + + if attrs.AccessLogsBucket != "my-access-logs" { + t.Errorf("expected bucket 'my-access-logs', got %q", attrs.AccessLogsBucket) + } +} + +func TestListenerRulesAzure(t *testing.T) { + ctx := context.Background() + p := NewAzure() + + lb, err := p.LB.CreateLoadBalancer(ctx, lbdriver.LBConfig{ + Name: "test-lb", Type: "application", Scheme: "internet-facing", + }) + if err != nil { + t.Fatal(err) + } + + tg, err := p.LB.CreateTargetGroup(ctx, lbdriver.TargetGroupConfig{ + Name: "test-tg", Protocol: "HTTP", Port: 80, VPCID: "vnet-1", + }) + if err != nil { + t.Fatal(err) + } + + li, err := p.LB.CreateListener(ctx, lbdriver.ListenerConfig{ + LBARN: lb.ARN, Protocol: "HTTP", Port: 80, TargetGroupARN: tg.ARN, + }) + if err != nil { + t.Fatal(err) + } + + rule, err := p.LB.CreateRule(ctx, lbdriver.RuleConfig{ + ListenerARN: li.ARN, + Priority: 10, + Conditions: []lbdriver.RuleCondition{{Field: "path-pattern", Values: []string{"/api/*"}}}, + Actions: []lbdriver.RuleAction{{Type: "forward", TargetGroupARN: tg.ARN}}, + }) + if err != nil { + t.Fatal(err) + } + + if rule.ARN == "" { + t.Error("expected non-empty rule ARN") + } + + rules, err := p.LB.DescribeRules(ctx, li.ARN) + if err != nil { + t.Fatal(err) + } + + if len(rules) != 1 { + t.Errorf("expected 1 rule, got %d", len(rules)) + } + + if err := p.LB.DeleteRule(ctx, rule.ARN); err != nil { + t.Fatal(err) + } + + rules, err = p.LB.DescribeRules(ctx, li.ARN) + if err != nil { + t.Fatal(err) + } + + if len(rules) != 0 { + t.Errorf("expected 0 rules after deletion, got %d", len(rules)) + } +} + +func TestListenerRulesGCP(t *testing.T) { + ctx := context.Background() + p := NewGCP() + + lb, err := p.LB.CreateLoadBalancer(ctx, lbdriver.LBConfig{ + Name: "test-lb", Type: "application", Scheme: "internet-facing", + }) + if err != nil { + t.Fatal(err) + } + + tg, err := p.LB.CreateTargetGroup(ctx, lbdriver.TargetGroupConfig{ + Name: "test-tg", Protocol: "HTTP", Port: 80, VPCID: "vpc-1", + }) + if err != nil { + t.Fatal(err) + } + + li, err := p.LB.CreateListener(ctx, lbdriver.ListenerConfig{ + LBARN: lb.ARN, Protocol: "HTTP", Port: 80, TargetGroupARN: tg.ARN, + }) + if err != nil { + t.Fatal(err) + } + + rule, err := p.LB.CreateRule(ctx, lbdriver.RuleConfig{ + ListenerARN: li.ARN, + Priority: 10, + Conditions: []lbdriver.RuleCondition{{Field: "path-pattern", Values: []string{"/api/*"}}}, + Actions: []lbdriver.RuleAction{{Type: "forward", TargetGroupARN: tg.ARN}}, + }) + if err != nil { + t.Fatal(err) + } + + if rule.ARN == "" { + t.Error("expected non-empty rule ARN") + } + + rules, err := p.LB.DescribeRules(ctx, li.ARN) + if err != nil { + t.Fatal(err) + } + + if len(rules) != 1 { + t.Errorf("expected 1 rule, got %d", len(rules)) + } + + if err := p.LB.DeleteRule(ctx, rule.ARN); err != nil { + t.Fatal(err) + } + + rules, err = p.LB.DescribeRules(ctx, li.ARN) + if err != nil { + t.Fatal(err) + } + + if len(rules) != 0 { + t.Errorf("expected 0 rules after deletion, got %d", len(rules)) + } +} diff --git a/database/database.go b/database/database.go index de67ba24..40ccdf4f 100644 --- a/database/database.go +++ b/database/database.go @@ -135,6 +135,22 @@ func (db *Database) GetItem(ctx context.Context, table string, key map[string]an return out.(map[string]any), nil } +func (db *Database) UpdateItem(ctx context.Context, input driver.UpdateItemInput) (map[string]any, error) { + out, err := db.do(ctx, "UpdateItem", map[string]any{"table": input.Table}, func() (any, error) { + return db.driver.UpdateItem(ctx, input) + }) + + if err != nil { + return nil, err + } + + if out == nil { + return nil, nil + } + + return out.(map[string]any), nil +} + func (db *Database) DeleteItem(ctx context.Context, table string, key map[string]any) error { _, err := db.do(ctx, "DeleteItem", map[string]any{"table": table}, func() (any, error) { return nil, db.driver.DeleteItem(ctx, table, key) diff --git a/database/driver/driver.go b/database/driver/driver.go index c0028bf3..b7dc29d3 100644 --- a/database/driver/driver.go +++ b/database/driver/driver.go @@ -52,6 +52,20 @@ type GSIConfig struct { SortKey string } +// UpdateAction represents a single field-level update action. +type UpdateAction struct { + Action string // "SET" or "REMOVE" + Field string + Value any // ignored for REMOVE +} + +// UpdateItemInput describes an update operation on an existing item. +type UpdateItemInput struct { + Table string + Key map[string]any + Actions []UpdateAction +} + // KeyCondition defines a key condition for queries. type KeyCondition struct { PartitionKey string @@ -102,6 +116,7 @@ type Database interface { PutItem(ctx context.Context, table string, item map[string]any) error GetItem(ctx context.Context, table string, key map[string]any) (map[string]any, error) + UpdateItem(ctx context.Context, input UpdateItemInput) (map[string]any, error) DeleteItem(ctx context.Context, table string, key map[string]any) error Query(ctx context.Context, input QueryInput) (*QueryResult, error) Scan(ctx context.Context, input ScanInput) (*QueryResult, error) diff --git a/loadbalancer/driver/driver.go b/loadbalancer/driver/driver.go index 3c0b8f2c..2543e949 100644 --- a/loadbalancer/driver/driver.go +++ b/loadbalancer/driver/driver.go @@ -64,6 +64,52 @@ type ListenerInfo struct { TargetGroupARN string } +// RuleCondition describes a condition for a listener rule (e.g., path-pattern or host-header). +type RuleCondition struct { + Field string // "path-pattern" or "host-header" + Values []string +} + +// RuleAction describes an action for a listener rule (e.g., forward to a target group). +type RuleAction struct { + Type string // "forward" + TargetGroupARN string +} + +// RuleConfig describes a listener rule to create. +type RuleConfig struct { + ListenerARN string + Priority int + Conditions []RuleCondition + Actions []RuleAction +} + +// RuleInfo describes a listener rule. +type RuleInfo struct { + ARN string + ListenerARN string + Priority int + Conditions []RuleCondition + Actions []RuleAction + IsDefault bool +} + +// ModifyListenerInput describes modifications to apply to a listener. +type ModifyListenerInput struct { + ListenerARN string + Port int + Protocol string + DefaultActions []RuleAction +} + +// LBAttributes describes configurable attributes of a load balancer. +type LBAttributes struct { + IdleTimeout int + DeletionProtection bool + AccessLogsEnabled bool + AccessLogsBucket string +} + // Target identifies a target (e.g., instance) in a target group. type Target struct { ID string @@ -91,6 +137,15 @@ type LoadBalancer interface { DeleteListener(ctx context.Context, arn string) error DescribeListeners(ctx context.Context, lbARN string) ([]ListenerInfo, error) + CreateRule(ctx context.Context, config RuleConfig) (*RuleInfo, error) + DeleteRule(ctx context.Context, ruleARN string) error + DescribeRules(ctx context.Context, listenerARN string) ([]RuleInfo, error) + + ModifyListener(ctx context.Context, input ModifyListenerInput) error + + GetLBAttributes(ctx context.Context, lbARN string) (*LBAttributes, error) + PutLBAttributes(ctx context.Context, lbARN string, attrs LBAttributes) error + RegisterTargets(ctx context.Context, targetGroupARN string, targets []Target) error DeregisterTargets(ctx context.Context, targetGroupARN string, targets []Target) error DescribeTargetHealth(ctx context.Context, targetGroupARN string) ([]TargetHealth, error) diff --git a/loadbalancer/loadbalancer.go b/loadbalancer/loadbalancer.go index 46ce862b..c5fcd9fa 100644 --- a/loadbalancer/loadbalancer.go +++ b/loadbalancer/loadbalancer.go @@ -154,6 +154,48 @@ func (lb *LB) DescribeListeners(ctx context.Context, lbARN string) ([]driver.Lis return out.([]driver.ListenerInfo), nil } +func (lb *LB) CreateRule(ctx context.Context, config driver.RuleConfig) (*driver.RuleInfo, error) { + out, err := lb.do(ctx, "CreateRule", config, func() (any, error) { return lb.driver.CreateRule(ctx, config) }) + if err != nil { + return nil, err + } + + return out.(*driver.RuleInfo), nil +} + +func (lb *LB) DeleteRule(ctx context.Context, ruleARN string) error { + _, err := lb.do(ctx, "DeleteRule", ruleARN, func() (any, error) { return nil, lb.driver.DeleteRule(ctx, ruleARN) }) + return err +} + +func (lb *LB) DescribeRules(ctx context.Context, listenerARN string) ([]driver.RuleInfo, error) { + out, err := lb.do(ctx, "DescribeRules", listenerARN, func() (any, error) { return lb.driver.DescribeRules(ctx, listenerARN) }) + if err != nil { + return nil, err + } + + return out.([]driver.RuleInfo), nil +} + +func (lb *LB) ModifyListener(ctx context.Context, input driver.ModifyListenerInput) error { + _, err := lb.do(ctx, "ModifyListener", input, func() (any, error) { return nil, lb.driver.ModifyListener(ctx, input) }) + return err +} + +func (lb *LB) GetLBAttributes(ctx context.Context, lbARN string) (*driver.LBAttributes, error) { + out, err := lb.do(ctx, "GetLBAttributes", lbARN, func() (any, error) { return lb.driver.GetLBAttributes(ctx, lbARN) }) + if err != nil { + return nil, err + } + + return out.(*driver.LBAttributes), nil +} + +func (lb *LB) PutLBAttributes(ctx context.Context, lbARN string, attrs driver.LBAttributes) error { + _, err := lb.do(ctx, "PutLBAttributes", lbARN, func() (any, error) { return nil, lb.driver.PutLBAttributes(ctx, lbARN, attrs) }) + return err +} + func (lb *LB) RegisterTargets(ctx context.Context, tgARN string, targets []driver.Target) error { _, err := lb.do(ctx, "RegisterTargets", tgARN, func() (any, error) { return nil, lb.driver.RegisterTargets(ctx, tgARN, targets) }) return err diff --git a/loadbalancer/loadbalancer_test.go b/loadbalancer/loadbalancer_test.go index d40e9758..e5a0c000 100644 --- a/loadbalancer/loadbalancer_test.go +++ b/loadbalancer/loadbalancer_test.go @@ -19,7 +19,9 @@ type mockDriver struct { lbs map[string]*driver.LBInfo targetGroups map[string]*driver.TargetGroupInfo listeners map[string]*driver.ListenerInfo + rules map[string]*driver.RuleInfo targets map[string][]driver.TargetHealth + attrs map[string]driver.LBAttributes seq int } @@ -28,7 +30,9 @@ func newMockDriver() *mockDriver { lbs: make(map[string]*driver.LBInfo), targetGroups: make(map[string]*driver.TargetGroupInfo), listeners: make(map[string]*driver.ListenerInfo), + rules: make(map[string]*driver.RuleInfo), targets: make(map[string][]driver.TargetHealth), + attrs: make(map[string]driver.LBAttributes), } } @@ -201,6 +205,83 @@ func (m *mockDriver) SetTargetHealth(_ context.Context, tgARN, targetID, state s return fmt.Errorf("target not found") } +func (m *mockDriver) CreateRule(_ context.Context, config driver.RuleConfig) (*driver.RuleInfo, error) { + if _, ok := m.listeners[config.ListenerARN]; !ok { + return nil, fmt.Errorf("listener not found") + } + + arn := "arn:rule/" + m.nextID("rule") + info := &driver.RuleInfo{ + ARN: arn, ListenerARN: config.ListenerARN, Priority: config.Priority, + Conditions: config.Conditions, Actions: config.Actions, + } + m.rules[arn] = info + + return info, nil +} + +func (m *mockDriver) DeleteRule(_ context.Context, ruleARN string) error { + if _, ok := m.rules[ruleARN]; !ok { + return fmt.Errorf("rule not found") + } + + delete(m.rules, ruleARN) + + return nil +} + +func (m *mockDriver) DescribeRules(_ context.Context, listenerARN string) ([]driver.RuleInfo, error) { + var result []driver.RuleInfo + + for _, r := range m.rules { + if r.ListenerARN == listenerARN { + result = append(result, *r) + } + } + + return result, nil +} + +func (m *mockDriver) ModifyListener(_ context.Context, input driver.ModifyListenerInput) error { + li, ok := m.listeners[input.ListenerARN] + if !ok { + return fmt.Errorf("listener not found") + } + + if input.Port != 0 { + li.Port = input.Port + } + + if input.Protocol != "" { + li.Protocol = input.Protocol + } + + return nil +} + +func (m *mockDriver) GetLBAttributes(_ context.Context, lbARN string) (*driver.LBAttributes, error) { + if _, ok := m.lbs[lbARN]; !ok { + return nil, fmt.Errorf("lb not found") + } + + attrs, ok := m.attrs[lbARN] + if !ok { + attrs = driver.LBAttributes{IdleTimeout: 60} + } + + return &attrs, nil +} + +func (m *mockDriver) PutLBAttributes(_ context.Context, lbARN string, attrs driver.LBAttributes) error { + if _, ok := m.lbs[lbARN]; !ok { + return fmt.Errorf("lb not found") + } + + m.attrs[lbARN] = attrs + + return nil +} + func newTestLB(opts ...Option) *LB { return NewLB(newMockDriver(), opts...) } diff --git a/providers/aws/dynamodb/dynamodb.go b/providers/aws/dynamodb/dynamodb.go index 65c64f3a..8f8dc232 100644 --- a/providers/aws/dynamodb/dynamodb.go +++ b/providers/aws/dynamodb/dynamodb.go @@ -190,6 +190,50 @@ func (m *Mock) GetItem(_ context.Context, table string, key map[string]any) (map return item, nil } +// UpdateItem applies partial updates to an existing item. +func (m *Mock) UpdateItem(_ context.Context, input driver.UpdateItemInput) (map[string]any, error) { + m.mu.Lock() + + td, exists := m.tables[input.Table] + if !exists { + m.mu.Unlock() + return nil, cerrors.Newf(cerrors.NotFound, "table %s not found", input.Table) + } + + k := itemKey(td.config, input.Key) + item, ok := td.items.Get(k) + + if !ok { + m.mu.Unlock() + return nil, cerrors.New(cerrors.NotFound, "item not found") + } + + oldItem := copyItem(item) + updated := copyItem(item) + + for _, action := range input.Actions { + switch action.Action { + case "SET": + updated[action.Field] = action.Value + case "REMOVE": + delete(updated, action.Field) + default: + m.mu.Unlock() + return nil, cerrors.Newf(cerrors.InvalidArgument, "unsupported action: %s", action.Action) + } + } + + td.items.Set(k, updated) + m.recordStreamEvent(td, oldItem, updated, true) + m.mu.Unlock() + + dims := map[string]string{"TableName": input.Table} + m.emitMetric("ConsumedWriteCapacityUnits", 1, dims) + m.emitMetric("SuccessfulRequestCount", 1, dims) + + return updated, nil +} + func (m *Mock) DeleteItem(_ context.Context, table string, key map[string]any) error { m.mu.Lock() diff --git a/providers/aws/dynamodb/dynamodb_test.go b/providers/aws/dynamodb/dynamodb_test.go index 51e5a4e2..36efbcd8 100644 --- a/providers/aws/dynamodb/dynamodb_test.go +++ b/providers/aws/dynamodb/dynamodb_test.go @@ -714,3 +714,193 @@ func assertNotEmpty(t *testing.T, s string) { t.Error("expected non-empty string") } } + +func TestUpdateItemSetFields(t *testing.T) { + m := newTestMock() + ctx := context.Background() + createTestTable(m, "tbl") + + _ = m.PutItem(ctx, "tbl", map[string]any{"pk": "u1", "sk": "info", "name": "Alice", "age": 30}) + + updated, err := m.UpdateItem(ctx, driver.UpdateItemInput{ + Table: "tbl", + Key: map[string]any{"pk": "u1", "sk": "info"}, + Actions: []driver.UpdateAction{ + {Action: "SET", Field: "name", Value: "Alice Smith"}, + {Action: "SET", Field: "email", Value: "alice@test.com"}, + }, + }) + requireNoError(t, err) + assertEqual(t, "Alice Smith", updated["name"]) + assertEqual(t, "alice@test.com", updated["email"]) + assertEqual(t, 30, updated["age"]) +} + +func TestUpdateItemRemoveFields(t *testing.T) { + m := newTestMock() + ctx := context.Background() + createTestTable(m, "tbl") + + _ = m.PutItem(ctx, "tbl", map[string]any{"pk": "u1", "sk": "info", "name": "Alice", "city": "NYC"}) + + updated, err := m.UpdateItem(ctx, driver.UpdateItemInput{ + Table: "tbl", + Key: map[string]any{"pk": "u1", "sk": "info"}, + Actions: []driver.UpdateAction{ + {Action: "REMOVE", Field: "city"}, + }, + }) + requireNoError(t, err) + + if _, has := updated["city"]; has { + t.Error("expected city to be removed") + } + + assertEqual(t, "Alice", updated["name"]) +} + +func TestUpdateItemSetAndRemoveCombined(t *testing.T) { + m := newTestMock() + ctx := context.Background() + createTestTable(m, "tbl") + + _ = m.PutItem(ctx, "tbl", map[string]any{"pk": "u1", "sk": "info", "name": "Alice", "old_field": "remove_me"}) + + updated, err := m.UpdateItem(ctx, driver.UpdateItemInput{ + Table: "tbl", + Key: map[string]any{"pk": "u1", "sk": "info"}, + Actions: []driver.UpdateAction{ + {Action: "SET", Field: "name", Value: "Bob"}, + {Action: "REMOVE", Field: "old_field"}, + }, + }) + requireNoError(t, err) + assertEqual(t, "Bob", updated["name"]) + + if _, has := updated["old_field"]; has { + t.Error("expected old_field to be removed") + } +} + +func TestUpdateItemPersistsChanges(t *testing.T) { + m := newTestMock() + ctx := context.Background() + createTestTable(m, "tbl") + + _ = m.PutItem(ctx, "tbl", map[string]any{"pk": "u1", "sk": "info", "v": "old"}) + + _, err := m.UpdateItem(ctx, driver.UpdateItemInput{ + Table: "tbl", + Key: map[string]any{"pk": "u1", "sk": "info"}, + Actions: []driver.UpdateAction{ + {Action: "SET", Field: "v", Value: "new"}, + }, + }) + requireNoError(t, err) + + got, err := m.GetItem(ctx, "tbl", map[string]any{"pk": "u1", "sk": "info"}) + requireNoError(t, err) + assertEqual(t, "new", got["v"]) +} + +func TestUpdateItemTableNotFound(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + _, err := m.UpdateItem(ctx, driver.UpdateItemInput{ + Table: "nonexistent", + Key: map[string]any{"pk": "x"}, + Actions: []driver.UpdateAction{{Action: "SET", Field: "v", Value: 1}}, + }) + assertError(t, err, true) +} + +func TestUpdateItemItemNotFound(t *testing.T) { + m := newTestMock() + ctx := context.Background() + createTestTable(m, "tbl") + + _, err := m.UpdateItem(ctx, driver.UpdateItemInput{ + Table: "tbl", + Key: map[string]any{"pk": "missing", "sk": "missing"}, + Actions: []driver.UpdateAction{{Action: "SET", Field: "v", Value: 1}}, + }) + assertError(t, err, true) +} + +func TestUpdateItemInvalidAction(t *testing.T) { + m := newTestMock() + ctx := context.Background() + createTestTable(m, "tbl") + + _ = m.PutItem(ctx, "tbl", map[string]any{"pk": "u1", "sk": "info", "v": 1}) + + _, err := m.UpdateItem(ctx, driver.UpdateItemInput{ + Table: "tbl", + Key: map[string]any{"pk": "u1", "sk": "info"}, + Actions: []driver.UpdateAction{{Action: "ADD", Field: "v", Value: 1}}, + }) + assertError(t, err, true) +} + +func TestUpdateItemEmitsStreamRecord(t *testing.T) { + m := newTestMock() + ctx := context.Background() + createTestTable(m, "tbl") + + _ = m.UpdateStreamConfig(ctx, "tbl", driver.StreamConfig{ + Enabled: true, ViewType: "NEW_AND_OLD_IMAGES", + }) + + _ = m.PutItem(ctx, "tbl", map[string]any{"pk": "u1", "sk": "info", "val": "old"}) + + _, err := m.UpdateItem(ctx, driver.UpdateItemInput{ + Table: "tbl", + Key: map[string]any{"pk": "u1", "sk": "info"}, + Actions: []driver.UpdateAction{ + {Action: "SET", Field: "val", Value: "new"}, + }, + }) + requireNoError(t, err) + + iter, err := m.GetStreamRecords(ctx, "tbl", 10, "") + requireNoError(t, err) + assertEqual(t, 2, len(iter.Records)) + assertEqual(t, "MODIFY", iter.Records[1].EventType) + assertEqual(t, "old", iter.Records[1].OldImage["val"]) + assertEqual(t, "new", iter.Records[1].NewImage["val"]) +} + +func TestUpdateItemEmitsMetrics(t *testing.T) { + fc := config.NewFakeClock(time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)) + opts := config.NewOptions(config.WithClock(fc)) + m := New(opts) + ctx := context.Background() + + cw := cloudwatch.New(opts) + m.SetMonitoring(cw) + createTestTable(m, "tbl") + + _ = m.PutItem(ctx, "tbl", map[string]any{"pk": "u1", "sk": "info"}) + + _, err := m.UpdateItem(ctx, driver.UpdateItemInput{ + Table: "tbl", + Key: map[string]any{"pk": "u1", "sk": "info"}, + Actions: []driver.UpdateAction{ + {Action: "SET", Field: "v", Value: "x"}, + }, + }) + requireNoError(t, err) + + result, err := cw.GetMetricData(ctx, mondriver.GetMetricInput{ + Namespace: "AWS/DynamoDB", + MetricName: "ConsumedWriteCapacityUnits", + Dimensions: map[string]string{"TableName": "tbl"}, + StartTime: fc.Now().Add(-1 * time.Hour), + EndTime: fc.Now().Add(1 * time.Hour), + Period: 60, + Stat: "Sum", + }) + requireNoError(t, err) + assertEqual(t, true, len(result.Values) > 0) +} diff --git a/providers/aws/elasticache/elasticache.go b/providers/aws/elasticache/elasticache.go index 28bab1e0..b868f705 100644 --- a/providers/aws/elasticache/elasticache.go +++ b/providers/aws/elasticache/elasticache.go @@ -5,6 +5,7 @@ import ( "context" "fmt" "path" + "strconv" "time" "github.com/stackshy/cloudemu/cache/driver" @@ -282,6 +283,136 @@ func (m *Mock) FlushAll(_ context.Context, cacheName string) error { return nil } +// Expire sets a TTL on an existing key. +func (m *Mock) Expire(_ context.Context, cacheName, key string, ttl time.Duration) error { + cd, ok := m.caches.Get(cacheName) + if !ok { + return errors.Newf(errors.NotFound, "cache %q not found", cacheName) + } + + item, ok := cd.items.Get(key) + if !ok || (item.HasTTL && m.opts.Clock.Now().After(item.ExpiresAt)) { + return errors.Newf(errors.NotFound, "key %q not found in cache %q", key, cacheName) + } + + item.HasTTL = true + item.ExpiresAt = m.opts.Clock.Now().Add(ttl) + cd.items.Set(key, item) + + return nil +} + +// GetTTL returns the remaining TTL for a key. Returns -1 if the key has no TTL. +func (m *Mock) GetTTL(_ context.Context, cacheName, key string) (time.Duration, error) { + cd, ok := m.caches.Get(cacheName) + if !ok { + return 0, errors.Newf(errors.NotFound, "cache %q not found", cacheName) + } + + item, ok := cd.items.Get(key) + if !ok || (item.HasTTL && m.opts.Clock.Now().After(item.ExpiresAt)) { + return 0, errors.Newf(errors.NotFound, "key %q not found in cache %q", key, cacheName) + } + + if !item.HasTTL { + return -1, nil + } + + return item.ExpiresAt.Sub(m.opts.Clock.Now()), nil +} + +// Persist removes the TTL from a key, making it persistent. +func (m *Mock) Persist(_ context.Context, cacheName, key string) error { + cd, ok := m.caches.Get(cacheName) + if !ok { + return errors.Newf(errors.NotFound, "cache %q not found", cacheName) + } + + item, ok := cd.items.Get(key) + if !ok || (item.HasTTL && m.opts.Clock.Now().After(item.ExpiresAt)) { + return errors.Newf(errors.NotFound, "key %q not found in cache %q", key, cacheName) + } + + item.HasTTL = false + item.ExpiresAt = time.Time{} + cd.items.Set(key, item) + + return nil +} + +// Incr atomically increments the integer value of a key by 1. +func (m *Mock) Incr(ctx context.Context, cacheName, key string) (int64, error) { + return m.IncrBy(ctx, cacheName, key, 1) +} + +// IncrBy atomically increments the integer value of a key by delta. +func (m *Mock) IncrBy(_ context.Context, cacheName, key string, delta int64) (int64, error) { + cd, ok := m.caches.Get(cacheName) + if !ok { + return 0, errors.Newf(errors.NotFound, "cache %q not found", cacheName) + } + + newVal, err := applyDelta(cd, key, delta, m.opts.Clock.Now()) + if err != nil { + return 0, err + } + + m.emitMetric("IncrCommands", 1, map[string]string{"CacheClusterId": cacheName}) + + return newVal, nil +} + +// Decr atomically decrements the integer value of a key by 1. +func (m *Mock) Decr(ctx context.Context, cacheName, key string) (int64, error) { + return m.DecrBy(ctx, cacheName, key, 1) +} + +// DecrBy atomically decrements the integer value of a key by delta. +func (m *Mock) DecrBy(_ context.Context, cacheName, key string, delta int64) (int64, error) { + cd, ok := m.caches.Get(cacheName) + if !ok { + return 0, errors.Newf(errors.NotFound, "cache %q not found", cacheName) + } + + newVal, err := applyDelta(cd, key, -delta, m.opts.Clock.Now()) + if err != nil { + return 0, err + } + + m.emitMetric("DecrCommands", 1, map[string]string{"CacheClusterId": cacheName}) + + return newVal, nil +} + +func applyDelta(cd *cacheData, key string, delta int64, now time.Time) (int64, error) { + item, ok := cd.items.Get(key) + + var current int64 + + if ok && (!item.HasTTL || !now.After(item.ExpiresAt)) { + val, err := strconv.ParseInt(string(item.Value), 10, 64) + if err != nil { + return 0, errors.New(errors.InvalidArgument, "value is not an integer") + } + + current = val + } + + newVal := current + delta + newItem := cacheItem{ + Value: []byte(strconv.FormatInt(newVal, 10)), + } + + if ok && item.HasTTL && !now.After(item.ExpiresAt) { + newItem.HasTTL = true + newItem.ExpiresAt = item.ExpiresAt + } + + cd.items.Set(key, newItem) + + return newVal, nil +} + // matchPattern matches a key against a glob-like pattern. // Supports full glob syntax including middle wildcards like "user:*:session". func matchPattern(pattern, key string) bool { diff --git a/providers/aws/elasticache/elasticache_test.go b/providers/aws/elasticache/elasticache_test.go index 153b83dc..a58ceac1 100644 --- a/providers/aws/elasticache/elasticache_test.go +++ b/providers/aws/elasticache/elasticache_test.go @@ -375,3 +375,160 @@ func TestMatchPattern(t *testing.T) { }) } } + +func TestExpire(t *testing.T) { + m, fc := newTestMock() + ctx := context.Background() + createTestCache(t, m, "c1") + + require.NoError(t, m.Set(ctx, "c1", "k1", []byte("val"), 0)) + + ttl, err := m.GetTTL(ctx, "c1", "k1") + require.NoError(t, err) + assert.Equal(t, time.Duration(-1), ttl) + + require.NoError(t, m.Expire(ctx, "c1", "k1", 1*time.Hour)) + + ttl, err = m.GetTTL(ctx, "c1", "k1") + require.NoError(t, err) + assert.True(t, ttl > 0 && ttl <= 1*time.Hour) + + fc.Advance(2 * time.Hour) + + _, err = m.Get(ctx, "c1", "k1") + require.Error(t, err) +} + +func TestExpireKeyNotFound(t *testing.T) { + m, _ := newTestMock() + ctx := context.Background() + createTestCache(t, m, "c1") + + err := m.Expire(ctx, "c1", "missing", 1*time.Hour) + require.Error(t, err) +} + +func TestGetTTLKeyNotFound(t *testing.T) { + m, _ := newTestMock() + ctx := context.Background() + createTestCache(t, m, "c1") + + _, err := m.GetTTL(ctx, "c1", "missing") + require.Error(t, err) +} + +func TestPersist(t *testing.T) { + m, fc := newTestMock() + ctx := context.Background() + createTestCache(t, m, "c1") + + require.NoError(t, m.Set(ctx, "c1", "k1", []byte("val"), 1*time.Hour)) + + require.NoError(t, m.Persist(ctx, "c1", "k1")) + + ttl, err := m.GetTTL(ctx, "c1", "k1") + require.NoError(t, err) + assert.Equal(t, time.Duration(-1), ttl) + + fc.Advance(2 * time.Hour) + + item, err := m.Get(ctx, "c1", "k1") + require.NoError(t, err) + assert.Equal(t, []byte("val"), item.Value) +} + +func TestPersistKeyNotFound(t *testing.T) { + m, _ := newTestMock() + ctx := context.Background() + createTestCache(t, m, "c1") + + err := m.Persist(ctx, "c1", "missing") + require.Error(t, err) +} + +func TestIncr(t *testing.T) { + m, _ := newTestMock() + ctx := context.Background() + createTestCache(t, m, "c1") + + val, err := m.Incr(ctx, "c1", "counter") + require.NoError(t, err) + assert.Equal(t, int64(1), val) + + val, err = m.Incr(ctx, "c1", "counter") + require.NoError(t, err) + assert.Equal(t, int64(2), val) +} + +func TestIncrBy(t *testing.T) { + m, _ := newTestMock() + ctx := context.Background() + createTestCache(t, m, "c1") + + require.NoError(t, m.Set(ctx, "c1", "counter", []byte("10"), 0)) + + val, err := m.IncrBy(ctx, "c1", "counter", 5) + require.NoError(t, err) + assert.Equal(t, int64(15), val) +} + +func TestDecr(t *testing.T) { + m, _ := newTestMock() + ctx := context.Background() + createTestCache(t, m, "c1") + + require.NoError(t, m.Set(ctx, "c1", "counter", []byte("10"), 0)) + + val, err := m.Decr(ctx, "c1", "counter") + require.NoError(t, err) + assert.Equal(t, int64(9), val) +} + +func TestDecrBy(t *testing.T) { + m, _ := newTestMock() + ctx := context.Background() + createTestCache(t, m, "c1") + + require.NoError(t, m.Set(ctx, "c1", "counter", []byte("20"), 0)) + + val, err := m.DecrBy(ctx, "c1", "counter", 7) + require.NoError(t, err) + assert.Equal(t, int64(13), val) +} + +func TestIncrNonInteger(t *testing.T) { + m, _ := newTestMock() + ctx := context.Background() + createTestCache(t, m, "c1") + + require.NoError(t, m.Set(ctx, "c1", "k1", []byte("not-a-number"), 0)) + + _, err := m.Incr(ctx, "c1", "k1") + require.Error(t, err) + assert.Contains(t, err.Error(), "not an integer") +} + +func TestIncrPreservesTTL(t *testing.T) { + m, _ := newTestMock() + ctx := context.Background() + createTestCache(t, m, "c1") + + require.NoError(t, m.Set(ctx, "c1", "counter", []byte("5"), 1*time.Hour)) + + val, err := m.IncrBy(ctx, "c1", "counter", 3) + require.NoError(t, err) + assert.Equal(t, int64(8), val) + + ttl, err := m.GetTTL(ctx, "c1", "counter") + require.NoError(t, err) + assert.True(t, ttl > 0) +} + +func TestIncrCacheNotFound(t *testing.T) { + m, _ := newTestMock() + ctx := context.Background() + + _, err := m.Incr(ctx, "nonexistent", "k1") + require.Error(t, err) + assert.Contains(t, err.Error(), "not found") +} diff --git a/providers/aws/elb/elb.go b/providers/aws/elb/elb.go index 82b383b6..9be13157 100644 --- a/providers/aws/elb/elb.go +++ b/providers/aws/elb/elb.go @@ -16,15 +16,22 @@ import ( // Compile-time check that Mock implements driver.LoadBalancer. var _ driver.LoadBalancer = (*Mock)(nil) +// defaultIdleTimeoutSec is the default idle timeout for load balancers in seconds. +const defaultIdleTimeoutSec = 60 + // Mock is an in-memory mock implementation of the AWS ELB service. type Mock struct { lbs *memstore.Store[driver.LBInfo] tgs *memstore.Store[driver.TargetGroupInfo] listeners *memstore.Store[driver.ListenerInfo] + rules *memstore.Store[driver.RuleInfo] opts *config.Options healthMu sync.RWMutex health map[string]map[string]*driver.TargetHealth // tgARN -> targetID -> health + + attrsMu sync.RWMutex + attrs map[string]driver.LBAttributes // lbARN -> attributes } // New creates a new ELB mock with the given configuration options. @@ -33,8 +40,10 @@ func New(opts *config.Options) *Mock { lbs: memstore.New[driver.LBInfo](), tgs: memstore.New[driver.TargetGroupInfo](), listeners: memstore.New[driver.ListenerInfo](), + rules: memstore.New[driver.RuleInfo](), opts: opts, health: make(map[string]map[string]*driver.TargetHealth), + attrs: make(map[string]driver.LBAttributes), } } @@ -186,6 +195,18 @@ func describeResources[T any](store *memstore.Store[T], keys []string) []T { return results } +// filterToSlice returns a slice of values from the store that match the predicate. +func filterToSlice[T any](store *memstore.Store[T], pred func(string, T) bool) []T { + filtered := store.Filter(pred) + + results := make([]T, 0, len(filtered)) + for _, item := range filtered { + results = append(results, item) + } + + return results +} + // CreateListener creates a new listener on a load balancer. func (m *Mock) CreateListener(_ context.Context, cfg driver.ListenerConfig) (*driver.ListenerInfo, error) { if _, ok := m.lbs.Get(cfg.LBARN); !ok { @@ -225,16 +246,114 @@ func (m *Mock) DescribeListeners(_ context.Context, lbARN string) ([]driver.List return nil, errors.Newf(errors.NotFound, "load balancer %q not found", lbARN) } - filtered := m.listeners.Filter(func(_ string, li driver.ListenerInfo) bool { + return filterToSlice(m.listeners, func(_ string, li driver.ListenerInfo) bool { return li.LBARN == lbARN - }) + }), nil +} - results := make([]driver.ListenerInfo, 0, len(filtered)) - for _, li := range filtered { - results = append(results, li) +// CreateRule creates a new listener rule. +func (m *Mock) CreateRule(_ context.Context, cfg driver.RuleConfig) (*driver.RuleInfo, error) { + if _, ok := m.listeners.Get(cfg.ListenerARN); !ok { + return nil, errors.Newf(errors.NotFound, "listener %q not found", cfg.ListenerARN) } - return results, nil + arn := idgen.AWSARN("elasticloadbalancing", m.opts.Region, m.opts.AccountID, + fmt.Sprintf("rule/%s/%s", cfg.ListenerARN, idgen.GenerateID("rule-"))) + + conditions := make([]driver.RuleCondition, len(cfg.Conditions)) + copy(conditions, cfg.Conditions) + + actions := make([]driver.RuleAction, len(cfg.Actions)) + copy(actions, cfg.Actions) + + rule := driver.RuleInfo{ + ARN: arn, + ListenerARN: cfg.ListenerARN, + Priority: cfg.Priority, + Conditions: conditions, + Actions: actions, + IsDefault: false, + } + + m.rules.Set(arn, rule) + + result := rule + + return &result, nil +} + +// DeleteRule deletes a listener rule by ARN. +func (m *Mock) DeleteRule(_ context.Context, ruleARN string) error { + if !m.rules.Delete(ruleARN) { + return errors.Newf(errors.NotFound, "rule %q not found", ruleARN) + } + + return nil +} + +// DescribeRules returns all rules for the specified listener. +func (m *Mock) DescribeRules(_ context.Context, listenerARN string) ([]driver.RuleInfo, error) { + if _, ok := m.listeners.Get(listenerARN); !ok { + return nil, errors.Newf(errors.NotFound, "listener %q not found", listenerARN) + } + + return filterToSlice(m.rules, func(_ string, r driver.RuleInfo) bool { + return r.ListenerARN == listenerARN + }), nil +} + +// ModifyListener modifies an existing listener's port, protocol, or default actions. +func (m *Mock) ModifyListener(_ context.Context, input driver.ModifyListenerInput) error { + li, ok := m.listeners.Get(input.ListenerARN) + if !ok { + return errors.Newf(errors.NotFound, "listener %q not found", input.ListenerARN) + } + + if input.Port != 0 { + li.Port = input.Port + } + + if input.Protocol != "" { + li.Protocol = input.Protocol + } + + if len(input.DefaultActions) > 0 { + li.TargetGroupARN = input.DefaultActions[0].TargetGroupARN + } + + m.listeners.Set(input.ListenerARN, li) + + return nil +} + +// GetLBAttributes returns the attributes for a load balancer. +func (m *Mock) GetLBAttributes(_ context.Context, lbARN string) (*driver.LBAttributes, error) { + if _, ok := m.lbs.Get(lbARN); !ok { + return nil, errors.Newf(errors.NotFound, "load balancer %q not found", lbARN) + } + + m.attrsMu.RLock() + defer m.attrsMu.RUnlock() + + attrs, ok := m.attrs[lbARN] + if !ok { + attrs = driver.LBAttributes{IdleTimeout: defaultIdleTimeoutSec} + } + + return &attrs, nil +} + +// PutLBAttributes sets the attributes for a load balancer. +func (m *Mock) PutLBAttributes(_ context.Context, lbARN string, attrs driver.LBAttributes) error { + if _, ok := m.lbs.Get(lbARN); !ok { + return errors.Newf(errors.NotFound, "load balancer %q not found", lbARN) + } + + m.attrsMu.Lock() + m.attrs[lbARN] = attrs + m.attrsMu.Unlock() + + return nil } // RegisterTargets registers targets with a target group. diff --git a/providers/aws/elb/elb_test.go b/providers/aws/elb/elb_test.go index bc836919..e04e7a02 100644 --- a/providers/aws/elb/elb_test.go +++ b/providers/aws/elb/elb_test.go @@ -323,6 +323,157 @@ func TestSetTargetHealth(t *testing.T) { }) } +func TestCreateRule(t *testing.T) { + m := newTestMock() + ctx := context.Background() + lb := createTestLB(m) + tg := createTestTG(m) + li, _ := m.CreateListener(ctx, driver.ListenerConfig{ + LBARN: lb.ARN, Protocol: "HTTP", Port: 80, TargetGroupARN: tg.ARN, + }) + + t.Run("success", func(t *testing.T) { + rule, err := m.CreateRule(ctx, driver.RuleConfig{ + ListenerARN: li.ARN, + Priority: 10, + Conditions: []driver.RuleCondition{{Field: "path-pattern", Values: []string{"/api/*"}}}, + Actions: []driver.RuleAction{{Type: "forward", TargetGroupARN: tg.ARN}}, + }) + requireNoError(t, err) + assertNotEmpty(t, rule.ARN) + assertEqual(t, li.ARN, rule.ListenerARN) + assertEqual(t, 10, rule.Priority) + assertEqual(t, false, rule.IsDefault) + }) + + t.Run("listener not found", func(t *testing.T) { + _, err := m.CreateRule(ctx, driver.RuleConfig{ListenerARN: "arn:nope"}) + assertError(t, err, true) + }) +} + +func TestDeleteRule(t *testing.T) { + m := newTestMock() + ctx := context.Background() + lb := createTestLB(m) + li, _ := m.CreateListener(ctx, driver.ListenerConfig{ + LBARN: lb.ARN, Protocol: "HTTP", Port: 80, + }) + rule, _ := m.CreateRule(ctx, driver.RuleConfig{ + ListenerARN: li.ARN, Priority: 10, + }) + + requireNoError(t, m.DeleteRule(ctx, rule.ARN)) + assertError(t, m.DeleteRule(ctx, "arn:nope"), true) +} + +func TestDescribeRules(t *testing.T) { + m := newTestMock() + ctx := context.Background() + lb := createTestLB(m) + tg := createTestTG(m) + li, _ := m.CreateListener(ctx, driver.ListenerConfig{ + LBARN: lb.ARN, Protocol: "HTTP", Port: 80, TargetGroupARN: tg.ARN, + }) + + _, _ = m.CreateRule(ctx, driver.RuleConfig{ + ListenerARN: li.ARN, Priority: 10, + Conditions: []driver.RuleCondition{{Field: "path-pattern", Values: []string{"/api/*"}}}, + Actions: []driver.RuleAction{{Type: "forward", TargetGroupARN: tg.ARN}}, + }) + _, _ = m.CreateRule(ctx, driver.RuleConfig{ + ListenerARN: li.ARN, Priority: 20, + Conditions: []driver.RuleCondition{{Field: "host-header", Values: []string{"example.com"}}}, + Actions: []driver.RuleAction{{Type: "forward", TargetGroupARN: tg.ARN}}, + }) + + t.Run("success", func(t *testing.T) { + rules, err := m.DescribeRules(ctx, li.ARN) + requireNoError(t, err) + assertEqual(t, 2, len(rules)) + }) + + t.Run("listener not found", func(t *testing.T) { + _, err := m.DescribeRules(ctx, "arn:nope") + assertError(t, err, true) + }) +} + +func TestModifyListener(t *testing.T) { + m := newTestMock() + ctx := context.Background() + lb := createTestLB(m) + tg := createTestTG(m) + li, _ := m.CreateListener(ctx, driver.ListenerConfig{ + LBARN: lb.ARN, Protocol: "HTTP", Port: 80, TargetGroupARN: tg.ARN, + }) + + t.Run("modify port", func(t *testing.T) { + err := m.ModifyListener(ctx, driver.ModifyListenerInput{ + ListenerARN: li.ARN, Port: 8080, + }) + requireNoError(t, err) + + listeners, _ := m.DescribeListeners(ctx, lb.ARN) + assertEqual(t, 8080, listeners[0].Port) + }) + + t.Run("modify protocol", func(t *testing.T) { + err := m.ModifyListener(ctx, driver.ModifyListenerInput{ + ListenerARN: li.ARN, Protocol: "HTTPS", + }) + requireNoError(t, err) + + listeners, _ := m.DescribeListeners(ctx, lb.ARN) + assertEqual(t, "HTTPS", listeners[0].Protocol) + }) + + t.Run("listener not found", func(t *testing.T) { + err := m.ModifyListener(ctx, driver.ModifyListenerInput{ListenerARN: "arn:nope", Port: 80}) + assertError(t, err, true) + }) +} + +func TestLBAttributes(t *testing.T) { + m := newTestMock() + ctx := context.Background() + lb := createTestLB(m) + + t.Run("default attributes", func(t *testing.T) { + attrs, err := m.GetLBAttributes(ctx, lb.ARN) + requireNoError(t, err) + assertEqual(t, 60, attrs.IdleTimeout) + assertEqual(t, false, attrs.DeletionProtection) + }) + + t.Run("put and get", func(t *testing.T) { + err := m.PutLBAttributes(ctx, lb.ARN, driver.LBAttributes{ + IdleTimeout: 120, + DeletionProtection: true, + AccessLogsEnabled: true, + AccessLogsBucket: "my-logs", + }) + requireNoError(t, err) + + attrs, err := m.GetLBAttributes(ctx, lb.ARN) + requireNoError(t, err) + assertEqual(t, 120, attrs.IdleTimeout) + assertEqual(t, true, attrs.DeletionProtection) + assertEqual(t, true, attrs.AccessLogsEnabled) + assertEqual(t, "my-logs", attrs.AccessLogsBucket) + }) + + t.Run("LB not found get", func(t *testing.T) { + _, err := m.GetLBAttributes(ctx, "arn:nope") + assertError(t, err, true) + }) + + t.Run("LB not found put", func(t *testing.T) { + err := m.PutLBAttributes(ctx, "arn:nope", driver.LBAttributes{}) + assertError(t, err, true) + }) +} + // --- test helpers --- func requireNoError(t *testing.T, err error) { diff --git a/providers/aws/s3/s3.go b/providers/aws/s3/s3.go index 115558cf..592f39d8 100644 --- a/providers/aws/s3/s3.go +++ b/providers/aws/s3/s3.go @@ -53,6 +53,9 @@ type bucketMeta struct { lifecycle *driver.LifecycleConfig multiparts *memstore.Store[*multipartUpload] versioning bool + policy *driver.BucketPolicy + corsConfig *driver.CORSConfig + encryption *driver.EncryptionConfig } // Mock is an in-memory mock implementation of the AWS S3 service. @@ -604,3 +607,114 @@ func (m *Mock) GetBucketVersioning(_ context.Context, bucket string) (bool, erro return bkt.versioning, nil } + +// PutBucketPolicy sets the bucket policy. +func (m *Mock) PutBucketPolicy(_ context.Context, bucket string, policy driver.BucketPolicy) error { + bkt, ok := m.buckets.Get(bucket) + if !ok { + return cerrors.Newf(cerrors.NotFound, "bucket %q not found", bucket) + } + + p := policy + bkt.policy = &p + + return nil +} + +// GetBucketPolicy returns the bucket policy. +func (m *Mock) GetBucketPolicy(_ context.Context, bucket string) (*driver.BucketPolicy, error) { + bkt, ok := m.buckets.Get(bucket) + if !ok { + return nil, cerrors.Newf(cerrors.NotFound, "bucket %q not found", bucket) + } + + if bkt.policy == nil { + return nil, cerrors.Newf(cerrors.NotFound, "no policy set for bucket %q", bucket) + } + + p := *bkt.policy + + return &p, nil +} + +// DeleteBucketPolicy removes the bucket policy. +func (m *Mock) DeleteBucketPolicy(_ context.Context, bucket string) error { + bkt, ok := m.buckets.Get(bucket) + if !ok { + return cerrors.Newf(cerrors.NotFound, "bucket %q not found", bucket) + } + + bkt.policy = nil + + return nil +} + +// PutCORSConfig sets the CORS configuration for a bucket. +func (m *Mock) PutCORSConfig(_ context.Context, bucket string, cfg driver.CORSConfig) error { + bkt, ok := m.buckets.Get(bucket) + if !ok { + return cerrors.Newf(cerrors.NotFound, "bucket %q not found", bucket) + } + + c := cfg + bkt.corsConfig = &c + + return nil +} + +// GetCORSConfig returns the CORS configuration for a bucket. +func (m *Mock) GetCORSConfig(_ context.Context, bucket string) (*driver.CORSConfig, error) { + bkt, ok := m.buckets.Get(bucket) + if !ok { + return nil, cerrors.Newf(cerrors.NotFound, "bucket %q not found", bucket) + } + + if bkt.corsConfig == nil { + return nil, cerrors.Newf(cerrors.NotFound, "no CORS config set for bucket %q", bucket) + } + + c := *bkt.corsConfig + + return &c, nil +} + +// DeleteCORSConfig removes the CORS configuration for a bucket. +func (m *Mock) DeleteCORSConfig(_ context.Context, bucket string) error { + bkt, ok := m.buckets.Get(bucket) + if !ok { + return cerrors.Newf(cerrors.NotFound, "bucket %q not found", bucket) + } + + bkt.corsConfig = nil + + return nil +} + +// PutEncryptionConfig sets the default encryption for a bucket. +func (m *Mock) PutEncryptionConfig(_ context.Context, bucket string, cfg driver.EncryptionConfig) error { + bkt, ok := m.buckets.Get(bucket) + if !ok { + return cerrors.Newf(cerrors.NotFound, "bucket %q not found", bucket) + } + + e := cfg + bkt.encryption = &e + + return nil +} + +// GetEncryptionConfig returns the default encryption for a bucket. +func (m *Mock) GetEncryptionConfig(_ context.Context, bucket string) (*driver.EncryptionConfig, error) { + bkt, ok := m.buckets.Get(bucket) + if !ok { + return nil, cerrors.Newf(cerrors.NotFound, "bucket %q not found", bucket) + } + + if bkt.encryption == nil { + return nil, cerrors.Newf(cerrors.NotFound, "no encryption config set for bucket %q", bucket) + } + + e := *bkt.encryption + + return &e, nil +} diff --git a/providers/azure/azurecache/azurecache.go b/providers/azure/azurecache/azurecache.go index e1bbe47a..62d610d5 100644 --- a/providers/azure/azurecache/azurecache.go +++ b/providers/azure/azurecache/azurecache.go @@ -5,6 +5,7 @@ import ( "context" "fmt" "path" + "strconv" "time" "github.com/stackshy/cloudemu/cache/driver" @@ -289,6 +290,136 @@ func (m *Mock) FlushAll(_ context.Context, cacheName string) error { return nil } +// Expire sets a TTL on an existing key. +func (m *Mock) Expire(_ context.Context, cacheName, key string, ttl time.Duration) error { + cd, ok := m.caches.Get(cacheName) + if !ok { + return errors.Newf(errors.NotFound, "cache %q not found", cacheName) + } + + item, ok := cd.items.Get(key) + if !ok || (item.HasTTL && m.opts.Clock.Now().After(item.ExpiresAt)) { + return errors.Newf(errors.NotFound, "key %q not found in cache %q", key, cacheName) + } + + item.HasTTL = true + item.ExpiresAt = m.opts.Clock.Now().Add(ttl) + cd.items.Set(key, item) + + return nil +} + +// GetTTL returns the remaining TTL for a key. Returns -1 if the key has no TTL. +func (m *Mock) GetTTL(_ context.Context, cacheName, key string) (time.Duration, error) { + cd, ok := m.caches.Get(cacheName) + if !ok { + return 0, errors.Newf(errors.NotFound, "cache %q not found", cacheName) + } + + item, ok := cd.items.Get(key) + if !ok || (item.HasTTL && m.opts.Clock.Now().After(item.ExpiresAt)) { + return 0, errors.Newf(errors.NotFound, "key %q not found in cache %q", key, cacheName) + } + + if !item.HasTTL { + return -1, nil + } + + return item.ExpiresAt.Sub(m.opts.Clock.Now()), nil +} + +// Persist removes the TTL from a key, making it persistent. +func (m *Mock) Persist(_ context.Context, cacheName, key string) error { + cd, ok := m.caches.Get(cacheName) + if !ok { + return errors.Newf(errors.NotFound, "cache %q not found", cacheName) + } + + item, ok := cd.items.Get(key) + if !ok || (item.HasTTL && m.opts.Clock.Now().After(item.ExpiresAt)) { + return errors.Newf(errors.NotFound, "key %q not found in cache %q", key, cacheName) + } + + item.HasTTL = false + item.ExpiresAt = time.Time{} + cd.items.Set(key, item) + + return nil +} + +// Incr atomically increments the integer value of a key by 1. +func (m *Mock) Incr(ctx context.Context, cacheName, key string) (int64, error) { + return m.IncrBy(ctx, cacheName, key, 1) +} + +// IncrBy atomically increments the integer value of a key by delta. +func (m *Mock) IncrBy(_ context.Context, cacheName, key string, delta int64) (int64, error) { + cd, ok := m.caches.Get(cacheName) + if !ok { + return 0, errors.Newf(errors.NotFound, "cache %q not found", cacheName) + } + + newVal, err := applyDelta(cd, key, delta, m.opts.Clock.Now()) + if err != nil { + return 0, err + } + + m.emitMetric(cacheName, map[string]float64{"TotalCommandsProcessed": 1}) + + return newVal, nil +} + +// Decr atomically decrements the integer value of a key by 1. +func (m *Mock) Decr(ctx context.Context, cacheName, key string) (int64, error) { + return m.DecrBy(ctx, cacheName, key, 1) +} + +// DecrBy atomically decrements the integer value of a key by delta. +func (m *Mock) DecrBy(_ context.Context, cacheName, key string, delta int64) (int64, error) { + cd, ok := m.caches.Get(cacheName) + if !ok { + return 0, errors.Newf(errors.NotFound, "cache %q not found", cacheName) + } + + newVal, err := applyDelta(cd, key, -delta, m.opts.Clock.Now()) + if err != nil { + return 0, err + } + + m.emitMetric(cacheName, map[string]float64{"TotalCommandsProcessed": 1}) + + return newVal, nil +} + +func applyDelta(cd *cacheData, key string, delta int64, now time.Time) (int64, error) { + item, ok := cd.items.Get(key) + + var current int64 + + if ok && (!item.HasTTL || !now.After(item.ExpiresAt)) { + val, err := strconv.ParseInt(string(item.Value), 10, 64) + if err != nil { + return 0, errors.New(errors.InvalidArgument, "value is not an integer") + } + + current = val + } + + newVal := current + delta + newItem := cacheItem{ + Value: []byte(strconv.FormatInt(newVal, 10)), + } + + if ok && item.HasTTL && !now.After(item.ExpiresAt) { + newItem.HasTTL = true + newItem.ExpiresAt = item.ExpiresAt + } + + cd.items.Set(key, newItem) + + return newVal, nil +} + // matchPattern matches a key against a glob-like pattern. // Supports full glob syntax including middle wildcards like "user:*:session". func matchPattern(pattern, key string) bool { diff --git a/providers/azure/azurecache/azurecache_test.go b/providers/azure/azurecache/azurecache_test.go index 06dfae89..3da43c5a 100644 --- a/providers/azure/azurecache/azurecache_test.go +++ b/providers/azure/azurecache/azurecache_test.go @@ -375,3 +375,123 @@ func TestMatchPattern(t *testing.T) { }) } } + +func TestExpire(t *testing.T) { + m, fc := newTestMock() + ctx := context.Background() + createTestCache(t, m, "c1") + + require.NoError(t, m.Set(ctx, "c1", "k1", []byte("val"), 0)) + + ttl, err := m.GetTTL(ctx, "c1", "k1") + require.NoError(t, err) + assert.Equal(t, time.Duration(-1), ttl) + + require.NoError(t, m.Expire(ctx, "c1", "k1", 1*time.Hour)) + + ttl, err = m.GetTTL(ctx, "c1", "k1") + require.NoError(t, err) + assert.True(t, ttl > 0 && ttl <= 1*time.Hour) + + fc.Advance(2 * time.Hour) + + _, err = m.Get(ctx, "c1", "k1") + require.Error(t, err) +} + +func TestPersist(t *testing.T) { + m, fc := newTestMock() + ctx := context.Background() + createTestCache(t, m, "c1") + + require.NoError(t, m.Set(ctx, "c1", "k1", []byte("val"), 1*time.Hour)) + require.NoError(t, m.Persist(ctx, "c1", "k1")) + + ttl, err := m.GetTTL(ctx, "c1", "k1") + require.NoError(t, err) + assert.Equal(t, time.Duration(-1), ttl) + + fc.Advance(2 * time.Hour) + + item, err := m.Get(ctx, "c1", "k1") + require.NoError(t, err) + assert.Equal(t, []byte("val"), item.Value) +} + +func TestIncr(t *testing.T) { + m, _ := newTestMock() + ctx := context.Background() + createTestCache(t, m, "c1") + + val, err := m.Incr(ctx, "c1", "counter") + require.NoError(t, err) + assert.Equal(t, int64(1), val) + + val, err = m.Incr(ctx, "c1", "counter") + require.NoError(t, err) + assert.Equal(t, int64(2), val) +} + +func TestIncrBy(t *testing.T) { + m, _ := newTestMock() + ctx := context.Background() + createTestCache(t, m, "c1") + + require.NoError(t, m.Set(ctx, "c1", "counter", []byte("10"), 0)) + + val, err := m.IncrBy(ctx, "c1", "counter", 5) + require.NoError(t, err) + assert.Equal(t, int64(15), val) +} + +func TestDecr(t *testing.T) { + m, _ := newTestMock() + ctx := context.Background() + createTestCache(t, m, "c1") + + require.NoError(t, m.Set(ctx, "c1", "counter", []byte("10"), 0)) + + val, err := m.Decr(ctx, "c1", "counter") + require.NoError(t, err) + assert.Equal(t, int64(9), val) +} + +func TestDecrBy(t *testing.T) { + m, _ := newTestMock() + ctx := context.Background() + createTestCache(t, m, "c1") + + require.NoError(t, m.Set(ctx, "c1", "counter", []byte("20"), 0)) + + val, err := m.DecrBy(ctx, "c1", "counter", 7) + require.NoError(t, err) + assert.Equal(t, int64(13), val) +} + +func TestIncrNonInteger(t *testing.T) { + m, _ := newTestMock() + ctx := context.Background() + createTestCache(t, m, "c1") + + require.NoError(t, m.Set(ctx, "c1", "k1", []byte("not-a-number"), 0)) + + _, err := m.Incr(ctx, "c1", "k1") + require.Error(t, err) + assert.Contains(t, err.Error(), "not an integer") +} + +func TestIncrPreservesTTL(t *testing.T) { + m, _ := newTestMock() + ctx := context.Background() + createTestCache(t, m, "c1") + + require.NoError(t, m.Set(ctx, "c1", "counter", []byte("5"), 1*time.Hour)) + + val, err := m.IncrBy(ctx, "c1", "counter", 3) + require.NoError(t, err) + assert.Equal(t, int64(8), val) + + ttl, err := m.GetTTL(ctx, "c1", "counter") + require.NoError(t, err) + assert.True(t, ttl > 0) +} diff --git a/providers/azure/azurelb/lb.go b/providers/azure/azurelb/lb.go index 334d4d70..6542ce9b 100644 --- a/providers/azure/azurelb/lb.go +++ b/providers/azure/azurelb/lb.go @@ -16,15 +16,22 @@ import ( // Compile-time check that Mock implements driver.LoadBalancer. var _ driver.LoadBalancer = (*Mock)(nil) +// defaultIdleTimeoutSec is the default idle timeout for load balancers in seconds. +const defaultIdleTimeoutSec = 60 + // Mock is an in-memory mock implementation of the Azure Load Balancer service. type Mock struct { lbs *memstore.Store[driver.LBInfo] tgs *memstore.Store[driver.TargetGroupInfo] listeners *memstore.Store[driver.ListenerInfo] + rules *memstore.Store[driver.RuleInfo] opts *config.Options healthMu sync.RWMutex health map[string]map[string]*driver.TargetHealth // tgARN -> targetID -> health + + attrsMu sync.RWMutex + attrs map[string]driver.LBAttributes // lbARN -> attributes } // New creates a new Azure Load Balancer mock with the given configuration options. @@ -33,8 +40,10 @@ func New(opts *config.Options) *Mock { lbs: memstore.New[driver.LBInfo](), tgs: memstore.New[driver.TargetGroupInfo](), listeners: memstore.New[driver.ListenerInfo](), + rules: memstore.New[driver.RuleInfo](), opts: opts, health: make(map[string]map[string]*driver.TargetHealth), + attrs: make(map[string]driver.LBAttributes), } } @@ -121,6 +130,18 @@ func describeResources[T any](store *memstore.Store[T], keys []string) []T { return results } +// filterToSlice returns a slice of values from the store that match the predicate. +func filterToSlice[T any](store *memstore.Store[T], pred func(string, T) bool) []T { + filtered := store.Filter(pred) + + results := make([]T, 0, len(filtered)) + for _, item := range filtered { + results = append(results, item) + } + + return results +} + // DescribeLoadBalancers returns load balancers matching the given ARNs. // If arns is empty, all load balancers are returned. func (m *Mock) DescribeLoadBalancers(_ context.Context, arns []string) ([]driver.LBInfo, error) { @@ -225,16 +246,114 @@ func (m *Mock) DescribeListeners(_ context.Context, lbARN string) ([]driver.List return nil, cerrors.Newf(cerrors.NotFound, "load balancer %q not found", lbARN) } - filtered := m.listeners.Filter(func(_ string, li driver.ListenerInfo) bool { + return filterToSlice(m.listeners, func(_ string, li driver.ListenerInfo) bool { return li.LBARN == lbARN - }) + }), nil +} - results := make([]driver.ListenerInfo, 0, len(filtered)) - for _, li := range filtered { - results = append(results, li) +// CreateRule creates a new routing rule for a load balancing rule (listener). +func (m *Mock) CreateRule(_ context.Context, cfg driver.RuleConfig) (*driver.RuleInfo, error) { + if _, ok := m.listeners.Get(cfg.ListenerARN); !ok { + return nil, cerrors.Newf(cerrors.NotFound, "listener %q not found", cfg.ListenerARN) } - return results, nil + arn := idgen.AzureID(m.opts.AccountID, "cloud-mock", "Microsoft.Network", + "routingRules", idgen.GenerateID("rule-")) + + conditions := make([]driver.RuleCondition, len(cfg.Conditions)) + copy(conditions, cfg.Conditions) + + actions := make([]driver.RuleAction, len(cfg.Actions)) + copy(actions, cfg.Actions) + + rule := driver.RuleInfo{ + ARN: arn, + ListenerARN: cfg.ListenerARN, + Priority: cfg.Priority, + Conditions: conditions, + Actions: actions, + IsDefault: false, + } + + m.rules.Set(arn, rule) + + result := rule + + return &result, nil +} + +// DeleteRule deletes a routing rule by ARN. +func (m *Mock) DeleteRule(_ context.Context, ruleARN string) error { + if !m.rules.Delete(ruleARN) { + return cerrors.Newf(cerrors.NotFound, "rule %q not found", ruleARN) + } + + return nil +} + +// DescribeRules returns all routing rules for the specified listener. +func (m *Mock) DescribeRules(_ context.Context, listenerARN string) ([]driver.RuleInfo, error) { + if _, ok := m.listeners.Get(listenerARN); !ok { + return nil, cerrors.Newf(cerrors.NotFound, "listener %q not found", listenerARN) + } + + return filterToSlice(m.rules, func(_ string, r driver.RuleInfo) bool { + return r.ListenerARN == listenerARN + }), nil +} + +// ModifyListener modifies an existing load balancing rule's port, protocol, or default actions. +func (m *Mock) ModifyListener(_ context.Context, input driver.ModifyListenerInput) error { + li, ok := m.listeners.Get(input.ListenerARN) + if !ok { + return cerrors.Newf(cerrors.NotFound, "listener %q not found", input.ListenerARN) + } + + if input.Port != 0 { + li.Port = input.Port + } + + if input.Protocol != "" { + li.Protocol = input.Protocol + } + + if len(input.DefaultActions) > 0 { + li.TargetGroupARN = input.DefaultActions[0].TargetGroupARN + } + + m.listeners.Set(input.ListenerARN, li) + + return nil +} + +// GetLBAttributes returns the attributes for a load balancer. +func (m *Mock) GetLBAttributes(_ context.Context, lbARN string) (*driver.LBAttributes, error) { + if _, ok := m.lbs.Get(lbARN); !ok { + return nil, cerrors.Newf(cerrors.NotFound, "load balancer %q not found", lbARN) + } + + m.attrsMu.RLock() + defer m.attrsMu.RUnlock() + + attrs, ok := m.attrs[lbARN] + if !ok { + attrs = driver.LBAttributes{IdleTimeout: defaultIdleTimeoutSec} + } + + return &attrs, nil +} + +// PutLBAttributes sets the attributes for a load balancer. +func (m *Mock) PutLBAttributes(_ context.Context, lbARN string, attrs driver.LBAttributes) error { + if _, ok := m.lbs.Get(lbARN); !ok { + return cerrors.Newf(cerrors.NotFound, "load balancer %q not found", lbARN) + } + + m.attrsMu.Lock() + m.attrs[lbARN] = attrs + m.attrsMu.Unlock() + + return nil } // RegisterTargets registers targets (backend instances) with a backend pool. diff --git a/providers/azure/azurelb/lb_test.go b/providers/azure/azurelb/lb_test.go index acbe87b1..79328dbb 100644 --- a/providers/azure/azurelb/lb_test.go +++ b/providers/azure/azurelb/lb_test.go @@ -409,6 +409,163 @@ func TestDescribeTargetHealthNotFound(t *testing.T) { assert.Contains(t, err.Error(), "not found") } +func TestCreateRule(t *testing.T) { + ctx := context.Background() + m := newTestMock() + lbARN := createTestLB(t, m) + tgARN := createTestTargetGroup(t, m) + + li, err := m.CreateListener(ctx, driver.ListenerConfig{ + LBARN: lbARN, Protocol: "HTTP", Port: 80, TargetGroupARN: tgARN, + }) + require.NoError(t, err) + + t.Run("success", func(t *testing.T) { + rule, ruleErr := m.CreateRule(ctx, driver.RuleConfig{ + ListenerARN: li.ARN, + Priority: 10, + Conditions: []driver.RuleCondition{{Field: "path-pattern", Values: []string{"/api/*"}}}, + Actions: []driver.RuleAction{{Type: "forward", TargetGroupARN: tgARN}}, + }) + require.NoError(t, ruleErr) + assert.NotEmpty(t, rule.ARN) + assert.Equal(t, li.ARN, rule.ListenerARN) + assert.Equal(t, 10, rule.Priority) + assert.False(t, rule.IsDefault) + }) + + t.Run("listener not found", func(t *testing.T) { + _, ruleErr := m.CreateRule(ctx, driver.RuleConfig{ListenerARN: "missing"}) + require.Error(t, ruleErr) + assert.Contains(t, ruleErr.Error(), "not found") + }) +} + +func TestDeleteRule(t *testing.T) { + ctx := context.Background() + m := newTestMock() + lbARN := createTestLB(t, m) + + li, err := m.CreateListener(ctx, driver.ListenerConfig{LBARN: lbARN, Protocol: "HTTP", Port: 80}) + require.NoError(t, err) + + rule, err := m.CreateRule(ctx, driver.RuleConfig{ListenerARN: li.ARN, Priority: 10}) + require.NoError(t, err) + + t.Run("success", func(t *testing.T) { + require.NoError(t, m.DeleteRule(ctx, rule.ARN)) + }) + + t.Run("not found", func(t *testing.T) { + err := m.DeleteRule(ctx, "missing") + require.Error(t, err) + assert.Contains(t, err.Error(), "not found") + }) +} + +func TestDescribeRules(t *testing.T) { + ctx := context.Background() + m := newTestMock() + lbARN := createTestLB(t, m) + tgARN := createTestTargetGroup(t, m) + + li, err := m.CreateListener(ctx, driver.ListenerConfig{ + LBARN: lbARN, Protocol: "HTTP", Port: 80, TargetGroupARN: tgARN, + }) + require.NoError(t, err) + + _, _ = m.CreateRule(ctx, driver.RuleConfig{ + ListenerARN: li.ARN, Priority: 10, + Conditions: []driver.RuleCondition{{Field: "path-pattern", Values: []string{"/api/*"}}}, + Actions: []driver.RuleAction{{Type: "forward", TargetGroupARN: tgARN}}, + }) + _, _ = m.CreateRule(ctx, driver.RuleConfig{ + ListenerARN: li.ARN, Priority: 20, + Conditions: []driver.RuleCondition{{Field: "host-header", Values: []string{"example.com"}}}, + Actions: []driver.RuleAction{{Type: "forward", TargetGroupARN: tgARN}}, + }) + + t.Run("success", func(t *testing.T) { + rules, descErr := m.DescribeRules(ctx, li.ARN) + require.NoError(t, descErr) + assert.Len(t, rules, 2) + }) + + t.Run("listener not found", func(t *testing.T) { + _, descErr := m.DescribeRules(ctx, "missing") + require.Error(t, descErr) + assert.Contains(t, descErr.Error(), "not found") + }) +} + +func TestModifyListener(t *testing.T) { + ctx := context.Background() + m := newTestMock() + lbARN := createTestLB(t, m) + tgARN := createTestTargetGroup(t, m) + + li, err := m.CreateListener(ctx, driver.ListenerConfig{ + LBARN: lbARN, Protocol: "HTTP", Port: 80, TargetGroupARN: tgARN, + }) + require.NoError(t, err) + + t.Run("modify port", func(t *testing.T) { + require.NoError(t, m.ModifyListener(ctx, driver.ModifyListenerInput{ + ListenerARN: li.ARN, Port: 8080, + })) + + listeners, _ := m.DescribeListeners(ctx, lbARN) + assert.Equal(t, 8080, listeners[0].Port) + }) + + t.Run("not found", func(t *testing.T) { + err := m.ModifyListener(ctx, driver.ModifyListenerInput{ListenerARN: "missing", Port: 80}) + require.Error(t, err) + assert.Contains(t, err.Error(), "not found") + }) +} + +func TestLBAttributes(t *testing.T) { + ctx := context.Background() + m := newTestMock() + lbARN := createTestLB(t, m) + + t.Run("default attributes", func(t *testing.T) { + attrs, err := m.GetLBAttributes(ctx, lbARN) + require.NoError(t, err) + assert.Equal(t, 60, attrs.IdleTimeout) + assert.False(t, attrs.DeletionProtection) + }) + + t.Run("put and get", func(t *testing.T) { + require.NoError(t, m.PutLBAttributes(ctx, lbARN, driver.LBAttributes{ + IdleTimeout: 120, + DeletionProtection: true, + AccessLogsEnabled: true, + AccessLogsBucket: "my-logs", + })) + + attrs, err := m.GetLBAttributes(ctx, lbARN) + require.NoError(t, err) + assert.Equal(t, 120, attrs.IdleTimeout) + assert.True(t, attrs.DeletionProtection) + assert.True(t, attrs.AccessLogsEnabled) + assert.Equal(t, "my-logs", attrs.AccessLogsBucket) + }) + + t.Run("LB not found get", func(t *testing.T) { + _, err := m.GetLBAttributes(ctx, "missing") + require.Error(t, err) + assert.Contains(t, err.Error(), "not found") + }) + + t.Run("LB not found put", func(t *testing.T) { + err := m.PutLBAttributes(ctx, "missing", driver.LBAttributes{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "not found") + }) +} + func TestDeleteLBCascadesListeners(t *testing.T) { ctx := context.Background() m := newTestMock() diff --git a/providers/azure/blobstorage/blobstorage.go b/providers/azure/blobstorage/blobstorage.go index 2fb7566c..5a4e03ea 100644 --- a/providers/azure/blobstorage/blobstorage.go +++ b/providers/azure/blobstorage/blobstorage.go @@ -55,6 +55,9 @@ type containerMeta struct { lifecycle *driver.LifecycleConfig multiparts *memstore.Store[*blobMultipartUpload] versioning bool + policy *driver.BucketPolicy + corsConfig *driver.CORSConfig + encryption *driver.EncryptionConfig } // Mock is an in-memory mock implementation of Azure Blob Storage. @@ -619,3 +622,106 @@ func (m *Mock) GetBucketVersioning(_ context.Context, bucket string) (bool, erro return ctr.versioning, nil } + +func (m *Mock) PutBucketPolicy(_ context.Context, bucket string, policy driver.BucketPolicy) error { + ctr, ok := m.containers.Get(bucket) + if !ok { + return cerrors.Newf(cerrors.NotFound, "container %q not found", bucket) + } + + p := policy + ctr.policy = &p + + return nil +} + +func (m *Mock) GetBucketPolicy(_ context.Context, bucket string) (*driver.BucketPolicy, error) { + ctr, ok := m.containers.Get(bucket) + if !ok { + return nil, cerrors.Newf(cerrors.NotFound, "container %q not found", bucket) + } + + if ctr.policy == nil { + return nil, cerrors.Newf(cerrors.NotFound, "no policy set for container %q", bucket) + } + + p := *ctr.policy + + return &p, nil +} + +func (m *Mock) DeleteBucketPolicy(_ context.Context, bucket string) error { + ctr, ok := m.containers.Get(bucket) + if !ok { + return cerrors.Newf(cerrors.NotFound, "container %q not found", bucket) + } + + ctr.policy = nil + + return nil +} + +func (m *Mock) PutCORSConfig(_ context.Context, bucket string, cfg driver.CORSConfig) error { + ctr, ok := m.containers.Get(bucket) + if !ok { + return cerrors.Newf(cerrors.NotFound, "container %q not found", bucket) + } + + c := cfg + ctr.corsConfig = &c + + return nil +} + +func (m *Mock) GetCORSConfig(_ context.Context, bucket string) (*driver.CORSConfig, error) { + ctr, ok := m.containers.Get(bucket) + if !ok { + return nil, cerrors.Newf(cerrors.NotFound, "container %q not found", bucket) + } + + if ctr.corsConfig == nil { + return nil, cerrors.Newf(cerrors.NotFound, "no CORS config set for container %q", bucket) + } + + c := *ctr.corsConfig + + return &c, nil +} + +func (m *Mock) DeleteCORSConfig(_ context.Context, bucket string) error { + ctr, ok := m.containers.Get(bucket) + if !ok { + return cerrors.Newf(cerrors.NotFound, "container %q not found", bucket) + } + + ctr.corsConfig = nil + + return nil +} + +func (m *Mock) PutEncryptionConfig(_ context.Context, bucket string, cfg driver.EncryptionConfig) error { + ctr, ok := m.containers.Get(bucket) + if !ok { + return cerrors.Newf(cerrors.NotFound, "container %q not found", bucket) + } + + e := cfg + ctr.encryption = &e + + return nil +} + +func (m *Mock) GetEncryptionConfig(_ context.Context, bucket string) (*driver.EncryptionConfig, error) { + ctr, ok := m.containers.Get(bucket) + if !ok { + return nil, cerrors.Newf(cerrors.NotFound, "container %q not found", bucket) + } + + if ctr.encryption == nil { + return nil, cerrors.Newf(cerrors.NotFound, "no encryption config set for container %q", bucket) + } + + e := *ctr.encryption + + return &e, nil +} diff --git a/providers/azure/cosmosdb/cosmosdb.go b/providers/azure/cosmosdb/cosmosdb.go index 3bb8d9c1..51699615 100644 --- a/providers/azure/cosmosdb/cosmosdb.go +++ b/providers/azure/cosmosdb/cosmosdb.go @@ -206,6 +206,48 @@ func (m *Mock) GetItem(_ context.Context, table string, key map[string]any) (map return item, nil } +// UpdateItem applies partial updates to an existing document in a container. +func (m *Mock) UpdateItem(_ context.Context, input driver.UpdateItemInput) (map[string]any, error) { + m.mu.Lock() + + td, exists := m.tables[input.Table] + if !exists { + m.mu.Unlock() + return nil, cerrors.Newf(cerrors.NotFound, "container %s not found", input.Table) + } + + k := itemKey(td.config, input.Key) + item, ok := td.items.Get(k) + + if !ok { + m.mu.Unlock() + return nil, cerrors.New(cerrors.NotFound, "item not found") + } + + oldItem := copyItem(item) + updated := copyItem(item) + + for _, action := range input.Actions { + switch action.Action { + case "SET": + updated[action.Field] = action.Value + case "REMOVE": + delete(updated, action.Field) + default: + m.mu.Unlock() + return nil, cerrors.Newf(cerrors.InvalidArgument, "unsupported action: %s", action.Action) + } + } + + td.items.Set(k, updated) + m.recordStreamEvent(td, oldItem, updated, true) + m.mu.Unlock() + + m.emitMetric(input.Table, map[string]float64{"TotalRequests": 1, "TotalRequestUnits": 1}) + + return updated, nil +} + // DeleteItem deletes an item from a container by key. func (m *Mock) DeleteItem(_ context.Context, table string, key map[string]any) error { m.mu.Lock() diff --git a/providers/azure/cosmosdb/cosmosdb_test.go b/providers/azure/cosmosdb/cosmosdb_test.go index f779d26c..aa6db9e0 100644 --- a/providers/azure/cosmosdb/cosmosdb_test.go +++ b/providers/azure/cosmosdb/cosmosdb_test.go @@ -687,3 +687,187 @@ func (c *cosmosMetricsCollector) hasMetric(namespace, metricName string) bool { } return false } + +func TestUpdateItemSetFields(t *testing.T) { + ctx := context.Background() + m := newTestMock() + createTestTable(t, m) + + require.NoError(t, m.PutItem(ctx, "users", map[string]any{ + "pk": "u1", "sk": "info", "name": "Alice", "age": 30, + })) + + updated, err := m.UpdateItem(ctx, driver.UpdateItemInput{ + Table: "users", + Key: map[string]any{"pk": "u1", "sk": "info"}, + Actions: []driver.UpdateAction{ + {Action: "SET", Field: "name", Value: "Alice Smith"}, + {Action: "SET", Field: "email", Value: "alice@test.com"}, + }, + }) + require.NoError(t, err) + assert.Equal(t, "Alice Smith", updated["name"]) + assert.Equal(t, "alice@test.com", updated["email"]) + assert.Equal(t, 30, updated["age"]) +} + +func TestUpdateItemRemoveFields(t *testing.T) { + ctx := context.Background() + m := newTestMock() + createTestTable(t, m) + + require.NoError(t, m.PutItem(ctx, "users", map[string]any{ + "pk": "u1", "sk": "info", "name": "Alice", "city": "NYC", + })) + + updated, err := m.UpdateItem(ctx, driver.UpdateItemInput{ + Table: "users", + Key: map[string]any{"pk": "u1", "sk": "info"}, + Actions: []driver.UpdateAction{ + {Action: "REMOVE", Field: "city"}, + }, + }) + require.NoError(t, err) + _, hasCityField := updated["city"] + assert.False(t, hasCityField, "expected city to be removed") + assert.Equal(t, "Alice", updated["name"]) +} + +func TestUpdateItemSetAndRemoveCombined(t *testing.T) { + ctx := context.Background() + m := newTestMock() + createTestTable(t, m) + + require.NoError(t, m.PutItem(ctx, "users", map[string]any{ + "pk": "u1", "sk": "info", "name": "Alice", "old_field": "remove_me", + })) + + updated, err := m.UpdateItem(ctx, driver.UpdateItemInput{ + Table: "users", + Key: map[string]any{"pk": "u1", "sk": "info"}, + Actions: []driver.UpdateAction{ + {Action: "SET", Field: "name", Value: "Bob"}, + {Action: "REMOVE", Field: "old_field"}, + }, + }) + require.NoError(t, err) + assert.Equal(t, "Bob", updated["name"]) + _, hasOld := updated["old_field"] + assert.False(t, hasOld, "expected old_field to be removed") +} + +func TestUpdateItemPersistsChanges(t *testing.T) { + ctx := context.Background() + m := newTestMock() + createTestTable(t, m) + + require.NoError(t, m.PutItem(ctx, "users", map[string]any{ + "pk": "u1", "sk": "info", "v": "old", + })) + + _, err := m.UpdateItem(ctx, driver.UpdateItemInput{ + Table: "users", + Key: map[string]any{"pk": "u1", "sk": "info"}, + Actions: []driver.UpdateAction{ + {Action: "SET", Field: "v", Value: "new"}, + }, + }) + require.NoError(t, err) + + got, err := m.GetItem(ctx, "users", map[string]any{"pk": "u1", "sk": "info"}) + require.NoError(t, err) + assert.Equal(t, "new", got["v"]) +} + +func TestUpdateItemTableNotFound(t *testing.T) { + ctx := context.Background() + m := newTestMock() + + _, err := m.UpdateItem(ctx, driver.UpdateItemInput{ + Table: "nonexistent", + Key: map[string]any{"pk": "x"}, + Actions: []driver.UpdateAction{{Action: "SET", Field: "v", Value: 1}}, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "not found") +} + +func TestUpdateItemItemNotFound(t *testing.T) { + ctx := context.Background() + m := newTestMock() + createTestTable(t, m) + + _, err := m.UpdateItem(ctx, driver.UpdateItemInput{ + Table: "users", + Key: map[string]any{"pk": "missing", "sk": "missing"}, + Actions: []driver.UpdateAction{{Action: "SET", Field: "v", Value: 1}}, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "not found") +} + +func TestUpdateItemInvalidAction(t *testing.T) { + ctx := context.Background() + m := newTestMock() + createTestTable(t, m) + + require.NoError(t, m.PutItem(ctx, "users", map[string]any{"pk": "u1", "sk": "info", "v": 1})) + + _, err := m.UpdateItem(ctx, driver.UpdateItemInput{ + Table: "users", + Key: map[string]any{"pk": "u1", "sk": "info"}, + Actions: []driver.UpdateAction{{Action: "ADD", Field: "v", Value: 1}}, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "unsupported") +} + +func TestUpdateItemEmitsStreamRecord(t *testing.T) { + ctx := context.Background() + m := newTestMock() + createTestTable(t, m) + + require.NoError(t, m.UpdateStreamConfig(ctx, "users", driver.StreamConfig{ + Enabled: true, ViewType: "NEW_AND_OLD_IMAGES", + })) + + require.NoError(t, m.PutItem(ctx, "users", map[string]any{"pk": "u1", "sk": "info", "val": "old"})) + + _, err := m.UpdateItem(ctx, driver.UpdateItemInput{ + Table: "users", + Key: map[string]any{"pk": "u1", "sk": "info"}, + Actions: []driver.UpdateAction{ + {Action: "SET", Field: "val", Value: "new"}, + }, + }) + require.NoError(t, err) + + iter, err := m.GetStreamRecords(ctx, "users", 10, "") + require.NoError(t, err) + require.Len(t, iter.Records, 2) + assert.Equal(t, "MODIFY", iter.Records[1].EventType) + assert.Equal(t, "old", iter.Records[1].OldImage["val"]) + assert.Equal(t, "new", iter.Records[1].NewImage["val"]) +} + +func TestUpdateItemEmitsMetrics(t *testing.T) { + ctx := context.Background() + m := newTestMock() + mon := &cosmosMetricsCollector{} + m.SetMonitoring(mon) + createTestTable(t, m) + + require.NoError(t, m.PutItem(ctx, "users", map[string]any{"pk": "u1", "sk": "info"})) + + mon.reset() + + _, err := m.UpdateItem(ctx, driver.UpdateItemInput{ + Table: "users", + Key: map[string]any{"pk": "u1", "sk": "info"}, + Actions: []driver.UpdateAction{ + {Action: "SET", Field: "v", Value: "x"}, + }, + }) + require.NoError(t, err) + assert.True(t, mon.hasMetric("Microsoft.DocumentDB/databaseAccounts", "TotalRequests")) +} diff --git a/providers/gcp/firestore/firestore.go b/providers/gcp/firestore/firestore.go index ee745629..f29f64ea 100644 --- a/providers/gcp/firestore/firestore.go +++ b/providers/gcp/firestore/firestore.go @@ -194,6 +194,48 @@ func (m *Mock) GetItem(ctx context.Context, table string, key map[string]any) (m return item, nil } +// UpdateItem applies partial updates to an existing document in a collection. +func (m *Mock) UpdateItem(ctx context.Context, input driver.UpdateItemInput) (map[string]any, error) { + m.mu.Lock() + + cd, exists := m.collections[input.Table] + if !exists { + m.mu.Unlock() + return nil, cerrors.Newf(cerrors.NotFound, "collection %s not found", input.Table) + } + + k := docKey(cd.config, input.Key) + item, ok := cd.items.Get(k) + + if !ok { + m.mu.Unlock() + return nil, cerrors.New(cerrors.NotFound, "document not found") + } + + oldItem := copyItem(item) + updated := copyItem(item) + + for _, action := range input.Actions { + switch action.Action { + case "SET": + updated[action.Field] = action.Value + case "REMOVE": + delete(updated, action.Field) + default: + m.mu.Unlock() + return nil, cerrors.Newf(cerrors.InvalidArgument, "unsupported action: %s", action.Action) + } + } + + cd.items.Set(k, updated) + m.recordStreamEvent(cd, oldItem, updated, true) + m.mu.Unlock() + + m.emitMetric(ctx, "document/write_count", 1, map[string]string{"collection_id": input.Table}) + + return updated, nil +} + func (m *Mock) DeleteItem(ctx context.Context, table string, key map[string]any) error { m.mu.Lock() diff --git a/providers/gcp/firestore/firestore_test.go b/providers/gcp/firestore/firestore_test.go index f29e7a82..4f3245a9 100644 --- a/providers/gcp/firestore/firestore_test.go +++ b/providers/gcp/firestore/firestore_test.go @@ -954,3 +954,192 @@ func TestScanUnsupportedFilter(t *testing.T) { require.NoError(t, err) assert.Equal(t, 0, result.Count) } + +func TestUpdateItemSetFields(t *testing.T) { + ctx := context.Background() + m := newTestMock() + + require.NoError(t, m.CreateTable(ctx, driver.TableConfig{Name: "col", PartitionKey: "pk", SortKey: "sk"})) + require.NoError(t, m.PutItem(ctx, "col", map[string]any{ + "pk": "u1", "sk": "info", "name": "Alice", "age": 30, + })) + + updated, err := m.UpdateItem(ctx, driver.UpdateItemInput{ + Table: "col", + Key: map[string]any{"pk": "u1", "sk": "info"}, + Actions: []driver.UpdateAction{ + {Action: "SET", Field: "name", Value: "Alice Smith"}, + {Action: "SET", Field: "email", Value: "alice@test.com"}, + }, + }) + require.NoError(t, err) + assert.Equal(t, "Alice Smith", updated["name"]) + assert.Equal(t, "alice@test.com", updated["email"]) + assert.Equal(t, 30, updated["age"]) +} + +func TestUpdateItemRemoveFields(t *testing.T) { + ctx := context.Background() + m := newTestMock() + + require.NoError(t, m.CreateTable(ctx, driver.TableConfig{Name: "col", PartitionKey: "pk", SortKey: "sk"})) + require.NoError(t, m.PutItem(ctx, "col", map[string]any{ + "pk": "u1", "sk": "info", "name": "Alice", "city": "NYC", + })) + + updated, err := m.UpdateItem(ctx, driver.UpdateItemInput{ + Table: "col", + Key: map[string]any{"pk": "u1", "sk": "info"}, + Actions: []driver.UpdateAction{ + {Action: "REMOVE", Field: "city"}, + }, + }) + require.NoError(t, err) + _, hasCityField := updated["city"] + assert.False(t, hasCityField, "expected city to be removed") + assert.Equal(t, "Alice", updated["name"]) +} + +func TestUpdateItemSetAndRemoveCombined(t *testing.T) { + ctx := context.Background() + m := newTestMock() + + require.NoError(t, m.CreateTable(ctx, driver.TableConfig{Name: "col", PartitionKey: "pk", SortKey: "sk"})) + require.NoError(t, m.PutItem(ctx, "col", map[string]any{ + "pk": "u1", "sk": "info", "name": "Alice", "old_field": "remove_me", + })) + + updated, err := m.UpdateItem(ctx, driver.UpdateItemInput{ + Table: "col", + Key: map[string]any{"pk": "u1", "sk": "info"}, + Actions: []driver.UpdateAction{ + {Action: "SET", Field: "name", Value: "Bob"}, + {Action: "REMOVE", Field: "old_field"}, + }, + }) + require.NoError(t, err) + assert.Equal(t, "Bob", updated["name"]) + _, hasOld := updated["old_field"] + assert.False(t, hasOld, "expected old_field to be removed") +} + +func TestUpdateItemPersistsChanges(t *testing.T) { + ctx := context.Background() + m := newTestMock() + + require.NoError(t, m.CreateTable(ctx, driver.TableConfig{Name: "col", PartitionKey: "pk", SortKey: "sk"})) + require.NoError(t, m.PutItem(ctx, "col", map[string]any{ + "pk": "u1", "sk": "info", "v": "old", + })) + + _, err := m.UpdateItem(ctx, driver.UpdateItemInput{ + Table: "col", + Key: map[string]any{"pk": "u1", "sk": "info"}, + Actions: []driver.UpdateAction{ + {Action: "SET", Field: "v", Value: "new"}, + }, + }) + require.NoError(t, err) + + got, err := m.GetItem(ctx, "col", map[string]any{"pk": "u1", "sk": "info"}) + require.NoError(t, err) + assert.Equal(t, "new", got["v"]) +} + +func TestUpdateItemCollectionNotFound(t *testing.T) { + ctx := context.Background() + m := newTestMock() + + _, err := m.UpdateItem(ctx, driver.UpdateItemInput{ + Table: "nonexistent", + Key: map[string]any{"pk": "x"}, + Actions: []driver.UpdateAction{{Action: "SET", Field: "v", Value: 1}}, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "not found") +} + +func TestUpdateItemDocumentNotFound(t *testing.T) { + ctx := context.Background() + m := newTestMock() + + require.NoError(t, m.CreateTable(ctx, driver.TableConfig{Name: "col", PartitionKey: "pk", SortKey: "sk"})) + + _, err := m.UpdateItem(ctx, driver.UpdateItemInput{ + Table: "col", + Key: map[string]any{"pk": "missing", "sk": "missing"}, + Actions: []driver.UpdateAction{{Action: "SET", Field: "v", Value: 1}}, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "not found") +} + +func TestUpdateItemInvalidAction(t *testing.T) { + ctx := context.Background() + m := newTestMock() + + require.NoError(t, m.CreateTable(ctx, driver.TableConfig{Name: "col", PartitionKey: "pk", SortKey: "sk"})) + require.NoError(t, m.PutItem(ctx, "col", map[string]any{"pk": "u1", "sk": "info", "v": 1})) + + _, err := m.UpdateItem(ctx, driver.UpdateItemInput{ + Table: "col", + Key: map[string]any{"pk": "u1", "sk": "info"}, + Actions: []driver.UpdateAction{{Action: "ADD", Field: "v", Value: 1}}, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "unsupported") +} + +func TestUpdateItemEmitsStreamRecord(t *testing.T) { + ctx := context.Background() + m := newTestMock() + + require.NoError(t, m.CreateTable(ctx, driver.TableConfig{Name: "col", PartitionKey: "pk", SortKey: "sk"})) + require.NoError(t, m.UpdateStreamConfig(ctx, "col", driver.StreamConfig{ + Enabled: true, ViewType: "NEW_AND_OLD_IMAGES", + })) + + require.NoError(t, m.PutItem(ctx, "col", map[string]any{"pk": "u1", "sk": "info", "val": "old"})) + + _, err := m.UpdateItem(ctx, driver.UpdateItemInput{ + Table: "col", + Key: map[string]any{"pk": "u1", "sk": "info"}, + Actions: []driver.UpdateAction{ + {Action: "SET", Field: "val", Value: "new"}, + }, + }) + require.NoError(t, err) + + iter, err := m.GetStreamRecords(ctx, "col", 10, "") + require.NoError(t, err) + require.Len(t, iter.Records, 2) + assert.Equal(t, "MODIFY", iter.Records[1].EventType) + assert.Equal(t, "old", iter.Records[1].OldImage["val"]) + assert.Equal(t, "new", iter.Records[1].NewImage["val"]) +} + +func TestUpdateItemEmitsMetrics(t *testing.T) { + ctx := context.Background() + clk := config.NewFakeClock(time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)) + opts := config.NewOptions(config.WithClock(clk), config.WithProjectID("test-project")) + + mon := &firestoreMonMock{data: make(map[string][]mondriver.MetricDatum)} + m := New(opts) + m.SetMonitoring(mon) + + require.NoError(t, m.CreateTable(ctx, driver.TableConfig{Name: "col", PartitionKey: "pk", SortKey: "sk"})) + require.NoError(t, m.PutItem(ctx, "col", map[string]any{"pk": "u1", "sk": "info"})) + + // Clear metrics from PutItem + mon.data = make(map[string][]mondriver.MetricDatum) + + _, err := m.UpdateItem(ctx, driver.UpdateItemInput{ + Table: "col", + Key: map[string]any{"pk": "u1", "sk": "info"}, + Actions: []driver.UpdateAction{ + {Action: "SET", Field: "v", Value: "x"}, + }, + }) + require.NoError(t, err) + assert.NotEmpty(t, mon.data["firestore.googleapis.com/document/write_count"]) +} diff --git a/providers/gcp/gcplb/lb.go b/providers/gcp/gcplb/lb.go index cfccfcc5..db5f0587 100644 --- a/providers/gcp/gcplb/lb.go +++ b/providers/gcp/gcplb/lb.go @@ -16,15 +16,22 @@ import ( // Compile-time check that Mock implements driver.LoadBalancer. var _ driver.LoadBalancer = (*Mock)(nil) +// defaultIdleTimeoutSec is the default idle timeout for load balancers in seconds. +const defaultIdleTimeoutSec = 60 + // Mock is an in-memory mock implementation of the GCP Cloud Load Balancing service. type Mock struct { lbs *memstore.Store[driver.LBInfo] tgs *memstore.Store[driver.TargetGroupInfo] listeners *memstore.Store[driver.ListenerInfo] + rules *memstore.Store[driver.RuleInfo] opts *config.Options healthMu sync.RWMutex health map[string]map[string]*driver.TargetHealth // tgARN -> targetID -> health + + attrsMu sync.RWMutex + attrs map[string]driver.LBAttributes // lbARN -> attributes } // New creates a new Cloud Load Balancing mock with the given configuration options. @@ -33,8 +40,10 @@ func New(opts *config.Options) *Mock { lbs: memstore.New[driver.LBInfo](), tgs: memstore.New[driver.TargetGroupInfo](), listeners: memstore.New[driver.ListenerInfo](), + rules: memstore.New[driver.RuleInfo](), opts: opts, health: make(map[string]map[string]*driver.TargetHealth), + attrs: make(map[string]driver.LBAttributes), } } @@ -186,6 +195,18 @@ func describeResources[T any](store *memstore.Store[T], keys []string) []T { return results } +// filterToSlice returns a slice of values from the store that match the predicate. +func filterToSlice[T any](store *memstore.Store[T], pred func(string, T) bool) []T { + filtered := store.Filter(pred) + + results := make([]T, 0, len(filtered)) + for _, item := range filtered { + results = append(results, item) + } + + return results +} + // CreateListener creates a new URL map / listener on a load balancer. func (m *Mock) CreateListener(_ context.Context, cfg driver.ListenerConfig) (*driver.ListenerInfo, error) { if _, ok := m.lbs.Get(cfg.LBARN); !ok { @@ -225,16 +246,113 @@ func (m *Mock) DescribeListeners(_ context.Context, lbARN string) ([]driver.List return nil, cerrors.Newf(cerrors.NotFound, "load balancer %q not found", lbARN) } - filtered := m.listeners.Filter(func(_ string, li driver.ListenerInfo) bool { + return filterToSlice(m.listeners, func(_ string, li driver.ListenerInfo) bool { return li.LBARN == lbARN - }) + }), nil +} - results := make([]driver.ListenerInfo, 0, len(filtered)) - for _, li := range filtered { - results = append(results, li) +// CreateRule creates a new URL map path rule for a listener. +func (m *Mock) CreateRule(_ context.Context, cfg driver.RuleConfig) (*driver.RuleInfo, error) { + if _, ok := m.listeners.Get(cfg.ListenerARN); !ok { + return nil, cerrors.Newf(cerrors.NotFound, "listener %q not found", cfg.ListenerARN) } - return results, nil + arn := idgen.GCPID(m.opts.ProjectID, "pathRules", idgen.GenerateID("rule-")) + + conditions := make([]driver.RuleCondition, len(cfg.Conditions)) + copy(conditions, cfg.Conditions) + + actions := make([]driver.RuleAction, len(cfg.Actions)) + copy(actions, cfg.Actions) + + rule := driver.RuleInfo{ + ARN: arn, + ListenerARN: cfg.ListenerARN, + Priority: cfg.Priority, + Conditions: conditions, + Actions: actions, + IsDefault: false, + } + + m.rules.Set(arn, rule) + + result := rule + + return &result, nil +} + +// DeleteRule deletes a URL map path rule by resource name (ARN). +func (m *Mock) DeleteRule(_ context.Context, ruleARN string) error { + if !m.rules.Delete(ruleARN) { + return cerrors.Newf(cerrors.NotFound, "rule %q not found", ruleARN) + } + + return nil +} + +// DescribeRules returns all path rules for the specified listener. +func (m *Mock) DescribeRules(_ context.Context, listenerARN string) ([]driver.RuleInfo, error) { + if _, ok := m.listeners.Get(listenerARN); !ok { + return nil, cerrors.Newf(cerrors.NotFound, "listener %q not found", listenerARN) + } + + return filterToSlice(m.rules, func(_ string, r driver.RuleInfo) bool { + return r.ListenerARN == listenerARN + }), nil +} + +// ModifyListener modifies an existing URL map listener's port, protocol, or default actions. +func (m *Mock) ModifyListener(_ context.Context, input driver.ModifyListenerInput) error { + li, ok := m.listeners.Get(input.ListenerARN) + if !ok { + return cerrors.Newf(cerrors.NotFound, "listener %q not found", input.ListenerARN) + } + + if input.Port != 0 { + li.Port = input.Port + } + + if input.Protocol != "" { + li.Protocol = input.Protocol + } + + if len(input.DefaultActions) > 0 { + li.TargetGroupARN = input.DefaultActions[0].TargetGroupARN + } + + m.listeners.Set(input.ListenerARN, li) + + return nil +} + +// GetLBAttributes returns the attributes for a load balancer. +func (m *Mock) GetLBAttributes(_ context.Context, lbARN string) (*driver.LBAttributes, error) { + if _, ok := m.lbs.Get(lbARN); !ok { + return nil, cerrors.Newf(cerrors.NotFound, "load balancer %q not found", lbARN) + } + + m.attrsMu.RLock() + defer m.attrsMu.RUnlock() + + attrs, ok := m.attrs[lbARN] + if !ok { + attrs = driver.LBAttributes{IdleTimeout: defaultIdleTimeoutSec} + } + + return &attrs, nil +} + +// PutLBAttributes sets the attributes for a load balancer. +func (m *Mock) PutLBAttributes(_ context.Context, lbARN string, attrs driver.LBAttributes) error { + if _, ok := m.lbs.Get(lbARN); !ok { + return cerrors.Newf(cerrors.NotFound, "load balancer %q not found", lbARN) + } + + m.attrsMu.Lock() + m.attrs[lbARN] = attrs + m.attrsMu.Unlock() + + return nil } // RegisterTargets adds instances to a backend service (target group). diff --git a/providers/gcp/gcplb/lb_test.go b/providers/gcp/gcplb/lb_test.go index ed7c257d..62cff86b 100644 --- a/providers/gcp/gcplb/lb_test.go +++ b/providers/gcp/gcplb/lb_test.go @@ -355,6 +355,179 @@ func TestDescribeListeners(t *testing.T) { }) } +func TestCreateRule(t *testing.T) { + ctx := context.Background() + m := newTestMock() + + lb, err := m.CreateLoadBalancer(ctx, driver.LBConfig{Name: "lb1"}) + require.NoError(t, err) + + tg, err := m.CreateTargetGroup(ctx, driver.TargetGroupConfig{Name: "tg1", Protocol: "HTTP", Port: 80}) + require.NoError(t, err) + + li, err := m.CreateListener(ctx, driver.ListenerConfig{ + LBARN: lb.ARN, Protocol: "HTTP", Port: 80, TargetGroupARN: tg.ARN, + }) + require.NoError(t, err) + + t.Run("success", func(t *testing.T) { + rule, ruleErr := m.CreateRule(ctx, driver.RuleConfig{ + ListenerARN: li.ARN, + Priority: 10, + Conditions: []driver.RuleCondition{{Field: "path-pattern", Values: []string{"/api/*"}}}, + Actions: []driver.RuleAction{{Type: "forward", TargetGroupARN: tg.ARN}}, + }) + require.NoError(t, ruleErr) + assert.NotEmpty(t, rule.ARN) + assert.Equal(t, li.ARN, rule.ListenerARN) + assert.Equal(t, 10, rule.Priority) + assert.False(t, rule.IsDefault) + }) + + t.Run("listener not found", func(t *testing.T) { + _, ruleErr := m.CreateRule(ctx, driver.RuleConfig{ListenerARN: "missing"}) + require.Error(t, ruleErr) + assert.Contains(t, ruleErr.Error(), "not found") + }) +} + +func TestDeleteRule(t *testing.T) { + ctx := context.Background() + m := newTestMock() + + lb, err := m.CreateLoadBalancer(ctx, driver.LBConfig{Name: "lb1"}) + require.NoError(t, err) + + li, err := m.CreateListener(ctx, driver.ListenerConfig{LBARN: lb.ARN, Protocol: "HTTP", Port: 80}) + require.NoError(t, err) + + rule, err := m.CreateRule(ctx, driver.RuleConfig{ListenerARN: li.ARN, Priority: 10}) + require.NoError(t, err) + + t.Run("success", func(t *testing.T) { + require.NoError(t, m.DeleteRule(ctx, rule.ARN)) + }) + + t.Run("not found", func(t *testing.T) { + err := m.DeleteRule(ctx, "missing") + require.Error(t, err) + assert.Contains(t, err.Error(), "not found") + }) +} + +func TestDescribeRules(t *testing.T) { + ctx := context.Background() + m := newTestMock() + + lb, err := m.CreateLoadBalancer(ctx, driver.LBConfig{Name: "lb1"}) + require.NoError(t, err) + + tg, err := m.CreateTargetGroup(ctx, driver.TargetGroupConfig{Name: "tg1", Protocol: "HTTP", Port: 80}) + require.NoError(t, err) + + li, err := m.CreateListener(ctx, driver.ListenerConfig{ + LBARN: lb.ARN, Protocol: "HTTP", Port: 80, TargetGroupARN: tg.ARN, + }) + require.NoError(t, err) + + _, _ = m.CreateRule(ctx, driver.RuleConfig{ + ListenerARN: li.ARN, Priority: 10, + Conditions: []driver.RuleCondition{{Field: "path-pattern", Values: []string{"/api/*"}}}, + Actions: []driver.RuleAction{{Type: "forward", TargetGroupARN: tg.ARN}}, + }) + _, _ = m.CreateRule(ctx, driver.RuleConfig{ + ListenerARN: li.ARN, Priority: 20, + Conditions: []driver.RuleCondition{{Field: "host-header", Values: []string{"example.com"}}}, + Actions: []driver.RuleAction{{Type: "forward", TargetGroupARN: tg.ARN}}, + }) + + t.Run("success", func(t *testing.T) { + rules, descErr := m.DescribeRules(ctx, li.ARN) + require.NoError(t, descErr) + assert.Len(t, rules, 2) + }) + + t.Run("listener not found", func(t *testing.T) { + _, descErr := m.DescribeRules(ctx, "missing") + require.Error(t, descErr) + assert.Contains(t, descErr.Error(), "not found") + }) +} + +func TestModifyListener(t *testing.T) { + ctx := context.Background() + m := newTestMock() + + lb, err := m.CreateLoadBalancer(ctx, driver.LBConfig{Name: "lb1"}) + require.NoError(t, err) + + tg, err := m.CreateTargetGroup(ctx, driver.TargetGroupConfig{Name: "tg1", Protocol: "HTTP", Port: 80}) + require.NoError(t, err) + + li, err := m.CreateListener(ctx, driver.ListenerConfig{ + LBARN: lb.ARN, Protocol: "HTTP", Port: 80, TargetGroupARN: tg.ARN, + }) + require.NoError(t, err) + + t.Run("modify port", func(t *testing.T) { + require.NoError(t, m.ModifyListener(ctx, driver.ModifyListenerInput{ + ListenerARN: li.ARN, Port: 8080, + })) + + listeners, _ := m.DescribeListeners(ctx, lb.ARN) + assert.Equal(t, 8080, listeners[0].Port) + }) + + t.Run("not found", func(t *testing.T) { + err := m.ModifyListener(ctx, driver.ModifyListenerInput{ListenerARN: "missing", Port: 80}) + require.Error(t, err) + assert.Contains(t, err.Error(), "not found") + }) +} + +func TestLBAttributes(t *testing.T) { + ctx := context.Background() + m := newTestMock() + + lb, err := m.CreateLoadBalancer(ctx, driver.LBConfig{Name: "lb1"}) + require.NoError(t, err) + + t.Run("default attributes", func(t *testing.T) { + attrs, attrErr := m.GetLBAttributes(ctx, lb.ARN) + require.NoError(t, attrErr) + assert.Equal(t, 60, attrs.IdleTimeout) + assert.False(t, attrs.DeletionProtection) + }) + + t.Run("put and get", func(t *testing.T) { + require.NoError(t, m.PutLBAttributes(ctx, lb.ARN, driver.LBAttributes{ + IdleTimeout: 120, + DeletionProtection: true, + AccessLogsEnabled: true, + AccessLogsBucket: "my-logs", + })) + + attrs, attrErr := m.GetLBAttributes(ctx, lb.ARN) + require.NoError(t, attrErr) + assert.Equal(t, 120, attrs.IdleTimeout) + assert.True(t, attrs.DeletionProtection) + assert.True(t, attrs.AccessLogsEnabled) + assert.Equal(t, "my-logs", attrs.AccessLogsBucket) + }) + + t.Run("LB not found get", func(t *testing.T) { + _, attrErr := m.GetLBAttributes(ctx, "missing") + require.Error(t, attrErr) + assert.Contains(t, attrErr.Error(), "not found") + }) + + t.Run("LB not found put", func(t *testing.T) { + attrErr := m.PutLBAttributes(ctx, "missing", driver.LBAttributes{}) + require.Error(t, attrErr) + assert.Contains(t, attrErr.Error(), "not found") + }) +} + func TestDeleteListenerCleansUpOnLBDelete(t *testing.T) { ctx := context.Background() m := newTestMock() diff --git a/providers/gcp/gcs/gcs.go b/providers/gcp/gcs/gcs.go index b1688b6d..247068f9 100644 --- a/providers/gcp/gcs/gcs.go +++ b/providers/gcp/gcs/gcs.go @@ -55,6 +55,9 @@ type bucketMeta struct { lifecycle *driver.LifecycleConfig multiparts *memstore.Store[*gcsMultipartUpload] versioning bool + policy *driver.BucketPolicy + corsConfig *driver.CORSConfig + encryption *driver.EncryptionConfig } // Mock is an in-memory mock implementation of Google Cloud Storage. @@ -616,3 +619,106 @@ func (m *Mock) GetBucketVersioning(_ context.Context, bucket string) (bool, erro return bkt.versioning, nil } + +func (m *Mock) PutBucketPolicy(_ context.Context, bucket string, policy driver.BucketPolicy) error { + bkt, ok := m.buckets.Get(bucket) + if !ok { + return cerrors.Newf(cerrors.NotFound, "bucket %q not found", bucket) + } + + p := policy + bkt.policy = &p + + return nil +} + +func (m *Mock) GetBucketPolicy(_ context.Context, bucket string) (*driver.BucketPolicy, error) { + bkt, ok := m.buckets.Get(bucket) + if !ok { + return nil, cerrors.Newf(cerrors.NotFound, "bucket %q not found", bucket) + } + + if bkt.policy == nil { + return nil, cerrors.Newf(cerrors.NotFound, "no policy set for bucket %q", bucket) + } + + p := *bkt.policy + + return &p, nil +} + +func (m *Mock) DeleteBucketPolicy(_ context.Context, bucket string) error { + bkt, ok := m.buckets.Get(bucket) + if !ok { + return cerrors.Newf(cerrors.NotFound, "bucket %q not found", bucket) + } + + bkt.policy = nil + + return nil +} + +func (m *Mock) PutCORSConfig(_ context.Context, bucket string, cfg driver.CORSConfig) error { + bkt, ok := m.buckets.Get(bucket) + if !ok { + return cerrors.Newf(cerrors.NotFound, "bucket %q not found", bucket) + } + + c := cfg + bkt.corsConfig = &c + + return nil +} + +func (m *Mock) GetCORSConfig(_ context.Context, bucket string) (*driver.CORSConfig, error) { + bkt, ok := m.buckets.Get(bucket) + if !ok { + return nil, cerrors.Newf(cerrors.NotFound, "bucket %q not found", bucket) + } + + if bkt.corsConfig == nil { + return nil, cerrors.Newf(cerrors.NotFound, "no CORS config set for bucket %q", bucket) + } + + c := *bkt.corsConfig + + return &c, nil +} + +func (m *Mock) DeleteCORSConfig(_ context.Context, bucket string) error { + bkt, ok := m.buckets.Get(bucket) + if !ok { + return cerrors.Newf(cerrors.NotFound, "bucket %q not found", bucket) + } + + bkt.corsConfig = nil + + return nil +} + +func (m *Mock) PutEncryptionConfig(_ context.Context, bucket string, cfg driver.EncryptionConfig) error { + bkt, ok := m.buckets.Get(bucket) + if !ok { + return cerrors.Newf(cerrors.NotFound, "bucket %q not found", bucket) + } + + e := cfg + bkt.encryption = &e + + return nil +} + +func (m *Mock) GetEncryptionConfig(_ context.Context, bucket string) (*driver.EncryptionConfig, error) { + bkt, ok := m.buckets.Get(bucket) + if !ok { + return nil, cerrors.Newf(cerrors.NotFound, "bucket %q not found", bucket) + } + + if bkt.encryption == nil { + return nil, cerrors.Newf(cerrors.NotFound, "no encryption config set for bucket %q", bucket) + } + + e := *bkt.encryption + + return &e, nil +} diff --git a/providers/gcp/memorystore/memorystore.go b/providers/gcp/memorystore/memorystore.go index 63499428..88a3fca4 100644 --- a/providers/gcp/memorystore/memorystore.go +++ b/providers/gcp/memorystore/memorystore.go @@ -5,6 +5,7 @@ import ( "context" "fmt" "path" + "strconv" "time" "github.com/stackshy/cloudemu/cache/driver" @@ -292,6 +293,136 @@ func (m *Mock) FlushAll(_ context.Context, cacheName string) error { return nil } +// Expire sets a TTL on an existing key. +func (m *Mock) Expire(_ context.Context, cacheName, key string, ttl time.Duration) error { + cd, ok := m.caches.Get(cacheName) + if !ok { + return errors.Newf(errors.NotFound, "cache %q not found", cacheName) + } + + item, ok := cd.items.Get(key) + if !ok || (item.HasTTL && m.opts.Clock.Now().After(item.ExpiresAt)) { + return errors.Newf(errors.NotFound, "key %q not found in cache %q", key, cacheName) + } + + item.HasTTL = true + item.ExpiresAt = m.opts.Clock.Now().Add(ttl) + cd.items.Set(key, item) + + return nil +} + +// GetTTL returns the remaining TTL for a key. Returns -1 if the key has no TTL. +func (m *Mock) GetTTL(_ context.Context, cacheName, key string) (time.Duration, error) { + cd, ok := m.caches.Get(cacheName) + if !ok { + return 0, errors.Newf(errors.NotFound, "cache %q not found", cacheName) + } + + item, ok := cd.items.Get(key) + if !ok || (item.HasTTL && m.opts.Clock.Now().After(item.ExpiresAt)) { + return 0, errors.Newf(errors.NotFound, "key %q not found in cache %q", key, cacheName) + } + + if !item.HasTTL { + return -1, nil + } + + return item.ExpiresAt.Sub(m.opts.Clock.Now()), nil +} + +// Persist removes the TTL from a key, making it persistent. +func (m *Mock) Persist(_ context.Context, cacheName, key string) error { + cd, ok := m.caches.Get(cacheName) + if !ok { + return errors.Newf(errors.NotFound, "cache %q not found", cacheName) + } + + item, ok := cd.items.Get(key) + if !ok || (item.HasTTL && m.opts.Clock.Now().After(item.ExpiresAt)) { + return errors.Newf(errors.NotFound, "key %q not found in cache %q", key, cacheName) + } + + item.HasTTL = false + item.ExpiresAt = time.Time{} + cd.items.Set(key, item) + + return nil +} + +// Incr atomically increments the integer value of a key by 1. +func (m *Mock) Incr(ctx context.Context, cacheName, key string) (int64, error) { + return m.IncrBy(ctx, cacheName, key, 1) +} + +// IncrBy atomically increments the integer value of a key by delta. +func (m *Mock) IncrBy(ctx context.Context, cacheName, key string, delta int64) (int64, error) { + cd, ok := m.caches.Get(cacheName) + if !ok { + return 0, errors.Newf(errors.NotFound, "cache %q not found", cacheName) + } + + newVal, err := applyDelta(cd, key, delta, m.opts.Clock.Now()) + if err != nil { + return 0, err + } + + m.emitMetric(ctx, "commands/total", 1, map[string]string{"instance_id": cacheName}) + + return newVal, nil +} + +// Decr atomically decrements the integer value of a key by 1. +func (m *Mock) Decr(ctx context.Context, cacheName, key string) (int64, error) { + return m.DecrBy(ctx, cacheName, key, 1) +} + +// DecrBy atomically decrements the integer value of a key by delta. +func (m *Mock) DecrBy(ctx context.Context, cacheName, key string, delta int64) (int64, error) { + cd, ok := m.caches.Get(cacheName) + if !ok { + return 0, errors.Newf(errors.NotFound, "cache %q not found", cacheName) + } + + newVal, err := applyDelta(cd, key, -delta, m.opts.Clock.Now()) + if err != nil { + return 0, err + } + + m.emitMetric(ctx, "commands/total", 1, map[string]string{"instance_id": cacheName}) + + return newVal, nil +} + +func applyDelta(cd *cacheData, key string, delta int64, now time.Time) (int64, error) { + item, ok := cd.items.Get(key) + + var current int64 + + if ok && (!item.HasTTL || !now.After(item.ExpiresAt)) { + val, err := strconv.ParseInt(string(item.Value), 10, 64) + if err != nil { + return 0, errors.New(errors.InvalidArgument, "value is not an integer") + } + + current = val + } + + newVal := current + delta + newItem := cacheItem{ + Value: []byte(strconv.FormatInt(newVal, 10)), + } + + if ok && item.HasTTL && !now.After(item.ExpiresAt) { + newItem.HasTTL = true + newItem.ExpiresAt = item.ExpiresAt + } + + cd.items.Set(key, newItem) + + return newVal, nil +} + // matchPattern matches a key against a glob-like pattern. // Supports full glob syntax including middle wildcards like "user:*:session". func matchPattern(pattern, key string) bool { diff --git a/providers/gcp/memorystore/memorystore_test.go b/providers/gcp/memorystore/memorystore_test.go index fb92ef91..fe032d2e 100644 --- a/providers/gcp/memorystore/memorystore_test.go +++ b/providers/gcp/memorystore/memorystore_test.go @@ -390,3 +390,123 @@ func TestMatchPattern(t *testing.T) { }) } } + +func TestExpire(t *testing.T) { + m, fc := newTestMock() + ctx := context.Background() + createTestCache(t, m, "c1") + + require.NoError(t, m.Set(ctx, "c1", "k1", []byte("val"), 0)) + + ttl, err := m.GetTTL(ctx, "c1", "k1") + require.NoError(t, err) + assert.Equal(t, time.Duration(-1), ttl) + + require.NoError(t, m.Expire(ctx, "c1", "k1", 1*time.Hour)) + + ttl, err = m.GetTTL(ctx, "c1", "k1") + require.NoError(t, err) + assert.True(t, ttl > 0 && ttl <= 1*time.Hour) + + fc.Advance(2 * time.Hour) + + _, err = m.Get(ctx, "c1", "k1") + require.Error(t, err) +} + +func TestPersist(t *testing.T) { + m, fc := newTestMock() + ctx := context.Background() + createTestCache(t, m, "c1") + + require.NoError(t, m.Set(ctx, "c1", "k1", []byte("val"), 1*time.Hour)) + require.NoError(t, m.Persist(ctx, "c1", "k1")) + + ttl, err := m.GetTTL(ctx, "c1", "k1") + require.NoError(t, err) + assert.Equal(t, time.Duration(-1), ttl) + + fc.Advance(2 * time.Hour) + + item, err := m.Get(ctx, "c1", "k1") + require.NoError(t, err) + assert.Equal(t, []byte("val"), item.Value) +} + +func TestIncr(t *testing.T) { + m, _ := newTestMock() + ctx := context.Background() + createTestCache(t, m, "c1") + + val, err := m.Incr(ctx, "c1", "counter") + require.NoError(t, err) + assert.Equal(t, int64(1), val) + + val, err = m.Incr(ctx, "c1", "counter") + require.NoError(t, err) + assert.Equal(t, int64(2), val) +} + +func TestIncrBy(t *testing.T) { + m, _ := newTestMock() + ctx := context.Background() + createTestCache(t, m, "c1") + + require.NoError(t, m.Set(ctx, "c1", "counter", []byte("10"), 0)) + + val, err := m.IncrBy(ctx, "c1", "counter", 5) + require.NoError(t, err) + assert.Equal(t, int64(15), val) +} + +func TestDecr(t *testing.T) { + m, _ := newTestMock() + ctx := context.Background() + createTestCache(t, m, "c1") + + require.NoError(t, m.Set(ctx, "c1", "counter", []byte("10"), 0)) + + val, err := m.Decr(ctx, "c1", "counter") + require.NoError(t, err) + assert.Equal(t, int64(9), val) +} + +func TestDecrBy(t *testing.T) { + m, _ := newTestMock() + ctx := context.Background() + createTestCache(t, m, "c1") + + require.NoError(t, m.Set(ctx, "c1", "counter", []byte("20"), 0)) + + val, err := m.DecrBy(ctx, "c1", "counter", 7) + require.NoError(t, err) + assert.Equal(t, int64(13), val) +} + +func TestIncrNonInteger(t *testing.T) { + m, _ := newTestMock() + ctx := context.Background() + createTestCache(t, m, "c1") + + require.NoError(t, m.Set(ctx, "c1", "k1", []byte("not-a-number"), 0)) + + _, err := m.Incr(ctx, "c1", "k1") + require.Error(t, err) + assert.Contains(t, err.Error(), "not an integer") +} + +func TestIncrPreservesTTL(t *testing.T) { + m, _ := newTestMock() + ctx := context.Background() + createTestCache(t, m, "c1") + + require.NoError(t, m.Set(ctx, "c1", "counter", []byte("5"), 1*time.Hour)) + + val, err := m.IncrBy(ctx, "c1", "counter", 3) + require.NoError(t, err) + assert.Equal(t, int64(8), val) + + ttl, err := m.GetTTL(ctx, "c1", "counter") + require.NoError(t, err) + assert.True(t, ttl > 0) +} diff --git a/storage/driver/driver.go b/storage/driver/driver.go index 02b77116..f5c77869 100644 --- a/storage/driver/driver.go +++ b/storage/driver/driver.go @@ -98,6 +98,41 @@ type UploadPart struct { Size int64 } +// BucketPolicy represents a bucket access policy. +type BucketPolicy struct { + Version string + Statements []PolicyStatement +} + +// PolicyStatement represents a single statement in a bucket policy. +type PolicyStatement struct { + Effect string // "Allow" or "Deny" + Principal string // "*" or specific principal + Actions []string // e.g., "s3:GetObject" + Resources []string // e.g., "arn:aws:s3:::bucket/*" +} + +// CORSRule defines a CORS rule for a bucket. +type CORSRule struct { + AllowedOrigins []string + AllowedMethods []string + AllowedHeaders []string + ExposeHeaders []string + MaxAgeSeconds int +} + +// CORSConfig is a set of CORS rules for a bucket. +type CORSConfig struct { + Rules []CORSRule +} + +// EncryptionConfig describes the default encryption for a bucket. +type EncryptionConfig struct { + Enabled bool + Algorithm string // "AES256" or "aws:kms" + KeyID string // KMS key ID (optional) +} + // Bucket is the interface that storage provider implementations must satisfy. type Bucket interface { CreateBucket(ctx context.Context, name string) error @@ -129,4 +164,18 @@ type Bucket interface { // Versioning SetBucketVersioning(ctx context.Context, bucket string, enabled bool) error GetBucketVersioning(ctx context.Context, bucket string) (bool, error) + + // Bucket Policy + PutBucketPolicy(ctx context.Context, bucket string, policy BucketPolicy) error + GetBucketPolicy(ctx context.Context, bucket string) (*BucketPolicy, error) + DeleteBucketPolicy(ctx context.Context, bucket string) error + + // CORS + PutCORSConfig(ctx context.Context, bucket string, config CORSConfig) error + GetCORSConfig(ctx context.Context, bucket string) (*CORSConfig, error) + DeleteCORSConfig(ctx context.Context, bucket string) error + + // Encryption + PutEncryptionConfig(ctx context.Context, bucket string, config EncryptionConfig) error + GetEncryptionConfig(ctx context.Context, bucket string) (*EncryptionConfig, error) } diff --git a/storage/storage.go b/storage/storage.go index 5e3febc4..a58f17a6 100644 --- a/storage/storage.go +++ b/storage/storage.go @@ -318,3 +318,84 @@ func (b *Bucket) GetBucketVersioning(ctx context.Context, bucket string) (bool, return out.(bool), nil } + +// PutBucketPolicy sets the bucket policy. +func (b *Bucket) PutBucketPolicy(ctx context.Context, bucket string, policy driver.BucketPolicy) error { + _, err := b.do(ctx, "PutBucketPolicy", bucket, func() (any, error) { + return nil, b.driver.PutBucketPolicy(ctx, bucket, policy) + }) + + return err +} + +// GetBucketPolicy returns the bucket policy. +func (b *Bucket) GetBucketPolicy(ctx context.Context, bucket string) (*driver.BucketPolicy, error) { + out, err := b.do(ctx, "GetBucketPolicy", bucket, func() (any, error) { + return b.driver.GetBucketPolicy(ctx, bucket) + }) + if err != nil { + return nil, err + } + + return out.(*driver.BucketPolicy), nil +} + +// DeleteBucketPolicy removes the bucket policy. +func (b *Bucket) DeleteBucketPolicy(ctx context.Context, bucket string) error { + _, err := b.do(ctx, "DeleteBucketPolicy", bucket, func() (any, error) { + return nil, b.driver.DeleteBucketPolicy(ctx, bucket) + }) + + return err +} + +// PutCORSConfig sets the CORS configuration for a bucket. +func (b *Bucket) PutCORSConfig(ctx context.Context, bucket string, cfg driver.CORSConfig) error { + _, err := b.do(ctx, "PutCORSConfig", bucket, func() (any, error) { + return nil, b.driver.PutCORSConfig(ctx, bucket, cfg) + }) + + return err +} + +// GetCORSConfig returns the CORS configuration for a bucket. +func (b *Bucket) GetCORSConfig(ctx context.Context, bucket string) (*driver.CORSConfig, error) { + out, err := b.do(ctx, "GetCORSConfig", bucket, func() (any, error) { + return b.driver.GetCORSConfig(ctx, bucket) + }) + if err != nil { + return nil, err + } + + return out.(*driver.CORSConfig), nil +} + +// DeleteCORSConfig removes the CORS configuration for a bucket. +func (b *Bucket) DeleteCORSConfig(ctx context.Context, bucket string) error { + _, err := b.do(ctx, "DeleteCORSConfig", bucket, func() (any, error) { + return nil, b.driver.DeleteCORSConfig(ctx, bucket) + }) + + return err +} + +// PutEncryptionConfig sets the default encryption for a bucket. +func (b *Bucket) PutEncryptionConfig(ctx context.Context, bucket string, cfg driver.EncryptionConfig) error { + _, err := b.do(ctx, "PutEncryptionConfig", bucket, func() (any, error) { + return nil, b.driver.PutEncryptionConfig(ctx, bucket, cfg) + }) + + return err +} + +// GetEncryptionConfig returns the default encryption for a bucket. +func (b *Bucket) GetEncryptionConfig(ctx context.Context, bucket string) (*driver.EncryptionConfig, error) { + out, err := b.do(ctx, "GetEncryptionConfig", bucket, func() (any, error) { + return b.driver.GetEncryptionConfig(ctx, bucket) + }) + if err != nil { + return nil, err + } + + return out.(*driver.EncryptionConfig), nil +}