diff --git a/.github/workflows/e2e-test-pr.yml b/.github/workflows/e2e-test-pr.yml index ac40302c6..9b2113c6e 100644 --- a/.github/workflows/e2e-test-pr.yml +++ b/.github/workflows/e2e-test-pr.yml @@ -2,8 +2,8 @@ on: pull_request: workflow_dispatch: inputs: - test_path: - description: 'Enter specific test path. E.g. linode_client/test_linode_client.py, models/test_account.py' + test_suite: + description: 'Enter specific test suite. E.g. domain, linode_client' required: false sha: description: 'The hash value of the commit.' @@ -26,7 +26,7 @@ jobs: - uses: actions-ecosystem/action-regex-match@v2 id: validate-tests with: - text: ${{ inputs.test_path }} + text: ${{ inputs.test_suite }} regex: '[^a-z0-9-:.\/_]' # Tests validation flags: gi @@ -71,6 +71,14 @@ jobs: - name: Install Python deps run: pip install -U setuptools wheel boto3 certifi + - name: Download kubectl and calicoctl for LKE clusters + run: | + curl -LO "https://storage.googleapis.com/kubernetes-release/release/$(curl -s https://storage.googleapis.com/kubernetes-release/release/stable.txt)/bin/linux/amd64/kubectl" + curl -LO "https://github.com/projectcalico/calico/releases/download/v3.25.0/calicoctl-linux-amd64" + chmod +x calicoctl-linux-amd64 kubectl + mv calicoctl-linux-amd64 /usr/local/bin/calicoctl + mv kubectl /usr/local/bin/kubectl + - name: Install Python SDK run: make dev-install env: @@ -80,14 +88,19 @@ jobs: run: | timestamp=$(date +'%Y%m%d%H%M') report_filename="${timestamp}_sdk_test_report.xml" - status=0 - if ! python3 -m pytest test/integration/${INTEGRATION_TEST_PATH} --disable-warnings --junitxml="${report_filename}"; then - echo "EXIT_STATUS=1" >> $GITHUB_ENV - fi + make testint TEST_ARGS="--junitxml=${report_filename}" TEST_SUITE="${{ github.event.inputs.test_suite }}" + env: + LINODE_TOKEN: ${{ secrets.LINODE_TOKEN }} + + - name: Apply Calico Rules to LKE + if: always() + run: | + cd scripts && ./lke_calico_rules_e2e.sh env: LINODE_TOKEN: ${{ secrets.LINODE_TOKEN }} - - name: Add additional information to XML report + - name: Upload test results + if: always() run: | filename=$(ls | grep -E '^[0-9]{12}_sdk_test_report\.xml$') python tod_scripts/add_to_xml_test_report.py \ @@ -95,11 +108,8 @@ jobs: --gha_run_id "$GITHUB_RUN_ID" \ --gha_run_number "$GITHUB_RUN_NUMBER" \ --xmlfile "${filename}" - - - name: Upload test results - run: | - report_filename=$(ls | grep -E '^[0-9]{12}_sdk_test_report\.xml$') - python3 tod_scripts/test_report_upload_script.py "${report_filename}" + sync + python3 tod_scripts/test_report_upload_script.py "${filename}" env: LINODE_CLI_OBJ_ACCESS_KEY: ${{ secrets.LINODE_CLI_OBJ_ACCESS_KEY }} LINODE_CLI_OBJ_SECRET_KEY: ${{ secrets.LINODE_CLI_OBJ_SECRET_KEY }} @@ -131,12 +141,3 @@ jobs: conclusion: process.env.conclusion }); return result; - - - name: Test Execution Status Handler - run: | - if [[ "$EXIT_STATUS" != 0 ]]; then - echo "Test execution contains failure(s)" - exit $EXIT_STATUS - else - echo "Tests passed!" - fi \ No newline at end of file diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index 0d22f5dd9..7729b6bc2 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -32,6 +32,14 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Download kubectl and calicoctl for LKE clusters + run: | + curl -LO "https://storage.googleapis.com/kubernetes-release/release/$(curl -s https://storage.googleapis.com/kubernetes-release/release/stable.txt)/bin/linux/amd64/kubectl" + curl -LO "https://github.com/projectcalico/calico/releases/download/v3.25.0/calicoctl-linux-amd64" + chmod +x calicoctl-linux-amd64 kubectl + mv calicoctl-linux-amd64 /usr/local/bin/calicoctl + mv kubectl /usr/local/bin/kubectl + - name: Run Integration tests run: | timestamp=$(date +'%Y%m%d%H%M') @@ -40,6 +48,13 @@ jobs: env: LINODE_TOKEN: ${{ secrets.LINODE_TOKEN }} + - name: Apply Calico Rules to LKE + if: always() + run: | + cd scripts && ./lke_calico_rules_e2e.sh + env: + LINODE_TOKEN: ${{ secrets.LINODE_TOKEN }} + - name: Upload test results if: always() run: | diff --git a/docs/linode_api4/linode_client.rst b/docs/linode_api4/linode_client.rst index 58c7025b8..9e8d135c6 100644 --- a/docs/linode_api4/linode_client.rst +++ b/docs/linode_api4/linode_client.rst @@ -155,6 +155,15 @@ with buckets and objects, use the s3 API directly with a library like `boto3`_. .. _boto3: https://github.com/boto/boto3 +PlacementAPIGroup +^^^^^^^^^^^^ + +Includes methods related to VM placement. + +.. autoclass:: linode_api4.linode_client.PlacementAPIGroup + :members: + :special-members: + PollingGroup ^^^^^^^^^^^^ diff --git a/docs/linode_api4/objects/models.rst b/docs/linode_api4/objects/models.rst index 6805ad889..8cef969c6 100644 --- a/docs/linode_api4/objects/models.rst +++ b/docs/linode_api4/objects/models.rst @@ -104,6 +104,15 @@ Object Storage Models :undoc-members: :inherited-members: +Placement Models +-------------- + +.. automodule:: linode_api4.objects.placement + :members: + :exclude-members: api_endpoint, properties, derived_url_path, id_attribute, parent_id_name + :undoc-members: + :inherited-members: + Profile Models -------------- diff --git a/linode_api4/groups/__init__.py b/linode_api4/groups/__init__.py index 25c4858eb..db08d8939 100644 --- a/linode_api4/groups/__init__.py +++ b/linode_api4/groups/__init__.py @@ -12,6 +12,7 @@ from .networking import * from .nodebalancer import * from .object_storage import * +from .placement import * from .polling import * from .profile import * from .region import * diff --git a/linode_api4/groups/account.py b/linode_api4/groups/account.py index 884503fe8..b45152908 100644 --- a/linode_api4/groups/account.py +++ b/linode_api4/groups/account.py @@ -19,7 +19,6 @@ ServiceTransfer, User, ) -from linode_api4.objects.profile import PersonalAccessToken class AccountGroup(Group): diff --git a/linode_api4/groups/linode.py b/linode_api4/groups/linode.py index 982eede81..5f69d2b94 100644 --- a/linode_api4/groups/linode.py +++ b/linode_api4/groups/linode.py @@ -2,24 +2,21 @@ import os from collections.abc import Iterable -from linode_api4 import Profile -from linode_api4.common import SSH_KEY_TYPES, load_and_validate_keys +from linode_api4.common import load_and_validate_keys from linode_api4.errors import UnexpectedResponseError from linode_api4.groups import Group from linode_api4.objects import ( - AuthorizedApp, Base, ConfigInterface, Firewall, Image, Instance, Kernel, - PersonalAccessToken, - SSHKey, StackScript, Type, ) from linode_api4.objects.filtering import Filter +from linode_api4.objects.linode import _expand_placement_group_assignment from linode_api4.paginated_list import PaginatedList @@ -269,6 +266,8 @@ def instance_create( :param interfaces: An array of Network Interfaces to add to this Linode’s Configuration Profile. At least one and up to three Interface objects can exist in this array. :type interfaces: list[ConfigInterface] or list[dict[str, Any]] + :param placement_group: A Placement Group to create this Linode under. + :type placement_group: Union[InstancePlacementGroupAssignment, PlacementGroup, Dict[str, Any], int] :returns: A new Instance object, or a tuple containing the new Instance and the generated password. @@ -315,6 +314,11 @@ def instance_create( for i in interfaces ] + if "placement_group" in kwargs: + kwargs["placement_group"] = _expand_placement_group_assignment( + kwargs.get("placement_group") + ) + params = { "type": ltype.id if issubclass(type(ltype), Base) else ltype, "region": region.id if issubclass(type(region), Base) else region, diff --git a/linode_api4/groups/obj.py b/linode_api4/groups/obj.py deleted file mode 100644 index 2ca2f0b6c..000000000 --- a/linode_api4/groups/obj.py +++ /dev/null @@ -1,163 +0,0 @@ -from linode_api4.errors import UnexpectedResponseError -from linode_api4.groups import Group -from linode_api4.objects import Base, ObjectStorageCluster, ObjectStorageKeys - - -class ObjectStorageGroup(Group): - """ - This group encapsulates all endpoints under /object-storage, including viewing - available clusters and managing keys. - """ - - def clusters(self, *filters): - """ - Returns a list of available Object Storage Clusters. You may filter - this query to return only Clusters that are available in a specific region:: - - us_east_clusters = client.object_storage.clusters(ObjectStorageCluster.region == "us-east") - - API Documentation: https://www.linode.com/docs/api/object-storage/#clusters-list - - :param filters: Any number of filters to apply to this query. - See :doc:`Filtering Collections` - for more details on filtering. - - :returns: A list of Object Storage Clusters that matched the query. - :rtype: PaginatedList of ObjectStorageCluster - """ - return self.client._get_and_filter(ObjectStorageCluster, *filters) - - def keys(self, *filters): - """ - Returns a list of Object Storage Keys active on this account. These keys - allow third-party applications to interact directly with Linode Object Storage. - - API Documentation: https://www.linode.com/docs/api/object-storage/#object-storage-keys-list - - :param filters: Any number of filters to apply to this query. - See :doc:`Filtering Collections` - for more details on filtering. - - :returns: A list of Object Storage Keys that matched the query. - :rtype: PaginatedList of ObjectStorageKeys - """ - return self.client._get_and_filter(ObjectStorageKeys, *filters) - - def keys_create(self, label, bucket_access=None): - """ - Creates a new Object Storage keypair that may be used to interact directly - with Linode Object Storage in third-party applications. This response is - the only time that "secret_key" will be populated - be sure to capture its - value or it will be lost forever. - - If given, `bucket_access` will cause the new keys to be restricted to only - the specified level of access for the specified buckets. For example, to - create a keypair that can only access the "example" bucket in all clusters - (and assuming you own that bucket in every cluster), you might do this:: - - client = LinodeClient(TOKEN) - - # look up clusters - all_clusters = client.object_storage.clusters() - - new_keys = client.object_storage.keys_create( - "restricted-keys", - bucket_access=[ - client.object_storage.bucket_access(cluster, "example", "read_write") - for cluster in all_clusters - ], - ) - - To create a keypair that can only read from the bucket "example2" in the - "us-east-1" cluster (an assuming you own that bucket in that cluster), - you might do this:: - - client = LinodeClient(TOKEN) - new_keys_2 = client.object_storage.keys_create( - "restricted-keys-2", - bucket_access=client.object_storage.bucket_access("us-east-1", "example2", "read_only"), - ) - - API Documentation: https://www.linode.com/docs/api/object-storage/#object-storage-key-create - - :param label: The label for this keypair, for identification only. - :type label: str - :param bucket_access: One or a list of dicts with keys "cluster," - "permissions", and "bucket_name". If given, the - resulting Object Storage keys will only have the - requested level of access to the requested buckets, - if they exist and are owned by you. See the provided - :any:`bucket_access` function for a convenient way - to create these dicts. - :type bucket_access: dict or list of dict - - :returns: The new keypair, with the secret key populated. - :rtype: ObjectStorageKeys - """ - params = {"label": label} - - if bucket_access is not None: - if not isinstance(bucket_access, list): - bucket_access = [bucket_access] - - ba = [ - { - "permissions": c.get("permissions"), - "bucket_name": c.get("bucket_name"), - "cluster": ( - c.id - if "cluster" in c - and issubclass(type(c["cluster"]), Base) - else c.get("cluster") - ), - } - for c in bucket_access - ] - - params["bucket_access"] = ba - - result = self.client.post("/object-storage/keys", data=params) - - if not "id" in result: - raise UnexpectedResponseError( - "Unexpected response when creating Object Storage Keys!", - json=result, - ) - - ret = ObjectStorageKeys(self.client, result["id"], result) - return ret - - def bucket_access(self, cluster, bucket_name, permissions): - """ - Returns a dict formatted to be included in the `bucket_access` argument - of :any:`keys_create`. See the docs for that method for an example of - usage. - - :param cluster: The Object Storage cluster to grant access in. - :type cluster: :any:`ObjectStorageCluster` or str - :param bucket_name: The name of the bucket to grant access to. - :type bucket_name: str - :param permissions: The permissions to grant. Should be one of "read_only" - or "read_write". - :type permissions: str - - :returns: A dict formatted correctly for specifying bucket access for - new keys. - :rtype: dict - """ - return { - "cluster": cluster, - "bucket_name": bucket_name, - "permissions": permissions, - } - - def cancel(self): - """ - Cancels Object Storage service. This may be a destructive operation. Once - cancelled, you will no longer receive the transfer for or be billed for - Object Storage, and all keys will be invalidated. - - API Documentation: https://www.linode.com/docs/api/object-storage/#object-storage-cancel - """ - self.client.post("/object-storage/cancel", data={}) - return True diff --git a/linode_api4/groups/object_storage.py b/linode_api4/groups/object_storage.py index 1e5fca65f..bbaf330d9 100644 --- a/linode_api4/groups/object_storage.py +++ b/linode_api4/groups/object_storage.py @@ -1,3 +1,4 @@ +from typing import List, Optional, Union from urllib import parse from linode_api4.errors import UnexpectedResponseError @@ -53,7 +54,11 @@ def keys(self, *filters): """ return self.client._get_and_filter(ObjectStorageKeys, *filters) - def keys_create(self, label, bucket_access=None): + def keys_create( + self, + label: str, + bucket_access: Optional[Union[dict, List[dict]]] = None, + ): """ Creates a new Object Storage keypair that may be used to interact directly with Linode Object Storage in third-party applications. This response is diff --git a/linode_api4/groups/placement.py b/linode_api4/groups/placement.py new file mode 100644 index 000000000..90456fd17 --- /dev/null +++ b/linode_api4/groups/placement.py @@ -0,0 +1,72 @@ +from typing import Union + +from linode_api4.errors import UnexpectedResponseError +from linode_api4.groups import Group +from linode_api4.objects.placement import PlacementGroup +from linode_api4.objects.region import Region + + +class PlacementAPIGroup(Group): + def groups(self, *filters): + """ + NOTE: Placement Groups may not currently be available to all users. + + Returns a list of Placement Groups on your account. You may filter + this query to return only Placement Groups that match specific criteria:: + + groups = client.placement.groups(PlacementGroup.label == "test") + + API Documentation: TODO + + :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. + + :returns: A list of Placement Groups that matched the query. + :rtype: PaginatedList of PlacementGroup + """ + return self.client._get_and_filter(PlacementGroup, *filters) + + def group_create( + self, + label: str, + region: Union[Region, str], + affinity_type: str, + is_strict: bool = False, + **kwargs, + ) -> PlacementGroup: + """ + NOTE: Placement Groups may not currently be available to all users. + + Create a placement group with the specified parameters. + + :param label: The label for the placement group. + :type label: str + :param region: The region where the placement group will be created. Can be either a Region object or a string representing the region ID. + :type region: Union[Region, str] + :param affinity_type: The affinity type of the placement group. + :type affinity_type: PlacementGroupAffinityType + :param is_strict: Whether the placement group is strict (defaults to False). + :type is_strict: bool + + :returns: The new Placement Group. + :rtype: PlacementGroup + """ + params = { + "label": label, + "region": region.id if isinstance(region, Region) else region, + "affinity_type": affinity_type, + "is_strict": is_strict, + } + + params.update(kwargs) + + result = self.client.post("/placement/groups", data=params) + + if not "id" in result: + raise UnexpectedResponseError( + "Unexpected response when creating Placement Group", json=result + ) + + d = PlacementGroup(self.client, result["id"], result) + return d diff --git a/linode_api4/groups/polling.py b/linode_api4/groups/polling.py index 3eaa0edda..4141b78b9 100644 --- a/linode_api4/groups/polling.py +++ b/linode_api4/groups/polling.py @@ -1,7 +1,6 @@ import polling from linode_api4.groups import Group -from linode_api4.objects.account import Event from linode_api4.polling import EventPoller, TimeoutContext diff --git a/linode_api4/linode_client.py b/linode_api4/linode_client.py index d55958884..8c7819119 100644 --- a/linode_api4/linode_client.py +++ b/linode_api4/linode_client.py @@ -31,11 +31,9 @@ VPCGroup, ) from linode_api4.objects import Image, and_ -from linode_api4.objects.filtering import Filter -from .common import SSH_KEY_TYPES, load_and_validate_keys +from .groups.placement import PlacementAPIGroup from .paginated_list import PaginatedList -from .util import drop_null_keys package_version = version("linode_api4") @@ -200,6 +198,9 @@ def __init__( #: Access methods related to Beta Program - See :any:`BetaProgramGroup` for more information. self.beta = BetaProgramGroup(self) + #: Access methods related to VM placement - See :any:`PlacementAPIGroup` for more information. + self.placement = PlacementAPIGroup(self) + @property def _user_agent(self): return "{}python-linode_api4/{} {}".format( diff --git a/linode_api4/objects/__init__.py b/linode_api4/objects/__init__.py index f10d4d04f..3ecce4584 100644 --- a/linode_api4/objects/__init__.py +++ b/linode_api4/objects/__init__.py @@ -20,3 +20,4 @@ from .database import * from .vpc import * from .beta import * +from .placement import * diff --git a/linode_api4/objects/linode.py b/linode_api4/objects/linode.py index f459f5918..d86ec1746 100644 --- a/linode_api4/objects/linode.py +++ b/linode_api4/objects/linode.py @@ -335,6 +335,18 @@ def subnet(self) -> VPCSubnet: return VPCSubnet(self._client, self.subnet_id, self.vpc_id) +@dataclass +class InstancePlacementGroupAssignment(JSONObject): + """ + Represents an assignment between an instance and a Placement Group. + This is intended to be used when creating, cloning, and migrating + instances. + """ + + id: int + compliant_only: bool = False + + @dataclass class ConfigInterface(JSONObject): """ @@ -870,6 +882,37 @@ def transfer(self): return self._transfer + @property + def placement_group(self) -> Optional["PlacementGroup"]: + """ + Returns the PlacementGroup object for the Instance. + + :returns: The Placement Group this instance is under. + :rtype: Optional[PlacementGroup] + """ + # Workaround to avoid circular import + from linode_api4.objects.placement import ( # pylint: disable=import-outside-toplevel + PlacementGroup, + ) + + if not hasattr(self, "_placement_group"): + # Refresh the instance if necessary + if not self._populated: + self._api_get() + + pg_data = self._raw_json.get("placement_group", None) + + if pg_data is None: + return None + + setattr( + self, + "_placement_group", + PlacementGroup(self._client, pg_data.get("id"), json=pg_data), + ) + + return self._placement_group + def _populate(self, json): if json is not None: # fixes ipv4 and ipv6 attribute of json to make base._populate work @@ -885,11 +928,16 @@ def invalidate(self): """Clear out cached properties""" if hasattr(self, "_avail_backups"): del self._avail_backups + if hasattr(self, "_ips"): del self._ips + if hasattr(self, "_transfer"): del self._transfer + if hasattr(self, "_placement_group"): + del self._placement_group + Base.invalidate(self) def boot(self, config=None): @@ -1471,6 +1519,9 @@ def initiate_migration( region=None, upgrade=None, migration_type: MigrationType = MigrationType.COLD, + placement_group: Union[ + InstancePlacementGroupAssignment, Dict[str, Any], int + ] = None, ): """ Initiates a pending migration that is already scheduled for this Linode @@ -1496,12 +1547,19 @@ def initiate_migration( :param migration_type: The type of migration that will be used for this Linode migration. Customers can only use this param when activating a support-created migration. Customers can choose between a cold and warm migration, cold is the default type. - :type: mirgation_type: str + :type: migration_type: str + + :param placement_group: Information about the placement group to create this instance under. + :type placement_group: Union[InstancePlacementGroupAssignment, Dict[str, Any], int] """ + params = { "region": region.id if issubclass(type(region), Base) else region, "upgrade": upgrade, "type": migration_type, + "placement_group": _expand_placement_group_assignment( + placement_group + ), } util.drop_null_keys(params) @@ -1583,6 +1641,12 @@ def clone( label=None, group=None, with_backups=None, + placement_group: Union[ + InstancePlacementGroupAssignment, + "PlacementGroup", + Dict[str, Any], + int, + ] = None, ): """ Clones this linode into a new linode or into a new linode in the given region @@ -1618,6 +1682,9 @@ def clone( enrolled in the Linode Backup service. This will incur an additional charge. :type: with_backups: bool + :param placement_group: Information about the placement group to create this instance under. + :type placement_group: Union[InstancePlacementGroupAssignment, PlacementGroup, Dict[str, Any], int] + :returns: The cloned Instance. :rtype: Instance """ @@ -1654,8 +1721,13 @@ def clone( "label": label, "group": group, "with_backups": with_backups, + "placement_group": _expand_placement_group_assignment( + placement_group + ), } + util.drop_null_keys(params) + result = self._client.post( "{}/clone".format(Instance.api_endpoint), model=self, data=params ) @@ -1790,3 +1862,40 @@ def _serialize(self): dct = Base._serialize(self) dct["images"] = [d.id for d in self.images] return dct + + +def _expand_placement_group_assignment( + pg: Union[ + InstancePlacementGroupAssignment, "PlacementGroup", Dict[str, Any], int + ] +) -> Optional[Dict[str, Any]]: + """ + Expands the placement group argument into a dict for use in an API request body. + + :param pg: The placement group argument to be expanded. + :type pg: Union[InstancePlacementGroupAssignment, PlacementGroup, Dict[str, Any], int] + + :returns: The expanded placement group. + :rtype: Optional[Dict[str, Any]] + """ + # Workaround to avoid circular import + from linode_api4.objects.placement import ( # pylint: disable=import-outside-toplevel + PlacementGroup, + ) + + if pg is None: + return None + + if isinstance(pg, dict): + return pg + + if isinstance(pg, InstancePlacementGroupAssignment): + return pg.dict + + if isinstance(pg, PlacementGroup): + return {"id": pg.id} + + if isinstance(pg, int): + return {"id": pg} + + raise TypeError(f"Invalid type for Placement Group: {type(pg)}") diff --git a/linode_api4/objects/lke.py b/linode_api4/objects/lke.py index 55dd0372e..4d3ec5a16 100644 --- a/linode_api4/objects/lke.py +++ b/linode_api4/objects/lke.py @@ -37,6 +37,7 @@ class LKEClusterControlPlaneACLAddressesOptions(JSONObject): """ ipv4: Optional[List[str]] = None + ipv6: Optional[List[str]] = None @@ -45,6 +46,8 @@ class LKEClusterControlPlaneACLOptions(JSONObject): """ LKEClusterControlPlaneACLOptions is used to set the ACL configuration of an LKE cluster's control plane. + + NOTE: Control Plane ACLs may not currently be available to all users. """ enabled: Optional[bool] = None @@ -78,6 +81,8 @@ class LKEClusterControlPlaneACL(JSONObject): """ LKEClusterControlPlaneACL describes the ACL configuration of an LKE cluster's control plane. + + NOTE: Control Plane ACLs may not currently be available to all users. """ enabled: bool = False @@ -264,6 +269,8 @@ def control_plane_acl(self) -> LKEClusterControlPlaneACL: """ Gets the ACL configuration of this cluster's control plane. + NOTE: Control Plane ACLs may not currently be available to all users. + API Documentation: TODO :returns: The cluster's control plane ACL configuration. @@ -435,6 +442,8 @@ def control_plane_acl_update( """ Updates the ACL configuration for this cluster's control plane. + NOTE: Control Plane ACLs may not currently be available to all users. + API Documentation: TODO :param acl: The ACL configuration to apply to this cluster. @@ -464,6 +473,8 @@ def control_plane_acl_delete(self): This has the same effect as calling control_plane_acl_update with the `enabled` field set to False. Access controls are disabled and all rules are deleted. + NOTE: Control Plane ACLs may not currently be available to all users. + API Documentation: TODO """ self._client.delete( diff --git a/linode_api4/objects/object_storage.py b/linode_api4/objects/object_storage.py index d9eb32433..685925c9b 100644 --- a/linode_api4/objects/object_storage.py +++ b/linode_api4/objects/object_storage.py @@ -1,3 +1,4 @@ +from typing import Optional from urllib import parse from linode_api4.errors import UnexpectedResponseError @@ -8,10 +9,11 @@ Property, Region, ) +from linode_api4.objects.serializable import StrEnum from linode_api4.util import drop_null_keys -class ObjectStorageACL: +class ObjectStorageACL(StrEnum): PRIVATE = "private" PUBLIC_READ = "public-read" AUTHENTICATED_READ = "authenticated-read" @@ -67,7 +69,7 @@ def make_instance(cls, id, client, parent_id=None, json=None): def access_modify( self, - acl: ObjectStorageACL = None, + acl: Optional[ObjectStorageACL] = None, cors_enabled=None, ): """ @@ -78,12 +80,6 @@ def access_modify( API Documentation: https://www.linode.com/docs/api/object-storage/#object-storage-bucket-access-modify - :param cluster_id: The ID of the cluster this bucket exists in. - :type cluster_id: str - - :param bucket: The bucket name. - :type bucket: str - :param acl: The Access Control Level of the bucket using a canned ACL string. For more fine-grained control of ACLs, use the S3 API directly. :type acl: str @@ -100,10 +96,9 @@ def access_modify( } resp = self._client.post( - "/object-storage/buckets/{}/{}/access".format( - parse.quote(str(self.cluster)), parse.quote(str(self.id)) - ), + f"{self.api_endpoint}/access", data=drop_null_keys(params), + model=self, ) if "errors" in resp: @@ -115,7 +110,7 @@ def access_modify( def access_update( self, - acl: ObjectStorageACL = None, + acl: Optional[ObjectStorageACL] = None, cors_enabled=None, ): """ @@ -126,12 +121,6 @@ def access_update( API Documentation: https://www.linode.com/docs/api/object-storage/#object-storage-bucket-access-update - :param cluster_id: The ID of the cluster this bucket exists in. - :type cluster_id: str - - :param bucket: The bucket name. - :type bucket: str - :param acl: The Access Control Level of the bucket using a canned ACL string. For more fine-grained control of ACLs, use the S3 API directly. :type acl: str @@ -148,10 +137,9 @@ def access_update( } resp = self._client.put( - "/object-storage/buckets/{}/{}/access".format( - parse.quote(str(self.cluster)), parse.quote(str(self.id)) - ), + f"{self.api_endpoint}/access", data=drop_null_keys(params), + model=self, ) if "errors" in resp: @@ -168,20 +156,13 @@ def ssl_cert_delete(self): API Documentation: https://www.linode.com/docs/api/object-storage/#object-storage-tlsssl-cert-delete - :param cluster_id: The ID of the cluster this bucket exists in. - :type cluster_id: str - - :param bucket: The bucket name. - :type bucket: str - :returns: True if the TLS/SSL certificate and private key in the bucket were successfully deleted. :rtype: bool """ resp = self._client.delete( - "/object-storage/buckets/{}/{}/ssl".format( - parse.quote(str(self.cluster)), parse.quote(str(self.id)) - ) + f"{self.api_endpoint}/ssl", + model=self, ) if "error" in resp: @@ -199,20 +180,13 @@ def ssl_cert(self): API Documentation: https://www.linode.com/docs/api/object-storage/#object-storage-tlsssl-cert-view - :param cluster_id: The ID of the cluster this bucket exists in. - :type cluster_id: str - - :param bucket: The bucket name. - :type bucket: str - :returns: A result object which has a bool field indicating if this Bucket has a corresponding TLS/SSL certificate that was uploaded by an Account user. :rtype: MappedObject """ result = self._client.get( - "/object-storage/buckets/{}/{}/ssl".format( - parse.quote(str(self.cluster)), parse.quote(str(self.id)) - ) + f"{self.api_endpoint}/ssl", + model=self, ) if not "ssl" in result: @@ -234,12 +208,6 @@ def ssl_cert_upload(self, certificate, private_key): API Documentation: https://www.linode.com/docs/api/object-storage/#object-storage-tlsssl-cert-upload - :param cluster_id: The ID of the cluster this bucket exists in. - :type cluster_id: str - - :param bucket: The bucket name. - :type bucket: str - :param certificate: Your Base64 encoded and PEM formatted SSL certificate. Line breaks must be represented as “\n” in the string for requests (but not when using the Linode CLI) @@ -259,10 +227,9 @@ def ssl_cert_upload(self, certificate, private_key): "private_key": private_key, } result = self._client.post( - "/object-storage/buckets/{}/{}/ssl".format( - parse.quote(str(self.cluster)), parse.quote(str(self.id)) - ), + f"{self.api_endpoint}/ssl", data=params, + model=self, ) if not "ssl" in result: @@ -291,12 +258,6 @@ def contents( API Documentation: https://www.linode.com/docs/api/object-storage/#object-storage-bucket-contents-list - :param cluster_id: The ID of the cluster this bucket exists in. - :type cluster_id: str - - :param bucket: The bucket name. - :type bucket: str - :param marker: The “marker” for this request, which can be used to paginate through large buckets. Its value should be the value of the next_marker property returned with the last page. Listing @@ -332,10 +293,9 @@ def contents( "page_size": page_size, } result = self._client.get( - "/object-storage/buckets/{}/{}/object-list".format( - parse.quote(str(self.cluster)), parse.quote(str(self.id)) - ), + f"{self.api_endpoint}/object-list", data=drop_null_keys(params), + model=self, ) if not "data" in result: @@ -357,12 +317,6 @@ def object_acl_config(self, name=None): API Documentation: https://www.linode.com/docs/api/object-storage/#object-storage-object-acl-config-view - :param cluster_id: The ID of the cluster this bucket exists in. - :type cluster_id: str - - :param bucket: The bucket name. - :type bucket: str - :param name: The name of the object for which to retrieve its Access Control List (ACL). Use the Object Storage Bucket Contents List endpoint to access all object names in a bucket. @@ -376,7 +330,7 @@ def object_acl_config(self, name=None): } result = self._client.get( - f"{ObjectStorageBucket.api_endpoint}/object-acl", + f"{type(self).api_endpoint}/object-acl", model=self, data=drop_null_keys(params), ) @@ -400,12 +354,6 @@ def object_acl_config_update(self, acl: ObjectStorageACL, name): API Documentation: https://www.linode.com/docs/api/object-storage/#object-storage-object-acl-config-update - :param cluster_id: The ID of the cluster this bucket exists in. - :type cluster_id: str - - :param bucket: The bucket name. - :type bucket: str - :param acl: The Access Control Level of the bucket, as a canned ACL string. For more fine-grained control of ACLs, use the S3 API directly. :type acl: str @@ -425,7 +373,7 @@ def object_acl_config_update(self, acl: ObjectStorageACL, name): } result = self._client.put( - f"{ObjectStorageBucket.api_endpoint}/object-acl", + f"{type(self).api_endpoint}/object-acl", model=self, data=params, ) diff --git a/linode_api4/objects/placement.py b/linode_api4/objects/placement.py new file mode 100644 index 000000000..eb5808eee --- /dev/null +++ b/linode_api4/objects/placement.py @@ -0,0 +1,101 @@ +from dataclasses import dataclass +from typing import List, Union + +from linode_api4.objects.base import Base, Property +from linode_api4.objects.linode import Instance +from linode_api4.objects.region import Region +from linode_api4.objects.serializable import JSONObject, StrEnum + + +class PlacementGroupAffinityType(StrEnum): + """ + An enum class that represents the available affinity policies for Linodes + in a Placement Group. + """ + + anti_affinity_local = "anti_affinity:local" + + +@dataclass +class PlacementGroupMember(JSONObject): + """ + Represents a member of a placement group. + """ + + linode_id: int = 0 + is_compliant: bool = False + + +class PlacementGroup(Base): + """ + NOTE: Placement Groups may not currently be available to all users. + + A VM Placement Group, defining the affinity policy for Linodes + created in a region. + + API Documentation: TODO + """ + + api_endpoint = "/placement/groups/{id}" + + properties = { + "id": Property(identifier=True), + "label": Property(mutable=True), + "region": Property(slug_relationship=Region), + "affinity_type": Property(), + "is_compliant": Property(), + "is_strict": Property(), + "members": Property(json_object=PlacementGroupMember), + } + + def assign( + self, + linodes: List[Union[Instance, int]], + compliant_only: bool = False, + ): + """ + Assigns the specified Linodes to the Placement Group. + + :param linodes: A list of Linodes to assign to the Placement Group. + :type linodes: List[Union[Instance, int]] + :param compliant_only: TODO + :type compliant_only: bool + """ + params = { + "linodes": [ + v.id if isinstance(v, Instance) else v for v in linodes + ], + "compliant_only": compliant_only, + } + + result = self._client.post( + f"{PlacementGroup.api_endpoint}/assign", model=self, data=params + ) + + # The assign endpoint returns the updated PG, so we can use this + # as an opportunity to refresh the object + self._populate(result) + + def unassign( + self, + linodes: List[Union[Instance, int]], + ): + """ + Unassign the specified Linodes from the Placement Group. + + :param linodes: A list of Linodes to unassign from the Placement Group. + :type linodes: List[Union[Instance, int]] + """ + params = { + "linodes": [ + v.id if isinstance(v, Instance) else v for v in linodes + ], + } + + result = self._client.post( + f"{PlacementGroup.api_endpoint}/unassign", model=self, data=params + ) + + # The unassign endpoint returns the updated PG, so we can use this + # as an opportunity to refresh the object + self._populate(result) diff --git a/linode_api4/objects/region.py b/linode_api4/objects/region.py index ab77074d0..9356da523 100644 --- a/linode_api4/objects/region.py +++ b/linode_api4/objects/region.py @@ -3,8 +3,17 @@ from linode_api4.errors import UnexpectedResponseError from linode_api4.objects.base import Base, JSONObject, Property -from linode_api4.objects.filtering import FilterableAttribute -from linode_api4.objects.serializable import JSONFilterableMetaclass + + +@dataclass +class RegionPlacementGroupLimits(JSONObject): + """ + Represents the Placement Group limits for the current account + in a specific region. + """ + + maximum_pgs_per_customer: int = 0 + maximum_linodes_per_pg: int = 0 class Region(Base): @@ -23,6 +32,9 @@ class Region(Base): "resolvers": Property(), "label": Property(), "site_type": Property(), + "placement_group_limits": Property( + json_object=RegionPlacementGroupLimits + ), } @property diff --git a/linode_api4/objects/serializable.py b/linode_api4/objects/serializable.py index 15494cdce..b0e7a2503 100644 --- a/linode_api4/objects/serializable.py +++ b/linode_api4/objects/serializable.py @@ -1,5 +1,6 @@ import inspect from dataclasses import dataclass +from enum import Enum from types import SimpleNamespace from typing import ( Any, @@ -223,3 +224,22 @@ def __delitem__(self, key): def __len__(self): return len(vars(self)) + + +class StrEnum(str, Enum): + """ + Used for enums that are of type string, which is necessary + for implicit JSON serialization. + + NOTE: Replace this with StrEnum once Python 3.10 has been EOL'd. + See: https://docs.python.org/3/library/enum.html#enum.StrEnum + """ + + def __new__(cls, *values): + value = str(*values) + member = str.__new__(cls, value) + member._value_ = value + return member + + def __str__(self): + return self._value_ diff --git a/linode_api4/objects/tag.py b/linode_api4/objects/tag.py index 5a604d445..856f0d751 100644 --- a/linode_api4/objects/tag.py +++ b/linode_api4/objects/tag.py @@ -1,7 +1,5 @@ -from linode_api4.errors import UnexpectedResponseError from linode_api4.objects import ( Base, - DerivedBase, Domain, Instance, NodeBalancer, diff --git a/pyproject.toml b/pyproject.toml index cec2adf11..4e2c60f00 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -86,5 +86,5 @@ ignore-init-module-imports = true ignore-pass-after-docstring = true in-place = true recursive = true -remove-all-unused-imports = false +remove-all-unused-imports = true remove-duplicate-keys = false diff --git a/scripts/lke-policy.yaml b/scripts/lke-policy.yaml new file mode 100644 index 000000000..9859ca8b4 --- /dev/null +++ b/scripts/lke-policy.yaml @@ -0,0 +1,78 @@ +apiVersion: projectcalico.org/v3 +kind: GlobalNetworkPolicy +metadata: + name: lke-rules +spec: + preDNAT: true + applyOnForward: true + order: 100 + # Remember to run calicoctl patch command for this to work + selector: "" + ingress: + # Allow ICMP + - action: Allow + protocol: ICMP + - action: Allow + protocol: ICMPv6 + + # Allow LKE-required ports + - action: Allow + protocol: TCP + destination: + nets: + - 192.168.128.0/17 + - 10.0.0.0/8 + ports: + - 10250 + - 10256 + - 179 + - action: Allow + protocol: UDP + destination: + nets: + - 192.168.128.0/17 + - 10.2.0.0/16 + ports: + - 51820 + + # Allow NodeBalancer ingress to the Node Ports & Allow DNS + - action: Allow + protocol: TCP + source: + nets: + - 192.168.255.0/24 + - 10.0.0.0/8 + destination: + ports: + - 53 + - 30000:32767 + - action: Allow + protocol: UDP + source: + nets: + - 192.168.255.0/24 + - 10.0.0.0/8 + destination: + ports: + - 53 + - 30000:32767 + + # Allow cluster internal communication + - action: Allow + destination: + nets: + - 10.0.0.0/8 + - action: Allow + source: + nets: + - 10.0.0.0/8 + + # 127.0.0.1/32 is needed for kubectl exec and node-shell + - action: Allow + destination: + nets: + - 127.0.0.1/32 + + # Block everything else + - action: Deny + - action: Log diff --git a/scripts/lke_calico_rules_e2e.sh b/scripts/lke_calico_rules_e2e.sh new file mode 100755 index 000000000..48ad5caec --- /dev/null +++ b/scripts/lke_calico_rules_e2e.sh @@ -0,0 +1,60 @@ +#!/bin/bash + +RETRIES=3 +DELAY=30 + +# Function to retry a command with exponential backoff +retry_command() { + local retries=$1 + local wait_time=60 + shift + until "$@"; do + if ((retries == 0)); then + echo "Command failed after multiple retries. Exiting." + exit 1 + fi + echo "Command failed. Retrying in $wait_time seconds..." + sleep $wait_time + ((retries--)) + wait_time=$((wait_time * 2)) + done +} + +# Fetch the list of LKE cluster IDs +CLUSTER_IDS=$(curl -s -H "Authorization: Bearer $LINODE_TOKEN" \ + -H "Content-Type: application/json" \ + "https://api.linode.com/v4/lke/clusters" | jq -r '.data[].id') + +# Check if CLUSTER_IDS is empty +if [ -z "$CLUSTER_IDS" ]; then + echo "All clusters have been cleaned and properly destroyed. No need to apply inbound or outbound rules" + exit 0 +fi + +for ID in $CLUSTER_IDS; do + echo "Applying Calico rules to nodes in Cluster ID: $ID" + + # Download cluster configuration file with retry + for ((i=1; i<=RETRIES; i++)); do + config_response=$(curl -sH "Authorization: Bearer $LINODE_TOKEN" "https://api.linode.com/v4/lke/clusters/$ID/kubeconfig") + if [[ $config_response != *"kubeconfig is not yet available"* ]]; then + echo $config_response | jq -r '.[] | @base64d' > "/tmp/${ID}_config.yaml" + break + fi + echo "Attempt $i to download kubeconfig for cluster $ID failed. Retrying in $DELAY seconds..." + sleep $DELAY + done + + if [[ $config_response == *"kubeconfig is not yet available"* ]]; then + echo "kubeconfig for cluster id:$ID not available after $RETRIES attempts, mostly likely it is an empty cluster. Skipping..." + else + # Export downloaded config file + export KUBECONFIG="/tmp/${ID}_config.yaml" + + retry_command $RETRIES kubectl get nodes + + retry_command $RETRIES calicoctl patch kubecontrollersconfiguration default --allow-version-mismatch --patch='{"spec": {"controllers": {"node": {"hostEndpoint": {"autoCreate": "Enabled"}}}}}' + + retry_command $RETRIES calicoctl apply --allow-version-mismatch -f "$(pwd)/lke-policy.yaml" + fi +done diff --git a/test/fixtures/linode_instances.json b/test/fixtures/linode_instances.json index 3d257938d..651fc56c1 100644 --- a/test/fixtures/linode_instances.json +++ b/test/fixtures/linode_instances.json @@ -40,7 +40,13 @@ "image": "linode/ubuntu17.04", "tags": ["something"], "host_uuid": "3a3ddd59d9a78bb8de041391075df44de62bfec8", - "watchdog_enabled": true + "watchdog_enabled": true, + "placement_group": { + "id": 123, + "label": "test", + "affinity_type": "anti_affinity:local", + "is_strict": true + } }, { "group": "test", @@ -79,7 +85,8 @@ "image": "linode/debian9", "tags": [], "host_uuid": "3a3ddd59d9a78bb8de041391075df44de62bfec8", - "watchdog_enabled": false + "watchdog_enabled": false, + "placement_group": null } ] } diff --git a/test/fixtures/placement_groups.json b/test/fixtures/placement_groups.json new file mode 100644 index 000000000..f518e838d --- /dev/null +++ b/test/fixtures/placement_groups.json @@ -0,0 +1,21 @@ +{ + "data": [ + { + "id": 123, + "label": "test", + "region": "eu-west", + "affinity_type": "anti_affinity:local", + "is_strict": true, + "is_compliant": true, + "members": [ + { + "linode_id": 123, + "is_compliant": true + } + ] + } + ], + "page": 1, + "pages": 1, + "results": 1 +} \ No newline at end of file diff --git a/test/fixtures/placement_groups_123.json b/test/fixtures/placement_groups_123.json new file mode 100644 index 000000000..5262bebe0 --- /dev/null +++ b/test/fixtures/placement_groups_123.json @@ -0,0 +1,14 @@ +{ + "id": 123, + "label": "test", + "region": "eu-west", + "affinity_type": "anti_affinity:local", + "is_strict": true, + "is_compliant": true, + "members": [ + { + "linode_id": 123, + "is_compliant": true + } + ] +} \ No newline at end of file diff --git a/test/fixtures/regions.json b/test/fixtures/regions.json index 9200455d4..5fe55e200 100644 --- a/test/fixtures/regions.json +++ b/test/fixtures/regions.json @@ -14,7 +14,11 @@ "ipv6": "2400:8904::f03c:91ff:fea5:659,2400:8904::f03c:91ff:fea5:9282,2400:8904::f03c:91ff:fea5:b9b3,2400:8904::f03c:91ff:fea5:925a,2400:8904::f03c:91ff:fea5:22cb,2400:8904::f03c:91ff:fea5:227a,2400:8904::f03c:91ff:fea5:924c,2400:8904::f03c:91ff:fea5:f7e2,2400:8904::f03c:91ff:fea5:2205,2400:8904::f03c:91ff:fea5:9207" }, "label": "label1", - "site_type": "core" + "site_type": "core", + "placement_group_limits": { + "maximum_pgs_per_customer": 5, + "maximum_linodes_per_pg": 5 + } }, { "id": "ca-central", @@ -30,7 +34,11 @@ "ipv6": "2600:3c04::f03c:91ff:fea9:f63,2600:3c04::f03c:91ff:fea9:f6d,2600:3c04::f03c:91ff:fea9:f80,2600:3c04::f03c:91ff:fea9:f0f,2600:3c04::f03c:91ff:fea9:f99,2600:3c04::f03c:91ff:fea9:fbd,2600:3c04::f03c:91ff:fea9:fdd,2600:3c04::f03c:91ff:fea9:fe2,2600:3c04::f03c:91ff:fea9:f68,2600:3c04::f03c:91ff:fea9:f4a" }, "label": "label2", - "site_type": "core" + "site_type": "core", + "placement_group_limits": { + "maximum_pgs_per_customer": 5, + "maximum_linodes_per_pg": 5 + } }, { "id": "ap-southeast", @@ -62,7 +70,11 @@ "ipv6": "2600:3c00::2,2600:3c00::9,2600:3c00::7,2600:3c00::5,2600:3c00::3,2600:3c00::8,2600:3c00::6,2600:3c00::4,2600:3c00::c,2600:3c00::b" }, "label": "label4", - "site_type": "core" + "site_type": "core", + "placement_group_limits": { + "maximum_pgs_per_customer": 5, + "maximum_linodes_per_pg": 5 + } }, { "id": "us-west", @@ -78,7 +90,11 @@ "ipv6": "2600:3c01::2,2600:3c01::9,2600:3c01::5,2600:3c01::7,2600:3c01::3,2600:3c01::8,2600:3c01::4,2600:3c01::b,2600:3c01::c,2600:3c01::6" }, "label": "label5", - "site_type": "core" + "site_type": "core", + "placement_group_limits": { + "maximum_pgs_per_customer": 5, + "maximum_linodes_per_pg": 5 + } }, { "id": "us-southeast", @@ -94,7 +110,11 @@ "ipv6": "2600:3c02::3,2600:3c02::5,2600:3c02::4,2600:3c02::6,2600:3c02::c,2600:3c02::7,2600:3c02::2,2600:3c02::9,2600:3c02::8,2600:3c02::b" }, "label": "label6", - "site_type": "core" + "site_type": "core", + "placement_group_limits": { + "maximum_pgs_per_customer": 5, + "maximum_linodes_per_pg": 5 + } }, { "id": "us-east", @@ -111,7 +131,11 @@ "ipv6": "2600:3c03::7,2600:3c03::4,2600:3c03::9,2600:3c03::6,2600:3c03::3,2600:3c03::c,2600:3c03::5,2600:3c03::b,2600:3c03::2,2600:3c03::8" }, "label": "label7", - "site_type": "core" + "site_type": "core", + "placement_group_limits": { + "maximum_pgs_per_customer": 5, + "maximum_linodes_per_pg": 5 + } }, { "id": "eu-west", @@ -127,7 +151,11 @@ "ipv6": "2a01:7e00::9,2a01:7e00::3,2a01:7e00::c,2a01:7e00::5,2a01:7e00::6,2a01:7e00::8,2a01:7e00::b,2a01:7e00::4,2a01:7e00::7,2a01:7e00::2" }, "label": "label8", - "site_type": "core" + "site_type": "core", + "placement_group_limits": { + "maximum_pgs_per_customer": 5, + "maximum_linodes_per_pg": 5 + } }, { "id": "ap-south", @@ -144,7 +172,11 @@ "ipv6": "2400:8901::5,2400:8901::4,2400:8901::b,2400:8901::3,2400:8901::9,2400:8901::2,2400:8901::8,2400:8901::7,2400:8901::c,2400:8901::6" }, "label": "label9", - "site_type": "core" + "site_type": "core", + "placement_group_limits": { + "maximum_pgs_per_customer": 5, + "maximum_linodes_per_pg": 5 + } }, { "id": "eu-central", @@ -161,7 +193,11 @@ "ipv6": "2a01:7e01::5,2a01:7e01::9,2a01:7e01::7,2a01:7e01::c,2a01:7e01::2,2a01:7e01::4,2a01:7e01::3,2a01:7e01::6,2a01:7e01::b,2a01:7e01::8" }, "label": "label10", - "site_type": "core" + "site_type": "core", + "placement_group_limits": { + "maximum_pgs_per_customer": 5, + "maximum_linodes_per_pg": 5 + } }, { "id": "ap-northeast", @@ -177,7 +213,11 @@ "ipv6": "2400:8902::3,2400:8902::6,2400:8902::c,2400:8902::4,2400:8902::2,2400:8902::8,2400:8902::7,2400:8902::5,2400:8902::b,2400:8902::9" }, "label": "label11", - "site_type": "core" + "site_type": "core", + "placement_group_limits": { + "maximum_pgs_per_customer": 5, + "maximum_linodes_per_pg": 5 + } } ], "page": 1, diff --git a/test/integration/conftest.py b/test/integration/conftest.py index 99670c56c..c9eab20eb 100644 --- a/test/integration/conftest.py +++ b/test/integration/conftest.py @@ -1,11 +1,13 @@ +import ipaddress import os import random import time from typing import Set import pytest +import requests -from linode_api4 import ApiError +from linode_api4 import ApiError, PlacementGroupAffinityType from linode_api4.linode_client import LinodeClient from linode_api4.objects import Region @@ -50,16 +52,90 @@ def run_long_tests(): return os.environ.get(RUN_LONG_TESTS, None) +@pytest.fixture(autouse=True, scope="session") +def e2e_test_firewall(test_linode_client): + def is_valid_ipv4(address): + try: + ipaddress.IPv4Address(address) + return True + except ipaddress.AddressValueError: + return False + + def is_valid_ipv6(address): + try: + ipaddress.IPv6Address(address) + return True + except ipaddress.AddressValueError: + return False + + def get_public_ip(ip_version="ipv4"): + url = ( + f"https://api64.ipify.org?format=json" + if ip_version == "ipv6" + else f"https://api.ipify.org?format=json" + ) + response = requests.get(url) + return str(response.json()["ip"]) + + def create_inbound_rule(ipv4_address, ipv6_address): + rule = [ + { + "protocol": "TCP", + "ports": "22", + "addresses": {}, + "action": "ACCEPT", + } + ] + if is_valid_ipv4(ipv4_address): + rule[0]["addresses"]["ipv4"] = [f"{ipv4_address}/32"] + + if is_valid_ipv6(ipv6_address): + rule[0]["addresses"]["ipv6"] = [f"{ipv6_address}/128"] + + return rule + + # Fetch the public IP addresses + + ipv4_address = get_public_ip("ipv4") + ipv6_address = get_public_ip("ipv6") + + inbound_rule = create_inbound_rule(ipv4_address, ipv6_address) + + client = test_linode_client + + rules = { + "outbound": [], + "outbound_policy": "ACCEPT", + "inbound": inbound_rule, + "inbound_policy": "DROP", + } + + label = "cloud_firewall_" + str(int(time.time())) + + firewall = client.networking.firewall_create( + label=label, rules=rules, status="enabled" + ) + + yield firewall + + firewall.delete() + + @pytest.fixture(scope="session") -def create_linode(test_linode_client): +def create_linode(test_linode_client, e2e_test_firewall): client = test_linode_client + available_regions = client.regions() chosen_region = available_regions[4] timestamp = str(time.time_ns()) label = "TestSDK-" + timestamp linode_instance, password = client.linode.instance_create( - "g6-nanode-1", chosen_region, image="linode/debian10", label=label + "g6-nanode-1", + chosen_region, + image="linode/debian12", + label=label, + firewall=e2e_test_firewall, ) yield linode_instance @@ -68,15 +144,20 @@ def create_linode(test_linode_client): @pytest.fixture -def create_linode_for_pass_reset(test_linode_client): +def create_linode_for_pass_reset(test_linode_client, e2e_test_firewall): client = test_linode_client + available_regions = client.regions() chosen_region = available_regions[4] timestamp = str(time.time_ns()) label = "TestSDK-" + timestamp linode_instance, password = client.linode.instance_create( - "g6-nanode-1", chosen_region, image="linode/debian10", label=label + "g6-nanode-1", + chosen_region, + image="linode/debian10", + label=label, + firewall=e2e_test_firewall, ) yield linode_instance, password @@ -303,7 +384,7 @@ def create_vpc_with_subnet(test_linode_client, create_vpc): @pytest.fixture(scope="session") def create_vpc_with_subnet_and_linode( - test_linode_client, create_vpc_with_subnet + test_linode_client, create_vpc_with_subnet, e2e_test_firewall ): vpc, subnet = create_vpc_with_subnet @@ -311,7 +392,11 @@ def create_vpc_with_subnet_and_linode( label = "TestSDK-" + timestamp instance, password = test_linode_client.linode.instance_create( - "g6-standard-1", vpc.region, image="linode/debian11", label=label + "g6-standard-1", + vpc.region, + image="linode/debian11", + label=label, + firewall=e2e_test_firewall, ) yield vpc, subnet, instance, password @@ -346,6 +431,42 @@ def create_multiple_vpcs(test_linode_client): vpc_2.delete() +@pytest.fixture(scope="session") +def create_placement_group(test_linode_client): + client = test_linode_client + + timestamp = str(int(time.time())) + + pg = client.placement.group_create( + "pythonsdk-" + timestamp, + get_region(test_linode_client, {"Placement Group"}), + PlacementGroupAffinityType.anti_affinity_local, + ) + yield pg + + pg.delete() + + +@pytest.fixture(scope="session") +def create_placement_group_with_linode( + test_linode_client, create_placement_group +): + client = test_linode_client + + inst = client.linode.instance_create( + "g6-nanode-1", + create_placement_group.region, + label=create_placement_group.label, + placement_group=create_placement_group, + ) + + create_placement_group.invalidate() + + yield create_placement_group, inst + + inst.delete() + + @pytest.mark.smoke def pytest_configure(config): config.addinivalue_line( diff --git a/test/integration/linode_client/test_linode_client.py b/test/integration/linode_client/test_linode_client.py index 3d26bdbb1..c9ce35d6e 100644 --- a/test/integration/linode_client/test_linode_client.py +++ b/test/integration/linode_client/test_linode_client.py @@ -4,19 +4,23 @@ import pytest -from linode_api4 import ApiError, LinodeClient +from linode_api4 import ApiError from linode_api4.objects import ConfigInterface, ObjectStorageKeys, Region -@pytest.fixture(scope="session", autouse=True) -def setup_client_and_linode(test_linode_client): +@pytest.fixture(scope="session") +def setup_client_and_linode(test_linode_client, e2e_test_firewall): client = test_linode_client available_regions = client.regions() chosen_region = available_regions[4] # us-ord (Chicago) label = get_test_label() linode_instance, password = client.linode.instance_create( - "g6-nanode-1", chosen_region, image="linode/debian10", label=label + "g6-nanode-1", + chosen_region, + image="linode/debian10", + label=label, + firewall=e2e_test_firewall, ) yield client, linode_instance diff --git a/test/integration/models/linode/test_linode.py b/test/integration/models/linode/test_linode.py index a749baad4..07ee54834 100644 --- a/test/integration/models/linode/test_linode.py +++ b/test/integration/models/linode/test_linode.py @@ -6,10 +6,9 @@ wait_for_condition, ) -import polling import pytest -from linode_api4 import LinodeClient, VPCIPAddress +from linode_api4 import VPCIPAddress from linode_api4.errors import ApiError from linode_api4.objects import ( Config, @@ -33,7 +32,7 @@ def linode_with_volume_firewall(test_linode_client): "outbound": [], "outbound_policy": "DROP", "inbound": [], - "inbound_policy": "ACCEPT", + "inbound_policy": "DROP", } linode_instance, password = client.linode.instance_create( @@ -69,7 +68,7 @@ def linode_with_volume_firewall(test_linode_client): @pytest.fixture(scope="session") -def linode_for_network_interface_tests(test_linode_client): +def linode_for_network_interface_tests(test_linode_client, e2e_test_firewall): client = test_linode_client available_regions = client.regions() chosen_region = available_regions[4] @@ -77,7 +76,11 @@ def linode_for_network_interface_tests(test_linode_client): label = "TestSDK-" + timestamp linode_instance, password = client.linode.instance_create( - "g6-nanode-1", chosen_region, image="linode/debian10", label=label + "g6-nanode-1", + chosen_region, + image="linode/debian10", + label=label, + firewall=e2e_test_firewall, ) yield linode_instance @@ -86,7 +89,7 @@ def linode_for_network_interface_tests(test_linode_client): @pytest.fixture -def linode_for_disk_tests(test_linode_client): +def linode_for_disk_tests(test_linode_client, e2e_test_firewall): client = test_linode_client available_regions = client.regions() chosen_region = available_regions[4] @@ -97,6 +100,7 @@ def linode_for_disk_tests(test_linode_client): chosen_region, image="linode/alpine3.19", label=label + "_long_tests", + firewall=e2e_test_firewall, ) # Provisioning time @@ -119,7 +123,7 @@ def linode_for_disk_tests(test_linode_client): @pytest.fixture -def create_linode_for_long_running_tests(test_linode_client): +def create_linode_for_long_running_tests(test_linode_client, e2e_test_firewall): client = test_linode_client available_regions = client.regions() chosen_region = available_regions[4] @@ -130,6 +134,7 @@ def create_linode_for_long_running_tests(test_linode_client): chosen_region, image="linode/debian10", label=label + "_long_tests", + firewall=e2e_test_firewall, ) yield linode_instance @@ -412,7 +417,6 @@ def test_disk_resize_and_duplicate(test_linode_client, linode_for_disk_tests): time.sleep(40) wait_for_disk_status(dup_disk, 120) - assert dup_disk.linode_id == linode.id diff --git a/test/integration/models/lke/test_lke.py b/test/integration/models/lke/test_lke.py index bbf87bedf..4967c067f 100644 --- a/test/integration/models/lke/test_lke.py +++ b/test/integration/models/lke/test_lke.py @@ -13,7 +13,7 @@ LKEClusterControlPlaneOptions, ) from linode_api4.errors import ApiError -from linode_api4.objects import LKECluster, LKENodePool, LKENodePoolNode +from linode_api4.objects import LKECluster, LKENodePool @pytest.fixture(scope="session") diff --git a/test/integration/models/nodebalancer/test_nodebalancer.py b/test/integration/models/nodebalancer/test_nodebalancer.py index 6cec442b4..ab3095aaa 100644 --- a/test/integration/models/nodebalancer/test_nodebalancer.py +++ b/test/integration/models/nodebalancer/test_nodebalancer.py @@ -7,7 +7,7 @@ @pytest.fixture(scope="session") -def linode_with_private_ip(test_linode_client): +def linode_with_private_ip(test_linode_client, e2e_test_firewall): client = test_linode_client available_regions = client.regions() chosen_region = available_regions[4] @@ -19,6 +19,7 @@ def linode_with_private_ip(test_linode_client): image="linode/debian10", label=label, private_ip=True, + firewall=e2e_test_firewall, ) yield linode_instance @@ -27,13 +28,15 @@ def linode_with_private_ip(test_linode_client): @pytest.fixture(scope="session") -def create_nb_config(test_linode_client): +def create_nb_config(test_linode_client, e2e_test_firewall): client = test_linode_client available_regions = client.regions() chosen_region = available_regions[4] label = "nodebalancer_test" - nb = client.nodebalancer_create(region=chosen_region, label=label) + nb = client.nodebalancer_create( + region=chosen_region, label=label, firewall=e2e_test_firewall.id + ) config = nb.config_create() diff --git a/test/integration/models/placement/test_placement.py b/test/integration/models/placement/test_placement.py new file mode 100644 index 000000000..7919ef432 --- /dev/null +++ b/test/integration/models/placement/test_placement.py @@ -0,0 +1,46 @@ +from linode_api4 import PlacementGroup + + +def test_get_pg(test_linode_client, create_placement_group): + """ + Tests that a Placement Group can be loaded. + """ + pg = test_linode_client.load(PlacementGroup, create_placement_group.id) + assert pg.id == create_placement_group.id + + +def test_update_pg(test_linode_client, create_placement_group): + """ + Tests that a Placement Group can be updated successfully. + """ + pg = create_placement_group + new_label = create_placement_group.label + "-updated" + + pg.label = new_label + pg.save() + + pg = test_linode_client.load(PlacementGroup, pg.id) + + assert pg.label == new_label + + +def test_pg_assignment(test_linode_client, create_placement_group_with_linode): + """ + Tests that a Placement Group can be updated successfully. + """ + pg, inst = create_placement_group_with_linode + + assert pg.members[0].linode_id == inst.id + assert inst.placement_group.id == pg.id + + pg.unassign([inst]) + inst.invalidate() + + assert len(pg.members) == 0 + assert inst.placement_group is None + + pg.assign([inst]) + inst.invalidate() + + assert pg.members[0].linode_id == inst.id + assert inst.placement_group.id == pg.id diff --git a/test/integration/models/tag/test_tag.py b/test/integration/models/tag/test_tag.py index a9357a896..d2edf84c5 100644 --- a/test/integration/models/tag/test_tag.py +++ b/test/integration/models/tag/test_tag.py @@ -2,7 +2,7 @@ import pytest -from linode_api4.objects import Instance, Tag +from linode_api4.objects import Tag @pytest.fixture diff --git a/test/integration/models/volume/test_volume.py b/test/integration/models/volume/test_volume.py index 1b351c14d..08e836a13 100644 --- a/test/integration/models/volume/test_volume.py +++ b/test/integration/models/volume/test_volume.py @@ -13,7 +13,7 @@ @pytest.fixture(scope="session") -def linode_for_volume(test_linode_client): +def linode_for_volume(test_linode_client, e2e_test_firewall): client = test_linode_client available_regions = client.regions() chosen_region = available_regions[4] @@ -21,7 +21,11 @@ def linode_for_volume(test_linode_client): label = "TestSDK-" + timestamp linode_instance, password = client.linode.instance_create( - "g6-nanode-1", chosen_region, image="linode/debian10", label=label + "g6-nanode-1", + chosen_region, + image="linode/debian10", + label=label, + firewall=e2e_test_firewall, ) yield linode_instance diff --git a/test/unit/objects/linode_test.py b/test/unit/objects/linode_test.py index 9759bba41..8b03cbe7c 100644 --- a/test/unit/objects/linode_test.py +++ b/test/unit/objects/linode_test.py @@ -1,7 +1,7 @@ from datetime import datetime from test.unit.base import ClientBaseCase -from linode_api4 import NetworkInterface +from linode_api4 import InstancePlacementGroupAssignment, NetworkInterface from linode_api4.objects import ( Config, ConfigInterface, @@ -476,6 +476,48 @@ def test_build_instance_metadata(self): {"user_data": "cool"}, ) + def test_get_placement_group(self): + """ + Tests that you can get the placement group for a Linode + """ + linode = Instance(self.client, 123) + + pg = linode.placement_group + + assert pg.id == 123 + assert pg.label == "test" + assert pg.affinity_type == "anti_affinity:local" + + # Invalidate the instance and try again + # This makes sure the implicit refresh/cache logic works + # as expected + linode.invalidate() + + pg = linode.placement_group + + assert pg.id == 123 + assert pg.label == "test" + assert pg.affinity_type == "anti_affinity:local" + + def test_create_with_placement_group(self): + """ + Tests that you can create a Linode with a Placement Group + """ + + with self.mock_post("linode/instances/123") as m: + self.client.linode.instance_create( + "g6-nanode-1", + "eu-west", + placement_group=InstancePlacementGroupAssignment( + id=123, + compliant_only=True, + ), + ) + + self.assertEqual( + m.call_data["placement_group"], {"id": 123, "compliant_only": True} + ) + class DiskTest(ClientBaseCase): """ diff --git a/test/unit/objects/placement_test.py b/test/unit/objects/placement_test.py new file mode 100644 index 000000000..f3809d898 --- /dev/null +++ b/test/unit/objects/placement_test.py @@ -0,0 +1,117 @@ +from test.unit.base import ClientBaseCase + +from linode_api4.objects import ( + PlacementGroup, + PlacementGroupAffinityType, + PlacementGroupMember, +) + + +class PlacementTest(ClientBaseCase): + """ + Tests methods of the Placement Group + """ + + def test_get_placement_group(self): + """ + Tests that a Placement Group is loaded correctly by ID + """ + + pg = PlacementGroup(self.client, 123) + assert not pg._populated + + self.validate_pg_123(pg) + assert pg._populated + + def test_list_pgs(self): + """ + Tests that you can list PGs. + """ + + pgs = self.client.placement.groups() + + self.validate_pg_123(pgs[0]) + assert pgs[0]._populated + + def test_create_pg(self): + """ + Tests that you can create a Placement Group. + """ + + with self.mock_post("/placement/groups/123") as m: + pg = self.client.placement.group_create( + "test", + "eu-west", + PlacementGroupAffinityType.anti_affinity_local, + is_strict=True, + ) + + assert m.call_url == "/placement/groups" + + self.assertEqual( + m.call_data, + { + "label": "test", + "region": "eu-west", + "affinity_type": str( + PlacementGroupAffinityType.anti_affinity_local + ), + "is_strict": True, + }, + ) + + assert pg._populated + self.validate_pg_123(pg) + + def test_pg_assign(self): + """ + Tests that you can assign to a PG. + """ + + pg = PlacementGroup(self.client, 123) + assert not pg._populated + + with self.mock_post("/placement/groups/123") as m: + pg.assign([123], compliant_only=True) + + assert m.call_url == "/placement/groups/123/assign" + + # Ensure the PG state was populated + assert pg._populated + + self.assertEqual( + m.call_data, + {"linodes": [123], "compliant_only": True}, + ) + + def test_pg_unassign(self): + """ + Tests that you can unassign from a PG. + """ + + pg = PlacementGroup(self.client, 123) + assert not pg._populated + + with self.mock_post("/placement/groups/123") as m: + pg.unassign([123]) + + assert m.call_url == "/placement/groups/123/unassign" + + # Ensure the PG state was populated + assert pg._populated + + self.assertEqual( + m.call_data, + {"linodes": [123]}, + ) + + def validate_pg_123(self, pg: PlacementGroup): + assert pg.id == 123 + assert pg.label == "test" + assert pg.region.id == "eu-west" + assert pg.affinity_type == "anti_affinity:local" + assert pg.is_strict + assert pg.is_compliant + assert pg.members[0] == PlacementGroupMember( + linode_id=123, is_compliant=True + ) diff --git a/test/unit/objects/region_test.py b/test/unit/objects/region_test.py index 77b7ee2a9..a7fcc2694 100644 --- a/test/unit/objects/region_test.py +++ b/test/unit/objects/region_test.py @@ -1,7 +1,7 @@ import json from test.unit.base import ClientBaseCase -from linode_api4.objects import Region, Type +from linode_api4.objects import Region from linode_api4.objects.region import RegionAvailabilityEntry @@ -23,6 +23,12 @@ def test_get_region(self): self.assertEqual(region.status, "ok") self.assertIsNotNone(region.resolvers) self.assertEqual(region.site_type, "core") + self.assertEqual( + region.placement_group_limits.maximum_pgs_per_customer, 5 + ) + self.assertEqual( + region.placement_group_limits.maximum_linodes_per_pg, 5 + ) def test_list_availability(self): """ diff --git a/test/unit/objects/vpc_test.py b/test/unit/objects/vpc_test.py index 7e4963d33..4d80716d4 100644 --- a/test/unit/objects/vpc_test.py +++ b/test/unit/objects/vpc_test.py @@ -2,7 +2,6 @@ from test.unit.base import ClientBaseCase from linode_api4 import DATE_FORMAT, VPC, VPCSubnet -from linode_api4.objects import Volume class VPCTest(ClientBaseCase):