Skip to content

Commit e52ea2d

Browse files
authored
fix: use Multipass-compatible instance names (#712)
Signed-off-by: Callahan Kovacs <callahan.kovacs@canonical.com>
1 parent 61f4f58 commit e52ea2d

File tree

7 files changed

+269
-169
lines changed

7 files changed

+269
-169
lines changed

craft_providers/executor.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,17 @@
1818
"""Executor module."""
1919

2020
import contextlib
21+
import hashlib
2122
import io
2223
import logging
2324
import pathlib
25+
import re
2426
import subprocess
2527
from abc import ABC, abstractmethod
2628
from typing import Dict, Generator, List, Optional
2729

2830
import craft_providers.util.temp_paths
31+
from craft_providers.errors import ProviderError
2932

3033
logger = logging.getLogger(__name__)
3134

@@ -187,3 +190,61 @@ def is_running(self) -> bool:
187190
188191
:returns: True if instance is running.
189192
"""
193+
194+
195+
def get_instance_name(name: str, error_class: type[ProviderError]) -> str:
196+
"""Get an instance-friendly name from a name.
197+
198+
LXD and Multipass instance names have the same naming convention
199+
as Linux hostnames.
200+
201+
Naming convention:
202+
- between 1 and 63 characters long
203+
- made up exclusively of letters, numbers, and hyphens from the ASCII table
204+
- not begin with a digit or a hyphen
205+
- not end with a hyphen
206+
207+
To create an instance name, invalid characters are removed, the name is
208+
truncated to 40 characters, then a hash is appended:
209+
<truncated-name>-<hash-of-name>
210+
└ 1 - 40 ┘1└ 20 ┘
211+
212+
:param name: the name to convert
213+
:param error_class: the exception class to raise if name is invalid
214+
215+
:raises error_class: if name contains no alphanumeric characters
216+
:returns: the instance name
217+
"""
218+
# remove anything that is not an alphanumeric character or hyphen
219+
name_with_valid_chars = re.sub(r"[^\w-]", "", name, flags=re.ASCII)
220+
if not name_with_valid_chars:
221+
raise error_class(
222+
brief=f"failed to create an instance with name {name!r}.",
223+
details="name must contain at least one alphanumeric character",
224+
)
225+
226+
# trim digits and hyphens from the beginning and hyphens from the end
227+
trimmed_name = re.compile(r"^[0-9-]*(?P<valid_name>.*?)[-]*$").search(
228+
name_with_valid_chars
229+
)
230+
if not trimmed_name or not trimmed_name.group("valid_name"):
231+
raise error_class(
232+
brief=f"failed to create an instance with name {name!r}.",
233+
details="name must contain at least one alphanumeric character",
234+
)
235+
valid_name = trimmed_name.group("valid_name")
236+
237+
# if the original name satisfies the naming convention, then use the original name
238+
if name == valid_name and len(name) <= 63:
239+
instance_name = name
240+
241+
# else, continue converting the name
242+
else:
243+
# truncate to 40 characters
244+
truncated_name = valid_name[:40]
245+
# hash the entire name, not the truncated name
246+
hashed_name = hashlib.sha1(name.encode()).hexdigest()[:20]
247+
instance_name = f"{truncated_name}-{hashed_name}"
248+
249+
logger.debug("Converted name %r to instance name %r", name, instance_name)
250+
return instance_name

craft_providers/lxd/lxd_instance.py

Lines changed: 17 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -17,20 +17,18 @@
1717

1818
"""LXD Instance Executor."""
1919

20-
import hashlib
2120
import io
2221
import logging
2322
import os
2423
import pathlib
25-
import re
2624
import shutil
2725
import subprocess
2826
import tempfile
2927
from typing import Any, Dict, List, Optional
3028

3129
from craft_providers.const import TIMEOUT_SIMPLE
3230
from craft_providers.errors import details_from_called_process_error
33-
from craft_providers.executor import Executor
31+
from craft_providers.executor import Executor, get_instance_name
3432
from craft_providers.lxd.errors import LXDError
3533
from craft_providers.lxd.lxc import LXC
3634
from craft_providers.util import env_cmd
@@ -39,7 +37,14 @@
3937

4038

4139
class LXDInstance(Executor):
42-
"""LXD Instance Lifecycle."""
40+
"""Wrapper for a LXD Instance.
41+
42+
:ivar name: The provided name for the instance.
43+
:ivar instance_name: The normalized name actually used for the instance.
44+
:ivar project: The name of the LXD project.
45+
:ivar remote: The name of the LXD remote.
46+
:ivar lxc: The LXC wrapper to use.
47+
"""
4348

4449
def __init__(
4550
self,
@@ -55,11 +60,13 @@ def __init__(
5560
To comply with LXD naming conventions, the supplied name is converted to a
5661
LXD-compatible name before creating the instance.
5762
58-
:param name: Unique name of the lxd instance
59-
:param default_command_environment: command environment
60-
:param project: name of lxd project
61-
:param remote: name of lxd remote
62-
:param lxc: LXC instance object
63+
:param name: The name of the LXDInstance.
64+
:param default_command_environment: The command environment.
65+
:param project: The name of the LXD project.
66+
:param remote: The name of the LXD remote.
67+
:param lxc: The LXC wrapper to use.
68+
69+
:raises LXDError: If the name is invalid.
6370
"""
6471
super().__init__()
6572

@@ -69,7 +76,7 @@ def __init__(
6976
self.default_command_environment = {}
7077

7178
self.name = name
72-
self._set_instance_name()
79+
self.instance_name = get_instance_name(name, LXDError)
7380
self.project = project
7481
self.remote = remote
7582

@@ -78,57 +85,6 @@ def __init__(
7885
else:
7986
self.lxc = lxc
8087

81-
def _set_instance_name(self) -> None:
82-
"""Convert a name to a LXD-compatible name.
83-
84-
LXD naming convention:
85-
- between 1 and 63 characters long
86-
- made up exclusively of letters, numbers, and hyphens from the ASCII table
87-
- not begin with a digit or a hyphen
88-
- not end with a hyphen
89-
90-
To create a LXD-compatible name, invalid characters are removed, the name is
91-
truncated to 40 characters, then a hash is appended:
92-
<truncated-name>-<hash-of-name>
93-
└ 1 - 40 ┘1└ 20 ┘
94-
95-
:param name: name of instance
96-
:raises LXDError: if name contains no alphanumeric characters
97-
"""
98-
# remove anything that is not an alphanumeric characters or hyphen
99-
name_with_valid_chars = re.sub(r"[^\w-]", "", self.name)
100-
if not name_with_valid_chars:
101-
raise LXDError(
102-
brief=f"failed to create LXD instance with name {self.name!r}.",
103-
details="name must contain at least one alphanumeric character",
104-
)
105-
106-
# trim digits and hyphens from the beginning and hyphens from the end
107-
trimmed_name = re.compile(r"^[0-9-]*(?P<valid_name>.*?)[-]*$").search(
108-
name_with_valid_chars
109-
)
110-
if not trimmed_name or not trimmed_name.group("valid_name"):
111-
raise LXDError(
112-
brief=f"failed to create LXD instance with name {self.name!r}.",
113-
details="name must contain at least one alphanumeric character",
114-
)
115-
valid_name = trimmed_name.group("valid_name")
116-
117-
# if the original name meets LXD's naming convention, then use the original name
118-
if self.name == valid_name and len(self.name) <= 63:
119-
instance_name = self.name
120-
121-
# else, continue converting the name
122-
else:
123-
# truncate to 40 characters
124-
truncated_name = valid_name[:40]
125-
# hash the entire name, not the truncated name
126-
hashed_name = hashlib.sha1(self.name.encode()).hexdigest()[:20]
127-
instance_name = f"{truncated_name}-{hashed_name}"
128-
129-
self.instance_name = instance_name
130-
logger.debug("Set LXD instance name to %r", instance_name)
131-
13288
def _finalize_lxc_command(
13389
self,
13490
command: List[str],

craft_providers/multipass/multipass_instance.py

Lines changed: 31 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
from craft_providers.const import TIMEOUT_COMPLEX, TIMEOUT_SIMPLE
2727
from craft_providers.util import env_cmd
2828

29-
from .. import Executor
29+
from ..executor import Executor, get_instance_name
3030
from .errors import MultipassError
3131
from .multipass import Multipass
3232

@@ -59,9 +59,10 @@ def _rootify_multipass_command(
5959

6060

6161
class MultipassInstance(Executor):
62-
"""Multipass Instance Lifecycle.
62+
"""Wrapper for a Multipass instance.
6363
64-
:param name: Name of multipass instance.
64+
:ivar name: The provided name for the instance.
65+
:ivar instance_name: The normalized name actually used for the instance.
6566
"""
6667

6768
def __init__(
@@ -70,9 +71,17 @@ def __init__(
7071
name: str,
7172
multipass: Optional[Multipass] = None,
7273
) -> None:
74+
"""Set up the wrapper class.
75+
76+
:param name: The name of the MultipassInstance.
77+
:param multipass: The Multipass wrapper to use.
78+
79+
:raises MultipassError: If the name is invalid.
80+
"""
7381
super().__init__()
7482

7583
self.name = name
84+
self.instance_name = get_instance_name(name, MultipassError)
7685

7786
if multipass is not None:
7887
self._multipass = multipass
@@ -146,7 +155,7 @@ def push_file_io(
146155
tmp_file_path = self._create_temp_file()
147156

148157
self._multipass.transfer_source_io(
149-
source=content, destination=f"{self.name}:{tmp_file_path}"
158+
source=content, destination=f"{self.instance_name}:{tmp_file_path}"
150159
)
151160

152161
# now that the file has been transferred, its ownership can be set
@@ -174,15 +183,15 @@ def push_file_io(
174183
raise MultipassError(
175184
brief=(
176185
f"Failed to create file {destination.as_posix()!r}"
177-
f" in Multipass instance {self.name!r}."
186+
f" in Multipass instance {self.instance_name!r}."
178187
),
179188
details=errors.details_from_called_process_error(error),
180189
) from error
181190

182191
def delete(self) -> None:
183192
"""Delete instance and purge."""
184193
return self._multipass.delete(
185-
instance_name=self.name,
194+
instance_name=self.instance_name,
186195
purge=True,
187196
)
188197

@@ -215,7 +224,7 @@ def execute_popen(
215224
:returns: Popen instance.
216225
"""
217226
return self._multipass.exec(
218-
instance_name=self.name,
227+
instance_name=self.instance_name,
219228
command=_rootify_multipass_command(command, cwd=cwd, env=env),
220229
runner=subprocess.Popen,
221230
timeout=timeout,
@@ -255,7 +264,7 @@ def execute_run(
255264
:raises subprocess.CalledProcessError: if command fails and check is True.
256265
"""
257266
return self._multipass.exec(
258-
instance_name=self.name,
267+
instance_name=self.instance_name,
259268
command=_rootify_multipass_command(command, cwd=cwd, env=env),
260269
runner=subprocess.run,
261270
timeout=timeout,
@@ -272,7 +281,7 @@ def exists(self) -> bool:
272281
"""
273282
vm_list = self._multipass.list()
274283

275-
return self.name in vm_list
284+
return self.instance_name in vm_list
276285

277286
def _get_info(self) -> Dict[str, Any]:
278287
"""Get configuration and state for instance.
@@ -282,15 +291,15 @@ def _get_info(self) -> Dict[str, Any]:
282291
283292
:raises MultipassError: If unable to parse VM info.
284293
"""
285-
info_data = self._multipass.info(instance_name=self.name).get("info")
294+
info_data = self._multipass.info(instance_name=self.instance_name).get("info")
286295

287-
if info_data is None or self.name not in info_data:
296+
if info_data is None or self.instance_name not in info_data:
288297
raise MultipassError(
289298
brief="Malformed multipass info",
290299
details=f"Returned data: {info_data!r}",
291300
)
292301

293-
return info_data[self.name]
302+
return info_data[self.instance_name]
294303

295304
def is_mounted(
296305
self, *, host_source: pathlib.Path, target: pathlib.PurePath
@@ -349,7 +358,7 @@ def launch(
349358
:raises MultipassError: On unexpected failure.
350359
"""
351360
self._multipass.launch(
352-
instance_name=self.name,
361+
instance_name=self.instance_name,
353362
image=image,
354363
cpus=str(cpus),
355364
disk=f"{disk_gb!s}G",
@@ -376,7 +385,7 @@ def mount(
376385

377386
self._multipass.mount(
378387
source=host_source,
379-
target=f"{self.name}:{target.as_posix()}",
388+
target=f"{self.instance_name}:{target.as_posix()}",
380389
)
381390

382391
def pull_file(self, *, source: pathlib.PurePath, destination: pathlib.Path) -> None:
@@ -402,7 +411,8 @@ def pull_file(self, *, source: pathlib.PurePath, destination: pathlib.Path) -> N
402411
raise FileNotFoundError(f"Directory not found: {str(destination.parent)!r}")
403412

404413
self._multipass.transfer(
405-
source=f"{self.name}:{source.as_posix()}", destination=str(destination)
414+
source=f"{self.instance_name}:{source.as_posix()}",
415+
destination=str(destination),
406416
)
407417

408418
def push_file(self, *, source: pathlib.Path, destination: pathlib.PurePath) -> None:
@@ -441,7 +451,7 @@ def push_file(self, *, source: pathlib.Path, destination: pathlib.PurePath) -> N
441451

442452
# push the file to a location where the `ubuntu` user has access
443453
self._multipass.transfer(
444-
source=str(source), destination=f"{self.name}:{tmp_file_path}"
454+
source=str(source), destination=f"{self.instance_name}:{tmp_file_path}"
445455
)
446456

447457
# if the destination was a directory, then we need to specify the filename
@@ -461,7 +471,7 @@ def push_file(self, *, source: pathlib.Path, destination: pathlib.PurePath) -> N
461471
raise MultipassError(
462472
brief=(
463473
f"Failed to push file {destination.as_posix()!r}"
464-
f" into Multipass instance {self.name!r}."
474+
f" into Multipass instance {self.instance_name!r}."
465475
),
466476
details=errors.details_from_called_process_error(error),
467477
) from error
@@ -471,7 +481,7 @@ def start(self) -> None:
471481
472482
:raises MultipassError: On unexpected failure.
473483
"""
474-
self._multipass.start(instance_name=self.name)
484+
self._multipass.start(instance_name=self.instance_name)
475485

476486
def stop(self, *, delay_mins: int = 0) -> None:
477487
"""Stop instance.
@@ -480,7 +490,7 @@ def stop(self, *, delay_mins: int = 0) -> None:
480490
481491
:raises MultipassError: On unexpected failure.
482492
"""
483-
self._multipass.stop(instance_name=self.name, delay_mins=delay_mins)
493+
self._multipass.stop(instance_name=self.instance_name, delay_mins=delay_mins)
484494

485495
def unmount(self, target: pathlib.Path) -> None:
486496
"""Unmount mount target shared with host.
@@ -489,7 +499,7 @@ def unmount(self, target: pathlib.Path) -> None:
489499
490500
:raises MultipassError: On failure to unmount target.
491501
"""
492-
mount = f"{self.name}:{target.as_posix()}"
502+
mount = f"{self.instance_name}:{target.as_posix()}"
493503

494504
self._multipass.umount(mount=mount)
495505

@@ -498,4 +508,4 @@ def unmount_all(self) -> None:
498508
499509
:raises MultipassError: On failure to unmount target.
500510
"""
501-
self._multipass.umount(mount=self.name)
511+
self._multipass.umount(mount=self.instance_name)

0 commit comments

Comments
 (0)