diff --git a/cloudexit/assets/template/egress.html b/cloudexit/assets/template/egress.html index 3243c38..117f62f 100644 --- a/cloudexit/assets/template/egress.html +++ b/cloudexit/assets/template/egress.html @@ -9,7 +9,7 @@ sizes="16x16" href="assets/img/logo/favicon.png" /> - EscapeCloud Community Edition - Data Landscape & Egress Estimation + {{ page_title }}
Egress Summary (PDF)Egress & Data Landscape (PDF)
@@ -95,21 +95,6 @@

-
Assessment Type

{{ assessment_name }}
+
+
+ +
+
+
Document ID
+
{{ document_id }}
+
+
@@ -260,7 +254,7 @@
class="text-muted small mb-0" style="font-size: 0.8rem; line-height: 1.4;" > - Your scanned footprint ({{ known_data_display }}) falls entirely within the cloud provider's complimentary monthly internet egress allowance @@ -323,15 +317,63 @@
Data Allocation
+
+
+
+

{{ introduction.heading }}

+

{{ introduction.purpose_heading }}

+

{{ introduction.purpose }}

+

{{ introduction.scope_heading }}

+

{{ introduction.scope }}

+

{{ introduction.limitations_heading }}

+ {% for paragraph in introduction.limitations %} +

{{ paragraph }}

+ {% endfor %} + {% if coverage %} +

{{ coverage.heading }}

+

{{ coverage.intro }}

+ + + + {% for header in coverage.headers %} + + {% endfor %} + + + + {% for name, note in coverage.rows %} + + + + + {% endfor %} + +
{{ header }}
{{ name }}{{ note }}
+ {% endif %} +
+
+
+
-

Data Landscape

+

{{ landscape.heading }}

+
+

{{ landscape.lead }}

+
+ {% for label, value, attention in landscape.stats %} +
+
{{ label }}
+
{{ value }}
+
+ {% endfor %} +
+
@@ -339,7 +381,7 @@

Data Landscape

- + @@ -426,6 +468,124 @@

Data Landscape

# Resource Type CategorySize{{ landscape.headers[2] }}
+
+ {% if landscape.not_sized %}

{{ landscape.not_sized }}

{% endif %} +

{{ landscape.caption }}

+

{{ landscape.source_note }}

+
+
+
+
+ +
+
+
+

{{ costs.heading }}

+

{{ costs.lead }}

+
+ {% for label, value, attention in costs.stats %} +
+
{{ label }}
+
{{ value }}
+
+ {% endfor %} +
+ {% if costs.rows %} + + + + + + + + + + + {% for row in costs.rows %} + + + + + + + {% endfor %} + + + + + + + +
{{ costs.headers[0] }}{{ costs.headers[1] }}{{ costs.headers[2] }}{{ costs.headers[3] }}
{{ row.label }}{{ row.size }}{{ row.share_text }}{{ row.fee }}
{{ costs.total[0] }}{{ costs.total[1] }}{{ costs.total[2] }}
+ {% endif %} +

{{ costs.caption }}

+
+
+
+ + {% if pricing %} +
+
+
+

{{ pricing.heading }}

+

{{ pricing.intro }}

+ + + + {% for header in pricing.headers %} + + {% endfor %} + + + + {% for row in pricing.rows %} + + {% for cell in row %} + + {% endfor %} + + {% endfor %} + +
{{ header }}
{{ cell }}
+
+ {% if pricing.units %}

{{ pricing.units }}

{% endif %} + {% if pricing.no_archive %}

{{ pricing.no_archive }}

{% endif %} + {% for template, url in pricing.sources %} +

{{ template.split("{link}")[0] }}{{ url }}{{ template.split("{link}")[1] }}

+ {% endfor %} +

{{ pricing.note }}

+
+

{{ pricing.glossary_heading }}

+
+ {% for term, definition in pricing.glossary %} +
{{ term }}
+
{{ definition }}
+ {% endfor %} +
+
+
+
+ {% endif %} + +
+
+
+

{{ next_steps.heading }}

+ {% for paragraph in next_steps.paragraphs %} +

+ {% for part in paragraph %}{% if part is string %}{{ part }}{% else %}{{ part[0] }}{% endif %}{% endfor %} +

+ {% endfor %}
@@ -474,14 +634,10 @@

{{ block.heading }}

if (value === null || value === undefined) { return "n/a"; } - const units = ["B", "KiB", "MiB", "GiB", "TiB"]; - let scaled = value; - let unitIndex = 0; - while (scaled >= 1024 && unitIndex < units.length - 1) { - scaled /= 1024; - unitIndex += 1; - } - return scaled.toFixed(1) + " " + units[unitIndex]; + return (value / 1024 ** 3).toLocaleString(undefined, { + minimumFractionDigits: 1, + maximumFractionDigits: 1, + }) + " GiB"; } diff --git a/cloudexit/assets/template/index.html b/cloudexit/assets/template/index.html index 2c787c1..0087909 100644 --- a/cloudexit/assets/template/index.html +++ b/cloudexit/assets/template/index.html @@ -294,6 +294,9 @@

Costs Overview

{% endif %} + {% if summary.costs_egress_note %} +

{{ summary.costs_egress_note }}

