Skip to content

Commit 1b406fc

Browse files
feat: add incus/LXC connector
1 parent eb92851 commit 1b406fc

File tree

2 files changed

+235
-0
lines changed

2 files changed

+235
-0
lines changed

pyinfra/connectors/incus.py

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
"""Incus / LXD connector
2+
3+
.. Note::
4+
It requires Incus/LXD agent in order to work properly;
5+
but it set the first IPv4 address as ssh_hostname when one is found which could be a workaround.
6+
7+
## Usage
8+
9+
.. code-block:: sh
10+
11+
pyinfra @incus/incus.example.net:instance_name files.get src=/tmp/n.log dest=n.log
12+
13+
# execute on all instance running on incus.example.net
14+
pyinfra --debug -vvv --dry @incus/incus.example.net: fact server.LinuxName
15+
16+
"""
17+
18+
# stdlib
19+
import json
20+
from io import IOBase
21+
from os import unlink
22+
from os.path import isfile, realpath
23+
from tempfile import NamedTemporaryFile
24+
from typing import TYPE_CHECKING, Iterator, Literal, Optional, Union
25+
26+
# pyinfra
27+
from pyinfra import local, logger
28+
from pyinfra.api import QuoteString, StringCommand
29+
from pyinfra.api.util import get_file_io
30+
from pyinfra.connectors.base import BaseConnector, DataMeta
31+
from pyinfra.connectors.local import LocalConnector
32+
from pyinfra.connectors.util import (
33+
CommandOutput,
34+
extract_control_arguments,
35+
make_unix_command_for_host,
36+
)
37+
from pyinfra.progress import progress_spinner
38+
39+
if TYPE_CHECKING:
40+
from pyinfra.api.arguments import ConnectorArguments
41+
from pyinfra.api.command import StringCommand
42+
from pyinfra.api.host import Host
43+
from pyinfra.api.state import State
44+
45+
# dependencies
46+
import click
47+
from typing_extensions import TypedDict, Unpack, override
48+
49+
50+
class ConnectorData(TypedDict):
51+
lxc_cwd: str
52+
lxc_env: dict[str, str]
53+
lxc_user: int
54+
55+
56+
connector_data_meta: dict[str, DataMeta] = {
57+
"lxc_cwd": DataMeta("Directory to run the command in"),
58+
"lxc_env": DataMeta("Environment variable to set"),
59+
"lxc_user": DataMeta("User ID to run the command as"),
60+
}
61+
62+
63+
class IncusConnector(BaseConnector):
64+
cmd = "incus"
65+
shell: Literal["ash", "bash", "dash", "posh", "sh", "zsh"] = "sh"
66+
handles_execution = True
67+
68+
local: LocalConnector
69+
70+
remote_instance: str #: [<remote>:]<instance>
71+
no_stop: bool = False
72+
73+
def __init__(self, state: "State", host: "Host"):
74+
"""
75+
Initialize the Incus connector.
76+
77+
Args:
78+
host (str): The hostname/IP address of the target machine
79+
state (`State`): Pyinfra state object
80+
"""
81+
super().__init__(state, host)
82+
self.local = LocalConnector(state, host)
83+
self.remote_instance = host.name.partition("/")[-1]
84+
85+
@classmethod
86+
def make_names_data(cls, name: str) -> Iterator[tuple[str, dict, list[str]]]:
87+
logger.warning(f"No {cls.cmd} base ID provided! targeting local server")
88+
89+
remote_instance = name.partition("/")[-1] if "/" in name else name
90+
remote, instance = remote_instance.rpartition(":")[::2]
91+
if remote:
92+
remote += ":"
93+
94+
with progress_spinner({f"{cls.cmd} list"}):
95+
output = local.shell(f"{cls.cmd} list --all-projects -c nc -f json {remote_instance}")
96+
progress_spinner(f"{cls.cmd} list")
97+
98+
for row in json.loads(output):
99+
data = {f"{cls.cmd}_identifier": f"{remote}{row['name']}"}
100+
for dev in row.get("devices", ""):
101+
if address := getattr(dev, "ipv4.address", None):
102+
data["ssh_hostname"] = address
103+
break
104+
yield (
105+
f"@{cls.cmd}/{remote}{row['name']}",
106+
data,
107+
[f"@{cls.cmd}"],
108+
)
109+
110+
@override
111+
def run_shell_command(
112+
self,
113+
command: "StringCommand",
114+
print_output: bool,
115+
print_input: bool,
116+
**arguments: Unpack["ConnectorArguments"],
117+
) -> tuple[bool, "CommandOutput"]:
118+
"""Run a shell command to the targeted instance"""
119+
local_arguments = extract_control_arguments(arguments)
120+
121+
return self.local.run_shell_command(
122+
StringCommand(
123+
self.cmd,
124+
"exec",
125+
"-t" if local_arguments.get("_get_pty") else "-T",
126+
self.remote_instance,
127+
"--",
128+
self.shell,
129+
"-c",
130+
StringCommand(
131+
QuoteString(
132+
make_unix_command_for_host(self.state, self.host, command, **arguments)
133+
)
134+
),
135+
),
136+
print_output=print_output,
137+
print_input=print_input,
138+
**local_arguments,
139+
)
140+
141+
@override
142+
def put_file(
143+
self,
144+
filename_or_io: Union[str, IOBase],
145+
remote_filename: str,
146+
remote_temp_filename: Optional[str] = None,
147+
print_output: bool = False,
148+
print_input: bool = False,
149+
**arguments: Unpack["ConnectorArguments"],
150+
) -> bool:
151+
try:
152+
filename = realpath(filename_or_io, strict=True) if isfile(filename_or_io) else ""
153+
except (TypeError, FileNotFoundError):
154+
filename = ""
155+
temporary = None
156+
try:
157+
if not filename:
158+
with (
159+
get_file_io(filename_or_io) as file_io,
160+
NamedTemporaryFile(delete=False) as temporary,
161+
):
162+
filename = temporary.name
163+
data = file_io.read()
164+
temporary.write(data.encode() if isinstance(data, str) else data)
165+
del data
166+
temporary.close()
167+
168+
status, output = self.local.run_shell_command(
169+
StringCommand(
170+
self.cmd,
171+
"file",
172+
"push",
173+
filename,
174+
f"{self.remote_instance}/{remote_filename}",
175+
),
176+
print_output=print_output,
177+
print_input=print_input,
178+
)
179+
finally:
180+
if temporary is not None:
181+
unlink(temporary.name)
182+
183+
if not status:
184+
raise IOError(output.stderr)
185+
186+
if print_output:
187+
click.echo(
188+
f"{self.host.print_prefix}file uploaded to instance: {remote_filename}",
189+
err=True,
190+
)
191+
192+
return status
193+
194+
@override
195+
def get_file(
196+
self,
197+
remote_filename: str,
198+
filename_or_io: Union[str, IOBase],
199+
remote_temp_filename: Optional[str] = None,
200+
print_output: bool = False,
201+
print_input: bool = False,
202+
**arguments: Unpack["ConnectorArguments"],
203+
) -> bool:
204+
with NamedTemporaryFile() as temporary:
205+
status, output = self.local.run_shell_command(
206+
StringCommand(
207+
self.cmd,
208+
"file",
209+
"pull",
210+
f"{self.remote_instance}/{remote_filename.lstrip('/')}",
211+
temporary.name,
212+
),
213+
print_output=print_output,
214+
print_input=print_input,
215+
)
216+
# Load the temporary file and write it to our file or IO object
217+
with get_file_io(filename_or_io, "wb") as file_io:
218+
file_io.write(temporary.read())
219+
220+
if not status:
221+
raise IOError(output.stderr)
222+
223+
if print_output:
224+
click.echo(
225+
f"{self.host.print_prefix}file downloaded from instance: {remote_filename}",
226+
err=True,
227+
)
228+
229+
return status
230+
231+
232+
class LXCConnector(IncusConnector):
233+
cmd = "lxc"

setup.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,8 @@ def get_readme_contents():
123123
"local = pyinfra.connectors.local:LocalConnector",
124124
"ssh = pyinfra.connectors.ssh:SSHConnector",
125125
"dockerssh = pyinfra.connectors.dockerssh:DockerSSHConnector",
126+
"incus = pyinfra.connectors.incus:IncusConnector",
127+
"lxc = pyinfra.connectors.incus:LXCConnector",
126128
# Inventory only connectors
127129
"terraform = pyinfra.connectors.terraform:TerraformInventoryConnector",
128130
"vagrant = pyinfra.connectors.vagrant:VagrantInventoryConnector",

0 commit comments

Comments
 (0)