From f80bbd3378fa075faeb04f7aad5dba5b472fddeb Mon Sep 17 00:00:00 2001 From: Richard Carson Derr Date: Sun, 23 Nov 2025 22:56:58 -0500 Subject: [PATCH 1/6] feat(issue-633): implement post machines endpoint --- go.mod | 15 + go.sum | 39 +++ services/machine/app/app.go | 20 ++ services/machine/config.yaml | 6 + services/machine/endpoint/post_machines.go | 109 ++++++ .../machine/endpoint/post_machines_test.go | 327 ++++++++++++++++++ services/machine/errors/problem.go | 87 +++++ services/machine/errors/problem_test.go | 158 +++++++++ services/machine/firestore/client.go | 81 +++++ services/machine/firestore/client_test.go | 89 +++++ services/machine/models/machine.go | 80 +++++ services/machine/models/machine_test.go | 147 ++++++++ 12 files changed, 1158 insertions(+) create mode 100644 services/machine/endpoint/post_machines.go create mode 100644 services/machine/endpoint/post_machines_test.go create mode 100644 services/machine/errors/problem.go create mode 100644 services/machine/errors/problem_test.go create mode 100644 services/machine/firestore/client.go create mode 100644 services/machine/firestore/client_test.go create mode 100644 services/machine/models/machine.go create mode 100644 services/machine/models/machine_test.go diff --git a/go.mod b/go.mod index 990d4b5a..287f5708 100644 --- a/go.mod +++ b/go.mod @@ -3,18 +3,28 @@ module github.com/Zaba505/infra go 1.24.0 require ( + cloud.google.com/go/firestore v1.20.0 github.com/swaggest/openapi-go v0.2.60 github.com/z5labs/humus v0.13.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/s2a-go v0.1.9 // indirect github.com/google/uuid v1.6.0 // 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 @@ -22,6 +32,7 @@ require ( 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 @@ -40,10 +51,14 @@ require ( 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..def9a282 100644 --- a/services/machine/app/app.go +++ b/services/machine/app/app.go @@ -2,18 +2,38 @@ package app import ( "context" + "net/http" + "github.com/Zaba505/infra/services/machine/endpoint" + "github.com/Zaba505/infra/services/machine/firestore" "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 := firestore.NewClient(ctx, cfg.Firestore.ProjectID) + if err != nil { + return nil, err + } + + 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..12bf51cf 100644 --- a/services/machine/config.yaml +++ b/services/machine/config.yaml @@ -0,0 +1,6 @@ +openapi: + title: "Machine Management Service" + version: "v1" + +firestore: + project_id: "${GCP_PROJECT_ID}" diff --git a/services/machine/endpoint/post_machines.go b/services/machine/endpoint/post_machines.go new file mode 100644 index 00000000..34b971c5 --- /dev/null +++ b/services/machine/endpoint/post_machines.go @@ -0,0 +1,109 @@ +package endpoint + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/Zaba505/infra/services/machine/errors" + "github.com/Zaba505/infra/services/machine/models" + "github.com/google/uuid" + "github.com/z5labs/humus/rest" + "github.com/z5labs/humus/rest/rpc" +) + +type FirestoreClient interface { + CreateMachine(ctx context.Context, machineID string, machine *models.MachineRequest) error + FindMachineByMAC(ctx context.Context, mac string) (string, bool, error) + Close() error +} + +type postMachinesHandler struct { + firestoreClient FirestoreClient +} + +func (h *postMachinesHandler) Handle(ctx context.Context, req *models.MachineRequest) (*models.MachineResponse, error) { + invalidFields := req.Validate() + if len(invalidFields) > 0 { + return nil, errors.NewValidationError("/api/v1/machines", invalidFields) + } + + for _, nic := range req.NICs { + existingID, found, err := h.firestoreClient.FindMachineByMAC(ctx, nic.MAC) + if err != nil { + return nil, errors.NewInternalError("/api/v1/machines", fmt.Sprintf("failed to check MAC uniqueness: %v", err)) + } + if found { + return nil, errors.NewConflictError("/api/v1/machines", nic.MAC, existingID) + } + } + + machineID, err := uuid.NewV7() + if err != nil { + return nil, errors.NewInternalError("/api/v1/machines", fmt.Sprintf("failed to generate machine ID: %v", err)) + } + + if err := h.firestoreClient.CreateMachine(ctx, machineID.String(), req); err != nil { + return nil, errors.NewInternalError("/api/v1/machines", fmt.Sprintf("failed to create machine: %v", err)) + } + + return &models.MachineResponse{ + ID: machineID.String(), + }, nil +} + +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) + } +} + +type responseWithLocation struct { + resp *models.MachineResponse + location string +} + +func (r *responseWithLocation) WriteHttpResponse(ctx context.Context, w http.ResponseWriter) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Location", r.location) + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(r.resp) +} + +type postMachinesHandlerWithLocation struct { + inner *postMachinesHandler +} + +func (h *postMachinesHandlerWithLocation) Handle(ctx context.Context, req *models.MachineRequest) (*responseWithLocation, error) { + resp, err := h.inner.Handle(ctx, req) + if err != nil { + return nil, err + } + + return &responseWithLocation{ + resp: resp, + location: fmt.Sprintf("/api/v1/machines/%s", resp.ID), + }, nil +} + +func PostMachines(firestoreClient FirestoreClient) rest.ApiOption { + handler := &postMachinesHandlerWithLocation{ + inner: &postMachinesHandler{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..3df2f49b --- /dev/null +++ b/services/machine/endpoint/post_machines_test.go @@ -0,0 +1,327 @@ +package endpoint + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Zaba505/infra/services/machine/errors" + "github.com/Zaba505/infra/services/machine/models" +) + +type mockFirestoreClient struct { + machines map[string]*models.MachineRequest + createErr error + findByMACErr error + existingMacID string +} + +func (m *mockFirestoreClient) CreateMachine(ctx context.Context, machineID string, machine *models.MachineRequest) error { + if m.createErr != nil { + return m.createErr + } + if m.machines == nil { + m.machines = make(map[string]*models.MachineRequest) + } + m.machines[machineID] = machine + return nil +} + +func (m *mockFirestoreClient) FindMachineByMAC(ctx context.Context, mac string) (string, bool, error) { + if m.findByMACErr != nil { + return "", false, m.findByMACErr + } + if m.existingMacID != "" { + return m.existingMacID, true, nil + } + return "", 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 := &models.MachineRequest{ + CPUs: []models.CPU{ + {Manufacturer: "Intel", ClockFrequency: 2400000000, Cores: 8}, + }, + MemoryModules: []models.MemoryModule{ + {Size: 17179869184}, + }, + NICs: []models.NIC{ + {MAC: "52:54:00:12:34:56"}, + }, + Drives: []models.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 := &models.MachineRequest{ + NICs: []models.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 := &models.MachineRequest{ + NICs: []models.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 := &models.MachineRequest{ + NICs: []models.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 := &models.MachineRequest{ + NICs: []models.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 := &models.MachineRequest{ + NICs: []models.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", []models.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) + } + }) +} + +func TestResponseWithLocation_WriteHttpResponse(t *testing.T) { + ctx := context.Background() + + resp := &responseWithLocation{ + resp: &models.MachineResponse{ + ID: "test-machine-id", + }, + location: "/api/v1/machines/test-machine-id", + } + + w := httptest.NewRecorder() + resp.WriteHttpResponse(ctx, w) + + if w.Code != http.StatusCreated { + t.Errorf("expected status %d, got %d", http.StatusCreated, w.Code) + } + + location := w.Header().Get("Location") + if location != "/api/v1/machines/test-machine-id" { + t.Errorf("expected Location '/api/v1/machines/test-machine-id', got '%s'", location) + } + + var response models.MachineResponse + if err := json.NewDecoder(w.Body).Decode(&response); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if response.ID != "test-machine-id" { + t.Errorf("expected response ID 'test-machine-id', got '%s'", response.ID) + } +} + +func TestPostMachinesHandlerWithLocation_Handle(t *testing.T) { + ctx := context.Background() + mock := &mockFirestoreClient{} + handler := &postMachinesHandlerWithLocation{ + inner: &postMachinesHandler{firestoreClient: mock}, + } + + req := &models.MachineRequest{ + NICs: []models.NIC{ + {MAC: "52:54:00:12:34:56"}, + }, + } + + resp, err := handler.Handle(ctx, req) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if resp.resp.ID == "" { + t.Error("expected non-empty machine ID") + } + + expectedLocation := fmt.Sprintf("/api/v1/machines/%s", resp.resp.ID) + if resp.location != expectedLocation { + t.Errorf("expected location '%s', got '%s'", expectedLocation, resp.location) + } +} diff --git a/services/machine/errors/problem.go b/services/machine/errors/problem.go new file mode 100644 index 00000000..a2a214ad --- /dev/null +++ b/services/machine/errors/problem.go @@ -0,0 +1,87 @@ +package errors + +import ( + "context" + "encoding/json" + "net/http" + + "github.com/Zaba505/infra/services/machine/models" +) + +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 []models.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 []models.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..e8cea0dc --- /dev/null +++ b/services/machine/errors/problem_test.go @@ -0,0 +1,158 @@ +package errors + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Zaba505/infra/services/machine/models" +) + +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 := []models.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 := []models.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/firestore/client.go b/services/machine/firestore/client.go new file mode 100644 index 00000000..443d4e38 --- /dev/null +++ b/services/machine/firestore/client.go @@ -0,0 +1,81 @@ +package firestore + +import ( + "context" + "fmt" + "strings" + + "cloud.google.com/go/firestore" + "github.com/Zaba505/infra/services/machine/models" + "google.golang.org/api/iterator" +) + +type Client struct { + client *firestore.Client +} + +func NewClient(ctx context.Context, projectID string) (*Client, error) { + client, err := firestore.NewClient(ctx, projectID) + if err != nil { + return nil, fmt.Errorf("failed to create firestore client: %w", err) + } + return &Client{client: client}, nil +} + +func (c *Client) CreateMachine(ctx context.Context, machineID string, machine *models.MachineRequest) error { + docRef := c.client.Collection("machines").Doc(machineID) + + data := map[string]interface{}{ + "id": machineID, + "cpus": machine.CPUs, + "memory_modules": machine.MemoryModules, + "accelerators": machine.Accelerators, + "nics": machine.NICs, + "drives": machine.Drives, + } + + _, err := docRef.Set(ctx, data) + if err != nil { + return fmt.Errorf("failed to create machine document: %w", err) + } + + return nil +} + +func (c *Client) FindMachineByMAC(ctx context.Context, mac string) (string, bool, error) { + normalizedMAC := strings.ToLower(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 "", false, nil + } + if err != nil { + return "", false, fmt.Errorf("failed to query machines by MAC: %w", err) + } + + var data struct { + ID string `firestore:"id"` + NICs []models.NIC `firestore:"nics"` + } + if err := doc.DataTo(&data); err != nil { + return "", false, fmt.Errorf("failed to decode machine document: %w", err) + } + + for _, nic := range data.NICs { + if strings.EqualFold(nic.MAC, mac) { + return data.ID, true, nil + } + } + + return "", false, nil +} + +func (c *Client) Close() error { + return c.client.Close() +} diff --git a/services/machine/firestore/client_test.go b/services/machine/firestore/client_test.go new file mode 100644 index 00000000..ba3c5bdc --- /dev/null +++ b/services/machine/firestore/client_test.go @@ -0,0 +1,89 @@ +package firestore + +import ( + "context" + "testing" + + "github.com/Zaba505/infra/services/machine/models" +) + +type mockFirestoreClient struct { + machines map[string]*models.MachineRequest + createErr error + findByMACErr error + existingMacID string +} + +func (m *mockFirestoreClient) CreateMachine(ctx context.Context, machineID string, machine *models.MachineRequest) error { + if m.createErr != nil { + return m.createErr + } + if m.machines == nil { + m.machines = make(map[string]*models.MachineRequest) + } + m.machines[machineID] = machine + return nil +} + +func (m *mockFirestoreClient) FindMachineByMAC(ctx context.Context, mac string) (string, bool, error) { + if m.findByMACErr != nil { + return "", false, m.findByMACErr + } + if m.existingMacID != "" { + return m.existingMacID, true, nil + } + return "", 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 := &models.MachineRequest{ + NICs: []models.NIC{{MAC: "aa:bb:cc:dd:ee:ff"}}, + } + + err := mock.CreateMachine(ctx, "test-id", 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{} + + id, found, err := mock.FindMachineByMAC(ctx, "aa:bb:cc:dd:ee:ff") + if err != nil { + t.Errorf("expected no error, got %v", err) + } + if found { + t.Errorf("expected not found, got found with ID %s", id) + } + }) + + t.Run("FindMachineByMAC found", func(t *testing.T) { + mock := &mockFirestoreClient{ + existingMacID: "existing-id", + } + + id, found, err := mock.FindMachineByMAC(ctx, "aa:bb:cc:dd:ee:ff") + if err != nil { + t.Errorf("expected no error, got %v", err) + } + if !found { + t.Error("expected found, got not found") + } + if id != "existing-id" { + t.Errorf("expected ID 'existing-id', got '%s'", id) + } + }) +} diff --git a/services/machine/models/machine.go b/services/machine/models/machine.go new file mode 100644 index 00000000..b3493bb0 --- /dev/null +++ b/services/machine/models/machine.go @@ -0,0 +1,80 @@ +package models + +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/models/machine_test.go b/services/machine/models/machine_test.go new file mode 100644 index 00000000..4c724d3a --- /dev/null +++ b/services/machine/models/machine_test.go @@ -0,0 +1,147 @@ +package models + +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) + } + }) + } +} From e94f9077c691ebdcc1e5960e4ecdbd78688431d6 Mon Sep 17 00:00:00 2001 From: Richard Carson Derr Date: Sun, 23 Nov 2025 23:05:35 -0500 Subject: [PATCH 2/6] fix(issue-633): ensure it matches docs --- services/machine/endpoint/post_machines.go | 32 +--------- .../machine/endpoint/post_machines_test.go | 60 ------------------- 2 files changed, 1 insertion(+), 91 deletions(-) diff --git a/services/machine/endpoint/post_machines.go b/services/machine/endpoint/post_machines.go index 34b971c5..592a7272 100644 --- a/services/machine/endpoint/post_machines.go +++ b/services/machine/endpoint/post_machines.go @@ -67,38 +67,8 @@ func errorHandler(ctx context.Context, w http.ResponseWriter, err error) { } } -type responseWithLocation struct { - resp *models.MachineResponse - location string -} - -func (r *responseWithLocation) WriteHttpResponse(ctx context.Context, w http.ResponseWriter) { - w.Header().Set("Content-Type", "application/json") - w.Header().Set("Location", r.location) - w.WriteHeader(http.StatusCreated) - json.NewEncoder(w).Encode(r.resp) -} - -type postMachinesHandlerWithLocation struct { - inner *postMachinesHandler -} - -func (h *postMachinesHandlerWithLocation) Handle(ctx context.Context, req *models.MachineRequest) (*responseWithLocation, error) { - resp, err := h.inner.Handle(ctx, req) - if err != nil { - return nil, err - } - - return &responseWithLocation{ - resp: resp, - location: fmt.Sprintf("/api/v1/machines/%s", resp.ID), - }, nil -} - func PostMachines(firestoreClient FirestoreClient) rest.ApiOption { - handler := &postMachinesHandlerWithLocation{ - inner: &postMachinesHandler{firestoreClient: firestoreClient}, - } + handler := &postMachinesHandler{firestoreClient: firestoreClient} return rest.Handle( http.MethodPost, diff --git a/services/machine/endpoint/post_machines_test.go b/services/machine/endpoint/post_machines_test.go index 3df2f49b..222b0d9f 100644 --- a/services/machine/endpoint/post_machines_test.go +++ b/services/machine/endpoint/post_machines_test.go @@ -265,63 +265,3 @@ func TestErrorHandler(t *testing.T) { } }) } - -func TestResponseWithLocation_WriteHttpResponse(t *testing.T) { - ctx := context.Background() - - resp := &responseWithLocation{ - resp: &models.MachineResponse{ - ID: "test-machine-id", - }, - location: "/api/v1/machines/test-machine-id", - } - - w := httptest.NewRecorder() - resp.WriteHttpResponse(ctx, w) - - if w.Code != http.StatusCreated { - t.Errorf("expected status %d, got %d", http.StatusCreated, w.Code) - } - - location := w.Header().Get("Location") - if location != "/api/v1/machines/test-machine-id" { - t.Errorf("expected Location '/api/v1/machines/test-machine-id', got '%s'", location) - } - - var response models.MachineResponse - if err := json.NewDecoder(w.Body).Decode(&response); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - if response.ID != "test-machine-id" { - t.Errorf("expected response ID 'test-machine-id', got '%s'", response.ID) - } -} - -func TestPostMachinesHandlerWithLocation_Handle(t *testing.T) { - ctx := context.Background() - mock := &mockFirestoreClient{} - handler := &postMachinesHandlerWithLocation{ - inner: &postMachinesHandler{firestoreClient: mock}, - } - - req := &models.MachineRequest{ - NICs: []models.NIC{ - {MAC: "52:54:00:12:34:56"}, - }, - } - - resp, err := handler.Handle(ctx, req) - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - - if resp.resp.ID == "" { - t.Error("expected non-empty machine ID") - } - - expectedLocation := fmt.Sprintf("/api/v1/machines/%s", resp.resp.ID) - if resp.location != expectedLocation { - t.Errorf("expected location '%s', got '%s'", expectedLocation, resp.location) - } -} From 1aa98a2eafd75baf694a81e8888c12f6186c4b7e Mon Sep 17 00:00:00 2001 From: Richard Carson Derr Date: Sun, 23 Nov 2025 23:27:42 -0500 Subject: [PATCH 3/6] refactor(issue-633): follow recommended humus structure --- services/machine/app/app.go | 4 +- .../{models/machine.go => endpoint/model.go} | 2 +- .../model_test.go} | 2 +- services/machine/endpoint/post_machines.go | 80 +++++++++++++++++-- .../machine/endpoint/post_machines_test.go | 41 +++++----- services/machine/errors/problem.go | 11 ++- services/machine/errors/problem_test.go | 6 +- .../client.go => service/firestore.go} | 19 +++-- .../firestore_test.go} | 14 ++-- services/machine/service/model.go | 31 +++++++ 10 files changed, 152 insertions(+), 58 deletions(-) rename services/machine/{models/machine.go => endpoint/model.go} (98%) rename services/machine/{models/machine_test.go => endpoint/model_test.go} (99%) rename services/machine/{firestore/client.go => service/firestore.go} (73%) rename services/machine/{firestore/client_test.go => service/firestore_test.go} (84%) create mode 100644 services/machine/service/model.go diff --git a/services/machine/app/app.go b/services/machine/app/app.go index def9a282..aff9585d 100644 --- a/services/machine/app/app.go +++ b/services/machine/app/app.go @@ -5,7 +5,7 @@ import ( "net/http" "github.com/Zaba505/infra/services/machine/endpoint" - "github.com/Zaba505/infra/services/machine/firestore" + "github.com/Zaba505/infra/services/machine/service" "github.com/z5labs/humus/rest" ) @@ -19,7 +19,7 @@ type FirestoreConfig struct { } func Init(ctx context.Context, cfg Config) (*rest.Api, error) { - fsClient, err := firestore.NewClient(ctx, cfg.Firestore.ProjectID) + fsClient, err := service.NewFirestoreClient(ctx, cfg.Firestore.ProjectID) if err != nil { return nil, err } diff --git a/services/machine/models/machine.go b/services/machine/endpoint/model.go similarity index 98% rename from services/machine/models/machine.go rename to services/machine/endpoint/model.go index b3493bb0..f31b9bb7 100644 --- a/services/machine/models/machine.go +++ b/services/machine/endpoint/model.go @@ -1,4 +1,4 @@ -package models +package endpoint import ( "fmt" diff --git a/services/machine/models/machine_test.go b/services/machine/endpoint/model_test.go similarity index 99% rename from services/machine/models/machine_test.go rename to services/machine/endpoint/model_test.go index 4c724d3a..fba11409 100644 --- a/services/machine/models/machine_test.go +++ b/services/machine/endpoint/model_test.go @@ -1,4 +1,4 @@ -package models +package endpoint import ( "testing" diff --git a/services/machine/endpoint/post_machines.go b/services/machine/endpoint/post_machines.go index 592a7272..44512668 100644 --- a/services/machine/endpoint/post_machines.go +++ b/services/machine/endpoint/post_machines.go @@ -2,19 +2,18 @@ package endpoint import ( "context" - "encoding/json" "fmt" "net/http" "github.com/Zaba505/infra/services/machine/errors" - "github.com/Zaba505/infra/services/machine/models" + "github.com/Zaba505/infra/services/machine/service" "github.com/google/uuid" "github.com/z5labs/humus/rest" "github.com/z5labs/humus/rest/rpc" ) type FirestoreClient interface { - CreateMachine(ctx context.Context, machineID string, machine *models.MachineRequest) error + CreateMachine(ctx context.Context, machineID string, machine *service.MachineRequest) error FindMachineByMAC(ctx context.Context, mac string) (string, bool, error) Close() error } @@ -23,10 +22,17 @@ type postMachinesHandler struct { firestoreClient FirestoreClient } -func (h *postMachinesHandler) Handle(ctx context.Context, req *models.MachineRequest) (*models.MachineResponse, error) { +func (h *postMachinesHandler) Handle(ctx context.Context, req *MachineRequest) (*MachineResponse, error) { invalidFields := req.Validate() if len(invalidFields) > 0 { - return nil, errors.NewValidationError("/api/v1/machines", invalidFields) + 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 { @@ -44,15 +50,75 @@ func (h *postMachinesHandler) Handle(ctx context.Context, req *models.MachineReq return nil, errors.NewInternalError("/api/v1/machines", fmt.Sprintf("failed to generate machine ID: %v", err)) } - if err := h.firestoreClient.CreateMachine(ctx, machineID.String(), req); err != nil { + serviceReq := &service.MachineRequest{ + CPUs: convertCPUs(req.CPUs), + MemoryModules: convertMemoryModules(req.MemoryModules), + Accelerators: convertAccelerators(req.Accelerators), + NICs: convertNICs(req.NICs), + Drives: convertDrives(req.Drives), + } + + if err := h.firestoreClient.CreateMachine(ctx, machineID.String(), serviceReq); err != nil { return nil, errors.NewInternalError("/api/v1/machines", fmt.Sprintf("failed to create machine: %v", err)) } - return &models.MachineResponse{ + 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: diff --git a/services/machine/endpoint/post_machines_test.go b/services/machine/endpoint/post_machines_test.go index 222b0d9f..1ddffa01 100644 --- a/services/machine/endpoint/post_machines_test.go +++ b/services/machine/endpoint/post_machines_test.go @@ -2,29 +2,28 @@ package endpoint import ( "context" - "encoding/json" "fmt" "net/http" "net/http/httptest" "testing" "github.com/Zaba505/infra/services/machine/errors" - "github.com/Zaba505/infra/services/machine/models" + "github.com/Zaba505/infra/services/machine/service" ) type mockFirestoreClient struct { - machines map[string]*models.MachineRequest + machines map[string]*service.MachineRequest createErr error findByMACErr error existingMacID string } -func (m *mockFirestoreClient) CreateMachine(ctx context.Context, machineID string, machine *models.MachineRequest) error { +func (m *mockFirestoreClient) CreateMachine(ctx context.Context, machineID string, machine *service.MachineRequest) error { if m.createErr != nil { return m.createErr } if m.machines == nil { - m.machines = make(map[string]*models.MachineRequest) + m.machines = make(map[string]*service.MachineRequest) } m.machines[machineID] = machine return nil @@ -51,17 +50,17 @@ func TestPostMachinesHandler_Handle(t *testing.T) { mock := &mockFirestoreClient{} handler := &postMachinesHandler{firestoreClient: mock} - req := &models.MachineRequest{ - CPUs: []models.CPU{ + req := &MachineRequest{ + CPUs: []CPU{ {Manufacturer: "Intel", ClockFrequency: 2400000000, Cores: 8}, }, - MemoryModules: []models.MemoryModule{ + MemoryModules: []MemoryModule{ {Size: 17179869184}, }, - NICs: []models.NIC{ + NICs: []NIC{ {MAC: "52:54:00:12:34:56"}, }, - Drives: []models.Drive{ + Drives: []Drive{ {Capacity: 500107862016}, }, } @@ -84,8 +83,8 @@ func TestPostMachinesHandler_Handle(t *testing.T) { mock := &mockFirestoreClient{} handler := &postMachinesHandler{firestoreClient: mock} - req := &models.MachineRequest{ - NICs: []models.NIC{}, + req := &MachineRequest{ + NICs: []NIC{}, } _, err := handler.Handle(ctx, req) @@ -111,8 +110,8 @@ func TestPostMachinesHandler_Handle(t *testing.T) { mock := &mockFirestoreClient{} handler := &postMachinesHandler{firestoreClient: mock} - req := &models.MachineRequest{ - NICs: []models.NIC{ + req := &MachineRequest{ + NICs: []NIC{ {MAC: "invalid-mac"}, }, } @@ -138,8 +137,8 @@ func TestPostMachinesHandler_Handle(t *testing.T) { } handler := &postMachinesHandler{firestoreClient: mock} - req := &models.MachineRequest{ - NICs: []models.NIC{ + req := &MachineRequest{ + NICs: []NIC{ {MAC: "52:54:00:12:34:56"}, }, } @@ -173,8 +172,8 @@ func TestPostMachinesHandler_Handle(t *testing.T) { } handler := &postMachinesHandler{firestoreClient: mock} - req := &models.MachineRequest{ - NICs: []models.NIC{ + req := &MachineRequest{ + NICs: []NIC{ {MAC: "52:54:00:12:34:56"}, }, } @@ -200,8 +199,8 @@ func TestPostMachinesHandler_Handle(t *testing.T) { } handler := &postMachinesHandler{firestoreClient: mock} - req := &models.MachineRequest{ - NICs: []models.NIC{ + req := &MachineRequest{ + NICs: []NIC{ {MAC: "52:54:00:12:34:56"}, }, } @@ -227,7 +226,7 @@ func TestErrorHandler(t *testing.T) { t.Run("handles ValidationProblem", func(t *testing.T) { w := httptest.NewRecorder() - err := errors.NewValidationError("/test", []models.InvalidField{ + err := errors.NewValidationError("/test", []errors.InvalidField{ {Field: "test", Reason: "test reason"}, }) diff --git a/services/machine/errors/problem.go b/services/machine/errors/problem.go index a2a214ad..d123c9b5 100644 --- a/services/machine/errors/problem.go +++ b/services/machine/errors/problem.go @@ -4,10 +4,13 @@ import ( "context" "encoding/json" "net/http" - - "github.com/Zaba505/infra/services/machine/models" ) +type InvalidField struct { + Field string `json:"field"` + Reason string `json:"reason"` +} + type Problem struct { Type string `json:"type"` Title string `json:"title"` @@ -28,7 +31,7 @@ func (p *Problem) WriteHttpResponse(ctx context.Context, w http.ResponseWriter) type ValidationProblem struct { Problem - InvalidFields []models.InvalidField `json:"invalid_fields"` + InvalidFields []InvalidField `json:"invalid_fields"` } func (vp *ValidationProblem) WriteHttpResponse(ctx context.Context, w http.ResponseWriter) { @@ -49,7 +52,7 @@ func (cp *ConflictProblem) WriteHttpResponse(ctx context.Context, w http.Respons json.NewEncoder(w).Encode(cp) } -func NewValidationError(instance string, fields []models.InvalidField) *ValidationProblem { +func NewValidationError(instance string, fields []InvalidField) *ValidationProblem { return &ValidationProblem{ Problem: Problem{ Type: "https://api.example.com/errors/validation-error", diff --git a/services/machine/errors/problem_test.go b/services/machine/errors/problem_test.go index e8cea0dc..a2934655 100644 --- a/services/machine/errors/problem_test.go +++ b/services/machine/errors/problem_test.go @@ -6,8 +6,6 @@ import ( "net/http" "net/http/httptest" "testing" - - "github.com/Zaba505/infra/services/machine/models" ) func TestProblem_WriteHttpResponse(t *testing.T) { @@ -54,7 +52,7 @@ func TestProblem_WriteHttpResponse(t *testing.T) { } func TestValidationProblem_WriteHttpResponse(t *testing.T) { - fields := []models.InvalidField{ + fields := []InvalidField{ {Field: "nics", Reason: "at least one NIC is required"}, } vp := NewValidationError("/api/v1/machines", fields) @@ -110,7 +108,7 @@ func TestConflictProblem_WriteHttpResponse(t *testing.T) { } func TestNewValidationError(t *testing.T) { - fields := []models.InvalidField{ + fields := []InvalidField{ {Field: "test", Reason: "test reason"}, } vp := NewValidationError("/test", fields) diff --git a/services/machine/firestore/client.go b/services/machine/service/firestore.go similarity index 73% rename from services/machine/firestore/client.go rename to services/machine/service/firestore.go index 443d4e38..1a92c509 100644 --- a/services/machine/firestore/client.go +++ b/services/machine/service/firestore.go @@ -1,4 +1,4 @@ -package firestore +package service import ( "context" @@ -6,23 +6,22 @@ import ( "strings" "cloud.google.com/go/firestore" - "github.com/Zaba505/infra/services/machine/models" "google.golang.org/api/iterator" ) -type Client struct { +type FirestoreClient struct { client *firestore.Client } -func NewClient(ctx context.Context, projectID string) (*Client, error) { +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 &Client{client: client}, nil + return &FirestoreClient{client: client}, nil } -func (c *Client) CreateMachine(ctx context.Context, machineID string, machine *models.MachineRequest) error { +func (c *FirestoreClient) CreateMachine(ctx context.Context, machineID string, machine *MachineRequest) error { docRef := c.client.Collection("machines").Doc(machineID) data := map[string]interface{}{ @@ -42,7 +41,7 @@ func (c *Client) CreateMachine(ctx context.Context, machineID string, machine *m return nil } -func (c *Client) FindMachineByMAC(ctx context.Context, mac string) (string, bool, error) { +func (c *FirestoreClient) FindMachineByMAC(ctx context.Context, mac string) (string, bool, error) { normalizedMAC := strings.ToLower(mac) iter := c.client.Collection("machines"). @@ -60,8 +59,8 @@ func (c *Client) FindMachineByMAC(ctx context.Context, mac string) (string, bool } var data struct { - ID string `firestore:"id"` - NICs []models.NIC `firestore:"nics"` + ID string `firestore:"id"` + NICs []NIC `firestore:"nics"` } if err := doc.DataTo(&data); err != nil { return "", false, fmt.Errorf("failed to decode machine document: %w", err) @@ -76,6 +75,6 @@ func (c *Client) FindMachineByMAC(ctx context.Context, mac string) (string, bool return "", false, nil } -func (c *Client) Close() error { +func (c *FirestoreClient) Close() error { return c.client.Close() } diff --git a/services/machine/firestore/client_test.go b/services/machine/service/firestore_test.go similarity index 84% rename from services/machine/firestore/client_test.go rename to services/machine/service/firestore_test.go index ba3c5bdc..258ecf65 100644 --- a/services/machine/firestore/client_test.go +++ b/services/machine/service/firestore_test.go @@ -1,25 +1,23 @@ -package firestore +package service import ( "context" "testing" - - "github.com/Zaba505/infra/services/machine/models" ) type mockFirestoreClient struct { - machines map[string]*models.MachineRequest + machines map[string]*MachineRequest createErr error findByMACErr error existingMacID string } -func (m *mockFirestoreClient) CreateMachine(ctx context.Context, machineID string, machine *models.MachineRequest) error { +func (m *mockFirestoreClient) CreateMachine(ctx context.Context, machineID string, machine *MachineRequest) error { if m.createErr != nil { return m.createErr } if m.machines == nil { - m.machines = make(map[string]*models.MachineRequest) + m.machines = make(map[string]*MachineRequest) } m.machines[machineID] = machine return nil @@ -44,8 +42,8 @@ func TestMockClient(t *testing.T) { t.Run("CreateMachine success", func(t *testing.T) { mock := &mockFirestoreClient{} - req := &models.MachineRequest{ - NICs: []models.NIC{{MAC: "aa:bb:cc:dd:ee:ff"}}, + req := &MachineRequest{ + NICs: []NIC{{MAC: "aa:bb:cc:dd:ee:ff"}}, } err := mock.CreateMachine(ctx, "test-id", req) 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"` +} From 0c5c4ace207131e1f481f1f76015c3224b2f5cae Mon Sep 17 00:00:00 2001 From: Richard Carson Derr Date: Mon, 24 Nov 2025 23:30:58 -0500 Subject: [PATCH 4/6] feat(issue-633): add humus ai instructions --- .../instructions/humus-common.instructions.md | 229 ++++++++++ .../instructions/humus-rest.instructions.md | 396 ++++++++++++++++++ 2 files changed, 625 insertions(+) create mode 100644 .github/instructions/humus-common.instructions.md create mode 100644 .github/instructions/humus-rest.instructions.md 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 From 35a7fc74899dacb4f64f01a824f2581c4aa21c99 Mon Sep 17 00:00:00 2001 From: Richard Carson Derr Date: Mon, 24 Nov 2025 23:33:43 -0500 Subject: [PATCH 5/6] feat(issue-633): update claude instructions --- CLAUDE.md | 71 ++++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 62 insertions(+), 9 deletions(-) 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 From d16631c712a54a64b9d7c7decbb6a451fbd216e0 Mon Sep 17 00:00:00 2001 From: Richard Carson Derr Date: Mon, 24 Nov 2025 23:44:25 -0500 Subject: [PATCH 6/6] refactor(issue-633): align to humus instructions --- go.mod | 8 +-- services/machine/app/app.go | 6 ++ services/machine/config.yaml | 6 +- services/machine/endpoint/post_machines.go | 30 +++++++--- .../machine/endpoint/post_machines_test.go | 19 ++++--- services/machine/service/firestore.go | 55 +++++++++++++------ services/machine/service/firestore_test.go | 46 ++++++++++------ 7 files changed, 112 insertions(+), 58 deletions(-) diff --git a/go.mod b/go.mod index 287f5708..6c7aec04 100644 --- a/go.mod +++ b/go.mod @@ -4,8 +4,12 @@ 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 ) @@ -22,20 +26,17 @@ require ( github.com/go-logr/stdr v1.2.2 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/google/s2a-go v0.1.9 // indirect - github.com/google/uuid v1.6.0 // 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 @@ -49,7 +50,6 @@ 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 diff --git a/services/machine/app/app.go b/services/machine/app/app.go index aff9585d..d1019fbe 100644 --- a/services/machine/app/app.go +++ b/services/machine/app/app.go @@ -6,6 +6,7 @@ import ( "github.com/Zaba505/infra/services/machine/endpoint" "github.com/Zaba505/infra/services/machine/service" + "github.com/z5labs/bedrock/lifecycle" "github.com/z5labs/humus/rest" ) @@ -24,6 +25,11 @@ func Init(ctx context.Context, cfg Config) (*rest.Api, error) { 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) }) diff --git a/services/machine/config.yaml b/services/machine/config.yaml index 12bf51cf..5c6a6f45 100644 --- a/services/machine/config.yaml +++ b/services/machine/config.yaml @@ -1,6 +1,6 @@ openapi: - title: "Machine Management Service" - version: "v1" + title: {{env "OPENAPI_TITLE" | default "Machine Management Service"}} + version: {{env "OPENAPI_VERSION" | default "v1"}} firestore: - project_id: "${GCP_PROJECT_ID}" + project_id: {{env "GCP_PROJECT_ID"}} diff --git a/services/machine/endpoint/post_machines.go b/services/machine/endpoint/post_machines.go index 44512668..92c1aa8d 100644 --- a/services/machine/endpoint/post_machines.go +++ b/services/machine/endpoint/post_machines.go @@ -3,22 +3,28 @@ 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, machineID string, machine *service.MachineRequest) error - FindMachineByMAC(ctx context.Context, mac string) (string, bool, error) + 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 } @@ -36,12 +42,14 @@ func (h *postMachinesHandler) Handle(ctx context.Context, req *MachineRequest) ( } for _, nic := range req.NICs { - existingID, found, err := h.firestoreClient.FindMachineByMAC(ctx, nic.MAC) + 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 found { - return nil, errors.NewConflictError("/api/v1/machines", nic.MAC, existingID) + if resp.Found { + return nil, errors.NewConflictError("/api/v1/machines", nic.MAC, resp.MachineID) } } @@ -58,7 +66,11 @@ func (h *postMachinesHandler) Handle(ctx context.Context, req *MachineRequest) ( Drives: convertDrives(req.Drives), } - if err := h.firestoreClient.CreateMachine(ctx, machineID.String(), serviceReq); err != nil { + _, 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)) } @@ -134,7 +146,11 @@ func errorHandler(ctx context.Context, w http.ResponseWriter, err error) { } func PostMachines(firestoreClient FirestoreClient) rest.ApiOption { - handler := &postMachinesHandler{firestoreClient: firestoreClient} + handler := &postMachinesHandler{ + tracer: otel.Tracer("machine/endpoint"), + log: humus.Logger("machine/endpoint"), + firestoreClient: firestoreClient, + } return rest.Handle( http.MethodPost, diff --git a/services/machine/endpoint/post_machines_test.go b/services/machine/endpoint/post_machines_test.go index 1ddffa01..43491121 100644 --- a/services/machine/endpoint/post_machines_test.go +++ b/services/machine/endpoint/post_machines_test.go @@ -18,25 +18,28 @@ type mockFirestoreClient struct { existingMacID string } -func (m *mockFirestoreClient) CreateMachine(ctx context.Context, machineID string, machine *service.MachineRequest) error { +func (m *mockFirestoreClient) CreateMachine(ctx context.Context, req *service.CreateMachineRequest) (*service.CreateMachineResponse, error) { if m.createErr != nil { - return m.createErr + return nil, m.createErr } if m.machines == nil { m.machines = make(map[string]*service.MachineRequest) } - m.machines[machineID] = machine - return nil + m.machines[req.MachineID] = req.Machine + return &service.CreateMachineResponse{}, nil } -func (m *mockFirestoreClient) FindMachineByMAC(ctx context.Context, mac string) (string, bool, error) { +func (m *mockFirestoreClient) FindMachineByMAC(ctx context.Context, req *service.FindMachineByMACRequest) (*service.FindMachineByMACResponse, error) { if m.findByMACErr != nil { - return "", false, m.findByMACErr + return nil, m.findByMACErr } if m.existingMacID != "" { - return m.existingMacID, true, nil + return &service.FindMachineByMACResponse{ + MachineID: m.existingMacID, + Found: true, + }, nil } - return "", false, nil + return &service.FindMachineByMACResponse{Found: false}, nil } func (m *mockFirestoreClient) Close() error { diff --git a/services/machine/service/firestore.go b/services/machine/service/firestore.go index 1a92c509..cdca75c4 100644 --- a/services/machine/service/firestore.go +++ b/services/machine/service/firestore.go @@ -9,6 +9,22 @@ import ( "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 } @@ -21,28 +37,28 @@ func NewFirestoreClient(ctx context.Context, projectID string) (*FirestoreClient return &FirestoreClient{client: client}, nil } -func (c *FirestoreClient) CreateMachine(ctx context.Context, machineID string, machine *MachineRequest) error { - docRef := c.client.Collection("machines").Doc(machineID) +func (c *FirestoreClient) CreateMachine(ctx context.Context, req *CreateMachineRequest) (*CreateMachineResponse, error) { + docRef := c.client.Collection("machines").Doc(req.MachineID) data := map[string]interface{}{ - "id": machineID, - "cpus": machine.CPUs, - "memory_modules": machine.MemoryModules, - "accelerators": machine.Accelerators, - "nics": machine.NICs, - "drives": machine.Drives, + "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 fmt.Errorf("failed to create machine document: %w", err) + return nil, fmt.Errorf("failed to create machine document: %w", err) } - return nil + return &CreateMachineResponse{}, nil } -func (c *FirestoreClient) FindMachineByMAC(ctx context.Context, mac string) (string, bool, error) { - normalizedMAC := strings.ToLower(mac) +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}). @@ -52,10 +68,10 @@ func (c *FirestoreClient) FindMachineByMAC(ctx context.Context, mac string) (str doc, err := iter.Next() if err == iterator.Done { - return "", false, nil + return &FindMachineByMACResponse{Found: false}, nil } if err != nil { - return "", false, fmt.Errorf("failed to query machines by MAC: %w", err) + return nil, fmt.Errorf("failed to query machines by MAC: %w", err) } var data struct { @@ -63,16 +79,19 @@ func (c *FirestoreClient) FindMachineByMAC(ctx context.Context, mac string) (str NICs []NIC `firestore:"nics"` } if err := doc.DataTo(&data); err != nil { - return "", false, fmt.Errorf("failed to decode machine document: %w", err) + return nil, fmt.Errorf("failed to decode machine document: %w", err) } for _, nic := range data.NICs { - if strings.EqualFold(nic.MAC, mac) { - return data.ID, true, nil + if strings.EqualFold(nic.MAC, req.MAC) { + return &FindMachineByMACResponse{ + MachineID: data.ID, + Found: true, + }, nil } } - return "", false, nil + return &FindMachineByMACResponse{Found: false}, nil } func (c *FirestoreClient) Close() error { diff --git a/services/machine/service/firestore_test.go b/services/machine/service/firestore_test.go index 258ecf65..07857f83 100644 --- a/services/machine/service/firestore_test.go +++ b/services/machine/service/firestore_test.go @@ -12,25 +12,28 @@ type mockFirestoreClient struct { existingMacID string } -func (m *mockFirestoreClient) CreateMachine(ctx context.Context, machineID string, machine *MachineRequest) error { +func (m *mockFirestoreClient) CreateMachine(ctx context.Context, req *CreateMachineRequest) (*CreateMachineResponse, error) { if m.createErr != nil { - return m.createErr + return nil, m.createErr } if m.machines == nil { m.machines = make(map[string]*MachineRequest) } - m.machines[machineID] = machine - return nil + m.machines[req.MachineID] = req.Machine + return &CreateMachineResponse{}, nil } -func (m *mockFirestoreClient) FindMachineByMAC(ctx context.Context, mac string) (string, bool, error) { +func (m *mockFirestoreClient) FindMachineByMAC(ctx context.Context, req *FindMachineByMACRequest) (*FindMachineByMACResponse, error) { if m.findByMACErr != nil { - return "", false, m.findByMACErr + return nil, m.findByMACErr } if m.existingMacID != "" { - return m.existingMacID, true, nil + return &FindMachineByMACResponse{ + MachineID: m.existingMacID, + Found: true, + }, nil } - return "", false, nil + return &FindMachineByMACResponse{Found: false}, nil } func (m *mockFirestoreClient) Close() error { @@ -42,11 +45,14 @@ func TestMockClient(t *testing.T) { t.Run("CreateMachine success", func(t *testing.T) { mock := &mockFirestoreClient{} - req := &MachineRequest{ - NICs: []NIC{{MAC: "aa:bb:cc:dd:ee:ff"}}, + req := &CreateMachineRequest{ + MachineID: "test-id", + Machine: &MachineRequest{ + NICs: []NIC{{MAC: "aa:bb:cc:dd:ee:ff"}}, + }, } - err := mock.CreateMachine(ctx, "test-id", req) + _, err := mock.CreateMachine(ctx, req) if err != nil { t.Errorf("expected no error, got %v", err) } @@ -59,12 +65,14 @@ func TestMockClient(t *testing.T) { t.Run("FindMachineByMAC not found", func(t *testing.T) { mock := &mockFirestoreClient{} - id, found, err := mock.FindMachineByMAC(ctx, "aa:bb:cc:dd:ee:ff") + 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 found { - t.Errorf("expected not found, got found with ID %s", id) + if resp.Found { + t.Errorf("expected not found, got found with ID %s", resp.MachineID) } }) @@ -73,15 +81,17 @@ func TestMockClient(t *testing.T) { existingMacID: "existing-id", } - id, found, err := mock.FindMachineByMAC(ctx, "aa:bb:cc:dd:ee:ff") + 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 !found { + if !resp.Found { t.Error("expected found, got not found") } - if id != "existing-id" { - t.Errorf("expected ID 'existing-id', got '%s'", id) + if resp.MachineID != "existing-id" { + t.Errorf("expected ID 'existing-id', got '%s'", resp.MachineID) } }) }