Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 11 additions & 6 deletions cloudexit/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -415,15 +415,19 @@ def perform_risk_assessment(exit_strategy: int, report_path: str) -> dict[str, A
return {"success": False, "logs": str(e)}


def _load_tfstate_scope(raw_data_path: str) -> dict[str, Any] | None:
# A missing or unreadable manifest must not fail report generation.
def _load_tfstate_scope(
raw_data_path: str,
) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
# A missing or unreadable manifest must not fail report generation. Older
# manifests have no coverage.
manifest_path = os.path.join(raw_data_path, "tfstate_manifest.json")
try:
with open(manifest_path, "r", encoding="utf-8") as manifest_file:
return json.load(manifest_file).get("scope")
manifest = json.load(manifest_file)
return manifest.get("scope"), manifest.get("coverage")
except (OSError, json.JSONDecodeError) as e:
logger.warning("Could not read the tfstate manifest for report scope: %s", e)
return None
return None, None


# Stage 8
Expand Down Expand Up @@ -477,10 +481,10 @@ def generate_report(
reports = {}

# One context feeds all three reports, so they say the same things
tfstate_scope = (
tfstate_scope, tfstate_coverage = (
_load_tfstate_scope(raw_data_path)
if provider_details.get("tfstatePath")
else None
else (None, None)
)
provenance = collect_provenance(db_path, run_id=sync_id, upload=upload)
context = build_context(
Expand All @@ -496,6 +500,7 @@ def generate_report(
alternative_technology_organizations=alternative_technology_organizations,
exit_strategy=exit_strategy,
tfstate_scope=tfstate_scope,
tfstate_coverage=tfstate_coverage,
provenance=provenance,
report_path=report_path,
)
Expand Down
8 changes: 4 additions & 4 deletions cloudexit/platform/upload.py
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,7 @@ def read_sync_files(
return found, problems


REPORT_KEYS = ("status", "reason", "sync_id", "assessment_id", "url")
REPORT_KEYS = ("status", "reason", "sync_id", "assessment_id", "url", "spooled")


def _record(result: UploadResult | None) -> dict[str, Any] | None:
Expand All @@ -273,9 +273,9 @@ def _record(result: UploadResult | None) -> dict[str, Any] | None:
return {key: value for key, value in info.items() if value}


def report_info(result: UploadResult | None) -> dict[str, str] | None:
# What the local report may show: the status and the link, nothing the
# platform computed.
def report_info(result: UploadResult | None) -> dict[str, Any] | None:
# What the local report may show: the status, the link and whether a failed
# run was kept, nothing the platform computed.
record = _record(result)
if record is None:
return None
Expand Down
34 changes: 30 additions & 4 deletions cloudexit/report/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,10 @@ class Provenance:
uploaded: bool = False
platform_url: str | None = None
assessment_id: str | None = None
# Why a requested upload did not happen, when it did not
# A requested upload that failed, why, and whether the run was kept
upload_failed: bool = False
upload_reason: str | None = None
upload_spooled: bool = False

@property
def run_short_id(self):
Expand Down Expand Up @@ -79,9 +81,11 @@ def collect_provenance(db_path, *, run_id=None, upload=None, collected_at=None):
uploaded=upload.get("status") == "uploaded",
platform_url=upload.get("url") if upload.get("status") == "uploaded" else None,
assessment_id=upload.get("assessment_id"),
upload_failed=upload.get("status") == "failed",
upload_reason=(
upload.get("reason") if upload.get("status") != "uploaded" else None
upload.get("reason") if upload.get("status") == "failed" else None
),
upload_spooled=bool(upload.get("spooled")) and upload.get("status") == "failed",
)


Expand Down Expand Up @@ -147,6 +151,14 @@ class ScopeFacts:
resource_group: str = ""
file_name: str = ""
locations: tuple = ()
# State file instances left out: of other providers, of types not in the
# data catalogue
not_assessed_other: int = 0
not_assessed_unmapped: int = 0

@property
def not_assessed(self):
return self.not_assessed_other + self.not_assessed_unmapped


@dataclass
Expand Down Expand Up @@ -316,7 +328,14 @@ def _alternative_entries(
return entries


def scope_facts(provider_details, cloud_service_provider, tfstate_scope):
def _excluded(coverage, key):
value = (coverage or {}).get(key)
return value if isinstance(value, int) and value > 0 else 0


def scope_facts(
provider_details, cloud_service_provider, tfstate_scope, tfstate_coverage=None
):
provider = PROVIDER_NAMES.get(cloud_service_provider, "–")
if provider_details.get("tfstatePath"):
scope = tfstate_scope or {
Expand All @@ -343,6 +362,12 @@ def scope_facts(provider_details, cloud_service_provider, tfstate_scope):
tuple(rows),
file_name=scope.get("file") or "",
locations=locations,
not_assessed_other=_excluded(
tfstate_coverage, "instances_excluded_other_provider"
),
not_assessed_unmapped=_excluded(
tfstate_coverage, "instances_excluded_unmapped"
),
)
if cloud_service_provider == 1:
rows = (
Expand Down Expand Up @@ -379,6 +404,7 @@ def build_context(
alternative_technology_organizations: list[dict[str, Any]],
exit_strategy: int,
tfstate_scope: dict[str, Any] | None,
tfstate_coverage: dict[str, Any] | None = None,
provenance: Provenance,
report_path: str,
) -> ReportContext:
Expand All @@ -392,7 +418,7 @@ def build_context(
provider=PROVIDER_NAMES.get(csp, "–"),
exit_strategy=exit_strategy,
strategy_code=STRATEGY_CODES.get(int(exit_strategy), ""),
scope=scope_facts(provider_details, csp, tfstate_scope),
scope=scope_facts(provider_details, csp, tfstate_scope, tfstate_coverage),
provenance=provenance,
resources=resources,
costs=_cost_entries(cost_data, provenance.collected_at),
Expand Down
35 changes: 35 additions & 0 deletions cloudexit/report/evidence.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,22 @@ def build_document_table(
return key_value_table(rows, context.appearance)


def _not_assessed_text(scope, b):
parts = [
_counted(b, key, n)
for key, n in (
("ev_not_assessed_other", scope.not_assessed_other),
("ev_not_assessed_unmapped", scope.not_assessed_unmapped),
)
if n
]
return ", ".join(parts)


def _counted(b, key, n):
return b[f"{key}_one"] if n == 1 else b[f"{key}_many"].format(n=n)


def scope_rows(context, b):
# (label, text, monospaced), the same for the PDF and the HTML report
scope = context.scope
Expand Down Expand Up @@ -91,6 +107,8 @@ def scope_rows(context, b):
):
if key in values:
rows.append((b[label], values[key] or b["ev_not_determinable"], False))
if scope.not_assessed:
rows.append((b["ev_not_assessed"], _not_assessed_text(scope, b), False))
source = b["ev_source_tfstate" if scope.kind == "tfstate" else "ev_source_live"]
rows.append(
(
Expand All @@ -117,6 +135,19 @@ def link_markup(url, color):
return f'<a href={quoteattr(url)} color="{color}">{escape(url)}</a>'


def platform_failure(provenance, b):
# What happened to an upload that failed, as it was true when the report was written
if provenance.upload_reason:
text = b["ev_platform_failed"].format(
reason=provenance.upload_reason.rstrip(". ")
)
else:
text = b["ev_platform_failed_no_reason"]
if provenance.upload_spooled:
text = f"{text} {b['ev_platform_kept']}"
return text


def build_provenance_table(context, b):
provenance = context.provenance
if provenance.catalogue_version and provenance.catalogue_sha256:
Expand All @@ -137,7 +168,11 @@ def build_provenance_table(context, b):
text = escape(b["ev_platform_uploaded"])
if provenance.platform_url:
text = f"{text}: {link_markup(provenance.platform_url, context.appearance.heading2_color)}"
elif provenance.assessment_id:
text = f"{text} · {escape(b['fa_uploaded_id'].format(assessment_id=provenance.assessment_id))}"
rows.append((b["ev_platform_label"], Paragraph(text, KV_VALUE)))
elif provenance.upload_failed:
rows.append((b["ev_platform_label"], platform_failure(provenance, b)))
else:
rows.append((b["ev_platform_label"], b["ev_platform_none"]))
return key_value_table(rows, context.appearance)
Expand Down
3 changes: 0 additions & 3 deletions cloudexit/report/executive_summary.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@
from .sections import (
alternative_technologies,
costs_overview,
full_assessment,
glossary,
introduction,
resource_inventory,
Expand Down Expand Up @@ -45,8 +44,6 @@ def build_content(context, styles, b):
content.append(Spacer(1, 18))
content += glossary(context, styles, b)
content.append(chapter_break())
content += full_assessment(context, styles, b)
content.append(chapter_break())
content += legal_notices(context, styles, b)
return content

Expand Down
37 changes: 14 additions & 23 deletions cloudexit/report/html_sections.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from markupsafe import Markup

from .evidence import scope_rows
from .evidence import platform_failure, scope_rows
from .helpers import (
format_change,
initials,
Expand All @@ -11,12 +11,9 @@
strategy_name,
)
from .html_egress import egress_page
from .notices import text_parts
from .notices import icon_notice, text_parts
from .palette import SEVERITIES
from .sections import (
AWS_ICONS_URL,
AWS_TRADEMARK_URL,
AZURE_ICONS_URL,
PRODUCT_SITES,
REPOSITORY_URL,
)
Expand Down Expand Up @@ -406,6 +403,15 @@ def _inventory(context, b):
]
if scope.kind == "tfstate":
caption_parts.append(b["html_inv_not_managed"])
if scope.not_assessed:
key = (
"html_inv_not_assessed_one"
if scope.not_assessed == 1
else "html_inv_not_assessed_many"
)
caption_parts.append(
b[key].format(n=scope.not_assessed, about=b["html_nav_about"])
)

if scope.kind == "azure":
empty = b["html_inv_empty_resource_group"].format(
Expand Down Expand Up @@ -544,10 +550,8 @@ def _count_more(b, n):
def _platform_cell(context, b):
provenance = context.provenance
if not provenance.uploaded:
if provenance.upload_reason:
return [
b["html_platform_not_uploaded"].format(reason=provenance.upload_reason)
]
if provenance.upload_failed:
return [platform_failure(provenance, b)]
return [b["ev_platform_none"]]
parts = [b["ev_platform_uploaded"]]
if provenance.platform_url:
Expand Down Expand Up @@ -667,20 +671,7 @@ def _about(context, egress, b):

def _notices(context, egress, b):
provenance = context.provenance
icons = None
if context.cloud_service_provider == 2:
icons = text_parts(
b["notices_icons_aws"],
{
"icons_url": (b["html_label_aws_icons"], AWS_ICONS_URL),
"guidelines_url": (b["html_label_aws_guidelines"], AWS_TRADEMARK_URL),
},
)
elif context.cloud_service_provider == 1:
icons = text_parts(
b["notices_icons_azure"],
{"icons_url": (b["html_label_azure_icons"], AZURE_ICONS_URL)},
)
icons = icon_notice(context.cloud_service_provider, b)
if context.scope.kind == "tfstate":
figures = b["html_figures_tfstate"]
else:
Expand Down
29 changes: 24 additions & 5 deletions cloudexit/report/notices.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,22 @@ def text_parts(template, links, values=None):
return parts


def _icon_notice(provider, b):
def icon_notice(provider, b):
# The names are the links: the HTML shows the name, the PDF the name and, in
# brackets, the address
if provider == 2:
return text_parts(
b["notices_icons_aws"],
{"icons_url": AWS_ICONS_URL, "guidelines_url": AWS_TRADEMARK_URL},
{
"icons": (b["notices_label_aws_icons"], AWS_ICONS_URL),
"guidelines": (b["notices_label_aws_guidelines"], AWS_TRADEMARK_URL),
},
)
if provider == 1:
return text_parts(b["notices_icons_azure"], {"icons_url": AZURE_ICONS_URL})
return text_parts(
b["notices_icons_azure"],
{"icons": (b["notices_label_azure_icons"], AZURE_ICONS_URL)},
)
return None


Expand All @@ -60,7 +68,7 @@ def notices_html(provider, generated_at, b=BLOCKS, egress=False):
*([[b["notices_egress"]]] if egress else []),
]
trademarks = [[b["notices_trademark_own"]], [b["notices_trademark_others"]]]
icons = _icon_notice(provider, b)
icons = icon_notice(provider, b)
if icons:
trademarks.append(icons)
return {
Expand All @@ -85,9 +93,20 @@ def notices_html(provider, generated_at, b=BLOCKS, egress=False):
}


def _link_markup(label, url, color):
# A link that is not its own address prints the address too, for print
if label == url:
return link_markup(url, color)
return f"{escape(label)} ({link_markup(url, color)})"


def parts_markup(parts, color):
return "".join(
link_markup(part[1], color) if isinstance(part, tuple) else escape(part)
(
_link_markup(part[0], part[1], color)
if isinstance(part, tuple)
else escape(part)
)
for part in parts
)

Expand Down
Loading
Loading