diff --git a/changelogs/fragments/9743-incus_connection-nonroot-user.yml b/changelogs/fragments/9743-incus_connection-nonroot-user.yml new file mode 100644 index 0000000000..f1a7aa9b5f --- /dev/null +++ b/changelogs/fragments/9743-incus_connection-nonroot-user.yml @@ -0,0 +1,2 @@ +minor_changes: + - incus connection plugin - adds ``remote_user`` and ``incus_become_method`` parameters for allowing a non-root user to connect to an Incus instance (https://github.com/ansible-collections/community.general/pull/9743). diff --git a/plugins/connection/incus.py b/plugins/connection/incus.py index 326e91ec38..842ad8f924 100644 --- a/plugins/connection/incus.py +++ b/plugins/connection/incus.py @@ -32,6 +32,15 @@ options: vars: - name: ansible_executable - name: ansible_incus_executable + incus_become_method: + description: + - Become command used to switch to a non-root user. + - Is only used when O(remote_user) is not V(root). + type: str + default: /bin/su + vars: + - name: incus_become_method + version_added: 10.4.0 remote: description: - The name of the Incus remote to use (per C(incus remote list)). @@ -40,6 +49,22 @@ options: default: local vars: - name: ansible_incus_remote + remote_user: + description: + - User to login/authenticate as. + - Can be set from the CLI via the C(--user) or C(-u) options. + type: string + default: root + vars: + - name: ansible_user + env: + - name: ANSIBLE_REMOTE_USER + ini: + - section: defaults + key: remote_user + keyword: + - name: remote_user + version_added: 10.4.0 project: description: - The name of the Incus project to use (per C(incus project list)). @@ -64,7 +89,6 @@ class Connection(ConnectionBase): transport = "incus" has_pipelining = True - default_user = 'root' def __init__(self, play_context, new_stdin, *args, **kwargs): super(Connection, self).__init__(play_context, new_stdin, *args, **kwargs) @@ -79,10 +103,34 @@ class Connection(ConnectionBase): super(Connection, self)._connect() if not self._connected: - self._display.vvv("ESTABLISH Incus CONNECTION FOR USER: root", + self._display.vvv(f"ESTABLISH Incus CONNECTION FOR USER: {self.get_option('remote_user')}", host=self._instance()) self._connected = True + def _build_command(self, cmd) -> str: + """build the command to execute on the incus host""" + + exec_cmd = [ + self._incus_cmd, + "--project", self.get_option("project"), + "exec", + f"{self.get_option('remote')}:{self._instance()}", + "--"] + + if self.get_option("remote_user") != "root": + self._display.vvv( + f"INFO: Running as non-root user: {self.get_option('remote_user')}, \ + trying to run 'incus exec' with become method: {self.get_option('incus_become_method')}", + host=self._instance(), + ) + exec_cmd.extend( + [self.get_option("incus_become_method"), self.get_option("remote_user"), "-c"] + ) + + exec_cmd.extend([self.get_option("executable"), "-c", cmd]) + + return exec_cmd + def _instance(self): # Return only the leading part of the FQDN as the instance name # as Incus instance names cannot be a FQDN. @@ -95,13 +143,8 @@ class Connection(ConnectionBase): self._display.vvv(f"EXEC {cmd}", host=self._instance()) - local_cmd = [ - self._incus_cmd, - "--project", self.get_option("project"), - "exec", - f"{self.get_option('remote')}:{self._instance()}", - "--", - self._play_context.executable, "-c", cmd] + local_cmd = self._build_command(cmd) + self._display.vvvvv(f"EXEC {local_cmd}", host=self._instance()) local_cmd = [to_bytes(i, errors='surrogate_or_strict') for i in local_cmd] in_data = to_bytes(in_data, errors='surrogate_or_strict', nonstring='passthru') @@ -120,6 +163,25 @@ class Connection(ConnectionBase): return process.returncode, stdout, stderr + def _get_remote_uid_gid(self) -> tuple[int, int]: + """Get the user and group ID of 'remote_user' from the instance.""" + + rc, uid_out, err = self.exec_command("/bin/id -u") + if rc != 0: + raise AnsibleError( + f"Failed to get remote uid for user {self.get_option('remote_user')}: {err}" + ) + uid = uid_out.strip() + + rc, gid_out, err = self.exec_command("/bin/id -g") + if rc != 0: + raise AnsibleError( + f"Failed to get remote gid for user {self.get_option('remote_user')}: {err}" + ) + gid = gid_out.strip() + + return int(uid), int(gid) + def put_file(self, in_path, out_path): """ put a file from local to Incus """ super(Connection, self).put_file(in_path, out_path) @@ -130,12 +192,35 @@ class Connection(ConnectionBase): if not os.path.isfile(to_bytes(in_path, errors='surrogate_or_strict')): raise AnsibleFileNotFound(f"input path is not a file: {in_path}") - local_cmd = [ - self._incus_cmd, - "--project", self.get_option("project"), - "file", "push", "--quiet", - in_path, - f"{self.get_option('remote')}:{self._instance()}/{out_path}"] + if self.get_option("remote_user") != "root": + uid, gid = self._get_remote_uid_gid() + local_cmd = [ + self._incus_cmd, + "--project", + self.get_option("project"), + "file", + "push", + "--uid", + str(uid), + "--gid", + str(gid), + "--quiet", + in_path, + f"{self.get_option('remote')}:{self._instance()}/{out_path}", + ] + else: + local_cmd = [ + self._incus_cmd, + "--project", + self.get_option("project"), + "file", + "push", + "--quiet", + in_path, + f"{self.get_option('remote')}:{self._instance()}/{out_path}", + ] + + self._display.vvvvv(f"PUT {local_cmd}", host=self._instance()) local_cmd = [to_bytes(i, errors='surrogate_or_strict') for i in local_cmd]