├── .github └── workflows │ └── test.yml ├── .gitignore ├── CONTRIBUTING.md ├── LICENSE ├── Makefile ├── README.md ├── ansible_tailscale_inventory.py ├── dev-requirements.txt ├── pyproject.toml └── tests ├── __init__.py ├── mock_data.py └── test_ansible_tailscale_intentory.py /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: test 3 | on: 4 | pull_request: 5 | workflow_dispatch: 6 | jobs: 7 | testpy38: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | - uses: actions/setup-python@v4 12 | with: 13 | python-version: "3.8" 14 | - run: make -j --output-sync=target test 15 | testpy39: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v4 19 | - uses: actions/setup-python@v4 20 | with: 21 | python-version: "3.9" 22 | - run: make -j --output-sync=target test 23 | testpy310: 24 | runs-on: ubuntu-latest 25 | steps: 26 | - uses: actions/checkout@v4 27 | - uses: actions/setup-python@v4 28 | with: 29 | python-version: "3.10" 30 | - run: make -j --output-sync=target test 31 | testpy311: 32 | runs-on: ubuntu-latest 33 | steps: 34 | - uses: actions/checkout@v4 35 | - uses: actions/setup-python@v4 36 | with: 37 | python-version: "3.11" 38 | - run: make -j --output-sync=target test 39 | testpy312: 40 | runs-on: ubuntu-latest 41 | steps: 42 | - uses: actions/checkout@v4 43 | - uses: actions/setup-python@v4 44 | with: 45 | python-version: "3.12" 46 | - run: make -j --output-sync=target test 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | .DS_Store 3 | .mypy_cache/ 4 | .pytest_cache/ 5 | .ruff_cache/ 6 | .vscode/ 7 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to ansible-tailscale-inventory 2 | 3 | Contributions are welcomed! Please feel free to [submit 4 | issues](https://github.com/m4wh6k/ansible-tailscale-inventory/issues) and propose fixes and changes to the script. 5 | 6 | ## Development dependencies 7 | 8 | - `make` 9 | - `python` 3.8 or higher 10 | 11 | Running `make dev` will install more pip dependencies. 12 | 13 | ## Formatting & Linting 14 | 15 | Code is expected to formatted and type annotated to conform with `mypy`, and `ruff`. Formatting can be tested with `make 16 | test`. Some formatting can be automatically applied by running `make fmt`. 17 | 18 | ## Testing 19 | 20 | We use `pytest` to ensure the script works as expected as we change things. Simply running `pytest` with no arguments 21 | will test the script. Or, you can run `make test` to run the full test suite with linting. 22 | 23 | Pytests can be found in the `tests/` dir. The main existing test simply checks that a given Tailscale status output will 24 | produce an Ansible inventory in an expected format. The expected inputs and outputs are defined in `tests/mock_data.py`. 25 | 26 | For most inventory structure changes it should be adequate to simply update the input and output data structures in 27 | `tests/mock_data.py`. A recommended development workflow is to update the mock data and then start updating the script 28 | to produce the expected output, running `pytest` along the way until it passes. 29 | 30 | A GitHub Actions workflow will test changes on Pull Request. It can also be run on-demand against branches. The GH 31 | Actions workflow will run tests using multiple versions of python to ensure the script remains compatible. 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Mat Hornbeek 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .DEFAULT_GOAL = dev 2 | 3 | .PHONY: clean 4 | clean: 5 | rm -rf \ 6 | __pycache__ \ 7 | .mypy_cache \ 8 | .pytest_cache \ 9 | .ruff_cache \ 10 | **/__pycache__ 11 | 12 | .PHONY: dev 13 | dev: 14 | pip3 install -U -r dev-requirements.txt 15 | 16 | .PHONY: fmt 17 | fmt: dev 18 | ruff format . 19 | ruff check --fix . 20 | 21 | .PHONY: test 22 | test: test-mypy test-pytest test-ruff 23 | 24 | .PHONY: test-mypy 25 | test-mypy: dev 26 | mypy 27 | 28 | .PHONY: test-pytest 29 | test-pytest: dev 30 | pytest 31 | 32 | .PHONY: test-ruff 33 | test-ruff: dev 34 | ruff format --check . 35 | ruff check . 36 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ansible-tailscale-inventory 2 | 3 | Dependency-free dynamic Ansible inventory for your Tailscale hosts. Allows you to reach your Tailscale hosts easily with 4 | Ansible. All you need is Tailscale installed and working, python 3.8+, and a copy of `ansible_tailscale_inventory.py` 5 | from this repo. 6 | 7 | ## Usage 8 | 9 | From one of your Tailscale nodes on your network, make `ansible_tailscale_inventory.py` available as an inventory to 10 | Ansible. This can be done as an argument to the `-i` option on Ansible commands, or by setting the `ANSIBLE_INVENTORY` 11 | environment variable's value as the path to the script. 12 | 13 | Notes: 14 | 15 | - At the time of writing, the inventory script has been tested with macOS and Linux, but not Windows. 16 | - If you copy/paste the script from GitHub rather than cloning the repo and copying the file, ensure the script is 17 | marked as an executable. 18 | 19 | ## Ansible Groups 20 | 21 | `ansible_tailscale_inventory.py` automatically provides a few groups. 22 | 23 | - There are groups of hosts for each operating system (`macOS`, `linux`, etc) 24 | - Online hosts are found in the `online` group, offline hosts in the `offline` group 25 | - The `self` group includes the local host 26 | - Each Tailscale tag that has at least one host will be a group as well. The name will be formatted as `tag_TagName` 27 | (`-` and `:` characters will be replaced with underscores) 28 | 29 | ## Inventory Metadata 30 | 31 | The inventory automatically adds all available Tailscale IPs as a list in the fact `tailscale_ips`. 32 | 33 | ## Contributing 34 | 35 | Check out the [contributing doc](CONTRIBUTING.md). 36 | -------------------------------------------------------------------------------- /ansible_tailscale_inventory.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # MIT License 3 | 4 | # Copyright (c) 2022 Mat Hornbeek 5 | 6 | # Permission is hereby granted, free of charge, to any person obtaining a copy 7 | # of this software and associated documentation files (the "Software"), to deal 8 | # in the Software without restriction, including without limitation the rights 9 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | # copies of the Software, and to permit persons to whom the Software is 11 | # furnished to do so, subject to the following conditions: 12 | 13 | # The above copyright notice and this permission notice shall be included in all 14 | # copies or substantial portions of the Software. 15 | 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | # SOFTWARE. 23 | 24 | from __future__ import annotations 25 | 26 | import json 27 | import platform 28 | import subprocess 29 | import sys 30 | from typing import Any, Dict, List, TypedDict, Union 31 | 32 | ansible_inventory_type = Dict[str, Dict[str, Union[List[str], Dict[str, Any]]]] 33 | 34 | 35 | class InventoryType(TypedDict): 36 | """ 37 | Type annotation for internal representation of the inventory 38 | """ 39 | 40 | metadata: dict[str, dict[str, Any]] # Maps hostnames to mappings of their hostvars 41 | groups: dict[str, list[str]] # Maps group names to their list of group members 42 | 43 | 44 | class TailscaleHostType(TypedDict, total=False): 45 | """ 46 | Type annotation for Tailscale hosts within status JSON 47 | """ 48 | 49 | Active: bool 50 | Addrs: list[str] | None 51 | Capabilities: list[str] 52 | CapMap: dict[str, Any] 53 | Created: str 54 | CurAddr: str | None 55 | DNSName: str 56 | ExitNode: bool 57 | ExitNodeOption: bool 58 | HostName: str 59 | ID: str 60 | InEngine: bool 61 | InMagicSock: bool 62 | InNetworkMap: bool 63 | KeyExpiry: str 64 | LastHandshake: str 65 | LastSeen: str 66 | LastWrite: str 67 | Online: bool 68 | OS: str 69 | PeerAPIURL: list[str] 70 | PublicKey: str 71 | Relay: str 72 | RxBytes: int 73 | Tags: list[str] 74 | TailscaleIPs: list[str] 75 | TxBytes: int 76 | UserID: int 77 | 78 | 79 | class TailscaleStatusType(TypedDict): 80 | """ 81 | Type annotation for the Tailscale status JSON 82 | """ 83 | 84 | AuthURL: str 85 | BackendState: str 86 | CertDomains: list[str] 87 | ClientVersion: dict[str, Any] 88 | CurrentTailnet: dict[str, Any] 89 | Health: Any 90 | MagicDNSSuffix: str 91 | Peer: dict[str, TailscaleHostType] 92 | Self: TailscaleHostType 93 | TailscaleIPs: list[str] 94 | TUN: bool 95 | User: dict[str, Any] 96 | Version: str 97 | 98 | 99 | def get_tailscale_status() -> TailscaleStatusType: 100 | """ 101 | Returns raw status information from the local tailscale install 102 | """ 103 | 104 | # Select tailscale binary to run based upon OS name 105 | system_os_name = platform.system() 106 | if system_os_name == "Linux": 107 | tailscale_cmd = "tailscale" 108 | elif system_os_name == "Darwin": 109 | tailscale_cmd = "/Applications/Tailscale.app/Contents/MacOS/Tailscale" 110 | else: 111 | print(f"{system_os_name} not currently supported. Contributions welcome!") 112 | sys.exit(1) 113 | 114 | try: 115 | tailscale_proc = subprocess.run( # noqa: S603 116 | [tailscale_cmd, "status", "--self", "--json"], 117 | capture_output=True, 118 | check=True, 119 | ) 120 | except FileNotFoundError as e: 121 | print(f"tailscale command not found: {e}") 122 | sys.exit(1) 123 | except subprocess.CalledProcessError as e: 124 | print(f"tailscale command failed. Is tailscale running?: {e}") 125 | sys.exit(1) 126 | 127 | tailscale_output_json: TailscaleStatusType = json.loads(tailscale_proc.stdout) 128 | return tailscale_output_json 129 | 130 | 131 | def assemble_all_tailscale_hosts( 132 | ts_status: TailscaleStatusType, 133 | ) -> list[TailscaleHostType]: 134 | """ 135 | Processes tailscale status into a list of all hosts with their metadata, including 136 | the "self" host 137 | """ 138 | 139 | all_hosts: list[TailscaleHostType] = list(ts_status["Peer"].values()) 140 | all_hosts.append(ts_status["Self"]) 141 | return all_hosts 142 | 143 | 144 | def assemble_inventory( 145 | tailscale_hosts: list[TailscaleHostType], 146 | tailscale_self_hostname: str, 147 | ) -> InventoryType: 148 | """ 149 | Given a list of tailscale hosts with their metadata return an inventory object. This 150 | is where we select set the metadata ansible will be aware of for each host as 151 | hostvars, and defines group memberships. The "self" hostname needs to be identified 152 | explicitly so it can be put into its own group 153 | """ 154 | 155 | # Create the base inventory data structure 156 | inventory: InventoryType = { 157 | "metadata": {}, 158 | "groups": { 159 | "all": [], 160 | "online": [], 161 | "offline": [], 162 | "self": [tailscale_self_hostname], 163 | }, 164 | } 165 | 166 | for host_data in tailscale_hosts: 167 | # We intentionally avoid adding any the funnel-ingress-node to the inventory 168 | # because we can't manage it 169 | if host_data["HostName"] == "funnel-ingress-node": 170 | continue 171 | 172 | # We ignore endpoints that have no OS, like Mullvad exit nodes 173 | if not host_data["OS"]: 174 | continue 175 | 176 | # We add each host to the list of all hosts 177 | inventory["groups"]["all"].append(host_data["HostName"]) 178 | 179 | # Set host's inventory metadata 180 | inventory["metadata"][host_data["HostName"]] = { 181 | "ansible_host": host_data["DNSName"], 182 | "tailscale_ips": host_data["TailscaleIPs"], 183 | } 184 | 185 | # Hosts that are offline will still be present in the inventory. We set-up these 186 | # groups so host patterns can be used to skip offline hosts. We could omit the 187 | # offline hosts entirely but there may be use cases where one does want to see 188 | # an error if they attempt to connect to an offline host 189 | if host_data["Online"]: 190 | inventory["groups"]["online"].append(host_data["HostName"]) 191 | else: 192 | inventory["groups"]["offline"].append(host_data["HostName"]) 193 | 194 | # If we encounter an OS type we don't have in the inventory yet, we create a 195 | # group for it, then we always add each host to the group for that OS 196 | if host_data["OS"] not in inventory["groups"]: 197 | inventory["groups"][host_data["OS"]] = [] 198 | inventory["groups"][host_data["OS"]].append(host_data["HostName"]) 199 | 200 | # We create groups for host tags. Tag names have to be modified to be compatible 201 | # with ansible 202 | if "Tags" in host_data: 203 | for tag in host_data["Tags"]: 204 | safe_tag = tag.replace(":", "_").replace("-", "_") 205 | if safe_tag in inventory["groups"]: 206 | inventory["groups"][safe_tag].append(host_data["HostName"]) 207 | else: 208 | inventory["groups"][safe_tag] = [host_data["HostName"]] 209 | 210 | return inventory 211 | 212 | 213 | def format_ansible_inventory(inventory: InventoryType) -> ansible_inventory_type: 214 | """ 215 | Given an inventory object, returns the inventory formatted to be read by ansible 216 | """ 217 | 218 | # Create the base ansible inventory object 219 | ansible_inventory: ansible_inventory_type = { 220 | "_meta": {"hostvars": inventory["metadata"]}, 221 | } 222 | 223 | # Create groups 224 | for key, value in inventory["groups"].items(): 225 | ansible_inventory[key] = {"hosts": value} 226 | 227 | return ansible_inventory 228 | 229 | 230 | def tailscale_status_to_ansible_inventory( 231 | ts_status: TailscaleStatusType, 232 | ) -> ansible_inventory_type: 233 | """ 234 | Given a tailscale status object this returns an ansible inventory object 235 | """ 236 | 237 | ts_all_hosts = assemble_all_tailscale_hosts(ts_status) 238 | inventory = assemble_inventory(ts_all_hosts, ts_status["Self"]["HostName"]) 239 | return format_ansible_inventory(inventory) 240 | 241 | 242 | def main() -> None: 243 | """ 244 | This is the main function run when the script is executed 245 | """ 246 | 247 | ts_status = get_tailscale_status() 248 | ansible_inventory = tailscale_status_to_ansible_inventory(ts_status) 249 | print(json.dumps(ansible_inventory, indent=2, sort_keys=True)) 250 | 251 | 252 | if __name__ == "__main__": 253 | # This makes it so the main() function will only run when the script is executed 254 | # directly vs being imported for tests 255 | main() 256 | -------------------------------------------------------------------------------- /dev-requirements.txt: -------------------------------------------------------------------------------- 1 | mypy==1.14.* 2 | pytest==8.3.* 3 | ruff==0.9.* 4 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.mypy] 2 | files = "**/*.py" 3 | strict = true 4 | implicit_reexport = true 5 | 6 | [tool.ruff] 7 | target-version = "py38" 8 | 9 | [tool.ruff.lint] 10 | ignore = ["COM812", "D", "EM", "ISC001", "T201", "TC001", "TRY003"] 11 | select = ["ALL"] 12 | 13 | [tool.ruff.lint.per-file-ignores] 14 | "mock_data.py" = ["E501"] 15 | "test_*.py" = ["S101", "TID252"] 16 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m4wh6k/ansible-tailscale-inventory/0713adaf28b4a397db299daec8a1cc4d75ab55a0/tests/__init__.py -------------------------------------------------------------------------------- /tests/mock_data.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from ansible_tailscale_inventory import TailscaleStatusType, ansible_inventory_type 4 | 5 | mock_tailscale_status_output: TailscaleStatusType = { 6 | "Version": "1.50.1-abc1234567-abc1234567", 7 | "TUN": True, 8 | "BackendState": "Running", 9 | "AuthURL": "", 10 | "TailscaleIPs": ["100.100.100.100", "2001:0db8:85a3:0000:0000:8a2e:0370:7334"], 11 | "Self": { 12 | "ID": "abc1234567DE", 13 | "PublicKey": "nodekey:1234567891011121314151617181920212223242526272829303132333435abc", 14 | "HostName": "macclient", 15 | "DNSName": "macclient.example-test.ts.net.", 16 | "OS": "macOS", 17 | "UserID": 123456789101112131, 18 | "TailscaleIPs": ["100.100.100.100", "2001:0db8:85a3:0000:0000:8a2e:0370:7334"], 19 | "Addrs": [ 20 | "100.100.100.100:41641", 21 | "[2001:0db8:85a3:0000:0000:8a2e:0370:7334]:41641", 22 | "100.100.100.100:41641", 23 | "[2001:0db8:85a3:0000:0000:8a2e:0370:7334]:41641", 24 | ], 25 | "CurAddr": "", 26 | "Relay": "abc", 27 | "RxBytes": 0, 28 | "TxBytes": 0, 29 | "Created": "2023-12-10T00:00:00.008494309Z", 30 | "LastWrite": "0001-01-01T00:00:00Z", 31 | "LastSeen": "0001-01-01T00:00:00Z", 32 | "LastHandshake": "0001-01-01T00:00:00Z", 33 | "Online": True, 34 | "ExitNode": False, 35 | "ExitNodeOption": False, 36 | "Active": False, 37 | "PeerAPIURL": [ 38 | "http://100.100.101.100:48498", 39 | "http://[2001:0db8:85a3:0000:0000:8a2e:0370:7344]:48498", 40 | ], 41 | "Capabilities": [ 42 | "https://tailscale.com/cap/is-admin", 43 | "https://tailscale.com/cap/tailnet-lock", 44 | ], 45 | "CapMap": { 46 | "https": None, 47 | "https://tailscale.com/cap/is-admin": None, 48 | "https://tailscale.com/cap/ssh": None, 49 | "https://tailscale.com/cap/tailnet-lock": None, 50 | }, 51 | "InNetworkMap": True, 52 | "InMagicSock": False, 53 | "InEngine": False, 54 | "KeyExpiry": "2024-04-02T17:00:45Z", 55 | }, 56 | "Health": None, 57 | "MagicDNSSuffix": "example-test.ts.net", 58 | "CurrentTailnet": { 59 | "Name": "user@example.com", 60 | "MagicDNSSuffix": "example-test.ts.net", 61 | "MagicDNSEnabled": True, 62 | }, 63 | "CertDomains": ["macclient.example-test.ts.net"], 64 | "Peer": { 65 | "nodekey:2234567891011121314151617181920212223242526272829303132333435abc": { 66 | "ID": "bbc1234567DE", 67 | "PublicKey": "nodekey:2234567891011121314151617181920212223242526272829303132333435abc", 68 | "HostName": "winserver", 69 | "DNSName": "winserver.example-test.ts.net.", 70 | "OS": "windows", 71 | "UserID": 223456789101112131, 72 | "TailscaleIPs": [ 73 | "100.100.100.105", 74 | "2001:0db8:85a3:0000:0000:8a2e:0370:7335", 75 | ], 76 | "Tags": ["tag:windows", "tag:dash-server", "tag:server"], 77 | "Addrs": None, 78 | "CurAddr": "", 79 | "Relay": "abc", 80 | "RxBytes": 0, 81 | "TxBytes": 0, 82 | "Created": "2023-11-20T21:00:55.842755699Z", 83 | "LastWrite": "0001-01-01T00:00:00Z", 84 | "LastSeen": "0001-01-01T00:00:00Z", 85 | "LastHandshake": "0001-01-01T00:00:00Z", 86 | "Online": True, 87 | "ExitNode": False, 88 | "ExitNodeOption": True, 89 | "Active": False, 90 | "PeerAPIURL": [ 91 | "100.100.100.105", 92 | "2001:0db8:85a3:0000:0000:8a2e:0370:7335", 93 | ], 94 | "InNetworkMap": True, 95 | "InMagicSock": True, 96 | "InEngine": False, 97 | }, 98 | "nodekey:3234567891011121314151617181920212223242526272829303132333435abc": { 99 | "ID": "cbc1234567DE", 100 | "PublicKey": "nodekey:2234567891011121314151617181920212223242526272829303132333435abc", 101 | "HostName": "linserver", 102 | "DNSName": "linserver.example-test.ts.net.", 103 | "OS": "linux", 104 | "UserID": 323456789101112131, 105 | "TailscaleIPs": [ 106 | "100.100.100.106", 107 | "2001:0db8:85a3:0000:0000:8a2e:0370:7336", 108 | ], 109 | "Tags": ["tag:Linux", "tag:dash-server", "tag:server"], 110 | "Addrs": None, 111 | "CurAddr": "", 112 | "Relay": "abc", 113 | "RxBytes": 0, 114 | "TxBytes": 0, 115 | "Created": "2023-11-20T21:00:55.842755699Z", 116 | "LastWrite": "0001-01-01T00:00:00Z", 117 | "LastSeen": "0001-01-01T00:00:00Z", 118 | "LastHandshake": "0001-01-01T00:00:00Z", 119 | "Online": True, 120 | "ExitNode": True, 121 | "ExitNodeOption": True, 122 | "Active": False, 123 | "PeerAPIURL": [ 124 | "100.100.100.106", 125 | "2001:0db8:85a3:0000:0000:8a2e:0370:7336", 126 | ], 127 | "InNetworkMap": True, 128 | "InMagicSock": True, 129 | "InEngine": False, 130 | }, 131 | }, 132 | "User": { 133 | "123456789101112131": { 134 | "ID": 123456789101112131, 135 | "LoginName": "user@example.com", 136 | "DisplayName": "user", 137 | "ProfilePicURL": "", 138 | "Roles": [], 139 | }, 140 | }, 141 | "ClientVersion": {"LatestVersion": "1.52.0"}, 142 | } 143 | 144 | expected_ansible_inventory_output: ansible_inventory_type = { 145 | "_meta": { 146 | "hostvars": { 147 | "linserver": { 148 | "ansible_host": "linserver.example-test.ts.net.", 149 | "tailscale_ips": [ 150 | "100.100.100.106", 151 | "2001:0db8:85a3:0000:0000:8a2e:0370:7336", 152 | ], 153 | }, 154 | "macclient": { 155 | "ansible_host": "macclient.example-test.ts.net.", 156 | "tailscale_ips": [ 157 | "100.100.100.100", 158 | "2001:0db8:85a3:0000:0000:8a2e:0370:7334", 159 | ], 160 | }, 161 | "winserver": { 162 | "ansible_host": "winserver.example-test.ts.net.", 163 | "tailscale_ips": [ 164 | "100.100.100.105", 165 | "2001:0db8:85a3:0000:0000:8a2e:0370:7335", 166 | ], 167 | }, 168 | }, 169 | }, 170 | "all": {"hosts": ["winserver", "linserver", "macclient"]}, 171 | "linux": {"hosts": ["linserver"]}, 172 | "macOS": {"hosts": ["macclient"]}, 173 | "offline": {"hosts": []}, 174 | "online": {"hosts": ["winserver", "linserver", "macclient"]}, 175 | "self": {"hosts": ["macclient"]}, 176 | "tag_dash_server": {"hosts": ["winserver", "linserver"]}, 177 | "tag_Linux": {"hosts": ["linserver"]}, 178 | "tag_server": {"hosts": ["winserver", "linserver"]}, 179 | "tag_windows": {"hosts": ["winserver"]}, 180 | "windows": {"hosts": ["winserver"]}, 181 | } 182 | -------------------------------------------------------------------------------- /tests/test_ansible_tailscale_intentory.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from ansible_tailscale_inventory import ( 4 | tailscale_status_to_ansible_inventory, 5 | ) 6 | from tests.mock_data import ( 7 | expected_ansible_inventory_output, 8 | mock_tailscale_status_output, 9 | ) 10 | 11 | 12 | def test_tailscale_status_to_ansible_inventory() -> None: 13 | """ 14 | Using mock data we test that fake tailscale output produces an expected ansible 15 | inventory structure 16 | """ 17 | 18 | assert ( 19 | tailscale_status_to_ansible_inventory(mock_tailscale_status_output) 20 | == expected_ansible_inventory_output 21 | ) 22 | --------------------------------------------------------------------------------