summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChad Smith <chad.smith@canonical.com>2022-10-21 15:55:58 -0600
committerGitHub <noreply@github.com>2022-10-21 15:55:58 -0600
commit41922bf0144ffe7ae3b3d3bc6378b921e076b3b1 (patch)
tree15338e9c0740baedc5fef0a97b0906ee7c75099f
parentcea875a4b500195b3c6dcdcbf7664857f1511aed (diff)
downloadcloud-init-git-41922bf0144ffe7ae3b3d3bc6378b921e076b3b1.tar.gz
cli: collect logs and apport subiquity support
cli/apport: collect-logs include subiquity logs config when present Add support for both cloud-init collect-logs and apport to include subiquity live installer artifacts if present to aid in bug triage. Apport integration to attach subiquity, curtin and ubuntu-desktop-installer report keys when logs or config files are present. `ubuntu-bug cloud-init` will also automatically tag the bug as curtin, subiquity or ubuntu-desktop-installer if related logs are present in the bug report. Additional collect-logs support to collect /var/lib/cloud/data in the event that cloud-init is disabled by systemd generator with /run/cloud-init/disabled flag. In these situations we want to collect /var/lib/cloud/data dir for more context on prior cloud-init behavior.
-rw-r--r--cloudinit/apport.py46
-rwxr-xr-xcloudinit/cmd/devel/logs.py65
-rw-r--r--tests/integration_tests/test_paths.py56
-rw-r--r--tests/unittests/cmd/devel/test_logs.py99
-rw-r--r--tests/unittests/test_apport.py39
5 files changed, 295 insertions, 10 deletions
diff --git a/cloudinit/apport.py b/cloudinit/apport.py
index 7cba0bf5..7f3d6e5a 100644
--- a/cloudinit/apport.py
+++ b/cloudinit/apport.py
@@ -3,11 +3,17 @@
# This file is part of cloud-init. See LICENSE file for license information.
"""Cloud-init apport interface"""
+
from cloudinit.cmd.devel import read_cfg_paths
+from cloudinit.cmd.devel.logs import (
+ INSTALLER_APPORT_FILES,
+ INSTALLER_APPORT_SENSITIVE_FILES,
+)
try:
from apport.hookutils import (
attach_file,
+ attach_file_if_exists,
attach_root_command_outputs,
root_command_output,
)
@@ -110,30 +116,57 @@ def attach_cloud_info(report, ui=None):
report["CloudName"] = "None"
+def attach_installer_files(report, ui=None):
+ """Attach any subiquity installer logs config.
+
+ To support decoupling apport integration from installer config/logs,
+ we eventually want to either source this function or APPORT_FILES
+ attribute from subiquity and/or ubuntu-desktop-installer package-hooks
+ python modules.
+ """
+ for apport_file in INSTALLER_APPORT_FILES:
+ attach_file_if_exists(report, apport_file.path, apport_file.label)
+
+
def attach_user_data(report, ui=None):
"""Optionally provide user-data if desired."""
if ui:
user_data_file = _get_user_data_file()
prompt = (
- "Your user-data or cloud-config file can optionally be provided"
- " from {0} and could be useful to developers when addressing this"
- " bug. Do you wish to attach user-data to this bug?".format(
- user_data_file
- )
+ "Your user-data, cloud-config or autoinstall files can optionally "
+ " be provided from {0} and could be useful to developers when"
+ " addressing this bug. Do you wish to attach user-data to this"
+ " bug?".format(user_data_file)
)
response = ui.yesno(prompt)
if response is None:
raise StopIteration # User cancelled
if response:
attach_file(report, user_data_file, "user_data.txt")
+ for apport_file in INSTALLER_APPORT_SENSITIVE_FILES:
+ attach_file_if_exists(
+ report, apport_file.path, apport_file.label
+ )
def add_bug_tags(report):
"""Add any appropriate tags to the bug."""
+ new_tags = []
+ if report.get("CurtinError"):
+ new_tags.append("curtin")
+ if report.get("SubiquityLog"):
+ new_tags.append("subiquity")
if "JournalErrors" in report.keys():
errors = report["JournalErrors"]
if "Breaking ordering cycle" in errors:
- report["Tags"] = "systemd-ordering"
+ new_tags.append("systemd-ordering")
+ if report.get("UdiLog"):
+ new_tags.append("ubuntu-desktop-installer")
+ if new_tags:
+ report.setdefault("Tags", "")
+ if report["Tags"]:
+ report["Tags"] += " "
+ report["Tags"] += " ".join(new_tags)
def add_info(report, ui):
@@ -151,6 +184,7 @@ def add_info(report, ui):
attach_hwinfo(report, ui)
attach_cloud_info(report, ui)
attach_user_data(report, ui)
+ attach_installer_files(report, ui)
add_bug_tags(report)
return True
diff --git a/cloudinit/cmd/devel/logs.py b/cloudinit/cmd/devel/logs.py
index 69f4d931..385554d8 100755
--- a/cloudinit/cmd/devel/logs.py
+++ b/cloudinit/cmd/devel/logs.py
@@ -11,6 +11,8 @@ import os
import shutil
import sys
from datetime import datetime
+from pathlib import Path
+from typing import NamedTuple
from cloudinit.cmd.devel import read_cfg_paths
from cloudinit.helpers import Paths
@@ -22,11 +24,46 @@ CLOUDINIT_LOGS = ["/var/log/cloud-init.log", "/var/log/cloud-init-output.log"]
CLOUDINIT_RUN_DIR = "/run/cloud-init"
+class ApportFile(NamedTuple):
+ path: str
+ label: str
+
+
+INSTALLER_APPORT_SENSITIVE_FILES = [
+ ApportFile(
+ "/var/log/installer/autoinstall-user-data", "AutoInstallUserData"
+ ),
+ ApportFile("/autoinstall.yaml", "AutoInstallYAML"),
+ ApportFile("/etc/cloud/cloud.cfg.d/99-installer.cfg", "InstallerCloudCfg"),
+]
+
+INSTALLER_APPORT_FILES = [
+ ApportFile("/var/log/installer/ubuntu_desktop_installer.log", "UdiLog"),
+ ApportFile(
+ "/var/log/installer/subiquity-server-debug.log", "SubiquityLog"
+ ),
+ ApportFile(
+ "/var/log/installer/subiquity-client-debug.log", "SubiquityClientLog"
+ ),
+ ApportFile("/var/log/installer/curtin-install.log", "CurtinLog"),
+ ApportFile(
+ "/var/log/installer/subiquity-curtin-install.cfg", "CurtinConfig"
+ ),
+ ApportFile("/var/log/installer/curtin-error-logs.tar", "CurtinError"),
+ ApportFile("/var/log/installer/block/probe-data.json", "ProbeData"),
+]
+
+
def _get_user_data_file() -> str:
paths = read_cfg_paths()
return paths.get_ipath_cur("userdata_raw")
+def _get_cloud_data_path() -> str:
+ paths = read_cfg_paths()
+ return paths.get_cpath("data")
+
+
def get_parser(parser=None):
"""Build or extend and arg parser for collect-logs utility.
@@ -110,6 +147,21 @@ def _collect_file(path, out_dir, verbosity):
_debug("file %s did not exist\n" % path, 2, verbosity)
+def collect_installer_logs(log_dir, include_userdata, verbosity):
+ """Obtain subiquity logs and config files."""
+ for src_file in INSTALLER_APPORT_FILES:
+ destination_dir = Path(log_dir + src_file.path).parent
+ if not destination_dir.exists():
+ ensure_dir(str(destination_dir))
+ _collect_file(src_file.path, str(destination_dir), verbosity)
+ if include_userdata:
+ for src_file in INSTALLER_APPORT_SENSITIVE_FILES:
+ destination_dir = Path(log_dir + src_file.path).parent
+ if not destination_dir.exists():
+ ensure_dir(str(destination_dir))
+ _collect_file(src_file.path, str(destination_dir), verbosity)
+
+
def collect_logs(tarfile, include_userdata: bool, verbosity=0):
"""Collect all cloud-init logs and tar them up into the provided tarfile.
@@ -123,8 +175,7 @@ def collect_logs(tarfile, include_userdata: bool, verbosity=0):
)
return 1
tarfile = os.path.abspath(tarfile)
- date = datetime.utcnow().date().strftime("%Y-%m-%d")
- log_dir = "cloud-init-logs-{0}".format(date)
+ log_dir = datetime.utcnow().date().strftime("cloud-init-logs-%Y-%m-%d")
with tempdir(dir="/tmp") as tmp_dir:
log_dir = os.path.join(tmp_dir, log_dir)
version = _write_command_output_to_file(
@@ -160,6 +211,8 @@ def collect_logs(tarfile, include_userdata: bool, verbosity=0):
if include_userdata:
user_data_file = _get_user_data_file()
_collect_file(user_data_file, log_dir, verbosity)
+ collect_installer_logs(log_dir, include_userdata, verbosity)
+
run_dir = os.path.join(log_dir, "run")
ensure_dir(run_dir)
if os.path.exists(CLOUDINIT_RUN_DIR):
@@ -179,6 +232,14 @@ def collect_logs(tarfile, include_userdata: bool, verbosity=0):
1,
verbosity,
)
+ if os.path.exists(os.path.join(CLOUDINIT_RUN_DIR, "disabled")):
+ # Fallback to grab previous cloud/data
+ cloud_data_dir = Path(_get_cloud_data_path())
+ if cloud_data_dir.exists():
+ shutil.copytree(
+ str(cloud_data_dir),
+ Path(log_dir + str(cloud_data_dir)),
+ )
with chdir(tmp_dir):
subp(["tar", "czvf", tarfile, log_dir.replace(tmp_dir + "/", "")])
sys.stderr.write("Wrote %s\n" % tarfile)
diff --git a/tests/integration_tests/test_paths.py b/tests/integration_tests/test_paths.py
index 20392e35..14513c82 100644
--- a/tests/integration_tests/test_paths.py
+++ b/tests/integration_tests/test_paths.py
@@ -1,8 +1,14 @@
+import os
import re
+from datetime import datetime
from typing import Iterator
import pytest
+from cloudinit.cmd.devel.logs import (
+ INSTALLER_APPORT_FILES,
+ INSTALLER_APPORT_SENSITIVE_FILES,
+)
from tests.integration_tests.instances import IntegrationInstance
from tests.integration_tests.util import verify_clean_log
@@ -43,12 +49,62 @@ class TestHonorCloudDir:
assert f"{NEW_CLOUD_DIR}/instance/user-data.txt" in re.sub(
r"\s+", "", help_result.stdout
), "user-data file not correctly render in collect-logs -h"
+
+ # Touch a couple of subiquity files to assert collected
+ installer_files = (
+ INSTALLER_APPORT_FILES[-1],
+ INSTALLER_APPORT_SENSITIVE_FILES[-1],
+ )
+
+ for apport_file in installer_files:
+ custom_client.execute(
+ f"mkdir -p {os.path.dirname(apport_file.path)}"
+ )
+ custom_client.execute(f"touch {apport_file.path}")
+
collect_logs_result = custom_client.execute(
"cloud-init collect-logs --include-userdata"
)
assert (
collect_logs_result.ok
), f"collect-logs error: {collect_logs_result.stderr}"
+ found_logs = custom_client.execute(
+ "tar -tf cloud-init.tar.gz"
+ ).stdout.splitlines()
+ dirname = datetime.utcnow().date().strftime("cloud-init-logs-%Y-%m-%d")
+ expected_logs = [
+ f"{dirname}/",
+ f"{dirname}/cloud-init.log",
+ f"{dirname}/cloud-init-output.log",
+ f"{dirname}/dmesg.txt",
+ f"{dirname}/user-data.txt",
+ f"{dirname}/version",
+ f"{dirname}/dpkg-version",
+ f"{dirname}/journal.txt",
+ f"{dirname}/run/",
+ f"{dirname}/run/cloud-init/",
+ f"{dirname}/run/cloud-init/result.json",
+ f"{dirname}/run/cloud-init/.instance-id",
+ f"{dirname}/run/cloud-init/cloud-init-generator.log",
+ f"{dirname}/run/cloud-init/enabled",
+ f"{dirname}/run/cloud-init/cloud-id",
+ f"{dirname}/run/cloud-init/instance-data.json",
+ f"{dirname}/run/cloud-init/instance-data-sensitive.json",
+ f"{dirname}{installer_files[0].path}",
+ f"{dirname}{installer_files[1].path}",
+ ]
+ for log in expected_logs:
+ assert log in found_logs
+ # Assert disabled cloud-init collect-logs grabs /var/lib/cloud/data
+ custom_client.execute("touch /run/cloud-init/disabled")
+ assert custom_client.execute(
+ "cloud-init collect-logs --include-userdata"
+ ).ok
+ found_logs = custom_client.execute(
+ "tar -tf cloud-init.tar.gz"
+ ).stdout.splitlines()
+ dirname = datetime.utcnow().date().strftime("cloud-init-logs-%Y-%m-%d")
+ assert f"{dirname}/new-cloud-dir/data/result.json" in found_logs
# LXD inserts some agent setup code into VMs on Bionic under
# /var/lib/cloud. The inserted script will cause this test to fail
diff --git a/tests/unittests/cmd/devel/test_logs.py b/tests/unittests/cmd/devel/test_logs.py
index 0a418618..4e3f30d4 100644
--- a/tests/unittests/cmd/devel/test_logs.py
+++ b/tests/unittests/cmd/devel/test_logs.py
@@ -1,13 +1,17 @@
# This file is part of cloud-init. See LICENSE file for license information.
+import glob
import os
import re
from datetime import datetime
from io import StringIO
+import pytest
+
from cloudinit.cmd.devel import logs
+from cloudinit.cmd.devel.logs import ApportFile
from cloudinit.subp import subp
-from cloudinit.util import load_file, write_file
+from cloudinit.util import ensure_dir, load_file, write_file
from tests.unittests.helpers import mock
M_PATH = "cloudinit.cmd.devel.logs."
@@ -81,6 +85,8 @@ class TestCollectLogs:
mocker.patch(M_PATH + "sys.stderr", fake_stderr)
mocker.patch(M_PATH + "CLOUDINIT_LOGS", [log1, log2])
mocker.patch(M_PATH + "CLOUDINIT_RUN_DIR", run_dir)
+ mocker.patch(M_PATH + "INSTALLER_APPORT_FILES", [])
+ mocker.patch(M_PATH + "INSTALLER_APPORT_SENSITIVE_FILES", [])
logs.collect_logs(output_tarfile, include_userdata=False)
# unpack the tarfile and check file contents
subp(["tar", "zxvf", output_tarfile, "-C", str(tmpdir)])
@@ -168,6 +174,8 @@ class TestCollectLogs:
mocker.patch(M_PATH + "sys.stderr", fake_stderr)
mocker.patch(M_PATH + "CLOUDINIT_LOGS", [log1, log2])
mocker.patch(M_PATH + "CLOUDINIT_RUN_DIR", run_dir)
+ mocker.patch(M_PATH + "INSTALLER_APPORT_FILES", [])
+ mocker.patch(M_PATH + "INSTALLER_APPORT_SENSITIVE_FILES", [])
mocker.patch(M_PATH + "_get_user_data_file", return_value=userdata)
logs.collect_logs(output_tarfile, include_userdata=True)
# unpack the tarfile and check file contents
@@ -187,6 +195,95 @@ class TestCollectLogs:
fake_stderr.write.assert_any_call("Wrote %s\n" % output_tarfile)
+class TestCollectInstallerLogs:
+ @pytest.mark.parametrize(
+ "include_userdata, apport_files, apport_sensitive_files",
+ (
+ pytest.param(True, [], [], id="no_files_include_userdata"),
+ pytest.param(False, [], [], id="no_files_exclude_userdata"),
+ pytest.param(
+ True,
+ (ApportFile("log1", "Label1"), ApportFile("log2", "Label2")),
+ (
+ ApportFile("private1", "LabelPrivate1"),
+ ApportFile("private2", "PrivateLabel2"),
+ ),
+ id="files_and_dirs_include_userdata",
+ ),
+ pytest.param(
+ False,
+ (ApportFile("log1", "Label1"), ApportFile("log2", "Label2")),
+ (
+ ApportFile("private1", "LabelPrivate1"),
+ ApportFile("private2", "PrivateLabel2"),
+ ),
+ id="files_and_dirs_exclude_userdata",
+ ),
+ ),
+ )
+ def test_include_installer_logs_when_present(
+ self,
+ include_userdata,
+ apport_files,
+ apport_sensitive_files,
+ tmpdir,
+ mocker,
+ ):
+ src_dir = tmpdir.join("src")
+ ensure_dir(src_dir.strpath)
+ # collect-logs nests full directory path to file in the tarfile
+ destination_dir = tmpdir.join(src_dir)
+
+ # Create tmppath-based userdata_files, installer_logs, installer_dirs
+ expected_files = []
+ # Create last file in list to assert ignoring absent files
+ apport_files = [
+ logs.ApportFile(src_dir.join(apport.path).strpath, apport.label)
+ for apport in apport_files
+ ]
+ if apport_files:
+ write_file(apport_files[-1].path, apport_files[-1].label)
+ expected_files += [
+ destination_dir.join(
+ os.path.basename(apport_files[-1].path)
+ ).strpath
+ ]
+ apport_sensitive_files = [
+ logs.ApportFile(src_dir.join(apport.path).strpath, apport.label)
+ for apport in apport_sensitive_files
+ ]
+ if apport_sensitive_files:
+ write_file(
+ apport_sensitive_files[-1].path,
+ apport_sensitive_files[-1].label,
+ )
+ if include_userdata:
+ expected_files += [
+ destination_dir.join(
+ os.path.basename(apport_sensitive_files[-1].path)
+ ).strpath
+ ]
+ mocker.patch(M_PATH + "INSTALLER_APPORT_FILES", apport_files)
+ mocker.patch(
+ M_PATH + "INSTALLER_APPORT_SENSITIVE_FILES", apport_sensitive_files
+ )
+ logs.collect_installer_logs(
+ log_dir=tmpdir.strpath,
+ include_userdata=include_userdata,
+ verbosity=0,
+ )
+ expect_userdata = bool(include_userdata and apport_sensitive_files)
+ # when subiquity artifacts exist, and userdata set true, expect logs
+ expect_subiquity_logs = any([apport_files, expect_userdata])
+ if expect_subiquity_logs:
+ assert destination_dir.exists(), "Missing subiquity artifact dir"
+ assert sorted(expected_files) == sorted(
+ glob.glob(f"{destination_dir.strpath}/*")
+ )
+ else:
+ assert not destination_dir.exists(), "Unexpected subiquity dir"
+
+
class TestParser:
def test_parser_help_has_userdata_file(self, mocker, tmpdir):
userdata = str(tmpdir.join("user-data.txt"))
diff --git a/tests/unittests/test_apport.py b/tests/unittests/test_apport.py
index a2c866b9..1876c1be 100644
--- a/tests/unittests/test_apport.py
+++ b/tests/unittests/test_apport.py
@@ -1,3 +1,5 @@
+import pytest
+
from tests.unittests.helpers import mock
M_PATH = "cloudinit.apport."
@@ -19,5 +21,40 @@ class TestApport:
report = object()
apport.attach_user_data(report, ui)
assert [
- mock.call(report, user_data_file, "user_data.txt")
+ mock.call(report, user_data_file, "user_data.txt"),
] == m_hookutils.attach_file.call_args_list
+ assert [
+ mock.call(
+ report,
+ "/var/log/installer/autoinstall-user-data",
+ "AutoInstallUserData",
+ ),
+ mock.call(report, "/autoinstall.yaml", "AutoInstallYAML"),
+ mock.call(
+ report,
+ "/etc/cloud/cloud.cfg.d/99-installer.cfg",
+ "InstallerCloudCfg",
+ ),
+ ] == m_hookutils.attach_file_if_exists.call_args_list
+
+ @pytest.mark.parametrize(
+ "report,tags",
+ (
+ ({"Irrelevant": "."}, ""),
+ ({"UdiLog": "."}, "ubuntu-desktop-installer"),
+ ({"CurtinError": ".", "SubiquityLog": "."}, "curtin subiquity"),
+ (
+ {
+ "UdiLog": ".",
+ "JournalErrors": "...Breaking ordering cycle...",
+ },
+ "systemd-ordering ubuntu-desktop-installer",
+ ),
+ ),
+ )
+ def test_add_bug_tags_assigns_proper_tags(self, report, tags):
+ """Tags are assigned based on non-empty project report key values."""
+ from cloudinit import apport
+
+ apport.add_bug_tags(report)
+ assert report.get("Tags", "") == tags