From fbc428ea7751a2e0914ebe6f17a4bf7338d12d1f Mon Sep 17 00:00:00 2001 From: voidstarit Date: Fri, 6 Nov 2026 20:43:06 -0500 Subject: [PATCH 1/2] Add typed clients and generated OpenAPI spec Generate a merged OpenAPI 2.0 document from the proto HTTP annotations and add a client package that wraps the generated gRPC stubs and HTTP/JSON gateway. The new clients provide mTLS helpers, request-ID propagation, typed methods for every volume and admin RPC, and grpc-gateway error mapping. --- Makefile | 1 + README.md | 15 + buf.gen.yaml | 11 + client/context.go | 18 + client/doc.go | 12 + client/gateway_test.go | 99 ++ client/ginkgo_suite_test.go | 46 + client/grpc.go | 109 ++ client/grpc_test.go | 150 +++ client/http.go | 603 +++++++++++ client/http_test.go | 476 +++++++++ client/openapi_test.go | 67 ++ client/testutil_test.go | 78 ++ client/tls.go | 30 + client/tls_test.go | 41 + docs/clients.md | 133 +++ gen/openapi/openapi.swagger.json | 1686 ++++++++++++++++++++++++++++++ proto/openapi.yaml | 16 + 18 files changed, 3591 insertions(+) create mode 100644 client/context.go create mode 100644 client/doc.go create mode 100644 client/gateway_test.go create mode 100644 client/ginkgo_suite_test.go create mode 100644 client/grpc.go create mode 100644 client/grpc_test.go create mode 100644 client/http.go create mode 100644 client/http_test.go create mode 100644 client/openapi_test.go create mode 100644 client/testutil_test.go create mode 100644 client/tls.go create mode 100644 client/tls_test.go create mode 100644 docs/clients.md create mode 100644 gen/openapi/openapi.swagger.json create mode 100644 proto/openapi.yaml diff --git a/Makefile b/Makefile index deb4562..5e565d3 100644 --- a/Makefile +++ b/Makefile @@ -34,6 +34,7 @@ tools: $(GO) install google.golang.org/protobuf/cmd/protoc-gen-go@v1.36.0 $(GO) install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.5.1 $(GO) install github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-grpc-gateway@v2.24.0 + $(GO) install github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2@v2.24.0 $(GO) install sigs.k8s.io/controller-tools/cmd/controller-gen@v0.16.5 $(GO) install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.15.0 diff --git a/README.md b/README.md index d4bcae7..96fb0d6 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,21 @@ CGO_ENABLED=0 go build -trimpath -o bin/kouja-csi-node ./cmd/kouja-csi-node Protocol generation and CRD generation require the tools installed by `make tools`, followed by `make proto` and `make generate`. +## Client SDK + +Generated gRPC stubs and the HTTP/JSON gateway live in `gen/kouja/v1`. The +merged OpenAPI 2.0 document is generated at +`gen/openapi/openapi.swagger.json`. The `client` package wraps both transports +with mTLS helpers and typed methods for every RPC: + +```go +grpcClient, err := client.NewGRPCFromFiles(target, certFile, keyFile, caFile) +httpClient, err := client.NewHTTPFromFiles("https://"+target, certFile, keyFile, caFile) +``` + +See [Client SDK](docs/clients.md) for complete examples, request-ID +propagation, error handling, and regeneration instructions. + ## Install the agent ### 1. Prepare the host diff --git a/buf.gen.yaml b/buf.gen.yaml index bd7a16d..469220a 100644 --- a/buf.gen.yaml +++ b/buf.gen.yaml @@ -12,3 +12,14 @@ plugins: out: gen opt: - paths=source_relative + - name: openapiv2 + out: gen/openapi + opt: + - allow_merge=true + - merge_file_name=openapi + - json_names_for_fields=true + - proto3_optional_nullable=true + - preserve_rpc_order=true + - openapi_naming_strategy=simple + - simple_operation_ids=true + - openapi_configuration=proto/openapi.yaml diff --git a/client/context.go b/client/context.go new file mode 100644 index 0000000..79af5ec --- /dev/null +++ b/client/context.go @@ -0,0 +1,18 @@ +package client + +import "context" + +type requestIDKey struct{} + +// WithRequestID returns a context that propagates requestID to the agent. The +// gRPC client sends it as x-request-id metadata and the HTTP client sends it +// as the X-Request-Id header. +func WithRequestID(ctx context.Context, requestID string) context.Context { + return context.WithValue(ctx, requestIDKey{}, requestID) +} + +// RequestIDFromContext returns the request ID stored by [WithRequestID]. +func RequestIDFromContext(ctx context.Context) string { + requestID, _ := ctx.Value(requestIDKey{}).(string) + return requestID +} diff --git a/client/doc.go b/client/doc.go new file mode 100644 index 0000000..4712974 --- /dev/null +++ b/client/doc.go @@ -0,0 +1,12 @@ +// Package client provides typed gRPC and HTTP/JSON clients for the kouja +// agent API. +// +// The gRPC client wraps the generated stubs in gen/kouja/v1. The HTTP client +// speaks the grpc-gateway REST/JSON surface defined by the google.api.http +// annotations in proto/kouja/v1. Both transports use mTLS with a client +// certificate trusted by the agent. +// +// Use [NewGRPC] or [NewGRPCFromFiles] for gRPC, and [NewHTTP] or +// [NewHTTPFromFiles] for HTTP/JSON. The OpenAPI description is generated at +// gen/openapi/openapi.swagger.json. +package client diff --git a/client/gateway_test.go b/client/gateway_test.go new file mode 100644 index 0000000..852b337 --- /dev/null +++ b/client/gateway_test.go @@ -0,0 +1,99 @@ +package client + +import ( + "context" + "net" + "net/http/httptest" + "testing" + + "github.com/grpc-ecosystem/grpc-gateway/v2/runtime" + koujav1 "github.com/voidstarit/kouja/gen/kouja/v1" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/test/bufconn" +) + +type gatewayVolumeService struct { + koujav1.UnimplementedVolumeServiceServer + created *koujav1.CreateVolumeRequest + list *koujav1.ListVolumesRequest + deleted *koujav1.DeleteVolumeRequest + credential *koujav1.GetCredentialRequest +} + +func (service *gatewayVolumeService) CreateVolume(_ context.Context, request *koujav1.CreateVolumeRequest) (*koujav1.CreateVolumeResponse, error) { + service.created = request + return &koujav1.CreateVolumeResponse{Volume: &koujav1.Volume{VolumeId: request.GetVolumeId()}}, nil +} + +func (service *gatewayVolumeService) ListVolumes(_ context.Context, request *koujav1.ListVolumesRequest) (*koujav1.ListVolumesResponse, error) { + service.list = request + return &koujav1.ListVolumesResponse{}, nil +} + +func (service *gatewayVolumeService) DeleteVolume(_ context.Context, request *koujav1.DeleteVolumeRequest) (*koujav1.DeleteVolumeResponse, error) { + service.deleted = request + return &koujav1.DeleteVolumeResponse{}, nil +} + +func (service *gatewayVolumeService) GetCredential(_ context.Context, request *koujav1.GetCredentialRequest) (*koujav1.GetCredentialResponse, error) { + service.credential = request + return &koujav1.GetCredentialResponse{}, nil +} + +func bddTestHTTPClientGatewayCompatibility(t testing.TB) { + listener := bufconn.Listen(1 << 20) + server := grpc.NewServer() + service := &gatewayVolumeService{} + koujav1.RegisterVolumeServiceServer(server, service) + go func() { _ = server.Serve(listener) }() + t.Cleanup(server.Stop) + + conn, err := grpc.NewClient( + "passthrough:///bufnet", + grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) { + return listener.DialContext(ctx) + }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + if err != nil { + t.Fatalf("grpc.NewClient: %v", err) + } + t.Cleanup(func() { _ = conn.Close() }) + + mux := runtime.NewServeMux() + if err := koujav1.RegisterVolumeServiceHandlerClient(context.Background(), mux, koujav1.NewVolumeServiceClient(conn)); err != nil { + t.Fatalf("RegisterVolumeServiceHandlerClient: %v", err) + } + httpServer := httptest.NewServer(mux) + defer httpServer.Close() + + client, err := NewHTTP(httpServer.URL) + if err != nil { + t.Fatalf("NewHTTP: %v", err) + } + if _, err := client.CreateVolume(context.Background(), &koujav1.CreateVolumeRequest{VolumeId: "vol-1", SizeBytes: 1024, Vg: "vg-1"}); err != nil { + t.Fatalf("CreateVolume: %v", err) + } + if service.created.GetVolumeId() != "vol-1" || service.created.GetSizeBytes() != 1024 || service.created.GetVg() != "vg-1" { + t.Fatalf("CreateVolume request = %#v", service.created) + } + if _, err := client.ListVolumes(context.Background(), &koujav1.ListVolumesRequest{PageSize: 10, PageToken: "next"}); err != nil { + t.Fatalf("ListVolumes: %v", err) + } + if service.list.GetPageSize() != 10 || service.list.GetPageToken() != "next" { + t.Fatalf("ListVolumes request = %#v", service.list) + } + if _, err := client.DeleteVolume(context.Background(), &koujav1.DeleteVolumeRequest{VolumeId: "vol-1", Force: true, RequestId: "req-1"}); err != nil { + t.Fatalf("DeleteVolume: %v", err) + } + if service.deleted.GetVolumeId() != "vol-1" || !service.deleted.GetForce() || service.deleted.GetRequestId() != "req-1" { + t.Fatalf("DeleteVolume request = %#v", service.deleted) + } + if _, err := client.GetCredential(context.Background(), &koujav1.GetCredentialRequest{VolumeId: "vol-1", Owner: "alice"}); err != nil { + t.Fatalf("GetCredential: %v", err) + } + if service.credential.GetVolumeId() != "vol-1" || service.credential.GetOwner() != "alice" { + t.Fatalf("GetCredential request = %#v", service.credential) + } +} diff --git a/client/ginkgo_suite_test.go b/client/ginkgo_suite_test.go new file mode 100644 index 0000000..a8fc042 --- /dev/null +++ b/client/ginkgo_suite_test.go @@ -0,0 +1,46 @@ +package client + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + gomega "github.com/onsi/gomega" +) + +func TestGinkgo(t *testing.T) { + gomega.RegisterFailHandler(Fail) + RunSpecs(t, "client Suite") +} + +var _ = Describe("unit tests", func() { + It("TestLoadTLSConfig", func() { + bddTestLoadTLSConfig(GinkgoTB()) + }) + It("TestGRPCClient", func() { + bddTestGRPCClient(GinkgoTB()) + }) + It("TestGRPCRequestIDInterceptor", func() { + bddTestGRPCRequestIDInterceptor(GinkgoTB()) + }) + It("TestHTTPClientCoverage", func() { + bddTestHTTPClientCoverage(GinkgoTB()) + }) + It("TestHTTPClientGatewayCompatibility", func() { + bddTestHTTPClientGatewayCompatibility(GinkgoTB()) + }) + It("TestHTTPClientBasePathAndRequestID", func() { + bddTestHTTPClientBasePathAndRequestID(GinkgoTB()) + }) + It("TestHTTPClientResponseDecoding", func() { + bddTestHTTPClientResponseDecoding(GinkgoTB()) + }) + It("TestHTTPClientErrors", func() { + bddTestHTTPClientErrors(GinkgoTB()) + }) + It("TestHTTPClientValidation", func() { + bddTestHTTPClientValidation(GinkgoTB()) + }) + It("TestOpenAPISpec", func() { + bddTestOpenAPISpec(GinkgoTB()) + }) +}) diff --git a/client/grpc.go b/client/grpc.go new file mode 100644 index 0000000..63d6992 --- /dev/null +++ b/client/grpc.go @@ -0,0 +1,109 @@ +package client + +import ( + "context" + "crypto/tls" + "fmt" + "net/url" + "strings" + + koujav1 "github.com/voidstarit/kouja/gen/kouja/v1" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/metadata" +) + +// GRPC is a combined VolumeService and AdminService gRPC client. +type GRPC struct { + koujav1.VolumeServiceClient + koujav1.AdminServiceClient + conn *grpc.ClientConn +} + +// NewGRPC creates a gRPC client for target. The TLS configuration must contain +// a client certificate trusted by the agent and the CA used to verify the +// agent certificate. A target may include an https:// prefix; it is removed +// before the connection is created. +// +// NewGRPC installs a unary interceptor to propagate request IDs. Supply any +// additional interceptors with grpc.WithChainUnaryInterceptor rather than +// grpc.WithUnaryInterceptor. +func NewGRPC(target string, tlsConfig *tls.Config, opts ...grpc.DialOption) (*GRPC, error) { + if tlsConfig == nil { + return nil, fmt.Errorf("client: TLS configuration is required") + } + dialOptions := make([]grpc.DialOption, 0, len(opts)+2) + dialOptions = append(dialOptions, grpc.WithTransportCredentials(credentials.NewTLS(tlsConfig.Clone()))) + dialOptions = append(dialOptions, opts...) + dialOptions = append(dialOptions, grpc.WithChainUnaryInterceptor(requestIDUnaryInterceptor)) + return newGRPC(target, dialOptions...) +} + +// NewGRPCFromFiles creates a gRPC client using certificate files on disk. +func NewGRPCFromFiles(target, certFile, keyFile, caFile string, opts ...grpc.DialOption) (*GRPC, error) { + tlsConfig, err := LoadTLSConfig(certFile, keyFile, caFile) + if err != nil { + return nil, err + } + return NewGRPC(target, tlsConfig, opts...) +} + +func newGRPC(target string, opts ...grpc.DialOption) (*GRPC, error) { + target, err := normalizeGRPCTarget(target) + if err != nil { + return nil, err + } + conn, err := grpc.NewClient(target, opts...) + if err != nil { + return nil, fmt.Errorf("client: create gRPC client for %s: %w", target, err) + } + return &GRPC{ + VolumeServiceClient: koujav1.NewVolumeServiceClient(conn), + AdminServiceClient: koujav1.NewAdminServiceClient(conn), + conn: conn, + }, nil +} + +// Close closes the underlying gRPC connection. +func (client *GRPC) Close() error { + if client == nil || client.conn == nil { + return nil + } + return client.conn.Close() +} + +func normalizeGRPCTarget(target string) (string, error) { + target = strings.TrimSpace(target) + if target == "" { + return "", fmt.Errorf("client: gRPC target is required") + } + if strings.HasPrefix(target, "https://") || strings.HasPrefix(target, "http://") { + parsed, err := url.Parse(target) + if err != nil { + return "", fmt.Errorf("client: parse gRPC target: %w", err) + } + if parsed.Host == "" { + return "", fmt.Errorf("client: gRPC target must include a host: %q", target) + } + if parsed.Path != "" && parsed.Path != "/" { + return "", fmt.Errorf("client: gRPC target must not include a path: %q", target) + } + return parsed.Host, nil + } + return target, nil +} + +func requestIDUnaryInterceptor( + ctx context.Context, + method string, + request any, + reply any, + conn *grpc.ClientConn, + invoker grpc.UnaryInvoker, + opts ...grpc.CallOption, +) error { + if requestID := RequestIDFromContext(ctx); requestID != "" { + ctx = metadata.AppendToOutgoingContext(ctx, "x-request-id", requestID) + } + return invoker(ctx, method, request, reply, conn, opts...) +} diff --git a/client/grpc_test.go b/client/grpc_test.go new file mode 100644 index 0000000..094d764 --- /dev/null +++ b/client/grpc_test.go @@ -0,0 +1,150 @@ +package client + +import ( + "context" + "net" + "testing" + + koujav1 "github.com/voidstarit/kouja/gen/kouja/v1" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/test/bufconn" +) + +type testVolumeService struct { + koujav1.UnimplementedVolumeServiceServer +} + +func (service *testVolumeService) GetInfo(context.Context, *koujav1.GetInfoRequest) (*koujav1.GetInfoResponse, error) { + return &koujav1.GetInfoResponse{HostUuid: "host-1", AgentVersion: "test", Ready: true}, nil +} + +type testAdminService struct { + koujav1.UnimplementedAdminServiceServer +} + +func (service *testAdminService) ListVGs(context.Context, *koujav1.ListVGsRequest) (*koujav1.ListVGsResponse, error) { + return &koujav1.ListVGsResponse{Vgs: []*koujav1.Capacity{{VgName: "vg-1"}}}, nil +} + +func bddTestGRPCClient(t testing.TB) { + listener := bufconn.Listen(1 << 20) + server := grpc.NewServer() + koujav1.RegisterVolumeServiceServer(server, &testVolumeService{}) + koujav1.RegisterAdminServiceServer(server, &testAdminService{}) + go func() { _ = server.Serve(listener) }() + t.Cleanup(server.Stop) + + client, err := newGRPC( + "passthrough:///bufnet", + grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) { + return listener.DialContext(ctx) + }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + if err != nil { + t.Fatalf("newGRPC: %v", err) + } + defer func() { + if err := client.Close(); err != nil { + t.Errorf("Close: %v", err) + } + }() + + info, err := client.GetInfo(context.Background(), &koujav1.GetInfoRequest{}) + if err != nil { + t.Fatalf("GetInfo: %v", err) + } + if info.GetHostUuid() != "host-1" || !info.GetReady() { + t.Fatalf("GetInfo = %#v", info) + } + vgs, err := client.ListVGs(context.Background(), &koujav1.ListVGsRequest{}) + if err != nil { + t.Fatalf("ListVGs: %v", err) + } + if len(vgs.GetVgs()) != 1 || vgs.GetVgs()[0].GetVgName() != "vg-1" { + t.Fatalf("ListVGs = %#v", vgs) + } + + if _, err := NewGRPC("localhost:9443", nil); err == nil { + t.Fatal("NewGRPC accepted a nil TLS configuration") + } + if _, err := NewGRPCFromFiles("localhost:9443", "missing.crt", "missing.key", "missing-ca.crt"); err == nil { + t.Fatal("NewGRPCFromFiles accepted missing files") + } + var nilClient *GRPC + if err := nilClient.Close(); err != nil { + t.Fatalf("nil Close: %v", err) + } + + targets := []struct { + input string + want string + ok bool + }{ + {input: "https://storage.example:9443", want: "storage.example:9443", ok: true}, + {input: "http://storage.example:9443", want: "storage.example:9443", ok: true}, + {input: "storage.example:9443", want: "storage.example:9443", ok: true}, + {input: "dns:///storage.example:9443", want: "dns:///storage.example:9443", ok: true}, + {input: "", ok: false}, + {input: "https://", ok: false}, + {input: "https://storage.example:9443/path", ok: false}, + } + for _, target := range targets { + got, err := normalizeGRPCTarget(target.input) + if target.ok && (err != nil || got != target.want) { + t.Fatalf("normalizeGRPCTarget(%q) = %q, %v; want %q", target.input, got, err, target.want) + } + if !target.ok && err == nil { + t.Fatalf("normalizeGRPCTarget(%q) succeeded", target.input) + } + } +} + +func bddTestGRPCRequestIDInterceptor(t testing.TB) { + ctx := WithRequestID(context.Background(), "req-1") + if got := RequestIDFromContext(ctx); got != "req-1" { + t.Fatalf("RequestIDFromContext = %q", got) + } + if got := RequestIDFromContext(context.Background()); got != "" { + t.Fatalf("empty RequestIDFromContext = %q", got) + } + + var outgoing metadata.MD + err := requestIDUnaryInterceptor( + ctx, + "/kouja.v1.VolumeService/GetInfo", + nil, + nil, + nil, + func(ctx context.Context, _ string, _, _ any, _ *grpc.ClientConn, _ ...grpc.CallOption) error { + outgoing, _ = metadata.FromOutgoingContext(ctx) + return nil + }, + ) + if err != nil { + t.Fatalf("requestIDUnaryInterceptor: %v", err) + } + if got := outgoing.Get("x-request-id"); len(got) != 1 || got[0] != "req-1" { + t.Fatalf("x-request-id metadata = %v", got) + } + + outgoing = nil + if err := requestIDUnaryInterceptor( + context.Background(), + "/kouja.v1.VolumeService/GetInfo", + nil, + nil, + nil, + func(ctx context.Context, _ string, _, _ any, _ *grpc.ClientConn, _ ...grpc.CallOption) error { + outgoing, _ = metadata.FromOutgoingContext(ctx) + return nil + }, + ); err != nil { + t.Fatalf("requestIDUnaryInterceptor without ID: %v", err) + } + if got := outgoing.Get("x-request-id"); len(got) != 0 { + t.Fatalf("unexpected x-request-id metadata = %v", got) + } +} diff --git a/client/http.go b/client/http.go new file mode 100644 index 0000000..61efb83 --- /dev/null +++ b/client/http.go @@ -0,0 +1,603 @@ +package client + +import ( + "bytes" + "context" + "crypto/tls" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + + koujav1 "github.com/voidstarit/kouja/gen/kouja/v1" + statuspb "google.golang.org/genproto/googleapis/rpc/status" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/anypb" +) + +const maxErrorBodyBytes = 1 << 20 + +// HTTP is a typed HTTP/JSON client for the kouja REST API. It implements the +// same operations as the gRPC services. +type HTTP struct { + baseURL string + client *http.Client +} + +type httpOptions struct { + client *http.Client +} + +// HTTPOption configures an HTTP client. +type HTTPOption func(*httpOptions) error + +// WithHTTPClient configures the underlying HTTP client. Use this to provide a +// custom transport, timeout, or instrumentation. +func WithHTTPClient(httpClient *http.Client) HTTPOption { + return func(options *httpOptions) error { + if httpClient == nil { + return fmt.Errorf("client: HTTP client is nil") + } + options.client = httpClient + return nil + } +} + +// WithTLSConfig configures an mTLS HTTP transport. The TLS configuration must +// contain a client certificate trusted by the agent and the CA used to verify +// the agent certificate. +func WithTLSConfig(tlsConfig *tls.Config) HTTPOption { + return func(options *httpOptions) error { + if tlsConfig == nil { + return fmt.Errorf("client: TLS configuration is nil") + } + transport, ok := http.DefaultTransport.(*http.Transport) + if !ok { + return fmt.Errorf("client: default HTTP transport is not *http.Transport") + } + cloned := transport.Clone() + cloned.TLSClientConfig = tlsConfig.Clone() + options.client = &http.Client{Transport: cloned} + return nil + } +} + +// NewHTTP creates an HTTP/JSON client for baseURL, which may include a path +// prefix. Use [WithTLSConfig] for mTLS, or pass a prepared client with +// [WithHTTPClient]. +func NewHTTP(baseURL string, opts ...HTTPOption) (*HTTP, error) { + normalized, err := normalizeBaseURL(baseURL) + if err != nil { + return nil, err + } + options := &httpOptions{client: http.DefaultClient} + for _, option := range opts { + if option == nil { + continue + } + if err := option(options); err != nil { + return nil, err + } + } + if options.client == nil { + return nil, fmt.Errorf("client: HTTP client is nil") + } + return &HTTP{baseURL: normalized, client: options.client}, nil +} + +// NewHTTPFromFiles creates an HTTP/JSON client using certificate files on +// disk. +func NewHTTPFromFiles(baseURL, certFile, keyFile, caFile string, opts ...HTTPOption) (*HTTP, error) { + tlsConfig, err := LoadTLSConfig(certFile, keyFile, caFile) + if err != nil { + return nil, err + } + options := make([]HTTPOption, 0, len(opts)+1) + options = append(options, WithTLSConfig(tlsConfig)) + options = append(options, opts...) + return NewHTTP(baseURL, options...) +} + +// CloseIdleConnections closes idle connections held by the underlying HTTP +// client. +func (client *HTTP) CloseIdleConnections() { + if client != nil && client.client != nil { + client.client.CloseIdleConnections() + } +} + +// CreateVolume creates a volume in a registered volume group. +func (client *HTTP) CreateVolume(ctx context.Context, request *koujav1.CreateVolumeRequest) (*koujav1.CreateVolumeResponse, error) { + if request == nil { + request = &koujav1.CreateVolumeRequest{} + } + response := &koujav1.CreateVolumeResponse{} + if err := client.do(ctx, http.MethodPost, "/v1/volumes", nil, request, response); err != nil { + return nil, err + } + return response, nil +} + +// ExportVolume exports a volume to one initiator. +func (client *HTTP) ExportVolume(ctx context.Context, request *koujav1.ExportVolumeRequest) (*koujav1.ExportVolumeResponse, error) { + if request == nil { + request = &koujav1.ExportVolumeRequest{} + } + volumeID, err := pathParam("volume_id", request.GetVolumeId()) + if err != nil { + return nil, err + } + response := &koujav1.ExportVolumeResponse{} + if err := client.do(ctx, http.MethodPost, "/v1/volumes/"+volumeID+":export", nil, request, response); err != nil { + return nil, err + } + return response, nil +} + +// UnexportVolume removes a volume export. +func (client *HTTP) UnexportVolume(ctx context.Context, request *koujav1.UnexportVolumeRequest) (*koujav1.UnexportVolumeResponse, error) { + if request == nil { + request = &koujav1.UnexportVolumeRequest{} + } + volumeID, err := pathParam("volume_id", request.GetVolumeId()) + if err != nil { + return nil, err + } + response := &koujav1.UnexportVolumeResponse{} + if err := client.do(ctx, http.MethodPost, "/v1/volumes/"+volumeID+":unexport", nil, request, response); err != nil { + return nil, err + } + return response, nil +} + +// ResizeVolume grows or shrinks a volume. +func (client *HTTP) ResizeVolume(ctx context.Context, request *koujav1.ResizeVolumeRequest) (*koujav1.ResizeVolumeResponse, error) { + if request == nil { + request = &koujav1.ResizeVolumeRequest{} + } + volumeID, err := pathParam("volume_id", request.GetVolumeId()) + if err != nil { + return nil, err + } + response := &koujav1.ResizeVolumeResponse{} + if err := client.do(ctx, http.MethodPost, "/v1/volumes/"+volumeID+":resize", nil, request, response); err != nil { + return nil, err + } + return response, nil +} + +// DeleteVolume deletes a volume. +func (client *HTTP) DeleteVolume(ctx context.Context, request *koujav1.DeleteVolumeRequest) (*koujav1.DeleteVolumeResponse, error) { + if request == nil { + request = &koujav1.DeleteVolumeRequest{} + } + volumeID, err := pathParam("volume_id", request.GetVolumeId()) + if err != nil { + return nil, err + } + query := url.Values{} + addBool(query, "force", request.GetForce()) + addString(query, "requestId", request.GetRequestId()) + response := &koujav1.DeleteVolumeResponse{} + if err := client.do(ctx, http.MethodDelete, "/v1/volumes/"+volumeID, query, nil, response); err != nil { + return nil, err + } + return response, nil +} + +// GetVolume returns the desired and observed state of a volume. +func (client *HTTP) GetVolume(ctx context.Context, request *koujav1.GetVolumeRequest) (*koujav1.GetVolumeResponse, error) { + if request == nil { + request = &koujav1.GetVolumeRequest{} + } + volumeID, err := pathParam("volume_id", request.GetVolumeId()) + if err != nil { + return nil, err + } + response := &koujav1.GetVolumeResponse{} + if err := client.do(ctx, http.MethodGet, "/v1/volumes/"+volumeID, nil, nil, response); err != nil { + return nil, err + } + return response, nil +} + +// ListVolumes lists volumes visible to the caller. +func (client *HTTP) ListVolumes(ctx context.Context, request *koujav1.ListVolumesRequest) (*koujav1.ListVolumesResponse, error) { + if request == nil { + request = &koujav1.ListVolumesRequest{} + } + query := url.Values{} + addInt32(query, "pageSize", request.GetPageSize()) + addString(query, "pageToken", request.GetPageToken()) + response := &koujav1.ListVolumesResponse{} + if err := client.do(ctx, http.MethodGet, "/v1/volumes", query, nil, response); err != nil { + return nil, err + } + return response, nil +} + +// GetCredential returns the export credential for a volume. +func (client *HTTP) GetCredential(ctx context.Context, request *koujav1.GetCredentialRequest) (*koujav1.GetCredentialResponse, error) { + if request == nil { + request = &koujav1.GetCredentialRequest{} + } + volumeID, err := pathParam("volume_id", request.GetVolumeId()) + if err != nil { + return nil, err + } + query := url.Values{} + addString(query, "owner", request.GetOwner()) + response := &koujav1.GetCredentialResponse{} + if err := client.do(ctx, http.MethodGet, "/v1/volumes/"+volumeID+"/credential", query, nil, response); err != nil { + return nil, err + } + return response, nil +} + +// RotateCredential rotates the export credential for a volume. +func (client *HTTP) RotateCredential(ctx context.Context, request *koujav1.RotateCredentialRequest) (*koujav1.RotateCredentialResponse, error) { + if request == nil { + request = &koujav1.RotateCredentialRequest{} + } + volumeID, err := pathParam("volume_id", request.GetVolumeId()) + if err != nil { + return nil, err + } + response := &koujav1.RotateCredentialResponse{} + if err := client.do(ctx, http.MethodPost, "/v1/volumes/"+volumeID+":rotate-credential", nil, request, response); err != nil { + return nil, err + } + return response, nil +} + +// GetCapacity returns capacity for every registered volume group. +func (client *HTTP) GetCapacity(ctx context.Context, _ *koujav1.GetCapacityRequest) (*koujav1.GetCapacityResponse, error) { + response := &koujav1.GetCapacityResponse{} + if err := client.do(ctx, http.MethodGet, "/v1/capacity", nil, nil, response); err != nil { + return nil, err + } + return response, nil +} + +// ListStorageClasses returns the storage classes configured on the agent. +func (client *HTTP) ListStorageClasses(ctx context.Context, _ *koujav1.ListStorageClassesRequest) (*koujav1.ListStorageClassesResponse, error) { + response := &koujav1.ListStorageClassesResponse{} + if err := client.do(ctx, http.MethodGet, "/v1/storage-classes", nil, nil, response); err != nil { + return nil, err + } + return response, nil +} + +// GetInfo returns host identity and API capability information. +func (client *HTTP) GetInfo(ctx context.Context, _ *koujav1.GetInfoRequest) (*koujav1.GetInfoResponse, error) { + response := &koujav1.GetInfoResponse{} + if err := client.do(ctx, http.MethodGet, "/v1/info", nil, nil, response); err != nil { + return nil, err + } + return response, nil +} + +// RegisterVG registers a volume group with the agent. +func (client *HTTP) RegisterVG(ctx context.Context, request *koujav1.RegisterVGRequest) (*koujav1.RegisterVGResponse, error) { + if request == nil { + request = &koujav1.RegisterVGRequest{} + } + response := &koujav1.RegisterVGResponse{} + if err := client.do(ctx, http.MethodPost, "/v1/admin/vgs", nil, request, response); err != nil { + return nil, err + } + return response, nil +} + +// DeregisterVG deregisters a volume group. +func (client *HTTP) DeregisterVG(ctx context.Context, request *koujav1.DeregisterVGRequest) (*koujav1.DeregisterVGResponse, error) { + if request == nil { + request = &koujav1.DeregisterVGRequest{} + } + vgName, err := pathParam("vg_name", request.GetVgName()) + if err != nil { + return nil, err + } + query := url.Values{} + addString(query, "requestId", request.GetRequestId()) + response := &koujav1.DeregisterVGResponse{} + if err := client.do(ctx, http.MethodDelete, "/v1/admin/vgs/"+vgName, query, nil, response); err != nil { + return nil, err + } + return response, nil +} + +// ListVGs lists every registered volume group. +func (client *HTTP) ListVGs(ctx context.Context, _ *koujav1.ListVGsRequest) (*koujav1.ListVGsResponse, error) { + response := &koujav1.ListVGsResponse{} + if err := client.do(ctx, http.MethodGet, "/v1/admin/vgs", nil, nil, response); err != nil { + return nil, err + } + return response, nil +} + +// ImportVolume imports an existing logical volume and optional export. +func (client *HTTP) ImportVolume(ctx context.Context, request *koujav1.ImportVolumeRequest) (*koujav1.ImportVolumeResponse, error) { + if request == nil { + request = &koujav1.ImportVolumeRequest{} + } + response := &koujav1.ImportVolumeResponse{} + if err := client.do(ctx, http.MethodPost, "/v1/admin/import", nil, request, response); err != nil { + return nil, err + } + return response, nil +} + +// ResolveDrift applies an administrator-selected drift action. +func (client *HTTP) ResolveDrift(ctx context.Context, request *koujav1.ResolveDriftRequest) (*koujav1.ResolveDriftResponse, error) { + if request == nil { + request = &koujav1.ResolveDriftRequest{} + } + volumeID, err := pathParam("volume_id", request.GetVolumeId()) + if err != nil { + return nil, err + } + response := &koujav1.ResolveDriftResponse{} + if err := client.do(ctx, http.MethodPost, "/v1/admin/drift/"+volumeID+":resolve", nil, request, response); err != nil { + return nil, err + } + return response, nil +} + +// ListOrphans lists volumes that have no matching export or host state. +func (client *HTTP) ListOrphans(ctx context.Context, _ *koujav1.ListOrphansRequest) (*koujav1.ListOrphansResponse, error) { + response := &koujav1.ListOrphansResponse{} + if err := client.do(ctx, http.MethodGet, "/v1/admin/orphans", nil, nil, response); err != nil { + return nil, err + } + return response, nil +} + +// WipeVolume destroys the data on a volume. +func (client *HTTP) WipeVolume(ctx context.Context, request *koujav1.WipeVolumeRequest) (*koujav1.WipeVolumeResponse, error) { + if request == nil { + request = &koujav1.WipeVolumeRequest{} + } + volumeID, err := pathParam("volume_id", request.GetVolumeId()) + if err != nil { + return nil, err + } + response := &koujav1.WipeVolumeResponse{} + if err := client.do(ctx, http.MethodPost, "/v1/admin/volumes/"+volumeID+":wipe", nil, request, response); err != nil { + return nil, err + } + return response, nil +} + +// TransferOwnership transfers a volume to a new owner. +func (client *HTTP) TransferOwnership(ctx context.Context, request *koujav1.TransferOwnershipRequest) (*koujav1.TransferOwnershipResponse, error) { + if request == nil { + request = &koujav1.TransferOwnershipRequest{} + } + response := &koujav1.TransferOwnershipResponse{} + if err := client.do(ctx, http.MethodPost, "/v1/admin/ownership:transfer", nil, request, response); err != nil { + return nil, err + } + return response, nil +} + +// AdminDeleteVolume deletes a volume as an administrator. +func (client *HTTP) AdminDeleteVolume(ctx context.Context, request *koujav1.AdminDeleteVolumeRequest) (*koujav1.AdminDeleteVolumeResponse, error) { + if request == nil { + request = &koujav1.AdminDeleteVolumeRequest{} + } + volumeID, err := pathParam("volume_id", request.GetVolumeId()) + if err != nil { + return nil, err + } + query := url.Values{} + addBool(query, "acknowledgeDataLoss", request.GetAcknowledgeDataLoss()) + addString(query, "owner", request.GetOwner()) + addString(query, "requestId", request.GetRequestId()) + response := &koujav1.AdminDeleteVolumeResponse{} + if err := client.do(ctx, http.MethodDelete, "/v1/admin/volumes/"+volumeID, query, nil, response); err != nil { + return nil, err + } + return response, nil +} + +// GrantCredential grants a node access to a volume credential. +func (client *HTTP) GrantCredential(ctx context.Context, request *koujav1.GrantCredentialRequest) (*koujav1.GrantCredentialResponse, error) { + if request == nil { + request = &koujav1.GrantCredentialRequest{} + } + response := &koujav1.GrantCredentialResponse{} + if err := client.do(ctx, http.MethodPost, "/v1/admin/grants", nil, request, response); err != nil { + return nil, err + } + return response, nil +} + +// RevokeGrant revokes a node credential grant. +func (client *HTTP) RevokeGrant(ctx context.Context, request *koujav1.RevokeGrantRequest) (*koujav1.RevokeGrantResponse, error) { + if request == nil { + request = &koujav1.RevokeGrantRequest{} + } + volumeID, err := pathParam("volume_id", request.GetVolumeId()) + if err != nil { + return nil, err + } + nodeID, err := pathParam("node_id", request.GetNodeId()) + if err != nil { + return nil, err + } + query := url.Values{} + addString(query, "owner", request.GetOwner()) + addString(query, "requestId", request.GetRequestId()) + response := &koujav1.RevokeGrantResponse{} + if err := client.do(ctx, http.MethodDelete, "/v1/admin/grants/"+volumeID+"/"+nodeID, query, nil, response); err != nil { + return nil, err + } + return response, nil +} + +// Error is returned when the agent responds with a non-2xx HTTP status. The +// code and message come from the grpc-gateway google.rpc.Status error body when +// one is present. +type Error struct { + HTTPStatus int + Code codes.Code + Message string + Details []*anypb.Any +} + +// Error implements error. +func (err *Error) Error() string { + if err == nil { + return "client: " + } + if err.HTTPStatus != 0 { + statusText := http.StatusText(err.HTTPStatus) + if statusText == "" { + statusText = "HTTP error" + } + if err.Message == "" { + return fmt.Sprintf("client: HTTP %d %s", err.HTTPStatus, statusText) + } + return fmt.Sprintf("client: HTTP %d %s: %s", err.HTTPStatus, statusText, err.Message) + } + if err.Message == "" { + return fmt.Sprintf("client: %s", err.Code) + } + return fmt.Sprintf("client: %s: %s", err.Code, err.Message) +} + +// GRPCStatus lets grpc/status translate an HTTP error into a gRPC status. +func (err *Error) GRPCStatus() *status.Status { + if err == nil { + return status.New(codes.Unknown, "") + } + code := err.Code + if code == codes.OK { + code = codes.Unknown + } + return status.New(code, err.Message) +} + +func (client *HTTP) do(ctx context.Context, method, path string, query url.Values, request, response proto.Message) error { + var body io.Reader + if request != nil { + payload, err := protojson.MarshalOptions{UseProtoNames: false}.Marshal(request) + if err != nil { + return fmt.Errorf("client: marshal %s request: %w", path, err) + } + body = bytes.NewReader(payload) + } + requestURL := client.baseURL + path + if len(query) != 0 { + requestURL += "?" + query.Encode() + } + httpRequest, err := http.NewRequestWithContext(ctx, method, requestURL, body) + if err != nil { + return fmt.Errorf("client: create %s request: %w", path, err) + } + httpRequest.Header.Set("Accept", "application/json") + if request != nil { + httpRequest.Header.Set("Content-Type", "application/json") + } + if requestID := RequestIDFromContext(ctx); requestID != "" { + httpRequest.Header.Set("X-Request-Id", requestID) + } + httpResponse, err := client.client.Do(httpRequest) + if err != nil { + return fmt.Errorf("client: %s %s: %w", method, requestURL, err) + } + defer func() { _ = httpResponse.Body.Close() }() + if httpResponse.StatusCode < http.StatusOK || httpResponse.StatusCode >= http.StatusMultipleChoices { + return decodeHTTPError(httpResponse) + } + if response == nil || httpResponse.StatusCode == http.StatusNoContent { + _, _ = io.Copy(io.Discard, httpResponse.Body) + return nil + } + payload, err := io.ReadAll(httpResponse.Body) + if err != nil { + return fmt.Errorf("client: read %s response: %w", path, err) + } + if len(bytes.TrimSpace(payload)) == 0 { + return fmt.Errorf("client: %s returned an empty response", path) + } + if err := (protojson.UnmarshalOptions{DiscardUnknown: true}).Unmarshal(payload, response); err != nil { + return fmt.Errorf("client: decode %s response: %w", path, err) + } + return nil +} + +func decodeHTTPError(response *http.Response) error { + result := &Error{HTTPStatus: response.StatusCode, Code: codes.Unknown} + payload, err := io.ReadAll(io.LimitReader(response.Body, maxErrorBodyBytes)) + if err != nil { + result.Message = response.Status + return result + } + var protoStatus statuspb.Status + if err := (protojson.UnmarshalOptions{DiscardUnknown: true}).Unmarshal(payload, &protoStatus); err == nil && + (protoStatus.GetCode() != 0 || protoStatus.GetMessage() != "") { + result.Code = codes.Code(protoStatus.GetCode()) //nolint:gosec // google.rpc.Code values are non-negative + result.Message = protoStatus.GetMessage() + result.Details = protoStatus.GetDetails() + } else { + result.Message = strings.TrimSpace(string(payload)) + } + if result.Message == "" { + result.Message = response.Status + } + return result +} + +func normalizeBaseURL(raw string) (string, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", fmt.Errorf("client: base URL is required") + } + parsed, err := url.Parse(raw) + if err != nil { + return "", fmt.Errorf("client: parse base URL: %w", err) + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return "", fmt.Errorf("client: base URL must use http or https: %q", raw) + } + if parsed.Host == "" { + return "", fmt.Errorf("client: base URL must include a host: %q", raw) + } + if parsed.RawQuery != "" || parsed.Fragment != "" { + return "", fmt.Errorf("client: base URL must not include a query or fragment: %q", raw) + } + parsed.Path = strings.TrimRight(parsed.Path, "/") + return parsed.String(), nil +} + +func pathParam(name, value string) (string, error) { + if value == "" { + return "", fmt.Errorf("client: %s is required", name) + } + return url.PathEscape(value), nil +} + +func addString(values url.Values, name, value string) { + if value != "" { + values.Set(name, value) + } +} + +func addBool(values url.Values, name string, value bool) { + if value { + values.Set(name, "true") + } +} + +func addInt32(values url.Values, name string, value int32) { + if value != 0 { + values.Set(name, strconv.FormatInt(int64(value), 10)) + } +} diff --git a/client/http_test.go b/client/http_test.go new file mode 100644 index 0000000..3affc24 --- /dev/null +++ b/client/http_test.go @@ -0,0 +1,476 @@ +package client + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "net/url" + "reflect" + "strings" + "testing" + + koujav1 "github.com/voidstarit/kouja/gen/kouja/v1" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +type recordedHTTPRequest struct { + method string + path string + query url.Values + header http.Header + body []byte +} + +type httpCall struct { + name string + method string + path string + query url.Values + wantBody map[string]any + invoke func(context.Context, *HTTP) error +} + +func bddTestHTTPClientCoverage(t testing.TB) { + calls := []httpCall{ + { + name: "CreateVolume", method: http.MethodPost, path: "/v1/volumes", + wantBody: map[string]any{"volumeId": "vol-1", "sizeBytes": "1024", "vg": "vg-1"}, + invoke: func(ctx context.Context, client *HTTP) error { + _, err := client.CreateVolume(ctx, &koujav1.CreateVolumeRequest{VolumeId: "vol-1", SizeBytes: 1024, Vg: "vg-1"}) + return err + }, + }, + { + name: "ExportVolume", method: http.MethodPost, path: "/v1/volumes/vol-1:export", + wantBody: map[string]any{"initiator": "iqn.test", "protocol": "PROTOCOL_ISCSI"}, + invoke: func(ctx context.Context, client *HTTP) error { + _, err := client.ExportVolume(ctx, &koujav1.ExportVolumeRequest{VolumeId: "vol-1", Initiator: "iqn.test", Protocol: koujav1.Protocol_PROTOCOL_ISCSI}) + return err + }, + }, + { + name: "UnexportVolume", method: http.MethodPost, path: "/v1/volumes/vol-1:unexport", + wantBody: map[string]any{"force": true}, + invoke: func(ctx context.Context, client *HTTP) error { + _, err := client.UnexportVolume(ctx, &koujav1.UnexportVolumeRequest{VolumeId: "vol-1", Force: true}) + return err + }, + }, + { + name: "ResizeVolume", method: http.MethodPost, path: "/v1/volumes/vol-1:resize", + wantBody: map[string]any{"sizeBytes": "2048", "allowShrink": true}, + invoke: func(ctx context.Context, client *HTTP) error { + _, err := client.ResizeVolume(ctx, &koujav1.ResizeVolumeRequest{VolumeId: "vol-1", SizeBytes: 2048, AllowShrink: true}) + return err + }, + }, + { + name: "DeleteVolume", method: http.MethodDelete, path: "/v1/volumes/vol-1", + query: url.Values{"force": {"true"}, "requestId": {"req-1"}}, + invoke: func(ctx context.Context, client *HTTP) error { + _, err := client.DeleteVolume(ctx, &koujav1.DeleteVolumeRequest{VolumeId: "vol-1", Force: true, RequestId: "req-1"}) + return err + }, + }, + { + name: "GetVolume", method: http.MethodGet, path: "/v1/volumes/vol-1", + invoke: func(ctx context.Context, client *HTTP) error { + _, err := client.GetVolume(ctx, &koujav1.GetVolumeRequest{VolumeId: "vol-1"}) + return err + }, + }, + { + name: "ListVolumes", method: http.MethodGet, path: "/v1/volumes", + query: url.Values{"pageSize": {"10"}, "pageToken": {"next"}}, + invoke: func(ctx context.Context, client *HTTP) error { + _, err := client.ListVolumes(ctx, &koujav1.ListVolumesRequest{PageSize: 10, PageToken: "next"}) + return err + }, + }, + { + name: "GetCredential", method: http.MethodGet, path: "/v1/volumes/vol-1/credential", + query: url.Values{"owner": {"alice"}}, + invoke: func(ctx context.Context, client *HTTP) error { + _, err := client.GetCredential(ctx, &koujav1.GetCredentialRequest{VolumeId: "vol-1", Owner: "alice"}) + return err + }, + }, + { + name: "RotateCredential", method: http.MethodPost, path: "/v1/volumes/vol-1:rotate-credential", + wantBody: map[string]any{"requestId": "req-1"}, + invoke: func(ctx context.Context, client *HTTP) error { + _, err := client.RotateCredential(ctx, &koujav1.RotateCredentialRequest{VolumeId: "vol-1", RequestId: "req-1"}) + return err + }, + }, + { + name: "GetCapacity", method: http.MethodGet, path: "/v1/capacity", + invoke: func(ctx context.Context, client *HTTP) error { + _, err := client.GetCapacity(ctx, &koujav1.GetCapacityRequest{}) + return err + }, + }, + { + name: "ListStorageClasses", method: http.MethodGet, path: "/v1/storage-classes", + invoke: func(ctx context.Context, client *HTTP) error { + _, err := client.ListStorageClasses(ctx, &koujav1.ListStorageClassesRequest{}) + return err + }, + }, + { + name: "GetInfo", method: http.MethodGet, path: "/v1/info", + invoke: func(ctx context.Context, client *HTTP) error { + _, err := client.GetInfo(ctx, &koujav1.GetInfoRequest{}) + return err + }, + }, + { + name: "RegisterVG", method: http.MethodPost, path: "/v1/admin/vgs", + wantBody: map[string]any{"vgName": "vg-1"}, + invoke: func(ctx context.Context, client *HTTP) error { + _, err := client.RegisterVG(ctx, &koujav1.RegisterVGRequest{VgName: "vg-1"}) + return err + }, + }, + { + name: "DeregisterVG", method: http.MethodDelete, path: "/v1/admin/vgs/vg-1", + query: url.Values{"requestId": {"req-1"}}, + invoke: func(ctx context.Context, client *HTTP) error { + _, err := client.DeregisterVG(ctx, &koujav1.DeregisterVGRequest{VgName: "vg-1", RequestId: "req-1"}) + return err + }, + }, + { + name: "ListVGs", method: http.MethodGet, path: "/v1/admin/vgs", + invoke: func(ctx context.Context, client *HTTP) error { + _, err := client.ListVGs(ctx, &koujav1.ListVGsRequest{}) + return err + }, + }, + { + name: "ImportVolume", method: http.MethodPost, path: "/v1/admin/import", + wantBody: map[string]any{"volumeId": "vol-1", "lvName": "lv-1"}, + invoke: func(ctx context.Context, client *HTTP) error { + _, err := client.ImportVolume(ctx, &koujav1.ImportVolumeRequest{VolumeId: "vol-1", LvName: "lv-1"}) + return err + }, + }, + { + name: "ResolveDrift", method: http.MethodPost, path: "/v1/admin/drift/vol-1:resolve", + wantBody: map[string]any{"action": "RESOLVE_ACTION_RETRY", "reason": "test"}, + invoke: func(ctx context.Context, client *HTTP) error { + _, err := client.ResolveDrift(ctx, &koujav1.ResolveDriftRequest{VolumeId: "vol-1", Action: koujav1.ResolveAction_RESOLVE_ACTION_RETRY, Reason: "test"}) + return err + }, + }, + { + name: "ListOrphans", method: http.MethodGet, path: "/v1/admin/orphans", + invoke: func(ctx context.Context, client *HTTP) error { + _, err := client.ListOrphans(ctx, &koujav1.ListOrphansRequest{}) + return err + }, + }, + { + name: "WipeVolume", method: http.MethodPost, path: "/v1/admin/volumes/vol-1:wipe", + wantBody: map[string]any{"acknowledgeDataLoss": true}, + invoke: func(ctx context.Context, client *HTTP) error { + _, err := client.WipeVolume(ctx, &koujav1.WipeVolumeRequest{VolumeId: "vol-1", AcknowledgeDataLoss: true}) + return err + }, + }, + { + name: "TransferOwnership", method: http.MethodPost, path: "/v1/admin/ownership:transfer", + wantBody: map[string]any{"newOwner": "bob"}, + invoke: func(ctx context.Context, client *HTTP) error { + _, err := client.TransferOwnership(ctx, &koujav1.TransferOwnershipRequest{VolumeId: "vol-1", NewOwner: "bob"}) + return err + }, + }, + { + name: "AdminDeleteVolume", method: http.MethodDelete, path: "/v1/admin/volumes/vol-1", + query: url.Values{"acknowledgeDataLoss": {"true"}, "owner": {"alice"}, "requestId": {"req-1"}}, + invoke: func(ctx context.Context, client *HTTP) error { + _, err := client.AdminDeleteVolume(ctx, &koujav1.AdminDeleteVolumeRequest{VolumeId: "vol-1", AcknowledgeDataLoss: true, Owner: "alice", RequestId: "req-1"}) + return err + }, + }, + { + name: "GrantCredential", method: http.MethodPost, path: "/v1/admin/grants", + wantBody: map[string]any{"nodeId": "node-1", "nodeCallerId": "caller-1"}, + invoke: func(ctx context.Context, client *HTTP) error { + _, err := client.GrantCredential(ctx, &koujav1.GrantCredentialRequest{VolumeId: "vol-1", NodeId: "node-1", NodeCallerId: "caller-1"}) + return err + }, + }, + { + name: "RevokeGrant", method: http.MethodDelete, path: "/v1/admin/grants/vol-1/node-1", + query: url.Values{"owner": {"alice"}, "requestId": {"req-1"}}, + invoke: func(ctx context.Context, client *HTTP) error { + _, err := client.RevokeGrant(ctx, &koujav1.RevokeGrantRequest{VolumeId: "vol-1", NodeId: "node-1", Owner: "alice", RequestId: "req-1"}) + return err + }, + }, + } + for _, call := range calls { + runHTTPCall(t, call) + } +} + +func runHTTPCall(t testing.TB, call httpCall) { + t.Helper() + var got recordedHTTPRequest + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + body, _ := io.ReadAll(request.Body) + got = recordedHTTPRequest{ + method: request.Method, + path: request.URL.Path, + query: request.URL.Query(), + header: request.Header.Clone(), + body: body, + } + response.Header().Set("Content-Type", "application/json") + _, _ = response.Write([]byte("{}")) + })) + defer server.Close() + + client, err := NewHTTP(server.URL) + if err != nil { + t.Fatalf("%s: NewHTTP: %v", call.name, err) + } + if err := call.invoke(context.Background(), client); err != nil { + t.Fatalf("%s: %v", call.name, err) + } + if got.method != call.method { + t.Fatalf("%s: method = %s, want %s", call.name, got.method, call.method) + } + if got.path != call.path { + t.Fatalf("%s: path = %s, want %s", call.name, got.path, call.path) + } + expectedQuery := call.query + if expectedQuery == nil { + expectedQuery = url.Values{} + } + if !reflect.DeepEqual(got.query, expectedQuery) { + t.Fatalf("%s: query = %v, want %v", call.name, got.query, expectedQuery) + } + if call.wantBody == nil { + if len(got.body) != 0 { + t.Fatalf("%s: unexpected body %q", call.name, got.body) + } + return + } + if got.header.Get("Content-Type") != "application/json" { + t.Fatalf("%s: Content-Type = %q", call.name, got.header.Get("Content-Type")) + } + var body map[string]any + if err := json.Unmarshal(got.body, &body); err != nil { + t.Fatalf("%s: decode body: %v", call.name, err) + } + for key, want := range call.wantBody { + if body[key] != want { + t.Fatalf("%s: body[%q] = %#v, want %#v (body %s)", call.name, key, body[key], want, got.body) + } + } +} + +func bddTestHTTPClientBasePathAndRequestID(t testing.TB) { + var gotPath, gotRequestID string + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + gotPath = request.URL.Path + gotRequestID = request.Header.Get("X-Request-Id") + response.Header().Set("Content-Type", "application/json") + _, _ = response.Write([]byte(`{"hostUuid":"host-1","ready":true}`)) + })) + defer server.Close() + + client, err := NewHTTP(server.URL + "/api/") + if err != nil { + t.Fatalf("NewHTTP: %v", err) + } + info, err := client.GetInfo(WithRequestID(context.Background(), "req-1"), &koujav1.GetInfoRequest{}) + if err != nil { + t.Fatalf("GetInfo: %v", err) + } + if gotPath != "/api/v1/info" { + t.Fatalf("path = %q, want /api/v1/info", gotPath) + } + if gotRequestID != "req-1" { + t.Fatalf("X-Request-Id = %q, want req-1", gotRequestID) + } + if info.GetHostUuid() != "host-1" || !info.GetReady() { + t.Fatalf("GetInfo = %#v", info) + } +} + +func bddTestHTTPClientResponseDecoding(t testing.TB) { + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) { + response.Header().Set("Content-Type", "application/json") + _, _ = response.Write([]byte(`{ + "desired": { + "volumeId": "vol-1", + "state": "VOLUME_STATE_AVAILABLE", + "requestedSizeBytes": "1024" + }, + "driftReasons": ["DRIFT_REASON_SIZE_MISMATCH"], + "operationId": "op-1" + }`)) + })) + defer server.Close() + + client, err := NewHTTP(server.URL) + if err != nil { + t.Fatalf("NewHTTP: %v", err) + } + response, err := client.GetVolume(context.Background(), &koujav1.GetVolumeRequest{VolumeId: "vol-1"}) + if err != nil { + t.Fatalf("GetVolume: %v", err) + } + if response.GetDesired().GetVolumeId() != "vol-1" { + t.Fatalf("volume id = %q", response.GetDesired().GetVolumeId()) + } + if response.GetDesired().GetState() != koujav1.VolumeState_VOLUME_STATE_AVAILABLE { + t.Fatalf("state = %v", response.GetDesired().GetState()) + } + if response.GetDesired().GetRequestedSizeBytes() != 1024 { + t.Fatalf("size = %d", response.GetDesired().GetRequestedSizeBytes()) + } + if len(response.GetDriftReasons()) != 1 || response.GetDriftReasons()[0] != koujav1.DriftReason_DRIFT_REASON_SIZE_MISMATCH { + t.Fatalf("drift reasons = %v", response.GetDriftReasons()) + } + if response.GetOperationId() != "op-1" { + t.Fatalf("operation id = %q", response.GetOperationId()) + } +} + +func bddTestHTTPClientErrors(t testing.TB) { + statusServer := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) { + response.Header().Set("Content-Type", "application/json") + response.WriteHeader(http.StatusNotFound) + _, _ = response.Write([]byte(`{"code":5,"message":"volume not found"}`)) + })) + defer statusServer.Close() + client, err := NewHTTP(statusServer.URL) + if err != nil { + t.Fatalf("NewHTTP: %v", err) + } + _, err = client.GetVolume(context.Background(), &koujav1.GetVolumeRequest{VolumeId: "missing"}) + var httpErr *Error + if !errors.As(err, &httpErr) { + t.Fatalf("error = %v, want *Error", err) + } + if httpErr.HTTPStatus != http.StatusNotFound { + t.Fatalf("HTTPStatus = %d", httpErr.HTTPStatus) + } + if httpErr.Code != codes.NotFound { + t.Fatalf("Code = %v", httpErr.Code) + } + if httpErr.Message != "volume not found" { + t.Fatalf("Message = %q", httpErr.Message) + } + if status.Code(err) != codes.NotFound { + t.Fatalf("status.Code = %v", status.Code(err)) + } + if !strings.Contains(err.Error(), "volume not found") { + t.Fatalf("Error() = %q", err.Error()) + } + + textServer := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) { + response.Header().Set("Content-Type", "text/plain") + response.WriteHeader(http.StatusBadGateway) + _, _ = response.Write([]byte("bad gateway")) + })) + defer textServer.Close() + textClient, err := NewHTTP(textServer.URL) + if err != nil { + t.Fatalf("NewHTTP: %v", err) + } + _, err = textClient.GetInfo(context.Background(), &koujav1.GetInfoRequest{}) + if !errors.As(err, &httpErr) { + t.Fatalf("text error = %v, want *Error", err) + } + if httpErr.HTTPStatus != http.StatusBadGateway || httpErr.Code != codes.Unknown || httpErr.Message != "bad gateway" { + t.Fatalf("text error = %#v", httpErr) + } + if status.Code(err) != codes.Unknown { + t.Fatalf("status.Code = %v", status.Code(err)) + } + + emptyServer := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) { + response.WriteHeader(http.StatusOK) + })) + defer emptyServer.Close() + emptyClient, err := NewHTTP(emptyServer.URL) + if err != nil { + t.Fatalf("NewHTTP: %v", err) + } + if _, err := emptyClient.GetInfo(context.Background(), &koujav1.GetInfoRequest{}); err == nil || !strings.Contains(err.Error(), "empty response") { + t.Fatalf("empty response error = %v", err) + } + + noContentServer := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) { + response.WriteHeader(http.StatusNoContent) + })) + defer noContentServer.Close() + noContentClient, err := NewHTTP(noContentServer.URL) + if err != nil { + t.Fatalf("NewHTTP: %v", err) + } + if _, err := noContentClient.GetInfo(context.Background(), &koujav1.GetInfoRequest{}); err != nil { + t.Fatalf("204 response: %v", err) + } + + closedServer := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + closedURL := closedServer.URL + closedServer.Close() + closedClient, err := NewHTTP(closedURL) + if err != nil { + t.Fatalf("NewHTTP: %v", err) + } + _, err = closedClient.GetInfo(context.Background(), &koujav1.GetInfoRequest{}) + if err == nil { + t.Fatal("transport error was ignored") + } + if errors.As(err, &httpErr) { + t.Fatalf("transport error was reported as *Error: %v", err) + } +} + +func bddTestHTTPClientValidation(t testing.TB) { + for _, baseURL := range []string{"", "ftp://host", "https://", "http://host?query=1", "http://host#fragment"} { + if _, err := NewHTTP(baseURL); err == nil { + t.Fatalf("NewHTTP(%q) succeeded", baseURL) + } + } + server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + defer server.Close() + if _, err := NewHTTP(server.URL, WithHTTPClient(nil)); err == nil { + t.Fatal("WithHTTPClient(nil) succeeded") + } + if _, err := NewHTTP(server.URL, WithTLSConfig(nil)); err == nil { + t.Fatal("WithTLSConfig(nil) succeeded") + } + certFile, keyFile, caFile := writeTestCertificates(t) + if _, err := NewHTTPFromFiles(server.URL, certFile, keyFile, caFile); err != nil { + t.Fatalf("NewHTTPFromFiles: %v", err) + } + if _, err := NewHTTPFromFiles(server.URL, "missing.crt", "missing.key", "missing-ca.crt"); err == nil { + t.Fatal("NewHTTPFromFiles accepted missing files") + } + client, err := NewHTTP(server.URL, nil) + if err != nil { + t.Fatalf("NewHTTP with nil option: %v", err) + } + if _, err := client.DeleteVolume(context.Background(), &koujav1.DeleteVolumeRequest{}); err == nil { + t.Fatal("DeleteVolume accepted an empty volume ID") + } + if _, err := client.RevokeGrant(context.Background(), &koujav1.RevokeGrantRequest{VolumeId: "vol-1"}); err == nil { + t.Fatal("RevokeGrant accepted an empty node ID") + } + client.CloseIdleConnections() + var nilClient *HTTP + nilClient.CloseIdleConnections() +} diff --git a/client/openapi_test.go b/client/openapi_test.go new file mode 100644 index 0000000..975057d --- /dev/null +++ b/client/openapi_test.go @@ -0,0 +1,67 @@ +package client + +import ( + "encoding/json" + "os" + "reflect" + "testing" +) + +func bddTestOpenAPISpec(t testing.TB) { + payload, err := os.ReadFile("../gen/openapi/openapi.swagger.json") + if err != nil { + t.Fatalf("read OpenAPI spec: %v", err) + } + var spec struct { + Swagger string `json:"swagger"` + Info struct { + Title string `json:"title"` + Version string `json:"version"` + } `json:"info"` + Paths map[string]map[string]struct { + OperationID string `json:"operationId"` + } `json:"paths"` + } + if err := json.Unmarshal(payload, &spec); err != nil { + t.Fatalf("decode OpenAPI spec: %v", err) + } + if spec.Swagger != "2.0" { + t.Fatalf("swagger = %q, want 2.0", spec.Swagger) + } + if spec.Info.Title != "kouja API" || spec.Info.Version != "v1" { + t.Fatalf("info = %#v", spec.Info) + } + + actual := make(map[string]map[string]string, len(spec.Paths)) + for path, operations := range spec.Paths { + actual[path] = make(map[string]string, len(operations)) + for method, operation := range operations { + actual[path][method] = operation.OperationID + } + } + expected := map[string]map[string]string{ + "/v1/admin/drift/{volumeId}:resolve": {"post": "ResolveDrift"}, + "/v1/admin/grants": {"post": "GrantCredential"}, + "/v1/admin/grants/{volumeId}/{nodeId}": {"delete": "RevokeGrant"}, + "/v1/admin/import": {"post": "ImportVolume"}, + "/v1/admin/orphans": {"get": "ListOrphans"}, + "/v1/admin/ownership:transfer": {"post": "TransferOwnership"}, + "/v1/admin/vgs": {"get": "ListVGs", "post": "RegisterVG"}, + "/v1/admin/vgs/{vgName}": {"delete": "DeregisterVG"}, + "/v1/admin/volumes/{volumeId}": {"delete": "AdminDeleteVolume"}, + "/v1/admin/volumes/{volumeId}:wipe": {"post": "WipeVolume"}, + "/v1/capacity": {"get": "GetCapacity"}, + "/v1/info": {"get": "GetInfo"}, + "/v1/storage-classes": {"get": "ListStorageClasses"}, + "/v1/volumes": {"get": "ListVolumes", "post": "CreateVolume"}, + "/v1/volumes/{volumeId}": {"delete": "DeleteVolume", "get": "GetVolume"}, + "/v1/volumes/{volumeId}/credential": {"get": "GetCredential"}, + "/v1/volumes/{volumeId}:export": {"post": "ExportVolume"}, + "/v1/volumes/{volumeId}:resize": {"post": "ResizeVolume"}, + "/v1/volumes/{volumeId}:rotate-credential": {"post": "RotateCredential"}, + "/v1/volumes/{volumeId}:unexport": {"post": "UnexportVolume"}, + } + if !reflect.DeepEqual(actual, expected) { + t.Fatalf("OpenAPI operations = %#v, want %#v", actual, expected) + } +} diff --git a/client/testutil_test.go b/client/testutil_test.go new file mode 100644 index 0000000..7a86442 --- /dev/null +++ b/client/testutil_test.go @@ -0,0 +1,78 @@ +package client + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "os" + "path/filepath" + "testing" + "time" +) + +func writeTestCertificates(t testing.TB) (certFile, keyFile, caFile string) { + t.Helper() + dir := t.TempDir() + + caKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + now := time.Now() + caTemplate := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "test-ca"}, + NotBefore: now.Add(-time.Hour), + NotAfter: now.Add(time.Hour), + IsCA: true, + BasicConstraintsValid: true, + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature, + } + caDER, err := x509.CreateCertificate(rand.Reader, caTemplate, caTemplate, &caKey.PublicKey, caKey) + if err != nil { + t.Fatal(err) + } + caCert, err := x509.ParseCertificate(caDER) + if err != nil { + t.Fatal(err) + } + caFile = filepath.Join(dir, "ca.crt") + writeTestPEM(t, caFile, "CERTIFICATE", caDER) + + clientKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + clientTemplate := &x509.Certificate{ + SerialNumber: big.NewInt(2), + Subject: pkix.Name{CommonName: "client"}, + NotBefore: now.Add(-time.Hour), + NotAfter: now.Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, + } + clientDER, err := x509.CreateCertificate(rand.Reader, clientTemplate, caCert, &clientKey.PublicKey, caKey) + if err != nil { + t.Fatal(err) + } + certFile = filepath.Join(dir, "client.crt") + keyFile = filepath.Join(dir, "client.key") + writeTestPEM(t, certFile, "CERTIFICATE", clientDER) + writeTestPEM(t, keyFile, "RSA PRIVATE KEY", x509.MarshalPKCS1PrivateKey(clientKey)) + return certFile, keyFile, caFile +} + +func writeTestPEM(t testing.TB, path, blockType string, data []byte) { + t.Helper() + file, err := os.Create(path) //nolint:gosec // temporary test certificate path + if err != nil { + t.Fatal(err) + } + defer func() { _ = file.Close() }() + if err := pem.Encode(file, &pem.Block{Type: blockType, Bytes: data}); err != nil { + t.Fatal(err) + } +} diff --git a/client/tls.go b/client/tls.go new file mode 100644 index 0000000..31ea7bb --- /dev/null +++ b/client/tls.go @@ -0,0 +1,30 @@ +package client + +import ( + "crypto/tls" + "crypto/x509" + "fmt" + "os" +) + +// LoadTLSConfig loads an mTLS client configuration from a certificate, a +// private key, and the CA used to verify the agent certificate. +func LoadTLSConfig(certFile, keyFile, caFile string) (*tls.Config, error) { + certificate, err := tls.LoadX509KeyPair(certFile, keyFile) + if err != nil { + return nil, fmt.Errorf("client: load certificate: %w", err) + } + caPEM, err := os.ReadFile(caFile) //nolint:gosec // operator-selected CA path + if err != nil { + return nil, fmt.Errorf("client: read CA: %w", err) + } + roots := x509.NewCertPool() + if !roots.AppendCertsFromPEM(caPEM) { + return nil, fmt.Errorf("client: no certificates found in %s", caFile) + } + return &tls.Config{ //nolint:gosec // kouja requires TLS 1.3 and hostname verification + MinVersion: tls.VersionTLS13, + Certificates: []tls.Certificate{certificate}, + RootCAs: roots, + }, nil +} diff --git a/client/tls_test.go b/client/tls_test.go new file mode 100644 index 0000000..2b5fb4a --- /dev/null +++ b/client/tls_test.go @@ -0,0 +1,41 @@ +package client + +import ( + "crypto/tls" + "os" + "path/filepath" + "testing" +) + +func bddTestLoadTLSConfig(t testing.TB) { + certFile, keyFile, caFile := writeTestCertificates(t) + config, err := LoadTLSConfig(certFile, keyFile, caFile) + if err != nil { + t.Fatalf("LoadTLSConfig: %v", err) + } + if config.MinVersion != tls.VersionTLS13 { + t.Fatalf("MinVersion = %x, want TLS 1.3", config.MinVersion) + } + if len(config.Certificates) != 1 { + t.Fatalf("Certificates = %d, want 1", len(config.Certificates)) + } + if config.RootCAs == nil { + t.Fatal("RootCAs is nil") + } + + missing := filepath.Join(t.TempDir(), "missing.crt") + if _, err := LoadTLSConfig(missing, keyFile, caFile); err == nil { + t.Fatal("LoadTLSConfig accepted a missing certificate") + } + if _, err := LoadTLSConfig(certFile, missing, caFile); err == nil { + t.Fatal("LoadTLSConfig accepted a missing key") + } + + badCA := filepath.Join(t.TempDir(), "bad-ca.crt") + if err := os.WriteFile(badCA, []byte("not a certificate"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := LoadTLSConfig(certFile, keyFile, badCA); err == nil { + t.Fatal("LoadTLSConfig accepted a CA file without certificates") + } +} diff --git a/docs/clients.md b/docs/clients.md new file mode 100644 index 0000000..342c674 --- /dev/null +++ b/docs/clients.md @@ -0,0 +1,133 @@ +# Client SDK + +The repository ships a Go client for both agent transports: + +| Artifact | Path | Generated | +|---|---|---| +| gRPC stubs and clients | `gen/kouja/v1/*.pb.go`, `*_grpc.pb.go` | `buf generate` | +| HTTP/JSON gateway | `gen/kouja/v1/*.pb.gw.go` | `buf generate` | +| OpenAPI 2.0 document | `gen/openapi/openapi.swagger.json` | `buf generate` | +| Typed gRPC and HTTP wrappers | `client/` | Hand maintained | + +The wrappers use the same protobuf request and response messages as the +generated stubs, so callers can switch transports without converting models. + +## gRPC + +```go +package main + +import ( + "context" + "log" + + "github.com/voidstarit/kouja/client" + koujav1 "github.com/voidstarit/kouja/gen/kouja/v1" +) + +func main() { + conn, err := client.NewGRPCFromFiles( + "storage.example:9443", + "/etc/kouja/tls/client.crt", + "/etc/kouja/tls/client.key", + "/etc/kouja/tls/ca.crt", + ) + if err != nil { + log.Fatal(err) + } + defer conn.Close() + + ctx := context.Background() + info, err := conn.GetInfo(ctx, &koujav1.GetInfoRequest{}) + if err != nil { + log.Fatal(err) + } + log.Printf("host=%s version=%s ready=%t", info.GetHostUuid(), info.GetAgentVersion(), info.GetReady()) +} +``` + +`NewGRPC` accepts a prepared `*tls.Config`; `NewGRPCFromFiles` loads the +certificate, key, and CA from disk. The target may be a plain +`host:port`, `https://host:port`, or a gRPC resolver target such as +`dns:///host:port`. + +## HTTP/JSON + +```go +package main + +import ( + "context" + "log" + + "github.com/voidstarit/kouja/client" + koujav1 "github.com/voidstarit/kouja/gen/kouja/v1" + "google.golang.org/protobuf/proto" +) + +func main() { + httpClient, err := client.NewHTTPFromFiles( + "https://storage.example:9443", + "/etc/kouja/tls/client.crt", + "/etc/kouja/tls/client.key", + "/etc/kouja/tls/ca.crt", + ) + if err != nil { + log.Fatal(err) + } + defer httpClient.CloseIdleConnections() + + volume, err := httpClient.CreateVolume(context.Background(), &koujav1.CreateVolumeRequest{ + VolumeId: "example-1", + SizeBytes: 1 << 30, + Vg: "kouja", + Export: proto.Bool(true), + Protocol: koujav1.Protocol_PROTOCOL_ISCSI, + Initiator: "iqn.2026-11.io.example:consumer", + }) + if err != nil { + log.Fatal(err) + } + log.Printf("state=%s target=%s", volume.GetVolume().GetState(), volume.GetExport().GetTargetName()) +} +``` + +The HTTP client implements every RPC in `volume.proto` and `admin.proto`, +including the admin operations. Requests and responses are encoded with +`protojson`, matching the agent's grpc-gateway surface. + +Use `client.WithHTTPClient` to supply a custom `*http.Client` for timeouts, +proxies, tracing, or tests. Use `client.WithTLSConfig` to attach an mTLS +transport to the default client. + +## Request IDs and errors + +`client.WithRequestID` stores a correlation ID in the context. The gRPC client +sends it as `x-request-id` metadata and the HTTP client sends it as the +`X-Request-Id` header, matching the agent's request-ID propagation. + +HTTP errors are returned as `*client.Error`. It exposes the HTTP status, the +gRPC code, the message, and any `google.rpc.Status` details. It also implements +`GRPCStatus()`, so `status.Code` and `status.Convert` work across both +transports: + +```go +_, err := httpClient.GetVolume(ctx, &koujav1.GetVolumeRequest{VolumeId: "missing"}) +if status.Code(err) == codes.NotFound { + // handle a missing volume +} +``` + +## Regenerating clients and the OpenAPI document + +Install the pinned tools once, then regenerate: + +```sh +make tools +make proto +``` + +`make proto` runs `buf lint` and `buf generate`. It writes the Go gRPC and +gateway code under `gen/kouja/v1` and merges the OpenAPI document into +`gen/openapi/openapi.swagger.json`. The OpenAPI title, description, and scheme +come from `proto/openapi.yaml`. diff --git a/gen/openapi/openapi.swagger.json b/gen/openapi/openapi.swagger.json new file mode 100644 index 0000000..0a796f4 --- /dev/null +++ b/gen/openapi/openapi.swagger.json @@ -0,0 +1,1686 @@ +{ + "swagger": "2.0", + "info": { + "title": "kouja API", + "description": "Per-host block storage agent API. All endpoints require a client certificate trusted by the agent.", + "version": "v1" + }, + "tags": [ + { + "name": "AdminService" + }, + { + "name": "VolumeService" + } + ], + "schemes": [ + "https" + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "paths": { + "/v1/admin/vgs": { + "get": { + "operationId": "ListVGs", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/ListVGsResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/Status" + } + } + }, + "tags": [ + "AdminService" + ] + }, + "post": { + "operationId": "RegisterVG", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/RegisterVGResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/Status" + } + } + }, + "parameters": [ + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/RegisterVGRequest" + } + } + ], + "tags": [ + "AdminService" + ] + } + }, + "/v1/admin/vgs/{vgName}": { + "delete": { + "operationId": "DeregisterVG", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/DeregisterVGResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/Status" + } + } + }, + "parameters": [ + { + "name": "vgName", + "in": "path", + "required": true, + "type": "string" + }, + { + "name": "requestId", + "in": "query", + "required": false, + "type": "string" + } + ], + "tags": [ + "AdminService" + ] + } + }, + "/v1/admin/import": { + "post": { + "operationId": "ImportVolume", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/ImportVolumeResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/Status" + } + } + }, + "parameters": [ + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/ImportVolumeRequest" + } + } + ], + "tags": [ + "AdminService" + ] + } + }, + "/v1/admin/drift/{volumeId}:resolve": { + "post": { + "operationId": "ResolveDrift", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/ResolveDriftResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/Status" + } + } + }, + "parameters": [ + { + "name": "volumeId", + "in": "path", + "required": true, + "type": "string" + }, + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/ResolveDriftBody" + } + } + ], + "tags": [ + "AdminService" + ] + } + }, + "/v1/admin/orphans": { + "get": { + "operationId": "ListOrphans", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/ListOrphansResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/Status" + } + } + }, + "tags": [ + "AdminService" + ] + } + }, + "/v1/admin/volumes/{volumeId}:wipe": { + "post": { + "operationId": "WipeVolume", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/WipeVolumeResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/Status" + } + } + }, + "parameters": [ + { + "name": "volumeId", + "in": "path", + "required": true, + "type": "string" + }, + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/WipeVolumeBody" + } + } + ], + "tags": [ + "AdminService" + ] + } + }, + "/v1/admin/ownership:transfer": { + "post": { + "operationId": "TransferOwnership", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/TransferOwnershipResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/Status" + } + } + }, + "parameters": [ + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/TransferOwnershipRequest" + } + } + ], + "tags": [ + "AdminService" + ] + } + }, + "/v1/admin/volumes/{volumeId}": { + "delete": { + "operationId": "AdminDeleteVolume", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/AdminDeleteVolumeResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/Status" + } + } + }, + "parameters": [ + { + "name": "volumeId", + "in": "path", + "required": true, + "type": "string" + }, + { + "name": "acknowledgeDataLoss", + "in": "query", + "required": false, + "type": "boolean" + }, + { + "name": "owner", + "in": "query", + "required": false, + "type": "string" + }, + { + "name": "requestId", + "in": "query", + "required": false, + "type": "string" + } + ], + "tags": [ + "AdminService" + ] + } + }, + "/v1/admin/grants": { + "post": { + "operationId": "GrantCredential", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/GrantCredentialResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/Status" + } + } + }, + "parameters": [ + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/GrantCredentialRequest" + } + } + ], + "tags": [ + "AdminService" + ] + } + }, + "/v1/admin/grants/{volumeId}/{nodeId}": { + "delete": { + "operationId": "RevokeGrant", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/RevokeGrantResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/Status" + } + } + }, + "parameters": [ + { + "name": "volumeId", + "in": "path", + "required": true, + "type": "string" + }, + { + "name": "nodeId", + "in": "path", + "required": true, + "type": "string" + }, + { + "name": "owner", + "in": "query", + "required": false, + "type": "string" + }, + { + "name": "requestId", + "in": "query", + "required": false, + "type": "string" + } + ], + "tags": [ + "AdminService" + ] + } + }, + "/v1/volumes": { + "get": { + "operationId": "ListVolumes", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/ListVolumesResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/Status" + } + } + }, + "parameters": [ + { + "name": "pageSize", + "in": "query", + "required": false, + "type": "integer", + "format": "int32" + }, + { + "name": "pageToken", + "in": "query", + "required": false, + "type": "string" + } + ], + "tags": [ + "VolumeService" + ] + }, + "post": { + "operationId": "CreateVolume", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/CreateVolumeResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/Status" + } + } + }, + "parameters": [ + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/CreateVolumeRequest" + } + } + ], + "tags": [ + "VolumeService" + ] + } + }, + "/v1/volumes/{volumeId}:export": { + "post": { + "operationId": "ExportVolume", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/ExportVolumeResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/Status" + } + } + }, + "parameters": [ + { + "name": "volumeId", + "in": "path", + "required": true, + "type": "string" + }, + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/ExportVolumeBody" + } + } + ], + "tags": [ + "VolumeService" + ] + } + }, + "/v1/volumes/{volumeId}:unexport": { + "post": { + "operationId": "UnexportVolume", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/UnexportVolumeResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/Status" + } + } + }, + "parameters": [ + { + "name": "volumeId", + "in": "path", + "required": true, + "type": "string" + }, + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/UnexportVolumeBody" + } + } + ], + "tags": [ + "VolumeService" + ] + } + }, + "/v1/volumes/{volumeId}:resize": { + "post": { + "operationId": "ResizeVolume", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/ResizeVolumeResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/Status" + } + } + }, + "parameters": [ + { + "name": "volumeId", + "in": "path", + "required": true, + "type": "string" + }, + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/ResizeVolumeBody" + } + } + ], + "tags": [ + "VolumeService" + ] + } + }, + "/v1/volumes/{volumeId}": { + "get": { + "operationId": "GetVolume", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/GetVolumeResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/Status" + } + } + }, + "parameters": [ + { + "name": "volumeId", + "in": "path", + "required": true, + "type": "string" + } + ], + "tags": [ + "VolumeService" + ] + }, + "delete": { + "operationId": "DeleteVolume", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/DeleteVolumeResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/Status" + } + } + }, + "parameters": [ + { + "name": "volumeId", + "in": "path", + "required": true, + "type": "string" + }, + { + "name": "force", + "in": "query", + "required": false, + "type": "boolean" + }, + { + "name": "requestId", + "in": "query", + "required": false, + "type": "string" + } + ], + "tags": [ + "VolumeService" + ] + } + }, + "/v1/volumes/{volumeId}/credential": { + "get": { + "operationId": "GetCredential", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/GetCredentialResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/Status" + } + } + }, + "parameters": [ + { + "name": "volumeId", + "in": "path", + "required": true, + "type": "string" + }, + { + "name": "owner", + "in": "query", + "required": false, + "type": "string" + } + ], + "tags": [ + "VolumeService" + ] + } + }, + "/v1/volumes/{volumeId}:rotate-credential": { + "post": { + "operationId": "RotateCredential", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/RotateCredentialResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/Status" + } + } + }, + "parameters": [ + { + "name": "volumeId", + "in": "path", + "required": true, + "type": "string" + }, + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/RotateCredentialBody" + } + } + ], + "tags": [ + "VolumeService" + ] + } + }, + "/v1/capacity": { + "get": { + "operationId": "GetCapacity", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/GetCapacityResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/Status" + } + } + }, + "tags": [ + "VolumeService" + ] + } + }, + "/v1/storage-classes": { + "get": { + "operationId": "ListStorageClasses", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/ListStorageClassesResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/Status" + } + } + }, + "tags": [ + "VolumeService" + ] + } + }, + "/v1/info": { + "get": { + "operationId": "GetInfo", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/GetInfoResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/Status" + } + } + }, + "tags": [ + "VolumeService" + ] + } + } + }, + "definitions": { + "AdminDeleteVolumeResponse": { + "type": "object", + "properties": { + "operationId": { + "type": "string" + }, + "requestId": { + "type": "string" + } + } + }, + "Any": { + "type": "object", + "properties": { + "@type": { + "type": "string" + } + }, + "additionalProperties": {} + }, + "Capacity": { + "type": "object", + "properties": { + "vgName": { + "type": "string" + }, + "vgUuid": { + "type": "string" + }, + "vgState": { + "type": "string" + }, + "totalBytes": { + "type": "string", + "format": "int64" + }, + "freeBytes": { + "type": "string", + "format": "int64" + }, + "allocatableBytes": { + "type": "string", + "format": "int64" + }, + "extentSizeBytes": { + "type": "string", + "format": "int64" + }, + "maxVolumeBytes": { + "type": "string", + "format": "int64" + }, + "observedAt": { + "type": "string", + "format": "date-time" + }, + "storageClass": { + "type": "string", + "description": "Storage class configured for this volume group, or the VG name when no\nexplicit class mapping exists." + } + } + }, + "CreateVolumeRequest": { + "type": "object", + "properties": { + "volumeId": { + "type": "string" + }, + "sizeBytes": { + "type": "string", + "format": "int64" + }, + "vg": { + "type": "string" + }, + "export": { + "type": "boolean", + "x-nullable": true + }, + "protocol": { + "$ref": "#/definitions/Protocol" + }, + "initiator": { + "type": "string" + }, + "wipe": { + "type": "boolean" + }, + "requestId": { + "type": "string" + }, + "includeCredential": { + "type": "boolean", + "x-nullable": true + }, + "storageClass": { + "type": "string", + "description": "Optional host storage class. When set, the agent resolves it to the\nconfigured LVM volume group. vg remains supported for compatibility." + } + } + }, + "CreateVolumeResponse": { + "type": "object", + "properties": { + "volume": { + "$ref": "#/definitions/Volume" + }, + "export": { + "$ref": "#/definitions/Export" + }, + "operationId": { + "type": "string" + }, + "requestId": { + "type": "string" + } + } + }, + "DeleteVolumeResponse": { + "type": "object", + "properties": { + "operationId": { + "type": "string" + }, + "requestId": { + "type": "string" + } + } + }, + "DeregisterVGResponse": { + "type": "object", + "properties": { + "requestId": { + "type": "string" + } + } + }, + "DriftReason": { + "type": "string", + "enum": [ + "DRIFT_REASON_UNSPECIFIED", + "DRIFT_REASON_LV_ABSENT", + "DRIFT_REASON_SIZE_MISMATCH", + "DRIFT_REASON_TARGET_ABSENT", + "DRIFT_REASON_NAMESPACE_ABSENT", + "DRIFT_REASON_ACL_ABSENT", + "DRIFT_REASON_INITIATOR_MISMATCH", + "DRIFT_REASON_UNATTRIBUTED_SESSION" + ], + "default": "DRIFT_REASON_UNSPECIFIED" + }, + "Export": { + "type": "object", + "properties": { + "protocol": { + "$ref": "#/definitions/Protocol" + }, + "initiator": { + "type": "string" + }, + "targetName": { + "type": "string" + }, + "lun": { + "type": "integer", + "format": "int32" + }, + "nsid": { + "type": "integer", + "format": "int32" + }, + "portal": { + "type": "string" + }, + "state": { + "$ref": "#/definitions/ExportState" + }, + "sessionState": { + "$ref": "#/definitions/SessionState" + }, + "credential": { + "type": "string", + "title": "owner-only; omitted from list responses" + } + } + }, + "ExportState": { + "type": "string", + "enum": [ + "EXPORT_STATE_UNSPECIFIED", + "EXPORT_STATE_EXPORTING", + "EXPORT_STATE_EXPORTED", + "EXPORT_STATE_UNEXPORTING", + "EXPORT_STATE_UNEXPORTED", + "EXPORT_STATE_FAILED", + "EXPORT_STATE_DRIFT" + ], + "default": "EXPORT_STATE_UNSPECIFIED" + }, + "ExportVolumeBody": { + "type": "object", + "properties": { + "protocol": { + "$ref": "#/definitions/Protocol" + }, + "initiator": { + "type": "string" + }, + "requestId": { + "type": "string" + }, + "rotateCredential": { + "type": "boolean", + "description": "Rotate the credential and re-create the target on this export. The\nattachment_id makes retries idempotent while a new node/controller\nattachment gets a new secret." + }, + "force": { + "type": "boolean" + }, + "attachmentId": { + "type": "string" + } + } + }, + "ExportVolumeResponse": { + "type": "object", + "properties": { + "volume": { + "$ref": "#/definitions/Volume" + }, + "export": { + "$ref": "#/definitions/Export" + }, + "operationId": { + "type": "string" + }, + "requestId": { + "type": "string" + } + } + }, + "GetCapacityResponse": { + "type": "object", + "properties": { + "vgs": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/Capacity" + } + } + } + }, + "GetCredentialResponse": { + "type": "object", + "properties": { + "protocol": { + "$ref": "#/definitions/Protocol" + }, + "credential": { + "type": "string" + }, + "operationId": { + "type": "string" + } + } + }, + "GetInfoResponse": { + "type": "object", + "properties": { + "hostUuid": { + "type": "string" + }, + "agentVersion": { + "type": "string" + }, + "apiVersion": { + "type": "string" + }, + "protocols": { + "type": "array", + "items": { + "$ref": "#/definitions/Protocol" + } + }, + "authModes": { + "type": "array", + "items": { + "type": "string" + } + }, + "ready": { + "type": "boolean" + } + } + }, + "GetVolumeResponse": { + "type": "object", + "properties": { + "desired": { + "$ref": "#/definitions/Volume" + }, + "observed": { + "$ref": "#/definitions/Observation" + }, + "export": { + "$ref": "#/definitions/Export" + }, + "driftReasons": { + "type": "array", + "items": { + "$ref": "#/definitions/DriftReason" + } + }, + "operationId": { + "type": "string" + } + } + }, + "GrantCredentialRequest": { + "type": "object", + "properties": { + "volumeId": { + "type": "string" + }, + "nodeId": { + "type": "string" + }, + "nodeCallerId": { + "type": "string" + }, + "expiresAt": { + "type": "string", + "format": "date-time" + }, + "owner": { + "type": "string" + }, + "requestId": { + "type": "string" + } + } + }, + "GrantCredentialResponse": { + "type": "object", + "properties": { + "requestId": { + "type": "string" + } + } + }, + "ImportVolumeRequest": { + "type": "object", + "properties": { + "volumeId": { + "type": "string" + }, + "owner": { + "type": "string" + }, + "vg": { + "type": "string" + }, + "lvUuid": { + "type": "string" + }, + "lvName": { + "type": "string" + }, + "sizeBytes": { + "type": "string", + "format": "int64" + }, + "protocol": { + "$ref": "#/definitions/Protocol" + }, + "initiator": { + "type": "string" + }, + "targetName": { + "type": "string" + }, + "lun": { + "type": "integer", + "format": "int32" + }, + "nsid": { + "type": "integer", + "format": "int32" + }, + "credential": { + "type": "string", + "format": "byte" + }, + "allowActiveSessions": { + "type": "boolean" + }, + "requestId": { + "type": "string" + } + } + }, + "ImportVolumeResponse": { + "type": "object", + "properties": { + "volume": { + "$ref": "#/definitions/Volume" + }, + "export": { + "$ref": "#/definitions/Export" + }, + "operationId": { + "type": "string" + }, + "requestId": { + "type": "string" + } + } + }, + "ListOrphansResponse": { + "type": "object", + "properties": { + "orphans": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/Volume" + } + } + } + }, + "ListStorageClassesResponse": { + "type": "object", + "properties": { + "classes": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/StorageClass" + } + } + } + }, + "ListVGsResponse": { + "type": "object", + "properties": { + "vgs": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/Capacity" + } + } + } + }, + "ListVolumesResponse": { + "type": "object", + "properties": { + "volumes": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/Volume" + } + }, + "nextPageToken": { + "type": "string" + } + } + }, + "Observation": { + "type": "object", + "properties": { + "lvPresent": { + "type": "boolean" + }, + "observedSizeBytes": { + "type": "string", + "format": "int64" + }, + "devicePath": { + "type": "string" + }, + "targetPresent": { + "type": "boolean" + }, + "namespacePresent": { + "type": "boolean" + }, + "aclPresent": { + "type": "boolean" + }, + "activeSessions": { + "type": "integer", + "format": "int32" + }, + "sessionState": { + "$ref": "#/definitions/SessionState" + }, + "observedInitiator": { + "type": "string" + }, + "observedAt": { + "type": "string", + "format": "date-time" + }, + "source": { + "type": "string" + } + } + }, + "Protocol": { + "type": "string", + "enum": [ + "PROTOCOL_UNSPECIFIED", + "PROTOCOL_ISCSI", + "PROTOCOL_NVME_TCP" + ], + "default": "PROTOCOL_UNSPECIFIED" + }, + "RegisterVGRequest": { + "type": "object", + "properties": { + "vgName": { + "type": "string" + }, + "vgUuid": { + "type": "string" + }, + "requestId": { + "type": "string" + } + } + }, + "RegisterVGResponse": { + "type": "object", + "properties": { + "capacity": { + "$ref": "#/definitions/Capacity" + }, + "requestId": { + "type": "string" + } + } + }, + "ResizeVolumeBody": { + "type": "object", + "properties": { + "sizeBytes": { + "type": "string", + "format": "int64" + }, + "allowShrink": { + "type": "boolean" + }, + "requestId": { + "type": "string" + } + } + }, + "ResizeVolumeResponse": { + "type": "object", + "properties": { + "volume": { + "$ref": "#/definitions/Volume" + }, + "export": { + "$ref": "#/definitions/Export" + }, + "rescanRequired": { + "type": "boolean" + }, + "reconnectRequired": { + "type": "boolean" + }, + "operationId": { + "type": "string" + }, + "requestId": { + "type": "string" + } + } + }, + "ResolveAction": { + "type": "string", + "enum": [ + "RESOLVE_ACTION_UNSPECIFIED", + "RESOLVE_ACTION_MARK_FAILED", + "RESOLVE_ACTION_MARK_AVAILABLE", + "RESOLVE_ACTION_MARK_EXPORTED", + "RESOLVE_ACTION_MARK_DELETED", + "RESOLVE_ACTION_RETRY", + "RESOLVE_ACTION_RECREATE_EXPORT" + ], + "default": "RESOLVE_ACTION_UNSPECIFIED" + }, + "ResolveDriftBody": { + "type": "object", + "properties": { + "action": { + "$ref": "#/definitions/ResolveAction" + }, + "reason": { + "type": "string" + }, + "owner": { + "type": "string" + }, + "requestId": { + "type": "string" + } + } + }, + "ResolveDriftResponse": { + "type": "object", + "properties": { + "volume": { + "$ref": "#/definitions/Volume" + }, + "operationId": { + "type": "string" + }, + "requestId": { + "type": "string" + } + } + }, + "RevokeGrantResponse": { + "type": "object", + "properties": { + "requestId": { + "type": "string" + } + } + }, + "RotateCredentialBody": { + "type": "object", + "properties": { + "requestId": { + "type": "string" + } + } + }, + "RotateCredentialResponse": { + "type": "object", + "properties": { + "credential": { + "type": "string" + }, + "operationId": { + "type": "string" + }, + "requestId": { + "type": "string" + } + } + }, + "SessionState": { + "type": "string", + "enum": [ + "SESSION_STATE_UNSPECIFIED", + "SESSION_STATE_NONE", + "SESSION_STATE_ACTIVE", + "SESSION_STATE_UNKNOWN" + ], + "default": "SESSION_STATE_UNSPECIFIED" + }, + "Status": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "details": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/Any" + } + } + } + }, + "StorageClass": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Stable class name configured on the agent." + }, + "vgName": { + "type": "string" + }, + "vgUuid": { + "type": "string" + }, + "vgState": { + "type": "string" + }, + "totalBytes": { + "type": "string", + "format": "int64" + }, + "freeBytes": { + "type": "string", + "format": "int64" + }, + "allocatableBytes": { + "type": "string", + "format": "int64" + }, + "extentSizeBytes": { + "type": "string", + "format": "int64" + }, + "maxVolumeBytes": { + "type": "string", + "format": "int64" + }, + "observedAt": { + "type": "string", + "format": "date-time" + } + } + }, + "TransferOwnershipRequest": { + "type": "object", + "properties": { + "volumeId": { + "type": "string" + }, + "newOwner": { + "type": "string" + }, + "owner": { + "type": "string" + }, + "requestId": { + "type": "string" + } + } + }, + "TransferOwnershipResponse": { + "type": "object", + "properties": { + "volume": { + "$ref": "#/definitions/Volume" + }, + "operationId": { + "type": "string" + }, + "requestId": { + "type": "string" + } + } + }, + "UnexportVolumeBody": { + "type": "object", + "properties": { + "force": { + "type": "boolean" + }, + "requestId": { + "type": "string" + } + } + }, + "UnexportVolumeResponse": { + "type": "object", + "properties": { + "volume": { + "$ref": "#/definitions/Volume" + }, + "export": { + "$ref": "#/definitions/Export" + }, + "operationId": { + "type": "string" + }, + "requestId": { + "type": "string" + } + } + }, + "Volume": { + "type": "object", + "properties": { + "owner": { + "type": "string" + }, + "volumeId": { + "type": "string" + }, + "resourceUuid": { + "type": "string" + }, + "lvName": { + "type": "string" + }, + "vgName": { + "type": "string" + }, + "requestedSizeBytes": { + "type": "string", + "format": "int64" + }, + "actualSizeBytes": { + "type": "string", + "format": "int64" + }, + "state": { + "$ref": "#/definitions/VolumeState" + }, + "driftReasons": { + "type": "array", + "items": { + "$ref": "#/definitions/DriftReason" + } + }, + "lvUuid": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "legacyIdentity": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "storageClass": { + "type": "string", + "description": "Storage class selected for this volume. The LVM volume group remains the\ncanonical backend identity; this is the policy/category label." + } + } + }, + "VolumeState": { + "type": "string", + "enum": [ + "VOLUME_STATE_UNSPECIFIED", + "VOLUME_STATE_CREATING", + "VOLUME_STATE_AVAILABLE", + "VOLUME_STATE_EXPORTING", + "VOLUME_STATE_EXPORTED", + "VOLUME_STATE_RESIZING", + "VOLUME_STATE_DELETING", + "VOLUME_STATE_DELETED", + "VOLUME_STATE_FAILED", + "VOLUME_STATE_DRIFT" + ], + "default": "VOLUME_STATE_UNSPECIFIED" + }, + "WipeVolumeBody": { + "type": "object", + "properties": { + "acknowledgeDataLoss": { + "type": "boolean" + }, + "force": { + "type": "boolean" + }, + "requestId": { + "type": "string" + }, + "owner": { + "type": "string" + } + } + }, + "WipeVolumeResponse": { + "type": "object", + "properties": { + "operationId": { + "type": "string" + }, + "requestId": { + "type": "string" + } + } + } + } +} diff --git a/proto/openapi.yaml b/proto/openapi.yaml new file mode 100644 index 0000000..eb79993 --- /dev/null +++ b/proto/openapi.yaml @@ -0,0 +1,16 @@ +openapi_options: + file: + - file: kouja/v1/common.proto + option: + info: + title: kouja API + description: >- + Per-host block storage agent API. All endpoints require a client + certificate trusted by the agent. + version: v1 + schemes: + - HTTPS + consumes: + - application/json + produces: + - application/json From d29785ceb5b5ad2106a6a24672af1716809bff80 Mon Sep 17 00:00:00 2001 From: voidstarit Date: Fri, 6 Nov 2026 20:58:44 -0500 Subject: [PATCH 2/2] Generate clients under gen Replace the hand-written client package with a protoc plugin that emits gen/client/client.gen.go from the proto google.api.http annotations. The generated client keeps the typed gRPC and HTTP APIs, mTLS helpers, request-ID propagation, and error mapping, while making gen/client reproducible through make proto. --- Makefile | 1 + README.md | 4 +- buf.gen.yaml | 2 + client/context.go | 18 - client/doc.go | 12 - client/grpc.go | 109 --- client/http.go | 603 --------------- client/tls.go | 30 - docs/clients.md | 9 +- gen/client/client.gen.go | 780 ++++++++++++++++++++ {client => gen/client}/gateway_test.go | 0 {client => gen/client}/ginkgo_suite_test.go | 0 {client => gen/client}/grpc_test.go | 0 {client => gen/client}/http_test.go | 0 {client => gen/client}/openapi_test.go | 2 +- {client => gen/client}/testutil_test.go | 0 {client => gen/client}/tls_test.go | 0 hack/protoc-gen-kouja-client/client.go.tmpl | 453 ++++++++++++ hack/protoc-gen-kouja-client/main.go | 305 ++++++++ 19 files changed, 1550 insertions(+), 778 deletions(-) delete mode 100644 client/context.go delete mode 100644 client/doc.go delete mode 100644 client/grpc.go delete mode 100644 client/http.go delete mode 100644 client/tls.go create mode 100644 gen/client/client.gen.go rename {client => gen/client}/gateway_test.go (100%) rename {client => gen/client}/ginkgo_suite_test.go (100%) rename {client => gen/client}/grpc_test.go (100%) rename {client => gen/client}/http_test.go (100%) rename {client => gen/client}/openapi_test.go (97%) rename {client => gen/client}/testutil_test.go (100%) rename {client => gen/client}/tls_test.go (100%) create mode 100644 hack/protoc-gen-kouja-client/client.go.tmpl create mode 100644 hack/protoc-gen-kouja-client/main.go diff --git a/Makefile b/Makefile index 5e565d3..c908415 100644 --- a/Makefile +++ b/Makefile @@ -35,6 +35,7 @@ tools: $(GO) install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.5.1 $(GO) install github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-grpc-gateway@v2.24.0 $(GO) install github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2@v2.24.0 + $(GO) install ./hack/protoc-gen-kouja-client $(GO) install sigs.k8s.io/controller-tools/cmd/controller-gen@v0.16.5 $(GO) install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.15.0 diff --git a/README.md b/README.md index 96fb0d6..c50a788 100644 --- a/README.md +++ b/README.md @@ -153,8 +153,8 @@ Protocol generation and CRD generation require the tools installed by Generated gRPC stubs and the HTTP/JSON gateway live in `gen/kouja/v1`. The merged OpenAPI 2.0 document is generated at -`gen/openapi/openapi.swagger.json`. The `client` package wraps both transports -with mTLS helpers and typed methods for every RPC: +`gen/openapi/openapi.swagger.json`. The generated `gen/client` package wraps +both transports with mTLS helpers and typed methods for every RPC: ```go grpcClient, err := client.NewGRPCFromFiles(target, certFile, keyFile, caFile) diff --git a/buf.gen.yaml b/buf.gen.yaml index 469220a..25a3789 100644 --- a/buf.gen.yaml +++ b/buf.gen.yaml @@ -12,6 +12,8 @@ plugins: out: gen opt: - paths=source_relative + - name: kouja-client + out: gen - name: openapiv2 out: gen/openapi opt: diff --git a/client/context.go b/client/context.go deleted file mode 100644 index 79af5ec..0000000 --- a/client/context.go +++ /dev/null @@ -1,18 +0,0 @@ -package client - -import "context" - -type requestIDKey struct{} - -// WithRequestID returns a context that propagates requestID to the agent. The -// gRPC client sends it as x-request-id metadata and the HTTP client sends it -// as the X-Request-Id header. -func WithRequestID(ctx context.Context, requestID string) context.Context { - return context.WithValue(ctx, requestIDKey{}, requestID) -} - -// RequestIDFromContext returns the request ID stored by [WithRequestID]. -func RequestIDFromContext(ctx context.Context) string { - requestID, _ := ctx.Value(requestIDKey{}).(string) - return requestID -} diff --git a/client/doc.go b/client/doc.go deleted file mode 100644 index 4712974..0000000 --- a/client/doc.go +++ /dev/null @@ -1,12 +0,0 @@ -// Package client provides typed gRPC and HTTP/JSON clients for the kouja -// agent API. -// -// The gRPC client wraps the generated stubs in gen/kouja/v1. The HTTP client -// speaks the grpc-gateway REST/JSON surface defined by the google.api.http -// annotations in proto/kouja/v1. Both transports use mTLS with a client -// certificate trusted by the agent. -// -// Use [NewGRPC] or [NewGRPCFromFiles] for gRPC, and [NewHTTP] or -// [NewHTTPFromFiles] for HTTP/JSON. The OpenAPI description is generated at -// gen/openapi/openapi.swagger.json. -package client diff --git a/client/grpc.go b/client/grpc.go deleted file mode 100644 index 63d6992..0000000 --- a/client/grpc.go +++ /dev/null @@ -1,109 +0,0 @@ -package client - -import ( - "context" - "crypto/tls" - "fmt" - "net/url" - "strings" - - koujav1 "github.com/voidstarit/kouja/gen/kouja/v1" - "google.golang.org/grpc" - "google.golang.org/grpc/credentials" - "google.golang.org/grpc/metadata" -) - -// GRPC is a combined VolumeService and AdminService gRPC client. -type GRPC struct { - koujav1.VolumeServiceClient - koujav1.AdminServiceClient - conn *grpc.ClientConn -} - -// NewGRPC creates a gRPC client for target. The TLS configuration must contain -// a client certificate trusted by the agent and the CA used to verify the -// agent certificate. A target may include an https:// prefix; it is removed -// before the connection is created. -// -// NewGRPC installs a unary interceptor to propagate request IDs. Supply any -// additional interceptors with grpc.WithChainUnaryInterceptor rather than -// grpc.WithUnaryInterceptor. -func NewGRPC(target string, tlsConfig *tls.Config, opts ...grpc.DialOption) (*GRPC, error) { - if tlsConfig == nil { - return nil, fmt.Errorf("client: TLS configuration is required") - } - dialOptions := make([]grpc.DialOption, 0, len(opts)+2) - dialOptions = append(dialOptions, grpc.WithTransportCredentials(credentials.NewTLS(tlsConfig.Clone()))) - dialOptions = append(dialOptions, opts...) - dialOptions = append(dialOptions, grpc.WithChainUnaryInterceptor(requestIDUnaryInterceptor)) - return newGRPC(target, dialOptions...) -} - -// NewGRPCFromFiles creates a gRPC client using certificate files on disk. -func NewGRPCFromFiles(target, certFile, keyFile, caFile string, opts ...grpc.DialOption) (*GRPC, error) { - tlsConfig, err := LoadTLSConfig(certFile, keyFile, caFile) - if err != nil { - return nil, err - } - return NewGRPC(target, tlsConfig, opts...) -} - -func newGRPC(target string, opts ...grpc.DialOption) (*GRPC, error) { - target, err := normalizeGRPCTarget(target) - if err != nil { - return nil, err - } - conn, err := grpc.NewClient(target, opts...) - if err != nil { - return nil, fmt.Errorf("client: create gRPC client for %s: %w", target, err) - } - return &GRPC{ - VolumeServiceClient: koujav1.NewVolumeServiceClient(conn), - AdminServiceClient: koujav1.NewAdminServiceClient(conn), - conn: conn, - }, nil -} - -// Close closes the underlying gRPC connection. -func (client *GRPC) Close() error { - if client == nil || client.conn == nil { - return nil - } - return client.conn.Close() -} - -func normalizeGRPCTarget(target string) (string, error) { - target = strings.TrimSpace(target) - if target == "" { - return "", fmt.Errorf("client: gRPC target is required") - } - if strings.HasPrefix(target, "https://") || strings.HasPrefix(target, "http://") { - parsed, err := url.Parse(target) - if err != nil { - return "", fmt.Errorf("client: parse gRPC target: %w", err) - } - if parsed.Host == "" { - return "", fmt.Errorf("client: gRPC target must include a host: %q", target) - } - if parsed.Path != "" && parsed.Path != "/" { - return "", fmt.Errorf("client: gRPC target must not include a path: %q", target) - } - return parsed.Host, nil - } - return target, nil -} - -func requestIDUnaryInterceptor( - ctx context.Context, - method string, - request any, - reply any, - conn *grpc.ClientConn, - invoker grpc.UnaryInvoker, - opts ...grpc.CallOption, -) error { - if requestID := RequestIDFromContext(ctx); requestID != "" { - ctx = metadata.AppendToOutgoingContext(ctx, "x-request-id", requestID) - } - return invoker(ctx, method, request, reply, conn, opts...) -} diff --git a/client/http.go b/client/http.go deleted file mode 100644 index 61efb83..0000000 --- a/client/http.go +++ /dev/null @@ -1,603 +0,0 @@ -package client - -import ( - "bytes" - "context" - "crypto/tls" - "fmt" - "io" - "net/http" - "net/url" - "strconv" - "strings" - - koujav1 "github.com/voidstarit/kouja/gen/kouja/v1" - statuspb "google.golang.org/genproto/googleapis/rpc/status" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" - "google.golang.org/protobuf/encoding/protojson" - "google.golang.org/protobuf/proto" - "google.golang.org/protobuf/types/known/anypb" -) - -const maxErrorBodyBytes = 1 << 20 - -// HTTP is a typed HTTP/JSON client for the kouja REST API. It implements the -// same operations as the gRPC services. -type HTTP struct { - baseURL string - client *http.Client -} - -type httpOptions struct { - client *http.Client -} - -// HTTPOption configures an HTTP client. -type HTTPOption func(*httpOptions) error - -// WithHTTPClient configures the underlying HTTP client. Use this to provide a -// custom transport, timeout, or instrumentation. -func WithHTTPClient(httpClient *http.Client) HTTPOption { - return func(options *httpOptions) error { - if httpClient == nil { - return fmt.Errorf("client: HTTP client is nil") - } - options.client = httpClient - return nil - } -} - -// WithTLSConfig configures an mTLS HTTP transport. The TLS configuration must -// contain a client certificate trusted by the agent and the CA used to verify -// the agent certificate. -func WithTLSConfig(tlsConfig *tls.Config) HTTPOption { - return func(options *httpOptions) error { - if tlsConfig == nil { - return fmt.Errorf("client: TLS configuration is nil") - } - transport, ok := http.DefaultTransport.(*http.Transport) - if !ok { - return fmt.Errorf("client: default HTTP transport is not *http.Transport") - } - cloned := transport.Clone() - cloned.TLSClientConfig = tlsConfig.Clone() - options.client = &http.Client{Transport: cloned} - return nil - } -} - -// NewHTTP creates an HTTP/JSON client for baseURL, which may include a path -// prefix. Use [WithTLSConfig] for mTLS, or pass a prepared client with -// [WithHTTPClient]. -func NewHTTP(baseURL string, opts ...HTTPOption) (*HTTP, error) { - normalized, err := normalizeBaseURL(baseURL) - if err != nil { - return nil, err - } - options := &httpOptions{client: http.DefaultClient} - for _, option := range opts { - if option == nil { - continue - } - if err := option(options); err != nil { - return nil, err - } - } - if options.client == nil { - return nil, fmt.Errorf("client: HTTP client is nil") - } - return &HTTP{baseURL: normalized, client: options.client}, nil -} - -// NewHTTPFromFiles creates an HTTP/JSON client using certificate files on -// disk. -func NewHTTPFromFiles(baseURL, certFile, keyFile, caFile string, opts ...HTTPOption) (*HTTP, error) { - tlsConfig, err := LoadTLSConfig(certFile, keyFile, caFile) - if err != nil { - return nil, err - } - options := make([]HTTPOption, 0, len(opts)+1) - options = append(options, WithTLSConfig(tlsConfig)) - options = append(options, opts...) - return NewHTTP(baseURL, options...) -} - -// CloseIdleConnections closes idle connections held by the underlying HTTP -// client. -func (client *HTTP) CloseIdleConnections() { - if client != nil && client.client != nil { - client.client.CloseIdleConnections() - } -} - -// CreateVolume creates a volume in a registered volume group. -func (client *HTTP) CreateVolume(ctx context.Context, request *koujav1.CreateVolumeRequest) (*koujav1.CreateVolumeResponse, error) { - if request == nil { - request = &koujav1.CreateVolumeRequest{} - } - response := &koujav1.CreateVolumeResponse{} - if err := client.do(ctx, http.MethodPost, "/v1/volumes", nil, request, response); err != nil { - return nil, err - } - return response, nil -} - -// ExportVolume exports a volume to one initiator. -func (client *HTTP) ExportVolume(ctx context.Context, request *koujav1.ExportVolumeRequest) (*koujav1.ExportVolumeResponse, error) { - if request == nil { - request = &koujav1.ExportVolumeRequest{} - } - volumeID, err := pathParam("volume_id", request.GetVolumeId()) - if err != nil { - return nil, err - } - response := &koujav1.ExportVolumeResponse{} - if err := client.do(ctx, http.MethodPost, "/v1/volumes/"+volumeID+":export", nil, request, response); err != nil { - return nil, err - } - return response, nil -} - -// UnexportVolume removes a volume export. -func (client *HTTP) UnexportVolume(ctx context.Context, request *koujav1.UnexportVolumeRequest) (*koujav1.UnexportVolumeResponse, error) { - if request == nil { - request = &koujav1.UnexportVolumeRequest{} - } - volumeID, err := pathParam("volume_id", request.GetVolumeId()) - if err != nil { - return nil, err - } - response := &koujav1.UnexportVolumeResponse{} - if err := client.do(ctx, http.MethodPost, "/v1/volumes/"+volumeID+":unexport", nil, request, response); err != nil { - return nil, err - } - return response, nil -} - -// ResizeVolume grows or shrinks a volume. -func (client *HTTP) ResizeVolume(ctx context.Context, request *koujav1.ResizeVolumeRequest) (*koujav1.ResizeVolumeResponse, error) { - if request == nil { - request = &koujav1.ResizeVolumeRequest{} - } - volumeID, err := pathParam("volume_id", request.GetVolumeId()) - if err != nil { - return nil, err - } - response := &koujav1.ResizeVolumeResponse{} - if err := client.do(ctx, http.MethodPost, "/v1/volumes/"+volumeID+":resize", nil, request, response); err != nil { - return nil, err - } - return response, nil -} - -// DeleteVolume deletes a volume. -func (client *HTTP) DeleteVolume(ctx context.Context, request *koujav1.DeleteVolumeRequest) (*koujav1.DeleteVolumeResponse, error) { - if request == nil { - request = &koujav1.DeleteVolumeRequest{} - } - volumeID, err := pathParam("volume_id", request.GetVolumeId()) - if err != nil { - return nil, err - } - query := url.Values{} - addBool(query, "force", request.GetForce()) - addString(query, "requestId", request.GetRequestId()) - response := &koujav1.DeleteVolumeResponse{} - if err := client.do(ctx, http.MethodDelete, "/v1/volumes/"+volumeID, query, nil, response); err != nil { - return nil, err - } - return response, nil -} - -// GetVolume returns the desired and observed state of a volume. -func (client *HTTP) GetVolume(ctx context.Context, request *koujav1.GetVolumeRequest) (*koujav1.GetVolumeResponse, error) { - if request == nil { - request = &koujav1.GetVolumeRequest{} - } - volumeID, err := pathParam("volume_id", request.GetVolumeId()) - if err != nil { - return nil, err - } - response := &koujav1.GetVolumeResponse{} - if err := client.do(ctx, http.MethodGet, "/v1/volumes/"+volumeID, nil, nil, response); err != nil { - return nil, err - } - return response, nil -} - -// ListVolumes lists volumes visible to the caller. -func (client *HTTP) ListVolumes(ctx context.Context, request *koujav1.ListVolumesRequest) (*koujav1.ListVolumesResponse, error) { - if request == nil { - request = &koujav1.ListVolumesRequest{} - } - query := url.Values{} - addInt32(query, "pageSize", request.GetPageSize()) - addString(query, "pageToken", request.GetPageToken()) - response := &koujav1.ListVolumesResponse{} - if err := client.do(ctx, http.MethodGet, "/v1/volumes", query, nil, response); err != nil { - return nil, err - } - return response, nil -} - -// GetCredential returns the export credential for a volume. -func (client *HTTP) GetCredential(ctx context.Context, request *koujav1.GetCredentialRequest) (*koujav1.GetCredentialResponse, error) { - if request == nil { - request = &koujav1.GetCredentialRequest{} - } - volumeID, err := pathParam("volume_id", request.GetVolumeId()) - if err != nil { - return nil, err - } - query := url.Values{} - addString(query, "owner", request.GetOwner()) - response := &koujav1.GetCredentialResponse{} - if err := client.do(ctx, http.MethodGet, "/v1/volumes/"+volumeID+"/credential", query, nil, response); err != nil { - return nil, err - } - return response, nil -} - -// RotateCredential rotates the export credential for a volume. -func (client *HTTP) RotateCredential(ctx context.Context, request *koujav1.RotateCredentialRequest) (*koujav1.RotateCredentialResponse, error) { - if request == nil { - request = &koujav1.RotateCredentialRequest{} - } - volumeID, err := pathParam("volume_id", request.GetVolumeId()) - if err != nil { - return nil, err - } - response := &koujav1.RotateCredentialResponse{} - if err := client.do(ctx, http.MethodPost, "/v1/volumes/"+volumeID+":rotate-credential", nil, request, response); err != nil { - return nil, err - } - return response, nil -} - -// GetCapacity returns capacity for every registered volume group. -func (client *HTTP) GetCapacity(ctx context.Context, _ *koujav1.GetCapacityRequest) (*koujav1.GetCapacityResponse, error) { - response := &koujav1.GetCapacityResponse{} - if err := client.do(ctx, http.MethodGet, "/v1/capacity", nil, nil, response); err != nil { - return nil, err - } - return response, nil -} - -// ListStorageClasses returns the storage classes configured on the agent. -func (client *HTTP) ListStorageClasses(ctx context.Context, _ *koujav1.ListStorageClassesRequest) (*koujav1.ListStorageClassesResponse, error) { - response := &koujav1.ListStorageClassesResponse{} - if err := client.do(ctx, http.MethodGet, "/v1/storage-classes", nil, nil, response); err != nil { - return nil, err - } - return response, nil -} - -// GetInfo returns host identity and API capability information. -func (client *HTTP) GetInfo(ctx context.Context, _ *koujav1.GetInfoRequest) (*koujav1.GetInfoResponse, error) { - response := &koujav1.GetInfoResponse{} - if err := client.do(ctx, http.MethodGet, "/v1/info", nil, nil, response); err != nil { - return nil, err - } - return response, nil -} - -// RegisterVG registers a volume group with the agent. -func (client *HTTP) RegisterVG(ctx context.Context, request *koujav1.RegisterVGRequest) (*koujav1.RegisterVGResponse, error) { - if request == nil { - request = &koujav1.RegisterVGRequest{} - } - response := &koujav1.RegisterVGResponse{} - if err := client.do(ctx, http.MethodPost, "/v1/admin/vgs", nil, request, response); err != nil { - return nil, err - } - return response, nil -} - -// DeregisterVG deregisters a volume group. -func (client *HTTP) DeregisterVG(ctx context.Context, request *koujav1.DeregisterVGRequest) (*koujav1.DeregisterVGResponse, error) { - if request == nil { - request = &koujav1.DeregisterVGRequest{} - } - vgName, err := pathParam("vg_name", request.GetVgName()) - if err != nil { - return nil, err - } - query := url.Values{} - addString(query, "requestId", request.GetRequestId()) - response := &koujav1.DeregisterVGResponse{} - if err := client.do(ctx, http.MethodDelete, "/v1/admin/vgs/"+vgName, query, nil, response); err != nil { - return nil, err - } - return response, nil -} - -// ListVGs lists every registered volume group. -func (client *HTTP) ListVGs(ctx context.Context, _ *koujav1.ListVGsRequest) (*koujav1.ListVGsResponse, error) { - response := &koujav1.ListVGsResponse{} - if err := client.do(ctx, http.MethodGet, "/v1/admin/vgs", nil, nil, response); err != nil { - return nil, err - } - return response, nil -} - -// ImportVolume imports an existing logical volume and optional export. -func (client *HTTP) ImportVolume(ctx context.Context, request *koujav1.ImportVolumeRequest) (*koujav1.ImportVolumeResponse, error) { - if request == nil { - request = &koujav1.ImportVolumeRequest{} - } - response := &koujav1.ImportVolumeResponse{} - if err := client.do(ctx, http.MethodPost, "/v1/admin/import", nil, request, response); err != nil { - return nil, err - } - return response, nil -} - -// ResolveDrift applies an administrator-selected drift action. -func (client *HTTP) ResolveDrift(ctx context.Context, request *koujav1.ResolveDriftRequest) (*koujav1.ResolveDriftResponse, error) { - if request == nil { - request = &koujav1.ResolveDriftRequest{} - } - volumeID, err := pathParam("volume_id", request.GetVolumeId()) - if err != nil { - return nil, err - } - response := &koujav1.ResolveDriftResponse{} - if err := client.do(ctx, http.MethodPost, "/v1/admin/drift/"+volumeID+":resolve", nil, request, response); err != nil { - return nil, err - } - return response, nil -} - -// ListOrphans lists volumes that have no matching export or host state. -func (client *HTTP) ListOrphans(ctx context.Context, _ *koujav1.ListOrphansRequest) (*koujav1.ListOrphansResponse, error) { - response := &koujav1.ListOrphansResponse{} - if err := client.do(ctx, http.MethodGet, "/v1/admin/orphans", nil, nil, response); err != nil { - return nil, err - } - return response, nil -} - -// WipeVolume destroys the data on a volume. -func (client *HTTP) WipeVolume(ctx context.Context, request *koujav1.WipeVolumeRequest) (*koujav1.WipeVolumeResponse, error) { - if request == nil { - request = &koujav1.WipeVolumeRequest{} - } - volumeID, err := pathParam("volume_id", request.GetVolumeId()) - if err != nil { - return nil, err - } - response := &koujav1.WipeVolumeResponse{} - if err := client.do(ctx, http.MethodPost, "/v1/admin/volumes/"+volumeID+":wipe", nil, request, response); err != nil { - return nil, err - } - return response, nil -} - -// TransferOwnership transfers a volume to a new owner. -func (client *HTTP) TransferOwnership(ctx context.Context, request *koujav1.TransferOwnershipRequest) (*koujav1.TransferOwnershipResponse, error) { - if request == nil { - request = &koujav1.TransferOwnershipRequest{} - } - response := &koujav1.TransferOwnershipResponse{} - if err := client.do(ctx, http.MethodPost, "/v1/admin/ownership:transfer", nil, request, response); err != nil { - return nil, err - } - return response, nil -} - -// AdminDeleteVolume deletes a volume as an administrator. -func (client *HTTP) AdminDeleteVolume(ctx context.Context, request *koujav1.AdminDeleteVolumeRequest) (*koujav1.AdminDeleteVolumeResponse, error) { - if request == nil { - request = &koujav1.AdminDeleteVolumeRequest{} - } - volumeID, err := pathParam("volume_id", request.GetVolumeId()) - if err != nil { - return nil, err - } - query := url.Values{} - addBool(query, "acknowledgeDataLoss", request.GetAcknowledgeDataLoss()) - addString(query, "owner", request.GetOwner()) - addString(query, "requestId", request.GetRequestId()) - response := &koujav1.AdminDeleteVolumeResponse{} - if err := client.do(ctx, http.MethodDelete, "/v1/admin/volumes/"+volumeID, query, nil, response); err != nil { - return nil, err - } - return response, nil -} - -// GrantCredential grants a node access to a volume credential. -func (client *HTTP) GrantCredential(ctx context.Context, request *koujav1.GrantCredentialRequest) (*koujav1.GrantCredentialResponse, error) { - if request == nil { - request = &koujav1.GrantCredentialRequest{} - } - response := &koujav1.GrantCredentialResponse{} - if err := client.do(ctx, http.MethodPost, "/v1/admin/grants", nil, request, response); err != nil { - return nil, err - } - return response, nil -} - -// RevokeGrant revokes a node credential grant. -func (client *HTTP) RevokeGrant(ctx context.Context, request *koujav1.RevokeGrantRequest) (*koujav1.RevokeGrantResponse, error) { - if request == nil { - request = &koujav1.RevokeGrantRequest{} - } - volumeID, err := pathParam("volume_id", request.GetVolumeId()) - if err != nil { - return nil, err - } - nodeID, err := pathParam("node_id", request.GetNodeId()) - if err != nil { - return nil, err - } - query := url.Values{} - addString(query, "owner", request.GetOwner()) - addString(query, "requestId", request.GetRequestId()) - response := &koujav1.RevokeGrantResponse{} - if err := client.do(ctx, http.MethodDelete, "/v1/admin/grants/"+volumeID+"/"+nodeID, query, nil, response); err != nil { - return nil, err - } - return response, nil -} - -// Error is returned when the agent responds with a non-2xx HTTP status. The -// code and message come from the grpc-gateway google.rpc.Status error body when -// one is present. -type Error struct { - HTTPStatus int - Code codes.Code - Message string - Details []*anypb.Any -} - -// Error implements error. -func (err *Error) Error() string { - if err == nil { - return "client: " - } - if err.HTTPStatus != 0 { - statusText := http.StatusText(err.HTTPStatus) - if statusText == "" { - statusText = "HTTP error" - } - if err.Message == "" { - return fmt.Sprintf("client: HTTP %d %s", err.HTTPStatus, statusText) - } - return fmt.Sprintf("client: HTTP %d %s: %s", err.HTTPStatus, statusText, err.Message) - } - if err.Message == "" { - return fmt.Sprintf("client: %s", err.Code) - } - return fmt.Sprintf("client: %s: %s", err.Code, err.Message) -} - -// GRPCStatus lets grpc/status translate an HTTP error into a gRPC status. -func (err *Error) GRPCStatus() *status.Status { - if err == nil { - return status.New(codes.Unknown, "") - } - code := err.Code - if code == codes.OK { - code = codes.Unknown - } - return status.New(code, err.Message) -} - -func (client *HTTP) do(ctx context.Context, method, path string, query url.Values, request, response proto.Message) error { - var body io.Reader - if request != nil { - payload, err := protojson.MarshalOptions{UseProtoNames: false}.Marshal(request) - if err != nil { - return fmt.Errorf("client: marshal %s request: %w", path, err) - } - body = bytes.NewReader(payload) - } - requestURL := client.baseURL + path - if len(query) != 0 { - requestURL += "?" + query.Encode() - } - httpRequest, err := http.NewRequestWithContext(ctx, method, requestURL, body) - if err != nil { - return fmt.Errorf("client: create %s request: %w", path, err) - } - httpRequest.Header.Set("Accept", "application/json") - if request != nil { - httpRequest.Header.Set("Content-Type", "application/json") - } - if requestID := RequestIDFromContext(ctx); requestID != "" { - httpRequest.Header.Set("X-Request-Id", requestID) - } - httpResponse, err := client.client.Do(httpRequest) - if err != nil { - return fmt.Errorf("client: %s %s: %w", method, requestURL, err) - } - defer func() { _ = httpResponse.Body.Close() }() - if httpResponse.StatusCode < http.StatusOK || httpResponse.StatusCode >= http.StatusMultipleChoices { - return decodeHTTPError(httpResponse) - } - if response == nil || httpResponse.StatusCode == http.StatusNoContent { - _, _ = io.Copy(io.Discard, httpResponse.Body) - return nil - } - payload, err := io.ReadAll(httpResponse.Body) - if err != nil { - return fmt.Errorf("client: read %s response: %w", path, err) - } - if len(bytes.TrimSpace(payload)) == 0 { - return fmt.Errorf("client: %s returned an empty response", path) - } - if err := (protojson.UnmarshalOptions{DiscardUnknown: true}).Unmarshal(payload, response); err != nil { - return fmt.Errorf("client: decode %s response: %w", path, err) - } - return nil -} - -func decodeHTTPError(response *http.Response) error { - result := &Error{HTTPStatus: response.StatusCode, Code: codes.Unknown} - payload, err := io.ReadAll(io.LimitReader(response.Body, maxErrorBodyBytes)) - if err != nil { - result.Message = response.Status - return result - } - var protoStatus statuspb.Status - if err := (protojson.UnmarshalOptions{DiscardUnknown: true}).Unmarshal(payload, &protoStatus); err == nil && - (protoStatus.GetCode() != 0 || protoStatus.GetMessage() != "") { - result.Code = codes.Code(protoStatus.GetCode()) //nolint:gosec // google.rpc.Code values are non-negative - result.Message = protoStatus.GetMessage() - result.Details = protoStatus.GetDetails() - } else { - result.Message = strings.TrimSpace(string(payload)) - } - if result.Message == "" { - result.Message = response.Status - } - return result -} - -func normalizeBaseURL(raw string) (string, error) { - raw = strings.TrimSpace(raw) - if raw == "" { - return "", fmt.Errorf("client: base URL is required") - } - parsed, err := url.Parse(raw) - if err != nil { - return "", fmt.Errorf("client: parse base URL: %w", err) - } - if parsed.Scheme != "http" && parsed.Scheme != "https" { - return "", fmt.Errorf("client: base URL must use http or https: %q", raw) - } - if parsed.Host == "" { - return "", fmt.Errorf("client: base URL must include a host: %q", raw) - } - if parsed.RawQuery != "" || parsed.Fragment != "" { - return "", fmt.Errorf("client: base URL must not include a query or fragment: %q", raw) - } - parsed.Path = strings.TrimRight(parsed.Path, "/") - return parsed.String(), nil -} - -func pathParam(name, value string) (string, error) { - if value == "" { - return "", fmt.Errorf("client: %s is required", name) - } - return url.PathEscape(value), nil -} - -func addString(values url.Values, name, value string) { - if value != "" { - values.Set(name, value) - } -} - -func addBool(values url.Values, name string, value bool) { - if value { - values.Set(name, "true") - } -} - -func addInt32(values url.Values, name string, value int32) { - if value != 0 { - values.Set(name, strconv.FormatInt(int64(value), 10)) - } -} diff --git a/client/tls.go b/client/tls.go deleted file mode 100644 index 31ea7bb..0000000 --- a/client/tls.go +++ /dev/null @@ -1,30 +0,0 @@ -package client - -import ( - "crypto/tls" - "crypto/x509" - "fmt" - "os" -) - -// LoadTLSConfig loads an mTLS client configuration from a certificate, a -// private key, and the CA used to verify the agent certificate. -func LoadTLSConfig(certFile, keyFile, caFile string) (*tls.Config, error) { - certificate, err := tls.LoadX509KeyPair(certFile, keyFile) - if err != nil { - return nil, fmt.Errorf("client: load certificate: %w", err) - } - caPEM, err := os.ReadFile(caFile) //nolint:gosec // operator-selected CA path - if err != nil { - return nil, fmt.Errorf("client: read CA: %w", err) - } - roots := x509.NewCertPool() - if !roots.AppendCertsFromPEM(caPEM) { - return nil, fmt.Errorf("client: no certificates found in %s", caFile) - } - return &tls.Config{ //nolint:gosec // kouja requires TLS 1.3 and hostname verification - MinVersion: tls.VersionTLS13, - Certificates: []tls.Certificate{certificate}, - RootCAs: roots, - }, nil -} diff --git a/docs/clients.md b/docs/clients.md index 342c674..ac83e4d 100644 --- a/docs/clients.md +++ b/docs/clients.md @@ -7,10 +7,13 @@ The repository ships a Go client for both agent transports: | gRPC stubs and clients | `gen/kouja/v1/*.pb.go`, `*_grpc.pb.go` | `buf generate` | | HTTP/JSON gateway | `gen/kouja/v1/*.pb.gw.go` | `buf generate` | | OpenAPI 2.0 document | `gen/openapi/openapi.swagger.json` | `buf generate` | -| Typed gRPC and HTTP wrappers | `client/` | Hand maintained | +| Typed gRPC and HTTP wrappers | `gen/client/client.gen.go` | `buf generate` | The wrappers use the same protobuf request and response messages as the generated stubs, so callers can switch transports without converting models. +The `protoc-gen-kouja-client` generator lives in +`hack/protoc-gen-kouja-client`; `make tools` installs it and `make proto` +regenerates `gen/client/client.gen.go` from the `google.api.http` annotations. ## gRPC @@ -21,7 +24,7 @@ import ( "context" "log" - "github.com/voidstarit/kouja/client" + "github.com/voidstarit/kouja/gen/client" koujav1 "github.com/voidstarit/kouja/gen/kouja/v1" ) @@ -60,7 +63,7 @@ import ( "context" "log" - "github.com/voidstarit/kouja/client" + "github.com/voidstarit/kouja/gen/client" koujav1 "github.com/voidstarit/kouja/gen/kouja/v1" "google.golang.org/protobuf/proto" ) diff --git a/gen/client/client.gen.go b/gen/client/client.gen.go new file mode 100644 index 0000000..fc4c33c --- /dev/null +++ b/gen/client/client.gen.go @@ -0,0 +1,780 @@ +// Code generated by protoc-gen-kouja-client. DO NOT EDIT. +// sources: kouja/v1/common.proto, kouja/v1/admin.proto, kouja/v1/volume.proto + +// Package client provides typed gRPC and HTTP/JSON clients for the kouja +// agent API. +// +// The gRPC client wraps the generated stubs in gen/kouja/v1. The HTTP client +// speaks the grpc-gateway REST/JSON surface defined by the google.api.http +// annotations in proto/kouja/v1. Both transports use mTLS with a client +// certificate trusted by the agent. +package client + +import ( + "bytes" + "context" + "crypto/tls" + "crypto/x509" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strconv" + "strings" + + pb "github.com/voidstarit/kouja/gen/kouja/v1" + statuspb "google.golang.org/genproto/googleapis/rpc/status" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/anypb" +) + +type requestIDKey struct{} + +// WithRequestID returns a context that propagates requestID to the agent. The +// gRPC client sends it as x-request-id metadata and the HTTP client sends it +// as the X-Request-Id header. +func WithRequestID(ctx context.Context, requestID string) context.Context { + return context.WithValue(ctx, requestIDKey{}, requestID) +} + +// RequestIDFromContext returns the request ID stored by [WithRequestID]. +func RequestIDFromContext(ctx context.Context) string { + requestID, _ := ctx.Value(requestIDKey{}).(string) + return requestID +} + +// LoadTLSConfig loads an mTLS client configuration from a certificate, a +// private key, and the CA used to verify the agent certificate. +func LoadTLSConfig(certFile, keyFile, caFile string) (*tls.Config, error) { + certificate, err := tls.LoadX509KeyPair(certFile, keyFile) + if err != nil { + return nil, fmt.Errorf("client: load certificate: %w", err) + } + caPEM, err := os.ReadFile(caFile) //nolint:gosec // operator-selected CA path + if err != nil { + return nil, fmt.Errorf("client: read CA: %w", err) + } + roots := x509.NewCertPool() + if !roots.AppendCertsFromPEM(caPEM) { + return nil, fmt.Errorf("client: no certificates found in %s", caFile) + } + return &tls.Config{ //nolint:gosec // kouja requires TLS 1.3 and hostname verification + MinVersion: tls.VersionTLS13, + Certificates: []tls.Certificate{certificate}, + RootCAs: roots, + }, nil +} + +// GRPC is a combined VolumeService and AdminService gRPC client. +type GRPC struct { + pb.VolumeServiceClient + pb.AdminServiceClient + conn *grpc.ClientConn +} + +// NewGRPC creates a gRPC client for target. The TLS configuration must contain +// a client certificate trusted by the agent and the CA used to verify the +// agent certificate. A target may include an https:// prefix; it is removed +// before the connection is created. +// +// NewGRPC installs a unary interceptor to propagate request IDs. Supply any +// additional interceptors with grpc.WithChainUnaryInterceptor rather than +// grpc.WithUnaryInterceptor. +func NewGRPC(target string, tlsConfig *tls.Config, opts ...grpc.DialOption) (*GRPC, error) { + if tlsConfig == nil { + return nil, fmt.Errorf("client: TLS configuration is required") + } + dialOptions := make([]grpc.DialOption, 0, len(opts)+2) + dialOptions = append(dialOptions, grpc.WithTransportCredentials(credentials.NewTLS(tlsConfig.Clone()))) + dialOptions = append(dialOptions, opts...) + dialOptions = append(dialOptions, grpc.WithChainUnaryInterceptor(requestIDUnaryInterceptor)) + return newGRPC(target, dialOptions...) +} + +// NewGRPCFromFiles creates a gRPC client using certificate files on disk. +func NewGRPCFromFiles(target, certFile, keyFile, caFile string, opts ...grpc.DialOption) (*GRPC, error) { + tlsConfig, err := LoadTLSConfig(certFile, keyFile, caFile) + if err != nil { + return nil, err + } + return NewGRPC(target, tlsConfig, opts...) +} + +func newGRPC(target string, opts ...grpc.DialOption) (*GRPC, error) { + target, err := normalizeGRPCTarget(target) + if err != nil { + return nil, err + } + conn, err := grpc.NewClient(target, opts...) + if err != nil { + return nil, fmt.Errorf("client: create gRPC client for %s: %w", target, err) + } + return &GRPC{ + VolumeServiceClient: pb.NewVolumeServiceClient(conn), + AdminServiceClient: pb.NewAdminServiceClient(conn), + conn: conn, + }, nil +} + +// Close closes the underlying gRPC connection. +func (client *GRPC) Close() error { + if client == nil || client.conn == nil { + return nil + } + return client.conn.Close() +} + +func normalizeGRPCTarget(target string) (string, error) { + target = strings.TrimSpace(target) + if target == "" { + return "", fmt.Errorf("client: gRPC target is required") + } + if strings.HasPrefix(target, "https://") || strings.HasPrefix(target, "http://") { + parsed, err := url.Parse(target) + if err != nil { + return "", fmt.Errorf("client: parse gRPC target: %w", err) + } + if parsed.Host == "" { + return "", fmt.Errorf("client: gRPC target must include a host: %q", target) + } + if parsed.Path != "" && parsed.Path != "/" { + return "", fmt.Errorf("client: gRPC target must not include a path: %q", target) + } + return parsed.Host, nil + } + return target, nil +} + +func requestIDUnaryInterceptor( + ctx context.Context, + method string, + request any, + reply any, + conn *grpc.ClientConn, + invoker grpc.UnaryInvoker, + opts ...grpc.CallOption, +) error { + if requestID := RequestIDFromContext(ctx); requestID != "" { + ctx = metadata.AppendToOutgoingContext(ctx, "x-request-id", requestID) + } + return invoker(ctx, method, request, reply, conn, opts...) +} + +const maxErrorBodyBytes = 1 << 20 + +// HTTP is a typed HTTP/JSON client for the kouja REST API. It implements the +// same operations as the gRPC services. +type HTTP struct { + baseURL string + client *http.Client +} + +type httpOptions struct { + client *http.Client +} + +// HTTPOption configures an HTTP client. +type HTTPOption func(*httpOptions) error + +// WithHTTPClient configures the underlying HTTP client. Use this to provide a +// custom transport, timeout, or instrumentation. +func WithHTTPClient(httpClient *http.Client) HTTPOption { + return func(options *httpOptions) error { + if httpClient == nil { + return fmt.Errorf("client: HTTP client is nil") + } + options.client = httpClient + return nil + } +} + +// WithTLSConfig configures an mTLS HTTP transport. The TLS configuration must +// contain a client certificate trusted by the agent and the CA used to verify +// the agent certificate. +func WithTLSConfig(tlsConfig *tls.Config) HTTPOption { + return func(options *httpOptions) error { + if tlsConfig == nil { + return fmt.Errorf("client: TLS configuration is nil") + } + transport, ok := http.DefaultTransport.(*http.Transport) + if !ok { + return fmt.Errorf("client: default HTTP transport is not *http.Transport") + } + cloned := transport.Clone() + cloned.TLSClientConfig = tlsConfig.Clone() + options.client = &http.Client{Transport: cloned} + return nil + } +} + +// NewHTTP creates an HTTP/JSON client for baseURL, which may include a path +// prefix. Use [WithTLSConfig] for mTLS, or pass a prepared client with +// [WithHTTPClient]. +func NewHTTP(baseURL string, opts ...HTTPOption) (*HTTP, error) { + normalized, err := normalizeBaseURL(baseURL) + if err != nil { + return nil, err + } + options := &httpOptions{client: http.DefaultClient} + for _, option := range opts { + if option == nil { + continue + } + if err := option(options); err != nil { + return nil, err + } + } + if options.client == nil { + return nil, fmt.Errorf("client: HTTP client is nil") + } + return &HTTP{baseURL: normalized, client: options.client}, nil +} + +// NewHTTPFromFiles creates an HTTP/JSON client using certificate files on +// disk. +func NewHTTPFromFiles(baseURL, certFile, keyFile, caFile string, opts ...HTTPOption) (*HTTP, error) { + tlsConfig, err := LoadTLSConfig(certFile, keyFile, caFile) + if err != nil { + return nil, err + } + options := make([]HTTPOption, 0, len(opts)+1) + options = append(options, WithTLSConfig(tlsConfig)) + options = append(options, opts...) + return NewHTTP(baseURL, options...) +} + +// CloseIdleConnections closes idle connections held by the underlying HTTP +// client. +func (client *HTTP) CloseIdleConnections() { + if client != nil && client.client != nil { + client.client.CloseIdleConnections() + } +} + +// Error is returned when the agent responds with a non-2xx HTTP status. The +// code and message come from the grpc-gateway google.rpc.Status error body when +// one is present. +type Error struct { + HTTPStatus int + Code codes.Code + Message string + Details []*anypb.Any +} + +// Error implements error. +func (err *Error) Error() string { + if err == nil { + return "client: " + } + if err.HTTPStatus != 0 { + statusText := http.StatusText(err.HTTPStatus) + if statusText == "" { + statusText = "HTTP error" + } + if err.Message == "" { + return fmt.Sprintf("client: HTTP %d %s", err.HTTPStatus, statusText) + } + return fmt.Sprintf("client: HTTP %d %s: %s", err.HTTPStatus, statusText, err.Message) + } + if err.Message == "" { + return fmt.Sprintf("client: %s", err.Code) + } + return fmt.Sprintf("client: %s: %s", err.Code, err.Message) +} + +// GRPCStatus lets grpc/status translate an HTTP error into a gRPC status. +func (err *Error) GRPCStatus() *status.Status { + if err == nil { + return status.New(codes.Unknown, "") + } + code := err.Code + if code == codes.OK { + code = codes.Unknown + } + return status.New(code, err.Message) +} + +func (client *HTTP) do(ctx context.Context, method, path string, query url.Values, request, response proto.Message) error { + var body io.Reader + if request != nil { + payload, err := (protojson.MarshalOptions{UseProtoNames: false}).Marshal(request) + if err != nil { + return fmt.Errorf("client: marshal %s request: %w", path, err) + } + body = bytes.NewReader(payload) + } + requestURL := client.baseURL + path + if len(query) != 0 { + requestURL += "?" + query.Encode() + } + httpRequest, err := http.NewRequestWithContext(ctx, method, requestURL, body) + if err != nil { + return fmt.Errorf("client: create %s request: %w", path, err) + } + httpRequest.Header.Set("Accept", "application/json") + if request != nil { + httpRequest.Header.Set("Content-Type", "application/json") + } + if requestID := RequestIDFromContext(ctx); requestID != "" { + httpRequest.Header.Set("X-Request-Id", requestID) + } + httpResponse, err := client.client.Do(httpRequest) + if err != nil { + return fmt.Errorf("client: %s %s: %w", method, requestURL, err) + } + defer func() { _ = httpResponse.Body.Close() }() + if httpResponse.StatusCode < http.StatusOK || httpResponse.StatusCode >= http.StatusMultipleChoices { + return decodeHTTPError(httpResponse) + } + if response == nil || httpResponse.StatusCode == http.StatusNoContent { + _, _ = io.Copy(io.Discard, httpResponse.Body) + return nil + } + payload, err := io.ReadAll(httpResponse.Body) + if err != nil { + return fmt.Errorf("client: read %s response: %w", path, err) + } + if len(bytes.TrimSpace(payload)) == 0 { + return fmt.Errorf("client: %s returned an empty response", path) + } + if err := (protojson.UnmarshalOptions{DiscardUnknown: true}).Unmarshal(payload, response); err != nil { + return fmt.Errorf("client: decode %s response: %w", path, err) + } + return nil +} + +func decodeHTTPError(response *http.Response) error { + result := &Error{HTTPStatus: response.StatusCode, Code: codes.Unknown} + payload, err := io.ReadAll(io.LimitReader(response.Body, maxErrorBodyBytes)) + if err != nil { + result.Message = response.Status + return result + } + var protoStatus statuspb.Status + if err := (protojson.UnmarshalOptions{DiscardUnknown: true}).Unmarshal(payload, &protoStatus); err == nil && + (protoStatus.GetCode() != 0 || protoStatus.GetMessage() != "") { + result.Code = codes.Code(protoStatus.GetCode()) //nolint:gosec // google.rpc.Code values are non-negative + result.Message = protoStatus.GetMessage() + result.Details = protoStatus.GetDetails() + } else { + result.Message = strings.TrimSpace(string(payload)) + } + if result.Message == "" { + result.Message = response.Status + } + return result +} + +func normalizeBaseURL(raw string) (string, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", fmt.Errorf("client: base URL is required") + } + parsed, err := url.Parse(raw) + if err != nil { + return "", fmt.Errorf("client: parse base URL: %w", err) + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return "", fmt.Errorf("client: base URL must use http or https: %q", raw) + } + if parsed.Host == "" { + return "", fmt.Errorf("client: base URL must include a host: %q", raw) + } + if parsed.RawQuery != "" || parsed.Fragment != "" { + return "", fmt.Errorf("client: base URL must not include a query or fragment: %q", raw) + } + parsed.Path = strings.TrimRight(parsed.Path, "/") + return parsed.String(), nil +} + +func pathParam(name, value string) (string, error) { + if value == "" { + return "", fmt.Errorf("client: %s is required", name) + } + return url.PathEscape(value), nil +} + +func addString(values url.Values, name, value string) { + if value != "" { + values.Set(name, value) + } +} + +func addBool(values url.Values, name string, value bool) { + if value { + values.Set(name, "true") + } +} + +func addInt32(values url.Values, name string, value int32) { + if value != 0 { + values.Set(name, strconv.FormatInt(int64(value), 10)) + } +} + +func addInt64(values url.Values, name string, value int64) { + if value != 0 { + values.Set(name, strconv.FormatInt(value, 10)) + } +} + +func addUint32(values url.Values, name string, value uint32) { + if value != 0 { + values.Set(name, strconv.FormatUint(uint64(value), 10)) + } +} + +func addUint64(values url.Values, name string, value uint64) { + if value != 0 { + values.Set(name, strconv.FormatUint(value, 10)) + } +} + +func addFloat32(values url.Values, name string, value float32) { + if value != 0 { + values.Set(name, strconv.FormatFloat(float64(value), 'f', -1, 32)) + } +} + +func addFloat64(values url.Values, name string, value float64) { + if value != 0 { + values.Set(name, strconv.FormatFloat(value, 'f', -1, 64)) + } +} + +// RegisterVG calls the AdminService RPC. +func (client *HTTP) RegisterVG(ctx context.Context, request *pb.RegisterVGRequest) (*pb.RegisterVGResponse, error) { + if request == nil { + request = &pb.RegisterVGRequest{} + } + response := &pb.RegisterVGResponse{} + if err := client.do(ctx, http.MethodPost, "/v1/admin/vgs", nil, request, response); err != nil { + return nil, err + } + return response, nil +} + +// DeregisterVG calls the AdminService RPC. +func (client *HTTP) DeregisterVG(ctx context.Context, request *pb.DeregisterVGRequest) (*pb.DeregisterVGResponse, error) { + if request == nil { + request = &pb.DeregisterVGRequest{} + } + pathValue0, err := pathParam("vg_name", request.GetVgName()) + if err != nil { + return nil, err + } + query := url.Values{} + addString(query, "requestId", request.GetRequestId()) + response := &pb.DeregisterVGResponse{} + if err := client.do(ctx, http.MethodDelete, "/v1/admin/vgs/"+pathValue0, query, nil, response); err != nil { + return nil, err + } + return response, nil +} + +// ListVGs calls the AdminService RPC. +func (client *HTTP) ListVGs(ctx context.Context, _ *pb.ListVGsRequest) (*pb.ListVGsResponse, error) { + response := &pb.ListVGsResponse{} + if err := client.do(ctx, http.MethodGet, "/v1/admin/vgs", nil, nil, response); err != nil { + return nil, err + } + return response, nil +} + +// ImportVolume calls the AdminService RPC. +func (client *HTTP) ImportVolume(ctx context.Context, request *pb.ImportVolumeRequest) (*pb.ImportVolumeResponse, error) { + if request == nil { + request = &pb.ImportVolumeRequest{} + } + response := &pb.ImportVolumeResponse{} + if err := client.do(ctx, http.MethodPost, "/v1/admin/import", nil, request, response); err != nil { + return nil, err + } + return response, nil +} + +// ResolveDrift calls the AdminService RPC. +func (client *HTTP) ResolveDrift(ctx context.Context, request *pb.ResolveDriftRequest) (*pb.ResolveDriftResponse, error) { + if request == nil { + request = &pb.ResolveDriftRequest{} + } + pathValue0, err := pathParam("volume_id", request.GetVolumeId()) + if err != nil { + return nil, err + } + response := &pb.ResolveDriftResponse{} + if err := client.do(ctx, http.MethodPost, "/v1/admin/drift/"+pathValue0+":resolve", nil, request, response); err != nil { + return nil, err + } + return response, nil +} + +// ListOrphans calls the AdminService RPC. +func (client *HTTP) ListOrphans(ctx context.Context, _ *pb.ListOrphansRequest) (*pb.ListOrphansResponse, error) { + response := &pb.ListOrphansResponse{} + if err := client.do(ctx, http.MethodGet, "/v1/admin/orphans", nil, nil, response); err != nil { + return nil, err + } + return response, nil +} + +// WipeVolume calls the AdminService RPC. +func (client *HTTP) WipeVolume(ctx context.Context, request *pb.WipeVolumeRequest) (*pb.WipeVolumeResponse, error) { + if request == nil { + request = &pb.WipeVolumeRequest{} + } + pathValue0, err := pathParam("volume_id", request.GetVolumeId()) + if err != nil { + return nil, err + } + response := &pb.WipeVolumeResponse{} + if err := client.do(ctx, http.MethodPost, "/v1/admin/volumes/"+pathValue0+":wipe", nil, request, response); err != nil { + return nil, err + } + return response, nil +} + +// TransferOwnership calls the AdminService RPC. +func (client *HTTP) TransferOwnership(ctx context.Context, request *pb.TransferOwnershipRequest) (*pb.TransferOwnershipResponse, error) { + if request == nil { + request = &pb.TransferOwnershipRequest{} + } + response := &pb.TransferOwnershipResponse{} + if err := client.do(ctx, http.MethodPost, "/v1/admin/ownership:transfer", nil, request, response); err != nil { + return nil, err + } + return response, nil +} + +// AdminDeleteVolume calls the AdminService RPC. +func (client *HTTP) AdminDeleteVolume(ctx context.Context, request *pb.AdminDeleteVolumeRequest) (*pb.AdminDeleteVolumeResponse, error) { + if request == nil { + request = &pb.AdminDeleteVolumeRequest{} + } + pathValue0, err := pathParam("volume_id", request.GetVolumeId()) + if err != nil { + return nil, err + } + query := url.Values{} + addBool(query, "acknowledgeDataLoss", request.GetAcknowledgeDataLoss()) + addString(query, "owner", request.GetOwner()) + addString(query, "requestId", request.GetRequestId()) + response := &pb.AdminDeleteVolumeResponse{} + if err := client.do(ctx, http.MethodDelete, "/v1/admin/volumes/"+pathValue0, query, nil, response); err != nil { + return nil, err + } + return response, nil +} + +// GrantCredential calls the AdminService RPC. +func (client *HTTP) GrantCredential(ctx context.Context, request *pb.GrantCredentialRequest) (*pb.GrantCredentialResponse, error) { + if request == nil { + request = &pb.GrantCredentialRequest{} + } + response := &pb.GrantCredentialResponse{} + if err := client.do(ctx, http.MethodPost, "/v1/admin/grants", nil, request, response); err != nil { + return nil, err + } + return response, nil +} + +// RevokeGrant calls the AdminService RPC. +func (client *HTTP) RevokeGrant(ctx context.Context, request *pb.RevokeGrantRequest) (*pb.RevokeGrantResponse, error) { + if request == nil { + request = &pb.RevokeGrantRequest{} + } + pathValue0, err := pathParam("volume_id", request.GetVolumeId()) + if err != nil { + return nil, err + } + pathValue1, err := pathParam("node_id", request.GetNodeId()) + if err != nil { + return nil, err + } + query := url.Values{} + addString(query, "owner", request.GetOwner()) + addString(query, "requestId", request.GetRequestId()) + response := &pb.RevokeGrantResponse{} + if err := client.do(ctx, http.MethodDelete, "/v1/admin/grants/"+pathValue0+"/"+pathValue1, query, nil, response); err != nil { + return nil, err + } + return response, nil +} + +// CreateVolume calls the VolumeService RPC. +func (client *HTTP) CreateVolume(ctx context.Context, request *pb.CreateVolumeRequest) (*pb.CreateVolumeResponse, error) { + if request == nil { + request = &pb.CreateVolumeRequest{} + } + response := &pb.CreateVolumeResponse{} + if err := client.do(ctx, http.MethodPost, "/v1/volumes", nil, request, response); err != nil { + return nil, err + } + return response, nil +} + +// ExportVolume calls the VolumeService RPC. +func (client *HTTP) ExportVolume(ctx context.Context, request *pb.ExportVolumeRequest) (*pb.ExportVolumeResponse, error) { + if request == nil { + request = &pb.ExportVolumeRequest{} + } + pathValue0, err := pathParam("volume_id", request.GetVolumeId()) + if err != nil { + return nil, err + } + response := &pb.ExportVolumeResponse{} + if err := client.do(ctx, http.MethodPost, "/v1/volumes/"+pathValue0+":export", nil, request, response); err != nil { + return nil, err + } + return response, nil +} + +// UnexportVolume calls the VolumeService RPC. +func (client *HTTP) UnexportVolume(ctx context.Context, request *pb.UnexportVolumeRequest) (*pb.UnexportVolumeResponse, error) { + if request == nil { + request = &pb.UnexportVolumeRequest{} + } + pathValue0, err := pathParam("volume_id", request.GetVolumeId()) + if err != nil { + return nil, err + } + response := &pb.UnexportVolumeResponse{} + if err := client.do(ctx, http.MethodPost, "/v1/volumes/"+pathValue0+":unexport", nil, request, response); err != nil { + return nil, err + } + return response, nil +} + +// ResizeVolume calls the VolumeService RPC. +func (client *HTTP) ResizeVolume(ctx context.Context, request *pb.ResizeVolumeRequest) (*pb.ResizeVolumeResponse, error) { + if request == nil { + request = &pb.ResizeVolumeRequest{} + } + pathValue0, err := pathParam("volume_id", request.GetVolumeId()) + if err != nil { + return nil, err + } + response := &pb.ResizeVolumeResponse{} + if err := client.do(ctx, http.MethodPost, "/v1/volumes/"+pathValue0+":resize", nil, request, response); err != nil { + return nil, err + } + return response, nil +} + +// DeleteVolume calls the VolumeService RPC. +func (client *HTTP) DeleteVolume(ctx context.Context, request *pb.DeleteVolumeRequest) (*pb.DeleteVolumeResponse, error) { + if request == nil { + request = &pb.DeleteVolumeRequest{} + } + pathValue0, err := pathParam("volume_id", request.GetVolumeId()) + if err != nil { + return nil, err + } + query := url.Values{} + addBool(query, "force", request.GetForce()) + addString(query, "requestId", request.GetRequestId()) + response := &pb.DeleteVolumeResponse{} + if err := client.do(ctx, http.MethodDelete, "/v1/volumes/"+pathValue0, query, nil, response); err != nil { + return nil, err + } + return response, nil +} + +// GetVolume calls the VolumeService RPC. +func (client *HTTP) GetVolume(ctx context.Context, request *pb.GetVolumeRequest) (*pb.GetVolumeResponse, error) { + if request == nil { + request = &pb.GetVolumeRequest{} + } + pathValue0, err := pathParam("volume_id", request.GetVolumeId()) + if err != nil { + return nil, err + } + response := &pb.GetVolumeResponse{} + if err := client.do(ctx, http.MethodGet, "/v1/volumes/"+pathValue0, nil, nil, response); err != nil { + return nil, err + } + return response, nil +} + +// ListVolumes calls the VolumeService RPC. +func (client *HTTP) ListVolumes(ctx context.Context, request *pb.ListVolumesRequest) (*pb.ListVolumesResponse, error) { + if request == nil { + request = &pb.ListVolumesRequest{} + } + query := url.Values{} + addInt32(query, "pageSize", request.GetPageSize()) + addString(query, "pageToken", request.GetPageToken()) + response := &pb.ListVolumesResponse{} + if err := client.do(ctx, http.MethodGet, "/v1/volumes", query, nil, response); err != nil { + return nil, err + } + return response, nil +} + +// GetCredential calls the VolumeService RPC. +func (client *HTTP) GetCredential(ctx context.Context, request *pb.GetCredentialRequest) (*pb.GetCredentialResponse, error) { + if request == nil { + request = &pb.GetCredentialRequest{} + } + pathValue0, err := pathParam("volume_id", request.GetVolumeId()) + if err != nil { + return nil, err + } + query := url.Values{} + addString(query, "owner", request.GetOwner()) + response := &pb.GetCredentialResponse{} + if err := client.do(ctx, http.MethodGet, "/v1/volumes/"+pathValue0+"/credential", query, nil, response); err != nil { + return nil, err + } + return response, nil +} + +// RotateCredential calls the VolumeService RPC. +func (client *HTTP) RotateCredential(ctx context.Context, request *pb.RotateCredentialRequest) (*pb.RotateCredentialResponse, error) { + if request == nil { + request = &pb.RotateCredentialRequest{} + } + pathValue0, err := pathParam("volume_id", request.GetVolumeId()) + if err != nil { + return nil, err + } + response := &pb.RotateCredentialResponse{} + if err := client.do(ctx, http.MethodPost, "/v1/volumes/"+pathValue0+":rotate-credential", nil, request, response); err != nil { + return nil, err + } + return response, nil +} + +// GetCapacity calls the VolumeService RPC. +func (client *HTTP) GetCapacity(ctx context.Context, _ *pb.GetCapacityRequest) (*pb.GetCapacityResponse, error) { + response := &pb.GetCapacityResponse{} + if err := client.do(ctx, http.MethodGet, "/v1/capacity", nil, nil, response); err != nil { + return nil, err + } + return response, nil +} + +// ListStorageClasses calls the VolumeService RPC. +func (client *HTTP) ListStorageClasses(ctx context.Context, _ *pb.ListStorageClassesRequest) (*pb.ListStorageClassesResponse, error) { + response := &pb.ListStorageClassesResponse{} + if err := client.do(ctx, http.MethodGet, "/v1/storage-classes", nil, nil, response); err != nil { + return nil, err + } + return response, nil +} + +// GetInfo calls the VolumeService RPC. +func (client *HTTP) GetInfo(ctx context.Context, _ *pb.GetInfoRequest) (*pb.GetInfoResponse, error) { + response := &pb.GetInfoResponse{} + if err := client.do(ctx, http.MethodGet, "/v1/info", nil, nil, response); err != nil { + return nil, err + } + return response, nil +} diff --git a/client/gateway_test.go b/gen/client/gateway_test.go similarity index 100% rename from client/gateway_test.go rename to gen/client/gateway_test.go diff --git a/client/ginkgo_suite_test.go b/gen/client/ginkgo_suite_test.go similarity index 100% rename from client/ginkgo_suite_test.go rename to gen/client/ginkgo_suite_test.go diff --git a/client/grpc_test.go b/gen/client/grpc_test.go similarity index 100% rename from client/grpc_test.go rename to gen/client/grpc_test.go diff --git a/client/http_test.go b/gen/client/http_test.go similarity index 100% rename from client/http_test.go rename to gen/client/http_test.go diff --git a/client/openapi_test.go b/gen/client/openapi_test.go similarity index 97% rename from client/openapi_test.go rename to gen/client/openapi_test.go index 975057d..9bec83c 100644 --- a/client/openapi_test.go +++ b/gen/client/openapi_test.go @@ -8,7 +8,7 @@ import ( ) func bddTestOpenAPISpec(t testing.TB) { - payload, err := os.ReadFile("../gen/openapi/openapi.swagger.json") + payload, err := os.ReadFile("../openapi/openapi.swagger.json") if err != nil { t.Fatalf("read OpenAPI spec: %v", err) } diff --git a/client/testutil_test.go b/gen/client/testutil_test.go similarity index 100% rename from client/testutil_test.go rename to gen/client/testutil_test.go diff --git a/client/tls_test.go b/gen/client/tls_test.go similarity index 100% rename from client/tls_test.go rename to gen/client/tls_test.go diff --git a/hack/protoc-gen-kouja-client/client.go.tmpl b/hack/protoc-gen-kouja-client/client.go.tmpl new file mode 100644 index 0000000..1c8e12a --- /dev/null +++ b/hack/protoc-gen-kouja-client/client.go.tmpl @@ -0,0 +1,453 @@ +// Code generated by protoc-gen-kouja-client. DO NOT EDIT. +// sources: {{.Sources}} + +// Package client provides typed gRPC and HTTP/JSON clients for the kouja +// agent API. +// +// The gRPC client wraps the generated stubs in gen/kouja/v1. The HTTP client +// speaks the grpc-gateway REST/JSON surface defined by the google.api.http +// annotations in proto/kouja/v1. Both transports use mTLS with a client +// certificate trusted by the agent. +package client + +import ( + "bytes" + "context" + "crypto/tls" + "crypto/x509" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strconv" + "strings" + + pb "{{.ProtoImport}}" + statuspb "google.golang.org/genproto/googleapis/rpc/status" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/anypb" +) + +type requestIDKey struct{} + +// WithRequestID returns a context that propagates requestID to the agent. The +// gRPC client sends it as x-request-id metadata and the HTTP client sends it +// as the X-Request-Id header. +func WithRequestID(ctx context.Context, requestID string) context.Context { + return context.WithValue(ctx, requestIDKey{}, requestID) +} + +// RequestIDFromContext returns the request ID stored by [WithRequestID]. +func RequestIDFromContext(ctx context.Context) string { + requestID, _ := ctx.Value(requestIDKey{}).(string) + return requestID +} + +// LoadTLSConfig loads an mTLS client configuration from a certificate, a +// private key, and the CA used to verify the agent certificate. +func LoadTLSConfig(certFile, keyFile, caFile string) (*tls.Config, error) { + certificate, err := tls.LoadX509KeyPair(certFile, keyFile) + if err != nil { + return nil, fmt.Errorf("client: load certificate: %w", err) + } + caPEM, err := os.ReadFile(caFile) //nolint:gosec // operator-selected CA path + if err != nil { + return nil, fmt.Errorf("client: read CA: %w", err) + } + roots := x509.NewCertPool() + if !roots.AppendCertsFromPEM(caPEM) { + return nil, fmt.Errorf("client: no certificates found in %s", caFile) + } + return &tls.Config{ //nolint:gosec // kouja requires TLS 1.3 and hostname verification + MinVersion: tls.VersionTLS13, + Certificates: []tls.Certificate{certificate}, + RootCAs: roots, + }, nil +} + +// GRPC is a combined VolumeService and AdminService gRPC client. +type GRPC struct { + pb.VolumeServiceClient + pb.AdminServiceClient + conn *grpc.ClientConn +} + +// NewGRPC creates a gRPC client for target. The TLS configuration must contain +// a client certificate trusted by the agent and the CA used to verify the +// agent certificate. A target may include an https:// prefix; it is removed +// before the connection is created. +// +// NewGRPC installs a unary interceptor to propagate request IDs. Supply any +// additional interceptors with grpc.WithChainUnaryInterceptor rather than +// grpc.WithUnaryInterceptor. +func NewGRPC(target string, tlsConfig *tls.Config, opts ...grpc.DialOption) (*GRPC, error) { + if tlsConfig == nil { + return nil, fmt.Errorf("client: TLS configuration is required") + } + dialOptions := make([]grpc.DialOption, 0, len(opts)+2) + dialOptions = append(dialOptions, grpc.WithTransportCredentials(credentials.NewTLS(tlsConfig.Clone()))) + dialOptions = append(dialOptions, opts...) + dialOptions = append(dialOptions, grpc.WithChainUnaryInterceptor(requestIDUnaryInterceptor)) + return newGRPC(target, dialOptions...) +} + +// NewGRPCFromFiles creates a gRPC client using certificate files on disk. +func NewGRPCFromFiles(target, certFile, keyFile, caFile string, opts ...grpc.DialOption) (*GRPC, error) { + tlsConfig, err := LoadTLSConfig(certFile, keyFile, caFile) + if err != nil { + return nil, err + } + return NewGRPC(target, tlsConfig, opts...) +} + +func newGRPC(target string, opts ...grpc.DialOption) (*GRPC, error) { + target, err := normalizeGRPCTarget(target) + if err != nil { + return nil, err + } + conn, err := grpc.NewClient(target, opts...) + if err != nil { + return nil, fmt.Errorf("client: create gRPC client for %s: %w", target, err) + } + return &GRPC{ + VolumeServiceClient: pb.NewVolumeServiceClient(conn), + AdminServiceClient: pb.NewAdminServiceClient(conn), + conn: conn, + }, nil +} + +// Close closes the underlying gRPC connection. +func (client *GRPC) Close() error { + if client == nil || client.conn == nil { + return nil + } + return client.conn.Close() +} + +func normalizeGRPCTarget(target string) (string, error) { + target = strings.TrimSpace(target) + if target == "" { + return "", fmt.Errorf("client: gRPC target is required") + } + if strings.HasPrefix(target, "https://") || strings.HasPrefix(target, "http://") { + parsed, err := url.Parse(target) + if err != nil { + return "", fmt.Errorf("client: parse gRPC target: %w", err) + } + if parsed.Host == "" { + return "", fmt.Errorf("client: gRPC target must include a host: %q", target) + } + if parsed.Path != "" && parsed.Path != "/" { + return "", fmt.Errorf("client: gRPC target must not include a path: %q", target) + } + return parsed.Host, nil + } + return target, nil +} + +func requestIDUnaryInterceptor( + ctx context.Context, + method string, + request any, + reply any, + conn *grpc.ClientConn, + invoker grpc.UnaryInvoker, + opts ...grpc.CallOption, +) error { + if requestID := RequestIDFromContext(ctx); requestID != "" { + ctx = metadata.AppendToOutgoingContext(ctx, "x-request-id", requestID) + } + return invoker(ctx, method, request, reply, conn, opts...) +} + +const maxErrorBodyBytes = 1 << 20 + +// HTTP is a typed HTTP/JSON client for the kouja REST API. It implements the +// same operations as the gRPC services. +type HTTP struct { + baseURL string + client *http.Client +} + +type httpOptions struct { + client *http.Client +} + +// HTTPOption configures an HTTP client. +type HTTPOption func(*httpOptions) error + +// WithHTTPClient configures the underlying HTTP client. Use this to provide a +// custom transport, timeout, or instrumentation. +func WithHTTPClient(httpClient *http.Client) HTTPOption { + return func(options *httpOptions) error { + if httpClient == nil { + return fmt.Errorf("client: HTTP client is nil") + } + options.client = httpClient + return nil + } +} + +// WithTLSConfig configures an mTLS HTTP transport. The TLS configuration must +// contain a client certificate trusted by the agent and the CA used to verify +// the agent certificate. +func WithTLSConfig(tlsConfig *tls.Config) HTTPOption { + return func(options *httpOptions) error { + if tlsConfig == nil { + return fmt.Errorf("client: TLS configuration is nil") + } + transport, ok := http.DefaultTransport.(*http.Transport) + if !ok { + return fmt.Errorf("client: default HTTP transport is not *http.Transport") + } + cloned := transport.Clone() + cloned.TLSClientConfig = tlsConfig.Clone() + options.client = &http.Client{Transport: cloned} + return nil + } +} + +// NewHTTP creates an HTTP/JSON client for baseURL, which may include a path +// prefix. Use [WithTLSConfig] for mTLS, or pass a prepared client with +// [WithHTTPClient]. +func NewHTTP(baseURL string, opts ...HTTPOption) (*HTTP, error) { + normalized, err := normalizeBaseURL(baseURL) + if err != nil { + return nil, err + } + options := &httpOptions{client: http.DefaultClient} + for _, option := range opts { + if option == nil { + continue + } + if err := option(options); err != nil { + return nil, err + } + } + if options.client == nil { + return nil, fmt.Errorf("client: HTTP client is nil") + } + return &HTTP{baseURL: normalized, client: options.client}, nil +} + +// NewHTTPFromFiles creates an HTTP/JSON client using certificate files on +// disk. +func NewHTTPFromFiles(baseURL, certFile, keyFile, caFile string, opts ...HTTPOption) (*HTTP, error) { + tlsConfig, err := LoadTLSConfig(certFile, keyFile, caFile) + if err != nil { + return nil, err + } + options := make([]HTTPOption, 0, len(opts)+1) + options = append(options, WithTLSConfig(tlsConfig)) + options = append(options, opts...) + return NewHTTP(baseURL, options...) +} + +// CloseIdleConnections closes idle connections held by the underlying HTTP +// client. +func (client *HTTP) CloseIdleConnections() { + if client != nil && client.client != nil { + client.client.CloseIdleConnections() + } +} + +// Error is returned when the agent responds with a non-2xx HTTP status. The +// code and message come from the grpc-gateway google.rpc.Status error body when +// one is present. +type Error struct { + HTTPStatus int + Code codes.Code + Message string + Details []*anypb.Any +} + +// Error implements error. +func (err *Error) Error() string { + if err == nil { + return "client: " + } + if err.HTTPStatus != 0 { + statusText := http.StatusText(err.HTTPStatus) + if statusText == "" { + statusText = "HTTP error" + } + if err.Message == "" { + return fmt.Sprintf("client: HTTP %d %s", err.HTTPStatus, statusText) + } + return fmt.Sprintf("client: HTTP %d %s: %s", err.HTTPStatus, statusText, err.Message) + } + if err.Message == "" { + return fmt.Sprintf("client: %s", err.Code) + } + return fmt.Sprintf("client: %s: %s", err.Code, err.Message) +} + +// GRPCStatus lets grpc/status translate an HTTP error into a gRPC status. +func (err *Error) GRPCStatus() *status.Status { + if err == nil { + return status.New(codes.Unknown, "") + } + code := err.Code + if code == codes.OK { + code = codes.Unknown + } + return status.New(code, err.Message) +} + +func (client *HTTP) do(ctx context.Context, method, path string, query url.Values, request, response proto.Message) error { + var body io.Reader + if request != nil { + payload, err := (protojson.MarshalOptions{UseProtoNames: false}).Marshal(request) + if err != nil { + return fmt.Errorf("client: marshal %s request: %w", path, err) + } + body = bytes.NewReader(payload) + } + requestURL := client.baseURL + path + if len(query) != 0 { + requestURL += "?" + query.Encode() + } + httpRequest, err := http.NewRequestWithContext(ctx, method, requestURL, body) + if err != nil { + return fmt.Errorf("client: create %s request: %w", path, err) + } + httpRequest.Header.Set("Accept", "application/json") + if request != nil { + httpRequest.Header.Set("Content-Type", "application/json") + } + if requestID := RequestIDFromContext(ctx); requestID != "" { + httpRequest.Header.Set("X-Request-Id", requestID) + } + httpResponse, err := client.client.Do(httpRequest) + if err != nil { + return fmt.Errorf("client: %s %s: %w", method, requestURL, err) + } + defer func() { _ = httpResponse.Body.Close() }() + if httpResponse.StatusCode < http.StatusOK || httpResponse.StatusCode >= http.StatusMultipleChoices { + return decodeHTTPError(httpResponse) + } + if response == nil || httpResponse.StatusCode == http.StatusNoContent { + _, _ = io.Copy(io.Discard, httpResponse.Body) + return nil + } + payload, err := io.ReadAll(httpResponse.Body) + if err != nil { + return fmt.Errorf("client: read %s response: %w", path, err) + } + if len(bytes.TrimSpace(payload)) == 0 { + return fmt.Errorf("client: %s returned an empty response", path) + } + if err := (protojson.UnmarshalOptions{DiscardUnknown: true}).Unmarshal(payload, response); err != nil { + return fmt.Errorf("client: decode %s response: %w", path, err) + } + return nil +} + +func decodeHTTPError(response *http.Response) error { + result := &Error{HTTPStatus: response.StatusCode, Code: codes.Unknown} + payload, err := io.ReadAll(io.LimitReader(response.Body, maxErrorBodyBytes)) + if err != nil { + result.Message = response.Status + return result + } + var protoStatus statuspb.Status + if err := (protojson.UnmarshalOptions{DiscardUnknown: true}).Unmarshal(payload, &protoStatus); err == nil && + (protoStatus.GetCode() != 0 || protoStatus.GetMessage() != "") { + result.Code = codes.Code(protoStatus.GetCode()) //nolint:gosec // google.rpc.Code values are non-negative + result.Message = protoStatus.GetMessage() + result.Details = protoStatus.GetDetails() + } else { + result.Message = strings.TrimSpace(string(payload)) + } + if result.Message == "" { + result.Message = response.Status + } + return result +} + +func normalizeBaseURL(raw string) (string, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", fmt.Errorf("client: base URL is required") + } + parsed, err := url.Parse(raw) + if err != nil { + return "", fmt.Errorf("client: parse base URL: %w", err) + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return "", fmt.Errorf("client: base URL must use http or https: %q", raw) + } + if parsed.Host == "" { + return "", fmt.Errorf("client: base URL must include a host: %q", raw) + } + if parsed.RawQuery != "" || parsed.Fragment != "" { + return "", fmt.Errorf("client: base URL must not include a query or fragment: %q", raw) + } + parsed.Path = strings.TrimRight(parsed.Path, "/") + return parsed.String(), nil +} + +func pathParam(name, value string) (string, error) { + if value == "" { + return "", fmt.Errorf("client: %s is required", name) + } + return url.PathEscape(value), nil +} + +func addString(values url.Values, name, value string) { + if value != "" { + values.Set(name, value) + } +} + +func addBool(values url.Values, name string, value bool) { + if value { + values.Set(name, "true") + } +} + +func addInt32(values url.Values, name string, value int32) { + if value != 0 { + values.Set(name, strconv.FormatInt(int64(value), 10)) + } +} + +func addInt64(values url.Values, name string, value int64) { + if value != 0 { + values.Set(name, strconv.FormatInt(value, 10)) + } +} + +func addUint32(values url.Values, name string, value uint32) { + if value != 0 { + values.Set(name, strconv.FormatUint(uint64(value), 10)) + } +} + +func addUint64(values url.Values, name string, value uint64) { + if value != 0 { + values.Set(name, strconv.FormatUint(value, 10)) + } +} + +func addFloat32(values url.Values, name string, value float32) { + if value != 0 { + values.Set(name, strconv.FormatFloat(float64(value), 'f', -1, 32)) + } +} + +func addFloat64(values url.Values, name string, value float64) { + if value != 0 { + values.Set(name, strconv.FormatFloat(value, 'f', -1, 64)) + } +} + +{{range .Methods}} +{{.}}{{end}} diff --git a/hack/protoc-gen-kouja-client/main.go b/hack/protoc-gen-kouja-client/main.go new file mode 100644 index 0000000..b349b6d --- /dev/null +++ b/hack/protoc-gen-kouja-client/main.go @@ -0,0 +1,305 @@ +package main + +import ( + "bytes" + _ "embed" + "fmt" + "net/http" + "regexp" + "strconv" + "strings" + "text/template" + + "google.golang.org/genproto/googleapis/api/annotations" + "google.golang.org/protobuf/compiler/protogen" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protoreflect" + "google.golang.org/protobuf/types/descriptorpb" + "google.golang.org/protobuf/types/pluginpb" +) + +//go:embed client.go.tmpl +var clientTemplate string + +var pathParameterPattern = regexp.MustCompile(`\{([^}=]+)(?:=[^}]*)?\}`) + +type templateData struct { + ProtoImport string + Sources string + Methods []string +} + +func main() { + protogen.Options{}.Run(generate) +} + +func generate(plugin *protogen.Plugin) error { + plugin.SupportedFeatures = uint64(pluginpb.CodeGeneratorResponse_FEATURE_PROTO3_OPTIONAL) + data, err := collect(plugin) + if err != nil { + return err + } + if data == nil { + return nil + } + parsed, err := template.New("client").Parse(clientTemplate) + if err != nil { + return fmt.Errorf("parse client template: %w", err) + } + var output bytes.Buffer + if err := parsed.Execute(&output, data); err != nil { + return fmt.Errorf("execute client template: %w", err) + } + generated := plugin.NewGeneratedFile("client/client.gen.go", "github.com/voidstarit/kouja/gen/client") + generated.P(strings.TrimSpace(output.String())) + return nil +} + +func collect(plugin *protogen.Plugin) (*templateData, error) { + var protoImport protogen.GoImportPath + var sources []string + var methods []string + seen := make(map[string]bool) + for _, file := range plugin.Files { + if !file.Generate { + continue + } + if protoImport == "" { + protoImport = file.GoImportPath + } + if file.GoImportPath != protoImport { + return nil, fmt.Errorf("all generated services must share one Go package: %s and %s", protoImport, file.GoImportPath) + } + sources = append(sources, file.Desc.Path()) + for _, service := range file.Services { + for _, method := range service.Methods { + rule := httpRule(method) + if rule == nil { + continue + } + if seen[method.GoName] { + return nil, fmt.Errorf("duplicate client method %s", method.GoName) + } + seen[method.GoName] = true + code, err := generateMethod(service, method, rule) + if err != nil { + return nil, fmt.Errorf("%s.%s: %w", service.Desc.FullName(), method.Desc.Name(), err) + } + methods = append(methods, code) + } + } + } + if len(methods) == 0 { + return nil, nil + } + return &templateData{ + ProtoImport: string(protoImport), + Sources: strings.Join(sources, ", "), + Methods: methods, + }, nil +} + +func httpRule(method *protogen.Method) *annotations.HttpRule { + options, ok := method.Desc.Options().(*descriptorpb.MethodOptions) + if !ok || options == nil { + return nil + } + rule, _ := proto.GetExtension(options, annotations.E_Http).(*annotations.HttpRule) + if rule == nil || rule.GetPattern() == nil { + return nil + } + return rule +} + +func generateMethod(service *protogen.Service, method *protogen.Method, rule *annotations.HttpRule) (string, error) { + httpMethod, path, body, err := ruleDetails(rule) + if err != nil { + return "", err + } + pathNames := pathParameterNames(path) + pathExpression, pathPrelude, err := buildPath(path, method.Input) + if err != nil { + return "", err + } + queryCode, queryUsesRequest, err := buildQuery(method.Input, pathNames, body) + if err != nil { + return "", err + } + usesRequest := body == "*" || len(pathNames) > 0 || queryUsesRequest + parameter := "request" + if !usesRequest { + parameter = "_" + } + + var code strings.Builder + fmt.Fprintf(&code, "// %s calls the %s RPC.\n", method.GoName, service.GoName) + fmt.Fprintf( + &code, + "func (client *HTTP) %s(ctx context.Context, %s *pb.%s) (*pb.%s, error) {\n", + method.GoName, + parameter, + method.Input.GoIdent.GoName, + method.Output.GoIdent.GoName, + ) + if usesRequest { + fmt.Fprintf(&code, "\tif request == nil {\n\t\trequest = &pb.%s{}\n\t}\n", method.Input.GoIdent.GoName) + } + code.WriteString(pathPrelude) + queryArgument := "nil" + if queryCode != "" { + code.WriteString("\tquery := url.Values{}\n") + code.WriteString(queryCode) + queryArgument = "query" + } + fmt.Fprintf(&code, "\tresponse := &pb.%s{}\n", method.Output.GoIdent.GoName) + requestArgument := "nil" + if body == "*" { + requestArgument = "request" + } + fmt.Fprintf( + &code, + "\tif err := client.do(ctx, %s, %s, %s, %s, response); err != nil {\n\t\treturn nil, err\n\t}\n", + methodConstant(httpMethod), + pathExpression, + queryArgument, + requestArgument, + ) + code.WriteString("\treturn response, nil\n}\n") + return code.String(), nil +} + +func ruleDetails(rule *annotations.HttpRule) (string, string, string, error) { + var method, path string + switch pattern := rule.GetPattern().(type) { + case *annotations.HttpRule_Get: + method, path = http.MethodGet, pattern.Get + case *annotations.HttpRule_Post: + method, path = http.MethodPost, pattern.Post + case *annotations.HttpRule_Put: + method, path = http.MethodPut, pattern.Put + case *annotations.HttpRule_Delete: + method, path = http.MethodDelete, pattern.Delete + case *annotations.HttpRule_Patch: + method, path = http.MethodPatch, pattern.Patch + default: + return "", "", "", fmt.Errorf("unsupported HTTP pattern %T", pattern) + } + body := rule.GetBody() + if body != "" && body != "*" { + return "", "", "", fmt.Errorf("unsupported HTTP body %q", body) + } + return method, path, body, nil +} + +func methodConstant(method string) string { + switch method { + case http.MethodGet: + return "http.MethodGet" + case http.MethodPost: + return "http.MethodPost" + case http.MethodPut: + return "http.MethodPut" + case http.MethodDelete: + return "http.MethodDelete" + case http.MethodPatch: + return "http.MethodPatch" + default: + return strconv.Quote(method) + } +} + +func pathParameterNames(path string) []string { + matches := pathParameterPattern.FindAllStringSubmatch(path, -1) + names := make([]string, 0, len(matches)) + for _, match := range matches { + names = append(names, match[1]) + } + return names +} + +func buildPath(path string, message *protogen.Message) (string, string, error) { + matches := pathParameterPattern.FindAllStringSubmatchIndex(path, -1) + if len(matches) == 0 { + return strconv.Quote(path), "", nil + } + var parts []string + var prelude strings.Builder + last := 0 + for index, match := range matches { + name := path[match[2]:match[3]] + field := findField(message, name) + if field == nil { + return "", "", fmt.Errorf("path parameter %q not found in %s", name, message.Desc.FullName()) + } + if literal := path[last:match[0]]; literal != "" { + parts = append(parts, strconv.Quote(literal)) + } + variable := fmt.Sprintf("pathValue%d", index) + fmt.Fprintf(&prelude, "\t%s, err := pathParam(%q, request.Get%s())\n", variable, name, field.GoName) + prelude.WriteString("\tif err != nil {\n\t\treturn nil, err\n\t}\n") + parts = append(parts, variable) + last = match[1] + } + if literal := path[last:]; literal != "" { + parts = append(parts, strconv.Quote(literal)) + } + return strings.Join(parts, " + "), prelude.String(), nil +} + +func buildQuery(message *protogen.Message, pathNames []string, body string) (string, bool, error) { + if body == "*" { + return "", false, nil + } + pathSet := make(map[string]bool, len(pathNames)) + for _, name := range pathNames { + pathSet[name] = true + } + var code strings.Builder + usesRequest := false + for _, field := range message.Fields { + if pathSet[string(field.Desc.Name())] { + continue + } + if field.Desc.HasPresence() { + return "", false, fmt.Errorf("optional query parameter %q is not supported", field.Desc.FullName()) + } + if field.Desc.IsList() || field.Desc.IsMap() { + return "", false, fmt.Errorf("repeated query parameter %q is not supported", field.Desc.FullName()) + } + name := field.Desc.JSONName() + getter := "request.Get" + field.GoName + "()" + switch field.Desc.Kind() { + case protoreflect.StringKind: + fmt.Fprintf(&code, "\taddString(query, %q, %s)\n", name, getter) + case protoreflect.BoolKind: + fmt.Fprintf(&code, "\taddBool(query, %q, %s)\n", name, getter) + case protoreflect.Int32Kind, protoreflect.Sint32Kind, protoreflect.Sfixed32Kind: + fmt.Fprintf(&code, "\taddInt32(query, %q, %s)\n", name, getter) + case protoreflect.Int64Kind, protoreflect.Sint64Kind, protoreflect.Sfixed64Kind: + fmt.Fprintf(&code, "\taddInt64(query, %q, %s)\n", name, getter) + case protoreflect.Uint32Kind, protoreflect.Fixed32Kind: + fmt.Fprintf(&code, "\taddUint32(query, %q, %s)\n", name, getter) + case protoreflect.Uint64Kind, protoreflect.Fixed64Kind: + fmt.Fprintf(&code, "\taddUint64(query, %q, %s)\n", name, getter) + case protoreflect.FloatKind: + fmt.Fprintf(&code, "\taddFloat32(query, %q, %s)\n", name, getter) + case protoreflect.DoubleKind: + fmt.Fprintf(&code, "\taddFloat64(query, %q, %s)\n", name, getter) + case protoreflect.EnumKind: + fmt.Fprintf(&code, "\tif value := %s; value != 0 {\n\t\tquery.Set(%q, value.String())\n\t}\n", getter, name) + default: + return "", false, fmt.Errorf("unsupported query parameter type %s for %q", field.Desc.Kind(), field.Desc.FullName()) + } + usesRequest = true + } + return code.String(), usesRequest, nil +} + +func findField(message *protogen.Message, name string) *protogen.Field { + for _, field := range message.Fields { + if string(field.Desc.Name()) == name { + return field + } + } + return nil +}