diff --git a/components/api-server/go.mod b/components/api-server/go.mod index 2d5ec7824..6f7c8b0c1 100644 --- a/components/api-server/go.mod +++ b/components/api-server/go.mod @@ -64,6 +64,7 @@ require ( github.com/containerd/log v0.1.0 // indirect github.com/containerd/platforms v0.2.1 // indirect github.com/cpuguy83/dockercfg v0.3.2 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/distribution/reference v0.6.0 // indirect github.com/docker/distribution v2.8.1+incompatible // indirect github.com/docker/docker v28.5.2+incompatible // indirect @@ -112,6 +113,7 @@ require ( github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect github.com/prometheus/common v0.42.0 // indirect github.com/prometheus/procfs v0.10.1 // indirect diff --git a/components/api-server/go.sum b/components/api-server/go.sum index 89159e86d..c8c408066 100644 --- a/components/api-server/go.sum +++ b/components/api-server/go.sum @@ -88,8 +88,9 @@ github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsr github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/denisenkom/go-mssqldb v0.0.0-20190423183735-731ef375ac02/go.mod h1:zAg7JM8CkOJ43xKXIj7eRO9kmWm/TW578qo+oDO6tuM= github.com/denisenkom/go-mssqldb v0.0.0-20190515213511-eb9f6a1743f3/go.mod h1:zAg7JM8CkOJ43xKXIj7eRO9kmWm/TW578qo+oDO6tuM= github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= @@ -302,8 +303,9 @@ github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= @@ -514,8 +516,8 @@ golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20191024005414-555d28b269f0 h1:/5xXl8Y5W96D+TtHSlonuFqGHIWVuyCkGJLwGh9JJFs= -golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/components/api-server/openapi/openapi.gateways.yaml b/components/api-server/openapi/openapi.gateways.yaml index 759aa7d25..9f559cf2a 100644 --- a/components/api-server/openapi/openapi.gateways.yaml +++ b/components/api-server/openapi/openapi.gateways.yaml @@ -59,9 +59,9 @@ paths: $ref: '#/components/schemas/Gateway' '400': description: | - Validation errors occurred, including a cluster_id that is empty or does not - reference a ManagedCluster with a registered control plane (non-empty - oidc_subject). On update, cluster_id is validated only when it changes. + Validation errors occurred, including an invalid or unavailable placement + intent. The backend resolves a valid intent to a registered ManagedCluster + before creating the gateway. content: application/json: schema: @@ -90,6 +90,31 @@ paths: application/json: schema: $ref: 'openapi.yaml#/components/schemas/Error' + /api/hypershell/v1/gateways/placement-availability: + get: + operationId: getGatewayPlacementAvailability + summary: Returns current provider placement eligibility + security: + - Bearer: [] + responses: + '200': + description: Current backend placement eligibility + content: + application/json: + schema: + $ref: '#/components/schemas/GatewayPlacementAvailability' + '401': + description: Auth token is invalid + content: + application/json: + schema: + $ref: 'openapi.yaml#/components/schemas/Error' + '500': + description: Unexpected error evaluating placement availability + content: + application/json: + schema: + $ref: 'openapi.yaml#/components/schemas/Error' # NEW ENDPOINT START /api/hypershell/v1/gateways/{id}: # NEW ENDPOINT END @@ -329,12 +354,12 @@ components: type: object required: - name - - cluster_id + - placement properties: name: type: string - cluster_id: - type: string + placement: + $ref: '#/components/schemas/GatewayPlacementIntent' external_dns: type: string tls_mode: @@ -365,6 +390,51 @@ components: credential_driver: type: string description: JSON-encoded credential storage driver configuration + GatewayPlacementIntent: + type: object + description: Exactly one of network/provider or mode must be supplied. + oneOf: + - title: Managed placement + required: [network, provider] + not: + required: [mode] + - title: Local Kind placement + required: [mode] + not: + anyOf: + - required: [network] + - required: [provider] + properties: + network: + type: string + enum: [public, vpn] + provider: + type: string + enum: [aws, ibm] + description: IBM Cloud supports public placement only; VPN requires AWS. + mode: + type: string + enum: [local-kind] + GatewayPlacementAvailability: + type: object + required: [aws_public, aws_vpn, ibm_public, ibm_vpn, local_kind] + properties: + aws_public: + type: boolean + aws_vpn: + type: boolean + ibm_public: + type: boolean + ibm_vpn: + type: boolean + local_kind: + type: boolean + aws_reason: + type: string + enum: [no-eligible-cluster] + ibm_reason: + type: string + enum: [no-eligible-cluster] # NEW SCHEMA START GatewayList: # NEW SCHEMA END diff --git a/components/api-server/openapi/openapi.managedClusters.yaml b/components/api-server/openapi/openapi.managedClusters.yaml index fbcc84b91..5efa023d5 100644 --- a/components/api-server/openapi/openapi.managedClusters.yaml +++ b/components/api-server/openapi/openapi.managedClusters.yaml @@ -301,6 +301,12 @@ components: type: string provider: type: string + description: Cloud provider capability used for gateway placement. + visibility: + type: string + enum: [public, vpn] + readOnly: true + description: Network visibility supported by this control plane for gateway placement. region: type: string kubeconfig_secret: @@ -329,6 +335,14 @@ components: description: type: string description: Optional description of the spoke. + provider: + type: string + enum: [aws, ibm, kind] + description: Cloud provider hosting this control plane. + visibility: + type: string + enum: [public, vpn] + description: Network visibility supported for gateways on this control plane. ManagedClusterRegistrationResponse: type: object required: diff --git a/components/api-server/openapi/openapi.yaml b/components/api-server/openapi/openapi.yaml index e37155f45..4d99919ad 100644 --- a/components/api-server/openapi/openapi.yaml +++ b/components/api-server/openapi/openapi.yaml @@ -34,6 +34,8 @@ paths: $ref: 'openapi.managedClusters.yaml#/paths/~1api~1hypershell~1v1~1managed_clusters~1{id}' /api/hypershell/v1/gateways: $ref: 'openapi.gateways.yaml#/paths/~1api~1hypershell~1v1~1gateways' + /api/hypershell/v1/gateways/placement-availability: + $ref: 'openapi.gateways.yaml#/paths/~1api~1hypershell~1v1~1gateways~1placement-availability' /api/hypershell/v1/gateways/{id}: $ref: 'openapi.gateways.yaml#/paths/~1api~1hypershell~1v1~1gateways~1{id}' /api/hypershell/v1/gateways/{gateway_id}/service_accounts: @@ -136,6 +138,10 @@ components: $ref: 'openapi.gateways.yaml#/components/schemas/GatewayList' GatewayPatchRequest: $ref: 'openapi.gateways.yaml#/components/schemas/GatewayPatchRequest' + GatewayPlacementIntent: + $ref: 'openapi.gateways.yaml#/components/schemas/GatewayPlacementIntent' + GatewayPlacementAvailability: + $ref: 'openapi.gateways.yaml#/components/schemas/GatewayPlacementAvailability' OpenShellGatewayServiceAccountRole: $ref: 'openapi.serviceAccounts.yaml#/components/schemas/OpenShellGatewayServiceAccountRole' OpenShellGatewayServiceAccountStatus: diff --git a/components/api-server/pkg/api/grpc/hypershell/v1/gateways.pb.go b/components/api-server/pkg/api/grpc/hypershell/v1/gateways.pb.go index b5ed58aea..e09b35045 100644 --- a/components/api-server/pkg/api/grpc/hypershell/v1/gateways.pb.go +++ b/components/api-server/pkg/api/grpc/hypershell/v1/gateways.pb.go @@ -136,6 +136,176 @@ func (x *ProvisioningCondition) GetMessage() string { return "" } +type GatewayPlacementIntent struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Placement: + // + // *GatewayPlacementIntent_NetworkProvider + // *GatewayPlacementIntent_LocalKind + Placement isGatewayPlacementIntent_Placement `protobuf_oneof:"placement"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GatewayPlacementIntent) Reset() { + *x = GatewayPlacementIntent{} + mi := &file_hypershell_v1_gateways_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GatewayPlacementIntent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GatewayPlacementIntent) ProtoMessage() {} + +func (x *GatewayPlacementIntent) ProtoReflect() protoreflect.Message { + mi := &file_hypershell_v1_gateways_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GatewayPlacementIntent.ProtoReflect.Descriptor instead. +func (*GatewayPlacementIntent) Descriptor() ([]byte, []int) { + return file_hypershell_v1_gateways_proto_rawDescGZIP(), []int{1} +} + +func (x *GatewayPlacementIntent) GetPlacement() isGatewayPlacementIntent_Placement { + if x != nil { + return x.Placement + } + return nil +} + +func (x *GatewayPlacementIntent) GetNetworkProvider() *NetworkProviderPlacement { + if x != nil { + if x, ok := x.Placement.(*GatewayPlacementIntent_NetworkProvider); ok { + return x.NetworkProvider + } + } + return nil +} + +func (x *GatewayPlacementIntent) GetLocalKind() *LocalKindPlacement { + if x != nil { + if x, ok := x.Placement.(*GatewayPlacementIntent_LocalKind); ok { + return x.LocalKind + } + } + return nil +} + +type isGatewayPlacementIntent_Placement interface { + isGatewayPlacementIntent_Placement() +} + +type GatewayPlacementIntent_NetworkProvider struct { + NetworkProvider *NetworkProviderPlacement `protobuf:"bytes,1,opt,name=network_provider,json=networkProvider,proto3,oneof"` +} + +type GatewayPlacementIntent_LocalKind struct { + LocalKind *LocalKindPlacement `protobuf:"bytes,2,opt,name=local_kind,json=localKind,proto3,oneof"` +} + +func (*GatewayPlacementIntent_NetworkProvider) isGatewayPlacementIntent_Placement() {} + +func (*GatewayPlacementIntent_LocalKind) isGatewayPlacementIntent_Placement() {} + +type NetworkProviderPlacement struct { + state protoimpl.MessageState `protogen:"open.v1"` + Network string `protobuf:"bytes,1,opt,name=network,proto3" json:"network,omitempty"` + Provider string `protobuf:"bytes,2,opt,name=provider,proto3" json:"provider,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *NetworkProviderPlacement) Reset() { + *x = NetworkProviderPlacement{} + mi := &file_hypershell_v1_gateways_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *NetworkProviderPlacement) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*NetworkProviderPlacement) ProtoMessage() {} + +func (x *NetworkProviderPlacement) ProtoReflect() protoreflect.Message { + mi := &file_hypershell_v1_gateways_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use NetworkProviderPlacement.ProtoReflect.Descriptor instead. +func (*NetworkProviderPlacement) Descriptor() ([]byte, []int) { + return file_hypershell_v1_gateways_proto_rawDescGZIP(), []int{2} +} + +func (x *NetworkProviderPlacement) GetNetwork() string { + if x != nil { + return x.Network + } + return "" +} + +func (x *NetworkProviderPlacement) GetProvider() string { + if x != nil { + return x.Provider + } + return "" +} + +type LocalKindPlacement struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *LocalKindPlacement) Reset() { + *x = LocalKindPlacement{} + mi := &file_hypershell_v1_gateways_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LocalKindPlacement) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LocalKindPlacement) ProtoMessage() {} + +func (x *LocalKindPlacement) ProtoReflect() protoreflect.Message { + mi := &file_hypershell_v1_gateways_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LocalKindPlacement.ProtoReflect.Descriptor instead. +func (*LocalKindPlacement) Descriptor() ([]byte, []int) { + return file_hypershell_v1_gateways_proto_rawDescGZIP(), []int{3} +} + type Gateway struct { state protoimpl.MessageState `protogen:"open.v1"` Metadata *ObjectReference `protobuf:"bytes,1,opt,name=metadata,proto3" json:"metadata,omitempty"` @@ -172,7 +342,7 @@ type Gateway struct { func (x *Gateway) Reset() { *x = Gateway{} - mi := &file_hypershell_v1_gateways_proto_msgTypes[1] + mi := &file_hypershell_v1_gateways_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -184,7 +354,7 @@ func (x *Gateway) String() string { func (*Gateway) ProtoMessage() {} func (x *Gateway) ProtoReflect() protoreflect.Message { - mi := &file_hypershell_v1_gateways_proto_msgTypes[1] + mi := &file_hypershell_v1_gateways_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -197,7 +367,7 @@ func (x *Gateway) ProtoReflect() protoreflect.Message { // Deprecated: Use Gateway.ProtoReflect.Descriptor instead. func (*Gateway) Descriptor() ([]byte, []int) { - return file_hypershell_v1_gateways_proto_rawDescGZIP(), []int{1} + return file_hypershell_v1_gateways_proto_rawDescGZIP(), []int{4} } func (x *Gateway) GetMetadata() *ObjectReference { @@ -369,28 +539,29 @@ func (x *Gateway) GetObservedGeneration() int64 { } type CreateGatewayRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` - ClusterId string `protobuf:"bytes,3,opt,name=cluster_id,json=clusterId,proto3" json:"cluster_id,omitempty"` - ReleaseId string `protobuf:"bytes,4,opt,name=release_id,json=releaseId,proto3" json:"release_id,omitempty"` - ExternalDns *string `protobuf:"bytes,6,opt,name=external_dns,json=externalDns,proto3,oneof" json:"external_dns,omitempty"` - TlsMode *string `protobuf:"bytes,7,opt,name=tls_mode,json=tlsMode,proto3,oneof" json:"tls_mode,omitempty"` - ServiceType *string `protobuf:"bytes,8,opt,name=service_type,json=serviceType,proto3,oneof" json:"service_type,omitempty"` - Status *string `protobuf:"bytes,9,opt,name=status,proto3,oneof" json:"status,omitempty"` - Phase *string `protobuf:"bytes,10,opt,name=phase,proto3,oneof" json:"phase,omitempty"` - Image *string `protobuf:"bytes,11,opt,name=image,proto3,oneof" json:"image,omitempty"` - SupervisorImage *string `protobuf:"bytes,12,opt,name=supervisor_image,json=supervisorImage,proto3,oneof" json:"supervisor_image,omitempty"` - ServerDnsNames []string `protobuf:"bytes,13,rep,name=server_dns_names,json=serverDnsNames,proto3" json:"server_dns_names,omitempty"` - Oidc *string `protobuf:"bytes,14,opt,name=oidc,proto3,oneof" json:"oidc,omitempty"` - Route *string `protobuf:"bytes,15,opt,name=route,proto3,oneof" json:"route,omitempty"` - CredentialDriver *string `protobuf:"bytes,17,opt,name=credential_driver,json=credentialDriver,proto3,oneof" json:"credential_driver,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + ClusterId string `protobuf:"bytes,3,opt,name=cluster_id,json=clusterId,proto3" json:"cluster_id,omitempty"` + ReleaseId string `protobuf:"bytes,4,opt,name=release_id,json=releaseId,proto3" json:"release_id,omitempty"` + ExternalDns *string `protobuf:"bytes,6,opt,name=external_dns,json=externalDns,proto3,oneof" json:"external_dns,omitempty"` + TlsMode *string `protobuf:"bytes,7,opt,name=tls_mode,json=tlsMode,proto3,oneof" json:"tls_mode,omitempty"` + ServiceType *string `protobuf:"bytes,8,opt,name=service_type,json=serviceType,proto3,oneof" json:"service_type,omitempty"` + Status *string `protobuf:"bytes,9,opt,name=status,proto3,oneof" json:"status,omitempty"` + Phase *string `protobuf:"bytes,10,opt,name=phase,proto3,oneof" json:"phase,omitempty"` + Image *string `protobuf:"bytes,11,opt,name=image,proto3,oneof" json:"image,omitempty"` + SupervisorImage *string `protobuf:"bytes,12,opt,name=supervisor_image,json=supervisorImage,proto3,oneof" json:"supervisor_image,omitempty"` + ServerDnsNames []string `protobuf:"bytes,13,rep,name=server_dns_names,json=serverDnsNames,proto3" json:"server_dns_names,omitempty"` + Oidc *string `protobuf:"bytes,14,opt,name=oidc,proto3,oneof" json:"oidc,omitempty"` + Route *string `protobuf:"bytes,15,opt,name=route,proto3,oneof" json:"route,omitempty"` + CredentialDriver *string `protobuf:"bytes,17,opt,name=credential_driver,json=credentialDriver,proto3,oneof" json:"credential_driver,omitempty"` + Placement *GatewayPlacementIntent `protobuf:"bytes,18,opt,name=placement,proto3" json:"placement,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *CreateGatewayRequest) Reset() { *x = CreateGatewayRequest{} - mi := &file_hypershell_v1_gateways_proto_msgTypes[2] + mi := &file_hypershell_v1_gateways_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -402,7 +573,7 @@ func (x *CreateGatewayRequest) String() string { func (*CreateGatewayRequest) ProtoMessage() {} func (x *CreateGatewayRequest) ProtoReflect() protoreflect.Message { - mi := &file_hypershell_v1_gateways_proto_msgTypes[2] + mi := &file_hypershell_v1_gateways_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -415,7 +586,7 @@ func (x *CreateGatewayRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CreateGatewayRequest.ProtoReflect.Descriptor instead. func (*CreateGatewayRequest) Descriptor() ([]byte, []int) { - return file_hypershell_v1_gateways_proto_rawDescGZIP(), []int{2} + return file_hypershell_v1_gateways_proto_rawDescGZIP(), []int{5} } func (x *CreateGatewayRequest) GetName() string { @@ -516,6 +687,13 @@ func (x *CreateGatewayRequest) GetCredentialDriver() string { return "" } +func (x *CreateGatewayRequest) GetPlacement() *GatewayPlacementIntent { + if x != nil { + return x.Placement + } + return nil +} + type CreateGatewayResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Gateway *Gateway `protobuf:"bytes,1,opt,name=gateway,proto3" json:"gateway,omitempty"` @@ -525,7 +703,7 @@ type CreateGatewayResponse struct { func (x *CreateGatewayResponse) Reset() { *x = CreateGatewayResponse{} - mi := &file_hypershell_v1_gateways_proto_msgTypes[3] + mi := &file_hypershell_v1_gateways_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -537,7 +715,7 @@ func (x *CreateGatewayResponse) String() string { func (*CreateGatewayResponse) ProtoMessage() {} func (x *CreateGatewayResponse) ProtoReflect() protoreflect.Message { - mi := &file_hypershell_v1_gateways_proto_msgTypes[3] + mi := &file_hypershell_v1_gateways_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -550,7 +728,7 @@ func (x *CreateGatewayResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use CreateGatewayResponse.ProtoReflect.Descriptor instead. func (*CreateGatewayResponse) Descriptor() ([]byte, []int) { - return file_hypershell_v1_gateways_proto_rawDescGZIP(), []int{3} + return file_hypershell_v1_gateways_proto_rawDescGZIP(), []int{6} } func (x *CreateGatewayResponse) GetGateway() *Gateway { @@ -569,7 +747,7 @@ type GetGatewayRequest struct { func (x *GetGatewayRequest) Reset() { *x = GetGatewayRequest{} - mi := &file_hypershell_v1_gateways_proto_msgTypes[4] + mi := &file_hypershell_v1_gateways_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -581,7 +759,7 @@ func (x *GetGatewayRequest) String() string { func (*GetGatewayRequest) ProtoMessage() {} func (x *GetGatewayRequest) ProtoReflect() protoreflect.Message { - mi := &file_hypershell_v1_gateways_proto_msgTypes[4] + mi := &file_hypershell_v1_gateways_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -594,7 +772,7 @@ func (x *GetGatewayRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetGatewayRequest.ProtoReflect.Descriptor instead. func (*GetGatewayRequest) Descriptor() ([]byte, []int) { - return file_hypershell_v1_gateways_proto_rawDescGZIP(), []int{4} + return file_hypershell_v1_gateways_proto_rawDescGZIP(), []int{7} } func (x *GetGatewayRequest) GetId() string { @@ -613,7 +791,7 @@ type GetGatewayResponse struct { func (x *GetGatewayResponse) Reset() { *x = GetGatewayResponse{} - mi := &file_hypershell_v1_gateways_proto_msgTypes[5] + mi := &file_hypershell_v1_gateways_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -625,7 +803,7 @@ func (x *GetGatewayResponse) String() string { func (*GetGatewayResponse) ProtoMessage() {} func (x *GetGatewayResponse) ProtoReflect() protoreflect.Message { - mi := &file_hypershell_v1_gateways_proto_msgTypes[5] + mi := &file_hypershell_v1_gateways_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -638,7 +816,7 @@ func (x *GetGatewayResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetGatewayResponse.ProtoReflect.Descriptor instead. func (*GetGatewayResponse) Descriptor() ([]byte, []int) { - return file_hypershell_v1_gateways_proto_rawDescGZIP(), []int{5} + return file_hypershell_v1_gateways_proto_rawDescGZIP(), []int{8} } func (x *GetGatewayResponse) GetGateway() *Gateway { @@ -684,7 +862,7 @@ type UpdateGatewayRequest struct { func (x *UpdateGatewayRequest) Reset() { *x = UpdateGatewayRequest{} - mi := &file_hypershell_v1_gateways_proto_msgTypes[6] + mi := &file_hypershell_v1_gateways_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -696,7 +874,7 @@ func (x *UpdateGatewayRequest) String() string { func (*UpdateGatewayRequest) ProtoMessage() {} func (x *UpdateGatewayRequest) ProtoReflect() protoreflect.Message { - mi := &file_hypershell_v1_gateways_proto_msgTypes[6] + mi := &file_hypershell_v1_gateways_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -709,7 +887,7 @@ func (x *UpdateGatewayRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use UpdateGatewayRequest.ProtoReflect.Descriptor instead. func (*UpdateGatewayRequest) Descriptor() ([]byte, []int) { - return file_hypershell_v1_gateways_proto_rawDescGZIP(), []int{6} + return file_hypershell_v1_gateways_proto_rawDescGZIP(), []int{9} } func (x *UpdateGatewayRequest) GetId() string { @@ -861,7 +1039,7 @@ type UpdateGatewayResponse struct { func (x *UpdateGatewayResponse) Reset() { *x = UpdateGatewayResponse{} - mi := &file_hypershell_v1_gateways_proto_msgTypes[7] + mi := &file_hypershell_v1_gateways_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -873,7 +1051,7 @@ func (x *UpdateGatewayResponse) String() string { func (*UpdateGatewayResponse) ProtoMessage() {} func (x *UpdateGatewayResponse) ProtoReflect() protoreflect.Message { - mi := &file_hypershell_v1_gateways_proto_msgTypes[7] + mi := &file_hypershell_v1_gateways_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -886,7 +1064,7 @@ func (x *UpdateGatewayResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use UpdateGatewayResponse.ProtoReflect.Descriptor instead. func (*UpdateGatewayResponse) Descriptor() ([]byte, []int) { - return file_hypershell_v1_gateways_proto_rawDescGZIP(), []int{7} + return file_hypershell_v1_gateways_proto_rawDescGZIP(), []int{10} } func (x *UpdateGatewayResponse) GetGateway() *Gateway { @@ -909,7 +1087,7 @@ type AdjustActiveSandboxCountRequest struct { func (x *AdjustActiveSandboxCountRequest) Reset() { *x = AdjustActiveSandboxCountRequest{} - mi := &file_hypershell_v1_gateways_proto_msgTypes[8] + mi := &file_hypershell_v1_gateways_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -921,7 +1099,7 @@ func (x *AdjustActiveSandboxCountRequest) String() string { func (*AdjustActiveSandboxCountRequest) ProtoMessage() {} func (x *AdjustActiveSandboxCountRequest) ProtoReflect() protoreflect.Message { - mi := &file_hypershell_v1_gateways_proto_msgTypes[8] + mi := &file_hypershell_v1_gateways_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -934,7 +1112,7 @@ func (x *AdjustActiveSandboxCountRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use AdjustActiveSandboxCountRequest.ProtoReflect.Descriptor instead. func (*AdjustActiveSandboxCountRequest) Descriptor() ([]byte, []int) { - return file_hypershell_v1_gateways_proto_rawDescGZIP(), []int{8} + return file_hypershell_v1_gateways_proto_rawDescGZIP(), []int{11} } func (x *AdjustActiveSandboxCountRequest) GetNamespace() string { @@ -962,7 +1140,7 @@ type AdjustActiveSandboxCountResponse struct { func (x *AdjustActiveSandboxCountResponse) Reset() { *x = AdjustActiveSandboxCountResponse{} - mi := &file_hypershell_v1_gateways_proto_msgTypes[9] + mi := &file_hypershell_v1_gateways_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -974,7 +1152,7 @@ func (x *AdjustActiveSandboxCountResponse) String() string { func (*AdjustActiveSandboxCountResponse) ProtoMessage() {} func (x *AdjustActiveSandboxCountResponse) ProtoReflect() protoreflect.Message { - mi := &file_hypershell_v1_gateways_proto_msgTypes[9] + mi := &file_hypershell_v1_gateways_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -987,7 +1165,7 @@ func (x *AdjustActiveSandboxCountResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use AdjustActiveSandboxCountResponse.ProtoReflect.Descriptor instead. func (*AdjustActiveSandboxCountResponse) Descriptor() ([]byte, []int) { - return file_hypershell_v1_gateways_proto_rawDescGZIP(), []int{9} + return file_hypershell_v1_gateways_proto_rawDescGZIP(), []int{12} } func (x *AdjustActiveSandboxCountResponse) GetActiveSandboxCount() int32 { @@ -1010,7 +1188,7 @@ type SetActiveSandboxCountRequest struct { func (x *SetActiveSandboxCountRequest) Reset() { *x = SetActiveSandboxCountRequest{} - mi := &file_hypershell_v1_gateways_proto_msgTypes[10] + mi := &file_hypershell_v1_gateways_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1022,7 +1200,7 @@ func (x *SetActiveSandboxCountRequest) String() string { func (*SetActiveSandboxCountRequest) ProtoMessage() {} func (x *SetActiveSandboxCountRequest) ProtoReflect() protoreflect.Message { - mi := &file_hypershell_v1_gateways_proto_msgTypes[10] + mi := &file_hypershell_v1_gateways_proto_msgTypes[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1035,7 +1213,7 @@ func (x *SetActiveSandboxCountRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SetActiveSandboxCountRequest.ProtoReflect.Descriptor instead. func (*SetActiveSandboxCountRequest) Descriptor() ([]byte, []int) { - return file_hypershell_v1_gateways_proto_rawDescGZIP(), []int{10} + return file_hypershell_v1_gateways_proto_rawDescGZIP(), []int{13} } func (x *SetActiveSandboxCountRequest) GetNamespace() string { @@ -1061,7 +1239,7 @@ type SetActiveSandboxCountResponse struct { func (x *SetActiveSandboxCountResponse) Reset() { *x = SetActiveSandboxCountResponse{} - mi := &file_hypershell_v1_gateways_proto_msgTypes[11] + mi := &file_hypershell_v1_gateways_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1073,7 +1251,7 @@ func (x *SetActiveSandboxCountResponse) String() string { func (*SetActiveSandboxCountResponse) ProtoMessage() {} func (x *SetActiveSandboxCountResponse) ProtoReflect() protoreflect.Message { - mi := &file_hypershell_v1_gateways_proto_msgTypes[11] + mi := &file_hypershell_v1_gateways_proto_msgTypes[14] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1086,7 +1264,7 @@ func (x *SetActiveSandboxCountResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SetActiveSandboxCountResponse.ProtoReflect.Descriptor instead. func (*SetActiveSandboxCountResponse) Descriptor() ([]byte, []int) { - return file_hypershell_v1_gateways_proto_rawDescGZIP(), []int{11} + return file_hypershell_v1_gateways_proto_rawDescGZIP(), []int{14} } func (x *SetActiveSandboxCountResponse) GetActiveSandboxCount() int32 { @@ -1108,7 +1286,7 @@ type SetGatewayVersionRequest struct { func (x *SetGatewayVersionRequest) Reset() { *x = SetGatewayVersionRequest{} - mi := &file_hypershell_v1_gateways_proto_msgTypes[12] + mi := &file_hypershell_v1_gateways_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1120,7 +1298,7 @@ func (x *SetGatewayVersionRequest) String() string { func (*SetGatewayVersionRequest) ProtoMessage() {} func (x *SetGatewayVersionRequest) ProtoReflect() protoreflect.Message { - mi := &file_hypershell_v1_gateways_proto_msgTypes[12] + mi := &file_hypershell_v1_gateways_proto_msgTypes[15] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1133,7 +1311,7 @@ func (x *SetGatewayVersionRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SetGatewayVersionRequest.ProtoReflect.Descriptor instead. func (*SetGatewayVersionRequest) Descriptor() ([]byte, []int) { - return file_hypershell_v1_gateways_proto_rawDescGZIP(), []int{12} + return file_hypershell_v1_gateways_proto_rawDescGZIP(), []int{15} } func (x *SetGatewayVersionRequest) GetId() string { @@ -1159,7 +1337,7 @@ type SetGatewayVersionResponse struct { func (x *SetGatewayVersionResponse) Reset() { *x = SetGatewayVersionResponse{} - mi := &file_hypershell_v1_gateways_proto_msgTypes[13] + mi := &file_hypershell_v1_gateways_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1171,7 +1349,7 @@ func (x *SetGatewayVersionResponse) String() string { func (*SetGatewayVersionResponse) ProtoMessage() {} func (x *SetGatewayVersionResponse) ProtoReflect() protoreflect.Message { - mi := &file_hypershell_v1_gateways_proto_msgTypes[13] + mi := &file_hypershell_v1_gateways_proto_msgTypes[16] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1184,7 +1362,7 @@ func (x *SetGatewayVersionResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SetGatewayVersionResponse.ProtoReflect.Descriptor instead. func (*SetGatewayVersionResponse) Descriptor() ([]byte, []int) { - return file_hypershell_v1_gateways_proto_rawDescGZIP(), []int{13} + return file_hypershell_v1_gateways_proto_rawDescGZIP(), []int{16} } func (x *SetGatewayVersionResponse) GetGatewayVersion() string { @@ -1203,7 +1381,7 @@ type DeleteGatewayRequest struct { func (x *DeleteGatewayRequest) Reset() { *x = DeleteGatewayRequest{} - mi := &file_hypershell_v1_gateways_proto_msgTypes[14] + mi := &file_hypershell_v1_gateways_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1215,7 +1393,7 @@ func (x *DeleteGatewayRequest) String() string { func (*DeleteGatewayRequest) ProtoMessage() {} func (x *DeleteGatewayRequest) ProtoReflect() protoreflect.Message { - mi := &file_hypershell_v1_gateways_proto_msgTypes[14] + mi := &file_hypershell_v1_gateways_proto_msgTypes[17] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1228,7 +1406,7 @@ func (x *DeleteGatewayRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteGatewayRequest.ProtoReflect.Descriptor instead. func (*DeleteGatewayRequest) Descriptor() ([]byte, []int) { - return file_hypershell_v1_gateways_proto_rawDescGZIP(), []int{14} + return file_hypershell_v1_gateways_proto_rawDescGZIP(), []int{17} } func (x *DeleteGatewayRequest) GetId() string { @@ -1253,7 +1431,7 @@ type ListGatewaysRequest struct { func (x *ListGatewaysRequest) Reset() { *x = ListGatewaysRequest{} - mi := &file_hypershell_v1_gateways_proto_msgTypes[15] + mi := &file_hypershell_v1_gateways_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1265,7 +1443,7 @@ func (x *ListGatewaysRequest) String() string { func (*ListGatewaysRequest) ProtoMessage() {} func (x *ListGatewaysRequest) ProtoReflect() protoreflect.Message { - mi := &file_hypershell_v1_gateways_proto_msgTypes[15] + mi := &file_hypershell_v1_gateways_proto_msgTypes[18] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1278,7 +1456,7 @@ func (x *ListGatewaysRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListGatewaysRequest.ProtoReflect.Descriptor instead. func (*ListGatewaysRequest) Descriptor() ([]byte, []int) { - return file_hypershell_v1_gateways_proto_rawDescGZIP(), []int{15} + return file_hypershell_v1_gateways_proto_rawDescGZIP(), []int{18} } func (x *ListGatewaysRequest) GetPage() int32 { @@ -1312,7 +1490,7 @@ type ListGatewaysResponse struct { func (x *ListGatewaysResponse) Reset() { *x = ListGatewaysResponse{} - mi := &file_hypershell_v1_gateways_proto_msgTypes[16] + mi := &file_hypershell_v1_gateways_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1324,7 +1502,7 @@ func (x *ListGatewaysResponse) String() string { func (*ListGatewaysResponse) ProtoMessage() {} func (x *ListGatewaysResponse) ProtoReflect() protoreflect.Message { - mi := &file_hypershell_v1_gateways_proto_msgTypes[16] + mi := &file_hypershell_v1_gateways_proto_msgTypes[19] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1337,7 +1515,7 @@ func (x *ListGatewaysResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListGatewaysResponse.ProtoReflect.Descriptor instead. func (*ListGatewaysResponse) Descriptor() ([]byte, []int) { - return file_hypershell_v1_gateways_proto_rawDescGZIP(), []int{16} + return file_hypershell_v1_gateways_proto_rawDescGZIP(), []int{19} } func (x *ListGatewaysResponse) GetItems() []*Gateway { @@ -1362,7 +1540,7 @@ type DeleteGatewayResponse struct { func (x *DeleteGatewayResponse) Reset() { *x = DeleteGatewayResponse{} - mi := &file_hypershell_v1_gateways_proto_msgTypes[17] + mi := &file_hypershell_v1_gateways_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1374,7 +1552,7 @@ func (x *DeleteGatewayResponse) String() string { func (*DeleteGatewayResponse) ProtoMessage() {} func (x *DeleteGatewayResponse) ProtoReflect() protoreflect.Message { - mi := &file_hypershell_v1_gateways_proto_msgTypes[17] + mi := &file_hypershell_v1_gateways_proto_msgTypes[20] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1387,7 +1565,7 @@ func (x *DeleteGatewayResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteGatewayResponse.ProtoReflect.Descriptor instead. func (*DeleteGatewayResponse) Descriptor() ([]byte, []int) { - return file_hypershell_v1_gateways_proto_rawDescGZIP(), []int{17} + return file_hypershell_v1_gateways_proto_rawDescGZIP(), []int{20} } type WatchGatewaysRequest struct { @@ -1403,7 +1581,7 @@ type WatchGatewaysRequest struct { func (x *WatchGatewaysRequest) Reset() { *x = WatchGatewaysRequest{} - mi := &file_hypershell_v1_gateways_proto_msgTypes[18] + mi := &file_hypershell_v1_gateways_proto_msgTypes[21] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1415,7 +1593,7 @@ func (x *WatchGatewaysRequest) String() string { func (*WatchGatewaysRequest) ProtoMessage() {} func (x *WatchGatewaysRequest) ProtoReflect() protoreflect.Message { - mi := &file_hypershell_v1_gateways_proto_msgTypes[18] + mi := &file_hypershell_v1_gateways_proto_msgTypes[21] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1428,7 +1606,7 @@ func (x *WatchGatewaysRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use WatchGatewaysRequest.ProtoReflect.Descriptor instead. func (*WatchGatewaysRequest) Descriptor() ([]byte, []int) { - return file_hypershell_v1_gateways_proto_rawDescGZIP(), []int{18} + return file_hypershell_v1_gateways_proto_rawDescGZIP(), []int{21} } func (x *WatchGatewaysRequest) GetClusterId() string { @@ -1449,7 +1627,7 @@ type WatchGatewaysResponse struct { func (x *WatchGatewaysResponse) Reset() { *x = WatchGatewaysResponse{} - mi := &file_hypershell_v1_gateways_proto_msgTypes[19] + mi := &file_hypershell_v1_gateways_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1461,7 +1639,7 @@ func (x *WatchGatewaysResponse) String() string { func (*WatchGatewaysResponse) ProtoMessage() {} func (x *WatchGatewaysResponse) ProtoReflect() protoreflect.Message { - mi := &file_hypershell_v1_gateways_proto_msgTypes[19] + mi := &file_hypershell_v1_gateways_proto_msgTypes[22] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1474,7 +1652,7 @@ func (x *WatchGatewaysResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use WatchGatewaysResponse.ProtoReflect.Descriptor instead. func (*WatchGatewaysResponse) Descriptor() ([]byte, []int) { - return file_hypershell_v1_gateways_proto_rawDescGZIP(), []int{19} + return file_hypershell_v1_gateways_proto_rawDescGZIP(), []int{22} } func (x *WatchGatewaysResponse) GetType() EventType { @@ -1506,7 +1684,16 @@ const file_hypershell_v1_gateways_proto_rawDesc = "" + "\x15ProvisioningCondition\x12\x12\n" + "\x04type\x18\x01 \x01(\tR\x04type\x12U\n" + "\x10condition_status\x18\x02 \x01(\x0e2*.hypershell.v1.ProvisioningConditionStatusR\x0fconditionStatus\x12\x18\n" + - "\amessage\x18\x03 \x01(\tR\amessage\"\x91\n" + + "\amessage\x18\x03 \x01(\tR\amessage\"\xbf\x01\n" + + "\x16GatewayPlacementIntent\x12T\n" + + "\x10network_provider\x18\x01 \x01(\v2'.hypershell.v1.NetworkProviderPlacementH\x00R\x0fnetworkProvider\x12B\n" + + "\n" + + "local_kind\x18\x02 \x01(\v2!.hypershell.v1.LocalKindPlacementH\x00R\tlocalKindB\v\n" + + "\tplacement\"P\n" + + "\x18NetworkProviderPlacement\x12\x18\n" + + "\anetwork\x18\x01 \x01(\tR\anetwork\x12\x1a\n" + + "\bprovider\x18\x02 \x01(\tR\bprovider\"\x14\n" + + "\x12LocalKindPlacement\"\x91\n" + "\n" + "\aGateway\x12:\n" + "\bmetadata\x18\x01 \x01(\v2\x1e.hypershell.v1.ObjectReferenceR\bmetadata\x12\x12\n" + @@ -1554,7 +1741,7 @@ const file_hypershell_v1_gateways_proto_rawDesc = "" + "\x10_console_addressB\x12\n" + "\x10_gateway_versionB\x16\n" + "\x14_observed_release_idB\x16\n" + - "\x14_observed_generationJ\x04\b\x03\x10\x04J\x04\b\x06\x10\aR\bfleet_idR\vdatabase_id\"\x9a\x05\n" + + "\x14_observed_generationJ\x04\b\x03\x10\x04J\x04\b\x06\x10\aR\bfleet_idR\vdatabase_id\"\xdf\x05\n" + "\x14CreateGatewayRequest\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12\x1d\n" + "\n" + @@ -1572,7 +1759,8 @@ const file_hypershell_v1_gateways_proto_rawDesc = "" + "\x10server_dns_names\x18\r \x03(\tR\x0eserverDnsNames\x12\x17\n" + "\x04oidc\x18\x0e \x01(\tH\aR\x04oidc\x88\x01\x01\x12\x19\n" + "\x05route\x18\x0f \x01(\tH\bR\x05route\x88\x01\x01\x120\n" + - "\x11credential_driver\x18\x11 \x01(\tH\tR\x10credentialDriver\x88\x01\x01B\x0f\n" + + "\x11credential_driver\x18\x11 \x01(\tH\tR\x10credentialDriver\x88\x01\x01\x12C\n" + + "\tplacement\x18\x12 \x01(\v2%.hypershell.v1.GatewayPlacementIntentR\tplacementB\x0f\n" + "\r_external_dnsB\v\n" + "\t_tls_modeB\x0f\n" + "\r_service_typeB\t\n" + @@ -1700,68 +1888,74 @@ func file_hypershell_v1_gateways_proto_rawDescGZIP() []byte { } var file_hypershell_v1_gateways_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_hypershell_v1_gateways_proto_msgTypes = make([]protoimpl.MessageInfo, 20) +var file_hypershell_v1_gateways_proto_msgTypes = make([]protoimpl.MessageInfo, 23) var file_hypershell_v1_gateways_proto_goTypes = []any{ (ProvisioningConditionStatus)(0), // 0: hypershell.v1.ProvisioningConditionStatus (*ProvisioningCondition)(nil), // 1: hypershell.v1.ProvisioningCondition - (*Gateway)(nil), // 2: hypershell.v1.Gateway - (*CreateGatewayRequest)(nil), // 3: hypershell.v1.CreateGatewayRequest - (*CreateGatewayResponse)(nil), // 4: hypershell.v1.CreateGatewayResponse - (*GetGatewayRequest)(nil), // 5: hypershell.v1.GetGatewayRequest - (*GetGatewayResponse)(nil), // 6: hypershell.v1.GetGatewayResponse - (*UpdateGatewayRequest)(nil), // 7: hypershell.v1.UpdateGatewayRequest - (*UpdateGatewayResponse)(nil), // 8: hypershell.v1.UpdateGatewayResponse - (*AdjustActiveSandboxCountRequest)(nil), // 9: hypershell.v1.AdjustActiveSandboxCountRequest - (*AdjustActiveSandboxCountResponse)(nil), // 10: hypershell.v1.AdjustActiveSandboxCountResponse - (*SetActiveSandboxCountRequest)(nil), // 11: hypershell.v1.SetActiveSandboxCountRequest - (*SetActiveSandboxCountResponse)(nil), // 12: hypershell.v1.SetActiveSandboxCountResponse - (*SetGatewayVersionRequest)(nil), // 13: hypershell.v1.SetGatewayVersionRequest - (*SetGatewayVersionResponse)(nil), // 14: hypershell.v1.SetGatewayVersionResponse - (*DeleteGatewayRequest)(nil), // 15: hypershell.v1.DeleteGatewayRequest - (*ListGatewaysRequest)(nil), // 16: hypershell.v1.ListGatewaysRequest - (*ListGatewaysResponse)(nil), // 17: hypershell.v1.ListGatewaysResponse - (*DeleteGatewayResponse)(nil), // 18: hypershell.v1.DeleteGatewayResponse - (*WatchGatewaysRequest)(nil), // 19: hypershell.v1.WatchGatewaysRequest - (*WatchGatewaysResponse)(nil), // 20: hypershell.v1.WatchGatewaysResponse - (*ObjectReference)(nil), // 21: hypershell.v1.ObjectReference - (*ListMeta)(nil), // 22: hypershell.v1.ListMeta - (EventType)(0), // 23: hypershell.v1.EventType + (*GatewayPlacementIntent)(nil), // 2: hypershell.v1.GatewayPlacementIntent + (*NetworkProviderPlacement)(nil), // 3: hypershell.v1.NetworkProviderPlacement + (*LocalKindPlacement)(nil), // 4: hypershell.v1.LocalKindPlacement + (*Gateway)(nil), // 5: hypershell.v1.Gateway + (*CreateGatewayRequest)(nil), // 6: hypershell.v1.CreateGatewayRequest + (*CreateGatewayResponse)(nil), // 7: hypershell.v1.CreateGatewayResponse + (*GetGatewayRequest)(nil), // 8: hypershell.v1.GetGatewayRequest + (*GetGatewayResponse)(nil), // 9: hypershell.v1.GetGatewayResponse + (*UpdateGatewayRequest)(nil), // 10: hypershell.v1.UpdateGatewayRequest + (*UpdateGatewayResponse)(nil), // 11: hypershell.v1.UpdateGatewayResponse + (*AdjustActiveSandboxCountRequest)(nil), // 12: hypershell.v1.AdjustActiveSandboxCountRequest + (*AdjustActiveSandboxCountResponse)(nil), // 13: hypershell.v1.AdjustActiveSandboxCountResponse + (*SetActiveSandboxCountRequest)(nil), // 14: hypershell.v1.SetActiveSandboxCountRequest + (*SetActiveSandboxCountResponse)(nil), // 15: hypershell.v1.SetActiveSandboxCountResponse + (*SetGatewayVersionRequest)(nil), // 16: hypershell.v1.SetGatewayVersionRequest + (*SetGatewayVersionResponse)(nil), // 17: hypershell.v1.SetGatewayVersionResponse + (*DeleteGatewayRequest)(nil), // 18: hypershell.v1.DeleteGatewayRequest + (*ListGatewaysRequest)(nil), // 19: hypershell.v1.ListGatewaysRequest + (*ListGatewaysResponse)(nil), // 20: hypershell.v1.ListGatewaysResponse + (*DeleteGatewayResponse)(nil), // 21: hypershell.v1.DeleteGatewayResponse + (*WatchGatewaysRequest)(nil), // 22: hypershell.v1.WatchGatewaysRequest + (*WatchGatewaysResponse)(nil), // 23: hypershell.v1.WatchGatewaysResponse + (*ObjectReference)(nil), // 24: hypershell.v1.ObjectReference + (*ListMeta)(nil), // 25: hypershell.v1.ListMeta + (EventType)(0), // 26: hypershell.v1.EventType } var file_hypershell_v1_gateways_proto_depIdxs = []int32{ 0, // 0: hypershell.v1.ProvisioningCondition.condition_status:type_name -> hypershell.v1.ProvisioningConditionStatus - 21, // 1: hypershell.v1.Gateway.metadata:type_name -> hypershell.v1.ObjectReference - 1, // 2: hypershell.v1.Gateway.provisioning_conditions:type_name -> hypershell.v1.ProvisioningCondition - 2, // 3: hypershell.v1.CreateGatewayResponse.gateway:type_name -> hypershell.v1.Gateway - 2, // 4: hypershell.v1.GetGatewayResponse.gateway:type_name -> hypershell.v1.Gateway - 1, // 5: hypershell.v1.UpdateGatewayRequest.provisioning_conditions:type_name -> hypershell.v1.ProvisioningCondition - 2, // 6: hypershell.v1.UpdateGatewayResponse.gateway:type_name -> hypershell.v1.Gateway - 2, // 7: hypershell.v1.ListGatewaysResponse.items:type_name -> hypershell.v1.Gateway - 22, // 8: hypershell.v1.ListGatewaysResponse.metadata:type_name -> hypershell.v1.ListMeta - 23, // 9: hypershell.v1.WatchGatewaysResponse.type:type_name -> hypershell.v1.EventType - 2, // 10: hypershell.v1.WatchGatewaysResponse.gateway:type_name -> hypershell.v1.Gateway - 5, // 11: hypershell.v1.GatewayService.GetGateway:input_type -> hypershell.v1.GetGatewayRequest - 3, // 12: hypershell.v1.GatewayService.CreateGateway:input_type -> hypershell.v1.CreateGatewayRequest - 7, // 13: hypershell.v1.GatewayService.UpdateGateway:input_type -> hypershell.v1.UpdateGatewayRequest - 15, // 14: hypershell.v1.GatewayService.DeleteGateway:input_type -> hypershell.v1.DeleteGatewayRequest - 16, // 15: hypershell.v1.GatewayService.ListGateways:input_type -> hypershell.v1.ListGatewaysRequest - 19, // 16: hypershell.v1.GatewayService.WatchGateways:input_type -> hypershell.v1.WatchGatewaysRequest - 9, // 17: hypershell.v1.GatewayService.AdjustActiveSandboxCount:input_type -> hypershell.v1.AdjustActiveSandboxCountRequest - 11, // 18: hypershell.v1.GatewayService.SetActiveSandboxCount:input_type -> hypershell.v1.SetActiveSandboxCountRequest - 13, // 19: hypershell.v1.GatewayService.SetGatewayVersion:input_type -> hypershell.v1.SetGatewayVersionRequest - 6, // 20: hypershell.v1.GatewayService.GetGateway:output_type -> hypershell.v1.GetGatewayResponse - 4, // 21: hypershell.v1.GatewayService.CreateGateway:output_type -> hypershell.v1.CreateGatewayResponse - 8, // 22: hypershell.v1.GatewayService.UpdateGateway:output_type -> hypershell.v1.UpdateGatewayResponse - 18, // 23: hypershell.v1.GatewayService.DeleteGateway:output_type -> hypershell.v1.DeleteGatewayResponse - 17, // 24: hypershell.v1.GatewayService.ListGateways:output_type -> hypershell.v1.ListGatewaysResponse - 20, // 25: hypershell.v1.GatewayService.WatchGateways:output_type -> hypershell.v1.WatchGatewaysResponse - 10, // 26: hypershell.v1.GatewayService.AdjustActiveSandboxCount:output_type -> hypershell.v1.AdjustActiveSandboxCountResponse - 12, // 27: hypershell.v1.GatewayService.SetActiveSandboxCount:output_type -> hypershell.v1.SetActiveSandboxCountResponse - 14, // 28: hypershell.v1.GatewayService.SetGatewayVersion:output_type -> hypershell.v1.SetGatewayVersionResponse - 20, // [20:29] is the sub-list for method output_type - 11, // [11:20] is the sub-list for method input_type - 11, // [11:11] is the sub-list for extension type_name - 11, // [11:11] is the sub-list for extension extendee - 0, // [0:11] is the sub-list for field type_name + 3, // 1: hypershell.v1.GatewayPlacementIntent.network_provider:type_name -> hypershell.v1.NetworkProviderPlacement + 4, // 2: hypershell.v1.GatewayPlacementIntent.local_kind:type_name -> hypershell.v1.LocalKindPlacement + 24, // 3: hypershell.v1.Gateway.metadata:type_name -> hypershell.v1.ObjectReference + 1, // 4: hypershell.v1.Gateway.provisioning_conditions:type_name -> hypershell.v1.ProvisioningCondition + 2, // 5: hypershell.v1.CreateGatewayRequest.placement:type_name -> hypershell.v1.GatewayPlacementIntent + 5, // 6: hypershell.v1.CreateGatewayResponse.gateway:type_name -> hypershell.v1.Gateway + 5, // 7: hypershell.v1.GetGatewayResponse.gateway:type_name -> hypershell.v1.Gateway + 1, // 8: hypershell.v1.UpdateGatewayRequest.provisioning_conditions:type_name -> hypershell.v1.ProvisioningCondition + 5, // 9: hypershell.v1.UpdateGatewayResponse.gateway:type_name -> hypershell.v1.Gateway + 5, // 10: hypershell.v1.ListGatewaysResponse.items:type_name -> hypershell.v1.Gateway + 25, // 11: hypershell.v1.ListGatewaysResponse.metadata:type_name -> hypershell.v1.ListMeta + 26, // 12: hypershell.v1.WatchGatewaysResponse.type:type_name -> hypershell.v1.EventType + 5, // 13: hypershell.v1.WatchGatewaysResponse.gateway:type_name -> hypershell.v1.Gateway + 8, // 14: hypershell.v1.GatewayService.GetGateway:input_type -> hypershell.v1.GetGatewayRequest + 6, // 15: hypershell.v1.GatewayService.CreateGateway:input_type -> hypershell.v1.CreateGatewayRequest + 10, // 16: hypershell.v1.GatewayService.UpdateGateway:input_type -> hypershell.v1.UpdateGatewayRequest + 18, // 17: hypershell.v1.GatewayService.DeleteGateway:input_type -> hypershell.v1.DeleteGatewayRequest + 19, // 18: hypershell.v1.GatewayService.ListGateways:input_type -> hypershell.v1.ListGatewaysRequest + 22, // 19: hypershell.v1.GatewayService.WatchGateways:input_type -> hypershell.v1.WatchGatewaysRequest + 12, // 20: hypershell.v1.GatewayService.AdjustActiveSandboxCount:input_type -> hypershell.v1.AdjustActiveSandboxCountRequest + 14, // 21: hypershell.v1.GatewayService.SetActiveSandboxCount:input_type -> hypershell.v1.SetActiveSandboxCountRequest + 16, // 22: hypershell.v1.GatewayService.SetGatewayVersion:input_type -> hypershell.v1.SetGatewayVersionRequest + 9, // 23: hypershell.v1.GatewayService.GetGateway:output_type -> hypershell.v1.GetGatewayResponse + 7, // 24: hypershell.v1.GatewayService.CreateGateway:output_type -> hypershell.v1.CreateGatewayResponse + 11, // 25: hypershell.v1.GatewayService.UpdateGateway:output_type -> hypershell.v1.UpdateGatewayResponse + 21, // 26: hypershell.v1.GatewayService.DeleteGateway:output_type -> hypershell.v1.DeleteGatewayResponse + 20, // 27: hypershell.v1.GatewayService.ListGateways:output_type -> hypershell.v1.ListGatewaysResponse + 23, // 28: hypershell.v1.GatewayService.WatchGateways:output_type -> hypershell.v1.WatchGatewaysResponse + 13, // 29: hypershell.v1.GatewayService.AdjustActiveSandboxCount:output_type -> hypershell.v1.AdjustActiveSandboxCountResponse + 15, // 30: hypershell.v1.GatewayService.SetActiveSandboxCount:output_type -> hypershell.v1.SetActiveSandboxCountResponse + 17, // 31: hypershell.v1.GatewayService.SetGatewayVersion:output_type -> hypershell.v1.SetGatewayVersionResponse + 23, // [23:32] is the sub-list for method output_type + 14, // [14:23] is the sub-list for method input_type + 14, // [14:14] is the sub-list for extension type_name + 14, // [14:14] is the sub-list for extension extendee + 0, // [0:14] is the sub-list for field type_name } func init() { file_hypershell_v1_gateways_proto_init() } @@ -1770,18 +1964,22 @@ func file_hypershell_v1_gateways_proto_init() { return } file_hypershell_v1_common_proto_init() - file_hypershell_v1_gateways_proto_msgTypes[1].OneofWrappers = []any{} - file_hypershell_v1_gateways_proto_msgTypes[2].OneofWrappers = []any{} - file_hypershell_v1_gateways_proto_msgTypes[6].OneofWrappers = []any{} - file_hypershell_v1_gateways_proto_msgTypes[15].OneofWrappers = []any{} + file_hypershell_v1_gateways_proto_msgTypes[1].OneofWrappers = []any{ + (*GatewayPlacementIntent_NetworkProvider)(nil), + (*GatewayPlacementIntent_LocalKind)(nil), + } + file_hypershell_v1_gateways_proto_msgTypes[4].OneofWrappers = []any{} + file_hypershell_v1_gateways_proto_msgTypes[5].OneofWrappers = []any{} + file_hypershell_v1_gateways_proto_msgTypes[9].OneofWrappers = []any{} file_hypershell_v1_gateways_proto_msgTypes[18].OneofWrappers = []any{} + file_hypershell_v1_gateways_proto_msgTypes[21].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_hypershell_v1_gateways_proto_rawDesc), len(file_hypershell_v1_gateways_proto_rawDesc)), NumEnums: 1, - NumMessages: 20, + NumMessages: 23, NumExtensions: 0, NumServices: 1, }, diff --git a/components/api-server/pkg/api/openapi/.openapi-generator/FILES b/components/api-server/pkg/api/openapi/.openapi-generator/FILES index b33907ac0..fa0ab9bc7 100644 --- a/components/api-server/pkg/api/openapi/.openapi-generator/FILES +++ b/components/api-server/pkg/api/openapi/.openapi-generator/FILES @@ -16,6 +16,8 @@ docs/GatewayAllOfProvisioningConditions.md docs/GatewayCreateRequest.md docs/GatewayList.md docs/GatewayPatchRequest.md +docs/GatewayPlacementAvailability.md +docs/GatewayPlacementIntent.md docs/InferenceRoute.md docs/InferenceRouteList.md docs/List.md @@ -66,6 +68,8 @@ model_gateway_all_of_provisioning_conditions.go model_gateway_create_request.go model_gateway_list.go model_gateway_patch_request.go +model_gateway_placement_availability.go +model_gateway_placement_intent.go model_inference_route.go model_inference_route_list.go model_list.go diff --git a/components/api-server/pkg/api/openapi/README.md b/components/api-server/pkg/api/openapi/README.md index c98e9b5e9..f157380f6 100644 --- a/components/api-server/pkg/api/openapi/README.md +++ b/components/api-server/pkg/api/openapi/README.md @@ -100,6 +100,7 @@ Class | Method | HTTP request | Description *DefaultAPI* | [**DeleteSecretSource**](docs/DefaultAPI.md#deletesecretsource) | **Delete** /api/hypershell/ext/secret_sources/{id} | Delete a secret source *DefaultAPI* | [**GetAgentRuntime**](docs/DefaultAPI.md#getagentruntime) | **Get** /api/hypershell/ext/agent_runtimes/{id} | Get an agent runtime by ID *DefaultAPI* | [**GetGateway**](docs/DefaultAPI.md#getgateway) | **Get** /api/hypershell/v1/gateways/{id} | Get an gateway by id +*DefaultAPI* | [**GetGatewayPlacementAvailability**](docs/DefaultAPI.md#getgatewayplacementavailability) | **Get** /api/hypershell/v1/gateways/placement-availability | Returns current provider placement eligibility *DefaultAPI* | [**GetGatewayServiceAccount**](docs/DefaultAPI.md#getgatewayserviceaccount) | **Get** /api/hypershell/v1/gateways/{gateway_id}/service_accounts/{service_account_id} | Get an OpenShell gateway service account *DefaultAPI* | [**GetInferenceRoute**](docs/DefaultAPI.md#getinferenceroute) | **Get** /api/hypershell/ext/inference_routes/{id} | Get an inference route by ID *DefaultAPI* | [**GetManagedCluster**](docs/DefaultAPI.md#getmanagedcluster) | **Get** /api/hypershell/v1/managed_clusters/{id} | Get an managedCluster by id @@ -145,6 +146,8 @@ Class | Method | HTTP request | Description - [GatewayCreateRequest](docs/GatewayCreateRequest.md) - [GatewayList](docs/GatewayList.md) - [GatewayPatchRequest](docs/GatewayPatchRequest.md) + - [GatewayPlacementAvailability](docs/GatewayPlacementAvailability.md) + - [GatewayPlacementIntent](docs/GatewayPlacementIntent.md) - [InferenceRoute](docs/InferenceRoute.md) - [InferenceRouteList](docs/InferenceRouteList.md) - [List](docs/List.md) diff --git a/components/api-server/pkg/api/openapi/api/openapi.yaml b/components/api-server/pkg/api/openapi/api/openapi.yaml index c7a2ebdd4..e739bc88c 100644 --- a/components/api-server/pkg/api/openapi/api/openapi.yaml +++ b/components/api-server/pkg/api/openapi/api/openapi.yaml @@ -474,9 +474,9 @@ paths: schema: $ref: "#/components/schemas/Error" description: | - Validation errors occurred, including a cluster_id that is empty or does not - reference a ManagedCluster with a registered control plane (non-empty - oidc_subject). On update, cluster_id is validated only when it changes. + Validation errors occurred, including an invalid or unavailable placement + intent. The backend resolves a valid intent to a registered ManagedCluster + before creating the gateway. "401": content: application/json: @@ -504,6 +504,31 @@ paths: security: - Bearer: [] summary: Create a new gateway + /api/hypershell/v1/gateways/placement-availability: + get: + operationId: getGatewayPlacementAvailability + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/GatewayPlacementAvailability" + description: Current backend placement eligibility + "401": + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + description: Auth token is invalid + "500": + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + description: Unexpected error evaluating placement availability + security: + - Bearer: [] + summary: Returns current provider placement eligibility /api/hypershell/v1/gateways/{id}: delete: operationId: deleteGateway @@ -2826,6 +2851,15 @@ components: name: type: string provider: + description: Cloud provider capability used for gateway placement. + type: string + visibility: + description: Network visibility supported by this control plane for gateway + placement. + enum: + - public + - vpn + readOnly: true type: string region: type: string @@ -2852,6 +2886,7 @@ components: - provider type: object example: + visibility: public kind: kind created_at: 2000-01-23T04:56:07.000+00:00 oidc_subject: oidc_subject @@ -2884,7 +2919,8 @@ components: id: id href: href items: - - kind: kind + - visibility: public + kind: kind created_at: 2000-01-23T04:56:07.000+00:00 oidc_subject: oidc_subject updated_at: 2000-01-23T04:56:07.000+00:00 @@ -2897,7 +2933,8 @@ components: last_seen_at: 2000-01-23T04:56:07.000+00:00 status: status api_server_url: api_server_url - - kind: kind + - visibility: public + kind: kind created_at: 2000-01-23T04:56:07.000+00:00 oidc_subject: oidc_subject updated_at: 2000-01-23T04:56:07.000+00:00 @@ -2934,6 +2971,8 @@ components: type: object ManagedClusterRegistrationRequest: example: + visibility: public + provider: aws name: name description: description properties: @@ -2944,6 +2983,19 @@ components: description: description: Optional description of the spoke. type: string + provider: + description: Cloud provider hosting this control plane. + enum: + - aws + - ibm + - kind + type: string + visibility: + description: Network visibility supported for gateways on this control plane. + enum: + - public + - vpn + type: string required: - name type: object @@ -3225,6 +3277,66 @@ components: credential_driver: type: string type: object + GatewayPlacementIntent: + description: Exactly one of network/provider or mode must be supplied. + example: + mode: local-kind + provider: aws + network: public + nullable: true + oneOf: [] + properties: + network: + enum: + - public + - vpn + type: string + provider: + description: IBM Cloud supports public placement only; VPN requires AWS. + enum: + - aws + - ibm + type: string + mode: + enum: + - local-kind + type: string + type: object + GatewayPlacementAvailability: + example: + aws_public: true + aws_reason: no-eligible-cluster + local_kind: true + ibm_reason: no-eligible-cluster + ibm_public: true + aws_vpn: true + ibm_vpn: true + properties: + aws_public: + type: boolean + aws_vpn: + type: boolean + ibm_public: + type: boolean + ibm_vpn: + type: boolean + local_kind: + type: boolean + aws_reason: + enum: + - no-eligible-cluster + type: string + ibm_reason: + enum: + - no-eligible-cluster + type: string + required: + - aws_public + - aws_vpn + - ibm_public + - ibm_vpn + - local_kind + type: object OpenShellGatewayServiceAccountRole: enum: - openshell-user @@ -4381,18 +4493,21 @@ components: credential_driver: credential_driver oidc: oidc supervisor_image: supervisor_image - cluster_id: cluster_id service_type: service_type tls_mode: tls_mode route: route name: name + placement: + mode: local-kind + provider: aws + network: public external_dns: external_dns status: status properties: name: type: string - cluster_id: - type: string + placement: + $ref: "#/components/schemas/GatewayPlacementIntent" external_dns: type: string tls_mode: @@ -4424,8 +4539,8 @@ components: description: JSON-encoded credential storage driver configuration type: string required: - - cluster_id - name + - placement type: object Gateway_allOf_provisioning_conditions: example: diff --git a/components/api-server/pkg/api/openapi/api_default.go b/components/api-server/pkg/api/openapi/api_default.go index 29f832a78..aa7c563da 100644 --- a/components/api-server/pkg/api/openapi/api_default.go +++ b/components/api-server/pkg/api/openapi/api_default.go @@ -2894,6 +2894,125 @@ func (a *DefaultAPIService) GetGatewayExecute(r ApiGetGatewayRequest) (*Gateway, return localVarReturnValue, localVarHTTPResponse, nil } +type ApiGetGatewayPlacementAvailabilityRequest struct { + ctx context.Context + ApiService *DefaultAPIService +} + +func (r ApiGetGatewayPlacementAvailabilityRequest) Execute() (*GatewayPlacementAvailability, *http.Response, error) { + return r.ApiService.GetGatewayPlacementAvailabilityExecute(r) +} + +/* +GetGatewayPlacementAvailability Returns current provider placement eligibility + + @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + @return ApiGetGatewayPlacementAvailabilityRequest +*/ +func (a *DefaultAPIService) GetGatewayPlacementAvailability(ctx context.Context) ApiGetGatewayPlacementAvailabilityRequest { + return ApiGetGatewayPlacementAvailabilityRequest{ + ApiService: a, + ctx: ctx, + } +} + +// Execute executes the request +// +// @return GatewayPlacementAvailability +func (a *DefaultAPIService) GetGatewayPlacementAvailabilityExecute(r ApiGetGatewayPlacementAvailabilityRequest) (*GatewayPlacementAvailability, *http.Response, error) { + var ( + localVarHTTPMethod = http.MethodGet + localVarPostBody interface{} + formFiles []formFile + localVarReturnValue *GatewayPlacementAvailability + ) + + localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "DefaultAPIService.GetGatewayPlacementAvailability") + if err != nil { + return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()} + } + + localVarPath := localBasePath + "/api/hypershell/v1/gateways/placement-availability" + + localVarHeaderParams := make(map[string]string) + localVarQueryParams := url.Values{} + localVarFormParams := url.Values{} + + // to determine the Content-Type header + localVarHTTPContentTypes := []string{} + + // set Content-Type header + localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes) + if localVarHTTPContentType != "" { + localVarHeaderParams["Content-Type"] = localVarHTTPContentType + } + + // to determine the Accept header + localVarHTTPHeaderAccepts := []string{"application/json"} + + // set Accept header + localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts) + if localVarHTTPHeaderAccept != "" { + localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept + } + req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, formFiles) + if err != nil { + return localVarReturnValue, nil, err + } + + localVarHTTPResponse, err := a.client.callAPI(req) + if err != nil || localVarHTTPResponse == nil { + return localVarReturnValue, localVarHTTPResponse, err + } + + localVarBody, err := io.ReadAll(localVarHTTPResponse.Body) + localVarHTTPResponse.Body.Close() + localVarHTTPResponse.Body = io.NopCloser(bytes.NewBuffer(localVarBody)) + if err != nil { + return localVarReturnValue, localVarHTTPResponse, err + } + + if localVarHTTPResponse.StatusCode >= 300 { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: localVarHTTPResponse.Status, + } + if localVarHTTPResponse.StatusCode == 401 { + var v Error + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + return localVarReturnValue, localVarHTTPResponse, newErr + } + if localVarHTTPResponse.StatusCode == 500 { + var v Error + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + } + return localVarReturnValue, localVarHTTPResponse, newErr + } + + err = a.client.decode(&localVarReturnValue, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: err.Error(), + } + return localVarReturnValue, localVarHTTPResponse, newErr + } + + return localVarReturnValue, localVarHTTPResponse, nil +} + type ApiGetGatewayServiceAccountRequest struct { ctx context.Context ApiService *DefaultAPIService diff --git a/components/api-server/pkg/api/openapi/docs/DefaultAPI.md b/components/api-server/pkg/api/openapi/docs/DefaultAPI.md index c10a0bccd..6c8a06334 100644 --- a/components/api-server/pkg/api/openapi/docs/DefaultAPI.md +++ b/components/api-server/pkg/api/openapi/docs/DefaultAPI.md @@ -26,6 +26,7 @@ Method | HTTP request | Description [**DeleteSecretSource**](DefaultAPI.md#DeleteSecretSource) | **Delete** /api/hypershell/ext/secret_sources/{id} | Delete a secret source [**GetAgentRuntime**](DefaultAPI.md#GetAgentRuntime) | **Get** /api/hypershell/ext/agent_runtimes/{id} | Get an agent runtime by ID [**GetGateway**](DefaultAPI.md#GetGateway) | **Get** /api/hypershell/v1/gateways/{id} | Get an gateway by id +[**GetGatewayPlacementAvailability**](DefaultAPI.md#GetGatewayPlacementAvailability) | **Get** /api/hypershell/v1/gateways/placement-availability | Returns current provider placement eligibility [**GetGatewayServiceAccount**](DefaultAPI.md#GetGatewayServiceAccount) | **Get** /api/hypershell/v1/gateways/{gateway_id}/service_accounts/{service_account_id} | Get an OpenShell gateway service account [**GetInferenceRoute**](DefaultAPI.md#GetInferenceRoute) | **Get** /api/hypershell/ext/inference_routes/{id} | Get an inference route by ID [**GetManagedCluster**](DefaultAPI.md#GetManagedCluster) | **Get** /api/hypershell/v1/managed_clusters/{id} | Get an managedCluster by id @@ -144,7 +145,7 @@ import ( ) func main() { - gatewayCreateRequest := *openapiclient.NewGatewayCreateRequest("Name_example", "ClusterId_example") // GatewayCreateRequest | Gateway data + gatewayCreateRequest := *openapiclient.NewGatewayCreateRequest("Name_example", "TODO") // GatewayCreateRequest | Gateway data configuration := openapiclient.NewConfiguration() apiClient := openapiclient.NewAPIClient(configuration) @@ -1508,6 +1509,65 @@ Name | Type | Description | Notes [[Back to README]](../README.md) +## GetGatewayPlacementAvailability + +> GatewayPlacementAvailability GetGatewayPlacementAvailability(ctx).Execute() + +Returns current provider placement eligibility + +### Example + +```go +package main + +import ( + "context" + "fmt" + "os" + openapiclient "github.com/GIT_USER_ID/GIT_REPO_ID" +) + +func main() { + + configuration := openapiclient.NewConfiguration() + apiClient := openapiclient.NewAPIClient(configuration) + resp, r, err := apiClient.DefaultAPI.GetGatewayPlacementAvailability(context.Background()).Execute() + if err != nil { + fmt.Fprintf(os.Stderr, "Error when calling `DefaultAPI.GetGatewayPlacementAvailability``: %v\n", err) + fmt.Fprintf(os.Stderr, "Full HTTP response: %v\n", r) + } + // response from `GetGatewayPlacementAvailability`: GatewayPlacementAvailability + fmt.Fprintf(os.Stdout, "Response from `DefaultAPI.GetGatewayPlacementAvailability`: %v\n", resp) +} +``` + +### Path Parameters + +This endpoint does not need any parameter. + +### Other Parameters + +Other parameters are passed through a pointer to a apiGetGatewayPlacementAvailabilityRequest struct via the builder pattern + + +### Return type + +[**GatewayPlacementAvailability**](GatewayPlacementAvailability.md) + +### Authorization + +[Bearer](../README.md#Bearer) + +### HTTP request headers + +- **Content-Type**: Not defined +- **Accept**: application/json + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) +[[Back to Model list]](../README.md#documentation-for-models) +[[Back to README]](../README.md) + + ## GetGatewayServiceAccount > OpenShellGatewayServiceAccountGetResponse GetGatewayServiceAccount(ctx, gatewayId, serviceAccountId).Execute() diff --git a/components/api-server/pkg/api/openapi/docs/GatewayCreateRequest.md b/components/api-server/pkg/api/openapi/docs/GatewayCreateRequest.md index 3a2a17300..33e981f18 100644 --- a/components/api-server/pkg/api/openapi/docs/GatewayCreateRequest.md +++ b/components/api-server/pkg/api/openapi/docs/GatewayCreateRequest.md @@ -5,7 +5,7 @@ Name | Type | Description | Notes ------------ | ------------- | ------------- | ------------- **Name** | **string** | | -**ClusterId** | **string** | | +**Placement** | [**NullableGatewayPlacementIntent**](GatewayPlacementIntent.md) | | **ExternalDns** | Pointer to **string** | | [optional] **TlsMode** | Pointer to **string** | | [optional] **ServiceType** | Pointer to **string** | | [optional] @@ -22,7 +22,7 @@ Name | Type | Description | Notes ### NewGatewayCreateRequest -`func NewGatewayCreateRequest(name string, clusterId string, ) *GatewayCreateRequest` +`func NewGatewayCreateRequest(name string, placement NullableGatewayPlacementIntent, ) *GatewayCreateRequest` NewGatewayCreateRequest instantiates a new GatewayCreateRequest object This constructor will assign default values to properties that have it defined, @@ -57,26 +57,36 @@ and a boolean to check if the value has been set. SetName sets Name field to given value. -### GetClusterId +### GetPlacement -`func (o *GatewayCreateRequest) GetClusterId() string` +`func (o *GatewayCreateRequest) GetPlacement() GatewayPlacementIntent` -GetClusterId returns the ClusterId field if non-nil, zero value otherwise. +GetPlacement returns the Placement field if non-nil, zero value otherwise. -### GetClusterIdOk +### GetPlacementOk -`func (o *GatewayCreateRequest) GetClusterIdOk() (*string, bool)` +`func (o *GatewayCreateRequest) GetPlacementOk() (*GatewayPlacementIntent, bool)` -GetClusterIdOk returns a tuple with the ClusterId field if it's non-nil, zero value otherwise +GetPlacementOk returns a tuple with the Placement field if it's non-nil, zero value otherwise and a boolean to check if the value has been set. -### SetClusterId +### SetPlacement -`func (o *GatewayCreateRequest) SetClusterId(v string)` +`func (o *GatewayCreateRequest) SetPlacement(v GatewayPlacementIntent)` -SetClusterId sets ClusterId field to given value. +SetPlacement sets Placement field to given value. +### SetPlacementNil + +`func (o *GatewayCreateRequest) SetPlacementNil(b bool)` + + SetPlacementNil sets the value for Placement to be an explicit nil + +### UnsetPlacement +`func (o *GatewayCreateRequest) UnsetPlacement()` + +UnsetPlacement ensures that no value is present for Placement, not even an explicit nil ### GetExternalDns `func (o *GatewayCreateRequest) GetExternalDns() string` diff --git a/components/api-server/pkg/api/openapi/docs/GatewayPlacementAvailability.md b/components/api-server/pkg/api/openapi/docs/GatewayPlacementAvailability.md new file mode 100644 index 000000000..386edab2e --- /dev/null +++ b/components/api-server/pkg/api/openapi/docs/GatewayPlacementAvailability.md @@ -0,0 +1,187 @@ +# GatewayPlacementAvailability + +## Properties + +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**AwsPublic** | **bool** | | +**AwsVpn** | **bool** | | +**IbmPublic** | **bool** | | +**IbmVpn** | **bool** | | +**LocalKind** | **bool** | | +**AwsReason** | Pointer to **string** | | [optional] +**IbmReason** | Pointer to **string** | | [optional] + +## Methods + +### NewGatewayPlacementAvailability + +`func NewGatewayPlacementAvailability(awsPublic bool, awsVpn bool, ibmPublic bool, ibmVpn bool, localKind bool, ) *GatewayPlacementAvailability` + +NewGatewayPlacementAvailability instantiates a new GatewayPlacementAvailability object +This constructor will assign default values to properties that have it defined, +and makes sure properties required by API are set, but the set of arguments +will change when the set of required properties is changed + +### NewGatewayPlacementAvailabilityWithDefaults + +`func NewGatewayPlacementAvailabilityWithDefaults() *GatewayPlacementAvailability` + +NewGatewayPlacementAvailabilityWithDefaults instantiates a new GatewayPlacementAvailability object +This constructor will only assign default values to properties that have it defined, +but it doesn't guarantee that properties required by API are set + +### GetAwsPublic + +`func (o *GatewayPlacementAvailability) GetAwsPublic() bool` + +GetAwsPublic returns the AwsPublic field if non-nil, zero value otherwise. + +### GetAwsPublicOk + +`func (o *GatewayPlacementAvailability) GetAwsPublicOk() (*bool, bool)` + +GetAwsPublicOk returns a tuple with the AwsPublic field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetAwsPublic + +`func (o *GatewayPlacementAvailability) SetAwsPublic(v bool)` + +SetAwsPublic sets AwsPublic field to given value. + + +### GetAwsVpn + +`func (o *GatewayPlacementAvailability) GetAwsVpn() bool` + +GetAwsVpn returns the AwsVpn field if non-nil, zero value otherwise. + +### GetAwsVpnOk + +`func (o *GatewayPlacementAvailability) GetAwsVpnOk() (*bool, bool)` + +GetAwsVpnOk returns a tuple with the AwsVpn field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetAwsVpn + +`func (o *GatewayPlacementAvailability) SetAwsVpn(v bool)` + +SetAwsVpn sets AwsVpn field to given value. + + +### GetIbmPublic + +`func (o *GatewayPlacementAvailability) GetIbmPublic() bool` + +GetIbmPublic returns the IbmPublic field if non-nil, zero value otherwise. + +### GetIbmPublicOk + +`func (o *GatewayPlacementAvailability) GetIbmPublicOk() (*bool, bool)` + +GetIbmPublicOk returns a tuple with the IbmPublic field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetIbmPublic + +`func (o *GatewayPlacementAvailability) SetIbmPublic(v bool)` + +SetIbmPublic sets IbmPublic field to given value. + + +### GetIbmVpn + +`func (o *GatewayPlacementAvailability) GetIbmVpn() bool` + +GetIbmVpn returns the IbmVpn field if non-nil, zero value otherwise. + +### GetIbmVpnOk + +`func (o *GatewayPlacementAvailability) GetIbmVpnOk() (*bool, bool)` + +GetIbmVpnOk returns a tuple with the IbmVpn field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetIbmVpn + +`func (o *GatewayPlacementAvailability) SetIbmVpn(v bool)` + +SetIbmVpn sets IbmVpn field to given value. + + +### GetLocalKind + +`func (o *GatewayPlacementAvailability) GetLocalKind() bool` + +GetLocalKind returns the LocalKind field if non-nil, zero value otherwise. + +### GetLocalKindOk + +`func (o *GatewayPlacementAvailability) GetLocalKindOk() (*bool, bool)` + +GetLocalKindOk returns a tuple with the LocalKind field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetLocalKind + +`func (o *GatewayPlacementAvailability) SetLocalKind(v bool)` + +SetLocalKind sets LocalKind field to given value. + + +### GetAwsReason + +`func (o *GatewayPlacementAvailability) GetAwsReason() string` + +GetAwsReason returns the AwsReason field if non-nil, zero value otherwise. + +### GetAwsReasonOk + +`func (o *GatewayPlacementAvailability) GetAwsReasonOk() (*string, bool)` + +GetAwsReasonOk returns a tuple with the AwsReason field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetAwsReason + +`func (o *GatewayPlacementAvailability) SetAwsReason(v string)` + +SetAwsReason sets AwsReason field to given value. + +### HasAwsReason + +`func (o *GatewayPlacementAvailability) HasAwsReason() bool` + +HasAwsReason returns a boolean if a field has been set. + +### GetIbmReason + +`func (o *GatewayPlacementAvailability) GetIbmReason() string` + +GetIbmReason returns the IbmReason field if non-nil, zero value otherwise. + +### GetIbmReasonOk + +`func (o *GatewayPlacementAvailability) GetIbmReasonOk() (*string, bool)` + +GetIbmReasonOk returns a tuple with the IbmReason field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetIbmReason + +`func (o *GatewayPlacementAvailability) SetIbmReason(v string)` + +SetIbmReason sets IbmReason field to given value. + +### HasIbmReason + +`func (o *GatewayPlacementAvailability) HasIbmReason() bool` + +HasIbmReason returns a boolean if a field has been set. + + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/components/api-server/pkg/api/openapi/docs/GatewayPlacementIntent.md b/components/api-server/pkg/api/openapi/docs/GatewayPlacementIntent.md new file mode 100644 index 000000000..7889310df --- /dev/null +++ b/components/api-server/pkg/api/openapi/docs/GatewayPlacementIntent.md @@ -0,0 +1,108 @@ +# GatewayPlacementIntent + +## Properties + +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**Network** | Pointer to **string** | | [optional] +**Provider** | Pointer to **string** | IBM Cloud supports public placement only; VPN requires AWS. | [optional] +**Mode** | Pointer to **string** | | [optional] + +## Methods + +### NewGatewayPlacementIntent + +`func NewGatewayPlacementIntent() *GatewayPlacementIntent` + +NewGatewayPlacementIntent instantiates a new GatewayPlacementIntent object +This constructor will assign default values to properties that have it defined, +and makes sure properties required by API are set, but the set of arguments +will change when the set of required properties is changed + +### NewGatewayPlacementIntentWithDefaults + +`func NewGatewayPlacementIntentWithDefaults() *GatewayPlacementIntent` + +NewGatewayPlacementIntentWithDefaults instantiates a new GatewayPlacementIntent object +This constructor will only assign default values to properties that have it defined, +but it doesn't guarantee that properties required by API are set + +### GetNetwork + +`func (o *GatewayPlacementIntent) GetNetwork() string` + +GetNetwork returns the Network field if non-nil, zero value otherwise. + +### GetNetworkOk + +`func (o *GatewayPlacementIntent) GetNetworkOk() (*string, bool)` + +GetNetworkOk returns a tuple with the Network field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetNetwork + +`func (o *GatewayPlacementIntent) SetNetwork(v string)` + +SetNetwork sets Network field to given value. + +### HasNetwork + +`func (o *GatewayPlacementIntent) HasNetwork() bool` + +HasNetwork returns a boolean if a field has been set. + +### GetProvider + +`func (o *GatewayPlacementIntent) GetProvider() string` + +GetProvider returns the Provider field if non-nil, zero value otherwise. + +### GetProviderOk + +`func (o *GatewayPlacementIntent) GetProviderOk() (*string, bool)` + +GetProviderOk returns a tuple with the Provider field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetProvider + +`func (o *GatewayPlacementIntent) SetProvider(v string)` + +SetProvider sets Provider field to given value. + +### HasProvider + +`func (o *GatewayPlacementIntent) HasProvider() bool` + +HasProvider returns a boolean if a field has been set. + +### GetMode + +`func (o *GatewayPlacementIntent) GetMode() string` + +GetMode returns the Mode field if non-nil, zero value otherwise. + +### GetModeOk + +`func (o *GatewayPlacementIntent) GetModeOk() (*string, bool)` + +GetModeOk returns a tuple with the Mode field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetMode + +`func (o *GatewayPlacementIntent) SetMode(v string)` + +SetMode sets Mode field to given value. + +### HasMode + +`func (o *GatewayPlacementIntent) HasMode() bool` + +HasMode returns a boolean if a field has been set. + + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/components/api-server/pkg/api/openapi/docs/ManagedCluster.md b/components/api-server/pkg/api/openapi/docs/ManagedCluster.md index 8799634fc..09d1479ba 100644 --- a/components/api-server/pkg/api/openapi/docs/ManagedCluster.md +++ b/components/api-server/pkg/api/openapi/docs/ManagedCluster.md @@ -10,7 +10,8 @@ Name | Type | Description | Notes **CreatedAt** | Pointer to **time.Time** | | [optional] **UpdatedAt** | Pointer to **time.Time** | | [optional] **Name** | **string** | | -**Provider** | **string** | | +**Provider** | **string** | Cloud provider capability used for gateway placement. | +**Visibility** | Pointer to **string** | Network visibility supported by this control plane for gateway placement. | [optional] [readonly] **Region** | Pointer to **string** | | [optional] **KubeconfigSecret** | **string** | | **Status** | Pointer to **string** | | [optional] @@ -202,6 +203,31 @@ and a boolean to check if the value has been set. SetProvider sets Provider field to given value. +### GetVisibility + +`func (o *ManagedCluster) GetVisibility() string` + +GetVisibility returns the Visibility field if non-nil, zero value otherwise. + +### GetVisibilityOk + +`func (o *ManagedCluster) GetVisibilityOk() (*string, bool)` + +GetVisibilityOk returns a tuple with the Visibility field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetVisibility + +`func (o *ManagedCluster) SetVisibility(v string)` + +SetVisibility sets Visibility field to given value. + +### HasVisibility + +`func (o *ManagedCluster) HasVisibility() bool` + +HasVisibility returns a boolean if a field has been set. + ### GetRegion `func (o *ManagedCluster) GetRegion() string` diff --git a/components/api-server/pkg/api/openapi/docs/ManagedClusterRegistrationRequest.md b/components/api-server/pkg/api/openapi/docs/ManagedClusterRegistrationRequest.md index 7706e10e0..ea4cec65e 100644 --- a/components/api-server/pkg/api/openapi/docs/ManagedClusterRegistrationRequest.md +++ b/components/api-server/pkg/api/openapi/docs/ManagedClusterRegistrationRequest.md @@ -6,6 +6,8 @@ Name | Type | Description | Notes ------------ | ------------- | ------------- | ------------- **Name** | **string** | Human-readable spoke name, unique per fleet (e.g. hyp0-mc1). Must match on every subsequent call. | **Description** | Pointer to **string** | Optional description of the spoke. | [optional] +**Provider** | Pointer to **string** | Cloud provider hosting this control plane. | [optional] +**Visibility** | Pointer to **string** | Network visibility supported for gateways on this control plane. | [optional] ## Methods @@ -71,6 +73,56 @@ SetDescription sets Description field to given value. HasDescription returns a boolean if a field has been set. +### GetProvider + +`func (o *ManagedClusterRegistrationRequest) GetProvider() string` + +GetProvider returns the Provider field if non-nil, zero value otherwise. + +### GetProviderOk + +`func (o *ManagedClusterRegistrationRequest) GetProviderOk() (*string, bool)` + +GetProviderOk returns a tuple with the Provider field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetProvider + +`func (o *ManagedClusterRegistrationRequest) SetProvider(v string)` + +SetProvider sets Provider field to given value. + +### HasProvider + +`func (o *ManagedClusterRegistrationRequest) HasProvider() bool` + +HasProvider returns a boolean if a field has been set. + +### GetVisibility + +`func (o *ManagedClusterRegistrationRequest) GetVisibility() string` + +GetVisibility returns the Visibility field if non-nil, zero value otherwise. + +### GetVisibilityOk + +`func (o *ManagedClusterRegistrationRequest) GetVisibilityOk() (*string, bool)` + +GetVisibilityOk returns a tuple with the Visibility field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetVisibility + +`func (o *ManagedClusterRegistrationRequest) SetVisibility(v string)` + +SetVisibility sets Visibility field to given value. + +### HasVisibility + +`func (o *ManagedClusterRegistrationRequest) HasVisibility() bool` + +HasVisibility returns a boolean if a field has been set. + [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) diff --git a/components/api-server/pkg/api/openapi/model_gateway_create_request.go b/components/api-server/pkg/api/openapi/model_gateway_create_request.go index bab7ebcab..452d01010 100644 --- a/components/api-server/pkg/api/openapi/model_gateway_create_request.go +++ b/components/api-server/pkg/api/openapi/model_gateway_create_request.go @@ -21,13 +21,13 @@ var _ MappedNullable = &GatewayCreateRequest{} // GatewayCreateRequest struct for GatewayCreateRequest type GatewayCreateRequest struct { - Name string `json:"name"` - ClusterId string `json:"cluster_id"` - ExternalDns *string `json:"external_dns,omitempty"` - TlsMode *string `json:"tls_mode,omitempty"` - ServiceType *string `json:"service_type,omitempty"` - Status *string `json:"status,omitempty"` - Phase *string `json:"phase,omitempty"` + Name string `json:"name"` + Placement NullableGatewayPlacementIntent `json:"placement"` + ExternalDns *string `json:"external_dns,omitempty"` + TlsMode *string `json:"tls_mode,omitempty"` + ServiceType *string `json:"service_type,omitempty"` + Status *string `json:"status,omitempty"` + Phase *string `json:"phase,omitempty"` // Container image for the gateway deployment Image *string `json:"image,omitempty"` // Container image for the supervisor sidecar @@ -48,10 +48,10 @@ type _GatewayCreateRequest GatewayCreateRequest // This constructor will assign default values to properties that have it defined, // and makes sure properties required by API are set, but the set of arguments // will change when the set of required properties is changed -func NewGatewayCreateRequest(name string, clusterId string) *GatewayCreateRequest { +func NewGatewayCreateRequest(name string, placement NullableGatewayPlacementIntent) *GatewayCreateRequest { this := GatewayCreateRequest{} this.Name = name - this.ClusterId = clusterId + this.Placement = placement return &this } @@ -87,28 +87,30 @@ func (o *GatewayCreateRequest) SetName(v string) { o.Name = v } -// GetClusterId returns the ClusterId field value -func (o *GatewayCreateRequest) GetClusterId() string { - if o == nil { - var ret string +// GetPlacement returns the Placement field value +// If the value is explicit nil, the zero value for GatewayPlacementIntent will be returned +func (o *GatewayCreateRequest) GetPlacement() GatewayPlacementIntent { + if o == nil || o.Placement.Get() == nil { + var ret GatewayPlacementIntent return ret } - return o.ClusterId + return *o.Placement.Get() } -// GetClusterIdOk returns a tuple with the ClusterId field value +// GetPlacementOk returns a tuple with the Placement field value // and a boolean to check if the value has been set. -func (o *GatewayCreateRequest) GetClusterIdOk() (*string, bool) { +// NOTE: If the value is an explicit nil, `nil, true` will be returned +func (o *GatewayCreateRequest) GetPlacementOk() (*GatewayPlacementIntent, bool) { if o == nil { return nil, false } - return &o.ClusterId, true + return o.Placement.Get(), o.Placement.IsSet() } -// SetClusterId sets field value -func (o *GatewayCreateRequest) SetClusterId(v string) { - o.ClusterId = v +// SetPlacement sets field value +func (o *GatewayCreateRequest) SetPlacement(v GatewayPlacementIntent) { + o.Placement.Set(&v) } // GetExternalDns returns the ExternalDns field value if set, zero value otherwise. @@ -474,7 +476,7 @@ func (o GatewayCreateRequest) MarshalJSON() ([]byte, error) { func (o GatewayCreateRequest) ToMap() (map[string]interface{}, error) { toSerialize := map[string]interface{}{} toSerialize["name"] = o.Name - toSerialize["cluster_id"] = o.ClusterId + toSerialize["placement"] = o.Placement.Get() if !IsNil(o.ExternalDns) { toSerialize["external_dns"] = o.ExternalDns } @@ -517,7 +519,7 @@ func (o *GatewayCreateRequest) UnmarshalJSON(data []byte) (err error) { // that every required field exists as a key in the generic map. requiredProperties := []string{ "name", - "cluster_id", + "placement", } allProperties := make(map[string]interface{}) diff --git a/components/api-server/pkg/api/openapi/model_gateway_placement_availability.go b/components/api-server/pkg/api/openapi/model_gateway_placement_availability.go new file mode 100644 index 000000000..56e041210 --- /dev/null +++ b/components/api-server/pkg/api/openapi/model_gateway_placement_availability.go @@ -0,0 +1,340 @@ +/* +HyperShell API + +HyperShell gateway management API + +API version: 1.0.0 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package openapi + +import ( + "bytes" + "encoding/json" + "fmt" +) + +// checks if the GatewayPlacementAvailability type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &GatewayPlacementAvailability{} + +// GatewayPlacementAvailability struct for GatewayPlacementAvailability +type GatewayPlacementAvailability struct { + AwsPublic bool `json:"aws_public"` + AwsVpn bool `json:"aws_vpn"` + IbmPublic bool `json:"ibm_public"` + IbmVpn bool `json:"ibm_vpn"` + LocalKind bool `json:"local_kind"` + AwsReason *string `json:"aws_reason,omitempty"` + IbmReason *string `json:"ibm_reason,omitempty"` +} + +type _GatewayPlacementAvailability GatewayPlacementAvailability + +// NewGatewayPlacementAvailability instantiates a new GatewayPlacementAvailability object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewGatewayPlacementAvailability(awsPublic bool, awsVpn bool, ibmPublic bool, ibmVpn bool, localKind bool) *GatewayPlacementAvailability { + this := GatewayPlacementAvailability{} + this.AwsPublic = awsPublic + this.AwsVpn = awsVpn + this.IbmPublic = ibmPublic + this.IbmVpn = ibmVpn + this.LocalKind = localKind + return &this +} + +// NewGatewayPlacementAvailabilityWithDefaults instantiates a new GatewayPlacementAvailability object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewGatewayPlacementAvailabilityWithDefaults() *GatewayPlacementAvailability { + this := GatewayPlacementAvailability{} + return &this +} + +// GetAwsPublic returns the AwsPublic field value +func (o *GatewayPlacementAvailability) GetAwsPublic() bool { + if o == nil { + var ret bool + return ret + } + + return o.AwsPublic +} + +// GetAwsPublicOk returns a tuple with the AwsPublic field value +// and a boolean to check if the value has been set. +func (o *GatewayPlacementAvailability) GetAwsPublicOk() (*bool, bool) { + if o == nil { + return nil, false + } + return &o.AwsPublic, true +} + +// SetAwsPublic sets field value +func (o *GatewayPlacementAvailability) SetAwsPublic(v bool) { + o.AwsPublic = v +} + +// GetAwsVpn returns the AwsVpn field value +func (o *GatewayPlacementAvailability) GetAwsVpn() bool { + if o == nil { + var ret bool + return ret + } + + return o.AwsVpn +} + +// GetAwsVpnOk returns a tuple with the AwsVpn field value +// and a boolean to check if the value has been set. +func (o *GatewayPlacementAvailability) GetAwsVpnOk() (*bool, bool) { + if o == nil { + return nil, false + } + return &o.AwsVpn, true +} + +// SetAwsVpn sets field value +func (o *GatewayPlacementAvailability) SetAwsVpn(v bool) { + o.AwsVpn = v +} + +// GetIbmPublic returns the IbmPublic field value +func (o *GatewayPlacementAvailability) GetIbmPublic() bool { + if o == nil { + var ret bool + return ret + } + + return o.IbmPublic +} + +// GetIbmPublicOk returns a tuple with the IbmPublic field value +// and a boolean to check if the value has been set. +func (o *GatewayPlacementAvailability) GetIbmPublicOk() (*bool, bool) { + if o == nil { + return nil, false + } + return &o.IbmPublic, true +} + +// SetIbmPublic sets field value +func (o *GatewayPlacementAvailability) SetIbmPublic(v bool) { + o.IbmPublic = v +} + +// GetIbmVpn returns the IbmVpn field value +func (o *GatewayPlacementAvailability) GetIbmVpn() bool { + if o == nil { + var ret bool + return ret + } + + return o.IbmVpn +} + +// GetIbmVpnOk returns a tuple with the IbmVpn field value +// and a boolean to check if the value has been set. +func (o *GatewayPlacementAvailability) GetIbmVpnOk() (*bool, bool) { + if o == nil { + return nil, false + } + return &o.IbmVpn, true +} + +// SetIbmVpn sets field value +func (o *GatewayPlacementAvailability) SetIbmVpn(v bool) { + o.IbmVpn = v +} + +// GetLocalKind returns the LocalKind field value +func (o *GatewayPlacementAvailability) GetLocalKind() bool { + if o == nil { + var ret bool + return ret + } + + return o.LocalKind +} + +// GetLocalKindOk returns a tuple with the LocalKind field value +// and a boolean to check if the value has been set. +func (o *GatewayPlacementAvailability) GetLocalKindOk() (*bool, bool) { + if o == nil { + return nil, false + } + return &o.LocalKind, true +} + +// SetLocalKind sets field value +func (o *GatewayPlacementAvailability) SetLocalKind(v bool) { + o.LocalKind = v +} + +// GetAwsReason returns the AwsReason field value if set, zero value otherwise. +func (o *GatewayPlacementAvailability) GetAwsReason() string { + if o == nil || IsNil(o.AwsReason) { + var ret string + return ret + } + return *o.AwsReason +} + +// GetAwsReasonOk returns a tuple with the AwsReason field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *GatewayPlacementAvailability) GetAwsReasonOk() (*string, bool) { + if o == nil || IsNil(o.AwsReason) { + return nil, false + } + return o.AwsReason, true +} + +// HasAwsReason returns a boolean if a field has been set. +func (o *GatewayPlacementAvailability) HasAwsReason() bool { + if o != nil && !IsNil(o.AwsReason) { + return true + } + + return false +} + +// SetAwsReason gets a reference to the given string and assigns it to the AwsReason field. +func (o *GatewayPlacementAvailability) SetAwsReason(v string) { + o.AwsReason = &v +} + +// GetIbmReason returns the IbmReason field value if set, zero value otherwise. +func (o *GatewayPlacementAvailability) GetIbmReason() string { + if o == nil || IsNil(o.IbmReason) { + var ret string + return ret + } + return *o.IbmReason +} + +// GetIbmReasonOk returns a tuple with the IbmReason field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *GatewayPlacementAvailability) GetIbmReasonOk() (*string, bool) { + if o == nil || IsNil(o.IbmReason) { + return nil, false + } + return o.IbmReason, true +} + +// HasIbmReason returns a boolean if a field has been set. +func (o *GatewayPlacementAvailability) HasIbmReason() bool { + if o != nil && !IsNil(o.IbmReason) { + return true + } + + return false +} + +// SetIbmReason gets a reference to the given string and assigns it to the IbmReason field. +func (o *GatewayPlacementAvailability) SetIbmReason(v string) { + o.IbmReason = &v +} + +func (o GatewayPlacementAvailability) MarshalJSON() ([]byte, error) { + toSerialize, err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o GatewayPlacementAvailability) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + toSerialize["aws_public"] = o.AwsPublic + toSerialize["aws_vpn"] = o.AwsVpn + toSerialize["ibm_public"] = o.IbmPublic + toSerialize["ibm_vpn"] = o.IbmVpn + toSerialize["local_kind"] = o.LocalKind + if !IsNil(o.AwsReason) { + toSerialize["aws_reason"] = o.AwsReason + } + if !IsNil(o.IbmReason) { + toSerialize["ibm_reason"] = o.IbmReason + } + return toSerialize, nil +} + +func (o *GatewayPlacementAvailability) UnmarshalJSON(data []byte) (err error) { + // This validates that all required properties are included in the JSON object + // by unmarshalling the object into a generic map with string keys and checking + // that every required field exists as a key in the generic map. + requiredProperties := []string{ + "aws_public", + "aws_vpn", + "ibm_public", + "ibm_vpn", + "local_kind", + } + + allProperties := make(map[string]interface{}) + + err = json.Unmarshal(data, &allProperties) + + if err != nil { + return err + } + + for _, requiredProperty := range requiredProperties { + if _, exists := allProperties[requiredProperty]; !exists { + return fmt.Errorf("no value given for required property %v", requiredProperty) + } + } + + varGatewayPlacementAvailability := _GatewayPlacementAvailability{} + + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + err = decoder.Decode(&varGatewayPlacementAvailability) + + if err != nil { + return err + } + + *o = GatewayPlacementAvailability(varGatewayPlacementAvailability) + + return err +} + +type NullableGatewayPlacementAvailability struct { + value *GatewayPlacementAvailability + isSet bool +} + +func (v NullableGatewayPlacementAvailability) Get() *GatewayPlacementAvailability { + return v.value +} + +func (v *NullableGatewayPlacementAvailability) Set(val *GatewayPlacementAvailability) { + v.value = val + v.isSet = true +} + +func (v NullableGatewayPlacementAvailability) IsSet() bool { + return v.isSet +} + +func (v *NullableGatewayPlacementAvailability) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableGatewayPlacementAvailability(val *GatewayPlacementAvailability) *NullableGatewayPlacementAvailability { + return &NullableGatewayPlacementAvailability{value: val, isSet: true} +} + +func (v NullableGatewayPlacementAvailability) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableGatewayPlacementAvailability) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/components/api-server/pkg/api/openapi/model_gateway_placement_intent.go b/components/api-server/pkg/api/openapi/model_gateway_placement_intent.go new file mode 100644 index 000000000..bf38526b8 --- /dev/null +++ b/components/api-server/pkg/api/openapi/model_gateway_placement_intent.go @@ -0,0 +1,197 @@ +/* +HyperShell API + +HyperShell gateway management API + +API version: 1.0.0 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package openapi + +import ( + "encoding/json" +) + +// checks if the GatewayPlacementIntent type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &GatewayPlacementIntent{} + +// GatewayPlacementIntent Exactly one of network/provider or mode must be supplied. +type GatewayPlacementIntent struct { + Network *string + // IBM Cloud supports public placement only; VPN requires AWS. + Provider *string + Mode *string +} + +// NewGatewayPlacementIntent instantiates a new GatewayPlacementIntent object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewGatewayPlacementIntent() *GatewayPlacementIntent { + this := GatewayPlacementIntent{} + return &this +} + +// NewGatewayPlacementIntentWithDefaults instantiates a new GatewayPlacementIntent object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewGatewayPlacementIntentWithDefaults() *GatewayPlacementIntent { + this := GatewayPlacementIntent{} + return &this +} + +// GetNetwork returns the Network field value if set, zero value otherwise. +func (o *GatewayPlacementIntent) GetNetwork() string { + if o == nil || IsNil(o.Network) { + var ret string + return ret + } + return *o.Network +} + +// GetNetworkOk returns a tuple with the Network field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *GatewayPlacementIntent) GetNetworkOk() (*string, bool) { + if o == nil || IsNil(o.Network) { + return nil, false + } + return o.Network, true +} + +// HasNetwork returns a boolean if a field has been set. +func (o *GatewayPlacementIntent) HasNetwork() bool { + if o != nil && !IsNil(o.Network) { + return true + } + + return false +} + +// SetNetwork gets a reference to the given string and assigns it to the Network field. +func (o *GatewayPlacementIntent) SetNetwork(v string) { + o.Network = &v +} + +// GetProvider returns the Provider field value if set, zero value otherwise. +func (o *GatewayPlacementIntent) GetProvider() string { + if o == nil || IsNil(o.Provider) { + var ret string + return ret + } + return *o.Provider +} + +// GetProviderOk returns a tuple with the Provider field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *GatewayPlacementIntent) GetProviderOk() (*string, bool) { + if o == nil || IsNil(o.Provider) { + return nil, false + } + return o.Provider, true +} + +// HasProvider returns a boolean if a field has been set. +func (o *GatewayPlacementIntent) HasProvider() bool { + if o != nil && !IsNil(o.Provider) { + return true + } + + return false +} + +// SetProvider gets a reference to the given string and assigns it to the Provider field. +func (o *GatewayPlacementIntent) SetProvider(v string) { + o.Provider = &v +} + +// GetMode returns the Mode field value if set, zero value otherwise. +func (o *GatewayPlacementIntent) GetMode() string { + if o == nil || IsNil(o.Mode) { + var ret string + return ret + } + return *o.Mode +} + +// GetModeOk returns a tuple with the Mode field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *GatewayPlacementIntent) GetModeOk() (*string, bool) { + if o == nil || IsNil(o.Mode) { + return nil, false + } + return o.Mode, true +} + +// HasMode returns a boolean if a field has been set. +func (o *GatewayPlacementIntent) HasMode() bool { + if o != nil && !IsNil(o.Mode) { + return true + } + + return false +} + +// SetMode gets a reference to the given string and assigns it to the Mode field. +func (o *GatewayPlacementIntent) SetMode(v string) { + o.Mode = &v +} + +func (o GatewayPlacementIntent) MarshalJSON() ([]byte, error) { + toSerialize, err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o GatewayPlacementIntent) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + if !IsNil(o.Network) { + toSerialize["network"] = o.Network + } + if !IsNil(o.Provider) { + toSerialize["provider"] = o.Provider + } + if !IsNil(o.Mode) { + toSerialize["mode"] = o.Mode + } + return toSerialize, nil +} + +type NullableGatewayPlacementIntent struct { + value *GatewayPlacementIntent + isSet bool +} + +func (v NullableGatewayPlacementIntent) Get() *GatewayPlacementIntent { + return v.value +} + +func (v *NullableGatewayPlacementIntent) Set(val *GatewayPlacementIntent) { + v.value = val + v.isSet = true +} + +func (v NullableGatewayPlacementIntent) IsSet() bool { + return v.isSet +} + +func (v *NullableGatewayPlacementIntent) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableGatewayPlacementIntent(val *GatewayPlacementIntent) *NullableGatewayPlacementIntent { + return &NullableGatewayPlacementIntent{value: val, isSet: true} +} + +func (v NullableGatewayPlacementIntent) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableGatewayPlacementIntent) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/components/api-server/pkg/api/openapi/model_managed_cluster.go b/components/api-server/pkg/api/openapi/model_managed_cluster.go index aba2ca4a4..a4d6b3acd 100644 --- a/components/api-server/pkg/api/openapi/model_managed_cluster.go +++ b/components/api-server/pkg/api/openapi/model_managed_cluster.go @@ -22,17 +22,20 @@ var _ MappedNullable = &ManagedCluster{} // ManagedCluster struct for ManagedCluster type ManagedCluster struct { - Id *string `json:"id,omitempty"` - Kind *string `json:"kind,omitempty"` - Href *string `json:"href,omitempty"` - CreatedAt *time.Time `json:"created_at,omitempty"` - UpdatedAt *time.Time `json:"updated_at,omitempty"` - Name string `json:"name"` - Provider string `json:"provider"` - Region *string `json:"region,omitempty"` - KubeconfigSecret string `json:"kubeconfig_secret"` - Status *string `json:"status,omitempty"` - ApiServerUrl *string `json:"api_server_url,omitempty"` + Id *string `json:"id,omitempty"` + Kind *string `json:"kind,omitempty"` + Href *string `json:"href,omitempty"` + CreatedAt *time.Time `json:"created_at,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` + Name string `json:"name"` + // Cloud provider capability used for gateway placement. + Provider string `json:"provider"` + // Network visibility supported by this control plane for gateway placement. + Visibility *string `json:"visibility,omitempty"` + Region *string `json:"region,omitempty"` + KubeconfigSecret string `json:"kubeconfig_secret"` + Status *string `json:"status,omitempty"` + ApiServerUrl *string `json:"api_server_url,omitempty"` // OIDC sub claim of the service account that registered this cluster. Server-assigned; not writable. OidcSubject *string `json:"oidc_subject,omitempty"` // Timestamp of the most recent registration call. Updated on every POST /registration. @@ -269,6 +272,38 @@ func (o *ManagedCluster) SetProvider(v string) { o.Provider = v } +// GetVisibility returns the Visibility field value if set, zero value otherwise. +func (o *ManagedCluster) GetVisibility() string { + if o == nil || IsNil(o.Visibility) { + var ret string + return ret + } + return *o.Visibility +} + +// GetVisibilityOk returns a tuple with the Visibility field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *ManagedCluster) GetVisibilityOk() (*string, bool) { + if o == nil || IsNil(o.Visibility) { + return nil, false + } + return o.Visibility, true +} + +// HasVisibility returns a boolean if a field has been set. +func (o *ManagedCluster) HasVisibility() bool { + if o != nil && !IsNil(o.Visibility) { + return true + } + + return false +} + +// SetVisibility gets a reference to the given string and assigns it to the Visibility field. +func (o *ManagedCluster) SetVisibility(v string) { + o.Visibility = &v +} + // GetRegion returns the Region field value if set, zero value otherwise. func (o *ManagedCluster) GetRegion() string { if o == nil || IsNil(o.Region) { @@ -480,6 +515,9 @@ func (o ManagedCluster) ToMap() (map[string]interface{}, error) { } toSerialize["name"] = o.Name toSerialize["provider"] = o.Provider + if !IsNil(o.Visibility) { + toSerialize["visibility"] = o.Visibility + } if !IsNil(o.Region) { toSerialize["region"] = o.Region } diff --git a/components/api-server/pkg/api/openapi/model_managed_cluster_registration_request.go b/components/api-server/pkg/api/openapi/model_managed_cluster_registration_request.go index 0ef47e4a2..3f253f8e2 100644 --- a/components/api-server/pkg/api/openapi/model_managed_cluster_registration_request.go +++ b/components/api-server/pkg/api/openapi/model_managed_cluster_registration_request.go @@ -25,6 +25,10 @@ type ManagedClusterRegistrationRequest struct { Name string `json:"name"` // Optional description of the spoke. Description *string `json:"description,omitempty"` + // Cloud provider hosting this control plane. + Provider *string `json:"provider,omitempty"` + // Network visibility supported for gateways on this control plane. + Visibility *string `json:"visibility,omitempty"` } type _ManagedClusterRegistrationRequest ManagedClusterRegistrationRequest @@ -103,6 +107,70 @@ func (o *ManagedClusterRegistrationRequest) SetDescription(v string) { o.Description = &v } +// GetProvider returns the Provider field value if set, zero value otherwise. +func (o *ManagedClusterRegistrationRequest) GetProvider() string { + if o == nil || IsNil(o.Provider) { + var ret string + return ret + } + return *o.Provider +} + +// GetProviderOk returns a tuple with the Provider field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *ManagedClusterRegistrationRequest) GetProviderOk() (*string, bool) { + if o == nil || IsNil(o.Provider) { + return nil, false + } + return o.Provider, true +} + +// HasProvider returns a boolean if a field has been set. +func (o *ManagedClusterRegistrationRequest) HasProvider() bool { + if o != nil && !IsNil(o.Provider) { + return true + } + + return false +} + +// SetProvider gets a reference to the given string and assigns it to the Provider field. +func (o *ManagedClusterRegistrationRequest) SetProvider(v string) { + o.Provider = &v +} + +// GetVisibility returns the Visibility field value if set, zero value otherwise. +func (o *ManagedClusterRegistrationRequest) GetVisibility() string { + if o == nil || IsNil(o.Visibility) { + var ret string + return ret + } + return *o.Visibility +} + +// GetVisibilityOk returns a tuple with the Visibility field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *ManagedClusterRegistrationRequest) GetVisibilityOk() (*string, bool) { + if o == nil || IsNil(o.Visibility) { + return nil, false + } + return o.Visibility, true +} + +// HasVisibility returns a boolean if a field has been set. +func (o *ManagedClusterRegistrationRequest) HasVisibility() bool { + if o != nil && !IsNil(o.Visibility) { + return true + } + + return false +} + +// SetVisibility gets a reference to the given string and assigns it to the Visibility field. +func (o *ManagedClusterRegistrationRequest) SetVisibility(v string) { + o.Visibility = &v +} + func (o ManagedClusterRegistrationRequest) MarshalJSON() ([]byte, error) { toSerialize, err := o.ToMap() if err != nil { @@ -117,6 +185,12 @@ func (o ManagedClusterRegistrationRequest) ToMap() (map[string]interface{}, erro if !IsNil(o.Description) { toSerialize["description"] = o.Description } + if !IsNil(o.Provider) { + toSerialize["provider"] = o.Provider + } + if !IsNil(o.Visibility) { + toSerialize["visibility"] = o.Visibility + } return toSerialize, nil } diff --git a/components/api-server/pkg/api/openapi_embed_test.go b/components/api-server/pkg/api/openapi_embed_test.go index 05e6f7a6c..4a43be33c 100644 --- a/components/api-server/pkg/api/openapi_embed_test.go +++ b/components/api-server/pkg/api/openapi_embed_test.go @@ -55,8 +55,8 @@ func TestGetOpenAPISpecReturnsCompleteContract(t *testing.T) { operationIDs[operation.OperationID] = method + " " + path } } - if operationCount != 54 { - t.Fatalf("embedded operation count = %d, want 54", operationCount) + if operationCount != 55 { + t.Fatalf("embedded operation count = %d, want 55", operationCount) } expectedDeletes := map[string]string{ diff --git a/components/api-server/plugins/gateways/availability_handler_test.go b/components/api-server/plugins/gateways/availability_handler_test.go new file mode 100644 index 000000000..af5b5a43d --- /dev/null +++ b/components/api-server/plugins/gateways/availability_handler_test.go @@ -0,0 +1,31 @@ +package gateways + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/openshift-online/hypershell/components/api-server/pkg/api/openapi" + "github.com/openshift-online/rh-trex-ai/components/api-server/pkg/errors" +) + +func TestGetPlacementAvailabilityAcceptsEmptyGetBody(t *testing.T) { + handler := gatewayHandler{ + availability: func(context.Context) (openapi.GatewayPlacementAvailability, *errors.ServiceError) { + return openapi.GatewayPlacementAvailability{LocalKind: true}, nil + }, + } + + request := httptest.NewRequest(http.MethodGet, "/gateways/placement-availability", nil) + response := httptest.NewRecorder() + handler.GetPlacementAvailability(response, request) + + if response.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", response.Code, response.Body.String()) + } + if !strings.Contains(response.Body.String(), `"local_kind":true`) { + t.Fatalf("response body = %s", response.Body.String()) + } +} diff --git a/components/api-server/plugins/gateways/cluster_fixture_test.go b/components/api-server/plugins/gateways/cluster_fixture_test.go index 9573f87d7..3663d5ccc 100644 --- a/components/api-server/plugins/gateways/cluster_fixture_test.go +++ b/components/api-server/plugins/gateways/cluster_fixture_test.go @@ -8,22 +8,31 @@ import ( . "github.com/onsi/gomega" + "github.com/openshift-online/hypershell/components/api-server/pkg/api/openapi" "github.com/openshift-online/hypershell/components/api-server/plugins/managedClusters" - "github.com/openshift-online/hypershell/components/api-server/test" "github.com/openshift-online/rh-trex-ai/components/api-server/pkg/api" "github.com/openshift-online/rh-trex-ai/components/api-server/pkg/environments" ) // registerTestCluster registers a ManagedCluster the way a control plane does // (a unique OIDC subject via POST /registration semantics) and returns its id. -// Gateway create/update handlers accept only a cluster_id that references a -// registered cluster (managed-cluster-registration.spec.md). +// Placement resolution and gateway update handlers use only registered +// clusters (managed-cluster-registration.spec.md). func registerTestCluster(t *testing.T) string { t.Helper() id, _ := registerTestClusterWithSubject(t) return id } +func managedGatewayCreateRequest(name string) openapi.GatewayCreateRequest { + request := openapi.GatewayCreateRequest{Name: name} + request.SetPlacement(openapi.GatewayPlacementIntent{ + Network: openapi.PtrString("public"), + Provider: openapi.PtrString("aws"), + }) + return request +} + // registerTestClusterWithSubject is registerTestCluster that also returns the // OIDC subject the cluster registered under, so a test can act as its control // plane. @@ -40,7 +49,7 @@ func mustRegisterCluster() (string, string) { subject := "cp-" + suffix name := fmt.Sprintf("mc-%s", suffix) svc := managedClusters.Service(&environments.Environment().Services) - cluster, _, svcErr := svc.Register(context.Background(), name, "", subject) + cluster, _, svcErr := svc.Register(context.Background(), name, "", "aws", "public", subject) Expect(svcErr).To(BeNil(), "register test managed cluster %s", name) return cluster.ID, subject } @@ -59,7 +68,3 @@ func createManualCluster(t *testing.T) string { } return cluster.ID } - -func openapiErrorReason(_ *test.Helper, err error) string { - return test.APIErrorReason(err) -} diff --git a/components/api-server/plugins/gateways/cluster_reference.go b/components/api-server/plugins/gateways/cluster_reference.go index 6902797c3..d49b4c0c9 100644 --- a/components/api-server/plugins/gateways/cluster_reference.go +++ b/components/api-server/plugins/gateways/cluster_reference.go @@ -4,9 +4,42 @@ import ( "context" "strings" + "github.com/openshift-online/hypershell/components/api-server/pkg/api/openapi" "github.com/openshift-online/rh-trex-ai/components/api-server/pkg/errors" ) +type PlacementResolver func(context.Context, openapi.GatewayPlacementIntent) (string, *errors.ServiceError) +type PlacementAvailabilityResolver func(context.Context) (openapi.GatewayPlacementAvailability, *errors.ServiceError) + +func validatePlacementIntent(intent openapi.GatewayPlacementIntent) *errors.ServiceError { + mode, network, provider := "", "", "" + if intent.Mode != nil { + mode = *intent.Mode + } + if intent.Network != nil { + network = *intent.Network + } + if intent.Provider != nil { + provider = *intent.Provider + } + if mode != "" { + if mode != "local-kind" || network != "" || provider != "" { + return errors.Validation("placement mode must be local-kind and cannot include network or provider") + } + return nil + } + if network != "public" && network != "vpn" { + return errors.Validation("placement.network must be public or vpn") + } + if provider != "aws" && provider != "ibm" { + return errors.Validation("placement.provider must be aws or ibm") + } + if network == "vpn" && provider != "aws" { + return errors.Validation("VPN placement requires provider aws") + } + return nil +} + // RegisteredClusterLookup reports whether id names a ManagedCluster that a // control plane registered (non-empty oidc_subject). It returns (false, nil) for // an unknown id or a manually created record, and an error only when the lookup diff --git a/components/api-server/plugins/gateways/grpc_handler.go b/components/api-server/plugins/gateways/grpc_handler.go index 1730d3c67..8136b86ab 100644 --- a/components/api-server/plugins/gateways/grpc_handler.go +++ b/components/api-server/plugins/gateways/grpc_handler.go @@ -11,6 +11,7 @@ import ( "google.golang.org/grpc/status" pb "github.com/openshift-online/hypershell/components/api-server/pkg/api/grpc/hypershell/v1" + "github.com/openshift-online/hypershell/components/api-server/pkg/api/openapi" "github.com/openshift-online/hypershell/components/api-server/pkg/gatewayhealth" "github.com/openshift-online/rh-trex-ai/components/api-server/pkg/api" pkgserver "github.com/openshift-online/rh-trex-ai/components/api-server/pkg/server" @@ -24,10 +25,15 @@ type gatewayGRPCHandler struct { generic services.GenericService brokerFunc func() *pkgserver.EventBroker clusters RegisteredClusterLookup + placement PlacementResolver } -func NewGatewayGRPCHandler(svc GatewayService, generic services.GenericService, brokerFunc func() *pkgserver.EventBroker, clusters RegisteredClusterLookup) pb.GatewayServiceServer { - return &gatewayGRPCHandler{service: svc, generic: generic, brokerFunc: brokerFunc, clusters: clusters} +func NewGatewayGRPCHandler(svc GatewayService, generic services.GenericService, brokerFunc func() *pkgserver.EventBroker, clusters RegisteredClusterLookup, placement ...PlacementResolver) pb.GatewayServiceServer { + h := &gatewayGRPCHandler{service: svc, generic: generic, brokerFunc: brokerFunc, clusters: clusters} + if len(placement) > 0 { + h.placement = placement[0] + } + return h } // validateGatewayPhase rejects a phase outside the canonical vocabulary. An @@ -58,13 +64,37 @@ func (h *gatewayGRPCHandler) CreateGateway(ctx context.Context, req *pb.CreateGa if err := grpcutil.ValidateStringField("name", req.Name, true); err != nil { return nil, err } - if err := grpcutil.ValidateStringField("cluster_id", req.ClusterId, true); err != nil { + if err := validateGatewayPhase(req.Phase); err != nil { return nil, err } - if err := validateGatewayPhase(req.Phase); err != nil { + clusterID := req.ClusterId + if req.Placement != nil { + if h.placement == nil { + return nil, status.Error(codes.FailedPrecondition, "gateway placement resolver is unavailable") + } + intent := openapi.GatewayPlacementIntent{} + if network := req.Placement.GetNetworkProvider(); network != nil { + intent.Network = &network.Network + intent.Provider = &network.Provider + } else if req.Placement.GetLocalKind() != nil { + mode := "local-kind" + intent.Mode = &mode + } else { + return nil, status.Error(codes.InvalidArgument, "placement intent is empty") + } + if svcErr := validatePlacementIntent(intent); svcErr != nil { + return nil, grpcutil.ServiceErrorToGRPC(svcErr) + } + resolved, svcErr := h.placement(ctx, intent) + if svcErr != nil { + return nil, grpcutil.ServiceErrorToGRPC(svcErr) + } + clusterID = resolved + } + if err := grpcutil.ValidateStringField("cluster_id", clusterID, true); err != nil { return nil, err } - if svcErr := validateClusterReference(ctx, h.clusters, req.ClusterId); svcErr != nil { + if svcErr := validateClusterReference(ctx, h.clusters, clusterID); svcErr != nil { return nil, grpcutil.ServiceErrorToGRPC(svcErr) } var serverDnsNamesJSON *string @@ -76,7 +106,7 @@ func (h *gatewayGRPCHandler) CreateGateway(ctx context.Context, req *pb.CreateGa gateway := &Gateway{ Name: req.Name, - ClusterId: req.ClusterId, + ClusterId: clusterID, ExternalDns: req.ExternalDns, TlsMode: req.TlsMode, ServiceType: req.ServiceType, diff --git a/components/api-server/plugins/gateways/grpc_integration_test.go b/components/api-server/plugins/gateways/grpc_integration_test.go index 8d0334b52..f27c9d775 100644 --- a/components/api-server/plugins/gateways/grpc_integration_test.go +++ b/components/api-server/plugins/gateways/grpc_integration_test.go @@ -13,7 +13,6 @@ import ( "google.golang.org/grpc/credentials/insecure" pb "github.com/openshift-online/hypershell/components/api-server/pkg/api/grpc/hypershell/v1" - "github.com/openshift-online/hypershell/components/api-server/pkg/api/openapi" "github.com/openshift-online/hypershell/components/api-server/test" ) @@ -151,7 +150,7 @@ func TestGRPCWatchGateways(t *testing.T) { wg.Add(2) sinkReady := make(chan struct{}) - watchClusterID := registerTestCluster(t) + registerTestCluster(t) go func() { defer wg.Done() @@ -159,10 +158,7 @@ func TestGRPCWatchGateways(t *testing.T) { time.Sleep(100 * time.Millisecond) for name := range itemNames { - gatewayInput := openapi.GatewayCreateRequest{ - Name: name, - ClusterId: watchClusterID, - } + gatewayInput := managedGatewayCreateRequest(name) _, resp, postErr := client.DefaultAPI.CreateGateway(ctx).GatewayCreateRequest(gatewayInput).Execute() if postErr != nil { sourceErr = fmt.Errorf("REST POST failed for %s: %v", name, postErr) diff --git a/components/api-server/plugins/gateways/handler.go b/components/api-server/plugins/gateways/handler.go index 8b1e2a71c..2f6b2d439 100644 --- a/components/api-server/plugins/gateways/handler.go +++ b/components/api-server/plugins/gateways/handler.go @@ -40,6 +40,24 @@ type gatewayHandler struct { visibilityFilter GatewayVisibilityFilter ownerLookup GatewayOwnerLookup clusters RegisteredClusterLookup + placement PlacementResolver + availability PlacementAvailabilityResolver +} + +func (h gatewayHandler) GetPlacementAvailability(w http.ResponseWriter, r *http.Request) { + if h.availability == nil { + handlers.HandleGet(w, r, &handlers.HandlerConfig{Action: func() (interface{}, *errors.ServiceError) { + return nil, errors.GeneralError("gateway placement resolver is unavailable") + }, ErrorHandler: handlers.HandleError}) + return + } + handlers.HandleGet(w, r, &handlers.HandlerConfig{Action: func() (interface{}, *errors.ServiceError) { + availability, svcErr := h.availability(r.Context()) + if svcErr != nil { + return nil, svcErr + } + return availability, nil + }, ErrorHandler: handlers.HandleError}) } // validateGatewayPhaseValue rejects a phase outside the canonical vocabulary. An @@ -54,15 +72,18 @@ func validateGatewayPhaseValue(phase *string) *errors.ServiceError { return nil } -func NewGatewayHandler(gateway GatewayService, generic services.GenericService, ownerBinding OwnerBindingCreator, visibilityFilter GatewayVisibilityFilter, ownerLookup GatewayOwnerLookup, clusters RegisteredClusterLookup) *gatewayHandler { - return &gatewayHandler{ +func NewGatewayHandler(gateway GatewayService, generic services.GenericService, ownerBinding OwnerBindingCreator, visibilityFilter GatewayVisibilityFilter, ownerLookup GatewayOwnerLookup, clusters RegisteredClusterLookup, placement PlacementResolver, availability PlacementAvailabilityResolver) *gatewayHandler { + h := &gatewayHandler{ gateway: gateway, generic: generic, ownerBinding: ownerBinding, visibilityFilter: visibilityFilter, ownerLookup: ownerLookup, clusters: clusters, + placement: placement, + availability: availability, } + return h } func (h gatewayHandler) Create(w http.ResponseWriter, r *http.Request) { @@ -73,6 +94,18 @@ func (h gatewayHandler) Create(w http.ResponseWriter, r *http.Request) { Action: func() (interface{}, *errors.ServiceError) { ctx := r.Context() gatewayModel := ConvertGateway(gateway) + placement := gateway.GetPlacement() + if placementErr := validatePlacementIntent(placement); placementErr != nil { + return nil, placementErr + } + if h.placement == nil { + return nil, errors.GeneralError("gateway placement resolver is unavailable") + } + clusterID, placementErr := h.placement(ctx, placement) + if placementErr != nil { + return nil, placementErr + } + gatewayModel.ClusterId = clusterID if phaseErr := validateGatewayPhaseValue(gatewayModel.Phase); phaseErr != nil { return nil, phaseErr } diff --git a/components/api-server/plugins/gateways/integration_test.go b/components/api-server/plugins/gateways/integration_test.go index e7d9dccee..b3b338d61 100644 --- a/components/api-server/plugins/gateways/integration_test.go +++ b/components/api-server/plugins/gateways/integration_test.go @@ -50,15 +50,13 @@ func TestGatewayPost(t *testing.T) { account := h.NewRandAccount() ctx := h.NewAuthenticatedContext(account) - gatewayInput := openapi.GatewayCreateRequest{ - Name: "test-name", - ClusterId: registerTestCluster(t), - ExternalDns: openapi.PtrString("test-external_dns"), - TlsMode: openapi.PtrString("test-tls_mode"), - ServiceType: openapi.PtrString("test-service_type"), - Status: openapi.PtrString("test-status"), - Phase: openapi.PtrString("Provisioning"), - } + clusterID := registerTestCluster(t) + gatewayInput := managedGatewayCreateRequest("test-name") + gatewayInput.ExternalDns = openapi.PtrString("test-external_dns") + gatewayInput.TlsMode = openapi.PtrString("test-tls_mode") + gatewayInput.ServiceType = openapi.PtrString("test-service_type") + gatewayInput.Status = openapi.PtrString("test-status") + gatewayInput.Phase = openapi.PtrString("Provisioning") gatewayOutput, resp, err := client.DefaultAPI.CreateGateway(ctx).GatewayCreateRequest(gatewayInput).Execute() Expect(err).NotTo(HaveOccurred(), "Error posting object: %v", err) @@ -66,6 +64,7 @@ func TestGatewayPost(t *testing.T) { Expect(*gatewayOutput.Id).NotTo(BeEmpty(), "Expected ID assigned on creation") Expect(*gatewayOutput.Kind).To(Equal("Gateway")) Expect(*gatewayOutput.Href).To(Equal(fmt.Sprintf("/api/hypershell/v1/gateways/%s", *gatewayOutput.Id))) + Expect(gatewayOutput.ClusterId).To(Equal(clusterID)) Expect(gatewayOutput.Namespace).To(MatchRegexp(`^openshell-[0-9a-f]{16}$`)) jwtToken := ctx.Value(openapi.ContextAccessToken) @@ -85,10 +84,7 @@ func TestGatewayPostMinimalFields(t *testing.T) { account := h.NewRandAccount() ctx := h.NewAuthenticatedContext(account) clusterID := registerTestCluster(t) - gatewayInput := openapi.GatewayCreateRequest{ - Name: "local-gateway", - ClusterId: clusterID, - } + gatewayInput := managedGatewayCreateRequest("local-gateway") gatewayOutput, resp, err := client.DefaultAPI.CreateGateway(ctx).GatewayCreateRequest(gatewayInput).Execute() Expect(err).NotTo(HaveOccurred(), "Error posting gateway with minimal fields: %v", err) @@ -97,43 +93,24 @@ func TestGatewayPostMinimalFields(t *testing.T) { Expect(gatewayOutput.Namespace).To(MatchRegexp(`^openshell-[0-9a-f]{16}$`)) } -// Gateways Reference a Registered Cluster: an empty cluster_id is a 400 naming -// cluster_id, and no gateway is created. -func TestGatewayPostRejectsEmptyClusterID(t *testing.T) { +// Gateway creation accepts placement intent only. A legacy direct cluster_id +// is rejected as an unknown request field even when valid placement is present. +func TestGatewayPostRejectsDirectClusterID(t *testing.T) { h, client := test.RegisterIntegration(t) account := h.NewRandAccount() ctx := h.NewAuthenticatedContext(account) - - _, resp, err := client.DefaultAPI.CreateGateway(ctx).GatewayCreateRequest(openapi.GatewayCreateRequest{ - Name: "no-cluster", - ClusterId: "", - }).Execute() - Expect(err).To(HaveOccurred()) - Expect(resp.StatusCode).To(Equal(http.StatusBadRequest)) - Expect(openapiErrorReason(h, err)).To(ContainSubstring("cluster_id")) - expectNoGatewayNamed(ctx, client, "no-cluster") -} - -// Gateways Reference a Registered Cluster: a cluster_id naming a manually -// created ManagedCluster (empty oidc_subject) or no ManagedCluster at all is a -// 400 stating the cluster has no registered control plane. -func TestGatewayPostRejectsUnregisteredCluster(t *testing.T) { - h, client := test.RegisterIntegration(t) - - account := h.NewRandAccount() - ctx := h.NewAuthenticatedContext(account) - - for _, clusterID := range []string{createManualCluster(t), "2doesnotexist000000000000000"} { - _, resp, err := client.DefaultAPI.CreateGateway(ctx).GatewayCreateRequest(openapi.GatewayCreateRequest{ - Name: "unregistered-cluster", - ClusterId: clusterID, - }).Execute() - Expect(err).To(HaveOccurred(), "cluster_id %s must be rejected", clusterID) - Expect(resp.StatusCode).To(Equal(http.StatusBadRequest)) - Expect(openapiErrorReason(h, err)).To(ContainSubstring("registered control plane")) - } - expectNoGatewayNamed(ctx, client, "unregistered-cluster") + registerTestCluster(t) + jwToken := ctx.Value(openapi.ContextAccessToken) + resp, err := resty.R(). + SetHeader("Content-Type", "application/json"). + SetHeader("Authorization", fmt.Sprintf("Bearer %s", jwToken)). + SetBody(`{"name":"direct-cluster","placement":{"network":"public","provider":"aws"},"cluster_id":"legacy-cluster"}`). + Post(h.RestURL("/gateways")) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode()).To(Equal(http.StatusBadRequest)) + Expect(string(resp.Body())).To(ContainSubstring("cluster_id")) + expectNoGatewayNamed(ctx, client, "direct-cluster") } // A PATCH that moves a gateway to an unregistered cluster is rejected; one that @@ -144,11 +121,8 @@ func TestGatewayPatchValidatesClusterReassignment(t *testing.T) { account := h.NewRandAccount() ctx := h.NewAuthenticatedContext(account) - clusterID := registerTestCluster(t) - created, _, err := client.DefaultAPI.CreateGateway(ctx).GatewayCreateRequest(openapi.GatewayCreateRequest{ - Name: "patch-cluster", - ClusterId: clusterID, - }).Execute() + registerTestCluster(t) + created, _, err := client.DefaultAPI.CreateGateway(ctx).GatewayCreateRequest(managedGatewayCreateRequest("patch-cluster")).Execute() Expect(err).NotTo(HaveOccurred()) _, resp, err := client.DefaultAPI.UpdateGateway(ctx, *created.Id).GatewayPatchRequest(openapi.GatewayPatchRequest{ @@ -195,10 +169,8 @@ func TestGatewayPostWithoutRouteRemainsUnrouted(t *testing.T) { account := h.NewRandAccount() ctx := h.NewAuthenticatedContext(account) - gatewayInput := openapi.GatewayCreateRequest{ - Name: "route-default-test", - ClusterId: registerTestCluster(t), - } + registerTestCluster(t) + gatewayInput := managedGatewayCreateRequest("route-default-test") gatewayOutput, resp, err := client.DefaultAPI.CreateGateway(ctx).GatewayCreateRequest(gatewayInput).Execute() Expect(err).NotTo(HaveOccurred()) @@ -213,11 +185,9 @@ func TestGatewayPostPreservesExplicitRoute(t *testing.T) { ctx := h.NewAuthenticatedContext(account) customRoute := `{"enabled":true,"host":"custom.example.com"}` - gatewayInput := openapi.GatewayCreateRequest{ - Name: "route-explicit-test", - ClusterId: registerTestCluster(t), - Route: openapi.PtrString(customRoute), - } + registerTestCluster(t) + gatewayInput := managedGatewayCreateRequest("route-explicit-test") + gatewayInput.Route = openapi.PtrString(customRoute) gatewayOutput, resp, err := client.DefaultAPI.CreateGateway(ctx).GatewayCreateRequest(gatewayInput).Execute() Expect(err).NotTo(HaveOccurred()) @@ -332,11 +302,9 @@ func TestGatewayPostWithCredentialDriver(t *testing.T) { ctx := h.NewAuthenticatedContext(account) credDriver := `{"type":"kubernetes-secrets","kubernetes_secrets":{"namespace":"creds-ns"}}` - gatewayInput := openapi.GatewayCreateRequest{ - Name: "test-cred-driver", - ClusterId: registerTestCluster(t), - CredentialDriver: openapi.PtrString(credDriver), - } + registerTestCluster(t) + gatewayInput := managedGatewayCreateRequest("test-cred-driver") + gatewayInput.CredentialDriver = openapi.PtrString(credDriver) gatewayOutput, resp, err := client.DefaultAPI.CreateGateway(ctx).GatewayCreateRequest(gatewayInput).Execute() Expect(err).NotTo(HaveOccurred(), "Error posting gateway with credential_driver: %v", err) diff --git a/components/api-server/plugins/gateways/phase_validation_test.go b/components/api-server/plugins/gateways/phase_validation_test.go index 1d1ef86fc..9a79a3075 100644 --- a/components/api-server/plugins/gateways/phase_validation_test.go +++ b/components/api-server/plugins/gateways/phase_validation_test.go @@ -43,6 +43,7 @@ func TestGRPCGatewayRejectsUnknownPhase(t *testing.T) { _, err = grpcClient.CreateGateway(ctx, &pb.CreateGatewayRequest{ Name: "reject-create", ClusterId: registerTestCluster(t), + ReleaseId: "test-release_id", Phase: &badPhase, }) Expect(err).To(HaveOccurred(), "create with an unknown phase must be rejected") @@ -53,6 +54,7 @@ func TestGRPCGatewayRejectsUnknownPhase(t *testing.T) { created, err := grpcClient.CreateGateway(ctx, &pb.CreateGatewayRequest{ Name: "accept-create", ClusterId: registerTestCluster(t), + ReleaseId: "test-release_id", Phase: &goodPhase, }) Expect(err).NotTo(HaveOccurred()) @@ -82,6 +84,7 @@ func TestGRPCGatewayRejectsUnknownPhase(t *testing.T) { _, err = grpcClient.CreateGateway(ctx, &pb.CreateGatewayRequest{ Name: "accept-absent-phase", ClusterId: registerTestCluster(t), + ReleaseId: "test-release_id", }) Expect(err).NotTo(HaveOccurred(), "create without a phase must be accepted") } @@ -120,11 +123,10 @@ func TestRESTGatewayRejectsUnknownPhase(t *testing.T) { account := h.NewRandAccount() ctx := h.NewAuthenticatedContext(account) - _, resp, err := client.DefaultAPI.CreateGateway(ctx).GatewayCreateRequest(openapi.GatewayCreateRequest{ - Name: "reject-rest-create", - ClusterId: registerTestCluster(t), - Phase: openapi.PtrString("Booting"), - }).Execute() + registerTestCluster(t) + request := managedGatewayCreateRequest("reject-rest-create") + request.Phase = openapi.PtrString("Booting") + _, resp, err := client.DefaultAPI.CreateGateway(ctx).GatewayCreateRequest(request).Execute() Expect(err).To(HaveOccurred(), "REST create with an unknown phase must be rejected") Expect(resp.StatusCode).To(Equal(http.StatusBadRequest)) } diff --git a/components/api-server/plugins/gateways/placement.go b/components/api-server/plugins/gateways/placement.go new file mode 100644 index 000000000..490f5ac61 --- /dev/null +++ b/components/api-server/plugins/gateways/placement.go @@ -0,0 +1,87 @@ +package gateways + +import ( + "crypto/rand" + "errors" + "fmt" + "math/big" + "time" +) + +var errNoEligiblePlacement = errors.New("no eligible placement is available") + +// PlacementIntent is the client-facing placement request. Cluster IDs are +// deliberately absent: they are selected by the API server at create time. +type PlacementIntent struct { + Mode string `json:"mode,omitempty"` + Network string `json:"network,omitempty"` + Provider string `json:"provider,omitempty"` +} + +const managedClusterConnectedMaxAge = 5 * time.Minute + +func PlacementControlPlaneConnected(lastSeen *time.Time, now time.Time) bool { + if lastSeen == nil { + return false + } + age := now.Sub(*lastSeen) + return age >= 0 && age < managedClusterConnectedMaxAge +} + +type PlacementCandidate struct { + ID string + Provider string + Visibility string + Connected bool +} + +func PlacementSupported(network, provider string) bool { + switch provider { + case "aws": + return network == "public" || network == "vpn" + case "ibm": + return network == "public" + default: + return false + } +} + +func eligiblePlacementCandidates(intent PlacementIntent, candidates []PlacementCandidate) []PlacementCandidate { + eligible := make([]PlacementCandidate, 0, len(candidates)) + for _, candidate := range candidates { + if candidate.Connected && candidate.Provider == intent.Provider && candidate.Visibility == intent.Network { + eligible = append(eligible, candidate) + } + } + return eligible +} + +func randomPlacementIndex(size int) (int, error) { + index, err := rand.Int(rand.Reader, big.NewInt(int64(size))) + if err != nil { + return 0, fmt.Errorf("select random placement: %w", err) + } + return int(index.Int64()), nil +} + +func resolvePlacement(intent PlacementIntent, candidates []PlacementCandidate, selectIndex func(int) (int, error)) (string, error) { + if !PlacementSupported(intent.Network, intent.Provider) { + return "", fmt.Errorf("placement %s/%s is unsupported", intent.Provider, intent.Network) + } + eligible := eligiblePlacementCandidates(intent, candidates) + if len(eligible) == 0 { + return "", errNoEligiblePlacement + } + index, err := selectIndex(len(eligible)) + if err != nil { + return "", err + } + if index < 0 || index >= len(eligible) { + return "", fmt.Errorf("placement selector returned invalid index %d", index) + } + return eligible[index].ID, nil +} + +func ResolvePlacement(intent PlacementIntent, candidates []PlacementCandidate) (string, error) { + return resolvePlacement(intent, candidates, randomPlacementIndex) +} diff --git a/components/api-server/plugins/gateways/placement_test.go b/components/api-server/plugins/gateways/placement_test.go new file mode 100644 index 000000000..8f1c3e2a8 --- /dev/null +++ b/components/api-server/plugins/gateways/placement_test.go @@ -0,0 +1,90 @@ +package gateways + +import ( + "errors" + "testing" + "time" +) + +func TestPlacementControlPlaneConnectedUsesHeartbeatWindow(t *testing.T) { + now := time.Now() + healthy := now.Add(-time.Minute) + offline := now.Add(-31 * time.Minute) + if !PlacementControlPlaneConnected(&healthy, now) { + t.Fatal("expected a recent heartbeat to be connected") + } + if PlacementControlPlaneConnected(&offline, now) || PlacementControlPlaneConnected(nil, now) { + t.Fatal("expected an absent or stale heartbeat to be disconnected") + } +} + +func TestPlacementSupported(t *testing.T) { + tests := []struct { + network string + provider string + want bool + }{ + {network: "public", provider: "aws", want: true}, + {network: "vpn", provider: "aws", want: true}, + {network: "public", provider: "ibm", want: true}, + {network: "vpn", provider: "ibm", want: false}, + } + for _, test := range tests { + if got := PlacementSupported(test.network, test.provider); got != test.want { + t.Errorf("PlacementSupported(%q, %q) = %t, want %t", test.network, test.provider, got, test.want) + } + } +} + +func TestResolvePlacementSelectsRandomEligibleCandidate(t *testing.T) { + selectedSize := 0 + got, err := resolvePlacement(PlacementIntent{Network: "public", Provider: "aws"}, []PlacementCandidate{ + {ID: "disconnected", Provider: "aws", Connected: false}, + {ID: "ibm", Provider: "ibm", Connected: true}, + {ID: "aws-a", Provider: "aws", Visibility: "public", Connected: true}, + {ID: "aws-b", Provider: "aws", Visibility: "public", Connected: true}, + }, func(size int) (int, error) { + selectedSize = size + return 1, nil + }) + if err != nil { + t.Fatal(err) + } + if selectedSize != 2 || got != "aws-b" { + t.Fatalf("eligible size = %d, selected = %q; want 2 and aws-b", selectedSize, got) + } +} + +func TestResolvePlacementRejectsUnsupportedAndUnavailablePlacement(t *testing.T) { + if _, err := ResolvePlacement(PlacementIntent{Network: "vpn", Provider: "ibm"}, nil); err == nil { + t.Fatal("expected unsupported placement error") + } + if _, err := ResolvePlacement(PlacementIntent{Network: "public", Provider: "aws"}, nil); err == nil { + t.Fatal("expected unavailable placement error") + } +} + +func TestResolvePlacementReturnsRandomSourceFailure(t *testing.T) { + want := errors.New("random unavailable") + _, err := resolvePlacement( + PlacementIntent{Network: "public", Provider: "aws"}, + []PlacementCandidate{{ID: "aws", Provider: "aws", Visibility: "public", Connected: true}}, + func(int) (int, error) { return 0, want }, + ) + if !errors.Is(err, want) { + t.Fatalf("error = %v, want %v", err, want) + } +} + +func TestManagedPlacementAvailableRequiresMatchingVisibility(t *testing.T) { + snapshot := placementSnapshot{providers: map[string]*providerPlacementState{ + "aws": {candidates: []PlacementCandidate{{ID: "public-aws", Provider: "aws", Visibility: "public", Connected: true}}}, + }} + + if !managedPlacementAvailable(snapshot, "public", "aws") { + t.Fatal("expected public AWS placement to be available") + } + if managedPlacementAvailable(snapshot, "vpn", "aws") { + t.Fatal("expected VPN AWS placement to be unavailable without a VPN cluster") + } +} diff --git a/components/api-server/plugins/gateways/plugin.go b/components/api-server/plugins/gateways/plugin.go index 0128e3c2a..600224c31 100644 --- a/components/api-server/plugins/gateways/plugin.go +++ b/components/api-server/plugins/gateways/plugin.go @@ -2,12 +2,19 @@ package gateways import ( "context" + stderrors "errors" + "fmt" "net/http" + "os" + "strings" + "time" + "github.com/golang/glog" "github.com/gorilla/mux" "google.golang.org/grpc" pb "github.com/openshift-online/hypershell/components/api-server/pkg/api/grpc/hypershell/v1" + "github.com/openshift-online/hypershell/components/api-server/pkg/api/openapi" "github.com/openshift-online/hypershell/components/api-server/pkg/rbac" "github.com/openshift-online/hypershell/components/api-server/plugins/managedClusters" "github.com/openshift-online/hypershell/components/api-server/plugins/roleBindings" @@ -110,6 +117,134 @@ func registeredClusterLookup(s *environments.Services) RegisteredClusterLookup { } } +type providerPlacementState struct { + candidates []PlacementCandidate +} + +type placementSnapshot struct { + providers map[string]*providerPlacementState + localID string +} + +type registeredPlacementService struct { + services *environments.Services + now func() time.Time +} + +func newRegisteredPlacementService(s *environments.Services) *registeredPlacementService { + return ®isteredPlacementService{services: s, now: time.Now} +} + +func normalizePlacementProvider(provider string) string { + provider = strings.ToLower(strings.TrimSpace(provider)) + if provider == "ibm cloud" { + return "ibm" + } + return provider +} + +func (p *registeredPlacementService) snapshot(ctx context.Context) (placementSnapshot, *errors.ServiceError) { + result := placementSnapshot{providers: map[string]*providerPlacementState{"aws": {}, "ibm": {}}} + svc := managedClusters.Service(p.services) + if svc == nil { + return result, errors.GeneralError("managed cluster service is not available") + } + clusters, svcErr := svc.All(ctx) + if svcErr != nil { + return result, svcErr + } + for _, cluster := range clusters { + if cluster == nil || cluster.OIDCSubject == "" || !PlacementControlPlaneConnected(cluster.LastSeenAt, p.now()) { + continue + } + provider := normalizePlacementProvider(cluster.Provider) + isLocal := cluster.Name == "local-kind" + if isLocal { + if os.Getenv("HYPERSHELL_PLATFORM_KIND") == "true" { + result.localID = cluster.ID + } + continue + } + state := result.providers[provider] + if state == nil { + continue + } + state.candidates = append(state.candidates, PlacementCandidate{ID: cluster.ID, Provider: provider, Visibility: cluster.Visibility, Connected: true}) + } + return result, nil +} + +func (p *registeredPlacementService) selectManaged(snapshot placementSnapshot, network, provider string) (string, error) { + state := snapshot.providers[provider] + if state == nil { + return "", fmt.Errorf("unsupported provider %q", provider) + } + return ResolvePlacement(PlacementIntent{Network: network, Provider: provider}, state.candidates) +} + +func managedPlacementAvailable(snapshot placementSnapshot, network, provider string) bool { + state := snapshot.providers[provider] + if !PlacementSupported(network, provider) || state == nil { + return false + } + for _, candidate := range state.candidates { + if candidate.Visibility == network { + return true + } + } + return false +} + +func (p *registeredPlacementService) Resolve(ctx context.Context, intent openapi.GatewayPlacementIntent) (string, *errors.ServiceError) { + snapshot, svcErr := p.snapshot(ctx) + if svcErr != nil { + return "", svcErr + } + if intent.Mode != nil && *intent.Mode == "local-kind" { + if os.Getenv("HYPERSHELL_PLATFORM_KIND") != "true" { + return "", errors.Validation("local-kind placement is only available in the Kind development environment") + } + if snapshot.localID == "" { + return "", errors.Validation("local-kind placement is unavailable") + } + return snapshot.localID, nil + } + if intent.Network == nil || intent.Provider == nil || !PlacementSupported(*intent.Network, *intent.Provider) { + return "", errors.Validation("the requested network and provider placement is unsupported") + } + selected, err := p.selectManaged(snapshot, *intent.Network, *intent.Provider) + if err != nil { + if stderrors.Is(err, errNoEligiblePlacement) { + return "", errors.Validation("no eligible managed cluster is available for the requested placement") + } + glog.Errorf("failed to resolve managed cluster placement provider=%s network=%s: %v", *intent.Provider, *intent.Network, err) + return "", errors.GeneralError("failed to resolve managed cluster placement") + } + return selected, nil +} + +func (p *registeredPlacementService) Availability(ctx context.Context) (openapi.GatewayPlacementAvailability, *errors.ServiceError) { + snapshot, svcErr := p.snapshot(ctx) + if svcErr != nil { + return openapi.GatewayPlacementAvailability{}, svcErr + } + availability := openapi.GatewayPlacementAvailability{} + availability.AwsPublic = managedPlacementAvailable(snapshot, "public", "aws") + availability.AwsVpn = managedPlacementAvailable(snapshot, "vpn", "aws") + availability.IbmPublic = managedPlacementAvailable(snapshot, "public", "ibm") + availability.IbmVpn = false + if !availability.AwsPublic && !availability.AwsVpn { + reason := "no-eligible-cluster" + availability.AwsReason = &reason + } + if !availability.IbmPublic { + reason := "no-eligible-cluster" + availability.IbmReason = &reason + } + availability.LocalKind = os.Getenv("HYPERSHELL_PLATFORM_KIND") == "true" && snapshot.localID != "" + return availability, nil +} + func init() { registry.RegisterService("Gateways", func(env interface{}) interface{} { return NewServiceLocator(env.(*environments.Env)) @@ -132,10 +267,12 @@ func init() { }) ownerLookup = rbService } - gatewayHandler := NewGatewayHandler(Service(envServices), listService(envServices), ownerBinding, visibilityFilter, ownerLookup, registeredClusterLookup(envServices)) + placement := newRegisteredPlacementService(envServices) + gatewayHandler := NewGatewayHandler(Service(envServices), listService(envServices), ownerBinding, visibilityFilter, ownerLookup, registeredClusterLookup(envServices), placement.Resolve, placement.Availability) gatewaysRouter := apiV1Router.PathPrefix("/gateways").Subrouter() gatewaysRouter.HandleFunc("", gatewayHandler.List).Methods(http.MethodGet) + gatewaysRouter.HandleFunc("/placement-availability", gatewayHandler.GetPlacementAvailability).Methods(http.MethodGet) gatewaysRouter.HandleFunc("/{id}", gatewayHandler.Get).Methods(http.MethodGet) gatewaysRouter.HandleFunc("", gatewayHandler.Create).Methods(http.MethodPost) gatewaysRouter.HandleFunc("/{id}", gatewayHandler.Patch).Methods(http.MethodPatch) @@ -172,7 +309,8 @@ func init() { } return nil } - pb.RegisterGatewayServiceServer(grpcServer, NewGatewayGRPCHandler(gatewayService, genericService, brokerFunc, registeredClusterLookup(envServices))) + placement := newRegisteredPlacementService(envServices) + pb.RegisterGatewayServiceServer(grpcServer, NewGatewayGRPCHandler(gatewayService, genericService, brokerFunc, registeredClusterLookup(envServices), placement.Resolve)) }) presenters.RegisterPath(Gateway{}, "gateways") diff --git a/components/api-server/plugins/gateways/presenter.go b/components/api-server/plugins/gateways/presenter.go index a94da0182..ad1b039b0 100644 --- a/components/api-server/plugins/gateways/presenter.go +++ b/components/api-server/plugins/gateways/presenter.go @@ -11,7 +11,8 @@ import ( func ConvertGateway(gateway openapi.GatewayCreateRequest) *Gateway { c := &Gateway{} c.Name = gateway.Name - c.ClusterId = gateway.ClusterId + // Placement resolves the concrete cluster in the create handler before the + // Gateway model is persisted. c.ExternalDns = gateway.ExternalDns c.TlsMode = gateway.TlsMode c.ServiceType = gateway.ServiceType diff --git a/components/api-server/plugins/managedClusters/dao.go b/components/api-server/plugins/managedClusters/dao.go index 7f4fd4853..484710155 100644 --- a/components/api-server/plugins/managedClusters/dao.go +++ b/components/api-server/plugins/managedClusters/dao.go @@ -24,8 +24,8 @@ type ManagedClusterDao interface { // (oidc_subject, name) spans soft-deleted rows, so there is at most one. FindDeletedBySubjectAndName(ctx context.Context, subject, name string) (*ManagedCluster, error) // Restore clears deleted_at on a soft-deleted record, keeping its id, and - // stamps last_seen_at. - Restore(ctx context.Context, id string, lastSeenAt time.Time) (*ManagedCluster, error) + // atomically refreshes its placement metadata from the new registration. + Restore(ctx context.Context, id, provider, visibility string, lastSeenAt time.Time) (*ManagedCluster, error) InventorySnapshot(ctx context.Context, evaluationTime time.Time) (*ClusterInventorySnapshot, error) } @@ -122,10 +122,16 @@ func (d *sqlManagedClusterDao) FindDeletedBySubjectAndName(ctx context.Context, return &managedCluster, nil } -func (d *sqlManagedClusterDao) Restore(ctx context.Context, id string, lastSeenAt time.Time) (*ManagedCluster, error) { +func (d *sqlManagedClusterDao) Restore(ctx context.Context, id, provider, visibility string, lastSeenAt time.Time) (*ManagedCluster, error) { g2 := (*d.sessionFactory).New(ctx) if err := g2.Unscoped().Model(&ManagedCluster{}).Where("id = ?", id). - Updates(map[string]interface{}{"deleted_at": nil, "last_seen_at": lastSeenAt, "updated_at": time.Now()}).Error; err != nil { + Updates(map[string]interface{}{ + "deleted_at": nil, + "provider": provider, + "visibility": visibility, + "last_seen_at": lastSeenAt, + "updated_at": time.Now(), + }).Error; err != nil { db.MarkForRollback(ctx, err) return nil, err } diff --git a/components/api-server/plugins/managedClusters/handler.go b/components/api-server/plugins/managedClusters/handler.go index d16d811cc..f0d477133 100644 --- a/components/api-server/plugins/managedClusters/handler.go +++ b/components/api-server/plugins/managedClusters/handler.go @@ -59,6 +59,28 @@ func (h managedClusterHandler) Register(w http.ResponseWriter, r *http.Request) )) return } + provider := req.GetProvider() + visibility := req.GetVisibility() + // Provider and visibility were added after managed-cluster registration was + // already deployed. Accept an omitted pair while older control planes are + // still posting heartbeats; the service preserves any placement metadata + // previously supplied by a newer client. + if (provider == "") != (visibility == "") { + handlers.HandleError(r.Context(), w, errors.Validation("provider and visibility must be supplied together")) + return + } + if provider != "" && provider != "aws" && provider != "ibm" && provider != "kind" { + handlers.HandleError(r.Context(), w, errors.Validation("provider must be aws, ibm, or kind")) + return + } + if visibility != "" && visibility != "public" && visibility != "vpn" { + handlers.HandleError(r.Context(), w, errors.Validation("visibility must be public or vpn")) + return + } + if provider == "ibm" && visibility == "vpn" { + handlers.HandleError(r.Context(), w, errors.Validation("IBM placement does not support VPN visibility")) + return + } ctx := r.Context() token, tokenErr := auth.TokenFromContext(ctx) @@ -82,7 +104,7 @@ func (h managedClusterHandler) Register(w http.ResponseWriter, r *http.Request) description = *req.Description } - cluster, created, svcErr := h.managedCluster.Register(ctx, req.Name, description, oidcSubject) + cluster, created, svcErr := h.managedCluster.Register(ctx, req.Name, description, provider, visibility, oidcSubject) if svcErr != nil { handlers.HandleError(r.Context(), w, svcErr) return diff --git a/components/api-server/plugins/managedClusters/migration.go b/components/api-server/plugins/managedClusters/migration.go index 38e561aeb..3cc75cce2 100644 --- a/components/api-server/plugins/managedClusters/migration.go +++ b/components/api-server/plugins/managedClusters/migration.go @@ -40,6 +40,18 @@ func migrationAddRegistrationFields() *gormigrate.Migration { } } +func migrationAddPlacementVisibility() *gormigrate.Migration { + return &gormigrate.Migration{ + ID: "2026092900000001", + Migrate: func(tx *gorm.DB) error { + return tx.Exec(`ALTER TABLE managed_clusters ADD COLUMN IF NOT EXISTS visibility TEXT`).Error + }, + Rollback: func(tx *gorm.DB) error { + return tx.Exec(`ALTER TABLE managed_clusters DROP COLUMN IF EXISTS visibility`).Error + }, + } +} + // migrationUniqueName makes managed_clusters.name unique across all live // records, registered or not, so discovery by name (seed scripts, e2e, // operators) is unambiguous and a registration can never create a second diff --git a/components/api-server/plugins/managedClusters/mock_dao.go b/components/api-server/plugins/managedClusters/mock_dao.go index 3eff7bf52..d502e1513 100644 --- a/components/api-server/plugins/managedClusters/mock_dao.go +++ b/components/api-server/plugins/managedClusters/mock_dao.go @@ -77,7 +77,7 @@ func (d *managedClusterDaoMock) FindDeletedBySubjectAndName(ctx context.Context, return nil, gorm.ErrRecordNotFound } -func (d *managedClusterDaoMock) Restore(ctx context.Context, id string, lastSeenAt time.Time) (*ManagedCluster, error) { +func (d *managedClusterDaoMock) Restore(ctx context.Context, id, provider, visibility string, lastSeenAt time.Time) (*ManagedCluster, error) { return nil, errors.NotImplemented("ManagedCluster").AsError() } diff --git a/components/api-server/plugins/managedClusters/model.go b/components/api-server/plugins/managedClusters/model.go index 149830caf..e21cdb554 100644 --- a/components/api-server/plugins/managedClusters/model.go +++ b/components/api-server/plugins/managedClusters/model.go @@ -13,6 +13,7 @@ type ManagedCluster struct { hypershellapi.TraceMeta Name string `json:"name"` Provider string `json:"provider"` + Visibility string `json:"visibility" gorm:"column:visibility"` Region *string `json:"region"` KubeconfigSecret string `json:"kubeconfig_secret"` Status *string `json:"status"` diff --git a/components/api-server/plugins/managedClusters/plugin.go b/components/api-server/plugins/managedClusters/plugin.go index aab2a17d8..4cf073565 100644 --- a/components/api-server/plugins/managedClusters/plugin.go +++ b/components/api-server/plugins/managedClusters/plugin.go @@ -100,5 +100,6 @@ func init() { db.RegisterMigration(migrationDropFleetId()) db.RegisterMigration(migrationAddTraceContext()) db.RegisterMigration(migrationAddRegistrationFields()) + db.RegisterMigration(migrationAddPlacementVisibility()) db.RegisterMigration(migrationUniqueName()) } diff --git a/components/api-server/plugins/managedClusters/presenter.go b/components/api-server/plugins/managedClusters/presenter.go index 7404edc94..a1d857e77 100644 --- a/components/api-server/plugins/managedClusters/presenter.go +++ b/components/api-server/plugins/managedClusters/presenter.go @@ -15,6 +15,7 @@ func ConvertManagedCluster(managedCluster openapi.ManagedCluster) *ManagedCluste } c.Name = managedCluster.Name c.Provider = managedCluster.Provider + c.Visibility = managedCluster.GetVisibility() c.Region = managedCluster.Region c.KubeconfigSecret = managedCluster.KubeconfigSecret c.Status = managedCluster.Status @@ -30,6 +31,10 @@ func ConvertManagedCluster(managedCluster openapi.ManagedCluster) *ManagedCluste func PresentManagedCluster(managedCluster *ManagedCluster) openapi.ManagedCluster { reference := presenters.PresentReference(managedCluster.ID, managedCluster) + var visibility *string + if managedCluster.Visibility != "" { + visibility = &managedCluster.Visibility + } result := openapi.ManagedCluster{ Id: reference.Id, Kind: reference.Kind, @@ -38,6 +43,7 @@ func PresentManagedCluster(managedCluster *ManagedCluster) openapi.ManagedCluste UpdatedAt: openapi.PtrTime(managedCluster.UpdatedAt), Name: managedCluster.Name, Provider: managedCluster.Provider, + Visibility: visibility, Region: managedCluster.Region, KubeconfigSecret: managedCluster.KubeconfigSecret, Status: managedCluster.Status, diff --git a/components/api-server/plugins/managedClusters/registration_integration_test.go b/components/api-server/plugins/managedClusters/registration_integration_test.go index 18b4fe47f..a68552f74 100644 --- a/components/api-server/plugins/managedClusters/registration_integration_test.go +++ b/components/api-server/plugins/managedClusters/registration_integration_test.go @@ -23,7 +23,7 @@ func controlPlaneContext(h *test.Helper, subject string) context.Context { func register(client *openapi.APIClient, ctx context.Context, name string) (*openapi.ManagedClusterRegistrationResponse, *http.Response, error) { return client.DefaultAPI.RegisterManagedCluster(ctx). - ManagedClusterRegistrationRequest(openapi.ManagedClusterRegistrationRequest{Name: name}). + ManagedClusterRegistrationRequest(openapi.ManagedClusterRegistrationRequest{Name: name, Provider: openapi.PtrString("aws"), Visibility: openapi.PtrString("public")}). Execute() } @@ -58,6 +58,87 @@ func TestManagedClusterRegistrationIdempotent(t *testing.T) { Expect(resp.StatusCode).To(Equal(http.StatusConflict)) } +func TestManagedClusterRegistrationAcceptsLegacyHeartbeatWithoutErasingPlacement(t *testing.T) { + h, client := test.RegisterIntegration(t) + ctx := controlPlaneContext(h, uniqueName("cp")) + name := uniqueName("hyp0-mc") + + legacy := openapi.ManagedClusterRegistrationRequest{Name: name} + first, resp, err := client.DefaultAPI.RegisterManagedCluster(ctx). + ManagedClusterRegistrationRequest(legacy). + Execute() + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusCreated)) + + _, resp, err = client.DefaultAPI.RegisterManagedCluster(ctx). + ManagedClusterRegistrationRequest(openapi.ManagedClusterRegistrationRequest{ + Name: name, + Provider: openapi.PtrString("ibm"), + Visibility: openapi.PtrString("public"), + }). + Execute() + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + + _, resp, err = client.DefaultAPI.RegisterManagedCluster(ctx). + ManagedClusterRegistrationRequest(legacy). + Execute() + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + + cluster, _, err := client.DefaultAPI.GetManagedCluster(ctx, first.ClusterId).Execute() + Expect(err).NotTo(HaveOccurred()) + Expect(cluster.Provider).To(Equal("ibm")) + Expect(cluster.GetVisibility()).To(Equal("public")) + + resp, err = client.DefaultAPI.DeleteManagedCluster(ctx, first.ClusterId).Execute() + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusNoContent)) + + _, resp, err = client.DefaultAPI.RegisterManagedCluster(ctx). + ManagedClusterRegistrationRequest(legacy). + Execute() + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusCreated)) + + cluster, _, err = client.DefaultAPI.GetManagedCluster(ctx, first.ClusterId).Execute() + Expect(err).NotTo(HaveOccurred()) + Expect(cluster.Provider).To(Equal("ibm")) + Expect(cluster.GetVisibility()).To(Equal("public")) +} + +// Re-registration restores placement metadata from the current registration, +// rather than retaining the provider and visibility from the deleted record. +func TestManagedClusterRegistrationRestoreRefreshesPlacementMetadata(t *testing.T) { + h, client := test.RegisterIntegration(t) + ctx := controlPlaneContext(h, uniqueName("cp")) + name := uniqueName("hyp0-mc") + + first, resp, err := register(client, ctx, name) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusCreated)) + + resp, err = client.DefaultAPI.DeleteManagedCluster(ctx, first.ClusterId).Execute() + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusNoContent)) + + restored, resp, err := client.DefaultAPI.RegisterManagedCluster(ctx). + ManagedClusterRegistrationRequest(openapi.ManagedClusterRegistrationRequest{ + Name: name, + Provider: openapi.PtrString("ibm"), + Visibility: openapi.PtrString("public"), + }).Execute() + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusCreated)) + Expect(restored.ClusterId).To(Equal(first.ClusterId)) + + cluster, _, err := client.DefaultAPI.GetManagedCluster(ctx, restored.ClusterId).Execute() + Expect(err).NotTo(HaveOccurred()) + Expect(cluster.Provider).To(Equal("ibm")) + Expect(cluster.GetVisibility()).To(Equal("public")) + Expect(cluster.LastSeenAt).NotTo(BeNil()) +} + // Name Collision Is a Conflict: a name held by a manually created record (empty // oidc_subject) is a 409 naming the record, nothing is created or adopted, and // deleting the record lets the control plane register (201, new id). diff --git a/components/api-server/plugins/managedClusters/service.go b/components/api-server/plugins/managedClusters/service.go index ba9138546..512324d44 100644 --- a/components/api-server/plugins/managedClusters/service.go +++ b/components/api-server/plugins/managedClusters/service.go @@ -31,7 +31,7 @@ type ManagedClusterService interface { // held by a record with a different or empty oidc_subject, or a subject // already registered under another name, is a 409 Conflict: registration // never creates a duplicate name and never adopts an existing record. - Register(ctx context.Context, name, description, oidcSubject string) (*ManagedCluster, bool, *errors.ServiceError) + Register(ctx context.Context, name, description, provider, visibility, oidcSubject string) (*ManagedCluster, bool, *errors.ServiceError) // FindRegisteredBySubject returns the ManagedCluster a control plane // registered under the given OIDC subject, or (nil, nil) when the subject // has not registered. An empty subject never matches. @@ -169,7 +169,7 @@ func (s *sqlManagedClusterService) All(ctx context.Context) (ManagedClusterList, return managedClusters, nil } -func (s *sqlManagedClusterService) Register(ctx context.Context, name, description, oidcSubject string) (*ManagedCluster, bool, *errors.ServiceError) { +func (s *sqlManagedClusterService) Register(ctx context.Context, name, description, provider, visibility, oidcSubject string) (*ManagedCluster, bool, *errors.ServiceError) { lockOwnerID, lockErr := s.lockFactory.NewAdvisoryLock(ctx, oidcSubject, managedClustersLockType) if lockErr != nil { return nil, false, errors.DatabaseAdvisoryLock(lockErr) @@ -187,6 +187,12 @@ func (s *sqlManagedClusterService) Register(ctx context.Context, name, descripti } now := time.Now() existing.LastSeenAt = &now + // A legacy control plane omits placement metadata. Its heartbeat must + // refresh connectivity without erasing values from a newer registration. + if provider != "" { + existing.Provider = provider + existing.Visibility = visibility + } updated, replaceErr := s.managedClusterDao.Replace(ctx, existing) if replaceErr != nil { return nil, false, services.HandleUpdateError("ManagedCluster", replaceErr) @@ -219,7 +225,11 @@ func (s *sqlManagedClusterService) Register(ctx context.Context, name, descripti return nil, false, errors.GeneralError("registration lookup failed: %s", delErr) } if deleted != nil { - restored, restoreErr := s.managedClusterDao.Restore(ctx, deleted.ID, now) + if provider == "" { + provider = deleted.Provider + visibility = deleted.Visibility + } + restored, restoreErr := s.managedClusterDao.Restore(ctx, deleted.ID, provider, visibility, now) if restoreErr != nil { if isUniqueViolation(restoreErr) { if winner, err := s.managedClusterDao.FindByName(ctx, name); err == nil && winner != nil { @@ -242,6 +252,8 @@ func (s *sqlManagedClusterService) Register(ctx context.Context, name, descripti Name: name, OIDCSubject: oidcSubject, LastSeenAt: &now, + Provider: provider, + Visibility: visibility, } cluster.CaptureTraceContext(ctx) created, createErr := s.managedClusterDao.Create(ctx, cluster) diff --git a/components/api-server/plugins/rbac/cluster_binding_integration_test.go b/components/api-server/plugins/rbac/cluster_binding_integration_test.go index cdef5e5b5..03e468d83 100644 --- a/components/api-server/plugins/rbac/cluster_binding_integration_test.go +++ b/components/api-server/plugins/rbac/cluster_binding_integration_test.go @@ -14,7 +14,7 @@ import ( "google.golang.org/grpc/status" pb "github.com/openshift-online/hypershell/components/api-server/pkg/api/grpc/hypershell/v1" - "github.com/openshift-online/hypershell/components/api-server/pkg/api/openapi" + "github.com/openshift-online/hypershell/components/api-server/plugins/gateways" "github.com/openshift-online/hypershell/components/api-server/plugins/managedClusters" "github.com/openshift-online/hypershell/components/api-server/test" "github.com/openshift-online/rh-trex-ai/components/api-server/pkg/api" @@ -50,7 +50,7 @@ func registerTestClusterWithSubject(t *testing.T) (string, string) { suffix := strings.ToLower(api.NewID()) subject := "cp-" + suffix svc := managedClusters.Service(&environments.Environment().Services) - cluster, _, svcErr := svc.Register(context.Background(), fmt.Sprintf("mc-%s", suffix), "", subject) + cluster, _, svcErr := svc.Register(context.Background(), fmt.Sprintf("mc-%s", suffix), "", "aws", "public", subject) if svcErr != nil { t.Fatalf("register test managed cluster: %v", svcErr) } @@ -68,7 +68,7 @@ func registerTestCluster(t *testing.T) string { // own cluster. Own id is accepted and delivers only that cluster's gateways; a // foreign id is PERMISSION_DENIED; a missing id is INVALID_ARGUMENT. func TestGRPCClusterCallerBinding(t *testing.T) { - h, client := test.RegisterIntegration(t) + h, _ := test.RegisterIntegration(t) h.StartControllersServer() ownID, subject := registerTestClusterWithSubject(t) @@ -114,23 +114,20 @@ func TestGRPCClusterCallerBinding(t *testing.T) { _, err = stream.Header() Expect(err).NotTo(HaveOccurred(), "own cluster watch must be accepted") - foreignGw, _, err := client.DefaultAPI.CreateGateway(userCtx).GatewayCreateRequest(openapi.GatewayCreateRequest{ - Name: "binding-foreign", ClusterId: foreignID, - }).Execute() - Expect(err).NotTo(HaveOccurred()) - ownGw, _, err := client.DefaultAPI.CreateGateway(userCtx).GatewayCreateRequest(openapi.GatewayCreateRequest{ - Name: "binding-own", ClusterId: ownID, - }).Execute() - Expect(err).NotTo(HaveOccurred()) + service := gateways.Service(&environments.Environment().Services) + foreignGw, svcErr := service.Create(userCtx, &gateways.Gateway{Name: "binding-foreign", ClusterId: foreignID}) + Expect(svcErr).NotTo(HaveOccurred()) + ownGw, svcErr := service.Create(userCtx, &gateways.Gateway{Name: "binding-own", ClusterId: ownID}) + Expect(svcErr).NotTo(HaveOccurred()) for { evt, recvErr := stream.Recv() Expect(recvErr).NotTo(HaveOccurred(), "stream closed before the own-cluster event") - Expect(evt.ResourceId).NotTo(Equal(*foreignGw.Id), "a foreign cluster's gateway was delivered") + Expect(evt.ResourceId).NotTo(Equal(foreignGw.ID), "a foreign cluster's gateway was delivered") if evt.GetGateway() != nil { Expect(evt.GetGateway().GetClusterId()).To(Equal(ownID)) } - if evt.ResourceId == *ownGw.Id { + if evt.ResourceId == ownGw.ID { break } } diff --git a/components/api-server/plugins/roleBindings/grpc_integration_test.go b/components/api-server/plugins/roleBindings/grpc_integration_test.go index 2f6c12d12..ab086e90b 100644 --- a/components/api-server/plugins/roleBindings/grpc_integration_test.go +++ b/components/api-server/plugins/roleBindings/grpc_integration_test.go @@ -105,22 +105,20 @@ func registerRoleBindingTestCluster(t *testing.T) string { t.Helper() suffix := strings.ToLower(api.NewID()) svc := managedClusters.Service(&environments.Environment().Services) - cluster, _, svcErr := svc.Register(context.Background(), fmt.Sprintf("mc-%s", suffix), "", "cp-"+suffix) + cluster, _, svcErr := svc.Register(context.Background(), fmt.Sprintf("mc-%s", suffix), "", "aws", "public", "cp-"+suffix) if svcErr != nil { t.Fatalf("register test managed cluster: %v", svcErr) } return cluster.ID } -// createGatewayWithOwnerBinding creates a gateway on clusterID through the REST -// API, grants userID gateway:owner on it, and returns the gateway id and that -// binding's id. +// createGatewayWithOwnerBinding creates an internal gateway fixture on +// clusterID, grants userID gateway:owner on it, and returns both ids. func createGatewayWithOwnerBinding(ctx context.Context, client *openapi.APIClient, userID, name, clusterID string) (string, string) { - gw, _, err := client.DefaultAPI.CreateGateway(ctx).GatewayCreateRequest(openapi.GatewayCreateRequest{ - Name: name, ClusterId: clusterID, - }).Execute() - Expect(err).NotTo(HaveOccurred(), "create gateway %s", name) - gatewayID := *gw.Id + _ = client + gw, svcErr := gateways.Service(&environments.Environment().Services).Create(ctx, &gateways.Gateway{Name: name, ClusterId: clusterID}) + Expect(svcErr).NotTo(HaveOccurred(), "create gateway %s", name) + gatewayID := gw.ID // The integration environment does not provision the caller into the // request context, so the create path does not bootstrap the owner binding; diff --git a/components/api-server/proto/hypershell/v1/gateways.proto b/components/api-server/proto/hypershell/v1/gateways.proto index e25985818..7ce3043e1 100644 --- a/components/api-server/proto/hypershell/v1/gateways.proto +++ b/components/api-server/proto/hypershell/v1/gateways.proto @@ -20,6 +20,20 @@ message ProvisioningCondition { string message = 3; } +message GatewayPlacementIntent { + oneof placement { + NetworkProviderPlacement network_provider = 1; + LocalKindPlacement local_kind = 2; + } +} + +message NetworkProviderPlacement { + string network = 1; + string provider = 2; +} + +message LocalKindPlacement {} + message Gateway { ObjectReference metadata = 1; string name = 2; @@ -74,6 +88,7 @@ message CreateGatewayRequest { optional string oidc = 14; optional string route = 15; optional string credential_driver = 17; + GatewayPlacementIntent placement = 18; } message CreateGatewayResponse { diff --git a/components/api-server/test/authz/authz_integration_test.go b/components/api-server/test/authz/authz_integration_test.go index 979f82d0f..b41539e1d 100644 --- a/components/api-server/test/authz/authz_integration_test.go +++ b/components/api-server/test/authz/authz_integration_test.go @@ -17,8 +17,11 @@ import ( pb "github.com/openshift-online/hypershell/components/api-server/pkg/api/grpc/hypershell/v1" "github.com/openshift-online/hypershell/components/api-server/pkg/api/openapi" + "github.com/openshift-online/hypershell/components/api-server/plugins/roleBindings" + "github.com/openshift-online/hypershell/components/api-server/plugins/users" "github.com/openshift-online/hypershell/components/api-server/test" "github.com/openshift-online/rh-trex-ai/components/api-server/pkg/api" + "github.com/openshift-online/rh-trex-ai/components/api-server/pkg/environments" ) // These tests pin the authorization of ManagedCluster records and of the @@ -258,11 +261,20 @@ func TestReRegistrationAfterDeleteDoesNotReclaimATakenName(t *testing.T) { func createGateway(t *testing.T, client *openapi.APIClient, owner principal, clusterID string) *openapi.Gateway { t.Helper() - gw, httpResp, err := client.DefaultAPI.CreateGateway(owner.rest).GatewayCreateRequest(openapi.GatewayCreateRequest{ - Name: fmt.Sprintf("gw-%s", strings.ToLower(api.NewID())[:10]), ClusterId: clusterID, - }).Execute() - Expect(err).NotTo(HaveOccurred(), "create gateway: HTTP %d %s", statusOf(httpResp), test.APIErrorReason(err)) - return gw + _ = client + created, err := pb.NewGatewayServiceClient(owner.conn).CreateGateway(context.Background(), &pb.CreateGatewayRequest{ + Name: fmt.Sprintf("gw-%s", strings.ToLower(api.NewID())[:10]), + ClusterId: clusterID, + ReleaseId: "test-release", + }) + Expect(err).NotTo(HaveOccurred(), "create gateway") + id := created.GetGateway().GetMetadata().GetId() + user, svcErr := users.Service(&environments.Environment().Services).GetByUsername(context.Background(), owner.name) + Expect(svcErr).NotTo(HaveOccurred(), "find gateway owner") + Expect(roleBindings.Service(&environments.Environment().Services).CreateGatewayOwnerBinding( + context.Background(), user.ID, id, + )).To(Succeed(), "bind gateway owner") + return &openapi.Gateway{Id: &id, Namespace: created.GetGateway().GetNamespace()} } func TestGRPCControlPlaneWritesAreScopedToItsCluster(t *testing.T) { diff --git a/components/cli/data/generated/tui/descriptor.json b/components/cli/data/generated/tui/descriptor.json index f675de985..091ec418a 100644 --- a/components/cli/data/generated/tui/descriptor.json +++ b/components/cli/data/generated/tui/descriptor.json @@ -545,6 +545,65 @@ "listOperationId": "listSecretSources", "source": {} }, + { + "id": "collection:/api/hypershell/v1/gateways/placement-availability:#/schemas/GatewayPlacementAvailability", + "kind": "collection", + "schemaRef": "#/schemas/GatewayPlacementAvailability", + "label": "GatewayPlacementAvailability", + "defaultSort": "aws_public", + "columns": [ + { + "property": "aws_public", + "label": "AWS PUBLIC", + "priority": 0, + "type": "boolean" + }, + { + "property": "aws_reason", + "label": "AWS REASON", + "priority": 0, + "type": "string" + }, + { + "property": "aws_vpn", + "label": "AWS VPN", + "priority": 0, + "type": "boolean" + }, + { + "property": "ibm_public", + "label": "IBM PUBLIC", + "priority": 0, + "type": "boolean" + }, + { + "property": "ibm_reason", + "label": "IBM REASON", + "priority": 0, + "type": "string" + }, + { + "property": "ibm_vpn", + "label": "IBM VPN", + "priority": 0, + "type": "boolean" + }, + { + "property": "local_kind", + "label": "LOCAL KIND", + "priority": 0, + "type": "boolean" + } + ], + "operationIds": [ + "getGatewayPlacementAvailability" + ], + "capabilities": [ + "get" + ], + "getOperationId": "getGatewayPlacementAvailability", + "source": {} + }, { "id": "collection:/api/hypershell/v1/gateways/{gateway_id}/service_accounts/{service_account_id}/revoke:#/schemas/OpenShellGatewayServiceAccountListItem", "kind": "collection", @@ -1062,6 +1121,12 @@ "priority": 0, "type": "string", "format": "date-time" + }, + { + "property": "visibility", + "label": "VISIBILITY", + "priority": 0, + "type": "string" } ], "operationIds": [ @@ -2264,6 +2329,12 @@ "priority": 0, "type": "string", "format": "date-time" + }, + { + "property": "visibility", + "label": "VISIBILITY", + "priority": 0, + "type": "string" } ], "operationIds": [ @@ -2638,11 +2709,6 @@ "required": true, "contentType": "application/json", "fields": [ - { - "name": "cluster_id", - "type": "string", - "required": true - }, { "name": "credential_driver", "type": "string", @@ -2676,6 +2742,12 @@ "type": "string", "required": false }, + { + "name": "placement", + "type": "object", + "required": true, + "description": "Exactly one of network/provider or mode must be supplied." + }, { "name": "route", "type": "string", @@ -2974,7 +3046,8 @@ { "name": "provider", "type": "string", - "required": true + "required": true, + "description": "Cloud provider capability used for gateway placement." }, { "name": "region", @@ -4195,6 +4268,44 @@ "summary": "Get an gateway by id", "presentation": {} }, + { + "id": "getGatewayPlacementAvailability", + "method": "GET", + "pathParts": [ + { + "literal": "/api/hypershell/v1/gateways/placement-availability" + } + ], + "response": { + "contentType": "application/json" + }, + "successStatuses": [ + "200" + ], + "servers": [ + { + "url": "http://localhost:8000" + }, + { + "url": "/" + } + ], + "security": { + "none": false, + "requirements": [ + { + "schemes": [ + "Bearer" + ] + } + ] + }, + "capabilities": [ + "get" + ], + "summary": "Returns current provider placement eligibility", + "presentation": {} + }, { "id": "getGatewayServiceAccount", "method": "GET", @@ -5889,6 +6000,27 @@ "type": "string", "required": true, "description": "Human-readable spoke name, unique per fleet (e.g. hyp0-mc1). Must match on every subsequent call." + }, + { + "name": "provider", + "type": "string", + "required": false, + "description": "Cloud provider hosting this control plane.", + "enum": [ + "aws", + "ibm", + "kind" + ] + }, + { + "name": "visibility", + "type": "string", + "required": false, + "description": "Network visibility supported for gateways on this control plane.", + "enum": [ + "public", + "vpn" + ] } ] }, diff --git a/components/control-plane/cmd/hypershell-controller/main.go b/components/control-plane/cmd/hypershell-controller/main.go index 7fa19ba96..eb0dd16a9 100644 --- a/components/control-plane/cmd/hypershell-controller/main.go +++ b/components/control-plane/cmd/hypershell-controller/main.go @@ -291,7 +291,7 @@ func main() { // service-account provisioner above is not cluster-scoped and is already // serving, so the pod's probes stay green while registration retries (for // example while the hub API server is still starting). - regClient := registration.NewClient(cfg.APIServerURL, cfg.ManagedClusterName, tokenProvider) + regClient := registration.NewClientWithPlacement(cfg.APIServerURL, cfg.ManagedClusterName, cfg.ManagedClusterProvider, cfg.ManagedClusterVisibility, tokenProvider) clusterID, regErr := registerWithBackoff(ctx, regClient, time.Second) if regErr != nil { if ctx.Err() != nil { diff --git a/components/control-plane/internal/config/config.go b/components/control-plane/internal/config/config.go index b0a772f22..c8477cf0c 100644 --- a/components/control-plane/internal/config/config.go +++ b/components/control-plane/internal/config/config.go @@ -39,7 +39,9 @@ type Config struct { // unregistered (unfiltered) mode, and a cluster id is never read from // configuration. Sourced from HYPERSHELL_MANAGED_CLUSTER_NAME. // See specs/platform/control-plane.spec.md ("Mandatory Cluster Identity"). - ManagedClusterName string + ManagedClusterName string + ManagedClusterProvider string + ManagedClusterVisibility string // OIDC client credentials used both for registration and as per-RPC bearer // credentials on every gRPC call (including the watch streams). All three @@ -102,6 +104,8 @@ func Load() (*Config, error) { Namespace: getEnv("HYPERSHELL_NAMESPACE", "hypershell"), LogLevel: strings.ToLower(getEnv("HYPERSHELL_LOG_LEVEL", "info")), ManagedClusterName: getEnv("HYPERSHELL_MANAGED_CLUSTER_NAME", ""), + ManagedClusterProvider: strings.ToLower(strings.TrimSpace(getEnv("HYPERSHELL_MANAGED_CLUSTER_PROVIDER", ""))), + ManagedClusterVisibility: strings.ToLower(strings.TrimSpace(getEnv("HYPERSHELL_MANAGED_CLUSTER_VISIBILITY", ""))), OIDCIssuer: getEnv("OIDC_ISSUER", ""), OIDCClientID: getEnv("OIDC_CLIENT_ID", ""), OIDCClientSecret: getEnv("OIDC_CLIENT_SECRET", ""), @@ -134,6 +138,8 @@ func Load() (*Config, error) { // the first missing variable. required := []struct{ name, value string }{ {"HYPERSHELL_MANAGED_CLUSTER_NAME", cfg.ManagedClusterName}, + {"HYPERSHELL_MANAGED_CLUSTER_PROVIDER", cfg.ManagedClusterProvider}, + {"HYPERSHELL_MANAGED_CLUSTER_VISIBILITY", cfg.ManagedClusterVisibility}, {"OIDC_ISSUER", cfg.OIDCIssuer}, {"OIDC_CLIENT_ID", cfg.OIDCClientID}, {"OIDC_CLIENT_SECRET", cfg.OIDCClientSecret}, @@ -143,6 +149,12 @@ func Load() (*Config, error) { return nil, fmt.Errorf("%s is required: every control plane registers with the hub as a managed cluster (specs/platform/control-plane.spec.md, Mandatory Cluster Identity)", r.name) } } + if (cfg.ManagedClusterProvider != "aws" && cfg.ManagedClusterProvider != "ibm" && cfg.ManagedClusterProvider != "kind") || + (cfg.ManagedClusterVisibility != "public" && cfg.ManagedClusterVisibility != "vpn") || + (cfg.ManagedClusterProvider == "ibm" && cfg.ManagedClusterVisibility == "vpn") || + (cfg.ManagedClusterProvider == "kind" && cfg.ManagedClusterVisibility != "public") { + return nil, fmt.Errorf("invalid managed cluster placement: provider=%q visibility=%q", cfg.ManagedClusterProvider, cfg.ManagedClusterVisibility) + } // An invalid GATEWAY_RESOURCES fails startup rather than falling back to the // defaults like the other tunables: silently ignoring an operator's sizing diff --git a/components/control-plane/internal/config/config_test.go b/components/control-plane/internal/config/config_test.go index 50a11dd51..a6c2b2747 100644 --- a/components/control-plane/internal/config/config_test.go +++ b/components/control-plane/internal/config/config_test.go @@ -12,6 +12,8 @@ func setRequiredEnv(t *testing.T) { t.Helper() t.Setenv("HYPERSHELL_GRPC_SERVER_ADDR", "localhost:9000") t.Setenv("HYPERSHELL_MANAGED_CLUSTER_NAME", "local-kind") + t.Setenv("HYPERSHELL_MANAGED_CLUSTER_PROVIDER", "kind") + t.Setenv("HYPERSHELL_MANAGED_CLUSTER_VISIBILITY", "public") t.Setenv("OIDC_ISSUER", "http://keycloak.example/realms/hypershell") t.Setenv("OIDC_CLIENT_ID", "hypershell-control-plane") t.Setenv("OIDC_CLIENT_SECRET", "secret") @@ -23,6 +25,8 @@ func setRequiredEnv(t *testing.T) { func TestLoadRequiresClusterIdentity(t *testing.T) { for _, name := range []string{ "HYPERSHELL_MANAGED_CLUSTER_NAME", + "HYPERSHELL_MANAGED_CLUSTER_PROVIDER", + "HYPERSHELL_MANAGED_CLUSTER_VISIBILITY", "OIDC_ISSUER", "OIDC_CLIENT_ID", "OIDC_CLIENT_SECRET", diff --git a/components/control-plane/internal/helm/values.go b/components/control-plane/internal/helm/values.go index a05edc980..60ac82127 100644 --- a/components/control-plane/internal/helm/values.go +++ b/components/control-plane/internal/helm/values.go @@ -146,6 +146,11 @@ func (b *ValuesBuilder) buildCoreValues(values map[string]interface{}) error { // Workload configuration setNestedValue(values, "deployment", "workload", "kind") setNestedValue(values, 1, "replicaCount") + // Keep the gateway workload requests and limits explicit. + setNestedValue(values, map[string]interface{}{ + "requests": map[string]interface{}{"cpu": "100m", "memory": "256Mi"}, + "limits": map[string]interface{}{"cpu": "500m", "memory": "512Mi"}, + }, "resources") // Gateway container resources. Always set: the upstream chart defaults to // `resources: {}`, which would run the gateway BestEffort. diff --git a/components/control-plane/internal/registration/client.go b/components/control-plane/internal/registration/client.go index ab9b8f451..b9eb7aefa 100644 --- a/components/control-plane/internal/registration/client.go +++ b/components/control-plane/internal/registration/client.go @@ -34,22 +34,32 @@ type TokenSource interface { type Client struct { apiServerURL string clusterName string + provider string + visibility string tokens TokenSource httpClient *http.Client } // NewClient creates a registration Client. func NewClient(apiServerURL, clusterName string, tokens TokenSource) *Client { + return NewClientWithPlacement(apiServerURL, clusterName, "", "", tokens) +} + +func NewClientWithPlacement(apiServerURL, clusterName, provider, visibility string, tokens TokenSource) *Client { return &Client{ apiServerURL: strings.TrimRight(apiServerURL, "/"), clusterName: clusterName, + provider: provider, + visibility: visibility, tokens: tokens, httpClient: &http.Client{Timeout: 30 * time.Second}, } } type registrationRequest struct { - Name string `json:"name"` + Name string `json:"name"` + Provider string `json:"provider,omitempty"` + Visibility string `json:"visibility,omitempty"` } type registrationResponse struct { @@ -66,7 +76,7 @@ func (c *Client) Register(ctx context.Context) (string, error) { return "", fmt.Errorf("get OIDC token: %w", err) } - body, err := json.Marshal(registrationRequest{Name: c.clusterName}) + body, err := json.Marshal(registrationRequest{Name: c.clusterName, Provider: c.provider, Visibility: c.visibility}) if err != nil { return "", fmt.Errorf("marshal registration request: %w", err) } diff --git a/components/sdk-go/client/agent_runtime_api.go b/components/sdk-go/client/agent_runtime_api.go index 9c34a007e..5ef17dbbb 100644 --- a/components/sdk-go/client/agent_runtime_api.go +++ b/components/sdk-go/client/agent_runtime_api.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e6bcd9eeb2b0f1844ca3cefb9576ac8b1acb78c6d19e0a83166f2a3c8acfdbd2 +// Spec SHA256: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d package client diff --git a/components/sdk-go/client/client.go b/components/sdk-go/client/client.go index a70246831..60f9c16dd 100644 --- a/components/sdk-go/client/client.go +++ b/components/sdk-go/client/client.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e6bcd9eeb2b0f1844ca3cefb9576ac8b1acb78c6d19e0a83166f2a3c8acfdbd2 +// Spec SHA256: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d package client diff --git a/components/sdk-go/client/gateway_api.go b/components/sdk-go/client/gateway_api.go index 9b4e06763..f78e2ff11 100644 --- a/components/sdk-go/client/gateway_api.go +++ b/components/sdk-go/client/gateway_api.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e6bcd9eeb2b0f1844ca3cefb9576ac8b1acb78c6d19e0a83166f2a3c8acfdbd2 +// Spec SHA256: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d package client @@ -22,6 +22,14 @@ func (c *Client) Gateways() *GatewayAPI { return &GatewayAPI{client: c} } +func (a *GatewayAPI) PlacementAvailability(ctx context.Context) (*types.GatewayPlacementAvailability, error) { + var result types.GatewayPlacementAvailability + if err := a.client.do(ctx, http.MethodGet, "/api/hypershell/v1/gateways/placement-availability", nil, http.StatusOK, &result); err != nil { + return nil, err + } + return &result, nil +} + func (a *GatewayAPI) Create(ctx context.Context, resource *types.Gateway) (*types.Gateway, error) { body, err := json.Marshal(resource) if err != nil { diff --git a/components/sdk-go/client/inference_route_api.go b/components/sdk-go/client/inference_route_api.go index 6b5487cae..7d770df5a 100644 --- a/components/sdk-go/client/inference_route_api.go +++ b/components/sdk-go/client/inference_route_api.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e6bcd9eeb2b0f1844ca3cefb9576ac8b1acb78c6d19e0a83166f2a3c8acfdbd2 +// Spec SHA256: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d package client diff --git a/components/sdk-go/client/iterator.go b/components/sdk-go/client/iterator.go index 6ec370aab..a0e3ca9ef 100644 --- a/components/sdk-go/client/iterator.go +++ b/components/sdk-go/client/iterator.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e6bcd9eeb2b0f1844ca3cefb9576ac8b1acb78c6d19e0a83166f2a3c8acfdbd2 +// Spec SHA256: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d package client diff --git a/components/sdk-go/client/managed_cluster_api.go b/components/sdk-go/client/managed_cluster_api.go index 9ac26b306..45869c2f6 100644 --- a/components/sdk-go/client/managed_cluster_api.go +++ b/components/sdk-go/client/managed_cluster_api.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e6bcd9eeb2b0f1844ca3cefb9576ac8b1acb78c6d19e0a83166f2a3c8acfdbd2 +// Spec SHA256: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d package client diff --git a/components/sdk-go/client/open_shell_gateway_service_account_api.go b/components/sdk-go/client/open_shell_gateway_service_account_api.go index b059bfc4a..481f50e84 100644 --- a/components/sdk-go/client/open_shell_gateway_service_account_api.go +++ b/components/sdk-go/client/open_shell_gateway_service_account_api.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e6bcd9eeb2b0f1844ca3cefb9576ac8b1acb78c6d19e0a83166f2a3c8acfdbd2 +// Spec SHA256: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d package client @@ -28,7 +28,7 @@ func (a *OpenShellGatewayServiceAccountAPI) Create(ctx context.Context, gatewayI return nil, fmt.Errorf("marshal open_shell_gateway_service_account create request: %w", err) } var result types.OpenShellGatewayServiceAccountCreateResponse - if _, err := a.client.doAny(ctx, http.MethodPost, fmt.Sprintf("/api/hypershell/v1/gateways/%s/service_accounts", url.PathEscape(gatewayID)), body, &result, http.StatusCreated); err != nil { + if _, err := a.client.doAny(ctx, http.MethodPost, fmt.Sprintf("/v1/gateways/%s/service_accounts", url.PathEscape(gatewayID)), body, &result, http.StatusCreated); err != nil { return nil, err } return &result, nil @@ -36,14 +36,14 @@ func (a *OpenShellGatewayServiceAccountAPI) Create(ctx context.Context, gatewayI func (a *OpenShellGatewayServiceAccountAPI) Get(ctx context.Context, gatewayID string, serviceAccountID string) (*types.OpenShellGatewayServiceAccountGetResponse, error) { var result types.OpenShellGatewayServiceAccountGetResponse - if _, err := a.client.doAny(ctx, http.MethodGet, fmt.Sprintf("/api/hypershell/v1/gateways/%s/service_accounts/%s", url.PathEscape(gatewayID), url.PathEscape(serviceAccountID)), nil, &result, http.StatusOK); err != nil { + if _, err := a.client.doAny(ctx, http.MethodGet, fmt.Sprintf("/v1/gateways/%s/service_accounts/%s", url.PathEscape(gatewayID), url.PathEscape(serviceAccountID)), nil, &result, http.StatusOK); err != nil { return nil, err } return &result, nil } func (a *OpenShellGatewayServiceAccountAPI) List(ctx context.Context, gatewayID string, opts *types.OpenShellGatewayServiceAccountListOptions) (*types.OpenShellGatewayServiceAccountList, error) { - path := fmt.Sprintf("/api/hypershell/v1/gateways/%s/service_accounts", url.PathEscape(gatewayID)) + path := fmt.Sprintf("/v1/gateways/%s/service_accounts", url.PathEscape(gatewayID)) if opts != nil { params := url.Values{} if opts.Page > 0 { @@ -77,7 +77,7 @@ func (a *OpenShellGatewayServiceAccountAPI) List(ctx context.Context, gatewayID func (a *OpenShellGatewayServiceAccountAPI) Revoke(ctx context.Context, gatewayID string, serviceAccountID string) (*types.OpenShellGatewayServiceAccountListItem, error) { var result types.OpenShellGatewayServiceAccountListItem - if _, err := a.client.doAny(ctx, http.MethodPost, fmt.Sprintf("/api/hypershell/v1/gateways/%s/service_accounts/%s", url.PathEscape(gatewayID), url.PathEscape(serviceAccountID))+"/revoke", nil, &result, http.StatusOK, http.StatusAccepted); err != nil { + if _, err := a.client.doAny(ctx, http.MethodPost, fmt.Sprintf("/v1/gateways/%s/service_accounts/%s", url.PathEscape(gatewayID), url.PathEscape(serviceAccountID))+"/revoke", nil, &result, http.StatusOK, http.StatusAccepted); err != nil { return nil, err } return &result, nil @@ -85,7 +85,7 @@ func (a *OpenShellGatewayServiceAccountAPI) Revoke(ctx context.Context, gatewayI func (a *OpenShellGatewayServiceAccountAPI) Delete(ctx context.Context, gatewayID string, serviceAccountID string) (*types.OpenShellGatewayServiceAccountListItem, error) { var result types.OpenShellGatewayServiceAccountListItem - status, err := a.client.doAny(ctx, http.MethodDelete, fmt.Sprintf("/api/hypershell/v1/gateways/%s/service_accounts/%s", url.PathEscape(gatewayID), url.PathEscape(serviceAccountID)), nil, &result, http.StatusNoContent, http.StatusAccepted) + status, err := a.client.doAny(ctx, http.MethodDelete, fmt.Sprintf("/v1/gateways/%s/service_accounts/%s", url.PathEscape(gatewayID), url.PathEscape(serviceAccountID)), nil, &result, http.StatusNoContent, http.StatusAccepted) if err != nil { return nil, err } diff --git a/components/sdk-go/client/provider_binding_api.go b/components/sdk-go/client/provider_binding_api.go index 795ab3d91..55fbd5cb2 100644 --- a/components/sdk-go/client/provider_binding_api.go +++ b/components/sdk-go/client/provider_binding_api.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e6bcd9eeb2b0f1844ca3cefb9576ac8b1acb78c6d19e0a83166f2a3c8acfdbd2 +// Spec SHA256: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d package client diff --git a/components/sdk-go/client/provider_spec_api.go b/components/sdk-go/client/provider_spec_api.go index 41f4a2384..ba9c3b810 100644 --- a/components/sdk-go/client/provider_spec_api.go +++ b/components/sdk-go/client/provider_spec_api.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e6bcd9eeb2b0f1844ca3cefb9576ac8b1acb78c6d19e0a83166f2a3c8acfdbd2 +// Spec SHA256: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d package client diff --git a/components/sdk-go/client/role_api.go b/components/sdk-go/client/role_api.go index 9eab829a8..ebfef98dd 100644 --- a/components/sdk-go/client/role_api.go +++ b/components/sdk-go/client/role_api.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e6bcd9eeb2b0f1844ca3cefb9576ac8b1acb78c6d19e0a83166f2a3c8acfdbd2 +// Spec SHA256: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d package client diff --git a/components/sdk-go/client/role_binding_api.go b/components/sdk-go/client/role_binding_api.go index 626e20b43..293336a0b 100644 --- a/components/sdk-go/client/role_binding_api.go +++ b/components/sdk-go/client/role_binding_api.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e6bcd9eeb2b0f1844ca3cefb9576ac8b1acb78c6d19e0a83166f2a3c8acfdbd2 +// Spec SHA256: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d package client diff --git a/components/sdk-go/client/sandbox_template_api.go b/components/sdk-go/client/sandbox_template_api.go index 93f7df7ac..ddebeabdf 100644 --- a/components/sdk-go/client/sandbox_template_api.go +++ b/components/sdk-go/client/sandbox_template_api.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e6bcd9eeb2b0f1844ca3cefb9576ac8b1acb78c6d19e0a83166f2a3c8acfdbd2 +// Spec SHA256: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d package client diff --git a/components/sdk-go/client/secret_source_api.go b/components/sdk-go/client/secret_source_api.go index c30ec4853..a1b36adef 100644 --- a/components/sdk-go/client/secret_source_api.go +++ b/components/sdk-go/client/secret_source_api.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e6bcd9eeb2b0f1844ca3cefb9576ac8b1acb78c6d19e0a83166f2a3c8acfdbd2 +// Spec SHA256: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d package client diff --git a/components/sdk-go/client/user_api.go b/components/sdk-go/client/user_api.go index 9cad600d0..89090e27e 100644 --- a/components/sdk-go/client/user_api.go +++ b/components/sdk-go/client/user_api.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e6bcd9eeb2b0f1844ca3cefb9576ac8b1acb78c6d19e0a83166f2a3c8acfdbd2 +// Spec SHA256: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d package client diff --git a/components/sdk-go/types/agent_runtime.go b/components/sdk-go/types/agent_runtime.go index cb7904353..eec996a6e 100644 --- a/components/sdk-go/types/agent_runtime.go +++ b/components/sdk-go/types/agent_runtime.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e6bcd9eeb2b0f1844ca3cefb9576ac8b1acb78c6d19e0a83166f2a3c8acfdbd2 +// Spec SHA256: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d package types diff --git a/components/sdk-go/types/base.go b/components/sdk-go/types/base.go index c150f3724..4eb1af4ef 100644 --- a/components/sdk-go/types/base.go +++ b/components/sdk-go/types/base.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e6bcd9eeb2b0f1844ca3cefb9576ac8b1acb78c6d19e0a83166f2a3c8acfdbd2 +// Spec SHA256: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d package types diff --git a/components/sdk-go/types/gateway.go b/components/sdk-go/types/gateway.go index cbdfa67b4..1f5f44841 100644 --- a/components/sdk-go/types/gateway.go +++ b/components/sdk-go/types/gateway.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e6bcd9eeb2b0f1844ca3cefb9576ac8b1acb78c6d19e0a83166f2a3c8acfdbd2 +// Spec SHA256: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d package types @@ -9,6 +9,22 @@ import ( "fmt" ) +type GatewayPlacementAvailability struct { + AwsPublic bool `json:"aws_public"` + AwsReason string `json:"aws_reason,omitempty"` + AwsVpn bool `json:"aws_vpn"` + IbmPublic bool `json:"ibm_public"` + IbmReason string `json:"ibm_reason,omitempty"` + IbmVpn bool `json:"ibm_vpn"` + LocalKind bool `json:"local_kind"` +} + +type GatewayPlacementIntent struct { + Mode string `json:"mode,omitempty"` + Network string `json:"network,omitempty"` + Provider string `json:"provider,omitempty"` +} + type Gateway struct { ObjectReference @@ -34,6 +50,8 @@ type Gateway struct { Status string `json:"status,omitempty"` SupervisorImage string `json:"supervisor_image,omitempty"` TLSMode string `json:"tls_mode,omitempty"` + + Placement GatewayPlacementIntent `json:"placement,omitempty"` } type GatewayList struct { @@ -47,19 +65,15 @@ func (l *GatewayList) GetPage() int { return l.Page } func (l *GatewayList) GetSize() int { return l.Size } type GatewayBuilder struct { - resource Gateway - errors []error + resource Gateway + errors []error + placementSet bool } func NewGatewayBuilder() *GatewayBuilder { return &GatewayBuilder{} } -func (b *GatewayBuilder) ClusterID(v string) *GatewayBuilder { - b.resource.ClusterID = v - return b -} - func (b *GatewayBuilder) CredentialDriver(v string) *GatewayBuilder { b.resource.CredentialDriver = v return b @@ -80,11 +94,22 @@ func (b *GatewayBuilder) Name(v string) *GatewayBuilder { return b } +func (b *GatewayBuilder) Oidc(v string) *GatewayBuilder { + b.resource.Oidc = v + return b +} + func (b *GatewayBuilder) Phase(v string) *GatewayBuilder { b.resource.Phase = v return b } +func (b *GatewayBuilder) Placement(v GatewayPlacementIntent) *GatewayBuilder { + b.resource.Placement = v + b.placementSet = true + return b +} + func (b *GatewayBuilder) Route(v string) *GatewayBuilder { b.resource.Route = v return b @@ -116,14 +141,11 @@ func (b *GatewayBuilder) TLSMode(v string) *GatewayBuilder { } func (b *GatewayBuilder) Build() (*Gateway, error) { - if b.resource.ClusterID == "" { - b.errors = append(b.errors, fmt.Errorf("cluster_id is required")) - } if b.resource.Name == "" { b.errors = append(b.errors, fmt.Errorf("name is required")) } - if b.resource.Namespace == "" { - b.errors = append(b.errors, fmt.Errorf("namespace is required")) + if !b.placementSet { + b.errors = append(b.errors, fmt.Errorf("placement is required")) } if len(b.errors) > 0 { return nil, fmt.Errorf("validation failed: %w", errors.Join(b.errors...)) diff --git a/components/sdk-go/types/inference_route.go b/components/sdk-go/types/inference_route.go index 34b306770..9b44c53f1 100644 --- a/components/sdk-go/types/inference_route.go +++ b/components/sdk-go/types/inference_route.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e6bcd9eeb2b0f1844ca3cefb9576ac8b1acb78c6d19e0a83166f2a3c8acfdbd2 +// Spec SHA256: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d package types diff --git a/components/sdk-go/types/list_options.go b/components/sdk-go/types/list_options.go index 42887b639..e1cfc1347 100644 --- a/components/sdk-go/types/list_options.go +++ b/components/sdk-go/types/list_options.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e6bcd9eeb2b0f1844ca3cefb9576ac8b1acb78c6d19e0a83166f2a3c8acfdbd2 +// Spec SHA256: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d package types diff --git a/components/sdk-go/types/managed_cluster.go b/components/sdk-go/types/managed_cluster.go index e7b44512c..e51e2e2eb 100644 --- a/components/sdk-go/types/managed_cluster.go +++ b/components/sdk-go/types/managed_cluster.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e6bcd9eeb2b0f1844ca3cefb9576ac8b1acb78c6d19e0a83166f2a3c8acfdbd2 +// Spec SHA256: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d package types @@ -21,6 +21,7 @@ type ManagedCluster struct { Provider string `json:"provider"` Region string `json:"region,omitempty"` Status string `json:"status,omitempty"` + Visibility string `json:"visibility,omitempty"` } type ManagedClusterList struct { diff --git a/components/sdk-go/types/open_shell_gateway_service_account.go b/components/sdk-go/types/open_shell_gateway_service_account.go index 5a4a9b2e3..c25321be9 100644 --- a/components/sdk-go/types/open_shell_gateway_service_account.go +++ b/components/sdk-go/types/open_shell_gateway_service_account.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e6bcd9eeb2b0f1844ca3cefb9576ac8b1acb78c6d19e0a83166f2a3c8acfdbd2 +// Spec SHA256: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d package types diff --git a/components/sdk-go/types/provider_binding.go b/components/sdk-go/types/provider_binding.go index fbc991fc2..a2ce63a9f 100644 --- a/components/sdk-go/types/provider_binding.go +++ b/components/sdk-go/types/provider_binding.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e6bcd9eeb2b0f1844ca3cefb9576ac8b1acb78c6d19e0a83166f2a3c8acfdbd2 +// Spec SHA256: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d package types diff --git a/components/sdk-go/types/provider_spec.go b/components/sdk-go/types/provider_spec.go index 70c2638cb..802f844a0 100644 --- a/components/sdk-go/types/provider_spec.go +++ b/components/sdk-go/types/provider_spec.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e6bcd9eeb2b0f1844ca3cefb9576ac8b1acb78c6d19e0a83166f2a3c8acfdbd2 +// Spec SHA256: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d package types diff --git a/components/sdk-go/types/role.go b/components/sdk-go/types/role.go index 5910f2f47..2eb79bfc0 100644 --- a/components/sdk-go/types/role.go +++ b/components/sdk-go/types/role.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e6bcd9eeb2b0f1844ca3cefb9576ac8b1acb78c6d19e0a83166f2a3c8acfdbd2 +// Spec SHA256: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d package types diff --git a/components/sdk-go/types/role_binding.go b/components/sdk-go/types/role_binding.go index 9b3c81cf5..19516384b 100644 --- a/components/sdk-go/types/role_binding.go +++ b/components/sdk-go/types/role_binding.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e6bcd9eeb2b0f1844ca3cefb9576ac8b1acb78c6d19e0a83166f2a3c8acfdbd2 +// Spec SHA256: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d package types diff --git a/components/sdk-go/types/sandbox_template.go b/components/sdk-go/types/sandbox_template.go index dbc4f56a9..fc1e0a92d 100644 --- a/components/sdk-go/types/sandbox_template.go +++ b/components/sdk-go/types/sandbox_template.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e6bcd9eeb2b0f1844ca3cefb9576ac8b1acb78c6d19e0a83166f2a3c8acfdbd2 +// Spec SHA256: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d package types diff --git a/components/sdk-go/types/secret_source.go b/components/sdk-go/types/secret_source.go index b31935435..c81f80a27 100644 --- a/components/sdk-go/types/secret_source.go +++ b/components/sdk-go/types/secret_source.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e6bcd9eeb2b0f1844ca3cefb9576ac8b1acb78c6d19e0a83166f2a3c8acfdbd2 +// Spec SHA256: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d package types diff --git a/components/sdk-go/types/user.go b/components/sdk-go/types/user.go index e06fb898b..9d083c20c 100644 --- a/components/sdk-go/types/user.go +++ b/components/sdk-go/types/user.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e6bcd9eeb2b0f1844ca3cefb9576ac8b1acb78c6d19e0a83166f2a3c8acfdbd2 +// Spec SHA256: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d package types diff --git a/components/sdk-typescript/src/agent_runtime.ts b/components/sdk-typescript/src/agent_runtime.ts index af222decf..76471d473 100644 --- a/components/sdk-typescript/src/agent_runtime.ts +++ b/components/sdk-typescript/src/agent_runtime.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e6bcd9eeb2b0f1844ca3cefb9576ac8b1acb78c6d19e0a83166f2a3c8acfdbd2 +// Spec SHA256: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d import type { ObjectReference, ListMeta } from './base.js'; diff --git a/components/sdk-typescript/src/agent_runtime_api.ts b/components/sdk-typescript/src/agent_runtime_api.ts index edb7f37e7..5d0c6d8d3 100644 --- a/components/sdk-typescript/src/agent_runtime_api.ts +++ b/components/sdk-typescript/src/agent_runtime_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e6bcd9eeb2b0f1844ca3cefb9576ac8b1acb78c6d19e0a83166f2a3c8acfdbd2 +// Spec SHA256: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d import type { SDKClientConfig, ListOptions, RequestOptions } from './base.js'; import { sdkFetch, buildQueryString } from './base.js'; diff --git a/components/sdk-typescript/src/base.ts b/components/sdk-typescript/src/base.ts index 935513a51..248c52f24 100644 --- a/components/sdk-typescript/src/base.ts +++ b/components/sdk-typescript/src/base.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e6bcd9eeb2b0f1844ca3cefb9576ac8b1acb78c6d19e0a83166f2a3c8acfdbd2 +// Spec SHA256: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d export type ObjectReference = { id: string; diff --git a/components/sdk-typescript/src/client.ts b/components/sdk-typescript/src/client.ts index 453f4104a..f37715bdb 100644 --- a/components/sdk-typescript/src/client.ts +++ b/components/sdk-typescript/src/client.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e6bcd9eeb2b0f1844ca3cefb9576ac8b1acb78c6d19e0a83166f2a3c8acfdbd2 +// Spec SHA256: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d import type { SDKClientConfig } from './base.js'; import { AgentRuntimeAPI } from './agent_runtime_api.js'; diff --git a/components/sdk-typescript/src/gateway.ts b/components/sdk-typescript/src/gateway.ts index 454a962a7..9976499b5 100644 --- a/components/sdk-typescript/src/gateway.ts +++ b/components/sdk-typescript/src/gateway.ts @@ -1,8 +1,26 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e6bcd9eeb2b0f1844ca3cefb9576ac8b1acb78c6d19e0a83166f2a3c8acfdbd2 +// Spec SHA256: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d import type { ObjectReference, ListMeta } from './base.js'; +export type GatewayPlacementAvailability = { + aws_public: boolean; + aws_reason?: "no-eligible-cluster"; + aws_vpn: boolean; + ibm_public: boolean; + ibm_reason?: "no-eligible-cluster"; + ibm_vpn: boolean; + local_kind: boolean; +}; + + + +export type GatewayPlacementIntent = + | { mode: 'local-kind' } + | { network: 'public'; provider: 'aws' | 'ibm' } + | { network: 'vpn'; provider: 'aws' }; + + export type Gateway = ObjectReference & { active_sandbox_count: number; @@ -34,12 +52,13 @@ export type GatewayList = ListMeta & { }; export type GatewayCreateRequest = { - cluster_id: string; credential_driver?: string; external_dns?: string; image?: string; name: string; + oidc?: string; phase?: string; + placement: GatewayPlacementIntent; route?: string; server_dns_names?: string; service_type?: string; @@ -69,11 +88,6 @@ export class GatewayBuilder { private data: Record = {}; - clusterId(value: string): this { - this.data['cluster_id'] = value; - return this; - } - credentialDriver(value: string): this { this.data['credential_driver'] = value; return this; @@ -94,11 +108,21 @@ export class GatewayBuilder { return this; } + oidc(value: string): this { + this.data['oidc'] = value; + return this; + } + phase(value: string): this { this.data['phase'] = value; return this; } + placement(value: GatewayPlacementIntent): this { + this.data['placement'] = value; + return this; + } + route(value: string): this { this.data['route'] = value; return this; @@ -130,14 +154,11 @@ export class GatewayBuilder { } build(): GatewayCreateRequest { - if (!this.data['cluster_id']) { - throw new Error('cluster_id is required'); - } if (!this.data['name']) { throw new Error('name is required'); } - if (!this.data['namespace']) { - throw new Error('namespace is required'); + if (this.data['placement'] === undefined) { + throw new Error('placement is required'); } return this.data as GatewayCreateRequest; } diff --git a/components/sdk-typescript/src/gateway_api.ts b/components/sdk-typescript/src/gateway_api.ts index 6e11ebce7..c5ef3d068 100644 --- a/components/sdk-typescript/src/gateway_api.ts +++ b/components/sdk-typescript/src/gateway_api.ts @@ -1,14 +1,18 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e6bcd9eeb2b0f1844ca3cefb9576ac8b1acb78c6d19e0a83166f2a3c8acfdbd2 +// Spec SHA256: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d import type { SDKClientConfig, ListOptions, RequestOptions } from './base.js'; import { sdkFetch, buildQueryString } from './base.js'; -import type { Gateway, GatewayList, GatewayCreateRequest, GatewayPatchRequest } from './gateway.js'; +import type { Gateway, GatewayList, GatewayCreateRequest, GatewayPatchRequest, GatewayPlacementAvailability } from './gateway.js'; export class GatewayAPI { constructor(private readonly config: SDKClientConfig) {} + async placementAvailability(opts?: RequestOptions): Promise { + return sdkFetch(this.config, 'GET', '/api/hypershell/v1/gateways/placement-availability', undefined, opts); + } + async create(data: GatewayCreateRequest, opts?: RequestOptions): Promise { return sdkFetch(this.config, 'POST', '/api/hypershell/v1/gateways', data, opts); } diff --git a/components/sdk-typescript/src/index.ts b/components/sdk-typescript/src/index.ts index 1d95b5174..349780669 100644 --- a/components/sdk-typescript/src/index.ts +++ b/components/sdk-typescript/src/index.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e6bcd9eeb2b0f1844ca3cefb9576ac8b1acb78c6d19e0a83166f2a3c8acfdbd2 +// Spec SHA256: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d export { SDKClient } from './client.js'; export type { SDKClientConfig, ListOptions, RequestOptions, ObjectReference, ListMeta, APIError } from './base.js'; diff --git a/components/sdk-typescript/src/inference_route.ts b/components/sdk-typescript/src/inference_route.ts index cec713235..f4473b185 100644 --- a/components/sdk-typescript/src/inference_route.ts +++ b/components/sdk-typescript/src/inference_route.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e6bcd9eeb2b0f1844ca3cefb9576ac8b1acb78c6d19e0a83166f2a3c8acfdbd2 +// Spec SHA256: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d import type { ObjectReference, ListMeta } from './base.js'; diff --git a/components/sdk-typescript/src/inference_route_api.ts b/components/sdk-typescript/src/inference_route_api.ts index ae85328c6..53e4b37aa 100644 --- a/components/sdk-typescript/src/inference_route_api.ts +++ b/components/sdk-typescript/src/inference_route_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e6bcd9eeb2b0f1844ca3cefb9576ac8b1acb78c6d19e0a83166f2a3c8acfdbd2 +// Spec SHA256: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d import type { SDKClientConfig, ListOptions, RequestOptions } from './base.js'; import { sdkFetch, buildQueryString } from './base.js'; diff --git a/components/sdk-typescript/src/managed_cluster.ts b/components/sdk-typescript/src/managed_cluster.ts index 806e55f49..be7c25483 100644 --- a/components/sdk-typescript/src/managed_cluster.ts +++ b/components/sdk-typescript/src/managed_cluster.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e6bcd9eeb2b0f1844ca3cefb9576ac8b1acb78c6d19e0a83166f2a3c8acfdbd2 +// Spec SHA256: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d import type { ObjectReference, ListMeta } from './base.js'; @@ -13,6 +13,7 @@ export type ManagedCluster = ObjectReference & { provider: string; region: string; status: string; + visibility: string; }; export type ManagedClusterList = ListMeta & { diff --git a/components/sdk-typescript/src/managed_cluster_api.ts b/components/sdk-typescript/src/managed_cluster_api.ts index 4d91f096c..b3c33139e 100644 --- a/components/sdk-typescript/src/managed_cluster_api.ts +++ b/components/sdk-typescript/src/managed_cluster_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e6bcd9eeb2b0f1844ca3cefb9576ac8b1acb78c6d19e0a83166f2a3c8acfdbd2 +// Spec SHA256: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d import type { SDKClientConfig, ListOptions, RequestOptions } from './base.js'; import { sdkFetch, buildQueryString } from './base.js'; diff --git a/components/sdk-typescript/src/open_shell_gateway_service_account.ts b/components/sdk-typescript/src/open_shell_gateway_service_account.ts index 4ed91fa5c..208a58543 100644 --- a/components/sdk-typescript/src/open_shell_gateway_service_account.ts +++ b/components/sdk-typescript/src/open_shell_gateway_service_account.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e6bcd9eeb2b0f1844ca3cefb9576ac8b1acb78c6d19e0a83166f2a3c8acfdbd2 +// Spec SHA256: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d export type OpenShellGatewayServiceAccountCapabilities = { diff --git a/components/sdk-typescript/src/open_shell_gateway_service_account_api.ts b/components/sdk-typescript/src/open_shell_gateway_service_account_api.ts index ae2a0fdbe..96bec4aac 100644 --- a/components/sdk-typescript/src/open_shell_gateway_service_account_api.ts +++ b/components/sdk-typescript/src/open_shell_gateway_service_account_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e6bcd9eeb2b0f1844ca3cefb9576ac8b1acb78c6d19e0a83166f2a3c8acfdbd2 +// Spec SHA256: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d import type { SDKClientConfig, RequestOptions } from './base.js'; import { sdkFetch } from './base.js'; @@ -17,11 +17,11 @@ export class OpenShellGatewayServiceAccountAPI { constructor(private readonly config: SDKClientConfig) {} async create(gatewayId: string, data: OpenShellGatewayServiceAccountCreateRequest, opts?: RequestOptions): Promise { - return sdkFetch(this.config, 'POST', `/api/hypershell/v1/gateways/${encodeURIComponent(gatewayId)}/service_accounts`, data, opts); + return sdkFetch(this.config, 'POST', `/v1/gateways/${encodeURIComponent(gatewayId)}/service_accounts`, data, opts); } async get(gatewayId: string, serviceAccountId: string, opts?: RequestOptions): Promise { - return sdkFetch(this.config, 'GET', `/api/hypershell/v1/gateways/${encodeURIComponent(gatewayId)}/service_accounts/${encodeURIComponent(serviceAccountId)}`, undefined, opts); + return sdkFetch(this.config, 'GET', `/v1/gateways/${encodeURIComponent(gatewayId)}/service_accounts/${encodeURIComponent(serviceAccountId)}`, undefined, opts); } async list(gatewayId: string, listOpts?: OpenShellGatewayServiceAccountListOptions, opts?: RequestOptions): Promise { @@ -45,16 +45,16 @@ export class OpenShellGatewayServiceAccountAPI { params.set('order', String(listOpts.order)); } const query = params.toString(); - const path = `/api/hypershell/v1/gateways/${encodeURIComponent(gatewayId)}/service_accounts` + (query ? `?${query}` : ''); + const path = `/v1/gateways/${encodeURIComponent(gatewayId)}/service_accounts` + (query ? `?${query}` : ''); return sdkFetch(this.config, 'GET', path, undefined, opts); } async revoke(gatewayId: string, serviceAccountId: string, opts?: RequestOptions): Promise { - return sdkFetch(this.config, 'POST', `/api/hypershell/v1/gateways/${encodeURIComponent(gatewayId)}/service_accounts/${encodeURIComponent(serviceAccountId)}` + '/revoke', undefined, opts); + return sdkFetch(this.config, 'POST', `/v1/gateways/${encodeURIComponent(gatewayId)}/service_accounts/${encodeURIComponent(serviceAccountId)}` + '/revoke', undefined, opts); } async delete(gatewayId: string, serviceAccountId: string, opts?: RequestOptions): Promise { - return sdkFetch(this.config, 'DELETE', `/api/hypershell/v1/gateways/${encodeURIComponent(gatewayId)}/service_accounts/${encodeURIComponent(serviceAccountId)}`, undefined, opts); + return sdkFetch(this.config, 'DELETE', `/v1/gateways/${encodeURIComponent(gatewayId)}/service_accounts/${encodeURIComponent(serviceAccountId)}`, undefined, opts); } async *listAll(gatewayId: string, size: number = 20, opts?: RequestOptions): AsyncGenerator { diff --git a/components/sdk-typescript/src/provider_binding.ts b/components/sdk-typescript/src/provider_binding.ts index 71eb178ec..a25ca4bbf 100644 --- a/components/sdk-typescript/src/provider_binding.ts +++ b/components/sdk-typescript/src/provider_binding.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e6bcd9eeb2b0f1844ca3cefb9576ac8b1acb78c6d19e0a83166f2a3c8acfdbd2 +// Spec SHA256: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d import type { ObjectReference, ListMeta } from './base.js'; diff --git a/components/sdk-typescript/src/provider_binding_api.ts b/components/sdk-typescript/src/provider_binding_api.ts index 5ea7cd252..efabea287 100644 --- a/components/sdk-typescript/src/provider_binding_api.ts +++ b/components/sdk-typescript/src/provider_binding_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e6bcd9eeb2b0f1844ca3cefb9576ac8b1acb78c6d19e0a83166f2a3c8acfdbd2 +// Spec SHA256: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d import type { SDKClientConfig, ListOptions, RequestOptions } from './base.js'; import { sdkFetch, buildQueryString } from './base.js'; diff --git a/components/sdk-typescript/src/provider_spec.ts b/components/sdk-typescript/src/provider_spec.ts index e0183c27f..1fe81f2f5 100644 --- a/components/sdk-typescript/src/provider_spec.ts +++ b/components/sdk-typescript/src/provider_spec.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e6bcd9eeb2b0f1844ca3cefb9576ac8b1acb78c6d19e0a83166f2a3c8acfdbd2 +// Spec SHA256: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d import type { ObjectReference, ListMeta } from './base.js'; diff --git a/components/sdk-typescript/src/provider_spec_api.ts b/components/sdk-typescript/src/provider_spec_api.ts index 500655296..4b588b7b4 100644 --- a/components/sdk-typescript/src/provider_spec_api.ts +++ b/components/sdk-typescript/src/provider_spec_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e6bcd9eeb2b0f1844ca3cefb9576ac8b1acb78c6d19e0a83166f2a3c8acfdbd2 +// Spec SHA256: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d import type { SDKClientConfig, ListOptions, RequestOptions } from './base.js'; import { sdkFetch, buildQueryString } from './base.js'; diff --git a/components/sdk-typescript/src/role.ts b/components/sdk-typescript/src/role.ts index 548ad0e03..2a9882186 100644 --- a/components/sdk-typescript/src/role.ts +++ b/components/sdk-typescript/src/role.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e6bcd9eeb2b0f1844ca3cefb9576ac8b1acb78c6d19e0a83166f2a3c8acfdbd2 +// Spec SHA256: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d import type { ObjectReference, ListMeta } from './base.js'; diff --git a/components/sdk-typescript/src/role_api.ts b/components/sdk-typescript/src/role_api.ts index be292b9c8..e514c4b28 100644 --- a/components/sdk-typescript/src/role_api.ts +++ b/components/sdk-typescript/src/role_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e6bcd9eeb2b0f1844ca3cefb9576ac8b1acb78c6d19e0a83166f2a3c8acfdbd2 +// Spec SHA256: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d import type { SDKClientConfig, ListOptions, RequestOptions } from './base.js'; import { sdkFetch, buildQueryString } from './base.js'; diff --git a/components/sdk-typescript/src/role_binding.ts b/components/sdk-typescript/src/role_binding.ts index a325243eb..f59522d16 100644 --- a/components/sdk-typescript/src/role_binding.ts +++ b/components/sdk-typescript/src/role_binding.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e6bcd9eeb2b0f1844ca3cefb9576ac8b1acb78c6d19e0a83166f2a3c8acfdbd2 +// Spec SHA256: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d import type { ObjectReference, ListMeta } from './base.js'; diff --git a/components/sdk-typescript/src/role_binding_api.ts b/components/sdk-typescript/src/role_binding_api.ts index e26c10472..b34d1677d 100644 --- a/components/sdk-typescript/src/role_binding_api.ts +++ b/components/sdk-typescript/src/role_binding_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e6bcd9eeb2b0f1844ca3cefb9576ac8b1acb78c6d19e0a83166f2a3c8acfdbd2 +// Spec SHA256: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d import type { SDKClientConfig, ListOptions, RequestOptions } from './base.js'; import { sdkFetch, buildQueryString } from './base.js'; diff --git a/components/sdk-typescript/src/sandbox_template.ts b/components/sdk-typescript/src/sandbox_template.ts index 2718f0bd8..7a519a122 100644 --- a/components/sdk-typescript/src/sandbox_template.ts +++ b/components/sdk-typescript/src/sandbox_template.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e6bcd9eeb2b0f1844ca3cefb9576ac8b1acb78c6d19e0a83166f2a3c8acfdbd2 +// Spec SHA256: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d import type { ObjectReference, ListMeta } from './base.js'; diff --git a/components/sdk-typescript/src/sandbox_template_api.ts b/components/sdk-typescript/src/sandbox_template_api.ts index a6641b447..35bf611ce 100644 --- a/components/sdk-typescript/src/sandbox_template_api.ts +++ b/components/sdk-typescript/src/sandbox_template_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e6bcd9eeb2b0f1844ca3cefb9576ac8b1acb78c6d19e0a83166f2a3c8acfdbd2 +// Spec SHA256: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d import type { SDKClientConfig, ListOptions, RequestOptions } from './base.js'; import { sdkFetch, buildQueryString } from './base.js'; diff --git a/components/sdk-typescript/src/secret_source.ts b/components/sdk-typescript/src/secret_source.ts index 0e545cc16..13f1bcff3 100644 --- a/components/sdk-typescript/src/secret_source.ts +++ b/components/sdk-typescript/src/secret_source.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e6bcd9eeb2b0f1844ca3cefb9576ac8b1acb78c6d19e0a83166f2a3c8acfdbd2 +// Spec SHA256: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d import type { ObjectReference, ListMeta } from './base.js'; diff --git a/components/sdk-typescript/src/secret_source_api.ts b/components/sdk-typescript/src/secret_source_api.ts index cb051300b..8586227b7 100644 --- a/components/sdk-typescript/src/secret_source_api.ts +++ b/components/sdk-typescript/src/secret_source_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e6bcd9eeb2b0f1844ca3cefb9576ac8b1acb78c6d19e0a83166f2a3c8acfdbd2 +// Spec SHA256: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d import type { SDKClientConfig, ListOptions, RequestOptions } from './base.js'; import { sdkFetch, buildQueryString } from './base.js'; diff --git a/components/sdk-typescript/src/user.ts b/components/sdk-typescript/src/user.ts index 4fb6ff4ef..e8cdc1305 100644 --- a/components/sdk-typescript/src/user.ts +++ b/components/sdk-typescript/src/user.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e6bcd9eeb2b0f1844ca3cefb9576ac8b1acb78c6d19e0a83166f2a3c8acfdbd2 +// Spec SHA256: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d import type { ObjectReference, ListMeta } from './base.js'; diff --git a/components/sdk-typescript/src/user_api.ts b/components/sdk-typescript/src/user_api.ts index 580c44195..8c0c428f6 100644 --- a/components/sdk-typescript/src/user_api.ts +++ b/components/sdk-typescript/src/user_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e6bcd9eeb2b0f1844ca3cefb9576ac8b1acb78c6d19e0a83166f2a3c8acfdbd2 +// Spec SHA256: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d import type { SDKClientConfig, ListOptions, RequestOptions } from './base.js'; import { sdkFetch, buildQueryString } from './base.js'; diff --git a/components/web-console/app/adapters/api/gateway-operations.test.ts b/components/web-console/app/adapters/api/gateway-operations.test.ts index 0d0cdf82b..a64bf9c46 100644 --- a/components/web-console/app/adapters/api/gateway-operations.test.ts +++ b/components/web-console/app/adapters/api/gateway-operations.test.ts @@ -18,6 +18,7 @@ const gatewayApi = { delete: vi.fn(), get: vi.fn(), list: vi.fn(), + placementAvailability: vi.fn(), update: vi.fn(), }; const managedClusterApi = { @@ -112,6 +113,7 @@ function managedCluster( region: "us-east-1", status: "Ready", updated_at: null, + visibility: "public", ...overrides, }; } @@ -666,27 +668,55 @@ describe("gateway API operations adapter", () => { ); }); - it("provisions on the selected cluster with hidden request defaults", async () => { + it("provisions from placement intent with hidden request defaults", async () => { gatewayApi.create.mockResolvedValue(gateway({})); await controlPlane.provisionGateway( { - clusterId: "cluster-east", name: "team-gateway", + placement: { network: "public", provider: "aws" }, }, context, ); expect(gatewayApi.create).toHaveBeenCalledWith( { - cluster_id: "cluster-east", name: "team-gateway", + placement: { network: "public", provider: "aws" }, route: '{"enabled":true}', }, { signal: undefined }, ); }); + it("maps backend placement availability", async () => { + gatewayApi.placementAvailability.mockResolvedValue({ + aws_public: true, + aws_vpn: true, + ibm_public: false, + ibm_vpn: false, + local_kind: true, + }); + + if (controlPlane.getGatewayPlacementAvailability === undefined) { + throw new Error("placement availability operation is unavailable"); + } + await expect( + controlPlane.getGatewayPlacementAvailability(context), + ).resolves.toEqual({ + awsPublic: true, + awsVpn: true, + awsReason: undefined, + ibmPublic: false, + ibmVpn: false, + ibmReason: undefined, + localKind: true, + }); + expect(gatewayApi.placementAvailability).toHaveBeenCalledWith({ + signal: undefined, + }); + }); + it("maps detail, rename, and deletion operations", async () => { gatewayApi.get.mockResolvedValue(gateway()); gatewayApi.update.mockResolvedValue(gateway({ name: "Renamed gateway" })); diff --git a/components/web-console/app/adapters/api/gateway-operations.ts b/components/web-console/app/adapters/api/gateway-operations.ts index 3520371e6..48c78ba85 100644 --- a/components/web-console/app/adapters/api/gateway-operations.ts +++ b/components/web-console/app/adapters/api/gateway-operations.ts @@ -5,6 +5,7 @@ import type { GatewayInvocationContext, GatewayListRequest, GatewayPlacement, + GatewayPlacementAvailability, GatewayRecord, OpenShellGatewayServiceAccountCapabilities, OpenShellGatewayServiceAccountConnection, @@ -34,7 +35,8 @@ import { type GatewayApi = Pick< SDKClient["gateways"], "create" | "delete" | "get" | "list" | "update" ->; +> & + Partial>; type ManagedClusterApi = Pick; type ServiceAccountApi = Pick< SDKClient["openShellGatewayServiceAccounts"], @@ -395,6 +397,26 @@ export function createGatewayControlPlaneAdapter( }; }); }, + async getGatewayPlacementAvailability(context) { + return mapFailure(async () => { + const gatewaysApi = apiClient(apiFactory, context).gateways; + if (!gatewaysApi.placementAvailability) { + throw new Error("placement availability API is unavailable"); + } + const availability = await gatewaysApi.placementAvailability({ + signal: context.signal, + }); + return { + awsPublic: availability.aws_public, + awsVpn: availability.aws_vpn, + awsReason: availability.aws_reason, + ibmPublic: availability.ibm_public, + ibmVpn: availability.ibm_vpn, + ibmReason: availability.ibm_reason, + localKind: availability.local_kind, + } satisfies GatewayPlacementAvailability; + }); + }, async getGatewayPlacement(clusterId, context) { return mapFailure(async () => toGatewayPlacement( @@ -541,12 +563,18 @@ export function createGatewayControlPlaneAdapter( }); }, async provisionGateway(input, context) { + if (input.placement === undefined) { + throw new GatewayOperationError("unknown", { + cause: new Error("gateway placement intent is required"), + }); + } + const placement = input.placement; return mapFailure(async () => toGatewayRecord( await apiClient(apiFactory, context).gateways.create( { - cluster_id: input.clusterId, name: input.name, + placement, route: JSON.stringify({ enabled: true }), }, { signal: context.signal }, diff --git a/components/web-console/app/features/gateways/gateway-create.stories.tsx b/components/web-console/app/features/gateways/gateway-create.stories.tsx new file mode 100644 index 000000000..6acd76685 --- /dev/null +++ b/components/web-console/app/features/gateways/gateway-create.stories.tsx @@ -0,0 +1,119 @@ +import type { + GatewayOperations, + GatewayPlacementAvailability, + GatewayRecord, + GatewayUiNavigation, +} from "@openshift-online/hypershell-gateway-management-ui"; +import { + GatewayCreatePage, + GatewayUiProvider, +} from "@openshift-online/hypershell-gateway-management-ui"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, userEvent, within } from "storybook/test"; + +const placementAvailability: GatewayPlacementAvailability = { + awsPublic: true, + awsVpn: true, + ibmPublic: true, + ibmVpn: false, + localKind: true, +} as const; + +const localDevelopmentAvailability: GatewayPlacementAvailability = { + awsPublic: false, + awsVpn: false, + ibmPublic: false, + ibmVpn: false, + localKind: true, +} as const; + +const previewGateway: GatewayRecord = { + clusterId: "preview-cluster", + id: "preview-gateway", + name: "preview-gateway", + namespace: "openshell", + status: "Provisioning", +}; + +const gatewayOperations = { + getGatewayPlacementAvailability: () => Promise.resolve(placementAvailability), + provisionGateway: () => Promise.resolve(previewGateway), +} as unknown as GatewayOperations; + +const navigation: GatewayUiNavigation = { + collectionHref: "/gateways", + createHref: "/gateways/new", + detailHref: (gatewayId) => `/gateways/${gatewayId}`, + navigate: () => undefined, +}; + +function GatewayCreatePreview({ + availability = placementAvailability, +}: { + availability?: GatewayPlacementAvailability; +}) { + const operationsForStory = { + ...gatewayOperations, + getGatewayPlacementAvailability: () => Promise.resolve(availability), + } as GatewayOperations; + return ( + + + + ); +} + +const meta = { + title: "HyperShell/Gateways/Create gateway", + component: GatewayCreatePage, + parameters: { + layout: "fullscreen", + }, + render: () => , +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const ManagedPlacementsAvailable: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await expect( + canvas.queryByRole("radio", { name: "Use local-kind" }), + ).toBeNull(); + await expect(canvas.getByRole("radio", { name: "Public" })).toBeVisible(); + await expect(canvas.getByRole("radio", { name: "VPN" })).toBeVisible(); + await expect( + canvas.queryByRole("radio", { name: "Amazon Web Services" }), + ).toBeNull(); + await expect(canvas.queryByRole("radio", { name: "IBM Cloud" })).toBeNull(); + + await userEvent.click(canvas.getByRole("radio", { name: "Public" })); + await expect( + canvas.getByRole("radio", { name: "Amazon Web Services" }), + ).toBeVisible(); + await expect( + canvas.getByRole("radio", { name: "IBM Cloud" }), + ).toBeVisible(); + }, +}; + +export const LocalDevelopmentOnly: Story = { + render: () => ( + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await expect( + canvas.getByRole("radio", { name: "Use local-kind" }), + ).toBeVisible(); + await expect(canvas.queryByRole("radio", { name: "Public" })).toBeNull(); + await expect(canvas.queryByRole("radio", { name: "VPN" })).toBeNull(); + await expect( + canvas.queryByRole("radio", { name: "Amazon Web Services" }), + ).toBeNull(); + await expect(canvas.queryByRole("radio", { name: "IBM Cloud" })).toBeNull(); + }, +}; diff --git a/components/web-console/e2e/application-shell.spec.ts b/components/web-console/e2e/application-shell.spec.ts index db0c1c9da..cbf664398 100644 --- a/components/web-console/e2e/application-shell.spec.ts +++ b/components/web-console/e2e/application-shell.spec.ts @@ -51,6 +51,26 @@ test.beforeEach(async ({ browserName, page }) => { } await page.route("**/api/hypershell/v1/gateways**", async (route) => { + if ( + new URL(route.request().url()).pathname.endsWith( + "/placement-availability", + ) + ) { + await route.fulfill({ + body: JSON.stringify({ + aws_public: true, + aws_reason: "no-eligible-cluster", + aws_vpn: false, + ibm_public: false, + ibm_reason: "no-eligible-cluster", + ibm_vpn: false, + local_kind: false, + }), + contentType: "application/json", + status: 200, + }); + return; + } const request = route.request(); if (request.method() === "DELETE") { gatewayDeleted = true; @@ -251,11 +271,9 @@ test("operates gateway rows and opens provisioning", async ({ page }) => { await page.getByRole("link", { name: "Provision gateway" }).click(); await expect(page).toHaveURL(/\/gateways\/new$/); - // The picker offers registered clusters only; the single registered - // cluster is preselected. - await expect(page.getByRole("combobox", { name: "Cluster" })).toHaveValue( - "Cluster East", - ); + await expect( + page.getByRole("heading", { level: 1, name: "Provision gateway" }), + ).toBeVisible(); await expect(page.getByLabel("Namespace", { exact: true })).toHaveCount(0); }); @@ -458,6 +476,24 @@ test("provisions a gateway on an existing managed cluster", async ({ page, }) => { let requestBody: Record | undefined; + await page.route( + "**/api/hypershell/v1/gateways/placement-availability", + async (route) => { + await route.fulfill({ + contentType: "application/json", + status: 200, + body: JSON.stringify({ + aws_public: true, + aws_reason: "no-eligible-cluster", + aws_vpn: false, + ibm_public: false, + ibm_reason: "no-eligible-cluster", + ibm_vpn: false, + local_kind: false, + }), + }); + }, + ); await page.route("**/api/hypershell/v1/gateways", async (route) => { if (route.request().method() !== "POST") { await route.continue(); @@ -491,14 +527,10 @@ test("provisions a gateway on an existing managed cluster", async ({ await expect( page.getByRole("heading", { level: 1, name: "Provision gateway" }), ).toBeFocused(); - const clusterInput = page.getByRole("combobox", { name: "Cluster" }); - // A single registered cluster is preselected; clearing and re-selecting it - // exercises the search flow. - await expect(clusterInput).toHaveValue("Cluster East"); - await page.getByRole("button", { name: "Clear cluster search" }).click(); - await clusterInput.fill("East"); - await page.getByText("Cluster East", { exact: true }).click(); - await expect(clusterInput).toHaveValue("Cluster East"); + await page.getByText("Public", { exact: true }).click({ force: true }); + await page + .getByText("Amazon Web Services", { exact: true }) + .click({ force: true }); await expect(page.getByText("Provider: AWS; region: us-east-1")).toHaveCount( 0, ); @@ -529,8 +561,8 @@ test("provisions a gateway on an existing managed cluster", async ({ ).toBeVisible(); await expect(page.getByText("Not available")).toHaveCount(1); expect(requestBody).toEqual({ - cluster_id: "cluster-east", name: "team-gateway", + placement: { network: "public", provider: "aws" }, route: '{"enabled":true}', }); }); diff --git a/components/web-console/locales/en.json b/components/web-console/locales/en.json index 53c4c60db..c614812e7 100644 --- a/components/web-console/locales/en.json +++ b/components/web-console/locales/en.json @@ -1519,6 +1519,18 @@ "defaultMessage": "Gateway details", "description": "Metadata title for a user-facing gateway details page." }, + "app.page.gatewayProvision.awsProvider": { + "defaultMessage": "Amazon Web Services", + "description": "AWS cloud provider option." + }, + "app.page.gatewayProvision.awsProviderDescription": { + "defaultMessage": "For workloads that rely heavily on AWS services or data.", + "description": "Description of the AWS provider option." + }, + "app.page.gatewayProvision.cloudProvider": { + "defaultMessage": "Cloud provider", + "description": "Cloud provider for public gateway placement." + }, "app.page.gatewayProvision.description": { "defaultMessage": "Configure a new OpenShell gateway.", "description": "Supporting text on the gateway provisioning page." @@ -1531,14 +1543,86 @@ "defaultMessage": "Gateway could not be provisioned", "description": "Title shown when gateway provisioning fails." }, + "app.page.gatewayProvision.ibmCloudProvider": { + "defaultMessage": "IBM Cloud", + "description": "IBM Cloud provider option." + }, + "app.page.gatewayProvision.ibmCloudProviderDescription": { + "defaultMessage": "The default home for gateways. General-purpose workloads with no special network or data needs.", + "description": "Description of the IBM Cloud provider option." + }, + "app.page.gatewayProvision.localDevelopment": { + "defaultMessage": "Local development", + "description": "Form group label for local Kind placement." + }, + "app.page.gatewayProvision.localKindPlacement": { + "defaultMessage": "Use local-kind", + "description": "Local Kind placement option." + }, + "app.page.gatewayProvision.localKindPlacementDescription": { + "defaultMessage": "Use the local Kind cluster for development.", + "description": "Description of local Kind placement." + }, + "app.page.gatewayProvision.networkAccess": { + "defaultMessage": "Network access", + "description": "Network exposure mode for a gateway." + }, + "app.page.gatewayProvision.noEligibleCluster": { + "defaultMessage": "no eligible managed cluster is connected.", + "description": "Provider unavailability reason when no matching connected cluster exists." + }, + "app.page.gatewayProvision.noManagedPlacement": { + "defaultMessage": "No managed placement is currently available.", + "description": "Warning shown when no provider or local development placement can be selected." + }, "app.page.gatewayProvision.pending": { "defaultMessage": "Provisioning gateway", "description": "Accessible progress text while a gateway is provisioning." }, + "app.page.gatewayProvision.placementAvailabilityError": { + "defaultMessage": "Placement availability could not be loaded. Cloud placement is disabled until availability can be verified.", + "description": "Warning shown when current gateway placement availability cannot be retrieved." + }, + "app.page.gatewayProvision.placementHelp": { + "defaultMessage": "A matching managed cluster is selected at random for the chosen network and provider.", + "description": "Explanation of automatic gateway placement." + }, + "app.page.gatewayProvision.publicNetwork": { + "defaultMessage": "Public", + "description": "Public network placement option." + }, + "app.page.gatewayProvision.publicNetworkDescription": { + "defaultMessage": "Accessible through a public endpoint.", + "description": "Description of public network placement." + }, "app.page.gatewayProvision.title": { "defaultMessage": "Provision gateway", "description": "Page title and action for provisioning a gateway." }, + "app.page.gatewayProvision.unavailableNetwork": { + "defaultMessage": "{network} is unavailable: {reason}", + "description": "Explanation shown when a network cannot currently accept placement." + }, + "app.page.gatewayProvision.unavailableProvider": { + "defaultMessage": "{provider} is unavailable: {reason}", + "description": "Explanation shown when a provider cannot currently accept placement." + }, + "app.page.gatewayProvision.vpnNetwork": { + "defaultMessage": "VPN", + "description": "VPN network placement option." + }, + "app.page.gatewayProvision.vpnNetworkDescription": { + "defaultMessage": "For gateways that need to reach GitLab and other internal Red Hat services.", + "description": "Description of VPN placement." + }, + "app.page.gatewayProvision.vpnRequiresAws": { + "defaultMessage": "VPN placement requires AWS.", + "description": "Explanation shown when IBM Cloud is disabled for VPN." + }, + "app.page.gatewayProvision.vpnRequiresLabel": { + "defaultMessage": "Red Hat VPN required", + "description": "Label shown above the VPN placement guidance." + }, "app.page.gateways.description": { "defaultMessage": "HyperShell gateways.", "description": "Browser metadata description for the gateways page." diff --git a/deploy/base/applications/api-server.yaml b/deploy/base/applications/api-server.yaml index 11f4cd3c4..3103945eb 100644 --- a/deploy/base/applications/api-server.yaml +++ b/deploy/base/applications/api-server.yaml @@ -104,6 +104,8 @@ spec: value: "hypershell-controller.$(POD_NAMESPACE).svc.cluster.local:9443" - name: DB_SSLMODE value: require + - name: HYPERSHELL_PLATFORM_KIND + value: "false" startupProbe: httpGet: path: /healthcheck diff --git a/deploy/base/control-plane/deployment.yaml b/deploy/base/control-plane/deployment.yaml deleted file mode 100644 index 6b2b579c4..000000000 --- a/deploy/base/control-plane/deployment.yaml +++ /dev/null @@ -1,74 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: hypershell-control-plane -spec: - # Keep replicas at 1. Attention gauges are cluster-local; the BFF dedupes - # same-cluster replica series via max by (hypershell_cluster_id), but a - # single replica remains the supported topology for the control plane. - replicas: 1 - selector: - matchLabels: - app: hypershell-control-plane - template: - metadata: - labels: - app: hypershell-control-plane - spec: - serviceAccountName: hypershell-control-plane - containers: - - name: control-plane - image: quay.io/redhat-services-prod/hcm-eng-prod-tenant/hypershell-main/hypershell-control-plane-main:latest - # HYPERSHELL_MANAGED_CLUSTER_NAME and OIDC_ISSUER / OIDC_CLIENT_ID / - # OIDC_CLIENT_SECRET are required and must be supplied by the - # consuming overlay; the control plane exits at startup without them - # (see deploy/base/controller.yaml and control-plane.spec.md). - env: - - name: HYPERSHELL_GRPC_SERVER_ADDR - value: "hypershell-api-server:9000" - - name: HYPERSHELL_API_SERVER_URL - value: "http://hypershell-api-server:8000" - - name: HYPERSHELL_NAMESPACE - value: "hypershell-stage" - - name: HYPERSHELL_LOG_LEVEL - value: "info" - - name: GATEWAY_API_BASE_DOMAIN - value: "openshell.stage.devshift.net" - - name: GATEWAY_API_GATEWAY_NAME - value: "openshell-grpc-gateway" - - name: GATEWAY_API_GATEWAY_NAMESPACE - value: "openshift-ingress" - - name: GATEWAY_API_HTTP_LISTENER_NAME - value: "grpc" - # Plaintext in-cluster gRPC provisioner endpoint. A NetworkPolicy - # restricts port 9443 to the api-server pod; the channel mirrors the - # sibling control-plane -> api-server watch channel (no mTLS). - - name: HYPERSHELL_SERVICE_ACCOUNT_PROVISIONER_BIND_ADDRESS - value: "0.0.0.0:9443" - - name: GATEWAY_IMAGE - value: quay.io/opendatahub/odh-openshell-gateway:v0.1.2-rhaiv.7 - - name: GATEWAY_SUPERVISOR_IMAGE - value: quay.io/opendatahub/odh-openshell-supervisor:v0.1.2-rhaiv.7 - - name: GATEWAY_SANDBOX_RUNTIME_IMAGE - value: quay.io/opendatahub/odh-openshell-sandbox:v0.1.2-rhaiv.7 - ports: - - name: http - containerPort: 8080 - - name: provisioner - containerPort: 9443 - resources: - requests: - cpu: 100m - memory: 256Mi - limits: - cpu: 500m - memory: 512Mi - securityContext: - allowPrivilegeEscalation: false - readOnlyRootFilesystem: true - runAsNonRoot: true - seccompProfile: - type: RuntimeDefault - capabilities: - drop: - - ALL diff --git a/deploy/base/platform-resources/controller.yaml b/deploy/base/platform-resources/controller.yaml index 94bb40cb1..4910822c4 100644 --- a/deploy/base/platform-resources/controller.yaml +++ b/deploy/base/platform-resources/controller.yaml @@ -60,6 +60,10 @@ spec: value: "hypershell-api-server:9000" - name: HYPERSHELL_API_SERVER_URL value: "http://hypershell-api-server:8000" + - name: HYPERSHELL_MANAGED_CLUSTER_PROVIDER + value: "aws" + - name: HYPERSHELL_MANAGED_CLUSTER_VISIBILITY + value: "public" - name: HYPERSHELL_NAMESPACE valueFrom: fieldRef: diff --git a/deploy/gitops/platform/kustomization.yaml b/deploy/gitops/platform/kustomization.yaml index 3a1a12b59..289a655e7 100644 --- a/deploy/gitops/platform/kustomization.yaml +++ b/deploy/gitops/platform/kustomization.yaml @@ -29,3 +29,7 @@ patches: env: - name: HYPERSHELL_MANAGED_CLUSTER_NAME value: "hub" + - name: HYPERSHELL_MANAGED_CLUSTER_PROVIDER + value: "aws" + - name: HYPERSHELL_MANAGED_CLUSTER_VISIBILITY + value: "public" diff --git a/deploy/ibm/kustomization.yaml b/deploy/ibm/kustomization.yaml index a17cf4ef8..99a6fcde2 100644 --- a/deploy/ibm/kustomization.yaml +++ b/deploy/ibm/kustomization.yaml @@ -64,6 +64,10 @@ patches: containers: - name: controller env: + - name: HYPERSHELL_MANAGED_CLUSTER_PROVIDER + value: "ibm" + - name: HYPERSHELL_MANAGED_CLUSTER_VISIBILITY + value: "public" - name: GATEWAY_INGRESS_MODE value: route - name: GATEWAY_API_BASE_DOMAIN diff --git a/deploy/kind/kustomization.yaml b/deploy/kind/kustomization.yaml index 16b2b9549..18aa0f11b 100644 --- a/deploy/kind/kustomization.yaml +++ b/deploy/kind/kustomization.yaml @@ -67,6 +67,10 @@ patches: # creating it (managed-cluster-registration.spec.md). - name: HYPERSHELL_MANAGED_CLUSTER_NAME value: "local-kind" + - name: HYPERSHELL_MANAGED_CLUSTER_PROVIDER + value: "kind" + - name: HYPERSHELL_MANAGED_CLUSTER_VISIBILITY + value: "public" - name: OIDC_ISSUER value: "http://keycloak-service.keycloak.svc.cluster.local:8080/realms/hypershell" - name: OIDC_CLIENT_ID @@ -213,6 +217,31 @@ patches: value: "true" - name: RBAC_SERVICE_ACCOUNTS value: "service-account-hypershell-control-plane" + - name: HYPERSHELL_PLATFORM_KIND + value: "true" + # --- Keycloak: fixed HTTPS issuer reachable in-cluster and from host --- + # The token `iss` is derived from KC_HOSTNAME. Gateways validate tokens by + # requiring config.issuer == discovery.issuer == token.iss, so the issuer + # must be a single URL reachable over HTTPS both in-cluster (via CoreDNS -> + # gateway LB) and from the host (via the gateway LB :443). The LB terminates + # TLS with the *.hypershell.localhost cert and forwards to keycloak-service:8080; + # Keycloak stays plain HTTP internally and honors X-Forwarded-Proto + # (KC_PROXY_HEADERS=xforwarded). This value matches E2E_OIDC_ISSUER and the + # seeded gateway OIDC issuer. + - patch: | + apiVersion: apps/v1 + kind: Deployment + metadata: + name: keycloak + namespace: keycloak + spec: + template: + spec: + containers: + - name: keycloak + env: + - name: KC_HOSTNAME + value: "https://keycloak.hypershell.localhost" - target: kind: Deployment name: hypershell-api-server diff --git a/deploy/openshift/kustomization.yaml b/deploy/openshift/kustomization.yaml index 251ef0448..7973e479c 100644 --- a/deploy/openshift/kustomization.yaml +++ b/deploy/openshift/kustomization.yaml @@ -53,6 +53,10 @@ patches: # instead of creating it (managed-cluster-registration.spec.md). - name: HYPERSHELL_MANAGED_CLUSTER_NAME value: "local-openshift" + - name: HYPERSHELL_MANAGED_CLUSTER_PROVIDER + value: "aws" + - name: HYPERSHELL_MANAGED_CLUSTER_VISIBILITY + value: "public" - name: OIDC_ISSUER valueFrom: secretKeyRef: diff --git a/packages/gateway-management-ui/src/application/gateway-operations.ts b/packages/gateway-management-ui/src/application/gateway-operations.ts index 08e5f090d..1fe538077 100644 --- a/packages/gateway-management-ui/src/application/gateway-operations.ts +++ b/packages/gateway-management-ui/src/application/gateway-operations.ts @@ -200,6 +200,15 @@ export function createGatewayOperations({ execute("find-placements", signal, (context) => controlPlane.findGatewayPlacements(search.trim(), context), ), + getGatewayPlacementAvailability: (signal) => + execute("placement-availability", signal, (context) => { + if (!controlPlane.getGatewayPlacementAvailability) { + throw new GatewayOperationError("unknown", { + cause: new Error("placement availability is unavailable"), + }); + } + return controlPlane.getGatewayPlacementAvailability(context); + }), getGatewayPlacement: (clusterId, signal) => execute("get-placement", signal, (context) => controlPlane.getGatewayPlacement(clusterId, context), diff --git a/packages/gateway-management-ui/src/application/gateway-probes.ts b/packages/gateway-management-ui/src/application/gateway-probes.ts index b80388b57..d0480b47e 100644 --- a/packages/gateway-management-ui/src/application/gateway-probes.ts +++ b/packages/gateway-management-ui/src/application/gateway-probes.ts @@ -9,6 +9,7 @@ export type GatewayAction = | "create-service-account" | "delete-service-account" | "find-placements" + | "placement-availability" | "get" | "get-placement" | "get-placements" diff --git a/packages/gateway-management-ui/src/application/gateway-types.ts b/packages/gateway-management-ui/src/application/gateway-types.ts index 35c0ac1d3..48d389ae6 100644 --- a/packages/gateway-management-ui/src/application/gateway-types.ts +++ b/packages/gateway-management-ui/src/application/gateway-types.ts @@ -81,8 +81,29 @@ export interface GatewayInvocationContext { } export interface GatewayProvisionInput { - clusterId: string; name: string; + placement?: GatewayPlacementIntent; + /** @deprecated Kept only for source compatibility with older adapters. */ + clusterId?: string; +} + +export type GatewayProvider = "aws" | "ibm"; + +export type GatewayPlacementIntent = + | { mode: "local-kind" } + | { network: "public"; provider: GatewayProvider } + | { network: "vpn"; provider: "aws" }; + +export type GatewayPlacementUnavailableReason = "no-eligible-cluster"; + +export interface GatewayPlacementAvailability { + awsPublic: boolean; + awsVpn: boolean; + awsReason?: GatewayPlacementUnavailableReason; + ibmPublic: boolean; + ibmVpn: boolean; + ibmReason?: GatewayPlacementUnavailableReason; + localKind: boolean; } export type OpenShellGatewayServiceAccountRole = @@ -233,6 +254,9 @@ export interface GatewayControlPlane { search: string, context: GatewayInvocationContext, ): Promise; + getGatewayPlacementAvailability?( + context: GatewayInvocationContext, + ): Promise; getGatewayPlacement( clusterId: string, context: GatewayInvocationContext, @@ -295,6 +319,9 @@ export interface GatewayOperations { search: string, signal?: AbortSignal, ): Promise; + getGatewayPlacementAvailability?( + signal?: AbortSignal, + ): Promise; getGatewayPlacement( clusterId: string, signal?: AbortSignal, diff --git a/packages/gateway-management-ui/src/assets/aws-logo.svg b/packages/gateway-management-ui/src/assets/aws-logo.svg new file mode 100644 index 000000000..80192a12d --- /dev/null +++ b/packages/gateway-management-ui/src/assets/aws-logo.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + diff --git a/packages/gateway-management-ui/src/assets/ibm-cloud.svg b/packages/gateway-management-ui/src/assets/ibm-cloud.svg new file mode 100644 index 000000000..5f704cc3b --- /dev/null +++ b/packages/gateway-management-ui/src/assets/ibm-cloud.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/gateway-management-ui/src/gateways/gateway-create.module.css b/packages/gateway-management-ui/src/gateways/gateway-create.module.css new file mode 100644 index 000000000..b99e0b508 --- /dev/null +++ b/packages/gateway-management-ui/src/gateways/gateway-create.module.css @@ -0,0 +1,33 @@ +.choice { + min-height: 7rem; +} +.choiceTitle { + font-weight: 700; + font-size: 1.1rem; +} +.choiceHeading { + display: flex; + align-items: center; + gap: 1rem; + min-height: 3rem; +} +.providerLogo { + display: block; + width: 10rem; + height: 4rem; + margin: 0 auto 0.75rem; + object-fit: contain; + object-position: center; + box-sizing: content-box; +} +.providerContent { + display: block; + text-align: center; +} +.descriptionAfterLabel { + display: block; + margin-top: 0.25rem; +} +.help { + margin-top: 1.5rem; +} diff --git a/packages/gateway-management-ui/src/gateways/gateway-create.test.tsx b/packages/gateway-management-ui/src/gateways/gateway-create.test.tsx index b00740962..e2cbfaa30 100644 --- a/packages/gateway-management-ui/src/gateways/gateway-create.test.tsx +++ b/packages/gateway-management-ui/src/gateways/gateway-create.test.tsx @@ -2,62 +2,57 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { IntlProvider } from "react-intl"; -import { vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { GatewayUiProvider } from "../gateway-ui-provider"; import { GatewayCreatePage } from "./gateway-create"; -const { createGatewayMock, findGatewayPlacementsMock, navigateMock } = - vi.hoisted(() => ({ - createGatewayMock: vi.fn(), - findGatewayPlacementsMock: vi.fn(), - navigateMock: vi.fn(), - })); +const { provisionGateway, navigate } = vi.hoisted(() => ({ + provisionGateway: vi.fn(), + navigate: vi.fn(), +})); -const gatewayOperations = { +const availablePlacement = { + awsPublic: true, + awsVpn: true, + ibmPublic: true, + ibmVpn: false, + localKind: false, +} as const; + +const operations = { createOpenShellGatewayServiceAccount: vi.fn(), deleteOpenShellGatewayServiceAccount: vi.fn(), - findGatewayPlacements: findGatewayPlacementsMock, + findGatewayPlacements: vi.fn(), getGateway: vi.fn(), getGatewayPlacement: vi.fn(), + getGatewayPlacementAvailability: vi.fn(), getGatewayPlacements: vi.fn(), getOpenShellGatewayServiceAccount: vi.fn(), listGateways: vi.fn(), listOpenShellGatewayServiceAccounts: vi.fn(), - provisionGateway: createGatewayMock, + provisionGateway, removeGateway: vi.fn(), renameGateway: vi.fn(), revokeOpenShellGatewayServiceAccount: vi.fn(), }; -const navigation = { - collectionHref: "/", - createHref: "/gateways/new", - detailHref: (gatewayId: string) => `/gateways/${gatewayId}`, - navigate: navigateMock, -}; - -const createdGateway = { - clusterId: "", - externalDns: "", - id: "gateway-1", - name: "team-gateway", - namespace: "openshell", - phase: "", - status: "", -}; - -function createTestQueryClient() { - return new QueryClient({ - defaultOptions: { mutations: { retry: false }, queries: { retry: false } }, +function renderPage() { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, }); -} - -function renderPage(queryClient = createTestQueryClient()) { return render( - - + + `/gateways/${id}`, + navigate, + }} + > @@ -65,433 +60,238 @@ function renderPage(queryClient = createTestQueryClient()) { ); } +async function enterNameAndSelectPublic( + user: ReturnType, +) { + await user.type( + screen.getByRole("textbox", { name: "Gateway name" }), + "team-gateway", + ); + await user.click(screen.getByRole("radio", { name: "Public" })); +} + +function radio(name: string) { + return screen.getByRole("radio", { name }); +} + describe("GatewayCreatePage", () => { beforeEach(() => { vi.clearAllMocks(); - findGatewayPlacementsMock.mockResolvedValue({ - hasMore: false, - items: [ - { - id: "cluster-east", - name: "Cluster East", - provider: "AWS", - region: "us-east-1", - status: "Ready", - }, - ], + operations.getGatewayPlacementAvailability.mockResolvedValue( + availablePlacement, + ); + provisionGateway.mockResolvedValue({ + id: "gateway-1", + name: "team-gateway", }); - navigateMock.mockResolvedValue(undefined); }); - it("preselects the only registered cluster without exposing a namespace", async () => { + it("defaults public placement to IBM Cloud", async () => { const user = userEvent.setup(); - createGatewayMock.mockResolvedValue({ - ...createdGateway, - clusterId: "cluster-east", - }); renderPage(); + await enterNameAndSelectPublic(user); - const clusterInput = screen.getByRole("combobox", { - name: "Cluster", - }); - await waitFor(() => { - expect(clusterInput.value).toBe("Cluster East"); - }); - expect( - screen.getByText( - "Only clusters with a connected control plane can host a gateway.", - ), - ).toBeTruthy(); - expect(screen.queryByLabelText("Namespace")).toBeNull(); - expect(screen.queryByLabelText("Gateway release")).toBeNull(); - - await user.type( - screen.getByRole("textbox", { name: "Gateway name" }), - " team-gateway ", - ); + expect(radio("IBM Cloud").checked).toBe(true); + expect(radio("Amazon Web Services").checked).toBe(false); await user.click(screen.getByRole("button", { name: "Provision gateway" })); await waitFor(() => { - expect(createGatewayMock).toHaveBeenCalledWith({ - clusterId: "cluster-east", + expect(provisionGateway).toHaveBeenCalledWith({ name: "team-gateway", + placement: { network: "public", provider: "ibm" }, }); }); - await waitFor(() => { - expect(navigateMock).toHaveBeenCalledWith("/gateways/gateway-1"); - }); }); - it("searches managed clusters and provisions on the selected result", async () => { + it("forces AWS for VPN and disables IBM Cloud", async () => { const user = userEvent.setup(); - createGatewayMock.mockResolvedValue({ - ...createdGateway, - clusterId: "cluster-east", - }); renderPage(); - - const clusterInput = screen.getByRole("combobox", { - name: "Cluster", - }); - await waitFor(() => { - expect(clusterInput.value).toBe("Cluster East"); - }); - await user.click( - screen.getByRole("button", { name: "Clear cluster search" }), - ); - await user.type(clusterInput, "East"); - const clusterOption = await screen.findByText("Cluster East"); - expect(screen.getByText("Provider: AWS; region: us-east-1")).toBeTruthy(); - await user.click(clusterOption); - expect(clusterInput.value).toBe("Cluster East"); - await user.type( screen.getByRole("textbox", { name: "Gateway name" }), "team-gateway", ); + await user.click(screen.getByRole("radio", { name: "VPN" })); + + expect(radio("Amazon Web Services").checked).toBe(true); + expect(radio("IBM Cloud").disabled).toBe(true); await user.click(screen.getByRole("button", { name: "Provision gateway" })); await waitFor(() => { - expect(createGatewayMock).toHaveBeenCalledWith({ - clusterId: "cluster-east", + expect(provisionGateway).toHaveBeenCalledWith({ name: "team-gateway", + placement: { network: "vpn", provider: "aws" }, }); }); - expect(findGatewayPlacementsMock).toHaveBeenCalledWith( - "East", - expect.any(AbortSignal), - ); }); - it("debounces placement searches into one API request", async () => { + it("uses accessible radio groups with mutually exclusive choices", async () => { const user = userEvent.setup(); renderPage(); - await waitFor(() => { - expect(findGatewayPlacementsMock).toHaveBeenCalledWith( - "", - expect.any(AbortSignal), - ); - }); - findGatewayPlacementsMock.mockClear(); - - const clusterInput = screen.getByRole("combobox", { - name: "Cluster", - }); - await waitFor(() => { - expect(clusterInput.value).toBe("Cluster East"); - }); - await user.click( - screen.getByRole("button", { name: "Clear cluster search" }), - ); - await user.type(clusterInput, "East"); - - expect(findGatewayPlacementsMock).not.toHaveBeenCalled(); - await waitFor(() => { - expect(findGatewayPlacementsMock).toHaveBeenCalledWith( - "East", - expect.any(AbortSignal), - ); - }); - expect(findGatewayPlacementsMock).toHaveBeenCalledOnce(); - }); - - it("shows one loading message while placement options are pending", async () => { - const user = userEvent.setup(); - findGatewayPlacementsMock.mockReturnValue(new Promise(() => undefined)); - renderPage(); - - const clusterInput = screen.getByRole("combobox", { name: "Cluster" }); - await user.click(clusterInput); - - expect(await screen.findAllByText("Loading managed clusters")).toHaveLength( - 1, - ); - expect(clusterInput.getAttribute("aria-busy")).toBe("true"); - }); - - it("reuses fresh placement results after remounting", async () => { - const user = userEvent.setup(); - const queryClient = createTestQueryClient(); - const firstRender = renderPage(queryClient); - await user.click(screen.getByRole("combobox", { name: "Cluster" })); - expect(await screen.findByText("Cluster East")).toBeTruthy(); - expect(findGatewayPlacementsMock).toHaveBeenCalledOnce(); - firstRender.unmount(); - - renderPage(queryClient); - await user.click(screen.getByRole("combobox", { name: "Cluster" })); - expect(await screen.findByText("Cluster East")).toBeTruthy(); - await new Promise((resolve) => window.setTimeout(resolve, 50)); - expect(findGatewayPlacementsMock).toHaveBeenCalledOnce(); - }); - - it("blocks provisioning when managed clusters fail to load", async () => { - const user = userEvent.setup(); - findGatewayPlacementsMock.mockRejectedValue( - new Error("managed cluster API unavailable"), - ); - renderPage(); expect( - await screen.findByText("Managed clusters could not be loaded"), + screen.getByRole("radiogroup", { name: "Network access" }), ).toBeTruthy(); - expect( - screen.getByText( - "A registered cluster is required to provision a gateway. Try loading managed clusters again.", - ), - ).toBeTruthy(); - expect( - screen.getByRole("combobox", { name: "Cluster" }).value, - ).toBe(""); - expect(screen.getByRole("button", { name: "Retry" })).toBeTruthy(); - - await user.type( - screen.getByRole("textbox", { name: "Gateway name" }), - "team-gateway", - ); - await user.click(screen.getByRole("button", { name: "Provision gateway" })); - expect(await screen.findByText("This field is required.")).toBeTruthy(); - expect(createGatewayMock).not.toHaveBeenCalled(); + await user.click(screen.getByRole("radio", { name: "Public" })); + expect(radio("Public").checked).toBe(true); + await user.click(screen.getByRole("radio", { name: "VPN" })); + expect(radio("VPN").checked).toBe(true); + expect(radio("Public").checked).toBe(false); }); - it("leaves the cluster unselected when several registered clusters exist", async () => { + it("shows required placement and name errors without disabling submit", async () => { const user = userEvent.setup(); - findGatewayPlacementsMock.mockResolvedValue({ - hasMore: false, - items: [ - { id: "cluster-east", name: "Cluster East", provider: "AWS" }, - { id: "cluster-west", name: "Cluster West", provider: "GCP" }, - ], - }); renderPage(); + const submit = screen.getByRole("button", { name: "Provision gateway" }); + await user.click(submit); - const clusterInput = screen.getByRole("combobox", { - name: "Cluster", - }); - await user.click(clusterInput); - expect(await screen.findByText("Cluster East")).toBeTruthy(); - expect(screen.getByText("Cluster West")).toBeTruthy(); - expect(clusterInput.value).toBe(""); + expect((submit as HTMLButtonElement).disabled).toBe(false); + expect(screen.getAllByText("This field is required.")).toHaveLength(2); + expect(provisionGateway).not.toHaveBeenCalled(); }); - it("does not override a field the user already touched with the single cluster", async () => { + it("shows progress only while provisioning is pending", async () => { const user = userEvent.setup(); - let resolvePlacements: - ((options: { hasMore: boolean; items: unknown[] }) => void) | undefined; - findGatewayPlacementsMock.mockReturnValue( + let resolveProvision: + ((gateway: { id: string; name: string }) => void) | undefined; + provisionGateway.mockReturnValue( new Promise((resolve) => { - resolvePlacements = resolve; + resolveProvision = resolve; }), ); renderPage(); + await enterNameAndSelectPublic(user); + const submit = screen.getByRole("button", { name: "Provision gateway" }); + await user.click(submit); - const clusterInput = screen.getByRole("combobox", { - name: "Cluster", + await waitFor(() => { + expect((submit as HTMLButtonElement).disabled).toBe(true); }); - await user.type(clusterInput, "E"); - await user.keyboard("{Backspace}"); - resolvePlacements?.({ - hasMore: false, - items: [{ id: "cluster-east", name: "Cluster East", provider: "AWS" }], + expect(submit.classList.contains("pf-m-progress")).toBe(true); + resolveProvision?.({ id: "gateway-1", name: "team-gateway" }); + await waitFor(() => { + expect(navigate).toHaveBeenCalledWith("/gateways/gateway-1"); }); - - expect(await screen.findByText("Cluster East")).toBeTruthy(); - expect(clusterInput.value).toBe(""); }); - it("requires an explicit option after a free-form cluster search", async () => { - const user = userEvent.setup(); - findGatewayPlacementsMock.mockResolvedValue({ - hasMore: false, - items: [], + it("offers and defaults local-kind when it is the only available placement", async () => { + operations.getGatewayPlacementAvailability.mockResolvedValue({ + awsPublic: false, + awsVpn: false, + ibmPublic: false, + ibmVpn: false, + localKind: true, }); - renderPage(); - - await user.type( - screen.getByRole("combobox", { name: "Cluster" }), - "Unknown placement", - ); - expect(await screen.findByText("No matching clusters")).toBeTruthy(); - await user.type( - screen.getByRole("textbox", { name: "Gateway name" }), - "team-gateway", - ); - await user.click(screen.getByRole("button", { name: "Provision gateway" })); - - expect(await screen.findByText("This field is required.")).toBeTruthy(); - expect(createGatewayMock).not.toHaveBeenCalled(); - }); - - it("supports keyboard selection and bounded-result guidance", async () => { const user = userEvent.setup(); - findGatewayPlacementsMock.mockResolvedValue({ - hasMore: true, - items: [ - { - id: "cluster-west", - name: "Cluster West", - provider: "GCP", - region: "us-west1", - status: "Ready", - }, - ], - }); renderPage(); expect( - await screen.findByText( - "More clusters are available. Refine your search to find a specific cluster.", - ), - ).toBeTruthy(); - const clusterInput = screen.getByRole("combobox", { - name: "Cluster", - }); - await user.type(clusterInput, "West"); - await screen.findByText("Cluster West"); - await user.keyboard("{ArrowDown}{Enter}"); - - expect(clusterInput.value).toBe("Cluster West"); - }); - - it("restores the last accepted placement when Escape cancels a search", async () => { - const user = userEvent.setup(); - createGatewayMock.mockResolvedValue({ - ...createdGateway, - clusterId: "cluster-east", - }); - renderPage(); - - const clusterInput = screen.getByRole("combobox", { - name: "Cluster", - }); - await user.type(clusterInput, "East"); - await user.click(await screen.findByText("Cluster East")); - - await user.clear(clusterInput); - await user.type(clusterInput, "Uncommitted search"); - await user.keyboard("{Escape}"); - - expect(clusterInput.value).toBe("Cluster East"); - expect(clusterInput.getAttribute("aria-expanded")).toBe("false"); - + ( + await screen.findByRole("radio", { + name: "Use local-kind", + }) + ).checked, + ).toBe(true); + expect(radio("Public").disabled).toBe(true); + expect(radio("VPN").disabled).toBe(true); await user.type( screen.getByRole("textbox", { name: "Gateway name" }), "team-gateway", ); await user.click(screen.getByRole("button", { name: "Provision gateway" })); await waitFor(() => { - expect(createGatewayMock).toHaveBeenCalledWith({ - clusterId: "cluster-east", + expect(provisionGateway).toHaveBeenCalledWith({ name: "team-gateway", + placement: { mode: "local-kind" }, }); }); }); - it("shows the available provider and region context for cluster options", async () => { - const user = userEvent.setup(); - findGatewayPlacementsMock.mockResolvedValue({ - hasMore: false, - items: [ - { - id: "provider-only", - name: "Provider cluster", - provider: "AWS", - }, - { - id: "region-only", - name: "Region cluster", - provider: "", - region: "eu-west-1", - }, - { - id: "without-context", - name: "Unclassified cluster", - provider: "", - }, - ], + it("hides local-kind when managed placement is also available", () => { + operations.getGatewayPlacementAvailability.mockResolvedValue({ + ...availablePlacement, + localKind: true, }); renderPage(); - await user.click(screen.getByRole("combobox", { name: "Cluster" })); - expect(await screen.findByText("Provider cluster")).toBeTruthy(); - expect(screen.getByText("Provider: AWS")).toBeTruthy(); - expect(screen.getByText("Region: eu-west-1")).toBeTruthy(); - expect(screen.getByText("Unclassified cluster")).toBeTruthy(); + expect(screen.queryByRole("radio", { name: "Use local-kind" })).toBeNull(); + expect(radio("Public").checked).toBe(false); + expect(radio("VPN").checked).toBe(false); }); - it("retries a failed managed-cluster request", async () => { + it("fails closed and retries when placement availability cannot be loaded", async () => { const user = userEvent.setup(); - findGatewayPlacementsMock - .mockRejectedValueOnce(new Error("managed cluster API unavailable")) - .mockResolvedValue({ - hasMore: false, - items: [ - { - id: "cluster-east", - name: "Cluster East", - provider: "AWS", - region: "us-east-1", - status: "Ready", - }, - ], - }); + operations.getGatewayPlacementAvailability + .mockRejectedValueOnce(new Error("unavailable")) + .mockResolvedValue(availablePlacement); renderPage(); - await user.click(await screen.findByRole("button", { name: "Retry" })); - await user.click(screen.getByRole("combobox", { name: "Cluster" })); - expect(await screen.findByText("Cluster East")).toBeTruthy(); expect( - screen.queryByText("Managed clusters could not be loaded"), - ).toBeNull(); - }); - - it("validates required values before sending a request", async () => { - const user = userEvent.setup(); - findGatewayPlacementsMock.mockResolvedValue({ - hasMore: false, - items: [ - { id: "cluster-east", name: "Cluster East", provider: "AWS" }, - { id: "cluster-west", name: "Cluster West", provider: "GCP" }, - ], - }); - renderPage(); + await screen.findByText( + "Placement availability could not be loaded. Cloud placement is disabled until availability can be verified.", + ), + ).toBeTruthy(); + expect(radio("Public").disabled).toBe(true); + expect(radio("VPN").disabled).toBe(true); - await user.click(screen.getByRole("button", { name: "Provision gateway" })); + await user.click(screen.getByRole("button", { name: "Retry" })); - expect(await screen.findAllByText("This field is required.")).toHaveLength( - 2, - ); - expect(createGatewayMock).not.toHaveBeenCalled(); + await waitFor(() => { + expect(radio("Public").disabled).toBe(false); + expect(radio("VPN").disabled).toBe(false); + }); + expect(operations.getGatewayPlacementAvailability).toHaveBeenCalledTimes(2); }); - it("shows progress only while the create request is pending", async () => { + it("uses network-specific availability and explains the backend reason", async () => { + operations.getGatewayPlacementAvailability.mockResolvedValue({ + awsPublic: true, + awsVpn: false, + awsReason: "no-eligible-cluster", + ibmPublic: false, + ibmVpn: false, + ibmReason: "no-eligible-cluster", + localKind: false, + }); const user = userEvent.setup(); - let resolveRequest: ((gateway: typeof createdGateway) => void) | undefined; - createGatewayMock.mockImplementation( - () => - new Promise((resolve) => { - resolveRequest = resolve; - }), - ); renderPage(); - - await user.type( - screen.getByRole("textbox", { name: "Gateway name" }), - "team-gateway", + const vpn = radio("VPN"); + expect(vpn.disabled).toBe(true); + expect(vpn.getAttribute("aria-describedby")).toBe( + "placement-vpn-description", ); - const submitButton = screen.getByRole("button", { - name: "Provision gateway", - }); - expect(submitButton.classList.contains("pf-m-progress")).toBe(false); - - await user.click(submitButton); + expect( + ( + await screen.findByText( + "VPN is unavailable: no eligible managed cluster is connected.", + ) + ).id, + ).toBe("placement-vpn-description"); + await user.click(screen.getByRole("radio", { name: "Public" })); + + expect(radio("IBM Cloud").disabled).toBe(true); + expect(radio("IBM Cloud").getAttribute("aria-describedby")).toBe( + "provider-ibm-description", + ); + expect( + screen.getByText( + "IBM Cloud is unavailable: no eligible managed cluster is connected.", + ), + ).toBeTruthy(); + }); - await waitFor(() => { - expect(submitButton.classList.contains("pf-m-progress")).toBe(true); - }); - resolveRequest?.(createdGateway); - await waitFor(() => { - expect(navigateMock).toHaveBeenCalledWith("/gateways/gateway-1"); + it("explains when neither managed nor local placement is available", async () => { + operations.getGatewayPlacementAvailability.mockResolvedValue({ + awsPublic: false, + awsVpn: false, + ibmPublic: false, + ibmVpn: false, + localKind: false, }); + renderPage(); + + expect( + await screen.findByText("No managed placement is currently available."), + ).toBeTruthy(); + expect(screen.queryByRole("radio", { name: "Use local-kind" })).toBeNull(); }); }); diff --git a/packages/gateway-management-ui/src/gateways/gateway-create.tsx b/packages/gateway-management-ui/src/gateways/gateway-create.tsx index 52b1d88ad..f87d0eefc 100644 --- a/packages/gateway-management-ui/src/gateways/gateway-create.tsx +++ b/packages/gateway-management-ui/src/gateways/gateway-create.tsx @@ -1,21 +1,35 @@ import { ActionGroup, Alert, + AlertActionLink, Button, + Card, + CardBody, + CardHeader, + CardTitle, Content, Form, FormGroup, + Gallery, FormHelperText, HelperText, HelperTextItem, + Label, PageSection, + Stack, + StackItem, TextInput, Title, } from "@patternfly/react-core"; import { zodResolver } from "@hookform/resolvers/zod"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { useMemo } from "react"; -import { Controller, useForm, type Control } from "react-hook-form"; +import { + skipToken, + useMutation, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; +import { useEffect, useMemo, type ReactNode } from "react"; +import { Controller, useForm, useWatch, type Control } from "react-hook-form"; import { FormattedMessage, useIntl } from "react-intl"; import { z } from "zod"; @@ -23,25 +37,170 @@ import { useGatewayUi } from "../gateway-ui-provider"; import type { GatewayProvisionInput } from "../application/gateway-types"; import { messages } from "../messages"; import { gatewayListQueryRoot, gatewayQueryKey } from "./gateway-data"; -import { GatewayPlacementSelect } from "./gateway-placement-select"; +import styles from "./gateway-create.module.css"; +import awsLogo from "../assets/aws-logo.svg"; +import ibmCloudLogo from "../assets/ibm-cloud.svg"; + +const placementReasonMessages = { + "no-eligible-cluster": messages.noEligibleCluster, +} as const; + +function placementReasonKey( + ...reasons: readonly (string | null | undefined)[] +): keyof typeof placementReasonMessages { + return (reasons.find( + (reason) => reason !== undefined && reason !== null && reason !== "", + ) ?? "no-eligible-cluster") as keyof typeof placementReasonMessages; +} export interface GatewayCreatePageProps { onCreated?: (gatewayId: string) => Promise | void; } interface GatewayFormValues { - clusterId: string | null; name: string; + network: "public" | "vpn" | null; + provider: "aws" | "ibm" | null; + localKind: boolean; } interface GatewayTextFieldProps { - control: Control; + control: Control; fieldId: string; isDisabled: boolean; label: string; name: "name"; } +function Choice({ + title, + description, + descriptionLabel, + value, + selected, + icon, + iconPadding, + isDisabled, + name, + onChoose, +}: { + title: string; + description?: string; + descriptionLabel?: string; + value: string; + selected?: boolean; + icon?: string; + iconPadding?: string; + isDisabled?: boolean; + name: string; + onChoose: () => void; +}) { + const labelId = `${name}-${value}-label`; + const descriptionId = description + ? `${name}-${value}-description` + : undefined; + return ( + { + if (!isDisabled) onChoose(); + }} + > + { + onChoose(); + }, + selectableActionAriaLabelledby: labelId, + selectableActionProps: { + value, + ...(descriptionId ? { "aria-describedby": descriptionId } : {}), + }, + variant: "single", + }} + > + + + + {title} + + + + + {description ? ( + + {descriptionLabel ? ( + + ) : null} + {icon ? ( + + + {description} + + ) : descriptionLabel ? ( + + {description} + + ) : ( + description + )} + + ) : null} + + ); +} + +function ChoiceGroup({ + id, + label, + error, + children, +}: { + id: string; + label: string; + error?: string; + children: ReactNode; +}) { + const errorId = `${id}-error`; + return ( + + + + + {children} + + + + {error ? ( + + + + {error} + + + + ) : null} + + ); +} + function GatewayTextField({ control, fieldId, @@ -93,31 +252,44 @@ export function GatewayCreatePage({ onCreated }: GatewayCreatePageProps = {}) { const schema = useMemo(() => { const requiredString = z.string().trim().min(1, requiredMessage); - return z.object({ - clusterId: z - .string({ error: requiredMessage }) - .nullable() - .transform((value, context) => { - if (value === null) { - context.addIssue({ code: "custom", message: requiredMessage }); - return z.NEVER; - } - return value; - }), - name: requiredString, - }); + return z + .object({ + name: requiredString, + network: z.enum(["public", "vpn"]).nullable(), + provider: z.enum(["aws", "ibm"]).nullable(), + localKind: z.boolean(), + }) + .superRefine((values, context) => { + if (!values.localKind && values.network === null) { + context.addIssue({ + code: "custom", + path: ["network"], + message: requiredMessage, + }); + } + if ( + !values.localKind && + values.network !== null && + values.provider === null + ) { + context.addIssue({ + code: "custom", + path: ["provider"], + message: requiredMessage, + }); + } + }); }, [requiredMessage]); - const { control, handleSubmit } = useForm< - GatewayFormValues, - undefined, - GatewayProvisionInput - >({ - defaultValues: { - clusterId: null, - name: "", - }, - resolver: zodResolver(schema), - }); + const { clearErrors, control, handleSubmit, setValue } = + useForm({ + defaultValues: { + name: "", + network: null, + provider: null, + localKind: false, + }, + resolver: zodResolver(schema) as never, + }); const createGateway = useMutation({ mutationFn: (values: GatewayProvisionInput) => { @@ -134,11 +306,97 @@ export function GatewayCreatePage({ onCreated }: GatewayCreatePageProps = {}) { await navigation.navigate(navigation.detailHref(gateway.id)); } }, + onError: () => { + void queryClient.invalidateQueries({ + queryKey: ["gateway", "placement-availability"], + }); + }, + }); + + const getPlacementAvailability = + gateways.getGatewayPlacementAvailability?.bind(gateways); + const availability = useQuery({ + queryKey: ["gateway", "placement-availability"], + queryFn: + getPlacementAvailability === undefined + ? skipToken + : ({ signal }) => getPlacementAvailability(signal), + staleTime: 30_000, }); + const hasManagedPlacement = availability.data + ? availability.data.awsPublic || + availability.data.awsVpn || + availability.data.ibmPublic || + availability.data.ibmVpn + : false; + const canUseLocalKind = + availability.data?.localKind === true && !hasManagedPlacement; + const defaultToLocalKind = canUseLocalKind && !hasManagedPlacement; + const publicPlacementAvailable = availability.data + ? availability.data.awsPublic || availability.data.ibmPublic + : false; + const vpnPlacementAvailable = availability.data?.awsVpn === true; const submit = handleSubmit((values) => { - createGateway.mutate(values); + if (values.localKind) { + createGateway.mutate({ + name: values.name, + placement: { mode: "local-kind" }, + }); + return; + } + if (values.network === null || values.provider === null) return; + if (values.network === "vpn") { + if (values.provider !== "aws") return; + createGateway.mutate({ + name: values.name, + placement: { network: "vpn", provider: "aws" }, + }); + return; + } + createGateway.mutate({ + name: values.name, + placement: { network: "public", provider: values.provider }, + }); }); + const network = useWatch({ control, name: "network" }); + const provider = useWatch({ control, name: "provider" }); + const localKind = useWatch({ control, name: "localKind" }); + useEffect(() => { + if (!availability.data || localKind) return; + if (network === "vpn") { + const availableProvider = availability.data.awsVpn ? "aws" : null; + if (provider === availableProvider) return; + setValue("provider", availableProvider, { + shouldValidate: true, + }); + } else if ( + network === "public" && + (provider === null || + (provider === "ibm" && !availability.data.ibmPublic) || + (provider === "aws" && !availability.data.awsPublic)) + ) { + setValue( + "provider", + availability.data.ibmPublic + ? "ibm" + : availability.data.awsPublic + ? "aws" + : null, + { shouldValidate: true }, + ); + } + }, [availability.data, localKind, network, provider, setValue]); + useEffect(() => { + if (defaultToLocalKind && !localKind) { + setValue("localKind", true, { shouldValidate: true }); + setValue("network", null); + setValue("provider", null); + clearErrors(["network", "provider"]); + } else if (!canUseLocalKind && localKind) { + setValue("localKind", false, { shouldValidate: true }); + } + }, [canUseLocalKind, clearErrors, defaultToLocalKind, localKind, setValue]); return ( <> @@ -152,7 +410,7 @@ export function GatewayCreatePage({ onCreated }: GatewayCreatePageProps = {}) {

- +
+ {canUseLocalKind ? ( + ( + + { + field.onChange(true); + setValue("network", null); + setValue("provider", null); + clearErrors(["network", "provider"]); + }} + /> + + )} + /> + ) : null} ( - + label={intl.formatMessage(messages.networkAccess)} + > + { + setValue("localKind", false); + field.onChange("public"); + setValue( + "provider", + availability.data?.ibmPublic + ? "ibm" + : availability.data?.awsPublic + ? "aws" + : null, + ); + }} + /> + { + setValue("localKind", false); + field.onChange("vpn"); + setValue( + "provider", + availability.data?.awsVpn === true ? "aws" : null, + ); + }} + /> + )} /> + {network !== null ? ( + ( + + { + field.onChange("aws"); + }} + /> + { + field.onChange("ibm"); + }} + /> + + )} + /> + ) : null} + {availability.data && !hasManagedPlacement && !canUseLocalKind ? ( + + ) : null} + {availability.isError ? ( + { + void availability.refetch(); + }} + > + {intl.formatMessage(messages.retry)} + + } + isInline + title={intl.formatMessage(messages.placementAvailabilityError)} + variant="warning" + /> + ) : null} +

