Skip to content

Commit 24500b6

Browse files
committed
Add IPI installation on AWS dedicated hosts
1 parent 1924260 commit 24500b6

File tree

11 files changed

+500
-1
lines changed

11 files changed

+500
-1
lines changed

data/data/install.openshift.io_installconfigs.yaml

Lines changed: 216 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package aws
2+
3+
import (
4+
"context"
5+
"fmt"
6+
7+
"github.com/aws/aws-sdk-go/aws"
8+
"github.com/aws/aws-sdk-go/aws/session"
9+
"github.com/aws/aws-sdk-go/service/ec2"
10+
)
11+
12+
// Host holds metadata for a dedicated host.
13+
type Host struct {
14+
ID string
15+
Name string
16+
Zone string
17+
}
18+
19+
// dedicatedHosts retrieves a list of dedicated hosts for the given region and
20+
// returns them in a map keyed by the host ID.
21+
func dedicatedHosts(ctx context.Context, session *session.Session, region string) (map[string]Host, error) {
22+
hostsByID := map[string]Host{}
23+
24+
client := ec2.New(session, aws.NewConfig().WithRegion(region))
25+
input := &ec2.DescribeHostsInput{}
26+
27+
if err := client.DescribeHostsPagesWithContext(ctx, input, func(page *ec2.DescribeHostsOutput, lastPage bool) bool {
28+
for _, h := range page.Hosts {
29+
id := aws.StringValue(h.HostId)
30+
if id == "" {
31+
// Skip entries lacking an ID (should not happen)
32+
continue
33+
}
34+
var name string
35+
for _, tag := range h.Tags {
36+
if aws.StringValue(tag.Key) == "Name" {
37+
name = aws.StringValue(tag.Value)
38+
break
39+
}
40+
}
41+
42+
fmt.Printf("Found dedicated host %s\n", id)
43+
hostsByID[id] = Host{
44+
ID: id,
45+
Name: name,
46+
Zone: aws.StringValue(h.AvailabilityZone),
47+
}
48+
}
49+
return !lastPage
50+
}); err != nil {
51+
return nil, fmt.Errorf("fetching dedicated hosts: %w", err)
52+
}
53+
54+
return hostsByID, nil
55+
}

pkg/asset/installconfig/aws/metadata.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ type Metadata struct {
2727
vpc VPC
2828
instanceTypes map[string]InstanceType
2929

30+
Hosts map[string]Host
3031
Region string `json:"region,omitempty"`
3132
ProvidedSubnets []typesaws.Subnet `json:"subnets,omitempty"`
3233
Services []typesaws.ServiceEndpoint `json:"services,omitempty"`
@@ -390,3 +391,21 @@ func (m *Metadata) InstanceTypes(ctx context.Context) (map[string]InstanceType,
390391

391392
return m.instanceTypes, nil
392393
}
394+
395+
// DedicatedHosts retrieves all hosts available for use to verify against this installation for configured region.
396+
func (m *Metadata) DedicatedHosts(ctx context.Context) (map[string]Host, error) {
397+
m.mutex.Lock()
398+
defer m.mutex.Unlock()
399+
400+
awsSession, err := m.unlockedSession(ctx)
401+
if err != nil {
402+
return nil, err
403+
}
404+
405+
m.Hosts, err = dedicatedHosts(ctx, awsSession, m.Region)
406+
if err != nil {
407+
return nil, fmt.Errorf("error listing dedicated hosts: %w", err)
408+
}
409+
410+
return m.Hosts, nil
411+
}

pkg/asset/installconfig/aws/validation.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -466,6 +466,8 @@ func validateMachinePool(ctx context.Context, meta *Metadata, fldPath *field.Pat
466466
}
467467
}
468468

469+
allErrs = append(allErrs, validateHostPlacement(ctx, meta, fldPath, pool)...)
470+
469471
return allErrs
470472
}
471473

