+
+
+
{{ a.document.heading }}
+
+ {% for label, value, mono in a.document.rows %}| {{ label }} | {{ value }} |
+ {% endfor %}
+
-
-
{{ summary.glossary.heading }}
-
{{ summary.glossary.lead }}
-
- {% for term, definition in summary.glossary.entries %}
- - {{ term }}
- - {{ definition }}
+
+
{{ a.scope.heading }}
+
+ {% for label, value, mono in a.scope.rows %}| {{ label }} | {{ value }} |
{% endfor %}
-
+
+
+
+
{{ a.provenance.heading }}
+
+ {% if a.provenance.run_id %}| {{ a.provenance.run_id[0] }} | {{ a.provenance.run_id[1] }} |
{% endif %}
+ | {{ a.provenance.catalogue[0] }} | {{ a.provenance.catalogue[1] }}{% if a.provenance.catalogue[2] %} · {{ a.provenance.catalogue[2] }}{% endif %} |
+ | {{ a.provenance.platform[0] }} | {{ parts(a.provenance.platform[1]) }} |
+
-
-
{{ summary.closing.heading }}
-
{{ summary.closing.lead }}
- {% if summary.closing.uploaded %}
- {% if summary.closing.url %}
-
- {{ summary.closing.url }}
-
- {% elif summary.closing.assessment_id %}
-
Assessment ID: {{ summary.closing.assessment_id }}
- {% endif %}
- {% else %}
+
+ {{ a.stored }}
+
+
+ {{ a.purpose.summary }}{{ a.purpose.meta }}
+
+ {% for paragraph in a.purpose.paragraphs %}
{{ paragraph }}
+ {% endfor %}
- {% for term, text in summary.closing.points %}
- - {{ term }} {{ text }}
+ {% for name, text in a.purpose.phases %}- {{ name }} {{ text }}
{% endfor %}
-
{{ summary.closing.how_heading }}
-
{{ summary.closing.how }}
-
{{ summary.closing.command }}
-
- {% for name, url in summary.closing.sites %}
- {{ url }}{% if not loop.last %} · {% endif %}
- {% endfor %}
-
- {% endif %}
+
{{ a.purpose.closing }}
-
-
{{ summary.notices.heading }}
- {% for block in summary.notices.blocks %}
-
{{ block.heading }}
- {% for paragraph in block.paragraphs %}
-
- {% for part in paragraph %}{% if part is string %}{{ part }}{% else %}{{ part[0] }}{% endif %}{% endfor %}
-
+
+
+ {{ a.limitations.summary }}
+
+ {% for paragraph in a.limitations.paragraphs %}
{{ paragraph }}
{% endfor %}
- {% endfor %}
-
{{ summary.notices.copyright }}
-
- {% endif %}
-
-
+
+
+ {{ a.severity.summary }}{{ a.severity.meta }}
+
+
+ | {{ a.severity.headers[0] }} | {{ a.severity.headers[1] }} |
+
+ {% for level, label, meaning in a.severity.rows %}| {{ label }} | {{ meaning }} |
+ {% endfor %}
+
+
+
{{ a.severity.note }}
+
+
+
+ {{ a.glossary.summary }}{{ a.glossary.meta }}
+
+
+ {% for term, definition in a.glossary.entries %}| {{ term }} | {{ definition }} |
+ {% endfor %}
+
+
+
+
+
+
+ {% set n = page.notices %}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/cloudexit/cli/app.py b/cloudexit/cli/app.py
index 69e218c..1c44cbf 100644
--- a/cloudexit/cli/app.py
+++ b/cloudexit/cli/app.py
@@ -29,7 +29,6 @@
create_subscription_client,
)
from cloudexit.egress.estimate import estimate_egress
-from cloudexit.report.egress import generate_egress_html_report
from cloudexit.platform.client import ApiError, check_key
from cloudexit.platform.run_output import build_run_json, exit_code_of
from cloudexit.platform.status import host_catalogue_lines, run_status
@@ -988,7 +987,6 @@ def run_assessment(
egress_failed = False
egress_collected = False
egress_json_path = None
- egress_html_path = None
if egress:
console.print("Stage #5 – Egress Estimation", style="bold")
@@ -1007,25 +1005,6 @@ def run_assessment(
print_step("Estimating egress data...", status="ok")
egress_json_path = egress_result.get("json_path")
egress_collected = True
-
- with console.status("Generating egress report...", spinner="dots"):
- egress_report_result = generate_egress_html_report(
- report_path,
- egress_json_path,
- config["providerDetails"],
- sync_id,
- )
-
- if egress_report_result["success"]:
- print_step("Generating egress report...", status="ok")
- egress_html_path = egress_report_result.get("html_path")
- else:
- print_step(
- "Generating egress report...",
- status="error",
- logs=egress_report_result["logs"],
- )
- egress_failed = True
else:
print_step(
"Estimating egress data...",
@@ -1219,8 +1198,6 @@ def run_assessment(
json_report_path = report_status.get("reports", {}).get("JSON")
if json_report_path:
console.print(f"JSON Report: {json_report_path}", style="cyan")
- if egress_html_path:
- console.print(f"Egress Report: {egress_html_path}", style="cyan")
if egress_pdf_path:
console.print(f"Egress PDF: {egress_pdf_path}", style="cyan")
console.print("-------------------------------------------")
diff --git a/cloudexit/engine.py b/cloudexit/engine.py
index f5da2a8..5507910 100644
--- a/cloudexit/engine.py
+++ b/cloudexit/engine.py
@@ -27,9 +27,9 @@
build_context,
collect_provenance,
)
+from cloudexit.report.egress import load_egress_estimate
from cloudexit.report.egress_report import generate_egress_report
from cloudexit.report.executive_summary import generate_executive_summary
-from cloudexit.report.html_sections import html_sections
from cloudexit.report.writers import (
generate_html_report,
generate_json_report,
@@ -499,25 +499,22 @@ def generate_report(
provenance=provenance,
report_path=report_path,
)
+ # The estimate is read once, for the page and for the egress PDF. When
+ # it cannot be read there is no egress in any report, and the failure
+ # is reported with the egress PDF's.
+ egress_estimate, egress_error = None, None
if egress_json_path:
- context.egress_document_id = context.document_id_for(EGRESS_DOCUMENT_CODE)
+ try:
+ egress_estimate = load_egress_estimate(report_path, egress_json_path)
+ context.egress_document_id = context.document_id_for(
+ EGRESS_DOCUMENT_CODE
+ )
+ except Exception as e:
+ logger.error("Error reading the egress estimate: %s", e, exc_info=True)
+ egress_error = str(e)
# Generate HTML report
- reports["HTML"] = generate_html_report(
- report_path,
- metadata,
- resource_type_mapping,
- resource_inventory,
- cost_data,
- scoring_data,
- risk_data,
- risk_definitions,
- alternatives,
- alternative_technologies,
- exit_strategy,
- alternative_technology_organizations,
- summary=html_sections(context),
- )
+ reports["HTML"] = generate_html_report(context, egress_estimate)
# Generate PDF report
reports["PDF"] = generate_executive_summary(context)
@@ -542,10 +539,12 @@ def generate_report(
# The Egress & Data Landscape is written last, from the same context: a
# problem with it never costs the Executive Summary
- if egress_json_path:
+ if egress_error:
+ result["egress_pdf_logs"] = egress_error
+ elif egress_estimate is not None:
try:
reports["EGRESS_PDF"] = generate_egress_report(
- context, egress_json_path
+ context, egress_json_path, estimate=egress_estimate
)
except Exception as e:
logger.error("Error generating the egress PDF: %s", e, exc_info=True)
diff --git a/cloudexit/report/aggregate.py b/cloudexit/report/aggregate.py
index c983a26..ea24404 100644
--- a/cloudexit/report/aggregate.py
+++ b/cloudexit/report/aggregate.py
@@ -80,29 +80,6 @@ def sort_cost_data(cost_data: list[dict[str, Any]]) -> list[dict[str, Any]]:
return sorted(cost_data, key=lambda x: datetime.strptime(x["month"], "%Y-%m-%d"))
-def summarize_costs(
- cost_data: list[dict[str, Any]], *, last_n: int | None = None
-) -> tuple[list[str], list[float], float, str, str]:
- sorted_costs = sort_cost_data(cost_data)
- if last_n is not None:
- sorted_costs = sorted_costs[-last_n:]
-
- months = [
- datetime.strptime(item["month"], "%Y-%m-%d").strftime("%b")
- for item in sorted_costs
- ]
- values = [item["cost"] for item in sorted_costs]
- total_cost = round(sum(values), 2)
-
- if sorted_costs:
- currency_code = sorted_costs[0].get("currency", "USD")
- else:
- currency_code = "USD"
- currency_symbol = CURRENCY_SYMBOLS.get(currency_code, currency_code)
-
- return months, values, total_cost, currency_code, currency_symbol
-
-
def summarize_risks(
risk_data: list[dict[str, Any]],
risk_definitions: list[dict[str, Any]],
diff --git a/cloudexit/report/assets.py b/cloudexit/report/assets.py
index 0bb3589..784d0dd 100644
--- a/cloudexit/report/assets.py
+++ b/cloudexit/report/assets.py
@@ -43,7 +43,7 @@ def copy_assets(report_path: str, cloud_service_provider: int) -> None:
# Create the 'assets' directory if it doesn't exist
os.makedirs(assets_path, exist_ok=True)
- for folder in ("css", "img"):
+ for folder in ("css", "fonts", "img", "js"):
src_path = asset_path(folder)
dest_path = os.path.join(assets_path, folder)
diff --git a/cloudexit/report/context.py b/cloudexit/report/context.py
index 9f24c96..da43ca6 100644
--- a/cloudexit/report/context.py
+++ b/cloudexit/report/context.py
@@ -38,6 +38,8 @@ 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
+ upload_reason: str | None = None
@property
def run_short_id(self):
@@ -77,6 +79,9 @@ 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_reason=(
+ upload.get("reason") if upload.get("status") != "uploaded" else None
+ ),
)
@@ -87,6 +92,9 @@ class ResourceTypeEntry:
icon: str | None
resources: int = 0
locations: set = field(default_factory=set)
+ location_counts: dict = field(default_factory=dict)
+ # The icon as the catalogue names it, relative to the report's assets
+ icon_rel: str = ""
@dataclass(frozen=True)
@@ -127,6 +135,7 @@ class AlternativesEntry:
icon: str | None
resources: int
alternatives: list = field(default_factory=list)
+ icon_rel: str = ""
@dataclass(frozen=True)
@@ -197,11 +206,16 @@ def _resource_entries(resource_inventory, resource_type_mapping, report_path):
key,
(info.get("name") or "").strip() or "–",
_icon_path(report_path, info.get("icon")),
+ icon_rel=str(info.get("icon") or "").lstrip("/"),
),
)
- entry.resources += int(row.get("count") or 0)
+ count = int(row.get("count") or 0)
+ entry.resources += count
if row.get("location"):
entry.locations.add(row["location"])
+ entry.location_counts[row["location"]] = (
+ entry.location_counts.get(row["location"], 0) + count
+ )
return sorted(
entries.values(), key=lambda entry: (-entry.resources, entry.name.lower())
)
@@ -296,6 +310,7 @@ def _alternative_entries(
resource.icon,
resource.resources,
found,
+ icon_rel=resource.icon_rel,
)
)
return entries
diff --git a/cloudexit/report/egress.py b/cloudexit/report/egress.py
index d4ca4a0..099eacd 100644
--- a/cloudexit/report/egress.py
+++ b/cloudexit/report/egress.py
@@ -4,12 +4,9 @@
import os
import sqlite3
from collections import Counter
-from datetime import datetime
from typing import Any
-from jinja2 import Environment
from cloudexit.report.helpers import format_gib, gib_number, initials, share_label
-from cloudexit.resources import asset_path
from cloudexit.catalogue.db import load_data
from cloudexit.egress.model import (
GIB,
@@ -18,14 +15,8 @@
)
from cloudexit.providers.aws.egress import ARCHIVE_TIERS as AWS_ARCHIVE_TIERS
from cloudexit.providers.azure.egress import ARCHIVE_TIERS as AZURE_ARCHIVE_TIERS
-from cloudexit.report.context import (
- DOCUMENT_CODE,
- EGRESS_DOCUMENT_CODE,
- PROVIDER_NAMES,
- format_document_id,
- scope_facts,
-)
-from cloudexit.report.notices import notices_html, text_parts
+from cloudexit.report.context import PROVIDER_NAMES
+from cloudexit.report.notices import text_parts
from cloudexit.report.sections import BOOK_DEMO_URL, PRODUCT_SITES
from cloudexit.report.wording import BLOCKS
@@ -37,39 +28,6 @@
ARCHIVE_TIERS_BY_CSP = {1: AZURE_ARCHIVE_TIERS, 2: AWS_ARCHIVE_TIERS}
-CATEGORIES = {
- "object": {
- "label": "Object Storage",
- "badge": "Object",
- "color": "rgba(34, 197, 94, 1)",
- "in_allocation": True,
- },
- "block": {
- "label": "Block (allocated)",
- "badge": "Block",
- "color": "rgba(83, 155, 255, 1)",
- "in_allocation": True,
- },
- "file": {
- "label": "File Storage",
- "badge": "File",
- "color": "rgba(245, 158, 11, 1)",
- "in_allocation": True,
- },
- "database": {
- "label": "Databases",
- "badge": "Database",
- "color": "rgba(168, 85, 247, 1)",
- "in_allocation": True,
- },
- "backup": {
- "label": None,
- "badge": "Backup",
- "color": None,
- "in_allocation": False,
- },
-}
-
PRICING_SOURCE_LINKS = {
1: {
"internet_egress": "https://azure.microsoft.com/pricing/details/bandwidth/",
@@ -183,7 +141,11 @@ def build_fee_estimate(
def format_fee(fee: float | None) -> str:
if fee is None:
return "n/a"
- return f"${fee:,.2f}"
+ return f"${fee_number(fee)}"
+
+
+def fee_number(fee: float) -> str:
+ return f"{fee:,.2f}"
def _free_tier(
@@ -218,6 +180,23 @@ def free_allowance_bytes(
return tier["tier_to"] * _unit_divisor(tier["unit"]) if tier else None
+def free_tier_headroom(
+ prices: list[dict[str, Any]], cloud_service_provider: int, known_bytes: int
+) -> dict[str, Any] | None:
+ tier = _free_tier(prices, cloud_service_provider)
+ if tier is None:
+ return None
+ limit = tier["tier_to"]
+ used = known_bytes / _unit_divisor(tier["unit"])
+ return {
+ "unit": tier["unit"],
+ "limit": limit,
+ "used": used,
+ "left": max(0.0, limit - used),
+ "used_share": min(100.0, used / limit * 100) if limit else 100.0,
+ }
+
+
def _summarize_totals(
totals: dict[str, Any],
total_fee: float,
@@ -271,29 +250,6 @@ def _resolve_icon(resource_type: str, icon_lookup: dict[str, str]) -> str | None
return FALLBACK_ICONS.get(code)
-def _build_allocation(rows: list[dict[str, Any]]) -> tuple[list, list, list]:
- allocation_categories = {
- key: info for key, info in CATEGORIES.items() if info["in_allocation"]
- }
- totals_by_category: dict[str, int] = {}
- for row in rows:
- if row["size_bytes"] is not None:
- category = row.get("category")
- if category in allocation_categories:
- totals_by_category[category] = (
- totals_by_category.get(category, 0) + row["size_bytes"]
- )
-
- labels, values, colors = [], [], []
- for category, info in allocation_categories.items():
- size_bytes = totals_by_category.get(category, 0)
- if size_bytes > 0:
- labels.append(info["label"])
- values.append(size_bytes)
- colors.append(info["color"])
- return labels, values, colors
-
-
def _build_type_groups(
rows: list[dict[str, Any]],
fees_by_id: dict[str, float | None],
@@ -307,10 +263,6 @@ def _build_type_groups(
"label": row["label"],
"icon": _resolve_icon(row["type"], icon_lookup),
"initials": initials(row["label"]),
- "category": row["category"],
- "category_label": (
- CATEGORIES.get(row["category"], {}).get("badge") or row["category"]
- ),
"size_bytes": None,
"fee": None,
"resources": [],
@@ -383,14 +335,6 @@ def load_egress_estimate(report_path: str, json_path: str) -> dict[str, Any]:
}
-def _generated_at(timestamp):
- # The run's own time, as the estimate stored it; now when it cannot be read
- try:
- return datetime.strptime(str(timestamp), "%Y-%m-%d %H:%M:%S UTC")
- except ValueError:
- return datetime.now()
-
-
def _share(part, whole):
return part / whole * 100 if whole else 0
@@ -765,78 +709,3 @@ def next_steps_facts(
)
)
return {"heading": b["eg_heading_next_steps"], "paragraphs": paragraphs}
-
-
-def generate_egress_html_report(
- report_path: str,
- json_path: str,
- provider_details: dict[str, Any] | None = None,
- run_id: str | None = None,
-) -> dict[str, Any]:
- try:
- estimate = load_egress_estimate(report_path, json_path)
- meta, rows = estimate["meta"], estimate["rows"]
- csp = meta["cloud_service_provider"]
- summary = estimate["summary"]
- b = BLOCKS
-
- allocation_labels, allocation_values, allocation_colors = _build_allocation(
- rows
- )
-
- type_groups = estimate["type_groups"]
- fee_groups = [group for group in type_groups if (group["fee"] or 0.0) > 0]
- fee_labels = [group["label"] for group in fee_groups]
- fee_values = [round(group["fee"], 2) for group in fee_groups]
-
- template_content = asset_path("template", "egress.html").read_text(
- encoding="utf-8"
- )
-
- scope = scope_facts(provider_details or {}, csp, None)
- executive_id = format_document_id(DOCUMENT_CODE, run_id)
- env = Environment(autoescape=True)
- template = env.from_string(template_content)
- html_content = template.render(
- page_title=f"{b['cover_title_egress']} – {meta['name']}",
- document_id=format_document_id(EGRESS_DOCUMENT_CODE, run_id),
- name=meta["name"],
- cloud_service_provider=csp,
- assessment_type=meta["assessment_type"],
- timestamp=meta["timestamp"],
- total_data_display=summary["total_data_display"],
- total_fee_display=summary["total_fee_display"],
- unknown_count=summary["unknown_count"],
- has_allocation=bool(allocation_values),
- allocation_labels_json=json.dumps(allocation_labels),
- allocation_values_json=json.dumps(allocation_values),
- allocation_colors_json=json.dumps(allocation_colors),
- has_fees=bool(fee_values),
- fee_labels_json=json.dumps(fee_labels),
- fee_values_json=json.dumps(fee_values),
- free_tier=summary["free_tier"],
- free_tier_limit_display=summary["free_tier_limit"],
- known_data_display=format_gib(estimate["totals"]["known_size_bytes"]),
- type_groups=type_groups,
- introduction=introduction_facts(scope, b),
- coverage=coverage_facts(csp, b),
- landscape=landscape_facts(estimate, b),
- costs=cost_facts(estimate, b),
- pricing=pricing_facts(estimate, b),
- next_steps=next_steps_facts(executive_id, b=b),
- notices=notices_html(csp, _generated_at(meta["timestamp"]), b, egress=True),
- )
-
- html_path = os.path.join(report_path, "egress.html")
- with open(html_path, "w", encoding="utf-8") as report_file:
- report_file.write(html_content)
-
- return {
- "success": True,
- "logs": "Egress report generated successfully.",
- "html_path": html_path,
- }
-
- except Exception as e:
- logger.error(f"Error generating egress report: {str(e)}", exc_info=True)
- return {"success": False, "logs": str(e)}
diff --git a/cloudexit/report/egress_report.py b/cloudexit/report/egress_report.py
index 6acc846..8e108f6 100644
--- a/cloudexit/report/egress_report.py
+++ b/cloudexit/report/egress_report.py
@@ -48,10 +48,10 @@ def build_egress_content(context, estimate, styles, b):
return content
-def generate_egress_report(context, json_path, path=None):
+def generate_egress_report(context, json_path, path=None, estimate=None):
path = path or os.path.join(context.report_path, "egress.pdf")
b = BLOCKS
- estimate = load_egress_estimate(context.report_path, json_path)
+ estimate = estimate or load_egress_estimate(context.report_path, json_path)
cover = RecordCover(
b["cover_title_egress"],
context,
diff --git a/cloudexit/report/evidence.py b/cloudexit/report/evidence.py
index 63600e3..e0ea97e 100644
--- a/cloudexit/report/evidence.py
+++ b/cloudexit/report/evidence.py
@@ -53,54 +53,62 @@ def build_document_table(
return key_value_table(rows, context.appearance)
-def _scope_rows(context, b):
+def scope_rows(context, b):
+ # (label, text, monospaced), the same for the PDF and the HTML report
scope = context.scope
values = dict(scope.rows)
- rows = [(b["ev_cloud_provider"], scope.provider or b["ev_dash"])]
+ rows = [(b["ev_cloud_provider"], scope.provider or b["ev_dash"], False)]
if scope.kind == "aws":
if values.get("account"):
- rows.append((b["ev_account_id"], _mono_value(values["account"])))
- rows.append((b["ev_region"], values.get("region") or b["ev_dash"]))
+ rows.append((b["ev_account_id"], values["account"], True))
+ rows.append((b["ev_region"], values.get("region") or b["ev_dash"], False))
elif scope.kind == "azure":
+ rows.append((b["ev_tenant_id"], values.get("tenant") or b["ev_dash"], True))
rows.append(
- (b["ev_tenant_id"], _mono_value(values.get("tenant")) or b["ev_dash"])
+ (b["ev_subscription_id"], values.get("subscription") or b["ev_dash"], True)
)
rows.append(
(
- b["ev_subscription_id"],
- _mono_value(values.get("subscription")) or b["ev_dash"],
+ b["ev_resource_group"],
+ values.get("resource_group") or b["ev_dash"],
+ False,
)
)
- rows.append(
- (b["ev_resource_group"], values.get("resource_group") or b["ev_dash"])
- )
else:
file_text = values.get("file") or b["ev_dash"]
if values.get("sha256"):
file_text = f"{file_text} (SHA-256 {_short(values['sha256'])})"
- rows.append((b["ev_file"], file_text))
+ rows.append((b["ev_file"], file_text, False))
if values.get("lineage"):
state = values["lineage"]
if values.get("serial") is not None:
state = f"{state} (serial {values['serial']})"
- rows.append((b["ev_state"], _mono_value(state)))
+ rows.append((b["ev_state"], state, True))
for key, label in (
("subscriptions", "ev_subscriptions"),
("resource_groups", "ev_resource_groups"),
("regions", "ev_regions"),
):
if key in values:
- rows.append((b[label], values[key] or b["ev_not_determinable"]))
+ rows.append((b[label], values[key] or b["ev_not_determinable"], False))
source = b["ev_source_tfstate" if scope.kind == "tfstate" else "ev_source_live"]
rows.append(
(
b["ev_source_label"],
b["ev_source_value"].format(source=source, completed=context.completed),
+ False,
)
)
return rows
+def _scope_rows(context, b):
+ return [
+ (label, _mono_value(text) if mono and text != b["ev_dash"] else text)
+ for label, text, mono in scope_rows(context, b)
+ ]
+
+
def build_scope_table(context, b):
return key_value_table(_scope_rows(context, b), context.appearance)
diff --git a/cloudexit/report/helpers.py b/cloudexit/report/helpers.py
index b79acf4..3536541 100644
--- a/cloudexit/report/helpers.py
+++ b/cloudexit/report/helpers.py
@@ -1,3 +1,5 @@
+import os
+
CURRENCY_SYMBOLS = {"USD": "$", "EUR": "€", "GBP": "£"}
@@ -19,10 +21,13 @@ def format_money(value, currency):
return f"{symbol}{amount}" if symbol else f"{currency} {amount}"
-def gib_number(size_bytes):
- # One decimal; a size that is not zero never reads as 0.0
+def gib_number(size_bytes, places=1):
+ # A size that is not zero never reads as 0.0
gib = size_bytes / 2**30
- return "<0.1" if 0 < gib < 0.1 else f"{gib:,.1f}"
+ smallest = 10**-places
+ if 0 < gib < smallest:
+ return f"<{smallest:.{places}f}"
+ return f"{gib:,.{places}f}"
def format_gib(size_bytes):
@@ -71,3 +76,19 @@ def initials(name):
if len(letters) == 2:
break
return "".join(letters) or "?"
+
+
+REPORT_ASSETS = "assets"
+
+
+def report_icon(report_path, icon_rel):
+ # The page only points at files in its own folder: an icon the report
+ # folder does not hold is left out, and the page draws a placeholder
+ if icon_rel and os.path.isfile(os.path.join(report_path, REPORT_ASSETS, icon_rel)):
+ return f"{REPORT_ASSETS}/{icon_rel}"
+ return None
+
+
+def percent(share):
+ # With a no-break space, so a figure and its sign stay together
+ return share_label(share).replace(" ", " ")
diff --git a/cloudexit/report/html_data.py b/cloudexit/report/html_data.py
deleted file mode 100644
index 40377d5..0000000
--- a/cloudexit/report/html_data.py
+++ /dev/null
@@ -1,63 +0,0 @@
-# cloudexit/report/html_data.py
-import logging
-from typing import Any
-
-from cloudexit.report.aggregate import (
- summarize_alternative_technologies,
- summarize_costs,
- summarize_risks,
-)
-
-# Configure logger
-logger = logging.getLogger(__name__)
-
-
-def transform_cost_inventory_for_html(
- cost_data: list[dict[str, Any]],
-) -> tuple[list[str], list[float], float, str, str]:
- return summarize_costs(cost_data)
-
-
-def transform_risk_inventory_for_html(
- risk_data: list[dict[str, Any]],
- risk_definitions: list[dict[str, Any]],
- resource_inventory: dict[str, dict[str, Any]],
-) -> tuple[list[dict[str, Any]], dict[str, int]]:
- severity_order = {"high": 1, "medium": 2, "low": 3}
- resource_name_map = {
- str(key): value["name"] for key, value in resource_inventory.items()
- }
- risks, severity_counts = summarize_risks(
- risk_data,
- risk_definitions,
- resource_name_map=resource_name_map,
- )
- risks.sort(key=lambda x: severity_order.get(x["severity"], 4))
- return risks, severity_counts
-
-
-def transform_alt_tech_for_html(
- resource_inventory: list[dict[str, Any]],
- alternatives: list[dict[str, Any]],
- alternative_technologies: list[dict[str, Any]],
- exit_strategy: int,
- alternative_technology_organizations: list[dict[str, Any]] | None = None,
-) -> list[dict[str, Any]]:
- alt_tech_data = []
- grouped_alt_tech = summarize_alternative_technologies(
- resource_inventory,
- alternatives,
- alternative_technologies,
- exit_strategy,
- alternative_technology_organizations=alternative_technology_organizations,
- )
- for resource in resource_inventory:
- resource_type = str(resource.get("resource_type"))
- for tech in grouped_alt_tech.get(resource_type, []):
- alt_tech_data.append(
- {
- "resource_type_id": resource.get("resource_type"),
- **tech,
- }
- )
- return alt_tech_data
diff --git a/cloudexit/report/html_egress.py b/cloudexit/report/html_egress.py
new file mode 100644
index 0000000..fa19b09
--- /dev/null
+++ b/cloudexit/report/html_egress.py
@@ -0,0 +1,248 @@
+from .egress import (
+ DEFAULT_PRICING_ZONE,
+ coverage_facts,
+ fee_number,
+ free_tier_headroom,
+ pricing_facts,
+)
+from .context import EGRESS_DOCUMENT_CODE, PROVIDER_NAMES
+from .helpers import gib_number, percent, report_icon
+from .wording import BLOCKS
+
+SEGMENT_COLORS = (
+ "var(--primary-800)",
+ "var(--primary-600)",
+ "#2dd4c8",
+ "#5eeae1",
+ "#99f6f0",
+)
+OTHER_COLOR = "var(--neutral-300)"
+
+
+def _share(part, whole):
+ return part / whole * 100 if whole else 0
+
+
+def _internet_unit(prices, csp):
+ return next(
+ (
+ price["unit"]
+ for price in prices
+ if price["csp"] == csp
+ and price["component"] == "internet_egress"
+ and price["zone"] == DEFAULT_PRICING_ZONE
+ ),
+ "GiB",
+ )
+
+
+def _kpis(estimate, sized, unsized, groups, b):
+ summary = estimate["summary"]
+ csp = estimate["meta"]["cloud_service_provider"]
+ total = estimate["totals"]["known_size_bytes"]
+ headroom = free_tier_headroom(estimate["pricing"], csp, total)
+ plus = "+" if summary["unknown_count"] > 0 else ""
+
+ kpis = [
+ {
+ "label": b["html_eg_kpi_fee"],
+ "figure": summary["total_fee_display"],
+ "unit": "",
+ "caption": b[
+ "html_eg_fee_free" if summary["free_tier"] else "html_eg_fee_paid"
+ ],
+ "primary": True,
+ },
+ {
+ "label": b["eg_st_volume"],
+ "figure": gib_number(total),
+ "unit": f"GiB{plus}",
+ "caption": (
+ b["html_eg_types_sized"].format(sized=len(sized), types=len(groups))
+ if groups
+ else ""
+ ),
+ },
+ ]
+ if headroom:
+ kpis.append(
+ {
+ "label": b["html_eg_kpi_headroom"],
+ "figure": f"{headroom['left']:,.1f}",
+ "unit": headroom["unit"],
+ "caption": b["html_eg_headroom_left"].format(
+ limit=f"{headroom['limit']:g} {headroom['unit']}"
+ ),
+ "bar": round(headroom["used_share"]),
+ "aria": b["html_eg_headroom_aria"].format(
+ share=percent(headroom["used_share"])
+ ),
+ }
+ )
+ else:
+ kpis.append(
+ {
+ "label": b["eg_st_billable"],
+ "figure": gib_number(total),
+ "unit": "GiB",
+ "caption": b["html_eg_billable_caption"],
+ }
+ )
+ kpis.append(
+ {
+ "label": b["eg_st_not_sized"],
+ "figure": str(len(unsized)),
+ "unit": "",
+ "caption": b["html_eg_not_sized_caption"],
+ }
+ )
+ return kpis, headroom
+
+
+def _volume(sized, unsized, total, b):
+ if not sized or total <= 0:
+ return None
+ shown, rest = sized[: len(SEGMENT_COLORS)], sized[len(SEGMENT_COLORS) :]
+ segments = [
+ (
+ group["label"],
+ _share(group["size_bytes"], total),
+ SEGMENT_COLORS[index],
+ )
+ for index, group in enumerate(shown)
+ ]
+ if rest:
+ segments.append(
+ (
+ b["html_eg_vol_other"],
+ _share(sum(group["size_bytes"] for group in rest), total),
+ OTHER_COLOR,
+ )
+ )
+ legend = [
+ {"text": f"{label} {percent(share)}", "color": color}
+ for label, share, color in segments
+ ]
+ return {
+ "segments": [
+ {"flex": round(share, 1), "color": color} for _, share, color in segments
+ ],
+ "legend": legend,
+ "unsized": (
+ b["html_eg_vol_unsized"].format(
+ types=", ".join(group["label"] for group in unsized)
+ )
+ if unsized
+ else None
+ ),
+ "aria": b["html_eg_vol_aria"].format(
+ parts=", ".join(item["text"] for item in legend)
+ ),
+ }
+
+
+def _table(context, estimate, sized, unsized, total, b):
+ plus = "+" if estimate["summary"]["unknown_count"] > 0 else ""
+
+ def row(group):
+ icon = group["icon"].lstrip("/") if group["icon"] else ""
+ return {
+ "label": group["label"],
+ "initials": group["initials"],
+ "icon": report_icon(context.report_path, icon),
+ }
+
+ rows = [
+ {
+ **row(group),
+ "size": gib_number(group["size_bytes"], 2),
+ "share": percent(_share(group["size_bytes"], total)),
+ "share_value": round(_share(group["size_bytes"], total)),
+ "fee": fee_number(group["fee"] or 0.0),
+ }
+ for group in sized
+ ]
+ rows += [
+ {**row(group), "size": None, "share": None, "share_value": 0, "fee": None}
+ for group in unsized
+ ]
+ return {
+ "headers": (
+ b["eg_th_resource_type"],
+ b["eg_th_size_gib"],
+ b["eg_th_share"],
+ b["eg_th_fee"],
+ ),
+ "rows": rows,
+ "not_sized": b["html_eg_not_sized"],
+ "total": (
+ b["eg_total"],
+ f"{gib_number(total, 2)}{plus}",
+ fee_number(estimate["total_fee"]),
+ ),
+ }
+
+
+def _notes(context, estimate, headroom, b):
+ csp = estimate["meta"]["cloud_service_provider"]
+ total = estimate["totals"]["known_size_bytes"]
+ provider = PROVIDER_NAMES.get(csp) or b["eg_provider_fallback"]
+ unit = _internet_unit(estimate["pricing"], csp)
+ if unit == "GB" and headroom:
+ used_gb = total / 10**9
+ units = b["html_eg_units_gb"].format(
+ provider=provider,
+ gib=gib_number(total),
+ gb=f"{used_gb:,.1f}",
+ limit=f"{headroom['limit']:g} {headroom['unit']}",
+ )
+ elif unit == "GB":
+ units = b["html_eg_units_gb_plain"].format(provider=provider)
+ else:
+ units = b["html_eg_units_gib"].format(provider=provider)
+
+ currencies = {entry.currency for entry in context.costs}
+ if len(currencies) == 1:
+ amounts = b["html_eg_amounts_costs"].format(currency=next(iter(currencies)))
+ else:
+ amounts = b["html_eg_amounts"]
+ return [f"{units} {amounts}", b["html_eg_scope_note"]]
+
+
+def egress_page(context, estimate, b=BLOCKS):
+ csp = estimate["meta"]["cloud_service_provider"]
+ total = estimate["totals"]["known_size_bytes"]
+ groups = estimate["type_groups"]
+ sized = sorted(
+ (group for group in groups if group["size_bytes"] is not None),
+ key=lambda group: -group["size_bytes"],
+ )
+ unsized = [group for group in groups if group["size_bytes"] is None]
+ kpis, headroom = _kpis(estimate, sized, unsized, groups, b)
+ prices = pricing_facts(estimate, b)
+ return {
+ "heading": b["ev_egress_report"],
+ "caption": b["html_eg_caption"].format(
+ document_id=context.egress_document_id
+ or context.document_id_for(EGRESS_DOCUMENT_CODE)
+ ),
+ "empty": b["eg_no_data_notice"] if not estimate["rows"] else None,
+ "kpis": kpis,
+ "volume": _volume(sized, unsized, total, b),
+ "table": _table(context, estimate, sized, unsized, total, b),
+ "notes": _notes(context, estimate, headroom, b),
+ "sized": {
+ "summary": b["html_eg_sized_summary"],
+ "facts": coverage_facts(csp, b),
+ },
+ "prices": (
+ {
+ "summary": b["html_eg_prices_summary"].format(
+ provider=PROVIDER_NAMES.get(csp) or b["eg_provider_fallback"]
+ ),
+ "facts": prices,
+ }
+ if prices
+ else None
+ ),
+ }
diff --git a/cloudexit/report/html_sections.py b/cloudexit/report/html_sections.py
index a087a15..e41ef37 100644
--- a/cloudexit/report/html_sections.py
+++ b/cloudexit/report/html_sections.py
@@ -1,126 +1,854 @@
-from xml.sax.saxutils import escape
+from markupsafe import Markup
-from .brief_summary import collect_brief_facts, finding_rows, lead_text
-from .helpers import severity_label
-from .notices import notices_html
+from .evidence import scope_rows
+from .helpers import (
+ format_change,
+ initials,
+ format_money,
+ percent,
+ report_icon,
+ severity_label,
+ strategy_name,
+)
+from .html_egress import egress_page
+from .notices import text_parts
from .palette import SEVERITIES
-from .sections import egress_note, glossary_entries, scope_boundary, scope_strategy
+from .sections import (
+ AWS_ICONS_URL,
+ AWS_TRADEMARK_URL,
+ AZURE_ICONS_URL,
+ PRODUCT_SITES,
+ REPOSITORY_URL,
+)
from .wording import BLOCKS
+ALT_VISIBLE = 8
+ALT_OPEN = 2
+OPEN_RISKS_UP_TO = 3
+AFFECTED_SHOWN = 3
+REGIONS_SHOWN = 5
+PROVIDER_LOGOS = {1: "azure", 2: "aws"}
+ABOUT_URL = "#about"
-def _version(text):
- return f"
{escape(text)}"
+def _count(b, key, n):
+ return b[f"{key}_one"] if n == 1 else b[f"{key}_many"].format(n=n)
-def html_sections(context, b=BLOCKS):
- # The same wording as the PDF; strings that carry markup are already
- # escaped, so the template may mark them safe.
- facts = collect_brief_facts(context, b)
+
+def _share(part, whole):
+ return part / whole * 100 if whole else 0
+
+
+def _month(month, b):
+ return b["html_months"][int(month[5:7]) - 1]
+
+
+def _mono(text):
+ return Markup('
{}').format(text)
+
+
+def _regions_line(context, b):
+ if not context.total_resources:
+ return b["html_scope_no_resources"]
+ names = sorted(context.regions)
+ return f"{_count(b, 'scope_regions', len(names))} · {', '.join(names)}"
+
+
+def _title(context, has_egress, b):
provenance = context.provenance
+ platform_url = provenance.platform_url if provenance.uploaded else None
+ logo = PROVIDER_LOGOS.get(context.cloud_service_provider)
+ downloads = [("report.pdf", b["html_download_summary"])]
+ if has_egress:
+ downloads.append(("egress.pdf", b["html_download_egress"]))
+ return {
+ "logo": f"assets/img/csp/{logo}.svg" if logo else None,
+ "provider": context.provider,
+ "name": context.name,
+ "uploaded": provenance.uploaded,
+ "chip": b["html_chip_uploaded" if provenance.uploaded else "html_chip_self"],
+ "chip_title": b[
+ (
+ "html_chip_uploaded_title"
+ if provenance.uploaded
+ else "html_chip_self_title"
+ )
+ ],
+ "platform_url": platform_url,
+ "downloads": downloads,
+ }
+
- if provenance.catalogue_version and provenance.catalogue_sha256:
- catalogue = (
- f"{provenance.catalogue_version} · (sha256 "
- f"{provenance.catalogue_sha256[:12]})"
+def _scope_card(context, b):
+ scope = context.scope
+ values = dict(scope.rows)
+ regions = _regions_line(context, b)
+ rows = [{"label": b["ev_cloud_provider"], "value": scope.provider or b["ev_dash"]}]
+ if scope.kind == "azure":
+ rows.append(
+ {
+ "label": b["ev_resource_group"],
+ "value": scope.resource_group or b["ev_dash"],
+ "small": regions,
+ }
+ )
+ elif scope.kind == "aws":
+ rows.append(
+ {
+ "label": b["ev_region"],
+ "value": scope.region or b["ev_dash"],
+ "small": regions,
+ }
)
else:
- catalogue = provenance.catalogue_version or b["ev_catalogue_unknown"]
+ serial = values.get("serial")
+ small = " · ".join(
+ part
+ for part in (
+ b["html_scope_serial"].format(serial=serial) if serial else "",
+ regions,
+ )
+ if part
+ )
+ rows.append(
+ {
+ "label": b["ev_file"],
+ "value": scope.file_name or b["ev_dash"],
+ "small": small,
+ }
+ )
+ rows.append(
+ {
+ "label": b["cover_exit_strategy"],
+ "value": strategy_name(context.strategy_code, b),
+ }
+ )
+ provenance = context.provenance
+ source = b["html_run_tfstate" if scope.kind == "tfstate" else "html_run_live"]
+ rows.append(
+ {
+ "label": b["html_run_label"],
+ "value": context.completed,
+ "small": Markup(b["html_run_small"]).format(
+ run=_mono(provenance.run_short_id or b["ev_dash"]), source=source
+ ),
+ }
+ )
+ sha = provenance.catalogue_sha256
+ rows.append(
+ {
+ "label": b["ev_catalogue_label"],
+ "value": provenance.catalogue_version or b["ev_catalogue_unknown"],
+ "small": b["html_catalogue_sha"].format(sha=sha[:12]) if sha else None,
+ "small_mono": True,
+ }
+ )
+ return {"label": b["html_scope_label"], "rows": rows}
+
+def _risks_card(context, b):
+ risks = context.risks
+ counts = {
+ severity: sum(1 for risk in risks if risk.severity == severity)
+ for severity in SEVERITIES
+ }
return {
- "title": b["ev_executive_report"],
- "brief": {
- "heading": b["bs_heading"],
- "lead": lead_text(facts, b),
- "findings_heading": b["bs_findings_heading"],
- "headers": (b["bs_th_area"], b["bs_th_finding"], b["bs_th_details"]),
- "rows": [
+ "label": b["html_risks_label"],
+ "total": len(risks),
+ "none": b["bs_find_risks_none"] if not risks else None,
+ "aria": b["html_sev_aria"].format(
+ counts=", ".join(
+ f"{counts[severity]} {severity_label(severity, b).lower()}"
+ for severity in SEVERITIES
+ )
+ ),
+ "rows": [
+ {
+ "severity": severity,
+ "label": severity_label(severity, b),
+ "count": counts[severity],
+ "width": round(_share(counts[severity], len(risks))),
+ }
+ for severity in SEVERITIES
+ ],
+ }
+
+
+def _costs_card(context, b):
+ if context.scope.kind == "tfstate":
+ return {
+ "kind": "message",
+ "label": b["html_costs_label_short"],
+ "figure": b["ev_dash"],
+ "message": b["cost_tfstate"],
+ "hint": b["html_costs_tfstate_hint"],
+ }
+ entries = context.costs
+ if not entries or len({entry.currency for entry in entries}) > 1:
+ return {
+ "kind": "message",
+ "label": b["html_costs_label"],
+ "figure": b["ev_dash"],
+ "message": (b["cost_mixed_currency"] if entries else b["tbl_cost_no_data"]),
+ "hint": None,
+ }
+
+ currency = entries[-1].currency
+ total = sum(entry.amount for entry in entries)
+ latest = entries[-1]
+ before = entries[-2] if len(entries) > 1 else None
+ biggest = max(entry.amount for entry in entries)
+ provisional = [entry for entry in entries if not entry.final]
+
+ def label(entry):
+ return _month(entry.month, b) + ("" if entry.final else "*")
+
+ parts = [
+ b[
+ "html_costs_aria_part" if entry.final else "html_costs_aria_provisional"
+ ].format(
+ month=_month(entry.month, b), amount=format_money(entry.amount, currency)
+ )
+ for entry in entries
+ ]
+ return {
+ "kind": "figures",
+ "label": b["html_costs_label"],
+ "figure": format_money(total, currency),
+ "caption": (
+ b["html_costs_none"]
+ if total == 0
+ else b["html_costs_average"].format(
+ amount=format_money(total / len(entries), currency)
+ )
+ ),
+ "change": format_change(
+ before.amount if before else None, latest.amount
+ ).replace(" %", " %"),
+ "change_caption": (
+ b["html_costs_change"].format(
+ latest=_month(latest.month, b), previous=_month(before.month, b)
+ )
+ if before
+ else ""
+ ),
+ "bars": [
+ {
+ "month": label(entry),
+ "height": round(_share(entry.amount, biggest)) if biggest else 0,
+ "zero": entry.amount <= 0,
+ "provisional": not entry.final,
+ }
+ for entry in entries
+ ],
+ "aria": b["html_costs_aria"].format(parts=", ".join(parts)),
+ "footnote": (
+ b["html_costs_provisional"].format(month=_month(provisional[0].month, b))
+ if provisional
+ else b["html_costs_context"]
+ ),
+ }
+
+
+def _hint(b):
+ return {
+ "title": b["html_hint_title"],
+ "text": b["html_hint_text"],
+ "sites": [
+ (b["html_site_exitcloud"], dict(PRODUCT_SITES)["exitcloud"]),
+ (b["html_site_escapecloud"], dict(PRODUCT_SITES)["escapecloud"]),
+ ],
+ }
+
+
+def _scores(alternatives_total, platform_url, b):
+ on_platform = f" {b['html_scores_on_platform']}" if platform_url else ""
+ lock_text = (
+ b["html_scores_lock_text"].format(
+ alternatives=_count(b, "html_alt_n", alternatives_total)
+ )
+ if alternatives_total
+ else b["html_scores_lock_text_plain"]
+ )
+ return {
+ "platform_url": platform_url,
+ "open_platform": b["html_open_platform"],
+ "pill": b["html_scores_pill"],
+ "hint": _hint(b),
+ "grade": {
+ "title": b["html_scores_grade"],
+ "placeholder": b["ev_dash"],
+ "scale": b["html_scores_scale"],
+ "bands": b["html_scores_bands"],
+ "text": b["html_scores_grade_text"] + on_platform,
+ },
+ "lock": {
+ "title": b["html_scores_lock"],
+ "dims": [
+ (name, b["html_scores_dim_value"].format(dash=b["ev_dash"]))
+ for name in b["html_scores_dims"]
+ ],
+ "text": lock_text + on_platform,
+ },
+ }
+
+
+def _risks(context, b):
+ risks = context.risks
+ names = {entry.resource_type_id: entry.name for entry in context.resources}
+ wide = sum(1 for risk in risks if risk.scope_wide)
+ if not risks:
+ caption = b["html_risks_none_caption"]
+ else:
+ count = _count(b, "html_risks_count", len(risks))
+ if not wide:
+ caption = b["html_risks_caption_only_count"].format(count=count)
+ else:
+ if wide == len(risks):
+ key = "html_risks_wide_sole" if wide == 1 else "html_risks_wide_all"
+ else:
+ key = "html_risks_wide_one" if wide == 1 else "html_risks_wide_many"
+ caption = b["html_risks_caption"].format(
+ count=count, scope=b[key].format(n=wide)
+ )
+
+ def affected(risk):
+ if risk.scope_wide:
+ return None
+ listed = sorted(
+ names.get(type_id, b["ev_dash"]) for type_id in risk.resource_type_ids
+ )
+ text = ", ".join(listed[:AFFECTED_SHOWN])
+ if len(listed) > AFFECTED_SHOWN:
+ text += " " + b["html_risks_more"].format(n=len(listed) - AFFECTED_SHOWN)
+ return text
+
+ groups, position = [], 0
+ for severity in SEVERITIES:
+ members = [risk for risk in risks if risk.severity == severity]
+ rows = []
+ for risk in members:
+ position += 1
+ rows.append(
{
- "area": area,
- "finding": finding,
- "details": details,
- "attention": attention,
+ "id": f"risk-{position}",
+ "code": risk.code,
+ "name": risk.name,
+ "description": risk.description,
+ "severity": severity,
+ "label": severity_label(severity, b),
+ "affected": affected(risk),
}
- for area, finding, details, attention in finding_rows(facts, b)
- ],
- "caption": b["bs_caption"].format(
- version=_version(facts.version), completed=escape(facts.completed)
+ )
+ if rows:
+ groups.append(
+ {
+ "heading": b["risk_group"].format(
+ severity=severity_label(severity, b), n=len(rows)
+ ),
+ "rows": rows,
+ }
+ )
+ return {
+ "heading": b["html_nav_risks"],
+ "caption": caption,
+ "headers": (
+ b["risk_th_code"],
+ b["risk_th_risk"],
+ b["risk_th_severity"],
+ b["html_risks_th_affected"],
+ ),
+ "scope_wide": b["risk_scope_wide"],
+ "groups": groups,
+ "open": len(risks) <= OPEN_RISKS_UP_TO,
+ "footnote": text_parts(
+ b["html_risks_footnote"], {"link": (b["html_nav_about"], ABOUT_URL)}
+ ),
+ "empty": (
+ {
+ "title": b["bs_find_risks_none"],
+ "text": b[
+ (
+ "html_risks_empty_clean"
+ if context.total_resources
+ else "html_risks_empty_scope"
+ )
+ ],
+ }
+ if not risks
+ else None
+ ),
+ }
+
+
+def _inventory(context, b):
+ total = context.total_resources
+ scope = context.scope
+ regions = {}
+ for entry in context.resources:
+ for location, count in entry.location_counts.items():
+ regions[location] = regions.get(location, 0) + count
+ ranked = sorted(regions.items(), key=lambda item: (-item[1], item[0]))
+ shown = [
+ f"{name} {percent(_share(count, total))}"
+ for name, count in ranked[:REGIONS_SHOWN]
+ ]
+ if len(ranked) > REGIONS_SHOWN:
+ shown.append(b["html_risks_more"].format(n=len(ranked) - REGIONS_SHOWN))
+ caption_parts = [_count(b, "scope_resources", total)]
+ if total:
+ caption_parts += [
+ _count(b, "scope_types", len(context.resources)),
+ b["html_inv_regions"].format(
+ regions=_count(b, "scope_regions", len(regions)),
+ shares=", ".join(shown),
),
- },
- "provenance": {
- "heading": b["ev_provenance_heading"],
+ ]
+ if scope.kind == "tfstate":
+ caption_parts.append(b["html_inv_not_managed"])
+
+ if scope.kind == "azure":
+ empty = b["html_inv_empty_resource_group"].format(
+ scope=scope.resource_group or b["ev_dash"]
+ )
+ elif scope.kind == "aws":
+ empty = b["html_inv_empty_region"].format(scope=scope.region or b["ev_dash"])
+ else:
+ empty = b["html_inv_empty_tfstate"].format(
+ scope=scope.file_name or b["ev_dash"]
+ )
+
+ return {
+ "heading": b["es_heading_resource_inventory"],
+ "caption": " · ".join(caption_parts),
+ "headers": (
+ b["res_th_resource_type"],
+ b["html_inv_th_locations"],
+ b["res_th_share"],
+ b["res_th_resources"],
+ ),
+ "rows": [
+ {
+ "name": entry.name,
+ "initials": initials(entry.name),
+ "icon": report_icon(context.report_path, entry.icon_rel),
+ "locations": ", ".join(
+ f"{name} · {count}"
+ for name, count in sorted(
+ entry.location_counts.items(),
+ key=lambda item: (-item[1], item[0]),
+ )
+ ),
+ "share": percent(_share(entry.resources, total)),
+ "share_value": round(_share(entry.resources, total)),
+ "count": entry.resources,
+ }
+ for entry in context.resources
+ ],
+ "total": (b["res_total"], total),
+ "footnote": b["es_res_caption"],
+ "empty": (
+ {"title": b["html_inv_empty_title"], "text": empty} if not total else None
+ ),
+ }
+
+
+def _alternatives(context, b):
+ entries = sorted(
+ context.alternatives,
+ key=lambda entry: (
+ not entry.alternatives,
+ -len(entry.alternatives),
+ -entry.resources,
+ entry.name.lower(),
+ ),
+ )
+ total = sum(len(entry.alternatives) for entry in entries)
+ with_alternatives = sum(1 for entry in entries if entry.alternatives)
+ provenance = context.provenance
+
+ groups = []
+ for position, entry in enumerate(entries):
+ rows = [
+ {
+ "name": alt.name,
+ "vendor": alt.vendor or b["ev_dash"],
+ "cc": alt.country or "",
+ "os": alt.open_source,
+ "sp": alt.support_plan,
+ "extra": index >= ALT_VISIBLE,
+ }
+ for index, alt in enumerate(entry.alternatives)
+ ]
+ resources = _count(b, "scope_resources", entry.resources)
+ hidden = max(0, len(rows) - ALT_VISIBLE)
+ groups.append(
+ {
+ "name": entry.name,
+ "initials": initials(entry.name),
+ "icon": report_icon(context.report_path, entry.icon_rel),
+ "meta": (
+ b["html_alt_meta"].format(
+ resources=resources,
+ alternatives=_count(b, "html_alt_n", len(rows)),
+ )
+ if rows
+ else b["html_alt_meta_none"].format(resources=resources)
+ ),
+ "rows": rows,
+ "open": position < ALT_OPEN and bool(rows),
+ "hidden": hidden,
+ "more": (_count_more(b, hidden) if hidden else None),
+ }
+ )
+
+ if not entries:
+ caption = _count(b, "html_alt_n", 0)
+ else:
+ key = "html_alt_caption" if len(entries) != 1 else "html_alt_caption_one_type"
+ caption = b[key].format(
+ alternatives=_count(b, "html_alt_n", total),
+ with_alternatives=with_alternatives,
+ types=len(entries),
+ )
+ return {
+ "heading": b["es_heading_alternative_technologies"],
+ "caption": caption,
+ "total": total,
+ "search": b["html_alt_search"],
+ "filters": [
+ ("os", b["html_filter_os"]),
+ ("sp", b["html_filter_sp"]),
+ ("eu", b["html_filter_eu"]),
+ ],
+ "headers": (b["html_alt_th_name"], b["html_filter_os"], b["html_filter_sp"]),
+ "yes": b["html_yes"],
+ "no": b["html_no"],
+ "no_match": b["html_alt_no_match"],
+ "groups": groups,
+ "footnote": b["html_alt_footnote"].format(
+ version=provenance.catalogue_version or b["ev_catalogue_unknown"]
+ ),
+ "empty": (
+ {"title": b["html_alt_empty_title"], "text": b["html_alt_empty_text"]}
+ if not entries
+ else None
+ ),
+ }
+
+
+def _count_more(b, n):
+ return b["html_alt_more_one"] if n == 1 else b["html_alt_more_many"].format(n=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)
+ ]
+ return [b["ev_platform_none"]]
+ parts = [b["ev_platform_uploaded"]]
+ if provenance.platform_url:
+ label = provenance.platform_url.split("://", 1)[-1]
+ if len(label) > 40:
+ label = label[:40] + "…"
+ return [f"{parts[0]}: ", (label, provenance.platform_url)]
+ if provenance.assessment_id:
+ parts.append(b["fa_uploaded_id"].format(assessment_id=provenance.assessment_id))
+ return [" · ".join(parts)]
+
+
+def _scope_table(context, b):
+ rows = scope_rows(context, b)
+ if context.scope.kind != "tfstate":
+ return rows
+ # A state file names no tenant, subscription or resource group of a run: its
+ # own file and state, and the regions of what it holds
+ dropped = {b["ev_subscriptions"], b["ev_resource_groups"]}
+ rows = [row for row in rows if row[0] not in dropped]
+ if not any(row[0] == b["ev_regions"] for row in rows):
+ regions = ", ".join(sorted(context.regions)) or b["ev_not_determinable"]
+ rows.insert(-1, (b["ev_regions"], regions, False))
+ return rows
+
+
+def _about(context, egress, b):
+ provenance = context.provenance
+ tfstate = context.scope.kind == "tfstate"
+ sha = provenance.catalogue_sha256
+ glossary = [
+ entry
+ for entry in b["html_glossary"]
+ if (entry["id"] != "aws" or context.cloud_service_provider == 2)
+ and (entry["id"] not in ("egress", "free_tier", "gib_gb") or egress is not None)
+ ]
+ phases = [
+ (
+ name,
+ b["html_method_discovery_tfstate"] if tfstate and index == 0 else text,
+ )
+ for index, (name, text) in enumerate(b["es_method_phases"])
+ ]
+ return {
+ "heading": b["html_nav_about"],
+ "caption": b["html_about_caption"],
+ "document": {
+ "heading": b["html_about_document"],
"rows": [
+ (b["ev_document_id"], context.document_id, True),
+ (b["ev_document_type"], b["ev_executive_report"], False),
+ (b["ev_language"], b["language_name"], False),
(
b["ev_collected_by"],
b["ev_collected_by_value"].format(
version=provenance.collector_version
),
+ False,
),
- (b["ev_collected_at"], provenance.collected_date),
- *([(b["ev_run_id"], provenance.run_id)] if provenance.run_id else []),
- (b["ev_catalogue_label"], catalogue),
(
- b["ev_platform_label"],
- (
- b["ev_platform_uploaded"]
- if provenance.uploaded
- else b["ev_platform_none"]
- ),
+ b["ev_generated_at"],
+ provenance.generated_at.strftime("%Y-%m-%d %H:%M UTC"),
+ False,
),
],
- "platform_url": provenance.platform_url,
- "document_id": context.document_id,
},
- "costs_egress_note": egress_note(context, b),
- "introduction": {
- "heading": b["es_heading_introduction"],
- "purpose_heading": b["es_heading_purpose"],
- "purpose": b["es_purpose_block"],
- "scope_heading": b["es_heading_scope"],
- "scope": [scope_boundary(context, b), scope_strategy(context, b)],
- "method_heading": b["es_heading_method"],
- "method": [b["es_method_lead"], b["es_method_phases_lead"]],
- "phases": b["es_method_phases"],
- "method_catalogue": b["es_method_catalogue"],
- "limitations_heading": b["es_heading_limitations"],
- "limitations": [b["err_limitations"], b["err_limitations_data"]],
+ "scope": {
+ "heading": b["ev_assessment_scope_heading"],
+ "rows": _scope_table(context, b),
+ },
+ "provenance": {
+ "heading": b["ev_provenance_heading"],
+ "run_id": (
+ (b["ev_run_id"], provenance.run_id) if provenance.run_id else None
+ ),
+ "catalogue": (
+ b["ev_catalogue_label"],
+ provenance.catalogue_version or b["ev_catalogue_unknown"],
+ b["html_catalogue_sha"].format(sha=sha[:12]) if sha else None,
+ ),
+ "platform": (b["ev_platform_label"], _platform_cell(context, b)),
+ },
+ "stored": b["ev_stored_footnote"].format(
+ run=provenance.run_short_id or b["ev_dash"]
+ ),
+ "purpose": {
+ "summary": b["html_about_purpose"],
+ "meta": b["html_about_phases_meta"].format(n=len(phases)),
+ "paragraphs": [b["es_purpose_block"], b["html_method_lead"]],
+ "phases": phases,
+ "closing": b["html_method_catalogue"],
+ },
+ "limitations": {
+ "summary": b["es_heading_limitations"],
+ "paragraphs": [b["err_limitations"], b["html_limitations_data"]],
},
"severity": {
- "heading": b["es_heading_severity"],
- "lead": [b["es_severity_lead"], b["es_severity_note"]],
+ "summary": b["es_heading_severity"],
+ "meta": b["html_about_levels_meta"].format(n=len(SEVERITIES)),
"headers": (b["risk_th_severity"], b["es_severity_th_meaning"]),
"rows": [
- (severity_label(level, b), b[f"es_severity_{level}"])
+ (level, severity_label(level, b), b[f"es_severity_{level}"])
for level in SEVERITIES
],
+ "note": b["es_severity_note"],
},
"glossary": {
- "heading": b["es_heading_glossary"],
- "lead": b["es_glossary_block"],
+ "summary": b["es_heading_glossary"],
+ "meta": b["html_about_terms_meta"].format(n=len(glossary)),
"entries": [
(entry["term"], entry["definition"])
- for entry in sorted(
- glossary_entries(context, b), key=lambda e: e["term"].lower()
- )
+ for entry in sorted(glossary, key=lambda e: e["term"].lower())
],
},
- "closing": {
- "heading": b[
- "fa_heading_uploaded" if provenance.uploaded else "fa_heading"
- ],
- "uploaded": provenance.uploaded,
- "lead": b["fa_uploaded"] if provenance.uploaded else b["fa_lead"],
- "points": [] if provenance.uploaded else b["fa_items"],
- "how_heading": b["fa_how_heading"],
- "how": b["fa_how"],
- "command": b["fa_command"],
- "sites": [
- ("EscapeCloud", "https://escapecloud.io"),
- ("ExitCloud", "https://exitcloud.io"),
+ }
+
+
+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)},
+ )
+ if context.scope.kind == "tfstate":
+ figures = b["html_figures_tfstate"]
+ else:
+ figures = b["html_figures_egress" if egress is not None else "html_figures"]
+ return {
+ "disclaimer": {
+ "heading": b["notices_disclaimer_heading"],
+ "paragraphs": [
+ [b["notices_disclaimer"]],
+ [f"{b['notices_generated']} {b['html_notice_advice']}"],
+ *([icons] if icons else []),
],
- "url": provenance.platform_url,
- "assessment_id": provenance.assessment_id,
},
- "notices": notices_html(
- context.cloud_service_provider, provenance.generated_at, b
+ "blocks": [
+ {
+ "heading": b["notices_trademarks_heading"],
+ "parts": [
+ f"{b['notices_trademark_own']} {b['notices_trademark_others']}"
+ ],
+ },
+ {"heading": b["html_figures_heading"], "parts": [figures]},
+ {
+ "heading": b["notices_software_heading"],
+ "parts": text_parts(
+ b["html_software"],
+ {"repository_link": (b["html_label_repository"], REPOSITORY_URL)},
+ ),
+ },
+ ],
+ "copyright": b["notices_copyright"].format(year=provenance.generated_at.year),
+ "ids": b["html_footer_ids"].format(
+ document_id=context.document_id, version=provenance.collector_version
+ ),
+ }
+
+
+def _tour(context, egress, linked, b):
+ tfstate = context.scope.kind == "tfstate"
+ sections = [b["html_tour_sections"]]
+ if not linked:
+ sections.append(b["html_tour_sections_lock"])
+ report = [b["html_tour_report"]]
+ if linked:
+ report.append(b["html_tour_report_platform"])
+ if egress is not None:
+ report.append(
+ b[
+ (
+ "html_tour_download_both_platform"
+ if linked
+ else "html_tour_download_both"
+ )
+ ]
+ )
+ else:
+ report.append(b["html_tour_download_one"])
+ scores = (
+ b["html_tour_scores_platform"]
+ if linked
+ else f"{b['html_tour_scores_available']} {b['html_hint_text']}"
+ )
+ steps = [
+ (".secnav", b["html_tour_sections_title"], " ".join(sections), "right"),
+ (".titlebar", b["html_tour_report_title"], " ".join(report), "bottom"),
+ (
+ "#overview .overview",
+ b["html_tour_overview_title"],
+ b["html_tour_overview_tfstate" if tfstate else "html_tour_overview"],
+ "bottom",
+ ),
+ ("#scores .scores", b["html_tour_scores_title"], scores, "bottom"),
+ (
+ "#risks .card-head",
+ b["html_tour_risks_title"],
+ b["html_tour_risks"],
+ "bottom",
),
+ (
+ "#inventory .card-head",
+ b["html_tour_inventory_title"],
+ b["html_tour_inventory"],
+ "bottom",
+ ),
+ (
+ "#alternatives .card-head",
+ b["html_tour_alternatives_title"],
+ b["html_tour_alternatives"],
+ "bottom",
+ ),
+ ]
+ if egress is not None:
+ steps.append(
+ (
+ "#egress .kpi-row",
+ b["html_tour_egress_title"],
+ b["html_tour_egress"],
+ "bottom",
+ )
+ )
+ steps.append(
+ ("#about .kv-grid", b["html_tour_about_title"], b["html_tour_about"], "top")
+ )
+ return [
+ {"element": element, "title": title, "description": text, "side": side}
+ for element, title, text, side in steps
+ ]
+
+
+def html_sections(context, egress=None, b=BLOCKS):
+ # Everything the page says, as plain data; the template only lays it out
+ provenance = context.provenance
+ platform_url = provenance.platform_url if provenance.uploaded else None
+ alternatives = _alternatives(context, b)
+ nav = [
+ {"id": "overview", "label": b["html_nav_overview"]},
+ {
+ "id": "scores",
+ "label": b["html_nav_scores"],
+ "lock": b["html_lock_label"] if not platform_url else None,
+ },
+ {"id": "risks", "label": b["html_nav_risks"], "count": len(context.risks)},
+ {
+ "id": "inventory",
+ "label": b["es_heading_resource_inventory"],
+ "count": context.total_resources,
+ },
+ {
+ "id": "alternatives",
+ "label": b["es_heading_alternative_technologies"],
+ "count": alternatives["total"],
+ },
+ ]
+ if egress is not None:
+ nav.append({"id": "egress", "label": b["ev_egress_report"]})
+ nav.append({"id": "about", "label": b["html_nav_about"]})
+
+ return {
+ "title": f"{b['ev_executive_report']} – {context.name}",
+ "language": "en",
+ "dash": b["ev_dash"],
+ "header": {
+ "tag": b["cover_edition_value"],
+ "logo_link": b["html_logo_link"],
+ "help": b["html_help"],
+ "help_aria": b["html_help_aria"],
+ "tour": {
+ "next": b["html_tour_next"],
+ "back": b["html_tour_back"],
+ "done": b["html_tour_done"],
+ "progress": b["html_tour_progress"],
+ },
+ },
+ "titlebar": _title(context, egress is not None, b),
+ "download": b["html_download"],
+ "open_platform": b["html_open_platform"],
+ "nav": nav,
+ "nav_label": b["html_nav_label"],
+ "overview": {
+ "scope": _scope_card(context, b),
+ "risks": _risks_card(context, b),
+ "costs": _costs_card(context, b),
+ },
+ "scores": _scores(alternatives["total"], platform_url, b),
+ "risks": _risks(context, b),
+ "inventory": _inventory(context, b),
+ "alternatives": alternatives,
+ "egress": egress_page(context, egress, b) if egress is not None else None,
+ "about": _about(context, egress, b),
+ "notices": _notices(context, egress, b),
+ "tour": _tour(context, egress, bool(platform_url), b),
}
diff --git a/cloudexit/report/notices.py b/cloudexit/report/notices.py
index 866a7c8..60c96d7 100644
--- a/cloudexit/report/notices.py
+++ b/cloudexit/report/notices.py
@@ -27,7 +27,9 @@ def text_parts(template, links, values=None):
for piece in FIELD.split(template):
field = piece[1:-1]
if field in links:
- parts.append((links[field], links[field]))
+ link = links[field]
+ # A (label, url) pair shows the label; a bare url shows itself
+ parts.append(link if isinstance(link, tuple) else (link, link))
elif values and field in values:
parts.append(str(values[field]))
elif piece:
diff --git a/cloudexit/report/wording.py b/cloudexit/report/wording.py
index 246033b..86cc08f 100644
--- a/cloudexit/report/wording.py
+++ b/cloudexit/report/wording.py
@@ -280,10 +280,10 @@
"alt_hotspot_short": "
No alternative in the data catalogue — lock-in hot-spot",
"alt_more": "+{k} more",
"alt_flag_open_source": "Open source",
- "alt_flag_support_plan": "Support plan",
+ "alt_flag_support_plan": "Enterprise support",
"alt_no_resources": "No resources were discovered in this assessment, so there are no alternatives to show.",
"alt_empty_set": "No alternative technologies were found for the discovered resource types.",
- "es_alttech_caption": "Examples are listed alphabetically; this report does not rank the alternatives. Open source and support plan as recorded in the data catalogue.",
+ "es_alttech_caption": "Examples are listed alphabetically; this report does not rank the alternatives. Open source and enterprise support as recorded in the data catalogue.",
"es_heading_severity": "Risk Severity Levels",
"es_severity_lead": (
"Severity expresses the expected impact of a risk on the effort and "
@@ -639,14 +639,17 @@
),
"notices_trademarks_heading": "Trademarks",
"notices_trademark_own": (
- "EscapeCloud, cloudexit and the EscapeCloud logo are trademarks. The "
- "AGPL-3.0 licence does not grant any right to use them; modified "
- "versions must not use them in a way that suggests they are official."
+ "EscapeCloud® is a registered trademark in the European Union and the "
+ "United States. The AGPL-3.0 licence applies solely to the cloudexit "
+ "software, not to the EscapeCloud name, logo, or brand assets. You may "
+ "not use EscapeCloud trademarks in connection with modified versions or "
+ "downstream distributions in any manner that implies endorsement, "
+ "affiliation, or origin with EscapeCloud."
),
"notices_trademark_others": (
- "Other product and company names in this report, including those of "
- "alternative technologies, are trademarks of their respective owners. "
- "Naming them does not imply endorsement or affiliation."
+ "Other product and company names and logos are trademarks of their "
+ "respective owners; naming them does not imply endorsement or "
+ "affiliation."
),
"notices_icons_aws": (
"AWS service icons are taken from the AWS Architecture Icons "
@@ -671,6 +674,365 @@
"licence."
),
"notices_copyright": "© 2024–{year} EscapeCloud",
+ "html_help": "Help",
+ "html_help_aria": "Help: how to read this report",
+ "html_logo_link": "EscapeCloud website (opens in a new tab)",
+ "html_chip_self": "Self-collected",
+ "html_chip_self_title": "Collected and evaluated on this machine; not uploaded",
+ "html_chip_uploaded": "Uploaded",
+ "html_chip_uploaded_title": "Collected on this machine and uploaded to the platform",
+ "html_download": "Download",
+ "html_download_summary": "Executive Summary (PDF)",
+ "html_download_egress": "Egress & Data Landscape (PDF)",
+ "html_open_platform": "Open on the platform",
+ "html_nav_label": "Sections",
+ "html_nav_overview": "Overview",
+ "html_nav_scores": "Scores",
+ "html_nav_risks": "Risks",
+ "html_nav_about": "About This Report",
+ "html_lock_label": "Light and Enterprise",
+ "html_scope_label": "Scope",
+ "html_run_label": "Run",
+ "html_run_small": "run {run} · {source}",
+ "html_run_live": "provider APIs",
+ "html_run_tfstate": "Terraform state",
+ "html_scope_no_resources": "no resources found",
+ "html_scope_serial": "serial {serial}",
+ "html_catalogue_sha": "sha256 {sha}",
+ "html_risks_label": "Unique Risks",
+ "html_sev_aria": "Risks by severity: {counts}",
+ "html_costs_label": "Costs, Last 6 Months",
+ "html_costs_label_short": "Costs",
+ "html_costs_average": "{amount} per month on average",
+ "html_costs_none": "no costs in the last six months",
+ "html_costs_change": "{latest} vs. {previous}",
+ "html_costs_aria": "Monthly costs: {parts}",
+ "html_costs_aria_part": "{month} {amount}",
+ "html_costs_aria_provisional": "{month} {amount} provisional",
+ "html_costs_provisional": (
+ "* {month} is provisional: the month was still accruing. Costs are "
+ "context only; they are not an exit-cost estimate."
+ ),
+ "html_costs_context": "Costs are context only; they are not an exit-cost estimate.",
+ "html_costs_tfstate_hint": (
+ "Run cloudexit against the provider APIs to include the last six "
+ "months of costs."
+ ),
+ "html_months": [
+ "Jan",
+ "Feb",
+ "Mar",
+ "Apr",
+ "May",
+ "Jun",
+ "Jul",
+ "Aug",
+ "Sep",
+ "Oct",
+ "Nov",
+ "Dec",
+ ],
+ "html_scores_grade": "Exit Readiness Grade",
+ "html_scores_lock": "Vendor Lock-in Score",
+ "html_scores_pill": "Light & Enterprise",
+ "html_scores_scale": "of 100",
+ "html_scores_bands": ["F", "E", "D", "C", "B", "A"],
+ "html_scores_dims": ["Human", "Technology", "Operational"],
+ "html_scores_dim_value": "{dash} / 5",
+ "html_scores_grade_text": (
+ "A grade from A to F for how ready this scope is for an exit, computed "
+ "from the risks in this report."
+ ),
+ "html_scores_lock_text": (
+ "How easily each resource type can be replaced, in skills, technology "
+ "and operations, with the {alternatives} rated and ranked."
+ ),
+ "html_scores_lock_text_plain": (
+ "How easily each resource type can be replaced, in skills, technology "
+ "and operations, with the alternatives rated and ranked."
+ ),
+ "html_scores_on_platform": "For this run it is on the platform.",
+ "html_platform_not_uploaded": "Not uploaded: {reason}",
+ "html_hint_title": "EscapeCloud Light & Enterprise",
+ "html_hint_text": (
+ "EscapeCloud's scoring engine offers several scoring methods to assess "
+ "how feasible an exit is, and tracks the continuously changing vendor "
+ "landscape of alternative technologies."
+ ),
+ "html_site_exitcloud": "exitcloud.io",
+ "html_site_escapecloud": "escapecloud.io",
+ "html_risks_caption": "{count} · {scope} · ordered by severity",
+ "html_risks_caption_only_count": "{count} · ordered by severity",
+ "html_risks_count_one": "1 unique risk",
+ "html_risks_count_many": "{n} unique risks",
+ "html_risks_wide_sole": "applies to the scope as a whole",
+ "html_risks_wide_all": "all apply to the scope as a whole",
+ "html_risks_wide_one": "1 applies to the scope as a whole",
+ "html_risks_wide_many": "{n} apply to the scope as a whole",
+ "html_risks_none_caption": "No exit risks",
+ "html_risks_th_affected": "Affected Resource Types",
+ "html_risks_more": "+{n} more",
+ "html_risks_footnote": (
+ "Risk codes are stable across runs. Severity levels and their meaning: "
+ "{link}."
+ ),
+ "html_risks_empty_scope": (
+ "With no resources in the scope, no risk from the data catalogue applies."
+ ),
+ "html_risks_empty_clean": (
+ "Every resource type has enough alternatives with enterprise support, "
+ "and the size of the scope is below the thresholds."
+ ),
+ "html_inv_caption": "{resources} · {types} · {regions}",
+ "html_inv_regions": "{regions} ({shares})",
+ "html_inv_not_managed": "resources that Terraform does not manage are not assessed",
+ "html_inv_th_locations": "Locations",
+ "html_inv_empty_title": "No resources were found in the defined scope.",
+ "html_inv_empty_resource_group": (
+ "Check that the resource group {scope} is the intended scope and that "
+ "the credentials can read it. Resource types not in the data catalogue "
+ "are not counted."
+ ),
+ "html_inv_empty_region": (
+ "Check that the region {scope} is the intended scope and that the "
+ "credentials can read it. Resource types not in the data catalogue are "
+ "not counted."
+ ),
+ "html_inv_empty_tfstate": (
+ "Check that the state file {scope} holds resources of this provider. "
+ "Resource types not in the data catalogue are not counted."
+ ),
+ "html_alt_caption": "{alternatives} for {with_alternatives} of {types} resource types",
+ "html_alt_caption_one_type": "{alternatives} for {with_alternatives} of 1 resource type",
+ "html_alt_n_one": "1 alternative",
+ "html_alt_n_many": "{n} alternatives",
+ "html_alt_search": "Search alternatives",
+ "html_filter_os": "Open Source",
+ "html_filter_sp": "Enterprise Support",
+ "html_filter_eu": "European Vendors",
+ "html_alt_th_name": "Alternative",
+ "html_alt_meta": "{resources} · {alternatives}",
+ "html_alt_meta_none": "{resources} · no alternative in the data catalogue",
+ "html_alt_more_one": "Show 1 more alternative",
+ "html_alt_more_many": "Show {n} more alternatives",
+ "html_alt_no_match": "No alternative matches the search and filters.",
+ "html_alt_footnote": (
+ "Alternatives are listed alphabetically from the data catalogue "
+ "({version}); this report doesn't rank them. Open source and "
+ "enterprise support as recorded in the data catalogue. Country = "
+ "vendor's registered country."
+ ),
+ "html_alt_empty_title": "No resource types to list alternatives for.",
+ "html_alt_empty_text": (
+ "Alternatives appear here for each resource type found in the scope."
+ ),
+ "html_yes": "Yes",
+ "html_no": "No",
+ "html_eg_caption": (
+ "Data held by the data-bearing resources, and the one-off fee to "
+ "transfer it out at list prices · also as PDF {document_id}"
+ ),
+ "html_eg_kpi_fee": "Estimated Transfer Fee",
+ "html_eg_kpi_headroom": "Free-Tier Headroom",
+ "html_eg_fee_free": "within the monthly free tier · USD list prices",
+ "html_eg_fee_paid": "at list prices · USD",
+ "html_eg_types_sized": "{sized} of {types} types sized",
+ "html_eg_headroom_left": "of {limit} per month left",
+ "html_eg_headroom_aria": "{share} of the free tier used",
+ "html_eg_billable_caption": "no free tier in the prices used",
+ "html_eg_not_sized_caption": "listed, not in the fee",
+ "html_eg_not_sized": "not sized",
+ "html_eg_vol_aria": "Share of data volume: {parts}",
+ "html_eg_vol_part": "{type} {share}",
+ "html_eg_vol_other": "Other types",
+ "html_eg_vol_unsized": "{types} not sized",
+ "html_eg_units_gb": (
+ "Data is shown in GiB; {provider} prices egress per GB (1 GiB = 1.074 "
+ "GB), so {gib} GiB is {gb} GB of the {limit} monthly free tier."
+ ),
+ "html_eg_units_gb_plain": (
+ "Data is shown in GiB; {provider} prices egress per GB (1 GiB = 1.074 GB)."
+ ),
+ "html_eg_units_gib": "Data is shown in GiB; {provider} prices egress per GiB.",
+ "html_eg_amounts": "Amounts in USD list prices; not converted.",
+ "html_eg_amounts_costs": (
+ "Amounts in USD list prices; not converted and not added to the costs "
+ "above ({currency}, monthly)."
+ ),
+ "html_eg_scope_note": (
+ "Network transfer fees only: request charges, cross-region traffic, "
+ "private connections and negotiated discounts are not included. Some "
+ "types report provisioned rather than used capacity. Not an estimate "
+ "of the full cost of an exit."
+ ),
+ "html_eg_sized_summary": "What is sized, and how",
+ "html_eg_prices_summary": "Prices used ({provider}, zone 1, public internet routing)",
+ "html_about_caption": (
+ "Document, scope and provenance · method, limitations, severity levels "
+ "and terms"
+ ),
+ "html_about_document": "Document",
+ "html_about_purpose": "Purpose and Method",
+ "html_about_phases_meta": "{n} phases",
+ "html_about_levels_meta": "{n} levels",
+ "html_about_terms_meta": "{n} terms",
+ "html_method_lead": (
+ "The assessment was collected and evaluated locally with cloudexit. "
+ "Once the scope is defined, it runs three phases:"
+ ),
+ "html_method_discovery_tfstate": (
+ "reads the resources managed in the Terraform state and classifies "
+ "them by type and region; a state file holds no costs."
+ ),
+ "html_method_catalogue": (
+ "Risks and alternatives are taken from the data catalogue version shown "
+ "above, so the results of this run can be reproduced."
+ ),
+ "html_limitations_data": (
+ "The analysis assumes that the discovered resources are correctly "
+ "configured and accessible. Dependencies outside the defined scope, "
+ "such as on-premises systems or other cloud accounts, are not covered; "
+ "results are indicative, not exhaustive."
+ ),
+ "html_glossary": [
+ {
+ "id": "aws",
+ "term": "AWS",
+ "definition": "Amazon Web Services",
+ },
+ {
+ "id": "data_catalogue",
+ "term": "Data catalogue",
+ "definition": "The catalogue of resource types, exit risks and alternative technologies used for this assessment.",
+ },
+ {
+ "id": "hotspot",
+ "term": "Lock-in hot-spot",
+ "definition": "A discovered resource type for which the data catalogue lists no alternative.",
+ },
+ {
+ "id": "run",
+ "term": "Run",
+ "definition": "One completed assessment of the defined scope, identified by its run ID.",
+ },
+ {
+ "id": "egress",
+ "term": "Egress",
+ "definition": "Data transferred out of the provider's network, which the provider bills per unit of volume.",
+ },
+ {
+ "id": "free_tier",
+ "term": "Free tier",
+ "definition": "The volume of internet egress per month that the provider does not bill.",
+ },
+ {
+ "id": "gib_gb",
+ "term": "GiB / GB",
+ "definition": "Gibibyte (2³⁰ bytes) and gigabyte (10⁹ bytes); 1 GiB = 1.074 GB.",
+ },
+ ],
+ "html_notice_advice": (
+ "It is not legal, financial or investment advice, an audit or an exit plan."
+ ),
+ "html_label_aws_icons": "AWS Architecture Icons",
+ "html_label_aws_guidelines": "AWS Trademark Guidelines",
+ "html_label_azure_icons": "Azure Architecture Icons",
+ "html_label_repository": "github.com/escapecloud/cloudexit",
+ "html_figures_heading": "Figures",
+ "html_figures": (
+ "Costs come from the provider's billing data for the scope. "
+ "Alternatives are entries from the data catalogue, not recommendations."
+ ),
+ "html_figures_egress": (
+ "Costs come from the provider's billing data for the scope; egress "
+ "prices are public list prices at the time of the run. Alternatives "
+ "are entries from the data catalogue, not recommendations."
+ ),
+ "html_figures_tfstate": (
+ "Resources are read from the Terraform state file; a state file holds "
+ "no billing data, so this report shows no costs. Alternatives are "
+ "entries from the data catalogue, not recommendations."
+ ),
+ "html_software": (
+ "cloudexit is free software, licensed under the GNU Affero General "
+ "Public License v3.0 (AGPL-3.0). Source code and issues: "
+ "{repository_link}. The provider icons are not covered by this "
+ "licence. Fonts: IBM Plex, SIL Open Font License 1.1. Guided tour: "
+ "driver.js, MIT License, included in this report's assets."
+ ),
+ "html_footer_ids": "{document_id} · cloudexit {version}",
+ "html_tour_next": "Next",
+ "html_tour_back": "Back",
+ "html_tour_done": "Done",
+ "html_tour_progress": "{{current}} of {{total}}",
+ "html_tour_sections_title": "Sections",
+ "html_tour_sections": (
+ "Jump to any part of the report; the section in view is highlighted."
+ ),
+ "html_tour_sections_lock": (
+ "Scores, marked with a lock, are available in EscapeCloud Light & "
+ "Enterprise."
+ ),
+ "html_tour_report_title": "This Report",
+ "html_tour_report": "The assessed scope and its cloud provider.",
+ "html_tour_report_platform": (
+ "This run was uploaded: Open on the platform shows its full results."
+ ),
+ "html_tour_download_both": (
+ "Download has the same results as PDF: the Executive Summary and the "
+ "Egress & Data Landscape."
+ ),
+ "html_tour_download_both_platform": (
+ "Download has the Executive Summary and the Egress & Data Landscape as " "PDF."
+ ),
+ "html_tour_download_one": "Download has the Executive Summary as PDF.",
+ "html_tour_overview_title": "Overview",
+ "html_tour_overview": (
+ "Scope, unique risks by severity and the last six months of costs. The "
+ "current month is provisional while it is still accruing; costs are "
+ "context, not an exit-cost estimate."
+ ),
+ "html_tour_overview_tfstate": (
+ "Scope and unique risks by severity. A Terraform state holds no "
+ "billing data, so there are no costs; resources that Terraform does "
+ "not manage are not assessed."
+ ),
+ "html_tour_scores_title": "Exit Readiness Grade & Vendor Lock-in Score",
+ "html_tour_scores_available": "Available in EscapeCloud Light & Enterprise.",
+ "html_tour_scores_platform": (
+ "This run was uploaded. Its Exit Readiness Grade and Vendor Lock-in "
+ "Score are on the platform: use Open on the platform. This local "
+ "report never shows platform scores."
+ ),
+ "html_tour_risks_title": "Risks",
+ "html_tour_risks": (
+ "Exit risks found in the scope, ordered by severity. Each risk has a "
+ "stable code, so a finding can be followed from one run to the next. "
+ "Open a row for its description."
+ ),
+ "html_tour_inventory_title": "Resource Inventory",
+ "html_tour_inventory": (
+ "One row per resource type, with its regions and its share of all "
+ "resources in the scope."
+ ),
+ "html_tour_alternatives_title": "Alternative Technologies",
+ "html_tour_alternatives": (
+ "Replacement options from the data catalogue for each resource type, "
+ "listed alphabetically and not ranked. Search, or filter by open "
+ "source, enterprise support or European vendors."
+ ),
+ "html_tour_egress_title": "Egress & Data Landscape",
+ "html_tour_egress": (
+ "Data held in the scope and the one-off fee to transfer it out at "
+ "public list prices. Data is shown in GiB; the provider prices per GB. "
+ "Types marked 'not sized' are listed but not in the fee."
+ ),
+ "html_tour_about_title": "About This Report",
+ "html_tour_about": (
+ "Document ID, scope and provenance. The run ID and the data catalogue "
+ "version make the result reproducible; method, limitations, severity "
+ "levels and terms are below."
+ ),
}
# Labels and headings that follow English Title Case, for the wording test.
@@ -784,4 +1146,41 @@
"fa_heading",
"fa_how_heading",
"fa_heading_uploaded",
+ "html_download",
+ "html_download_summary",
+ "html_download_egress",
+ "html_nav_overview",
+ "html_nav_scores",
+ "html_nav_risks",
+ "html_nav_about",
+ "html_scope_label",
+ "html_risks_label",
+ "html_costs_label",
+ "html_costs_label_short",
+ "html_scores_grade",
+ "html_scores_lock",
+ "html_scores_pill",
+ "html_risks_th_affected",
+ "html_inv_th_locations",
+ "html_filter_os",
+ "html_filter_sp",
+ "html_filter_eu",
+ "html_alt_th_name",
+ "html_eg_kpi_fee",
+ "html_eg_kpi_headroom",
+ "html_about_document",
+ "html_about_purpose",
+ "html_figures_heading",
+ "html_tour_sections_title",
+ "html_tour_report_title",
+ "html_tour_overview_title",
+ "html_tour_scores_title",
+ "html_tour_risks_title",
+ "html_tour_inventory_title",
+ "html_tour_alternatives_title",
+ "html_tour_egress_title",
+ "html_tour_about_title",
+ "html_nav_label",
+ "html_run_label",
+ "html_help",
)
diff --git a/cloudexit/report/writers.py b/cloudexit/report/writers.py
index 7be8d5f..6f55445 100644
--- a/cloudexit/report/writers.py
+++ b/cloudexit/report/writers.py
@@ -5,16 +5,9 @@
from typing import Any
from jinja2 import Environment
-# ReportLab
-
-# Utils
-from cloudexit.report.helpers import initials
+from cloudexit.report.context import ReportContext
+from cloudexit.report.html_sections import html_sections
from cloudexit.resources import asset_path
-from cloudexit.report.html_data import (
- transform_cost_inventory_for_html,
- transform_risk_inventory_for_html,
- transform_alt_tech_for_html,
-)
from cloudexit.report.json_data import (
transform_resource_inventory_for_json,
transform_cost_inventory_for_json,
@@ -27,114 +20,15 @@
def generate_html_report(
- report_path: str,
- metadata: dict[str, Any],
- resource_type_mapping: dict[str, dict[str, Any]],
- resource_inventory: list[dict[str, Any]],
- cost_data: list[dict[str, Any]],
- scoring_data: dict[str, Any] | None,
- risk_data: list[dict[str, Any]],
- risk_definitions: list[dict[str, Any]],
- alternatives: list[dict[str, Any]],
- alternative_technologies: list[dict[str, Any]],
- exit_strategy: int,
- alternative_technology_organizations: list[dict[str, Any]] | None = None,
- summary: dict[str, Any] | None = None,
+ context: ReportContext, egress_estimate: dict[str, Any] | None = None
) -> str:
-
- # Transform resource inventory
- resource_inventory_dict = {
- str(item["resource_type"]): {
- **item,
- "name": resource_type_mapping.get(str(item["resource_type"]), {}).get(
- "name", "Unknown Resource"
- ),
- "icon": (
- "/assets" + icon
- if (
- icon := resource_type_mapping.get(
- str(item["resource_type"]), {}
- ).get("icon")
- )
- else ""
- ),
- }
- for item in resource_inventory
- }
-
- # Transform risks
- risks, severity_counts = transform_risk_inventory_for_html(
- risk_data, risk_definitions, resource_inventory_dict
- )
-
- # Transform costs
- months, cost_values, total_cost, currency, currency_symbol = (
- transform_cost_inventory_for_html(cost_data)
- )
-
- # Transform resource data with names and icons
- resource_counts = []
- for resource_type, resource in resource_inventory_dict.items():
- count = resource.get("count", 0)
- resource_info = resource_type_mapping.get(str(resource_type), {})
- name = resource_info.get("name", "Unknown Resource")
- icon = (resource_info.get("icon") or "").lstrip("/")
-
- resource_counts.append(
- {
- "resource_type": resource_type,
- "name": name,
- "icon": icon,
- "initials": initials(name),
- "count": count,
- }
- )
-
- # Calculate total resources
- total_resources = sum(item["count"] for item in resource_counts)
-
- # Transform alternative technologies
- alternative_technologies_data = transform_alt_tech_for_html(
- resource_inventory,
- alternatives,
- alternative_technologies,
- exit_strategy,
- alternative_technology_organizations=alternative_technology_organizations,
- )
-
- # Scoring Data
- scoring_context = {
- "scoring_data": bool(scoring_data),
- "exit_score": scoring_data.get("exit_score", 0) if scoring_data else 0,
- "human": scoring_data.get("human_score", 0) if scoring_data else 0,
- "technology": scoring_data.get("technology_score", 0) if scoring_data else 0,
- "operational": scoring_data.get("operational_score", 0) if scoring_data else 0,
- }
-
- # Render the HTML template
template_content = asset_path("template", "index.html").read_text(encoding="utf-8")
env = Environment(autoescape=True)
template = env.from_string(template_content)
- html_content = template.render(
- **metadata,
- **scoring_context,
- risks=risks,
- high_risk_count=severity_counts["high"],
- medium_risk_count=severity_counts["medium"],
- low_risk_count=severity_counts["low"],
- total_cost=total_cost,
- months_json=json.dumps(months),
- costs_json=json.dumps(cost_values),
- currency_symbol=currency_symbol,
- total_resources=total_resources,
- resource_inventory=resource_counts,
- alternative_technologies=alternative_technologies_data,
- summary=summary,
- )
+ html_content = template.render(page=html_sections(context, egress_estimate))
- # Save HTML report
- html_path = os.path.join(report_path, "index.html")
+ html_path = os.path.join(context.report_path, "index.html")
with open(html_path, "w", encoding="utf-8") as report_file:
report_file.write(html_content)
diff --git a/tests/catalogue/test_dataset_shapes.py b/tests/catalogue/test_dataset_shapes.py
index acdcdea..be5f09b 100644
--- a/tests/catalogue/test_dataset_shapes.py
+++ b/tests/catalogue/test_dataset_shapes.py
@@ -118,7 +118,8 @@ def test_a_legacy_dataset_runs_with_an_unknown_version_and_numbered_risks(self):
result = self.run_shape("legacy")
self.assert_a_complete_offline_run(result)
- self.assertIn("High: R2 No alternatives", result.text())
+ self.assertRegex(result.text(), r"R2 No alternatives High")
+ self.assertIn("High · 1", result.text())
self.assertIn("unknown", result.html().lower())
self.assertIsNone(result.report_json()["provenance"]["catalogue_version"])
# Without catalogue_meta the sync document makes no claim about a version
@@ -130,7 +131,8 @@ def test_the_current_dataset_gives_real_codes_and_the_version(self):
self.assert_a_complete_offline_run(result)
text = result.text()
# catalogue_severity wins over severity, and risk.code over R
- self.assertIn("Critical: LOCK-002 No alternatives", text)
+ self.assertRegex(text, r"LOCK-002 No alternatives Critical")
+ self.assertIn("Critical · 1", text)
self.assertNotIn("R2 No alternatives", text)
self.assertIn(VERSION, text)
provenance = result.report_json()["provenance"]
@@ -159,7 +161,7 @@ def test_a_future_additive_dataset_works_exactly_like_the_current_one(self):
future.report_json()["data"]["resource_inventory"],
current.report_json()["data"]["resource_inventory"],
)
- self.assertIn("Critical: LOCK-002 No alternatives", future.text())
+ self.assertRegex(future.text(), r"LOCK-002 No alternatives Critical")
class RejectedShapeTests(ShapeTestCase):
diff --git a/tests/cli/test_app.py b/tests/cli/test_app.py
index 7fe1f83..0af07c8 100644
--- a/tests/cli/test_app.py
+++ b/tests/cli/test_app.py
@@ -624,11 +624,6 @@ class EgressStageTests(unittest.TestCase):
"logs": "",
"json_path": "/tmp/report/raw/egress_inventory_raw_data.json",
}
- _REPORT_OK = {
- "success": True,
- "logs": "",
- "html_path": "/tmp/report/egress.html",
- }
# The egress PDF is written with the report, from the same context
_WITH_PDF = {
"success": True,
@@ -658,7 +653,7 @@ def _azure_config():
},
}
- def _run(self, config, provider, report=None, estimate=None, html=None, **kwargs):
+ def _run(self, config, provider, report=None, estimate=None, **kwargs):
patches = _base_patches()
for p in patches:
p.start()
@@ -671,10 +666,6 @@ def _run(self, config, provider, report=None, estimate=None, html=None, **kwargs
"cloudexit.cli.app.estimate_egress",
return_value=estimate or self._ESTIMATE_OK,
).start(),
- "html": patch(
- "cloudexit.cli.app.generate_egress_html_report",
- return_value=html or self._REPORT_OK,
- ).start(),
"print": patch("cloudexit.cli.app.console.print").start(),
"step": patch("cloudexit.cli.app.print_step").start(),
}
@@ -684,14 +675,19 @@ def _run(self, config, provider, report=None, estimate=None, html=None, **kwargs
patch.stopall()
return mocks
+ def _steps(self, mocks):
+ return [call.args[0] for call in mocks["step"].call_args_list]
+
+ def _printed(self, mocks):
+ return [str(call) for call in mocks["print"].call_args_list]
+
def test_egress_module_not_invoked_without_flag(self):
mocks = self._run(self._azure_config(), "azure")
mocks["estimate"].assert_not_called()
- mocks["html"].assert_not_called()
self.assertIsNone(mocks["report"].call_args.kwargs["egress_json_path"])
- def test_egress_runs_in_the_collection_stages_before_the_report(self):
+ def test_the_estimate_comes_before_the_report_and_goes_into_it(self):
manager = MagicMock()
patches = _base_patches()
for p in patches:
@@ -702,13 +698,8 @@ def test_egress_runs_in_the_collection_stages_before_the_report(self):
mock_estimate = patch(
"cloudexit.cli.app.estimate_egress", return_value=self._ESTIMATE_OK
).start()
- mock_render = patch(
- "cloudexit.cli.app.generate_egress_html_report",
- return_value=self._REPORT_OK,
- ).start()
manager.attach_mock(mock_report, "generate_report")
manager.attach_mock(mock_estimate, "estimate_egress")
- manager.attach_mock(mock_render, "generate_egress_html_report")
config = self._azure_config()
try:
app.run_assessment(config, "azure", egress=True)
@@ -724,39 +715,34 @@ def test_egress_runs_in_the_collection_stages_before_the_report(self):
exit_strategy=config["exitStrategy"],
assessment_type=config["assessmentType"],
)
- # The HTML carries the scope and the run, so its ID matches the PDF's
- mock_render.assert_called_once_with(
- "/tmp/report",
- "/tmp/report/raw/egress_inventory_raw_data.json",
- config["providerDetails"],
- ANY,
- )
- sync_id = mock_render.call_args.args[3]
self.assertEqual(
mock_report.call_args.kwargs["egress_json_path"],
"/tmp/report/raw/egress_inventory_raw_data.json",
)
- self.assertEqual(mock_report.call_args.kwargs["sync_id"], sync_id)
call_names = [name for name, _, _ in manager.mock_calls]
self.assertLess(
- call_names.index("estimate_egress"),
- call_names.index("generate_egress_html_report"),
- )
- # The PDF is part of the report, after the upload
- self.assertLess(
- call_names.index("generate_egress_html_report"),
- call_names.index("generate_report"),
+ call_names.index("estimate_egress"), call_names.index("generate_report")
)
- def test_the_egress_pdf_step_is_printed_and_the_path_listed(self):
+ def test_there_is_no_separate_egress_report_step_any_more(self):
mocks = self._run(self._azure_config(), "azure", egress=True)
- steps = [call.args[0] for call in mocks["step"].call_args_list]
+ steps = self._steps(mocks)
+ self.assertNotIn("Generating egress report...", steps)
+ self.assertIn("Estimating egress data...", steps)
self.assertIn("Generating egress PDF...", steps)
- printed = [str(call) for call in mocks["print"].call_args_list]
+ printed = self._printed(mocks)
+ self.assertFalse(any("Egress Report:" in line for line in printed))
+ self.assertFalse(any("egress.html" in line for line in printed))
+
+ def test_the_egress_pdf_path_is_listed(self):
+ mocks = self._run(self._azure_config(), "azure", egress=True)
+
+ printed = self._printed(mocks)
self.assertTrue(
any("Egress PDF: /tmp/report/egress.pdf" in line for line in printed)
)
+ self.assertTrue(any("HTML Report:" in line for line in printed))
def test_egress_failure_still_prints_outputs_and_exits_egress(self):
patches = _base_patches()
@@ -766,31 +752,8 @@ def test_egress_failure_still_prints_outputs_and_exits_egress(self):
"cloudexit.cli.app.estimate_egress",
return_value={"success": False, "logs": "metrics api error"},
).start()
- mock_render = patch("cloudexit.cli.app.generate_egress_html_report").start()
- mock_print = patch("cloudexit.cli.app.console.print").start()
- try:
- with self.assertRaises(SystemExit) as ctx:
- app.run_assessment(self._azure_config(), "azure", egress=True)
- finally:
- patch.stopall()
-
- self.assertEqual(ctx.exception.code, codes.EGRESS)
- mock_render.assert_not_called()
- printed = [str(call) for call in mock_print.call_args_list]
- self.assertTrue(any("Outputs:" in line for line in printed))
-
- def test_egress_report_failure_still_prints_outputs_and_exits_egress(self):
- patches = _base_patches()
- for p in patches:
- p.start()
- patch(
- "cloudexit.cli.app.estimate_egress", return_value=self._ESTIMATE_OK
- ).start()
- patch(
- "cloudexit.cli.app.generate_egress_html_report",
- return_value={"success": False, "logs": "template error"},
- ).start()
- patch("cloudexit.cli.app.generate_report", return_value=self._WITH_PDF).start()
+ report = patch("cloudexit.cli.app.generate_report").start()
+ report.return_value = {"success": True, "reports": {"HTML": "/tmp/index.html"}}
mock_print = patch("cloudexit.cli.app.console.print").start()
try:
with self.assertRaises(SystemExit) as ctx:
@@ -801,12 +764,7 @@ def test_egress_report_failure_still_prints_outputs_and_exits_egress(self):
self.assertEqual(ctx.exception.code, codes.EGRESS)
printed = [str(call) for call in mock_print.call_args_list]
self.assertTrue(any("Outputs:" in line for line in printed))
- # The JSON path is internal input for the reports; it is never listed
- # in Outputs. The failed HTML report is not listed, but the PDF is
- # written independently and succeeded, so it is.
- self.assertFalse(any("Egress Estimate:" in line for line in printed))
- self.assertFalse(any("Egress Report:" in line for line in printed))
- self.assertTrue(any("Egress PDF:" in line for line in printed))
+ self.assertIsNone(report.call_args.kwargs["egress_json_path"])
def test_egress_pdf_failure_still_prints_outputs_and_exits_egress(self):
patches = _base_patches()
@@ -815,10 +773,6 @@ def test_egress_pdf_failure_still_prints_outputs_and_exits_egress(self):
patch(
"cloudexit.cli.app.estimate_egress", return_value=self._ESTIMATE_OK
).start()
- patch(
- "cloudexit.cli.app.generate_egress_html_report",
- return_value=self._REPORT_OK,
- ).start()
patch(
"cloudexit.cli.app.generate_report", return_value=self._PDF_FAILED
).start()
@@ -835,7 +789,6 @@ def test_egress_pdf_failure_still_prints_outputs_and_exits_egress(self):
printed = [str(call) for call in mock_print.call_args_list]
self.assertTrue(any("Outputs:" in line for line in printed))
self.assertTrue(any("HTML Report:" in line for line in printed))
- self.assertTrue(any("Egress Report:" in line for line in printed))
self.assertFalse(any("Egress PDF:" in line for line in printed))
failed = [
call
@@ -846,9 +799,13 @@ def test_egress_pdf_failure_still_prints_outputs_and_exits_egress(self):
self.assertEqual(failed[0].kwargs["logs"], "reportlab error")
def test_a_failed_report_is_still_a_report_failure(self):
- mocks_report = {"success": False, "logs": "boom"}
with self.assertRaises(SystemExit) as ctx:
- self._run(self._azure_config(), "azure", report=mocks_report, egress=True)
+ self._run(
+ self._azure_config(),
+ "azure",
+ report={"success": False, "logs": "boom"},
+ egress=True,
+ )
self.assertEqual(ctx.exception.code, codes.REPORT)
@@ -859,10 +816,6 @@ def test_egress_invoked_for_aws_with_provider_code(self):
mock_estimate = patch(
"cloudexit.cli.app.estimate_egress", return_value=self._ESTIMATE_OK
).start()
- patch(
- "cloudexit.cli.app.generate_egress_html_report",
- return_value=self._REPORT_OK,
- ).start()
patch("cloudexit.cli.app.generate_report", return_value=self._WITH_PDF).start()
config = VALID_CONFIG.copy()
try:
diff --git a/tests/platform/test_document.py b/tests/platform/test_document.py
index 6397e75..016b86a 100644
--- a/tests/platform/test_document.py
+++ b/tests/platform/test_document.py
@@ -863,10 +863,6 @@ def run_assessment(self, report, *, egress_ok=None, **kwargs):
"cloudexit.cli.app.estimate_egress",
return_value={"success": egress_ok, "logs": "x", "json_path": "p"},
),
- patch(
- "cloudexit.cli.app.generate_egress_html_report",
- return_value={"success": True, "html_path": "h"},
- ),
]
for item in patches:
item.start()
diff --git a/tests/report/test_basic_report.py b/tests/report/test_basic_report.py
index a4b7dae..24606d8 100644
--- a/tests/report/test_basic_report.py
+++ b/tests/report/test_basic_report.py
@@ -6,7 +6,6 @@
import unittest
from datetime import datetime, timezone
from pathlib import Path
-from unittest.mock import patch
from pypdf import PdfReader
@@ -14,7 +13,6 @@
from cloudexit.report import fonts
from cloudexit.report.context import Provenance
from cloudexit.report.executive_summary import generate_executive_summary
-from cloudexit.report.egress import generate_egress_html_report
from cloudexit.report.notices import notices_html
from cloudexit.report.palette import (
DEFAULT_APPEARANCE,
@@ -31,8 +29,6 @@
REPOSITORY_URL,
)
from cloudexit.report.wording import BLOCKS, TITLE_CASE_KEYS
-from tests.report import test_egress as egress_helpers
-from tests.report.test_no_credentials import pricing_for_both_clouds
from tests.support.flow import build_flow_run
from tests.support.html import html_text
from tests.support.report import (
@@ -584,66 +580,6 @@ def test_the_closing_page_only_names_what_the_full_assessment_adds(self):
)
self.assertNotRegex(closing, r"\d+ / (100|5)")
- def test_the_html_report_has_no_scores_before_the_closing_section(self):
- report = build_flow_run(
- self.tmp / "html",
- resources=[(1, "eu-central-1", 3)],
- costs=[("2026-09-01", 10.0, "USD")],
- )
- perform_risk_assessment(1, report)
- generate_report(
- 2,
- {"region": "eu-central-1"},
- 1,
- 1,
- "Flow",
- report,
- str(Path(report) / "raw_data"),
- sync_id=SYNC_ID,
- )
- text = html_text((Path(report) / "index.html").read_text(encoding="utf-8"))
- before = text[: text.index("What the Full Assessment Adds")]
-
- for pattern in SCORE_PATTERNS:
- self.assertIsNone(re.search(pattern, before, re.IGNORECASE), pattern)
-
- def test_the_html_follows_the_same_sections(self):
- report = build_flow_run(
- self.tmp / "html2",
- resources=[(1, "eu-central-1", 3)],
- costs=[("2026-09-01", 10.0, "USD")],
- )
- perform_risk_assessment(1, report)
- generate_report(
- 2,
- {"region": "eu-central-1"},
- 1,
- 1,
- "Flow",
- report,
- str(Path(report) / "raw_data"),
- sync_id=SYNC_ID,
- )
- text = html_text((Path(report) / "index.html").read_text(encoding="utf-8"))
-
- # The findings table names the sections too, so each heading is searched
- # for after the one before it.
- position = 0
- for heading in (
- "Brief Summary",
- "Provenance",
- "Introduction",
- "Risk Inventory",
- "Costs Overview",
- "Resource Inventory",
- "Alternative Technologies",
- "Risk Severity Levels",
- "Glossary",
- "What the Full Assessment Adds",
- "Legal Notices",
- ):
- position = text.index(heading, position)
-
class TermGuardTests(BasicReportTestCase):
def test_no_regulatory_word_or_foreign_brand_in_pdf_html_or_metadata(self):
@@ -835,109 +771,6 @@ def test_the_year_comes_from_the_generation_time(self):
self.assertEqual(notices["copyright"], f"© 2024–{year} EscapeCloud")
-class NoticesHtmlTests(BasicReportTestCase):
- def index_text(self, provider, resources):
- report = build_flow_run(
- self.tmp / f"html-{provider}",
- resources=resources,
- costs=[("2026-09-01", 10.0, "USD")],
- )
- perform_risk_assessment(1, report)
- details = (
- {"region": "eu-central-1"} if provider == 2 else {"resourceGroupName": "rg"}
- )
- generate_report(
- provider,
- details,
- 1,
- 1,
- "Flow",
- report,
- str(Path(report) / "raw_data"),
- sync_id=SYNC_ID,
- )
- return html_text((Path(report) / "index.html").read_text(encoding="utf-8"))
-
- def test_index_html_ends_with_the_notices_and_the_providers_icon_notice(self):
- for provider, resources, own, other in (
- (2, [(1, "eu-central-1", 3)], "Amazon.com", "Microsoft group"),
- (1, [(3, "westeurope", 3)], "Microsoft group", "Amazon.com"),
- ):
- with self.subTest(provider=provider):
- text = self.index_text(provider, resources)
-
- self.assertGreater(
- text.index("Legal Notices"),
- text.index("What the Full Assessment Adds"),
- )
- tail = text[text.index("Legal Notices") :]
- self.assertIn(BLOCKS["notices_disclaimer"], tail)
- self.assertIn(own, tail)
- self.assertNotIn(other, text)
- self.assertIn(REPOSITORY_URL, tail)
- self.assertRegex(tail.rstrip(), r"© 2024–\d{4} EscapeCloud$")
- self.assertEqual(find_terms(text), [])
-
- def test_the_links_in_index_html_are_real_links(self):
- report = build_flow_run(
- self.tmp / "html-links", resources=[(1, "eu-central-1", 3)], costs=[]
- )
- perform_risk_assessment(1, report)
- generate_report(
- 2,
- {"region": "eu-central-1"},
- 1,
- 1,
- "Flow",
- report,
- str(Path(report) / "raw_data"),
- sync_id=SYNC_ID,
- )
- html = (Path(report) / "index.html").read_text(encoding="utf-8")
-
- for url in (REPOSITORY_URL, LICENCE_URL, AWS_ICONS_URL, AWS_TRADEMARK_URL):
- self.assertIn(f'href="{url}"', html)
-
- def egress_text(self, provider):
- rows = [
- egress_helpers._row(
- "proddata", "Storage", "object", 2048 * egress_helpers.GIB
- )
- ]
- payload = egress_helpers._payload(rows)
- payload["meta"]["cloud_service_provider"] = provider
- folder = self.report_dir()
- json_path = folder / "egress_inventory_raw_data.json"
- json_path.write_text(json.dumps(payload), encoding="utf-8")
- egress_helpers._seed_egress_inventory(
- str(folder), rows, egress_helpers._archive_tiers(rows)
- )
- with patch(
- "cloudexit.report.egress.load_data", side_effect=pricing_for_both_clouds
- ):
- result = generate_egress_html_report(str(folder), str(json_path))
- self.assertTrue(result["success"], result["logs"])
- html = Path(result["html_path"]).read_text(encoding="utf-8")
- return html, html_text(html)
-
- def test_egress_html_ends_with_the_same_notices(self):
- for provider, own, other in (
- (1, "Microsoft group", "Amazon.com"),
- (2, "Amazon.com", "Microsoft group"),
- ):
- with self.subTest(provider=provider):
- html, text = self.egress_text(provider)
- tail = text[text.index("Legal Notices") :]
-
- self.assertIn(BLOCKS["notices_disclaimer"], tail)
- self.assertIn(own, tail)
- self.assertNotIn(other, text)
- # The timestamp of the estimate gives the year
- self.assertTrue(tail.rstrip().endswith("© 2024–2026 EscapeCloud"))
- self.assertIn(f'href="{LICENCE_URL}"', html)
- self.assertEqual(find_terms(text), [])
-
-
class WordingGuardTests(unittest.TestCase):
def test_the_licence_name_is_a_proper_name_and_other_license_is_not(self):
self.assertEqual(
@@ -1015,8 +848,9 @@ def test_no_wording_uses_a_retired_term_or_american_spelling(self):
def test_no_wording_carries_regulatory_words_or_scores(self):
for key, text in self.strings():
- # The pages that point to the platforms name what they add
- if key.startswith(("fa_", "eg_next_")):
+ # The pages that point to the platforms name what they add, and the
+ # Scores row of the HTML report names the results they compute
+ if key.startswith(("fa_", "eg_next_", "html_scores_", "html_tour_")):
continue
with self.subTest(key):
self.assertEqual(find_terms(text), [])
diff --git a/tests/report/test_egress.py b/tests/report/test_egress.py
index b1c33f9..3c9b3d6 100644
--- a/tests/report/test_egress.py
+++ b/tests/report/test_egress.py
@@ -1,5 +1,4 @@
# tests/report/test_egress.py
-import json
import os
import sqlite3
import tempfile
@@ -7,19 +6,18 @@
from unittest.mock import patch
-from cloudexit.resources import asset_path
from cloudexit.egress.model import GIB, write_egress_inventory
+from cloudexit.report.helpers import format_gib, gib_number, percent
from cloudexit.catalogue.store import active_catalogue_path
from cloudexit.report.egress import (
- CATEGORIES,
- _build_allocation,
_build_type_groups,
_resolve_icon,
build_fee_estimate,
calculate_tiered_cost,
+ fee_number,
format_fee,
+ free_tier_headroom,
free_tier_limit_display,
- generate_egress_html_report,
load_pricing,
)
@@ -180,34 +178,6 @@ def _seed_egress_inventory(report_path, rows, archive_tiers=frozenset()):
return db_path
-class CategoryCoverageTests(unittest.TestCase):
- # The CHECK constraint on resourcetype_data.data_category. A category the
- # report does not know about renders an unstyled badge and, worse, drops
- # out of the allocation chart while still counting towards the totals.
- DATA_CATEGORIES = {"object", "block", "file", "database", "backup"}
-
- def test_every_master_data_category_is_known_to_the_report(self):
- self.assertEqual(set(CATEGORIES), self.DATA_CATEGORIES)
-
- def test_only_backup_is_left_out_of_the_allocation_chart(self):
- excluded = {k for k, v in CATEGORIES.items() if not v["in_allocation"]}
-
- # Backup is enumerated but never sized, so it has nothing to allocate.
- self.assertEqual(excluded, {"backup"})
-
- def test_allocated_categories_have_a_colour_and_a_label(self):
- for key, info in CATEGORIES.items():
- if info["in_allocation"]:
- self.assertIsNotNone(info["color"], key)
- self.assertIsNotNone(info["label"], key)
-
- def test_template_styles_every_category_badge(self):
- css = asset_path("template", "egress.html").read_text(encoding="utf-8")
-
- for key in CATEGORIES:
- self.assertIn(f".category-{key} {{", css, f"no badge rule for {key!r}")
-
-
class LoadPricingTests(unittest.TestCase):
@staticmethod
def _make_report_db(report_path, rows):
@@ -392,34 +362,6 @@ def test_unknown_type_has_no_icon(self):
self.assertIsNone(_resolve_icon("Vendor.Unknown/things", {}))
-class AllocationSeriesTests(unittest.TestCase):
- def test_groups_known_bytes_by_category_in_fixed_order(self):
- rows = [
- _row("disk1", "Managed Disk", "block", 40 * GIB),
- _row("sa1", "Storage Account", "object", 60 * GIB),
- _row("db1", "SQL Database", "database", 10 * GIB),
- _row("vault1", "Recovery Services Vault", "backup", None),
- _row("db2", "SQL Database", "database", None, size_unknown=True),
- ]
-
- labels, values, colors = _build_allocation(rows)
-
- self.assertEqual(
- labels,
- ["Object Storage", "Block (allocated)", "Databases"],
- )
- self.assertEqual(values, [60 * GIB, 40 * GIB, 10 * GIB])
- self.assertEqual(len(colors), 3)
-
- def test_zero_categories_are_omitted(self):
- rows = [_row("sa1", "Storage Account", "object", 5 * GIB)]
-
- labels, values, _ = _build_allocation(rows)
-
- self.assertEqual(labels, ["Object Storage"])
- self.assertEqual(values, [5 * GIB])
-
-
class TypeGroupTests(unittest.TestCase):
def test_groups_by_label_and_sorts_by_fee(self):
rows = [
@@ -466,124 +408,52 @@ def test_flags_and_notes_become_resource_detail(self):
)
-class GenerateEgressHtmlReportTests(unittest.TestCase):
- def _generate(self, payload):
- with tempfile.TemporaryDirectory() as tmp_dir:
- json_path = os.path.join(tmp_dir, "egress_inventory_raw_data.json")
- with open(json_path, "w", encoding="utf-8") as json_file:
- json.dump(payload, json_file)
- _seed_egress_inventory(
- tmp_dir,
- payload["data"]["resources"],
- _archive_tiers(payload["data"]["resources"]),
- )
-
- with patch(
- "cloudexit.report.egress.load_data",
- side_effect=_fake_load_data,
- ):
- result = generate_egress_html_report(tmp_dir, json_path)
-
- html = None
- if result["success"]:
- self.assertEqual(
- result["html_path"], os.path.join(tmp_dir, "egress.html")
- )
- with open(result["html_path"], encoding="utf-8") as html_file:
- html = html_file.read()
- return result, html
-
- def test_renders_report_with_fees_table_and_charts(self):
- rows = [
- _row(
- "proddata",
- "Storage Account",
- "object",
- 2048 * GIB,
- resource_type="Microsoft.Storage/storageAccounts",
- ),
- _row(
- "vm-osdisk",
- "Managed Disk",
- "block",
- 512 * GIB,
- resource_type="Microsoft.Compute/disks",
- flags=["allocated (upper bound)"],
- ),
- ]
- result, html = self._generate(_payload(rows))
-
- self.assertTrue(result["success"], result["logs"])
- self.assertIn("Exit Assessment Test", html)
- normalized_html = " ".join(html.split())
- # The alpha banner is a limitation in the Introduction now
- self.assertNotIn("alpha", html)
- self.assertNotIn("mailto:beta@escapecloud.io", html)
- # (2560 - 100) GiB * 0.087 = $214.02 total internet egress fee
- self.assertIn("$214.02", html)
- self.assertIn("Estimated Egress Fee", html)
- self.assertIn("Estimated one-time charge to transfer the identified", html)
- self.assertIn(
- 'any resources whose size could not be measured. A "+" means',
- normalized_html,
- )
- self.assertIn("lower-bound estimate.", html)
- self.assertIn("feesChart", html)
- self.assertIn("allocationChart", html)
- self.assertIn("proddata", html)
- self.assertIn("category-object", html)
- self.assertIn("allocated (upper bound)", html)
- self.assertNotIn("Estimates only", html)
- self.assertNotIn("At least", html)
-
- def test_unknown_sizes_add_plus_suffix_to_headlines(self):
- rows = [
- _row("sa1", "Storage Account", "object", 2048 * GIB),
- _row("db1", "SQL Database", "database", None, size_unknown=True),
+class GibAndFeeFormatTests(unittest.TestCase):
+ def test_a_size_that_is_not_zero_never_reads_as_zero(self):
+ self.assertEqual(gib_number(0), "0.0")
+ self.assertEqual(gib_number(GIB // 100), "<0.1")
+ self.assertEqual(gib_number(GIB // 50, places=2), "0.02")
+ self.assertEqual(gib_number(GIB // 1000, places=2), "<0.01")
+
+ def test_places_and_grouping(self):
+ self.assertEqual(gib_number(2048 * GIB), "2,048.0")
+ self.assertEqual(gib_number(22 * GIB, places=2), "22.00")
+ self.assertEqual(format_gib(20 * GIB), "20.0 GiB")
+ self.assertEqual(format_gib(None), "n/a")
+
+ def test_the_fee_has_one_format_with_and_without_the_symbol(self):
+ self.assertEqual(fee_number(0), "0.00")
+ self.assertEqual(fee_number(1732.1), "1,732.10")
+ self.assertEqual(format_fee(1732.1), f"${fee_number(1732.1)}")
+
+ def test_a_share_keeps_its_sign_and_never_reads_zero_when_it_is_not(self):
+ self.assertEqual(percent(63.4), "63\u00a0%")
+ self.assertEqual(percent(0.2), "<1\u00a0%")
+ self.assertEqual(percent(0), "0\u00a0%")
+
+
+class FreeTierHeadroomTests(unittest.TestCase):
+ def test_the_headroom_is_in_the_unit_the_provider_prices_in(self):
+ prices = [
+ _price_row(1, "internet_egress", 0, 100, 0.0, unit="GB"),
+ _price_row(1, "internet_egress", 100, None, 0.1, unit="GB"),
]
- result, html = self._generate(_payload(rows, unknown_count=1))
-
- self.assertTrue(result["success"], result["logs"])
- # 2048 GiB known: (2048 - 100) * 0.087 = $169.48, "+" marks the lower bound
- self.assertIn("2,048.0 GiB+", html)
- self.assertNotIn("TiB", html)
- self.assertIn("$169.48+", html)
- self.assertIn("Excludes 1 resource", html)
- self.assertIn("n/a", html)
-
- def test_free_tier_environment_shows_included_in_free_tier_card(self):
- rows = [_row("sa1", "Storage Account", "object", 5 * GIB)]
- result, html = self._generate(_payload(rows))
-
- self.assertTrue(result["success"], result["logs"])
- self.assertIn("Included in Free Tier", html)
- # The fee headline stays visible above the card, in the one money format
- self.assertIn("Estimated Egress Fee", html)
- self.assertIn('$0.00
', html)
- self.assertIn("5.0 GiB", html)
- self.assertIn("100 GiB free tier", html)
- self.assertNotIn("No fee estimate available.", html)
- self.assertNotIn("feesChart", html)
-
- def test_empty_inventory_renders_empty_states(self):
- result, html = self._generate(_payload([]))
-
- self.assertTrue(result["success"], result["logs"])
- self.assertIn("$0.00", html)
- # Zero known bytes is the empty state, not the free-tier success card.
- self.assertIn("No fee estimate available.", html)
- self.assertNotIn("Included in Free Tier", html)
- self.assertIn("No data volume available.", html)
- self.assertIn(
- "No data-bearing resources were discovered during the",
- html,
- )
- self.assertNotIn("allocationChart", html)
- def test_missing_json_returns_failure(self):
- with tempfile.TemporaryDirectory() as tmp_dir:
- result = generate_egress_html_report(
- tmp_dir, os.path.join(tmp_dir, "missing.json")
- )
+ headroom = free_tier_headroom(prices, 1, 40 * 10**9)
+
+ self.assertEqual(headroom["unit"], "GB")
+ self.assertAlmostEqual(headroom["left"], 60.0)
+ self.assertAlmostEqual(headroom["used_share"], 40.0)
+
+ def test_a_footprint_over_the_free_tier_has_none_left(self):
+ headroom = free_tier_headroom(SIMPLE_PRICING, 1, 150 * GIB)
+
+ self.assertEqual(headroom["left"], 0.0)
+ self.assertEqual(headroom["used_share"], 100.0)
+
+ def test_no_free_tier_means_no_headroom(self):
+ self.assertIsNone(free_tier_headroom(SIMPLE_PRICING, 2, GIB))
+
- self.assertFalse(result["success"])
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/report/test_egress_report.py b/tests/report/test_egress_report.py
index d1bfc4d..85e1087 100644
--- a/tests/report/test_egress_report.py
+++ b/tests/report/test_egress_report.py
@@ -14,7 +14,6 @@
Provenance,
format_document_id,
)
-from cloudexit.report.egress import generate_egress_html_report
from cloudexit.report.egress_report import generate_egress_report
from cloudexit.report.sections import AWS_ICONS_URL, AZURE_ICONS_URL
from cloudexit.report.wording import BLOCKS
@@ -30,6 +29,7 @@
uploaded,
)
from tests.report.test_no_credentials import pricing_for_both_clouds
+from tests.support.egress import empty_estimate
from tests.support.flow import build_flow_run
from tests.support.html import html_text
from tests.support.report import (
@@ -112,18 +112,6 @@ def render(self, rows=MULTI_TIB, provider=1, prov=None):
path = generate_egress_report(context, json_path)
return path, context, PdfReader(path)
- def render_html(self, rows=MULTI_TIB, provider=1, details=None):
- folder, json_path = self.prepare(rows, provider)
- with patch(
- "cloudexit.report.egress.load_data", side_effect=pricing_for_both_clouds
- ):
- result = generate_egress_html_report(
- str(folder), json_path, details, SYNC_ID
- )
- self.assertTrue(result["success"], result["logs"])
- html = Path(result["html_path"]).read_text(encoding="utf-8")
- return html, html_text(html)
-
def scenarios(self):
for provider, name in ((1, "azure"), (2, "aws")):
for state, prov in (("offline", provenance()), ("uploaded", uploaded())):
@@ -437,55 +425,6 @@ def test_the_term_wording_and_retired_term_guards_pass_on_every_page(self):
self.assertEqual(retired_term_hits(text), [])
self.assertEqual(american_hits(text), [])
- def test_the_html_follows_the_pdf_and_passes_the_guards(self):
- for provider, details in (
- (1, {"resourceGroupName": "Prod-RG", "tenantId": "t-1"}),
- (2, {"region": "eu-central-1", "accountId": "123456789012"}),
- ):
- with self.subTest(provider=provider):
- html, text = self.render_html(MULTI_TIB, provider, details)
-
- position = 0
- for heading in (
- "Introduction",
- "Purpose",
- "Scope of the Estimation",
- "Data Landscape",
- "Estimated Costs",
- "Appendix – Egress Prices",
- "Glossary",
- "Next Steps",
- "Legal Notices",
- ):
- position = text.index(heading, position)
- self.assertIn(EDL_ID, text)
- self.assertIn(f"({ES_ID})", text)
- self.assertTrue(
- text.rstrip().endswith("© 2024–2026 EscapeCloud"), text[-80:]
- )
- self.assertEqual(find_terms(text), [])
- self.assertEqual(find_terms(html.split("Egress & Data Landscape – Exit Assessment Test", html
- )
- self.assertNotIn("EscapeCloud Community Edition -", html.split("")[0])
- self.assertNotIn("TiB", html)
- self.assertIn(BLOCKS["notices_egress"], html.replace("'", "'"))
-
- def test_the_html_scope_names_no_account_or_tenant(self):
- _html, text = self.render_html(
- MULTI_TIB, 2, {"region": "eu-central-1", "accountId": "123456789012"}
- )
-
- self.assertIn("AWS region eu-central-1", text)
- self.assertNotIn("123456789012", text)
-
class GenerationOrderTests(unittest.TestCase):
def setUp(self):
@@ -518,13 +457,18 @@ def es_text(self):
def test_the_egress_pdf_is_made_from_the_context_of_the_report(self):
upload = {"status": "uploaded", "url": URL, "assessment_id": "a-1"}
seen = {}
+ estimate = empty_estimate()
- def fake(context, json_path):
- seen["context"] = context
- seen["json_path"] = json_path
+ def fake(context, json_path, path=None, estimate=None):
+ seen.update(context=context, json_path=json_path, estimate=estimate)
return "egress.pdf"
- with patch("cloudexit.engine.generate_egress_report", side_effect=fake):
+ with (
+ patch(
+ "cloudexit.engine.load_egress_estimate", return_value=estimate
+ ) as load,
+ patch("cloudexit.engine.generate_egress_report", side_effect=fake),
+ ):
result = self.generate(upload=upload, egress_json_path="estimate.json")
self.assertTrue(result["success"])
@@ -536,10 +480,17 @@ def fake(context, json_path):
self.assertEqual(context.provenance.platform_url, URL)
self.assertEqual(context.egress_document_id, EDL_ID)
self.assertEqual(seen["json_path"], "estimate.json")
-
- def test_the_summary_names_the_egress_document_only_when_there_is_one(self):
- with patch(
- "cloudexit.engine.generate_egress_report", return_value="egress.pdf"
+ # The estimate is read once, and the page and the PDF share it
+ load.assert_called_once_with(self.report, "estimate.json")
+ self.assertIs(seen["estimate"], estimate)
+
+ def test_the_summary_and_the_page_name_the_egress_document_only_with_one(self):
+ with (
+ patch(
+ "cloudexit.engine.load_egress_estimate",
+ return_value=empty_estimate(),
+ ),
+ patch("cloudexit.engine.generate_egress_report", return_value="egress.pdf"),
):
self.generate(egress_json_path="estimate.json")
with_egress = self.es_text()
@@ -547,34 +498,49 @@ def test_the_summary_names_the_egress_document_only_when_there_is_one(self):
sentence = BLOCKS["es_costs_egress_note"].format(document_id=EDL_ID)
self.assertIn(sentence, with_egress)
- self.assertIn("A separate Egress & Data Landscape", html)
- self.assertIn(EDL_ID, html)
+ # The page carries it in its egress section, not as a sentence
+ self.assertIn(f"also as PDF {EDL_ID}", html_text(html))
+ self.assertIn('id="egress"', html)
+ self.assertIn('href="egress.pdf"', html)
self.generate()
self.assertNotIn("A separate Egress", self.es_text())
html = (Path(self.report) / "index.html").read_text(encoding="utf-8")
- self.assertNotIn("A separate Egress", html)
+ self.assertNotIn(EDL_ID, html)
+ self.assertNotIn('id="egress"', html)
+ self.assertNotIn("egress.pdf", html)
- def test_a_failing_egress_pdf_leaves_the_summary_written(self):
+ def test_a_failing_egress_pdf_leaves_the_summary_and_the_page_written(self):
(Path(self.report) / "report.pdf").unlink(missing_ok=True)
- with patch(
- "cloudexit.engine.generate_egress_report",
- side_effect=RuntimeError("reportlab went wrong"),
+ with (
+ patch(
+ "cloudexit.engine.load_egress_estimate",
+ return_value=empty_estimate(),
+ ),
+ patch(
+ "cloudexit.engine.generate_egress_report",
+ side_effect=RuntimeError("reportlab went wrong"),
+ ),
):
result = self.generate(egress_json_path="estimate.json")
self.assertTrue(result["success"])
self.assertTrue((Path(self.report) / "report.pdf").is_file())
- self.assertTrue((Path(self.report) / "index.html").is_file())
+ html = (Path(self.report) / "index.html").read_text(encoding="utf-8")
+ self.assertIn('id="egress"', html)
self.assertNotIn("EGRESS_PDF", result["reports"])
self.assertEqual(result["egress_pdf_logs"], "reportlab went wrong")
- def test_an_unreadable_estimate_is_the_same_failure(self):
+ def test_an_unreadable_estimate_leaves_egress_out_of_every_report(self):
result = self.generate(egress_json_path="/nowhere/estimate.json")
self.assertTrue(result["success"])
self.assertIn("egress_pdf_logs", result)
+ self.assertNotIn("EGRESS_PDF", result["reports"])
self.assertTrue((Path(self.report) / "report.pdf").is_file())
+ self.assertNotIn("A separate Egress", self.es_text())
+ html = (Path(self.report) / "index.html").read_text(encoding="utf-8")
+ self.assertNotIn('id="egress"', html)
def test_without_an_estimate_nothing_is_added(self):
result = self.generate()
diff --git a/tests/report/test_html_report.py b/tests/report/test_html_report.py
new file mode 100644
index 0000000..128569d
--- /dev/null
+++ b/tests/report/test_html_report.py
@@ -0,0 +1,955 @@
+import hashlib
+import json
+import posixpath
+import re
+import tempfile
+import unittest
+from html.parser import HTMLParser
+from pathlib import Path
+from urllib.parse import urlparse
+
+from pypdf import PdfReader
+
+from cloudexit.engine import generate_report, perform_risk_assessment
+from cloudexit.report.sections import AWS_ICONS_URL, AZURE_ICONS_URL, REPOSITORY_URL
+from cloudexit.report.wording import BLOCKS
+from cloudexit.resources import asset_path
+from tests.report.test_basic_report import page_texts
+from tests.samples.rendered import rendered
+from tests.support.flow import SERVER_SCORES, build_flow_run
+from tests.support.html import html_text
+from tests.support.report_terms import find_terms
+from tests.support.sync import SYNC_ID
+
+DRIVER_JS_SHA256 = "3e3790dea8118c80a7a1d81af9454f8f6fe9a5dd180e843ce8ba638c5b446753"
+DRIVER_CSS_SHA256 = "4b398481a7ce8375af4d9f58f39410c73a0b70726fe513686d3d51c10ad76cb5"
+BASE_SECTIONS = ["overview", "scores", "risks", "inventory", "alternatives", "about"]
+WITH_EGRESS = [
+ "overview",
+ "scores",
+ "risks",
+ "inventory",
+ "alternatives",
+ "egress",
+ "about",
+]
+TOUR_TITLES = [
+ "Sections",
+ "This Report",
+ "Overview",
+ "Exit Readiness Grade & Vendor Lock-in Score",
+ "Risks",
+ "Resource Inventory",
+ "Alternative Technologies",
+ "Egress & Data Landscape",
+ "About This Report",
+]
+PROVIDER_LOGOS = {"aws": "Amazon Web Services", "azure": "Microsoft Azure"}
+PLATFORM_URL = "https://platform.example.test/assessments/a-1"
+
+STATE = {}
+
+
+def setUpModule():
+ STATE.update(rendered())
+
+
+def tearDownModule():
+ STATE.clear()
+
+
+class References(HTMLParser):
+ # What the page loads: stylesheets, scripts and images
+ def __init__(self):
+ super().__init__()
+ self.found = []
+
+ def handle_starttag(self, tag, attrs):
+ attrs = dict(attrs)
+ if tag == "link" and attrs.get("href"):
+ self.found.append((tag, attrs["href"]))
+ elif tag in ("script", "img") and attrs.get("src"):
+ self.found.append((tag, attrs["src"]))
+
+
+def references(html):
+ parser = References()
+ parser.feed(html)
+ return parser.found
+
+
+def is_local(value, base=""):
+ # A relative path that stays inside the report folder; base is the folder of
+ # the file that holds the reference, relative to the report folder
+ parsed = urlparse(value)
+ if parsed.scheme or parsed.netloc or value.startswith(("/", "\\")):
+ return False
+ return not posixpath.normpath(posixpath.join(base, value)).startswith("..")
+
+
+def css_references(css):
+ found = re.findall(r"@import\s+(?:url\()?\s*[\"']?([^\"')\s;]+)", css)
+ return found + re.findall(r"url\(\s*[\"']?([^\"')]+)", css)
+
+
+def offline_problems(html, stylesheets=()):
+ # Anything the page would load from outside its own folder; a stylesheet is
+ # given as (folder, text), the folder relative to the report folder
+ problems = [
+ f"<{tag}> {value}" for tag, value in references(html) if not is_local(value)
+ ]
+ for base, css in (("", html),) + tuple(stylesheets):
+ problems += [
+ f"css {value}" for value in css_references(css) if not is_local(value, base)
+ ]
+ return problems
+
+
+def composed_of(text, pieces):
+ # Whether the text is built only from the pieces, joined by single spaces
+ if not text:
+ return True
+ return any(
+ text.startswith(piece)
+ and (len(text) == len(piece) or text[len(piece)] == " ")
+ and composed_of(text[len(piece) :].lstrip(" "), pieces)
+ for piece in pieces
+ if piece
+ )
+
+
+class SampleTestCase(unittest.TestCase):
+ def cases(self):
+ yield from STATE["scenarios"].items()
+
+ def folder(self, case):
+ return STATE["results"][case].directory
+
+ def page(self, case):
+ return (self.folder(case) / "index.html").read_text(encoding="utf-8")
+
+ def text(self, case):
+ return html_text(self.page(case))
+
+ def section(self, case, name):
+ match = re.search(
+ rf'', self.page(case), re.DOTALL
+ )
+ self.assertIsNotNone(match, f"{case}: no section {name}")
+ return match.group(0)
+
+ def tour(self, case):
+ match = re.search(
+ r'',
+ self.page(case),
+ re.DOTALL,
+ )
+ return json.loads(match.group(1))
+
+ def sections(self, case):
+ return re.findall(r'