diff --git a/.github/instructions/humus-common.instructions.md b/.github/instructions/humus-common.instructions.md new file mode 100644 index 00000000..a810f736 --- /dev/null +++ b/.github/instructions/humus-common.instructions.md @@ -0,0 +1,229 @@ +--- +description: 'Common patterns and best practices for all Humus application types (REST, gRPC, Queue, Job)' +applyTo: '**/*.go' +--- + +# Humus Framework - Common Patterns + +This file provides common patterns and best practices applicable to all Humus application types (REST, gRPC, Queue, Job). Copy this file along with your application-type-specific instructions to your repository's `.github/` or `instructions/` directory. + +## Overview + +Humus is a modular Go framework built on [Bedrock](https://github.com/z5labs/bedrock) for creating production-ready REST APIs, gRPC services, jobs, and queue processors. Every application automatically includes OpenTelemetry instrumentation, health monitoring, and graceful shutdown. + +## Configuration + +**Always embed the appropriate Config type:** +```go +type Config struct { + rest.Config `config:",squash"` // For REST services + // OR + grpc.Config `config:",squash"` // For gRPC services + // OR + humus.Config `config:",squash"` // For Job/Queue services + + // Service-specific configuration + Database struct { + URL string `config:"url"` + } `config:"database"` +} +``` + +**Use Go templates in config.yaml:** +```yaml +openapi: + title: {{env "SERVICE_NAME" | default "My Service"}} + version: {{env "VERSION" | default "v1.0.0"}} + +database: + url: {{env "DATABASE_URL" | default "postgres://localhost/mydb"}} +``` + +## Logging & Observability + +**Always use humus.Logger:** +```go +log := humus.Logger("service-name") +log.Info("user created", slog.String("user_id", userID)) +log.Error("failed to process", slog.String("error", err.Error())) +``` + +**Logs automatically correlate with OpenTelemetry traces** - no manual instrumentation needed. + +## Health Monitoring + +**Binary Health Check:** +```go +monitor := new(health.Binary) +monitor.MarkHealthy() // Service is healthy +monitor.MarkUnhealthy() // Service is unhealthy +``` + +**Composite Monitors:** +```go +// Both must be healthy +health.And(dbMonitor, cacheMonitor) + +// At least one must be healthy +health.Or(replica1Monitor, replica2Monitor) +``` + +## Lifecycle Management + +**Use lifecycle hooks for resource cleanup:** +```go +func Init(ctx context.Context, cfg Config) (*rest.Api, error) { + db, err := sql.Open("postgres", cfg.DB.URL) + if err != nil { + return nil, err + } + + lc, _ := lifecycle.FromContext(ctx) + lc.OnPostRun(lifecycle.HookFunc(func(ctx context.Context) error { + return db.Close() + })) + + return api, nil +} +``` + +## Backend Service Clients + +Define all backend service clients in a single `service` package. Each client should follow this pattern where methods have 2 arguments (`context.Context` and `*Request`) and 2 return values (`*Response` and `error`): + +```go +// service/user.go +package service + +import ( + "context" + "net/http" +) + +type UserClient struct { + httpClient *http.Client + baseURL string +} + +func NewUserClient(httpClient *http.Client, baseURL string) *UserClient { + return &UserClient{ + httpClient: httpClient, + baseURL: baseURL, + } +} + +type GetUserRequest struct { + ID string +} + +type GetUserResponse struct { + ID string + Name string + Email string +} + +func (c *UserClient) GetUser(ctx context.Context, req *GetUserRequest) (*GetUserResponse, error) { + // Make HTTP request to backend service + return &GetUserResponse{}, nil +} + +type CreateUserRequest struct { + Name string + Email string +} + +type CreateUserResponse struct { + ID string +} + +func (c *UserClient) CreateUser(ctx context.Context, req *CreateUserRequest) (*CreateUserResponse, error) { + // Make HTTP request to backend service + return &CreateUserResponse{}, nil +} +``` + +## Best Practices + +### DO ✅ + +1. **Keep main.go minimal** - just call `rest.Run()`, `grpc.Run()`, or `queue.Run()` with `app.Init` +2. **Embed configuration files** - use `//go:embed config.yaml` for portability +3. **Use the app/ package for Init function** - keeps business logic separate from main +4. **Embed humus.Config (or rest.Config/grpc.Config) in custom Config** - required for OpenTelemetry +5. **Use Go templates in config.yaml** - `{{env "VAR" | default "value"}}` +6. **Return early to reduce nesting** - keep the happy path left-aligned +7. **Use lifecycle hooks for cleanup** - ensures resources are released properly +8. **Always handle errors** - don't ignore them +9. **Use humus.Logger** - integrates automatically with OpenTelemetry + +### DON'T ❌ + +1. **Don't bypass lifecycle wrappers** - manually starting servers breaks OTel and graceful shutdown +2. **Don't put business logic in main.go** - use app/app.go and domain packages +3. **Don't hardcode configuration** - use environment variables with templates +4. **Don't duplicate package declarations** - each .go file has exactly ONE package line +5. **Don't forget to close resources** - use lifecycle hooks for cleanup +6. **Don't ignore errors** - always handle or propagate them +7. **Don't manually initialize OpenTelemetry** - Humus does this automatically +8. **Don't create goroutines without cleanup** - know how they will exit +9. **Don't share state without synchronization** - use mutexes or channels + +## Common Pitfalls + +### Incorrect Config Embedding + +❌ **Wrong:** +```go +type Config struct { + Title string + // Missing humus.Config embedding +} +``` + +✅ **Correct:** +```go +type Config struct { + rest.Config `config:",squash"` // or grpc.Config or humus.Config + // Your config here +} +``` + +### Not Using Lifecycle Hooks + +❌ **Wrong (resource leak):** +```go +func Init(ctx context.Context, cfg Config) (*rest.Api, error) { + db, _ := sql.Open("postgres", cfg.DB.URL) + // db never gets closed! + return api, nil +} +``` + +✅ **Correct:** +```go +func Init(ctx context.Context, cfg Config) (*rest.Api, error) { + db, _ := sql.Open("postgres", cfg.DB.URL) + + lc, _ := lifecycle.FromContext(ctx) + lc.OnPostRun(lifecycle.HookFunc(func(ctx context.Context) error { + return db.Close() + })) + + return api, nil +} +``` + +## Additional Resources + +- **Documentation**: https://z5labs.dev/humus/ +- **Repository**: https://github.com/z5labs/humus +- **Bedrock Framework**: https://github.com/z5labs/bedrock +- **Examples**: https://github.com/z5labs/humus/tree/main/example + +## Version Information + +This instructions file is designed for Humus applications using: +- Go 1.24 or later +- Humus framework latest version + +Keep this file updated as you add project-specific patterns and conventions. \ No newline at end of file diff --git a/.github/instructions/humus-rest.instructions.md b/.github/instructions/humus-rest.instructions.md new file mode 100644 index 00000000..c79c04cf --- /dev/null +++ b/.github/instructions/humus-rest.instructions.md @@ -0,0 +1,396 @@ +--- +description: 'Patterns and best practices for REST API applications using Humus' +applyTo: '**/*.go' +--- + +# Humus Framework - REST Service Instructions + +This file provides patterns and best practices specific to REST API applications using Humus. Use this file alongside `humus-common.instructions.md` for complete guidance. + +## Project Structure + +Use this structure for production services. This matches the examples in the Humus repository: + +``` +my-rest-service/ +├── main.go # Minimal entry point (just calls app.Init) +├── config.yaml # Configuration +├── app/ +│ └── app.go # Init function and Config type +├── endpoint/ # REST endpoint handlers +│ ├── create_user.go +│ ├── get_user.go +│ └── list_users.go +├── service/ # Backend service clients +│ ├── user.go # User service client +│ └── order.go # Order service client +├── go.mod +└── go.sum +``` + +**main.go:** +```go +package main + +import ( + "bytes" + _ "embed" + "github.com/z5labs/humus/rest" + "my-service/app" +) + +//go:embed config.yaml +var configBytes []byte + +func main() { + rest.Run(bytes.NewReader(configBytes), app.Init) +} +``` + +**app/app.go:** +```go +package app + +import ( + "context" + "my-service/endpoint" + "github.com/z5labs/humus/rest" +) + +type Config struct { + rest.Config `config:",squash"` + // Add service-specific config here +} + +func Init(ctx context.Context, cfg Config) (*rest.Api, error) { + api := rest.NewApi( + cfg.OpenApi.Title, + cfg.OpenApi.Version, + endpoint.CreateUser(), + endpoint.GetUser(), + endpoint.ListUsers(), + ) + return api, nil +} +``` + +## Configuration + +**Custom Config with provider interface:** + +If you need to customize the HTTP server listener (e.g., custom port), implement the `ListenerProvider` interface: + +```go +type Config struct { + rest.Config `config:",squash"` + + HTTP struct { + Port uint `config:"port"` + } `config:"http"` +} + +func (c Config) Listener(ctx context.Context) (net.Listener, error) { + return net.Listen("tcp", fmt.Sprintf(":%d", c.HTTP.Port)) +} +``` + +See `humus-common.instructions.md` for general configuration patterns like using Go templates in YAML and the backend service client pattern. + +## REST Service Patterns + +### Entry Point + +The entry point should use embedded config bytes (see main.go in Project Structure above): + +```go +package main + +import ( + "bytes" + _ "embed" + "github.com/z5labs/humus/rest" + "my-service/app" +) + +//go:embed config.yaml +var configBytes []byte + +func main() { + rest.Run(bytes.NewReader(configBytes), app.Init) +} +``` + +### Handler Types + +Handlers should be implemented as struct types that implement the specific interface (`rpc.Producer`, `rpc.Consumer`, or `rpc.Handler`). + +#### 1. Producer (GET endpoints - no request body) + +Implement the `rpc.Producer` interface with a `Produce` method: + +```go +// endpoint/list_users.go +package endpoint + +import ( + "context" + "database/sql" + "log/slog" + "net/http" + + "github.com/z5labs/bedrock/lifecycle" + "github.com/z5labs/humus" + "github.com/z5labs/humus/rest" + "github.com/z5labs/humus/rest/rpc" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/trace" +) + +type listUsersHandler struct { + tracer trace.Tracer + log *slog.Logger + listUsersStmt *sql.Stmt +} + +type ListUsersResponse []*User + +func ListUsers(ctx context.Context, db *sql.DB) rest.ApiOption { + stmt, err := db.Prepare("SELECT id, name FROM users LIMIT ?") + if err != nil { + panic(err) + } + + lc, ok := lifecycle.FromContext(ctx) + if !ok { + panic("lifecycle must be present in context") + } + lc.OnPostRun(lifecycle.HookFunc(func(ctx context.Context) error { + return stmt.Close() + })) + + h := &listUsersHandler{ + tracer: otel.Tracer("my-service/endpoint"), + log: humus.Logger("my-service/endpoint"), + listUsersStmt: stmt, + } + + return rest.Handle( + http.MethodGet, + rest.BasePath("/users"), + rpc.ProduceJson(h), + ) +} + +func (h *listUsersHandler) Produce(ctx context.Context) (*ListUsersResponse, error) { + // Implement query logic + return nil, nil +} +``` + +#### 2. Consumer (POST webhooks - no response body) + +Implement the `rpc.Consumer` interface with a `Consume` method: + +```go +// endpoint/webhook.go +package endpoint + +type webhookHandler struct { + tracer trace.Tracer + log *slog.Logger +} + +type WebhookRequest struct { + Event string `json:"event"` + Data any `json:"data"` +} + +func Webhook(ctx context.Context) rest.ApiOption { + h := &webhookHandler{ + tracer: otel.Tracer("my-service/endpoint"), + log: humus.Logger("my-service/endpoint"), + } + + return rest.Handle( + http.MethodPost, + rest.BasePath("/webhook"), + rpc.ConsumeOnlyJson(h), + ) +} + +func (h *webhookHandler) Consume(ctx context.Context, req *WebhookRequest) error { + // Process webhook + return nil +} +``` + +#### 3. Handler (full request/response) + +Implement the `rpc.Handler` interface with a `Handle` method: + +```go +// endpoint/create_user.go +package endpoint + +type createUserHandler struct { + tracer trace.Tracer + log *slog.Logger + createUserStmt *sql.Stmt +} + +type CreateUserRequest struct { + Name string `json:"name"` + Email string `json:"email"` +} + +type CreateUserResponse struct { + ID string `json:"id"` +} + +func CreateUser(ctx context.Context, db *sql.DB) rest.ApiOption { + stmt, err := db.Prepare("INSERT INTO users (name, email) VALUES (?, ?)") + if err != nil { + panic(err) + } + + lc, ok := lifecycle.FromContext(ctx) + if !ok { + panic("lifecycle must be present in context") + } + lc.OnPostRun(lifecycle.HookFunc(func(ctx context.Context) error { + return stmt.Close() + })) + + h := &createUserHandler{ + tracer: otel.Tracer("my-service/endpoint"), + log: humus.Logger("my-service/endpoint"), + createUserStmt: stmt, + } + + return rest.Handle( + http.MethodPost, + rest.BasePath("/users"), + rpc.HandleJson(h), + ) +} + +func (h *createUserHandler) Handle(ctx context.Context, req *CreateUserRequest) (*CreateUserResponse, error) { + // Create user + return &CreateUserResponse{ID: "123"}, nil +} +``` + +### Path Building + +```go +// Simple path +rest.BasePath("/users") + +// Path with segments +rest.BasePath("/api").Segment("v1").Segment("users") + +// Path with parameters +rest.BasePath("/users").Param("id") // /users/{id} +``` + +### Parameter Options + +```go +rest.Handle(method, path, handler, + rest.QueryParam("format", rest.Required()), + rest.PathParam("id", rest.Required()), + rest.Header("Authorization", rest.Required(), rest.JWTAuth("jwt")), +) +``` + +### Operation-Level Error Handling + +```go +rest.Handle(method, path, handler, + rest.OnError(rest.ErrorHandlerFunc(func(ctx context.Context, w http.ResponseWriter, err error) { + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) + })), +) +``` + +## OpenAPI Generation + +REST handlers automatically generate OpenAPI 3.0 specifications: + +- **Available at**: `/openapi.json` +- **Uses Go struct tags**: `json:"field_name"` tags define schema +- **Supports validation**: Use parameter options for required fields, regex patterns +- **Authentication**: Use `rest.JWTAuth()`, `rest.APIKeyAuth()`, `rest.BasicAuth()` + +## Health Endpoints + +All REST services automatically include health endpoints: + +- **Liveness**: `/health/liveness` - Always returns 200 when server is running +- **Readiness**: `/health/readiness` - Returns 200 when service is ready (checks monitors) + +## REST-Specific Best Practices + +### DO ✅ + +1. **Organize handlers in endpoint/ package** - one file per endpoint/operation +2. **Use rpc.HandleJson for full request/response** - it's shorthand for ConsumeJson(ReturnJson(handler)) +3. **Use proper handler types** - Producer for GET, Consumer for webhooks, Handler for full request/response +4. **Leverage OpenAPI generation** - your handlers automatically generate documentation +5. **Use path building helpers** - `BasePath().Segment().Param()` for clarity + +### DON'T ❌ + +1. **Don't change handler signatures** without understanding OpenAPI generation +2. **Don't mix raw http.Handler with rest.Handle** - use the rpc wrappers +3. **Don't bypass the rpc helpers** - they provide type safety and OpenAPI generation +4. **Don't hardcode paths** - use the path building helpers +5. **Don't ignore parameter validation** - use Required(), regex patterns, etc. + +## Common REST Pitfalls + +### Wrong Handler Pattern + +❌ **Wrong (mixing raw http.Handler with rest.Handle):** +```go +api.HandleFunc("/users", func(w http.ResponseWriter, r *http.Request) { + // Manual JSON marshaling, no OpenAPI generation +}) +``` + +✅ **Correct:** +```go +func CreateUser() rest.Operation { + handler := rpc.HandlerFunc[CreateUserRequest, UserResponse]( + func(ctx context.Context, req *CreateUserRequest) (*UserResponse, error) { + return &UserResponse{}, nil + }, + ) + return rest.Handle(http.MethodPost, rest.BasePath("/users"), rpc.HandleJson(handler)) +} +``` + +## Quick Reference + +| Pattern | Code | +|---------|------| +| REST path | `rest.BasePath("/api").Segment("v1").Param("id")` | +| Query param | `rest.QueryParam("name", rest.Required())` | +| Path param | `rest.PathParam("id", rest.Required())` | +| Header | `rest.Header("Authorization", rest.JWTAuth("jwt"))` | +| Producer handler | `rpc.ProduceJson(rpc.ProducerFunc[Response](...))` | +| Consumer handler | `rpc.ConsumeOnlyJson(rpc.ConsumerFunc[Request](...))` | +| Full handler | `rpc.HandleJson(rpc.HandlerFunc[Req, Resp](...))` | + +## Example Project + +Study this example in the Humus repository: + +- **REST API**: `example/rest/petstore/` - Complete REST service structure + +## Additional Resources + +- **REST Documentation**: https://z5labs.dev/humus/features/rest/ +- **Authentication Guide**: https://z5labs.dev/humus/features/rest/authentication/ +- **Common patterns**: See `humus-common.instructions.md` \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 190f2dc4..e99a5df3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,15 +25,29 @@ No automated apply; manual deployment required. Modules in `cloud/` are reusable ### Go Services ```bash +go test ./... # Run all tests +go test ./services/machine/... # Test specific service go mod tidy # Dependency management ``` Current version: Go 1.24.0 (module requires 1.25 for `sync.WaitGroup.Go()`) +**Module structure**: `github.com/Zaba505/infra` with service imports like `github.com/Zaba505/infra/services/machine/app` + ## Architecture Patterns ### Go Services (Humus Framework) -Services follow `z5labs/humus` pattern in `services/`: +Services follow `z5labs/humus` pattern with this directory structure: +``` +services/{service-name}/ +├── main.go # Entry point with embedded config +├── config.yaml # Service configuration (supports Go templates) +├── app/ +│ └── app.go # Init function and Config type +├── endpoint/ # HTTP handlers (one per operation) +├── service/ # Backend service clients +└── errors/ # Custom error types +``` ```go // main.go - Embed config and bootstrap @@ -45,6 +59,12 @@ func main() { } ``` +**Config templating**: Use `${ENV_VAR}` syntax in config.yaml: +```yaml +firestore: + project_id: "${GCP_PROJECT_ID}" +``` + ```go // app/app.go - Wire up API func Init(ctx context.Context, cfg Config) (*rest.Api, error) { @@ -58,15 +78,26 @@ func Init(ctx context.Context, cfg Config) (*rest.Api, error) { } ``` +**Endpoint handler types** (choose based on operation): +- `rpc.Producer` - GET endpoints (no request body, returns response) +- `rpc.Consumer` - Webhooks (accepts request, no response body) +- `rpc.Handler` - Full request/response operations + +Handler functions return `rest.ApiOption`: ```go -// endpoint/ - OpenAPI-first handlers -type Handler struct{} -func (h *Handler) RequestBody() openapi3.RequestBodyOrRef { } -func (h *Handler) Responses() openapi3.Responses { } -func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } +// endpoint/create_user.go +func CreateUser(ctx context.Context, db *sql.DB) rest.ApiOption { + handler := &createUserHandler{...} + return rest.Handle( + http.MethodPost, + rest.BasePath("/users"), + rpc.HandleJson(handler), + ) +} ``` -Standard health checks: `/health/startup`, `/health/liveness` (30s timeout, 10s period, 3 failures) +**Standard health checks**: `/health/startup`, `/health/liveness` (30s timeout, 10s period, 3 failures) +**OpenAPI spec**: Auto-generated at `/openapi.json` ### Terraform Modules @@ -101,12 +132,34 @@ go func() { defer wg.Done(); task() }() **Interfaces**: Accept interfaces, return concrete types. Keep small (1-3 methods). Define close to usage. +**Resource cleanup**: Use lifecycle hooks in `Init` for graceful shutdown: +```go +func Init(ctx context.Context, cfg Config) (*rest.Api, error) { + db, _ := sql.Open("postgres", cfg.DB.URL) + + lc, _ := lifecycle.FromContext(ctx) + lc.OnPostRun(lifecycle.HookFunc(func(ctx context.Context) error { + return db.Close() + })) + + return api, nil +} +``` + ## CI/CD Workflows - **terraform.yml** - Lints `**.tf` on PR/push to main -- **docs.yaml** - Auto-deploys Hugo to GitHub Pages on `docs/**` changes +- **docs.yaml** - Auto-deploys Hugo to GitHub Pages on `docs/**` changes to main +- **docs-preview.yaml** - Deploys PR preview sites for `docs/**` changes - **codeql.yaml** - Security analysis on `.go` changes -- **Renovate** - Runs before 4am, auto-tidies go.mod +- **Renovate** - Runs before 4am, auto-tidies go.mod, updates indirect dependencies + +## Commit & Branch Conventions + +Commits use prefixes to link to GitHub issues: +- Branch: `story/issue-{number}/{description}` or `fix/issue-{number}/{description}` +- Commit: `feat(issue-123): description` or `fix(issue-123): description` +- Search issues in commits: `git log --oneline --all --grep="story\|issue"` ## Important Files diff --git a/go.mod b/go.mod index 990d4b5a..6c7aec04 100644 --- a/go.mod +++ b/go.mod @@ -3,28 +3,40 @@ module github.com/Zaba505/infra go 1.24.0 require ( + cloud.google.com/go/firestore v1.20.0 + github.com/google/uuid v1.6.0 github.com/swaggest/openapi-go v0.2.60 + github.com/z5labs/bedrock v0.20.2 github.com/z5labs/humus v0.13.0 + go.opentelemetry.io/otel v1.38.0 + go.opentelemetry.io/otel/trace v1.38.0 + google.golang.org/api v0.256.0 ) require ( + cloud.google.com/go v0.121.6 // indirect + cloud.google.com/go/auth v0.17.0 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect + cloud.google.com/go/compute/metadata v0.9.0 // indirect + cloud.google.com/go/longrunning v0.6.7 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-chi/chi/v5 v5.2.3 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect - github.com/google/uuid v1.6.0 // indirect + github.com/google/s2a-go v0.1.9 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect + github.com/googleapis/gax-go/v2 v2.15.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect github.com/swaggest/jsonschema-go v0.3.79 // indirect github.com/swaggest/refl v1.4.0 // indirect - github.com/z5labs/bedrock v0.20.2 // indirect github.com/z5labs/sdk-go v0.2.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/bridges/otelslog v0.13.0 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect go.opentelemetry.io/contrib/instrumentation/runtime v0.63.0 // indirect - go.opentelemetry.io/otel v1.38.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.14.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.14.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.38.0 // indirect @@ -38,12 +50,15 @@ require ( go.opentelemetry.io/otel/sdk v1.38.0 // indirect go.opentelemetry.io/otel/sdk/log v0.14.0 // indirect go.opentelemetry.io/otel/sdk/metric v1.38.0 // indirect - go.opentelemetry.io/otel/trace v1.38.0 // indirect go.opentelemetry.io/proto/otlp v1.9.0 // indirect + golang.org/x/crypto v0.44.0 // indirect golang.org/x/net v0.47.0 // indirect + golang.org/x/oauth2 v0.33.0 // indirect golang.org/x/sync v0.18.0 // indirect golang.org/x/sys v0.38.0 // indirect golang.org/x/text v0.31.0 // indirect + golang.org/x/time v0.14.0 // indirect + google.golang.org/genproto v0.0.0-20250603155806-513f23925822 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20251111163417-95abcf5c77ba // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251111163417-95abcf5c77ba // indirect google.golang.org/grpc v1.77.0 // indirect diff --git a/go.sum b/go.sum index 512b5619..e85d709d 100644 --- a/go.sum +++ b/go.sum @@ -1,11 +1,30 @@ +cloud.google.com/go v0.121.6 h1:waZiuajrI28iAf40cWgycWNgaXPO06dupuS+sgibK6c= +cloud.google.com/go v0.121.6/go.mod h1:coChdst4Ea5vUpiALcYKXEpR1S9ZgXbhEzzMcMR66vI= +cloud.google.com/go/auth v0.17.0 h1:74yCm7hCj2rUyyAocqnFzsAYXgJhrG26XCFimrc/Kz4= +cloud.google.com/go/auth v0.17.0/go.mod h1:6wv/t5/6rOPAX4fJiRjKkJCvswLwdet7G8+UGXt7nCQ= +cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +cloud.google.com/go/firestore v1.20.0 h1:JLlT12QP0fM2SJirKVyu2spBCO8leElaW0OOtPm6HEo= +cloud.google.com/go/firestore v1.20.0/go.mod h1:jqu4yKdBmDN5srneWzx3HlKrHFWFdlkgjgQ6BKIOFQo= +cloud.google.com/go/longrunning v0.6.7 h1:IGtfDWHhQCgCjwQjV9iiLnUta9LBCo8R9QmAFsS/PrE= +cloud.google.com/go/longrunning v0.6.7/go.mod h1:EAFV3IZAKmM56TyiE6VAP3VoTzhZzySwI/YI1s/nRsY= github.com/bool64/dev v0.2.43 h1:yQ7qiZVef6WtCl2vDYU0Y+qSq+0aBrQzY8KXkklk9cQ= github.com/bool64/dev v0.2.43/go.mod h1:iJbh1y/HkunEPhgebWRNcs8wfGq7sjvJ6W5iabL8ACg= github.com/bool64/shared v0.1.5 h1:fp3eUhBsrSjNCQPcSdQqZxxh9bBwrYiZ+zOKFkM0/2E= github.com/bool64/shared v0.1.5/go.mod h1:081yz68YC9jeFB3+Bbmno2RFWvGKv1lPKkMP6MHJlPs= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f h1:Y8xYupdHxryycyPlc9Y+bSQAYZnetRJ70VMVKm5CKI0= +github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f/go.mod h1:HlzOvOjVBOfTGSRXRyY0OiCS/3J1akRGQQpRO/7zyF4= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/envoyproxy/go-control-plane v0.13.5-0.20251024222203-75eaa193e329 h1:K+fnvUM0VZ7ZFJf0n4L/BRlnsb9pL/GuDG6FqaH+PwM= +github.com/envoyproxy/go-control-plane/envoy v1.35.0 h1:ixjkELDE+ru6idPxcHLj8LBVc2bFP7iBytj353BoHUo= +github.com/envoyproxy/go-control-plane/envoy v1.35.0/go.mod h1:09qwbGVuSWWAyN5t/b3iyVfz5+z8QWGrzkoqm/8SbEs= +github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8= +github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE= @@ -21,8 +40,14 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.3.7 h1:zrn2Ee/nWmHulBx5sAVrGgAa0f2/R35S4DJwfFaUPFQ= +github.com/googleapis/enterprise-certificate-proxy v0.3.7/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= +github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo= +github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4= github.com/iancoleman/orderedmap v0.3.0 h1:5cbR2grmZR/DiVt+VJopEhtVs9YGInGIxAoMJn+Ichc= @@ -31,6 +56,8 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= @@ -61,6 +88,8 @@ go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/bridges/otelslog v0.13.0 h1:bwnLpizECbPr1RrQ27waeY2SPIPeccCx/xLuoYADZ9s= go.opentelemetry.io/contrib/bridges/otelslog v0.13.0/go.mod h1:3nWlOiiqA9UtUnrcNk82mYasNxD8ehOspL0gOfEo6Y4= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg= go.opentelemetry.io/contrib/instrumentation/runtime v0.63.0 h1:PeBoRj6af6xMI7qCupwFvTbbnd49V7n5YpG6pg8iDYQ= @@ -105,16 +134,26 @@ go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjce go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU= +golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo= +golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/api v0.256.0 h1:u6Khm8+F9sxbCTYNoBHg6/Hwv0N/i+V94MvkOSor6oI= +google.golang.org/api v0.256.0/go.mod h1:KIgPhksXADEKJlnEoRa9qAII4rXcy40vfI8HRqcU964= +google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4= +google.golang.org/genproto v0.0.0-20250603155806-513f23925822/go.mod h1:HubltRL7rMh0LfnQPkMH4NPDFEWp0jw3vixw7jEM53s= google.golang.org/genproto/googleapis/api v0.0.0-20251111163417-95abcf5c77ba h1:B14OtaXuMaCQsl2deSvNkyPKIzq3BjfxQp8d00QyWx4= google.golang.org/genproto/googleapis/api v0.0.0-20251111163417-95abcf5c77ba/go.mod h1:G5IanEx8/PgI9w6CFcYQf7jMtHQhZruvfM1i3qOqk5U= google.golang.org/genproto/googleapis/rpc v0.0.0-20251111163417-95abcf5c77ba h1:UKgtfRM7Yh93Sya0Fo8ZzhDP4qBckrrxEr2oF5UIVb8= diff --git a/services/machine/app/app.go b/services/machine/app/app.go index f60e8430..d1019fbe 100644 --- a/services/machine/app/app.go +++ b/services/machine/app/app.go @@ -2,18 +2,44 @@ package app import ( "context" + "net/http" + "github.com/Zaba505/infra/services/machine/endpoint" + "github.com/Zaba505/infra/services/machine/service" + "github.com/z5labs/bedrock/lifecycle" "github.com/z5labs/humus/rest" ) type Config struct { rest.Config `config:",squash"` + Firestore FirestoreConfig `config:"firestore"` +} + +type FirestoreConfig struct { + ProjectID string `config:"project_id"` } func Init(ctx context.Context, cfg Config) (*rest.Api, error) { + fsClient, err := service.NewFirestoreClient(ctx, cfg.Firestore.ProjectID) + if err != nil { + return nil, err + } + + lc, _ := lifecycle.FromContext(ctx) + lc.OnPostRun(lifecycle.HookFunc(func(ctx context.Context) error { + return fsClient.Close() + })) + + healthHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + api := rest.NewApi( cfg.OpenApi.Title, cfg.OpenApi.Version, + rest.Liveness(healthHandler), + rest.Readiness(healthHandler), + endpoint.PostMachines(fsClient), ) return api, nil diff --git a/services/machine/config.yaml b/services/machine/config.yaml index e69de29b..5c6a6f45 100644 --- a/services/machine/config.yaml +++ b/services/machine/config.yaml @@ -0,0 +1,6 @@ +openapi: + title: {{env "OPENAPI_TITLE" | default "Machine Management Service"}} + version: {{env "OPENAPI_VERSION" | default "v1"}} + +firestore: + project_id: {{env "GCP_PROJECT_ID"}} diff --git a/services/machine/endpoint/model.go b/services/machine/endpoint/model.go new file mode 100644 index 00000000..f31b9bb7 --- /dev/null +++ b/services/machine/endpoint/model.go @@ -0,0 +1,80 @@ +package endpoint + +import ( + "fmt" + "regexp" +) + +var macAddressRegex = regexp.MustCompile(`^([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}$`) + +type MachineRequest struct { + CPUs []CPU `json:"cpus"` + MemoryModules []MemoryModule `json:"memory_modules"` + Accelerators []Accelerator `json:"accelerators"` + NICs []NIC `json:"nics"` + Drives []Drive `json:"drives"` +} + +type CPU struct { + Manufacturer string `json:"manufacturer"` + ClockFrequency int64 `json:"clock_frequency"` + Cores int64 `json:"cores"` +} + +type MemoryModule struct { + Size int64 `json:"size"` +} + +type Accelerator struct { + Manufacturer string `json:"manufacturer"` +} + +type NIC struct { + MAC string `json:"mac"` +} + +type Drive struct { + Capacity int64 `json:"capacity"` +} + +type MachineResponse struct { + ID string `json:"id"` +} + +type InvalidField struct { + Field string `json:"field"` + Reason string `json:"reason"` +} + +func (r *MachineRequest) Validate() []InvalidField { + var invalidFields []InvalidField + + if len(r.NICs) == 0 { + invalidFields = append(invalidFields, InvalidField{ + Field: "nics", + Reason: "at least one NIC is required", + }) + return invalidFields + } + + for i, nic := range r.NICs { + if err := ValidateMACAddress(nic.MAC); err != nil { + invalidFields = append(invalidFields, InvalidField{ + Field: fmt.Sprintf("nics[%d].mac", i), + Reason: err.Error(), + }) + } + } + + return invalidFields +} + +func ValidateMACAddress(mac string) error { + if mac == "" { + return fmt.Errorf("MAC address cannot be empty") + } + if !macAddressRegex.MatchString(mac) { + return fmt.Errorf("invalid MAC address format, expected format: aa:bb:cc:dd:ee:ff") + } + return nil +} diff --git a/services/machine/endpoint/model_test.go b/services/machine/endpoint/model_test.go new file mode 100644 index 00000000..fba11409 --- /dev/null +++ b/services/machine/endpoint/model_test.go @@ -0,0 +1,147 @@ +package endpoint + +import ( + "testing" +) + +func TestValidateMACAddress(t *testing.T) { + tests := []struct { + name string + mac string + wantErr bool + }{ + { + name: "valid MAC address lowercase", + mac: "52:54:00:12:34:56", + wantErr: false, + }, + { + name: "valid MAC address uppercase", + mac: "AA:BB:CC:DD:EE:FF", + wantErr: false, + }, + { + name: "valid MAC address mixed case", + mac: "aA:bB:cC:dD:eE:fF", + wantErr: false, + }, + { + name: "empty MAC address", + mac: "", + wantErr: true, + }, + { + name: "invalid format - missing colons", + mac: "aabbccddeeff", + wantErr: true, + }, + { + name: "invalid format - wrong separator", + mac: "aa-bb-cc-dd-ee-ff", + wantErr: true, + }, + { + name: "invalid format - too short", + mac: "aa:bb:cc:dd:ee", + wantErr: true, + }, + { + name: "invalid format - too long", + mac: "aa:bb:cc:dd:ee:ff:gg", + wantErr: true, + }, + { + name: "invalid characters", + mac: "zz:yy:xx:ww:vv:uu", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateMACAddress(tt.mac) + if (err != nil) != tt.wantErr { + t.Errorf("ValidateMACAddress() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestMachineRequest_Validate(t *testing.T) { + tests := []struct { + name string + req *MachineRequest + wantInvalidFields int + }{ + { + name: "valid request", + req: &MachineRequest{ + NICs: []NIC{ + {MAC: "52:54:00:12:34:56"}, + }, + }, + wantInvalidFields: 0, + }, + { + name: "missing NICs", + req: &MachineRequest{ + NICs: []NIC{}, + }, + wantInvalidFields: 1, + }, + { + name: "nil NICs", + req: &MachineRequest{}, + wantInvalidFields: 1, + }, + { + name: "invalid MAC address format", + req: &MachineRequest{ + NICs: []NIC{ + {MAC: "invalid-mac"}, + }, + }, + wantInvalidFields: 1, + }, + { + name: "empty MAC address", + req: &MachineRequest{ + NICs: []NIC{ + {MAC: ""}, + }, + }, + wantInvalidFields: 1, + }, + { + name: "multiple NICs with one invalid", + req: &MachineRequest{ + NICs: []NIC{ + {MAC: "52:54:00:12:34:56"}, + {MAC: "invalid"}, + {MAC: "aa:bb:cc:dd:ee:ff"}, + }, + }, + wantInvalidFields: 1, + }, + { + name: "multiple invalid MACs", + req: &MachineRequest{ + NICs: []NIC{ + {MAC: "invalid1"}, + {MAC: "invalid2"}, + }, + }, + wantInvalidFields: 2, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + invalidFields := tt.req.Validate() + if len(invalidFields) != tt.wantInvalidFields { + t.Errorf("MachineRequest.Validate() returned %d invalid fields, want %d: %+v", + len(invalidFields), tt.wantInvalidFields, invalidFields) + } + }) + } +} diff --git a/services/machine/endpoint/post_machines.go b/services/machine/endpoint/post_machines.go new file mode 100644 index 00000000..92c1aa8d --- /dev/null +++ b/services/machine/endpoint/post_machines.go @@ -0,0 +1,161 @@ +package endpoint + +import ( + "context" + "fmt" + "log/slog" + "net/http" + + "github.com/Zaba505/infra/services/machine/errors" + "github.com/Zaba505/infra/services/machine/service" + "github.com/google/uuid" + "github.com/z5labs/humus" + "github.com/z5labs/humus/rest" + "github.com/z5labs/humus/rest/rpc" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/trace" +) + +type FirestoreClient interface { + CreateMachine(ctx context.Context, req *service.CreateMachineRequest) (*service.CreateMachineResponse, error) + FindMachineByMAC(ctx context.Context, req *service.FindMachineByMACRequest) (*service.FindMachineByMACResponse, error) + Close() error +} + +type postMachinesHandler struct { + tracer trace.Tracer + log *slog.Logger + firestoreClient FirestoreClient +} + +func (h *postMachinesHandler) Handle(ctx context.Context, req *MachineRequest) (*MachineResponse, error) { + invalidFields := req.Validate() + if len(invalidFields) > 0 { + errFields := make([]errors.InvalidField, len(invalidFields)) + for i, f := range invalidFields { + errFields[i] = errors.InvalidField{ + Field: f.Field, + Reason: f.Reason, + } + } + return nil, errors.NewValidationError("/api/v1/machines", errFields) + } + + for _, nic := range req.NICs { + resp, err := h.firestoreClient.FindMachineByMAC(ctx, &service.FindMachineByMACRequest{ + MAC: nic.MAC, + }) + if err != nil { + return nil, errors.NewInternalError("/api/v1/machines", fmt.Sprintf("failed to check MAC uniqueness: %v", err)) + } + if resp.Found { + return nil, errors.NewConflictError("/api/v1/machines", nic.MAC, resp.MachineID) + } + } + + machineID, err := uuid.NewV7() + if err != nil { + return nil, errors.NewInternalError("/api/v1/machines", fmt.Sprintf("failed to generate machine ID: %v", err)) + } + + serviceReq := &service.MachineRequest{ + CPUs: convertCPUs(req.CPUs), + MemoryModules: convertMemoryModules(req.MemoryModules), + Accelerators: convertAccelerators(req.Accelerators), + NICs: convertNICs(req.NICs), + Drives: convertDrives(req.Drives), + } + + _, err = h.firestoreClient.CreateMachine(ctx, &service.CreateMachineRequest{ + MachineID: machineID.String(), + Machine: serviceReq, + }) + if err != nil { + return nil, errors.NewInternalError("/api/v1/machines", fmt.Sprintf("failed to create machine: %v", err)) + } + + return &MachineResponse{ + ID: machineID.String(), + }, nil +} + +func convertCPUs(cpus []CPU) []service.CPU { + result := make([]service.CPU, len(cpus)) + for i, cpu := range cpus { + result[i] = service.CPU{ + Manufacturer: cpu.Manufacturer, + ClockFrequency: cpu.ClockFrequency, + Cores: cpu.Cores, + } + } + return result +} + +func convertMemoryModules(modules []MemoryModule) []service.MemoryModule { + result := make([]service.MemoryModule, len(modules)) + for i, module := range modules { + result[i] = service.MemoryModule{ + Size: module.Size, + } + } + return result +} + +func convertAccelerators(accelerators []Accelerator) []service.Accelerator { + result := make([]service.Accelerator, len(accelerators)) + for i, accelerator := range accelerators { + result[i] = service.Accelerator{ + Manufacturer: accelerator.Manufacturer, + } + } + return result +} + +func convertNICs(nics []NIC) []service.NIC { + result := make([]service.NIC, len(nics)) + for i, nic := range nics { + result[i] = service.NIC{ + MAC: nic.MAC, + } + } + return result +} + +func convertDrives(drives []Drive) []service.Drive { + result := make([]service.Drive, len(drives)) + for i, drive := range drives { + result[i] = service.Drive{ + Capacity: drive.Capacity, + } + } + return result +} + +func errorHandler(ctx context.Context, w http.ResponseWriter, err error) { + switch e := err.(type) { + case *errors.ValidationProblem: + e.WriteHttpResponse(ctx, w) + case *errors.ConflictProblem: + e.WriteHttpResponse(ctx, w) + case *errors.Problem: + e.WriteHttpResponse(ctx, w) + default: + genericErr := errors.NewInternalError("", err.Error()) + genericErr.WriteHttpResponse(ctx, w) + } +} + +func PostMachines(firestoreClient FirestoreClient) rest.ApiOption { + handler := &postMachinesHandler{ + tracer: otel.Tracer("machine/endpoint"), + log: humus.Logger("machine/endpoint"), + firestoreClient: firestoreClient, + } + + return rest.Handle( + http.MethodPost, + rest.BasePath("/api/v1/machines"), + rpc.HandleJson(handler), + rest.OnError(rest.ErrorHandlerFunc(errorHandler)), + ) +} diff --git a/services/machine/endpoint/post_machines_test.go b/services/machine/endpoint/post_machines_test.go new file mode 100644 index 00000000..43491121 --- /dev/null +++ b/services/machine/endpoint/post_machines_test.go @@ -0,0 +1,269 @@ +package endpoint + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Zaba505/infra/services/machine/errors" + "github.com/Zaba505/infra/services/machine/service" +) + +type mockFirestoreClient struct { + machines map[string]*service.MachineRequest + createErr error + findByMACErr error + existingMacID string +} + +func (m *mockFirestoreClient) CreateMachine(ctx context.Context, req *service.CreateMachineRequest) (*service.CreateMachineResponse, error) { + if m.createErr != nil { + return nil, m.createErr + } + if m.machines == nil { + m.machines = make(map[string]*service.MachineRequest) + } + m.machines[req.MachineID] = req.Machine + return &service.CreateMachineResponse{}, nil +} + +func (m *mockFirestoreClient) FindMachineByMAC(ctx context.Context, req *service.FindMachineByMACRequest) (*service.FindMachineByMACResponse, error) { + if m.findByMACErr != nil { + return nil, m.findByMACErr + } + if m.existingMacID != "" { + return &service.FindMachineByMACResponse{ + MachineID: m.existingMacID, + Found: true, + }, nil + } + return &service.FindMachineByMACResponse{Found: false}, nil +} + +func (m *mockFirestoreClient) Close() error { + return nil +} + +func TestPostMachinesHandler_Handle(t *testing.T) { + ctx := context.Background() + + t.Run("successful machine registration", func(t *testing.T) { + mock := &mockFirestoreClient{} + handler := &postMachinesHandler{firestoreClient: mock} + + req := &MachineRequest{ + CPUs: []CPU{ + {Manufacturer: "Intel", ClockFrequency: 2400000000, Cores: 8}, + }, + MemoryModules: []MemoryModule{ + {Size: 17179869184}, + }, + NICs: []NIC{ + {MAC: "52:54:00:12:34:56"}, + }, + Drives: []Drive{ + {Capacity: 500107862016}, + }, + } + + resp, err := handler.Handle(ctx, req) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if resp.ID == "" { + t.Error("expected non-empty machine ID") + } + + if len(mock.machines) != 1 { + t.Errorf("expected 1 machine in store, got %d", len(mock.machines)) + } + }) + + t.Run("validation error - missing NICs", func(t *testing.T) { + mock := &mockFirestoreClient{} + handler := &postMachinesHandler{firestoreClient: mock} + + req := &MachineRequest{ + NICs: []NIC{}, + } + + _, err := handler.Handle(ctx, req) + if err == nil { + t.Fatal("expected validation error, got nil") + } + + validationErr, ok := err.(*errors.ValidationProblem) + if !ok { + t.Fatalf("expected *errors.ValidationProblem, got %T", err) + } + + if validationErr.Status != http.StatusBadRequest { + t.Errorf("expected status %d, got %d", http.StatusBadRequest, validationErr.Status) + } + + if len(validationErr.InvalidFields) == 0 { + t.Error("expected invalid fields, got none") + } + }) + + t.Run("validation error - invalid MAC format", func(t *testing.T) { + mock := &mockFirestoreClient{} + handler := &postMachinesHandler{firestoreClient: mock} + + req := &MachineRequest{ + NICs: []NIC{ + {MAC: "invalid-mac"}, + }, + } + + _, err := handler.Handle(ctx, req) + if err == nil { + t.Fatal("expected validation error, got nil") + } + + validationErr, ok := err.(*errors.ValidationProblem) + if !ok { + t.Fatalf("expected *errors.ValidationProblem, got %T", err) + } + + if len(validationErr.InvalidFields) == 0 { + t.Error("expected invalid fields, got none") + } + }) + + t.Run("conflict error - duplicate MAC address", func(t *testing.T) { + mock := &mockFirestoreClient{ + existingMacID: "existing-machine-id", + } + handler := &postMachinesHandler{firestoreClient: mock} + + req := &MachineRequest{ + NICs: []NIC{ + {MAC: "52:54:00:12:34:56"}, + }, + } + + _, err := handler.Handle(ctx, req) + if err == nil { + t.Fatal("expected conflict error, got nil") + } + + conflictErr, ok := err.(*errors.ConflictProblem) + if !ok { + t.Fatalf("expected *errors.ConflictProblem, got %T", err) + } + + if conflictErr.Status != http.StatusConflict { + t.Errorf("expected status %d, got %d", http.StatusConflict, conflictErr.Status) + } + + if conflictErr.MACAddress != "52:54:00:12:34:56" { + t.Errorf("expected MAC '52:54:00:12:34:56', got '%s'", conflictErr.MACAddress) + } + + if conflictErr.ExistingMachineID != "existing-machine-id" { + t.Errorf("expected existing ID 'existing-machine-id', got '%s'", conflictErr.ExistingMachineID) + } + }) + + t.Run("internal error - FindMachineByMAC fails", func(t *testing.T) { + mock := &mockFirestoreClient{ + findByMACErr: fmt.Errorf("firestore error"), + } + handler := &postMachinesHandler{firestoreClient: mock} + + req := &MachineRequest{ + NICs: []NIC{ + {MAC: "52:54:00:12:34:56"}, + }, + } + + _, err := handler.Handle(ctx, req) + if err == nil { + t.Fatal("expected error, got nil") + } + + internalErr, ok := err.(*errors.Problem) + if !ok { + t.Fatalf("expected *errors.Problem, got %T", err) + } + + if internalErr.Status != http.StatusInternalServerError { + t.Errorf("expected status %d, got %d", http.StatusInternalServerError, internalErr.Status) + } + }) + + t.Run("internal error - CreateMachine fails", func(t *testing.T) { + mock := &mockFirestoreClient{ + createErr: fmt.Errorf("firestore create error"), + } + handler := &postMachinesHandler{firestoreClient: mock} + + req := &MachineRequest{ + NICs: []NIC{ + {MAC: "52:54:00:12:34:56"}, + }, + } + + _, err := handler.Handle(ctx, req) + if err == nil { + t.Fatal("expected error, got nil") + } + + internalErr, ok := err.(*errors.Problem) + if !ok { + t.Fatalf("expected *errors.Problem, got %T", err) + } + + if internalErr.Status != http.StatusInternalServerError { + t.Errorf("expected status %d, got %d", http.StatusInternalServerError, internalErr.Status) + } + }) +} + +func TestErrorHandler(t *testing.T) { + ctx := context.Background() + + t.Run("handles ValidationProblem", func(t *testing.T) { + w := httptest.NewRecorder() + err := errors.NewValidationError("/test", []errors.InvalidField{ + {Field: "test", Reason: "test reason"}, + }) + + errorHandler(ctx, w, err) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected status %d, got %d", http.StatusBadRequest, w.Code) + } + + contentType := w.Header().Get("Content-Type") + if contentType != "application/problem+json" { + t.Errorf("expected Content-Type 'application/problem+json', got '%s'", contentType) + } + }) + + t.Run("handles ConflictProblem", func(t *testing.T) { + w := httptest.NewRecorder() + err := errors.NewConflictError("/test", "aa:bb:cc:dd:ee:ff", "test-id") + + errorHandler(ctx, w, err) + + if w.Code != http.StatusConflict { + t.Errorf("expected status %d, got %d", http.StatusConflict, w.Code) + } + }) + + t.Run("handles generic error", func(t *testing.T) { + w := httptest.NewRecorder() + err := fmt.Errorf("generic error") + + errorHandler(ctx, w, err) + + if w.Code != http.StatusInternalServerError { + t.Errorf("expected status %d, got %d", http.StatusInternalServerError, w.Code) + } + }) +} diff --git a/services/machine/errors/problem.go b/services/machine/errors/problem.go new file mode 100644 index 00000000..d123c9b5 --- /dev/null +++ b/services/machine/errors/problem.go @@ -0,0 +1,90 @@ +package errors + +import ( + "context" + "encoding/json" + "net/http" +) + +type InvalidField struct { + Field string `json:"field"` + Reason string `json:"reason"` +} + +type Problem struct { + Type string `json:"type"` + Title string `json:"title"` + Status int `json:"status"` + Detail string `json:"detail"` + Instance string `json:"instance"` +} + +func (p *Problem) Error() string { + return p.Detail +} + +func (p *Problem) WriteHttpResponse(ctx context.Context, w http.ResponseWriter) { + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(p.Status) + json.NewEncoder(w).Encode(p) +} + +type ValidationProblem struct { + Problem + InvalidFields []InvalidField `json:"invalid_fields"` +} + +func (vp *ValidationProblem) WriteHttpResponse(ctx context.Context, w http.ResponseWriter) { + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(vp.Status) + json.NewEncoder(w).Encode(vp) +} + +type ConflictProblem struct { + Problem + MACAddress string `json:"mac_address"` + ExistingMachineID string `json:"existing_machine_id"` +} + +func (cp *ConflictProblem) WriteHttpResponse(ctx context.Context, w http.ResponseWriter) { + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(cp.Status) + json.NewEncoder(w).Encode(cp) +} + +func NewValidationError(instance string, fields []InvalidField) *ValidationProblem { + return &ValidationProblem{ + Problem: Problem{ + Type: "https://api.example.com/errors/validation-error", + Title: "Validation Error", + Status: http.StatusBadRequest, + Detail: "The request body failed validation", + Instance: instance, + }, + InvalidFields: fields, + } +} + +func NewConflictError(instance, mac, existingID string) *ConflictProblem { + return &ConflictProblem{ + Problem: Problem{ + Type: "https://api.example.com/errors/duplicate-mac-address", + Title: "Duplicate MAC Address", + Status: http.StatusConflict, + Detail: "A machine with MAC address " + mac + " already exists", + Instance: instance, + }, + MACAddress: mac, + ExistingMachineID: existingID, + } +} + +func NewInternalError(instance, detail string) *Problem { + return &Problem{ + Type: "https://api.example.com/errors/internal-error", + Title: "Internal Server Error", + Status: http.StatusInternalServerError, + Detail: detail, + Instance: instance, + } +} diff --git a/services/machine/errors/problem_test.go b/services/machine/errors/problem_test.go new file mode 100644 index 00000000..a2934655 --- /dev/null +++ b/services/machine/errors/problem_test.go @@ -0,0 +1,156 @@ +package errors + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestProblem_WriteHttpResponse(t *testing.T) { + p := &Problem{ + Type: "https://api.example.com/errors/test", + Title: "Test Error", + Status: http.StatusBadRequest, + Detail: "This is a test error", + Instance: "/test", + } + + w := httptest.NewRecorder() + p.WriteHttpResponse(context.Background(), w) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected status %d, got %d", http.StatusBadRequest, w.Code) + } + + contentType := w.Header().Get("Content-Type") + if contentType != "application/problem+json" { + t.Errorf("expected Content-Type 'application/problem+json', got '%s'", contentType) + } + + var response Problem + if err := json.NewDecoder(w.Body).Decode(&response); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if response.Type != p.Type { + t.Errorf("expected Type '%s', got '%s'", p.Type, response.Type) + } + if response.Title != p.Title { + t.Errorf("expected Title '%s', got '%s'", p.Title, response.Title) + } + if response.Status != p.Status { + t.Errorf("expected Status %d, got %d", p.Status, response.Status) + } + if response.Detail != p.Detail { + t.Errorf("expected Detail '%s', got '%s'", p.Detail, response.Detail) + } + if response.Instance != p.Instance { + t.Errorf("expected Instance '%s', got '%s'", p.Instance, response.Instance) + } +} + +func TestValidationProblem_WriteHttpResponse(t *testing.T) { + fields := []InvalidField{ + {Field: "nics", Reason: "at least one NIC is required"}, + } + vp := NewValidationError("/api/v1/machines", fields) + + w := httptest.NewRecorder() + vp.WriteHttpResponse(context.Background(), w) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected status %d, got %d", http.StatusBadRequest, w.Code) + } + + contentType := w.Header().Get("Content-Type") + if contentType != "application/problem+json" { + t.Errorf("expected Content-Type 'application/problem+json', got '%s'", contentType) + } + + var response ValidationProblem + if err := json.NewDecoder(w.Body).Decode(&response); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if len(response.InvalidFields) != 1 { + t.Errorf("expected 1 invalid field, got %d", len(response.InvalidFields)) + } +} + +func TestConflictProblem_WriteHttpResponse(t *testing.T) { + cp := NewConflictError("/api/v1/machines", "aa:bb:cc:dd:ee:ff", "018c7dbd-a000-7000-8000-fedcba987650") + + w := httptest.NewRecorder() + cp.WriteHttpResponse(context.Background(), w) + + if w.Code != http.StatusConflict { + t.Errorf("expected status %d, got %d", http.StatusConflict, w.Code) + } + + contentType := w.Header().Get("Content-Type") + if contentType != "application/problem+json" { + t.Errorf("expected Content-Type 'application/problem+json', got '%s'", contentType) + } + + var response ConflictProblem + if err := json.NewDecoder(w.Body).Decode(&response); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if response.MACAddress != "aa:bb:cc:dd:ee:ff" { + t.Errorf("expected MACAddress 'aa:bb:cc:dd:ee:ff', got '%s'", response.MACAddress) + } + if response.ExistingMachineID != "018c7dbd-a000-7000-8000-fedcba987650" { + t.Errorf("expected ExistingMachineID '018c7dbd-a000-7000-8000-fedcba987650', got '%s'", response.ExistingMachineID) + } +} + +func TestNewValidationError(t *testing.T) { + fields := []InvalidField{ + {Field: "test", Reason: "test reason"}, + } + vp := NewValidationError("/test", fields) + + if vp.Status != http.StatusBadRequest { + t.Errorf("expected status %d, got %d", http.StatusBadRequest, vp.Status) + } + if vp.Title != "Validation Error" { + t.Errorf("expected title 'Validation Error', got '%s'", vp.Title) + } + if len(vp.InvalidFields) != 1 { + t.Errorf("expected 1 invalid field, got %d", len(vp.InvalidFields)) + } +} + +func TestNewConflictError(t *testing.T) { + cp := NewConflictError("/test", "aa:bb:cc:dd:ee:ff", "test-id") + + if cp.Status != http.StatusConflict { + t.Errorf("expected status %d, got %d", http.StatusConflict, cp.Status) + } + if cp.Title != "Duplicate MAC Address" { + t.Errorf("expected title 'Duplicate MAC Address', got '%s'", cp.Title) + } + if cp.MACAddress != "aa:bb:cc:dd:ee:ff" { + t.Errorf("expected MACAddress 'aa:bb:cc:dd:ee:ff', got '%s'", cp.MACAddress) + } + if cp.ExistingMachineID != "test-id" { + t.Errorf("expected ExistingMachineID 'test-id', got '%s'", cp.ExistingMachineID) + } +} + +func TestNewInternalError(t *testing.T) { + p := NewInternalError("/test", "Something went wrong") + + if p.Status != http.StatusInternalServerError { + t.Errorf("expected status %d, got %d", http.StatusInternalServerError, p.Status) + } + if p.Title != "Internal Server Error" { + t.Errorf("expected title 'Internal Server Error', got '%s'", p.Title) + } + if p.Detail != "Something went wrong" { + t.Errorf("expected detail 'Something went wrong', got '%s'", p.Detail) + } +} diff --git a/services/machine/service/firestore.go b/services/machine/service/firestore.go new file mode 100644 index 00000000..cdca75c4 --- /dev/null +++ b/services/machine/service/firestore.go @@ -0,0 +1,99 @@ +package service + +import ( + "context" + "fmt" + "strings" + + "cloud.google.com/go/firestore" + "google.golang.org/api/iterator" +) + +type CreateMachineRequest struct { + MachineID string + Machine *MachineRequest +} + +type CreateMachineResponse struct{} + +type FindMachineByMACRequest struct { + MAC string +} + +type FindMachineByMACResponse struct { + MachineID string + Found bool +} + +type FirestoreClient struct { + client *firestore.Client +} + +func NewFirestoreClient(ctx context.Context, projectID string) (*FirestoreClient, error) { + client, err := firestore.NewClient(ctx, projectID) + if err != nil { + return nil, fmt.Errorf("failed to create firestore client: %w", err) + } + return &FirestoreClient{client: client}, nil +} + +func (c *FirestoreClient) CreateMachine(ctx context.Context, req *CreateMachineRequest) (*CreateMachineResponse, error) { + docRef := c.client.Collection("machines").Doc(req.MachineID) + + data := map[string]interface{}{ + "id": req.MachineID, + "cpus": req.Machine.CPUs, + "memory_modules": req.Machine.MemoryModules, + "accelerators": req.Machine.Accelerators, + "nics": req.Machine.NICs, + "drives": req.Machine.Drives, + } + + _, err := docRef.Set(ctx, data) + if err != nil { + return nil, fmt.Errorf("failed to create machine document: %w", err) + } + + return &CreateMachineResponse{}, nil +} + +func (c *FirestoreClient) FindMachineByMAC(ctx context.Context, req *FindMachineByMACRequest) (*FindMachineByMACResponse, error) { + normalizedMAC := strings.ToLower(req.MAC) + + iter := c.client.Collection("machines"). + Where("nics", "array-contains", map[string]interface{}{"mac": normalizedMAC}). + Limit(1). + Documents(ctx) + defer iter.Stop() + + doc, err := iter.Next() + if err == iterator.Done { + return &FindMachineByMACResponse{Found: false}, nil + } + if err != nil { + return nil, fmt.Errorf("failed to query machines by MAC: %w", err) + } + + var data struct { + ID string `firestore:"id"` + NICs []NIC `firestore:"nics"` + } + if err := doc.DataTo(&data); err != nil { + return nil, fmt.Errorf("failed to decode machine document: %w", err) + } + + for _, nic := range data.NICs { + if strings.EqualFold(nic.MAC, req.MAC) { + return &FindMachineByMACResponse{ + MachineID: data.ID, + Found: true, + }, nil + } + } + + return &FindMachineByMACResponse{Found: false}, nil +} + +func (c *FirestoreClient) Close() error { + return c.client.Close() +} diff --git a/services/machine/service/firestore_test.go b/services/machine/service/firestore_test.go new file mode 100644 index 00000000..07857f83 --- /dev/null +++ b/services/machine/service/firestore_test.go @@ -0,0 +1,97 @@ +package service + +import ( + "context" + "testing" +) + +type mockFirestoreClient struct { + machines map[string]*MachineRequest + createErr error + findByMACErr error + existingMacID string +} + +func (m *mockFirestoreClient) CreateMachine(ctx context.Context, req *CreateMachineRequest) (*CreateMachineResponse, error) { + if m.createErr != nil { + return nil, m.createErr + } + if m.machines == nil { + m.machines = make(map[string]*MachineRequest) + } + m.machines[req.MachineID] = req.Machine + return &CreateMachineResponse{}, nil +} + +func (m *mockFirestoreClient) FindMachineByMAC(ctx context.Context, req *FindMachineByMACRequest) (*FindMachineByMACResponse, error) { + if m.findByMACErr != nil { + return nil, m.findByMACErr + } + if m.existingMacID != "" { + return &FindMachineByMACResponse{ + MachineID: m.existingMacID, + Found: true, + }, nil + } + return &FindMachineByMACResponse{Found: false}, nil +} + +func (m *mockFirestoreClient) Close() error { + return nil +} + +func TestMockClient(t *testing.T) { + ctx := context.Background() + + t.Run("CreateMachine success", func(t *testing.T) { + mock := &mockFirestoreClient{} + req := &CreateMachineRequest{ + MachineID: "test-id", + Machine: &MachineRequest{ + NICs: []NIC{{MAC: "aa:bb:cc:dd:ee:ff"}}, + }, + } + + _, err := mock.CreateMachine(ctx, req) + if err != nil { + t.Errorf("expected no error, got %v", err) + } + + if len(mock.machines) != 1 { + t.Errorf("expected 1 machine, got %d", len(mock.machines)) + } + }) + + t.Run("FindMachineByMAC not found", func(t *testing.T) { + mock := &mockFirestoreClient{} + + resp, err := mock.FindMachineByMAC(ctx, &FindMachineByMACRequest{ + MAC: "aa:bb:cc:dd:ee:ff", + }) + if err != nil { + t.Errorf("expected no error, got %v", err) + } + if resp.Found { + t.Errorf("expected not found, got found with ID %s", resp.MachineID) + } + }) + + t.Run("FindMachineByMAC found", func(t *testing.T) { + mock := &mockFirestoreClient{ + existingMacID: "existing-id", + } + + resp, err := mock.FindMachineByMAC(ctx, &FindMachineByMACRequest{ + MAC: "aa:bb:cc:dd:ee:ff", + }) + if err != nil { + t.Errorf("expected no error, got %v", err) + } + if !resp.Found { + t.Error("expected found, got not found") + } + if resp.MachineID != "existing-id" { + t.Errorf("expected ID 'existing-id', got '%s'", resp.MachineID) + } + }) +} diff --git a/services/machine/service/model.go b/services/machine/service/model.go new file mode 100644 index 00000000..3b2604c5 --- /dev/null +++ b/services/machine/service/model.go @@ -0,0 +1,31 @@ +package service + +type MachineRequest struct { + CPUs []CPU `firestore:"cpus"` + MemoryModules []MemoryModule `firestore:"memory_modules"` + Accelerators []Accelerator `firestore:"accelerators"` + NICs []NIC `firestore:"nics"` + Drives []Drive `firestore:"drives"` +} + +type CPU struct { + Manufacturer string `firestore:"manufacturer"` + ClockFrequency int64 `firestore:"clock_frequency"` + Cores int64 `firestore:"cores"` +} + +type MemoryModule struct { + Size int64 `firestore:"size"` +} + +type Accelerator struct { + Manufacturer string `firestore:"manufacturer"` +} + +type NIC struct { + MAC string `firestore:"mac"` +} + +type Drive struct { + Capacity int64 `firestore:"capacity"` +}