@@ -484,6 +486,32 @@ func translateEC2Arches(arches []string) sets.Set[string] {
484486
return res
485487
}
486488

489+
func validateHostPlacement(ctx context.Context, meta *Metadata, fldPath *field.Path, pool *awstypes.MachinePool) field.ErrorList {
490+
allErrs := field.ErrorList{}
491+
492+
if pool.HostPlacement != nil {
493+
placementPath := fldPath.Child("hostPlacement")
494+
if pool.HostPlacement.DedicatedHost != nil {
495+
configuredHosts := pool.HostPlacement.DedicatedHost
496+
foundHosts, err := meta.DedicatedHosts(ctx)
497+
if err != nil {
498+
allErrs = append(allErrs, field.InternalError(placementPath.Child("dedicatedHost"), err))
499+
} else {
500+
// Check the returned configured hosts to see if the dedicated hosts defined in install-config exists.
501+
for _, host := range *configuredHosts {
502+
_, ok := foundHosts[host.ID]
503+
if !ok {
504+
errMsg := fmt.Sprintf("dedicated host %s not found", host.ID)
505+
allErrs = append(allErrs, field.Invalid(placementPath.Child("dedicatedHost"), pool.InstanceType, errMsg))
506+
}
507+
}
508+
}
509+
}
510+
}
511+
512+
return allErrs
513+
}
514+
487515
func validateSecurityGroupIDs(ctx context.Context, meta *Metadata, fldPath *field.Path, platform *awstypes.Platform, pool *awstypes.MachinePool) field.ErrorList {
488516
allErrs := field.ErrorList{}
489517

pkg/asset/machines/aws/awsmachines.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ type MachineInput struct {
3333
PublicIP bool
3434
PublicIpv4Pool string
3535
Ignition *capa.Ignition
36+
HostAffinity string
37+
HostIDs []string
3638
}
3739

3840
// GenerateMachines returns manifests and runtime objects to provision the control plane (including bootstrap, if applicable) nodes using CAPI.

pkg/asset/machines/aws/machines.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"k8s.io/apimachinery/pkg/runtime"
1212
"k8s.io/apimachinery/pkg/util/sets"
1313
"k8s.io/utils/pointer"
14+
"k8s.io/utils/ptr"
1415

1516
v1 "github.com/openshift/api/config/v1"
1617
machinev1 "github.com/openshift/api/machine/v1"
@@ -35,6 +36,7 @@ type machineProviderInput struct {
3536
userTags map[string]string
3637
publicSubnet bool
3738
securityGroupIDs []string
39+
dedicatedHost string
3840
}
3941

4042
// Machines returns a list of machines for a machinepool.
@@ -291,6 +293,15 @@ func provider(in *machineProviderInput) (*machineapi.AWSMachineProviderConfig, e
291293
config.MetadataServiceOptions.Authentication = machineapi.MetadataServiceAuthentication(in.imds.Authentication)
292294
}
293295

296+
if in.dedicatedHost != "" {
297+
config.HostPlacement = &machineapi.HostPlacement{
298+
Affinity: ptr.To(machineapi.HostAffinityDedicatedHost),
299+
DedicatedHost: &machineapi.DedicatedHost{
300+
ID: in.dedicatedHost,
301+
},
302+
}
303+
}
304+
294305
return config, nil
295306
}
296307

@@ -340,3 +351,18 @@ func ConfigMasters(machines []machineapi.Machine, controlPlane *machinev1.Contro
340351
providerSpec := controlPlane.Spec.Template.OpenShiftMachineV1Beta1Machine.Spec.ProviderSpec.Value.Object.(*machineapi.AWSMachineProviderConfig)
341352
providerSpec.LoadBalancers = lbrefs
342353
}
354+
355+
// DedicatedHost sets dedicated hosts for the specified zone.
356+
func DedicatedHost(hosts map[string]aws.Host, placement *awstypes.HostPlacement, zone string) string {
357+
// If install-config has HostPlacements configured, lets check the DedicatedHosts to see if one matches our region & zone.
358+
if placement != nil {
359+
// We only support one host ID currently for an instance. Need to also get host that matches the zone the machines will be put into.
360+
for _, host := range *placement.DedicatedHost {
361+
hostDetails, found := hosts[host.ID]
362+
if found && hostDetails.Zone == zone {
363+
return hostDetails.ID
364+
}
365+
}
366+
}
367+
return ""
368+
}