+ {% endif %} diff --git a/cloudexit/cli/app.py b/cloudexit/cli/app.py index 8667119..69e218c 100644 --- a/cloudexit/cli/app.py +++ b/cloudexit/cli/app.py @@ -29,10 +29,7 @@ create_subscription_client, ) from cloudexit.egress.estimate import estimate_egress -from cloudexit.report.egress import ( - generate_egress_html_report, - generate_egress_pdf_report, -) +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 @@ -992,7 +989,6 @@ def run_assessment( egress_collected = False egress_json_path = None egress_html_path = None - egress_pdf_path = None if egress: console.print("Stage #5 – Egress Estimation", style="bold") @@ -1014,7 +1010,10 @@ def run_assessment( with console.status("Generating egress report...", spinner="dots"): egress_report_result = generate_egress_html_report( - report_path, egress_json_path + report_path, + egress_json_path, + config["providerDetails"], + sync_id, ) if egress_report_result["success"]: @@ -1027,22 +1026,6 @@ def run_assessment( logs=egress_report_result["logs"], ) egress_failed = True - - with console.status("Generating egress PDF...", spinner="dots"): - egress_pdf_result = generate_egress_pdf_report( - report_path, egress_json_path, config["providerDetails"] - ) - - if egress_pdf_result["success"]: - print_step("Generating egress PDF...", status="ok") - egress_pdf_path = egress_pdf_result.get("pdf_path") - else: - print_step( - "Generating egress PDF...", - status="error", - logs=egress_pdf_result["logs"], - ) - egress_failed = True else: print_step( "Estimating egress data...", @@ -1170,6 +1153,7 @@ def run_assessment( raw_data_path, upload=report_info(upload_result), sync_id=sync_id, + egress_json_path=egress_json_path, ) # Handle the result @@ -1181,6 +1165,20 @@ def run_assessment( ) sys.exit(codes.REPORT) + # The egress PDF is written with the report, after the upload, so it can + # show the run and the platform link. Its failure is an egress failure, + # not a report failure. + egress_pdf_path = report_status.get("reports", {}).get("EGRESS_PDF") + if egress_pdf_path: + print_step("Generating egress PDF...", status="ok") + elif egress_json_path: + print_step( + "Generating egress PDF...", + status="error", + logs=report_status.get("egress_pdf_logs", "no egress PDF was written"), + ) + egress_failed = True + # Output the report path after the separator if not (dry_run or non_interactive): console.print("-------------------------------------------") diff --git a/cloudexit/engine.py b/cloudexit/engine.py index 2c3efa6..f5da2a8 100644 --- a/cloudexit/engine.py +++ b/cloudexit/engine.py @@ -22,7 +22,12 @@ from cloudexit.providers.azure.sdk import azure_import from cloudexit.catalogue.db import connect, load_data from cloudexit.tfstate import build_tfstate_resource_inventory -from cloudexit.report.context import build_context, collect_provenance +from cloudexit.report.context import ( + EGRESS_DOCUMENT_CODE, + build_context, + collect_provenance, +) +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 ( @@ -432,6 +437,7 @@ def generate_report( raw_data_path: str, upload: dict[str, str] | None = None, sync_id: str | None = None, + egress_json_path: str | None = None, ) -> dict[str, Any]: try: db_path = os.path.join(report_path, "data", "assessment.db") @@ -493,6 +499,8 @@ def generate_report( provenance=provenance, report_path=report_path, ) + if egress_json_path: + context.egress_document_id = context.document_id_for(EGRESS_DOCUMENT_CODE) # Generate HTML report reports["HTML"] = generate_html_report( @@ -530,7 +538,20 @@ def generate_report( provenance=provenance.as_dict(), ) - return {"success": True, "reports": reports} + result = {"success": True, "reports": reports} + + # The Egress & Data Landscape is written last, from the same context: a + # problem with it never costs the Executive Summary + if egress_json_path: + try: + reports["EGRESS_PDF"] = generate_egress_report( + context, egress_json_path + ) + except Exception as e: + logger.error("Error generating the egress PDF: %s", e, exc_info=True) + result["egress_pdf_logs"] = str(e) + + return result except Exception as e: return {"success": False, "logs": f"Error generating report: {str(e)}"} diff --git a/cloudexit/report/context.py b/cloudexit/report/context.py index e56e52e..9f24c96 100644 --- a/cloudexit/report/context.py +++ b/cloudexit/report/context.py @@ -14,6 +14,16 @@ STRATEGY_CODES = {1: "repatriation", 2: "hybrid_cloud", 3: "alternate_cloud"} COST_MONTHS = 6 DOCUMENT_CODE = "ES" +EGRESS_DOCUMENT_CODE = "EDL" + + +def short_run_id(run_id): + # The end of a UUIDv7 is random; its start is the time. + return run_id.replace("-", "")[-8:] if run_id else "" + + +def format_document_id(code, run_id): + return f"{code}-{short_run_id(run_id) or 'LOCAL'}-EN" @dataclass(frozen=True) @@ -31,8 +41,7 @@ class Provenance: @property def run_short_id(self): - # The end of a UUIDv7 is random; its start is the time. - return self.run_id.replace("-", "")[-8:] if self.run_id else "" + return short_run_id(self.run_id) @property def collected_date(self): @@ -146,10 +155,16 @@ class ReportContext: alternatives: list report_path: str appearance: Appearance = DEFAULT_APPEARANCE + # Set when the run has an egress estimate, so the Executive Summary can name + # the document that carries it. + egress_document_id: str | None = None + + def document_id_for(self, code): + return format_document_id(code, self.provenance.run_id) @property def document_id(self): - return f"{DOCUMENT_CODE}-{self.provenance.run_short_id or 'LOCAL'}-EN" + return self.document_id_for(DOCUMENT_CODE) @property def total_resources(self): diff --git a/cloudexit/report/cover.py b/cloudexit/report/cover.py index 4af84e6..6e9c0a6 100644 --- a/cloudexit/report/cover.py +++ b/cloudexit/report/cover.py @@ -88,10 +88,10 @@ def _paragraph(value, style): class RecordCover: - def __init__(self, title, context, b): + def __init__(self, title, context, b, document_id=None): self.title = title self.project_name = context.name - self.document_id = context.document_id + self.document_id = document_id or context.document_id self.appearance = context.appearance self.report_path = context.report_path self.provenance = context.provenance diff --git a/cloudexit/report/egress.py b/cloudexit/report/egress.py index 36bd1ca..d4ca4a0 100644 --- a/cloudexit/report/egress.py +++ b/cloudexit/report/egress.py @@ -3,49 +3,37 @@ import logging import os import sqlite3 +from collections import Counter from datetime import datetime from typing import Any -from xml.sax.saxutils import escape from jinja2 import Environment -from reportlab.lib import colors -from reportlab.lib.colors import HexColor -from reportlab.graphics.charts.piecharts import Pie -from reportlab.graphics.shapes import Drawing, Rect, String -from reportlab.lib.pagesizes import A4 -from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet -from reportlab.lib.units import cm -from reportlab.platypus import ( - Image, - PageBreak, - Paragraph, - SimpleDocTemplate, - Spacer, - Table, - TableStyle, -) -from cloudexit.report.helpers import initials -from cloudexit.report.placeholder import IconPlaceholder -from cloudexit.resources import asset_path, find_asset +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, compute_totals, - format_bytes, read_egress_inventory, ) 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 scope_facts -from cloudexit.report.notices import notices_html +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.sections import BOOK_DEMO_URL, PRODUCT_SITES from cloudexit.report.wording import BLOCKS -PDF_HEADER_TITLE = "EscapeCloud Community Edition - Data & Egress" - logger = logging.getLogger(__name__) DEFAULT_PRICING_ZONE = "zone1" UNIT_DIVISORS = {"GB": 10**9, "GiB": 2**30} +COMPONENTS = ("internet_egress", "archive_retrieval") ARCHIVE_TIERS_BY_CSP = {1: AZURE_ARCHIVE_TIERS, 2: AWS_ARCHIVE_TIERS} @@ -81,46 +69,6 @@ "in_allocation": False, }, } -EGRESS_FEE_CHART_COLORS = [ - HexColor("#115e59"), - HexColor("#ffae1f"), - HexColor("#539bff"), - HexColor("#a855f7"), - HexColor("#22c55e"), - HexColor("#991b1b"), -] - -PROVIDER_NAMES = {1: "Microsoft Azure", 2: "Amazon Web Services"} -COMPONENT_LABELS = { - "internet_egress": "Internet Egress", - "archive_retrieval": "Archive Retrieval", -} -COVERAGE_NOTES = { - 1: [ - ("Storage Accounts", "Capacity metrics can lag by up to roughly 48 hours"), - ("Managed Disks, Snapshots", "Provisioned size; counted as an upper bound"), - ("SQL Databases", "Used space from database metrics"), - ("Cosmos DB Accounts", "Used space from database metrics"), - ("PostgreSQL / MySQL Flexible Servers", "Used space from database metrics"), - ("Recovery Services Vaults", "Not sized; recovery points cannot be exported"), - ], - 2: [ - ("S3 Buckets", "Metrics can lag by up to roughly 48 hours"), - ( - "EBS Volumes, Snapshots", - "Upper bound; snapshots can over-count shared blocks", - ), - ("RDS Instances", "Aurora is not sized"), - ("DynamoDB Tables", ""), - ("AWS Backup Vaults", "Not sized; recovery points cannot be exported"), - ], -} - -ALPHA_NOTE = ( - "NOTE: This Data Landscape & Egress Estimation feature is currently in " - "alpha version. Estimated values may be incomplete or inaccurate in some " - "environments." -) PRICING_SOURCE_LINKS = { 1: { @@ -135,6 +83,8 @@ }, } +COVERAGE_NOTES_KEYS = {1: "eg_coverage_notes_azure", 2: "eg_coverage_notes_aws"} + FALLBACK_ICONS = { "microsoft.compute/disks": "/icons/azure/compute/10032-icon-service-Disks.png", "microsoft.compute/snapshots": ( @@ -148,187 +98,6 @@ } -def default_table_style() -> TableStyle: - """Shared header style used by summary and scope tables.""" - return TableStyle( - [ - ("BACKGROUND", (0, 0), (-1, 0), HexColor("#115e59")), - ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), - ("GRID", (0, 0), (-1, -1), 1, HexColor("#000000")), - ("ALIGN", (0, 0), (-1, -1), "LEFT"), - ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), - ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"), - ("FONTSIZE", (0, 0), (-1, 0), 11), - ("BOTTOMPADDING", (0, 0), (-1, 0), 12), - ("TOPPADDING", (0, 0), (-1, 0), 12), - ] - ) - - -def build_summary_section(metadata, styles, content_style): - """Page 1: Summary table.""" - content = [] - content.append(Spacer(1, 12)) - content.append(Paragraph("Summary", styles["Heading1"])) - content.append(Paragraph("Quick overview of the assessment:", content_style)) - - cloud_service_provider_map = { - "1": "Microsoft Azure", - "2": "Amazon Web Services", - "3": "Alibaba Cloud", - "4": "Google Cloud", - } - exit_strategy_map = { - "1": "Repatriation to On-Premises", - "2": "Hybrid Cloud Adoption", - "3": "Migration to Alternate Cloud", - } - type_map = {"1": "Basic", "2": "Standard"} - - summary_data = [ - ["Name", "Value"], - [ - "Cloud Service Provider", - cloud_service_provider_map.get( - str(metadata["cloud_service_provider"]), "Unknown" - ), - ], - [ - "Exit Strategy", - exit_strategy_map.get(str(metadata["exit_strategy"]), "Unknown"), - ], - ["Assessment Type", type_map.get(str(metadata["assessment_type"]), "Unknown")], - ["TimeStamp", metadata["timestamp"]], - ] - - summary_table = Table(summary_data, colWidths=[4 * cm, 11.5 * cm]) - summary_table.setStyle(default_table_style()) - content.append(summary_table) - content.append(Spacer(1, 12)) - - upload = metadata.get("upload") - if upload: - if upload.get("status") == "uploaded": - note = "Uploaded to the platform." - if upload.get("url"): - url = escape(upload["url"]) - note += f' {url}' - else: - note = f"Not uploaded: {escape(upload.get('reason', 'unknown'))}" - content.append(Paragraph(note, content_style)) - return content - - -# Cap on how many subscriptions / resource groups / regions a scope row lists -# before it summarises the remainder. The full list stays in the manifest. -MAX_SCOPE_VALUES = 3 - - -def _shorten(value: str, head: int = 4, tail: int = 4) -> str: - if not isinstance(value, str): - return "N/A" - if len(value) <= head + tail + 1: - return value - return f"{value[:head]}…{value[-tail:]}" - - -def _join_scope_values(values) -> str: - if not values: - return "Not determinable from state" - shown = list(values)[:MAX_SCOPE_VALUES] - remaining = len(values) - len(shown) - text = ", ".join(shown) - return f"{text}, +{remaining} more" if remaining > 0 else text - - -def _build_tfstate_scope_rows(metadata, scope): - rows = [] - - file_name = scope.get("file") or "N/A" - sha256 = scope.get("sha256") - if sha256: - file_name = f"{file_name} (SHA-256 {_shorten(sha256)})" - rows.append(["File", file_name]) - - lineage = scope.get("lineage") - serial = scope.get("serial") - if lineage and serial is not None: - rows.append(["State", f"{lineage} (Serial: {serial})"]) - elif lineage: - rows.append(["State", lineage]) - elif serial is not None: - rows.append(["State", f"Serial: {serial}"]) - - if metadata["cloud_service_provider"] == 1: # Azure - rows.append(["Subscription", _join_scope_values(scope.get("subscriptions"))]) - rows.append( - ["Resource Group(s)", _join_scope_values(scope.get("resource_groups"))] - ) - else: # AWS - rows.append(["Region(s)", _join_scope_values(scope.get("locations"))]) - - return rows - - -def _live_scope_rows(cloud_service_provider, provider_details): - # The same facts as the Assessment Scope on page 2 of the Executive - # Summary, and nothing that identifies the credentials in use - b = BLOCKS - scope = scope_facts(provider_details, cloud_service_provider, None) - values = dict(scope.rows) - if scope.kind == "azure": - return [ - [b["ev_tenant_id"], values.get("tenant") or b["ev_dash"]], - [b["ev_subscription_id"], values.get("subscription") or b["ev_dash"]], - [b["ev_resource_group"], values.get("resource_group") or b["ev_dash"]], - ] - rows = [] - if values.get("account"): - rows.append([b["ev_account_id"], values["account"]]) - rows.append([b["ev_region"], values.get("region") or b["ev_dash"]]) - return rows - - -def build_scope_section( - metadata, provider_details, styles, content_style, tfstate_scope=None -): - """Page 1: Scope of Assessment table.""" - content = [] - content.append(Paragraph("Scope of Assessment", styles["Heading2"])) - - tfstate_path = provider_details.get("tfstatePath") - if tfstate_path: - content.append( - Paragraph( - "Assessed from Terraform state. Resources not managed by " - "Terraform are not included.", - content_style, - ) - ) - else: - content.append(Paragraph("Defined scope of assessment:", content_style)) - - scope_data = [["Name", "Value"]] - - if tfstate_path: - # Fall back to the little we know from the config when the manifest is - # unavailable, rather than rendering a table of N/A. - scope = tfstate_scope or {"file": os.path.basename(tfstate_path)} - scope_data.extend(_build_tfstate_scope_rows(metadata, scope)) - elif metadata["cloud_service_provider"] in (1, 2): - scope_data.extend( - _live_scope_rows(metadata["cloud_service_provider"], provider_details) - ) - else: - scope_data.append(["N/A", "N/A"]) - - scope_table = Table(scope_data, colWidths=[4 * cm, 11.5 * cm]) - scope_table.setStyle(default_table_style()) - content.append(scope_table) - content.append(Spacer(1, 12)) - return content - - def _unit_divisor(unit: str) -> float: return UNIT_DIVISORS.get(unit, GIB) @@ -417,9 +186,9 @@ def format_fee(fee: float | None) -> str: return f"${fee:,.2f}" -def free_tier_limit_display( +def _free_tier( prices: list[dict[str, Any]], cloud_service_provider: int -) -> str | None: +) -> dict[str, Any] | None: tiers = sorted( ( price @@ -431,10 +200,24 @@ def free_tier_limit_display( key=lambda tier: tier["tier_from"], ) if tiers and tiers[0]["price_per_unit"] == 0.0 and tiers[0]["tier_to"]: - return f"{tiers[0]['tier_to']:g} {tiers[0]['unit']}" + return tiers[0] return None +def free_tier_limit_display( + prices: list[dict[str, Any]], cloud_service_provider: int +) -> str | None: + tier = _free_tier(prices, cloud_service_provider) + return f"{tier['tier_to']:g} {tier['unit']}" if tier else None + + +def free_allowance_bytes( + prices: list[dict[str, Any]], cloud_service_provider: int +) -> float | None: + tier = _free_tier(prices, cloud_service_provider) + return tier["tier_to"] * _unit_divisor(tier["unit"]) if tier else None + + def _summarize_totals( totals: dict[str, Any], total_fee: float, @@ -448,8 +231,8 @@ def _summarize_totals( and free_tier_limit is not None ) unknown_count = totals["resources_with_unknown_size"] - total_fee_display = "$0" if free_tier else format_fee(total_fee) - total_data_display = format_bytes(totals["known_size_bytes"]) + total_fee_display = format_fee(total_fee) + total_data_display = format_gib(totals["known_size_bytes"]) if unknown_count > 0: total_data_display = f"{total_data_display}+" if not free_tier: @@ -547,7 +330,7 @@ def _build_type_groups( ) for group in groups.values(): - group["size_display"] = format_bytes(group["size_bytes"]) + group["size_display"] = format_gib(group["size_bytes"]) group["fee_display"] = format_fee(group["fee"]) return sorted( @@ -583,6 +366,23 @@ def _load_estimate( return meta, rows, totals, pricing, total_fee, type_groups +def load_egress_estimate(report_path: str, json_path: str) -> dict[str, Any]: + meta, rows, totals, pricing, total_fee, type_groups = _load_estimate( + report_path, json_path + ) + return { + "meta": meta, + "rows": rows, + "totals": totals, + "pricing": pricing, + "total_fee": total_fee, + "type_groups": type_groups, + "summary": _summarize_totals( + totals, total_fee, pricing, meta["cloud_service_provider"] + ), + } + + def _generated_at(timestamp): # The run's own time, as the estimate stored it; now when it cannot be read try: @@ -591,652 +391,452 @@ def _generated_at(timestamp): return datetime.now() -def generate_egress_html_report(report_path: str, json_path: str) -> dict[str, Any]: - try: - meta, rows, totals, pricing, total_fee, type_groups = _load_estimate( - report_path, json_path - ) +def _share(part, whole): + return part / whole * 100 if whole else 0 - summary = _summarize_totals( - totals, total_fee, pricing, meta["cloud_service_provider"] - ) - allocation_labels, allocation_values, allocation_colors = _build_allocation( - rows - ) +def _plus(text, estimate): + # Resources without a size are left out, so the figure is a lower bound + return f"{text}+" if estimate["summary"]["unknown_count"] > 0 else text - 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" - ) - - env = Environment(autoescape=True) - template = env.from_string(template_content) - html_content = template.render( - name=meta["name"], - cloud_service_provider=meta["cloud_service_provider"], - 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_bytes(totals["known_size_bytes"]), - type_groups=type_groups, - notices=notices_html( - meta["cloud_service_provider"], _generated_at(meta["timestamp"]) - ), - ) - - 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)} - - -def _icon_flowable(report_path: str, icon: str | None, label: str) -> Any: - icon_path = find_asset(report_path, icon) if icon else None - if icon_path is None: - return IconPlaceholder(label, 20) - return Image(str(icon_path), width=20, height=20) +def introduction_facts(scope, b=BLOCKS): + return { + "heading": b["eg_heading_introduction"], + "purpose_heading": b["eg_heading_purpose"], + "purpose": b["eg_purpose_block"], + "scope_heading": b["eg_heading_scope"], + "scope": scope_sentence(scope, b), + "limitations_heading": b["eg_heading_limitations"], + "limitations": [ + b["eg_limitations_block"], + b["eg_limitations_scope"], + b["eg_limitations_data"], + ], + } -def _fee_breakdown_display(group: dict[str, Any]) -> str: - if group["fee"] is None: - return "n/a (not sized)" - return group["fee_display"] +def scope_sentence(scope, b=BLOCKS): + if scope.kind == "azure": + return b["eg_scope_azure"].format( + resource_group=scope.resource_group or b["ev_dash"] + ) + if scope.kind == "aws": + return b["eg_scope_aws"].format(region=scope.region or b["ev_dash"]) + return b["eg_scope_plain"] -def _egress_fee_chart_groups( - type_groups: list[dict[str, Any]], max_segments: int = 5 -) -> list[tuple[str, float]]: - fee_groups = [ - (group["label"], group["fee"]) - for group in type_groups - if group.get("fee") is not None and group["fee"] > 0 - ] - if len(fee_groups) <= max_segments: - return fee_groups - visible = fee_groups[: max_segments - 1] - other_total = sum(fee for _, fee in fee_groups[max_segments - 1 :]) - if other_total > 0: - visible.append(("Other Components", other_total)) - return visible - - -def _draw_egress_fee_chart(type_groups: list[dict[str, Any]]) -> Drawing | None: - chart_groups = _egress_fee_chart_groups(type_groups) - if not chart_groups: +def coverage_facts(cloud_service_provider, b=BLOCKS): + key = COVERAGE_NOTES_KEYS.get(cloud_service_provider) + if not key: return None - - total_fee = sum(fee for _, fee in chart_groups) - drawing = Drawing(360, 170) - - pie = Pie() - pie.x = 35 - pie.y = 20 - pie.width = 125 - pie.height = 125 - pie.data = [fee for _, fee in chart_groups] - pie.innerRadiusFraction = 0.55 - for index, _ in enumerate(chart_groups): - color = EGRESS_FEE_CHART_COLORS[index % len(EGRESS_FEE_CHART_COLORS)] - pie.slices[index].fillColor = color - pie.slices[index].strokeColor = colors.white - pie.slices[index].strokeWidth = 1 - drawing.add(pie) - - legend_x = 190 - legend_y = 130 - for index, (label, fee) in enumerate(chart_groups): - y = legend_y - index * 22 - color = EGRESS_FEE_CHART_COLORS[index % len(EGRESS_FEE_CHART_COLORS)] - share = (fee / total_fee * 100) if total_fee else 0 - drawing.add(Rect(legend_x, y, 8, 8, fillColor=color, strokeColor=color)) - drawing.add( - String( - legend_x + 14, - y - 1, - f"{label}: {share:.1f}%", - fontName="Helvetica", - fontSize=8, - fillColor=HexColor("#112726"), - ) - ) - return drawing + return { + "heading": b["eg_heading_coverage"], + "intro": b["eg_coverage_intro"], + "headers": (b["eg_th_resource_type"], b["eg_th_measured"]), + "rows": [(name, note or b["ev_dash"]) for name, note in b[key]], + } -def _build_coverage_section(cloud_service_provider, styles, content_style): - rows = COVERAGE_NOTES.get(cloud_service_provider) - if not rows: - return [] - - content = [] - content.append( - Paragraph( - "The estimation covers the following data-related resource types " - "within the assessed scope:", - content_style, - ) +def landscape_facts(estimate, b=BLOCKS): + rows = estimate["rows"] + summary = estimate["summary"] + total = estimate["totals"]["known_size_bytes"] + resources = len(rows) + unsized = [row for row in rows if row["size_bytes"] is None] + sized = resources - len(unsized) + counts = Counter(row["label"] for row in rows) + groups = sorted( + (group for group in estimate["type_groups"] if group["size_bytes"] is not None), + key=lambda group: -group["size_bytes"], ) - table_data = [["Name", "Notes"]] + [[name, notes] for name, notes in rows] - coverage_table = Table(table_data, colWidths=[7 * cm, 8.5 * cm]) - coverage_table.setStyle(default_table_style()) - content.append(coverage_table) - content.append(Spacer(1, 6)) - note_style = ParagraphStyle( - "CoverageNote", - fontSize=8, - leading=10, - textColor=HexColor("#6c757d"), - ) - content.append(Paragraph(ALPHA_NOTE, note_style)) - content.append(PageBreak()) - return content - + top = groups[0] if groups else None -def _build_estimated_costs_section( - total_fee, totals, free_tier, free_tier_limit, type_groups, styles, content_style -): - content = [] - content.append(Spacer(1, 12)) - content.append(Paragraph("Estimated Costs", styles["Heading2"])) - - unknown_count = totals["resources_with_unknown_size"] - if free_tier: - fee_display = "$0" - intro = ( - f"The scanned footprint ({format_bytes(totals['known_size_bytes'])}) " - "falls entirely within the cloud provider's complimentary monthly " - f"internet egress allowance ({free_tier_limit} free tier)." - ) + if not resources: + lead = b["eg_no_data_notice"] + elif not sized: + key = "eg_land_none_sized_one" if resources == 1 else "eg_land_none_sized" + lead = b[key].format(resources=resources) else: - fee_display = format_fee(total_fee) - if unknown_count > 0: - fee_display = f"{fee_display}+" - intro = ( - "Estimated one-time internet egress fee for transferring the data " - "identified on the previous page out of the cloud provider." - ) - content.append(Paragraph(intro, content_style)) - - card_title_style = ParagraphStyle( - "EgressCostCardTitle", - parent=content_style, - fontName="Helvetica-Bold", - fontSize=8, - leading=10, - textColor=HexColor("#115e59"), - ) - card_amount_style = ParagraphStyle( - "EgressCostCardAmount", - parent=content_style, - fontName="Helvetica-Bold", - fontSize=20, - leading=24, - textColor=HexColor("#112726"), - ) - warning_style = ParagraphStyle( - "EgressCostWarning", - parent=content_style, - fontSize=8, - leading=10, - textColor=HexColor("#664d03"), - ) - breakdown_header_style = ParagraphStyle( - "EgressCostBreakdownHeader", - parent=card_title_style, - textColor=colors.white, - ) - total_card_data = [ - [ - Paragraph("TOTAL ESTIMATED NETWORK TRANSIT FEE", card_title_style), - ], - [Paragraph(fee_display, card_amount_style)], - ] - if unknown_count > 0: - total_card_data.append( - [ - Paragraph( - f"Excludes {unknown_count} " - f"resource{'s' if unknown_count != 1 else ''} with " - "unavailable size calculations.", - warning_style, + sentences = [ + b["eg_land_lead_one" if resources == 1 else "eg_land_lead"].format( + total=format_gib(total), sized=sized, resources=resources + ) + ] + if len(groups) > 1 and total > 0 and top["size_bytes"]: + sentences.append( + b["eg_land_top"].format( + type=top["label"], + size=format_gib(top["size_bytes"]), + share=share_label(_share(top["size_bytes"], total)), ) - ] - ) - total_card = Table(total_card_data, colWidths=[15.5 * cm]) - total_card.setStyle( - TableStyle( - [ - ("BACKGROUND", (0, 0), (-1, -1), HexColor("#f8fafc")), - ("BOX", (0, 0), (-1, -1), 1, HexColor("#d7e3e1")), - ("LEFTPADDING", (0, 0), (-1, -1), 12), - ("RIGHTPADDING", (0, 0), (-1, -1), 12), - ("TOPPADDING", (0, 0), (-1, -1), 8), - ("BOTTOMPADDING", (0, 0), (-1, -1), 8), - ] - ) - ) - content.append(total_card) - content.append(Spacer(1, 12)) - - chart = _draw_egress_fee_chart(type_groups) - if chart: - chart_table = Table([[chart]], colWidths=[15.5 * cm]) - chart_table.setStyle( - TableStyle( - [ - ("BOX", (0, 0), (-1, -1), 1, HexColor("#d7e3e1")), - ("ALIGN", (0, 0), (-1, -1), "CENTER"), - ("TOPPADDING", (0, 0), (-1, -1), 10), - ("BOTTOMPADDING", (0, 0), (-1, -1), 10), - ] ) - ) - content.append(chart_table) - content.append(Spacer(1, 12)) + if len(unsized) == 1: + sentences.append(b["eg_land_unsized_one"]) + elif unsized: + sentences.append(b["eg_land_unsized_many"].format(count=len(unsized))) + lead = " ".join(sentences) - breakdown_data = [ - [ - Paragraph("Resource Type", breakdown_header_style), - Paragraph("Estimated Cost (List Rate)", breakdown_header_style), - ] - ] - for group in type_groups: - breakdown_data.append([group["label"], _fee_breakdown_display(group)]) - breakdown_data.append( - [ - Paragraph("Estimated Total Egress Fee", breakdown_header_style), - Paragraph(fee_display, breakdown_header_style), - ] - ) - breakdown_table = Table(breakdown_data, colWidths=[10 * cm, 5.5 * cm]) - breakdown_table.setStyle( - TableStyle( - [ - ("BACKGROUND", (0, 0), (-1, 0), HexColor("#115e59")), - ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), - ("BACKGROUND", (0, -1), (-1, -1), HexColor("#115e59")), - ("TEXTCOLOR", (0, -1), (-1, -1), colors.white), - ("BOX", (0, 0), (-1, -1), 1, HexColor("#112726")), - ("INNERGRID", (0, 0), (-1, -1), 0.5, HexColor("#d7e3e1")), - ("ALIGN", (1, 1), (1, -1), "RIGHT"), - ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), - ("LEFTPADDING", (0, 0), (-1, -1), 8), - ("RIGHTPADDING", (0, 0), (-1, -1), 8), - ("TOPPADDING", (0, 0), (-1, -1), 7), - ("BOTTOMPADDING", (0, 0), (-1, -1), 7), - ] + unsized_counts = Counter(row["label"] for row in unsized) + not_sized = [ + b["eg_land_not_sized_one" if count == 1 else "eg_land_not_sized_many"].format( + type=label, count=count ) - ) - content.append(Paragraph("Cost Breakdown", styles["Heading2"])) - content.append(breakdown_table) - content.append(PageBreak()) - return content - - -def _build_data_landscape_section( - type_groups, - total_size_display, - report_path, - styles, - content_style, -): - content = [] - content.append(Spacer(1, 12)) - content.append(Paragraph("Data Landscape", styles["Heading1"])) - content.append( - Paragraph( - "The Data Landscape summarizes the data-bearing cloud resources " - "identified within the defined scope, with the amount of data per " - "resource type:", - content_style, + for label, count in sorted( + unsized_counts.items(), key=lambda item: (-item[1], item[0]) ) + ] + return { + "heading": b["eg_heading_landscape"], + "empty": not resources, + "lead": lead, + "stats": [ + (b["eg_st_volume"], summary["total_data_display"], False), + ( + b["eg_st_sized"], + b["eg_st_sized_value"].format(sized=sized, resources=resources), + False, + ), + (b["eg_st_not_sized"], str(len(unsized)), bool(unsized)), + (b["eg_st_largest"], top["label"] if top else b["ev_dash"], False), + ], + "headers": ( + b["eg_th_resource_type"], + b["eg_th_resources"], + b["eg_th_size_gib"], + b["eg_th_share"], + ), + "rows": [ + { + "label": group["label"], + "icon": group["icon"], + "initials": group["initials"], + "resources": counts[group["label"]], + "size": gib_number(group["size_bytes"]), + "share": _share(group["size_bytes"], total), + "share_text": share_label(_share(group["size_bytes"], total)), + } + for group in groups + ], + "total": (b["eg_total"], resources, _plus(gib_number(total), estimate)), + "not_sized": ( + b["eg_land_not_sized"].format(types=", ".join(not_sized)) + if not_sized + else None + ), + "caption": b["eg_land_caption"], + "source_note": b["eg_land_source_note"], + } + + +def _allowance_text(prices, cloud_service_provider): + # The provider's own figure, plus the GiB value when it is published in GB + tier = _free_tier(prices, cloud_service_provider) + if tier is None: + return None + limit = f"{tier['tier_to']:g} {tier['unit']}" + if tier["unit"] == "GiB": + return limit + return ( + f"{limit} ({format_gib(free_allowance_bytes(prices, cloud_service_provider))})" ) - content.append(Spacer(1, 12)) - table_data = [["#", "Resource Type", "", "Size"]] - for index, group in enumerate(type_groups, start=1): - table_data.append( - [ - str(index), - group["label"], - _icon_flowable(report_path, group["icon"], group["label"]), - group["size_display"], - ] - ) - table_data.append(["Total", "", "", total_size_display]) - landscape_table = Table(table_data, colWidths=[1 * cm, 9 * cm, 1.5 * cm, 4 * cm]) - landscape_table.setStyle( - TableStyle( +def cost_facts(estimate, b=BLOCKS): + rows = estimate["rows"] + summary = estimate["summary"] + csp = estimate["meta"]["cloud_service_provider"] + total = estimate["totals"]["known_size_bytes"] + total_fee = estimate["total_fee"] + resources = len(rows) + unsized = sum(1 for row in rows if row["size_bytes"] is None) + sized = resources - unsized + allowance = free_allowance_bytes(estimate["pricing"], csp) + allowance_text = _allowance_text(estimate["pricing"], csp) + groups = [ + group for group in estimate["type_groups"] if group["size_bytes"] is not None + ] + billable = total if allowance is None else max(0, total - allowance) + + if not resources: + sentences = [b["eg_no_data_notice"]] + elif not sized: + sentences = [b["eg_cost_lead_none"]] + elif summary["free_tier"]: + sentences = [ + b["eg_cost_lead_free"].format(fee=format_fee(total_fee)), + b["eg_cost_headroom"].format( + used=format_gib(total), allowance=allowance_text + ), + ] + else: + sentences = [ + b["eg_cost_lead_paid"].format( + total=format_gib(total), fee=format_fee(total_fee) + ) + ] + if allowance is not None: + sentences.append( + b["eg_cost_lead_allowance"].format( + allowance=allowance_text, billable=format_gib(billable) + ) + ) + top = max(groups, key=lambda group: group["fee"] or 0) + if total_fee > 0 and top["fee"] and len(groups) > 1: + sentences.append( + b["eg_cost_lead_top"].format( + type=top["label"], + share=share_label(_share(top["fee"], total_fee)), + ) + ) + if sized and unsized == 1: + sentences.append(b["eg_cost_unsized_one"]) + elif sized and unsized: + sentences.append(b["eg_cost_unsized_many"].format(count=unsized)) + + if summary["free_tier"]: + volume_stat = (b["eg_st_allowance"], summary["free_tier_limit"], False) + else: + volume_stat = (b["eg_st_billable"], format_gib(billable), False) + return { + "heading": b["eg_heading_costs"], + "empty": not resources, + "lead": " ".join(sentences), + "stats": [ + (b["eg_st_fee"], summary["total_fee_display"], False), + (b["eg_st_volume"], summary["total_data_display"], False), + volume_stat, + (b["eg_st_not_sized"], str(unsized), bool(unsized)), + ], + "headers": ( + b["eg_th_resource_type"], + b["eg_th_size_gib"], + b["eg_th_share"], + b["eg_th_fee"], + ), + # A fee of zero has no split to show + "rows": ( [ - ("BACKGROUND", (0, 0), (-1, 0), HexColor("#115e59")), - ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), - ("BACKGROUND", (0, -1), (-1, -1), HexColor("#115e59")), - ("TEXTCOLOR", (0, -1), (-1, -1), colors.white), - ("BOX", (0, 0), (-1, -1), 1, HexColor("#112726")), - ("BOTTOMPADDING", (0, 0), (-1, 0), 12), - ("TOPPADDING", (0, 0), (-1, 0), 12), - ("ALIGN", (0, 1), (1, -2), "LEFT"), - ("ALIGN", (2, 1), (2, -2), "CENTER"), - ("ALIGN", (3, 0), (-1, -1), "CENTER"), - ("VALIGN", (0, 1), (-1, -1), "MIDDLE"), + { + "label": group["label"], + "icon": group["icon"], + "initials": group["initials"], + "size": gib_number(group["size_bytes"]), + "share": _share(group["size_bytes"], total), + "share_text": share_label(_share(group["size_bytes"], total)), + "fee": format_fee(group["fee"]), + } + for group in groups ] - ) - ) - content.append(landscape_table) - content.append(PageBreak()) - return content + if total_fee > 0 + else [] + ), + "total": ( + b["eg_total"], + _plus(gib_number(total), estimate), + summary["total_fee_display"], + ), + "caption": b["eg_cost_caption"], + } -def _tier_display(row: dict[str, Any]) -> str: +def _tier_display(row, b): unit = row["unit"] if row["tier_from"] == 0 and row["tier_to"] is None: - return "all volumes" + return b["eg_tier_all_volumes"] if row["tier_from"] == 0 and row["price_per_unit"] == 0: - return f"first {row['tier_to']:,.0f} {unit} / month" + return b["eg_tier_first"].format(to=f"{row['tier_to']:,.0f}", unit=unit) if row["tier_to"] is None: - return f"over {row['tier_from']:,.0f} {unit}" - return f"{row['tier_from']:,.0f} {unit} – {row['tier_to']:,.0f} {unit}" + return b["eg_tier_over"].format(from_=f"{row['tier_from']:,.0f}", unit=unit) + return b["eg_tier_range"].format( + from_=f"{row['tier_from']:,.0f}", to=f"{row['tier_to']:,.0f}", unit=unit + ) -def _price_display(row: dict[str, Any]) -> str: +def _price_display(row, b): if row["price_per_unit"] == 0: - return "Free" - return f"${row['price_per_unit']:g} / {row['unit']}" + return b["eg_price_free"] + return f"{row['price_per_unit']:g} / {row['unit']}" -def _build_pricing_basis_section(prices, cloud_service_provider, styles, content_style): +def pricing_facts(estimate, b=BLOCKS): + csp = estimate["meta"]["cloud_service_provider"] rows = sorted( ( price - for price in prices - if price["csp"] == cloud_service_provider + for price in estimate["pricing"] + if price["csp"] == csp and price["zone"] == DEFAULT_PRICING_ZONE - and price["component"] in COMPONENT_LABELS - ), - key=lambda price: ( - list(COMPONENT_LABELS).index(price["component"]), - price["tier_from"], + and price["component"] in COMPONENTS ), + key=lambda price: (COMPONENTS.index(price["component"]), price["tier_from"]), ) if not rows: - return [] - - content = [] - content.append(Spacer(1, 12)) - content.append(Paragraph("Appendix – Egress Prices", styles["Heading1"])) - - provider_name = PROVIDER_NAMES.get(cloud_service_provider, "Unknown Provider") - content.append( - Paragraph( - "Our calculations and estimations are based on the following " - f"{provider_name} list prices (Zone 1 – US/Europe, public " - "internet routing):", - content_style, - ) - ) - content.append(Spacer(1, 12)) + return None - show_valid_from = len({row["valid_from"] for row in rows}) > 1 - header = ["#", "Component", "Tier", "Price"] + provider = PROVIDER_NAMES.get(csp) or b["eg_provider_fallback"] + valid_dates = {row["valid_from"] for row in rows} + show_valid_from = len(valid_dates) > 1 + if show_valid_from: + intro = b["eg_pricing_intro_mixed"].format(provider=provider) + else: + intro = b["eg_pricing_intro"].format( + provider=provider, valid=next(iter(valid_dates)) + ) + headers = [b["eg_th_component"], b["eg_th_tier"], b["eg_th_price"]] if show_valid_from: - header.append("Valid from") - table_data = [header] - for index, row in enumerate(rows, start=1): + headers.append(b["eg_th_valid_from"]) + + table = [] + for row in rows: table_row = [ - str(index), - COMPONENT_LABELS[row["component"]], - _tier_display(row), - _price_display(row), + b[f"eg_component_{row['component']}"], + _tier_display(row, b), + _price_display(row, b), ] if show_valid_from: table_row.append(row["valid_from"]) - table_data.append(table_row) + table.append(table_row) - col_widths = ( - [1 * cm, 4 * cm, 5.5 * cm, 2.5 * cm, 2.5 * cm] - if show_valid_from - else [1 * cm, 4.5 * cm, 6 * cm, 4 * cm] - ) - pricing_table = Table(table_data, colWidths=col_widths) - pricing_table.setStyle( - TableStyle( - [ - ("BACKGROUND", (0, 0), (-1, 0), HexColor("#115e59")), - ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), - ("BOX", (0, 0), (-1, -1), 1, HexColor("#112726")), - ("BOTTOMPADDING", (0, 0), (-1, 0), 12), - ("TOPPADDING", (0, 0), (-1, 0), 12), - ("ALIGN", (0, 1), (2, -1), "LEFT"), - ("ALIGN", (3, 0), (-1, -1), "CENTER"), - ("VALIGN", (0, 1), (-1, -1), "MIDDLE"), - ] - ) + unit = next( + (row["unit"] for row in rows if row["component"] == "internet_egress"), None ) - content.append(pricing_table) - content.append(Spacer(1, 12)) - - footnote_style = ParagraphStyle( - "PricingFootnote", - fontSize=8, - leading=10, - textColor=HexColor("#6c757d"), - spaceAfter=4, - ) - if not any(row["component"] == "archive_retrieval" for row in rows): - content.append( - Paragraph( - "Archive retrieval pricing was not available for this provider " - "at assessment time; rehydration fees are not included in the " - "estimate.", - footnote_style, - ) - ) - links = PRICING_SOURCE_LINKS.get(cloud_service_provider, {}) - components_shown = {row["component"] for row in rows} - source_lines = [ - (COMPONENT_LABELS[component], links[component]) - for component in COMPONENT_LABELS - if component in components_shown and component in links + units = {"GB": "eg_pricing_units", "GiB": "eg_pricing_units_gib"}.get(unit) + shown = {row["component"] for row in rows} + links = PRICING_SOURCE_LINKS.get(csp, {}) + sources = [ + (component, links[component]) + for component in COMPONENTS + if component in shown and component in links ] - if len(source_lines) == 1: - content.append(Paragraph(f"Source: {source_lines[0][1]}", footnote_style)) + if len(sources) == 1: + source_lines = [(b["eg_pricing_source"], sources[0][1])] else: - for label, link in source_lines: - content.append(Paragraph(f"Source ({label}): {link}", footnote_style)) - content.append( - Paragraph( - "List prices at the time of the assessment; excludes " - "request/transaction costs and cross-region traffic.", - footnote_style, - ) - ) - return content - - -def draw_header_footer( - report_path: str, - canvas, - doc, - title: str = "EscapeCloud Community Edition - Report", -) -> None: - # Save the state of the canvas to not affect the drawing - canvas.saveState() - width, height = A4 - - # Include the date in the format mm-dd-yyyy - current_date = datetime.now().strftime("%m-%d-%Y") - left_text_content1 = title - left_text_content2 = f"Date: {current_date}" - - # Define the header content with Paragraphs - header_style = ParagraphStyle( - "HeaderStyle", fontSize=10, textColor=HexColor("#9cafae") - ) - header_data = [ - [Paragraph(left_text_content1, header_style), "", ""], - [Paragraph(left_text_content2, header_style), "", ""], - ] + source_lines = [ + ( + b["eg_pricing_source_component"].replace( + "{component}", b[f"eg_component_{component}"] + ), + link, + ) + for component, link in sources + ] + return { + "heading": b["eg_heading_pricing"], + "intro": intro, + "headers": headers, + "rows": table, + "units": b[units].format(provider=provider) if units else None, + "no_archive": ( + None if "archive_retrieval" in shown else b["eg_pricing_no_archive"] + ), + # (template with a {link} field, url) + "sources": source_lines, + "note": b["eg_pricing_note"], + "glossary_heading": b["eg_heading_glossary"], + "glossary": [ + (entry["term"], entry["definition"]) + for entry in sorted( + b["eg_glossary"], key=lambda entry: entry["term"].lower() + ) + ], + } - # Create the header table - table = Table( - header_data, colWidths=[width - 188 - doc.rightMargin - doc.leftMargin, 10, 150] - ) - # Define the style for the table - table.setStyle( - TableStyle( - [ - ("SPAN", (1, 0), (1, 1)), # Merge Column 2 in both rows - ("SPAN", (2, 0), (2, 1)), # Merge Column 3 in both rows - ( - "ALIGN", - (0, 0), - (0, 0), - "LEFT", - ), # Align left_text_content1 to the left - ( - "ALIGN", - (0, 1), - (0, 1), - "LEFT", - ), # Align left_text_content2 to the left - ("ALIGN", (2, 0), (2, 1), "RIGHT"), # Align logo to the right - ("VALIGN", (0, 0), (0, 1), "TOP"), # Vertically align to the top - ("VALIGN", (2, 0), (2, 1), "MIDDLE"), # Vertically align to the middle - # ('GRID', (0, 0), (-1, -1), 0.5, colors.red), # Temporary borders for visualization - ] +def next_steps_facts( + executive_id, uploaded=False, platform_url=None, assessment_id=None, b=BLOCKS +): + # Paragraphs as text and (text, url) links, like the notices + sites = dict(PRODUCT_SITES) + paragraphs = [text_parts(b["eg_next_lead"], {}, {"executive_id": executive_id})] + if uploaded: + paragraphs.append([b["eg_next_uploaded"]]) + if platform_url: + paragraphs.append(text_parts(b["fa_uploaded_link"], {"link": platform_url})) + elif assessment_id: + paragraphs.append( + text_parts(b["fa_uploaded_id"], {}, {"assessment_id": assessment_id}) + ) + else: + paragraphs.append( + text_parts( + b["eg_next_platforms"], + {"exitcloud_url": sites["exitcloud"], "book_demo_url": BOOK_DEMO_URL}, + ) ) - ) - # Build the header table and draw it on the canvas - table.wrapOn(canvas, doc.leftMargin, height - doc.topMargin) - table.drawOn(canvas, doc.leftMargin, height - doc.topMargin) - - # Add the logo - logo_path = find_asset(report_path, "img/logo/report_logo.png") - logo = Image(str(logo_path), width=150, height=30) - - # Aligning logo vertically with the text - logo_y = height - doc.topMargin + 5 - logo.drawOn(canvas, width - 150 - doc.rightMargin, logo_y) - - # Line below the header - canvas.setStrokeColor(HexColor("#115e59")) - canvas.setLineWidth(1) - line_y = height - doc.topMargin - 10 - canvas.line(doc.leftMargin, line_y, width - doc.rightMargin, line_y) - - # Footer - footer_padding = 15 # Add padding under the page number - canvas.setStrokeColor(HexColor("#115e59")) - canvas.line(40, 60 + footer_padding, A4[0] - 40, 60 + footer_padding) - - canvas.setFont("Helvetica", 8) - canvas.drawString(A4[0] / 2 - 30, 60 + footer_padding - 15, f"Page {doc.page}") - - canvas.setFont("Helvetica-Oblique", 8) - canvas.setFillColor(HexColor("#9cafae")) - canvas.drawCentredString( - A4[0] / 2, - 40, - "EscapeCloud Community Edition - This report is provided 'As Is,' without any warranty of any kind.", - ) - canvas.drawCentredString( - A4[0] / 2, - 30, - "EscapeCloud makes no warranty that the information contained in this report is complete or error-free. Copyright 2024-2026", - ) + return {"heading": b["eg_heading_next_steps"], "paragraphs": paragraphs} - # Restore the state of the canvas - canvas.restoreState() - -def generate_egress_pdf_report( - report_path: str, json_path: str, provider_details: dict[str, Any] +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: - meta, rows, totals, pricing, total_fee, type_groups = _load_estimate( - report_path, json_path - ) + estimate = load_egress_estimate(report_path, json_path) + meta, rows = estimate["meta"], estimate["rows"] + csp = meta["cloud_service_provider"] + summary = estimate["summary"] + b = BLOCKS - summary = _summarize_totals( - totals, total_fee, pricing, meta["cloud_service_provider"] + allocation_labels, allocation_values, allocation_colors = _build_allocation( + rows ) - pdf_path = os.path.join(report_path, "egress.pdf") - - def header_footer(canvas, doc): - draw_header_footer(report_path, canvas, doc, title=PDF_HEADER_TITLE) + 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] - doc = SimpleDocTemplate( - pdf_path, pagesize=A4, title="EscapeCloud_-_Data_and_Egress_Report" - ) - styles = getSampleStyleSheet() - content_style = ParagraphStyle( - "ContentStyle", fontSize=10, leading=12, spaceAfter=10 - ) - styles["Heading1"].leading = 1.5 * styles["Heading1"].fontSize - styles["Heading1"].textColor = HexColor("#112726") - styles["Heading2"].leading = 1.5 * styles["Heading2"].fontSize - styles["Heading2"].textColor = HexColor("#112726") - - content = [] - content += build_summary_section(meta, styles, content_style) - content += build_scope_section(meta, provider_details, styles, content_style) - content += _build_coverage_section( - meta["cloud_service_provider"], styles, content_style - ) - content += _build_data_landscape_section( - type_groups, - summary["total_data_display"], - report_path, - styles, - content_style, - ) - content += _build_estimated_costs_section( - total_fee, - totals, - summary["free_tier"], - summary["free_tier_limit"], - type_groups, - styles, - content_style, + template_content = asset_path("template", "egress.html").read_text( + encoding="utf-8" ) - content += _build_pricing_basis_section( - pricing, meta["cloud_service_provider"], styles, content_style + + 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), ) - doc.build(content, onFirstPage=header_footer, onLaterPages=header_footer) + 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 PDF report generated successfully.", - "pdf_path": pdf_path, + "logs": "Egress report generated successfully.", + "html_path": html_path, } except Exception as e: - logger.error(f"Error generating egress PDF report: {str(e)}", exc_info=True) + 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 new file mode 100644 index 0000000..6acc846 --- /dev/null +++ b/cloudexit/report/egress_report.py @@ -0,0 +1,76 @@ +import os +from functools import partial + +from reportlab.lib.pagesizes import A4 +from reportlab.lib.units import cm +from reportlab.platypus import Spacer + +from .base import NumberedCanvas, ReportDocTemplate, set_pdf_metadata +from .context import EGRESS_DOCUMENT_CODE +from .cover import RecordCover +from .egress import load_egress_estimate +from .egress_sections import ( + appendix, + coverage, + data_landscape, + estimated_costs, + introduction, + next_steps, +) +from .evidence import build_document_information_page +from .flow import chapter_break, section_break +from .notices import legal_notices +from .styles import load_styles +from .wording import BLOCKS + + +def build_egress_content(context, estimate, styles, b): + content = [Spacer(1, 0), chapter_break()] + content += build_document_information_page( + context, + b, + document_id=context.document_id_for(EGRESS_DOCUMENT_CODE), + document_type=b["ev_egress_report"], + companion_id=context.document_id, + ) + content += introduction(context, styles, b) + content += coverage(context, styles, b) + content.append(chapter_break()) + content += data_landscape(context, estimate, styles, b) + content.append(section_break()) + content += estimated_costs(context, estimate, styles, b) + content.append(chapter_break()) + content += appendix(context, estimate, styles, b) + content.append(Spacer(1, 12)) + content += next_steps(context, styles, b) + content.append(chapter_break()) + content += legal_notices(context, styles, b, egress=True) + return content + + +def generate_egress_report(context, json_path, path=None): + path = path or os.path.join(context.report_path, "egress.pdf") + b = BLOCKS + estimate = load_egress_estimate(context.report_path, json_path) + cover = RecordCover( + b["cover_title_egress"], + context, + b, + document_id=context.document_id_for(EGRESS_DOCUMENT_CODE), + ) + doc = ReportDocTemplate( + path, + context, + cover=cover, + pagesize=A4, + rightMargin=2.5 * cm, + leftMargin=2.5 * cm, + topMargin=2.5 * cm, + bottomMargin=2.5 * cm, + ) + set_pdf_metadata(doc, context, b) + doc.multiBuild( + build_egress_content(context, estimate, load_styles(context.appearance), b), + canvasmaker=partial(NumberedCanvas, page_of=b["footer_page_of"]), + ) + return path diff --git a/cloudexit/report/egress_sections.py b/cloudexit/report/egress_sections.py new file mode 100644 index 0000000..553a46a --- /dev/null +++ b/cloudexit/report/egress_sections.py @@ -0,0 +1,324 @@ +from xml.sax.saxutils import escape, quoteattr + +from reportlab.lib.colors import HexColor +from reportlab.lib.enums import TA_RIGHT +from reportlab.lib.units import cm +from reportlab.platypus import Paragraph, Spacer, Table, TableStyle + +from cloudexit.resources import find_asset + +from .egress import ( + coverage_facts, + cost_facts, + introduction_facts, + landscape_facts, + next_steps_facts, + pricing_facts, +) +from .evidence import link_markup +from .flow import section +from .notices import parts_markup +from .palette import HAIRLINE, HOTSPOT_COUNT, INK, MUTED, PROVENANCE_BACKGROUND, RULE +from .placeholder import LabelWithPlaceholder +from .sections import GLOSSARY_TERM_WIDTH +from .table_style import data_table, header_cell, share_bar, summary_strip, text_style + +BODY = text_style("EgressBody", 8.5, 11) +BODY_RIGHT = text_style("EgressBodyRight", 8.5, 11, alignment=TA_RIGHT) +BODY_BOLD = text_style("EgressBodyBold", 8.5, 11, bold=True) +BODY_BOLD_RIGHT = text_style( + "EgressBodyBoldRight", 8.5, 11, bold=True, alignment=TA_RIGHT +) +CAPTION = text_style("EgressCaption", 7.5, 10, MUTED) + + +def _text(value, style=BODY): + return Paragraph(escape(str(value)), style) + + +def _right_header(text, appearance): + return header_cell(text, appearance, TA_RIGHT, split_long_words=False) + + +def _total_row_style(rows): + return [ + ("LINEABOVE", (0, len(rows)), (-1, len(rows)), 0.8, HexColor(INK)), + ("NOSPLIT", (0, len(rows) - 1), (-1, len(rows))), + ] + + +def _type_cell(row, report_path): + label = f"{escape(row['label'])}" + icon = find_asset(report_path, row["icon"]) if row["icon"] else None + if icon is None: + # A missing icon must not cost the whole document + return LabelWithPlaceholder(row["label"], label, BODY) + return Paragraph( + f' ' + f"{label}", + BODY, + ) + + +def _heading(facts, styles, key="heading", style="Heading1"): + return Paragraph(escape(facts[key]), styles[style]) + + +def introduction(context, styles, b): + facts = introduction_facts(context.scope, b) + body = styles["ContentStyle"] + content = [ + _heading(facts, styles), + _heading(facts, styles, "purpose_heading", "Heading2"), + Paragraph(escape(facts["purpose"]), body), + _heading(facts, styles, "scope_heading", "Heading2"), + Paragraph(escape(facts["scope"]), body), + _heading(facts, styles, "limitations_heading", "Heading2"), + ] + for index, text in enumerate(facts["limitations"]): + if index: + content.append(Spacer(1, 6)) + content.append(Paragraph(escape(text), body)) + return content + + +def coverage(context, styles, b): + facts = coverage_facts(context.cloud_service_provider, b) + if facts is None: + return [] + rows = [list(facts["headers"])] + [ + [_text(name, BODY_BOLD), _text(note)] for name, note in facts["rows"] + ] + return [ + _heading(facts, styles, style="Heading2"), + Paragraph(escape(facts["intro"]), styles["ContentStyle"]), + Spacer(1, 8), + data_table(rows, [6 * cm, 10 * cm], context.appearance), + ] + + +def _landscape_table(facts, context): + appearance = context.appearance + types, resources, size, share = facts["headers"] + rows = [ + [ + types, + _right_header(resources, appearance), + _right_header(size, appearance), + share, + ] + ] + for row in facts["rows"]: + rows.append( + [ + _type_cell(row, context.report_path), + _text(row["resources"], BODY_RIGHT), + _text(row["size"], BODY_RIGHT), + share_bar(row["share"], appearance), + ] + ) + extra = _total_row_style(rows) + label, count, total = facts["total"] + rows.append( + [ + _text(label, BODY_BOLD), + _text(count, BODY_BOLD_RIGHT), + _text(total, BODY_BOLD_RIGHT), + "", + ] + ) + return data_table(rows, [5.9 * cm, 2.0 * cm, 2.6 * cm, 5.5 * cm], appearance, extra) + + +def _stats(facts): + return summary_strip( + [ + (label, value, HOTSPOT_COUNT if attention else INK) + for label, value, attention in facts["stats"] + ] + ) + + +def data_landscape(context, estimate, styles, b): + facts = landscape_facts(estimate, b) + head = [ + _heading(facts, styles), + Paragraph(escape(facts["lead"]), styles["ContentStyle"]), + Spacer(1, 8), + ] + if facts["empty"]: + return section(head, []) + + body = [_stats(facts), Spacer(1, 12)] + if facts["rows"]: + body += [_landscape_table(facts, context), Spacer(1, 6)] + if facts["not_sized"]: + body += [Paragraph(escape(facts["not_sized"]), CAPTION), Spacer(1, 2)] + body += [ + Paragraph(escape(facts["caption"]), CAPTION), + Spacer(1, 2), + Paragraph(escape(facts["source_note"]), CAPTION), + ] + return section(head, body) + + +def _cost_table(facts, context): + appearance = context.appearance + types, size, share, fee = facts["headers"] + rows = [ + [ + types, + _right_header(size, appearance), + share, + _right_header(fee, appearance), + ] + ] + for row in facts["rows"]: + rows.append( + [ + _type_cell(row, context.report_path), + _text(row["size"], BODY_RIGHT), + share_bar(row["share"], appearance), + _text(row["fee"], BODY_RIGHT), + ] + ) + extra = _total_row_style(rows) + label, size_total, fee_total = facts["total"] + rows.append( + [ + _text(label, BODY_BOLD), + _text(size_total, BODY_BOLD_RIGHT), + "", + _text(fee_total, BODY_BOLD_RIGHT), + ] + ) + return data_table(rows, [5.4 * cm, 2.6 * cm, 5.5 * cm, 2.5 * cm], appearance, extra) + + +def estimated_costs(context, estimate, styles, b): + facts = cost_facts(estimate, b) + head = [ + _heading(facts, styles), + Paragraph(escape(facts["lead"]), styles["ContentStyle"]), + Spacer(1, 8), + ] + if facts["empty"]: + return section(head, []) + + body = [_stats(facts), Spacer(1, 12)] + if facts["rows"]: + body += [_cost_table(facts, context), Spacer(1, 6)] + body.append(Paragraph(escape(facts["caption"]), CAPTION)) + return section(head, body) + + +def _glossary_table(entries, appearance): + table = Table( + [[_text(term, BODY_BOLD), _text(definition)] for term, definition in entries], + colWidths=[GLOSSARY_TERM_WIDTH, 16 * cm - GLOSSARY_TERM_WIDTH], + hAlign="LEFT", + ) + table.setStyle( + TableStyle( + [ + ( + "LINEABOVE", + (0, 0), + (-1, 0), + 1, + HexColor(appearance.header_row_color), + ), + ("LINEBELOW", (0, 0), (-1, -1), 0.4, HexColor(HAIRLINE)), + ("LEFTPADDING", (0, 0), (-1, -1), 5), + ("RIGHTPADDING", (0, 0), (-1, -1), 5), + ("TOPPADDING", (0, 0), (-1, -1), 3.5), + ("BOTTOMPADDING", (0, 0), (-1, -1), 3.5), + ("VALIGN", (0, 0), (-1, -1), "TOP"), + ] + ) + ) + return table + + +def _prices(facts, context, styles): + appearance = context.appearance + headers = list(facts["headers"]) + # Price is the third column, right aligned like the other figures + headers[2] = _right_header(headers[2], appearance) + rows = [headers] + for row in facts["rows"]: + cells = [_text(cell) for cell in row] + cells[2] = _text(row[2], BODY_RIGHT) + rows.append(cells) + widths = ( + [4.0 * cm, 5.4 * cm, 3.6 * cm, 3.0 * cm] + if len(headers) == 4 + else [4.6 * cm, 6.8 * cm, 4.6 * cm] + ) + content = [ + _heading(facts, styles), + Paragraph(escape(facts["intro"]), styles["ContentStyle"]), + Spacer(1, 8), + data_table(rows, widths, appearance), + Spacer(1, 6), + ] + color = appearance.heading2_color + for text in (facts["units"], facts["no_archive"]): + if text: + content += [Paragraph(escape(text), CAPTION), Spacer(1, 2)] + for template, url in facts["sources"]: + content += [ + Paragraph(template.format(link=link_markup(url, color)), CAPTION), + Spacer(1, 2), + ] + content.append(Paragraph(escape(facts["note"]), CAPTION)) + return content + + +def appendix(context, estimate, styles, b): + facts = pricing_facts(estimate, b) + content = _prices(facts, context, styles) + content += [ + Spacer(1, 12), + _heading(facts, styles, "glossary_heading", "Heading2"), + Spacer(1, 4), + _glossary_table(facts["glossary"], context.appearance), + ] + return content + + +def next_steps(context, styles, b): + provenance = context.provenance + facts = next_steps_facts( + context.document_id, + provenance.uploaded, + provenance.platform_url, + provenance.assessment_id, + b, + ) + color = context.appearance.heading2_color + box = Table( + [ + [ + [ + Paragraph(parts_markup(parts, color), styles["ContentStyle"]) + for parts in facts["paragraphs"] + ] + ] + ], + colWidths=[16 * cm], + hAlign="LEFT", + ) + box.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (-1, -1), HexColor(PROVENANCE_BACKGROUND)), + ("BOX", (0, 0), (-1, -1), 0.6, HexColor(RULE)), + ("LEFTPADDING", (0, 0), (-1, -1), 10), + ("RIGHTPADDING", (0, 0), (-1, -1), 10), + ("TOPPADDING", (0, 0), (-1, -1), 4), + ("BOTTOMPADDING", (0, 0), (-1, -1), 4), + ] + ) + ) + return [_heading(facts, styles), Spacer(1, 4), box] diff --git a/cloudexit/report/evidence.py b/cloudexit/report/evidence.py index daf91cc..63600e3 100644 --- a/cloudexit/report/evidence.py +++ b/cloudexit/report/evidence.py @@ -27,11 +27,21 @@ def _mono_value(text): return Paragraph(escape(text), KV_MONO) if text else None -def build_document_table(context, b): +def build_document_table( + context, b, document_id=None, document_type=None, companion_id=None +): provenance = context.provenance rows = [ - (b["ev_document_id"], Paragraph(escape(context.document_id), KV_MONO)), - (b["ev_document_type"], b["ev_executive_report"]), + ( + b["ev_document_id"], + Paragraph(escape(document_id or context.document_id), KV_MONO), + ), + (b["ev_document_type"], document_type or b["ev_executive_report"]), + *( + [(b["ev_companion_document"], Paragraph(escape(companion_id), KV_MONO))] + if companion_id + else [] + ), (b["ev_language"], b["language_name"]), ( b["ev_collected_by"], @@ -125,11 +135,13 @@ def build_provenance_table(context, b): return key_value_table(rows, context.appearance) -def build_document_information_page(context, b): +def build_document_information_page( + context, b, document_id=None, document_type=None, companion_id=None +): heading = _section_heading(context.appearance) return [ Paragraph(b["ev_document_information_heading"], heading), - build_document_table(context, b), + build_document_table(context, b, document_id, document_type, companion_id), Paragraph(b["ev_assessment_scope_heading"], heading), build_scope_table(context, b), Paragraph(b["ev_provenance_heading"], heading), diff --git a/cloudexit/report/helpers.py b/cloudexit/report/helpers.py index 0dbd794..b79acf4 100644 --- a/cloudexit/report/helpers.py +++ b/cloudexit/report/helpers.py @@ -19,6 +19,25 @@ 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 + gib = size_bytes / 2**30 + return "<0.1" if 0 < gib < 0.1 else f"{gib:,.1f}" + + +def format_gib(size_bytes): + if size_bytes is None: + return "n/a" + return f"{gib_number(size_bytes)} GiB" + + +def share_label(share): + # A share that is not zero never reads as "0 %" + if 0 < share < 0.5: + return "<1 %" + return f"{share:.0f} %" + + def format_change(previous, current): if not previous or current is None: return "–" diff --git a/cloudexit/report/html_sections.py b/cloudexit/report/html_sections.py index 14127b5..a087a15 100644 --- a/cloudexit/report/html_sections.py +++ b/cloudexit/report/html_sections.py @@ -4,7 +4,7 @@ from .helpers import severity_label from .notices import notices_html from .palette import SEVERITIES -from .sections import glossary_entries, scope_boundary, scope_strategy +from .sections import egress_note, glossary_entries, scope_boundary, scope_strategy from .wording import BLOCKS @@ -70,6 +70,7 @@ def html_sections(context, b=BLOCKS): "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"], diff --git a/cloudexit/report/notices.py b/cloudexit/report/notices.py index e404996..866a7c8 100644 --- a/cloudexit/report/notices.py +++ b/cloudexit/report/notices.py @@ -20,7 +20,7 @@ FIELD = re.compile(r"({[a-z_]+})") -def _parts(template, links, values=None): +def text_parts(template, links, values=None): # A template split into plain text and (text, url) links, so the PDF and # the HTML are built from the same content parts = [] @@ -37,18 +37,26 @@ def _parts(template, links, values=None): def _icon_notice(provider, b): if provider == 2: - return _parts( + return text_parts( b["notices_icons_aws"], {"icons_url": AWS_ICONS_URL, "guidelines_url": AWS_TRADEMARK_URL}, ) if provider == 1: - return _parts(b["notices_icons_azure"], {"icons_url": AZURE_ICONS_URL}) + return text_parts(b["notices_icons_azure"], {"icons_url": AZURE_ICONS_URL}) return None -def notices_html(provider, generated_at, b=BLOCKS): +def notices_html(provider, generated_at, b=BLOCKS, egress=False): # The notices as plain data. A paragraph is a list of text and # (text, url) links. Only the notice for the report's own provider's icons. + # The Egress & Data Landscape adds what its figures are and are not. + disclaimer = [ + [b["notices_disclaimer"]], + [b["notices_generated"]], + [b["notices_purpose"]], + [b["notices_figures"]], + *([[b["notices_egress"]]] if egress else []), + ] trademarks = [[b["notices_trademark_own"]], [b["notices_trademark_others"]]] icons = _icon_notice(provider, b) if icons: @@ -58,18 +66,13 @@ def notices_html(provider, generated_at, b=BLOCKS): "blocks": [ { "heading": b["notices_disclaimer_heading"], - "paragraphs": [ - [b["notices_disclaimer"]], - [b["notices_generated"]], - [b["notices_purpose"]], - [b["notices_figures"]], - ], + "paragraphs": disclaimer, }, {"heading": b["notices_trademarks_heading"], "paragraphs": trademarks}, { "heading": b["notices_software_heading"], "paragraphs": [ - _parts( + text_parts( b["notices_software"], {"repository_url": REPOSITORY_URL, "licence_url": LICENCE_URL}, ) @@ -80,7 +83,7 @@ def notices_html(provider, generated_at, b=BLOCKS): } -def _markup(parts, color): +def parts_markup(parts, color): return "".join( link_markup(part[1], color) if isinstance(part, tuple) else escape(part) for part in parts @@ -104,9 +107,9 @@ def _styles(styles): ) -def legal_notices(context, styles, b): +def legal_notices(context, styles, b, egress=False): notices = notices_html( - context.cloud_service_provider, context.provenance.generated_at, b + context.cloud_service_provider, context.provenance.generated_at, b, egress ) color = context.appearance.heading2_color body, label = _styles(styles) @@ -114,7 +117,7 @@ def legal_notices(context, styles, b): for block in notices["blocks"]: content.append(Paragraph(escape(block["heading"]), label)) for parts in block["paragraphs"]: - content.append(Paragraph(_markup(parts, color), body)) + content.append(Paragraph(parts_markup(parts, color), body)) content.append(Spacer(1, 10)) content.append(Paragraph(escape(notices["copyright"]), body)) return content diff --git a/cloudexit/report/sections.py b/cloudexit/report/sections.py index 8e5e7e8..c9d5584 100644 --- a/cloudexit/report/sections.py +++ b/cloudexit/report/sections.py @@ -66,6 +66,7 @@ ("escapecloud", "https://escapecloud.io"), ("exitcloud", "https://exitcloud.io"), ) +BOOK_DEMO_URL = "https://escapecloud.io/book-demo/" AWS_ICONS_URL = "https://aws.amazon.com/architecture/icons/" AWS_TRADEMARK_URL = "https://aws.amazon.com/trademark-guidelines/" AZURE_ICONS_URL = "https://learn.microsoft.com/en-us/azure/architecture/icons/" @@ -252,15 +253,24 @@ def _cost_table(context, b): ] +def egress_note(context, b): + # The companion document, when the run has an egress estimate + if not context.egress_document_id: + return None + return b["es_costs_egress_note"].format(document_id=context.egress_document_id) + + def costs_overview(context, facts, styles, b): head = [ Paragraph(b["es_heading_costs_overview"], styles["Heading1"]), Paragraph(cost_finding(facts, b), styles["ContentStyle"]), Spacer(1, 8), ] + note = egress_note(context, b) + closing = [Paragraph(escape(note), styles["ContentStyle"])] if note else [] problem = cost_problem(context, b) if problem is not None: - return section(head, []) + return section(head, closing) entries = context.costs total = sum(entry.amount for entry in entries) latest = entries[-1] @@ -293,6 +303,7 @@ def costs_overview(context, facts, styles, b): *_cost_table(context, b), Spacer(1, 12), Paragraph(b["es_cost_note"], styles["ContentStyle"]), + *closing, ] return section(head, items) diff --git a/cloudexit/report/table_style.py b/cloudexit/report/table_style.py index 5b54247..5333dd7 100644 --- a/cloudexit/report/table_style.py +++ b/cloudexit/report/table_style.py @@ -1,5 +1,6 @@ from xml.sax.saxutils import escape +from reportlab.graphics.shapes import Drawing, Rect, String from reportlab.lib.colors import HexColor from reportlab.lib.enums import TA_LEFT from reportlab.lib.styles import ParagraphStyle @@ -7,6 +8,7 @@ from reportlab.platypus import Paragraph, Table, TableStyle from .fonts import FONT, FONT_BOLD, FONT_ITALIC, FONT_MONO +from .helpers import share_label from .palette import HAIRLINE, INK, MUTED, SUBTITLE, tint SECONDARY = SUBTITLE @@ -165,3 +167,32 @@ def summary_strip(stats, widths=None): ) ) return strip + + +def share_bar(share, appearance, width=5.0 * cm): + # A share of 0-100 as a bar with its label; the track is the full width + track = width - 34 + drawing = Drawing(width, 9) + drawing.add(Rect(0, 1, track, 7, fillColor=HexColor(HAIRLINE), strokeColor=None)) + if share: + drawing.add( + Rect( + 0, + 1, + track * share / 100, + 7, + fillColor=HexColor(appearance.header_row_color), + strokeColor=None, + ) + ) + drawing.add( + String( + track + 4, + 1.5, + share_label(share), + fontName=FONT, + fontSize=7.8, + fillColor=HexColor(INK), + ) + ) + return drawing diff --git a/cloudexit/report/wording.py b/cloudexit/report/wording.py index 7e95447..246033b 100644 --- a/cloudexit/report/wording.py +++ b/cloudexit/report/wording.py @@ -7,8 +7,10 @@ "ev_assessment_scope_heading": "Assessment Scope", "ev_provenance_heading": "Provenance", "ev_executive_report": "Executive Summary", + "ev_egress_report": "Egress & Data Landscape", "ev_document_id": "Document ID", "ev_document_type": "Document Type", + "ev_companion_document": "Companion Document", "ev_collected_by": "Collected by", "ev_collected_by_value": "cloudexit {version}", "ev_collected_at": "Collected on", @@ -41,6 +43,7 @@ "unchanged. Later runs are reported in their own documents." ), "cover_title_executive_summary": "Executive Summary", + "cover_title_egress": "Egress & Data Landscape", "cover_doc_id": "Document ID", "cover_edition": "Edition", "cover_edition_value": "Community", @@ -206,6 +209,10 @@ "cost_caption_provisional": "Provisional = the month was still accruing when the data was collected.", "cost_tfstate": "Terraform state files hold no billing data, so there are no costs to show for this assessment.", "es_cost_note": "Costs are shown for context. They do not affect the risk evaluation and are not an estimate of exit costs.", + "es_costs_egress_note": ( + "A separate Egress & Data Landscape ({document_id}) estimates the data " + "held in scope and the cost of moving it out." + ), "es_heading_resource_inventory": "Resource Inventory", "es_res_lead": ( "Cloud resources discovered in the defined scope. Each resource type " @@ -348,10 +355,6 @@ "Scored Alternatives", "the alternative technologies for each resource type, rated and ranked.", ), - ( - "Egress & Data Landscape", - "the data held by the resources in scope and an estimate of the one-time fee for moving it out.", - ), ], "fa_how_heading": "How to Get Them", "fa_how": ( @@ -369,6 +372,247 @@ ), "fa_uploaded_link": "Open the reports: {link}", "fa_uploaded_id": "Assessment ID: {assessment_id}", + "eg_heading_introduction": "Introduction", + "eg_heading_purpose": "Purpose", + "eg_heading_scope": "Scope", + "eg_heading_limitations": "Limitations", + "eg_purpose_block": ( + "This document quantifies the data held by the data-bearing resources " + "in the defined scope and estimates the one-time fee for moving that " + "data out of the provider at list prices. The fee is one component of " + "the cost of an exit; it is shown early so that it can be planned " + "rather than discovered during migration. It accompanies the " + "Executive Summary of the same run." + ), + "eg_scope_azure": ( + "The estimate covers the data-bearing resources in Microsoft Azure " + "resource group {resource_group}. Resources outside this resource " + "group are not included." + ), + "eg_scope_aws": ( + "The estimate covers the data-bearing resources in AWS region " + "{region}. Other regions are not included." + ), + "eg_scope_plain": ( + "The estimate covers the data-bearing resources in the defined scope." + ), + "eg_limitations_block": ( + "Sizes are collected from provider metrics and, for some resource " + "types, represent provisioned rather than used capacity. Resources " + "that cannot be sized through the provider APIs are listed but " + "excluded from the fee estimate. Fees are calculated from public list " + "prices at the time of the run and exclude request and transaction " + "charges, cross-region traffic, negotiated discounts and any " + "committed-use agreements already in place." + ), + "eg_limitations_scope": ( + "The estimate covers data transfer fees only. It is not an estimate " + "of the full cost of an exit, which also includes migration effort, " + "parallel operation and contract terms. Figures are estimates and can " + "be incomplete in some environments." + ), + "eg_limitations_data": ( + "Figures reflect the data of the run shown on page 2 and may change " + "as the environment changes." + ), + "eg_heading_coverage": "Scope of the Estimation", + "eg_coverage_intro": ( + "The estimate covers the following data-bearing resource types. Each " + "note states how the size is measured, or why a type is not sized." + ), + "eg_th_resource_type": "Resource Type", + "eg_th_measured": "How the Size Is Measured", + "eg_coverage_notes_azure": [ + ("Storage Accounts", "Capacity metrics can lag by up to roughly 48 hours"), + ( + "Managed Disks and Point-in-Time Copies", + "Provisioned size; counted as an upper bound", + ), + ("SQL Databases", "Used space from database metrics"), + ("Cosmos DB Accounts", "Used space from database metrics"), + ("PostgreSQL / MySQL Flexible Servers", "Used space from database metrics"), + ("Recovery Services Vaults", "Not sized; recovery points cannot be exported"), + ], + "eg_coverage_notes_aws": [ + ("S3 Buckets", "Metrics can lag by up to roughly 48 hours"), + ( + "EBS Volumes and Point-in-Time Copies", + "Upper bound; copies can over-count shared blocks", + ), + ("RDS Instances", "Aurora is not sized"), + ("DynamoDB Tables", ""), + ("AWS Backup Vaults", "Not sized; recovery points cannot be exported"), + ], + "eg_heading_landscape": "Data Landscape", + "eg_no_data_notice": ( + "No data-bearing resources were identified in the defined scope, so " + "there is no data to show and no egress fee to estimate." + ), + "eg_land_lead": ( + "The defined scope holds {total} of data in {sized} of {resources} " + "data-bearing resources." + ), + "eg_land_lead_one": ( + "The defined scope holds {total} of data in its only data-bearing " "resource." + ), + "eg_land_top": "{type} holds the largest share ({size}, {share}).", + "eg_land_unsized_one": "1 resource could not be sized and is not in the totals.", + "eg_land_unsized_many": ( + "{count} resources could not be sized and are not in the totals." + ), + "eg_land_none_sized": ( + "None of the {resources} data-bearing resources could be sized." + ), + "eg_land_none_sized_one": "The data-bearing resource could not be sized.", + "eg_st_volume": "Data Volume", + "eg_st_sized": "Resources Sized", + "eg_st_sized_value": "{sized} of {resources}", + "eg_st_not_sized": "Not Sized", + "eg_st_largest": "Largest Type", + "eg_th_resources": "Resources", + "eg_th_size_gib": "Size (GiB)", + "eg_th_share": "Share of Volume", + "eg_total": "Total", + "eg_land_not_sized": "Not sized: {types}.", + "eg_land_not_sized_one": "{type} (1 resource)", + "eg_land_not_sized_many": "{type} ({count} resources)", + "eg_land_caption": ( + "All sizes in GiB (1 GiB = 1,073,741,824 bytes). Some resource types " + "report provisioned rather than used capacity; see Scope of the " + "Estimation." + ), + "eg_land_source_note": ( + "Data-bearing resources are collected separately from the resource " + "inventory of the Executive Summary and can include types it does " + "not list, such as managed disks." + ), + "eg_heading_costs": "Estimated Costs", + "eg_cost_lead_free": ( + "The one-time egress fee for the sized data is estimated at {fee}: it " + "stays within the provider's free monthly internet egress allowance." + ), + "eg_cost_headroom": ( + "The sized data takes up {used} of the {allowance} monthly free tier." + ), + "eg_cost_lead_paid": ( + "Moving the sized data ({total}) out of the provider is estimated to " + "cost {fee} once, at list prices." + ), + "eg_cost_lead_allowance": ( + "The first {allowance} per month are free; the remaining {billable} " + "are billed at tiered rates (see the Appendix)." + ), + "eg_cost_lead_top": "{type} accounts for the largest share ({share}).", + "eg_cost_lead_none": ( + "No fee can be estimated because none of the data-bearing resources " + "could be sized." + ), + "eg_cost_unsized_one": "1 resource could not be sized and is not included.", + "eg_cost_unsized_many": ( + "{count} resources could not be sized and are not included." + ), + "eg_st_fee": "Estimated Fee (USD)", + "eg_st_allowance": "Free Allowance / Month", + "eg_st_billable": "Billable Volume", + "eg_th_fee": "Estimated Fee (USD)", + "eg_cost_caption": ( + "Amounts in USD: the provider publishes egress list prices in USD and " + "they are not converted. Internet egress is billed on the whole " + "volume, so the fee of each resource type is its share of the total, " + "allocated by size. Excludes request and transaction charges, " + "cross-region traffic and negotiated discounts. A + means that " + "resources without a size are left out, so the figure is a lower " + "bound." + ), + "eg_heading_pricing": "Appendix – Egress Prices", + "eg_th_component": "Component", + "eg_th_tier": "Tier", + "eg_th_price": "Price (USD)", + "eg_th_valid_from": "Valid From", + "eg_tier_all_volumes": "all volumes", + "eg_tier_first": "first {to} {unit} / month", + "eg_tier_over": "over {from_} {unit}", + "eg_tier_range": "{from_} – {to} {unit}", + "eg_price_free": "Free", + "eg_component_internet_egress": "Internet egress", + "eg_component_archive_retrieval": "Archive retrieval", + "eg_pricing_intro": ( + "The estimate uses the following {provider} list prices (zone 1 – " + "US/Europe, public internet routing), valid from {valid}." + ), + "eg_pricing_intro_mixed": ( + "The estimate uses the following {provider} list prices (zone 1 – " + "US/Europe, public internet routing)." + ), + "eg_pricing_units": ( + "Prices per GB as published by {provider} (1 GB = 1,000,000,000 " + "bytes). Sizes in this document are in GiB (1,073,741,824 bytes); " + "1 GiB = 1.074 GB." + ), + "eg_pricing_units_gib": "Prices per GiB as published by {provider}.", + "eg_provider_fallback": "the cloud provider", + "eg_pricing_no_archive": ( + "Archive retrieval pricing was not available for this provider at the " + "time of the run; rehydration fees are not included in the estimate." + ), + "eg_pricing_note": ( + "List prices at the time of the run; they exclude request and " + "transaction charges and cross-region traffic." + ), + "eg_pricing_source": "Source: {link}", + "eg_pricing_source_component": "Source ({component}): {link}", + "eg_heading_glossary": "Glossary", + "eg_glossary": [ + { + "id": "egress", + "term": "Egress", + "definition": "Data that leaves a cloud provider's network, for example to the internet, an on-premises site or another provider.", + }, + { + "id": "free_tier", + "term": "Free tier", + "definition": "The monthly volume of internet egress that a provider does not charge for.", + }, + { + "id": "gib", + "term": "GiB", + "definition": "Gibibyte, 1,073,741,824 bytes. Sizes in this document are in GiB.", + }, + { + "id": "list_price", + "term": "List price", + "definition": "The price a provider publishes, before any negotiated discount or commitment.", + }, + { + "id": "switching", + "term": "Switching charges", + "definition": "Fees a provider charges for moving data or workloads to another provider, including data egress.", + }, + ], + "eg_heading_next_steps": "Next Steps", + "eg_next_lead": ( + "Use this estimate together with the Executive Summary of the same " + "run ({executive_id}) when planning an exit." + ), + "eg_next_platforms": ( + "Connected ({exitcloud_url}) and Enterprise ({book_demo_url}) combine " + "this estimate with the Exit Readiness Report and version history." + ), + "eg_next_uploaded": ( + "This run was uploaded. The platform combines this estimate with the " + "Exit Readiness Report and version history." + ), + "notices_egress": ( + "Egress figures are estimates based on the provider's public list " + "prices at the time of the run. Actual invoices can differ: " + "negotiated discounts and commitments, request charges, cross-region " + "traffic and private connections are not included. Under the EU Data " + "Act, providers may charge only reduced, cost-based fees for " + "switching, including data egress, until 12 January 2027, and no " + "switching charges after that date. Several providers already waive " + "egress fees on request for customers who leave. This estimate " + "assumes no waiver." + ), "notices_heading": "Legal Notices", "notices_disclaimer_heading": "Disclaimer", "notices_disclaimer": ( @@ -444,6 +688,37 @@ "ev_run_id", "ev_platform_label", "cover_title_executive_summary", + "cover_title_egress", + "ev_egress_report", + "ev_companion_document", + "eg_heading_introduction", + "eg_heading_purpose", + "eg_heading_scope", + "eg_heading_limitations", + "eg_heading_coverage", + "eg_heading_landscape", + "eg_heading_costs", + "eg_heading_pricing", + "eg_heading_glossary", + "eg_heading_next_steps", + "eg_th_resource_type", + "eg_th_measured", + "eg_th_resources", + "eg_th_size_gib", + "eg_th_share", + "eg_th_fee", + "eg_th_component", + "eg_th_tier", + "eg_th_price", + "eg_th_valid_from", + "eg_total", + "eg_st_volume", + "eg_st_sized", + "eg_st_not_sized", + "eg_st_largest", + "eg_st_fee", + "eg_st_allowance", + "eg_st_billable", "cover_doc_id", "cover_edition", "cover_edition_value", diff --git a/tests/cli/test_app.py b/tests/cli/test_app.py index bae1436..7fe1f83 100644 --- a/tests/cli/test_app.py +++ b/tests/cli/test_app.py @@ -629,10 +629,18 @@ class EgressStageTests(unittest.TestCase): "logs": "", "html_path": "/tmp/report/egress.html", } - _PDF_OK = { + # The egress PDF is written with the report, from the same context + _WITH_PDF = { "success": True, - "logs": "", - "pdf_path": "/tmp/report/egress.pdf", + "reports": { + "HTML": "/tmp/report/index.html", + "EGRESS_PDF": "/tmp/report/egress.pdf", + }, + } + _PDF_FAILED = { + "success": True, + "reports": {"HTML": "/tmp/report/index.html"}, + "egress_pdf_logs": "reportlab error", } @staticmethod @@ -650,21 +658,38 @@ def _azure_config(): }, } - def test_egress_module_not_invoked_without_flag(self): + def _run(self, config, provider, report=None, estimate=None, html=None, **kwargs): patches = _base_patches() for p in patches: p.start() - mock_estimate = patch("cloudexit.cli.app.estimate_egress").start() - mock_render = patch("cloudexit.cli.app.generate_egress_html_report").start() - mock_pdf = patch("cloudexit.cli.app.generate_egress_pdf_report").start() + mocks = { + "report": patch( + "cloudexit.cli.app.generate_report", + return_value=report or self._WITH_PDF, + ).start(), + "estimate": patch( + "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(), + } try: - app.run_assessment(self._azure_config(), "azure") + app.run_assessment(config, provider, **kwargs) finally: patch.stopall() + return mocks - mock_estimate.assert_not_called() - mock_render.assert_not_called() - mock_pdf.assert_not_called() + 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): manager = MagicMock() @@ -672,8 +697,7 @@ def test_egress_runs_in_the_collection_stages_before_the_report(self): for p in patches: p.start() mock_report = patch( - "cloudexit.cli.app.generate_report", - return_value={"success": True, "reports": {}}, + "cloudexit.cli.app.generate_report", return_value=self._WITH_PDF ).start() mock_estimate = patch( "cloudexit.cli.app.estimate_egress", return_value=self._ESTIMATE_OK @@ -682,13 +706,9 @@ def test_egress_runs_in_the_collection_stages_before_the_report(self): "cloudexit.cli.app.generate_egress_html_report", return_value=self._REPORT_OK, ).start() - mock_pdf = patch( - "cloudexit.cli.app.generate_egress_pdf_report", return_value=self._PDF_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") - manager.attach_mock(mock_pdf, "generate_egress_pdf_report") config = self._azure_config() try: app.run_assessment(config, "azure", egress=True) @@ -704,29 +724,40 @@ 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" - ) - mock_pdf.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_egress_pdf_report"), - ) - # The estimate is part of the collection stages, before the local report - self.assertLess( - call_names.index("generate_egress_pdf_report"), call_names.index("generate_report"), ) + def test_the_egress_pdf_step_is_printed_and_the_path_listed(self): + mocks = self._run(self._azure_config(), "azure", egress=True) + + steps = [call.args[0] for call in mocks["step"].call_args_list] + self.assertIn("Generating egress PDF...", steps) + printed = [str(call) for call in mocks["print"].call_args_list] + self.assertTrue( + any("Egress PDF: /tmp/report/egress.pdf" in line for line in printed) + ) + def test_egress_failure_still_prints_outputs_and_exits_egress(self): patches = _base_patches() for p in patches: @@ -736,7 +767,6 @@ def test_egress_failure_still_prints_outputs_and_exits_egress(self): return_value={"success": False, "logs": "metrics api error"}, ).start() mock_render = patch("cloudexit.cli.app.generate_egress_html_report").start() - mock_pdf = patch("cloudexit.cli.app.generate_egress_pdf_report").start() mock_print = patch("cloudexit.cli.app.console.print").start() try: with self.assertRaises(SystemExit) as ctx: @@ -746,7 +776,6 @@ def test_egress_failure_still_prints_outputs_and_exits_egress(self): self.assertEqual(ctx.exception.code, codes.EGRESS) mock_render.assert_not_called() - mock_pdf.assert_not_called() printed = [str(call) for call in mock_print.call_args_list] self.assertTrue(any("Outputs:" in line for line in printed)) @@ -761,9 +790,7 @@ def test_egress_report_failure_still_prints_outputs_and_exits_egress(self): "cloudexit.cli.app.generate_egress_html_report", return_value={"success": False, "logs": "template error"}, ).start() - patch( - "cloudexit.cli.app.generate_egress_pdf_report", return_value=self._PDF_OK - ).start() + patch("cloudexit.cli.app.generate_report", return_value=self._WITH_PDF).start() mock_print = patch("cloudexit.cli.app.console.print").start() try: with self.assertRaises(SystemExit) as ctx: @@ -775,8 +802,8 @@ def test_egress_report_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)) # 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 ran - # independently and succeeded, so it is. + # 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)) @@ -793,9 +820,9 @@ def test_egress_pdf_failure_still_prints_outputs_and_exits_egress(self): return_value=self._REPORT_OK, ).start() patch( - "cloudexit.cli.app.generate_egress_pdf_report", - return_value={"success": False, "logs": "reportlab error"}, + "cloudexit.cli.app.generate_report", return_value=self._PDF_FAILED ).start() + mock_step = patch("cloudexit.cli.app.print_step").start() mock_print = patch("cloudexit.cli.app.console.print").start() try: with self.assertRaises(SystemExit) as ctx: @@ -803,11 +830,27 @@ def test_egress_pdf_failure_still_prints_outputs_and_exits_egress(self): finally: patch.stopall() + # The Executive Summary was written; only the egress exit code is set 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)) + 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 + for call in mock_step.call_args_list + if call.args[:1] == ("Generating egress PDF...",) + ] + self.assertEqual(failed[0].kwargs["status"], "error") + 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.assertEqual(ctx.exception.code, codes.REPORT) def test_egress_invoked_for_aws_with_provider_code(self): patches = _base_patches() @@ -820,9 +863,7 @@ def test_egress_invoked_for_aws_with_provider_code(self): "cloudexit.cli.app.generate_egress_html_report", return_value=self._REPORT_OK, ).start() - patch( - "cloudexit.cli.app.generate_egress_pdf_report", return_value=self._PDF_OK - ).start() + patch("cloudexit.cli.app.generate_report", return_value=self._WITH_PDF).start() config = VALID_CONFIG.copy() try: app.run_assessment(config, "aws", egress=True) diff --git a/tests/platform/test_document.py b/tests/platform/test_document.py index 94a8e78..6397e75 100644 --- a/tests/platform/test_document.py +++ b/tests/platform/test_document.py @@ -848,7 +848,10 @@ def run_assessment(self, report, *, egress_ok=None, **kwargs): ), patch( "cloudexit.cli.app.generate_report", - return_value={"success": True, "reports": {}}, + return_value={ + "success": True, + "reports": {"EGRESS_PDF": "p"} if egress_ok else {}, + }, ), patch("cloudexit.cli.app.add_run_log_handler"), patch("cloudexit.cli.app.print_step"), @@ -864,10 +867,6 @@ def run_assessment(self, report, *, egress_ok=None, **kwargs): "cloudexit.cli.app.generate_egress_html_report", return_value={"success": True, "html_path": "h"}, ), - patch( - "cloudexit.cli.app.generate_egress_pdf_report", - return_value={"success": True, "pdf_path": "p"}, - ), ] for item in patches: item.start() diff --git a/tests/report/test_basic_report.py b/tests/report/test_basic_report.py index a1aea40..5da2f82 100644 --- a/tests/report/test_basic_report.py +++ b/tests/report/test_basic_report.py @@ -600,9 +600,13 @@ def test_the_closing_page_only_names_what_the_full_assessment_adds(self): "Exit Readiness Grade", "Vendor Lock-in Score", "Scored Alternatives", - "Egress & Data Landscape", ): self.assertIn(name, closing) + # The Community Edition produces it itself, so the platforms do not add it + self.assertNotIn("Egress & Data Landscape", closing) + self.assertFalse( + any("Egress" in term for term, _text in BLOCKS["fa_items"]), + ) self.assertNotRegex(closing, r"\d+ / (100|5)") def test_the_html_report_has_no_scores_before_the_closing_section(self): @@ -979,6 +983,21 @@ def test_the_new_labels_are_in_the_title_case_list(self): ): self.assertIn(key, TITLE_CASE_KEYS) + def test_the_egress_labels_are_in_the_title_case_list(self): + for key in ( + "cover_title_egress", + "ev_egress_report", + "ev_companion_document", + "eg_heading_coverage", + "eg_heading_landscape", + "eg_heading_costs", + "eg_heading_pricing", + "eg_heading_next_steps", + "eg_th_size_gib", + "eg_st_largest", + ): + self.assertIn(key, TITLE_CASE_KEYS) + def test_the_retired_classification_is_gone(self): self.assertNotIn("cover_classification", BLOCKS) self.assertNotIn("cover_classification_value", BLOCKS) @@ -1021,7 +1040,8 @@ 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(): - if key.startswith("fa_"): + # The pages that point to the platforms name what they add + if key.startswith(("fa_", "eg_next_")): 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 77cb804..b1c33f9 100644 --- a/tests/report/test_egress.py +++ b/tests/report/test_egress.py @@ -6,10 +6,6 @@ import unittest from unittest.mock import patch -from reportlab.graphics.shapes import Drawing -from reportlab.platypus import PageBreak, Paragraph, Table - -from tests.support.report import stage_report_assets from cloudexit.resources import asset_path from cloudexit.egress.model import GIB, write_egress_inventory @@ -17,10 +13,6 @@ from cloudexit.report.egress import ( CATEGORIES, _build_allocation, - _build_coverage_section, - _build_data_landscape_section, - _build_estimated_costs_section, - _build_pricing_basis_section, _build_type_groups, _resolve_icon, build_fee_estimate, @@ -28,7 +20,6 @@ format_fee, free_tier_limit_display, generate_egress_html_report, - generate_egress_pdf_report, load_pricing, ) @@ -525,11 +516,9 @@ def test_renders_report_with_fees_table_and_charts(self): self.assertTrue(result["success"], result["logs"]) self.assertIn("Exit Assessment Test", html) normalized_html = " ".join(html.split()) - self.assertIn( - "The Data Landscape & Egress Estimation feature is currently in", - html, - ) - self.assertIn("mailto:beta@escapecloud.io", html) + # 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) @@ -556,7 +545,8 @@ def test_unknown_sizes_add_plus_suffix_to_headlines(self): self.assertTrue(result["success"], result["logs"]) # 2048 GiB known: (2048 - 100) * 0.087 = $169.48, "+" marks the lower bound - self.assertIn("2.0 TiB+", html) + 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) @@ -567,9 +557,9 @@ def test_free_tier_environment_shows_included_in_free_tier_card(self): self.assertTrue(result["success"], result["logs"]) self.assertIn("Included in Free Tier", html) - # The fee headline stays visible above the card, as a clean "$0". + # The fee headline stays visible above the card, in the one money format self.assertIn("Estimated Egress Fee", html) - self.assertIn('
$0
', 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) @@ -597,369 +587,3 @@ def test_missing_json_returns_failure(self): ) self.assertFalse(result["success"]) - - -def _make_pdf_styles(): - from reportlab.lib.colors import HexColor - from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet - - styles = getSampleStyleSheet() - content_style = ParagraphStyle( - "ContentStyle", fontSize=10, leading=12, spaceAfter=10 - ) - styles["Heading1"].textColor = HexColor("#112726") - styles["Heading2"].textColor = HexColor("#112726") - return styles, content_style - - -class BuildEstimatedCostsSectionTests(unittest.TestCase): - def setUp(self): - self.styles, self.content_style = _make_pdf_styles() - self.type_groups = [ - { - "label": "Storage Account", - "fee": 180.0, - "fee_display": "$180.00", - }, - { - "label": "Managed Disk", - "fee": 34.02, - "fee_display": "$34.02", - }, - { - "label": "Backup Vault", - "fee": None, - "fee_display": "n/a", - }, - ] - - def _totals(self, known, unknown_count=0): - return { - "known_size_bytes": known, - "archive_tier_bytes": 0, - "resources_discovered": 1, - "resources_with_unknown_size": unknown_count, - } - - def _tables(self, content): - tables = [f for f in content if isinstance(f, Table)] - self.assertGreaterEqual(len(tables), 2) - return tables - - def test_normal_fee_shell(self): - content = _build_estimated_costs_section( - 214.02, - self._totals(2560 * GIB), - False, - "100 GiB", - self.type_groups, - self.styles, - self.content_style, - ) - - tables = self._tables(content) - self.assertEqual(tables[0]._cellvalues[1][0].text, "$214.02") - self.assertIsInstance(tables[1]._cellvalues[0][0], Drawing) - breakdown = tables[-1]._cellvalues - self.assertEqual(breakdown[1], ["Storage Account", "$180.00"]) - self.assertEqual(breakdown[2], ["Managed Disk", "$34.02"]) - self.assertEqual(breakdown[3], ["Backup Vault", "n/a (not sized)"]) - self.assertEqual(breakdown[-1][0].text, "Estimated Total Egress Fee") - self.assertEqual(breakdown[-1][1].text, "$214.02") - paragraphs = [f.text for f in content if isinstance(f, Paragraph)] - self.assertTrue(any("Estimated Costs" in text for text in paragraphs)) - self.assertTrue(any("Cost Breakdown" in text for text in paragraphs)) - - def test_unknowns_add_plus_suffix_to_fee(self): - content = _build_estimated_costs_section( - 100.0, - self._totals(2000 * GIB, unknown_count=2), - False, - "100 GiB", - self.type_groups, - self.styles, - self.content_style, - ) - - tables = self._tables(content) - self.assertEqual(tables[0]._cellvalues[1][0].text, "$100.00+") - self.assertIn("Excludes 2 resources", tables[0]._cellvalues[2][0].text) - paragraphs = [f.text for f in content if isinstance(f, Paragraph)] - self.assertTrue(any("Estimated Costs" in text for text in paragraphs)) - - def test_free_tier_variant(self): - content = _build_estimated_costs_section( - 0.0, - self._totals(5 * GIB), - True, - "100 GiB", - [{"label": "Storage Account", "fee": 0.0, "fee_display": "$0.00"}], - self.styles, - self.content_style, - ) - - tables = self._tables(content) - self.assertEqual(tables[0]._cellvalues[1][0].text, "$0") - self.assertEqual(len(tables), 2) - paragraphs = [f.text for f in content if isinstance(f, Paragraph)] - self.assertTrue(any("100 GiB free tier" in text for text in paragraphs)) - - -class BuildDataLandscapeSectionTests(unittest.TestCase): - def test_table_rows_and_total(self): - styles, content_style = _make_pdf_styles() - type_groups = [ - { - "label": "S3 Bucket", - "icon": "/icons/aws/Storage/Simple-Storage-Service.png", - "fee_display": "$200.00", - "size_display": "20.0 MiB", - }, - { - "label": "Backup Vault", - "icon": "/icons/aws/Storage/Backup.png", - "fee_display": "n/a", - "size_display": "n/a", - }, - ] - - with tempfile.TemporaryDirectory() as tmp_dir: - content = _build_data_landscape_section( - type_groups, - "20.0 MiB+", - tmp_dir, - styles, - content_style, - ) - - tables = [f for f in content if isinstance(f, Table)] - self.assertEqual(len(tables), 1) - cells = tables[0]._cellvalues - # Costs column deliberately absent — dollars live on the Estimated - # Costs page so each type is not shown twice in the report. - self.assertEqual(cells[0], ["#", "Resource Type", "", "Size"]) - self.assertEqual(cells[1][1], "S3 Bucket") - self.assertEqual(cells[1][3], "20.0 MiB") - self.assertEqual(cells[2][3], "n/a") - self.assertEqual(cells[-1][0], "Total") - self.assertEqual(cells[-1][3], "20.0 MiB+") - for row in cells: - for cell in row: - self.assertNotIn("$", str(cell)) - - -class BuildCoverageSectionTests(unittest.TestCase): - def setUp(self): - self.styles, self.content_style = _make_pdf_styles() - - def _cells(self, content): - tables = [f for f in content if isinstance(f, Table)] - self.assertEqual(len(tables), 1) - return tables[0]._cellvalues - - def test_aws_coverage_rows_and_alpha_note(self): - content = _build_coverage_section(2, self.styles, self.content_style) - - cells = self._cells(content) - self.assertEqual(cells[0], ["Name", "Notes"]) - names = [row[0] for row in cells[1:]] - self.assertIn("S3 Buckets", names) - self.assertIn("AWS Backup Vaults", names) - texts = [f.text for f in content if isinstance(f, Paragraph)] - self.assertTrue(any(text.startswith("NOTE:") for text in texts)) - self.assertTrue(any("alpha version" in text for text in texts)) - # Estimated Costs starts on a fresh page after the coverage block. - self.assertIsInstance(content[-1], PageBreak) - - def test_azure_coverage_rows(self): - content = _build_coverage_section(1, self.styles, self.content_style) - - names = [row[0] for row in self._cells(content)[1:]] - self.assertIn("Storage Accounts", names) - self.assertIn("Recovery Services Vaults", names) - - def test_unknown_provider_renders_nothing(self): - self.assertEqual( - _build_coverage_section(9, self.styles, self.content_style), [] - ) - - -class BuildPricingBasisSectionTests(unittest.TestCase): - def setUp(self): - self.styles, self.content_style = _make_pdf_styles() - - def _texts(self, content): - return [f.text for f in content if isinstance(f, Paragraph)] - - def _table(self, content): - tables = [f for f in content if isinstance(f, Table)] - self.assertEqual(len(tables), 1) - return tables[0]._cellvalues - - def test_renders_used_price_rows_with_free_tier(self): - content = _build_pricing_basis_section( - RENDER_PRICING, 1, self.styles, self.content_style - ) - - texts = self._texts(content) - self.assertTrue(any("Appendix – Egress Prices" in text for text in texts)) - self.assertTrue( - any( - "Microsoft Azure list prices (Zone 1 – US/Europe, public " - "internet routing):" in text - for text in texts - ) - ) - self.assertFalse(any("effective as of" in text for text in texts)) - cells = self._table(content) - self.assertEqual(cells[0], ["#", "Component", "Tier", "Price"]) - self.assertEqual(cells[1][1], "Internet Egress") - self.assertEqual(cells[1][2], "first 100 GiB / month") - self.assertEqual(cells[1][3], "Free") - self.assertEqual(cells[2][3], "$0.087 / GiB") - self.assertEqual(cells[-1][1], "Archive Retrieval") - self.assertEqual(cells[-1][2], "all volumes") - self.assertEqual(cells[-1][3], "$0.02 / GiB") - self.assertFalse(any("rehydration fees" in text for text in texts)) - self.assertTrue( - any( - "Source (Internet Egress): " - "https://azure.microsoft.com/pricing/details/bandwidth/" in text - for text in texts - ) - ) - self.assertTrue( - any( - "Source (Archive Retrieval): " - "https://azure.microsoft.com/pricing/details/storage/blobs/" in text - for text in texts - ) - ) - self.assertTrue( - any("List prices at the time of the assessment" in text for text in texts) - ) - - def test_missing_retrieval_rows_add_note(self): - content = _build_pricing_basis_section( - AZURE_INTERNET_TIERS, 1, self.styles, self.content_style - ) - - texts = self._texts(content) - self.assertTrue( - any("rehydration fees are not included" in text for text in texts) - ) - self.assertTrue( - any( - "Source: https://azure.microsoft.com/pricing/details/bandwidth/" in text - for text in texts - ) - ) - self.assertFalse(any("storage/blobs" in text for text in texts)) - - def test_unused_zone_component_and_provider_rows_are_excluded(self): - prices = RENDER_PRICING + [ - _price_row(1, "internet_egress", 0, None, 9.9, zone="zone3"), - _price_row(1, "internet_egress_transit_isp", 0, None, 9.9), - _price_row(2, "internet_egress", 0, None, 9.9), - ] - - content = _build_pricing_basis_section( - prices, 1, self.styles, self.content_style - ) - - cells = self._table(content) - self.assertEqual(len(cells) - 1, len(RENDER_PRICING)) - - def test_mixed_valid_from_adds_column_without_effective_date_claim(self): - retrieval = dict( - _price_row(1, "archive_retrieval", 0, None, 0.02), - valid_from="2026-07-14", - ) - prices = AZURE_INTERNET_TIERS + [retrieval] - - content = _build_pricing_basis_section( - prices, 1, self.styles, self.content_style - ) - - texts = self._texts(content) - self.assertFalse(any("effective as of" in text for text in texts)) - cells = self._table(content) - self.assertEqual(cells[0][-1], "Valid from") - self.assertEqual(cells[-1][-1], "2026-07-14") - - def test_empty_pricing_renders_nothing(self): - self.assertEqual( - _build_pricing_basis_section([], 1, self.styles, self.content_style), [] - ) - - -class GenerateEgressPdfReportTests(unittest.TestCase): - _PROVIDER_DETAILS = { - "tenantId": "tenant-id", - "subscriptionId": "sub-id", - "resourceGroupName": "my-rg", - } - - def _generate(self, payload): - with tempfile.TemporaryDirectory() as tmp_dir: - stage_report_assets(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_pdf_report( - tmp_dir, json_path, self._PROVIDER_DETAILS - ) - - pdf_size = 0 - if result["success"]: - self.assertEqual( - result["pdf_path"], os.path.join(tmp_dir, "egress.pdf") - ) - pdf_size = os.path.getsize(result["pdf_path"]) - return result, pdf_size - - def test_generates_pdf_document(self): - rows = [ - _row("proddata", "Storage Account", "object", 2048 * GIB), - _row( - "vm-osdisk", - "Managed Disk", - "block", - 512 * GIB, - flags=["allocated (upper bound)"], - ), - _row("vault1", "Recovery Services Vault", "backup", None), - ] - result, pdf_size = self._generate(_payload(rows)) - - self.assertTrue(result["success"], result["logs"]) - self.assertGreater(pdf_size, 0) - - def test_free_tier_footprint_generates_pdf(self): - rows = [_row("sa1", "Storage Account", "object", 5 * GIB)] - result, pdf_size = self._generate(_payload(rows)) - - self.assertTrue(result["success"], result["logs"]) - self.assertGreater(pdf_size, 0) - - def test_missing_json_returns_failure(self): - with tempfile.TemporaryDirectory() as tmp_dir: - result = generate_egress_pdf_report( - tmp_dir, os.path.join(tmp_dir, "missing.json"), {} - ) - - 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 new file mode 100644 index 0000000..efd0352 --- /dev/null +++ b/tests/report/test_egress_report.py @@ -0,0 +1,600 @@ +import copy +import json +import re +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from pypdf import PdfReader + +from cloudexit.engine import generate_report, perform_risk_assessment +from cloudexit.report.context import ( + EGRESS_DOCUMENT_CODE, + 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 +from cloudexit.version import __version__ +from tests.report import test_egress as egress_helpers +from tests.report.test_basic_report import ( + COLLECTED, + URL, + html_text, + metadata_texts, + outline_entries, + page_texts, + provenance, + uploaded, +) +from tests.report.test_no_credentials import pricing_for_both_clouds +from tests.support.flow import build_flow_run +from tests.support.report import ( + build_fixture_context, + build_report_fixture, + stage_minimal_assets, +) +from tests.support.report_terms import american_hits, find_terms, retired_term_hits +from tests.support.sync import SYNC_ID + +GIB = egress_helpers.GIB +ES_ID = format_document_id("ES", SYNC_ID) +EDL_ID = format_document_id(EGRESS_DOCUMENT_CODE, SYNC_ID) +OUTLINE = [ + (0, "Introduction"), + (1, "Purpose"), + (1, "Scope"), + (1, "Limitations"), + (1, "Scope of the Estimation"), + (0, "Data Landscape"), + (0, "Estimated Costs"), + (0, "Appendix – Egress Prices"), + (1, "Glossary"), + (0, "Next Steps"), + (0, "Legal Notices"), +] +MULTI_TIB = [ + egress_helpers._row("proddata", "Storage Account", "object", 2048 * GIB), + egress_helpers._row("vm-osdisk", "Managed Disk", "block", 512 * GIB), + egress_helpers._row("vault1", "Recovery Services Vault", "backup", None), +] +FREE_TIER = [egress_helpers._row("sa1", "Storage Account", "object", 20 * GIB)] +WITH_UNKNOWN = [ + egress_helpers._row("sa1", "Storage Account", "object", 2048 * GIB), + egress_helpers._row("db1", "SQL Database", "database", None, size_unknown=True), +] + + +class EgressReportTestCase(unittest.TestCase): + def setUp(self): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.tmp = Path(tmp.name) + self.count = 0 + + def fixture(self, provider): + fixture = copy.deepcopy(build_report_fixture()) + fixture["metadata"]["cloud_service_provider"] = provider + if provider == 1: + fixture["provider_details"] = { + "tenantId": "11111111-1111-4111-8111-111111111111", + "subscriptionId": "22222222-2222-4222-8222-222222222222", + "resourceGroupName": "Prod-RG", + "clientSecret": "very-secret-client-value", + } + return fixture + + def prepare(self, rows, provider): + self.count += 1 + folder = self.tmp / f"report-{self.count}" + folder.mkdir() + stage_minimal_assets(str(folder)) + payload = egress_helpers._payload(rows) + payload["meta"]["cloud_service_provider"] = provider + 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) + ) + return folder, str(json_path) + + def render(self, rows=MULTI_TIB, provider=1, prov=None): + folder, json_path = self.prepare(rows, provider) + context = build_fixture_context( + self.fixture(provider), folder, provenance=prov or provenance() + ) + with patch( + "cloudexit.report.egress.load_data", side_effect=pricing_for_both_clouds + ): + 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())): + yield f"{name} {state}", provider, prov + + +class IdentityTests(unittest.TestCase): + def test_a_context_names_a_document_per_code(self): + context = build_fixture_context( + build_report_fixture(), tempfile.gettempdir(), provenance=provenance() + ) + + self.assertEqual(context.document_id, ES_ID) + self.assertEqual(context.document_id_for("EDL"), EDL_ID) + self.assertRegex(EDL_ID, r"^EDL-[0-9a-f]{8}-EN$") + # Both end with the same run, so the pair can be matched by eye + self.assertEqual(ES_ID[3:], EDL_ID[4:]) + self.assertIsNone(context.egress_document_id) + + def test_a_run_without_an_id_is_local(self): + self.assertEqual(format_document_id("EDL", None), "EDL-LOCAL-EN") + + +class CoverAndPageTwoTests(EgressReportTestCase): + def test_the_cover_names_the_document_and_the_edition(self): + for name, provider, prov in self.scenarios(): + with self.subTest(name): + _path, _context, reader = self.render(provider=provider, prov=prov) + cover = page_texts(reader)[0] + + self.assertIn("Egress & Data Landscape", cover) + self.assertIn(EDL_ID, cover) + self.assertIn("EDITION Community", cover) + self.assertNotIn("Classification", cover) + self.assertNotIn("CLASSIFICATION", cover) + everything = " ".join(page_texts(reader)).lower() + self.assertNotIn("confidential", everything) + self.assertNotIn("Page 1 of", cover) + + def test_page_two_is_the_document_information_with_the_companion(self): + for name, provider, prov in self.scenarios(): + with self.subTest(name): + _path, _context, reader = self.render(provider=provider, prov=prov) + pages = page_texts(reader) + + self.assertIn("Document Information", pages[1]) + self.assertIn("Assessment Scope", pages[1]) + self.assertIn("Provenance", pages[1]) + self.assertIn("Document Type Egress & Data Landscape", pages[1]) + self.assertIn(f"Companion Document {ES_ID}", pages[1]) + self.assertIn(EDL_ID, pages[1]) + self.assertIn(f"Page 2 of {len(pages)}", pages[1]) + + def test_an_uploaded_run_shows_the_platform_link_on_page_two(self): + # The PDF is written after the upload, so it knows about it + _path, _context, reader = self.render(prov=uploaded()) + self.assertIn(URL, page_texts(reader)[1]) + self.assertIn("Uploaded to the platform", page_texts(reader)[1]) + + _path, _context, offline = self.render(prov=provenance()) + self.assertNotIn(URL, page_texts(offline)[1]) + self.assertIn("Self-collected data", page_texts(offline)[1]) + + def test_the_pdf_metadata_follows_the_cover(self): + _path, _context, reader = self.render() + info = reader.metadata + + self.assertEqual(info.title, "Egress & Data Landscape – Smoke Test Assessment") + self.assertEqual(info.author, "cloudexit") + self.assertEqual(info.creator, f"cloudexit {__version__}") + self.assertIn(EDL_ID, info.subject) + for keyword in (EDL_ID, SYNC_ID): + self.assertIn(keyword, info["/Keywords"]) + for text in metadata_texts(reader): + self.assertNotIn("EscapeCloud_-_", text) + + +class StructureTests(EgressReportTestCase): + def test_the_outline_is_the_planned_list(self): + for name, provider, prov in self.scenarios(): + with self.subTest(name): + _path, _context, reader = self.render(provider=provider, prov=prov) + self.assertEqual(outline_entries(reader.outline), OUTLINE) + + def test_the_outline_does_not_depend_on_the_data(self): + for rows in (MULTI_TIB, FREE_TIER, WITH_UNKNOWN, []): + with self.subTest(rows=len(rows)): + _path, _context, reader = self.render(rows) + self.assertEqual(outline_entries(reader.outline), OUTLINE) + + def test_the_last_page_is_the_legal_notices_and_the_document_is_short(self): + for name, provider, prov in self.scenarios(): + with self.subTest(name): + _path, _context, reader = self.render(provider=provider, prov=prov) + pages = page_texts(reader) + + self.assertIn("Legal Notices", pages[-1][:200]) + self.assertNotIn("Legal Notices", " ".join(pages[:-1])) + self.assertTrue(5 <= len(pages) <= 7, len(pages)) + + def test_chapters_start_pages(self): + _path, _context, reader = self.render() + pages = page_texts(reader) + + self.assertIn("Introduction", pages[2]) + self.assertIn("Data Landscape", pages[3]) + starts = { + entry.title: reader.get_destination_page_number(entry) + for entry in reader.outline + if not isinstance(entry, list) + } + self.assertEqual(starts["Introduction"], 2) + self.assertEqual(starts["Data Landscape"], 3) + self.assertGreater(starts["Appendix – Egress Prices"], starts["Data Landscape"]) + + def test_every_running_header_and_footer_names_the_document(self): + _path, _context, reader = self.render() + pages = page_texts(reader) + + for index, text in enumerate(pages[1:], start=2): + self.assertIn("Smoke Test Assessment", text) + self.assertIn(EDL_ID, text) + self.assertIn("COMMUNITY EDITION", text) + self.assertIn(f"Page {index} of {len(pages)}", text) + + def test_the_fonts_are_plex_and_nothing_else(self): + path, _context, _reader = self.render() + names = set(re.findall(rb"/BaseFont\s*/(\S+)", Path(path).read_bytes())) + + self.assertTrue(names) + self.assertTrue(all(b"IBMPlex" in name for name in names), names) + + +class ContentTests(EgressReportTestCase): + def text(self, rows, **kwargs): + _path, _context, reader = self.render(rows, **kwargs) + return " ".join(page_texts(reader)) + + def test_the_scope_of_the_estimation_lists_the_providers_types(self): + azure = self.text(MULTI_TIB, provider=1) + aws = self.text(MULTI_TIB, provider=2) + + self.assertIn("Recovery Services Vaults", azure) + self.assertNotIn("S3 Buckets", azure) + self.assertIn("S3 Buckets", aws) + self.assertIn("Aurora is not sized", aws) + self.assertNotIn("Recovery Services Vaults", aws) + self.assertIn("Prod-RG", azure) + + def test_a_multi_tib_footprint_shows_the_cost_table_and_the_total(self): + text = self.text(MULTI_TIB) + + # (2560 - 100) GiB at 0.087 per GiB + self.assertIn("$214.02", text) + self.assertIn("2,560.0 GiB", text) + self.assertEqual(text.count("Share of Volume"), 2) + self.assertIn("Storage Account", text) + self.assertIn("BILLABLE VOLUME", text) + self.assertNotIn("TiB", text) + + def test_a_free_tier_footprint_says_so_and_leaves_out_the_cost_table(self): + text = self.text(FREE_TIER) + + self.assertIn("20.0 GiB of the 100 GiB monthly free tier", text) + self.assertIn("$0.00", text) + # Only the landscape table has the share column + self.assertEqual(text.count("Share of Volume"), 1) + self.assertNotIn("BILLABLE VOLUME", text) + self.assertIn("FREE ALLOWANCE / MONTH", text) + + def test_money_has_one_format_everywhere(self): + for rows in (MULTI_TIB, FREE_TIER, WITH_UNKNOWN): + with self.subTest(rows=len(rows)): + text = self.text(rows) + amounts = re.findall(r"\$[\d,]+(?:\.\d+)?", text) + + self.assertTrue(amounts) + for amount in amounts: + self.assertRegex(amount, r"^\$[\d,]+\.\d\d$") + + def test_unsized_types_are_listed_and_the_totals_carry_a_plus(self): + text = self.text(MULTI_TIB) + self.assertIn("Not sized: Recovery Services Vault (1 resource).", text) + self.assertIn("1 resource could not be sized", text) + + text = self.text(WITH_UNKNOWN) + self.assertIn("Not sized: SQL Database (1 resource).", text) + # (2048 - 100) GiB * 0.087, and the + marks the lower bound + self.assertIn("2,048.0 GiB+", text) + self.assertIn("$169.48+", text) + + def test_an_empty_inventory_renders_the_notice_and_no_tables(self): + text = self.text([]) + + self.assertIn("No data-bearing resources were identified", text) + self.assertIn("Estimated Costs", text) + self.assertEqual(text.count("Share of Volume"), 0) + + def test_the_appendix_names_the_prices_the_sources_and_the_units(self): + text = self.text(MULTI_TIB) + + for expected in ( + "Appendix – Egress Prices", + "first 100 GiB / month", + "0.087 / GiB", + "Prices per GiB as published by Microsoft Azure", + "https://azure.microsoft.com/pricing/details/bandwidth/", + "https://azure.microsoft.com/pricing/details/storage/blobs/", + "Glossary", + "Switching charges", + "Free tier", + ): + self.assertIn(expected, text) + + def test_next_steps_name_the_companion_and_the_platforms(self): + text = self.text(MULTI_TIB, prov=provenance()) + self.assertIn(f"Executive Summary of the same run ({ES_ID})", text) + self.assertIn("Connected (https://exitcloud.io)", text) + self.assertIn("Enterprise (https://escapecloud.io/book-demo/)", text) + + text = self.text(MULTI_TIB, prov=uploaded()) + self.assertIn(f"Open the reports: {URL}", text) + self.assertNotIn("Connected (https://exitcloud.io)", text) + + def test_no_date_is_written_month_first(self): + for name, provider, prov in self.scenarios(): + with self.subTest(name): + _path, _context, reader = self.render(provider=provider, prov=prov) + for text in page_texts(reader): + self.assertIsNone(re.search(r"\b\d{2}-\d{2}-\d{4}\b", text)) + + def test_no_credential_is_shown(self): + _path, _context, reader = self.render(provider=1) + text = " ".join(page_texts(reader) + metadata_texts(reader)) + + self.assertNotIn("very-secret-client-value", text) + self.assertNotIn("Client", text) + + def test_the_figures_do_not_move_with_the_presentation(self): + # The same rows give the same fee as the data functions + folder, json_path = self.prepare(MULTI_TIB, 1) + from cloudexit.report.egress import load_egress_estimate + + with patch( + "cloudexit.report.egress.load_data", side_effect=pricing_for_both_clouds + ): + estimate = load_egress_estimate(str(folder), json_path) + + self.assertAlmostEqual(estimate["total_fee"], 2460 * 0.087) + self.assertEqual(estimate["totals"]["known_size_bytes"], 2560 * GIB) + + +class NoticesTests(EgressReportTestCase): + MARKERS = {1: "Microsoft group", 2: "Amazon.com"} + + def test_the_notices_carry_the_egress_paragraph_and_the_providers_icons(self): + for name, provider, prov in self.scenarios(): + with self.subTest(name): + _path, _context, reader = self.render(provider=provider, prov=prov) + pages = page_texts(reader) + other = 1 if provider == 2 else 2 + + self.assertIn(BLOCKS["notices_egress"], pages[-1]) + self.assertIn("12 January 2027", pages[-1]) + self.assertIn(self.MARKERS[provider], pages[-1]) + self.assertNotIn(self.MARKERS[other], " ".join(pages)) + self.assertIn(BLOCKS["notices_disclaimer"], pages[-1]) + + def test_the_egress_paragraph_comes_before_the_trademarks(self): + _path, _context, reader = self.render() + last = page_texts(reader)[-1] + + self.assertLess(last.index(BLOCKS["notices_egress"]), last.index("Trademarks")) + + def test_the_executive_summary_notices_do_not_carry_it(self): + from cloudexit.report.notices import notices_html + + plain = notices_html(1, COLLECTED, BLOCKS) + egress = notices_html(1, COLLECTED, BLOCKS, egress=True) + + self.assertNotIn([BLOCKS["notices_egress"]], plain["blocks"][0]["paragraphs"]) + self.assertIn([BLOCKS["notices_egress"]], egress["blocks"][0]["paragraphs"]) + self.assertEqual( + egress["blocks"][1:], plain["blocks"][1:], "only the disclaimer grows" + ) + + def test_the_icon_links_follow_the_provider(self): + for provider, own, other in ( + (1, AZURE_ICONS_URL, AWS_ICONS_URL), + (2, AWS_ICONS_URL, AZURE_ICONS_URL), + ): + with self.subTest(provider=provider): + path, _context, _reader = self.render(provider=provider) + from tests.report.test_basic_report import link_targets + + links = link_targets(PdfReader(path)) + self.assertIn(own, links) + self.assertNotIn(other, links) + + +class GuardTests(EgressReportTestCase): + def test_the_term_wording_and_retired_term_guards_pass_on_every_page(self): + for name, provider, prov in self.scenarios(): + for rows in (MULTI_TIB, FREE_TIER, []): + with self.subTest(name, rows=len(rows)): + _path, _context, reader = self.render(rows, provider, prov) + for text in metadata_texts(reader): + self.assertEqual(find_terms(text), []) + for text in page_texts(reader): + self.assertEqual(find_terms(text), []) + 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): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.report = build_flow_run( + Path(tmp.name) / "run", + resources=[(1, "eu-central-1", 3)], + costs=[("2026-09-01", 10.0, "USD")], + ) + perform_risk_assessment(1, self.report) + + def generate(self, **kwargs): + return generate_report( + 2, + {"region": "eu-central-1"}, + 1, + 1, + "Flow", + self.report, + str(Path(self.report) / "raw_data"), + sync_id=SYNC_ID, + **kwargs, + ) + + def es_text(self): + reader = PdfReader(str(Path(self.report) / "report.pdf")) + return " ".join(page_texts(reader)) + + def test_the_egress_pdf_is_made_from_the_context_of_the_report(self): + upload = {"status": "uploaded", "url": URL, "assessment_id": "a-1"} + seen = {} + + def fake(context, json_path): + seen["context"] = context + seen["json_path"] = json_path + return "egress.pdf" + + with patch("cloudexit.engine.generate_egress_report", side_effect=fake): + result = self.generate(upload=upload, egress_json_path="estimate.json") + + self.assertTrue(result["success"]) + self.assertEqual(result["reports"]["EGRESS_PDF"], "egress.pdf") + self.assertNotIn("egress_pdf_logs", result) + context = seen["context"] + # The upload is known by the time the PDF is made + self.assertTrue(context.provenance.uploaded) + 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" + ): + self.generate(egress_json_path="estimate.json") + with_egress = self.es_text() + html = (Path(self.report) / "index.html").read_text(encoding="utf-8") + + 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) + + 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) + + def test_a_failing_egress_pdf_leaves_the_summary_written(self): + (Path(self.report) / "report.pdf").unlink(missing_ok=True) + with 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()) + 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): + result = self.generate(egress_json_path="/nowhere/estimate.json") + + self.assertTrue(result["success"]) + self.assertIn("egress_pdf_logs", result) + self.assertTrue((Path(self.report) / "report.pdf").is_file()) + + def test_without_an_estimate_nothing_is_added(self): + result = self.generate() + + self.assertTrue(result["success"]) + self.assertNotIn("EGRESS_PDF", result["reports"]) + self.assertNotIn("egress_pdf_logs", result) + + +class ProvenanceDefaultsTests(unittest.TestCase): + def test_the_provenance_helpers_still_name_the_run(self): + prov = Provenance( + collector_version="2.0.0", + collected_at=COLLECTED, + generated_at=COLLECTED, + run_id=SYNC_ID, + ) + + self.assertEqual(prov.run_short_id, SYNC_ID.replace("-", "")[-8:]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/report/test_icon_placeholder.py b/tests/report/test_icon_placeholder.py index 35f6a25..1efc2a0 100644 --- a/tests/report/test_icon_placeholder.py +++ b/tests/report/test_icon_placeholder.py @@ -20,11 +20,9 @@ from cloudexit.report.sections import ALT_BODY from cloudexit.egress.model import GIB from cloudexit.report.writers import generate_html_report -from cloudexit.report.egress import ( - _icon_flowable, - generate_egress_html_report, - generate_egress_pdf_report, -) +from cloudexit.report.egress import generate_egress_html_report +from cloudexit.report.egress_report import generate_egress_report +from cloudexit.report.egress_sections import _type_cell from cloudexit.report.aggregate import enrich_resource_inventory from tests.support.report import ( build_fixture_context, @@ -350,27 +348,35 @@ def run_report(self, generate, icons): self.assertTrue(result["success"], result.get("logs")) return result - def test_the_icon_flowable_is_a_placeholder_without_an_icon_file(self): + def run_pdf(self, icons): + directory, json_path, icons = self.prepare(icons) + fixture = build_report_fixture() + fixture["metadata"]["cloud_service_provider"] = 1 + context = build_fixture_context(fixture, directory) + with ( + patch("cloudexit.report.egress.load_data", side_effect=_fake_load_data), + patch("cloudexit.report.egress._build_icon_lookup", return_value=icons), + ): + return generate_egress_report(context, json_path) + + def test_the_type_cell_is_a_placeholder_without_an_icon_file(self): directory = self.directory() stage_report_assets(str(directory)) + def cell(icon): + return _type_cell({"label": "Managed Disk", "icon": icon}, str(directory)) + + self.assertIsInstance(cell(None), LabelWithPlaceholder) self.assertIsInstance( - _icon_flowable(str(directory), None, "Managed Disk"), IconPlaceholder - ) - self.assertIsInstance( - _icon_flowable(str(directory), "/icons/aws/Compute/NoSuchIcon.png", "X"), - IconPlaceholder, + cell("/icons/aws/Compute/NoSuchIcon.png"), LabelWithPlaceholder ) - image = _icon_flowable(str(directory), EC2_ICON, "Managed Disk") - self.assertNotIsInstance(image, IconPlaceholder) + self.assertNotIsInstance(cell(EC2_ICON), LabelWithPlaceholder) def test_the_pdf_shows_a_placeholder_for_a_type_without_an_icon(self): icons = {"microsoft.storage/storageaccounts": EC2_ICON} - result = self.run_report( - lambda d, j: generate_egress_pdf_report(d, j, {}), icons - ) - text = pdf_text(result["pdf_path"]) + text = pdf_text(self.run_pdf(icons)) + self.assertRegex(text, r"MD") self.assertIn("Managed Disk", text) @@ -380,15 +386,9 @@ def test_the_pdf_has_one_image_less_when_a_type_loses_its_icon(self): "some/type": LAMBDA_ICON, } one = {"microsoft.storage/storageaccounts": EC2_ICON} - with_both = self.run_report( - lambda d, j: generate_egress_pdf_report(d, j, {}), both - ) - count_both = image_count(with_both["pdf_path"]) - with_one = self.run_report( - lambda d, j: generate_egress_pdf_report(d, j, {}), one - ) + count_both = image_count(self.run_pdf(both)) - self.assertEqual(image_count(with_one["pdf_path"]), count_both - 1) + self.assertEqual(image_count(self.run_pdf(one)), count_both - 1) def test_the_html_shows_a_placeholder_and_keeps_the_images(self): icons = {"microsoft.storage/storageaccounts": EC2_ICON} diff --git a/tests/report/test_no_credentials.py b/tests/report/test_no_credentials.py index 38b4fa1..5edd280 100644 --- a/tests/report/test_no_credentials.py +++ b/tests/report/test_no_credentials.py @@ -202,7 +202,7 @@ def test_the_egress_pdf_shows_the_scope_without_the_credentials(self): report = self.run_with_egress(1, AZURE_DETAILS) text = folder_text(report)["egress.pdf"] - self.assertIn("Scope of Assessment", text) + self.assertIn("Assessment Scope", text) self.assertIn("Subscription ID", text) self.assertIn(AZURE_DETAILS["subscriptionId"], text) self.assertNotIn("Client", text) diff --git a/tests/report/test_pdf_sections.py b/tests/report/test_pdf_sections.py deleted file mode 100644 index 08aef73..0000000 --- a/tests/report/test_pdf_sections.py +++ /dev/null @@ -1,276 +0,0 @@ -import unittest - -from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle -from reportlab.lib.colors import HexColor -from reportlab.platypus import Paragraph, Table, TableStyle - -from cloudexit.report.egress import ( - build_scope_section, - build_summary_section, - default_table_style, -) -from cloudexit.report.context import scope_facts -from cloudexit.report.wording import BLOCKS -from tests.support.credentials import AWS_DETAILS, AZURE_DETAILS, leaked_fragments -from tests.support.report import ( - build_report_fixture, -) - - -def _make_styles(): - styles = getSampleStyleSheet() - content_style = ParagraphStyle( - "ContentStyle", fontSize=10, leading=12, spaceAfter=10 - ) - styles["Heading1"].leading = 1.5 * styles["Heading1"].fontSize - styles["Heading1"].textColor = HexColor("#112726") - styles["Heading2"].leading = 1.5 * styles["Heading2"].fontSize - styles["Heading2"].textColor = HexColor("#112726") - return styles, content_style - - -class DefaultTableStyleTests(unittest.TestCase): - def test_returns_table_style_instance(self): - style = default_table_style() - self.assertIsInstance(style, TableStyle) - - def test_style_has_expected_commands(self): - style = default_table_style() - commands = style.getCommands() - command_names = [cmd[0] for cmd in commands] - self.assertIn("BACKGROUND", command_names) - self.assertIn("TEXTCOLOR", command_names) - self.assertIn("GRID", command_names) - self.assertIn("FONTNAME", command_names) - - -class BuildSummarySectionTests(unittest.TestCase): - def setUp(self): - self.styles, self.content_style = _make_styles() - self.fixture = build_report_fixture() - - def test_returns_non_empty_list(self): - content = build_summary_section( - self.fixture["metadata"], self.styles, self.content_style - ) - self.assertIsInstance(content, list) - self.assertGreater(len(content), 0) - - def test_contains_heading_paragraph(self): - content = build_summary_section( - self.fixture["metadata"], self.styles, self.content_style - ) - paragraphs = [item for item in content if isinstance(item, Paragraph)] - heading_texts = [p.text for p in paragraphs] - self.assertIn("Summary", heading_texts) - - def test_contains_table_with_metadata(self): - content = build_summary_section( - self.fixture["metadata"], self.styles, self.content_style - ) - tables = [item for item in content if isinstance(item, Table)] - self.assertEqual(len(tables), 1) - - def test_azure_provider_maps_correctly(self): - metadata = {**self.fixture["metadata"], "cloud_service_provider": 1} - content = build_summary_section(metadata, self.styles, self.content_style) - tables = [item for item in content if isinstance(item, Table)] - self.assertEqual(len(tables), 1) - - -class BuildScopeSectionTests(unittest.TestCase): - def setUp(self): - self.styles, self.content_style = _make_styles() - self.fixture = build_report_fixture() - self.aws = {**self.fixture["metadata"], "cloud_service_provider": 2} - self.azure = {**self.fixture["metadata"], "cloud_service_provider": 1} - - def rows(self, metadata, provider_details): - content = build_scope_section( - metadata, provider_details, self.styles, self.content_style - ) - tables = [item for item in content if isinstance(item, Table)] - self.assertEqual(len(tables), 1) - return [list(row) for row in tables[0]._cellvalues[1:]] - - def test_aws_scope_is_the_account_and_the_region(self): - rows = self.rows(self.aws, AWS_DETAILS) - - self.assertEqual( - rows, [["Account ID", "123456789012"], ["Region", "eu-central-1"]] - ) - - def test_aws_scope_leaves_out_an_account_that_is_not_known(self): - details = {k: v for k, v in AWS_DETAILS.items() if k != "accountId"} - - self.assertEqual(self.rows(self.aws, details), [["Region", "eu-central-1"]]) - - def test_a_missing_value_is_a_dash_not_na(self): - self.assertEqual(self.rows(self.aws, {}), [["Region", "–"]]) - self.assertEqual( - self.rows(self.azure, {}), - [["Tenant ID", "–"], ["Subscription ID", "–"], ["Resource Group", "–"]], - ) - - def test_azure_scope_is_the_tenant_the_subscription_and_the_resource_group(self): - rows = self.rows(self.azure, AZURE_DETAILS) - - self.assertEqual( - rows, - [ - ["Tenant ID", AZURE_DETAILS["tenantId"]], - ["Subscription ID", AZURE_DETAILS["subscriptionId"]], - ["Resource Group", "rg-leakcheck"], - ], - ) - - def test_no_row_names_or_shows_a_credential(self): - for metadata, details in ((self.aws, AWS_DETAILS), (self.azure, AZURE_DETAILS)): - with self.subTest(provider=metadata["cloud_service_provider"]): - rows = self.rows(metadata, details) - for label, value in rows: - for word in ("Key", "Secret", "Client", "Token"): - self.assertNotIn(word, label) - text = " ".join(map(str, sum(rows, []))) - for name, fragment in leaked_fragments(details).items(): - self.assertNotIn(fragment, text, name) - - def test_the_rows_agree_with_the_scope_on_page_two(self): - # Both documents take the facts from scope_facts and name them the same way - self.assertEqual( - [row[0] for row in self.rows(self.azure, AZURE_DETAILS)], - [ - BLOCKS["ev_tenant_id"], - BLOCKS["ev_subscription_id"], - BLOCKS["ev_resource_group"], - ], - ) - self.assertEqual( - [row[0] for row in self.rows(self.aws, AWS_DETAILS)], - [BLOCKS["ev_account_id"], BLOCKS["ev_region"]], - ) - facts = dict(scope_facts(AWS_DETAILS, 2, None).rows) - self.assertEqual(self.rows(self.aws, AWS_DETAILS)[0][1], facts["account"]) - - def test_unknown_provider_returns_na(self): - metadata = {**self.fixture["metadata"], "cloud_service_provider": 99} - - self.assertEqual(self.rows(metadata, {}), [["N/A", "N/A"]]) - - -class BuildTfstateScopeSectionTests(unittest.TestCase): - _SHA = "a3f1" + "b" * 56 + "9c2e" - _LINEAGE = "121f3456-7d0f-a7be-ceb0-c91d44c6a689" - - def setUp(self): - self.styles, self.content_style = _make_styles() - self.fixture = build_report_fixture() - - def _rows(self, metadata, provider_details, tfstate_scope=None): - content = build_scope_section( - metadata, - provider_details, - self.styles, - self.content_style, - tfstate_scope, - ) - tables = [item for item in content if isinstance(item, Table)] - self.assertEqual(len(tables), 1) - return {row[0]: row[1] for row in tables[0]._cellvalues[1:]} - - def _aws_scope(self, **overrides): - scope = { - "file": "infra.tfstate", - "sha256": self._SHA, - "lineage": self._LINEAGE, - "serial": 7, - "locations": ["eu-central-1"], - "subscriptions": [], - "resource_groups": [], - } - scope.update(overrides) - return scope - - def test_aws_renders_file_state_and_regions(self): - rows = self._rows( - self.fixture["metadata"], - {"tfstatePath": "/tmp/infra.tfstate"}, - self._aws_scope(), - ) - - self.assertEqual(list(rows), ["File", "State", "Region(s)"]) - self.assertEqual(rows["File"], "infra.tfstate (SHA-256 a3f1…9c2e)") - self.assertEqual(rows["State"], f"{self._LINEAGE} (Serial: 7)") - self.assertEqual(rows["Region(s)"], "eu-central-1") - - def test_azure_renders_subscription_and_resource_groups(self): - metadata = {**self.fixture["metadata"], "cloud_service_provider": 1} - scope = self._aws_scope( - subscriptions=["0299bf7a-8ca8-479b-8659-c62e62cd7bae"], - resource_groups=["escape-test-rg-1", "escape-test-rg-2"], - ) - - rows = self._rows(metadata, {"tfstatePath": "/tmp/infra.tfstate"}, scope) - - self.assertEqual( - list(rows), ["File", "State", "Subscription", "Resource Group(s)"] - ) - self.assertEqual(rows["Subscription"], "0299bf7a-8ca8-479b-8659-c62e62cd7bae") - self.assertEqual( - rows["Resource Group(s)"], "escape-test-rg-1, escape-test-rg-2" - ) - # Azure deliberately mirrors the live table, which shows no location. - self.assertNotIn("Location(s)", rows) - self.assertNotIn("Region(s)", rows) - - def test_many_values_are_capped_with_a_remainder(self): - metadata = {**self.fixture["metadata"], "cloud_service_provider": 1} - scope = self._aws_scope( - subscriptions=["sub-1"], - resource_groups=[f"rg-{i}" for i in range(6)], - ) - - rows = self._rows(metadata, {"tfstatePath": "/tmp/infra.tfstate"}, scope) - - self.assertEqual(rows["Resource Group(s)"], "rg-0, rg-1, rg-2, +3 more") - - def test_missing_values_report_not_determinable(self): - rows = self._rows( - self.fixture["metadata"], - {"tfstatePath": "/tmp/infra.tfstate"}, - self._aws_scope(locations=[]), - ) - - self.assertEqual(rows["Region(s)"], "Not determinable from state") - - def test_state_row_omitted_when_lineage_and_serial_absent(self): - rows = self._rows( - self.fixture["metadata"], - {"tfstatePath": "/tmp/infra.tfstate"}, - self._aws_scope(lineage=None, serial=None), - ) - - self.assertEqual(list(rows), ["File", "Region(s)"]) - self.assertEqual(rows["File"], "infra.tfstate (SHA-256 a3f1…9c2e)") - - def test_missing_manifest_falls_back_to_file_name_only(self): - rows = self._rows( - self.fixture["metadata"], {"tfstatePath": "/tmp/infra.tfstate"}, None - ) - - self.assertEqual(rows["File"], "infra.tfstate") - self.assertEqual(rows["Region(s)"], "Not determinable from state") - # Never the credential rows, which would all be N/A here. - self.assertNotIn("Access Key", rows) - - def test_live_mode_table_has_no_credential_rows(self): - rows = self._rows( - self.fixture["metadata"], self.fixture["provider_details"], None - ) - - self.assertEqual(list(rows), ["Region"]) - self.assertEqual(rows["Region"], "eu-central-1") - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/report/test_report_flow.py b/tests/report/test_report_flow.py index 599b01a..2361328 100644 --- a/tests/report/test_report_flow.py +++ b/tests/report/test_report_flow.py @@ -426,9 +426,10 @@ def run_assessment( "cloudexit.cli.app.generate_egress_html_report", return_value={"success": True, "html_path": "h"}, ), + # The PDF is written by generate_report, from the estimate file patch( - "cloudexit.cli.app.generate_egress_pdf_report", - return_value={"success": True, "pdf_path": "p"}, + "cloudexit.engine.generate_egress_report", + return_value="egress.pdf", ), ] for item in patches: @@ -740,10 +741,7 @@ def traced_risk(*args, **kwargs): "cloudexit.cli.app.generate_egress_html_report", return_value={"success": True}, ), - patch( - "cloudexit.cli.app.generate_egress_pdf_report", - return_value={"success": True}, - ), + patch("cloudexit.engine.generate_egress_report", return_value="egress.pdf"), ): self.run_assessment( report,