Skip to content

Commit

Permalink
new: Add InfoModule class to simplify info module implementations (#409)
Browse files Browse the repository at this point in the history
* WIP

* Support secondary responses

* Implement instance_info

* Fix docs

* Fix lint; need tests

* Move away from inheritance model

* InfoModuleBase -> InfoModule

* make format

* Add unit tests; migrate from pytest to ansible-test

* Update unit test target

* Improve error handling
  • Loading branch information
lgarber-akamai authored Sep 19, 2023
1 parent de79912 commit 1ff5049
Show file tree
Hide file tree
Showing 14 changed files with 435 additions and 254 deletions.
5 changes: 5 additions & 0 deletions .github/workflows/unit-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,14 @@ name: Run Unit test
jobs:
run-tests:
runs-on: ubuntu-latest
defaults:
run:
working-directory: .ansible/collections/ansible_collections/linode/cloud
steps:
- name: checkout repo
uses: actions/checkout@v3
with:
path: .ansible/collections/ansible_collections/linode/cloud

- name: update packages
run: sudo apt-get update -y
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,5 @@ tmp
__pycache__/
galaxy.yml
venv
collections
.pytest_cache
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ testall: create-integration-config
./scripts/test_all.sh

unittest:
python -m pytest tests/unit/
ansible-test units --target-python default

create-integration-config:
ifneq ("${LINODE_TOKEN}", "")
Expand Down
12 changes: 6 additions & 6 deletions docs/modules/instance_info.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,12 @@ Get info about a Linode Instance.
| Field | Type | Required | Description |
|-----------|------|----------|------------------------------------------------------------------------------|
| `id` | <center>`int`</center> | <center>Optional</center> | The unique ID of the instance. Optional if `label` is defined. **(Conflicts With: `label`)** |
| `label` | <center>`str`</center> | <center>Optional</center> | The instance’s label. Optional if `id` is defined. **(Conflicts With: `id`)** |
| `label` | <center>`str`</center> | <center>Optional</center> | The label of the Instance to resolve. |
| `id` | <center>`int`</center> | <center>Optional</center> | The ID of the Instance to resolve. |

## Return Values

- `instance` - The instance description in JSON serialized form.
- `instance` - The returned Instance.

- Sample Response:
```json
Expand Down Expand Up @@ -82,7 +82,7 @@ Get info about a Linode Instance.
- See the [Linode API response documentation](https://www.linode.com/docs/api/linode-instances/#linode-view__responses) for a list of returned fields


- `configs` - A list of configs tied to this Linode Instance.
- `configs` - The returned Configs.

- Sample Response:
```json
Expand Down Expand Up @@ -150,7 +150,7 @@ Get info about a Linode Instance.
- See the [Linode API response documentation](https://www.linode.com/docs/api/linode-instances/#configuration-profile-view__responses) for a list of returned fields


- `disks` - A list of disks tied to this Linode Instance.
- `disks` - The returned Disks.

- Sample Response:
```json
Expand All @@ -169,7 +169,7 @@ Get info about a Linode Instance.
- See the [Linode API response documentation](https://www.linode.com/docs/api/linode-instances/#disk-view__responses) for a list of returned fields


- `networking` - Networking information about this Linode Instance.
- `networking` - The returned Networking Configuration.

- Sample Response:
```json
Expand Down
7 changes: 3 additions & 4 deletions docs/modules/stackscript_info.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,12 @@ Get info about a Linode StackScript.
| Field | Type | Required | Description |
|-----------|------|----------|------------------------------------------------------------------------------|
| `id` | <center>`int`</center> | <center>Optional</center> | The ID of the StackScript. **(Conflicts With: `label`)** |
| `label` | <center>`str`</center> | <center>Optional</center> | The label of the StackScript. **(Conflicts With: `id`)** |
| `label` | <center>`str`</center> | <center>Optional</center> | The label of the StackScript to resolve. |
| `id` | <center>`int`</center> | <center>Optional</center> | The ID of the StackScript to resolve. |

## Return Values

- `stackscript` - The StackScript in JSON serialized form.
- `stackscript` - The returned StackScript.

- Sample Response:
```json
Expand Down Expand Up @@ -64,6 +64,5 @@ Get info about a Linode StackScript.
"username": "myuser"
}
```
- See the [Linode API response documentation](https://www.linode.com/docs/api/stackscripts/#stackscript-view__response-samples) for a list of returned fields


215 changes: 215 additions & 0 deletions plugins/module_utils/linode_common_info.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
#!/usr/bin/python
# -*- coding: utf-8 -*-

"""This module allows users to list SSH keys in their Linode profile."""

from __future__ import absolute_import, division, print_function

from dataclasses import dataclass
from typing import Any, Callable, Dict, List, Optional

from ansible_collections.linode.cloud.plugins.module_utils.linode_common import (
LinodeModuleBase,
)
from ansible_collections.linode.cloud.plugins.module_utils.linode_docs import (
global_authors,
global_requirements,
)
from ansible_specdoc.objects import (
FieldType,
SpecDocMeta,
SpecField,
SpecReturnValue,
)
from linode_api4 import LinodeClient


@dataclass
class InfoModuleParam:
"""
Contains information about a required parameter that is necessary to resolve a resource.
e.g. A parent resource ID.
Attributes:
display_name (str): The formatted name of this param for documentation purposes.
type (FieldType): The type of this field.
"""

name: str
display_name: str
type: FieldType


@dataclass
class InfoModuleAttr:
"""
Contains information about an attribute that can be used to select a specific resource
by property.
Attributes:
display_name (str): The formatted name of this attribute for documentation purposes.
type (FieldType): The type of this field.
get (Callable): A function to retrieve a resource from this attribute.
"""

name: str
display_name: str
type: FieldType
get: Callable[[LinodeClient, Dict[str, Any]], Any]


@dataclass
class InfoModuleResult:
"""
Contains information about a result field returned from an info module.
Attributes:
field_name (str): The name of the field to be returned. (e.g. `returned_field`)
field_type (FieldType): The type of the field to be returned.
display_name (str): The formatted name of this field for use in documentation.
docs_url (Optional[str]): The URL of the related API documentation for this field.
samples (Optional[List[str]]): A list of sample results for this field.
get (Optional[Callable]): A function to call out to the API and return the data
for this field.
NOTE: This is only relevant for secondary results.
"""

field_name: str
field_type: FieldType
display_name: str

docs_url: Optional[str] = None
samples: Optional[List[str]] = None
get: Optional[
Callable[[LinodeClient, Dict[str, Any], Dict[str, Any]], Any]
] = None


class InfoModule(LinodeModuleBase):
"""A common module for listing API resources given a set of filters."""

def __init__(
self,
primary_result: InfoModuleResult,
secondary_results: List[InfoModuleResult] = None,
params: List[InfoModuleParam] = None,
attributes: List[InfoModuleAttr] = None,
examples: List[str] = None,
) -> None:
self.primary_result = primary_result
self.secondary_results = secondary_results or []
self.params = params or []
self.attributes = attributes or []
self.examples = examples or []

self.module_arg_spec = self.spec.ansible_spec
self.results: Dict[str, Any] = {
k: None
for k in [
v.field_name
for v in self.secondary_results + [self.primary_result]
]
}

def exec_module(self, **kwargs: Any) -> Optional[dict]:
"""Entrypoint for info modules."""

primary_result = None

# Get the primary result using the attr get functions
for attr in self.attributes:
attr_value = kwargs.get(attr.name)
if attr_value is None:
continue

try:
primary_result = attr.get(self.client, kwargs)
except Exception as exception:
self.fail(
msg=f"Failed to get {self.primary_result.display_name} "
f"with {attr.display_name} {attr_value}: {exception}"
)
break

if primary_result is None:
raise ValueError("Expected a result; got None")

self.results[self.primary_result.field_name] = primary_result

# Pass primary result into secondary result get functions
for attr in self.secondary_results:
try:
secondary_result = attr.get(self.client, primary_result, kwargs)
except Exception as exception:
self.fail(
msg=f"Failed to get {attr.display_name} for "
f"{self.primary_result.display_name}: {exception}"
)
self.results[attr.field_name] = secondary_result

return self.results

@property
def spec(self):
"""
Returns the ansible-specdoc spec for this module.
"""

options = {
"state": SpecField(
type=FieldType.string, required=False, doc_hide=True
),
"label": SpecField(
type=FieldType.string, required=False, doc_hide=True
),
}

# Add params to spec
for param in self.params:
options[param.name] = SpecField(
type=param.type,
required=True,
description=f"The ID of the {param.display_name} for this resource.",
)

# Add attrs to spec
for attr in self.attributes:
options[attr.name] = SpecField(
type=attr.type,
description=f"The {attr.display_name} of the "
f"{self.primary_result.display_name} to resolve.",
)

# Add responses to spec
responses = {
v.field_name: SpecReturnValue(
description=f"The returned {v.display_name}.",
docs_url=v.docs_url,
type=v.field_type,
sample=v.samples,
)
for v in [self.primary_result] + self.secondary_results
}

return SpecDocMeta(
description=[
f"Get info about a Linode {self.primary_result.display_name}."
],
requirements=global_requirements,
author=global_authors,
options=options,
examples=self.examples,
return_values=responses,
)

def run(self) -> None:
"""
Initializes and runs the info module.
"""
attribute_names = [v.name for v in self.attributes]

super().__init__(
module_arg_spec=self.module_arg_spec,
required_one_of=[attribute_names],
mutually_exclusive=[attribute_names],
)
15 changes: 15 additions & 0 deletions plugins/module_utils/linode_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -305,3 +305,18 @@ def poll_condition(
step=step,
timeout=timeout,
)


def safe_find(
func: Callable[[Tuple[Filter]], List[Any]], *filters: Filter
) -> Any:
"""
Wraps a resource list function with error handling.
If no entries are returned, this function returns None rather than
raising an error.
"""
try:
list_results = func(*filters)
return None if len(list_results) < 1 else list_results[0]
except Exception as exception:
raise Exception(f"failed to get resource: {exception}") from exception
Loading

0 comments on commit 1ff5049

Please sign in to comment.