pkg/asset/machines/aws/machinesets.go

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ type MachineSetInput struct {
2525
Pool *types.MachinePool
2626
Role string
2727
UserDataSecret string
28+
Hosts map[string]icaws.Host
2829
}
2930

3031
// MachineSets returns a list of machinesets for a machinepool.
@@ -87,6 +88,8 @@ func MachineSets(in *MachineSetInput) ([]*machineapi.MachineSet, error) {
8788
instanceProfile = fmt.Sprintf("%s-worker-profile", in.ClusterID)
8889
}
8990

91+
dedicatedHost := DedicatedHost(in.Hosts, mpool.HostPlacement, az)
92+
9093
provider, err := provider(&machineProviderInput{
9194
clusterID: in.ClusterID,
9295
region: in.InstallConfigPlatformAWS.Region,
@@ -102,12 +105,21 @@ func MachineSets(in *MachineSetInput) ([]*machineapi.MachineSet, error) {
102105
userTags: in.InstallConfigPlatformAWS.UserTags,
103106
publicSubnet: publicSubnet,
104107
securityGroupIDs: in.Pool.Platform.AWS.AdditionalSecurityGroupIDs,
108+
dedicatedHost: dedicatedHost,
105109
})
106110
if err != nil {
107111
return nil, errors.Wrap(err, "failed to create provider")
108112
}
113+
114+
// If we are using any feature that is only available via CAPI, we must set the authoritativeAPI = ClusterAPI
115+
authoritativeAPI := machineapi.MachineAuthorityMachineAPI
116+
if isAuthoritativeClusterAPIRequired(provider) {
117+
authoritativeAPI = machineapi.MachineAuthorityClusterAPI
118+
}
119+
109120
name := fmt.Sprintf("%s-%s-%s", in.ClusterID, in.Pool.Name, az)
110121
spec := machineapi.MachineSpec{
122+
AuthoritativeAPI: authoritativeAPI,
111123
ProviderSpec: machineapi.ProviderSpec{
112124
Value: &runtime.RawExtension{Object: provider},
113125
},
@@ -130,7 +142,8 @@ func MachineSets(in *MachineSetInput) ([]*machineapi.MachineSet, error) {
130142
},
131143
},
132144
Spec: machineapi.MachineSetSpec{
133-
Replicas: &replicas,
145+
AuthoritativeAPI: authoritativeAPI,
146+
Replicas: &replicas,
134147
Selector: metav1.LabelSelector{
135148
MatchLabels: map[string]string{
136149
"machine.openshift.io/cluster-api-machineset": name,
@@ -151,8 +164,17 @@ func MachineSets(in *MachineSetInput) ([]*machineapi.MachineSet, error) {
151164
},
152165
},
153166
}
167+
154168
machinesets = append(machinesets, mset)
155169
}
156170

157171
return machinesets, nil
158172
}
173+
174+
// isAuthoritativeClusterAPIRequired is called to determine if the machine spec should have the AuthoritativeAPI set to ClusterAPI.
175+
func isAuthoritativeClusterAPIRequired(provider *machineapi.AWSMachineProviderConfig) bool {
176+
if provider.HostPlacement != nil && *provider.HostPlacement.Affinity != machineapi.HostAffinityAnyAvailable {
177+
return true
178+
}
179+
return false
180+
}

