"""
Execution module for managing Incus instances.
Provides instance lifecycle (create, delete, start, stop, config, devices) as
thin wrappers over the client seam, plus agentless in-instance state via
:py:func:`call`, :py:func:`sls` and :py:func:`sls_build`, which ship the Salt
thin into an instance over ``incus exec`` and run ``salt-call --local`` there.
No resident minion is required: the thin is shipped per run and staging is
removed on every exit.
:py:func:`sls` has two render strategies via ``precompiled``: the default ships
SLS source and renders in-instance; ``precompiled=True`` compiles the low state
on the control node and ships a self-contained tarball. Pillar is always
resolved on the control node and shipped as a root-only file, never on the
command line.
:depends: incus binary
"""
import json
import logging
import os
import shlex
import shutil
import tarfile
import tempfile
import time
import uuid
import salt.client.ssh.state
import salt.fileclient
import salt.utils.hashutils
import salt.utils.json
import salt.utils.path
import salt.utils.state
import salt.utils.thin
from salt.exceptions import CommandExecutionError
from salt.exceptions import SaltInvocationError
from salt.loader.dunder import __file_client__
from salt.state import HighState
log = logging.getLogger(__name__)
__virtualname__ = "incus"
# Don't shadow the built-in ``list``.
__func_alias__ = {"list_": "list"}
class _UtilsProxy:
"""Expose seam functions in ``__utils__`` as attributes (``proxy.exec_in``)."""
def __init__(self, utils):
self._utils = utils
def __getattr__(self, name):
try:
return self._utils["incus." + name]
except KeyError as err:
raise AttributeError(name) from err
def _seam():
"""
Return the Incus client seam (:py:mod:`incus.utils.incus`).
Imports the seam directly when the extension is installed as a package;
falls back to the copy in ``__utils__`` when the files are synced as custom
modules, where cross-package imports are not possible.
"""
try:
# pylint: disable-next=import-outside-toplevel
from saltext.incus.utils import incus as _incus
except ImportError:
return _UtilsProxy(globals().get("__utils__") or {})
return _incus
# In-instance staging lives on /run (tmpfs, root-only).
_INSTANCE_STAGE_BASE = "/run"
[docs]
def __virtual__():
"""Only load when the ``incus`` binary is present."""
if salt.utils.path.which("incus"):
return __virtualname__
return (False, "the incus binary was not found")
def _config(key, default):
"""Read a layered config value under the ``incus:`` namespace."""
return __salt__["config.get"]("incus:" + key, default)
def _validate_transport(transport):
"""Reject transports other than ``thin`` or ``baked``."""
if transport not in ("thin", "baked"):
raise SaltInvocationError(f"transport must be 'thin' or 'baked', got {transport!r}")
def _normalize_mods(mods):
"""Normalize ``mods`` (list or comma-separated string) to a clean list."""
if isinstance(mods, str):
return [item.strip() for item in mods.split(",") if item.strip()]
return [str(item).strip() for item in (mods or []) if str(item).strip()]
# ----------------------------------------------------------------------------
# Lifecycle
# ----------------------------------------------------------------------------
[docs]
def create(
name,
image,
profiles=None,
config=None,
devices=None,
start=False,
ephemeral=False,
project=None,
):
"""
Create an instance from ``image`` and return its info dict.
name
Instance name.
image
Source image, for example ``images:debian/12``.
profiles
Optional list of profile names to attach. When omitted the Incus
default profile applies. Profile membership is not reconciled after
creation in this version.
config
Optional mapping of instance config keys to set at creation.
devices
Optional mapping of device name to device definition. Each definition
must include a ``type`` key.
start : False
Start the instance after creating it.
ephemeral : False
Create an ephemeral instance (deleted when stopped).
CLI Example:
.. code-block:: bash
salt '*' incus.create web01 images:debian/12 start=True
salt '*' incus.create web01 images:debian/12 config='{boot.autostart: true}'
"""
_seam().create_instance(
name,
image,
profiles=profiles,
config=config,
devices=devices,
ephemeral=ephemeral,
project=project,
)
if start:
_seam().start_instance(name, project=project)
return _seam().query_instance(name, project=project)
[docs]
def delete(name, force=True, project=None):
"""
Delete an instance.
CLI Example:
.. code-block:: bash
salt '*' incus.delete web01
"""
_seam().delete_instance(name, force=force, project=project)
return True
[docs]
def start(name, project=None):
"""
Start an instance.
CLI Example:
.. code-block:: bash
salt '*' incus.start web01
"""
_seam().start_instance(name, project=project)
return True
[docs]
def stop(name, timeout=30, force=False, project=None):
"""
Stop an instance.
CLI Example:
.. code-block:: bash
salt '*' incus.stop web01
"""
_seam().stop_instance(name, timeout=timeout, force=force, project=project)
return True
[docs]
def restart(name, timeout=30, force=False, project=None):
"""
Restart an instance.
CLI Example:
.. code-block:: bash
salt '*' incus.restart web01
"""
_seam().restart_instance(name, timeout=timeout, force=force, project=project)
return True
[docs]
def info(name, project=None):
"""
Return the instance info dict (config, devices, profiles, status) or
``None`` if it does not exist.
CLI Example:
.. code-block:: bash
salt '*' incus.info web01
"""
return _seam().query_instance(name, project=project)
[docs]
def exists(name, project=None):
"""
Return ``True`` if the instance exists.
CLI Example:
.. code-block:: bash
salt '*' incus.exists web01
"""
return _seam().query_instance(name, project=project) is not None
[docs]
def list_(project=None):
"""
Return a list of instance names.
CLI Example:
.. code-block:: bash
salt '*' incus.list
"""
return [item["name"] for item in _seam().list_instances(project=project)]
[docs]
def config_set(name, key, value, project=None):
"""
Set a single instance config key.
CLI Example:
.. code-block:: bash
salt '*' incus.config_set web01 boot.autostart true
"""
_seam().set_config(name, key, value, project=project)
return True
[docs]
def config_unset(name, key, project=None):
"""
Unset a single instance config key.
CLI Example:
.. code-block:: bash
salt '*' incus.config_unset web01 boot.autostart
"""
_seam().unset_config(name, key, project=project)
return True
[docs]
def device_add(name, device_name, device_type, project=None, **options):
"""
Add a device to an instance.
CLI Example:
.. code-block:: bash
salt '*' incus.device_add web01 data disk source=/srv/data path=/data
"""
opts = {key: value for key, value in options.items() if not key.startswith("__")}
_seam().add_device(name, device_name, device_type, options=opts, project=project)
return True
[docs]
def device_remove(name, device_name, project=None):
"""
Remove a device from an instance.
CLI Example:
.. code-block:: bash
salt '*' incus.device_remove web01 data
"""
_seam().remove_device(name, device_name, project=project)
return True
# ----------------------------------------------------------------------------
# In-instance apply: shared helpers
# ----------------------------------------------------------------------------
def _generate_tmp_path():
"""
A unique, root-only staging path inside the instance, on tmpfs.
"""
return os.path.join(_INSTANCE_STAGE_BASE, f"salt.incus.{uuid.uuid4().hex[:8]}")
def _local_stage():
"""
A 0700 staging directory on the control node, on tmpfs when available, with
a restrictive umask so anything written inside is private.
"""
base = "/dev/shm" if os.path.isdir("/dev/shm") else None
old_umask = os.umask(0o077)
try:
return tempfile.mkdtemp(prefix="incus-sls.", dir=base)
finally:
os.umask(old_umask)
def _file_client():
"""
Return a file client, reusing the loader's if one is set.
"""
if __file_client__:
return __file_client__.value()
return salt.fileclient.get_file_client(__opts__)
def _detect_python(name, project=None):
"""
Find a usable Python interpreter inside the instance.
"""
for candidate in ("python3", "/usr/libexec/platform-python", "python"):
ret = _seam().exec_in(name, [candidate, "--version"], project=project)
if ret["retcode"] == 0:
return candidate
raise CommandExecutionError(f"no Python 3 interpreter found inside instance '{name}'")
def _parse_salt_call(stdout):
"""
Parse ``salt-call --metadata --out json`` output into ``(return, retcode)``.
"""
try:
data = salt.utils.json.find_json(stdout)
except ValueError:
return None, None
local = data.get("local", data) if isinstance(data, dict) else data
if isinstance(local, dict):
return local.get("return", local), local.get("retcode")
return local, None
def _run_in_instance(name, inst_dir, salt_argv, project=None, cleanup=True):
"""
Run a ``salt-call`` invocation inside the instance, returning
``(return, retcode)``.
With ``cleanup`` set, the command is wrapped in a shell ``trap`` so staging
is removed on exit even if the process is killed.
"""
joined = " ".join(shlex.quote(str(arg)) for arg in salt_argv)
if cleanup:
script = f"trap 'rm -rf {shlex.quote(inst_dir)}' EXIT; {joined}"
else:
script = joined
ret = _seam().exec_in(name, ["sh", "-c", script], project=project)
return_value, retcode = _parse_salt_call(ret["stdout"])
if return_value is None and ret["retcode"] != 0:
raise CommandExecutionError(
"salt-call inside instance '{}' failed (rc={}): {}".format(
name, ret["retcode"], ret["stderr"] or ret["stdout"]
)
)
if retcode is None:
retcode = ret["retcode"]
return return_value, retcode
def _wait_for_exec(name, project=None, timeout=60, interval=1):
"""
Block until the instance can run a trivial command, or raise on timeout.
Used after starting a freshly created instance (``sls_build``) so the first
``exec`` does not race instance start-up.
"""
deadline = time.time() + timeout
while True:
ret = _seam().exec_in(name, ["true"], project=project)
if ret["retcode"] == 0:
return True
if time.time() >= deadline:
raise CommandExecutionError(
f"instance '{name}' did not become ready for exec within {timeout}s"
)
time.sleep(interval)
def _gen_thin():
"""Generate the Salt thin tarball."""
return salt.utils.thin.gen_thin(
__opts__["cachedir"],
extra_mods=_config("thin_extra_mods", ""),
so_mods=_config("thin_so_mods", ""),
)
def _ship_thin(name, inst_dir, python_bin, project=None):
"""
Push the thin tarball into ``inst_dir`` and extract it there. Returns the
path to the extracted ``salt-call`` entrypoint.
"""
thin_path = _gen_thin()
remote = os.path.join(inst_dir, os.path.basename(thin_path))
_seam().push_file(name, thin_path, remote, mode="0600", project=project)
untar = [
python_bin,
"-c",
f'import tarfile; tarfile.open("{remote}").extractall(path="{inst_dir}")',
]
ret = _seam().exec_in(name, untar, project=project)
if ret["retcode"] != 0:
raise CommandExecutionError(
"could not unpack thin in instance '{}': {}".format(name, ret["stderr"])
)
return os.path.join(inst_dir, "salt-call")
# ----------------------------------------------------------------------------
# In-instance apply: call
# ----------------------------------------------------------------------------
[docs]
def call(name, function, *args, project=None, transport="thin", **kwargs):
"""
Run a single Salt execution function inside an instance.
The instance does not need Salt installed (with ``transport='thin'``, the
default); it only needs a Python 3 interpreter. With ``transport='baked'``
the instance's own ``salt-call`` is used and no thin is shipped.
CLI Example:
.. code-block:: bash
salt '*' incus.call web01 test.ping
salt '*' incus.call web01 cmd.run 'id -un'
"""
if function is None:
raise CommandExecutionError("Missing function parameter")
_validate_transport(transport)
inst_dir = _generate_tmp_path()
ret = _seam().exec_in(name, ["mkdir", "-p", "-m", "0700", inst_dir], project=project)
if ret["retcode"] != 0:
raise CommandExecutionError(
"could not create staging dir in instance '{}': {}".format(name, ret["stderr"])
)
try:
python_bin = _detect_python(name, project=project)
if transport == "baked":
salt_argv = ["salt-call"]
else:
salt_call = _ship_thin(name, inst_dir, python_bin, project=project)
salt_argv = [python_bin, salt_call]
salt_argv += [
"--metadata",
"--local",
"--log-file",
os.path.join(inst_dir, "log"),
"--cachedir",
os.path.join(inst_dir, "cache"),
"--out",
"json",
"-l",
"quiet",
"--retcode-passthrough",
"--",
function,
]
salt_argv += [str(arg) for arg in args]
salt_argv += [f"{key}={value}" for key, value in kwargs.items() if not key.startswith("__")]
return_value, retcode = _run_in_instance(
name, inst_dir, salt_argv, project=project, cleanup=True
)
if retcode is not None:
__context__["retcode"] = retcode
return return_value
finally:
_seam().delete_path(name, inst_dir, project=project)
# ----------------------------------------------------------------------------
# In-instance apply: sls (source strategy)
# ----------------------------------------------------------------------------
def _stage_sls_source(states_dir, mods, saltenv):
"""
Copy the SLS source for ``mods`` into ``states_dir``.
Ships each mod's whole top-level component (so ``access.users`` pulls all of
``salt://access/``), covering ``include:`` and ``map.jinja``. File sources in
other components are not shipped; use ``precompiled=True`` for those.
"""
components = {mod.split(".")[0] for mod in mods}
sls_files = {mod.replace(".", "/") + ".sls" for mod in mods}
component_sls = {component + ".sls" for component in components}
available = __salt__["cp.list_master"](saltenv) or []
selected = set()
for relpath in available:
seg0 = relpath.split("/")[0]
if seg0 in components or relpath in sls_files or relpath in component_sls:
selected.add(relpath)
if not selected:
raise CommandExecutionError(f"no SLS source found for mods {mods} in saltenv '{saltenv}'")
for relpath in sorted(selected):
cached = __salt__["cp.cache_file"]("salt://" + relpath, saltenv)
if not cached:
continue
dest = os.path.join(states_dir, *relpath.split("/"))
os.makedirs(os.path.dirname(dest), exist_ok=True)
shutil.copyfile(cached, dest)
def _stage_pillar(pillar_dir, pillar):
"""
Write the resolved pillar as a root-only file tree. The ``#!json`` renderer
sidesteps any Jinja/YAML ambiguity in pillar values.
"""
if not pillar:
return
with open(os.path.join(pillar_dir, "top.sls"), "w", encoding="utf-8") as fh:
fh.write("#!json\n")
json.dump({"base": {"*": ["incus_pillar"]}}, fh)
with open(os.path.join(pillar_dir, "incus_pillar.sls"), "w", encoding="utf-8") as fh:
fh.write("#!json\n")
json.dump(pillar, fh)
def _make_stage_tar(stage_dir):
"""
Tar the contents of ``stage_dir`` (top-level entries become root entries)
so a single push and a single in-instance extraction reproduce the layout.
"""
tar_path = stage_dir + ".tgz"
with tarfile.open(tar_path, "w:gz") as tar:
for entry in sorted(os.listdir(stage_dir)):
tar.add(os.path.join(stage_dir, entry), arcname=entry)
return tar_path
def _sls_source(name, mods, saltenv, pillar, test, transport, pillar_mode, cleanup, project):
"""Apply ``mods`` by shipping SLS source and running ``state.apply`` in-instance."""
if pillar_mode != "file":
raise SaltInvocationError(
"incus supports pillar_mode='file' only; passing pillar on the "
"command line is intentionally unsupported"
)
inst_dir = _generate_tmp_path()
stage = _local_stage()
stage_tar = None
try:
ret = _seam().exec_in(name, ["mkdir", "-p", "-m", "0700", inst_dir], project=project)
if ret["retcode"] != 0:
raise CommandExecutionError(
"could not create staging dir in instance '{}': {}".format(name, ret["stderr"])
)
python_bin = _detect_python(name, project=project)
states_dir = os.path.join(stage, "states")
pillar_dir = os.path.join(stage, "pillar")
conf_dir = os.path.join(stage, "conf")
for directory in (states_dir, pillar_dir, conf_dir):
os.makedirs(directory, mode=0o700, exist_ok=True)
_stage_sls_source(states_dir, mods, saltenv)
_stage_pillar(pillar_dir, pillar)
# Masterless minion config points at the in-instance paths under inst_dir.
minion_conf = {
"file_client": "local",
"fileserver_backend": ["roots"],
"file_roots": {"base": [os.path.join(inst_dir, "states")]},
"pillar_roots": {"base": [os.path.join(inst_dir, "pillar")]},
"cachedir": os.path.join(inst_dir, "cache"),
"log_file": os.path.join(inst_dir, "salt.log"),
"log_level": "quiet",
"log_level_logfile": "quiet",
}
with open(os.path.join(conf_dir, "minion"), "w", encoding="utf-8") as fh:
json.dump(minion_conf, fh)
thin_basename = None
if transport != "baked":
thin_path = _gen_thin()
thin_basename = os.path.basename(thin_path)
shutil.copyfile(thin_path, os.path.join(stage, thin_basename))
stage_tar = _make_stage_tar(stage)
remote_tar = os.path.join(inst_dir, "stage.tgz")
_seam().push_file(name, stage_tar, remote_tar, mode="0600", project=project)
extract = [
python_bin,
"-c",
f'import tarfile; tarfile.open("{remote_tar}").extractall(path="{inst_dir}")',
]
ret = _seam().exec_in(name, extract, project=project)
if ret["retcode"] != 0:
raise CommandExecutionError(
"could not unpack staging tarball in instance '{}': {}".format(name, ret["stderr"])
)
_seam().exec_in(name, ["chmod", "-R", "go-rwx", inst_dir], project=project)
if transport == "baked":
salt_call = ["salt-call"]
else:
thin_dir = os.path.join(inst_dir, "thin")
untar = [
python_bin,
"-c",
'import tarfile; tarfile.open("{}").extractall(path="{}")'.format(
os.path.join(inst_dir, thin_basename), thin_dir
),
]
ret = _seam().exec_in(name, untar, project=project)
if ret["retcode"] != 0:
raise CommandExecutionError(
"could not unpack thin in instance '{}': {}".format(name, ret["stderr"])
)
salt_call = [python_bin, os.path.join(thin_dir, "salt-call")]
salt_argv = salt_call + [
"--config-dir",
os.path.join(inst_dir, "conf"),
"--local",
"--metadata",
"--out",
"json",
"-l",
"quiet",
"--retcode-passthrough",
"state.apply",
",".join(mods),
]
if test:
salt_argv.append("test=True")
return_value, _ = _run_in_instance(
name, inst_dir, salt_argv, project=project, cleanup=cleanup
)
return return_value
finally:
shutil.rmtree(stage, ignore_errors=True)
if stage_tar:
try:
os.remove(stage_tar)
except OSError:
pass
if cleanup:
_seam().delete_path(name, inst_dir, project=project)
# ----------------------------------------------------------------------------
# In-instance apply: sls (precompiled strategy)
# ----------------------------------------------------------------------------
def _compile_state(sls_opts, mods):
"""
Compile the requested ``mods`` to low-state chunks on the control node.
Returns the list of chunks, or a list of error strings if compilation
failed. Adapted from ``docker.sls``.
"""
with HighState(sls_opts) as st_:
if not mods:
return st_.compile_low_chunks()
high_data, errors = st_.render_highstate({sls_opts["saltenv"]: mods})
high_data, ext_errors = st_.state.reconcile_extend(high_data)
errors += ext_errors
errors += st_.state.verify_high(high_data)
if errors:
return errors
high_data, req_in_errors = st_.state.requisite_in(high_data)
errors += req_in_errors
high_data = st_.state.apply_exclude(high_data)
if errors:
return errors
return st_.state.compile_high_data(high_data)
def _prepare_trans_tar(sls_opts, mods, pillar, extra_filerefs=""):
"""
Build a self-contained state tarball (compiled chunks + referenced files +
pillar) using the salt-ssh state machinery.
"""
chunks = _compile_state(sls_opts, mods)
if not chunks:
raise CommandExecutionError(f"no state was compiled for mods {mods}")
if isinstance(chunks, list) and chunks and isinstance(chunks[0], str):
raise CommandExecutionError(
"state compilation failed: {}".format("; ".join(str(item) for item in chunks))
)
refs = salt.client.ssh.state.lowstate_file_refs(chunks, extra_filerefs)
with _file_client() as fileclient:
return salt.client.ssh.state.prep_trans_tar(
fileclient, chunks, refs, pillar or {}, __opts__["id"]
)
def _sls_precompiled(
name, mods, saltenv, pillar, test, transport, cleanup, project, extra_filerefs
):
"""Apply ``mods`` by compiling low state on the control node and shipping a tarball."""
# Gather the instance's grains so host-side rendering matches the instance.
grains = call(name, "grains.items", project=project, transport=transport)
sls_opts = salt.utils.state.get_sls_opts(__opts__, saltenv=saltenv)
if isinstance(grains, dict):
sls_opts["grains"].update(grains)
if pillar:
sls_opts["pillar"].update(pillar)
trans_tar = _prepare_trans_tar(sls_opts, mods, pillar, extra_filerefs)
try:
trans_sha = salt.utils.hashutils.get_hash(trans_tar, "sha256")
tar_dir = _generate_tmp_path()
ret = _seam().exec_in(name, ["mkdir", "-p", "-m", "0700", tar_dir], project=project)
if ret["retcode"] != 0:
raise CommandExecutionError(
"could not create staging dir in instance '{}': {}".format(name, ret["stderr"])
)
remote_tar = os.path.join(tar_dir, "salt_state.tgz")
_seam().push_file(name, trans_tar, remote_tar, mode="0600", project=project)
try:
return call(
name,
"state.pkg",
remote_tar,
trans_sha,
"sha256",
test=test,
project=project,
transport=transport,
)
finally:
if cleanup:
_seam().delete_path(name, tar_dir, project=project)
finally:
try:
os.remove(trans_tar)
except OSError:
pass
[docs]
def sls(
name,
mods,
saltenv="base",
pillar=None,
test=False,
transport="thin",
pillar_mode="file",
precompiled=False,
cleanup=True,
project=None,
extra_filerefs="",
):
"""
Apply the states in ``mods`` inside an instance and return the highstate
result dict.
name
Instance name.
mods
SLS modules to apply, as a list or a comma-separated string.
saltenv : base
Environment to pull the SLS / compile against on the control node.
pillar
Pillar to use, already resolved on the control node (decrypt GPG/Vault
values before passing them here). Shipped as a root-only file, never on
the command line.
test : False
Run in test mode; nothing is changed inside the instance.
transport : thin
``thin`` ships the Salt thin per run (the instance needs only Python 3).
``baked`` uses the instance's own ``salt-call``.
pillar_mode : file
How pillar is delivered. Only ``file`` is supported.
precompiled : False
``False`` ships SLS source and runs ``state.apply`` in-instance.
``True`` compiles low state on the control node and applies it with
``state.pkg`` so no source reaches the instance.
cleanup : True
Remove the in-instance staging directory after the run. Set ``False``
to leave it in place for debugging.
CLI Example:
.. code-block:: bash
salt '*' incus.sls web01 mods=access.users,access.sshd
salt '*' incus.sls web01 mods=hardening precompiled=True test=True
"""
_validate_transport(transport)
mods = _normalize_mods(mods)
if not mods:
raise SaltInvocationError("at least one SLS module is required")
if precompiled:
result = _sls_precompiled(
name, mods, saltenv, pillar, test, transport, cleanup, project, extra_filerefs
)
else:
result = _sls_source(
name, mods, saltenv, pillar, test, transport, pillar_mode, cleanup, project
)
if not isinstance(result, dict):
__context__["retcode"] = 1
elif not salt.utils.state.check_result(result):
__context__["retcode"] = 2
else:
__context__["retcode"] = 0
return result
# ----------------------------------------------------------------------------
# In-instance apply: sls_build
# ----------------------------------------------------------------------------
[docs]
def sls_build(
name,
base,
mods,
project=None,
public=False,
saltenv="base",
pillar=None,
test=False,
transport="thin",
precompiled=False,
cleanup=True,
):
"""
Build an image by applying states to a throwaway instance, then publishing.
A temporary instance is launched from ``base``, ``mods`` are applied inside
it with :py:func:`sls`, the instance is stopped and published to a local
image aliased ``name``, and the temporary instance is always deleted.
name
Alias for the resulting image.
base
Source image to build from, for example ``images:debian/12``.
mods
SLS modules to apply, as a list or comma-separated string.
test : False
Compile and apply in test mode and skip publishing (a dry run).
CLI Example:
.. code-block:: bash
salt '*' incus.sls_build mycorp/web base=images:debian/12 mods=web,hardening
"""
_validate_transport(transport)
build_name = f"salt-build-{uuid.uuid4().hex[:8]}"
created = False
running = False
try:
_seam().create_instance(build_name, base, project=project)
created = True
_seam().start_instance(build_name, project=project)
running = True
_wait_for_exec(build_name, project=project)
result = sls(
build_name,
mods,
saltenv=saltenv,
pillar=pillar,
test=test,
transport=transport,
precompiled=precompiled,
cleanup=cleanup,
project=project,
)
if not (isinstance(result, dict) and salt.utils.state.check_result(result)):
raise CommandExecutionError(
"state application failed during sls_build; image not published"
)
if test:
return {"result": result, "published": False, "comment": "test mode: not published"}
_seam().stop_instance(build_name, project=project)
running = False
published = _seam().publish(build_name, alias=name, public=public, project=project)
return {"result": result, "published": published}
finally:
if running:
try:
_seam().stop_instance(build_name, force=True, project=project)
except CommandExecutionError:
log.error("sls_build: could not stop build instance '%s'", build_name)
if created:
try:
_seam().delete_instance(build_name, force=True, project=project)
except CommandExecutionError:
log.error("sls_build: could not delete build instance '%s'", build_name)