diff --git a/cmd/cloudemu/serve.go b/cmd/cloudemu/serve.go index e91b40614..d2eb90c2f 100644 --- a/cmd/cloudemu/serve.go +++ b/cmd/cloudemu/serve.go @@ -24,12 +24,14 @@ import ( "github.com/stackshy/cloudemu/v2/features/topology" "github.com/stackshy/cloudemu/v2/persist" eksprov "github.com/stackshy/cloudemu/v2/providers/aws/eks" + "github.com/stackshy/cloudemu/v2/providers/openshift/ocm" "github.com/stackshy/cloudemu/v2/seed" "github.com/stackshy/cloudemu/v2/server/admin" awsserver "github.com/stackshy/cloudemu/v2/server/aws" azureserver "github.com/stackshy/cloudemu/v2/server/azure" gcpserver "github.com/stackshy/cloudemu/v2/server/gcp" ociserver "github.com/stackshy/cloudemu/v2/server/oci" + ocmserver "github.com/stackshy/cloudemu/v2/server/openshift/ocm" "github.com/stackshy/cloudemu/v2/services/kubernetes" "github.com/stackshy/cloudemu/v2/services/pricing" "github.com/stackshy/cloudemu/v2/services/resourcediscovery" @@ -206,6 +208,12 @@ func runServe(args []string) error { // reference and needs it separately, or EKS still advertises // the sentinel. cloud.EKS.SetK8sAPI(k8s) + // ROSA/OCM: a Red Hat cluster-manager REST surface hosted on + // the AWS endpoint (rosa is AWS-only). Shares the same data + // plane so `rosa`-created clusters yield a working oc kubeconfig. + ocmMock := ocm.New(config.NewOptions(opts...)) + ocmMock.SetK8sAPI(k8s) + d.OCM = ocmserver.New(ocmMock) fresh["aws"] = wrap(awsserver.New(d), "aws", c.logReqs) freshTargets["aws"] = seed.Target{Storage: cloud.S3, Database: cloud.DynamoDB, Secrets: cloud.SecretsManager, Compute: cloud.EC2} freshEngine = topology.New(cloud.EC2, cloud.VPC, cloud.Route53) @@ -223,6 +231,7 @@ func runServe(args []string) error { d := azureserver.DriversFrom(cloud) d.K8sAPI = k8s cloud.AKS.SetK8sAPI(k8s) + cloud.ARO.SetK8sAPI(k8s) fresh["azure"] = wrap(azureserver.New(d), "azure", c.logReqs) freshTargets["azure"] = seed.Target{Storage: cloud.BlobStorage, Database: cloud.CosmosDB, Secrets: cloud.KeyVault, Compute: cloud.VirtualMachines} freshDiscovery["azure"] = cloud.ResourceDiscovery diff --git a/docs/coverage/README.md b/docs/coverage/README.md index 3fd0dbb11..2f0247359 100644 --- a/docs/coverage/README.md +++ b/docs/coverage/README.md @@ -40,7 +40,7 @@ capability the code does not implement. Machine-readable: [`coverage.json`](./co | `messagequeue` | [SQS](./aws/sqs.md) | [QueueStorage](./azure/queuestorage.md) | [PubSub](./gcp/pubsub.md) | — | 14 | | `monitoring` | [CloudWatch](./aws/cloudwatch.md) | [Monitor](./azure/monitor.md) | [CloudMonitoring](./gcp/cloudmonitoring.md) | [Monitoring](./oci/monitoring.md) | 12 | | `networkfirewall` | [NetworkFirewall](./aws/networkfirewall.md) | — | — | — | 20 | -| `networking` | [VPC](./aws/vpc.md) | — | — | [VCN](./oci/vcn.md) | 3 | +| `networking` | [VPC](./aws/vpc.md) | [VNet](./azure/vnet.md) | [VPC](./gcp/vpc.md) | [VCN](./oci/vcn.md) | 57 | | `notification` | [SNS](./aws/sns.md) | [NotificationHubs](./azure/notificationhubs.md) | [FCM](./gcp/fcm.md) | — | 9 | | `opensearch` | [OpenSearch](./aws/opensearch.md) | — | — | — | 96 | | `parameterstore` | [SSM](./aws/ssm.md) | — | — | — | 9 | diff --git a/docs/coverage/aws/README.md b/docs/coverage/aws/README.md index 70f37857f..b324b6bae 100644 --- a/docs/coverage/aws/README.md +++ b/docs/coverage/aws/README.md @@ -41,6 +41,6 @@ Services cloudemu emulates for AWS, by native name. Back to the [cross-provider | [SSM](./ssm.md) | `parameterstore` | 9 | | [SageMaker](./sagemaker.md) | `sagemaker` | 121 | | [SecretsManager](./secretsmanager.md) | `secrets` | 7 | -| [VPC](./vpc.md) | `networking` | 3 | +| [VPC](./vpc.md) | `networking` | 57 | | [VPCLattice](./vpclattice.md) | `vpclattice` | 73 | | [WAFv2](./wafv2.md) | `wafv2` | 39 | diff --git a/docs/coverage/aws/vpc.md b/docs/coverage/aws/vpc.md index f4a857d66..30bee7dc3 100644 --- a/docs/coverage/aws/vpc.md +++ b/docs/coverage/aws/vpc.md @@ -1,15 +1,69 @@ # VPC -AWS's `networking` service · portable interface `driver.NetworkInterfaces` · [AWS index](./README.md) +AWS's `networking` service · portable interface `driver.Networking` · [AWS index](./README.md) -## Operations (3) +## Operations (57) | Operation | Description | | --- | --- | -| `DeleteNetworkInterface` | | -| `DescribeNetworkInterfaces` | | -| `DetachNetworkInterface` | | +| `AcceptPeeringConnection` | | +| `AddEgressRule` | | +| `AddIngressRule` | | +| `AddNetworkACLRule` | | +| `AllocateAddress` | Elastic IPs | +| `AssociateAddress` | | +| `AssociateRouteTable` | Route Table Associations | +| `AttachInternetGateway` | | +| `CreateFlowLog` | Flow Logs | +| `CreateInternetGateway` | Internet Gateways | +| `CreateNATGateway` | NAT Gateways | +| `CreateNetworkACL` | Network ACLs | +| `CreatePeeringConnection` | VPC Peering | +| `CreateRoute` | | +| `CreateRouteTable` | Route Tables | +| `CreateSecurityGroup` | | +| `CreateSubnet` | | +| `CreateVPC` | | +| `CreateVPCEndpoint` | VPC Endpoints | +| `DeleteFlowLog` | | +| `DeleteInternetGateway` | | +| `DeleteNATGateway` | | +| `DeleteNetworkACL` | | +| `DeletePeeringConnection` | | +| `DeleteRoute` | | +| `DeleteRouteTable` | | +| `DeleteSecurityGroup` | | +| `DeleteSubnet` | | +| `DeleteVPC` | | +| `DeleteVPCEndpoint` | | +| `DescribeAddresses` | | +| `DescribeFlowLogs` | | +| `DescribeInternetGateways` | | +| `DescribeNATGateways` | | +| `DescribeNetworkACLs` | | +| `DescribePeeringConnections` | | +| `DescribeRouteTables` | | +| `DescribeSecurityGroups` | | +| `DescribeSubnets` | | +| `DescribeVPCEndpoints` | | +| `DescribeVPCs` | | +| `DetachInternetGateway` | | +| `DisassociateAddress` | | +| `DisassociateRouteTable` | | +| `GetFlowLogRecords` | | +| `ModifyVPCEndpoint` | | +| `RejectPeeringConnection` | | +| `ReleaseAddress` | | +| `RemoveEgressRule` | | +| `RemoveIngressRule` | | +| `RemoveNetworkACLRule` | | +| `RemoveSecurityGroupTags` | | +| `RemoveSubnetTags` | | +| `RemoveVPCTags` | | +| `UpdateSecurityGroupTags` | | +| `UpdateSubnetTags` | | +| `UpdateVPCTags` | Tag mutation. Update* merges keys into the resource's existing Tags | ## Optional capabilities @@ -216,69 +270,15 @@ NetworkInterfaceCreator is the AWS-specific ENI-creation surface. It's kept | --- | --- | | `CreateNetworkInterface` | | -### Networking +### NetworkInterfaces -Networking is the interface that networking provider +NetworkInterfaces is an OPTIONAL capability, discovered by type assertion. | Operation | Description | | --- | --- | -| `AcceptPeeringConnection` | | -| `AddEgressRule` | | -| `AddIngressRule` | | -| `AddNetworkACLRule` | | -| `AllocateAddress` | Elastic IPs | -| `AssociateAddress` | | -| `AssociateRouteTable` | Route Table Associations | -| `AttachInternetGateway` | | -| `CreateFlowLog` | Flow Logs | -| `CreateInternetGateway` | Internet Gateways | -| `CreateNATGateway` | NAT Gateways | -| `CreateNetworkACL` | Network ACLs | -| `CreatePeeringConnection` | VPC Peering | -| `CreateRoute` | | -| `CreateRouteTable` | Route Tables | -| `CreateSecurityGroup` | | -| `CreateSubnet` | | -| `CreateVPC` | | -| `CreateVPCEndpoint` | VPC Endpoints | -| `DeleteFlowLog` | | -| `DeleteInternetGateway` | | -| `DeleteNATGateway` | | -| `DeleteNetworkACL` | | -| `DeletePeeringConnection` | | -| `DeleteRoute` | | -| `DeleteRouteTable` | | -| `DeleteSecurityGroup` | | -| `DeleteSubnet` | | -| `DeleteVPC` | | -| `DeleteVPCEndpoint` | | -| `DescribeAddresses` | | -| `DescribeFlowLogs` | | -| `DescribeInternetGateways` | | -| `DescribeNATGateways` | | -| `DescribeNetworkACLs` | | -| `DescribePeeringConnections` | | -| `DescribeRouteTables` | | -| `DescribeSecurityGroups` | | -| `DescribeSubnets` | | -| `DescribeVPCEndpoints` | | -| `DescribeVPCs` | | -| `DetachInternetGateway` | | -| `DisassociateAddress` | | -| `DisassociateRouteTable` | | -| `GetFlowLogRecords` | | -| `ModifyVPCEndpoint` | | -| `RejectPeeringConnection` | | -| `ReleaseAddress` | | -| `RemoveEgressRule` | | -| `RemoveIngressRule` | | -| `RemoveNetworkACLRule` | | -| `RemoveSecurityGroupTags` | | -| `RemoveSubnetTags` | | -| `RemoveVPCTags` | | -| `UpdateSecurityGroupTags` | | -| `UpdateSubnetTags` | | -| `UpdateVPCTags` | Tag mutation. Update* merges keys into the resource's existing Tags | +| `DeleteNetworkInterface` | | +| `DescribeNetworkInterfaces` | | +| `DetachNetworkInterface` | | ### PrefixLists diff --git a/docs/coverage/azure/README.md b/docs/coverage/azure/README.md index d7573a9f2..4bd0d6127 100644 --- a/docs/coverage/azure/README.md +++ b/docs/coverage/azure/README.md @@ -26,4 +26,5 @@ Services cloudemu emulates for Azure, by native name. Back to the [cross-provide | [QueueStorage](./queuestorage.md) | `messagequeue` | 14 | | [Search](./search.md) | `azuresearch` | 53 | | [TableStorage](./tablestorage.md) | `tablestorage` | 8 | +| [VNet](./vnet.md) | `networking` | 57 | | [VirtualMachines](./virtualmachines.md) | `compute` | 37 | diff --git a/docs/coverage/azure/vnet.md b/docs/coverage/azure/vnet.md new file mode 100644 index 000000000..32cd774a9 --- /dev/null +++ b/docs/coverage/azure/vnet.md @@ -0,0 +1,395 @@ + +# VNet + +Azure's `networking` service · portable interface `driver.Networking` · [Azure index](./README.md) + +## Operations (57) + +| Operation | Description | +| --- | --- | +| `AcceptPeeringConnection` | | +| `AddEgressRule` | | +| `AddIngressRule` | | +| `AddNetworkACLRule` | | +| `AllocateAddress` | Elastic IPs | +| `AssociateAddress` | | +| `AssociateRouteTable` | Route Table Associations | +| `AttachInternetGateway` | | +| `CreateFlowLog` | Flow Logs | +| `CreateInternetGateway` | Internet Gateways | +| `CreateNATGateway` | NAT Gateways | +| `CreateNetworkACL` | Network ACLs | +| `CreatePeeringConnection` | VPC Peering | +| `CreateRoute` | | +| `CreateRouteTable` | Route Tables | +| `CreateSecurityGroup` | | +| `CreateSubnet` | | +| `CreateVPC` | | +| `CreateVPCEndpoint` | VPC Endpoints | +| `DeleteFlowLog` | | +| `DeleteInternetGateway` | | +| `DeleteNATGateway` | | +| `DeleteNetworkACL` | | +| `DeletePeeringConnection` | | +| `DeleteRoute` | | +| `DeleteRouteTable` | | +| `DeleteSecurityGroup` | | +| `DeleteSubnet` | | +| `DeleteVPC` | | +| `DeleteVPCEndpoint` | | +| `DescribeAddresses` | | +| `DescribeFlowLogs` | | +| `DescribeInternetGateways` | | +| `DescribeNATGateways` | | +| `DescribeNetworkACLs` | | +| `DescribePeeringConnections` | | +| `DescribeRouteTables` | | +| `DescribeSecurityGroups` | | +| `DescribeSubnets` | | +| `DescribeVPCEndpoints` | | +| `DescribeVPCs` | | +| `DetachInternetGateway` | | +| `DisassociateAddress` | | +| `DisassociateRouteTable` | | +| `GetFlowLogRecords` | | +| `ModifyVPCEndpoint` | | +| `RejectPeeringConnection` | | +| `ReleaseAddress` | | +| `RemoveEgressRule` | | +| `RemoveIngressRule` | | +| `RemoveNetworkACLRule` | | +| `RemoveSecurityGroupTags` | | +| `RemoveSubnetTags` | | +| `RemoveVPCTags` | | +| `UpdateSecurityGroupTags` | | +| `UpdateSubnetTags` | | +| `UpdateVPCTags` | Tag mutation. Update* merges keys into the resource's existing Tags | + +## Optional capabilities + +Discovered by type assertion; only some providers implement these. + +### ClientVPN + +ClientVPN is an OPTIONAL AWS capability (type-asserted). + +| Operation | Description | +| --- | --- | +| `AssociateClientVPNTargetNetwork` | | +| `AuthorizeClientVPNIngress` | | +| `CreateClientVPNEndpoint` | | +| `CreateClientVPNRoute` | | +| `DeleteClientVPNEndpoint` | | +| `DeleteClientVPNRoute` | | +| `DescribeClientVPNAuthorizationRules` | | +| `DescribeClientVPNEndpoints` | | +| `DescribeClientVPNRoutes` | | +| `DescribeClientVPNTargetNetworks` | | +| `DisassociateClientVPNTargetNetwork` | | +| `RevokeClientVPNIngress` | | + +### DHCPOptionSets + +DHCPOptionSets is an OPTIONAL AWS capability (type-asserted). + +| Operation | Description | +| --- | --- | +| `AssociateDHCPOptions` | | +| `CreateDHCPOptions` | | +| `DeleteDHCPOptions` | | +| `DescribeDHCPOptions` | | + +### EgressOnlyInternetGateways + +EgressOnlyInternetGateways is an OPTIONAL AWS capability (type-asserted). + +| Operation | Description | +| --- | --- | +| `CreateEgressOnlyInternetGateway` | | +| `DeleteEgressOnlyInternetGateway` | | +| `DescribeEgressOnlyInternetGateways` | | + +### IPAM + +IPAM is an OPTIONAL AWS capability (type-asserted on the vpc driver). + +| Operation | Description | +| --- | --- | +| `AllocateIpamPoolCidr` | | +| `CreateIpam` | | +| `CreateIpamPool` | | +| `CreateIpamScope` | | +| `DeleteIpam` | | +| `DeleteIpamPool` | | +| `DeleteIpamScope` | | +| `DeprovisionIpamPoolCidr` | | +| `DescribeIpamPools` | | +| `DescribeIpamScopes` | | +| `DescribeIpams` | | +| `GetIpamPoolAllocations` | | +| `GetIpamPoolCidrs` | | +| `ModifyIpam` | | +| `ModifyIpamPool` | | +| `ModifyIpamPoolAllocation` | | +| `ModifyIpamScope` | | +| `ProvisionIpamPoolCidr` | | +| `ReleaseIpamPoolAllocation` | | + +### IPAMByoasn + +IPAMByoasn is an OPTIONAL AWS capability for bring-your-own ASN. + +| Operation | Description | +| --- | --- | +| `AssociateIpamByoasn` | | +| `DeprovisionIpamByoasn` | | +| `DescribeIpamByoasn` | | +| `DisassociateIpamByoasn` | | +| `ProvisionIpamByoasn` | | + +### IPAMByoip + +IPAMByoip is an OPTIONAL AWS capability for bring-your-own public IP CIDRs + +| Operation | Description | +| --- | --- | +| `AdvertiseByoipCidr` | | +| `DeprovisionByoipCidr` | | +| `DescribeByoipCidrs` | | +| `MoveByoipCidrToIpam` | | +| `ProvisionByoipCidr` | | +| `WithdrawByoipCidr` | | + +### IPAMDiscovery + +IPAMDiscovery is an OPTIONAL AWS capability for IPAM resource discovery. + +| Operation | Description | +| --- | --- | +| `AssociateIpamResourceDiscovery` | | +| `CreateIpamResourceDiscovery` | | +| `DeleteIpamResourceDiscovery` | | +| `DescribeIpamResourceDiscoveries` | | +| `DescribeIpamResourceDiscoveryAssociations` | | +| `DisassociateIpamResourceDiscovery` | | +| `GetIpamDiscoveredAccounts` | | +| `GetIpamDiscoveredPublicAddresses` | | +| `GetIpamDiscoveredResourceCidrs` | | +| `ModifyIpamResourceDiscovery` | | + +### IPAMExternalToken + +IPAMExternalToken is an OPTIONAL AWS capability for external-resource + +| Operation | Description | +| --- | --- | +| `CreateIpamExternalResourceVerificationToken` | | +| `DeleteIpamExternalResourceVerificationToken` | | +| `DescribeIpamExternalResourceVerificationTokens` | | + +### IPAMMetrics + +IPAMMetrics is an OPTIONAL capability that exposes the AWS/IPAM CloudWatch + +| Operation | Description | +| --- | --- | +| `IpamMetrics` | | + +### IPAMPolicy + +IPAMPolicy is an OPTIONAL AWS capability for IPAM policies and the + +| Operation | Description | +| --- | --- | +| `CreateIpamPolicy` | | +| `DeleteIpamPolicy` | | +| `DescribeIpamPolicies` | | +| `DisableIpamOrganizationAdminAccount` | | +| `DisableIpamPolicy` | | +| `EnableIpamOrganizationAdminAccount` | | +| `EnableIpamPolicy` | | +| `GetEnabledIpamPolicy` | | +| `GetIpamPolicyAllocationRules` | | +| `GetIpamPolicyOrganizationTargets` | | +| `ModifyIpamPolicyAllocationRules` | | + +### IPAMPrefixListResolver + +IPAMPrefixListResolver is an OPTIONAL AWS capability for IPAM prefix-list + +| Operation | Description | +| --- | --- | +| `CreateIpamPrefixListResolver` | | +| `CreateIpamPrefixListResolverTarget` | | +| `DeleteIpamPrefixListResolver` | | +| `DeleteIpamPrefixListResolverTarget` | | +| `DescribeIpamPrefixListResolverTargets` | | +| `DescribeIpamPrefixListResolvers` | | +| `GetIpamPrefixListResolverRules` | | +| `GetIpamPrefixListResolverVersionEntries` | | +| `GetIpamPrefixListResolverVersions` | | +| `ModifyIpamPrefixListResolver` | | +| `ModifyIpamPrefixListResolverTarget` | | + +### IPAMResources + +IPAMResources is an OPTIONAL AWS capability exposing IPAM's view of the + +| Operation | Description | +| --- | --- | +| `GetIpamAddressHistory` | | +| `GetIpamResourceCidrs` | | +| `ModifyIpamResourceCidr` | | + +### NetworkInsights + +NetworkInsights is an OPTIONAL AWS capability (type-asserted). It covers both + +| Operation | Description | +| --- | --- | +| `CreateNetworkInsightsAccessScope` | | +| `CreateNetworkInsightsPath` | | +| `DeleteNetworkInsightsAccessScope` | | +| `DeleteNetworkInsightsAccessScopeAnalysis` | | +| `DeleteNetworkInsightsAnalysis` | | +| `DeleteNetworkInsightsPath` | | +| `DescribeNetworkInsightsAccessScopeAnalyses` | | +| `DescribeNetworkInsightsAccessScopes` | | +| `DescribeNetworkInsightsAnalyses` | | +| `DescribeNetworkInsightsPaths` | | +| `GetNetworkInsightsAccessScopeAnalysisFindings` | | +| `GetNetworkInsightsAccessScopeContent` | | +| `StartNetworkInsightsAccessScopeAnalysis` | | +| `StartNetworkInsightsAnalysis` | | + +### NetworkInterfaceCreator + +NetworkInterfaceCreator is the AWS-specific ENI-creation surface. It's kept + +| Operation | Description | +| --- | --- | +| `CreateNetworkInterface` | | + +### NetworkInterfaces + +NetworkInterfaces is an OPTIONAL capability, discovered by type assertion. + +| Operation | Description | +| --- | --- | +| `DeleteNetworkInterface` | | +| `DescribeNetworkInterfaces` | | +| `DetachNetworkInterface` | | + +### PrefixLists + +PrefixLists is an OPTIONAL AWS capability (type-asserted). + +| Operation | Description | +| --- | --- | +| `CreateManagedPrefixList` | | +| `DeleteManagedPrefixList` | | +| `DescribeManagedPrefixLists` | | +| `GetManagedPrefixListEntries` | | +| `ModifyManagedPrefixList` | | + +### TrafficMirroring + +TrafficMirroring is an OPTIONAL AWS capability (type-asserted). + +| Operation | Description | +| --- | --- | +| `CreateTrafficMirrorFilter` | | +| `CreateTrafficMirrorFilterRule` | | +| `CreateTrafficMirrorSession` | | +| `CreateTrafficMirrorTarget` | | +| `DeleteTrafficMirrorFilter` | | +| `DeleteTrafficMirrorFilterRule` | | +| `DeleteTrafficMirrorSession` | | +| `DeleteTrafficMirrorTarget` | | +| `DescribeTrafficMirrorFilterRules` | | +| `DescribeTrafficMirrorFilters` | | +| `DescribeTrafficMirrorSessions` | | +| `DescribeTrafficMirrorTargets` | | +| `ModifyTrafficMirrorFilterNetworkServices` | | +| `ModifyTrafficMirrorFilterRule` | | +| `ModifyTrafficMirrorSession` | | + +### TransitGateways + +TransitGateways is an OPTIONAL AWS capability (type-asserted). + +| Operation | Description | +| --- | --- | +| `AssociateTransitGatewayRouteTable` | | +| `CreateTransitGateway` | | +| `CreateTransitGatewayRoute` | | +| `CreateTransitGatewayRouteTable` | | +| `CreateTransitGatewayVPCAttachment` | | +| `DeleteTransitGateway` | | +| `DeleteTransitGatewayRoute` | | +| `DeleteTransitGatewayRouteTable` | | +| `DeleteTransitGatewayVPCAttachment` | | +| `DescribeTransitGatewayRouteTables` | | +| `DescribeTransitGatewayVPCAttachments` | | +| `DescribeTransitGateways` | | +| `DisableTransitGatewayRouteTablePropagation` | | +| `EnableTransitGatewayRouteTablePropagation` | | +| `SearchTransitGatewayRoutes` | | + +### VPCAttributes + +VPCAttributes is an OPTIONAL capability, discovered by type assertion. + +| Operation | Description | +| --- | --- | +| `ModifyVPCAttribute` | | + +### VPCBlockPublicAccess + +VPCBlockPublicAccess is an OPTIONAL AWS capability (type-asserted). + +| Operation | Description | +| --- | --- | +| `CreateVPCBlockPublicAccessExclusion` | | +| `DeleteVPCBlockPublicAccessExclusion` | | +| `DescribeVPCBlockPublicAccessExclusions` | | +| `DescribeVPCBlockPublicAccessOptions` | | +| `ModifyVPCBlockPublicAccessExclusion` | | +| `ModifyVPCBlockPublicAccessOptions` | | + +### VPCEndpointServices + +VPCEndpointServices is an OPTIONAL AWS capability (type-asserted). + +| Operation | Description | +| --- | --- | +| `CreateVPCEndpointServiceConfiguration` | | +| `DeleteVPCEndpointServiceConfiguration` | | +| `DescribeVPCEndpointServiceConfigurations` | | +| `DescribeVPCEndpointServicePermissions` | | +| `ModifyVPCEndpointServicePermissions` | | + +### VPNConnections + +VPNConnections is an OPTIONAL AWS capability (type-asserted). + +| Operation | Description | +| --- | --- | +| `AttachVPNGateway` | | +| `CreateCustomerGateway` | | +| `CreateVPNConnection` | | +| `CreateVPNConnectionRoute` | | +| `CreateVPNGateway` | | +| `DeleteCustomerGateway` | | +| `DeleteVPNConnection` | | +| `DeleteVPNConnectionRoute` | | +| `DeleteVPNGateway` | | +| `DescribeCustomerGateways` | | +| `DescribeVPNConnections` | | +| `DescribeVPNGateways` | | +| `DetachVPNGateway` | | +| `ModifyVPNConnection` | | + +## Not in scope + +_Not documented yet. See the [emulator boundary](../../../README.md) for cloudemu-wide non-goals._ diff --git a/docs/coverage/coverage.json b/docs/coverage/coverage.json index c53fbfe88..928a85917 100644 --- a/docs/coverage/coverage.json +++ b/docs/coverage/coverage.json @@ -5406,16 +5406,188 @@ }, { "service": "networking", - "interface": "NetworkInterfaces", + "interface": "Networking", "operations": [ { - "name": "DeleteNetworkInterface" + "name": "AcceptPeeringConnection" }, { - "name": "DescribeNetworkInterfaces" + "name": "AddEgressRule" }, { - "name": "DetachNetworkInterface" + "name": "AddIngressRule" + }, + { + "name": "AddNetworkACLRule" + }, + { + "name": "AllocateAddress", + "doc": "Elastic IPs" + }, + { + "name": "AssociateAddress" + }, + { + "name": "AssociateRouteTable", + "doc": "Route Table Associations" + }, + { + "name": "AttachInternetGateway" + }, + { + "name": "CreateFlowLog", + "doc": "Flow Logs" + }, + { + "name": "CreateInternetGateway", + "doc": "Internet Gateways" + }, + { + "name": "CreateNATGateway", + "doc": "NAT Gateways" + }, + { + "name": "CreateNetworkACL", + "doc": "Network ACLs" + }, + { + "name": "CreatePeeringConnection", + "doc": "VPC Peering" + }, + { + "name": "CreateRoute" + }, + { + "name": "CreateRouteTable", + "doc": "Route Tables" + }, + { + "name": "CreateSecurityGroup" + }, + { + "name": "CreateSubnet" + }, + { + "name": "CreateVPC" + }, + { + "name": "CreateVPCEndpoint", + "doc": "VPC Endpoints" + }, + { + "name": "DeleteFlowLog" + }, + { + "name": "DeleteInternetGateway" + }, + { + "name": "DeleteNATGateway" + }, + { + "name": "DeleteNetworkACL" + }, + { + "name": "DeletePeeringConnection" + }, + { + "name": "DeleteRoute" + }, + { + "name": "DeleteRouteTable" + }, + { + "name": "DeleteSecurityGroup" + }, + { + "name": "DeleteSubnet" + }, + { + "name": "DeleteVPC" + }, + { + "name": "DeleteVPCEndpoint" + }, + { + "name": "DescribeAddresses" + }, + { + "name": "DescribeFlowLogs" + }, + { + "name": "DescribeInternetGateways" + }, + { + "name": "DescribeNATGateways" + }, + { + "name": "DescribeNetworkACLs" + }, + { + "name": "DescribePeeringConnections" + }, + { + "name": "DescribeRouteTables" + }, + { + "name": "DescribeSecurityGroups" + }, + { + "name": "DescribeSubnets" + }, + { + "name": "DescribeVPCEndpoints" + }, + { + "name": "DescribeVPCs" + }, + { + "name": "DetachInternetGateway" + }, + { + "name": "DisassociateAddress" + }, + { + "name": "DisassociateRouteTable" + }, + { + "name": "GetFlowLogRecords" + }, + { + "name": "ModifyVPCEndpoint" + }, + { + "name": "RejectPeeringConnection" + }, + { + "name": "ReleaseAddress" + }, + { + "name": "RemoveEgressRule" + }, + { + "name": "RemoveIngressRule" + }, + { + "name": "RemoveNetworkACLRule" + }, + { + "name": "RemoveSecurityGroupTags" + }, + { + "name": "RemoveSubnetTags" + }, + { + "name": "RemoveVPCTags" + }, + { + "name": "UpdateSecurityGroupTags" + }, + { + "name": "UpdateSubnetTags" + }, + { + "name": "UpdateVPCTags", + "doc": "Tag mutation. Update* merges keys into the resource's existing Tags" } ], "optionalCapabilities": [ @@ -5813,189 +5985,17 @@ ] }, { - "name": "Networking", - "doc": "Networking is the interface that networking provider", + "name": "NetworkInterfaces", + "doc": "NetworkInterfaces is an OPTIONAL capability, discovered by type assertion.", "operations": [ { - "name": "AcceptPeeringConnection" - }, - { - "name": "AddEgressRule" - }, - { - "name": "AddIngressRule" - }, - { - "name": "AddNetworkACLRule" - }, - { - "name": "AllocateAddress", - "doc": "Elastic IPs" - }, - { - "name": "AssociateAddress" - }, - { - "name": "AssociateRouteTable", - "doc": "Route Table Associations" - }, - { - "name": "AttachInternetGateway" - }, - { - "name": "CreateFlowLog", - "doc": "Flow Logs" - }, - { - "name": "CreateInternetGateway", - "doc": "Internet Gateways" - }, - { - "name": "CreateNATGateway", - "doc": "NAT Gateways" - }, - { - "name": "CreateNetworkACL", - "doc": "Network ACLs" - }, - { - "name": "CreatePeeringConnection", - "doc": "VPC Peering" - }, - { - "name": "CreateRoute" - }, - { - "name": "CreateRouteTable", - "doc": "Route Tables" - }, - { - "name": "CreateSecurityGroup" - }, - { - "name": "CreateSubnet" - }, - { - "name": "CreateVPC" - }, - { - "name": "CreateVPCEndpoint", - "doc": "VPC Endpoints" - }, - { - "name": "DeleteFlowLog" - }, - { - "name": "DeleteInternetGateway" - }, - { - "name": "DeleteNATGateway" - }, - { - "name": "DeleteNetworkACL" - }, - { - "name": "DeletePeeringConnection" - }, - { - "name": "DeleteRoute" - }, - { - "name": "DeleteRouteTable" - }, - { - "name": "DeleteSecurityGroup" - }, - { - "name": "DeleteSubnet" - }, - { - "name": "DeleteVPC" - }, - { - "name": "DeleteVPCEndpoint" - }, - { - "name": "DescribeAddresses" - }, - { - "name": "DescribeFlowLogs" - }, - { - "name": "DescribeInternetGateways" - }, - { - "name": "DescribeNATGateways" - }, - { - "name": "DescribeNetworkACLs" - }, - { - "name": "DescribePeeringConnections" - }, - { - "name": "DescribeRouteTables" - }, - { - "name": "DescribeSecurityGroups" - }, - { - "name": "DescribeSubnets" - }, - { - "name": "DescribeVPCEndpoints" - }, - { - "name": "DescribeVPCs" - }, - { - "name": "DetachInternetGateway" - }, - { - "name": "DisassociateAddress" - }, - { - "name": "DisassociateRouteTable" - }, - { - "name": "GetFlowLogRecords" - }, - { - "name": "ModifyVPCEndpoint" - }, - { - "name": "RejectPeeringConnection" - }, - { - "name": "ReleaseAddress" - }, - { - "name": "RemoveEgressRule" - }, - { - "name": "RemoveIngressRule" - }, - { - "name": "RemoveNetworkACLRule" - }, - { - "name": "RemoveSecurityGroupTags" - }, - { - "name": "RemoveSubnetTags" - }, - { - "name": "RemoveVPCTags" - }, - { - "name": "UpdateSecurityGroupTags" + "name": "DeleteNetworkInterface" }, { - "name": "UpdateSubnetTags" + "name": "DescribeNetworkInterfaces" }, { - "name": "UpdateVPCTags", - "doc": "Tag mutation. Update* merges keys into the resource's existing Tags" + "name": "DetachNetworkInterface" } ] }, @@ -6227,6 +6227,8 @@ ], "providers": { "aws": "VPC", + "azure": "VNet", + "gcp": "VPC", "oci": "VCN" } }, diff --git a/docs/coverage/gcp/README.md b/docs/coverage/gcp/README.md index 71bc10b3e..1817cbcc0 100644 --- a/docs/coverage/gcp/README.md +++ b/docs/coverage/gcp/README.md @@ -22,4 +22,5 @@ Services cloudemu emulates for GCP, by native name. Back to the [cross-provider | [Memorystore](./memorystore.md) | `cache` | 17 | | [PubSub](./pubsub.md) | `messagequeue` | 14 | | [SecretManager](./secretmanager.md) | `secrets` | 7 | +| [VPC](./vpc.md) | `networking` | 57 | | [VertexAI](./vertexai.md) | `vertexai` | 125 | diff --git a/docs/coverage/gcp/vpc.md b/docs/coverage/gcp/vpc.md new file mode 100644 index 000000000..57b588078 --- /dev/null +++ b/docs/coverage/gcp/vpc.md @@ -0,0 +1,395 @@ + +# VPC + +GCP's `networking` service · portable interface `driver.Networking` · [GCP index](./README.md) + +## Operations (57) + +| Operation | Description | +| --- | --- | +| `AcceptPeeringConnection` | | +| `AddEgressRule` | | +| `AddIngressRule` | | +| `AddNetworkACLRule` | | +| `AllocateAddress` | Elastic IPs | +| `AssociateAddress` | | +| `AssociateRouteTable` | Route Table Associations | +| `AttachInternetGateway` | | +| `CreateFlowLog` | Flow Logs | +| `CreateInternetGateway` | Internet Gateways | +| `CreateNATGateway` | NAT Gateways | +| `CreateNetworkACL` | Network ACLs | +| `CreatePeeringConnection` | VPC Peering | +| `CreateRoute` | | +| `CreateRouteTable` | Route Tables | +| `CreateSecurityGroup` | | +| `CreateSubnet` | | +| `CreateVPC` | | +| `CreateVPCEndpoint` | VPC Endpoints | +| `DeleteFlowLog` | | +| `DeleteInternetGateway` | | +| `DeleteNATGateway` | | +| `DeleteNetworkACL` | | +| `DeletePeeringConnection` | | +| `DeleteRoute` | | +| `DeleteRouteTable` | | +| `DeleteSecurityGroup` | | +| `DeleteSubnet` | | +| `DeleteVPC` | | +| `DeleteVPCEndpoint` | | +| `DescribeAddresses` | | +| `DescribeFlowLogs` | | +| `DescribeInternetGateways` | | +| `DescribeNATGateways` | | +| `DescribeNetworkACLs` | | +| `DescribePeeringConnections` | | +| `DescribeRouteTables` | | +| `DescribeSecurityGroups` | | +| `DescribeSubnets` | | +| `DescribeVPCEndpoints` | | +| `DescribeVPCs` | | +| `DetachInternetGateway` | | +| `DisassociateAddress` | | +| `DisassociateRouteTable` | | +| `GetFlowLogRecords` | | +| `ModifyVPCEndpoint` | | +| `RejectPeeringConnection` | | +| `ReleaseAddress` | | +| `RemoveEgressRule` | | +| `RemoveIngressRule` | | +| `RemoveNetworkACLRule` | | +| `RemoveSecurityGroupTags` | | +| `RemoveSubnetTags` | | +| `RemoveVPCTags` | | +| `UpdateSecurityGroupTags` | | +| `UpdateSubnetTags` | | +| `UpdateVPCTags` | Tag mutation. Update* merges keys into the resource's existing Tags | + +## Optional capabilities + +Discovered by type assertion; only some providers implement these. + +### ClientVPN + +ClientVPN is an OPTIONAL AWS capability (type-asserted). + +| Operation | Description | +| --- | --- | +| `AssociateClientVPNTargetNetwork` | | +| `AuthorizeClientVPNIngress` | | +| `CreateClientVPNEndpoint` | | +| `CreateClientVPNRoute` | | +| `DeleteClientVPNEndpoint` | | +| `DeleteClientVPNRoute` | | +| `DescribeClientVPNAuthorizationRules` | | +| `DescribeClientVPNEndpoints` | | +| `DescribeClientVPNRoutes` | | +| `DescribeClientVPNTargetNetworks` | | +| `DisassociateClientVPNTargetNetwork` | | +| `RevokeClientVPNIngress` | | + +### DHCPOptionSets + +DHCPOptionSets is an OPTIONAL AWS capability (type-asserted). + +| Operation | Description | +| --- | --- | +| `AssociateDHCPOptions` | | +| `CreateDHCPOptions` | | +| `DeleteDHCPOptions` | | +| `DescribeDHCPOptions` | | + +### EgressOnlyInternetGateways + +EgressOnlyInternetGateways is an OPTIONAL AWS capability (type-asserted). + +| Operation | Description | +| --- | --- | +| `CreateEgressOnlyInternetGateway` | | +| `DeleteEgressOnlyInternetGateway` | | +| `DescribeEgressOnlyInternetGateways` | | + +### IPAM + +IPAM is an OPTIONAL AWS capability (type-asserted on the vpc driver). + +| Operation | Description | +| --- | --- | +| `AllocateIpamPoolCidr` | | +| `CreateIpam` | | +| `CreateIpamPool` | | +| `CreateIpamScope` | | +| `DeleteIpam` | | +| `DeleteIpamPool` | | +| `DeleteIpamScope` | | +| `DeprovisionIpamPoolCidr` | | +| `DescribeIpamPools` | | +| `DescribeIpamScopes` | | +| `DescribeIpams` | | +| `GetIpamPoolAllocations` | | +| `GetIpamPoolCidrs` | | +| `ModifyIpam` | | +| `ModifyIpamPool` | | +| `ModifyIpamPoolAllocation` | | +| `ModifyIpamScope` | | +| `ProvisionIpamPoolCidr` | | +| `ReleaseIpamPoolAllocation` | | + +### IPAMByoasn + +IPAMByoasn is an OPTIONAL AWS capability for bring-your-own ASN. + +| Operation | Description | +| --- | --- | +| `AssociateIpamByoasn` | | +| `DeprovisionIpamByoasn` | | +| `DescribeIpamByoasn` | | +| `DisassociateIpamByoasn` | | +| `ProvisionIpamByoasn` | | + +### IPAMByoip + +IPAMByoip is an OPTIONAL AWS capability for bring-your-own public IP CIDRs + +| Operation | Description | +| --- | --- | +| `AdvertiseByoipCidr` | | +| `DeprovisionByoipCidr` | | +| `DescribeByoipCidrs` | | +| `MoveByoipCidrToIpam` | | +| `ProvisionByoipCidr` | | +| `WithdrawByoipCidr` | | + +### IPAMDiscovery + +IPAMDiscovery is an OPTIONAL AWS capability for IPAM resource discovery. + +| Operation | Description | +| --- | --- | +| `AssociateIpamResourceDiscovery` | | +| `CreateIpamResourceDiscovery` | | +| `DeleteIpamResourceDiscovery` | | +| `DescribeIpamResourceDiscoveries` | | +| `DescribeIpamResourceDiscoveryAssociations` | | +| `DisassociateIpamResourceDiscovery` | | +| `GetIpamDiscoveredAccounts` | | +| `GetIpamDiscoveredPublicAddresses` | | +| `GetIpamDiscoveredResourceCidrs` | | +| `ModifyIpamResourceDiscovery` | | + +### IPAMExternalToken + +IPAMExternalToken is an OPTIONAL AWS capability for external-resource + +| Operation | Description | +| --- | --- | +| `CreateIpamExternalResourceVerificationToken` | | +| `DeleteIpamExternalResourceVerificationToken` | | +| `DescribeIpamExternalResourceVerificationTokens` | | + +### IPAMMetrics + +IPAMMetrics is an OPTIONAL capability that exposes the AWS/IPAM CloudWatch + +| Operation | Description | +| --- | --- | +| `IpamMetrics` | | + +### IPAMPolicy + +IPAMPolicy is an OPTIONAL AWS capability for IPAM policies and the + +| Operation | Description | +| --- | --- | +| `CreateIpamPolicy` | | +| `DeleteIpamPolicy` | | +| `DescribeIpamPolicies` | | +| `DisableIpamOrganizationAdminAccount` | | +| `DisableIpamPolicy` | | +| `EnableIpamOrganizationAdminAccount` | | +| `EnableIpamPolicy` | | +| `GetEnabledIpamPolicy` | | +| `GetIpamPolicyAllocationRules` | | +| `GetIpamPolicyOrganizationTargets` | | +| `ModifyIpamPolicyAllocationRules` | | + +### IPAMPrefixListResolver + +IPAMPrefixListResolver is an OPTIONAL AWS capability for IPAM prefix-list + +| Operation | Description | +| --- | --- | +| `CreateIpamPrefixListResolver` | | +| `CreateIpamPrefixListResolverTarget` | | +| `DeleteIpamPrefixListResolver` | | +| `DeleteIpamPrefixListResolverTarget` | | +| `DescribeIpamPrefixListResolverTargets` | | +| `DescribeIpamPrefixListResolvers` | | +| `GetIpamPrefixListResolverRules` | | +| `GetIpamPrefixListResolverVersionEntries` | | +| `GetIpamPrefixListResolverVersions` | | +| `ModifyIpamPrefixListResolver` | | +| `ModifyIpamPrefixListResolverTarget` | | + +### IPAMResources + +IPAMResources is an OPTIONAL AWS capability exposing IPAM's view of the + +| Operation | Description | +| --- | --- | +| `GetIpamAddressHistory` | | +| `GetIpamResourceCidrs` | | +| `ModifyIpamResourceCidr` | | + +### NetworkInsights + +NetworkInsights is an OPTIONAL AWS capability (type-asserted). It covers both + +| Operation | Description | +| --- | --- | +| `CreateNetworkInsightsAccessScope` | | +| `CreateNetworkInsightsPath` | | +| `DeleteNetworkInsightsAccessScope` | | +| `DeleteNetworkInsightsAccessScopeAnalysis` | | +| `DeleteNetworkInsightsAnalysis` | | +| `DeleteNetworkInsightsPath` | | +| `DescribeNetworkInsightsAccessScopeAnalyses` | | +| `DescribeNetworkInsightsAccessScopes` | | +| `DescribeNetworkInsightsAnalyses` | | +| `DescribeNetworkInsightsPaths` | | +| `GetNetworkInsightsAccessScopeAnalysisFindings` | | +| `GetNetworkInsightsAccessScopeContent` | | +| `StartNetworkInsightsAccessScopeAnalysis` | | +| `StartNetworkInsightsAnalysis` | | + +### NetworkInterfaceCreator + +NetworkInterfaceCreator is the AWS-specific ENI-creation surface. It's kept + +| Operation | Description | +| --- | --- | +| `CreateNetworkInterface` | | + +### NetworkInterfaces + +NetworkInterfaces is an OPTIONAL capability, discovered by type assertion. + +| Operation | Description | +| --- | --- | +| `DeleteNetworkInterface` | | +| `DescribeNetworkInterfaces` | | +| `DetachNetworkInterface` | | + +### PrefixLists + +PrefixLists is an OPTIONAL AWS capability (type-asserted). + +| Operation | Description | +| --- | --- | +| `CreateManagedPrefixList` | | +| `DeleteManagedPrefixList` | | +| `DescribeManagedPrefixLists` | | +| `GetManagedPrefixListEntries` | | +| `ModifyManagedPrefixList` | | + +### TrafficMirroring + +TrafficMirroring is an OPTIONAL AWS capability (type-asserted). + +| Operation | Description | +| --- | --- | +| `CreateTrafficMirrorFilter` | | +| `CreateTrafficMirrorFilterRule` | | +| `CreateTrafficMirrorSession` | | +| `CreateTrafficMirrorTarget` | | +| `DeleteTrafficMirrorFilter` | | +| `DeleteTrafficMirrorFilterRule` | | +| `DeleteTrafficMirrorSession` | | +| `DeleteTrafficMirrorTarget` | | +| `DescribeTrafficMirrorFilterRules` | | +| `DescribeTrafficMirrorFilters` | | +| `DescribeTrafficMirrorSessions` | | +| `DescribeTrafficMirrorTargets` | | +| `ModifyTrafficMirrorFilterNetworkServices` | | +| `ModifyTrafficMirrorFilterRule` | | +| `ModifyTrafficMirrorSession` | | + +### TransitGateways + +TransitGateways is an OPTIONAL AWS capability (type-asserted). + +| Operation | Description | +| --- | --- | +| `AssociateTransitGatewayRouteTable` | | +| `CreateTransitGateway` | | +| `CreateTransitGatewayRoute` | | +| `CreateTransitGatewayRouteTable` | | +| `CreateTransitGatewayVPCAttachment` | | +| `DeleteTransitGateway` | | +| `DeleteTransitGatewayRoute` | | +| `DeleteTransitGatewayRouteTable` | | +| `DeleteTransitGatewayVPCAttachment` | | +| `DescribeTransitGatewayRouteTables` | | +| `DescribeTransitGatewayVPCAttachments` | | +| `DescribeTransitGateways` | | +| `DisableTransitGatewayRouteTablePropagation` | | +| `EnableTransitGatewayRouteTablePropagation` | | +| `SearchTransitGatewayRoutes` | | + +### VPCAttributes + +VPCAttributes is an OPTIONAL capability, discovered by type assertion. + +| Operation | Description | +| --- | --- | +| `ModifyVPCAttribute` | | + +### VPCBlockPublicAccess + +VPCBlockPublicAccess is an OPTIONAL AWS capability (type-asserted). + +| Operation | Description | +| --- | --- | +| `CreateVPCBlockPublicAccessExclusion` | | +| `DeleteVPCBlockPublicAccessExclusion` | | +| `DescribeVPCBlockPublicAccessExclusions` | | +| `DescribeVPCBlockPublicAccessOptions` | | +| `ModifyVPCBlockPublicAccessExclusion` | | +| `ModifyVPCBlockPublicAccessOptions` | | + +### VPCEndpointServices + +VPCEndpointServices is an OPTIONAL AWS capability (type-asserted). + +| Operation | Description | +| --- | --- | +| `CreateVPCEndpointServiceConfiguration` | | +| `DeleteVPCEndpointServiceConfiguration` | | +| `DescribeVPCEndpointServiceConfigurations` | | +| `DescribeVPCEndpointServicePermissions` | | +| `ModifyVPCEndpointServicePermissions` | | + +### VPNConnections + +VPNConnections is an OPTIONAL AWS capability (type-asserted). + +| Operation | Description | +| --- | --- | +| `AttachVPNGateway` | | +| `CreateCustomerGateway` | | +| `CreateVPNConnection` | | +| `CreateVPNConnectionRoute` | | +| `CreateVPNGateway` | | +| `DeleteCustomerGateway` | | +| `DeleteVPNConnection` | | +| `DeleteVPNConnectionRoute` | | +| `DeleteVPNGateway` | | +| `DescribeCustomerGateways` | | +| `DescribeVPNConnections` | | +| `DescribeVPNGateways` | | +| `DetachVPNGateway` | | +| `ModifyVPNConnection` | | + +## Not in scope + +_Not documented yet. See the [emulator boundary](../../../README.md) for cloudemu-wide non-goals._ diff --git a/docs/coverage/oci/README.md b/docs/coverage/oci/README.md index f262b93e9..297185bff 100644 --- a/docs/coverage/oci/README.md +++ b/docs/coverage/oci/README.md @@ -7,4 +7,4 @@ Services cloudemu emulates for OCI, by native name. Back to the [cross-provider | --- | --- | --- | | [Identity](./identity.md) | `iam` | 40 | | [Monitoring](./monitoring.md) | `monitoring` | 12 | -| [VCN](./vcn.md) | `networking` | 3 | +| [VCN](./vcn.md) | `networking` | 57 | diff --git a/docs/coverage/oci/vcn.md b/docs/coverage/oci/vcn.md index e6d24958f..9885fd178 100644 --- a/docs/coverage/oci/vcn.md +++ b/docs/coverage/oci/vcn.md @@ -1,15 +1,69 @@ # VCN -OCI's `networking` service · portable interface `driver.NetworkInterfaces` · [OCI index](./README.md) +OCI's `networking` service · portable interface `driver.Networking` · [OCI index](./README.md) -## Operations (3) +## Operations (57) | Operation | Description | | --- | --- | -| `DeleteNetworkInterface` | | -| `DescribeNetworkInterfaces` | | -| `DetachNetworkInterface` | | +| `AcceptPeeringConnection` | | +| `AddEgressRule` | | +| `AddIngressRule` | | +| `AddNetworkACLRule` | | +| `AllocateAddress` | Elastic IPs | +| `AssociateAddress` | | +| `AssociateRouteTable` | Route Table Associations | +| `AttachInternetGateway` | | +| `CreateFlowLog` | Flow Logs | +| `CreateInternetGateway` | Internet Gateways | +| `CreateNATGateway` | NAT Gateways | +| `CreateNetworkACL` | Network ACLs | +| `CreatePeeringConnection` | VPC Peering | +| `CreateRoute` | | +| `CreateRouteTable` | Route Tables | +| `CreateSecurityGroup` | | +| `CreateSubnet` | | +| `CreateVPC` | | +| `CreateVPCEndpoint` | VPC Endpoints | +| `DeleteFlowLog` | | +| `DeleteInternetGateway` | | +| `DeleteNATGateway` | | +| `DeleteNetworkACL` | | +| `DeletePeeringConnection` | | +| `DeleteRoute` | | +| `DeleteRouteTable` | | +| `DeleteSecurityGroup` | | +| `DeleteSubnet` | | +| `DeleteVPC` | | +| `DeleteVPCEndpoint` | | +| `DescribeAddresses` | | +| `DescribeFlowLogs` | | +| `DescribeInternetGateways` | | +| `DescribeNATGateways` | | +| `DescribeNetworkACLs` | | +| `DescribePeeringConnections` | | +| `DescribeRouteTables` | | +| `DescribeSecurityGroups` | | +| `DescribeSubnets` | | +| `DescribeVPCEndpoints` | | +| `DescribeVPCs` | | +| `DetachInternetGateway` | | +| `DisassociateAddress` | | +| `DisassociateRouteTable` | | +| `GetFlowLogRecords` | | +| `ModifyVPCEndpoint` | | +| `RejectPeeringConnection` | | +| `ReleaseAddress` | | +| `RemoveEgressRule` | | +| `RemoveIngressRule` | | +| `RemoveNetworkACLRule` | | +| `RemoveSecurityGroupTags` | | +| `RemoveSubnetTags` | | +| `RemoveVPCTags` | | +| `UpdateSecurityGroupTags` | | +| `UpdateSubnetTags` | | +| `UpdateVPCTags` | Tag mutation. Update* merges keys into the resource's existing Tags | ## Optional capabilities @@ -216,69 +270,15 @@ NetworkInterfaceCreator is the AWS-specific ENI-creation surface. It's kept | --- | --- | | `CreateNetworkInterface` | | -### Networking +### NetworkInterfaces -Networking is the interface that networking provider +NetworkInterfaces is an OPTIONAL capability, discovered by type assertion. | Operation | Description | | --- | --- | -| `AcceptPeeringConnection` | | -| `AddEgressRule` | | -| `AddIngressRule` | | -| `AddNetworkACLRule` | | -| `AllocateAddress` | Elastic IPs | -| `AssociateAddress` | | -| `AssociateRouteTable` | Route Table Associations | -| `AttachInternetGateway` | | -| `CreateFlowLog` | Flow Logs | -| `CreateInternetGateway` | Internet Gateways | -| `CreateNATGateway` | NAT Gateways | -| `CreateNetworkACL` | Network ACLs | -| `CreatePeeringConnection` | VPC Peering | -| `CreateRoute` | | -| `CreateRouteTable` | Route Tables | -| `CreateSecurityGroup` | | -| `CreateSubnet` | | -| `CreateVPC` | | -| `CreateVPCEndpoint` | VPC Endpoints | -| `DeleteFlowLog` | | -| `DeleteInternetGateway` | | -| `DeleteNATGateway` | | -| `DeleteNetworkACL` | | -| `DeletePeeringConnection` | | -| `DeleteRoute` | | -| `DeleteRouteTable` | | -| `DeleteSecurityGroup` | | -| `DeleteSubnet` | | -| `DeleteVPC` | | -| `DeleteVPCEndpoint` | | -| `DescribeAddresses` | | -| `DescribeFlowLogs` | | -| `DescribeInternetGateways` | | -| `DescribeNATGateways` | | -| `DescribeNetworkACLs` | | -| `DescribePeeringConnections` | | -| `DescribeRouteTables` | | -| `DescribeSecurityGroups` | | -| `DescribeSubnets` | | -| `DescribeVPCEndpoints` | | -| `DescribeVPCs` | | -| `DetachInternetGateway` | | -| `DisassociateAddress` | | -| `DisassociateRouteTable` | | -| `GetFlowLogRecords` | | -| `ModifyVPCEndpoint` | | -| `RejectPeeringConnection` | | -| `ReleaseAddress` | | -| `RemoveEgressRule` | | -| `RemoveIngressRule` | | -| `RemoveNetworkACLRule` | | -| `RemoveSecurityGroupTags` | | -| `RemoveSubnetTags` | | -| `RemoveVPCTags` | | -| `UpdateSecurityGroupTags` | | -| `UpdateSubnetTags` | | -| `UpdateVPCTags` | Tag mutation. Update* merges keys into the resource's existing Tags | +| `DeleteNetworkInterface` | | +| `DescribeNetworkInterfaces` | | +| `DetachNetworkInterface` | | ### PrefixLists diff --git a/internal/coveragegen/coveragegen_test.go b/internal/coveragegen/coveragegen_test.go index a404db9c5..9088e9ad8 100644 --- a/internal/coveragegen/coveragegen_test.go +++ b/internal/coveragegen/coveragegen_test.go @@ -41,6 +41,9 @@ func TestProviderNativeNamesResolve(t *testing.T) { // monitoring must resolve to the real metrics service, not a consumer // like EKS that merely imports monitoring/driver. "monitoring": {"aws": "CloudWatch"}, + // networking resolves to driver.Networking (implemented by all three), + // not the AWS-only NetworkInterfaces optional capability (#383). + "networking": {"aws": "VPC", "azure": "VNet", "gcp": "VPC"}, } for svcName, want := range cases { @@ -57,6 +60,31 @@ func TestProviderNativeNamesResolve(t *testing.T) { } } +// TestPrimaryInterfaceNotOptionalCapability guards against #383: a service whose +// primary driver interface coexists with a smaller optional capability must +// report the primary. networking is the canonical case — driver.Networking +// (~50+ ops) alongside the 3-method driver.NetworkInterfaces capability. +func TestPrimaryInterfaceNotOptionalCapability(t *testing.T) { + services := loadServices(t) + + svc, ok := services["networking"] + if !ok { + t.Fatal("service \"networking\" not found") + } + + if svc.Interface != "Networking" { + t.Errorf("networking primary interface = %q, want %q", svc.Interface, "Networking") + } + + // The optional NetworkInterfaces capability has 3 methods; the primary + // carries the full surface, so a correct resolution has many more. + const minPrimaryOps = 10 + if len(svc.Operations) < minPrimaryOps { + t.Errorf("networking has %d operations, want >= %d — likely resolved to an optional capability", + len(svc.Operations), minPrimaryOps) + } +} + // TestEmbeddedInterfacesFlatten guards the facade services whose primary // interface embeds sub-interfaces (SageMaker, AzureAI): a broken flattener // reports zero operations. diff --git a/internal/coveragegen/services.go b/internal/coveragegen/services.go index a5a727847..a1c75376e 100644 --- a/internal/coveragegen/services.go +++ b/internal/coveragegen/services.go @@ -223,8 +223,11 @@ func lenMethods(ifaces []ifaceDecl, name string) int { return 0 } -// referencedInterface scans the portable package for `.` selectors -// whose Name is one of the driver interfaces, returning the most-referenced one. +// referencedInterface returns the driver interface the portable package holds as +// its backend — the one used as a struct-field or parameter type (e.g. +// `driver driver.Networking`), the most-referenced such type. Only type +// positions count, so an optional capability a wrapper probes for via a type +// assertion (`x.(driver.NetworkInterfaces)`) is never mistaken for the primary. func referencedInterface(portableDir string, ifaces []ifaceDecl) string { fset := token.NewFileSet() @@ -241,7 +244,7 @@ func referencedInterface(portableDir string, ifaces []ifaceDecl) string { for _, pkg := range pkgs { for _, file := range pkg.Files { - countSelectors(file, known) + countFieldTypes(file, known) } } @@ -255,21 +258,42 @@ func referencedInterface(portableDir string, ifaces []ifaceDecl) string { return best } -func countSelectors(file *ast.File, known map[string]int) { +// countFieldTypes counts, per known interface, how often it is the type of a +// struct field, function parameter, or result (every ast.Field carries a type), +// i.e. where the package stores or consumes the interface — never a type +// assertion, which is an expression, not a field. +func countFieldTypes(file *ast.File, known map[string]int) { ast.Inspect(file, func(n ast.Node) bool { - sel, ok := n.(*ast.SelectorExpr) + field, ok := n.(*ast.Field) if !ok { return true } - if _, tracked := known[sel.Sel.Name]; tracked { - known[sel.Sel.Name]++ + if name := selectorType(field.Type); name != "" { + if _, tracked := known[name]; tracked { + known[name]++ + } } return true }) } +// selectorType returns the selector name for a `pkg.Name` type expression, +// unwrapping a leading pointer, else "". +func selectorType(expr ast.Expr) string { + if star, ok := expr.(*ast.StarExpr); ok { + expr = star.X + } + + sel, ok := expr.(*ast.SelectorExpr) + if !ok { + return "" + } + + return sel.Sel.Name +} + func notTest(fi os.FileInfo) bool { return !strings.HasSuffix(fi.Name(), "_test.go") } func declDoc(gd *ast.GenDecl, ts *ast.TypeSpec) string { diff --git a/internal/k8spki/pki.go b/internal/k8spki/pki.go index c3b241a90..2c1d34615 100644 --- a/internal/k8spki/pki.go +++ b/internal/k8spki/pki.go @@ -29,6 +29,13 @@ const ( rsaKeyBits = 2048 caValidYears = 10 serialBits = 128 + // leafValidDays caps the serving (leaf) certificate's validity. Go 1.23+ + // crypto/x509 (and the clients built on it, e.g. `oc`) reject an end-entity + // certificate whose validity exceeds 825 days as non-standards-compliant — + // even under InsecureSkipVerify. 397 days stays under both that limit and + // the CA/Browser Forum's 398-day maximum, so the emulator's serving cert is + // accepted by strict TLS clients. (The CA itself may remain long-lived.) + leafValidDays = 397 ) // loadCA generates the CA once and reuses it. The private key is retained (not @@ -121,7 +128,7 @@ func ServingTLSConfig(hosts []string) (*tls.Config, error) { SerialNumber: serial, Subject: pkix.Name{CommonName: "cloudemu-k8s"}, NotBefore: time.Now().Add(-time.Hour), - NotAfter: time.Now().AddDate(caValidYears, 0, 0), + NotAfter: time.Now().AddDate(0, 0, leafValidDays), KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, // Assert cA=FALSE explicitly so end-entity-strict verifiers accept the leaf. diff --git a/providers/azure/aro/aro.go b/providers/azure/aro/aro.go new file mode 100644 index 000000000..8f1ec95d3 --- /dev/null +++ b/providers/azure/aro/aro.go @@ -0,0 +1,282 @@ +// Package aro emulates Azure Red Hat OpenShift (ARO) — +// Microsoft.RedHatOpenShift/openShiftClusters. It mirrors the AKS mock's shape: +// each cluster registers an OpenShift-flavored ClusterState with a shared +// kubernetes.APIServer (when wired), so the kubeconfig it hands back points at a +// real in-memory OpenShift data plane that `oc` and client-go drive end-to-end. +// Without a wired data plane, the kubeconfig points at a NOT-IMPLEMENTED +// sentinel, matching the AKS Wave-1 fallback. +package aro + +import ( + "context" + "fmt" + "sync" + "time" + + "github.com/stackshy/cloudemu/v2/config" + cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/internal/k8spki" + "github.com/stackshy/cloudemu/v2/internal/memstore" + "github.com/stackshy/cloudemu/v2/services/kubernetes" +) + +const ( + // ARMProvider / ResourceType are the ARM namespace and resource the server + // handler matches to route requests here. + ARMProvider = "Microsoft.RedHatOpenShift" + ResourceType = "openShiftClusters" + + // defaultOCPVersion is the emulated OpenShift release (kube 1.29), matching + // the data plane's identity singletons. + defaultOCPVersion = "4.16.0" + + // aroAppsDomain is the synthetic ingress domain ARO console/API URLs hang off. + aroAppsDomain = "aroapp.io" +) + +// OpenShiftCluster is a stored ARO cluster. +type OpenShiftCluster struct { + ID string + Name string + ResourceGroup string + Subscription string + Location string + ProvisioningState string + Version string + ConsoleURL string + APIServerURL string + Tags map[string]string + CreatedAt time.Time +} + +// ClusterInput is the create/update payload the server handler decodes from ARM. +type ClusterInput struct { + Subscription string + ResourceGroup string + Name string + Location string + Version string + Tags map[string]string +} + +// Mock is the ARO control-plane emulator. +type Mock struct { + mu sync.RWMutex + + // clusters key = "{subscription}/{rg}/{name}". + clusters *memstore.Store[OpenShiftCluster] + + opts *config.Options + + // k8sAPI is the shared in-memory Kubernetes data-plane server. When set, + // CreateOrUpdateCluster registers an OpenShift-flavored ClusterState and + // Kubeconfig points at it; when nil, the sentinel fallback is used. + k8sAPI *kubernetes.APIServer + // k8sUIDs maps the cluster key → the data-plane UID registered for it. + k8sUIDs map[string]string +} + +// New creates an ARO mock. +func New(opts *config.Options) *Mock { + return &Mock{ + clusters: memstore.New[OpenShiftCluster](), + opts: opts, + k8sUIDs: make(map[string]string), + } +} + +// SetK8sAPI wires the shared Kubernetes data-plane server. Clusters created +// after this call back their kubeconfig with a real OpenShift data plane. +func (m *Mock) SetK8sAPI(api *kubernetes.APIServer) { + m.mu.Lock() + m.k8sAPI = api + m.mu.Unlock() +} + +func clusterKey(subscription, rg, name string) string { + return subscription + "/" + rg + "/" + name +} + +// ClusterResourceID returns the ARM resource ID for an ARO cluster. +func ClusterResourceID(subscription, rg, name string) string { + return fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/%s/%s/%s", + subscription, rg, ARMProvider, ResourceType, name) +} + +func copyTags(src map[string]string) map[string]string { + if src == nil { + return nil + } + + out := make(map[string]string, len(src)) + for k, v := range src { + out[k] = v + } + + return out +} + +func (m *Mock) now() time.Time { + if m.opts != nil && m.opts.Clock != nil { + return m.opts.Clock.Now().UTC() + } + + return time.Now().UTC() +} + +// CreateOrUpdateCluster provisions (or updates) an ARO cluster. On first +// creation it registers an OpenShift-flavored data-plane ClusterState so the +// cluster's kubeconfig is immediately usable; the cluster comes up Succeeded. +// +//nolint:gocritic // hugeParam: input mirrors the ARM request body; value is intentional. +func (m *Mock) CreateOrUpdateCluster(_ context.Context, input ClusterInput) (*OpenShiftCluster, error) { + if input.Name == "" { + return nil, cerrors.Newf(cerrors.InvalidArgument, "aro: cluster name is required") + } + + m.mu.Lock() + defer m.mu.Unlock() + + key := clusterKey(input.Subscription, input.ResourceGroup, input.Name) + + version := input.Version + if version == "" { + version = defaultOCPVersion + } + + // Register a data plane on first sighting only (CreateOrUpdate is idempotent + // on the same name). + if m.k8sAPI != nil { + if _, ok := m.k8sUIDs[key]; !ok { + uid, _ := m.k8sAPI.RegisterClusterWithFlavor(kubernetes.FlavorOpenShift) + m.k8sUIDs[key] = uid + } + } + + existing, found := m.clusters.Get(key) + created := m.now() + + if found { + created = existing.CreatedAt + } + + cluster := OpenShiftCluster{ + ID: ClusterResourceID(input.Subscription, input.ResourceGroup, input.Name), + Name: input.Name, + ResourceGroup: input.ResourceGroup, + Subscription: input.Subscription, + Location: input.Location, + ProvisioningState: "Succeeded", + Version: version, + ConsoleURL: fmt.Sprintf("https://console-openshift-console.apps.%s.%s/", input.Name, aroAppsDomain), + APIServerURL: fmt.Sprintf("https://api.%s.%s:6443/", input.Name, aroAppsDomain), + Tags: copyTags(input.Tags), + CreatedAt: created, + } + + m.clusters.Set(key, cluster) + + out := cluster + + return &out, nil +} + +// GetCluster returns a cluster by resource group and name. +func (m *Mock) GetCluster(_ context.Context, subscription, rg, name string) (*OpenShiftCluster, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + cluster, ok := m.clusters.Get(clusterKey(subscription, rg, name)) + if !ok { + return nil, cerrors.Newf(cerrors.NotFound, "aro: cluster not found: %s", name) + } + + out := cluster + + return &out, nil +} + +// ListClustersByResourceGroup lists clusters in a resource group. +func (m *Mock) ListClustersByResourceGroup(_ context.Context, subscription, rg string) []OpenShiftCluster { + m.mu.RLock() + defer m.mu.RUnlock() + + var out []OpenShiftCluster + + for _, c := range m.clusters.SortedValues() { //nolint:gocritic // rangeValCopy: read-only filter, copy is fine. + if c.Subscription == subscription && c.ResourceGroup == rg { + out = append(out, c) + } + } + + return out +} + +// ListClusters lists all clusters in a subscription. +func (m *Mock) ListClusters(_ context.Context, subscription string) []OpenShiftCluster { + m.mu.RLock() + defer m.mu.RUnlock() + + var out []OpenShiftCluster + + for _, c := range m.clusters.SortedValues() { //nolint:gocritic // rangeValCopy: read-only filter, copy is fine. + if c.Subscription == subscription { + out = append(out, c) + } + } + + return out +} + +// DeleteCluster removes a cluster and deregisters its data plane. +func (m *Mock) DeleteCluster(_ context.Context, subscription, rg, name string) error { + m.mu.Lock() + defer m.mu.Unlock() + + key := clusterKey(subscription, rg, name) + + if !m.clusters.Has(key) { + return cerrors.Newf(cerrors.NotFound, "aro: cluster not found: %s", name) + } + + if uid, ok := m.k8sUIDs[key]; ok && m.k8sAPI != nil { + m.k8sAPI.DeregisterCluster(uid) + delete(m.k8sUIDs, key) + } + + m.clusters.Delete(key) + + return nil +} + +// Kubeconfig returns the admin kubeconfig for a cluster (the ARM +// listAdminCredentials response). When a data plane is wired it points at the +// per-cluster OpenShift API under /k8s/; otherwise a NOT-IMPLEMENTED +// sentinel, mirroring AKS. +func (m *Mock) Kubeconfig(subscription, rg, name string) []byte { + m.mu.RLock() + defer m.mu.RUnlock() + + if m.k8sAPI != nil { + if uid, ok := m.k8sUIDs[clusterKey(subscription, rg, name)]; ok { + if base := m.k8sAPI.BaseURL(); base != "" { + return kubernetes.RenderKubeconfig(base, uid, name) + } + } + } + + return fmt.Appendf(nil, `apiVersion: v1 +kind: Config +clusters: +- name: %s + cluster: + server: https://ARO-DATAPLANE-NOT-IMPLEMENTED.cloudemu.local + certificate-authority-data: %s +contexts: +- name: %s + context: + cluster: %s + user: %s +current-context: %s +`, name, k8spki.CertificatePEM(), name, name, name, name) +} diff --git a/providers/azure/aro/aro_test.go b/providers/azure/aro/aro_test.go new file mode 100644 index 000000000..35488b083 --- /dev/null +++ b/providers/azure/aro/aro_test.go @@ -0,0 +1,141 @@ +package aro_test + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stackshy/cloudemu/v2/config" + "github.com/stackshy/cloudemu/v2/providers/azure/aro" + "github.com/stackshy/cloudemu/v2/services/kubernetes" +) + +const ( + testSub = "sub-1" + testRG = "rg-1" +) + +// serverURLFromKubeconfig extracts the cluster server URL from a rendered +// kubeconfig. +func serverURLFromKubeconfig(t *testing.T, kubeconfig []byte) string { + t.Helper() + + for _, line := range strings.Split(string(kubeconfig), "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "server:") { + return strings.TrimSpace(strings.TrimPrefix(line, "server:")) + } + } + + t.Fatalf("no server URL in kubeconfig:\n%s", kubeconfig) + + return "" +} + +// TestARO_CreateGetDelete_WithDataPlane is the end-to-end provisioning story: +// creating an ARO cluster backs it with a real OpenShift data plane whose +// kubeconfig reaches the OpenShift API; deleting it tears the data plane down. +func TestARO_CreateGetDelete_WithDataPlane(t *testing.T) { + api := kubernetes.NewAPIServer() + ts := httptest.NewServer(api) + t.Cleanup(ts.Close) + + api.SetBaseURL(ts.URL) + + m := aro.New(config.NewOptions()) + m.SetK8sAPI(api) + + ctx := context.Background() + + cluster, err := m.CreateOrUpdateCluster(ctx, aro.ClusterInput{ + Subscription: testSub, ResourceGroup: testRG, Name: "ocp1", Location: "eastus", + }) + if err != nil { + t.Fatalf("CreateOrUpdateCluster: %v", err) + } + + if cluster.ProvisioningState != "Succeeded" { + t.Errorf("provisioningState: got %q, want Succeeded", cluster.ProvisioningState) + } + + if cluster.Version != "4.16.0" { + t.Errorf("version: got %q, want 4.16.0", cluster.Version) + } + + // The kubeconfig must reach a real OpenShift-flavored data plane: hitting the + // ClusterVersion singleton through it proves the cluster was registered with + // FlavorOpenShift (a plain Kubernetes cluster would 404 that path). + server := serverURLFromKubeconfig(t, m.Kubeconfig(testSub, testRG, "ocp1")) + if !strings.Contains(server, "/k8s/") { + t.Fatalf("kubeconfig server %q does not point at the data plane", server) + } + + resp, err := http.Get(server + "/apis/config.openshift.io/v1/clusterversions/version") //nolint:noctx // test. + if err != nil { + t.Fatalf("GET clusterversion via kubeconfig: %v", err) + } + + resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("clusterversion via ARO kubeconfig: status %d, want 200 (OpenShift data plane not provisioned)", + resp.StatusCode) + } + + // Get returns the stored cluster. + got, err := m.GetCluster(ctx, testSub, testRG, "ocp1") + if err != nil { + t.Fatalf("GetCluster: %v", err) + } + + if got.ID == "" || !strings.Contains(got.ID, "Microsoft.RedHatOpenShift") { + t.Errorf("cluster ID malformed: %q", got.ID) + } + + // Delete tears down the data plane. + if err := m.DeleteCluster(ctx, testSub, testRG, "ocp1"); err != nil { + t.Fatalf("DeleteCluster: %v", err) + } + + if _, err := m.GetCluster(ctx, testSub, testRG, "ocp1"); err == nil { + t.Error("GetCluster after delete: want error, got nil") + } + + after, err := http.Get(server + "/apis/config.openshift.io/v1/clusterversions/version") //nolint:noctx // test. + if err != nil { + t.Fatalf("GET after delete: %v", err) + } + + after.Body.Close() + + if after.StatusCode != http.StatusNotFound { + t.Errorf("data plane after delete: status %d, want 404 (deregistered)", after.StatusCode) + } +} + +// TestARO_ListAndFallbackKubeconfig covers listing and the no-data-plane +// kubeconfig fallback. +func TestARO_ListAndFallbackKubeconfig(t *testing.T) { + m := aro.New(config.NewOptions()) // no k8sAPI wired + ctx := context.Background() + + for _, name := range []string{"a", "b"} { + if _, err := m.CreateOrUpdateCluster(ctx, aro.ClusterInput{ + Subscription: testSub, ResourceGroup: testRG, Name: name, Location: "eastus", + }); err != nil { + t.Fatalf("create %s: %v", name, err) + } + } + + if got := m.ListClustersByResourceGroup(ctx, testSub, testRG); len(got) != 2 { + t.Fatalf("list by rg: got %d, want 2", len(got)) + } + + // No data plane wired -> sentinel kubeconfig, still structurally valid. + kc := string(m.Kubeconfig(testSub, testRG, "a")) + if !strings.Contains(kc, "ARO-DATAPLANE-NOT-IMPLEMENTED") { + t.Errorf("fallback kubeconfig missing sentinel:\n%s", kc) + } +} diff --git a/providers/azure/azure.go b/providers/azure/azure.go index a011511ec..498ecb5b2 100644 --- a/providers/azure/azure.go +++ b/providers/azure/azure.go @@ -9,6 +9,7 @@ import ( "github.com/stackshy/cloudemu/v2/providers/azure/acr" "github.com/stackshy/cloudemu/v2/providers/azure/ai" "github.com/stackshy/cloudemu/v2/providers/azure/aks" + "github.com/stackshy/cloudemu/v2/providers/azure/aro" "github.com/stackshy/cloudemu/v2/providers/azure/blobstorage" "github.com/stackshy/cloudemu/v2/providers/azure/cache" "github.com/stackshy/cloudemu/v2/providers/azure/cosmosdb" @@ -143,6 +144,7 @@ type Provider struct { PostgresFlex *postgresflex.Mock MySQLFlex *mysqlflex.Mock AKS *aks.Mock + ARO *aro.Mock Databricks *databricks.Mock AI *ai.Mock Search *search.Mock @@ -185,6 +187,7 @@ func New(opts ...config.Option) *Provider { PostgresFlex: postgresflex.New(o), MySQLFlex: mysqlflex.New(o), AKS: aks.New(o), + ARO: aro.New(o), Databricks: databricks.New(o), AI: ai.New(o), Search: search.New(o), diff --git a/providers/openshift/ocm/ocm.go b/providers/openshift/ocm/ocm.go new file mode 100644 index 000000000..bf54ff2ec --- /dev/null +++ b/providers/openshift/ocm/ocm.go @@ -0,0 +1,213 @@ +// Package ocm emulates the Red Hat OpenShift Cluster Manager (OCM) cluster +// management surface that the `rosa` CLI drives — api.openshift.com's +// /api/clusters_mgmt/v1/clusters. Each created cluster registers an +// OpenShift-flavored ClusterState with a shared kubernetes.APIServer (when +// wired), so the api.url OCM reports points at a real in-memory OpenShift data +// plane that `oc` operates against. +// +// ROSA on a real cluster is a heavy, asynchronous provision (AWS roles, OIDC, +// installer); the emulator converges instantly — a created cluster is `ready` +// immediately — because its value is exercising the OCM API contract and the +// resulting data plane, not simulating install time. +package ocm + +import ( + "context" + "sync" + "time" + + "github.com/stackshy/cloudemu/v2/config" + cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/internal/idgen" + "github.com/stackshy/cloudemu/v2/internal/memstore" + "github.com/stackshy/cloudemu/v2/services/kubernetes" +) + +// defaultOCPVersion is the emulated OpenShift release (kube 1.29), matching the +// data plane's identity singletons. +const defaultOCPVersion = "4.16.0" + +// Cluster is a stored OCM/ROSA cluster. +type Cluster struct { + ID string + Name string + State string + CloudProvider string + Region string + Version string + APIURL string + ConsoleURL string + Product string + CreatedAt time.Time +} + +// ClusterInput is the create payload decoded from the OCM cluster body. +type ClusterInput struct { + Name string + CloudProvider string + Region string + Version string + Product string +} + +// Mock is the OCM cluster-management emulator. +type Mock struct { + mu sync.RWMutex + + clusters *memstore.Store[Cluster] + + opts *config.Options + + k8sAPI *kubernetes.APIServer + k8sUIDs map[string]string // cluster ID -> data-plane UID +} + +// New creates an OCM mock. +func New(opts *config.Options) *Mock { + return &Mock{ + clusters: memstore.New[Cluster](), + opts: opts, + k8sUIDs: make(map[string]string), + } +} + +// SetK8sAPI wires the shared Kubernetes data-plane server. +func (m *Mock) SetK8sAPI(api *kubernetes.APIServer) { + m.mu.Lock() + m.k8sAPI = api + m.mu.Unlock() +} + +func (m *Mock) now() time.Time { + if m.opts != nil && m.opts.Clock != nil { + return m.opts.Clock.Now().UTC() + } + + return time.Now().UTC() +} + +// CreateCluster provisions an OCM cluster. It registers an OpenShift-flavored +// data plane and reports the cluster `ready` with an api.url pointing at it. +// +//nolint:gocritic // hugeParam: input mirrors the OCM request body; value is intentional. +func (m *Mock) CreateCluster(_ context.Context, input ClusterInput) (*Cluster, error) { + if input.Name == "" { + return nil, cerrors.Newf(cerrors.InvalidArgument, "ocm: cluster name is required") + } + + m.mu.Lock() + defer m.mu.Unlock() + + id := idgen.GenerateID("") + + version := input.Version + if version == "" { + version = defaultOCPVersion + } + + product := input.Product + if product == "" { + product = "rosa" + } + + cloud := input.CloudProvider + if cloud == "" { + cloud = "aws" + } + + apiURL := "https://api." + input.Name + ".cloudemu.openshiftapps.com:6443" + + if m.k8sAPI != nil { + uid, _ := m.k8sAPI.RegisterClusterWithFlavor(kubernetes.FlavorOpenShift) + m.k8sUIDs[id] = uid + + if base := m.k8sAPI.BaseURL(); base != "" { + apiURL = base + "/k8s/" + uid + } + } + + cluster := Cluster{ + ID: id, + Name: input.Name, + State: "ready", + CloudProvider: cloud, + Region: input.Region, + Version: version, + APIURL: apiURL, + ConsoleURL: "https://console-openshift-console.apps." + input.Name + ".cloudemu.openshiftapps.com", + Product: product, + CreatedAt: m.now(), + } + + m.clusters.Set(id, cluster) + + out := cluster + + return &out, nil +} + +// GetCluster returns a cluster by ID. +func (m *Mock) GetCluster(_ context.Context, id string) (*Cluster, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + cluster, ok := m.clusters.Get(id) + if !ok { + return nil, cerrors.Newf(cerrors.NotFound, "ocm: cluster not found: %s", id) + } + + out := cluster + + return &out, nil +} + +// ListClusters returns all clusters sorted by ID. +func (m *Mock) ListClusters(_ context.Context) []Cluster { + m.mu.RLock() + defer m.mu.RUnlock() + + return m.clusters.SortedValues() +} + +// DeleteCluster tears down a cluster and its data plane. The cluster transitions +// to `uninstalling` and is removed; OCM returns 204. +func (m *Mock) DeleteCluster(_ context.Context, id string) error { + m.mu.Lock() + defer m.mu.Unlock() + + if !m.clusters.Has(id) { + return cerrors.Newf(cerrors.NotFound, "ocm: cluster not found: %s", id) + } + + if uid, ok := m.k8sUIDs[id]; ok && m.k8sAPI != nil { + m.k8sAPI.DeregisterCluster(uid) + delete(m.k8sUIDs, id) + } + + m.clusters.Delete(id) + + return nil +} + +// Kubeconfig returns the admin kubeconfig for a cluster (the OCM +// clusters/{id}/credentials response), pointing at the OpenShift data plane when +// wired. +func (m *Mock) Kubeconfig(id string) ([]byte, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + cluster, ok := m.clusters.Get(id) + if !ok { + return nil, cerrors.Newf(cerrors.NotFound, "ocm: cluster not found: %s", id) + } + + if m.k8sAPI != nil { + if uid, wired := m.k8sUIDs[id]; wired { + if base := m.k8sAPI.BaseURL(); base != "" { + return kubernetes.RenderKubeconfig(base, uid, cluster.Name), nil + } + } + } + + return nil, cerrors.Newf(cerrors.FailedPrecondition, "ocm: no data plane wired for cluster %s", id) +} diff --git a/providers/openshift/ocm/ocm_test.go b/providers/openshift/ocm/ocm_test.go new file mode 100644 index 000000000..144d752ec --- /dev/null +++ b/providers/openshift/ocm/ocm_test.go @@ -0,0 +1,65 @@ +package ocm_test + +import ( + "context" + "strings" + "testing" + + "github.com/stackshy/cloudemu/v2/config" + cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/providers/openshift/ocm" +) + +func TestOCM_CreateGetListDelete(t *testing.T) { + m := ocm.New(config.NewOptions()) + ctx := context.Background() + + c, err := m.CreateCluster(ctx, ocm.ClusterInput{Name: "c1", Region: "us-east-1"}) + if err != nil { + t.Fatalf("CreateCluster: %v", err) + } + + if c.State != "ready" || c.Product != "rosa" || c.Version != "4.16.0" || c.CloudProvider != "aws" { + t.Errorf("defaults wrong: %+v", c) + } + + got, err := m.GetCluster(ctx, c.ID) + if err != nil || got.Name != "c1" { + t.Fatalf("GetCluster: %v %+v", err, got) + } + + if list := m.ListClusters(ctx); len(list) != 1 { + t.Fatalf("ListClusters: got %d, want 1", len(list)) + } + + if err := m.DeleteCluster(ctx, c.ID); err != nil { + t.Fatalf("DeleteCluster: %v", err) + } + + if _, err := m.GetCluster(ctx, c.ID); !cerrors.IsNotFound(err) { + t.Errorf("GetCluster after delete: want NotFound, got %v", err) + } +} + +func TestOCM_ErrorPaths(t *testing.T) { + m := ocm.New(config.NewOptions()) + ctx := context.Background() + + if _, err := m.CreateCluster(ctx, ocm.ClusterInput{}); !cerrors.IsInvalidArgument(err) { + t.Errorf("empty name: want InvalidArgument, got %v", err) + } + + if _, err := m.GetCluster(ctx, "nope"); !cerrors.IsNotFound(err) { + t.Errorf("get missing: want NotFound, got %v", err) + } + + if err := m.DeleteCluster(ctx, "nope"); !cerrors.IsNotFound(err) { + t.Errorf("delete missing: want NotFound, got %v", err) + } + + // No data plane wired -> Kubeconfig fails precondition (no api server). + c, _ := m.CreateCluster(ctx, ocm.ClusterInput{Name: "c1"}) + if _, err := m.Kubeconfig(c.ID); err == nil || !strings.Contains(err.Error(), "no data plane") { + t.Errorf("Kubeconfig without data plane: want failure, got %v", err) + } +} diff --git a/server/aws/aws.go b/server/aws/aws.go index e59be55cf..fbabfa047 100644 --- a/server/aws/aws.go +++ b/server/aws/aws.go @@ -204,6 +204,11 @@ type Drivers struct { // kubeconfig issued by any provider's control plane (EKS/AKS/GKE) reaches // the same backend. Leave nil to disable Kubernetes data-plane support. K8sAPI *kubernetes.APIServer + // OCM is the Red Hat OpenShift Cluster Manager (ROSA) REST handler. Its + // paths (/api/clusters_mgmt/, /auth/realms/) are disjoint from every AWS SDK + // path, so it registers on the AWS server the same way K8sAPI does. Leave + // nil to disable ROSA/OCM support. + OCM server.Handler // ResourceDiscovery is the cross-service inventory engine. Required to // serve Resource Explorer 2 and Resource Groups Tagging API requests. // Leave nil to omit both handlers. AccountID and Region are needed for @@ -299,6 +304,14 @@ func NewFromProvider(p *awsprovider.Provider) *server.Server { func New(d Drivers) *server.Server { srv := server.New() + // ROSA/OCM (Red Hat OpenShift Cluster Manager) is registered FIRST so its + // specific path match (/api/clusters_mgmt/, /auth/realms/…/token) wins over + // the AWS Query handlers — several of which claim any form-encoded POST and + // would otherwise answer the OCM SSO token request with InvalidAction. + if d.OCM != nil { + srv.Register(d.OCM) + } + if d.CloudWatch != nil { // The VPC driver optionally supplies derived AWS/IPAM metrics; surface // them through CloudWatch when it implements the capability. @@ -565,8 +578,6 @@ func New(d Drivers) *server.Server { srv.Register(sagemakersrv.New(d.SageMaker)) } - // Kubernetes data-plane API. Matches /k8s/{uid}/... — disjoint from - // every other AWS path. Registered before S3's REST fallback. if d.K8sAPI != nil { srv.Register(d.K8sAPI) } diff --git a/server/azure/aro/handler.go b/server/azure/aro/handler.go new file mode 100644 index 000000000..3627ce3db --- /dev/null +++ b/server/azure/aro/handler.go @@ -0,0 +1,126 @@ +// Package aro implements the Azure Red Hat OpenShift +// (Microsoft.RedHatOpenShift/openShiftClusters) ARM REST API as a +// server.Handler. Real armredhatopenshift clients configured with a custom +// endpoint hit this handler the same way they hit management.azure.com. +// +// Coverage (control plane): +// +// PUT .../providers/Microsoft.RedHatOpenShift/openShiftClusters/{name} — Create or update cluster +// GET .../providers/Microsoft.RedHatOpenShift/openShiftClusters/{name} — Get cluster +// DELETE .../providers/Microsoft.RedHatOpenShift/openShiftClusters/{name} — Delete cluster +// GET .../providers/Microsoft.RedHatOpenShift/openShiftClusters — List in resource group +// GET /subscriptions/{s}/providers/Microsoft.RedHatOpenShift/openShiftClusters — List in subscription +// POST .../openShiftClusters/{name}/listAdminCredentials — Admin kubeconfig +// POST .../openShiftClusters/{name}/listCredentials — kubeadmin username/password +// +// When a shared kubernetes.APIServer is wired into the ARO provider, the +// kubeconfig returned by listAdminCredentials points at a real, OpenShift- +// flavored in-memory data plane, so `oc` operates end-to-end. +package aro + +import ( + "context" + "net/http" + "strings" + + "github.com/stackshy/cloudemu/v2/providers/azure/aro" + "github.com/stackshy/cloudemu/v2/server/wire/azurearm" +) + +const providerName = "Microsoft.RedHatOpenShift" + +// Backend is the ARO surface the handler needs. *aro.Mock satisfies it. +type Backend interface { + CreateOrUpdateCluster(ctx context.Context, input aro.ClusterInput) (*aro.OpenShiftCluster, error) + GetCluster(ctx context.Context, subscription, rg, name string) (*aro.OpenShiftCluster, error) + ListClustersByResourceGroup(ctx context.Context, subscription, rg string) []aro.OpenShiftCluster + ListClusters(ctx context.Context, subscription string) []aro.OpenShiftCluster + DeleteCluster(ctx context.Context, subscription, rg, name string) error + Kubeconfig(subscription, rg, name string) []byte +} + +// Handler serves Microsoft.RedHatOpenShift ARM requests against an ARO Backend. +type Handler struct { + be Backend +} + +// New returns an ARO handler backed by be. +func New(be Backend) *Handler { + return &Handler{be: be} +} + +// Matches returns true for ARM Microsoft.RedHatOpenShift openShiftClusters paths. +func (*Handler) Matches(r *http.Request) bool { + rp, ok := azurearm.ParsePath(r.URL.Path) + if !ok { + return false + } + + return strings.EqualFold(rp.Provider, providerName) && + strings.EqualFold(rp.ResourceType, resourceTypeOpenShiftClusters) +} + +// ServeHTTP routes the request by path shape and method. +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + rp, ok := azurearm.ParsePath(r.URL.Path) + if !ok { + azurearm.WriteError(w, http.StatusBadRequest, "InvalidPath", "malformed ARM path") + + return + } + + if rp.ResourceName == "" { + if r.Method != http.MethodGet { + writeMethodNotAllowed(w) + + return + } + + h.listClusters(w, r, &rp) + + return + } + + switch { + case strings.EqualFold(rp.SubResource, "listAdminCredentials"): + h.postOnly(w, r, &rp, h.listAdminCredentials) + case strings.EqualFold(rp.SubResource, "listCredentials"): + h.postOnly(w, r, &rp, h.listCredentials) + case rp.SubResource == "": + h.serveCluster(w, r, &rp) + default: + azurearm.WriteError(w, http.StatusNotImplemented, "NotImplemented", + "ARO sub-resource not implemented: "+rp.SubResource) + } +} + +func (h *Handler) serveCluster(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { + switch r.Method { + case http.MethodPut: + h.createOrUpdateCluster(w, r, rp) + case http.MethodGet: + h.getCluster(w, r, rp) + case http.MethodDelete: + h.deleteCluster(w, r, rp) + default: + writeMethodNotAllowed(w) + } +} + +// postOnly enforces POST for the credential-listing sub-resources. +func (*Handler) postOnly( + w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath, + fn func(http.ResponseWriter, *http.Request, *azurearm.ResourcePath), +) { + if r.Method != http.MethodPost { + writeMethodNotAllowed(w) + + return + } + + fn(w, r, rp) +} + +func writeMethodNotAllowed(w http.ResponseWriter) { + azurearm.WriteError(w, http.StatusMethodNotAllowed, "MethodNotAllowed", "method not allowed") +} diff --git a/server/azure/aro/handler_test.go b/server/azure/aro/handler_test.go new file mode 100644 index 000000000..da13df59d --- /dev/null +++ b/server/azure/aro/handler_test.go @@ -0,0 +1,152 @@ +package aro_test + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stackshy/cloudemu/v2/config" + aroprovider "github.com/stackshy/cloudemu/v2/providers/azure/aro" + aroserver "github.com/stackshy/cloudemu/v2/server/azure/aro" + "github.com/stackshy/cloudemu/v2/services/kubernetes" +) + +// TestAROHandler_ARMLifecycle drives the ARO control plane over the real ARM +// JSON wire: PUT create -> GET -> listAdminCredentials (whose kubeconfig reaches +// a live OpenShift data plane) -> DELETE. +func TestAROHandler_ARMLifecycle(t *testing.T) { + // Data plane. + api := kubernetes.NewAPIServer() + dp := httptest.NewServer(api) + t.Cleanup(dp.Close) + api.SetBaseURL(dp.URL) + + // ARO ARM control plane. + mock := aroprovider.New(config.NewOptions()) + mock.SetK8sAPI(api) + arm := httptest.NewServer(aroserver.New(mock)) + t.Cleanup(arm.Close) + + const clusterPath = "/subscriptions/sub-1/resourceGroups/rg-1/providers/" + + "Microsoft.RedHatOpenShift/openShiftClusters/ocp1" + base := arm.URL + clusterPath + "?api-version=2023-09-04" + + // PUT — create. + putBody := `{"location":"eastus","properties":{"clusterProfile":{"version":"4.16.0"}}}` + put := do(t, http.MethodPut, base, putBody) + + if put.status != http.StatusOK { + t.Fatalf("PUT: status %d, want 200\n%s", put.status, put.body) + } + + var cluster struct { + Type string `json:"type"` + Properties struct { + ProvisioningState string `json:"provisioningState"` + ClusterProfile struct { + Version string `json:"version"` + } `json:"clusterProfile"` + } `json:"properties"` + } + + if err := json.Unmarshal([]byte(put.body), &cluster); err != nil { + t.Fatalf("decode PUT response: %v\n%s", err, put.body) + } + + if cluster.Properties.ProvisioningState != "Succeeded" { + t.Errorf("provisioningState: got %q, want Succeeded", cluster.Properties.ProvisioningState) + } + + if cluster.Type != "Microsoft.RedHatOpenShift/openShiftClusters" { + t.Errorf("type: got %q", cluster.Type) + } + + // GET. + if g := do(t, http.MethodGet, base, ""); g.status != http.StatusOK { + t.Fatalf("GET: status %d, want 200\n%s", g.status, g.body) + } + + // listAdminCredentials -> kubeconfig -> reach the OpenShift data plane. + cred := do(t, http.MethodPost, arm.URL+clusterPath+"/listAdminCredentials?api-version=2023-09-04", "") + if cred.status != http.StatusOK { + t.Fatalf("listAdminCredentials: status %d, want 200\n%s", cred.status, cred.body) + } + + var creds struct { + Kubeconfig []byte `json:"kubeconfig"` + } + + if err := json.Unmarshal([]byte(cred.body), &creds); err != nil { + t.Fatalf("decode credentials: %v", err) + } + + server := serverURL(t, creds.Kubeconfig) + + cv, err := http.Get(server + "/apis/config.openshift.io/v1/clusterversions/version") //nolint:noctx // test. + if err != nil { + t.Fatalf("GET clusterversion via ARO kubeconfig: %v", err) + } + + cv.Body.Close() + + if cv.StatusCode != http.StatusOK { + t.Fatalf("clusterversion via ARO kubeconfig: status %d, want 200", cv.StatusCode) + } + + // DELETE. + if d := do(t, http.MethodDelete, base, ""); d.status != http.StatusNoContent { + t.Fatalf("DELETE: status %d, want 204\n%s", d.status, d.body) + } + + if g := do(t, http.MethodGet, base, ""); g.status == http.StatusOK { + t.Error("GET after delete returned 200, want error") + } +} + +type resp struct { + status int + body string +} + +func do(t *testing.T, method, url, body string) resp { + t.Helper() + + var rdr io.Reader + if body != "" { + rdr = bytes.NewReader([]byte(body)) + } + + req, err := http.NewRequest(method, url, rdr) + if err != nil { + t.Fatalf("new request: %v", err) + } + + r, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("%s %s: %v", method, url, err) + } + + defer r.Body.Close() + + b, _ := io.ReadAll(r.Body) + + return resp{status: r.StatusCode, body: string(b)} +} + +func serverURL(t *testing.T, kubeconfig []byte) string { + t.Helper() + + for _, line := range strings.Split(string(kubeconfig), "\n") { + if line = strings.TrimSpace(line); strings.HasPrefix(line, "server:") { + return strings.TrimSpace(strings.TrimPrefix(line, "server:")) + } + } + + t.Fatalf("no server URL in kubeconfig:\n%s", kubeconfig) + + return "" +} diff --git a/server/azure/aro/operations.go b/server/azure/aro/operations.go new file mode 100644 index 000000000..12980169c --- /dev/null +++ b/server/azure/aro/operations.go @@ -0,0 +1,102 @@ +package aro + +import ( + "net/http" + + "github.com/stackshy/cloudemu/v2/providers/azure/aro" + "github.com/stackshy/cloudemu/v2/server/wire/azurearm" +) + +func (h *Handler) createOrUpdateCluster(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { + var body armOpenShiftCluster + if !azurearm.DecodeJSON(w, r, &body) { + return + } + + in := aro.ClusterInput{ + Subscription: rp.Subscription, + ResourceGroup: rp.ResourceGroup, + Name: rp.ResourceName, + Location: body.Location, + Tags: fromPtrTags(body.Tags), + } + if body.Properties != nil && body.Properties.ClusterProfile != nil { + in.Version = body.Properties.ClusterProfile.Version + } + + cluster, err := h.be.CreateOrUpdateCluster(r.Context(), in) + if err != nil { + azurearm.WriteCErr(w, err) + + return + } + + azurearm.WriteJSON(w, http.StatusOK, toARMCluster(cluster)) +} + +func (h *Handler) getCluster(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { + cluster, err := h.be.GetCluster(r.Context(), rp.Subscription, rp.ResourceGroup, rp.ResourceName) + if err != nil { + azurearm.WriteCErr(w, err) + + return + } + + azurearm.WriteJSON(w, http.StatusOK, toARMCluster(cluster)) +} + +func (h *Handler) deleteCluster(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { + if err := h.be.DeleteCluster(r.Context(), rp.Subscription, rp.ResourceGroup, rp.ResourceName); err != nil { + azurearm.WriteCErr(w, err) + + return + } + + w.WriteHeader(http.StatusNoContent) +} + +func (h *Handler) listClusters(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { + var clusters []aro.OpenShiftCluster + if rp.ResourceGroup == "" { + clusters = h.be.ListClusters(r.Context(), rp.Subscription) + } else { + clusters = h.be.ListClustersByResourceGroup(r.Context(), rp.Subscription, rp.ResourceGroup) + } + + out := make([]armOpenShiftCluster, 0, len(clusters)) + for i := range clusters { + out = append(out, toARMCluster(&clusters[i])) + } + + azurearm.WriteJSON(w, http.StatusOK, armList[armOpenShiftCluster]{Value: out}) +} + +// listAdminCredentials returns the admin kubeconfig (base64-encoded by the +// []byte field), pointing at the cluster's OpenShift data plane when wired. +func (h *Handler) listAdminCredentials(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { + if _, err := h.be.GetCluster(r.Context(), rp.Subscription, rp.ResourceGroup, rp.ResourceName); err != nil { + azurearm.WriteCErr(w, err) + + return + } + + kubeconfig := h.be.Kubeconfig(rp.Subscription, rp.ResourceGroup, rp.ResourceName) + + azurearm.WriteJSON(w, http.StatusOK, armAdminKubeconfig{Kubeconfig: kubeconfig}) +} + +// listCredentials returns the kubeadmin username/password. cloudemu is +// unauthenticated, so the password is a stable placeholder — the credential +// exists so `az aro list-credentials` round-trips. +func (h *Handler) listCredentials(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { + if _, err := h.be.GetCluster(r.Context(), rp.Subscription, rp.ResourceGroup, rp.ResourceName); err != nil { + azurearm.WriteCErr(w, err) + + return + } + + azurearm.WriteJSON(w, http.StatusOK, armCredentials{ + KubeadminUsername: "kubeadmin", + KubeadminPassword: "cloudemu-kubeadmin", + }) +} diff --git a/server/azure/aro/types.go b/server/azure/aro/types.go new file mode 100644 index 000000000..92c0a30b8 --- /dev/null +++ b/server/azure/aro/types.go @@ -0,0 +1,108 @@ +package aro + +import "github.com/stackshy/cloudemu/v2/providers/azure/aro" + +// ARM resource type identifiers for Microsoft.RedHatOpenShift. +const ( + resourceTypeOpenShiftClusters = "openShiftClusters" + resourceTypeOpenShiftFull = "Microsoft.RedHatOpenShift/openShiftClusters" +) + +// armOpenShiftCluster mirrors the JSON shape ARM uses for +// Microsoft.RedHatOpenShift/openShiftClusters. Only the fields cloudemu wires +// through are modeled; unknown fields decode and drop harmlessly. +type armOpenShiftCluster struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Type string `json:"type,omitempty"` + Location string `json:"location,omitempty"` + Tags map[string]*string `json:"tags,omitempty"` + Properties *armOSProperties `json:"properties,omitempty"` +} + +type armOSProperties struct { + ProvisioningState string `json:"provisioningState,omitempty"` + ClusterProfile *armClusterProfile `json:"clusterProfile,omitempty"` + ConsoleProfile *armConsoleProfile `json:"consoleProfile,omitempty"` + APIServerProfile *armAPIServerProfile `json:"apiserverProfile,omitempty"` +} + +type armClusterProfile struct { + Version string `json:"version,omitempty"` + Domain string `json:"domain,omitempty"` +} + +type armConsoleProfile struct { + URL string `json:"url,omitempty"` +} + +type armAPIServerProfile struct { + URL string `json:"url,omitempty"` + Visibility string `json:"visibility,omitempty"` +} + +// armAdminKubeconfig is the listAdminCredentials response (base64-encoded +// kubeconfig, encoded automatically because the field is []byte). +type armAdminKubeconfig struct { + Kubeconfig []byte `json:"kubeconfig,omitempty"` +} + +// armCredentials is the listCredentials response (kubeadmin username/password). +type armCredentials struct { + KubeadminUsername string `json:"kubeadminUsername,omitempty"` + KubeadminPassword string `json:"kubeadminPassword,omitempty"` +} + +// armList is the ARM list-response envelope. +type armList[T any] struct { + Value []T `json:"value"` + NextLink string `json:"nextLink,omitempty"` +} + +// toARMCluster converts a stored ARO cluster to its ARM JSON shape. +func toARMCluster(c *aro.OpenShiftCluster) armOpenShiftCluster { + return armOpenShiftCluster{ + ID: c.ID, + Name: c.Name, + Type: resourceTypeOpenShiftFull, + Location: c.Location, + Tags: toPtrTags(c.Tags), + Properties: &armOSProperties{ + ProvisioningState: c.ProvisioningState, + ClusterProfile: &armClusterProfile{Version: c.Version}, + ConsoleProfile: &armConsoleProfile{URL: c.ConsoleURL}, + APIServerProfile: &armAPIServerProfile{URL: c.APIServerURL, Visibility: "Public"}, + }, + } +} + +func toPtrTags(in map[string]string) map[string]*string { + if in == nil { + return nil + } + + out := make(map[string]*string, len(in)) + + for k, v := range in { + val := v + out[k] = &val + } + + return out +} + +func fromPtrTags(in map[string]*string) map[string]string { + if in == nil { + return nil + } + + out := make(map[string]string, len(in)) + + for k, v := range in { + if v != nil { + out[k] = *v + } + } + + return out +} diff --git a/server/azure/azure.go b/server/azure/azure.go index e22a9fd01..7c9ca2630 100644 --- a/server/azure/azure.go +++ b/server/azure/azure.go @@ -11,6 +11,7 @@ import ( "github.com/stackshy/cloudemu/v2/server/azure/acr" azureaiserver "github.com/stackshy/cloudemu/v2/server/azure/ai" aksserver "github.com/stackshy/cloudemu/v2/server/azure/aks" + aroserver "github.com/stackshy/cloudemu/v2/server/azure/aro" "github.com/stackshy/cloudemu/v2/server/azure/blobstorage" cachesrv "github.com/stackshy/cloudemu/v2/server/azure/cache" "github.com/stackshy/cloudemu/v2/server/azure/cosmosaccount" @@ -120,6 +121,7 @@ type Drivers struct { PostgresFlex rdbdriver.RelationalDB MySQLFlex rdbdriver.RelationalDB AKS aksserver.Backend + ARO aroserver.Backend IAM iamdriver.IAM ACR crdriver.ContainerRegistry // KeyVault serves the Key Vault secrets data-plane API (/secrets/…) @@ -313,6 +315,12 @@ func New(d Drivers) *server.Server { srv.Register(aksserver.New(d.AKS)) } + // ARO matches on Microsoft.RedHatOpenShift — a distinct ARM provider, so + // registration order relative to AKS/compute/etc. is unconstrained. + if d.ARO != nil { + srv.Register(aroserver.New(d.ARO)) + } + // Databricks matches on Microsoft.Databricks/workspaces — a distinct ARM // provider name, so registration order is unconstrained. if d.Databricks != nil { diff --git a/server/azure/from_provider.go b/server/azure/from_provider.go index 72502e185..ac0589385 100644 --- a/server/azure/from_provider.go +++ b/server/azure/from_provider.go @@ -37,6 +37,7 @@ func DriversFrom(p *azureprovider.Provider) Drivers { PostgresFlex: p.PostgresFlex, MySQLFlex: p.MySQLFlex, AKS: p.AKS, + ARO: p.ARO, IAM: p.IAM, ACR: p.ACR, KeyVault: p.KeyVault, diff --git a/server/openshift/ocm/handler.go b/server/openshift/ocm/handler.go new file mode 100644 index 000000000..df167467a --- /dev/null +++ b/server/openshift/ocm/handler.go @@ -0,0 +1,121 @@ +// Package ocm implements the Red Hat OpenShift Cluster Manager (OCM) REST API +// that the `rosa` CLI and OCM SDK drive — the /api/clusters_mgmt/v1 cluster +// surface plus the SSO token endpoint. It is a server.Handler registered on the +// AWS server (ROSA is AWS-hosted); its paths (/api/clusters_mgmt/, /auth/realms/) +// are disjoint from every AWS SDK path, so registration is collision-free. +// +// Coverage: +// +// POST /auth/realms/{realm}/protocol/openid-connect/token — SSO token (rosa login) +// POST /api/clusters_mgmt/v1/clusters — Create cluster +// GET /api/clusters_mgmt/v1/clusters — List clusters +// GET /api/clusters_mgmt/v1/clusters/{id} — Describe cluster +// DELETE /api/clusters_mgmt/v1/clusters/{id} — Delete cluster +// GET /api/clusters_mgmt/v1/clusters/{id}/credentials — Admin kubeconfig +// +// cloudemu is unauthenticated: the token endpoint mints a token for any +// credentials so `rosa login` succeeds, and the cluster ops don't verify it. +package ocm + +import ( + "context" + "net/http" + "strings" + + "github.com/stackshy/cloudemu/v2/providers/openshift/ocm" +) + +const ( + clustersPrefix = "/api/clusters_mgmt/v1/clusters" + tokenSuffix = "/protocol/openid-connect/token" +) + +// Backend is the OCM surface the handler needs. *ocm.Mock satisfies it. +type Backend interface { + CreateCluster(ctx context.Context, input ocm.ClusterInput) (*ocm.Cluster, error) + GetCluster(ctx context.Context, id string) (*ocm.Cluster, error) + ListClusters(ctx context.Context) []ocm.Cluster + DeleteCluster(ctx context.Context, id string) error + Kubeconfig(id string) ([]byte, error) +} + +// Handler serves the OCM REST API against a Backend. +type Handler struct { + be Backend +} + +// New returns an OCM handler backed by be. +func New(be Backend) *Handler { + return &Handler{be: be} +} + +// Matches returns true for OCM cluster-management and SSO token paths. +func (*Handler) Matches(r *http.Request) bool { + p := r.URL.Path + + return strings.HasPrefix(p, clustersPrefix) || + (strings.HasPrefix(p, "/auth/realms/") && strings.HasSuffix(p, tokenSuffix)) +} + +// ServeHTTP routes the request by path shape and method. +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + p := r.URL.Path + + if strings.HasPrefix(p, "/auth/realms/") && strings.HasSuffix(p, tokenSuffix) { + h.serveToken(w, r) + + return + } + + // Strip the clusters prefix; what remains selects collection / item / sub. + rest := strings.Trim(strings.TrimPrefix(p, clustersPrefix), "/") + + if rest == "" { + h.serveClusterCollection(w, r) + + return + } + + parts := strings.Split(rest, "/") + + switch { + case len(parts) == 1: + h.serveCluster(w, r, parts[0]) + case len(parts) == 2 && parts[1] == "credentials": + h.serveCredentials(w, r, parts[0]) + default: + writeOCMError(w, http.StatusNotFound, "404", "OCM sub-resource not found: "+rest) + } +} + +func (h *Handler) serveClusterCollection(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodPost: + h.createCluster(w, r) + case http.MethodGet: + h.listClusters(w, r) + default: + writeOCMError(w, http.StatusMethodNotAllowed, "405", "method not allowed") + } +} + +func (h *Handler) serveCluster(w http.ResponseWriter, r *http.Request, id string) { + switch r.Method { + case http.MethodGet: + h.getCluster(w, r, id) + case http.MethodDelete: + h.deleteCluster(w, r, id) + default: + writeOCMError(w, http.StatusMethodNotAllowed, "405", "method not allowed") + } +} + +func (h *Handler) serveCredentials(w http.ResponseWriter, r *http.Request, id string) { + if r.Method != http.MethodGet { + writeOCMError(w, http.StatusMethodNotAllowed, "405", "method not allowed") + + return + } + + h.getCredentials(w, r, id) +} diff --git a/server/openshift/ocm/handler_test.go b/server/openshift/ocm/handler_test.go new file mode 100644 index 000000000..a95750d1a --- /dev/null +++ b/server/openshift/ocm/handler_test.go @@ -0,0 +1,178 @@ +package ocm_test + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stackshy/cloudemu/v2/config" + ocmprovider "github.com/stackshy/cloudemu/v2/providers/openshift/ocm" + ocmserver "github.com/stackshy/cloudemu/v2/server/openshift/ocm" + "github.com/stackshy/cloudemu/v2/services/kubernetes" +) + +// TestOCMHandler_RosaLifecycle drives the OCM REST API the way `rosa` does: +// login token -> create cluster -> describe -> list -> credentials (whose +// kubeconfig reaches a live OpenShift data plane) -> delete. +func TestOCMHandler_RosaLifecycle(t *testing.T) { + api := kubernetes.NewAPIServer() + dp := httptest.NewServer(api) + t.Cleanup(dp.Close) + api.SetBaseURL(dp.URL) + + mock := ocmprovider.New(config.NewOptions()) + mock.SetK8sAPI(api) + ocm := httptest.NewServer(ocmserver.New(mock)) + t.Cleanup(ocm.Close) + + // rosa login -> SSO token. + tok := do(t, http.MethodPost, + ocm.URL+"/auth/realms/redhat-external/protocol/openid-connect/token", "grant_type=client_credentials") + if tok.status != http.StatusOK { + t.Fatalf("token: status %d, want 200\n%s", tok.status, tok.body) + } + + var token struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + } + + mustJSON(t, tok.body, &token) + + if token.AccessToken == "" || token.TokenType != "Bearer" { + t.Fatalf("bad token response: %s", tok.body) + } + + // rosa create cluster. + create := do(t, http.MethodPost, ocm.URL+"/api/clusters_mgmt/v1/clusters", + `{"name":"rosa1","region":{"id":"us-east-1"},"cloud_provider":{"id":"aws"},"product":{"id":"rosa"}}`) + if create.status != http.StatusCreated { + t.Fatalf("create: status %d, want 201\n%s", create.status, create.body) + } + + var cluster struct { + Kind string `json:"kind"` + ID string `json:"id"` + State string `json:"state"` + API struct { + URL string `json:"url"` + } `json:"api"` + } + + mustJSON(t, create.body, &cluster) + + if cluster.Kind != "Cluster" || cluster.State != "ready" || cluster.ID == "" { + t.Fatalf("unexpected cluster: %s", create.body) + } + + clusterURL := ocm.URL + "/api/clusters_mgmt/v1/clusters/" + cluster.ID + + // rosa describe. + if g := do(t, http.MethodGet, clusterURL, ""); g.status != http.StatusOK { + t.Fatalf("describe: status %d, want 200\n%s", g.status, g.body) + } + + // rosa list. + list := do(t, http.MethodGet, ocm.URL+"/api/clusters_mgmt/v1/clusters", "") + + var cl struct { + Kind string `json:"kind"` + Total int `json:"total"` + } + + mustJSON(t, list.body, &cl) + + if cl.Kind != "ClusterList" || cl.Total != 1 { + t.Fatalf("list: kind=%q total=%d, want ClusterList/1", cl.Kind, cl.Total) + } + + // credentials -> kubeconfig -> reach the OpenShift data plane. + cred := do(t, http.MethodGet, clusterURL+"/credentials", "") + if cred.status != http.StatusOK { + t.Fatalf("credentials: status %d, want 200\n%s", cred.status, cred.body) + } + + var creds struct { + Kubeconfig string `json:"kubeconfig"` + } + + mustJSON(t, cred.body, &creds) + + server := serverURL(t, []byte(creds.Kubeconfig)) + + cv, err := http.Get(server + "/apis/config.openshift.io/v1/clusterversions/version") //nolint:noctx // test. + if err != nil { + t.Fatalf("GET clusterversion via rosa kubeconfig: %v", err) + } + + cv.Body.Close() + + if cv.StatusCode != http.StatusOK { + t.Fatalf("clusterversion via rosa kubeconfig: status %d, want 200", cv.StatusCode) + } + + // rosa delete. + if d := do(t, http.MethodDelete, clusterURL, ""); d.status != http.StatusNoContent { + t.Fatalf("delete: status %d, want 204\n%s", d.status, d.body) + } + + if g := do(t, http.MethodGet, clusterURL, ""); g.status != http.StatusNotFound { + t.Errorf("get after delete: status %d, want 404", g.status) + } +} + +type resp struct { + status int + body string +} + +func do(t *testing.T, method, url, body string) resp { + t.Helper() + + var rdr io.Reader + if body != "" { + rdr = bytes.NewReader([]byte(body)) + } + + req, err := http.NewRequest(method, url, rdr) + if err != nil { + t.Fatalf("new request: %v", err) + } + + r, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("%s %s: %v", method, url, err) + } + + defer r.Body.Close() + + b, _ := io.ReadAll(r.Body) + + return resp{status: r.StatusCode, body: string(b)} +} + +func mustJSON(t *testing.T, body string, v any) { + t.Helper() + + if err := json.Unmarshal([]byte(body), v); err != nil { + t.Fatalf("decode %T: %v\n%s", v, err, body) + } +} + +func serverURL(t *testing.T, kubeconfig []byte) string { + t.Helper() + + for _, line := range strings.Split(string(kubeconfig), "\n") { + if line = strings.TrimSpace(line); strings.HasPrefix(line, "server:") { + return strings.TrimSpace(strings.TrimPrefix(line, "server:")) + } + } + + t.Fatalf("no server URL in kubeconfig:\n%s", kubeconfig) + + return "" +} diff --git a/server/openshift/ocm/ocm_aws_integration_test.go b/server/openshift/ocm/ocm_aws_integration_test.go new file mode 100644 index 000000000..094e2e225 --- /dev/null +++ b/server/openshift/ocm/ocm_aws_integration_test.go @@ -0,0 +1,65 @@ +package ocm_test + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + cloudemu "github.com/stackshy/cloudemu/v2" + "github.com/stackshy/cloudemu/v2/config" + ocmprovider "github.com/stackshy/cloudemu/v2/providers/openshift/ocm" + awsserver "github.com/stackshy/cloudemu/v2/server/aws" + ocmserver "github.com/stackshy/cloudemu/v2/server/openshift/ocm" +) + +// TestOCM_TokenThroughAWSServer verifies the OCM SSO token endpoint is reachable +// through the FULL AWS server with a form-encoded body — the request shape +// `rosa login` sends. OCM registers ahead of the AWS Query handlers, several of +// which claim any form-encoded POST and would otherwise answer the token request +// with InvalidAction. This guards that registration ordering (found via a live +// `rosa` drive, not the isolated handler test). +func TestOCM_TokenThroughAWSServer(t *testing.T) { + cloud := cloudemu.NewAWS(config.WithAccountID("000000000000")) + d := awsserver.DriversFrom(cloud) + d.OCM = ocmserver.New(ocmprovider.New(config.NewOptions())) + + ts := httptest.NewServer(awsserver.New(d)) + t.Cleanup(ts.Close) + + form := url.Values{"grant_type": {"client_credentials"}, "client_id": {"cloud-services"}} + req, _ := http.NewRequest(http.MethodPost, + ts.URL+"/auth/realms/redhat-external/protocol/openid-connect/token", + strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("token request: %v", err) + } + + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + + if resp.StatusCode != http.StatusOK { + t.Fatalf("token via AWS server: status %d, want 200 (OCM intercepted by an AWS Query handler?)\n%s", + resp.StatusCode, body) + } + + var tok struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + } + + if err := json.Unmarshal(body, &tok); err != nil { + t.Fatalf("decode token: %v\n%s", err, body) + } + + if tok.AccessToken == "" || tok.TokenType != "Bearer" { + t.Fatalf("unexpected token response (AWS handler intercepted?): %s", body) + } +} diff --git a/server/openshift/ocm/operations.go b/server/openshift/ocm/operations.go new file mode 100644 index 000000000..8e899da1d --- /dev/null +++ b/server/openshift/ocm/operations.go @@ -0,0 +1,153 @@ +package ocm + +import ( + "encoding/json" + "net/http" + + cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/internal/idgen" + "github.com/stackshy/cloudemu/v2/providers/openshift/ocm" +) + +// serveToken mints an SSO access token for any credentials, so `rosa login` +// (client-credentials or offline-token grant) succeeds against the emulator. +func (*Handler) serveToken(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, tokenResponse{ + AccessToken: "cloudemu-ocm-" + idgen.GenerateID(""), + TokenType: "Bearer", + ExpiresIn: 900, + Scope: "openid", + }) +} + +func (h *Handler) createCluster(w http.ResponseWriter, r *http.Request) { + var body struct { + Name string `json:"name"` + Region *ocmLink `json:"region"` + CloudProvider *ocmLink `json:"cloud_provider"` + Version *ocmLink `json:"version"` + Product *ocmLink `json:"product"` + } + + if !decodeJSON(w, r, &body) { + return + } + + in := ocm.ClusterInput{Name: body.Name} + if body.Region != nil { + in.Region = body.Region.ID + } + + if body.CloudProvider != nil { + in.CloudProvider = body.CloudProvider.ID + } + + if body.Product != nil { + in.Product = body.Product.ID + } + + if body.Version != nil { + in.Version = normalizeVersion(body.Version.ID) + } + + cluster, err := h.be.CreateCluster(r.Context(), in) + if err != nil { + writeCErr(w, err) + + return + } + + writeJSON(w, http.StatusCreated, toOCMCluster(cluster)) +} + +func (h *Handler) getCluster(w http.ResponseWriter, r *http.Request, id string) { + cluster, err := h.be.GetCluster(r.Context(), id) + if err != nil { + writeCErr(w, err) + + return + } + + writeJSON(w, http.StatusOK, toOCMCluster(cluster)) +} + +func (h *Handler) listClusters(w http.ResponseWriter, r *http.Request) { + clusters := h.be.ListClusters(r.Context()) + + items := make([]ocmCluster, 0, len(clusters)) + for i := range clusters { + items = append(items, toOCMCluster(&clusters[i])) + } + + writeJSON(w, http.StatusOK, ocmClusterList{ + Kind: "ClusterList", Page: 1, Size: len(items), Total: len(items), Items: items, + }) +} + +func (h *Handler) deleteCluster(w http.ResponseWriter, r *http.Request, id string) { + if err := h.be.DeleteCluster(r.Context(), id); err != nil { + writeCErr(w, err) + + return + } + + w.WriteHeader(http.StatusNoContent) +} + +func (h *Handler) getCredentials(w http.ResponseWriter, _ *http.Request, id string) { + kubeconfig, err := h.be.Kubeconfig(id) + if err != nil { + writeCErr(w, err) + + return + } + + writeJSON(w, http.StatusOK, ocmCredentials{Kind: "Credentials", Kubeconfig: string(kubeconfig)}) +} + +// ---- helpers ---- + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +func decodeJSON(w http.ResponseWriter, r *http.Request, v any) bool { + if err := json.NewDecoder(r.Body).Decode(v); err != nil { + writeOCMError(w, http.StatusBadRequest, "400", "invalid request body: "+err.Error()) + + return false + } + + return true +} + +func writeOCMError(w http.ResponseWriter, status int, code, reason string) { + writeJSON(w, status, ocmError{Kind: "Error", ID: code, Code: "CLUSTERS-MGMT-" + code, Reason: reason}) +} + +// writeCErr maps a cloudemu error to the OCM error envelope + HTTP status. +func writeCErr(w http.ResponseWriter, err error) { + status := http.StatusInternalServerError + + switch { + case cerrors.IsNotFound(err): + status = http.StatusNotFound + case cerrors.IsInvalidArgument(err): + status = http.StatusBadRequest + } + + writeOCMError(w, status, http.StatusText(status), err.Error()) +} + +// normalizeVersion strips OCM's "openshift-v" version-id prefix to a bare +// semver ("openshift-v4.16.0" -> "4.16.0"), leaving bare versions unchanged. +func normalizeVersion(id string) string { + const prefix = "openshift-v" + if len(id) > len(prefix) && id[:len(prefix)] == prefix { + return id[len(prefix):] + } + + return id +} diff --git a/server/openshift/ocm/types.go b/server/openshift/ocm/types.go new file mode 100644 index 000000000..fe0cf4ec3 --- /dev/null +++ b/server/openshift/ocm/types.go @@ -0,0 +1,83 @@ +package ocm + +import "github.com/stackshy/cloudemu/v2/providers/openshift/ocm" + +// OCM wire shapes for /api/clusters_mgmt/v1. OCM models every resource with a +// "kind" discriminator and link sub-objects ({kind:"...Link", id:"..."}); the +// rosa CLI and OCM SDK decode these shapes. + +type ocmLink struct { + Kind string `json:"kind,omitempty"` + ID string `json:"id,omitempty"` + Href string `json:"href,omitempty"` +} + +type ocmURL struct { + URL string `json:"url,omitempty"` +} + +type ocmCluster struct { + Kind string `json:"kind"` + ID string `json:"id"` + Href string `json:"href"` + Name string `json:"name"` + State string `json:"state"` + CloudProvider ocmLink `json:"cloud_provider"` + Region ocmLink `json:"region"` + OpenShiftVersion string `json:"openshift_version"` + Version ocmLink `json:"version"` + API ocmURL `json:"api"` + Console ocmURL `json:"console"` + Product ocmLink `json:"product"` +} + +type ocmClusterList struct { + Kind string `json:"kind"` + Page int `json:"page"` + Size int `json:"size"` + Total int `json:"total"` + Items []ocmCluster `json:"items"` +} + +// ocmCredentials is the clusters/{id}/credentials response — OCM returns the +// kubeconfig as a raw YAML string. +type ocmCredentials struct { + Kind string `json:"kind"` + Kubeconfig string `json:"kubeconfig"` +} + +// ocmError is the OCM error envelope. +type ocmError struct { + Kind string `json:"kind"` + ID string `json:"id"` + Code string `json:"code"` + Reason string `json:"reason"` +} + +// tokenResponse is the SSO token endpoint response. +type tokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + Scope string `json:"scope"` +} + +const clustersHref = "/api/clusters_mgmt/v1/clusters/" + +// toOCMCluster converts a stored cluster to its OCM JSON shape. +func toOCMCluster(c *ocm.Cluster) ocmCluster { + return ocmCluster{ + Kind: "Cluster", + ID: c.ID, + Href: clustersHref + c.ID, + Name: c.Name, + State: c.State, + CloudProvider: ocmLink{Kind: "CloudProviderLink", ID: c.CloudProvider}, + Region: ocmLink{Kind: "CloudRegionLink", ID: c.Region}, + OpenShiftVersion: c.Version, + Version: ocmLink{Kind: "VersionLink", ID: "openshift-v" + c.Version}, + API: ocmURL{URL: c.APIURL}, + Console: ocmURL{URL: c.ConsoleURL}, + Product: ocmLink{Kind: "ProductLink", ID: c.Product}, + } +} diff --git a/services/kubernetes/apiserver.go b/services/kubernetes/apiserver.go index 783dbc2c5..dfc26fb02 100644 --- a/services/kubernetes/apiserver.go +++ b/services/kubernetes/apiserver.go @@ -70,14 +70,23 @@ func (s *APIServer) SetClock(c config.Clock) { s.mu.Unlock() } -// RegisterCluster allocates fresh state for a new cluster and returns its -// generated UID. The UID is the path segment that goes into the kubeconfig's -// server URL — kubeconfig "server" becomes "/k8s/". +// RegisterCluster allocates fresh state for a new Kubernetes-flavored cluster +// and returns its generated UID. The UID is the path segment that goes into the +// kubeconfig's server URL — kubeconfig "server" becomes "/k8s/". +// This is what the EKS/AKS/GKE control planes call. func (s *APIServer) RegisterCluster() (string, *ClusterState) { + return s.RegisterClusterWithFlavor(FlavorKubernetes) +} + +// RegisterClusterWithFlavor allocates fresh state for a new cluster of the given +// flavor. FlavorOpenShift additionally serves the *.openshift.io groups and +// seeds the OpenShift identity singletons — the ROSA/ARO control planes call +// this to back an OpenShift cluster with the same shared data plane. +func (s *APIServer) RegisterClusterWithFlavor(flavor Flavor) (string, *ClusterState) { uid := newUID() s.mu.Lock() - state := newClusterState(s.clock, s.admissionEnabled, s.admissionClient) + state := newClusterState(s.clock, s.admissionEnabled, s.admissionClient, flavor) s.clusters[uid] = state s.mu.Unlock() @@ -184,9 +193,31 @@ func (s *APIServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { // Kubernetes paths (/api/v1/..., /apis/apps/v1/...) without the cluster // prefix. r.URL.Path = rest[slash:] + + // The OpenShift OAuth server (oc login) needs the cluster's ABSOLUTE URL to + // advertise its endpoints, so it is dispatched here — where the host and UID + // are still known — rather than from ClusterState, which only sees the + // stripped path. + if state.flavor == FlavorOpenShift && isOpenShiftOAuthPath(r.URL.Path) { + state.serveOAuth(w, r, requestScheme(r)+"://"+r.Host+pathPrefix+uid) + + return + } + state.ServeHTTP(w, r) } +// requestScheme returns "https" when the request arrived over TLS, else "http". +// The OAuth metadata endpoints must be absolute and scheme-correct so oc follows +// them back to the same server. +func requestScheme(r *http.Request) string { + if r.TLS != nil { + return "https" + } + + return "http" +} + // newUID returns a fresh 32-char lowercase hex string used as the cluster's // data-plane identifier in the kubeconfig. func newUID() string { diff --git a/services/kubernetes/discovery.go b/services/kubernetes/discovery.go index 05879a508..ee7a7946f 100644 --- a/services/kubernetes/discovery.go +++ b/services/kubernetes/discovery.go @@ -292,6 +292,13 @@ var registryShortNames = map[string][]string{ "events": {"ev"}, "nodes": {"no"}, "customresourcedefinitions": {"crd", "crds"}, + // OpenShift (*.openshift.io) short names — only surface on OpenShift-flavored + // clusters, where these plurals are registered. `oc get dc/is/bc/scc/...`. + "deploymentconfigs": {"dc"}, + "imagestreams": {"is"}, + "buildconfigs": {"bc"}, + "securitycontextconstraints": {"scc"}, + "clusterresourcequotas": {"clusterquota", "crq"}, } func policyResources() []apiResource { diff --git a/services/kubernetes/openshift.go b/services/kubernetes/openshift.go new file mode 100644 index 000000000..52fb6f36b --- /dev/null +++ b/services/kubernetes/openshift.go @@ -0,0 +1,296 @@ +package kubernetes + +import ( + "net/http" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/types" +) + +// Flavor selects which API surface a cluster serves. A Kubernetes-flavored +// cluster serves only the upstream groups (what EKS/AKS/GKE emulate); an +// OpenShift-flavored cluster additionally serves the *.openshift.io groups and +// boots with the cluster-identity singletons (ClusterVersion, Infrastructure) +// that `oc` and the OpenShift SDKs read first. +// +// OpenShift is a strict superset of Kubernetes, so an OpenShift cluster is a +// Kubernetes cluster plus the extra groups — the flavor only ever ADDS surface. +type Flavor int + +const ( + // FlavorKubernetes is a vanilla upstream cluster (the default; what + // RegisterCluster and the EKS/AKS/GKE control planes create). + FlavorKubernetes Flavor = iota + // FlavorOpenShift additionally serves the *.openshift.io groups and seeds + // the OpenShift cluster-identity singletons. Created via + // RegisterClusterWithFlavor and the ROSA/ARO control planes. + FlavorOpenShift +) + +// OpenShift API group names. Grouped here so the per-group registration files +// (openshift_route.go, openshift_apps.go, …) share one authoritative set. +const ( + apiGroupOSApps = "apps.openshift.io" + apiGroupOSAuthorization = "authorization.openshift.io" + apiGroupOSBuild = "build.openshift.io" + apiGroupOSConfig = "config.openshift.io" + apiGroupOSImage = "image.openshift.io" + apiGroupOSOAuth = "oauth.openshift.io" + apiGroupOSProject = "project.openshift.io" + apiGroupOSQuota = "quota.openshift.io" + apiGroupOSRoute = "route.openshift.io" + apiGroupOSSecurity = "security.openshift.io" + apiGroupOSTemplate = "template.openshift.io" + apiGroupOSUser = "user.openshift.io" + apiGroupOSConsole = "console.openshift.io" + apiGroupOSOperator = "operator.openshift.io" + apiGroupOSMachine = "machine.openshift.io" + apiGroupOSAutoscaling = "autoscaling.openshift.io" +) + +// Emulated OpenShift release. OCP 4.16 ships Kubernetes 1.29 — the same version +// this emulator reports at /version — so a 4.16 identity keeps the OpenShift +// layer internally consistent with the Kubernetes layer beneath it. +const ( + openShiftVersion = "4.16.0" + openShiftChannel = "stable-4.16" +) + +// openshiftRegistryDefs returns the resourceDefs for every base OKD/OCP +// *.openshift.io kind. It is concatenated into a cluster's registry ONLY when +// the cluster is OpenShift-flavored, so Kubernetes clusters never advertise or +// serve these groups. Each group lives in its own file (openshift_.go), +// mirroring registry_defs.go's per-group split. +func openshiftRegistryDefs() []*resourceDef { + return concat( + openshiftConfigDefs(), + openshiftAppsDefs(), + openshiftRouteDefs(), + openshiftBuildDefs(), + openshiftImageDefs(), + openshiftProjectDefs(), + openshiftUserDefs(), + openshiftOAuthDefs(), + openshiftSecurityDefs(), + openshiftQuotaDefs(), + openshiftAuthorizationDefs(), + openshiftTemplateDefs(), + openshiftConsoleDefs(), + openshiftOperatorDefs(), + openshiftMachineDefs(), + openshiftAutoscalingDefs(), + ) +} + +// openshiftConfigDefs registers the config.openshift.io/v1 kinds. These are the +// cluster-scoped operator/identity singletons (ClusterVersion, Infrastructure, +// and the config knobs `oc get cluster` reads). They are plain CRUD +// stores — an emulator has no operators to reconcile them — but registering +// them makes `oc get clusterversion|infrastructure|...` and the OpenShift +// config client round-trip. +func openshiftConfigDefs() []*resourceDef { + defs := make([]*resourceDef, 0, len(openshiftConfigSingletonKinds)) + for _, k := range openshiftConfigSingletonKinds { + defs = append(defs, &resourceDef{ + group: apiGroupOSConfig, version: "v1", kind: k.kind, listKind: k.kind + "List", + plural: k.plural, namespaced: false, hasStatus: true, + }) + } + + return defs +} + +// openshiftConfigSingletonKinds is the config.openshift.io/v1 cluster-scoped +// singleton surface (captured from a live OCP 4.21 cluster). All are +// cluster-scoped with a status subresource. +// +//nolint:gochecknoglobals // immutable registration table. +var openshiftConfigSingletonKinds = []struct{ kind, plural string }{ + {"APIServer", "apiservers"}, + {"Authentication", "authentications"}, + {"Build", "builds"}, + {"ClusterOperator", "clusteroperators"}, + {"ClusterVersion", "clusterversions"}, + {"Console", "consoles"}, + {"DNS", "dnses"}, + {"FeatureGate", "featuregates"}, + {"Image", "images"}, + {"Infrastructure", "infrastructures"}, + {"Ingress", "ingresses"}, + {"Network", "networks"}, + {"Node", "nodes"}, + {"OAuth", "oauths"}, + {"OperatorHub", "operatorhubs"}, + {"Project", "projects"}, + {"Proxy", "proxies"}, + {"Scheduler", "schedulers"}, +} + +// serveOpenShiftIntercept handles the OpenShift-only pseudo-endpoints that have +// no registry store or Route shape — `oc whoami` (GET users/~) and the POST-only +// RPCs (project requests, build instantiate, processed templates, authorization +// reviews). It returns true when it served the request. No-op (false) for +// Kubernetes-flavored clusters. +func (s *ClusterState) serveOpenShiftIntercept(w http.ResponseWriter, r *http.Request) bool { + if s.flavor != FlavorOpenShift { + return false + } + + switch r.Method { + case http.MethodGet: + if isWhoamiPath(r.URL.Path) { + s.serveWhoami(w, r) + + return true + } + // `oc new-project` GETs the projectrequests collection before POSTing + // (its "can I request projects?" probe). projectrequests is a virtual + // verb, not a stored kind, so answer the GET with an empty list — without + // it the GET falls through to the registry and 404s, aborting the command. + if r.URL.Path == projectRequestPath { + serveProjectRequestList(w) + + return true + } + case http.MethodPost: + return s.serveOpenShiftPost(w, r) + } + + return false +} + +// serveOpenShiftPost dispatches the POST-only OpenShift RPCs. Returns true when +// it served the request. +func (s *ClusterState) serveOpenShiftPost(w http.ResponseWriter, r *http.Request) bool { + switch r.URL.Path { + case projectRequestPath: + s.serveProjectRequest(w, r) + case selfSubjectReviewPath: + s.serveSelfSubjectReview(w, r) + default: + return s.serveOpenShiftPostByShape(w, r) + } + + return true +} + +// serveOpenShiftPostByShape handles the POST RPCs whose target is encoded in the +// path shape (build instantiate, processed templates, authorization reviews). +func (s *ClusterState) serveOpenShiftPostByShape(w http.ResponseWriter, r *http.Request) bool { + if ns, bc, ok := buildInstantiateTarget(r.URL.Path); ok { + s.serveBuildInstantiate(w, ns, bc) + + return true + } + + if _, ok := processedTemplateTarget(r.URL.Path); ok { + s.serveProcessedTemplate(w, r) + + return true + } + + if plural := openShiftReviewKind(r.URL.Path); plural != "" { + s.serveOpenShiftReview(w, r, plural) + + return true + } + + return false +} + +// seedOpenShiftSingletonsLocked populates an OpenShift-flavored cluster's +// registry with the identity singletons a fresh cluster always has: the +// ClusterVersion ("version") and Infrastructure ("cluster") objects. Both are +// cluster-scoped config.openshift.io/v1 kinds. Callers hold no lock yet — this +// runs during newClusterState before the state is published, so it writes the +// stores directly (the pattern state.go uses to seed the synthetic Node). +func (s *ClusterState) seedOpenShiftSingletonsLocked() { + if st := s.reg.getStore(apiGroupOSConfig, "v1", "clusterversions"); st != nil { + cv := newClusterVersionObject() + st.items[objKey("", cv.GetName())] = cv + } + + if st := s.reg.getStore(apiGroupOSConfig, "v1", "infrastructures"); st != nil { + infra := newInfrastructureObject() + st.items[objKey("", infra.GetName())] = infra + } +} + +// newClusterVersionObject builds the singleton ClusterVersion ("version") a +// fresh OpenShift cluster reports. Shape follows a live OCP cluster: spec +// carries the channel and a synthetic clusterID; status reports the desired +// release as available and Progressing=False/Available=True. +func newClusterVersionObject() *unstructured.Unstructured { + cv := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": apiGroupOSConfig + "/v1", + "kind": "ClusterVersion", + "metadata": map[string]any{ + "name": "version", + "creationTimestamp": nil, + }, + "spec": map[string]any{ + "channel": openShiftChannel, + "clusterID": newClusterID(), + }, + "status": map[string]any{ + "desired": map[string]any{ + "version": openShiftVersion, + "image": "quay.io/openshift-release-dev/ocp-release@sha256:" + newUID() + newUID(), + }, + "observedGeneration": int64(1), + "versionHash": newUID()[:22], + "conditions": []any{ + map[string]any{"type": "Available", "status": "True", "message": "Done applying " + openShiftVersion}, + map[string]any{"type": "Failing", "status": "False"}, + map[string]any{"type": "Progressing", "status": "False", "message": "Cluster version is " + openShiftVersion}, + }, + "history": []any{ + map[string]any{"state": "Completed", "version": openShiftVersion, "verified": false}, + }, + }, + }} + cv.SetUID(types.UID(newUID())) + cv.SetResourceVersion("1") + cv.SetGeneration(1) + + return cv +} + +// newClusterID formats a fresh 32-hex UID as the 8-4-4-4-12 UUID a +// ClusterVersion.spec.clusterID carries. +func newClusterID() string { + u := newUID() + + return u[0:8] + "-" + u[8:12] + "-" + u[12:16] + "-" + u[16:20] + "-" + u[20:32] +} + +// newInfrastructureObject builds the singleton Infrastructure ("cluster") a +// fresh OpenShift cluster reports. Shape follows a live cluster's status: +// infrastructureName, apiServerURL, and platform. Platform is reported as None +// (bare metal / generic) here; the ROSA/ARO control planes overwrite it with +// AWS/Azure specifics when they provision a cloud-hosted cluster. +func newInfrastructureObject() *unstructured.Unstructured { + infra := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": apiGroupOSConfig + "/v1", + "kind": "Infrastructure", + "metadata": map[string]any{ + "name": "cluster", + "creationTimestamp": nil, + }, + "spec": map[string]any{ + "platformSpec": map[string]any{"type": "None"}, + }, + "status": map[string]any{ + "infrastructureName": "cloudemu-" + newUID()[:5], + "controlPlaneTopology": "HighlyAvailable", + "infrastructureTopology": "HighlyAvailable", + "platform": "None", + "platformStatus": map[string]any{"type": "None"}, + }, + }} + infra.SetUID(types.UID(newUID())) + infra.SetResourceVersion("1") + infra.SetGeneration(1) + + return infra +} diff --git a/services/kubernetes/openshift_authz.go b/services/kubernetes/openshift_authz.go new file mode 100644 index 000000000..f67b1e9e0 --- /dev/null +++ b/services/kubernetes/openshift_authz.go @@ -0,0 +1,122 @@ +package kubernetes + +import ( + "encoding/json" + "io" + "net/http" + "strings" +) + +// OpenShift authorization reviews. `oc login` (and `oc policy`, the console) +// POST these non-persisted "review" verbs to ask "can I do X?". They are not +// registry kinds — each is a POST-only RPC with a bespoke response shape. +// +// cloudemu is an unauthenticated, allow-all backend, so every review resolves +// permissively: access reviews return allowed=true, rules reviews return a +// full-access rule. This is what lets `oc login` finish (it checks whether the +// user may list projectrequests) instead of erroring on a 404. +const apiPrefixOSAuthzV1 = "/apis/authorization.openshift.io/v1/" + +// maxReviewBodyBytes caps how much of a review request body is read when +// resolving the target namespace. +const maxReviewBodyBytes = 1 << 20 + +// selfSubjectReviewPath is the authentication.k8s.io self-review `oc whoami` +// probes; answering it avoids oc's noisy fallback path. +const selfSubjectReviewPath = "/apis/authentication.k8s.io/v1/selfsubjectreviews" + +// openShiftReviewPlurals are the authorization.openshift.io/v1 review verbs +// answered permissively. +// +//nolint:gochecknoglobals // immutable lookup set. +var openShiftReviewPlurals = map[string]bool{ + "subjectaccessreviews": true, + "localsubjectaccessreviews": true, + "resourceaccessreviews": true, + "localresourceaccessreviews": true, + "selfsubjectrulesreviews": true, + "subjectrulesreviews": true, +} + +// openShiftReviewKind returns the review plural a POST path targets under +// authorization.openshift.io/v1 (cluster-scoped or /namespaces//), +// or "" if the path is not a review. +func openShiftReviewKind(path string) string { + if !strings.HasPrefix(path, apiPrefixOSAuthzV1) { + return "" + } + + plural := path[strings.LastIndex(path, "/")+1:] + if openShiftReviewPlurals[plural] { + return plural + } + + return "" +} + +// serveOpenShiftReview answers an authorization.openshift.io review permissively. +func (s *ClusterState) serveOpenShiftReview(w http.ResponseWriter, r *http.Request, plural string) { + ns := reviewNamespace(r) + + const gv = apiGroupOSAuthorization + "/v1" + + switch plural { + case "subjectaccessreviews", "localsubjectaccessreviews": + writeJSON(w, http.StatusOK, map[string]any{ + "kind": "SubjectAccessReviewResponse", "apiVersion": gv, + "namespace": ns, "allowed": true, "reason": "cloudemu allows all", + }) + case "resourceaccessreviews", "localresourceaccessreviews": + writeJSON(w, http.StatusOK, map[string]any{ + "kind": "ResourceAccessReviewResponse", "apiVersion": gv, + "namespace": ns, "users": []any{s.userForRequest(r)}, "groups": []any{"system:authenticated"}, + }) + default: // selfsubjectrulesreviews, subjectrulesreviews + writeJSON(w, http.StatusOK, map[string]any{ + "kind": "SelfSubjectRulesReview", "apiVersion": gv, + "status": map[string]any{ + "rules": []any{ + map[string]any{"verbs": []any{"*"}, "apiGroups": []any{"*"}, "resources": []any{"*"}}, + }, + }, + }) + } +} + +// serveSelfSubjectReview answers the authentication.k8s.io SelfSubjectReview +// `oc whoami` posts, echoing the caller's resolved identity. +func (s *ClusterState) serveSelfSubjectReview(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]any{ + "kind": "SelfSubjectReview", "apiVersion": "authentication.k8s.io/v1", + "status": map[string]any{ + "userInfo": map[string]any{ + "username": s.userForRequest(r), + "groups": []any{"system:authenticated", "system:authenticated:oauth"}, + }, + }, + }) +} + +// reviewNamespace pulls the namespace a review targets: the /namespaces// +// path segment for a local review, else the body's namespace field. +func reviewNamespace(r *http.Request) string { + if i := strings.Index(r.URL.Path, "/namespaces/"); i >= 0 { + rest := r.URL.Path[i+len("/namespaces/"):] + if j := strings.IndexByte(rest, '/'); j >= 0 { + return rest[:j] + } + } + + body, err := io.ReadAll(io.LimitReader(r.Body, maxReviewBodyBytes)) + if err != nil { + return "" + } + + var probe struct { + Namespace string `json:"namespace"` + } + + _ = json.Unmarshal(body, &probe) + + return probe.Namespace +} diff --git a/services/kubernetes/openshift_build.go b/services/kubernetes/openshift_build.go new file mode 100644 index 000000000..3fa9bf065 --- /dev/null +++ b/services/kubernetes/openshift_build.go @@ -0,0 +1,164 @@ +package kubernetes + +import ( + "net/http" + "strconv" + "strings" + "time" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/types" +) + +// reconcileBuild drives a Build to completion the way the emulator drives a Job: +// it materializes the single build Pod (-build) as Succeeded and marks +// the Build Complete with start/completion timestamps. A real cluster runs an +// actual builder Pod (S2I/Docker); the emulator converges it instantly so +// `oc get builds` and `oc logs build/` see a finished build. Terminal +// builds are left untouched so a re-reconcile doesn't resurrect the Pod. +func reconcileBuild(s *ClusterState, obj *unstructured.Unstructured) { + phase, _, _ := unstructured.NestedString(obj.Object, "status", "phase") + if isTerminalBuildPhase(phase) { + return + } + + ns := obj.GetNamespace() + owner := ownerRefOf(obj) + + if len(s.podsOwnedByLocked(ns, owner.UID)) == 0 { + pod := s.buildControllerPod(ns, obj.GetName()+"-build", buildPodTemplate(obj.GetName()), owner) + s.markPodSucceededLocked(pod) + s.pods[podKey(ns, pod.Name)] = pod + s.wPods.publish(EventAdded, ns, *pod.DeepCopy()) + } + + ts := s.now().Time.UTC().Format(time.RFC3339) + _ = unstructured.SetNestedField(obj.Object, buildPhaseComplete, "status", "phase") + _ = unstructured.SetNestedField(obj.Object, ts, "status", "startTimestamp") + _ = unstructured.SetNestedField(obj.Object, ts, "status", "completionTimestamp") +} + +// isTerminalBuildPhase reports whether a Build has reached a phase that must not +// be re-run. +func isTerminalBuildPhase(phase string) bool { + switch phase { + case buildPhaseComplete, "Failed", "Error", "Canceled": //nolint:goconst // one-off terminal build phases. + return true + default: + return false + } +} + +// buildPhaseComplete is the terminal phase a converged Build reports. +const buildPhaseComplete = "Complete" + +// buildPodTemplate synthesizes the minimal builder Pod spec a Build runs. The +// concrete builder image is irrelevant to the emulator (nothing executes); the +// Pod exists so the build surfaces in `oc get pods` with the conventional +// openshift.io/build.name label. +func buildPodTemplate(buildName string) corev1.PodTemplateSpec { + return corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"openshift.io/build.name": buildName}}, + Spec: corev1.PodSpec{ + RestartPolicy: corev1.RestartPolicyNever, + Containers: []corev1.Container{ + {Name: "docker-build", Image: "openshift/origin-docker-builder"}, + }, + }, + } +} + +// buildInstantiateSuffix is the `oc start-build` subresource verb. +const buildInstantiateSuffix = "/instantiate" + +// buildInstantiateTarget returns the (namespace, buildConfig) a `oc start-build` +// POST targets, or ok=false when the path is not a buildconfig instantiate. +func buildInstantiateTarget(path string) (namespace, name string, ok bool) { + const marker = "/apis/build.openshift.io/v1/namespaces/" + if !strings.HasPrefix(path, marker) || !strings.HasSuffix(path, buildInstantiateSuffix) { + return "", "", false + } + + // /buildconfigs//instantiate + rest := strings.TrimSuffix(strings.TrimPrefix(path, marker), buildInstantiateSuffix) + + parts := strings.Split(rest, "/") + if len(parts) != 3 || parts[1] != "buildconfigs" { + return "", "", false + } + + return parts[0], parts[2], true +} + +// serveBuildInstantiate implements `oc start-build`: it mints a Build from a +// BuildConfig (copying its spec, bumping the config's lastVersion), drives it to +// completion via reconcileBuild, and returns the Build. +func (s *ClusterState) serveBuildInstantiate(w http.ResponseWriter, namespace, bcName string) { + s.mu.Lock() + defer s.mu.Unlock() + + bcStore := s.reg.getStore(apiGroupOSBuild, "v1", "buildconfigs") + buildStore := s.reg.getStore(apiGroupOSBuild, "v1", "builds") + + if bcStore == nil || buildStore == nil { + writeNotFound(w, "openshift: build stores unavailable") + + return + } + + bc, ok := bcStore.items[objKey(namespace, bcName)] + if !ok { + writeNotFound(w, "openshift: buildconfig not found: "+objKey(namespace, bcName)) + + return + } + + version, _, _ := unstructured.NestedInt64(bc.Object, "status", "lastVersion") + version++ + _ = unstructured.SetNestedField(bc.Object, version, "status", "lastVersion") + bcStore.stampRVLocked(bc) + + build := newBuildFromConfig(bc, namespace, bcName, version) + build.SetCreationTimestamp(s.now()) + buildStore.stampRVLocked(build) + buildStore.items[objKey(namespace, build.GetName())] = build + + reconcileBuild(s, build) + + buildStore.watch.publish(EventAdded, namespace, *build.DeepCopy()) + + writeJSON(w, http.StatusCreated, build) +} + +// newBuildFromConfig builds a Build object seeded from a BuildConfig's spec, +// named - with the conventional build-config label. +func newBuildFromConfig(bc *unstructured.Unstructured, namespace, bcName string, version int64) *unstructured.Unstructured { + spec, _, _ := unstructured.NestedMap(bc.Object, "spec") + if spec == nil { + spec = map[string]any{} + } + + name := bcName + "-" + strconv.FormatInt(version, 10) + + build := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": apiGroupOSBuild + "/v1", + "kind": "Build", + "metadata": map[string]any{ + "name": name, + "namespace": namespace, + "labels": map[string]any{"openshift.io/build-config.name": bcName}, + "annotations": map[string]any{ + "openshift.io/build-config.name": bcName, + "openshift.io/build.number": strconv.FormatInt(version, 10), + }, + }, + "spec": spec, + "status": map[string]any{"phase": "New"}, + }} + build.SetUID(types.UID(newUID())) + build.SetGeneration(1) + + return build +} diff --git a/services/kubernetes/openshift_defs.go b/services/kubernetes/openshift_defs.go new file mode 100644 index 000000000..114e5f04e --- /dev/null +++ b/services/kubernetes/openshift_defs.go @@ -0,0 +1,305 @@ +package kubernetes + +// This file registers the base OKD/OCP *.openshift.io kinds beyond the +// config.openshift.io singletons (which live in openshift.go). Each group is a +// small function returning its resourceDefs; openshiftRegistryDefs concatenates +// them. Kinds are registered as plain CRUD stores here — runtime behavior +// (Route admission, DeploymentConfig rollout, ImageStream status, Project SCC +// annotations, …) is layered on as reconcile hooks separately. +// +// Kinds that are POST-only "request"/"review" verbs or server-computed virtual +// views (ProjectRequest, *Review, ImageStreamTag/ImageStreamImage, +// UserIdentityMapping, AppliedClusterResourceQuota) are intentionally NOT +// registered as stores — advertising them as CRUD would have clients issue +// calls the generic store answers with the wrong semantics. They are added with +// dedicated handlers where needed. + +// openshiftAppsDefs registers apps.openshift.io/v1 DeploymentConfig — the +// pre-Deployment workload controller. It scales (spec.replicas) and reports +// status, so it carries both subresources. +func openshiftAppsDefs() []*resourceDef { + return []*resourceDef{ + { + group: apiGroupOSApps, version: "v1", kind: "DeploymentConfig", listKind: "DeploymentConfigList", + plural: "deploymentconfigs", namespaced: true, hasStatus: true, hasScale: true, + reconcile: reconcileDeploymentConfig, + }, + } +} + +// openshiftRouteDefs registers route.openshift.io/v1 Route — OpenShift's +// pre-Ingress north-south exposure object. status.ingress admission is a +// reconcile behavior added later. +func openshiftRouteDefs() []*resourceDef { + return []*resourceDef{ + { + group: apiGroupOSRoute, version: "v1", kind: "Route", listKind: "RouteList", + plural: "routes", namespaced: true, hasStatus: true, reconcile: reconcileRoute, + }, + } +} + +// openshiftBuildDefs registers build.openshift.io/v1 Build and BuildConfig — the +// source-to-image build system's request (BuildConfig) and instance (Build). +func openshiftBuildDefs() []*resourceDef { + return []*resourceDef{ + { + group: apiGroupOSBuild, version: "v1", kind: "BuildConfig", listKind: "BuildConfigList", + plural: "buildconfigs", namespaced: true, hasStatus: true, + }, + { + group: apiGroupOSBuild, version: "v1", kind: "Build", listKind: "BuildList", + plural: "builds", namespaced: true, hasStatus: true, reconcile: reconcileBuild, + }, + } +} + +// openshiftImageDefs registers image.openshift.io/v1 ImageStream (namespaced, +// status carries the synthesized registry repositories) and Image (the +// cluster-scoped image metadata object). The derived tag/import views +// (imagestreamtags, imagestreamimages, imagestreamimports, imagestreammappings) +// are server-computed and handled separately, not as plain stores. +func openshiftImageDefs() []*resourceDef { + return []*resourceDef{ + { + group: apiGroupOSImage, version: "v1", kind: "ImageStream", listKind: "ImageStreamList", + plural: "imagestreams", namespaced: true, hasStatus: true, reconcile: reconcileImageStream, + }, + { + group: apiGroupOSImage, version: "v1", kind: "Image", listKind: "ImageList", + plural: "images", namespaced: false, + }, + } +} + +// openshiftProjectDefs registers project.openshift.io/v1 Project — the +// cluster-scoped tenancy object OpenShift layers over a Namespace. (ProjectRequest, +// the self-service create verb, is a POST-only handler added separately.) +func openshiftProjectDefs() []*resourceDef { + return []*resourceDef{ + { + group: apiGroupOSProject, version: "v1", kind: "Project", listKind: "ProjectList", + plural: "projects", namespaced: false, hasStatus: true, reconcile: reconcileProject, + }, + } +} + +// openshiftUserDefs registers user.openshift.io/v1 User, Group, and Identity — +// all cluster-scoped identity objects. (UserIdentityMapping is a POST-only +// mapping verb, handled separately.) +func openshiftUserDefs() []*resourceDef { + return []*resourceDef{ + { + group: apiGroupOSUser, version: "v1", kind: "User", listKind: "UserList", + plural: "users", namespaced: false, + }, + { + group: apiGroupOSUser, version: "v1", kind: "Group", listKind: "GroupList", + plural: "groups", namespaced: false, + }, + { + group: apiGroupOSUser, version: "v1", kind: "Identity", listKind: "IdentityList", + plural: "identities", namespaced: false, + }, + } +} + +// openshiftOAuthDefs registers oauth.openshift.io/v1 OAuth registration and +// token objects (all cluster-scoped). These back the OAuth server the oc-login +// flow uses; here they are the persisted stores clients can list/inspect. +func openshiftOAuthDefs() []*resourceDef { + return []*resourceDef{ + { + group: apiGroupOSOAuth, version: "v1", kind: "OAuthClient", listKind: "OAuthClientList", + plural: "oauthclients", namespaced: false, + }, + { + group: apiGroupOSOAuth, version: "v1", kind: "OAuthAccessToken", listKind: "OAuthAccessTokenList", + plural: "oauthaccesstokens", namespaced: false, + }, + { + group: apiGroupOSOAuth, version: "v1", kind: "OAuthAuthorizeToken", listKind: "OAuthAuthorizeTokenList", + plural: "oauthauthorizetokens", namespaced: false, + }, + { + group: apiGroupOSOAuth, version: "v1", kind: "OAuthClientAuthorization", + listKind: "OAuthClientAuthorizationList", plural: "oauthclientauthorizations", namespaced: false, + }, + } +} + +// openshiftSecurityDefs registers security.openshift.io/v1 SecurityContextConstraints +// (the pod-security policy predating PSA) and RangeAllocation. Both cluster-scoped. +func openshiftSecurityDefs() []*resourceDef { + return []*resourceDef{ + { + group: apiGroupOSSecurity, version: "v1", kind: "SecurityContextConstraints", + listKind: "SecurityContextConstraintsList", plural: "securitycontextconstraints", namespaced: false, + }, + { + group: apiGroupOSSecurity, version: "v1", kind: "RangeAllocation", listKind: "RangeAllocationList", + plural: "rangeallocations", namespaced: false, + }, + } +} + +// openshiftQuotaDefs registers quota.openshift.io/v1 ClusterResourceQuota — a +// multi-namespace quota, cluster-scoped with a status subresource. +// (AppliedClusterResourceQuota is a per-namespace derived view, not a store.) +func openshiftQuotaDefs() []*resourceDef { + return []*resourceDef{ + { + group: apiGroupOSQuota, version: "v1", kind: "ClusterResourceQuota", + listKind: "ClusterResourceQuotaList", plural: "clusterresourcequotas", namespaced: false, hasStatus: true, + }, + } +} + +// openshiftAuthorizationDefs registers the legacy authorization.openshift.io/v1 +// Role/RoleBinding/ClusterRole/ClusterRoleBinding views (OpenShift's RBAC +// predating rbac.authorization.k8s.io) plus RoleBindingRestriction. The +// *Review verbs are POST-only and handled separately. +func openshiftAuthorizationDefs() []*resourceDef { + return []*resourceDef{ + { + group: apiGroupOSAuthorization, version: "v1", kind: "Role", listKind: "RoleList", + plural: "roles", namespaced: true, + }, + { + group: apiGroupOSAuthorization, version: "v1", kind: "RoleBinding", listKind: "RoleBindingList", + plural: "rolebindings", namespaced: true, + }, + { + group: apiGroupOSAuthorization, version: "v1", kind: "ClusterRole", listKind: "ClusterRoleList", + plural: "clusterroles", namespaced: false, + }, + { + group: apiGroupOSAuthorization, version: "v1", kind: "ClusterRoleBinding", + listKind: "ClusterRoleBindingList", plural: "clusterrolebindings", namespaced: false, + }, + { + group: apiGroupOSAuthorization, version: "v1", kind: "RoleBindingRestriction", + listKind: "RoleBindingRestrictionList", plural: "rolebindingrestrictions", namespaced: true, + }, + } +} + +// openshiftTemplateDefs registers template.openshift.io/v1 Template (namespaced), +// TemplateInstance (namespaced, status), and BrokerTemplateInstance (cluster). +func openshiftTemplateDefs() []*resourceDef { + return []*resourceDef{ + { + group: apiGroupOSTemplate, version: "v1", kind: "Template", listKind: "TemplateList", + plural: "templates", namespaced: true, + }, + { + group: apiGroupOSTemplate, version: "v1", kind: "TemplateInstance", listKind: "TemplateInstanceList", + plural: "templateinstances", namespaced: true, hasStatus: true, + }, + { + group: apiGroupOSTemplate, version: "v1", kind: "BrokerTemplateInstance", + listKind: "BrokerTemplateInstanceList", plural: "brokertemplateinstances", namespaced: false, + }, + } +} + +// openshiftConsoleDefs registers the console.openshift.io/v1 web-console +// customization kinds (all cluster-scoped) — the objects `oc get consolelink` +// / the console operator manage. +func openshiftConsoleDefs() []*resourceDef { + kinds := []struct{ kind, plural string }{ + {"ConsoleCLIDownload", "consoleclidownloads"}, + {"ConsoleExternalLogLink", "consoleexternalloglinks"}, + {"ConsoleLink", "consolelinks"}, + {"ConsoleNotification", "consolenotifications"}, + {"ConsolePlugin", "consoleplugins"}, + {"ConsoleQuickStart", "consolequickstarts"}, + {"ConsoleSample", "consolesamples"}, + {"ConsoleYAMLSample", "consoleyamlsamples"}, + } + + defs := make([]*resourceDef, 0, len(kinds)) + for _, k := range kinds { + defs = append(defs, &resourceDef{ + group: apiGroupOSConsole, version: "v1", kind: k.kind, listKind: k.kind + "List", + plural: k.plural, namespaced: false, + }) + } + + return defs +} + +// openshiftOperatorDefs registers operator.openshift.io/v1 kinds: the +// cluster-scoped second-level operator config singletons plus the namespaced +// IngressController (the object that manages routers/Routes). +func openshiftOperatorDefs() []*resourceDef { + singletons := []struct{ kind, plural string }{ + {"Authentication", "authentications"}, + {"CloudCredential", "cloudcredentials"}, + {"Config", "configs"}, + {"Console", "consoles"}, + {"CSISnapshotController", "csisnapshotcontrollers"}, + {"DNS", "dnses"}, + {"Etcd", "etcds"}, + {"KubeAPIServer", "kubeapiservers"}, + {"KubeControllerManager", "kubecontrollermanagers"}, + {"KubeScheduler", "kubeschedulers"}, + {"Network", "networks"}, + {"OpenShiftAPIServer", "openshiftapiservers"}, + {"OpenShiftControllerManager", "openshiftcontrollermanagers"}, + {"ServiceCA", "servicecas"}, + {"Storage", "storages"}, + } + + defs := make([]*resourceDef, 0, len(singletons)+1) + for _, k := range singletons { + defs = append(defs, &resourceDef{ + group: apiGroupOSOperator, version: "v1", kind: k.kind, listKind: k.kind + "List", + plural: k.plural, namespaced: false, hasStatus: true, + }) + } + + return append(defs, &resourceDef{ + group: apiGroupOSOperator, version: "v1", kind: "IngressController", listKind: "IngressControllerList", + plural: "ingresscontrollers", namespaced: true, hasStatus: true, + }) +} + +// openshiftMachineDefs registers machine.openshift.io Machine/MachineSet/ +// MachineHealthCheck (v1beta1, namespaced) and ControlPlaneMachineSet (v1). +func openshiftMachineDefs() []*resourceDef { + return []*resourceDef{ + { + group: apiGroupOSMachine, version: "v1beta1", kind: "Machine", listKind: "MachineList", + plural: "machines", namespaced: true, hasStatus: true, + }, + { + group: apiGroupOSMachine, version: "v1beta1", kind: "MachineSet", listKind: "MachineSetList", + plural: "machinesets", namespaced: true, hasStatus: true, hasScale: true, + }, + { + group: apiGroupOSMachine, version: "v1beta1", kind: "MachineHealthCheck", + listKind: "MachineHealthCheckList", plural: "machinehealthchecks", namespaced: true, hasStatus: true, + }, + { + group: apiGroupOSMachine, version: "v1", kind: "ControlPlaneMachineSet", + listKind: "ControlPlaneMachineSetList", plural: "controlplanemachinesets", + namespaced: true, hasStatus: true, hasScale: true, + }, + } +} + +// openshiftAutoscalingDefs registers autoscaling.openshift.io ClusterAutoscaler +// (v1, cluster) and MachineAutoscaler (v1beta1, namespaced). +func openshiftAutoscalingDefs() []*resourceDef { + return []*resourceDef{ + { + group: apiGroupOSAutoscaling, version: "v1", kind: "ClusterAutoscaler", + listKind: "ClusterAutoscalerList", plural: "clusterautoscalers", namespaced: false, hasStatus: true, + }, + { + group: apiGroupOSAutoscaling, version: "v1beta1", kind: "MachineAutoscaler", + listKind: "MachineAutoscalerList", plural: "machineautoscalers", namespaced: true, hasStatus: true, + }, + } +} diff --git a/services/kubernetes/openshift_oauth.go b/services/kubernetes/openshift_oauth.go new file mode 100644 index 000000000..0648b41c6 --- /dev/null +++ b/services/kubernetes/openshift_oauth.go @@ -0,0 +1,312 @@ +package kubernetes + +import ( + "encoding/base64" + "net/http" + "net/url" + "strings" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/types" +) + +// OpenShift OAuth server — the surface `oc login` drives. +// +// `oc login -u -p ` runs a challenging-client flow: +// 1. GET /.well-known/oauth-authorization-server for the endpoints. +// 2. GET the authorization_endpoint with response_type=token; when unauthenticated +// the server challenges with WWW-Authenticate: Basic, oc retries with the +// credentials, and the server 302-redirects with the token in the URL fragment. +// 3. oc extracts #access_token and writes it into the kubeconfig, then calls +// GET users/~ (whoami) to confirm the identity. +// +// A real cluster serves this from a dedicated oauth-openshift.apps.* host and +// authenticates against a real identity provider. The emulator serves it inline +// under the cluster's own URL and accepts ANY credentials (it is an +// unauthenticated test backend) — the point is that the wire flow completes so +// `oc login` works, not that access is actually gated. +const ( + oauthWellKnownPath = "/.well-known/oauth-authorization-server" + oauthAuthorizePath = "/oauth/authorize" + oauthTokenPath = "/oauth/token" //nolint:gosec // endpoint path, not a credential. + oauthImplicitPath = "/oauth/token/implicit" + + // oauthDefaultUser is the identity returned when a caller presents no + // credentials (matches the well-known bootstrap admin name). + oauthDefaultUser = "kubeadmin" +) + +// isOpenShiftOAuthPath reports whether an already-prefix-stripped request path +// targets the OpenShift OAuth server. +func isOpenShiftOAuthPath(path string) bool { + return path == oauthWellKnownPath || strings.HasPrefix(path, "/oauth/") +} + +// isWhoamiPath reports whether path is the `oc whoami` self-user lookup. +func isWhoamiPath(path string) bool { + return path == "/apis/user.openshift.io/v1/users/~" +} + +// serveOAuth dispatches the OAuth discovery / authorize / token endpoints. +// absBase is the cluster's absolute URL (scheme://host/k8s/) — the OAuth +// metadata must advertise absolute endpoints, so the caller (APIServer.ServeHTTP) +// computes it from the live request before the /k8s/ prefix is stripped. +func (s *ClusterState) serveOAuth(w http.ResponseWriter, r *http.Request, absBase string) { + switch r.URL.Path { + case oauthWellKnownPath: + serveOAuthMetadata(w, absBase) + case oauthAuthorizePath: + s.serveOAuthAuthorize(w, r, absBase) + case oauthTokenPath: + s.serveOAuthToken(w, r) + default: + writeNotFound(w, "openshift oauth: unrecognized path "+r.URL.Path) + } +} + +// serveOAuthMetadata answers RFC 8414 authorization-server metadata, matching +// the shape captured from a live OCP cluster. +func serveOAuthMetadata(w http.ResponseWriter, absBase string) { + writeJSON(w, http.StatusOK, map[string]any{ + "issuer": absBase, + "authorization_endpoint": absBase + oauthAuthorizePath, + "token_endpoint": absBase + oauthTokenPath, + "scopes_supported": []string{ + "user:check-access", "user:full", "user:info", "user:list-projects", "user:list-scoped-projects", + }, + "response_types_supported": []string{"code", "token"}, + "grant_types_supported": []string{"authorization_code", "implicit"}, + "code_challenge_methods_supported": []string{"plain", "S256"}, + }) +} + +// serveOAuthAuthorize implements the challenging-client authorize endpoint. With +// no credentials it issues a Basic challenge; with credentials it mints a token +// and 302-redirects with the token in the fragment (response_type=token) or a +// code in the query (response_type=code). +func (s *ClusterState) serveOAuthAuthorize(w http.ResponseWriter, r *http.Request, absBase string) { + user, ok := basicAuthUser(r) + if !ok { + // Challenge — oc retries the request with Authorization: Basic. + w.Header().Set("WWW-Authenticate", `Basic realm="openshift"`) + writeStatus(w, http.StatusUnauthorized, metav1.StatusReasonUnauthorized, + "openshift oauth: authentication required") + + return + } + + q := r.URL.Query() + + // Resolve the redirect target from the server's OWN OAuth endpoint, not from + // the client-supplied string: an authorize endpoint that echoes an arbitrary + // redirect_uri is an open redirect that leaks the access token (carried in the + // fragment). The client's redirect_uri is only accepted when it matches the + // cluster's implicit endpoint; the value emitted is always server-constructed. + target, valid := allowedRedirectTarget(absBase, q.Get("redirect_uri")) + if !valid { + writeBadRequest(w, "openshift oauth: redirect_uri does not match the cluster OAuth endpoint") + + return + } + + token := s.mintOAuthToken(user) + + if q.Get("response_type") == "code" { + redirectWithCode(w, r, target, q.Get("state"), token) + + return + } + + // Default (response_type=token) — the implicit flow oc login uses. + frag := url.Values{ + "access_token": {token}, + "expires_in": {"86400"}, + "scope": {"user:full"}, + "token_type": {"Bearer"}, + } + if st := q.Get("state"); st != "" { + frag.Set("state", st) + } + + http.Redirect(w, r, target+"#"+frag.Encode(), http.StatusFound) +} + +// redirectWithCode 302-redirects an authorization-code response to the +// server-constructed target (already validated by the caller). The emulator +// reuses the minted token as the code (a real server exchanges the code at the +// token endpoint; here the token endpoint just re-mints, so either resolves). +func redirectWithCode(w http.ResponseWriter, r *http.Request, target, state, code string) { + q := url.Values{"code": {code}} + if state != "" { + q.Set("state", state) + } + + sep := "?" + if strings.Contains(target, "?") { + sep = "&" + } + + http.Redirect(w, r, target+sep+q.Encode(), http.StatusFound) +} + +// allowedRedirectTarget resolves the OAuth redirect target from the server's own +// absolute base — the cluster's /oauth/token/implicit endpoint. An empty +// redirect_uri defaults to it; a supplied redirect_uri is accepted only when it +// exactly matches it (the challenging client always sends this). The returned +// value is ALWAYS the server-constructed URL, never the client string, so it +// cannot be an open redirect regardless of what the client sends. +func allowedRedirectTarget(absBase, redirectURI string) (string, bool) { + expected := absBase + oauthImplicitPath + if redirectURI == "" || redirectURI == expected { + return expected, true + } + + return "", false +} + +// serveOAuthToken implements the token endpoint (authorization_code grant that +// oc's PKCE flow uses). The request's Basic auth is the CLIENT +// (openshift-challenging-client), not the user — the user identity rides on the +// `code`, which redirectWithCode set to a user-bound token minted at authorize. +// So the code is looked up to recover the user and returned as the access token; +// only when there is no resolvable code does it fall back to minting a fresh one. +func (s *ClusterState) serveOAuthToken(w http.ResponseWriter, r *http.Request) { + code := r.FormValue("code") // ParseForm reads the urlencoded POST body. + + token := code + if token == "" || s.userForToken(token) == "" { + token = s.mintOAuthToken(oauthDefaultUser) + } + + writeJSON(w, http.StatusOK, map[string]any{ + "access_token": token, + "token_type": "Bearer", + "expires_in": 86400, + "scope": "user:full", + }) +} + +// userForToken returns the user a token was minted for, or "" if unknown. +func (s *ClusterState) userForToken(token string) string { + s.mu.RLock() + defer s.mu.RUnlock() + + return s.oauthTokens[token] +} + +// serveWhoami answers GET users/~ — the self-user lookup `oc whoami` and +// `oc login` use to confirm the identity. It resolves the caller from the +// bearer token minted at login, defaulting to the bootstrap admin. +func (s *ClusterState) serveWhoami(w http.ResponseWriter, r *http.Request) { + user := s.userForRequest(r) + + s.mu.RLock() + st := s.reg.getStore(apiGroupOSUser, "v1", "users") + + var stored *unstructured.Unstructured + if st != nil { + stored = st.items[objKey("", user)] + } + s.mu.RUnlock() + + if stored != nil { + writeJSON(w, http.StatusOK, stored.DeepCopy()) + + return + } + + writeJSON(w, http.StatusOK, newUserObject(user)) +} + +// userForRequest resolves the username for a request from its bearer token, or +// the default admin when the token is unknown/absent (the emulator is +// unauthenticated, so an unrecognized token still resolves to a usable identity). +func (s *ClusterState) userForRequest(r *http.Request) string { + token := bearerToken(r) + if token == "" { + return oauthDefaultUser + } + + s.mu.RLock() + defer s.mu.RUnlock() + + if u, ok := s.oauthTokens[token]; ok { + return u + } + + return oauthDefaultUser +} + +// mintOAuthToken issues a fresh OpenShift-style bearer token for user, records +// the token->user mapping for whoami, and upserts the User object so a +// subsequent `oc get user` sees it. +func (s *ClusterState) mintOAuthToken(user string) string { + token := "sha256~" + newUID() + newUID() + + s.mu.Lock() + defer s.mu.Unlock() + + s.oauthTokens[token] = user + + if st := s.reg.getStore(apiGroupOSUser, "v1", "users"); st != nil { + key := objKey("", user) + if _, exists := st.items[key]; !exists { + st.items[key] = newUserObject(user) + } + } + + return token +} + +// newUserObject builds a user.openshift.io/v1 User with the default groups a +// real cluster reports for an authenticated OAuth identity. +func newUserObject(name string) *unstructured.Unstructured { + u := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": apiGroupOSUser + "/v1", + "kind": "User", + "metadata": map[string]any{ + "name": name, + "creationTimestamp": nil, + }, + "fullName": name, + "identities": []any{"cloudemu:" + name}, + "groups": []any{"system:authenticated", "system:authenticated:oauth"}, + }} + u.SetUID(types.UID(newUID())) + u.SetResourceVersion("1") + + return u +} + +// basicAuthUser extracts the username from a Basic Authorization header. The +// password is ignored — the emulator authenticates no one. Returns ok=false +// when no Basic credentials are present (so the caller can issue a challenge). +func basicAuthUser(r *http.Request) (string, bool) { + h := r.Header.Get("Authorization") + if !strings.HasPrefix(h, "Basic ") { + return "", false + } + + raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(h, "Basic ")) + if err != nil { + return "", false + } + + user, _, found := strings.Cut(string(raw), ":") + if !found || user == "" { + return "", false + } + + return user, true +} + +// bearerToken extracts a Bearer token from the Authorization header, or "". +func bearerToken(r *http.Request) string { + h := r.Header.Get("Authorization") + if !strings.HasPrefix(h, "Bearer ") { + return "" + } + + return strings.TrimPrefix(h, "Bearer ") +} diff --git a/services/kubernetes/openshift_oc_e2e_test.go b/services/kubernetes/openshift_oc_e2e_test.go new file mode 100644 index 000000000..db3349c52 --- /dev/null +++ b/services/kubernetes/openshift_oc_e2e_test.go @@ -0,0 +1,82 @@ +package kubernetes_test + +import ( + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/stackshy/cloudemu/v2/internal/k8spki" + "github.com/stackshy/cloudemu/v2/services/kubernetes" +) + +// ocBinary returns the path to a real `oc` CLI to exercise, or "" if none is +// configured. Set OC_BIN, else fall back to a conventional local path. When +// absent the E2E test skips, so this never breaks CI on machines without oc. +func ocBinary() string { + if p := os.Getenv("OC_BIN"); p != "" { + return p + } + + const fallback = "/tmp/ostools/oc" + if _, err := os.Stat(fallback); err == nil { + return fallback + } + + return "" +} + +// TestOpenShift_OcLoginE2E drives the REAL `oc` CLI against the emulator: it +// runs `oc login -u developer -p ...` (the challenging-client OAuth flow) and +// then `oc whoami`, asserting the whole wire flow completes end-to-end from a +// user's perspective. Skips when no oc binary is available. +func TestOpenShift_OcLoginE2E(t *testing.T) { + oc := ocBinary() + if oc == "" { + t.Skip("no oc binary (set OC_BIN or place one at /tmp/ostools/oc)") + } + + api := kubernetes.NewAPIServer() + uid, _ := api.RegisterClusterWithFlavor(kubernetes.FlavorOpenShift) + // oc's OAuth challenge path assumes an HTTPS endpoint (real clusters always + // are) and dereferences the TLS transport, so serve over TLS. Use the REAL + // serving cert (k8spki.ServingTLSConfig) rather than httptest's own cert, so + // this E2E exercises the exact certificate path the `serve` binary presents — + // catching regressions like a leaf validity that strict TLS clients reject. + tlsCfg, err := k8spki.ServingTLSConfig([]string{"127.0.0.1", "localhost"}) + if err != nil { + t.Fatalf("serving TLS config: %v", err) + } + + ts := httptest.NewUnstartedServer(api) + ts.TLS = tlsCfg + ts.StartTLS() + t.Cleanup(ts.Close) + + server := ts.URL + "/k8s/" + uid + kubeconfig := filepath.Join(t.TempDir(), "kubeconfig") + + env := append(os.Environ(), "KUBECONFIG="+kubeconfig) + + login := exec.Command(oc, "login", server, //nolint:gosec // test-controlled args. + "-u", "developer", "-p", "anything", "--insecure-skip-tls-verify=true") + login.Env = env + + if out, err := login.CombinedOutput(); err != nil { + t.Fatalf("oc login failed: %v\n%s", err, out) + } + + who := exec.Command(oc, "whoami") //nolint:gosec // test-controlled args. + who.Env = env + + out, err := who.CombinedOutput() + if err != nil { + t.Fatalf("oc whoami failed: %v\n%s", err, out) + } + + if got := strings.TrimSpace(string(out)); got != "developer" { + t.Errorf("oc whoami: got %q, want developer", got) + } +} diff --git a/services/kubernetes/openshift_project.go b/services/kubernetes/openshift_project.go new file mode 100644 index 000000000..4cec2d325 --- /dev/null +++ b/services/kubernetes/openshift_project.go @@ -0,0 +1,123 @@ +package kubernetes + +import ( + "net/http" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/types" +) + +// projectRequestPath is the POST-only `oc new-project` verb. A ProjectRequest is +// not a stored kind — posting one provisions a namespace and its paired Project. +const projectRequestPath = "/apis/project.openshift.io/v1/projectrequests" + +// serveProjectRequestList answers GET on the projectrequests collection with an +// empty ProjectRequestList. `oc new-project` issues this GET before its POST to +// check whether the caller may request projects; on this unauthenticated backend +// anyone may, so an empty list (HTTP 200) lets the command proceed to the POST. +func serveProjectRequestList(w http.ResponseWriter) { + writeJSON(w, http.StatusOK, map[string]any{ + "apiVersion": apiGroupOSProject + "/v1", + "kind": "ProjectRequestList", + "metadata": map[string]any{}, + "items": []any{}, + }) +} + +// serveProjectRequest implements `oc new-project`: it creates the backing +// Namespace (the real tenancy boundary) and the paired Project object, then +// returns the Project. On a real cluster the project controller creates the +// Namespace from the request; the emulator does both inline so the new project +// is immediately usable for `oc apply`. +func (s *ClusterState) serveProjectRequest(w http.ResponseWriter, r *http.Request) { + var req struct { + Metadata struct { + Name string `json:"name"` + } `json:"metadata"` + DisplayName string `json:"displayName"` + Description string `json:"description"` + } + + if !readJSON(w, r, &req) { + return + } + + name := req.Metadata.Name + if name == "" { + writeBadRequest(w, "openshift: projectrequest metadata.name is required") + + return + } + + s.mu.Lock() + defer s.mu.Unlock() + + st := s.reg.getStore(apiGroupOSProject, "v1", "projects") + if st == nil { + writeNotFound(w, "openshift: project store unavailable") + + return + } + + if _, exists := st.items[objKey("", name)]; exists { + writeAlreadyExists(w, "openshift: project already exists: "+name) + + return + } + + s.ensureNamespaceLocked(name) + + proj := newProjectObject(name, req.DisplayName, req.Description) + proj.SetCreationTimestamp(s.now()) + reconcileProject(s, proj) + st.stampRVLocked(proj) + st.items[objKey("", name)] = proj + st.watch.publish(EventAdded, "", *proj.DeepCopy()) + + writeJSON(w, http.StatusCreated, proj) +} + +// ensureNamespaceLocked creates the namespace (and its default ServiceAccount) +// if absent, mirroring createNamespace's bootstrap. Callers hold s.mu. +func (s *ClusterState) ensureNamespaceLocked(name string) { + if _, ok := s.namespaces[name]; ok { + return + } + + ns := s.newNamespaceObject(name) + s.namespaces[name] = ns + + sa := s.newServiceAccountObject(name, "default") + s.serviceAccounts[serviceAccountKey(name, "default")] = sa + + s.wNamespaces.publish(EventAdded, "", *ns.DeepCopy()) + s.wServiceAccounts.publish(EventAdded, name, *sa.DeepCopy()) +} + +// newProjectObject builds a project.openshift.io/v1 Project. displayName and +// description, when set, become the openshift.io/{display-name,description} +// annotations a real ProjectRequest records. +func newProjectObject(name, displayName, description string) *unstructured.Unstructured { + annotations := map[string]any{} + if displayName != "" { + annotations["openshift.io/display-name"] = displayName + } + + if description != "" { + annotations["openshift.io/description"] = description + } + + proj := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": apiGroupOSProject + "/v1", + "kind": "Project", + "metadata": map[string]any{ + "name": name, + "annotations": annotations, + }, + "status": map[string]any{"phase": "Active"}, + }} + proj.SetUID(types.UID(newUID())) + proj.SetGeneration(1) + + return proj +} diff --git a/services/kubernetes/openshift_reconcile.go b/services/kubernetes/openshift_reconcile.go new file mode 100644 index 000000000..769745fd3 --- /dev/null +++ b/services/kubernetes/openshift_reconcile.go @@ -0,0 +1,147 @@ +package kubernetes + +import ( + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +// OpenShift synthesized-domain constants. A real cluster derives these from its +// ingress config (*.apps.); the emulator has no DNS, so it uses +// a stable synthetic apps domain for admitted Route hosts and the integrated +// registry's public repository — matching the SHAPE captured from a live cluster +// so tooling parsing these values sees the expected structure. +const ( + openShiftAppsDomain = "apps.cloudemu.local" + openShiftInternalReg = "image-registry.openshift-image-registry.svc:5000" + openShiftPublicRegPrefix = "default-route-openshift-image-registry." + openShiftAppsDomain + openShiftDefaultRouter = "default" +) + +// reconcileRoute admits a Route the way the OpenShift router does: it fills an +// empty spec.host with the synthesized -.apps. and +// publishes a status.ingress entry marked Admitted=True. Real OpenShift assigns +// the host and admission asynchronously via the router; the emulator does it +// synchronously so a create is immediately reachable/inspectable, mirroring the +// captured status.ingress[].conditions[Admitted] shape. +func reconcileRoute(_ *ClusterState, obj *unstructured.Unstructured) { + host, _, _ := unstructured.NestedString(obj.Object, "spec", "host") + if host == "" { + host = obj.GetName() + "-" + obj.GetNamespace() + "." + openShiftAppsDomain + _ = unstructured.SetNestedField(obj.Object, host, "spec", "host") + } + + wildcard, found, _ := unstructured.NestedString(obj.Object, "spec", "wildcardPolicy") + if !found || wildcard == "" { + wildcard = "None" + } + + ingress := []any{map[string]any{ + "host": host, + "routerName": openShiftDefaultRouter, + "routerCanonicalHostname": "router-" + openShiftDefaultRouter + "." + openShiftAppsDomain, + "wildcardPolicy": wildcard, + "conditions": []any{ + map[string]any{"type": "Admitted", "status": "True"}, + }, + }} + + _ = unstructured.SetNestedSlice(obj.Object, ingress, "status", "ingress") +} + +// reconcileDeploymentConfig converges a DeploymentConfig the way the emulator +// converges a ReplicaSet: it materializes spec.replicas Running Pods owned by +// the DC and reports them in status. It additionally maintains the +// DeploymentConfig-specific status fields — latestVersion (the rollout counter, +// started at 1 and bumped when the spec changes) and the Available/Progressing +// conditions — so `oc rollout status dc/` and `oc get dc` read correctly. +// +// A real cluster interposes a ReplicationController and a deployer Pod per +// rollout; the emulator converges Pods directly (its established model for every +// workload controller), which keeps `oc get pods` correct without modeling the +// deprecated RC materialization. +func reconcileDeploymentConfig(s *ClusterState, obj *unstructured.Unstructured) { + // Read the prior rollout counter/observed generation BEFORE setWorkloadStatus + // overwrites observedGeneration, so latestVersion can bump on a spec change. + prevLatest, _, _ := unstructured.NestedInt64(obj.Object, "status", "latestVersion") + prevObserved, _, _ := unstructured.NestedInt64(obj.Object, "status", "observedGeneration") + + requested := rawReplicasOf(obj) + desired := clampPodCount(requested) + noteClampUnstructured(obj, requested, desired) + + ready := s.syncScaledPods(obj.GetNamespace(), obj.GetName(), ownerRefOf(obj), + podTemplateFromUnstructured(obj), desired) + setWorkloadStatus(obj, ready) + + latest := prevLatest + + switch { + case latest == 0: + latest = 1 // first rollout + case obj.GetGeneration() > prevObserved: + latest++ // spec changed since last observation -> new rollout + } + + _ = unstructured.SetNestedField(obj.Object, latest, "status", "latestVersion") + _ = unstructured.SetNestedSlice(obj.Object, deploymentConfigConditions(), "status", "conditions") + + s.resyncEndpointsForNamespaceLocked(obj.GetNamespace()) +} + +// deploymentConfigConditions returns the Available/Progressing conditions a +// rolled-out DeploymentConfig reports. +func deploymentConfigConditions() []any { + return []any{ + map[string]any{"type": "Available", "status": "True", + "message": "Deployment config has minimum availability."}, + map[string]any{"type": "Progressing", "status": "True", + "reason": "NewReplicationControllerAvailable", "message": "replication controller successfully rolled out"}, + } +} + +// reconcileImageStream fills an ImageStream's status with the integrated-registry +// repositories a real cluster synthesizes: the in-cluster +// image-registry.openshift-image-registry.svc:5000// and the public +// default-route-openshift-image-registry.//. Matches the +// captured status.dockerImageRepository / publicDockerImageRepository shape. +func reconcileImageStream(_ *ClusterState, obj *unstructured.Unstructured) { + repo := obj.GetNamespace() + "/" + obj.GetName() + + _ = unstructured.SetNestedField(obj.Object, + openShiftInternalReg+"/"+repo, "status", "dockerImageRepository") + _ = unstructured.SetNestedField(obj.Object, + openShiftPublicRegPrefix+"/"+repo, "status", "publicDockerImageRepository") +} + +// reconcileProject stamps the openshift.io/sa.scc.* annotations OpenShift's +// project controller injects on every project: the UID range, SELinux MCS +// labels, and supplemental groups that the restricted SCC allocates pods from. +// The emulator hands out a fixed, deterministic range (a real cluster allocates +// per-project); the point is that the annotations EXIST and parse, since tooling +// and pod admission read them. Only stamps them when absent, so a client that +// sets its own is not overwritten. +func reconcileProject(_ *ClusterState, obj *unstructured.Unstructured) { + ann := obj.GetAnnotations() + if ann == nil { + ann = map[string]string{} + } + + defaults := map[string]string{ + "openshift.io/sa.scc.uid-range": "1000000000/10000", + "openshift.io/sa.scc.supplemental-groups": "1000000000/10000", + "openshift.io/sa.scc.mcs": "s0:c10,c5", + "openshift.io/node-selector": "", + } + + changed := false + + for k, v := range defaults { + if _, ok := ann[k]; !ok { + ann[k] = v + changed = true + } + } + + if changed { + obj.SetAnnotations(ann) + } +} diff --git a/services/kubernetes/openshift_template.go b/services/kubernetes/openshift_template.go new file mode 100644 index 000000000..d6f763ffb --- /dev/null +++ b/services/kubernetes/openshift_template.go @@ -0,0 +1,250 @@ +package kubernetes + +import ( + "crypto/rand" + "encoding/json" + "net/http" + "strings" +) + +// `oc process` server-side: POST a Template to +// /apis/template.openshift.io/v1/namespaces//processedtemplates and get the +// Template back with every parameter resolved (provided value, else a value +// generated from its `generate`/`from` expression) and every ${PARAM} / +// ${{PARAM}} reference in the objects substituted. It does NOT create the +// objects — that is what a client then pipes into `oc apply`. This matches the +// real processedtemplates endpoint's contract. + +// processedTemplateTarget returns the namespace a processedtemplates POST +// targets, or ok=false when the path is not that endpoint. +func processedTemplateTarget(path string) (namespace string, ok bool) { + const marker = "/apis/template.openshift.io/v1/namespaces/" + if !strings.HasPrefix(path, marker) || !strings.HasSuffix(path, "/processedtemplates") { + return "", false + } + + rest := strings.TrimSuffix(strings.TrimPrefix(path, marker), "/processedtemplates") + if rest == "" || strings.Contains(rest, "/") { + return "", false + } + + return rest, true +} + +// serveProcessedTemplate resolves a Template's parameters and substitutes them +// into its objects, returning the processed Template. +func (*ClusterState) serveProcessedTemplate(w http.ResponseWriter, r *http.Request) { + var tmpl map[string]any + if err := json.NewDecoder(r.Body).Decode(&tmpl); err != nil { + writeBadRequest(w, "openshift: processedtemplate: invalid body: "+err.Error()) + + return + } + + values := resolveTemplateParameters(tmpl) + + if objs, ok := tmpl["objects"].([]any); ok { + substituted, err := substituteTemplateObjects(objs, values) + if err != nil { + writeBadRequest(w, "openshift: processedtemplate: "+err.Error()) + + return + } + + tmpl["objects"] = substituted + } + + tmpl["apiVersion"] = apiGroupOSTemplate + "/v1" + tmpl["kind"] = "Template" + + writeJSON(w, http.StatusOK, tmpl) +} + +// resolveTemplateParameters fills each parameter's value in place (using the +// provided value, else generating one from its expression) and returns the +// name->value map used for substitution. +func resolveTemplateParameters(tmpl map[string]any) map[string]string { + values := map[string]string{} + + params, ok := tmpl["parameters"].([]any) + if !ok { + return values + } + + for _, raw := range params { + p, ok := raw.(map[string]any) + if !ok { + continue + } + + name, _ := p["name"].(string) + if name == "" { + continue + } + + value, _ := p["value"].(string) + if value == "" { + if from, _ := p["from"].(string); from != "" { + if gen, _ := p["generate"].(string); gen != "" { + value = genFromExpression(from) + } + } + } + + p["value"] = value + values[name] = value + } + + return values +} + +// substituteTemplateObjects replaces ${{PARAM}} (unquoted, for numeric/bool +// fields) then ${PARAM} (string) across each object's JSON. Two passes over the +// marshaled form keep it type-agnostic without walking the object tree. +func substituteTemplateObjects(objs []any, values map[string]string) ([]any, error) { + out := make([]any, 0, len(objs)) + + for _, obj := range objs { + b, err := json.Marshal(obj) + if err != nil { + return nil, err + } + + text := string(b) + for name, value := range values { + text = strings.ReplaceAll(text, "${{"+name+"}}", value) + text = strings.ReplaceAll(text, "${"+name+"}", value) + } + + var decoded any + if err := json.Unmarshal([]byte(text), &decoded); err != nil { + return nil, err + } + + out = append(out, decoded) + } + + return out, nil +} + +// genFromExpression expands an OpenShift parameter `from` pseudo-regex — a +// sequence of literal characters and [class]{count} segments (e.g. +// "[a-z0-9]{16}") — into a random string. Unsupported constructs degrade to +// their literal characters. +func genFromExpression(from string) string { + var b strings.Builder + + runes := []rune(from) + for i := 0; i < len(runes); { + if runes[i] != '[' { + b.WriteRune(runes[i]) + + i++ + + continue + } + + closeIdx := indexRune(runes, ']', i+1) + if closeIdx < 0 { + b.WriteRune(runes[i]) + + i++ + + continue + } + + charset := expandCharClass(string(runes[i+1 : closeIdx])) + + count, next := parseCount(runes, closeIdx+1) + if count == 0 { + count = 1 + } + + b.WriteString(genRandomString(charset, count)) + + i = next + } + + return b.String() +} + +// expandCharClass expands a character class body ("a-z0-9", "A-Za-z") into the +// concrete set of characters it matches. +func expandCharClass(body string) string { + var b strings.Builder + + r := []rune(body) + for i := 0; i < len(r); { + if i+2 < len(r) && r[i+1] == '-' { + for c := r[i]; c <= r[i+2]; c++ { + b.WriteRune(c) + } + + i += 3 + + continue + } + + b.WriteRune(r[i]) + + i++ + } + + if b.Len() == 0 { + return "abcdefghijklmnopqrstuvwxyz0123456789" + } + + return b.String() +} + +// parseCount reads a {n} quantifier starting at i, returning the count and the +// index past the '}'. Without a quantifier it returns (1, i). +func parseCount(runes []rune, i int) (count, next int) { + if i >= len(runes) || runes[i] != '{' { + return 1, i + } + + closeIdx := indexRune(runes, '}', i+1) + if closeIdx < 0 { + return 1, i + } + + n := 0 + + for _, c := range runes[i+1 : closeIdx] { + if c < '0' || c > '9' { + return 1, i + } + + n = n*10 + int(c-'0') + } + + return n, closeIdx + 1 +} + +func indexRune(runes []rune, target rune, from int) int { + for i := from; i < len(runes); i++ { + if runes[i] == target { + return i + } + } + + return -1 +} + +// genRandomString returns n characters drawn uniformly from charset. +func genRandomString(charset string, n int) string { + if charset == "" || n <= 0 { + return "" + } + + buf := make([]byte, n) + _, _ = rand.Read(buf) + + cs := []byte(charset) + for i := range buf { + buf[i] = cs[int(buf[i])%len(cs)] + } + + return string(buf) +} diff --git a/services/kubernetes/openshift_test.go b/services/kubernetes/openshift_test.go new file mode 100644 index 000000000..d086e79a8 --- /dev/null +++ b/services/kubernetes/openshift_test.go @@ -0,0 +1,883 @@ +package kubernetes_test + +import ( + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/stackshy/cloudemu/v2/services/kubernetes" +) + +// openshiftBase registers an OpenShift-flavored cluster on a fresh httptest +// server and returns the per-cluster base URL (/k8s/). +func openshiftBase(t *testing.T) string { + t.Helper() + + api := kubernetes.NewAPIServer() + uid, _ := api.RegisterClusterWithFlavor(kubernetes.FlavorOpenShift) + ts := httptest.NewServer(api) + t.Cleanup(ts.Close) + + return ts.URL + "/k8s/" + uid +} + +// TestOpenShift_ConfigGroupInDiscovery asserts an OpenShift cluster advertises +// config.openshift.io in /apis discovery and lists its singleton kinds under +// the group-version — the surface `oc` negotiates before any config read. +func TestOpenShift_ConfigGroupInDiscovery(t *testing.T) { + base := openshiftBase(t) + + resp := mustDo(t, http.MethodGet, base+"/apis", nil) + defer resp.Body.Close() + + var groups struct { + Groups []struct { + Name string `json:"name"` + } `json:"groups"` + } + + mustDecode(t, resp.Body, &groups) + + if !hasGroup(groups.Groups, "config.openshift.io") { + t.Fatalf("config.openshift.io not advertised in /apis; groups=%+v", groups.Groups) + } + + gv := mustDo(t, http.MethodGet, base+"/apis/config.openshift.io/v1", nil) + defer gv.Body.Close() + + var rl struct { + Resources []struct { + Name string `json:"name"` + Kind string `json:"kind"` + } `json:"resources"` + } + + mustDecode(t, gv.Body, &rl) + + for _, want := range []string{"clusterversions", "infrastructures"} { + if !hasResource(rl.Resources, want) { + t.Fatalf("resource %q missing from config.openshift.io/v1 discovery", want) + } + } +} + +// TestOpenShift_IdentitySingletonsSeeded asserts a fresh OpenShift cluster boots +// with the ClusterVersion "version" and Infrastructure "cluster" objects, at the +// wire shapes captured from a live OCP cluster. +func TestOpenShift_IdentitySingletonsSeeded(t *testing.T) { + base := openshiftBase(t) + + cv := mustDo(t, http.MethodGet, base+"/apis/config.openshift.io/v1/clusterversions/version", nil) + defer cv.Body.Close() + + if cv.StatusCode != http.StatusOK { + t.Fatalf("GET clusterversion/version: status %d, want 200", cv.StatusCode) + } + + var cvObj struct { + Spec struct { + Channel string `json:"channel"` + ClusterID string `json:"clusterID"` + } `json:"spec"` + Status struct { + Desired struct { + Version string `json:"version"` + } `json:"desired"` + } `json:"status"` + } + + mustDecode(t, cv.Body, &cvObj) + + if cvObj.Spec.Channel != "stable-4.16" { + t.Errorf("clusterversion channel: got %q, want stable-4.16", cvObj.Spec.Channel) + } + + if cvObj.Status.Desired.Version != "4.16.0" { + t.Errorf("clusterversion desired version: got %q, want 4.16.0", cvObj.Status.Desired.Version) + } + + if cvObj.Spec.ClusterID == "" { + t.Error("clusterversion spec.clusterID is empty") + } + + infra := mustDo(t, http.MethodGet, base+"/apis/config.openshift.io/v1/infrastructures/cluster", nil) + defer infra.Body.Close() + + if infra.StatusCode != http.StatusOK { + t.Fatalf("GET infrastructure/cluster: status %d, want 200", infra.StatusCode) + } + + var infraObj struct { + Status struct { + InfrastructureName string `json:"infrastructureName"` + Platform string `json:"platform"` + } `json:"status"` + } + + mustDecode(t, infra.Body, &infraObj) + + if infraObj.Status.InfrastructureName == "" { + t.Error("infrastructure status.infrastructureName is empty") + } +} + +// TestKubernetes_FlavorHidesOpenShiftGroups asserts flavor gating: a vanilla +// (EKS/AKS/GKE-style) cluster must NOT advertise or serve the *.openshift.io +// groups, and must not carry the identity singletons. +func TestKubernetes_FlavorHidesOpenShiftGroups(t *testing.T) { + api := kubernetes.NewAPIServer() + uid, _ := api.RegisterCluster() // FlavorKubernetes + ts := httptest.NewServer(api) + t.Cleanup(ts.Close) + + base := ts.URL + "/k8s/" + uid + + resp := mustDo(t, http.MethodGet, base+"/apis", nil) + defer resp.Body.Close() + + var groups struct { + Groups []struct { + Name string `json:"name"` + } `json:"groups"` + } + + mustDecode(t, resp.Body, &groups) + + if hasGroup(groups.Groups, "config.openshift.io") { + t.Fatal("Kubernetes-flavored cluster leaked config.openshift.io into discovery") + } + + cv := mustDo(t, http.MethodGet, base+"/apis/config.openshift.io/v1/clusterversions/version", nil) + defer cv.Body.Close() + + if cv.StatusCode != http.StatusNotFound { + t.Fatalf("GET clusterversion on Kubernetes cluster: status %d, want 404", cv.StatusCode) + } +} + +// TestOpenShift_AllBaseGroupsAdvertised asserts every base OKD group the PR +// registers surfaces in /apis discovery on an OpenShift cluster. +func TestOpenShift_AllBaseGroupsAdvertised(t *testing.T) { + base := openshiftBase(t) + + resp := mustDo(t, http.MethodGet, base+"/apis", nil) + defer resp.Body.Close() + + var groups struct { + Groups []struct { + Name string `json:"name"` + } `json:"groups"` + } + + mustDecode(t, resp.Body, &groups) + + want := []string{ + "config.openshift.io", "apps.openshift.io", "route.openshift.io", + "build.openshift.io", "image.openshift.io", "project.openshift.io", + "user.openshift.io", "oauth.openshift.io", "security.openshift.io", + "quota.openshift.io", "authorization.openshift.io", "template.openshift.io", + } + + for _, g := range want { + if !hasGroup(groups.Groups, g) { + t.Errorf("group %q not advertised in /apis discovery", g) + } + } +} + +// TestOpenShift_RouteCRUD round-trips a namespaced Route through the generic +// store: create, get, list. +func TestOpenShift_RouteCRUD(t *testing.T) { + base := openshiftBase(t) + col := base + "/apis/route.openshift.io/v1/namespaces/default/routes" + + body := []byte(`{ + "apiVersion":"route.openshift.io/v1","kind":"Route", + "metadata":{"name":"web"}, + "spec":{"host":"web.example.com","to":{"kind":"Service","name":"web","weight":100},"wildcardPolicy":"None"} + }`) + + create := mustDo(t, http.MethodPost, col, body) + defer create.Body.Close() + + if create.StatusCode != http.StatusCreated { + t.Fatalf("create Route: status %d, want 201", create.StatusCode) + } + + get := mustDo(t, http.MethodGet, col+"/web", nil) + defer get.Body.Close() + + if get.StatusCode != http.StatusOK { + t.Fatalf("get Route: status %d, want 200", get.StatusCode) + } + + var route struct { + Spec struct { + Host string `json:"host"` + } `json:"spec"` + } + + mustDecode(t, get.Body, &route) + + if route.Spec.Host != "web.example.com" { + t.Errorf("route spec.host: got %q, want web.example.com", route.Spec.Host) + } +} + +// TestOpenShift_UserCRUD round-trips a cluster-scoped User. +func TestOpenShift_UserCRUD(t *testing.T) { + base := openshiftBase(t) + col := base + "/apis/user.openshift.io/v1/users" + + body := []byte(`{"apiVersion":"user.openshift.io/v1","kind":"User","metadata":{"name":"alice"},"fullName":"Alice"}`) + + create := mustDo(t, http.MethodPost, col, body) + defer create.Body.Close() + + if create.StatusCode != http.StatusCreated { + t.Fatalf("create User: status %d, want 201", create.StatusCode) + } + + get := mustDo(t, http.MethodGet, col+"/alice", nil) + defer get.Body.Close() + + if get.StatusCode != http.StatusOK { + t.Fatalf("get User: status %d, want 200", get.StatusCode) + } +} + +// TestOpenShift_RouteAdmission asserts the Route reconcile synthesizes a host +// when none is given and publishes an Admitted status.ingress entry. +func TestOpenShift_RouteAdmission(t *testing.T) { + base := openshiftBase(t) + col := base + "/apis/route.openshift.io/v1/namespaces/default/routes" + + // No spec.host — the router must synthesize one. + body := []byte(`{ + "apiVersion":"route.openshift.io/v1","kind":"Route", + "metadata":{"name":"api"}, + "spec":{"to":{"kind":"Service","name":"api","weight":100}} + }`) + + create := mustDo(t, http.MethodPost, col, body) + defer create.Body.Close() + + if create.StatusCode != http.StatusCreated { + t.Fatalf("create Route: status %d, want 201", create.StatusCode) + } + + var route struct { + Spec struct { + Host string `json:"host"` + } `json:"spec"` + Status struct { + Ingress []struct { + Host string `json:"host"` + RouterName string `json:"routerName"` + Conditions []struct { + Type string `json:"type"` + Status string `json:"status"` + } `json:"conditions"` + } `json:"ingress"` + } `json:"status"` + } + + mustDecode(t, create.Body, &route) + + wantHost := "api-default." + "apps.cloudemu.local" + if route.Spec.Host != wantHost { + t.Errorf("synthesized spec.host: got %q, want %q", route.Spec.Host, wantHost) + } + + if len(route.Status.Ingress) != 1 { + t.Fatalf("status.ingress: got %d entries, want 1", len(route.Status.Ingress)) + } + + ing := route.Status.Ingress[0] + if ing.Host != wantHost { + t.Errorf("status.ingress[0].host: got %q, want %q", ing.Host, wantHost) + } + + if ing.RouterName != "default" { + t.Errorf("status.ingress[0].routerName: got %q, want default", ing.RouterName) + } + + if len(ing.Conditions) != 1 || ing.Conditions[0].Type != "Admitted" || ing.Conditions[0].Status != "True" { + t.Errorf("status.ingress[0].conditions: got %+v, want [Admitted=True]", ing.Conditions) + } +} + +// TestOpenShift_ImageStreamStatus asserts the ImageStream reconcile synthesizes +// the integrated-registry repositories at the captured shape. +func TestOpenShift_ImageStreamStatus(t *testing.T) { + base := openshiftBase(t) + col := base + "/apis/image.openshift.io/v1/namespaces/default/imagestreams" + + body := []byte(`{"apiVersion":"image.openshift.io/v1","kind":"ImageStream","metadata":{"name":"app"}}`) + + create := mustDo(t, http.MethodPost, col, body) + defer create.Body.Close() + + if create.StatusCode != http.StatusCreated { + t.Fatalf("create ImageStream: status %d, want 201", create.StatusCode) + } + + var is struct { + Status struct { + DockerImageRepository string `json:"dockerImageRepository"` + PublicDockerImageRepository string `json:"publicDockerImageRepository"` + } `json:"status"` + } + + mustDecode(t, create.Body, &is) + + wantInternal := "image-registry.openshift-image-registry.svc:5000/default/app" + if is.Status.DockerImageRepository != wantInternal { + t.Errorf("dockerImageRepository: got %q, want %q", is.Status.DockerImageRepository, wantInternal) + } + + wantPublic := "default-route-openshift-image-registry.apps.cloudemu.local/default/app" + if is.Status.PublicDockerImageRepository != wantPublic { + t.Errorf("publicDockerImageRepository: got %q, want %q", is.Status.PublicDockerImageRepository, wantPublic) + } +} + +// TestOpenShift_ProjectAnnotations asserts the Project reconcile stamps the +// openshift.io/sa.scc.* annotations OpenShift's project controller injects. +func TestOpenShift_ProjectAnnotations(t *testing.T) { + base := openshiftBase(t) + col := base + "/apis/project.openshift.io/v1/projects" + + body := []byte(`{"apiVersion":"project.openshift.io/v1","kind":"Project","metadata":{"name":"team-a"}}`) + + create := mustDo(t, http.MethodPost, col, body) + defer create.Body.Close() + + if create.StatusCode != http.StatusCreated { + t.Fatalf("create Project: status %d, want 201", create.StatusCode) + } + + var proj struct { + Metadata struct { + Annotations map[string]string `json:"annotations"` + } `json:"metadata"` + } + + mustDecode(t, create.Body, &proj) + + for _, key := range []string{ + "openshift.io/sa.scc.uid-range", + "openshift.io/sa.scc.supplemental-groups", + "openshift.io/sa.scc.mcs", + } { + if proj.Metadata.Annotations[key] == "" { + t.Errorf("project missing annotation %q; got %+v", key, proj.Metadata.Annotations) + } + } +} + +// TestOpenShift_DeploymentConfigRollout asserts a DeploymentConfig materializes +// Running Pods and reports rollout status (replicas + latestVersion). +func TestOpenShift_DeploymentConfigRollout(t *testing.T) { + base := openshiftBase(t) + col := base + "/apis/apps.openshift.io/v1/namespaces/default/deploymentconfigs" + + body := []byte(`{ + "apiVersion":"apps.openshift.io/v1","kind":"DeploymentConfig", + "metadata":{"name":"web"}, + "spec":{"replicas":3,"selector":{"app":"web"}, + "template":{"metadata":{"labels":{"app":"web"}}, + "spec":{"containers":[{"name":"web","image":"nginx"}]}}} + }`) + + create := mustDo(t, http.MethodPost, col, body) + defer create.Body.Close() + + if create.StatusCode != http.StatusCreated { + t.Fatalf("create DeploymentConfig: status %d, want 201", create.StatusCode) + } + + var dc struct { + Status struct { + Replicas int64 `json:"replicas"` + ReadyReplicas int64 `json:"readyReplicas"` + LatestVersion int64 `json:"latestVersion"` + } `json:"status"` + } + + mustDecode(t, create.Body, &dc) + + if dc.Status.Replicas != 3 { + t.Errorf("dc status.replicas: got %d, want 3", dc.Status.Replicas) + } + + if dc.Status.LatestVersion != 1 { + t.Errorf("dc status.latestVersion: got %d, want 1", dc.Status.LatestVersion) + } + + // The DC's Pods must exist in the namespace. + pods := mustDo(t, http.MethodGet, base+"/api/v1/namespaces/default/pods", nil) + defer pods.Body.Close() + + var podList struct { + Items []struct { + Status struct { + Phase string `json:"phase"` + } `json:"status"` + } `json:"items"` + } + + mustDecode(t, pods.Body, &podList) + + if len(podList.Items) != 3 { + t.Fatalf("materialized pods: got %d, want 3", len(podList.Items)) + } + + for i, p := range podList.Items { + if p.Status.Phase != "Running" { + t.Errorf("pod[%d] phase: got %q, want Running", i, p.Status.Phase) + } + } +} + +// TestOpenShift_BuildRunsToComplete asserts a Build reconciles to Complete and +// materializes its builder Pod. +func TestOpenShift_BuildRunsToComplete(t *testing.T) { + base := openshiftBase(t) + col := base + "/apis/build.openshift.io/v1/namespaces/default/builds" + + body := []byte(`{"apiVersion":"build.openshift.io/v1","kind":"Build","metadata":{"name":"app-1"}, + "spec":{"strategy":{"type":"Docker"}}}`) + + create := mustDo(t, http.MethodPost, col, body) + defer create.Body.Close() + + if create.StatusCode != http.StatusCreated { + t.Fatalf("create Build: status %d, want 201", create.StatusCode) + } + + var build struct { + Status struct { + Phase string `json:"phase"` + } `json:"status"` + } + + mustDecode(t, create.Body, &build) + + if build.Status.Phase != "Complete" { + t.Errorf("build status.phase: got %q, want Complete", build.Status.Phase) + } + + pods := mustDo(t, http.MethodGet, base+"/api/v1/namespaces/default/pods/app-1-build", nil) + defer pods.Body.Close() + + if pods.StatusCode != http.StatusOK { + t.Fatalf("builder pod app-1-build: status %d, want 200", pods.StatusCode) + } +} + +// TestOpenShift_StartBuild asserts `oc start-build` (BuildConfig instantiate) +// mints a Build named - that runs to completion. +func TestOpenShift_StartBuild(t *testing.T) { + base := openshiftBase(t) + + bc := []byte(`{"apiVersion":"build.openshift.io/v1","kind":"BuildConfig","metadata":{"name":"api"}, + "spec":{"strategy":{"type":"Source"}}}`) + + mkBC := mustDo(t, http.MethodPost, base+"/apis/build.openshift.io/v1/namespaces/default/buildconfigs", bc) + if mkBC.StatusCode != http.StatusCreated { + mkBC.Body.Close() + t.Fatalf("create BuildConfig: status %d, want 201", mkBC.StatusCode) + } + + mkBC.Body.Close() + + inst := mustDo(t, http.MethodPost, + base+"/apis/build.openshift.io/v1/namespaces/default/buildconfigs/api/instantiate", []byte(`{}`)) + defer inst.Body.Close() + + if inst.StatusCode != http.StatusCreated { + t.Fatalf("start-build: status %d, want 201", inst.StatusCode) + } + + var build struct { + Metadata struct { + Name string `json:"name"` + } `json:"metadata"` + Status struct { + Phase string `json:"phase"` + } `json:"status"` + } + + mustDecode(t, inst.Body, &build) + + if build.Metadata.Name != "api-1" { + t.Errorf("started build name: got %q, want api-1", build.Metadata.Name) + } + + if build.Status.Phase != "Complete" { + t.Errorf("started build phase: got %q, want Complete", build.Status.Phase) + } +} + +// TestOpenShift_NewProject asserts `oc new-project` (ProjectRequest) creates +// both the Project and its backing Namespace. +func TestOpenShift_NewProject(t *testing.T) { + base := openshiftBase(t) + + body := []byte(`{"apiVersion":"project.openshift.io/v1","kind":"ProjectRequest", + "metadata":{"name":"team-x"},"displayName":"Team X","description":"x team"}`) + + create := mustDo(t, http.MethodPost, base+"/apis/project.openshift.io/v1/projectrequests", body) + defer create.Body.Close() + + if create.StatusCode != http.StatusCreated { + t.Fatalf("projectrequest: status %d, want 201", create.StatusCode) + } + + var proj struct { + Metadata struct { + Name string `json:"name"` + Annotations map[string]string `json:"annotations"` + } `json:"metadata"` + } + + mustDecode(t, create.Body, &proj) + + if proj.Metadata.Name != "team-x" { + t.Errorf("project name: got %q, want team-x", proj.Metadata.Name) + } + + if proj.Metadata.Annotations["openshift.io/display-name"] != "Team X" { + t.Errorf("display-name annotation: got %q, want Team X", proj.Metadata.Annotations["openshift.io/display-name"]) + } + + if proj.Metadata.Annotations["openshift.io/sa.scc.uid-range"] == "" { + t.Error("project missing sa.scc.uid-range annotation from reconcile") + } + + // The backing namespace must exist (so the project is usable). + ns := mustDo(t, http.MethodGet, base+"/api/v1/namespaces/team-x", nil) + defer ns.Body.Close() + + if ns.StatusCode != http.StatusOK { + t.Fatalf("backing namespace team-x: status %d, want 200", ns.StatusCode) + } +} + +// TestOpenShift_ProjectRequestsGET asserts the projectrequests collection +// answers GET with an empty list — the probe `oc new-project` issues before it +// POSTs. Without it the GET 404s and the CLI command aborts. +func TestOpenShift_ProjectRequestsGET(t *testing.T) { + base := openshiftBase(t) + + resp := mustDo(t, http.MethodGet, base+"/apis/project.openshift.io/v1/projectrequests", nil) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("GET projectrequests: status %d, want 200", resp.StatusCode) + } + + var list struct { + Kind string `json:"kind"` + Items []any `json:"items"` + } + + mustDecode(t, resp.Body, &list) + + if list.Kind != "ProjectRequestList" { + t.Errorf("kind: got %q, want ProjectRequestList", list.Kind) + } +} + +// TestOpenShift_ProcessTemplate asserts `oc process` (processedtemplates) fills +// parameters (provided + generated) and substitutes ${PARAM} into the objects. +func TestOpenShift_ProcessTemplate(t *testing.T) { + base := openshiftBase(t) + + body := []byte(`{ + "apiVersion":"template.openshift.io/v1","kind":"Template","metadata":{"name":"tmpl"}, + "parameters":[ + {"name":"NAME","value":"myapp"}, + {"name":"SECRET","generate":"expression","from":"[a-z0-9]{12}"} + ], + "objects":[ + {"apiVersion":"v1","kind":"Service","metadata":{"name":"${NAME}"}, + "data":{"token":"${SECRET}"}} + ] + }`) + + resp := mustDo(t, http.MethodPost, + base+"/apis/template.openshift.io/v1/namespaces/default/processedtemplates", body) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("processedtemplates: status %d, want 200", resp.StatusCode) + } + + var processed struct { + Parameters []struct { + Name string `json:"name"` + Value string `json:"value"` + } `json:"parameters"` + Objects []struct { + Metadata struct { + Name string `json:"name"` + } `json:"metadata"` + Data struct { + Token string `json:"token"` + } `json:"data"` + } `json:"objects"` + } + + mustDecode(t, resp.Body, &processed) + + if len(processed.Objects) != 1 { + t.Fatalf("processed objects: got %d, want 1", len(processed.Objects)) + } + + if processed.Objects[0].Metadata.Name != "myapp" { + t.Errorf("${NAME} not substituted: got %q, want myapp", processed.Objects[0].Metadata.Name) + } + + var secret string + + for _, p := range processed.Parameters { + if p.Name == "SECRET" { + secret = p.Value + } + } + + if len(secret) != 12 { + t.Errorf("generated SECRET length: got %d (%q), want 12", len(secret), secret) + } + + if processed.Objects[0].Data.Token != secret { + t.Errorf("${SECRET} substitution mismatch: object=%q param=%q", + processed.Objects[0].Data.Token, secret) + } +} + +// TestOpenShift_RemainingGroupsAdvertised asserts the console/operator/machine/ +// autoscaling groups surface in discovery and round-trip a representative kind. +func TestOpenShift_RemainingGroupsAdvertised(t *testing.T) { + base := openshiftBase(t) + + resp := mustDo(t, http.MethodGet, base+"/apis", nil) + defer resp.Body.Close() + + var groups struct { + Groups []struct { + Name string `json:"name"` + } `json:"groups"` + } + + mustDecode(t, resp.Body, &groups) + + for _, g := range []string{ + "console.openshift.io", "operator.openshift.io", + "machine.openshift.io", "autoscaling.openshift.io", + } { + if !hasGroup(groups.Groups, g) { + t.Errorf("group %q not advertised", g) + } + } + + // A cluster-scoped ConsoleLink round-trips. + link := []byte(`{"apiVersion":"console.openshift.io/v1","kind":"ConsoleLink", + "metadata":{"name":"docs"},"spec":{"href":"https://docs","location":"HelpMenu","text":"Docs"}}`) + + create := mustDo(t, http.MethodPost, base+"/apis/console.openshift.io/v1/consolelinks", link) + defer create.Body.Close() + + if create.StatusCode != http.StatusCreated { + t.Fatalf("create ConsoleLink: status %d, want 201", create.StatusCode) + } +} + +// TestOpenShift_OAuthMetadata asserts the OAuth well-known document advertises +// absolute endpoints rooted at the cluster's own URL. +func TestOpenShift_OAuthMetadata(t *testing.T) { + base := openshiftBase(t) + + resp := mustDo(t, http.MethodGet, base+"/.well-known/oauth-authorization-server", nil) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("well-known: status %d, want 200", resp.StatusCode) + } + + var md struct { + Issuer string `json:"issuer"` + AuthorizationEndpoint string `json:"authorization_endpoint"` + TokenEndpoint string `json:"token_endpoint"` + } + + mustDecode(t, resp.Body, &md) + + if md.Issuer != base { + t.Errorf("issuer: got %q, want %q", md.Issuer, base) + } + + if md.AuthorizationEndpoint != base+"/oauth/authorize" { + t.Errorf("authorization_endpoint: got %q, want %q", md.AuthorizationEndpoint, base+"/oauth/authorize") + } + + if md.TokenEndpoint != base+"/oauth/token" { + t.Errorf("token_endpoint: got %q, want %q", md.TokenEndpoint, base+"/oauth/token") + } +} + +// TestOpenShift_OAuthRejectsCrossHostRedirect asserts the authorize endpoint +// refuses a cross-origin redirect_uri (open-redirect / token-exfiltration +// guard) rather than 302-ing the token to an attacker-controlled host. +func TestOpenShift_OAuthRejectsCrossHostRedirect(t *testing.T) { + base := openshiftBase(t) + authorize := base + "/oauth/authorize?client_id=openshift-challenging-client&response_type=token&redirect_uri=" + + url.QueryEscape("https://evil.example.com/steal") + + noRedirect := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }} + + req, _ := http.NewRequest(http.MethodGet, authorize, nil) + req.SetBasicAuth("developer", "x") + + resp, err := noRedirect.Do(req) + if err != nil { + t.Fatalf("authorize request: %v", err) + } + + defer resp.Body.Close() + + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("cross-host redirect_uri: status %d, want 400 (must not redirect)", resp.StatusCode) + } + + if loc := resp.Header.Get("Location"); strings.Contains(loc, "evil.example.com") { + t.Errorf("server redirected to attacker host: %q", loc) + } +} + +// TestOpenShift_OAuthChallengeThenToken asserts the challenging-client flow: +// no credentials -> 401 Basic challenge; Basic credentials -> 302 with the +// access token in the redirect fragment; and that token then resolves via +// whoami to the authenticated user. +func TestOpenShift_OAuthChallengeThenToken(t *testing.T) { + base := openshiftBase(t) + // redirect_uri must be same-host (the server rejects cross-origin to prevent + // an open redirect) — exactly what the real oc challenging-client sends. + authorize := base + "/oauth/authorize?client_id=openshift-challenging-client&response_type=token&redirect_uri=" + + url.QueryEscape(base+"/oauth/token/implicit") + + noRedirect := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }} + + // No credentials -> challenge. + chReq, _ := http.NewRequest(http.MethodGet, authorize, nil) + chResp, err := noRedirect.Do(chReq) + if err != nil { + t.Fatalf("challenge request: %v", err) + } + + chResp.Body.Close() + + if chResp.StatusCode != http.StatusUnauthorized { + t.Fatalf("unauthenticated authorize: status %d, want 401", chResp.StatusCode) + } + + if !strings.HasPrefix(chResp.Header.Get("WWW-Authenticate"), "Basic") { + t.Errorf("missing Basic challenge; WWW-Authenticate=%q", chResp.Header.Get("WWW-Authenticate")) + } + + // With Basic credentials -> 302 with token in fragment. + req, _ := http.NewRequest(http.MethodGet, authorize, nil) + req.SetBasicAuth("developer", "any-password") + + resp, err := noRedirect.Do(req) + if err != nil { + t.Fatalf("authorize request: %v", err) + } + + defer resp.Body.Close() + + if resp.StatusCode != http.StatusFound { + t.Fatalf("authenticated authorize: status %d, want 302", resp.StatusCode) + } + + loc := resp.Header.Get("Location") + + frag := "" + if i := strings.Index(loc, "#"); i >= 0 { + frag = loc[i+1:] + } + + token := valueFromEncoded(frag, "access_token") + if token == "" { + t.Fatalf("no access_token in redirect fragment: %q", loc) + } + + // The token resolves via whoami to the authenticated user. + who, _ := http.NewRequest(http.MethodGet, base+"/apis/user.openshift.io/v1/users/~", nil) + who.Header.Set("Authorization", "Bearer "+token) + + whoResp, err := http.DefaultClient.Do(who) + if err != nil { + t.Fatalf("whoami request: %v", err) + } + + defer whoResp.Body.Close() + + var user struct { + Metadata struct { + Name string `json:"name"` + } `json:"metadata"` + } + + mustDecode(t, whoResp.Body, &user) + + if user.Metadata.Name != "developer" { + t.Errorf("whoami: got %q, want developer", user.Metadata.Name) + } +} + +// valueFromEncoded pulls key's value out of a urlencoded fragment/query string +// without depending on ordering. +func valueFromEncoded(encoded, key string) string { + for _, kv := range strings.Split(encoded, "&") { + k, v, found := strings.Cut(kv, "=") + if found && k == key { + return v + } + } + + return "" +} + +func hasGroup(groups []struct { + Name string `json:"name"` +}, name string) bool { + for _, g := range groups { + if g.Name == name { + return true + } + } + + return false +} + +func hasResource(res []struct { + Name string `json:"name"` + Kind string `json:"kind"` +}, name string) bool { + for _, r := range res { + if r.Name == name { + return true + } + } + + return false +} diff --git a/services/kubernetes/state.go b/services/kubernetes/state.go index fc457eeac..5f51e7762 100644 --- a/services/kubernetes/state.go +++ b/services/kubernetes/state.go @@ -81,6 +81,16 @@ type ClusterState struct { admissionEnabled bool admissionClient *http.Client + // flavor selects the served API surface. FlavorOpenShift additionally + // registers the *.openshift.io groups and seeds the OpenShift identity + // singletons; FlavorKubernetes (the default) serves only upstream groups. + flavor Flavor + + // oauthTokens maps a minted OAuth access token to the username it was + // issued for, so `oc whoami` (GET users/~) can resolve the caller. Only + // written by the OpenShift OAuth server (openshift_oauth.go). Guarded by mu. + oauthTokens map[string]string + // Per-resource Watch broadcasters. Handlers publish on Create/Update/ // Patch/Delete; ?watch=true requests subscribe via streamWatch. wNamespaces *broadcaster @@ -103,7 +113,9 @@ const firstClusterIPOffset uint32 = 1 // namespaces (default, kube-system, kube-public) and a "default" // ServiceAccount in each, matching the bootstrap state of a fresh real // cluster. -func newClusterState(clock config.Clock, admissionEnabled bool, admissionClient *http.Client) *ClusterState { +func newClusterState( + clock config.Clock, admissionEnabled bool, admissionClient *http.Client, flavor Flavor, +) *ClusterState { if clock == nil { clock = config.RealClock{} } @@ -112,8 +124,16 @@ func newClusterState(clock config.Clock, admissionEnabled bool, admissionClient admissionClient = &http.Client{Timeout: defaultAdmissionTimeout} } + // OpenShift is a superset: it serves the upstream groups plus the + // *.openshift.io groups. Kubernetes clusters get only the upstream set. + defs := registeredResources() + if flavor == FlavorOpenShift { + defs = concat(defs, openshiftRegistryDefs()) + } + s := &ClusterState{ clock: clock, + flavor: flavor, namespaces: make(map[string]*corev1.Namespace), configMaps: make(map[string]*corev1.ConfigMap), pods: make(map[string]*corev1.Pod), @@ -125,9 +145,10 @@ func newClusterState(clock config.Clock, admissionEnabled bool, admissionClient endpoints: make(map[string]*corev1.Endpoints), nextClusterIP: firstClusterIPOffset, nextPodIP: 1, - reg: newRegistry(registeredResources()), + reg: newRegistry(defs), admissionEnabled: admissionEnabled, admissionClient: admissionClient, + oauthTokens: make(map[string]string), wNamespaces: newBroadcaster(), wConfigMaps: newBroadcaster(), wPods: newBroadcaster(), @@ -156,6 +177,12 @@ func newClusterState(clock config.Clock, admissionEnabled bool, admissionClient store.items[objKey("", node.GetName())] = node } + // OpenShift-flavored clusters boot with the identity singletons a real + // cluster always has (ClusterVersion "version", Infrastructure "cluster"). + if flavor == FlavorOpenShift { + s.seedOpenShiftSingletonsLocked() + } + return s } @@ -171,6 +198,13 @@ func (s *ClusterState) now() metav1.Time { // prefix by APIServer.ServeHTTP, so r.URL.Path here starts with /api/v1/... // or /apis///... func (s *ClusterState) ServeHTTP(w http.ResponseWriter, r *http.Request) { + // OpenShift-only pseudo-endpoints (whoami, project requests, build + // instantiate, processed templates, authorization reviews) have no registry + // store or Route shape, so they are intercepted before discovery/routing. + if s.serveOpenShiftIntercept(w, r) { + return + } + // Discovery first: /api, /apis and the group-version lists are not // resource paths and parseRoute cannot represent them. if s.serveDiscovery(w, r) {