pkg/asset/machines/worker.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -543,6 +543,7 @@ func (w *Worker) Generate(ctx context.Context, dependencies asset.Parents) error
543543
Pool: &pool,
544544
Role: pool.Name,
545545
UserDataSecret: workerUserDataSecretName,
546+
Hosts: installConfig.AWS.Hosts,
546547
})
547548
if err != nil {
548549
return errors.Wrap(err, "failed to create worker machine objects")

pkg/types/aws/machinepool.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,14 @@ type MachinePool struct {
4848
// +kubebuilder:validation:MaxItems=10
4949
// +optional
5050
AdditionalSecurityGroupIDs []string `json:"additionalSecurityGroupIDs,omitempty"`
51+
52+
// hostPlacement configures placement on AWS Dedicated Hosts. This allows admins to assign instances to specific host
53+
// for a variety of needs including for regulatory compliance, to leverage existing per-socket or per-core software licenses (BYOL),
54+
// and to gain visibility and control over instance placement on a physical server.
55+
// When omitted, the instance is not constrained to a dedicated host.
56+
// +openshift:enable:FeatureGate=AWSDedicatedHosts
57+
// +optional
58+
HostPlacement *HostPlacement `json:"hostPlacement,omitempty"`
5159
}
5260

5361
// Set sets the values from `required` to `a`.
@@ -96,6 +104,10 @@ func (a *MachinePool) Set(required *MachinePool) {
96104
if len(required.AdditionalSecurityGroupIDs) > 0 {
97105
a.AdditionalSecurityGroupIDs = required.AdditionalSecurityGroupIDs
98106
}
107+
108+
if required.HostPlacement != nil {
109+
a.HostPlacement = required.HostPlacement
110+
}
99111
}
100112

101113
// EC2RootVolume defines the storage for an ec2 instance.
@@ -135,3 +147,50 @@ type EC2Metadata struct {
135147
// +optional
136148
Authentication string `json:"authentication,omitempty"`
137149
}
150+
151+
// HostPlacement is the type that will be used to configure the placement of AWS instances.
152+
// This can be configured for default placement (AnyAvailable) and dedicated hosts (DedicatedHost).
153+
// +kubebuilder:validation:XValidation:rule="has(self.type) && self.affinity == 'DedicatedHost' ? has(self.dedicatedHost) : !has(self.dedicatedHost)",message="dedicatedHost is required when affinity is DedicatedHost, and forbidden otherwise"
154+
type HostPlacement struct {
155+
// affinity specifies the affinity setting for the instance.
156+
// Allowed values are AnyAvailable and DedicatedHost.
157+
// When Affinity is set to DedicatedHost, an instance started onto a specific host always restarts on the same host if stopped. In this scenario, the `dedicatedHost` field must be set.
158+
// When Affinity is set to AnyAvailable, and you stop and restart the instance, it can be restarted on any available host.
159+
// +required
160+
// +unionDiscriminator
161+
Affinity *HostAffinity `json:"affinity,omitempty"`
162+
163+
// dedicatedHost specifies the exact host that an instance should be restarted on if stopped.
164+
// dedicatedHost is required when 'affinity' is set to DedicatedHost, and forbidden otherwise.
165+
// +optional
166+
// +unionMember
167+
DedicatedHost *[]DedicatedHost `json:"dedicatedHost,omitempty"`
168+
}
169+
170+
// HostAffinity selects how an instance should be placed on AWS Dedicated Hosts.
171+
// +kubebuilder:validation:Enum:=DedicatedHost;AnyAvailable
172+
type HostAffinity string
173+
174+
const (
175+
// HostAffinityAnyAvailable lets the platform select any available dedicated host.
176+
HostAffinityAnyAvailable HostAffinity = "AnyAvailable"
177+
178+
// HostAffinityDedicatedHost requires specifying a particular host via dedicatedHost.host.hostID.
179+
HostAffinityDedicatedHost HostAffinity = "DedicatedHost"
180+
)
181+
182+
// DedicatedHost represents the configuration for the usage of dedicated host.
183+
type DedicatedHost struct {
184+
// id identifies the AWS Dedicated Host on which the instance must run.
185+
// The value must start with "h-" followed by 17 lowercase hexadecimal characters (0-9 and a-f).
186+
// Must be exactly 19 characters in length.
187+
// +kubebuilder:validation:XValidation:rule="self.matches('^h-[0-9a-f]{17}$')",message="hostID must start with 'h-' followed by 17 lowercase hexadecimal characters (0-9 and a-f)"
188+
// +kubebuilder:validation:MinLength=19
189+
// +kubebuilder:validation:MaxLength=19
190+
// +required
191+
ID string `json:"id,omitempty"`
192+
193+
// zone is the availability zone that the dedicated host belongs to
194+
// +optional
195+
Zone string `json:"zone,omitempty"`
196+
}

pkg/types/aws/validation/machinepool.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package validation
22

33
import (
44
"fmt"
5+
"regexp"
56
"strings"
67

78
"k8s.io/apimachinery/pkg/util/sets"
@@ -27,6 +28,9 @@ var (
2728
}()
2829

2930
validMetadataAuthValues = sets.NewString("Required", "Optional")
31+
32+
// awsDedicatedHostNamePattern is a regex expression that defines the dedicated host id format.
33+
awsDedicatedHostNamePattern = regexp.MustCompile(`^h-[0-9a-f]{17}$`)
3034
)
3135

3236
// AWS has a limit of 16 security groups. See:
@@ -54,6 +58,40 @@ func ValidateMachinePool(platform *aws.Platform, p *aws.MachinePool, fldPath *fi
5458

5559
allErrs = append(allErrs, validateSecurityGroups(platform, p, fldPath)...)
5660

61+
if p.HostPlacement != nil {
62+
allErrs = append(allErrs, validateHostPlacement(p, fldPath.Child("hostPlacement"))...)
63+
}
64+
65+
return allErrs
66+
}
67+
68+
func validateHostPlacement(p *aws.MachinePool, fldPath *field.Path) field.ErrorList {
69+
allErrs := field.ErrorList{}
70+
71+
if p.HostPlacement.Affinity != nil {
72+
switch *p.HostPlacement.Affinity {
73+
case aws.HostAffinityAnyAvailable:
74+
if p.HostPlacement.DedicatedHost != nil {
75+
allErrs = append(allErrs, field.Required(fldPath.Child("dedicatedHost"), "dedicatedHost is required when 'affinity' is set to DedicatedHost, and forbidden otherwise"))
76+
}
77+
case aws.HostAffinityDedicatedHost:
78+
if p.HostPlacement.DedicatedHost == nil {
79+
allErrs = append(allErrs, field.Required(fldPath.Child("dedicatedHost"), "dedicatedHost is required when 'affinity' is set to DedicatedHost, and forbidden otherwise"))
80+
} else {
81+
for index, host := range *p.HostPlacement.DedicatedHost {
82+
hostPath := fldPath.Child("dedicatedHost").Index(index)
83+
if len(host.ID) == 0 {
84+
allErrs = append(allErrs, field.Required(hostPath.Child("id"), "a hostID must be specified when hostAffinity is set"))
85+
} else if awsDedicatedHostNamePattern.FindStringSubmatch(host.ID) == nil {
86+
allErrs = append(allErrs, field.Invalid(hostPath.Child("id"), host.ID, "id must start with 'h-' followed by 17 lowercase hexadecimal characters (0-9 and a-f)"))
87+
}
88+
}
89+
}
90+
default:
91+
allErrs = append(allErrs, field.Invalid(fldPath.Child("affinity"), p.HostPlacement.Affinity, "affinity must be either AnyAvailable or DedicatedHost"))
92+
}
93+
}
94+
5795
return allErrs
5896
}
5997

0 commit comments

Comments
 (0)