You've already forked pgbackrest
mirror of
https://github.com/pgbackrest/pgbackrest.git
synced 2026-10-28 10:39:37 +03:00
Most of these are prose in a python docstring or comment wrapped a few characters wide, which black does not reflow, and macro continuation backslashes left stranded past the margin. The rest are long calls broken after the first parenthesis, section separator comments with too many dashes, and long expectation strings split along what they hold. A few long shell commands in the workflows are wrapped with continuations. hrnTestResultDiff() had a continuation backslash left over from when it was a macro, which spliced the closing brace onto the line above it. It is removed. The linter that enforces the limit follows, so this commit is only the reformatting and can be ignored by blame.
686 lines
30 KiB
Python
686 lines
30 KiB
Python
"""Document Execution.
|
|
|
|
Runs what the user guide says to run. A document is a sequence of commands against hosts, so building it is running them in order
|
|
and keeping what they wrote, which is what a reader sees.
|
|
|
|
Running them takes long enough that the result is cached. A cache entry is keyed by everything that decides what the command would
|
|
be, so replaying the cache checks as it goes that the document still describes the same run. When a key no longer matches, the cache
|
|
no longer describes the document and the whole document is built again.
|
|
|
|
Configuration is handled here rather than in a renderer because a configuration file is built up across a document: a section adds
|
|
an option and a later section shows the file with everything added so far. That also makes this the only place that knows what the
|
|
file looked like the last time the document showed it, so it is here that each line is marked with what the section changed."""
|
|
|
|
####################################################################################################################################
|
|
import copy
|
|
import difflib
|
|
import json
|
|
import os
|
|
import re
|
|
|
|
from common.error import ToolError
|
|
from common.exec import exec_result
|
|
from common.ini import ini_render
|
|
from common.log import *
|
|
from common.storage import file_read, file_write
|
|
from common.user import user_name
|
|
from common.xml import (
|
|
xml_node_attribute,
|
|
xml_node_child_list,
|
|
xml_node_field,
|
|
)
|
|
from command.render.host import Host, HostGroup
|
|
from command.render.render import DocRender
|
|
|
|
# Where a command that is split over more than one line breaks, and what it looks like when it does
|
|
_CMD_SPLIT_EXP = re.compile(r"[ ]*\\?[ ]*\n[ ]*")
|
|
_CMD_CONTINUE = " \\\n "
|
|
|
|
# How long a line of a command may be before it is too wide to read
|
|
_CMD_LINE_LEN = 80
|
|
|
|
# Options that are set so the build can watch what happens but are not part of what the documentation is showing
|
|
_CONFIG_SECTION_GLOBAL = "global"
|
|
_CONFIG_HIDE_LIST = ("log-level-stderr", "log-timestamp")
|
|
|
|
# What a line of a configuration file is marked with, i.e. what the change the document is showing did to it
|
|
CONFIG_MARK_ADD = "add"
|
|
CONFIG_MARK_REMOVE = "remove"
|
|
CONFIG_MARK_SAME = "same"
|
|
|
|
# Linefeeds around what a command wrote, which are how the output ends rather than part of it
|
|
_OUTPUT_TRIM_EXP = re.compile(r"^\n+|\n$")
|
|
|
|
# What is shown in place of output when the commands are not being run
|
|
_SUPPRESS_OUTPUT = "Output suppressed for testing"
|
|
_SUPPRESS_CONFIG = "Config suppressed for testing"
|
|
|
|
|
|
####################################################################################################################################
|
|
def doc_user():
|
|
"""The user building the documentation, which is the user a command runs as unless it says otherwise."""
|
|
|
|
return "ubuntu" if user_name() == "root" else user_name()
|
|
|
|
|
|
####################################################################################################################################
|
|
class CacheInvalidError(ToolError):
|
|
"""The cache no longer describes the document, so the document has to be built again."""
|
|
|
|
|
|
####################################################################################################################################
|
|
class DocExecute(DocRender):
|
|
"""Renders a document, running the commands it holds."""
|
|
|
|
def __init__(self, type, manifest, key, exe):
|
|
super().__init__(type, manifest, exe, key)
|
|
|
|
self.cache = self.source.cache_list is not None
|
|
self.cache_idx = 0
|
|
|
|
self.host_map = {}
|
|
self.config_map = {}
|
|
self.pg_config_map = {}
|
|
self.config_show_map = {} # Each configuration file as the document last showed it, which a change is marked against
|
|
|
|
self.cmd_line_len = int(xml_node_attribute(self.root, "cmd-line-len") or _CMD_LINE_LEN)
|
|
|
|
################################################################################################################################
|
|
def _replace(self, string):
|
|
"""Replace every variable in a string."""
|
|
|
|
return self.manifest.var_store.replace_str(string)
|
|
|
|
################################################################################################################################
|
|
def execute_key(self, host_name, command):
|
|
"""Everything that decides what a command would be, which is what the cache is keyed by."""
|
|
|
|
# A command runs as the user building the documentation unless it says otherwise, and a user it names is reached with sudo
|
|
# unless the command is showing what running as that user looks like
|
|
user_force = xml_node_attribute(command, "user-force") == "y"
|
|
user = self._replace(xml_node_attribute(command, "user") or doc_user())
|
|
cmd = self._replace(xml_node_field(command, "exe-cmd", True).strip())
|
|
|
|
if user != doc_user() and not user_force:
|
|
cmd = "sudo %s%s" % ("" if user == "root" else "-u %s " % user, cmd)
|
|
|
|
# A command written over more than one line is shown that way, with a backslash where it breaks
|
|
cmd = _CMD_SPLIT_EXP.sub(lambda _: _CMD_CONTINUE, cmd)
|
|
cmd = cmd.replace(" \\@ \\", "")
|
|
|
|
key = {
|
|
"host": host_name,
|
|
"cmd": cmd.split("\n"),
|
|
"output": False,
|
|
"run-as-user": user if user_force else None,
|
|
}
|
|
|
|
extra = xml_node_field(command, "exe-cmd-extra")
|
|
|
|
if extra is not None:
|
|
key["cmd-extra"] = self._replace(extra)
|
|
|
|
err_expect = xml_node_attribute(command, "err-expect")
|
|
|
|
if err_expect is not None:
|
|
key["err-expect"] = err_expect
|
|
|
|
# Output is kept when the documentation shows it or takes a variable from it
|
|
if (
|
|
xml_node_attribute(command, "output") == "y"
|
|
or xml_node_attribute(command, "show") == "y"
|
|
or xml_node_attribute(command, "variable-key") is not None
|
|
):
|
|
key["output"] = True
|
|
|
|
key["load-env"] = xml_node_attribute(command, "load-env") != "n"
|
|
key["bash-wrap"] = xml_node_attribute(command, "bash-wrap") != "n"
|
|
|
|
highlight = xml_node_field(command, "exe-highlight")
|
|
|
|
if highlight is not None:
|
|
key["output"] = True
|
|
key["highlight"] = {
|
|
"filter": xml_node_attribute(command, "filter") != "n",
|
|
"filter-context": xml_node_attribute(command, "filter-context") or 2,
|
|
"list": [self._replace(highlight)],
|
|
}
|
|
|
|
return key
|
|
|
|
################################################################################################################################
|
|
def _output_filter(self, output, highlight, filter_context):
|
|
"""Keep the lines of the output that matter and say how many were dropped.
|
|
|
|
A command can write a great deal and the documentation is showing one thing in it, so what is kept is the lines that match
|
|
with a little either side."""
|
|
|
|
line_list = output.split("\n")
|
|
expression = re.compile(highlight)
|
|
result = None
|
|
last = -1
|
|
|
|
def add(text):
|
|
return text if result is None else result + "\n" + text
|
|
|
|
for idx, line in enumerate(line_list):
|
|
if expression.search(line) is None:
|
|
continue
|
|
|
|
# Do not go back past the start, or repeat lines that have already been kept
|
|
first = max(idx - filter_context, 0)
|
|
first = last + 1 if first <= last else first
|
|
end = min(idx + filter_context, len(line_list) - 1)
|
|
|
|
if first > last + 1:
|
|
dropped = first - (last + 1)
|
|
|
|
# One dropped line is not worth saying so, and takes as much room to say as to show
|
|
if dropped > 1:
|
|
result = add(" [filtered %d lines of output]" % dropped)
|
|
else:
|
|
first -= 1
|
|
|
|
for keep in range(first, end + 1):
|
|
result = add(line_list[keep])
|
|
|
|
last = end
|
|
|
|
dropped = (len(line_list) - 1) - (last + 1)
|
|
|
|
if dropped > 0:
|
|
result = add(" [filtered %d lines of output]" % dropped if dropped > 1 else line_list[-1])
|
|
|
|
return result
|
|
|
|
################################################################################################################################
|
|
def execute(self, section, host_name, command, indent=1, cache=True, show=True):
|
|
"""Run a command and return what was run and what it wrote."""
|
|
|
|
key = self.execute_key(host_name, command)
|
|
cmd = "\n".join(key["cmd"])
|
|
output = None
|
|
|
|
if show and self.exe and self.is_required(section) and xml_node_attribute(command, "show") != "n":
|
|
# A line that is too wide to read is an error, since the documentation is showing it to be read. A command that is not
|
|
# shown is never read, so how wide it is does not matter. A command may raise the limit for itself when it holds
|
|
# something that cannot be broken, e.g. a url.
|
|
line_len = int(xml_node_attribute(command, "cmd-line-len") or self.cmd_line_len)
|
|
|
|
for line in cmd.split("\n"):
|
|
if len(line.strip()) > line_len:
|
|
raise ToolError("command has a line > %d characters:\n%s\noffending line: %s" % (line_len, cmd, line))
|
|
|
|
log(DEBUG, " " * indent + "execute: %s" % cmd)
|
|
|
|
if self._replace(xml_node_attribute(command, "skip") or "n") != "y":
|
|
if self.exe and self.is_required(section):
|
|
hit, cache_type, key, value = self.cache_pop("exe", key)
|
|
|
|
if hit:
|
|
output = None if value.get("output") is None else "\n".join(value["output"])
|
|
else:
|
|
output = self._execute_run(host_name, command, key, cmd)
|
|
|
|
if cache:
|
|
self.cache_push(cache_type, key, None if output is None else {"output": output.split("\n")})
|
|
|
|
# A command the documentation takes a variable from sets it from what the command wrote
|
|
if xml_node_attribute(command, "variable-key") is not None:
|
|
self.manifest.var_store.set(xml_node_attribute(command, "variable-key"), (output or "").strip())
|
|
elif key["output"]:
|
|
output = _SUPPRESS_OUTPUT
|
|
|
|
# A variable the documentation takes from a command still has to have a value when the command was not run
|
|
variable_key = xml_node_attribute(command, "variable-key")
|
|
|
|
if variable_key is not None and self.manifest.var_store.get(variable_key) is None:
|
|
self.manifest.var_store.set(variable_key, "[Test Variable]")
|
|
|
|
return cmd, output
|
|
|
|
################################################################################################################################
|
|
def _execute_run(self, host_name, command, key, cmd):
|
|
"""Run a command on a host and return what it wrote."""
|
|
|
|
host = self.host_map.get(host_name)
|
|
|
|
if host is None:
|
|
raise ToolError("cannot execute on host %s because the host does not exist" % host_name)
|
|
|
|
status, output, error = host.execute(
|
|
cmd + ("" if key.get("cmd-extra") is None else " " + key["cmd-extra"]),
|
|
user=key["run-as-user"],
|
|
load_env=key["load-env"],
|
|
bash_wrap=key["bash-wrap"],
|
|
status_expect=int(key["err-expect"]) if key.get("err-expect") is not None else 0,
|
|
suppress_error=xml_node_attribute(command, "err-suppress") == "y",
|
|
retry=int(xml_node_attribute(command, "retry")) if xml_node_attribute(command, "retry") is not None else None,
|
|
)
|
|
|
|
result = _OUTPUT_TRIM_EXP.sub("", output) if output != "" else None
|
|
|
|
# A command that is expected to fail is showing the error, so the error is part of what it wrote
|
|
if key.get("err-expect") is not None and error != "":
|
|
result = (result or "") + error
|
|
|
|
if key["output"] and key.get("highlight") is not None and key["highlight"]["filter"] and result is not None:
|
|
result = self._output_filter(result, key["highlight"]["list"][0], int(key["highlight"]["filter-context"]))
|
|
|
|
return result if key["output"] else None
|
|
|
|
################################################################################################################################
|
|
def config_key(self, config):
|
|
"""Everything that decides what a configuration change would be."""
|
|
|
|
key = {
|
|
"host": self._replace(xml_node_attribute(config, "host", True)),
|
|
"file": self._replace(xml_node_attribute(config, "file", True)),
|
|
}
|
|
|
|
if xml_node_attribute(config, "reset") == "y":
|
|
key["reset"] = True
|
|
|
|
backrest = config.tag == "backrest-config"
|
|
option_map = {}
|
|
|
|
for option in xml_node_child_list(config, "%s-option" % config.tag):
|
|
entry = {}
|
|
|
|
if xml_node_attribute(option, "remove") == "y":
|
|
entry["remove"] = True
|
|
|
|
if option.text is not None and option.text.strip() != "":
|
|
entry["value"] = self._replace(option.text)
|
|
|
|
name = self._replace(xml_node_attribute(option, "key", True))
|
|
|
|
if backrest:
|
|
option_map.setdefault(self._replace(xml_node_attribute(option, "section", True)), {})[name] = entry
|
|
else:
|
|
option_map[name] = entry
|
|
|
|
key["option"] = option_map
|
|
|
|
return key
|
|
|
|
################################################################################################################################
|
|
def _config_mark(self, section, key, config, show):
|
|
"""Mark each line of a configuration file with what the change the document is showing did to it.
|
|
|
|
A file is shown in full every time it changes, so a change of one option in a file that has grown to fifty leaves the reader
|
|
to find which one it was. The file is compared against the file as the document last showed it, which is not the same as the
|
|
file as it was: a change the document did not show is left for the next change that is shown, so that nothing the reader is
|
|
shown goes unmarked. A file that was reset has nothing to compare against and is shown as all new.
|
|
|
|
A change that is shown but leaves the file as the reader last saw it is an error, since the reader is given a file to read
|
|
and nothing in it to find. The change belongs where it changes something, or nowhere."""
|
|
|
|
file_key = (key["host"], key["file"])
|
|
line_list = [] if config is None else config.rstrip("\n").split("\n")
|
|
prior_list = [] if key.get("reset") else self.config_show_map.get(file_key, [])
|
|
result = []
|
|
|
|
matcher = difflib.SequenceMatcher(None, prior_list, line_list, autojunk=False)
|
|
|
|
for tag, prior_begin, prior_end, begin, end in matcher.get_opcodes():
|
|
if tag in ("delete", "replace"):
|
|
result += [(CONFIG_MARK_REMOVE, line) for line in prior_list[prior_begin:prior_end]]
|
|
|
|
if tag in ("insert", "replace"):
|
|
result += [(CONFIG_MARK_ADD, line) for line in line_list[begin:end]]
|
|
|
|
if tag == "equal":
|
|
result += [(CONFIG_MARK_SAME, line) for line in line_list[begin:end]]
|
|
|
|
if show:
|
|
if not any(mark != CONFIG_MARK_SAME for mark, _ in result):
|
|
raise ToolError(
|
|
"config %s:%s in section %s is shown but changes nothing since it was last shown"
|
|
% (key["host"], key["file"], xml_node_attribute(section, "path"))
|
|
)
|
|
|
|
self.config_show_map[file_key] = line_list
|
|
|
|
return result
|
|
|
|
################################################################################################################################
|
|
def backrest_config(self, section, config, depth):
|
|
"""Apply a change to the configuration and return the file, the lines of it and how each changed, and whether to show it."""
|
|
|
|
key = self.config_key(config)
|
|
file = key["file"]
|
|
show = xml_node_attribute(config, "show") != "n"
|
|
|
|
log(DEBUG, " " * depth + "process backrest config: %s" % file)
|
|
|
|
if self.exe and self.is_required(section):
|
|
hit, cache_type, key, value = self.cache_pop("cfg-pgbackrest", key)
|
|
|
|
if hit:
|
|
result = None if value.get("config") is None else "\n".join(value["config"])
|
|
else:
|
|
result = self._backrest_config_apply(config, key, depth)
|
|
self.cache_push(cache_type, key, {"config": None if result is None else result.split("\n")})
|
|
|
|
result = self._config_mark(section, key, result, show)
|
|
else:
|
|
# A build that does not run the commands has no file to show and nothing to compare it against
|
|
result = [(CONFIG_MARK_SAME, _SUPPRESS_CONFIG)]
|
|
|
|
return file, result, show
|
|
|
|
################################################################################################################################
|
|
def _backrest_config_apply(self, config, key, depth):
|
|
"""Apply a change to the configuration, install it on the host, and return what a reader should see."""
|
|
|
|
host_name = self._replace(xml_node_attribute(config, "host", True))
|
|
host = self.host_map.get(host_name)
|
|
|
|
if host is None:
|
|
raise ToolError("cannot configure backrest on host %s because the host does not exist" % host_name)
|
|
|
|
file_map = self.config_map.setdefault(host_name, {})
|
|
|
|
if xml_node_attribute(config, "reset") == "y":
|
|
file_map.pop(key["file"], None)
|
|
|
|
section_map = file_map.setdefault(key["file"], {})
|
|
|
|
for option in xml_node_child_list(config, "backrest-config-option"):
|
|
option_section = self._replace(xml_node_attribute(option, "section", True))
|
|
name = self._replace(xml_node_attribute(option, "key", True))
|
|
value = None
|
|
|
|
if xml_node_attribute(option, "remove") != "y":
|
|
value = self._replace((option.text or "").strip())
|
|
|
|
if value is None or value == "":
|
|
section_map.get(option_section, {}).pop(name, None)
|
|
|
|
if option_section in section_map and len(section_map[option_section]) == 0:
|
|
del section_map[option_section]
|
|
|
|
log(DEBUG, " " * (depth + 1) + "reset %s->%s" % (option_section, name))
|
|
else:
|
|
current = section_map.setdefault(option_section, {})
|
|
|
|
# An option that may be given more than once adds to what is already there rather than replacing it
|
|
if xml_node_attribute(option, "multi") == "y" and name in current:
|
|
existing = current[name]
|
|
current[name] = (existing if isinstance(existing, list) else [existing]) + [value]
|
|
else:
|
|
current[name] = value
|
|
|
|
log(DEBUG, " " * (depth + 1) + "set %s->%s = %s" % (option_section, name, value))
|
|
|
|
path_local = os.path.join(self.manifest.path_doc, "output/pgbackrest.conf")
|
|
|
|
file_write(path_local, ini_render(section_map))
|
|
host.copy_to(path_local, key["file"], self._replace(xml_node_attribute(config, "owner") or "postgres:postgres"), "640")
|
|
|
|
# What a reader sees leaves out the options that are only there so the build can watch what happens
|
|
clean = copy.deepcopy(section_map)
|
|
|
|
for name in _CONFIG_HIDE_LIST:
|
|
clean.get(_CONFIG_SECTION_GLOBAL, {}).pop(name, None)
|
|
|
|
if _CONFIG_SECTION_GLOBAL in clean and len(clean[_CONFIG_SECTION_GLOBAL]) == 0:
|
|
del clean[_CONFIG_SECTION_GLOBAL]
|
|
|
|
result = ini_render(clean)
|
|
|
|
return result if result.strip() != "" else None
|
|
|
|
################################################################################################################################
|
|
def postgres_config(self, section, config, depth):
|
|
"""Apply a change to the PostgreSQL configuration and return the file, what was added and how it changed, and whether to
|
|
show it."""
|
|
|
|
key = self.config_key(config)
|
|
file = key["file"]
|
|
show = xml_node_attribute(config, "show") != "n"
|
|
|
|
if self.exe and self.is_required(section):
|
|
hit, cache_type, key, value = self.cache_pop("cfg-postgresql", key)
|
|
|
|
if hit:
|
|
result = None if value.get("config") is None else "\n".join(value["config"])
|
|
else:
|
|
result = self._postgres_config_apply(config, key, depth)
|
|
self.cache_push(cache_type, key, {"config": None if result is None else result.split("\n")})
|
|
|
|
result = self._config_mark(section, key, result, show)
|
|
else:
|
|
# A build that does not run the commands has no file to show and nothing to compare it against
|
|
result = [(CONFIG_MARK_SAME, _SUPPRESS_CONFIG)]
|
|
|
|
return file, result, show
|
|
|
|
################################################################################################################################
|
|
def _postgres_config_apply(self, config, key, depth):
|
|
"""Apply a change to the PostgreSQL configuration, install it on the host, and return what was added."""
|
|
|
|
host_name = self._replace(xml_node_attribute(config, "host", True))
|
|
host = self.host_map.get(host_name)
|
|
|
|
if host is None:
|
|
raise ToolError("cannot configure postgres on host %s because the host does not exist" % host_name)
|
|
|
|
path_local = os.path.join(self.manifest.path_doc, "output/postgresql.conf")
|
|
host.copy_from(key["file"], path_local)
|
|
|
|
file_map = self.pg_config_map.setdefault(host_name, {}).setdefault(key["file"], {})
|
|
|
|
# The file as it was before the documentation added anything, which everything added is appended to
|
|
if "base" not in file_map:
|
|
file_map["base"] = file_read(path_local)
|
|
|
|
option_map = dict(file_map.get("old", {}))
|
|
|
|
log(DEBUG, " " * depth + "process postgres config: %s" % key["file"])
|
|
|
|
for option in xml_node_child_list(config, "postgres-config-option"):
|
|
name = xml_node_attribute(option, "key", True)
|
|
value = self._replace((option.text or "").strip())
|
|
|
|
if value == "":
|
|
option_map.pop(name, None)
|
|
|
|
log(DEBUG, " " * (depth + 1) + "reset %s" % name)
|
|
else:
|
|
option_map[name] = value
|
|
|
|
log(DEBUG, " " * (depth + 1) + "set %s = %s" % (name, value))
|
|
|
|
result = "\n".join("%s = %s" % (name, option_map[name]) for name in sorted(option_map))
|
|
|
|
file_write(path_local, file_map["base"] + ("\n# pgBackRest Configuration\n%s\n" % result if result != "" else ""))
|
|
host.copy_to(path_local, key["file"], "postgres:postgres", "640")
|
|
|
|
file_map["old"] = option_map
|
|
|
|
return result if result.strip() != "" else None
|
|
|
|
################################################################################################################################
|
|
def host_key(self, host):
|
|
"""Everything that decides what a host would be."""
|
|
|
|
key = {
|
|
"name": self._replace(xml_node_attribute(host, "name", True)),
|
|
"image": self._replace(xml_node_attribute(host, "image", True)),
|
|
}
|
|
|
|
key["id"] = self._replace(xml_node_attribute(host, "id")) if xml_node_attribute(host, "id") is not None else key["name"]
|
|
|
|
for name in ("option", "param", "os"):
|
|
if xml_node_attribute(host, name) is not None:
|
|
key[name] = self._replace(xml_node_attribute(host, name))
|
|
|
|
key["update-hosts"] = xml_node_attribute(host, "update-hosts") != "n"
|
|
|
|
return key
|
|
|
|
################################################################################################################################
|
|
def cache_pop(self, cache_type, key):
|
|
"""Take the next entry from the cache and check that it describes what is about to happen."""
|
|
|
|
if not self.cache:
|
|
if self.manifest.cache_only:
|
|
raise ToolError("cache only operation forced by --cache-only option")
|
|
|
|
return False, cache_type, key, None
|
|
|
|
if self.cache_idx >= len(self.source.cache_list):
|
|
raise CacheInvalidError("unable to get index from cache")
|
|
|
|
entry = self.source.cache_list[self.cache_idx]
|
|
|
|
if "key" not in entry or "type" not in entry:
|
|
raise CacheInvalidError("unable to get key or type from cache")
|
|
|
|
if entry["type"] != cache_type:
|
|
raise CacheInvalidError("types do not match, cache is invalid")
|
|
|
|
if _canonical(entry["key"]) != _canonical(key):
|
|
raise CacheInvalidError(
|
|
"keys at index %d do not match, cache is invalid.\n cache key: %s\ncurrent key: %s"
|
|
% (self.cache_idx, _canonical(entry["key"]), _canonical(key))
|
|
)
|
|
|
|
self.cache_idx += 1
|
|
|
|
return True, cache_type, key, entry.get("value", {})
|
|
|
|
################################################################################################################################
|
|
def cache_push(self, cache_type, key, value):
|
|
"""Add an entry to the cache."""
|
|
|
|
if self.cache:
|
|
raise ToolError("cache push should not be called when the cache is already present")
|
|
|
|
entry = {"key": key, "type": cache_type}
|
|
|
|
if value is not None:
|
|
entry["value"] = value
|
|
|
|
if self.source.cache_list is None:
|
|
self.source.cache_list = []
|
|
|
|
self.source.cache_list.append(entry)
|
|
|
|
################################################################################################################################
|
|
def section_child_process(self, section, child, depth):
|
|
"""Handle what a section holds that every renderer handles the same way, which is starting a host."""
|
|
|
|
log(DEBUG, " " * (depth + 1) + "process child: %s" % child.tag)
|
|
|
|
if child.tag == "host-add":
|
|
if self.exe and self.is_required(section):
|
|
hit, cache_type, key, value = self.cache_pop("host", self.host_key(child))
|
|
|
|
if hit:
|
|
self.manifest.var_store.set("host-%s-ip" % key["id"], value["ip"])
|
|
else:
|
|
self.cache_push(cache_type, key, {"ip": self._host_add(section, child, key, depth)})
|
|
elif child.tag != "title":
|
|
raise ToolError("unable to process child type %s" % child.tag)
|
|
|
|
################################################################################################################################
|
|
def _host_add(self, section, child, key, depth):
|
|
"""Start a host and run the commands that set it up, and return its address."""
|
|
|
|
if key["name"] in self.host_map:
|
|
raise ToolError("cannot add host %s because the host already exists" % key["name"])
|
|
|
|
path_data = os.path.join(os.environ["HOME"], "data", key["name"])
|
|
|
|
exec_result("rm -rf %s" % path_data)
|
|
exec_result("mkdir -p %s/etc" % path_data)
|
|
|
|
image = key["image"]
|
|
host_user = self._replace(xml_node_attribute(child, "user", True))
|
|
|
|
# Commands marked pre are run while the image is built rather than against the host, so a rebuild of the documentation does
|
|
# not run them again
|
|
if len(self.pre_execute(key["name"])) > 0:
|
|
image = self._image_pre_build(key, host_user)
|
|
|
|
# The repository is mounted from where it is on the host running the build, which is not where it is in this container
|
|
path_repo = os.path.dirname(self.manifest.path_doc)
|
|
mount = xml_node_attribute(child, "mount")
|
|
mount = None if mount is None else self._replace(mount).replace("{[host-repo-path]}", path_repo)
|
|
option = None if key.get("option") is None else key["option"].replace("{[host-repo-path]}", path_repo)
|
|
|
|
host = Host(
|
|
key["name"],
|
|
"doc-%s" % key["name"],
|
|
image,
|
|
host_user,
|
|
[mount] if mount is not None else None,
|
|
option,
|
|
key.get("param"),
|
|
key["update-hosts"],
|
|
)
|
|
|
|
self.host_map[key["name"]] = host
|
|
self.manifest.var_store.set("host-%s-ip" % key["id"], host.ip)
|
|
|
|
HOST_GROUP.host_add(host)
|
|
|
|
for execute in xml_node_child_list(child, "execute"):
|
|
self.execute(section, key["name"], execute, indent=depth + 1, cache=False, show=False)
|
|
|
|
return host.ip
|
|
|
|
################################################################################################################################
|
|
def _image_pre_build(self, key, host_user):
|
|
"""Build an image with the commands marked pre already run, and return what it is called."""
|
|
|
|
image = "%s-%s" % (key["image"], key["name"])
|
|
|
|
log(INFO, "Build vm '%s' from '%s'" % (image, key["image"]))
|
|
|
|
command_list = []
|
|
|
|
for execute in self.pre_execute(key["name"]):
|
|
execute_key = self.execute_key(key["name"], execute)
|
|
command = "\n".join(execute_key["cmd"]) + (
|
|
"" if execute_key.get("cmd-extra") is None else " " + execute_key["cmd-extra"]
|
|
)
|
|
command = command.replace("'", "'\\''")
|
|
|
|
command = "sudo -u %s%s" % (
|
|
host_user,
|
|
(
|
|
" bash%s -c '%s'" % (" -l" if execute_key["load-env"] else "", command)
|
|
if execute_key["bash-wrap"]
|
|
else " " + command
|
|
),
|
|
)
|
|
|
|
command_list.append("RUN %s" % command)
|
|
|
|
log(DETAIL, " Pre command %s" % command)
|
|
|
|
path_docker_file = os.path.join(self.manifest.path_doc, "output/doc-host.dockerfile")
|
|
|
|
file_write(path_docker_file, "FROM %s\n\n%s\n" % (key["image"], self._replace("\n".join(command_list)).strip()))
|
|
|
|
exec_result("docker build -f %s -t %s %s" % (path_docker_file, image, self.manifest.path_doc), suppress_stderr=True)
|
|
|
|
return image
|
|
|
|
|
|
####################################################################################################################################
|
|
# Every host of the build, so a host that is added learns about the ones that are already up
|
|
HOST_GROUP = HostGroup()
|
|
|
|
|
|
####################################################################################################################################
|
|
def _canonical(value):
|
|
"""Render a cache key the one way it is written, so two keys are compared by what they hold rather than by how they were
|
|
built."""
|
|
|
|
return json.dumps(value, sort_keys=True, separators=(",", ":"))
|