+ +

- - - + setProvider("aws")} + /> + setProvider("ibm")} + /> + + + ) : null} +

+ {showLocalDevelopment + ? "The local Kind cluster is used for development." + : "A matching managed cluster is selected at random for the chosen network and provider."} +

+ + + + + +
); } diff --git a/specs/web-console/user_flows.md b/specs/web-console/user_flows.md index 0d9619e11..9ceb57e7e 100644 --- a/specs/web-console/user_flows.md +++ b/specs/web-console/user_flows.md @@ -153,7 +153,8 @@ flowchart LR | Gateway name | All users with create permission | Required and validated before submission | | Intended use - personal or team | All users with create permission | Proposed; needed only if personal and team ownership become different product concepts | | Network visibility - VPN or Public | All users with create permission | Required; unavailable choices explain why they can't be selected | -| Cloud provider - AWS or IBM Cloud | All users with create permission | Required; available providers depend on network visibility and placement availability | +| Cloud provider - Amazon Web Services or IBM Cloud | All users with create permission | Required for managed placement; available providers depend on network visibility and placement availability | +| Local development - Use local-kind | Kind development users with no managed placement available | Replaces network and cloud-provider selection when the connected local-kind cluster is available | | Placement availability | All users with create permission | Shows whether a matching cluster is available without exposing the selected cluster ID | | Team owner | Team users | Proposed; current authorization is based on individual creator and gateway bindings | | Initial team members | Team users | Proposed; group access and team membership are not yet defined | @@ -170,9 +171,12 @@ flowchart LR login --> list["View my gateways"] list --> start["Select Create gateway"] start --> name["Enter gateway name"] - name --> network["Select VPN or Public"] + name --> placement{"Managed placement available?"} + placement -- Yes --> network["Select VPN or Public"] network --> provider["Select cloud provider"] provider --> submit["Submit gateway"] + placement -- No, Kind local-kind available --> local["Select Use local-kind"] + local --> submit submit --> valid{"Form valid?"} valid -- No --> errors["Show field errors"] errors --> name diff --git a/tests/e2e/e2e-console.sh b/tests/e2e/e2e-console.sh index 704e868e4..306a6aacd 100755 --- a/tests/e2e/e2e-console.sh +++ b/tests/e2e/e2e-console.sh @@ -383,18 +383,16 @@ else exit 1 fi -CLUSTER_OPTION_RE="^$(ab_regex_escape "$SEED_CLUSTER_NAME")( |$)" -TOGGLE_REF=$(ab_ref button '^Select a cluster$' || true) -OPTION_REF="" -if [[ -n "$TOGGLE_REF" ]]; then - show_cmd "agent-browser click ${TOGGLE_REF} # Select a cluster" - ab_ok click "$TOGGLE_REF" || true - OPTION_REF=$(ab_wait_ref option "$CLUSTER_OPTION_RE" 15 || true) +LOCAL_KIND_REF=$(ab_wait_ref radio '^Use local-kind$' 15 || true) +if [[ -z "$LOCAL_KIND_REF" ]]; then + ab_fail "Use local-kind radio was not found in the provisioning form" + exit 1 fi -if [[ -n "$OPTION_REF" ]] && show_cmd "agent-browser click ${OPTION_REF} # ${SEED_CLUSTER_NAME}" && ab_ok click "$OPTION_REF"; then - pass "Selected cluster ${SEED_CLUSTER_NAME} in the Cluster typeahead" +if show_cmd "agent-browser eval local-kind radio click" \ + && ab_ok eval 'document.querySelector("input[value=local-kind]").click()'; then + pass "Selected local-kind placement" else - ab_fail "Cluster option ${SEED_CLUSTER_NAME} not found in the Cluster typeahead" + ab_fail "Could not activate the Use local-kind radio" exit 1 fi @@ -737,44 +735,29 @@ if [[ -n "$WORKSPACE" ]] && e2e_step long; then fi sep -# ── 7. Cluster typeahead + CLI install docs ────────────────────────────── -# CON-E2E-14: the gateway create form lists every registered ManagedCluster as a -# selectable target, and the connection tab links to the external OpenShell CLI -# install docs (opens in a new tab with a safe rel). +# ── 7. Placement choices + CLI install docs ─────────────────────────────── +# The gateway create form exposes server-side placement choices, and the +# connection tab links to the external OpenShell CLI install docs. echo "" -e2e_area "7. Cluster Typeahead and CLI Install Docs" +e2e_area "7. Placement Choices and CLI Install Docs" echo "" ab_use "$ADMIN_SESSION" -# Cluster typeahead: the seeded cluster is listed and selectable. (Area 3 already -# selects it to provision; here we assert the listing explicitly, and both -# clusters under E2E_MULTICLUSTER.) +# Placement choices: the Kind-local placement is listed and selectable. if ab_open "${CONSOLE_HOST}/gateways/new" \ && ab_wait_ref textbox "$NAME_TEXTBOX" "$PAGE_TIMEOUT_S" >/dev/null; then - TA_TOGGLE=$(ab_ref button '^Select a cluster$' || true) - TA_OPTION="" - if [[ -n "$TA_TOGGLE" ]] && ab_ok click "$TA_TOGGLE"; then - TA_OPTION=$(ab_wait_ref option "^$(ab_regex_escape "$SEED_CLUSTER_NAME")( |\$)" 15 || true) - fi - if [[ -n "$TA_OPTION" ]]; then - pass "Cluster typeahead lists the registered cluster ${SEED_CLUSTER_NAME} as selectable" - else - ab_fail "Cluster typeahead did not list ${SEED_CLUSTER_NAME}" - fi - if e2e_truthy "${E2E_MULTICLUSTER}" && [[ -n "${E2E_SEED_CLUSTER_NAME_2:-}" ]]; then - TA_OPTION2=$(ab_wait_ref option "^$(ab_regex_escape "$E2E_SEED_CLUSTER_NAME_2")( |\$)" 15 || true) - if [[ -n "$TA_OPTION2" ]]; then - pass "Cluster typeahead also lists the second cluster ${E2E_SEED_CLUSTER_NAME_2} (E2E_MULTICLUSTER)" - else - ab_fail "Cluster typeahead did not list the second cluster ${E2E_SEED_CLUSTER_NAME_2}" - fi + TA_LOCAL_KIND=$(ab_wait_ref radio '^Use local-kind$' 15 || true) + if [[ -z "$TA_LOCAL_KIND" ]]; then + ab_fail "Use local-kind radio was not found in the placement form" + elif ab_ok eval 'document.querySelector("input[value=local-kind]").click()'; then + pass "Placement choices include local-kind" else - dim " Second-cluster typeahead check skipped (E2E_MULTICLUSTER unset)" + ab_fail "Could not activate the Use local-kind radio" fi else - ab_fail "Could not open the gateway create form for the cluster typeahead check" + ab_fail "Could not open the gateway create form for the placement check" fi # Install-docs link on the gateway detail (connection) view. It renders once the diff --git a/tests/e2e/e2e-openshell.sh b/tests/e2e/e2e-openshell.sh index 233e33bff..4bf0a0ac9 100755 --- a/tests/e2e/e2e-openshell.sh +++ b/tests/e2e/e2e-openshell.sh @@ -444,7 +444,7 @@ else fail_test "Could not discover seeded cluster id" exit 1 fi - dim " Creating gateway on registered cluster_id=${E2E_CLUSTER_ID}; the gateway database is provisioned by the control plane" + dim " Creating gateway with server-side placement; the gateway database is provisioned by the control plane" show_cmd "api_curl -X POST ${API_HOST}/api/hypershell/v1/gateways -d '{name: ${GW_NAME}, oidc: ...}'" GW_CREATE_BODY=$(e2e_gateway_create_body "$GW_NAME") @@ -1832,7 +1832,8 @@ except Exception: import json, os body = { 'name': os.environ['GW_NAME'], - 'cluster_id': os.environ['E2E_CLUSTER_ID'], + 'placement': ({'mode': 'local-kind'} if os.environ.get('E2E_INFRA_DRIVER') == 'kind' + else {'network': 'public', 'provider': 'aws'}), 'oidc': json.dumps({ 'issuer': os.environ['E2E_OIDC_ISSUER'], 'audience': os.environ['E2E_OIDC_CLIENT_ID'], @@ -2003,7 +2004,8 @@ print('true' if has_owner else 'false') import json, os body = { 'name': os.environ['GW_NAME'], - 'cluster_id': os.environ['E2E_CLUSTER_ID'], + 'placement': ({'mode': 'local-kind'} if os.environ.get('E2E_INFRA_DRIVER') == 'kind' + else {'network': 'public', 'provider': 'aws'}), 'oidc': json.dumps({ 'issuer': os.environ['E2E_OIDC_ISSUER'], 'audience': os.environ['E2E_OIDC_CLIENT_ID'], @@ -2396,6 +2398,7 @@ import json, os print(json.dumps({ 'name': os.environ['GW_NAME'], 'cluster_id': '', + 'placement': {'mode': 'local-kind'} if os.environ.get('E2E_INFRA_DRIVER') == 'kind' else {'network': 'public', 'provider': 'aws'}, 'oidc': json.dumps({'issuer': os.environ['E2E_OIDC_ISSUER'], 'audience': os.environ['E2E_OIDC_CLIENT_ID'], 'roles_claim': 'groups', 'admin_role': 'hypershell-admins', 'user_role': 'hypershell-users'}), 'route': json.dumps({'enabled': True}), @@ -2425,6 +2428,7 @@ import json, os print(json.dumps({ 'name': os.environ['GW_NAME'], 'cluster_id': os.environ['PH_ID'], + 'placement': {'mode': 'local-kind'} if os.environ.get('E2E_INFRA_DRIVER') == 'kind' else {'network': 'public', 'provider': 'aws'}, 'oidc': json.dumps({'issuer': os.environ['E2E_OIDC_ISSUER'], 'audience': os.environ['E2E_OIDC_CLIENT_ID'], 'roles_claim': 'groups', 'admin_role': 'hypershell-admins', 'user_role': 'hypershell-users'}), 'route': json.dumps({'enabled': True}), diff --git a/tests/e2e/lib.sh b/tests/e2e/lib.sh index 24ca9f081..36820398b 100755 --- a/tests/e2e/lib.sh +++ b/tests/e2e/lib.sh @@ -796,16 +796,18 @@ print(obj.get('cluster_id', '') or '') [[ -z "${E2E_CLUSTER_ID:-}" && -n "$cluster" ]] && E2E_CLUSTER_ID="$cluster" } -# Print a gateway create body that reuses the seeded cluster id. +# Print a gateway create body using the deployment's placement intent. e2e_gateway_create_body() { local name="${1:?gateway name required}" GW_NAME="$name" E2E_OIDC_ISSUER="$E2E_OIDC_ISSUER" \ E2E_OIDC_CLIENT_ID="$E2E_OIDC_CLIENT_ID" \ - E2E_CLUSTER_ID="${E2E_CLUSTER_ID}" python3 -c " + E2E_INFRA_DRIVER="${E2E_INFRA_DRIVER:-}" python3 -c " import json, os +placement = ({'mode': 'local-kind'} if os.environ['E2E_INFRA_DRIVER'] == 'kind' + else {'network': 'public', 'provider': 'aws'}) body = { 'name': os.environ['GW_NAME'], - 'cluster_id': os.environ['E2E_CLUSTER_ID'], + 'placement': placement, 'oidc': json.dumps({ 'issuer': os.environ['E2E_OIDC_ISSUER'], 'audience': os.environ['E2E_OIDC_CLIENT_ID'], diff --git a/tests/e2e/perf/lib_test.sh b/tests/e2e/perf/lib_test.sh index 71be79c47..a7a980b1c 100755 --- a/tests/e2e/perf/lib_test.sh +++ b/tests/e2e/perf/lib_test.sh @@ -147,21 +147,20 @@ eval "$_orig_fetch" unset _orig_discover _orig_fetch E2E_CLUSTER_ID="" -# The create body carries the registered cluster id only (no release_id since -# GatewayRelease has been removed from the data model). +# The create body carries placement intent without the retired release_id. E2E_CLUSTER_ID="c-registered" E2E_OIDC_ISSUER=https://example/realms/x E2E_OIDC_CLIENT_ID=cli body=$(e2e_gateway_create_body gw-unseeded) -if echo "$body" | grep -q '"cluster_id": "c-registered"' && ! echo "$body" | grep -q '"release_id"'; then - pass_u "gateway create body sends the registered cluster_id without a release_id" +if echo "$body" | grep -q '"placement": {"network": "public", "provider": "aws"}' && ! echo "$body" | grep -q '"release_id"'; then + pass_u "gateway create body sends placement intent without release_id" else - fail_u "gateway create body should send cluster_id without release_id: ${body:0:200}" + fail_u "gateway create body should send placement intent without release_id: ${body:0:200}" fi E2E_CLUSTER_ID="" E2E_CLUSTER_ID=c1 E2E_OIDC_ISSUER=https://example/realms/x E2E_OIDC_CLIENT_ID=cli body=$(e2e_gateway_create_body gw-test) -if echo "$body" | grep -q '"cluster_id": "c1"' && ! echo "$body" | grep -q 'fleet_id' && ! echo "$body" | grep -q 'database_id'; then - pass_u "gateway create body omits fleet_id and database_id" +if echo "$body" | grep -q '"placement": {"network": "public", "provider": "aws"}' && ! echo "$body" | grep -q 'cluster_id' && ! echo "$body" | grep -q 'fleet_id' && ! echo "$body" | grep -q 'database_id'; then + pass_u "gateway create body uses placement and omits cluster/fleet/database ids" else fail_u "gateway create body unexpected: ${body:0:200}" fi