fix: populate routes to BGP neighbors (Equinix Metal)

Fixes #8267

Also refactor the code so that we don't fail hard on mutiple bonds, but
it's not clear still how to attach addresses, as they don't have a
interface name field, so for now attaching to the first bond.

Fixes #8411

Signed-off-by: Andrey Smirnov <andrey.smirnov@siderolabs.com>
This commit is contained in:
Andrey Smirnov 2024-03-21 15:29:14 +04:00
parent 19f15a840c
commit f737e6495c
No known key found for this signature in database
GPG Key ID: FE042E3D4085A811
7 changed files with 166 additions and 127 deletions

View File

@ -9,7 +9,6 @@ import (
"bytes" "bytes"
"context" "context"
"encoding/json" "encoding/json"
stderrors "errors"
"fmt" "fmt"
"io" "io"
"log" "log"
@ -71,6 +70,12 @@ type Address struct {
Gateway string `json:"gateway"` Gateway string `json:"gateway"`
} }
// BGPNeighbor holds BGP neighbor info from the equinixmetal metadata.
type BGPNeighbor struct {
AddressFamily int `json:"address_family"`
PeerIPs []string `json:"peer_ips"`
}
const ( const (
// EquinixMetalUserDataEndpoint is the local metadata endpoint for Equinix. // EquinixMetalUserDataEndpoint is the local metadata endpoint for Equinix.
EquinixMetalUserDataEndpoint = "https://metadata.platformequinix.com/userdata" EquinixMetalUserDataEndpoint = "https://metadata.platformequinix.com/userdata"
@ -122,27 +127,23 @@ func (p *EquinixMetal) ParseMetadata(ctx context.Context, equinixMetadata *Metad
// translate the int returned from bond mode metadata to the type needed by network resources // translate the int returned from bond mode metadata to the type needed by network resources
bondMode := nethelpers.BondMode(uint8(equinixMetadata.Network.Bonding.Mode)) bondMode := nethelpers.BondMode(uint8(equinixMetadata.Network.Bonding.Mode))
// determine bond name and build list of interfaces enslaved by the bond
bondName := ""
hostInterfaces, err := safe.StateListAll[*network.LinkStatus](ctx, st) hostInterfaces, err := safe.StateListAll[*network.LinkStatus](ctx, st)
if err != nil { if err != nil {
return nil, fmt.Errorf("error listing host interfaces: %w", err) return nil, fmt.Errorf("error listing host interfaces: %w", err)
} }
slaveIndex := 0 bondSlaveIndexes := map[string]int{}
firstBond := ""
for _, iface := range equinixMetadata.Network.Interfaces { for _, iface := range equinixMetadata.Network.Interfaces {
if iface.Bond == "" { if iface.Bond == "" {
continue continue
} }
if bondName != "" && iface.Bond != bondName { if firstBond == "" {
return nil, stderrors.New("encountered multiple bonds. this is unexpected in the equinix metal platform") firstBond = iface.Bond
} }
bondName = iface.Bond
found := false found := false
hostInterfaceIter := hostInterfaces.Iterator() hostInterfaceIter := hostInterfaces.Iterator()
@ -154,17 +155,20 @@ func (p *EquinixMetal) ParseMetadata(ctx context.Context, equinixMetadata *Metad
if hostInterfaceIter.Value().TypedSpec().PermanentAddr.String() == iface.MAC { if hostInterfaceIter.Value().TypedSpec().PermanentAddr.String() == iface.MAC {
found = true found = true
slaveIndex := bondSlaveIndexes[iface.Bond]
networkConfig.Links = append(networkConfig.Links, networkConfig.Links = append(networkConfig.Links,
network.LinkSpecSpec{ network.LinkSpecSpec{
Name: hostInterfaceIter.Value().Metadata().ID(), Name: hostInterfaceIter.Value().Metadata().ID(),
Up: true, Up: true,
BondSlave: network.BondSlave{ BondSlave: network.BondSlave{
MasterName: bondName, MasterName: iface.Bond,
SlaveIndex: slaveIndex, SlaveIndex: slaveIndex,
}, },
ConfigLayer: network.ConfigPlatform, ConfigLayer: network.ConfigPlatform,
}) })
slaveIndex++
bondSlaveIndexes[iface.Bond]++
break break
} }
@ -173,20 +177,24 @@ func (p *EquinixMetal) ParseMetadata(ctx context.Context, equinixMetadata *Metad
if !found { if !found {
log.Printf("interface with MAC %q wasn't found on the host, adding with the name from metadata", iface.MAC) log.Printf("interface with MAC %q wasn't found on the host, adding with the name from metadata", iface.MAC)
slaveIndex := bondSlaveIndexes[iface.Bond]
networkConfig.Links = append(networkConfig.Links, networkConfig.Links = append(networkConfig.Links,
network.LinkSpecSpec{ network.LinkSpecSpec{
ConfigLayer: network.ConfigPlatform, ConfigLayer: network.ConfigPlatform,
Name: iface.Name, Name: iface.Name,
Up: true, Up: true,
BondSlave: network.BondSlave{ BondSlave: network.BondSlave{
MasterName: bondName, MasterName: iface.Bond,
SlaveIndex: slaveIndex, SlaveIndex: slaveIndex,
}, },
}) })
slaveIndex++
bondSlaveIndexes[iface.Bond]++
} }
} }
for bondName := range bondSlaveIndexes {
bondLink := network.LinkSpecSpec{ bondLink := network.LinkSpecSpec{
ConfigLayer: network.ConfigPlatform, ConfigLayer: network.ConfigPlatform,
Name: bondName, Name: bondName,
@ -206,6 +214,7 @@ func (p *EquinixMetal) ParseMetadata(ctx context.Context, equinixMetadata *Metad
networkadapter.BondMasterSpec(&bondLink.BondMaster).FillDefaults() networkadapter.BondMasterSpec(&bondLink.BondMaster).FillDefaults()
networkConfig.Links = append(networkConfig.Links, bondLink) networkConfig.Links = append(networkConfig.Links, bondLink)
}
// 2. addresses // 2. addresses
@ -233,7 +242,7 @@ func (p *EquinixMetal) ParseMetadata(ctx context.Context, equinixMetadata *Metad
networkConfig.Addresses = append(networkConfig.Addresses, networkConfig.Addresses = append(networkConfig.Addresses,
network.AddressSpecSpec{ network.AddressSpecSpec{
ConfigLayer: network.ConfigPlatform, ConfigLayer: network.ConfigPlatform,
LinkName: bondName, LinkName: firstBond,
Address: ipAddr, Address: ipAddr,
Scope: nethelpers.ScopeGlobal, Scope: nethelpers.ScopeGlobal,
Flags: nethelpers.AddressFlags(nethelpers.AddressPermanent), Flags: nethelpers.AddressFlags(nethelpers.AddressPermanent),
@ -249,6 +258,7 @@ func (p *EquinixMetal) ParseMetadata(ctx context.Context, equinixMetadata *Metad
} }
// 3. routes // 3. routes
var privateGateway netip.Addr
for _, addr := range equinixMetadata.Network.Addresses { for _, addr := range equinixMetadata.Network.Addresses {
if !(addr.Enabled && addr.Management) { if !(addr.Enabled && addr.Management) {
@ -275,7 +285,7 @@ func (p *EquinixMetal) ParseMetadata(ctx context.Context, equinixMetadata *Metad
route := network.RouteSpecSpec{ route := network.RouteSpecSpec{
ConfigLayer: network.ConfigPlatform, ConfigLayer: network.ConfigPlatform,
Gateway: gw, Gateway: gw,
OutLinkName: bondName, OutLinkName: firstBond,
Table: nethelpers.TableMain, Table: nethelpers.TableMain,
Protocol: nethelpers.ProtocolStatic, Protocol: nethelpers.ProtocolStatic,
Type: nethelpers.TypeUnicast, Type: nethelpers.TypeUnicast,
@ -298,6 +308,8 @@ func (p *EquinixMetal) ParseMetadata(ctx context.Context, equinixMetadata *Metad
return nil, err return nil, err
} }
privateGateway = gw
dest, err := netip.ParsePrefix(privSubnet) dest, err := netip.ParsePrefix(privSubnet)
if err != nil { if err != nil {
return nil, err return nil, err
@ -307,7 +319,7 @@ func (p *EquinixMetal) ParseMetadata(ctx context.Context, equinixMetadata *Metad
ConfigLayer: network.ConfigPlatform, ConfigLayer: network.ConfigPlatform,
Gateway: gw, Gateway: gw,
Destination: dest, Destination: dest,
OutLinkName: bondName, OutLinkName: firstBond,
Table: nethelpers.TableMain, Table: nethelpers.TableMain,
Protocol: nethelpers.ProtocolStatic, Protocol: nethelpers.ProtocolStatic,
Type: nethelpers.TypeUnicast, Type: nethelpers.TypeUnicast,
@ -347,6 +359,36 @@ func (p *EquinixMetal) ParseMetadata(ctx context.Context, equinixMetadata *Metad
ProviderID: fmt.Sprintf("equinixmetal://%s", equinixMetadata.ID), ProviderID: fmt.Sprintf("equinixmetal://%s", equinixMetadata.ID),
} }
// 6. BGP neighbors
for _, bgpNeighbor := range equinixMetadata.BGPNeighbors {
if bgpNeighbor.AddressFamily != 4 {
continue
}
for _, peerIP := range bgpNeighbor.PeerIPs {
peer, err := netip.ParseAddr(peerIP)
if err != nil {
return nil, err
}
route := network.RouteSpecSpec{
ConfigLayer: network.ConfigPlatform,
Gateway: privateGateway,
Destination: netip.PrefixFrom(peer, 32),
OutLinkName: firstBond,
Table: nethelpers.TableMain,
Protocol: nethelpers.ProtocolStatic,
Type: nethelpers.TypeUnicast,
Family: nethelpers.FamilyInet4,
}
route.Normalize()
networkConfig.Routes = append(networkConfig.Routes, route)
}
}
return networkConfig, nil return networkConfig, nil
} }

View File

@ -12,5 +12,6 @@ type MetadataConfig struct {
Metro string `json:"metro"` Metro string `json:"metro"`
Facility string `json:"facility"` Facility string `json:"facility"`
Network Network `json:"network"` Network Network `json:"network"`
BGPNeighbors []BGPNeighbor `json:"bgp_neighbors"`
PrivateSubnets []string `json:"private_subnets"` PrivateSubnets []string `json:"private_subnets"`
} }

View File

@ -95,6 +95,28 @@ routes:
flags: "" flags: ""
protocol: static protocol: static
layer: platform layer: platform
- family: inet4
dst: 169.254.255.1/32
src: ""
gateway: 10.66.142.16
outLinkName: bond0
table: main
scope: global
type: unicast
flags: ""
protocol: static
layer: platform
- family: inet4
dst: 169.254.255.2/32
src: ""
gateway: 10.66.142.16
outLinkName: bond0
table: main
scope: global
type: unicast
flags: ""
protocol: static
layer: platform
hostnames: hostnames:
- hostname: infra-green-ci - hostname: infra-green-ci
domainname: "" domainname: ""

View File

@ -111,6 +111,38 @@
], ],
"metal_gateways": [] "metal_gateways": []
}, },
"bgp_neighbors": [
{
"address_family": 4,
"customer_as": 65000,
"customer_ip": "10.67.50.1",
"md5_enabled": false,
"md5_password": null,
"multihop": true,
"peer_as": 65530,
"peer_ips": [
"169.254.255.1",
"169.254.255.2"
],
"routes_in": [],
"routes_out": []
},
{
"address_family": 6,
"customer_as": 65000,
"customer_ip": "2604:1380:45e1:5000::1",
"md5_enabled": false,
"md5_password": null,
"multihop": true,
"peer_as": 65530,
"peer_ips": [
"fc00:0000:0000:0000:0000:0000:0000:000e",
"fc00:0000:0000:0000:0000:0000:0000:000f"
],
"routes_in": [],
"routes_out": []
}
],
"api_url": "https://metadata.packet.net", "api_url": "https://metadata.packet.net",
"phone_home_url": "http://tinkerbell.ny5.packet.net/phone-home", "phone_home_url": "http://tinkerbell.ny5.packet.net/phone-home",
"user_state_url": "http://tinkerbell.ny5.packet.net/events" "user_state_url": "http://tinkerbell.ny5.packet.net/events"

View File

@ -4,8 +4,8 @@ no_list: true
linkTitle: "Documentation" linkTitle: "Documentation"
cascade: cascade:
type: docs type: docs
lastRelease: v1.6.4 lastRelease: v1.6.7
kubernetesRelease: "1.29.1" kubernetesRelease: "1.29.3"
prevKubernetesRelease: "1.28.3" prevKubernetesRelease: "1.28.3"
nvidiaContainerToolkitRelease: "v1.13.5" nvidiaContainerToolkitRelease: "v1.13.5"
nvidiaDriverRelease: "535.129.03" nvidiaDriverRelease: "535.129.03"

View File

@ -5,8 +5,7 @@ aliases:
- ../../../bare-metal-platforms/equinix-metal - ../../../bare-metal-platforms/equinix-metal
--- ---
You can create a Talos Linux cluster on Equinix Metal in a variety of ways, such as through the EM web UI, the `metal` command line too, or through PXE booting. You can create a Talos Linux cluster on Equinix Metal in a variety of ways, such as through the EM web UI, or the `metal` command line tool.
Talos Linux is a supported OS install option on Equinix Metal, so it's an easy process.
Regardless of the method, the process is: Regardless of the method, the process is:
@ -14,8 +13,8 @@ Regardless of the method, the process is:
* Generate the configurations using `talosctl`. * Generate the configurations using `talosctl`.
* Provision your machines on Equinix Metal. * Provision your machines on Equinix Metal.
* Push the configurations to your servers (if not done as part of the machine provisioning). * Push the configurations to your servers (if not done as part of the machine provisioning).
* configure your Kubernetes endpoint to point to the newly created control plane nodes * Configure your Kubernetes endpoint to point to the newly created control plane nodes.
* bootstrap the cluster * Bootstrap the cluster.
## Define the Kubernetes Endpoint ## Define the Kubernetes Endpoint
@ -65,10 +64,16 @@ The convention we use is `#!talos`.
## Provision the machines in Equinix Metal ## Provision the machines in Equinix Metal
Talos Linux can be PXE-booted on Equinix Metal using [Image Factory]({{< relref "../../../learn-more/image-factory" >}}), using the `equinixMetal` platform: e.g.
`https://pxe.factory.talos.dev/pxe/376567988ad370138ad8b2698212367b8edcb69b5fd68c80be1f2ec7d603b4ba/{{< release >}}/equinixMetal-amd64` (this URL references the default schematic and `amd64` architecture).
Follow the Image Factory guide to create a custom schematic, e.g. with CPU microcode updates.
The PXE boot URL can be used as the iPXE script URL.
### Using the Equinix Metal UI ### Using the Equinix Metal UI
Simply select the location and type of machines in the Equinix Metal web interface. Simply select the location and type of machines in the Equinix Metal web interface.
Select Talos as the Operating System, then select the number of servers to create, and name them (in lowercase only.) Select 'Custom iPXE' as the Operating System and enter the Image Factory PXE URL as the iPXE script URL, then select the number of servers to create, and name them (in lowercase only.)
Under *optional settings*, you can optionally paste in the contents of `controlplane.yaml` that was generated, above (ensuring you add a first line of `#!talos`). Under *optional settings*, you can optionally paste in the contents of `controlplane.yaml` that was generated, above (ensuring you add a first line of `#!talos`).
You can repeat this process to create machines of different types for control plane and worker nodes (although you would pass in `worker.yaml` for the worker nodes, as user data). You can repeat this process to create machines of different types for control plane and worker nodes (although you would pass in `worker.yaml` for the worker nodes, as user data).
@ -81,8 +86,6 @@ If you did not pass in the machine configuration as User Data, you need to provi
This guide assumes the user has a working API token,and the [Equinix Metal CLI](https://github.com/equinix/metal-cli/) installed. This guide assumes the user has a working API token,and the [Equinix Metal CLI](https://github.com/equinix/metal-cli/) installed.
Because Talos Linux is a supported operating system, Talos Linux machines can be provisioned directly via the CLI, using the `-O talos_v1` parameter (for Operating System).
<!-- textlint-disable one-sentence-per-line --> <!-- textlint-disable one-sentence-per-line -->
> Note: Ensure you have prepended `#!talos` to the `controlplane.yaml` file. > Note: Ensure you have prepended `#!talos` to the `controlplane.yaml` file.
<!-- textlint-enable one-sentence-per-line --> <!-- textlint-enable one-sentence-per-line -->
@ -91,49 +94,17 @@ Because Talos Linux is a supported operating system, Talos Linux machines can be
metal device create \ metal device create \
--project-id $PROJECT_ID \ --project-id $PROJECT_ID \
--facility $FACILITY \ --facility $FACILITY \
--operating-system "talos_v1" \ --operating-system "custom_ipxe" \
--ipxe-script-url "https://pxe.factory.talos.dev/pxe/376567988ad370138ad8b2698212367b8edcb69b5fd68c80be1f2ec7d603b4ba/{{< release >}}/equinixMetal-amd64" \
--plan $PLAN\ --plan $PLAN\
--hostname $HOSTNAME\ --hostname $HOSTNAME\
--userdata-file controlplane.yaml --userdata-file controlplane.yaml
``` ```
e.g. `metal device create -p <projectID> -f da11 -O talos_v1 -P c3.small.x86 -H steve.test.11 --userdata-file ./controlplane.yaml` e.g. `metal device create -p <projectID> -f da11 -O custom_ipxe -P c3.small.x86 -H steve.test.11 --userdata-file ./controlplane.yaml --ipxe-script-url "https://pxe.factory.talos.dev/pxe/376567988ad370138ad8b2698212367b8edcb69b5fd68c80be1f2ec7d603b4ba/{{< release >}}/equinixMetal-amd64"`
Repeat this to create each control plane node desired: there should usually be 3 for a HA cluster. Repeat this to create each control plane node desired: there should usually be 3 for a HA cluster.
### Network Booting via iPXE
Talos Linux can be PXE-booted on Equinix Metal using [Image Factory]({{< relref "../../../learn-more/image-factory" >}}), using the `equinixMetal` platform: e.g.
`https://pxe.factory.talos.dev/pxe/376567988ad370138ad8b2698212367b8edcb69b5fd68c80be1f2ec7d603b4ba/{{< release >}}/equinixMetal-amd64` (this URL references the default schematic and `amd64` architecture).
#### Create the Control Plane Nodes
```bash
metal device create \
--project-id $PROJECT_ID \
--facility $FACILITY \
--ipxe-script-url $PXE_SERVER \
--operating-system "custom_ipxe" \
--plan $PLAN\
--hostname $HOSTNAME\
--userdata-file controlplane.yaml
```
> Note: Repeat this to create each control plane node desired: there should usually be 3 for a HA cluster.
#### Create the Worker Nodes
```bash
metal device create \
--project-id $PROJECT_ID \
--facility $FACILITY \
--ipxe-script-url $PXE_SERVER \
--operating-system "custom_ipxe" \
--plan $PLAN\
--hostname $HOSTNAME\
--userdata-file worker.yaml
```
## Update the Kubernetes endpoint ## Update the Kubernetes endpoint
Now our control plane nodes have been created, and we know their IP addresses, we can associate them with the Kubernetes endpoint. Now our control plane nodes have been created, and we know their IP addresses, we can associate them with the Kubernetes endpoint.

View File

@ -5,8 +5,7 @@ aliases:
- ../../../bare-metal-platforms/equinix-metal - ../../../bare-metal-platforms/equinix-metal
--- ---
You can create a Talos Linux cluster on Equinix Metal in a variety of ways, such as through the EM web UI, the `metal` command line too, or through PXE booting. You can create a Talos Linux cluster on Equinix Metal in a variety of ways, such as through the EM web UI, or the `metal` command line tool.
Talos Linux is a supported OS install option on Equinix Metal, so it's an easy process.
Regardless of the method, the process is: Regardless of the method, the process is:
@ -14,8 +13,8 @@ Regardless of the method, the process is:
* Generate the configurations using `talosctl`. * Generate the configurations using `talosctl`.
* Provision your machines on Equinix Metal. * Provision your machines on Equinix Metal.
* Push the configurations to your servers (if not done as part of the machine provisioning). * Push the configurations to your servers (if not done as part of the machine provisioning).
* configure your Kubernetes endpoint to point to the newly created control plane nodes * Configure your Kubernetes endpoint to point to the newly created control plane nodes.
* bootstrap the cluster * Bootstrap the cluster.
## Define the Kubernetes Endpoint ## Define the Kubernetes Endpoint
@ -65,10 +64,16 @@ The convention we use is `#!talos`.
## Provision the machines in Equinix Metal ## Provision the machines in Equinix Metal
Talos Linux can be PXE-booted on Equinix Metal using [Image Factory]({{< relref "../../../learn-more/image-factory" >}}), using the `equinixMetal` platform: e.g.
`https://pxe.factory.talos.dev/pxe/376567988ad370138ad8b2698212367b8edcb69b5fd68c80be1f2ec7d603b4ba/{{< release >}}/equinixMetal-amd64` (this URL references the default schematic and `amd64` architecture).
Follow the Image Factory guide to create a custom schematic, e.g. with CPU microcode updates.
The PXE boot URL can be used as the iPXE script URL.
### Using the Equinix Metal UI ### Using the Equinix Metal UI
Simply select the location and type of machines in the Equinix Metal web interface. Simply select the location and type of machines in the Equinix Metal web interface.
Select Talos as the Operating System, then select the number of servers to create, and name them (in lowercase only.) Select 'Custom iPXE' as the Operating System and enter the Image Factory PXE URL as the iPXE script URL, then select the number of servers to create, and name them (in lowercase only.)
Under *optional settings*, you can optionally paste in the contents of `controlplane.yaml` that was generated, above (ensuring you add a first line of `#!talos`). Under *optional settings*, you can optionally paste in the contents of `controlplane.yaml` that was generated, above (ensuring you add a first line of `#!talos`).
You can repeat this process to create machines of different types for control plane and worker nodes (although you would pass in `worker.yaml` for the worker nodes, as user data). You can repeat this process to create machines of different types for control plane and worker nodes (although you would pass in `worker.yaml` for the worker nodes, as user data).
@ -81,8 +86,6 @@ If you did not pass in the machine configuration as User Data, you need to provi
This guide assumes the user has a working API token,and the [Equinix Metal CLI](https://github.com/equinix/metal-cli/) installed. This guide assumes the user has a working API token,and the [Equinix Metal CLI](https://github.com/equinix/metal-cli/) installed.
Because Talos Linux is a supported operating system, Talos Linux machines can be provisioned directly via the CLI, using the `-O talos_v1` parameter (for Operating System).
<!-- textlint-disable one-sentence-per-line --> <!-- textlint-disable one-sentence-per-line -->
> Note: Ensure you have prepended `#!talos` to the `controlplane.yaml` file. > Note: Ensure you have prepended `#!talos` to the `controlplane.yaml` file.
<!-- textlint-enable one-sentence-per-line --> <!-- textlint-enable one-sentence-per-line -->
@ -91,49 +94,17 @@ Because Talos Linux is a supported operating system, Talos Linux machines can be
metal device create \ metal device create \
--project-id $PROJECT_ID \ --project-id $PROJECT_ID \
--facility $FACILITY \ --facility $FACILITY \
--operating-system "talos_v1" \ --operating-system "custom_ipxe" \
--ipxe-script-url "https://pxe.factory.talos.dev/pxe/376567988ad370138ad8b2698212367b8edcb69b5fd68c80be1f2ec7d603b4ba/{{< release >}}/equinixMetal-amd64" \
--plan $PLAN\ --plan $PLAN\
--hostname $HOSTNAME\ --hostname $HOSTNAME\
--userdata-file controlplane.yaml --userdata-file controlplane.yaml
``` ```
e.g. `metal device create -p <projectID> -f da11 -O talos_v1 -P c3.small.x86 -H steve.test.11 --userdata-file ./controlplane.yaml` e.g. `metal device create -p <projectID> -f da11 -O custom_ipxe -P c3.small.x86 -H steve.test.11 --userdata-file ./controlplane.yaml --ipxe-script-url "https://pxe.factory.talos.dev/pxe/376567988ad370138ad8b2698212367b8edcb69b5fd68c80be1f2ec7d603b4ba/{{< release >}}/equinixMetal-amd64"`
Repeat this to create each control plane node desired: there should usually be 3 for a HA cluster. Repeat this to create each control plane node desired: there should usually be 3 for a HA cluster.
### Network Booting via iPXE
Talos Linux can be PXE-booted on Equinix Metal using [Image Factory]({{< relref "../../../learn-more/image-factory" >}}), using the `equinixMetal` platform: e.g.
`https://pxe.factory.talos.dev/pxe/376567988ad370138ad8b2698212367b8edcb69b5fd68c80be1f2ec7d603b4ba/{{< release >}}/equinixMetal-amd64` (this URL references the default schematic and `amd64` architecture).
#### Create the Control Plane Nodes
```bash
metal device create \
--project-id $PROJECT_ID \
--facility $FACILITY \
--ipxe-script-url $PXE_SERVER \
--operating-system "custom_ipxe" \
--plan $PLAN\
--hostname $HOSTNAME\
--userdata-file controlplane.yaml
```
> Note: Repeat this to create each control plane node desired: there should usually be 3 for a HA cluster.
#### Create the Worker Nodes
```bash
metal device create \
--project-id $PROJECT_ID \
--facility $FACILITY \
--ipxe-script-url $PXE_SERVER \
--operating-system "custom_ipxe" \
--plan $PLAN\
--hostname $HOSTNAME\
--userdata-file worker.yaml
```
## Update the Kubernetes endpoint ## Update the Kubernetes endpoint
Now our control plane nodes have been created, and we know their IP addresses, we can associate them with the Kubernetes endpoint. Now our control plane nodes have been created, and we know their IP addresses, we can associate them with the Kubernetes endpoint.