github.com/grailbio/base@v0.0.11/cloud/spotadvisor/spotadvisor_test.go (about)

     1  // Copyright 2021 GRAIL, Inc. All rights reserved.
     2  // Use of this source code is governed by the Apache 2.0
     3  // license that can be found in the LICENSE file.
     4  
     5  package spotadvisor_test
     6  
     7  import (
     8  	"context"
     9  	"fmt"
    10  	"io/ioutil"
    11  	"log"
    12  	"net/http"
    13  	"net/http/httptest"
    14  	"reflect"
    15  	"sort"
    16  	"testing"
    17  
    18  	sa "github.com/grailbio/base/cloud/spotadvisor"
    19  )
    20  
    21  // Contains an abridged version of a real response to make got/want comparisons easier.
    22  const testDataPath = "./testdata/test-spot-advisor-data.json"
    23  
    24  // TestGetAndFilterByInterruptRate tests both GetInstancesWithMaxInterruptProbability and FilterByMaxInterruptProbability.
    25  func TestGetAndFilterByInterruptRate(t *testing.T) {
    26  	defer setupMockTestServer(t).Close()
    27  	adv, err := sa.NewSpotAdvisor(testLogger, context.Background().Done())
    28  	if err != nil {
    29  		t.Fatalf(err.Error())
    30  	}
    31  	tests := []struct {
    32  		name             string
    33  		osType           sa.OsType
    34  		region           sa.AwsRegion
    35  		maxInterruptProb sa.InterruptProbability
    36  		candidates       []string
    37  		want             []string
    38  		wantErr          error
    39  	}{
    40  		{
    41  			name:             "simple",
    42  			osType:           sa.Windows,
    43  			region:           sa.AwsRegion("eu-west-2"),
    44  			candidates:       testAvailableInstanceTypes,
    45  			maxInterruptProb: sa.LessThanFivePct,
    46  			want:             []string{"r4.xlarge"},
    47  		},
    48  		{
    49  			name:             "<5%",
    50  			osType:           sa.Linux,
    51  			region:           sa.AwsRegion("eu-west-2"),
    52  			candidates:       testAvailableInstanceTypes,
    53  			maxInterruptProb: sa.LessThanFivePct,
    54  			want:             []string{"m5a.4xlarge"},
    55  		},
    56  		{
    57  			name:             "<10%",
    58  			osType:           sa.Linux,
    59  			region:           sa.AwsRegion("eu-west-2"),
    60  			candidates:       testAvailableInstanceTypes,
    61  			maxInterruptProb: sa.LessThanTenPct,
    62  			want:             []string{"m5a.4xlarge", "t3.nano"},
    63  		},
    64  		{
    65  			name:             "<15%",
    66  			osType:           sa.Linux,
    67  			region:           sa.AwsRegion("eu-west-2"),
    68  			candidates:       testAvailableInstanceTypes,
    69  			maxInterruptProb: sa.LessThanFifteenPct,
    70  			want:             []string{"m5a.4xlarge", "t3.nano", "g4dn.12xlarge"},
    71  		},
    72  		{
    73  			name:             "<20%",
    74  			osType:           sa.Linux,
    75  			region:           sa.AwsRegion("eu-west-2"),
    76  			candidates:       testAvailableInstanceTypes,
    77  			maxInterruptProb: sa.LessThanTwentyPct,
    78  			want:             []string{"m5a.4xlarge", "t3.nano", "g4dn.12xlarge", "r5d.8xlarge"},
    79  		},
    80  		{
    81  			name:             "Any",
    82  			osType:           sa.Linux,
    83  			region:           sa.AwsRegion("eu-west-2"),
    84  			candidates:       testAvailableInstanceTypes,
    85  			maxInterruptProb: sa.Any,
    86  			want:             testAvailableInstanceTypes,
    87  		},
    88  		{
    89  			name:             "bad_interrupt_prob_neg",
    90  			osType:           sa.Linux,
    91  			region:           sa.AwsRegion("eu-west-2"),
    92  			candidates:       testAvailableInstanceTypes,
    93  			maxInterruptProb: sa.InterruptProbability(-1),
    94  			want:             nil,
    95  			wantErr:          fmt.Errorf("invalid InterruptProbability: -1"),
    96  		},
    97  		{
    98  			name:             "bad_interrupt_prob_pos",
    99  			osType:           sa.Linux,
   100  			region:           sa.AwsRegion("eu-west-2"),
   101  			candidates:       testAvailableInstanceTypes,
   102  			maxInterruptProb: sa.InterruptProbability(6),
   103  			want:             nil,
   104  			wantErr:          fmt.Errorf("invalid InterruptProbability: 6"),
   105  		},
   106  		{
   107  			name:             "bad_instance_region",
   108  			osType:           sa.Linux,
   109  			region:           sa.AwsRegion("us-foo-2"),
   110  			candidates:       testAvailableInstanceTypes,
   111  			maxInterruptProb: sa.LessThanFifteenPct,
   112  			want:             nil,
   113  			wantErr:          fmt.Errorf("no spot advisor data for: {Linux, us-foo-2, < 15%%}"),
   114  		},
   115  	}
   116  	for _, tt := range tests {
   117  		name := fmt.Sprintf("%s_%s_%s_%d", tt.name, tt.osType, tt.region, tt.maxInterruptProb)
   118  		t.Run(name, func(t *testing.T) {
   119  			got, gotErr := adv.FilterByMaxInterruptProbability(tt.osType, tt.region, tt.candidates, tt.maxInterruptProb)
   120  			checkErr(t, tt.wantErr, gotErr)
   121  			if tt.wantErr == nil {
   122  				checkEqual(t, tt.want, got)
   123  			}
   124  		})
   125  	}
   126  }
   127  
   128  func TestGetInterruptRange(t *testing.T) {
   129  	defer setupMockTestServer(t).Close()
   130  	adv, err := sa.NewSpotAdvisor(testLogger, context.Background().Done())
   131  	if err != nil {
   132  		t.Fatalf(err.Error())
   133  	}
   134  	tests := []struct {
   135  		name         string
   136  		osType       sa.OsType
   137  		region       sa.AwsRegion
   138  		instanceType sa.InstanceType
   139  		want         sa.InterruptRange
   140  		wantErr      error
   141  	}{
   142  		{
   143  			name:         "simple",
   144  			osType:       sa.Windows,
   145  			region:       sa.AwsRegion("us-west-2"),
   146  			instanceType: "c5a.24xlarge",
   147  			want:         sa.TenToFifteenPct,
   148  		},
   149  		{
   150  			name:         "bad_region",
   151  			osType:       sa.Windows,
   152  			region:       sa.AwsRegion("us-foo-2"),
   153  			instanceType: "c5a.24xlarge",
   154  			want:         -1,
   155  			wantErr:      fmt.Errorf("no spot advisor data for: us-foo-2"),
   156  		},
   157  		{
   158  			name:         "bad_os",
   159  			osType:       sa.OsType("Unix"),
   160  			region:       sa.AwsRegion("us-west-2"),
   161  			instanceType: "c5a.24xlarge",
   162  			want:         -1,
   163  			wantErr:      fmt.Errorf("invalid OS: Unix"),
   164  		},
   165  		{
   166  			name:         "bad_instance_type",
   167  			osType:       sa.Linux,
   168  			region:       sa.AwsRegion("us-west-2"),
   169  			instanceType: "foo.bar",
   170  			want:         -1,
   171  			wantErr:      fmt.Errorf("no spot advisor data for Linux instance type 'foo.bar' in us-west-2"),
   172  		},
   173  	}
   174  	for _, tt := range tests {
   175  		name := fmt.Sprintf("%s_%s_%s", tt.name, tt.osType, tt.region)
   176  		t.Run(name, func(t *testing.T) {
   177  			got, gotErr := adv.GetInterruptRange(tt.osType, tt.region, tt.instanceType)
   178  			checkErr(t, tt.wantErr, gotErr)
   179  			if tt.wantErr == nil && tt.want != got {
   180  				t.Fatalf("want: %s, got: %s", tt.want, got)
   181  			}
   182  		})
   183  	}
   184  }
   185  
   186  func TestGetMaxInterruptProbability(t *testing.T) {
   187  	defer setupMockTestServer(t).Close()
   188  	adv, err := sa.NewSpotAdvisor(testLogger, context.Background().Done())
   189  	if err != nil {
   190  		t.Fatalf(err.Error())
   191  	}
   192  	tests := []struct {
   193  		name         string
   194  		osType       sa.OsType
   195  		region       sa.AwsRegion
   196  		instanceType sa.InstanceType
   197  		want         sa.InterruptProbability
   198  		wantErr      error
   199  	}{
   200  		{
   201  			name:         "simple_<5%",
   202  			osType:       sa.Linux,
   203  			region:       sa.AwsRegion("eu-west-2"),
   204  			instanceType: "m5a.4xlarge",
   205  			want:         sa.LessThanFivePct,
   206  		},
   207  		{
   208  			name:         "simple_<10%",
   209  			osType:       sa.Linux,
   210  			region:       sa.AwsRegion("eu-west-2"),
   211  			instanceType: "t3.nano",
   212  			want:         sa.LessThanTenPct,
   213  		},
   214  		{
   215  			name:         "simple_<15%",
   216  			osType:       sa.Linux,
   217  			region:       sa.AwsRegion("eu-west-2"),
   218  			instanceType: "g4dn.12xlarge",
   219  			want:         sa.LessThanFifteenPct,
   220  		},
   221  		{
   222  			name:         "simple_<20%",
   223  			osType:       sa.Linux,
   224  			region:       sa.AwsRegion("eu-west-2"),
   225  			instanceType: "r5d.8xlarge",
   226  			want:         sa.LessThanTwentyPct,
   227  		},
   228  		{
   229  			name:         "simple_Any",
   230  			osType:       sa.Linux,
   231  			region:       sa.AwsRegion("eu-west-2"),
   232  			instanceType: "i3.2xlarge",
   233  			want:         sa.Any,
   234  		},
   235  		{
   236  			name:         "bad_region",
   237  			osType:       sa.Windows,
   238  			region:       sa.AwsRegion("us-foo-2"),
   239  			instanceType: "c5a.24xlarge",
   240  			want:         -1,
   241  			wantErr:      fmt.Errorf("no spot advisor data for: us-foo-2"),
   242  		},
   243  		{
   244  			name:         "bad_os",
   245  			osType:       sa.OsType("Unix"),
   246  			region:       sa.AwsRegion("us-west-2"),
   247  			instanceType: "c5a.24xlarge",
   248  			want:         -1,
   249  			wantErr:      fmt.Errorf("invalid OS: Unix"),
   250  		},
   251  		{
   252  			name:         "bad_instance_type",
   253  			osType:       sa.Linux,
   254  			region:       sa.AwsRegion("us-west-2"),
   255  			instanceType: "foo.bar",
   256  			want:         -1,
   257  			wantErr:      fmt.Errorf("no spot advisor data for Linux instance type 'foo.bar' in us-west-2"),
   258  		},
   259  	}
   260  	for _, tt := range tests {
   261  		t.Run(tt.name, func(t *testing.T) {
   262  			got, gotErr := adv.GetMaxInterruptProbability(tt.osType, tt.region, tt.instanceType)
   263  			checkErr(t, tt.wantErr, gotErr)
   264  			if tt.wantErr == nil && tt.want != got {
   265  				t.Fatalf("want: %s, got: %s", tt.want, got)
   266  			}
   267  		})
   268  	}
   269  
   270  }
   271  
   272  // setupMockTestServer starts a test server and replaces the actual spot advisor
   273  // data URL with the test server's URL. A request to the server will return the
   274  // contents of the file at testDataPath. The caller is expected to call Close()
   275  // on the returned test server.
   276  func setupMockTestServer(t *testing.T) *httptest.Server {
   277  	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
   278  		b, err := ioutil.ReadFile(testDataPath)
   279  		if err != nil {
   280  			t.Fatal(err)
   281  		}
   282  		if _, err := w.Write(b); err != nil {
   283  			t.Fatal(err)
   284  		}
   285  	}))
   286  	sa.SetSpotAdvisorDataUrl(ts.URL)
   287  	return ts
   288  }
   289  
   290  func checkEqual(t *testing.T, want []string, got []string) {
   291  	if len(want) != len(got) {
   292  		t.Fatalf("\nwant:\t%s\ngot:\t%s", want, got)
   293  	}
   294  	sort.Strings(want)
   295  	sort.Strings(got)
   296  	if !reflect.DeepEqual(got, got) {
   297  		t.Fatalf("\nwant:\t%s\ngot:\t%s", want, got)
   298  	}
   299  }
   300  
   301  func checkErr(t *testing.T, want error, got error) {
   302  	if want != nil && got != nil {
   303  		if want.Error() != got.Error() {
   304  			t.Fatalf("want: %s, got: %s", want, got)
   305  		} else {
   306  			return
   307  		}
   308  	}
   309  	if want != got {
   310  		t.Fatalf("want: %s, got: %s", want, got)
   311  	}
   312  }
   313  
   314  var testLogger = log.New(ioutil.Discard, "", 0)
   315  
   316  var testAvailableInstanceTypes = []string{
   317  	"a1.2xlarge", "a1.4xlarge", "a1.large", "a1.metal", "a1.xlarge", "c1.xlarge", "c3.2xlarge", "c3.4xlarge", "c3.8xlarge", "c3.large", "c3.xlarge",
   318  	"c4.2xlarge", "c4.4xlarge", "c4.8xlarge", "c4.large", "c4.xlarge", "c5.12xlarge", "c5.18xlarge", "c5.24xlarge", "c5.2xlarge", "c5.4xlarge", "c5.9xlarge",
   319  	"c5.large", "c5.metal", "c5.xlarge", "c5a.12xlarge", "c5a.16xlarge", "c5a.24xlarge", "c5a.2xlarge", "c5a.4xlarge", "c5a.8xlarge", "c5a.large", "c5a.xlarge",
   320  	"c5ad.12xlarge", "c5ad.16xlarge", "c5ad.24xlarge", "c5ad.2xlarge", "c5ad.4xlarge", "c5ad.8xlarge", "c5ad.large", "c5ad.xlarge", "c5d.12xlarge", "c5d.18xlarge",
   321  	"c5d.24xlarge", "c5d.2xlarge", "c5d.4xlarge", "c5d.9xlarge", "c5d.large", "c5d.metal", "c5d.xlarge", "c5n.18xlarge", "c5n.2xlarge", "c5n.4xlarge", "c5n.9xlarge",
   322  	"c5n.large", "c5n.metal", "c5n.xlarge", "c6g.12xlarge", "c6g.16xlarge", "c6g.2xlarge", "c6g.4xlarge", "c6g.8xlarge", "c6g.large", "c6g.metal", "c6g.xlarge",
   323  	"c6gd.12xlarge", "c6gd.16xlarge", "c6gd.2xlarge", "c6gd.4xlarge", "c6gd.8xlarge", "c6gd.large", "c6gd.metal", "c6gd.xlarge", "c6gn.12xlarge", "c6gn.16xlarge", "c6gn.2xlarge",
   324  	"c6gn.4xlarge", "c6gn.8xlarge", "c6gn.large", "c6gn.xlarge", "cr1.8xlarge", "d2.2xlarge", "d2.4xlarge", "d2.8xlarge", "d2.xlarge", "d3.2xlarge", "d3.4xlarge",
   325  	"d3.8xlarge", "d3.xlarge", "d3en.12xlarge", "d3en.2xlarge", "d3en.4xlarge", "d3en.6xlarge", "d3en.8xlarge", "d3en.xlarge", "f1.16xlarge", "f1.2xlarge", "f1.4xlarge",
   326  	"g2.2xlarge", "g2.8xlarge", "g3.16xlarge", "g3.4xlarge", "g3.8xlarge", "g3s.xlarge", "g4ad.16xlarge", "g4ad.4xlarge", "g4ad.8xlarge", "g4dn.12xlarge", "g4dn.16xlarge",
   327  	"g4dn.2xlarge", "g4dn.4xlarge", "g4dn.8xlarge", "g4dn.metal", "g4dn.xlarge", "h1.16xlarge", "h1.2xlarge", "h1.4xlarge", "h1.8xlarge", "hs1.8xlarge", "i2.2xlarge",
   328  	"i2.4xlarge", "i2.8xlarge", "i2.xlarge", "i3.16xlarge", "i3.2xlarge", "i3.4xlarge", "i3.8xlarge", "i3.large", "i3.metal", "i3.xlarge", "i3en.12xlarge",
   329  	"i3en.24xlarge", "i3en.2xlarge", "i3en.3xlarge", "i3en.6xlarge", "i3en.large", "i3en.metal", "i3en.xlarge", "inf1.24xlarge", "inf1.2xlarge", "inf1.6xlarge", "inf1.xlarge",
   330  	"m1.large", "m1.xlarge", "m2.2xlarge", "m2.4xlarge", "m2.xlarge", "m3.2xlarge", "m3.large", "m3.xlarge", "m4.10xlarge", "m4.16xlarge", "m4.2xlarge",
   331  	"m4.4xlarge", "m4.large", "m4.xlarge", "m5.12xlarge", "m5.16xlarge", "m5.24xlarge", "m5.2xlarge", "m5.4xlarge", "m5.8xlarge", "m5.large", "m5.metal",
   332  	"m5.xlarge", "m5a.12xlarge", "m5a.16xlarge", "m5a.24xlarge", "m5a.2xlarge", "m5a.4xlarge", "m5a.8xlarge", "m5a.large", "m5a.xlarge", "m5ad.12xlarge", "m5ad.16xlarge",
   333  	"m5ad.24xlarge", "m5ad.2xlarge", "m5ad.4xlarge", "m5ad.8xlarge", "m5ad.large", "m5ad.xlarge", "m5d.12xlarge", "m5d.16xlarge", "m5d.24xlarge", "m5d.2xlarge", "m5d.4xlarge",
   334  	"m5d.8xlarge", "m5d.large", "m5d.metal", "m5d.xlarge", "m5dn.12xlarge", "m5dn.16xlarge", "m5dn.24xlarge", "m5dn.2xlarge", "m5dn.4xlarge", "m5dn.8xlarge", "m5dn.large",
   335  	"m5dn.metal", "m5dn.xlarge", "m5n.12xlarge", "m5n.16xlarge", "m5n.24xlarge", "m5n.2xlarge", "m5n.4xlarge", "m5n.8xlarge", "m5n.large", "m5n.metal", "m5n.xlarge",
   336  	"m5zn.12xlarge", "m5zn.2xlarge", "m5zn.3xlarge", "m5zn.6xlarge", "m5zn.large", "m5zn.metal", "m5zn.xlarge", "m6g.12xlarge", "m6g.16xlarge", "m6g.2xlarge", "m6g.4xlarge",
   337  	"m6g.8xlarge", "m6g.large", "m6g.metal", "m6g.xlarge", "m6gd.12xlarge", "m6gd.16xlarge", "m6gd.2xlarge", "m6gd.4xlarge", "m6gd.8xlarge", "m6gd.large", "m6gd.metal",
   338  	"m6gd.xlarge", "p2.16xlarge", "p2.8xlarge", "p2.xlarge", "p3.16xlarge", "p3.2xlarge", "p3.8xlarge", "p3dn.24xlarge", "p4d.24xlarge", "r3.2xlarge", "r3.4xlarge",
   339  	"r3.8xlarge", "r3.large", "r3.xlarge", "r4.16xlarge", "r4.2xlarge", "r4.4xlarge", "r4.8xlarge", "r4.large", "r4.xlarge", "r5.12xlarge", "r5.16xlarge",
   340  	"r5.24xlarge", "r5.2xlarge", "r5.4xlarge", "r5.8xlarge", "r5.large", "r5.metal", "r5.xlarge", "r5a.12xlarge", "r5a.16xlarge", "r5a.24xlarge", "r5a.2xlarge",
   341  	"r5a.4xlarge", "r5a.8xlarge", "r5a.large", "r5a.xlarge", "r5ad.12xlarge", "r5ad.16xlarge", "r5ad.24xlarge", "r5ad.2xlarge", "r5ad.4xlarge", "r5ad.8xlarge", "r5ad.large",
   342  	"r5ad.xlarge", "r5b.12xlarge", "r5b.16xlarge", "r5b.24xlarge", "r5b.2xlarge", "r5b.4xlarge", "r5b.8xlarge", "r5b.large", "r5b.metal", "r5b.xlarge", "r5d.12xlarge",
   343  	"r5d.16xlarge", "r5d.24xlarge", "r5d.2xlarge", "r5d.4xlarge", "r5d.8xlarge", "r5d.large", "r5d.metal", "r5d.xlarge", "r5dn.12xlarge", "r5dn.16xlarge", "r5dn.24xlarge",
   344  	"r5dn.2xlarge", "r5dn.4xlarge", "r5dn.8xlarge", "r5dn.large", "r5dn.metal", "r5dn.xlarge", "r5n.12xlarge", "r5n.16xlarge", "r5n.24xlarge", "r5n.2xlarge", "r5n.4xlarge",
   345  	"r5n.8xlarge", "r5n.large", "r5n.metal", "r5n.xlarge", "r6g.12xlarge", "r6g.16xlarge", "r6g.2xlarge", "r6g.4xlarge", "r6g.8xlarge", "r6g.large", "r6g.metal",
   346  	"r6g.xlarge", "r6gd.12xlarge", "r6gd.16xlarge", "r6gd.2xlarge", "r6gd.4xlarge", "r6gd.8xlarge", "r6gd.large", "r6gd.metal", "r6gd.xlarge", "t1.micro", "t2.2xlarge",
   347  	"t2.large", "t2.micro", "t2.nano", "t2.xlarge", "t3.2xlarge", "t3.large", "t3.micro", "t3.nano", "t3.xlarge", "t3a.2xlarge", "t3a.large",
   348  	"t3a.micro", "t3a.nano", "t3a.xlarge", "t4g.2xlarge", "t4g.large", "t4g.micro", "t4g.nano", "t4g.xlarge", "u-12tb1.112xlarge", "u-6tb1.112xlarge", "u-6tb1.56xlarge",
   349  	"u-9tb1.112xlarge", "x1.16xlarge", "x1.32xlarge", "x1e.16xlarge", "x1e.2xlarge", "x1e.32xlarge", "x1e.4xlarge", "x1e.8xlarge", "x1e.xlarge", "x2gd.12xlarge", "x2gd.16xlarge",
   350  	"x2gd.2xlarge", "x2gd.4xlarge", "x2gd.8xlarge", "x2gd.large", "x2gd.metal", "x2gd.xlarge", "z1d.12xlarge", "z1d.2xlarge", "z1d.3xlarge", "z1d.6xlarge", "z1d.large",
   351  	"z1d.metal", "z1d.xlarge",
   352  }