Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions api/holodeck/v1alpha1/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,12 @@ type Instance struct {
Type string `json:"type"`
Region string `json:"region"`

// AvailabilityZone places the instance in a specific zone of Region
// (e.g., "us-west-2a"). When unset, Holodeck picks a zone that offers
// the instance type.
// +optional
AvailabilityZone string `json:"availabilityZone,omitempty"`

// OS specifies the operating system by ID (e.g., "ubuntu-22.04").
// When set, the AMI is automatically resolved for the region and
// architecture. Takes precedence over Image.ImageId if both are specified.
Expand Down Expand Up @@ -169,6 +175,12 @@ type ClusterSpec struct {
// +required
Region string `json:"region"`

// AvailabilityZone places all cluster nodes in a specific zone of Region
// (e.g., "us-west-2a"). When unset, Holodeck picks a zone that offers
// both the control-plane and worker instance types.
// +optional
AvailabilityZone string `json:"availabilityZone,omitempty"`

// ControlPlane defines the control-plane node configuration.
// +required
ControlPlane ControlPlaneSpec `json:"controlPlane"`
Expand Down
18 changes: 14 additions & 4 deletions cmd/cli/describe/describe.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import (
"github.com/NVIDIA/holodeck/internal/logger"
"github.com/NVIDIA/holodeck/pkg/jyaml"
"github.com/NVIDIA/holodeck/pkg/output"
"github.com/NVIDIA/holodeck/pkg/provider/aws"

cli "github.com/urfave/cli/v3"
)
Expand Down Expand Up @@ -59,10 +60,11 @@ type InstanceInfo struct {

// ProviderInfo contains provider configuration
type ProviderInfo struct {
Type string `json:"type" yaml:"type"`
Region string `json:"region,omitempty" yaml:"region,omitempty"`
Username string `json:"username" yaml:"username"`
KeyName string `json:"keyName" yaml:"keyName"`
Type string `json:"type" yaml:"type"`
Region string `json:"region,omitempty" yaml:"region,omitempty"`
AvailabilityZone string `json:"availabilityZone,omitempty" yaml:"availabilityZone,omitempty"`
Username string `json:"username" yaml:"username"`
KeyName string `json:"keyName" yaml:"keyName"`
}

// ClusterInfo contains cluster configuration
Expand Down Expand Up @@ -308,6 +310,11 @@ func (m command) buildDescribeOutput(instance *instances.Instance, env *v1alpha1
} else {
output.Provider.Region = env.Spec.Region
}
for _, property := range env.Status.Properties {
if property.Name == aws.AvailabilityZone {
output.Provider.AvailabilityZone = property.Value
}
}

// Cluster info
if env.Spec.Cluster != nil {
Expand Down Expand Up @@ -555,6 +562,9 @@ func (m command) printTableFormat(d *DescribeOutput) error {
if d.Provider.Region != "" {
fmt.Printf("Region: %s\n", d.Provider.Region)
}
if d.Provider.AvailabilityZone != "" {
fmt.Printf("Zone: %s\n", d.Provider.AvailabilityZone)
}
fmt.Printf("Username: %s\n", d.Provider.Username)
fmt.Printf("Key Name: %s\n", d.Provider.KeyName)

Expand Down
46 changes: 46 additions & 0 deletions cmd/cli/describe/describe_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ package describe
import (
"testing"
"time"

"github.com/NVIDIA/holodeck/api/holodeck/v1alpha1"
"github.com/NVIDIA/holodeck/internal/instances"
)

func TestDescribeOutput_InstanceInfo(t *testing.T) {
Expand Down Expand Up @@ -198,3 +201,46 @@ func TestAWSResourcesInfo(t *testing.T) {
t.Errorf("expected vpc-123, got %s", output.AWSResources.VpcID)
}
}

func TestBuildDescribeOutput_AvailabilityZone(t *testing.T) {
singleNodeSpec := v1alpha1.EnvironmentSpec{
Provider: v1alpha1.ProviderAWS,
Instance: v1alpha1.Instance{Region: "us-west-2"},
}
clusterSpec := v1alpha1.EnvironmentSpec{
Provider: v1alpha1.ProviderAWS,
Cluster: &v1alpha1.ClusterSpec{Region: "us-west-2"},
}
propertiesWithZone := []v1alpha1.Properties{
{Name: "vpc-id", Value: "vpc-123"},
{Name: "availability-zone", Value: "us-west-2c"},
}
propertiesWithoutZone := []v1alpha1.Properties{
{Name: "vpc-id", Value: "vpc-123"},
}

tests := []struct {
name string
spec v1alpha1.EnvironmentSpec
properties []v1alpha1.Properties
want string
}{
{"single node", singleNodeSpec, propertiesWithZone, "us-west-2c"},
{"cluster", clusterSpec, propertiesWithZone, "us-west-2c"},
{"cache written before the zone was recorded", singleNodeSpec, propertiesWithoutZone, ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
env := &v1alpha1.Environment{
Spec: tt.spec,
Status: v1alpha1.EnvironmentStatus{Properties: tt.properties},
}

output := command{}.buildDescribeOutput(&instances.Instance{}, env, time.Hour)

if output.Provider.AvailabilityZone != tt.want {
t.Errorf("expected availability zone %q, got %q", tt.want, output.Provider.AvailabilityZone)
}
})
}
}
22 changes: 22 additions & 0 deletions docs/commands/create.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,28 @@ spec:
See the [Multinode Clusters Guide](../guides/multinode-clusters.md) for detailed
configuration options and examples.

### Availability Zone

Not every instance type is offered in every Availability Zone of a region.
Holodeck creates the environment's subnets in a zone that offers all of the
requested instance types, and fails before creating any resources if no zone
does. To pin a zone, set `availabilityZone` under `instance` (or `cluster`):

```yaml
instance:
type: g5g.xlarge
region: us-west-2
availabilityZone: <zone>
```

Zone names differ between AWS accounts, so leave `availabilityZone` unset to let
Holodeck choose, or pick a zone that offers the instance type; if it does not,
the pre-flight error lists the zones that do.

Choosing a zone needs the `ec2:DescribeAvailabilityZones` and
`ec2:DescribeInstanceTypeOfferings` permissions; without them Holodeck lets AWS
choose the zone and rejects a pinned `availabilityZone`.

## Automated IP Detection

Holodeck now automatically detects your public IP address when creating AWS
Expand Down
1 change: 1 addition & 0 deletions docs/guides/multinode-clusters.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ holodeck create -f cluster.yaml --provision -k kubeconfig.yaml
| Field | Type | Description |
|-------|------|-------------|
| `region` | string | AWS region for all nodes (required) |
| `availabilityZone` | string | Zone for all nodes (optional; by default a zone offering every instance type is picked). Zone names differ between AWS accounts |
| `controlPlane` | ControlPlaneSpec | Control plane node configuration |
| `workers` | WorkerPoolSpec | Worker node pool configuration |
| `highAvailability` | HAConfig | HA settings (optional) |
Expand Down
7 changes: 7 additions & 0 deletions docs/prerequisites.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@ To use the AWS provider, you need:
- VPC configuration
- Security group management
- IAM role management
- Pre-flight checks run by `holodeck create` and `holodeck dryrun`:
`ec2:DescribeInstanceTypes`
- Recommended: `ec2:DescribeInstanceTypeOfferings` and
`ec2:DescribeAvailabilityZones`, used to find an Availability Zone that
offers the requested instance types. Without them Holodeck logs a
warning and lets AWS choose the zone, and rejects a pinned
`availabilityZone`

### SSH Provider

Expand Down
98 changes: 98 additions & 0 deletions internal/aws/awsfake/awsfake_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -944,3 +944,101 @@ func TestSetInstanceTypeCatalog(t *testing.T) {
t.Fatalf("empty catalog must return no types, got %+v", empty.InstanceTypes)
}
}

// The provider's zone-selection tests depend on these seeding controls and on following NextToken.
func TestDescribeInstanceTypeOfferings(t *testing.T) {
f := New()
f.Store.SeedInstanceTypeZones("g5g.xlarge", "us-west-2b", "us-west-2c")
f.Store.SeedInstanceTypeAbsent("t99.nonexistent")
f.Store.SeedAvailabilityZone(ec2types.AvailabilityZone{
ZoneName: aws.String("us-west-2-lax-1a"),
ZoneType: aws.String("local-zone"),
State: ec2types.AvailabilityZoneStateAvailable,
})

zonesByInstanceType := map[string][]string{}
input := &ec2.DescribeInstanceTypeOfferingsInput{
LocationType: ec2types.LocationTypeAvailabilityZone,
Filters: []ec2types.Filter{{
Name: aws.String("instance-type"),
Values: []string{"g5g.xlarge", "t3.medium", "t99.nonexistent"},
}},
}
pageCount := 0
for {
out, err := f.EC2.DescribeInstanceTypeOfferings(ctx, input)
if err != nil {
t.Fatalf("DescribeInstanceTypeOfferings: %v", err)
}
pageCount++
for _, offering := range out.InstanceTypeOfferings {
instanceType := string(offering.InstanceType)
zonesByInstanceType[instanceType] = append(zonesByInstanceType[instanceType], aws.ToString(offering.Location))
}
if out.NextToken == nil {
break
}
input.NextToken = out.NextToken
}

if pageCount < 2 {
t.Fatalf("expected paginated results, got %d page(s)", pageCount)
}
if got, want := zonesByInstanceType["g5g.xlarge"], []string{"us-west-2b", "us-west-2c"}; !slices.Equal(got, want) {
t.Fatalf("g5g.xlarge zones = %v, want %v", got, want)
}
if got, want := zonesByInstanceType["t3.medium"], []string{"us-west-2a", "us-west-2b", "us-west-2c", "us-west-2d"}; !slices.Equal(got, want) {
t.Fatalf("t3.medium zones = %v, want %v (local zones excluded by default)", got, want)
}
if got := zonesByInstanceType["t99.nonexistent"]; len(got) != 0 {
t.Fatalf("absent type must be offered nowhere, got %v", got)
}
}

// The provider filters on zone-type to exclude Local Zones, which sort before the region's own zones.
func TestDescribeAvailabilityZonesFiltersByZoneType(t *testing.T) {
f := New()
f.Store.SeedAvailabilityZone(ec2types.AvailabilityZone{
ZoneName: aws.String("us-west-2-lax-1a"),
ZoneType: aws.String("local-zone"),
State: ec2types.AvailabilityZoneStateAvailable,
})

zones, err := f.EC2.DescribeAvailabilityZones(ctx, &ec2.DescribeAvailabilityZonesInput{
Filters: []ec2types.Filter{{Name: aws.String("zone-type"), Values: []string{"availability-zone"}}},
})
if err != nil {
t.Fatalf("DescribeAvailabilityZones: %v", err)
}
if len(zones.AvailabilityZones) != 4 {
t.Fatalf("zone-type filter must drop the local zone, got %+v", zones.AvailabilityZones)
}
}

// The mock e2e test compares each stored subnet's zone with the zone Create records.
func TestCreateSubnetRecordsAvailabilityZone(t *testing.T) {
f := New()
subnet, err := f.EC2.CreateSubnet(ctx, &ec2.CreateSubnetInput{
VpcId: aws.String("vpc-x"),
CidrBlock: aws.String("10.0.0.0/24"),
AvailabilityZone: aws.String("us-west-2c"),
})
if err != nil {
t.Fatalf("CreateSubnet: %v", err)
}
if got := aws.ToString(subnet.Subnet.AvailabilityZone); got != "us-west-2c" {
t.Fatalf("subnet zone = %q, want us-west-2c", got)
}
}

// A negative NextToken parses cleanly, so it must be range-checked before it
// is used as a slice index.
func TestDescribeInstanceTypeOfferingsRejectsNegativeNextToken(t *testing.T) {
f := New()
_, err := f.EC2.DescribeInstanceTypeOfferings(ctx, &ec2.DescribeInstanceTypeOfferingsInput{
NextToken: aws.String("-1"),
})
if err == nil {
t.Fatal("expected an error for a negative NextToken")
}
}
Loading
Loading