diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d65acf9e6..fecd97885 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,5 +67,5 @@ jobs: python-version: ${{ matrix.python-version }} - name: Run tests run: | - pip install ".[test]" - tox + pip install ".[unit-test]" + make test-unit diff --git a/AGENTS.md b/AGENTS.md index 8cc3170c5..20f924d80 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,8 +3,7 @@ ## Source of Truth - This is the official synchronous Python SDK for the Linode API. Supported - Python versions come from [pyproject.toml](pyproject.toml) (`>=3.10`); do not - infer support from the older environment names still present in [tox.ini](tox.ini). + Python versions come from [pyproject.toml](pyproject.toml) (`>=3.10`). - Make changes only in canonical source trees such as `linode_api4/`, `test/`, `docs/`, `examples/`, and `.github/`. Do not edit generated copies or output in `build/`, `dist/`, `docs/build/`, `docs/_build/`, or `linode_api4.egg-info/`. diff --git a/README.rst b/README.rst index 5615bb488..3f7f00a30 100644 --- a/README.rst +++ b/README.rst @@ -102,14 +102,13 @@ Tests ----- Tests live in the ``test`` directory. When invoking tests, make sure you are -in the root directory of this project. To run the full suite across all -supported python versions, use tox_: +in the root directory of this project. Install unit-test dependencies and run +the mocked unit suite with: .. code-block:: shell - tox - -Running tox also runs pylint and coverage reports. + python3 -m pip install -e ".[unit-test]" + make test-unit The test suite uses fixtures stored as JSON in ``test/fixtures``. These files contain sanitized JSON responses from the API - the file name is the URL called @@ -131,8 +130,6 @@ from the api base url that should be returned, for example:: linode = self.client.linode.instance_create('g6-standard-2', 'us-east') self.assertEqual(linode.id, 123) # passes -.. _tox: http://tox.readthedocs.io - Integration Tests ----------------- diff --git a/linode_api4/groups/nodebalancer.py b/linode_api4/groups/nodebalancer.py index 20252efaa..002c33e3d 100644 --- a/linode_api4/groups/nodebalancer.py +++ b/linode_api4/groups/nodebalancer.py @@ -35,6 +35,27 @@ def create(self, region, **kwargs): :param ipv4: A reserved IPv4 address to assign to this NodeBalancer. NOTE: Reserved IP feature may not currently be available to all users. :type ipv4: str + :param type: The NodeBalancer type. Supported values include + ``common``, ``premium``, ``premium_40gb``, and + ``enterprise``. This cannot be changed after creation. + NOTE: Creating premium or enterprise NodeBalancers may not + currently be available to all users. + :type type: str + :param backend_connectivity: How this NodeBalancer communicates with + backends (``legacy``, ``ipv6``, or ``vpc``). If omitted, + the API infers a value from ``vpcs`` or config nodes, or + returns ``undefined`` until the first node is added. + ``undefined`` cannot be sent by clients. This cannot be + changed after creation. + NOTE: This field may not currently be available to all users. + :type backend_connectivity: NodeBalancerBackendConnectivity | str + :param vpcs: VPC attachments for this NodeBalancer. Required when + ``backend_connectivity`` is ``vpc``. + :type vpcs: list[dict] + :param configs: NodeBalancer configs and optional nodes to create + with this NodeBalancer. Node addresses must match the + selected or inferred backend connectivity. + :type configs: list[dict] :returns: The new NodeBalancer :rtype: NodeBalancer diff --git a/linode_api4/objects/nodebalancer.py b/linode_api4/objects/nodebalancer.py index f70553295..371eeed9b 100644 --- a/linode_api4/objects/nodebalancer.py +++ b/linode_api4/objects/nodebalancer.py @@ -7,6 +7,16 @@ from linode_api4.objects.dbase import DerivedBase from linode_api4.objects.networking import Firewall, IPAddress from linode_api4.objects.region import Region +from linode_api4.objects.serializable import StrEnum + + +class NodeBalancerBackendConnectivity(StrEnum): + """Backend connectivity modes accepted when creating a NodeBalancer.""" + + IPV6 = "ipv6" + LEGACY = "legacy" + UNDEFINED = "undefined" + VPC = "vpc" class NodeBalancerType(Base): @@ -162,8 +172,10 @@ def node_create(self, label, address, **kwargs): API documentation: https://techdocs.akamai.com/linode-api/reference/post-node-balancer-node - :param address: The private IP Address where this backend can be reached. - This must be a private IP address. + :param address: The address and port where this backend can be reached. + The address may be a private IPv4 address, a public + IPv6 address, or a VPC address. The address type must + match this NodeBalancer's ``backend_connectivity``. :type address: str :param label: The label for this node. This is for display purposes only. @@ -255,6 +267,9 @@ class NodeBalancer(Base): "tags": Property(mutable=True, unordered=True), "client_udp_sess_throttle": Property(mutable=True), "locks": Property(unordered=True), + "type": Property(), + "backend_connectivity": Property(), + "backend_ipv6_prefix": Property(), } # create derived objects diff --git a/pyproject.toml b/pyproject.toml index 3b3d982b7..2e0e1ed41 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,32 +35,37 @@ dependencies = ["requests", "polling", "deprecated"] dynamic = ["version"] [project.optional-dependencies] -test = ["tox>=4.4.0"] - -dev = [ - "tox>=4.4.0", - "mock>=5.0.0", +test-shared = [ "pytest>=7.3.1", "httpretty>=1.1.4", + "pytest-rerunfailures", +] +unit-test = [ + "linode_api4[test-shared]", + "mock>=5.0.0", +] +int-test = [ + "linode_api4[test-shared]", +] +lint = [ "black>=23.1.0", "isort>=5.12.0", "autoflake>=2.0.1", "pylint", "twine>=4.0.2", "build>=0.10.0", - "Sphinx>=6.0.0", - "sphinx-autobuild>=2021.3.14", - "sphinxcontrib-fulltoc>=1.2.0", - "build>=0.10.0", - "twine>=4.0.2", - "pytest-rerunfailures", ] - doc = [ "Sphinx>=6.0.0", "sphinx-autobuild>=2021.3.14", "sphinxcontrib-fulltoc>=1.2.0", ] +dev = [ + "linode_api4[unit-test]", + "linode_api4[int-test]", + "linode_api4[lint]", + "linode_api4[doc]", +] [project.urls] Homepage = "https://github.com/linode/linode_api4-python" diff --git a/test/fixtures/linode_instances_123_nodebalancers.json b/test/fixtures/linode_instances_123_nodebalancers.json index 821ff4801..fc92c79fb 100644 --- a/test/fixtures/linode_instances_123_nodebalancers.json +++ b/test/fixtures/linode_instances_123_nodebalancers.json @@ -8,6 +8,9 @@ "ipv4": "203.0.113.1", "ipv6": null, "label": "balancer12345", + "type": "premium", + "backend_connectivity": "ipv6", + "backend_ipv6_prefix": "2600:3c22:1:20:0:3039::/96", "region": "us-east", "tags": [ "example tag", diff --git a/test/fixtures/nodebalancers.json b/test/fixtures/nodebalancers.json index 9b4dc8dae..487943e74 100644 --- a/test/fixtures/nodebalancers.json +++ b/test/fixtures/nodebalancers.json @@ -11,7 +11,10 @@ "label": "balancer123456", "client_conn_throttle": 0, "tags": ["something"], - "locks": ["cannot_delete_with_subresources"] + "locks": ["cannot_delete_with_subresources"], + "type": "premium", + "backend_connectivity": "ipv6", + "backend_ipv6_prefix": "2600:3c22:1:20:0:3039::/96" }, { "created": "2018-01-01T00:01:01", @@ -24,7 +27,10 @@ "label": "balancer123457", "client_conn_throttle": 0, "tags": [], - "locks": [] + "locks": [], + "type": "premium", + "backend_connectivity": "ipv6", + "backend_ipv6_prefix": null } ], "results": 2, diff --git a/test/fixtures/nodebalancers_123456.json b/test/fixtures/nodebalancers_123456.json index a78c8d3e3..fbec0bec7 100644 --- a/test/fixtures/nodebalancers_123456.json +++ b/test/fixtures/nodebalancers_123456.json @@ -13,5 +13,8 @@ ], "locks": [ "cannot_delete_with_subresources" - ] -} \ No newline at end of file + ], + "type": "premium", + "backend_connectivity": "ipv6", + "backend_ipv6_prefix": "2600:3c22:1:20:0:3039::/96" +} diff --git a/test/integration/models/nodebalancer/test_nodebalancer.py b/test/integration/models/nodebalancer/test_nodebalancer.py index 4ddefb15c..97e76e88f 100644 --- a/test/integration/models/nodebalancer/test_nodebalancer.py +++ b/test/integration/models/nodebalancer/test_nodebalancer.py @@ -106,11 +106,18 @@ def test_create_nb(test_linode_client, e2e_test_firewall): label=label, firewall=e2e_test_firewall.id, client_udp_sess_throttle=5, + type="premium", + backend_connectivity="ipv6", ) assert TEST_REGION, nb.region assert label == nb.label assert 5 == nb.client_udp_sess_throttle + assert nb.type == "premium" + assert nb.backend_connectivity == "ipv6" + assert nb.backend_ipv6_prefix is None or isinstance( + nb.backend_ipv6_prefix, str + ) nb.delete() diff --git a/test/unit/linode_client_test.py b/test/unit/linode_client_test.py index 3b225d9e2..78a542210 100644 --- a/test/unit/linode_client_test.py +++ b/test/unit/linode_client_test.py @@ -5,6 +5,7 @@ from linode_api4.objects.beta import BetaProgram from linode_api4.objects.linode import Instance from linode_api4.objects.networking import IPAddress +from linode_api4.objects.nodebalancer import NodeBalancerBackendConnectivity from linode_api4.objects.object_storage import ( ObjectStorageACL, ObjectStorageCluster, @@ -1589,6 +1590,84 @@ def test_nodebalancer_types(self): self.assertEqual(types[0].region_prices[0].hourly, 0.018) self.assertEqual(types[0].region_prices[0].monthly, 12) + def test_create_with_type_and_backend_connectivity(self): + """ + Tests that creating a NodeBalancer forwards type, backend_connectivity, + vpcs, and config node addresses. + """ + with self.mock_post( + { + "id": 1234, + "label": "my-premium-nb", + "type": "premium", + "backend_connectivity": "ipv6", + "backend_ipv6_prefix": "2600:3c22:1:20:0:3039::/96", + "region": "us-east", + } + ) as m: + nb = self.client.nodebalancers.create( + "us-east", + label="my-premium-nb", + type="premium", + backend_connectivity=NodeBalancerBackendConnectivity.IPV6, + configs=[ + { + "port": 80, + "nodes": [ + { + "address": "[2001:db8:abcd:0012::1]:80", + "label": "node1", + } + ], + } + ], + ) + + self.assertEqual(m.call_url, "/nodebalancers") + self.assertEqual(m.call_data["region"], "us-east") + self.assertEqual(m.call_data["type"], "premium") + self.assertEqual(m.call_data["backend_connectivity"], "ipv6") + self.assertIs(type(m.call_data["backend_connectivity"]), str) + self.assertEqual( + m.call_data["configs"][0]["nodes"][0]["address"], + "[2001:db8:abcd:0012::1]:80", + ) + self.assertEqual(nb.id, 1234) + self.assertEqual(nb.type, "premium") + self.assertEqual(nb.backend_connectivity, "ipv6") + self.assertEqual( + nb.backend_ipv6_prefix, "2600:3c22:1:20:0:3039::/96" + ) + self.assertNotIn("backend_ipv6_prefix", m.call_data) + + def test_create_with_vpc_backend_connectivity(self): + """ + Tests that creating a NodeBalancer forwards vpc backend connectivity. + """ + with self.mock_post( + { + "id": 1234, + "label": "my-nb", + "type": "common", + "backend_connectivity": "vpc", + "region": "us-east", + } + ) as m: + nb = self.client.nodebalancers.create( + "us-east", + label="my-nb", + backend_connectivity="vpc", + vpcs=[{"subnet_id": 123456, "ipv4_range": "10.0.250.4/30"}], + ) + + self.assertEqual(m.call_url, "/nodebalancers") + self.assertEqual(m.call_data["backend_connectivity"], "vpc") + self.assertEqual( + m.call_data["vpcs"], + [{"subnet_id": 123456, "ipv4_range": "10.0.250.4/30"}], + ) + self.assertEqual(nb.backend_connectivity, "vpc") + class VolumeGroupTest(ClientBaseCase): """ diff --git a/test/unit/objects/nodebalancers_test.py b/test/unit/objects/nodebalancers_test.py index c02b40ea3..ebc65607d 100644 --- a/test/unit/objects/nodebalancers_test.py +++ b/test/unit/objects/nodebalancers_test.py @@ -113,6 +113,29 @@ def test_create_node(self): }, ) + def test_create_ipv6_node(self): + """ + Tests that a node can be created with a public IPv6 backend address. + """ + with self.mock_post( + "nodebalancers/123456/configs/65432/nodes/54321" + ) as m: + config = NodeBalancerConfig(self.client, 65432, 123456) + node = config.node_create( + "node54321", + "[2001:db8:abcd:0012::1]:80", + weight=50, + mode="accept", + ) + + self.assertIsNotNone(node) + self.assertEqual( + m.call_url, "/nodebalancers/123456/configs/65432/nodes" + ) + self.assertEqual( + m.call_data["address"], "[2001:db8:abcd:0012::1]:80" + ) + def test_update_node(self): """ Tests that a node can be updated @@ -154,6 +177,19 @@ def test_delete_node(self): class NodeBalancerTest(ClientBaseCase): + def test_get(self): + """ + Tests that a NodeBalancer is loaded correctly by ID. + """ + nb = NodeBalancer(self.client, 123456) + self.assertEqual(nb._populated, False) + + self.assertEqual(nb.label, "balancer123456") + self.assertEqual(nb._populated, True) + self.assertEqual(nb.type, "premium") + self.assertEqual(nb.backend_connectivity, "ipv6") + self.assertEqual(nb.backend_ipv6_prefix, "2600:3c22:1:20:0:3039::/96") + def test_update(self): """ Test that you can update a NodeBalancer. @@ -193,6 +229,25 @@ def test_locks_not_in_put(self): self.assertNotIn("locks", m.call_data) self.assertEqual(m.call_data["label"], "new-label") + def test_type_and_backend_connectivity_not_in_put(self): + """ + Test that type and backend_connectivity are not included in PUT + requests. These fields cannot be changed after creation. + """ + nb = NodeBalancer(self.client, 123456) + self.assertEqual(nb.type, "premium") + self.assertEqual(nb.backend_connectivity, "ipv6") + + nb.label = "new-label" + + with self.mock_put("nodebalancers/123456") as m: + nb.save() + self.assertEqual(m.call_url, "/nodebalancers/123456") + self.assertNotIn("type", m.call_data) + self.assertNotIn("backend_connectivity", m.call_data) + self.assertNotIn("backend_ipv6_prefix", m.call_data) + self.assertEqual(m.call_data["label"], "new-label") + def test_firewalls(self): """ Test that you can get the firewalls for the requested NodeBalancer. @@ -205,6 +260,13 @@ def test_firewalls(self): self.assertEqual(m.call_url, firewalls_url) self.assertEqual(len(result), 1) + def test_backend_ipv6_prefix_null(self): + """ + Test that a NodeBalancer can have a null backend IPv6 prefix. + """ + nb = NodeBalancer(self.client, 123457) + self.assertIsNone(nb.backend_ipv6_prefix) + def test_config_rebuild(self): """ Test that you can rebuild the cofig of a node balancer. @@ -251,6 +313,30 @@ def test_config_rebuild(self): }, ) + def test_config_rebuild_ipv6(self): + """ + Test that a config can be rebuilt with public IPv6 backend addresses. + """ + config_rebuild_url = "/nodebalancers/12345/configs/4567/rebuild" + with self.mock_post(config_rebuild_url) as m: + nb = NodeBalancer(self.client, 12345) + nodes = [ + { + "address": "[2001:db8:abcd:0012::1]:80", + "label": "node1", + "weight": 50, + "mode": "accept", + } + ] + + result = nb.config_rebuild(4567, nodes, port=80, protocol="http") + self.assertIsNotNone(result) + self.assertEqual(m.call_url, config_rebuild_url) + self.assertEqual( + m.call_data["nodes"][0]["address"], + "[2001:db8:abcd:0012::1]:80", + ) + def test_statistics(self): """ Test that you can get the statistics about the requested NodeBalancer. diff --git a/tox.ini b/tox.ini deleted file mode 100644 index 266c26717..000000000 --- a/tox.ini +++ /dev/null @@ -1,17 +0,0 @@ -[tox] -envlist = py38,py39,py310,py311,py312 -skip_missing_interpreters = true - -[testenv] -deps = - pytest - coverage - mock - pylint - httpretty - pytest-rerunfailures -commands = - python -m pip install . - coverage run --source linode_api4 -m pytest test/unit - coverage report - pylint linode_api4