diff --git a/go.work.sum b/go.work.sum index c6a00f684..d95a8c33a 100644 --- a/go.work.sum +++ b/go.work.sum @@ -57,6 +57,7 @@ golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0Y golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= +golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4= @@ -78,6 +79,7 @@ golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= diff --git a/nodebalancer.go b/nodebalancer.go index a6ed82343..ac23408e9 100644 --- a/nodebalancer.go +++ b/nodebalancer.go @@ -58,10 +58,12 @@ type NodeBalancerCreateOptions struct { Region string `json:"region,omitempty"` ClientConnThrottle *int `json:"client_conn_throttle,omitempty"` Configs []*NodeBalancerConfigCreateOptions `json:"configs,omitempty"` - Tags []string `json:"tags"` - FirewallID int `json:"firewall_id,omitempty"` - Type NodeBalancerPlanType `json:"type,omitempty"` - VPCs []NodeBalancerVPCOptions `json:"vpcs,omitempty"` + Tags []string `json:"tags,omitempty"` + // NOTE: IP assignment feature may not currently be available to all users. + IPv4 *string `json:"ipv4,omitempty"` + FirewallID int `json:"firewall_id,omitempty"` + Type NodeBalancerPlanType `json:"type,omitempty"` + VPCs []NodeBalancerVPCOptions `json:"vpcs,omitempty"` } // NodeBalancerUpdateOptions are the options permitted for UpdateNodeBalancer diff --git a/test/integration/fixtures/TestNodeBalancer_With_ReservedIP_Create.yaml b/test/integration/fixtures/TestNodeBalancer_With_ReservedIP_Create.yaml new file mode 100644 index 000000000..e8bb35479 --- /dev/null +++ b/test/integration/fixtures/TestNodeBalancer_With_ReservedIP_Create.yaml @@ -0,0 +1,255 @@ +--- +version: 1 +interactions: +- request: + body: '{"region":"us-east"}' + form: {} + headers: + Accept: + - application/json + Content-Type: + - application/json + User-Agent: + - linodego/dev https://github.com/linode/linodego + url: https://api.linode.com/v4beta/networking/reserved/ips + method: POST + response: + body: '{"address": "45.79.134.221", "gateway": "45.79.134.1", "subnet_mask": "255.255.255.0", + "prefix": 24, "type": "ipv4", "public": true, "rdns": "45-79-134-221.ip.linodeusercontent.com", + "linode_id": null, "interface_id": null, "region": "us-east", "vpc_nat_1_1": + null, "reserved": true, "assigned_entity": null}' + headers: + Access-Control-Allow-Credentials: + - "true" + Access-Control-Allow-Headers: + - Authorization, Origin, X-Requested-With, Content-Type, Accept, X-Filter + Access-Control-Allow-Methods: + - HEAD, GET, OPTIONS, POST, PUT, DELETE + Access-Control-Allow-Origin: + - '*' + Access-Control-Expose-Headers: + - X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Status + Akamai-Internal-Account: + - '*' + Cache-Control: + - max-age=0, no-cache, no-store + Connection: + - keep-alive + Content-Length: + - "308" + Content-Security-Policy: + - default-src 'none' + Content-Type: + - application/json + Expires: + - Thu, 08 May 2025 20:48:26 GMT + Pragma: + - no-cache + Strict-Transport-Security: + - max-age=31536000 + Vary: + - Authorization, X-Filter + X-Accepted-Oauth-Scopes: + - ips:read_write + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - DENY + - DENY + X-Oauth-Scopes: + - '*' + X-Ratelimit-Limit: + - "1600" + X-Xss-Protection: + - 1; mode=block + status: 200 OK + code: 200 + duration: "" +- request: + body: '{"label":"go-test-def","region":"us-east","client_conn_throttle":20,"ipv4":"45.79.134.221","firewall_id":2511562}' + form: {} + headers: + Accept: + - application/json + Content-Type: + - application/json + User-Agent: + - linodego/dev https://github.com/linode/linodego + url: https://api.linode.com/v4beta/nodebalancers + method: POST + response: + body: '{"id": 1540254, "label": "go-test-def", "region": "us-east", "type": "common", + "hostname": "45-79-134-221.ip.linodeusercontent.com", "ipv4": "45.79.134.221", + "ipv6": "2600:3c03:1::45a4:dfc0", "created": "2018-01-02T03:04:05", "updated": + "2018-01-02T03:04:05", "client_conn_throttle": 20, "client_udp_sess_throttle": + 0, "lke_cluster": null, "tags": [], "transfer": {"in": null, "out": null, "total": + null}}' + headers: + Access-Control-Allow-Credentials: + - "true" + Access-Control-Allow-Headers: + - Authorization, Origin, X-Requested-With, Content-Type, Accept, X-Filter + Access-Control-Allow-Methods: + - HEAD, GET, OPTIONS, POST, PUT, DELETE + Access-Control-Allow-Origin: + - '*' + Access-Control-Expose-Headers: + - X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Status + Akamai-Internal-Account: + - '*' + Cache-Control: + - max-age=0, no-cache, no-store + Connection: + - keep-alive + Content-Length: + - "407" + Content-Security-Policy: + - default-src 'none' + Content-Type: + - application/json + Expires: + - Thu, 08 May 2025 20:48:27 GMT + Pragma: + - no-cache + Strict-Transport-Security: + - max-age=31536000 + Vary: + - Authorization, X-Filter + X-Accepted-Oauth-Scopes: + - nodebalancers:read_write + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - DENY + - DENY + X-Oauth-Scopes: + - '*' + X-Ratelimit-Limit: + - "400" + X-Xss-Protection: + - 1; mode=block + status: 200 OK + code: 200 + duration: "" +- request: + body: "" + form: {} + headers: + Accept: + - application/json + Content-Type: + - application/json + User-Agent: + - linodego/dev https://github.com/linode/linodego + url: https://api.linode.com/v4beta/nodebalancers/1540254 + method: DELETE + response: + body: '{}' + headers: + Access-Control-Allow-Credentials: + - "true" + Access-Control-Allow-Headers: + - Authorization, Origin, X-Requested-With, Content-Type, Accept, X-Filter + Access-Control-Allow-Methods: + - HEAD, GET, OPTIONS, POST, PUT, DELETE + Access-Control-Allow-Origin: + - '*' + Access-Control-Expose-Headers: + - X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Status + Akamai-Internal-Account: + - '*' + Cache-Control: + - max-age=0, no-cache, no-store + Connection: + - keep-alive + Content-Length: + - "2" + Content-Security-Policy: + - default-src 'none' + Content-Type: + - application/json + Expires: + - Thu, 08 May 2025 20:48:27 GMT + Pragma: + - no-cache + Strict-Transport-Security: + - max-age=31536000 + Vary: + - Authorization, X-Filter + X-Accepted-Oauth-Scopes: + - nodebalancers:read_write + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - DENY + - DENY + X-Oauth-Scopes: + - '*' + X-Ratelimit-Limit: + - "1600" + X-Xss-Protection: + - 1; mode=block + status: 200 OK + code: 200 + duration: "" +- request: + body: "" + form: {} + headers: + Accept: + - application/json + Content-Type: + - application/json + User-Agent: + - linodego/dev https://github.com/linode/linodego + url: https://api.linode.com/v4beta/networking/reserved/ips/45.79.134.221 + method: DELETE + response: + body: '{}' + headers: + Access-Control-Allow-Credentials: + - "true" + Access-Control-Allow-Headers: + - Authorization, Origin, X-Requested-With, Content-Type, Accept, X-Filter + Access-Control-Allow-Methods: + - HEAD, GET, OPTIONS, POST, PUT, DELETE + Access-Control-Allow-Origin: + - '*' + Access-Control-Expose-Headers: + - X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Status + Akamai-Internal-Account: + - '*' + Cache-Control: + - max-age=0, no-cache, no-store + Connection: + - keep-alive + Content-Length: + - "2" + Content-Security-Policy: + - default-src 'none' + Content-Type: + - application/json + Expires: + - Thu, 08 May 2025 20:48:27 GMT + Pragma: + - no-cache + Strict-Transport-Security: + - max-age=31536000 + Vary: + - Authorization, X-Filter + X-Accepted-Oauth-Scopes: + - ips:read_write + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - DENY + - DENY + X-Oauth-Scopes: + - '*' + X-Ratelimit-Limit: + - "10" + X-Xss-Protection: + - 1; mode=block + status: 200 OK + code: 200 + duration: "" diff --git a/test/integration/nodebalancers_test.go b/test/integration/nodebalancers_test.go index ef406aff3..40c1280f3 100644 --- a/test/integration/nodebalancers_test.go +++ b/test/integration/nodebalancers_test.go @@ -53,6 +53,27 @@ func TestNodeBalancer_Create_Type(t *testing.T) { assertDateSet(t, nodebalancer.Updated) } +func TestNodeBalancer_Create_with_ReservedIP(t *testing.T) { + _, reserveIP, nodebalancer, teardown, err := setupNodeBalancerWithReservedIP(t, "fixtures/TestNodeBalancer_With_ReservedIP_Create") + defer teardown() + + if err != nil { + t.Errorf("Error creating nodebalancer: %v", err) + } + + // when comparing fixtures to random value Label will differ, compare the known suffix + if !strings.Contains(*nodebalancer.Label, label) { + t.Errorf("nodebalancer returned does not match nodebalancer create request") + } + + if reserveIP.Address != *nodebalancer.IPv4 { + t.Errorf("nodebalancer address: %s does not matched requested reserved IP: %s", *nodebalancer.IPv4, reserveIP.Address) + } + + assertDateSet(t, nodebalancer.Created) + assertDateSet(t, nodebalancer.Updated) +} + func TestNodeBalancer_Create_with_vpc(t *testing.T) { _, nodebalancer, _, _, teardown, err := setupNodeBalancerWithVPC(t, "fixtures/TestNodeBalancer_With_VPC_Create") defer teardown() @@ -150,6 +171,43 @@ func setupNodeBalancer(t *testing.T, fixturesYaml string, nbModifiers []nbModifi return client, nodebalancer, teardown, err } +func setupNodeBalancerWithReservedIP(t *testing.T, fixturesYaml string) (*linodego.Client, *linodego.InstanceIP, *linodego.NodeBalancer, func(), error) { + t.Helper() + var fixtureTeardown func() + client, fixtureTeardown := createTestClient(t, fixturesYaml) + reserveIP, err := client.ReserveIPAddress(context.Background(), linodego.ReserveIPOptions{ + Region: "us-east", + }) + if err != nil { + t.Fatalf("Failed to reserve IP %v", err) + } + t.Logf("Successfully reserved IP: %s", reserveIP.Address) + + createOpts := linodego.NodeBalancerCreateOptions{ + Label: &label, + Region: "us-east", + ClientConnThrottle: &clientConnThrottle, + FirewallID: GetFirewallID(), + IPv4: &reserveIP.Address, + } + + nodebalancer, err := client.CreateNodeBalancer(context.Background(), createOpts) + if err != nil { + t.Fatalf("Error listing nodebalancers, expected struct, got error %v", err) + } + + teardown := func() { + if err := client.DeleteNodeBalancer(context.Background(), nodebalancer.ID); err != nil { + t.Errorf("Expected to delete a nodebalancer, but got %v", err) + } + if err := client.DeleteReservedIPAddress(context.Background(), reserveIP.Address); err != nil { + t.Errorf("Expected to delete a reserved IP, but got %v", err) + } + fixtureTeardown() + } + return client, reserveIP, nodebalancer, teardown, err +} + func setupNodeBalancerWithVPC(t *testing.T, fixturesYaml string, vpcModifier ...vpcModifier) (*linodego.Client, *linodego.NodeBalancer, *linodego.VPC, *linodego.VPCSubnet, func(), error) { t.Helper() var fixtureTeardown func() diff --git a/test/unit/fixtures/nodebalancer_create_with_ipv4.json b/test/unit/fixtures/nodebalancer_create_with_ipv4.json new file mode 100644 index 000000000..e46d83dc6 --- /dev/null +++ b/test/unit/fixtures/nodebalancer_create_with_ipv4.json @@ -0,0 +1,17 @@ +{ + "id": 124, + "label": "Test NodeBalancer IPv4", + "region": "us-east", + "hostname": "nb-192-0-2-2.nodebalancer.linode.com", + "ipv4": "192.0.2.2", + "ipv6": null, + "client_conn_throttle": 0, + "transfer": { + "total": 0, + "out": 0, + "in": 0 + }, + "tags": ["test", "example"], + "created": "2023-01-01T00:00:00", + "updated": "2023-01-01T00:00:00" +} \ No newline at end of file diff --git a/test/unit/nodebalancer_test.go b/test/unit/nodebalancer_test.go index 5f18e0fcb..e96692fbc 100644 --- a/test/unit/nodebalancer_test.go +++ b/test/unit/nodebalancer_test.go @@ -9,29 +9,60 @@ import ( ) func TestNodeBalancer_Create(t *testing.T) { - fixtureData, err := fixtures.GetFixture("nodebalancer_create") - assert.NoError(t, err) + tests := []struct { + name string + createOpts linodego.NodeBalancerCreateOptions + fixture string + expectIPv4 string + }{ + { + name: "basic creation", + createOpts: linodego.NodeBalancerCreateOptions{ + Label: String("Test NodeBalancer"), + Region: "us-east", + Tags: []string{"test", "example"}, + }, + fixture: "nodebalancer_create", + expectIPv4: "192.0.2.1", // whatever is in the fixture + }, + { + name: "creation with specific IPv4", + createOpts: linodego.NodeBalancerCreateOptions{ + Label: String("Test NodeBalancer IPv4"), + Region: "us-east", + Tags: []string{"test", "example"}, + IPv4: String("192.0.2.2"), + }, + fixture: "nodebalancer_create_with_ipv4", + expectIPv4: "192.0.2.2", + }, + } - var base ClientBaseCase - base.SetUp(t) - defer base.TearDown(t) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fixtureData, err := fixtures.GetFixture(tt.fixture) + assert.NoError(t, err) - // Mock the POST request with the fixture response - base.MockPost("nodebalancers", fixtureData) + var base ClientBaseCase + base.SetUp(t) + defer base.TearDown(t) - label := "Test NodeBalancer" - createOpts := linodego.NodeBalancerCreateOptions{ - Label: &label, - Region: "us-east", - Tags: []string{"test", "example"}, + base.MockPost("nodebalancers", fixtureData) + + nodebalancer, err := base.Client.CreateNodeBalancer(context.Background(), tt.createOpts) + assert.NoError(t, err) + + assert.Equal(t, *tt.createOpts.Label, *nodebalancer.Label) + assert.Equal(t, tt.createOpts.Region, nodebalancer.Region) + assert.Equal(t, tt.createOpts.Tags, nodebalancer.Tags) + assert.Equal(t, tt.expectIPv4, *nodebalancer.IPv4) + }) } - nodebalancer, err := base.Client.CreateNodeBalancer(context.Background(), createOpts) - assert.NoError(t, err) +} - assert.Equal(t, 123, nodebalancer.ID) - assert.Equal(t, "Test NodeBalancer", *nodebalancer.Label) - assert.Equal(t, "us-east", nodebalancer.Region) - assert.Equal(t, []string{"test", "example"}, nodebalancer.Tags) +// Helper function if not already defined +func String(s string) *string { + return &s } func TestNodeBalancer_Get(t *testing.T) {