diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..16b426a --- /dev/null +++ b/.gitattributes @@ -0,0 +1,7 @@ +# The definition copy is compared by file hash, so it must not get CRLF line endings +docs/api-v2/** text eol=lf + +# The bundled driver.js files are checked against a pinned hash and shipped +# unmodified, so no line ending conversion may touch them +cloudexit/assets/js/** -text +cloudexit/assets/css/driver.css -text diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 79e7ed0..330c98e 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -29,7 +29,7 @@ jobs: pip install -r requirements-dev.txt - name: Ruff - run: ruff check main.py core utils tests + run: ruff check main.py cloudexit tests check-types: if: github.event.pull_request.draft == false @@ -49,18 +49,28 @@ jobs: pip install -r requirements-dev.txt - name: Black - run: black --check main.py core utils tests + run: black --check main.py cloudexit tests check-tests: if: github.event.pull_request.draft == false - runs-on: ubuntu-latest + name: tests (${{ matrix.os }}, Python ${{ matrix.python }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - { os: ubuntu-latest, python: "3.12" } + - { os: ubuntu-latest, python: "3.13" } + - { os: ubuntu-latest, python: "3.14", coverage: true } + - { os: macos-latest, python: "3.14" } + - { os: windows-latest, python: "3.14" } steps: - uses: actions/checkout@v5 - name: Set up Python uses: actions/setup-python@v6 with: - python-version: ${{ env.PYTHON_VERSION }} + python-version: ${{ matrix.python }} cache: pip - name: Install dependencies @@ -69,4 +79,154 @@ jobs: pip install -r requirements-dev.txt - name: Unit tests - run: python -m unittest discover -s tests + if: ${{ !matrix.coverage }} + run: python -m unittest discover -s tests -t . + + - name: Unit tests with coverage + if: ${{ matrix.coverage }} + run: | + coverage run -m unittest discover -s tests -t . + coverage report + + install-smoke: + if: github.event.pull_request.draft == false + name: install smoke (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v5 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Locate the virtual environment + run: | + if [ "$RUNNER_OS" = "Windows" ]; then bin=Scripts; else bin=bin; fi + echo "VENV_BIN=$RUNNER_TEMP/venv/$bin" >> "$GITHUB_ENV" + + - name: Install the package (not editable) + run: | + python -m venv "$RUNNER_TEMP/venv" + "$VENV_BIN/python" -m pip install --upgrade pip + "$VENV_BIN/python" -m pip install . + + - name: Build the smoke dataset + run: '"$VENV_BIN/python" -m tests.make_smoke_dataset "$RUNNER_TEMP/smoke.db"' + + - name: Run from an unrelated directory + env: + ESC_EXIT_STRATEGY: "1" + ESC_ASSESSMENT_TYPE: "1" + run: | + cloudexit="$VENV_BIN/cloudexit" + workdir="$(mktemp -d)" + cd "$workdir" + + "$cloudexit" --version + "$cloudexit" config path + "$VENV_BIN/python" - <<'PY' + from cloudexit.providers.azure.clients import ( + create_cost_client, + create_resource_client, + create_subscription_client, + ) + + + class Credential: + def get_token(self, *scopes, **kwargs): + raise RuntimeError("no network in this check") + + + create_resource_client(Credential(), "00000000-0000-0000-0000-000000000000") + create_subscription_client(Credential()) + create_cost_client(Credential()) + print("the Azure clients import and build") + PY + "$cloudexit" aws \ + --tfstate "$GITHUB_WORKSPACE/tests/fixtures/smoke/aws.tfstate" \ + --catalogue "$RUNNER_TEMP/smoke.db" \ + --non-interactive + + report="$(ls -d reports/*/ | head -n 1)" + test -f "${report}index.html" + test -f "${report}report.pdf" + test -d "${report}assets/css" + test -d "${report}assets/img" + test -n "$(find "${report}assets/icons/aws" -name '*.png' | head -n 1)" + test "$(wc -c < "${report}report.pdf")" -gt 10000 + + upgrade-smoke: + if: github.event.pull_request.draft == false + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Install the 1.1.7 environment + run: | + python -m venv "$RUNNER_TEMP/venv" + "$RUNNER_TEMP/venv/bin/python" -m pip install --upgrade pip + git show v1.1.7:requirements.txt > "$RUNNER_TEMP/req-1.1.7.txt" + "$RUNNER_TEMP/venv/bin/python" -m pip install -r "$RUNNER_TEMP/req-1.1.7.txt" + + - name: Upgrade it to this branch + run: '"$RUNNER_TEMP/venv/bin/python" -m pip install .' + + - name: Build the smoke dataset + run: '"$RUNNER_TEMP/venv/bin/python" -m tests.make_smoke_dataset "$RUNNER_TEMP/smoke.db"' + + - name: Run from an unrelated directory + env: + ESC_EXIT_STRATEGY: "1" + ESC_ASSESSMENT_TYPE: "1" + run: | + cloudexit="$RUNNER_TEMP/venv/bin/cloudexit" + workdir="$(mktemp -d)" + cd "$workdir" + + "$cloudexit" --version + "$cloudexit" config path + "$RUNNER_TEMP/venv/bin/python" - <<'PY' + from cloudexit.providers.azure.clients import ( + create_cost_client, + create_resource_client, + create_subscription_client, + ) + + + class Credential: + def get_token(self, *scopes, **kwargs): + raise RuntimeError("no network in this check") + + + create_resource_client(Credential(), "00000000-0000-0000-0000-000000000000") + create_subscription_client(Credential()) + create_cost_client(Credential()) + print("the Azure clients import and build") + PY + "$cloudexit" aws \ + --tfstate "$GITHUB_WORKSPACE/tests/fixtures/smoke/aws.tfstate" \ + --catalogue "$RUNNER_TEMP/smoke.db" \ + --non-interactive + + report="$(ls -d reports/*/ | head -n 1)" + test -f "${report}index.html" + test -f "${report}report.pdf" + test -d "${report}assets/css" + test -d "${report}assets/img" + test -n "$(find "${report}assets/icons/aws" -name '*.png' | head -n 1)" + test "$(wc -c < "${report}report.pdf")" -gt 10000 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f714524..40496b7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,7 +4,7 @@ on: workflow_dispatch: inputs: version: - description: "Version, no leading v (e.g. 1.1.5)" + description: "Version, no leading v (e.g. 1.1.5, or 2.0.0-rc.1 from a release branch)" required: true permissions: @@ -18,31 +18,38 @@ jobs: with: token: ${{ secrets.RELEASE_TOKEN }} - - name: Guard against non-main dispatch + - name: Validate branch, version and guard against existing tag + env: + VERSION: ${{ inputs.version }} run: | - if [ "${{ github.ref }}" != "refs/heads/main" ]; then - echo "Release must be dispatched from main, got ${{ github.ref }}"; exit 1 + case "$GITHUB_REF" in + refs/heads/main) + echo "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$' || { echo "Final versions (X.Y.Z) only from main, got: $VERSION"; exit 1; } + ;; + refs/heads/release/*) + echo "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$' || { echo "Only release candidates (X.Y.Z-rc.N) from a release branch, got: $VERSION"; exit 1; } + ;; + *) + echo "Release must be dispatched from main or a release/* branch, got $GITHUB_REF"; exit 1 + ;; + esac + if git ls-remote --exit-code --tags origin "refs/tags/v$VERSION" >/dev/null 2>&1; then + echo "Tag v$VERSION already exists on origin"; exit 1 fi - - name: Validate version and guard against existing tag + - name: Bump cloudexit/version.py + env: + VERSION: ${{ inputs.version }} run: | - v="${{ inputs.version }}" - echo "$v" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$' || { echo "Bad version format: $v"; exit 1; } - if git ls-remote --exit-code --tags origin "refs/tags/v$v" >/dev/null 2>&1; then - echo "Tag v$v already exists on origin"; exit 1 - fi - - - name: Bump utils/version.py - run: | - v="${{ inputs.version }}" - sed -i "s/^__version__ = .*/__version__ = \"$v\"/" utils/version.py - grep -q "^__version__ = \"$v\"$" utils/version.py || { echo "sed did not update version"; exit 1; } + sed -i "s/^__version__ = .*/__version__ = \"$VERSION\"/" cloudexit/version.py + grep -q "^__version__ = \"$VERSION\"$" cloudexit/version.py || { echo "sed did not update version"; exit 1; } - - name: Commit, tag, and push to main + - name: Commit, tag, and push to the dispatched branch + env: + VERSION: ${{ inputs.version }} run: | - v="${{ inputs.version }}" git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - git commit -am "Release v$v" - git tag -a "v$v" -m "Release v$v" - git push origin HEAD "refs/tags/v$v" + git commit -am "Release v$VERSION" + git tag -a "v$VERSION" -m "Release v$VERSION" + git push origin HEAD "refs/tags/v$VERSION" diff --git a/.gitignore b/.gitignore index b585a66..bca627c 100644 --- a/.gitignore +++ b/.gitignore @@ -100,9 +100,18 @@ web_modules/ # Generated reports reports/ -# Generated reports -config/aws.json -config/azure.json +# Rendered sample reports (python -m tests.samples) +/samples/ + +# Local configs (only the example templates are tracked) +config/* +!config/*_example.json + +# Terraform state and downloaded datasets +*.tfstate +!tests/fixtures/smoke/aws.tfstate +!tests/fixtures/samples/tfstate/*.tfstate +datasets/ # TypeScript cache *.tsbuildinfo @@ -404,3 +413,6 @@ $RECYCLE.BIN/ output/ logs/ *.xlsx + +# Local platform credentials (deprecated) +/config.py diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..bc2d6fe --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,72 @@ +# AGENTS.md + +## What this is + +cloudexit is an offline-first CLI. It collects cloud inventory and cost data with the user's own credentials, builds a local report, and can optionally upload the results to a platform through API v2. Cloud credentials stay on the user's machine and are never sent anywhere. + +## Setup + +- Develop on Python 3.14. CI also tests 3.12 and 3.13 on Linux and 3.14 on macOS and Windows, because `requires-python` is `>=3.12`: don't use anything newer than 3.12 (for example `uuid.uuid7` only behind a fallback). +- Create and activate a virtualenv, then `pip install -r requirements-dev.txt`. +- Run the CLI with `python3 main.py`, or `pip install -e .` and use the `cloudexit` command. +- Local configs go in `config/` (start from a `*_example.json` template). They are never committed. + +## Lint and format + +CI runs exactly these, with the tool versions pinned in `requirements-dev.txt`: + +```bash +ruff check main.py cloudexit tests +black --check main.py cloudexit tests +``` + +Fix with `ruff check --fix main.py cloudexit tests` and `black main.py cloudexit tests`. Don't add a `# noqa` or an ignore without a code comment saying why. + +## Tests + +```bash +python -m unittest discover -s tests -t . +``` + +- Add or update tests with every behaviour change. Tests sit in sub-directories that mirror the source layout (`tests/platform/`, `tests/report/`, ...); shared builders are in `tests/support/` and data files in `tests/fixtures/`. +- Always discover with `-t .`: without it `tests/` itself becomes the top-level directory and `tests/platform/` would be imported as `platform`, shadowing the standard library. +- Tests never call real cloud APIs or real platforms. Mock `boto3`, the Azure SDK and HTTP. +- Tests that cannot run on a platform (POSIX file modes on Windows) are skipped with `unittest.skipIf` or `skipUnless` and a reason, never deleted. +- The Ubuntu 3.14 job runs the suite under `coverage` and prints the report. There is no threshold. + +## Branches + +- Name branches `task/`, one topic per branch. +- During 2.0 development, branch from and target `release/2.0`. `main` only gets hotfixes and Dependabot PRs (Dependabot always targets `main`). +- Before each release candidate, bring `main` into `release/2.0` with a PR from a branch `task/sync_main` (`git merge origin/main`). Merge it with a merge commit, not a squash. +- After the 2.0 release, branch from and target `main`. + +## Commits + +One line, lowercase, no trailing period, plain words about what changed, for example `load the egress resource registry from master data`. + +## Pull requests + +- A sentence-case title. +- A description with a Summary, a Changes section grouped by area, and a closing line on user-visible impact. +- State compatibility explicitly: config, exit codes, report output, the dataset and the API v2 definition copy. +- Wait for PR Checks to pass, then squash-merge (except `task/sync_main`, see Branches). + +## Never commit + +- Real values in `config.py`. +- Anything in `config/` except the `*_example.json` templates. +- `*.tfstate` files. +- `datasets/` and `reports/`. +- `.env` files, or credentials of any kind. + +## Compatibility rules + +- The CLI must keep working fully offline, without an account. +- The public contract is the `cloudexit` command, `python -m cloudexit` and `cloudexit.cli:main`. Everything else under `cloudexit.*` is internal and may move in any 2.x release. `python3 main.py` works from a checkout through a small root script that is not installed. +- `docs/api-v2/` is a vendored copy of the API v2 definition. Never edit it here; the version check test enforces this. +- The dataset is read only by column name. The CLI must tolerate extra tables and columns. + +## Security + +Never log API keys, cloud credentials, the `Authorization` header or full request and response payloads. Never add telemetry. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..cd3af69 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,146 @@ +# Changelog + +All notable changes to cloudexit are recorded here. The format follows +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and cloudexit uses +[Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +The public contract is the `cloudexit` command, `python -m cloudexit` and +`cloudexit.cli:main`. Everything under `cloudexit.*` is internal and may move in +any 2.x release. + +## [2.0.0] - unreleased + +Upgrading from 1.x? Follow the [migration guide](./docs/migration-2.0.md). + +### Breaking + +- `config.py` is no longer read. Settings come from flags, `CLOUDEXIT_*` + environment variables or `~/.config/cloudexit/config.toml`. Under + `python -m cloudexit` the current directory is first on `sys.path`, so a + `config.py` there used to be imported and executed at start-up; it no longer + is. When one is present and nothing else supplies a host or a key, the run + logs a single warning saying where to move the values. The file is never + imported, opened or parsed. +- The `HOST` and `KEY` environment variables are no longer read. Use + `CLOUDEXIT_HOST` and `CLOUDEXIT_API_KEY_FILE` (or `CLOUDEXIT_API_KEY`). The + same single warning covers them; their values are never read or logged. +- A run that relied on either source now runs offline, or fails with a + configuration error under `--upload required`. +- Platform uploads use API v2. A 1.x platform key does not work; create a new + assessment key on the assessment page. +- The sync document is `raw_data/sync.json` in the shape defined by + [`docs/api-v2/`](./docs/api-v2/), replacing 1.x `payload.json`. +- Exit codes 10–15 are reserved for upload outcomes and 130 for Ctrl+C, so a + pipeline that treated any non-zero exit as a collection failure needs + updating. See + [Exit codes](https://cloudexit.escapecloud.io/reference/exit-codes.html). +- The catalogue dataset lives in the user data directory and updates itself. A + `./datasets/data.db` from 1.x is no longer picked up implicitly; install it + with `cloudexit catalogue import datasets/data.db`. +- The package is `cloudexit/`. 1.x `core.*` and `utils.*` imports are gone. +- There is no `egress.html`. The egress section is part of `index.html`, with + `egress.pdf` as its printable form. + +### Added + +- `--upload auto|required|off` (also `CLOUDEXIT_UPLOAD` and `upload` in the + config file), so a pipeline can insist that an upload happens rather than + falling back to an offline run. +- A config file with profiles: `[default]` and `[profiles.]`, selected + with `--config-profile` or `CLOUDEXIT_PROFILE`. Template: + [`config.example.toml`](./config.example.toml). +- Key files: `--api-key-file`, `CLOUDEXIT_API_KEY_FILE` and `api_key_file`, with + a warning when the file is readable by group or others. +- `cloudexit status` — the resolved settings with their sources, the TLS chain, + the key (kind, organisation, assessment, scopes, expiry) and the platform's + own `/meta`, plus the spool and a catalogue comparison. `--output json` prints + the same as one JSON object. +- `cloudexit config path` and `cloudexit config show` — where settings are + looked up, and every resolved value with its source. Keys are masked. +- `cloudexit catalogue status|update|import` — inspect, download or install the + catalogue dataset, including on an air-gapped machine (`--sha256` verifies the + file). `--catalogue FILE` uses a file for one run without installing it, and + `CLOUDEXIT_CATALOGUE_URL` points the download at a mirror. +- `cloudexit upload` — upload sync documents, or folders of them, for example + from an air-gapped machine. With an account key it takes `--assessment-id`, or + `--name` and `--exit-strategy`. +- A spool for uploads that could not be delivered, with + `cloudexit spool list|flush|purge`. Retries honour `Retry-After`, carry one + idempotency key per sync so a repeat is never charged twice, and stay within + `--upload-budget` seconds. `--no-flush` leaves earlier syncs alone and + `--spool-on-error` also keeps syncs the platform refused. +- `--emit-payload DIR` — write a second copy of the sync document for review or + for a later upload. +- `--strict` — fail the run when the platform reports warnings, after the report + has been written. +- `--output json` — one JSON object on stdout at the end of a run, with the step + output on stderr. +- `--egress-names plain|hashed` — send egress resource names as they are, or as + HMAC-SHA256 hashes with a per-scope salt that stays on your machine. +- `--ca-bundle` and `CLOUDEXIT_CA_BUNDLE` for private certificate authorities; + `REQUESTS_CA_BUNDLE`, `SSL_CERT_FILE`, `HTTPS_PROXY` and `NO_PROXY` are + honoured. Certificate verification cannot be turned off. +- `--timeout` and `--upload-budget` for slow links. +- Exit strategy 2, Hybrid Cloud Adoption, can be chosen in the interactive + prompt and with `ESC_EXIT_STRATEGY`, matching the JSON config, the catalogue + and the reports. +- This changelog, and the [2.0 migration guide](./docs/migration-2.0.md). + +### Changed + +- `index.html` is one self-contained file. Fonts, icons and styles are bundled + and nothing is fetched from the network, so the report opens on an air-gapped + machine and can be attached to an email. +- `report.pdf` is the Basic Executive Summary, set in IBM Plex, opening with a + cover and a document information page that record the cloudexit version, the + run ID, the catalogue version and hash, and either the platform link or the + statement that the data was self-collected. The same facts are in the PDF + metadata and in a `provenance` object in the JSON report. Both PDFs end with + the Legal Notices. +- The local report is always the Basic report, computed on your machine. What a + platform computes stays on the platform; after an upload the local report + records only the link. +- A failed upload still writes the report, with a short banner, and the run + exits with the upload code once everything else has finished. Collection + failures take precedence over upload codes. +- Platform messages name the host rather than a product, an offline run is + reported as a normal mode instead of a warning, and the assessment type is + only asked when an upload will happen. +- `-vv` no longer writes request signing details or credentials to the console + or to `run.log`: botocore's signing and credential loggers stay quiet, and a + filter on both handlers redacts `Authorization` values, credentials, session + tokens and signatures. +- Egress figures that leave unsized resources out are marked with a `+`, and an + estimate where nothing could be sized shows `–` rather than a zero. +- A half-configured platform (a host without a key, or the reverse) is a + configuration error instead of a silent offline run. +- Loggers are named after their module, so `run.log` says which part of the CLI + wrote each line. +- Dependencies refreshed; `requires-python` is `>=3.12`. +- Installation is from a release tag with `pip install .`. cloudexit is not + published on PyPI. +- Data Landscape & Egress Estimation and the Infrastructure-as-Code State Scan + are no longer marked alpha. +- The platform, catalogue, upload and report reference moved from the README to + the [documentation site](https://cloudexit.escapecloud.io), so it is written + once. The README keeps a short pointer and a links table. + +### Removed + +- `config.py` and the `HOST` / `KEY` environment variables as settings sources + (see Breaking). +- The end-of-run promotional block in the terminal, and the "Next Steps" page of + the egress PDF. The terminal states what happened and where the outputs are. +- The two-second artificial pause behind the progress spinner. +- `egress.html` as a separate file, and the shared icon assets that were fetched + per run. + +### Deprecated + +- Boolean `upload` values in the config file and the environment. `true` and + `false` still map to `auto` and `off`, but write the mode instead. + +## Earlier releases + +1.x release notes are on the +[releases page](https://github.com/escapecloud/cloudexit/releases). diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/README.md b/README.md index d0ed975..42eed00 100644 --- a/README.md +++ b/README.md @@ -38,21 +38,25 @@ It is the open-source foundation of the [EscapeCloud](https://escapecloud.io) ec **Prerequisites:** Python 3.12+, and either an AWS CLI profile or an Azure CLI session. ```bash -git clone git@github.com:escapecloud/cloudexit.git +git clone --branch v2.0.0 https://github.com/escapecloud/cloudexit.git cd cloudexit -python -m venv .venv && source .venv/bin/activate -pip install -r requirements.txt +python3 -m venv .venv && source .venv/bin/activate +pip install . ``` +That installs the `cloudexit` command. To run from the checkout instead, use `pip install -r requirements.txt` and `python3 main.py`. cloudexit is not published on PyPI. + +Upgrading from 1.x? Install into a new virtual environment, not over the old one. + Run your first assessment: ```bash # AWS — using your existing CLI profile -python main.py aws --profile default +python3 main.py aws --profile default # Azure — using your Azure CLI session az login -python main.py azure --cli +python3 main.py azure --cli ``` Reports are written locally as HTML, PDF, and JSON. Full credential and permissions setup is covered in the [documentation](https://cloudexit.escapecloud.io). @@ -66,7 +70,7 @@ cloudexit produces a structured exit readiness report covering: - **Risk Assessment** — rule-based evaluation of exit feasibility across each identified service - **Alternative Technologies** — open-source and portable replacements for each identified cloud service, with additional details such as company ownership and location -![Report](./docs/images/Report.png) +The [documentation](https://cloudexit.escapecloud.io/reports/overview.html) shows an example report. ## Cloud Providers @@ -87,44 +91,61 @@ The CLI supports multiple input modes for both AWS and Azure. | Mode | Command | |---|---| -| Interactive (manual credentials) | `python main.py aws` | -| AWS CLI profile | `python main.py aws --profile PROFILE` | -| JSON config file | `python main.py aws --config config.json` | -| Terraform/OpenTofu state file | `python main.py aws --tfstate infra.tfstate` | -| Non-interactive (env vars, CI) | `python main.py aws --non-interactive` | +| Interactive (manual credentials) | `python3 main.py aws` | +| AWS CLI profile | `python3 main.py aws --profile PROFILE` | +| JSON config file | `python3 main.py aws --config config.json` | +| Terraform/OpenTofu state file | `python3 main.py aws --tfstate infra.tfstate` | +| Non-interactive (env vars, CI) | `python3 main.py aws --non-interactive` | **Azure** | Mode | Command | |---|---| -| Interactive (service principal) | `python main.py azure` | -| Azure CLI session | `python main.py azure --cli` | -| JSON config file | `python main.py azure --config config.json` | -| Terraform/OpenTofu state file | `python main.py azure --tfstate infra.tfstate` | -| Non-interactive (env vars, CI) | `python main.py azure --non-interactive` | +| Interactive (service principal) | `python3 main.py azure` | +| Azure CLI session | `python3 main.py azure --cli` | +| JSON config file | `python3 main.py azure --config config.json` | +| Terraform/OpenTofu state file | `python3 main.py azure --tfstate infra.tfstate` | +| Non-interactive (env vars, CI) | `python3 main.py azure --non-interactive` | See the [configuration reference](https://cloudexit.escapecloud.io/config/config-schema.html) for required permissions and config file format. -Want to see how a regulatory-aligned report looks (DORA / FINMA / UK PRA)? Run with `--dry-run` and send the output `payload.json` to request_report@escapecloud.io — we'll generate a sample you can share with your risk or compliance team. +## Connecting to a platform + +Connecting to a platform is optional; without a host and an API key cloudexit runs fully offline. Create an **assessment key** on the assessment's page, then set `CLOUDEXIT_HOST` and `CLOUDEXIT_API_KEY_FILE` — a key file readable by you only, rather than a key in plain text. In CI add `--upload required`, so a missing key fails the job before anything is collected instead of quietly producing an offline report. `cloudexit status` checks the settings, the TLS chain and the key; `cloudexit config show` says where each setting came from. + +> **Changed in 2.0:** `config.py` and the `HOST` / `KEY` environment variables are no longer read. See [Migrating from 1.x](./docs/migration-2.0.md). + +## Documentation + +| Topic | Page | +|---|---| +| Settings, precedence, config file, key files | [Platform settings →](https://cloudexit.escapecloud.io/config/platform-settings.html) | +| Keys, upload modes, the spool, what gets uploaded | [Connected Mode →](https://cloudexit.escapecloud.io/connected-mode/upload-modes.html) | +| Running in CI/CD | [Non-Interactive Mode →](https://cloudexit.escapecloud.io/non-interactive/overview.html) | +| The catalogue dataset | [Catalogue →](https://cloudexit.escapecloud.io/catalogue/overview.html) | +| Exit codes | [Exit codes →](https://cloudexit.escapecloud.io/reference/exit-codes.html) | +| Troubleshooting | [Troubleshooting →](https://cloudexit.escapecloud.io/troubleshooting/common-errors.html) | +| Migrating from 1.x | [docs/migration-2.0.md](./docs/migration-2.0.md) | +| Release history | [CHANGELOG.md](./CHANGELOG.md) | -## Data Landscape & Egress Estimation (alpha) +## Data Landscape & Egress Estimation Add `--egress` to any assessment to measure how much data lives in the assessed scope and estimate the one-time internet egress fee for moving it out — based on the provider's tiered list prices, with no additional permissions required. ```bash -python main.py azure --cli --egress -python main.py aws --profile PROFILE --egress +python3 main.py azure --cli --egress +python3 main.py aws --profile PROFILE --egress ``` See the [egress reference](https://cloudexit.escapecloud.io/egress/overview.html) for details. -## Infrastructure-as-Code State Scan (alpha) +## Infrastructure-as-Code State Scan Instead of connecting to a cloud account, cloudexit can build the assessment from a local **Terraform / OpenTofu state file** — no credentials, no API calls, nothing leaves your machine. ```bash -python main.py aws --tfstate infra.tfstate -python main.py azure --tfstate infra.tfstate +python3 main.py aws --tfstate infra.tfstate +python3 main.py azure --tfstate infra.tfstate ``` If your state lives in a remote backend (S3, Azure Storage, Terraform Cloud, …), export it first: @@ -153,6 +174,10 @@ cloudexit runs headlessly in CI pipelines via `--non-interactive` and environmen [View on GitHub Marketplace →](https://github.com/marketplace/actions/github-action-for-cloudexit) +The Action is at `v1`; a 2.0 version is coming. Until then, pass the 2.0 flags to the CLI directly, as below. + +In a pipeline that must upload, add `--upload=required` so a missing key fails the job before anything is collected, `--strict` to fail on platform warnings, and `--output json` to read the result from stdout. + ## Ecosystem cloudexit is the open-source, offline-first tier of a broader platform. @@ -169,9 +194,15 @@ Running cloudexit offline always produces a Basic assessment. Connect it to exit Contributions are welcome — bug reports, documentation improvements, new service mappings, and pull requests. -See the [contribution guidelines](https://cloudexit.escapecloud.io/contributing/how-to-contribute.html) for details. +See the [contribution guidelines](https://cloudexit.escapecloud.io/contributing/how-to-contribute.html) for details. Working rules for contributors and AI agents (setup, checks, branches, commits, pull requests) are in [AGENTS.md](./AGENTS.md). + +The public contract is the `cloudexit` command, `python -m cloudexit` and `cloudexit.cli:main`. Everything else under `cloudexit.*` is internal and may move in any 2.x release. ## License -cloudexit is licensed under the **GNU Affero General Public License v3 (AGPL-3.0)**. +© 2024–2026 EscapeCloud. cloudexit is licensed under the **GNU Affero General Public License v3 (AGPL-3.0)**. See [LICENSE](./LICENSE) for details. + +A commercial licence is available through the EscapeCloud enterprise package for organizations that cannot use AGPL-licensed software; see [escapecloud.io](https://escapecloud.io). + +EscapeCloud, cloudexit and the EscapeCloud logo are trademarks. The AGPL-3.0 licence does not grant any right to use them; modified versions must not use them in a way that suggests they are official. diff --git a/assets/css/style.css b/assets/css/style.css deleted file mode 100644 index 8e2234f..0000000 --- a/assets/css/style.css +++ /dev/null @@ -1,1092 +0,0 @@ -/* ======================================================== - Base: Variables - ======================================================== */ -:root { - /* Blue */ - --blue-100: #dbe6fe; - --blue-800: #1e4baf; - - /* Green */ - --green-100: #dcfce7; - --green-600: #16a34a; - --green-700: #047854; - - /* Neutral */ - --neutral-50: #f9fbfb; - --neutral-100: #f3f6f6; - --neutral-200: #e5ebeb; - --neutral-300: #d1dbdb; - --neutral-400: #9cafae; - --neutral-600: #4b6361; - --neutral-800: #1f3735; - --neutral-900: #112726; - - /* Primary */ - --primary-600: #0d948b; - --primary-800: #115e59; - --primary-950: #042f2c; - - /* Red */ - --red-50: #fef2f2; - --red-100: #fee2e2; - --red-700: #b91c1c; - --red-800: #991b1b; - - /* Yellow */ - --yellow-100: #fee4c7; - --yellow-850: #92400e; - /* Color Palette */ - --white: #fff; - /* Transition Defaults */ - --transition-speed: 0.3s; - - /* Font Sizes */ - --text-heading-2: 24px; - --text-heading-3: 20px; - --text-body: 16px; - --text-label: 14px; - --text-label-small: 12px; - /* Radius */ - --rounded-md: 12px; - --rounded-sm: 8px; - --rounded-xs: 4px; - /* Sidebar */ -} - -/* ======================================================== - Base: Reset - ======================================================== */ -* { - margin: 0; - padding: 0; - -webkit-box-sizing: border-box; - box-sizing: border-box; -} - -body { - font-family: - -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; - line-height: 1.375; - font-weight: 400; - color: var(--neutral-900); - background: var(--neutral-100); - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-font-smoothing: antialiased; -} - -img { - max-width: 100%; - height: auto; -} - -ul { - list-style: none; - padding: 0; - margin: 0; -} - -.divider-border { - height: 1px; - background: var(--neutral-200); -} - -.container { -} - -/* ======================================================== - Base: Typography - ======================================================== */ -a { - text-decoration: none; - -webkit-transition: all var(--transition-speed) ease; - -o-transition: all var(--transition-speed) ease; - transition: all var(--transition-speed) ease; -} - -a:hover { - text-decoration: none !important; -} - - -h2, -h3 { - color: var(--neutral-900); - font-weight: 500; -} - - -h2, -h3, -li, -p { - margin: 0; -} - - -h2 { - font-size: var(--text-heading-2); - line-height: 1.33; -} - - -h3 { - font-size: var(--text-heading-3); - line-height: 1.4; -} - - -body, -p { - font-size: var(--text-body); - line-height: 1.375; -} - -.label, -label { - font-size: var(--text-label); - line-height: 1.43; -} - -.small, -label.small { - font-size: var(--text-label-small); - line-height: 1.33; -} - -.bg-white { - background: var(--white); -} - -a*:active, -a*:focus { - outline: 0; - border: 0; -} - -main { - padding-bottom: 32px; - padding-top: 88px; -} - -/* Scrollbars */ -::-webkit-scrollbar { - width: 5px; - border-radius: var(--rounded-sm); -} - -::-webkit-scrollbar-track { - background: var(--neutral-100); - border-radius: var(--rounded-sm); -} - -::-webkit-scrollbar-thumb { - background: var(--primary-600); - border-radius: var(--rounded-sm); -} - -::-webkit-scrollbar-thumb:hover { - background: var(--primary-800); - border-radius: var(--rounded-sm); -} - -.dropdown-item span { - font-size: var(--text-label); - font-weight: 400; - color: var(--neutral-900); -} - -.dropdown-item { - padding-top: 8px; - padding-bottom: 8px; -} - -.dropdown-menu { - padding: 0; -} - -.dropdown-menu li a { - line-height: 1.5; -} - -/* ======================================================== - Layout: Shared Cards And Charts - ======================================================== */ - -.chart-card-head { - padding: 16px 24px; -} - -.chart-card-head h3 { - margin-bottom: 5px; - color: var(--neutral-900); -} -.chart-card-head h6 { - color: var(--neutral-600); - font-size: var(--text-label); - font-weight: 400; -} -.chart-card-head h6 span { - display: inline-block; - font-weight: 500; -} - -.risk-header { - display: -webkit-box; - display: -ms-flexbox; - display: flex; - -webkit-box-pack: justify; - -ms-flex-pack: justify; - justify-content: space-between; - -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; - /* padding: 16px 24px; */ -} - -.risk-dashboard { - display: flex; - flex-direction: column; - height: 100%; -} - -.risk-count h2 { - color: var(--neutral-600); - font-size: var(--text-label); - font-weight: 400; -} - -.risk-count .count { - font-size: var(--text-heading-3); - font-weight: 500; - color: var(--neutral-900); - margin: 0; -} - -.chart-container { - display: -webkit-box; - display: -ms-flexbox; - display: flex; - -webkit-box-pack: center; - -ms-flex-pack: center; - justify-content: center; - -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; - flex: 1; -} - -.chart-box { - height: 350px; -} - -.chart-empty-state { - flex: 1; - min-height: 220px; - display: flex; - align-items: center; - justify-content: center; - text-align: center; -} - -.chart-empty-state-inner { - display: flex; - flex-direction: column; - align-items: center; - gap: 10px; -} - -.chart-empty-state i { - font-size: 28px; - line-height: 1; - color: var(--neutral-900); -} - -.chart-empty-state p { - margin: 0; - color: var(--neutral-700); -} - -.alt-tech-empty-state { - min-height: 260px; -} - -.scoring-empty-state { - min-height: 300px; -} - -.scoring-card { - overflow: hidden; -} - -.scoring-inner { - display: flex; - justify-content: center; - align-items: center; - height: 100%; - min-height: 300px; -} - -.chart-wrapper { - max-width: 350px; - overflow: hidden; - position: relative; - border-radius: 12px; - mask-image: linear-gradient(to bottom, black 85%, transparent 100%); - -webkit-mask-image: linear-gradient(to bottom, black 85%, transparent 100%); -} - -.scoring { - width: 100%; - max-width: 420px; - height: 320px !important; - margin: 0 auto; -} - -.form-title { - font-size: var(--text-heading-3); - font-weight: 500; - line-height: 1.33; - color: var(--neutral-900); -} - -.shadow-s { - -webkit-box-shadow: 0 1px 4px 0 rgba(0, 0, 0, 0.1); - box-shadow: 0 1px 4px 0 rgba(0, 0, 0, 0.1); -} - -.input { - border: 1px solid var(--neutral-300); -} - -/* ======================================================== - Components: Buttons - ======================================================== */ - -.dropdown-toggle::after { - float: right; - margin-top: 8px; -} - -/* ======================================================== - Components: Summary, Scoring, Resources, Alt Tech Cards - ======================================================== */ -::-webkit-input-placeholder { - color: var(--neutral-600); - font-size: var(--text-body); - font-style: normal; - font-weight: 400; - line-height: 38px; -} - -::-moz-placeholder { - color: var(--neutral-600); - font-size: var(--text-body); - font-style: normal; - font-weight: 400; - line-height: 38px; -} - -.resource-card { - border: 1px solid var(--neutral-200); -} - -.resource-card h3 { - font-weight: 500; - min-height: 44px; - font-size: var(--text-label); - font-size: 16px; - font-weight: 500; - font-stretch: normal; - line-height: 1.38; - color: var(--neutral-900); -} - -.resource-card h6 { - font-size: var(--text-label); - font-weight: 400; - margin: 0; -} - -.resource-card { - height: 100%; -} - -.resource-card p { - margin-top: 16px; - margin-bottom: 16px; -} - -.resource-card img { - max-width: 100%; - margin: 0 0 16px; - height: 32px; -} - -.gapy-3 { - gap: 16px 0; -} - -.alttech-card { - border: 1px solid var(--neutral-200); - height: 100%; -} - -.alttech-title { -} - -.alttech-title h5 { - font-size: var(--text-body); - margin: 0; - color: var(--neutral-900); -} - -.alttech-title h6 { - font-size: var(--text-label-small); - font-weight: 400; -} - -.green-700 { - color: var(--green-700); -} - -.alttech-text p { - /*overflow: hidden;*/ - /*display: -webkit-box;*/ - font-size: 14px; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; -} - -.sync-status { - gap: 28px 0; -} - -.sync-status-text { -} - -.sync-status-text h5 { - margin: 0; - font-size: var(--text-body); - color: var(--neutral-900); -} - -.sync-status-text h6 { - color: var(--neutral-600); - font-size: var(--text-label); - margin-bottom: 5px; - font-weight: 400; -} - -.sync-status-icon span{ - border-radius: 8px; - background:rgba(5, 81, 96, 0.10); - height: 32px; - width: 32px; - display: flex; - align-items: center; - justify-content: center; -} - -.view-more button { - background: transparent; - border: 0; -} - -/* ======================================================== - Components: Risk Table - ======================================================== */ -.risk-title-cell { - position: relative; - padding-left: 40px !important; - cursor: pointer; -} - -.chevron-icon { - position: absolute; - left: 16px; - top: 50%; - -webkit-transform: translateY(-50%); - -ms-transform: translateY(-50%); - transform: translateY(-50%); - font-size: var(--text-label-small); - color: var(--neutral-600); - -webkit-transition: -webkit-transform 0.2s ease; - transition: -webkit-transform 0.2s ease; - -o-transition: transform 0.2s ease; - transition: transform 0.2s ease; - transition: - transform 0.2s ease, - -webkit-transform 0.2s ease; -} - -.risk-row.expanded .chevron-icon { - -webkit-transform: translateY(-50%) rotate(90deg); - -ms-transform: translateY(-50%) rotate(90deg); - transform: translateY(-50%) rotate(90deg); -} - -.expandable-content { - display: none; - background-color: #f5f5f5; -} - -.expandable-content.show { - display: table-row; -} - -.expandable-content td { - padding-left: 20px; -} - -.risk-table-container .table tr th { - font-weight: 500; - font-size: var(--text-label); -} - -.risk-table-container .table th:first-child, -.risk-table-container .table td:first-child { - width: auto !important; -} - -.risk-table-container .table tr th:nth-child(3), -.risk-table-container .table tr th:nth-child(4) { - text-align: center; -} - -.impacted-count { - text-align: center; -} - -.description { - background: var(--neutral-200); - padding-top: 12px; - padding-bottom: 12px; -} - -.description-section { - margin-bottom: 10px; -} - -.risk-title { - color: var(--neutral-900); -} - -.section-label { - font-size: var(--text-label-small); - font-weight: 400; - color: var(--neutral-600); -} - -.section-content { - font-size: var(--text-label); -} - -.impacted-resources-content { - font-size: var(--text-label); -} - -.severity-badge { - color: var(--colors-red-800); - font-size: var(--text-label); - font-weight: 500; - padding: 3px 12px; - border-radius: var(--rounded-md); -} - -.severity-high { - background-color: var(--red-100); - color: var(--red-800); -} - -.severity-medium { - background: var(--yellow-100); - color: var(--yellow-850); -} - -.severity-low { - background: var(--blue-100); - color: var(--blue-800); -} - -.btn { - border-radius: 6px; - line-height: 40px; - padding: 0 16px; - font-weight: 500; - -webkit-transition: 0.4s; - -o-transition: 0.4s; - transition: 0.4s; -} - -.btn-primary { - background: var(--primary-800); - color: var(--white); -} - -.btn-outline-primary { - color: var(--primary-800); -} - -.btn-light { - background: var(--neutral-100); - border: 1px solid var(--neutral-100); - color: var(--neutral-600); -} - -.btn-outline-primary, -.btn-primary { - border: 1px solid var(--primary-800); -} - -.btn-sm { - font-size: var(--text-label); - font-weight: 500; - line-height: 34px; -} - -.btn-primary:hover { - background: var(--primary-950); - border-color: var(--primary-950); -} - -.btn-primary:hover svg:not(.filter-icon) path { - fill: var(--white); -} - -.btn-outline-primary:focus svg:not path, -.btn-outline-primary:active svg:not path, -.btn-outline-primary:hover svg:not path { - fill: var(--white); -} - -.btn-outline-primary:hover svg.filter-icon path { - stroke: var(--white); -} - -.btn-outline-primary:active, -.btn-outline-primary:hover { - background: var(--primary-800) !important; - border-color: var(--primary-800) !important; - color: var(--white) !important; -} -.btn-outline-primary:active svg path, -.btn-outline-primary:hover svg path { - fill: white; -} - -.btn-light:hover { - background: var(--neutral-200); - border-color: var(--neutral-200); -} - -.dropdown-toggle::after { - border: 0; - display: none; -} - -.dropdown-toggle { - display: -webkit-box; - display: -ms-flexbox; - display: flex; - -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; - -webkit-box-pack: center; - -ms-flex-pack: center; - justify-content: center; - gap: 8px; - padding-left: 16px; - padding-right: 16px; -} - -.btn-sm.dropdown-toggle { - padding-left: 12px; - padding-right: 12px; -} - -.btn:focus-visible, -.btn.show:focus-visible, -.btn:first-child:active:focus-visible, -:not(.btn-check) + .btn:active:focus-visible { - -webkit-box-shadow: none !important; - box-shadow: none !important; -} - -.btn-primary, -.btn-outline-primary { - /* --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); - --bs-btn-focus-shadow-rgb: 13, 110, 253; - --bs-btn-disabled-bg: transparent; - --bs-btn-active-color: var(--neutral-white); - --bs-btn-hover-color: var(--neutral-white); - --bs-gradient: none; */ -} - -.btn-outline-primary { - --bs-btn-color: var(--primary-800); - --bs-btn-border-color: var(--primary-800); - --bs-btn-hover-color: var(--white); - --bs-btn-hover-bg: var(--primary-800); - --bs-btn-hover-border-color: var(--primary-800); - --bs-btn-active-color: var(--white); - --bs-btn-active-bg: var(--primary-800); - --bs-btn-active-border-color: var(--primary-800); - --bs-btn-disabled-color: var(--neutral-400); - --bs-btn-disabled-border-color: var(--neutral-300); - --bs-btn-focus-shadow-rgb: 17, 94, 89; -} - -.btn-outline-primary.show, -.btn-check:checked + .btn-outline-primary, -.btn-check:active + .btn-outline-primary, -.btn-outline-primary:focus, -.btn-outline-primary:focus-visible { - background: var(--primary-800) !important; - border-color: var(--primary-800) !important; - color: var(--white) !important; -} - -.btn-outline-primary.show svg.filter-icon path, -.btn-check:checked + .btn-outline-primary svg.filter-icon path, -.btn-check:active + .btn-outline-primary svg.filter-icon path, -.btn-outline-primary:focus svg.filter-icon path, -.btn-outline-primary:focus-visible svg.filter-icon path { - stroke: var(--white); -} - -.btn-primary.disabled, -.btn-primary:disabled { - border-color: var(--neutral-400); - background: var(--neutral-400); - color: var(--white); -} - -.btn-outline-primary.disabled, -.btn-outline-primary:disabled { - border-color: var(--neutral-300); - color: var(--neutral-400); -} - -/* ======================================================== - Components: Filter Toggles - ======================================================== */ -.toggle-switch { - position: relative; - width: 44px; - height: 24px; -} - -.toggle-switch input[type="checkbox"] { - opacity: 0; - width: 0; - height: 0; -} - -.toggle-slider { - position: absolute; - cursor: pointer; - top: 0; - left: 0; - right: 0; - bottom: 0; - background-color: var(--neutral-200); - -webkit-transition: 0.3s ease; - -o-transition: 0.3s ease; - transition: 0.3s ease; - border-radius: var(--rounded-md); -} - -.toggle-slider:before { - position: absolute; - content: ""; - height: 20px; - width: 20px; - left: 2px; - bottom: 2px; - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='none'%3E%3Ccircle cx='10' cy='10' r='9.25' fill='white' stroke='%23D1DBDB' stroke-width='1.5'/%3E%3Cpath d='M6.5 6.5L13.5 13.5M13.5 6.5L6.5 13.5' stroke='%234B6361' stroke-width='1.6' stroke-linecap='round'/%3E%3C/svg%3E"); -} - -.toggle-slider:before { - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='none'%3E%3Ccircle cx='10' cy='10' r='9.25' fill='white' stroke='%23D1DBDB' stroke-width='1.5'/%3E%3Cpath d='M6.5 6.5L13.5 13.5M13.5 6.5L6.5 13.5' stroke='%234B6361' stroke-width='1.6' stroke-linecap='round'/%3E%3C/svg%3E"); - background-repeat: no-repeat; - background-position: center; - background-size: 20px 20px; -} - -input:checked + .toggle-slider { - background-color: var(--primary-800); -} - -input:checked + .toggle-slider:before { - -webkit-transform: translateX(20px); - -ms-transform: translateX(20px); - transform: translateX(20px); - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='none'%3E%3Ccircle cx='10' cy='10' r='9.25' fill='white' stroke='%230D948B' stroke-width='1.5'/%3E%3Cpath d='M5.5 10.5L8.5 13.5L14.5 7.5' stroke='%230D948B' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); - background-repeat: no-repeat; - background-position: center; - background-size: 20px 20px; -} - -/* ======================================================== - Components: Tables - ======================================================== */ -table:not("#assessmentsTable") { - table-layout: fixed; - width: 100%; -} - -.table > :not(caption) > * > * { - color: var(--neutral-900); - font-size: var(--text-body); - padding-top: 16px; - padding-bottom: 16px; - border-color: var(--neutral-200); -} - -tr:last-child td { - border-bottom: 0; -} - -.risk-table-container { - overflow-x: auto; - -webkit-overflow-scrolling: touch; -} - -.risk-table-container table { - min-width: 420px; -} - -.risk-table-container::-webkit-scrollbar { - height: 20px; -} - -.risk-table-container::-webkit-scrollbar-track { - background: var(--white); -} - -.risk-table-container::-webkit-scrollbar-thumb { - background: var(--neutral-300); - border-radius: 20px; -} - -.risk-table-container::-webkit-scrollbar-thumb:hover { - background: var(--neutral-600); -} - -th:first-child, -td:first-child { - width: 50px !important; - font-weight: 500; -} - -td .number, -td button { - margin-left: auto; - margin-right: auto; -} - -/* ======================================================== - Layout: Main Content And Forms - ======================================================== */ - -#main-content { - height: 100%; - width: 100%; - overflow: hidden; - padding: 20px 60px 20px 60px; - transition: all 0.3s linear; - -webkit-transition: all 0.3s linear; -} - -.visit span { - font-size: 14px; - word-break: break-all; -} - -.form-control, -.form-select { - color: var(--neutral-600); - font-size: var(--text-body); - font-style: normal; - font-weight: 400; - border-radius: 8px; - border: 1px solid var(--neutral-300); - background: var(--white); - -webkit-transition: border-color 0.3s ease, box-shadow 0.3s ease; - -o-transition: border-color 0.3s ease, box-shadow 0.3s ease; - transition: border-color 0.3s ease, box-shadow 0.3s ease; - -webkit-box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); - box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); -} - -.form-select { - -webkit-appearance: none; - appearance: none; - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16' fill='none'%3E%3Cpath d='M4 6L8 10L12 6' stroke='%23115E59' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); - background-repeat: no-repeat; - background-position: right 12px center; - background-size: 16px 16px; - padding-right: 40px; -} - -.form-control:focus, -.form-select:focus { - border-color: var(--primary-800); - box-shadow: 0 0 0 0.2rem rgba(17, 94, 89, 0.12); -} - -.custom-search { - min-width: 240px; - height: 36px; - border-radius: 8px; - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20' viewBox='0 0 20 20' fill='none'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M9 3.5C5.96243 3.5 3.5 5.96243 3.5 9C3.5 12.0376 5.96243 14.5 9 14.5C10.519 14.5 11.893 13.8852 12.8891 12.8891C13.8852 11.893 14.5 10.519 14.5 9C14.5 5.96243 12.0376 3.5 9 3.5ZM2 9C2 5.13401 5.13401 2 9 2C12.866 2 16 5.13401 16 9C16 10.6625 15.4197 12.1906 14.4517 13.3911L17.7803 16.7197C18.0732 17.0126 18.0732 17.4874 17.7803 17.7803C17.4874 18.0732 17.0126 18.0732 16.7197 17.7803L13.3911 14.4517C12.1906 15.4197 10.6625 16 9 16C5.13401 16 2 12.866 2 9Z' fill='%236B807F'/%3E%3C/svg%3E") !important; - background-repeat: no-repeat !important; - background-position: 16px center !important; - padding: 0 16px 0 48px !important; - width: 400px; -} - -.btn-clear:hover { - text-decoration: underline; -} - -.alt-tech-card { - background: #f5f5f5; - border-radius: 10px; -} - -.alt-tech-card h3 { - font-size: 18px; - margin: 0; -} - -/* ======================================================== - Components: Alternative Technology Status And Hints - ======================================================== */ - -.verified { - background: var(--green-600); - color: var(--white); -} -.green-100 { - background: var(--green-100); -} -.green-700 { - color: var(--green-700); -} -.tags span { - background: var(--neutral-100); - padding: 4px 8px; - border-radius: 6px; - font-size: var(--text-label); - color: var(--neutral-700); - font-weight: 500; -} -.verified span { - font-size: var(--text-label); -} -.red-700 { - color: var(--red-700); -} -.red-50 { - background: var(--red-50); -} - -.info-hint-box { - position: relative; - display: inline-block; - margin-left: 6px; - cursor: pointer; -} -.info-hint-box i { - font-size: 16px; - color: var(--primary-600, #007bff); - transition: color 0.2s; -} -.info-hint-box:hover i { - color: var(--primary-800, #0056b3); -} -.hint-hoverbox { - display: none; - position: absolute; - top: 28px; - right: 0; - z-index: 1000; - background: #fff; - border: 1px solid var(--neutral-200, #e5e7eb); - border-radius: 8px; - padding: 12px 14px; - width: 280px; - box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.1); -} -.info-hint-box:hover .hint-hoverbox { - display: block; -} -.hint-hoverbox { - opacity: 0; - visibility: hidden; - transition: all 0.2s ease-in-out; -} -.info-hint-box:hover .hint-hoverbox { - opacity: 1; - visibility: visible; -} -.hint-hoverbox h5 { - font-size: 14px; - font-weight: 600; - margin-bottom: 6px; -} -.hint-hoverbox p { - font-size: 13px; - color: #555; - margin: 0; - line-height: 1.4; -} - -/* ======================================================== - Responsive - ======================================================== */ - -@media (max-width: 991px) { - - .alt-tech-card div { - width: 100%; - /* justify-content: flex-end; */ - } - - .alt-tech-card { - -ms-flex-wrap: wrap; - flex-wrap: wrap; - gap: 16px; - } - - .chart-card-head { - padding-left: 16px; - padding-right: 16px; - } - - #main-content { - margin-left: 0; - } -} - -@media (max-width: 767px) { - main { - padding-top: 24px; - } - - .btn { - padding-left: 12px; - padding-right: 12px; - } - - main { - padding-top: 88px; - } - -} - -@media (max-width: 575px) { - -} diff --git a/assets/icons/aws/Analytics/Athena.svg b/assets/icons/aws/Analytics/Athena.svg deleted file mode 100644 index 7446f8b..0000000 --- a/assets/icons/aws/Analytics/Athena.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-Athena_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Analytics/Clean-Rooms.svg b/assets/icons/aws/Analytics/Clean-Rooms.svg deleted file mode 100644 index 6d0f58e..0000000 --- a/assets/icons/aws/Analytics/Clean-Rooms.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Clean-Rooms_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Analytics/CloudSearch.svg b/assets/icons/aws/Analytics/CloudSearch.svg deleted file mode 100644 index 0bd8f7b..0000000 --- a/assets/icons/aws/Analytics/CloudSearch.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-CloudSearch_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Analytics/Data-Exchange.svg b/assets/icons/aws/Analytics/Data-Exchange.svg deleted file mode 100644 index d27a3ba..0000000 --- a/assets/icons/aws/Analytics/Data-Exchange.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Data-Exchange_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Analytics/Data-Firehose.svg b/assets/icons/aws/Analytics/Data-Firehose.svg deleted file mode 100644 index 73a9b1a..0000000 --- a/assets/icons/aws/Analytics/Data-Firehose.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-Kinesis-Data-Firehose_64 - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Analytics/Data-Pipeline.svg b/assets/icons/aws/Analytics/Data-Pipeline.svg deleted file mode 100644 index f6dfb21..0000000 --- a/assets/icons/aws/Analytics/Data-Pipeline.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - - Icon-Architecture/64/Arch_AWS-Data-Pipeline_64 - Created with Sketch. - - - - - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Analytics/DataZone.svg b/assets/icons/aws/Analytics/DataZone.svg deleted file mode 100644 index be29a65..0000000 --- a/assets/icons/aws/Analytics/DataZone.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-DataZone_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Analytics/EMR.svg b/assets/icons/aws/Analytics/EMR.svg deleted file mode 100644 index 7f0e69d..0000000 --- a/assets/icons/aws/Analytics/EMR.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-EMR_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Analytics/Entity-Resolution.svg b/assets/icons/aws/Analytics/Entity-Resolution.svg deleted file mode 100644 index 630f7ed..0000000 --- a/assets/icons/aws/Analytics/Entity-Resolution.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Entity-Resolution_64 - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Analytics/FinSpace.svg b/assets/icons/aws/Analytics/FinSpace.svg deleted file mode 100644 index 25a4ade..0000000 --- a/assets/icons/aws/Analytics/FinSpace.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-FinSpace_64 - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Analytics/Glue-DataBrew.svg b/assets/icons/aws/Analytics/Glue-DataBrew.svg deleted file mode 100644 index a8427ae..0000000 --- a/assets/icons/aws/Analytics/Glue-DataBrew.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Glue-DataBrew_64 - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Analytics/Glue-Elastic-Views.svg b/assets/icons/aws/Analytics/Glue-Elastic-Views.svg deleted file mode 100644 index bb8cd70..0000000 --- a/assets/icons/aws/Analytics/Glue-Elastic-Views.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Glue-Elastic-Views_64 - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Analytics/Glue.svg b/assets/icons/aws/Analytics/Glue.svg deleted file mode 100644 index a699bc3..0000000 --- a/assets/icons/aws/Analytics/Glue.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Glue_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Analytics/Kinesis-Data-Analytics.svg b/assets/icons/aws/Analytics/Kinesis-Data-Analytics.svg deleted file mode 100644 index 71f439a..0000000 --- a/assets/icons/aws/Analytics/Kinesis-Data-Analytics.svg +++ /dev/null @@ -1,20 +0,0 @@ - - - - Icon-Architecture/64/Arch_Amazon-Kinesis-Data-Analytics_64 - Created with Sketch. - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Analytics/Kinesis-Data-Streams.svg b/assets/icons/aws/Analytics/Kinesis-Data-Streams.svg deleted file mode 100644 index e94c858..0000000 --- a/assets/icons/aws/Analytics/Kinesis-Data-Streams.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-Kinesis-Data-Streams_64 - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Analytics/Kinesis-Firehose.svg b/assets/icons/aws/Analytics/Kinesis-Firehose.svg deleted file mode 100644 index 0aaaca7..0000000 --- a/assets/icons/aws/Analytics/Kinesis-Firehose.svg +++ /dev/null @@ -1,20 +0,0 @@ - - - - Icon-Architecture/64/Arch_Amazon-Kinesis-Firehose_64 - Created with Sketch. - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Analytics/Kinesis-Video-Streams.svg b/assets/icons/aws/Analytics/Kinesis-Video-Streams.svg deleted file mode 100644 index 919d7af..0000000 --- a/assets/icons/aws/Analytics/Kinesis-Video-Streams.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-Kinesis-Video-Streams_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Analytics/Kinesis.svg b/assets/icons/aws/Analytics/Kinesis.svg deleted file mode 100644 index e18a525..0000000 --- a/assets/icons/aws/Analytics/Kinesis.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-Kinesis_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Analytics/Lake-Formation.svg b/assets/icons/aws/Analytics/Lake-Formation.svg deleted file mode 100644 index cb6697d..0000000 --- a/assets/icons/aws/Analytics/Lake-Formation.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Lake-Formation_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Analytics/Managed-Service-for-Apache-Flink.svg b/assets/icons/aws/Analytics/Managed-Service-for-Apache-Flink.svg deleted file mode 100644 index 32d21b5..0000000 --- a/assets/icons/aws/Analytics/Managed-Service-for-Apache-Flink.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-Managed-Service-for-Apache-Flink_64 - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Analytics/Managed-Streaming-for-Apache-Kafka.svg b/assets/icons/aws/Analytics/Managed-Streaming-for-Apache-Kafka.svg deleted file mode 100644 index 43cfc2f..0000000 --- a/assets/icons/aws/Analytics/Managed-Streaming-for-Apache-Kafka.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-Managed-Streaming-for-Apache-Kafka_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Analytics/OpenSearch-Service.svg b/assets/icons/aws/Analytics/OpenSearch-Service.svg deleted file mode 100644 index 46477bd..0000000 --- a/assets/icons/aws/Analytics/OpenSearch-Service.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-OpenSearch-Service_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Analytics/QuickSight.svg b/assets/icons/aws/Analytics/QuickSight.svg deleted file mode 100644 index e8cc60e..0000000 --- a/assets/icons/aws/Analytics/QuickSight.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - - Icon-Architecture/64/Arch_Amazon-QuickSight_64 - Created with Sketch. - - - - - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Analytics/Redshift.svg b/assets/icons/aws/Analytics/Redshift.svg deleted file mode 100644 index 4a36d2d..0000000 --- a/assets/icons/aws/Analytics/Redshift.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-Redshift_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/App-Integration/API-Gateway.svg b/assets/icons/aws/App-Integration/API-Gateway.svg deleted file mode 100644 index 6515f49..0000000 --- a/assets/icons/aws/App-Integration/API-Gateway.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/assets/icons/aws/App-Integration/AppFlow.svg b/assets/icons/aws/App-Integration/AppFlow.svg deleted file mode 100644 index 8830c97..0000000 --- a/assets/icons/aws/App-Integration/AppFlow.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-AppFlow_64 - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/App-Integration/AppSync.svg b/assets/icons/aws/App-Integration/AppSync.svg deleted file mode 100644 index 9e00578..0000000 --- a/assets/icons/aws/App-Integration/AppSync.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-AppSync_64 - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/App-Integration/B2B-Data-Interchange.svg b/assets/icons/aws/App-Integration/B2B-Data-Interchange.svg deleted file mode 100644 index cdfc3a3..0000000 --- a/assets/icons/aws/App-Integration/B2B-Data-Interchange.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-B2B-Data-Interchange_64 - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/App-Integration/Console-Mobile-Application.svg b/assets/icons/aws/App-Integration/Console-Mobile-Application.svg deleted file mode 100644 index 6742658..0000000 --- a/assets/icons/aws/App-Integration/Console-Mobile-Application.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Console-Mobile-Application _64 - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/App-Integration/EventBridge.svg b/assets/icons/aws/App-Integration/EventBridge.svg deleted file mode 100644 index df90db2..0000000 --- a/assets/icons/aws/App-Integration/EventBridge.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-EventBridge_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/App-Integration/Express-Workflows.svg b/assets/icons/aws/App-Integration/Express-Workflows.svg deleted file mode 100644 index 3ffd4c1..0000000 --- a/assets/icons/aws/App-Integration/Express-Workflows.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Express-Workflow_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/App-Integration/MQ.svg b/assets/icons/aws/App-Integration/MQ.svg deleted file mode 100644 index 48b4a63..0000000 --- a/assets/icons/aws/App-Integration/MQ.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-MQ_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/App-Integration/Managed-Workflows-for-Apache-Airflow.svg b/assets/icons/aws/App-Integration/Managed-Workflows-for-Apache-Airflow.svg deleted file mode 100644 index 4a1135a..0000000 --- a/assets/icons/aws/App-Integration/Managed-Workflows-for-Apache-Airflow.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-Managed-Workflows-for-Apache-Airflow_64 - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/App-Integration/Simple-Notification-Service.svg b/assets/icons/aws/App-Integration/Simple-Notification-Service.svg deleted file mode 100644 index 0cfd322..0000000 --- a/assets/icons/aws/App-Integration/Simple-Notification-Service.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-Simple-Notification-Service_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/App-Integration/Simple-Queue-Service.svg b/assets/icons/aws/App-Integration/Simple-Queue-Service.svg deleted file mode 100644 index e900ea0..0000000 --- a/assets/icons/aws/App-Integration/Simple-Queue-Service.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-Simple-Queue-Service_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/App-Integration/Step-Functions.svg b/assets/icons/aws/App-Integration/Step-Functions.svg deleted file mode 100644 index 74c3bc3..0000000 --- a/assets/icons/aws/App-Integration/Step-Functions.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Step-Functions_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Blockchain/Managed-Blockchain.svg b/assets/icons/aws/Blockchain/Managed-Blockchain.svg deleted file mode 100644 index b746238..0000000 --- a/assets/icons/aws/Blockchain/Managed-Blockchain.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-Managed-Blockchain_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Blockchain/Quantum-Ledger-Database.svg b/assets/icons/aws/Blockchain/Quantum-Ledger-Database.svg deleted file mode 100644 index 973946f..0000000 --- a/assets/icons/aws/Blockchain/Quantum-Ledger-Database.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - - Icon-Architecture/64/Arch_Amazon-Quantum-Ledger-Database_64 - Created with Sketch. - - - - - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Business-Applications/Alexa-For-Business.svg b/assets/icons/aws/Business-Applications/Alexa-For-Business.svg deleted file mode 100644 index 9235e0b..0000000 --- a/assets/icons/aws/Business-Applications/Alexa-For-Business.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - - Icon-Architecture/64/Arch_Alexa-For-Business_64 - Created with Sketch. - - - - - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Business-Applications/AppFabric.svg b/assets/icons/aws/Business-Applications/AppFabric.svg deleted file mode 100644 index 3da8245..0000000 --- a/assets/icons/aws/Business-Applications/AppFabric.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-AppFabric_64 - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Business-Applications/Chime-SDK.svg b/assets/icons/aws/Business-Applications/Chime-SDK.svg deleted file mode 100644 index c4ea5c6..0000000 --- a/assets/icons/aws/Business-Applications/Chime-SDK.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-Chime-SDK_64 - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Business-Applications/Chime-Voice-Connector.svg b/assets/icons/aws/Business-Applications/Chime-Voice-Connector.svg deleted file mode 100644 index e3bb94b..0000000 --- a/assets/icons/aws/Business-Applications/Chime-Voice-Connector.svg +++ /dev/null @@ -1,16 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-Chime-Voice-Connector_64 - - - - - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Business-Applications/Chime.svg b/assets/icons/aws/Business-Applications/Chime.svg deleted file mode 100644 index d7f78ff..0000000 --- a/assets/icons/aws/Business-Applications/Chime.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-Chime_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Business-Applications/Connect.svg b/assets/icons/aws/Business-Applications/Connect.svg deleted file mode 100644 index aa77f14..0000000 --- a/assets/icons/aws/Business-Applications/Connect.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-Connect_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Business-Applications/End-User-Messaging.svg b/assets/icons/aws/Business-Applications/End-User-Messaging.svg deleted file mode 100644 index e742a6b..0000000 --- a/assets/icons/aws/Business-Applications/End-User-Messaging.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/assets/icons/aws/Business-Applications/Honeycode.svg b/assets/icons/aws/Business-Applications/Honeycode.svg deleted file mode 100644 index 02b9a3c..0000000 --- a/assets/icons/aws/Business-Applications/Honeycode.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-Honeycode_64 - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Business-Applications/Pinpoint-APIs.svg b/assets/icons/aws/Business-Applications/Pinpoint-APIs.svg deleted file mode 100644 index e4f4f4e..0000000 --- a/assets/icons/aws/Business-Applications/Pinpoint-APIs.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-Pinpoint-APIs_64 - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Business-Applications/Pinpoint.svg b/assets/icons/aws/Business-Applications/Pinpoint.svg deleted file mode 100644 index a695691..0000000 --- a/assets/icons/aws/Business-Applications/Pinpoint.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-Pinpoint_64 - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Business-Applications/Quick.svg b/assets/icons/aws/Business-Applications/Quick.svg deleted file mode 100644 index 5a22fcb..0000000 --- a/assets/icons/aws/Business-Applications/Quick.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/assets/icons/aws/Business-Applications/Simple-Email-Service.svg b/assets/icons/aws/Business-Applications/Simple-Email-Service.svg deleted file mode 100644 index aeb14de..0000000 --- a/assets/icons/aws/Business-Applications/Simple-Email-Service.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-Simple-Email-Service_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Business-Applications/Supply-Chain.svg b/assets/icons/aws/Business-Applications/Supply-Chain.svg deleted file mode 100644 index ab72cdf..0000000 --- a/assets/icons/aws/Business-Applications/Supply-Chain.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Supply-Chain_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Business-Applications/Wickr.svg b/assets/icons/aws/Business-Applications/Wickr.svg deleted file mode 100644 index 86e9e33..0000000 --- a/assets/icons/aws/Business-Applications/Wickr.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Wickr_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Business-Applications/WorkDocs-SDK.svg b/assets/icons/aws/Business-Applications/WorkDocs-SDK.svg deleted file mode 100644 index bd36bc6..0000000 --- a/assets/icons/aws/Business-Applications/WorkDocs-SDK.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-WorkDocs-SDK_64 - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Business-Applications/WorkDocs.svg b/assets/icons/aws/Business-Applications/WorkDocs.svg deleted file mode 100644 index 1225ea5..0000000 --- a/assets/icons/aws/Business-Applications/WorkDocs.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-WorkDocs_64 - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Business-Applications/WorkMail.svg b/assets/icons/aws/Business-Applications/WorkMail.svg deleted file mode 100644 index 3272122..0000000 --- a/assets/icons/aws/Business-Applications/WorkMail.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-WorkMail_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Cloud-Financial-Management/Application-Cost-Profiler.svg b/assets/icons/aws/Cloud-Financial-Management/Application-Cost-Profiler.svg deleted file mode 100644 index 96102f5..0000000 --- a/assets/icons/aws/Cloud-Financial-Management/Application-Cost-Profiler.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Application-Cost-Profiler_64 - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Cloud-Financial-Management/Billing-Conductor.svg b/assets/icons/aws/Cloud-Financial-Management/Billing-Conductor.svg deleted file mode 100644 index b43998c..0000000 --- a/assets/icons/aws/Cloud-Financial-Management/Billing-Conductor.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Billing-Conductor_64 - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Cloud-Financial-Management/Budgets.svg b/assets/icons/aws/Cloud-Financial-Management/Budgets.svg deleted file mode 100644 index 86ca3a0..0000000 --- a/assets/icons/aws/Cloud-Financial-Management/Budgets.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Budgets_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Cloud-Financial-Management/Cost-Explorer.svg b/assets/icons/aws/Cloud-Financial-Management/Cost-Explorer.svg deleted file mode 100644 index a4e0b8c..0000000 --- a/assets/icons/aws/Cloud-Financial-Management/Cost-Explorer.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Cost-Explorer_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Cloud-Financial-Management/Cost-and-Usage-Report.svg b/assets/icons/aws/Cloud-Financial-Management/Cost-and-Usage-Report.svg deleted file mode 100644 index f384607..0000000 --- a/assets/icons/aws/Cloud-Financial-Management/Cost-and-Usage-Report.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Cost-and-Usage-Report_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Cloud-Financial-Management/Reserved-Instance-Reporting.svg b/assets/icons/aws/Cloud-Financial-Management/Reserved-Instance-Reporting.svg deleted file mode 100644 index 39ebdbe..0000000 --- a/assets/icons/aws/Cloud-Financial-Management/Reserved-Instance-Reporting.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_Reserved-Instance-Reporting_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Cloud-Financial-Management/Savings-Plans.svg b/assets/icons/aws/Cloud-Financial-Management/Savings-Plans.svg deleted file mode 100644 index 30f8728..0000000 --- a/assets/icons/aws/Cloud-Financial-Management/Savings-Plans.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - Icon-Architecture/64/Arch_Savings-Plans_64 - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Compute/App-Runner.svg b/assets/icons/aws/Compute/App-Runner.svg deleted file mode 100644 index 2f833a1..0000000 --- a/assets/icons/aws/Compute/App-Runner.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-App-Runner_64 - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Compute/Application-Auto-Scaling.svg b/assets/icons/aws/Compute/Application-Auto-Scaling.svg deleted file mode 100644 index 13faf01..0000000 --- a/assets/icons/aws/Compute/Application-Auto-Scaling.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Application-Auto-Scaling_64 - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Compute/Batch.svg b/assets/icons/aws/Compute/Batch.svg deleted file mode 100644 index caf08e4..0000000 --- a/assets/icons/aws/Compute/Batch.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Batch_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Compute/Bottlerocket.svg b/assets/icons/aws/Compute/Bottlerocket.svg deleted file mode 100644 index 4f3052f..0000000 --- a/assets/icons/aws/Compute/Bottlerocket.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - Icon-Architecture/64/Arch_Bottlerocket_64 - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Compute/Compute-Optimizer.svg b/assets/icons/aws/Compute/Compute-Optimizer.svg deleted file mode 100644 index e1605da..0000000 --- a/assets/icons/aws/Compute/Compute-Optimizer.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/assets/icons/aws/Compute/DCV.svg b/assets/icons/aws/Compute/DCV.svg deleted file mode 100644 index bb4115a..0000000 --- a/assets/icons/aws/Compute/DCV.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - Icon-Architecture/64/Arch_NICE-DCV_64 - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Compute/EC2-Auto-Scaling.svg b/assets/icons/aws/Compute/EC2-Auto-Scaling.svg deleted file mode 100644 index 20826d9..0000000 --- a/assets/icons/aws/Compute/EC2-Auto-Scaling.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-EC2-Auto-Scaling_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Compute/EC2-Image-Builder.svg b/assets/icons/aws/Compute/EC2-Image-Builder.svg deleted file mode 100644 index 0459520..0000000 --- a/assets/icons/aws/Compute/EC2-Image-Builder.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-EC2-Image-Builder_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Compute/EC2.svg b/assets/icons/aws/Compute/EC2.svg deleted file mode 100644 index 31a57ec..0000000 --- a/assets/icons/aws/Compute/EC2.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-EC2_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Compute/Elastic-Beanstalk.svg b/assets/icons/aws/Compute/Elastic-Beanstalk.svg deleted file mode 100644 index 21686f0..0000000 --- a/assets/icons/aws/Compute/Elastic-Beanstalk.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Elastic-Beanstalk_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Compute/Elastic-Fabric-Adapter.svg b/assets/icons/aws/Compute/Elastic-Fabric-Adapter.svg deleted file mode 100644 index d467323..0000000 --- a/assets/icons/aws/Compute/Elastic-Fabric-Adapter.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - Icon-Architecture/64/Arch_Elastic-Fabric-Adapter_64 - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Compute/Elastic-VMware-Service.svg b/assets/icons/aws/Compute/Elastic-VMware-Service.svg deleted file mode 100644 index e91ed48..0000000 --- a/assets/icons/aws/Compute/Elastic-VMware-Service.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/assets/icons/aws/Compute/Fargate.svg b/assets/icons/aws/Compute/Fargate.svg deleted file mode 100644 index 3d05509..0000000 --- a/assets/icons/aws/Compute/Fargate.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Fargate_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Compute/Genomics-CLI.svg b/assets/icons/aws/Compute/Genomics-CLI.svg deleted file mode 100644 index 3b72d9e..0000000 --- a/assets/icons/aws/Compute/Genomics-CLI.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-Genomics-CLI_64 - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Compute/Lambda.svg b/assets/icons/aws/Compute/Lambda.svg deleted file mode 100644 index e4d2e7f..0000000 --- a/assets/icons/aws/Compute/Lambda.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Lambda_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Compute/Lightsail-for-Research.svg b/assets/icons/aws/Compute/Lightsail-for-Research.svg deleted file mode 100644 index 5e58745..0000000 --- a/assets/icons/aws/Compute/Lightsail-for-Research.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-Lightsail-for-Research_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Compute/Lightsail.svg b/assets/icons/aws/Compute/Lightsail.svg deleted file mode 100644 index 09d7d39..0000000 --- a/assets/icons/aws/Compute/Lightsail.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-Lightsail_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Compute/Local-Zones.svg b/assets/icons/aws/Compute/Local-Zones.svg deleted file mode 100644 index bd7aa1a..0000000 --- a/assets/icons/aws/Compute/Local-Zones.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Local-Zones_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Compute/NICE-DCV.svg b/assets/icons/aws/Compute/NICE-DCV.svg deleted file mode 100644 index 27fcca4..0000000 --- a/assets/icons/aws/Compute/NICE-DCV.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-NICE-DCV_64 - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Compute/NICE-EnginFrame.svg b/assets/icons/aws/Compute/NICE-EnginFrame.svg deleted file mode 100644 index 9c1df61..0000000 --- a/assets/icons/aws/Compute/NICE-EnginFrame.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - Icon-Architecture/64/Arch_NICE-EnginFrame_64 - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Compute/Nitro-Enclaves.svg b/assets/icons/aws/Compute/Nitro-Enclaves.svg deleted file mode 100644 index dcfa6d9..0000000 --- a/assets/icons/aws/Compute/Nitro-Enclaves.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Nitro-Enclaves_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Compute/Outposts-family.svg b/assets/icons/aws/Compute/Outposts-family.svg deleted file mode 100644 index ad0e809..0000000 --- a/assets/icons/aws/Compute/Outposts-family.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Outposts-family_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Compute/Outposts-rack.svg b/assets/icons/aws/Compute/Outposts-rack.svg deleted file mode 100644 index 700009a..0000000 --- a/assets/icons/aws/Compute/Outposts-rack.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Outposts-rack_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Compute/Outposts-servers.svg b/assets/icons/aws/Compute/Outposts-servers.svg deleted file mode 100644 index e396173..0000000 --- a/assets/icons/aws/Compute/Outposts-servers.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Outposts-servers_64 - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Compute/Parallel-Cluster.svg b/assets/icons/aws/Compute/Parallel-Cluster.svg deleted file mode 100644 index c8dfde8..0000000 --- a/assets/icons/aws/Compute/Parallel-Cluster.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Parallel-Cluster_64 - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Compute/Parallel-Computing-Service.svg b/assets/icons/aws/Compute/Parallel-Computing-Service.svg deleted file mode 100644 index 21362d9..0000000 --- a/assets/icons/aws/Compute/Parallel-Computing-Service.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - diff --git a/assets/icons/aws/Compute/ParallelCluster.svg b/assets/icons/aws/Compute/ParallelCluster.svg deleted file mode 100644 index 52566cd..0000000 --- a/assets/icons/aws/Compute/ParallelCluster.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Parallel-Cluster_64 - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Compute/Serverless-Application-Repository.svg b/assets/icons/aws/Compute/Serverless-Application-Repository.svg deleted file mode 100644 index 0777c77..0000000 --- a/assets/icons/aws/Compute/Serverless-Application-Repository.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Serverless-Application-Repository_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Compute/SimSpace-Weaver.svg b/assets/icons/aws/Compute/SimSpace-Weaver.svg deleted file mode 100644 index 38ddb7e..0000000 --- a/assets/icons/aws/Compute/SimSpace-Weaver.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-SimSpace-Weaver_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Compute/Thinkbox-Deadline.svg b/assets/icons/aws/Compute/Thinkbox-Deadline.svg deleted file mode 100644 index d7337f7..0000000 --- a/assets/icons/aws/Compute/Thinkbox-Deadline.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Thinkbox-Deadline_64 - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Compute/Thinkbox-Frost.svg b/assets/icons/aws/Compute/Thinkbox-Frost.svg deleted file mode 100644 index a6cac38..0000000 --- a/assets/icons/aws/Compute/Thinkbox-Frost.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Thinkbox-Frost_64 - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Compute/Thinkbox-Krakatoa.svg b/assets/icons/aws/Compute/Thinkbox-Krakatoa.svg deleted file mode 100644 index f0a5dae..0000000 --- a/assets/icons/aws/Compute/Thinkbox-Krakatoa.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Thinkbox-Krakatoa_64 - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Compute/Thinkbox-Sequoia.svg b/assets/icons/aws/Compute/Thinkbox-Sequoia.svg deleted file mode 100644 index df0c77e..0000000 --- a/assets/icons/aws/Compute/Thinkbox-Sequoia.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-ThinkBox-Sequoia_64 - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Compute/Thinkbox-Stoke.svg b/assets/icons/aws/Compute/Thinkbox-Stoke.svg deleted file mode 100644 index eea687d..0000000 --- a/assets/icons/aws/Compute/Thinkbox-Stoke.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Thinkbox-Stoke_64 - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Compute/Thinkbox-XMesh.svg b/assets/icons/aws/Compute/Thinkbox-XMesh.svg deleted file mode 100644 index afb9b9b..0000000 --- a/assets/icons/aws/Compute/Thinkbox-XMesh.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Thinkbox-XMesh_64 - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Compute/VMware-Cloud-on-AWS.svg b/assets/icons/aws/Compute/VMware-Cloud-on-AWS.svg deleted file mode 100644 index 89dd76c..0000000 --- a/assets/icons/aws/Compute/VMware-Cloud-on-AWS.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - - Icon-Architecture/64/Arch_VMware-Cloud-on-AWS_64 - Created with Sketch. - - - - - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Compute/Wavelength.svg b/assets/icons/aws/Compute/Wavelength.svg deleted file mode 100644 index 721e608..0000000 --- a/assets/icons/aws/Compute/Wavelength.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Wavelength_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Containers/ECS-Anywhere.svg b/assets/icons/aws/Containers/ECS-Anywhere.svg deleted file mode 100644 index 1e38864..0000000 --- a/assets/icons/aws/Containers/ECS-Anywhere.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-ECS-Anywhere_64 - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Containers/EKS-Anywhere.svg b/assets/icons/aws/Containers/EKS-Anywhere.svg deleted file mode 100644 index c2bbd41..0000000 --- a/assets/icons/aws/Containers/EKS-Anywhere.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-EKS-Anywhere_64 - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Containers/EKS-Cloud.svg b/assets/icons/aws/Containers/EKS-Cloud.svg deleted file mode 100644 index 5c4ca06..0000000 --- a/assets/icons/aws/Containers/EKS-Cloud.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-EKS-Cloud_64 - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Containers/EKS-Distro.svg b/assets/icons/aws/Containers/EKS-Distro.svg deleted file mode 100644 index 174415c..0000000 --- a/assets/icons/aws/Containers/EKS-Distro.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-EKS-Distro_64 - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Containers/Elastic-Container-Registry.svg b/assets/icons/aws/Containers/Elastic-Container-Registry.svg deleted file mode 100644 index a889ae6..0000000 --- a/assets/icons/aws/Containers/Elastic-Container-Registry.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-Elastic-Container-Registry_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Containers/Elastic-Container-Service.svg b/assets/icons/aws/Containers/Elastic-Container-Service.svg deleted file mode 100644 index b7c3191..0000000 --- a/assets/icons/aws/Containers/Elastic-Container-Service.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-Elastic-Container-Service_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Containers/Elastic-Kubernetes-Service.svg b/assets/icons/aws/Containers/Elastic-Kubernetes-Service.svg deleted file mode 100644 index 6c938c3..0000000 --- a/assets/icons/aws/Containers/Elastic-Kubernetes-Service.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-Elastic-Kubernetes-Service_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Containers/Fargate.svg b/assets/icons/aws/Containers/Fargate.svg deleted file mode 100644 index 3d05509..0000000 --- a/assets/icons/aws/Containers/Fargate.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Fargate_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Containers/Red-Hat-OpenShift-Service-on-AWS.svg b/assets/icons/aws/Containers/Red-Hat-OpenShift-Service-on-AWS.svg deleted file mode 100644 index 6ecb810..0000000 --- a/assets/icons/aws/Containers/Red-Hat-OpenShift-Service-on-AWS.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - Icon-Architecture/64/Arch_Red-Hat-OpenShift-Service-on-AWS_64 - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Customer-Enablement/Activate.svg b/assets/icons/aws/Customer-Enablement/Activate.svg deleted file mode 100644 index 0c5801e..0000000 --- a/assets/icons/aws/Customer-Enablement/Activate.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Activate_64 - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Customer-Enablement/IQ.svg b/assets/icons/aws/Customer-Enablement/IQ.svg deleted file mode 100644 index e06ee07..0000000 --- a/assets/icons/aws/Customer-Enablement/IQ.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-IQ_64 - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Customer-Enablement/Managed-Services.svg b/assets/icons/aws/Customer-Enablement/Managed-Services.svg deleted file mode 100644 index bb13bcd..0000000 --- a/assets/icons/aws/Customer-Enablement/Managed-Services.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Managed-Services_64 - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Customer-Enablement/Professional-Services.svg b/assets/icons/aws/Customer-Enablement/Professional-Services.svg deleted file mode 100644 index 256e45f..0000000 --- a/assets/icons/aws/Customer-Enablement/Professional-Services.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Professional-Services_64 - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Customer-Enablement/Support.svg b/assets/icons/aws/Customer-Enablement/Support.svg deleted file mode 100644 index b9e0d44..0000000 --- a/assets/icons/aws/Customer-Enablement/Support.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Support_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Customer-Enablement/Training-Certification.svg b/assets/icons/aws/Customer-Enablement/Training-Certification.svg deleted file mode 100644 index 0b9c9b1..0000000 --- a/assets/icons/aws/Customer-Enablement/Training-Certification.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Training-Certification_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Customer-Enablement/rePost-Private.svg b/assets/icons/aws/Customer-Enablement/rePost-Private.svg deleted file mode 100644 index 1541122..0000000 --- a/assets/icons/aws/Customer-Enablement/rePost-Private.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-rePost-Private_64 - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Customer-Enablement/rePost.svg b/assets/icons/aws/Customer-Enablement/rePost.svg deleted file mode 100644 index 32677e6..0000000 --- a/assets/icons/aws/Customer-Enablement/rePost.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-re:Post_64 - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Database/Aurora.svg b/assets/icons/aws/Database/Aurora.svg deleted file mode 100644 index 68e112e..0000000 --- a/assets/icons/aws/Database/Aurora.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-Aurora_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Database/Database-Migration-Service.svg b/assets/icons/aws/Database/Database-Migration-Service.svg deleted file mode 100644 index ca46608..0000000 --- a/assets/icons/aws/Database/Database-Migration-Service.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Database-Migration-Service_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Database/DocumentDB.svg b/assets/icons/aws/Database/DocumentDB.svg deleted file mode 100644 index b0ca744..0000000 --- a/assets/icons/aws/Database/DocumentDB.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-DocumentDB_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Database/DynamoDB.svg b/assets/icons/aws/Database/DynamoDB.svg deleted file mode 100644 index 8906ae7..0000000 --- a/assets/icons/aws/Database/DynamoDB.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-DynamoDB_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Database/ElastiCache.svg b/assets/icons/aws/Database/ElastiCache.svg deleted file mode 100644 index 2fbaf06..0000000 --- a/assets/icons/aws/Database/ElastiCache.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-ElastiCache_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Database/Keyspaces.svg b/assets/icons/aws/Database/Keyspaces.svg deleted file mode 100644 index 5e844bc..0000000 --- a/assets/icons/aws/Database/Keyspaces.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-Keyspaces_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Database/MemoryDB-for-Redis.svg b/assets/icons/aws/Database/MemoryDB-for-Redis.svg deleted file mode 100644 index eaaf36e..0000000 --- a/assets/icons/aws/Database/MemoryDB-for-Redis.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-MemoryDB-for-Redis_64 - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Database/MemoryDB.svg b/assets/icons/aws/Database/MemoryDB.svg deleted file mode 100644 index 92f297e..0000000 --- a/assets/icons/aws/Database/MemoryDB.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-MemoryDB-for-Redis_64 - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Database/Neptune.svg b/assets/icons/aws/Database/Neptune.svg deleted file mode 100644 index 31a098f..0000000 --- a/assets/icons/aws/Database/Neptune.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-Neptune_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Database/Oracle-Database-at-AWS.svg b/assets/icons/aws/Database/Oracle-Database-at-AWS.svg deleted file mode 100644 index 77eed1a..0000000 --- a/assets/icons/aws/Database/Oracle-Database-at-AWS.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/assets/icons/aws/Database/RDS-on-VMware.svg b/assets/icons/aws/Database/RDS-on-VMware.svg deleted file mode 100644 index 205cf4e..0000000 --- a/assets/icons/aws/Database/RDS-on-VMware.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - - Icon-Architecture/64/Arch_Amazon-RDS-for-VMware_64 - Created with Sketch. - - - - - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Database/RDS.svg b/assets/icons/aws/Database/RDS.svg deleted file mode 100644 index 915636e..0000000 --- a/assets/icons/aws/Database/RDS.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-RDS_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Database/Timestream.svg b/assets/icons/aws/Database/Timestream.svg deleted file mode 100644 index 74e49c7..0000000 --- a/assets/icons/aws/Database/Timestream.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-Timestream_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Developer-Tools/Application-Composer.svg b/assets/icons/aws/Developer-Tools/Application-Composer.svg deleted file mode 100644 index 30a0f72..0000000 --- a/assets/icons/aws/Developer-Tools/Application-Composer.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Application-Composer_64 - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Developer-Tools/Cloud-Control-API.svg b/assets/icons/aws/Developer-Tools/Cloud-Control-API.svg deleted file mode 100644 index eadec68..0000000 --- a/assets/icons/aws/Developer-Tools/Cloud-Control-API.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Cloud-Control-API_64 - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Developer-Tools/Cloud-Development-Kit.svg b/assets/icons/aws/Developer-Tools/Cloud-Development-Kit.svg deleted file mode 100644 index ecd7f17..0000000 --- a/assets/icons/aws/Developer-Tools/Cloud-Development-Kit.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Cloud-Development-Kit_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Developer-Tools/Cloud9.svg b/assets/icons/aws/Developer-Tools/Cloud9.svg deleted file mode 100644 index fd52686..0000000 --- a/assets/icons/aws/Developer-Tools/Cloud9.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Cloud9_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Developer-Tools/CloudShell.svg b/assets/icons/aws/Developer-Tools/CloudShell.svg deleted file mode 100644 index 2ff3d6e..0000000 --- a/assets/icons/aws/Developer-Tools/CloudShell.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-CloudShell_64 - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Developer-Tools/CodeArtifact.svg b/assets/icons/aws/Developer-Tools/CodeArtifact.svg deleted file mode 100644 index 8f37f80..0000000 --- a/assets/icons/aws/Developer-Tools/CodeArtifact.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-CodeArtifact_64 - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Developer-Tools/CodeBuild.svg b/assets/icons/aws/Developer-Tools/CodeBuild.svg deleted file mode 100644 index f4d49ba..0000000 --- a/assets/icons/aws/Developer-Tools/CodeBuild.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-CodeBuild_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Developer-Tools/CodeCatalyst.svg b/assets/icons/aws/Developer-Tools/CodeCatalyst.svg deleted file mode 100644 index 913789a..0000000 --- a/assets/icons/aws/Developer-Tools/CodeCatalyst.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-CodeCatalyst_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Developer-Tools/CodeCommit.svg b/assets/icons/aws/Developer-Tools/CodeCommit.svg deleted file mode 100644 index a28598e..0000000 --- a/assets/icons/aws/Developer-Tools/CodeCommit.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-CodeCommit_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Developer-Tools/CodeDeploy.svg b/assets/icons/aws/Developer-Tools/CodeDeploy.svg deleted file mode 100644 index cb4915e..0000000 --- a/assets/icons/aws/Developer-Tools/CodeDeploy.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-CodeDeploy_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Developer-Tools/CodePipeline.svg b/assets/icons/aws/Developer-Tools/CodePipeline.svg deleted file mode 100644 index 9c8e1a8..0000000 --- a/assets/icons/aws/Developer-Tools/CodePipeline.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-CodePipeline_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Developer-Tools/CodeStar.svg b/assets/icons/aws/Developer-Tools/CodeStar.svg deleted file mode 100644 index 3ad1385..0000000 --- a/assets/icons/aws/Developer-Tools/CodeStar.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - - Icon-Architecture/64/Arch_Amazon-Codestar_64 - Created with Sketch. - - - - - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Developer-Tools/Command-Line-Interface.svg b/assets/icons/aws/Developer-Tools/Command-Line-Interface.svg deleted file mode 100644 index 1245bf3..0000000 --- a/assets/icons/aws/Developer-Tools/Command-Line-Interface.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Command-Line-Interface_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Developer-Tools/Corretto.svg b/assets/icons/aws/Developer-Tools/Corretto.svg deleted file mode 100644 index b9fcebb..0000000 --- a/assets/icons/aws/Developer-Tools/Corretto.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-Corretto_64 - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Developer-Tools/Fault-Injection-Service.svg b/assets/icons/aws/Developer-Tools/Fault-Injection-Service.svg deleted file mode 100644 index 2dc3034..0000000 --- a/assets/icons/aws/Developer-Tools/Fault-Injection-Service.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/assets/icons/aws/Developer-Tools/Infrastructure-Composer.svg b/assets/icons/aws/Developer-Tools/Infrastructure-Composer.svg deleted file mode 100644 index ad3d01d..0000000 --- a/assets/icons/aws/Developer-Tools/Infrastructure-Composer.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Application-Composer_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Developer-Tools/Tools-and-SDKs.svg b/assets/icons/aws/Developer-Tools/Tools-and-SDKs.svg deleted file mode 100644 index b2bfda3..0000000 --- a/assets/icons/aws/Developer-Tools/Tools-and-SDKs.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Tools-and-SDKs_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Developer-Tools/X-Ray.svg b/assets/icons/aws/Developer-Tools/X-Ray.svg deleted file mode 100644 index 22d165a..0000000 --- a/assets/icons/aws/Developer-Tools/X-Ray.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-X-Ray_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/End-User-Computing/AppStream.svg b/assets/icons/aws/End-User-Computing/AppStream.svg deleted file mode 100644 index a0108ee..0000000 --- a/assets/icons/aws/End-User-Computing/AppStream.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - - Icon-Architecture/64/Arch_Amazon-App-Stream_64 - Created with Sketch. - - - - - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/End-User-Computing/WorkLink.svg b/assets/icons/aws/End-User-Computing/WorkLink.svg deleted file mode 100644 index 79678c5..0000000 --- a/assets/icons/aws/End-User-Computing/WorkLink.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - - Icon-Architecture/64/Arch_AWS-WorkLink_64 - Created with Sketch. - - - - - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/End-User-Computing/WorkSpaces-Family.svg b/assets/icons/aws/End-User-Computing/WorkSpaces-Family.svg deleted file mode 100644 index 9a17c17..0000000 --- a/assets/icons/aws/End-User-Computing/WorkSpaces-Family.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-WorkSpaces-Family_64 - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/End-User-Computing/WorkSpaces.svg b/assets/icons/aws/End-User-Computing/WorkSpaces.svg deleted file mode 100644 index 5d16b27..0000000 --- a/assets/icons/aws/End-User-Computing/WorkSpaces.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/assets/icons/aws/Front-End-Web-Mobile/Amplify.svg b/assets/icons/aws/Front-End-Web-Mobile/Amplify.svg deleted file mode 100644 index 2466048..0000000 --- a/assets/icons/aws/Front-End-Web-Mobile/Amplify.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Amplify_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Front-End-Web-Mobile/Device-Farm.svg b/assets/icons/aws/Front-End-Web-Mobile/Device-Farm.svg deleted file mode 100644 index 9ce9c36..0000000 --- a/assets/icons/aws/Front-End-Web-Mobile/Device-Farm.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Device-Farm_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Front-End-Web-Mobile/Location-Service.svg b/assets/icons/aws/Front-End-Web-Mobile/Location-Service.svg deleted file mode 100644 index e71f9c2..0000000 --- a/assets/icons/aws/Front-End-Web-Mobile/Location-Service.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-Location-Service_64 - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Games/GameKit.svg b/assets/icons/aws/Games/GameKit.svg deleted file mode 100644 index b0a2543..0000000 --- a/assets/icons/aws/Games/GameKit.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-GameKit_64 - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Games/GameLift-Servers.svg b/assets/icons/aws/Games/GameLift-Servers.svg deleted file mode 100644 index 9358e41..0000000 --- a/assets/icons/aws/Games/GameLift-Servers.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-GameLift_64 - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Games/GameLift-Streams.svg b/assets/icons/aws/Games/GameLift-Streams.svg deleted file mode 100644 index 66a72ce..0000000 --- a/assets/icons/aws/Games/GameLift-Streams.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/assets/icons/aws/Games/GameLift.svg b/assets/icons/aws/Games/GameLift.svg deleted file mode 100644 index 0e73c2f..0000000 --- a/assets/icons/aws/Games/GameLift.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-GameLift_64 - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Games/GameSparks.svg b/assets/icons/aws/Games/GameSparks.svg deleted file mode 100644 index 7f2c820..0000000 --- a/assets/icons/aws/Games/GameSparks.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-GameSparks_64 - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Games/Lumberyard.svg b/assets/icons/aws/Games/Lumberyard.svg deleted file mode 100644 index 659ea28..0000000 --- a/assets/icons/aws/Games/Lumberyard.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-Lumberyard_64 - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Games/Open-3D-Engine.svg b/assets/icons/aws/Games/Open-3D-Engine.svg deleted file mode 100644 index ec63fde..0000000 --- a/assets/icons/aws/Games/Open-3D-Engine.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - Icon-Architecture/64/Arch_Open-3D-Engine_64 - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/General-Icons/Marketplace_Dark.svg b/assets/icons/aws/General-Icons/Marketplace_Dark.svg deleted file mode 100644 index 78a48e1..0000000 --- a/assets/icons/aws/General-Icons/Marketplace_Dark.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Marketplace_Dark_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/General-Icons/Marketplace_Light.svg b/assets/icons/aws/General-Icons/Marketplace_Light.svg deleted file mode 100644 index 38cd3e9..0000000 --- a/assets/icons/aws/General-Icons/Marketplace_Light.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Marketplace_Light_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Internet-of-Things/FreeRTOS.svg b/assets/icons/aws/Internet-of-Things/FreeRTOS.svg deleted file mode 100644 index 2cdb378..0000000 --- a/assets/icons/aws/Internet-of-Things/FreeRTOS.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_FreeRTOS_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Internet-of-Things/IoT-1-Click.svg b/assets/icons/aws/Internet-of-Things/IoT-1-Click.svg deleted file mode 100644 index a285637..0000000 --- a/assets/icons/aws/Internet-of-Things/IoT-1-Click.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - - Icon-Architecture/64/Arch_AWS-IoT-1-Click_64 - Created with Sketch. - - - - - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Internet-of-Things/IoT-Analytics.svg b/assets/icons/aws/Internet-of-Things/IoT-Analytics.svg deleted file mode 100644 index eeade03..0000000 --- a/assets/icons/aws/Internet-of-Things/IoT-Analytics.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - - Icon-Architecture/64/Arch_AWS-IoT-Analytics_64 - Created with Sketch. - - - - - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Internet-of-Things/IoT-Button.svg b/assets/icons/aws/Internet-of-Things/IoT-Button.svg deleted file mode 100644 index c0f23f9..0000000 --- a/assets/icons/aws/Internet-of-Things/IoT-Button.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - - Icon-Architecture/64/Arch_AWS-IoT-Button_64 - Created with Sketch. - - - - - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Internet-of-Things/IoT-Core.svg b/assets/icons/aws/Internet-of-Things/IoT-Core.svg deleted file mode 100644 index 7cab7ad..0000000 --- a/assets/icons/aws/Internet-of-Things/IoT-Core.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-IoT-Core_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Internet-of-Things/IoT-Device-Defender.svg b/assets/icons/aws/Internet-of-Things/IoT-Device-Defender.svg deleted file mode 100644 index 289a1c6..0000000 --- a/assets/icons/aws/Internet-of-Things/IoT-Device-Defender.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-IoT-Device-Defender_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Internet-of-Things/IoT-Device-Management.svg b/assets/icons/aws/Internet-of-Things/IoT-Device-Management.svg deleted file mode 100644 index ddb0e05..0000000 --- a/assets/icons/aws/Internet-of-Things/IoT-Device-Management.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-IoT-Device-Management_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Internet-of-Things/IoT-EduKit.svg b/assets/icons/aws/Internet-of-Things/IoT-EduKit.svg deleted file mode 100644 index 2cf645b..0000000 --- a/assets/icons/aws/Internet-of-Things/IoT-EduKit.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-IoT-EduKit_64 - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Internet-of-Things/IoT-Events.svg b/assets/icons/aws/Internet-of-Things/IoT-Events.svg deleted file mode 100644 index 00ae35f..0000000 --- a/assets/icons/aws/Internet-of-Things/IoT-Events.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-IoT-Events_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Internet-of-Things/IoT-ExpressLink.svg b/assets/icons/aws/Internet-of-Things/IoT-ExpressLink.svg deleted file mode 100644 index 2ac361c..0000000 --- a/assets/icons/aws/Internet-of-Things/IoT-ExpressLink.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-IoT-ExpressLink_64 - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Internet-of-Things/IoT-FleetWise.svg b/assets/icons/aws/Internet-of-Things/IoT-FleetWise.svg deleted file mode 100644 index 022f4e2..0000000 --- a/assets/icons/aws/Internet-of-Things/IoT-FleetWise.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-IoT-FleetWise_64 - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Internet-of-Things/IoT-Greengrass.svg b/assets/icons/aws/Internet-of-Things/IoT-Greengrass.svg deleted file mode 100644 index b235c9b..0000000 --- a/assets/icons/aws/Internet-of-Things/IoT-Greengrass.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-IoT-Greengrass_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Internet-of-Things/IoT-RoboRunner.svg b/assets/icons/aws/Internet-of-Things/IoT-RoboRunner.svg deleted file mode 100644 index 4c3e76e..0000000 --- a/assets/icons/aws/Internet-of-Things/IoT-RoboRunner.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-IoT-RoboRunner_64 - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Internet-of-Things/IoT-SiteWise.svg b/assets/icons/aws/Internet-of-Things/IoT-SiteWise.svg deleted file mode 100644 index 8dd14c3..0000000 --- a/assets/icons/aws/Internet-of-Things/IoT-SiteWise.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-IoT-SiteWise_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Internet-of-Things/IoT-Things-Graph.svg b/assets/icons/aws/Internet-of-Things/IoT-Things-Graph.svg deleted file mode 100644 index 8a8452a..0000000 --- a/assets/icons/aws/Internet-of-Things/IoT-Things-Graph.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - - Icon-Architecture/64/Arch_AWS-IoT-Things-Graph_64 - Created with Sketch. - - - - - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Internet-of-Things/IoT-TwinMaker.svg b/assets/icons/aws/Internet-of-Things/IoT-TwinMaker.svg deleted file mode 100644 index b999899..0000000 --- a/assets/icons/aws/Internet-of-Things/IoT-TwinMaker.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-IoT-TwinMaker_64 - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Machine-Learning/Apache-MXNet-on-AWS.svg b/assets/icons/aws/Machine-Learning/Apache-MXNet-on-AWS.svg deleted file mode 100644 index 8118960..0000000 --- a/assets/icons/aws/Machine-Learning/Apache-MXNet-on-AWS.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_Apache-MXNet-on-AWS_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Machine-Learning/App-Studio.svg b/assets/icons/aws/Machine-Learning/App-Studio.svg deleted file mode 100644 index cd9391d..0000000 --- a/assets/icons/aws/Machine-Learning/App-Studio.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - diff --git a/assets/icons/aws/Machine-Learning/Augmented-AI-A2I.svg b/assets/icons/aws/Machine-Learning/Augmented-AI-A2I.svg deleted file mode 100644 index cf86b46..0000000 --- a/assets/icons/aws/Machine-Learning/Augmented-AI-A2I.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-Augmented-AI-A2I_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Machine-Learning/Bedrock-AgentCore.svg b/assets/icons/aws/Machine-Learning/Bedrock-AgentCore.svg deleted file mode 100644 index 0ed34ac..0000000 --- a/assets/icons/aws/Machine-Learning/Bedrock-AgentCore.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/assets/icons/aws/Machine-Learning/Bedrock.svg b/assets/icons/aws/Machine-Learning/Bedrock.svg deleted file mode 100644 index 2e5077b..0000000 --- a/assets/icons/aws/Machine-Learning/Bedrock.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-Bedrock_64 - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Machine-Learning/CodeGuru.svg b/assets/icons/aws/Machine-Learning/CodeGuru.svg deleted file mode 100644 index c59d19a..0000000 --- a/assets/icons/aws/Machine-Learning/CodeGuru.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-CodeGuru_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Machine-Learning/CodeWhisperer.svg b/assets/icons/aws/Machine-Learning/CodeWhisperer.svg deleted file mode 100644 index a0a3119..0000000 --- a/assets/icons/aws/Machine-Learning/CodeWhisperer.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-CodeWhisperer_64 - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Machine-Learning/Comprehend-Medical.svg b/assets/icons/aws/Machine-Learning/Comprehend-Medical.svg deleted file mode 100644 index 1e1be93..0000000 --- a/assets/icons/aws/Machine-Learning/Comprehend-Medical.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-Comprehend-Medical_64 - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Machine-Learning/Comprehend.svg b/assets/icons/aws/Machine-Learning/Comprehend.svg deleted file mode 100644 index 3061707..0000000 --- a/assets/icons/aws/Machine-Learning/Comprehend.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-Comprehend_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Machine-Learning/Deep-Learning-AMIs.svg b/assets/icons/aws/Machine-Learning/Deep-Learning-AMIs.svg deleted file mode 100644 index 0874dab..0000000 --- a/assets/icons/aws/Machine-Learning/Deep-Learning-AMIs.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Deep-Learning-AMIs_64 - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Machine-Learning/Deep-Learning-Containers.svg b/assets/icons/aws/Machine-Learning/Deep-Learning-Containers.svg deleted file mode 100644 index 1539007..0000000 --- a/assets/icons/aws/Machine-Learning/Deep-Learning-Containers.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Deep-Learning-Containers_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Machine-Learning/DeepComposer.svg b/assets/icons/aws/Machine-Learning/DeepComposer.svg deleted file mode 100644 index cf5e997..0000000 --- a/assets/icons/aws/Machine-Learning/DeepComposer.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - - Icon-Architecture/64/Arch_AWS-Deep-Composer_64 - Created with Sketch. - - - - - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Machine-Learning/DeepLens.svg b/assets/icons/aws/Machine-Learning/DeepLens.svg deleted file mode 100644 index e349eaf..0000000 --- a/assets/icons/aws/Machine-Learning/DeepLens.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - - Icon-Architecture/64/Arch_AWS-Deep-Lense_64 - Created with Sketch. - - - - - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Machine-Learning/DeepRacer.svg b/assets/icons/aws/Machine-Learning/DeepRacer.svg deleted file mode 100644 index f3ff2f4..0000000 --- a/assets/icons/aws/Machine-Learning/DeepRacer.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-DeepRacer_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Machine-Learning/DevOps-Guru.svg b/assets/icons/aws/Machine-Learning/DevOps-Guru.svg deleted file mode 100644 index d81104b..0000000 --- a/assets/icons/aws/Machine-Learning/DevOps-Guru.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-DevOps-Guru_64 - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Machine-Learning/Elastic-Inference.svg b/assets/icons/aws/Machine-Learning/Elastic-Inference.svg deleted file mode 100644 index 592c50a..0000000 --- a/assets/icons/aws/Machine-Learning/Elastic-Inference.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-Elastic-Inference_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Machine-Learning/Elemental-Inference.svg b/assets/icons/aws/Machine-Learning/Elemental-Inference.svg deleted file mode 100644 index 895bba0..0000000 --- a/assets/icons/aws/Machine-Learning/Elemental-Inference.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/assets/icons/aws/Machine-Learning/Forecast.svg b/assets/icons/aws/Machine-Learning/Forecast.svg deleted file mode 100644 index e7c3857..0000000 --- a/assets/icons/aws/Machine-Learning/Forecast.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-Forecast_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Machine-Learning/Fraud-Detector.svg b/assets/icons/aws/Machine-Learning/Fraud-Detector.svg deleted file mode 100644 index 1060db0..0000000 --- a/assets/icons/aws/Machine-Learning/Fraud-Detector.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-Fraud-Detector_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Machine-Learning/HealthImaging.svg b/assets/icons/aws/Machine-Learning/HealthImaging.svg deleted file mode 100644 index c43d8a2..0000000 --- a/assets/icons/aws/Machine-Learning/HealthImaging.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-HealthImaging_64 - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Machine-Learning/HealthLake.svg b/assets/icons/aws/Machine-Learning/HealthLake.svg deleted file mode 100644 index 7f16fff..0000000 --- a/assets/icons/aws/Machine-Learning/HealthLake.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-HealthLake_64 - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Machine-Learning/HealthOmics.svg b/assets/icons/aws/Machine-Learning/HealthOmics.svg deleted file mode 100644 index 7d55406..0000000 --- a/assets/icons/aws/Machine-Learning/HealthOmics.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-HealthOmics_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Machine-Learning/HealthScribe.svg b/assets/icons/aws/Machine-Learning/HealthScribe.svg deleted file mode 100644 index 09c4781..0000000 --- a/assets/icons/aws/Machine-Learning/HealthScribe.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-HealthScribe_64 - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Machine-Learning/Kendra.svg b/assets/icons/aws/Machine-Learning/Kendra.svg deleted file mode 100644 index 7e2254d..0000000 --- a/assets/icons/aws/Machine-Learning/Kendra.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-Kendra_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Machine-Learning/Lex.svg b/assets/icons/aws/Machine-Learning/Lex.svg deleted file mode 100644 index c1ae9e1..0000000 --- a/assets/icons/aws/Machine-Learning/Lex.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-Lex_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Machine-Learning/Lookout-for-Equipment.svg b/assets/icons/aws/Machine-Learning/Lookout-for-Equipment.svg deleted file mode 100644 index ce88401..0000000 --- a/assets/icons/aws/Machine-Learning/Lookout-for-Equipment.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-Lookout-for-Equipment_64 - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Machine-Learning/Lookout-for-Metrics.svg b/assets/icons/aws/Machine-Learning/Lookout-for-Metrics.svg deleted file mode 100644 index 1315f65..0000000 --- a/assets/icons/aws/Machine-Learning/Lookout-for-Metrics.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-Poirot_64 - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Machine-Learning/Lookout-for-Vision.svg b/assets/icons/aws/Machine-Learning/Lookout-for-Vision.svg deleted file mode 100644 index 7cb9cf6..0000000 --- a/assets/icons/aws/Machine-Learning/Lookout-for-Vision.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-Lookout-for-Vision_64 - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Machine-Learning/Monitron.svg b/assets/icons/aws/Machine-Learning/Monitron.svg deleted file mode 100644 index 6cdc08a..0000000 --- a/assets/icons/aws/Machine-Learning/Monitron.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-Monitron_64 - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Machine-Learning/Neuron.svg b/assets/icons/aws/Machine-Learning/Neuron.svg deleted file mode 100644 index c4d8463..0000000 --- a/assets/icons/aws/Machine-Learning/Neuron.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Neuron_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Machine-Learning/Nova.svg b/assets/icons/aws/Machine-Learning/Nova.svg deleted file mode 100644 index d4a07bc..0000000 --- a/assets/icons/aws/Machine-Learning/Nova.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/assets/icons/aws/Machine-Learning/Omics.svg b/assets/icons/aws/Machine-Learning/Omics.svg deleted file mode 100644 index 134c4fa..0000000 --- a/assets/icons/aws/Machine-Learning/Omics.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-Omics_64 - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Machine-Learning/Panorama.svg b/assets/icons/aws/Machine-Learning/Panorama.svg deleted file mode 100644 index ba97e1b..0000000 --- a/assets/icons/aws/Machine-Learning/Panorama.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Panorama_64 - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Machine-Learning/Personalize.svg b/assets/icons/aws/Machine-Learning/Personalize.svg deleted file mode 100644 index 651415f..0000000 --- a/assets/icons/aws/Machine-Learning/Personalize.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-Personalize_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Machine-Learning/Polly.svg b/assets/icons/aws/Machine-Learning/Polly.svg deleted file mode 100644 index 6468e9d..0000000 --- a/assets/icons/aws/Machine-Learning/Polly.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-Polly_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Machine-Learning/PyTorch-on-AWS.svg b/assets/icons/aws/Machine-Learning/PyTorch-on-AWS.svg deleted file mode 100644 index 12649d7..0000000 --- a/assets/icons/aws/Machine-Learning/PyTorch-on-AWS.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - Icon-Architecture/64/Arch_TorchServe_64 - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Machine-Learning/Q.svg b/assets/icons/aws/Machine-Learning/Q.svg deleted file mode 100644 index 927d49c..0000000 --- a/assets/icons/aws/Machine-Learning/Q.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-Q_64 - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Machine-Learning/Rekognition.svg b/assets/icons/aws/Machine-Learning/Rekognition.svg deleted file mode 100644 index 5097f66..0000000 --- a/assets/icons/aws/Machine-Learning/Rekognition.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-Rekognition_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Machine-Learning/SageMaker-AI.svg b/assets/icons/aws/Machine-Learning/SageMaker-AI.svg deleted file mode 100644 index 9589ab0..0000000 --- a/assets/icons/aws/Machine-Learning/SageMaker-AI.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-SageMaker_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Machine-Learning/SageMaker-Ground-Truth.svg b/assets/icons/aws/Machine-Learning/SageMaker-Ground-Truth.svg deleted file mode 100644 index 7cfdbf9..0000000 --- a/assets/icons/aws/Machine-Learning/SageMaker-Ground-Truth.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-SageMaker-Ground-Truth_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Machine-Learning/SageMaker-Studio-Lab.svg b/assets/icons/aws/Machine-Learning/SageMaker-Studio-Lab.svg deleted file mode 100644 index d8db378..0000000 --- a/assets/icons/aws/Machine-Learning/SageMaker-Studio-Lab.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-SageMaker-Studio-Lab_64 - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Machine-Learning/SageMaker.svg b/assets/icons/aws/Machine-Learning/SageMaker.svg deleted file mode 100644 index 51233ec..0000000 --- a/assets/icons/aws/Machine-Learning/SageMaker.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/assets/icons/aws/Machine-Learning/TensorFlow-on-AWS.svg b/assets/icons/aws/Machine-Learning/TensorFlow-on-AWS.svg deleted file mode 100644 index 50ed17f..0000000 --- a/assets/icons/aws/Machine-Learning/TensorFlow-on-AWS.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_TensorFlow-on-AWS_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Machine-Learning/Textract.svg b/assets/icons/aws/Machine-Learning/Textract.svg deleted file mode 100644 index a4264b0..0000000 --- a/assets/icons/aws/Machine-Learning/Textract.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-Textract_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Machine-Learning/TorchServe.svg b/assets/icons/aws/Machine-Learning/TorchServe.svg deleted file mode 100644 index 43d1ae5..0000000 --- a/assets/icons/aws/Machine-Learning/TorchServe.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - Icon-Architecture/64/Arch_TorchServe_64 - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Machine-Learning/Transcribe.svg b/assets/icons/aws/Machine-Learning/Transcribe.svg deleted file mode 100644 index a714203..0000000 --- a/assets/icons/aws/Machine-Learning/Transcribe.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-Transcribe_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Machine-Learning/Translate.svg b/assets/icons/aws/Machine-Learning/Translate.svg deleted file mode 100644 index f4deae9..0000000 --- a/assets/icons/aws/Machine-Learning/Translate.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-Translate_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Management-Governance/AppConfig.svg b/assets/icons/aws/Management-Governance/AppConfig.svg deleted file mode 100644 index 354930b..0000000 --- a/assets/icons/aws/Management-Governance/AppConfig.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-AppConfig_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Management-Governance/Application-Auto-Scaling.svg b/assets/icons/aws/Management-Governance/Application-Auto-Scaling.svg deleted file mode 100644 index 13faf01..0000000 --- a/assets/icons/aws/Management-Governance/Application-Auto-Scaling.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Application-Auto-Scaling_64 - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Management-Governance/Auto-Scaling.svg b/assets/icons/aws/Management-Governance/Auto-Scaling.svg deleted file mode 100644 index 04b88e0..0000000 --- a/assets/icons/aws/Management-Governance/Auto-Scaling.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Auto-Scaling_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Management-Governance/Backint-Agent.svg b/assets/icons/aws/Management-Governance/Backint-Agent.svg deleted file mode 100644 index 999d119..0000000 --- a/assets/icons/aws/Management-Governance/Backint-Agent.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Backint-Agent_64 - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Management-Governance/Chatbot.svg b/assets/icons/aws/Management-Governance/Chatbot.svg deleted file mode 100644 index 4d838f1..0000000 --- a/assets/icons/aws/Management-Governance/Chatbot.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Chatbot_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Management-Governance/CloudFormation.svg b/assets/icons/aws/Management-Governance/CloudFormation.svg deleted file mode 100644 index bbc2572..0000000 --- a/assets/icons/aws/Management-Governance/CloudFormation.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-CloudFormation_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Management-Governance/CloudTrail.svg b/assets/icons/aws/Management-Governance/CloudTrail.svg deleted file mode 100644 index 8d292ca..0000000 --- a/assets/icons/aws/Management-Governance/CloudTrail.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-CloudTrail_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Management-Governance/CloudWatch.svg b/assets/icons/aws/Management-Governance/CloudWatch.svg deleted file mode 100644 index 97a7e5f..0000000 --- a/assets/icons/aws/Management-Governance/CloudWatch.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-CloudWatch_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Management-Governance/Config.svg b/assets/icons/aws/Management-Governance/Config.svg deleted file mode 100644 index 8887c3f..0000000 --- a/assets/icons/aws/Management-Governance/Config.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Config_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Management-Governance/Control-Tower.svg b/assets/icons/aws/Management-Governance/Control-Tower.svg deleted file mode 100644 index 84f7d42..0000000 --- a/assets/icons/aws/Management-Governance/Control-Tower.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Control-Tower_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Management-Governance/DevOps-Agent.svg b/assets/icons/aws/Management-Governance/DevOps-Agent.svg deleted file mode 100644 index ce8f3bc..0000000 --- a/assets/icons/aws/Management-Governance/DevOps-Agent.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/assets/icons/aws/Management-Governance/Distro-for-OpenTelemetry.svg b/assets/icons/aws/Management-Governance/Distro-for-OpenTelemetry.svg deleted file mode 100644 index 09e8875..0000000 --- a/assets/icons/aws/Management-Governance/Distro-for-OpenTelemetry.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Distro-for-OpenTelemetry_64 - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Management-Governance/Fault-Injection-Simulator.svg b/assets/icons/aws/Management-Governance/Fault-Injection-Simulator.svg deleted file mode 100644 index 47532d9..0000000 --- a/assets/icons/aws/Management-Governance/Fault-Injection-Simulator.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Valkyrie_64 - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Management-Governance/Health-Dashboard.svg b/assets/icons/aws/Management-Governance/Health-Dashboard.svg deleted file mode 100644 index a587da3..0000000 --- a/assets/icons/aws/Management-Governance/Health-Dashboard.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Health-Dashboard_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Management-Governance/Launch-Wizard.svg b/assets/icons/aws/Management-Governance/Launch-Wizard.svg deleted file mode 100644 index 2fe3153..0000000 --- a/assets/icons/aws/Management-Governance/Launch-Wizard.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Launch-Wizard_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Management-Governance/License-Manager.svg b/assets/icons/aws/Management-Governance/License-Manager.svg deleted file mode 100644 index ae9b5d8..0000000 --- a/assets/icons/aws/Management-Governance/License-Manager.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-License-Manager_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Management-Governance/Managed-Grafana.svg b/assets/icons/aws/Management-Governance/Managed-Grafana.svg deleted file mode 100644 index 60dc225..0000000 --- a/assets/icons/aws/Management-Governance/Managed-Grafana.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-Managed-Grafana_64 - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Management-Governance/Managed-Service-for-Prometheus.svg b/assets/icons/aws/Management-Governance/Managed-Service-for-Prometheus.svg deleted file mode 100644 index 1c50052..0000000 --- a/assets/icons/aws/Management-Governance/Managed-Service-for-Prometheus.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-Managed-Service-for-Prometheus_64 - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Management-Governance/Management-Console.svg b/assets/icons/aws/Management-Governance/Management-Console.svg deleted file mode 100644 index 603a148..0000000 --- a/assets/icons/aws/Management-Governance/Management-Console.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Management-Console_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Management-Governance/OpsWorks.svg b/assets/icons/aws/Management-Governance/OpsWorks.svg deleted file mode 100644 index 03afbd9..0000000 --- a/assets/icons/aws/Management-Governance/OpsWorks.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - - Icon-Architecture/64/Arch_AWS-OpsWorks_64 - Created with Sketch. - - - - - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Management-Governance/Organizations.svg b/assets/icons/aws/Management-Governance/Organizations.svg deleted file mode 100644 index 40e2fec..0000000 --- a/assets/icons/aws/Management-Governance/Organizations.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Organizations_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Management-Governance/Partner-Central.svg b/assets/icons/aws/Management-Governance/Partner-Central.svg deleted file mode 100644 index c8e619b..0000000 --- a/assets/icons/aws/Management-Governance/Partner-Central.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/assets/icons/aws/Management-Governance/Personal-Health-Dashboard.svg b/assets/icons/aws/Management-Governance/Personal-Health-Dashboard.svg deleted file mode 100644 index 354efc0..0000000 --- a/assets/icons/aws/Management-Governance/Personal-Health-Dashboard.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - - Icon-Architecture/64/Arch_AWS-Personal-Health-Dashboard_64 - Created with Sketch. - - - - - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Management-Governance/Proton.svg b/assets/icons/aws/Management-Governance/Proton.svg deleted file mode 100644 index c8cfa9f..0000000 --- a/assets/icons/aws/Management-Governance/Proton.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Proton_64 - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Management-Governance/Resilience-Hub.svg b/assets/icons/aws/Management-Governance/Resilience-Hub.svg deleted file mode 100644 index 9d810aa..0000000 --- a/assets/icons/aws/Management-Governance/Resilience-Hub.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Resilience-Hub_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Management-Governance/Resource-Explorer.svg b/assets/icons/aws/Management-Governance/Resource-Explorer.svg deleted file mode 100644 index d7a5cf5..0000000 --- a/assets/icons/aws/Management-Governance/Resource-Explorer.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Resource-Explorer_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Management-Governance/Service-Catalog.svg b/assets/icons/aws/Management-Governance/Service-Catalog.svg deleted file mode 100644 index e556405..0000000 --- a/assets/icons/aws/Management-Governance/Service-Catalog.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Service-Catalog_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Management-Governance/Service-Management-Connector.svg b/assets/icons/aws/Management-Governance/Service-Management-Connector.svg deleted file mode 100644 index 9d08d22..0000000 --- a/assets/icons/aws/Management-Governance/Service-Management-Connector.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Service-Management-Connector_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Management-Governance/Sustainability.svg b/assets/icons/aws/Management-Governance/Sustainability.svg deleted file mode 100644 index fde884b..0000000 --- a/assets/icons/aws/Management-Governance/Sustainability.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/assets/icons/aws/Management-Governance/Systems-Manager.svg b/assets/icons/aws/Management-Governance/Systems-Manager.svg deleted file mode 100644 index 52796bb..0000000 --- a/assets/icons/aws/Management-Governance/Systems-Manager.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Systems-Manager_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Management-Governance/Telco-Network-Builder.svg b/assets/icons/aws/Management-Governance/Telco-Network-Builder.svg deleted file mode 100644 index 02de6a5..0000000 --- a/assets/icons/aws/Management-Governance/Telco-Network-Builder.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Telco-Network-Builder_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Management-Governance/Trusted-Advisor.svg b/assets/icons/aws/Management-Governance/Trusted-Advisor.svg deleted file mode 100644 index 91c68d5..0000000 --- a/assets/icons/aws/Management-Governance/Trusted-Advisor.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Trusted-Advisor_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Management-Governance/User-Notifications.svg b/assets/icons/aws/Management-Governance/User-Notifications.svg deleted file mode 100644 index 67efe71..0000000 --- a/assets/icons/aws/Management-Governance/User-Notifications.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/assets/icons/aws/Management-Governance/Well-Architected-Tool.svg b/assets/icons/aws/Management-Governance/Well-Architected-Tool.svg deleted file mode 100644 index 64b2ce9..0000000 --- a/assets/icons/aws/Management-Governance/Well-Architected-Tool.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Well-Architected-Tool_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Media-Services/Deadline-Cloud.svg b/assets/icons/aws/Media-Services/Deadline-Cloud.svg deleted file mode 100644 index cd18329..0000000 --- a/assets/icons/aws/Media-Services/Deadline-Cloud.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-Deadline-Cloud_64 - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Media-Services/Elastic-Transcoder.svg b/assets/icons/aws/Media-Services/Elastic-Transcoder.svg deleted file mode 100644 index d5a44cf..0000000 --- a/assets/icons/aws/Media-Services/Elastic-Transcoder.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - - Icon-Architecture/64/Arch_Amazon-Elastic-Transcoder_64 - Created with Sketch. - - - - - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Media-Services/Elemental-Appliances-&-Software.svg b/assets/icons/aws/Media-Services/Elemental-Appliances-&-Software.svg deleted file mode 100644 index ad48e20..0000000 --- a/assets/icons/aws/Media-Services/Elemental-Appliances-&-Software.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Elemental-Appliances-&-Software_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Media-Services/Elemental-Conductor.svg b/assets/icons/aws/Media-Services/Elemental-Conductor.svg deleted file mode 100644 index fd15cb6..0000000 --- a/assets/icons/aws/Media-Services/Elemental-Conductor.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Elemental-Conductor_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Media-Services/Elemental-Delta.svg b/assets/icons/aws/Media-Services/Elemental-Delta.svg deleted file mode 100644 index 1420955..0000000 --- a/assets/icons/aws/Media-Services/Elemental-Delta.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Elemental-Delta_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Media-Services/Elemental-Link.svg b/assets/icons/aws/Media-Services/Elemental-Link.svg deleted file mode 100644 index be1f09d..0000000 --- a/assets/icons/aws/Media-Services/Elemental-Link.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Elemental-Link_64 - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Media-Services/Elemental-Live.svg b/assets/icons/aws/Media-Services/Elemental-Live.svg deleted file mode 100644 index 69bf89e..0000000 --- a/assets/icons/aws/Media-Services/Elemental-Live.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Elemental-Live_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Media-Services/Elemental-MediaConnect.svg b/assets/icons/aws/Media-Services/Elemental-MediaConnect.svg deleted file mode 100644 index 4b25bf7..0000000 --- a/assets/icons/aws/Media-Services/Elemental-MediaConnect.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Elemental-MediaConnect_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Media-Services/Elemental-MediaConvert.svg b/assets/icons/aws/Media-Services/Elemental-MediaConvert.svg deleted file mode 100644 index f18da7e..0000000 --- a/assets/icons/aws/Media-Services/Elemental-MediaConvert.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Elemental-MediaConvert_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Media-Services/Elemental-MediaLive.svg b/assets/icons/aws/Media-Services/Elemental-MediaLive.svg deleted file mode 100644 index 7fa7fe5..0000000 --- a/assets/icons/aws/Media-Services/Elemental-MediaLive.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Elemental-MediaLive_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Media-Services/Elemental-MediaPackage.svg b/assets/icons/aws/Media-Services/Elemental-MediaPackage.svg deleted file mode 100644 index 702e6f1..0000000 --- a/assets/icons/aws/Media-Services/Elemental-MediaPackage.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Elemental-MediaPackage_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Media-Services/Elemental-MediaStore.svg b/assets/icons/aws/Media-Services/Elemental-MediaStore.svg deleted file mode 100644 index 1d9e4a8..0000000 --- a/assets/icons/aws/Media-Services/Elemental-MediaStore.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Elemental-MediaStore_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Media-Services/Elemental-MediaTailor.svg b/assets/icons/aws/Media-Services/Elemental-MediaTailor.svg deleted file mode 100644 index 98c564d..0000000 --- a/assets/icons/aws/Media-Services/Elemental-MediaTailor.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Elemental-MediaTailor_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Media-Services/Elemental-Server.svg b/assets/icons/aws/Media-Services/Elemental-Server.svg deleted file mode 100644 index 653bb2a..0000000 --- a/assets/icons/aws/Media-Services/Elemental-Server.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Elemental-Server_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Media-Services/Interactive-Video-Service.svg b/assets/icons/aws/Media-Services/Interactive-Video-Service.svg deleted file mode 100644 index 8f19562..0000000 --- a/assets/icons/aws/Media-Services/Interactive-Video-Service.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-Interactive-Video-Service_64 - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Media-Services/Kinesis-Video-Streams.svg b/assets/icons/aws/Media-Services/Kinesis-Video-Streams.svg deleted file mode 100644 index 919d7af..0000000 --- a/assets/icons/aws/Media-Services/Kinesis-Video-Streams.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-Kinesis-Video-Streams_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Media-Services/Nimble-Studio.svg b/assets/icons/aws/Media-Services/Nimble-Studio.svg deleted file mode 100644 index 4649ac2..0000000 --- a/assets/icons/aws/Media-Services/Nimble-Studio.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-Nimble-Studio_64 - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Migration-Transfer/Application-Discovery-Service.svg b/assets/icons/aws/Migration-Transfer/Application-Discovery-Service.svg deleted file mode 100644 index 7edac60..0000000 --- a/assets/icons/aws/Migration-Transfer/Application-Discovery-Service.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Application-Discovery-Service_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Migration-Transfer/Application-Migration-Service.svg b/assets/icons/aws/Migration-Transfer/Application-Migration-Service.svg deleted file mode 100644 index 3cc5694..0000000 --- a/assets/icons/aws/Migration-Transfer/Application-Migration-Service.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Application-Migration-Service_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Migration-Transfer/Data-Transfer-Terminal.svg b/assets/icons/aws/Migration-Transfer/Data-Transfer-Terminal.svg deleted file mode 100644 index 6dc2323..0000000 --- a/assets/icons/aws/Migration-Transfer/Data-Transfer-Terminal.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/assets/icons/aws/Migration-Transfer/DataSync.svg b/assets/icons/aws/Migration-Transfer/DataSync.svg deleted file mode 100644 index 5fefa79..0000000 --- a/assets/icons/aws/Migration-Transfer/DataSync.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-DataSync_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Migration-Transfer/Mainframe-Modernization.svg b/assets/icons/aws/Migration-Transfer/Mainframe-Modernization.svg deleted file mode 100644 index b2e458f..0000000 --- a/assets/icons/aws/Migration-Transfer/Mainframe-Modernization.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Mainframe-Modernization_64 - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Migration-Transfer/Migration-Evaluator.svg b/assets/icons/aws/Migration-Transfer/Migration-Evaluator.svg deleted file mode 100644 index ce0bc1b..0000000 --- a/assets/icons/aws/Migration-Transfer/Migration-Evaluator.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Migration-Evaluator_64 - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Migration-Transfer/Migration-Hub.svg b/assets/icons/aws/Migration-Transfer/Migration-Hub.svg deleted file mode 100644 index 750f2b3..0000000 --- a/assets/icons/aws/Migration-Transfer/Migration-Hub.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Migration-Hub_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Migration-Transfer/Server-Migration-Service.svg b/assets/icons/aws/Migration-Transfer/Server-Migration-Service.svg deleted file mode 100644 index 97a5dc4..0000000 --- a/assets/icons/aws/Migration-Transfer/Server-Migration-Service.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - - Icon-Architecture/64/Arch_AWS-Server-Migration-Service_64 - Created with Sketch. - - - - - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Migration-Transfer/Transfer-Family.svg b/assets/icons/aws/Migration-Transfer/Transfer-Family.svg deleted file mode 100644 index 2a11155..0000000 --- a/assets/icons/aws/Migration-Transfer/Transfer-Family.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Transfer-Family_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Migration-Transfer/Transform.svg b/assets/icons/aws/Migration-Transfer/Transform.svg deleted file mode 100644 index 0e69cf1..0000000 --- a/assets/icons/aws/Migration-Transfer/Transform.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/assets/icons/aws/Networking-Content-Delivery/App-Mesh.svg b/assets/icons/aws/Networking-Content-Delivery/App-Mesh.svg deleted file mode 100644 index 9d4c5a1..0000000 --- a/assets/icons/aws/Networking-Content-Delivery/App-Mesh.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-App-Mesh_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Networking-Content-Delivery/Application-Recovery-Controller.svg b/assets/icons/aws/Networking-Content-Delivery/Application-Recovery-Controller.svg deleted file mode 100644 index 0aba2b0..0000000 --- a/assets/icons/aws/Networking-Content-Delivery/Application-Recovery-Controller.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/assets/icons/aws/Networking-Content-Delivery/Client-VPN.svg b/assets/icons/aws/Networking-Content-Delivery/Client-VPN.svg deleted file mode 100644 index 7a45a4b..0000000 --- a/assets/icons/aws/Networking-Content-Delivery/Client-VPN.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Client-VPN_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Networking-Content-Delivery/Cloud-Directory.svg b/assets/icons/aws/Networking-Content-Delivery/Cloud-Directory.svg deleted file mode 100644 index e1b6858..0000000 --- a/assets/icons/aws/Networking-Content-Delivery/Cloud-Directory.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-Cloud-Directory_64 - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Networking-Content-Delivery/Cloud-Map.svg b/assets/icons/aws/Networking-Content-Delivery/Cloud-Map.svg deleted file mode 100644 index 23ca26e..0000000 --- a/assets/icons/aws/Networking-Content-Delivery/Cloud-Map.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Cloud-Map_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Networking-Content-Delivery/Cloud-WAN.svg b/assets/icons/aws/Networking-Content-Delivery/Cloud-WAN.svg deleted file mode 100644 index a87b555..0000000 --- a/assets/icons/aws/Networking-Content-Delivery/Cloud-WAN.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Cloud-WAN_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Networking-Content-Delivery/CloudFront.svg b/assets/icons/aws/Networking-Content-Delivery/CloudFront.svg deleted file mode 100644 index 96b38fe..0000000 --- a/assets/icons/aws/Networking-Content-Delivery/CloudFront.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-CloudFront_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Networking-Content-Delivery/Direct-Connect.svg b/assets/icons/aws/Networking-Content-Delivery/Direct-Connect.svg deleted file mode 100644 index 9385d66..0000000 --- a/assets/icons/aws/Networking-Content-Delivery/Direct-Connect.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Direct-Connect_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Networking-Content-Delivery/Elastic-Load-Balancing.svg b/assets/icons/aws/Networking-Content-Delivery/Elastic-Load-Balancing.svg deleted file mode 100644 index 76c495b..0000000 --- a/assets/icons/aws/Networking-Content-Delivery/Elastic-Load-Balancing.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_Elastic-Load-Balancing_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Networking-Content-Delivery/Global-Accelerator.svg b/assets/icons/aws/Networking-Content-Delivery/Global-Accelerator.svg deleted file mode 100644 index 45c17f6..0000000 --- a/assets/icons/aws/Networking-Content-Delivery/Global-Accelerator.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Global-Accelerator_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Networking-Content-Delivery/Interconnect.svg b/assets/icons/aws/Networking-Content-Delivery/Interconnect.svg deleted file mode 100644 index 3d43e3a..0000000 --- a/assets/icons/aws/Networking-Content-Delivery/Interconnect.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/assets/icons/aws/Networking-Content-Delivery/Private-5G.svg b/assets/icons/aws/Networking-Content-Delivery/Private-5G.svg deleted file mode 100644 index 22565ed..0000000 --- a/assets/icons/aws/Networking-Content-Delivery/Private-5G.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Private-5G_64 - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Networking-Content-Delivery/PrivateLink.svg b/assets/icons/aws/Networking-Content-Delivery/PrivateLink.svg deleted file mode 100644 index f4b3287..0000000 --- a/assets/icons/aws/Networking-Content-Delivery/PrivateLink.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-PrivateLink_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Networking-Content-Delivery/RTB-Fabric.svg b/assets/icons/aws/Networking-Content-Delivery/RTB-Fabric.svg deleted file mode 100644 index f185552..0000000 --- a/assets/icons/aws/Networking-Content-Delivery/RTB-Fabric.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/assets/icons/aws/Networking-Content-Delivery/Route-53.svg b/assets/icons/aws/Networking-Content-Delivery/Route-53.svg deleted file mode 100644 index 6150693..0000000 --- a/assets/icons/aws/Networking-Content-Delivery/Route-53.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-Route-53_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Networking-Content-Delivery/Site-to-Site-VPN.svg b/assets/icons/aws/Networking-Content-Delivery/Site-to-Site-VPN.svg deleted file mode 100644 index 5d623d2..0000000 --- a/assets/icons/aws/Networking-Content-Delivery/Site-to-Site-VPN.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Site-to-Site-VPN_64 - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Networking-Content-Delivery/Transit-Gateway.svg b/assets/icons/aws/Networking-Content-Delivery/Transit-Gateway.svg deleted file mode 100644 index 001f59c..0000000 --- a/assets/icons/aws/Networking-Content-Delivery/Transit-Gateway.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Transit-Gateway_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Networking-Content-Delivery/VPC-Lattice.svg b/assets/icons/aws/Networking-Content-Delivery/VPC-Lattice.svg deleted file mode 100644 index 8e3e9c9..0000000 --- a/assets/icons/aws/Networking-Content-Delivery/VPC-Lattice.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-VPC-Lattice_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Networking-Content-Delivery/Verified-Access.svg b/assets/icons/aws/Networking-Content-Delivery/Verified-Access.svg deleted file mode 100644 index 2d04ca8..0000000 --- a/assets/icons/aws/Networking-Content-Delivery/Verified-Access.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Verified-Access_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Networking-Content-Delivery/Virtual-Private-Cloud.svg b/assets/icons/aws/Networking-Content-Delivery/Virtual-Private-Cloud.svg deleted file mode 100644 index 9aef6ed..0000000 --- a/assets/icons/aws/Networking-Content-Delivery/Virtual-Private-Cloud.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-Virtual-Private-Cloud_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Quantum-Technologies/Braket.svg b/assets/icons/aws/Quantum-Technologies/Braket.svg deleted file mode 100644 index e9d0a5b..0000000 --- a/assets/icons/aws/Quantum-Technologies/Braket.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-Braket_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Robotics/RoboMaker.svg b/assets/icons/aws/Robotics/RoboMaker.svg deleted file mode 100644 index e4e8533..0000000 --- a/assets/icons/aws/Robotics/RoboMaker.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - - Icon-Architecture/64/Arch_AWS-RoboMaker_64 - Created with Sketch. - - - - - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Satellite/Ground-Station.svg b/assets/icons/aws/Satellite/Ground-Station.svg deleted file mode 100644 index 588bf79..0000000 --- a/assets/icons/aws/Satellite/Ground-Station.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Ground-Station_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Security-Identity-Compliance/Artifact.svg b/assets/icons/aws/Security-Identity-Compliance/Artifact.svg deleted file mode 100644 index 4edeed3..0000000 --- a/assets/icons/aws/Security-Identity-Compliance/Artifact.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Artifact_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Security-Identity-Compliance/Audit-Manager.svg b/assets/icons/aws/Security-Identity-Compliance/Audit-Manager.svg deleted file mode 100644 index 1d1f9b4..0000000 --- a/assets/icons/aws/Security-Identity-Compliance/Audit-Manager.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Audit-Manager_64 - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Security-Identity-Compliance/Certificate-Manager.svg b/assets/icons/aws/Security-Identity-Compliance/Certificate-Manager.svg deleted file mode 100644 index a68b0fb..0000000 --- a/assets/icons/aws/Security-Identity-Compliance/Certificate-Manager.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Certificate-Manager_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Security-Identity-Compliance/Cloud-Directory.svg b/assets/icons/aws/Security-Identity-Compliance/Cloud-Directory.svg deleted file mode 100644 index e1b6858..0000000 --- a/assets/icons/aws/Security-Identity-Compliance/Cloud-Directory.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-Cloud-Directory_64 - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Security-Identity-Compliance/CloudHSM.svg b/assets/icons/aws/Security-Identity-Compliance/CloudHSM.svg deleted file mode 100644 index 3da7f41..0000000 --- a/assets/icons/aws/Security-Identity-Compliance/CloudHSM.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-CloudHSM_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Security-Identity-Compliance/Cognito.svg b/assets/icons/aws/Security-Identity-Compliance/Cognito.svg deleted file mode 100644 index 06141d9..0000000 --- a/assets/icons/aws/Security-Identity-Compliance/Cognito.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-Cognito_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Security-Identity-Compliance/Detective.svg b/assets/icons/aws/Security-Identity-Compliance/Detective.svg deleted file mode 100644 index 86792b9..0000000 --- a/assets/icons/aws/Security-Identity-Compliance/Detective.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-Detective_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Security-Identity-Compliance/Directory-Service.svg b/assets/icons/aws/Security-Identity-Compliance/Directory-Service.svg deleted file mode 100644 index 06ba491..0000000 --- a/assets/icons/aws/Security-Identity-Compliance/Directory-Service.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Directory-Service_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Security-Identity-Compliance/Firewall-Manager.svg b/assets/icons/aws/Security-Identity-Compliance/Firewall-Manager.svg deleted file mode 100644 index cc1ba63..0000000 --- a/assets/icons/aws/Security-Identity-Compliance/Firewall-Manager.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Firewall-Manager_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Security-Identity-Compliance/GuardDuty.svg b/assets/icons/aws/Security-Identity-Compliance/GuardDuty.svg deleted file mode 100644 index 5079ad5..0000000 --- a/assets/icons/aws/Security-Identity-Compliance/GuardDuty.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-GuardDuty_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Security-Identity-Compliance/IAM-Identity-Center.svg b/assets/icons/aws/Security-Identity-Compliance/IAM-Identity-Center.svg deleted file mode 100644 index 2e03135..0000000 --- a/assets/icons/aws/Security-Identity-Compliance/IAM-Identity-Center.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-IAM-Identity-Center_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Security-Identity-Compliance/Identity-and-Access-Management.svg b/assets/icons/aws/Security-Identity-Compliance/Identity-and-Access-Management.svg deleted file mode 100644 index 135ff86..0000000 --- a/assets/icons/aws/Security-Identity-Compliance/Identity-and-Access-Management.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Identity-and-Access-Management_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Security-Identity-Compliance/Inspector.svg b/assets/icons/aws/Security-Identity-Compliance/Inspector.svg deleted file mode 100644 index 8fcb0fd..0000000 --- a/assets/icons/aws/Security-Identity-Compliance/Inspector.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-Inspector_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Security-Identity-Compliance/Key-Management-Service.svg b/assets/icons/aws/Security-Identity-Compliance/Key-Management-Service.svg deleted file mode 100644 index e7a800d..0000000 --- a/assets/icons/aws/Security-Identity-Compliance/Key-Management-Service.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Key-Management-Service_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Security-Identity-Compliance/Macie.svg b/assets/icons/aws/Security-Identity-Compliance/Macie.svg deleted file mode 100644 index a0a1191..0000000 --- a/assets/icons/aws/Security-Identity-Compliance/Macie.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-Macie_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Security-Identity-Compliance/Network-Firewall.svg b/assets/icons/aws/Security-Identity-Compliance/Network-Firewall.svg deleted file mode 100644 index bbae0fd..0000000 --- a/assets/icons/aws/Security-Identity-Compliance/Network-Firewall.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Network-Firewall_64 - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Security-Identity-Compliance/Payment-Cryptography.svg b/assets/icons/aws/Security-Identity-Compliance/Payment-Cryptography.svg deleted file mode 100644 index 32e49b8..0000000 --- a/assets/icons/aws/Security-Identity-Compliance/Payment-Cryptography.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Payment-Cryptography_64 - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Security-Identity-Compliance/Private-Certificate-Authority.svg b/assets/icons/aws/Security-Identity-Compliance/Private-Certificate-Authority.svg deleted file mode 100644 index 07e9423..0000000 --- a/assets/icons/aws/Security-Identity-Compliance/Private-Certificate-Authority.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Private-Certificate-Authority_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Security-Identity-Compliance/Resource-Access-Manager.svg b/assets/icons/aws/Security-Identity-Compliance/Resource-Access-Manager.svg deleted file mode 100644 index fa8d2c2..0000000 --- a/assets/icons/aws/Security-Identity-Compliance/Resource-Access-Manager.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Resource-Access-Manager_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Security-Identity-Compliance/Secrets-Manager.svg b/assets/icons/aws/Security-Identity-Compliance/Secrets-Manager.svg deleted file mode 100644 index 469564a..0000000 --- a/assets/icons/aws/Security-Identity-Compliance/Secrets-Manager.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Secrets-Manager_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Security-Identity-Compliance/Security-Agent.svg b/assets/icons/aws/Security-Identity-Compliance/Security-Agent.svg deleted file mode 100644 index 7443528..0000000 --- a/assets/icons/aws/Security-Identity-Compliance/Security-Agent.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/assets/icons/aws/Security-Identity-Compliance/Security-Hub.svg b/assets/icons/aws/Security-Identity-Compliance/Security-Hub.svg deleted file mode 100644 index b7962e6..0000000 --- a/assets/icons/aws/Security-Identity-Compliance/Security-Hub.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Security-Hub_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Security-Identity-Compliance/Security-Incident-Response.svg b/assets/icons/aws/Security-Identity-Compliance/Security-Incident-Response.svg deleted file mode 100644 index c9579d2..0000000 --- a/assets/icons/aws/Security-Identity-Compliance/Security-Incident-Response.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/assets/icons/aws/Security-Identity-Compliance/Security-Lake.svg b/assets/icons/aws/Security-Identity-Compliance/Security-Lake.svg deleted file mode 100644 index 4a0b1d4..0000000 --- a/assets/icons/aws/Security-Identity-Compliance/Security-Lake.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-Security-Lake_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Security-Identity-Compliance/Shield.svg b/assets/icons/aws/Security-Identity-Compliance/Shield.svg deleted file mode 100644 index bd801c4..0000000 --- a/assets/icons/aws/Security-Identity-Compliance/Shield.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Shield_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Security-Identity-Compliance/Signer.svg b/assets/icons/aws/Security-Identity-Compliance/Signer.svg deleted file mode 100644 index c6dcab8..0000000 --- a/assets/icons/aws/Security-Identity-Compliance/Signer.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Signer_64 - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Security-Identity-Compliance/Verified-Permissions.svg b/assets/icons/aws/Security-Identity-Compliance/Verified-Permissions.svg deleted file mode 100644 index 8b83e1f..0000000 --- a/assets/icons/aws/Security-Identity-Compliance/Verified-Permissions.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-Verified-Permissions_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Security-Identity-Compliance/WAF.svg b/assets/icons/aws/Security-Identity-Compliance/WAF.svg deleted file mode 100644 index 8c96961..0000000 --- a/assets/icons/aws/Security-Identity-Compliance/WAF.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-WAF_64 - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Storage/Backup.svg b/assets/icons/aws/Storage/Backup.svg deleted file mode 100644 index a61aa5d..0000000 --- a/assets/icons/aws/Storage/Backup.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Backup_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Storage/EFS.svg b/assets/icons/aws/Storage/EFS.svg deleted file mode 100644 index c93a7b6..0000000 --- a/assets/icons/aws/Storage/EFS.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-EFS_64 - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Storage/Elastic-Block-Store.svg b/assets/icons/aws/Storage/Elastic-Block-Store.svg deleted file mode 100644 index f68c7d2..0000000 --- a/assets/icons/aws/Storage/Elastic-Block-Store.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-Elastic-Block-Store_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Storage/Elastic-Disaster-Recovery.svg b/assets/icons/aws/Storage/Elastic-Disaster-Recovery.svg deleted file mode 100644 index e3729a3..0000000 --- a/assets/icons/aws/Storage/Elastic-Disaster-Recovery.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Elastic-Disaster-Recovery_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Storage/FSx-for-Lustre.svg b/assets/icons/aws/Storage/FSx-for-Lustre.svg deleted file mode 100644 index 8580517..0000000 --- a/assets/icons/aws/Storage/FSx-for-Lustre.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-FSx-for-Lustre_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Storage/FSx-for-NetApp-ONTAP.svg b/assets/icons/aws/Storage/FSx-for-NetApp-ONTAP.svg deleted file mode 100644 index c1a5be4..0000000 --- a/assets/icons/aws/Storage/FSx-for-NetApp-ONTAP.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-FSx-for-NetApp-ONTAP_64 - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Storage/FSx-for-OpenZFS.svg b/assets/icons/aws/Storage/FSx-for-OpenZFS.svg deleted file mode 100644 index a848e9e..0000000 --- a/assets/icons/aws/Storage/FSx-for-OpenZFS.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-FSx-for-OpenZFS_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Storage/FSx-for-WFS.svg b/assets/icons/aws/Storage/FSx-for-WFS.svg deleted file mode 100644 index cebd4b7..0000000 --- a/assets/icons/aws/Storage/FSx-for-WFS.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-FSx-for-WFS_64 - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Storage/FSx.svg b/assets/icons/aws/Storage/FSx.svg deleted file mode 100644 index 6e2c92b..0000000 --- a/assets/icons/aws/Storage/FSx.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-FSx_64 - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Storage/File-Cache.svg b/assets/icons/aws/Storage/File-Cache.svg deleted file mode 100644 index eeeab67..0000000 --- a/assets/icons/aws/Storage/File-Cache.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-File-Cache_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Storage/S3-on-Outposts.svg b/assets/icons/aws/Storage/S3-on-Outposts.svg deleted file mode 100644 index adfa0ea..0000000 --- a/assets/icons/aws/Storage/S3-on-Outposts.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-S3-on-Outposts_64 - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Storage/Simple-Storage-Service-Glacier.svg b/assets/icons/aws/Storage/Simple-Storage-Service-Glacier.svg deleted file mode 100644 index 8ec8779..0000000 --- a/assets/icons/aws/Storage/Simple-Storage-Service-Glacier.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-Simple-Storage-Service-Glacier_64 - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Storage/Simple-Storage-Service.svg b/assets/icons/aws/Storage/Simple-Storage-Service.svg deleted file mode 100644 index a562fe1..0000000 --- a/assets/icons/aws/Storage/Simple-Storage-Service.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - Icon-Architecture/64/Arch_Amazon-Simple-Storage-Service_64 - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Storage/Snowball-Edge.svg b/assets/icons/aws/Storage/Snowball-Edge.svg deleted file mode 100644 index c47bb42..0000000 --- a/assets/icons/aws/Storage/Snowball-Edge.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Snowball-Edge_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Storage/Snowball.svg b/assets/icons/aws/Storage/Snowball.svg deleted file mode 100644 index eb137b6..0000000 --- a/assets/icons/aws/Storage/Snowball.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Snowball_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Storage/Snowcone.svg b/assets/icons/aws/Storage/Snowcone.svg deleted file mode 100644 index fcee172..0000000 --- a/assets/icons/aws/Storage/Snowcone.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Snowcone_64 - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Storage/Snowmobile.svg b/assets/icons/aws/Storage/Snowmobile.svg deleted file mode 100644 index 7250539..0000000 --- a/assets/icons/aws/Storage/Snowmobile.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - - Icon-Architecture/64/Arch_AWS-Snowmobile_64 - Created with Sketch. - - - - - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/Storage/Storage-Gateway.svg b/assets/icons/aws/Storage/Storage-Gateway.svg deleted file mode 100644 index 36796dd..0000000 --- a/assets/icons/aws/Storage/Storage-Gateway.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Icon-Architecture/64/Arch_AWS-Storage-Gateway_64 - - - - - - - \ No newline at end of file diff --git a/assets/icons/aws/VR-AR/Sumerian.svg b/assets/icons/aws/VR-AR/Sumerian.svg deleted file mode 100644 index 0767896..0000000 --- a/assets/icons/aws/VR-AR/Sumerian.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - - Icon-Architecture/64/Arch_AWS-Sumerian_64 - Created with Sketch. - - - - - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/azure/ai-machine-learning/00028-icon-service-Batch-AI.svg b/assets/icons/azure/ai-machine-learning/00028-icon-service-Batch-AI.svg deleted file mode 100644 index 62276b4..0000000 --- a/assets/icons/azure/ai-machine-learning/00028-icon-service-Batch-AI.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-machinelearning-161 \ No newline at end of file diff --git a/assets/icons/azure/ai-machine-learning/00030-icon-service-Machine-Learning-Studio-(Classic)-Web-Services.svg b/assets/icons/azure/ai-machine-learning/00030-icon-service-Machine-Learning-Studio-(Classic)-Web-Services.svg deleted file mode 100644 index 047d7dc..0000000 --- a/assets/icons/azure/ai-machine-learning/00030-icon-service-Machine-Learning-Studio-(Classic)-Web-Services.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/ai-machine-learning/00031-icon-service-Genomics.svg b/assets/icons/azure/ai-machine-learning/00031-icon-service-Genomics.svg deleted file mode 100644 index 3e1438e..0000000 --- a/assets/icons/azure/ai-machine-learning/00031-icon-service-Genomics.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-machinelearning-164 \ No newline at end of file diff --git a/assets/icons/azure/ai-machine-learning/00792-icon-service-Computer-Vision.svg b/assets/icons/azure/ai-machine-learning/00792-icon-service-Computer-Vision.svg deleted file mode 100644 index d56e7f2..0000000 --- a/assets/icons/azure/ai-machine-learning/00792-icon-service-Computer-Vision.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/ai-machine-learning/00793-icon-service-Custom-Vision.svg b/assets/icons/azure/ai-machine-learning/00793-icon-service-Custom-Vision.svg deleted file mode 100644 index a85534a..0000000 --- a/assets/icons/azure/ai-machine-learning/00793-icon-service-Custom-Vision.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/ai-machine-learning/00794-icon-service-Face-APIs.svg b/assets/icons/azure/ai-machine-learning/00794-icon-service-Face-APIs.svg deleted file mode 100644 index 5364545..0000000 --- a/assets/icons/azure/ai-machine-learning/00794-icon-service-Face-APIs.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/ai-machine-learning/00795-icon-service-Content-Moderators.svg b/assets/icons/azure/ai-machine-learning/00795-icon-service-Content-Moderators.svg deleted file mode 100644 index 5aed478..0000000 --- a/assets/icons/azure/ai-machine-learning/00795-icon-service-Content-Moderators.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/ai-machine-learning/00796-icon-service-Personalizers.svg b/assets/icons/azure/ai-machine-learning/00796-icon-service-Personalizers.svg deleted file mode 100644 index f56c56d..0000000 --- a/assets/icons/azure/ai-machine-learning/00796-icon-service-Personalizers.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/ai-machine-learning/00797-icon-service-Speech-Services.svg b/assets/icons/azure/ai-machine-learning/00797-icon-service-Speech-Services.svg deleted file mode 100644 index 5b3b7bf..0000000 --- a/assets/icons/azure/ai-machine-learning/00797-icon-service-Speech-Services.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/ai-machine-learning/00799-icon-service-QnA-Makers.svg b/assets/icons/azure/ai-machine-learning/00799-icon-service-QnA-Makers.svg deleted file mode 100644 index 4b9d4ad..0000000 --- a/assets/icons/azure/ai-machine-learning/00799-icon-service-QnA-Makers.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/ai-machine-learning/00800-icon-service-Translator-Text.svg b/assets/icons/azure/ai-machine-learning/00800-icon-service-Translator-Text.svg deleted file mode 100644 index cc51733..0000000 --- a/assets/icons/azure/ai-machine-learning/00800-icon-service-Translator-Text.svg +++ /dev/null @@ -1,2 +0,0 @@ -​ - \ No newline at end of file diff --git a/assets/icons/azure/ai-machine-learning/00801-icon-service-Language-Understanding.svg b/assets/icons/azure/ai-machine-learning/00801-icon-service-Language-Understanding.svg deleted file mode 100644 index b21151d..0000000 --- a/assets/icons/azure/ai-machine-learning/00801-icon-service-Language-Understanding.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/ai-machine-learning/00812-icon-service-Immersive-Readers.svg b/assets/icons/azure/ai-machine-learning/00812-icon-service-Immersive-Readers.svg deleted file mode 100644 index 4b06efc..0000000 --- a/assets/icons/azure/ai-machine-learning/00812-icon-service-Immersive-Readers.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/ai-machine-learning/00814-icon-service-Anomaly-Detector.svg b/assets/icons/azure/ai-machine-learning/00814-icon-service-Anomaly-Detector.svg deleted file mode 100644 index 6f09746..0000000 --- a/assets/icons/azure/ai-machine-learning/00814-icon-service-Anomaly-Detector.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/ai-machine-learning/00819-icon-service-Form-Recognizers.svg b/assets/icons/azure/ai-machine-learning/00819-icon-service-Form-Recognizers.svg deleted file mode 100644 index 8f3695e..0000000 --- a/assets/icons/azure/ai-machine-learning/00819-icon-service-Form-Recognizers.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/ai-machine-learning/01239-icon-service-Azure-Experimentation-Studio.svg b/assets/icons/azure/ai-machine-learning/01239-icon-service-Azure-Experimentation-Studio.svg deleted file mode 100644 index b70337d..0000000 --- a/assets/icons/azure/ai-machine-learning/01239-icon-service-Azure-Experimentation-Studio.svg +++ /dev/null @@ -1,2 +0,0 @@ -​ - \ No newline at end of file diff --git a/assets/icons/azure/ai-machine-learning/01688-icon-service-Azure-Object-Understanding.svg b/assets/icons/azure/ai-machine-learning/01688-icon-service-Azure-Object-Understanding.svg deleted file mode 100644 index 52ed571..0000000 --- a/assets/icons/azure/ai-machine-learning/01688-icon-service-Azure-Object-Understanding.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/ai-machine-learning/02409-icon-service-Metrics-Advisor.svg b/assets/icons/azure/ai-machine-learning/02409-icon-service-Metrics-Advisor.svg deleted file mode 100644 index a657161..0000000 --- a/assets/icons/azure/ai-machine-learning/02409-icon-service-Metrics-Advisor.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/ai-machine-learning/02749-icon-service-Azure-Applied-AI-Services.svg b/assets/icons/azure/ai-machine-learning/02749-icon-service-Azure-Applied-AI-Services.svg deleted file mode 100644 index fdd78f4..0000000 --- a/assets/icons/azure/ai-machine-learning/02749-icon-service-Azure-Applied-AI-Services.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/ai-machine-learning/02876-icon-service-Language.svg b/assets/icons/azure/ai-machine-learning/02876-icon-service-Language.svg deleted file mode 100644 index bf167ec..0000000 --- a/assets/icons/azure/ai-machine-learning/02876-icon-service-Language.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/ai-machine-learning/029455661-icon-service-FRD-QA.svg b/assets/icons/azure/ai-machine-learning/029455661-icon-service-FRD-QA.svg deleted file mode 100644 index d3d2d2c..0000000 --- a/assets/icons/azure/ai-machine-learning/029455661-icon-service-FRD-QA.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/ai-machine-learning/03173-icon-service-Cognitive-Services-Decisions.svg b/assets/icons/azure/ai-machine-learning/03173-icon-service-Cognitive-Services-Decisions.svg deleted file mode 100644 index f027b04..0000000 --- a/assets/icons/azure/ai-machine-learning/03173-icon-service-Cognitive-Services-Decisions.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/ai-machine-learning/03321-icon-service-Serverless-Search.svg b/assets/icons/azure/ai-machine-learning/03321-icon-service-Serverless-Search.svg deleted file mode 100644 index 8eea3f6..0000000 --- a/assets/icons/azure/ai-machine-learning/03321-icon-service-Serverless-Search.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/ai-machine-learning/03337-icon-service-Bonsai.svg b/assets/icons/azure/ai-machine-learning/03337-icon-service-Bonsai.svg deleted file mode 100644 index 1c3169b..0000000 --- a/assets/icons/azure/ai-machine-learning/03337-icon-service-Bonsai.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/ai-machine-learning/03390-icon-service-Content-Safety.svg b/assets/icons/azure/ai-machine-learning/03390-icon-service-Content-Safety.svg deleted file mode 100644 index f8727a0..0000000 --- a/assets/icons/azure/ai-machine-learning/03390-icon-service-Content-Safety.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/ai-machine-learning/03438-icon-service-Azure-OpenAI.svg b/assets/icons/azure/ai-machine-learning/03438-icon-service-Azure-OpenAI.svg deleted file mode 100644 index a0e8f8e..0000000 --- a/assets/icons/azure/ai-machine-learning/03438-icon-service-Azure-OpenAI.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/ai-machine-learning/03513-icon-service-AI-Studio.svg b/assets/icons/azure/ai-machine-learning/03513-icon-service-AI-Studio.svg deleted file mode 100644 index 710e86f..0000000 --- a/assets/icons/azure/ai-machine-learning/03513-icon-service-AI-Studio.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/ai-machine-learning/035746832-icon-service-AI-Foundry.svg b/assets/icons/azure/ai-machine-learning/035746832-icon-service-AI-Foundry.svg deleted file mode 100644 index 38930a3..0000000 --- a/assets/icons/azure/ai-machine-learning/035746832-icon-service-AI-Foundry.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/ai-machine-learning/036509374-icon-service-Foundry-Application.svg b/assets/icons/azure/ai-machine-learning/036509374-icon-service-Foundry-Application.svg deleted file mode 100644 index ba4c29f..0000000 --- a/assets/icons/azure/ai-machine-learning/036509374-icon-service-Foundry-Application.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/ai-machine-learning/036509415-icon-service-Foundry-Project.svg b/assets/icons/azure/ai-machine-learning/036509415-icon-service-Foundry-Project.svg deleted file mode 100644 index bdd928f..0000000 --- a/assets/icons/azure/ai-machine-learning/036509415-icon-service-Foundry-Project.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/ai-machine-learning/03755-icon-service-Planetary-Computer-Pro.svg b/assets/icons/azure/ai-machine-learning/03755-icon-service-Planetary-Computer-Pro.svg deleted file mode 100644 index 480eae8..0000000 --- a/assets/icons/azure/ai-machine-learning/03755-icon-service-Planetary-Computer-Pro.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/ai-machine-learning/038470497-icon-service-Azure-AI-Foundry-IQ.svg b/assets/icons/azure/ai-machine-learning/038470497-icon-service-Azure-AI-Foundry-IQ.svg deleted file mode 100644 index 35d4252..0000000 --- a/assets/icons/azure/ai-machine-learning/038470497-icon-service-Azure-AI-Foundry-IQ.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/ai-machine-learning/038470510-icon-service-Azure-Machine-Learning.svg b/assets/icons/azure/ai-machine-learning/038470510-icon-service-Azure-Machine-Learning.svg deleted file mode 100644 index 4654bc8..0000000 --- a/assets/icons/azure/ai-machine-learning/038470510-icon-service-Azure-Machine-Learning.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/ai-machine-learning/038470523-icon-service-Foundry-Agent-Service.svg b/assets/icons/azure/ai-machine-learning/038470523-icon-service-Foundry-Agent-Service.svg deleted file mode 100644 index aae3367..0000000 --- a/assets/icons/azure/ai-machine-learning/038470523-icon-service-Foundry-Agent-Service.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/ai-machine-learning/038470534-icon-service-Foundry-Control-Plane.svg b/assets/icons/azure/ai-machine-learning/038470534-icon-service-Foundry-Control-Plane.svg deleted file mode 100644 index f9e4132..0000000 --- a/assets/icons/azure/ai-machine-learning/038470534-icon-service-Foundry-Control-Plane.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/ai-machine-learning/038470555-icon-service-Foundry-Labs.svg b/assets/icons/azure/ai-machine-learning/038470555-icon-service-Foundry-Labs.svg deleted file mode 100644 index 198a8db..0000000 --- a/assets/icons/azure/ai-machine-learning/038470555-icon-service-Foundry-Labs.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/ai-machine-learning/038470593-icon-service-Foundry-Local.svg b/assets/icons/azure/ai-machine-learning/038470593-icon-service-Foundry-Local.svg deleted file mode 100644 index 2af3fb7..0000000 --- a/assets/icons/azure/ai-machine-learning/038470593-icon-service-Foundry-Local.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/ai-machine-learning/038470614-icon-service-Foundry-Models.svg b/assets/icons/azure/ai-machine-learning/038470614-icon-service-Foundry-Models.svg deleted file mode 100644 index 4da2576..0000000 --- a/assets/icons/azure/ai-machine-learning/038470614-icon-service-Foundry-Models.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/ai-machine-learning/10044-icon-service-Cognitive-Search.svg b/assets/icons/azure/ai-machine-learning/10044-icon-service-Cognitive-Search.svg deleted file mode 100644 index 53e44a5..0000000 --- a/assets/icons/azure/ai-machine-learning/10044-icon-service-Cognitive-Search.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-web-44 \ No newline at end of file diff --git a/assets/icons/azure/ai-machine-learning/10162-icon-service-Cognitive-Services.svg b/assets/icons/azure/ai-machine-learning/10162-icon-service-Cognitive-Services.svg deleted file mode 100644 index 3c47ed9..0000000 --- a/assets/icons/azure/ai-machine-learning/10162-icon-service-Cognitive-Services.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-machinelearning-162 \ No newline at end of file diff --git a/assets/icons/azure/ai-machine-learning/10164-icon-service-Genomics-Accounts.svg b/assets/icons/azure/ai-machine-learning/10164-icon-service-Genomics-Accounts.svg deleted file mode 100644 index 3e1438e..0000000 --- a/assets/icons/azure/ai-machine-learning/10164-icon-service-Genomics-Accounts.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-machinelearning-164 \ No newline at end of file diff --git a/assets/icons/azure/ai-machine-learning/10165-icon-service-Bot-Services.svg b/assets/icons/azure/ai-machine-learning/10165-icon-service-Bot-Services.svg deleted file mode 100644 index 63520c2..0000000 --- a/assets/icons/azure/ai-machine-learning/10165-icon-service-Bot-Services.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-machinelearning-165 \ No newline at end of file diff --git a/assets/icons/azure/ai-machine-learning/10166-icon-service-Machine-Learning.svg b/assets/icons/azure/ai-machine-learning/10166-icon-service-Machine-Learning.svg deleted file mode 100644 index 12f82cf..0000000 --- a/assets/icons/azure/ai-machine-learning/10166-icon-service-Machine-Learning.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-166Artboard 1 \ No newline at end of file diff --git a/assets/icons/azure/ai-machine-learning/10167-icon-service-Machine-Learning-Studio-Workspaces.svg b/assets/icons/azure/ai-machine-learning/10167-icon-service-Machine-Learning-Studio-Workspaces.svg deleted file mode 100644 index d571ed9..0000000 --- a/assets/icons/azure/ai-machine-learning/10167-icon-service-Machine-Learning-Studio-Workspaces.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-machinelearning-167 \ No newline at end of file diff --git a/assets/icons/azure/ai-machine-learning/10168-icon-service-Machine-Learning-Studio-Web-Service-Plans.svg b/assets/icons/azure/ai-machine-learning/10168-icon-service-Machine-Learning-Studio-Web-Service-Plans.svg deleted file mode 100644 index 0cd341c..0000000 --- a/assets/icons/azure/ai-machine-learning/10168-icon-service-Machine-Learning-Studio-Web-Service-Plans.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-machinelearning-168 \ No newline at end of file diff --git a/assets/icons/azure/analytics/00009-icon-service-Log-Analytics-Workspaces.svg b/assets/icons/azure/analytics/00009-icon-service-Log-Analytics-Workspaces.svg deleted file mode 100644 index ec1c0ce..0000000 --- a/assets/icons/azure/analytics/00009-icon-service-Log-Analytics-Workspaces.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-manage-307 \ No newline at end of file diff --git a/assets/icons/azure/analytics/00039-icon-service-Event-Hubs.svg b/assets/icons/azure/analytics/00039-icon-service-Event-Hubs.svg deleted file mode 100644 index 7bbf33a..0000000 --- a/assets/icons/azure/analytics/00039-icon-service-Event-Hubs.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-analytics-144 \ No newline at end of file diff --git a/assets/icons/azure/analytics/00042-icon-service-Stream-Analytics-Jobs.svg b/assets/icons/azure/analytics/00042-icon-service-Stream-Analytics-Jobs.svg deleted file mode 100644 index 098f62b..0000000 --- a/assets/icons/azure/analytics/00042-icon-service-Stream-Analytics-Jobs.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-analytics-147 \ No newline at end of file diff --git a/assets/icons/azure/analytics/00562-icon-service-Endpoint-Analytics.svg b/assets/icons/azure/analytics/00562-icon-service-Endpoint-Analytics.svg deleted file mode 100644 index ef50cad..0000000 --- a/assets/icons/azure/analytics/00562-icon-service-Endpoint-Analytics.svg +++ /dev/null @@ -1,2 +0,0 @@ -​ - \ No newline at end of file diff --git a/assets/icons/azure/analytics/00606-icon-service-Azure-Synapse-Analytics.svg b/assets/icons/azure/analytics/00606-icon-service-Azure-Synapse-Analytics.svg deleted file mode 100644 index 235283b..0000000 --- a/assets/icons/azure/analytics/00606-icon-service-Azure-Synapse-Analytics.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/analytics/02189-icon-service-Azure-Workbooks.svg b/assets/icons/azure/analytics/02189-icon-service-Azure-Workbooks.svg deleted file mode 100644 index 2082ecc..0000000 --- a/assets/icons/azure/analytics/02189-icon-service-Azure-Workbooks.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/analytics/02209-icon-service-Private-Link-Services.svg b/assets/icons/azure/analytics/02209-icon-service-Private-Link-Services.svg deleted file mode 100644 index d1ba972..0000000 --- a/assets/icons/azure/analytics/02209-icon-service-Private-Link-Services.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/analytics/03332-icon-service-Power-BI-Embedded.svg b/assets/icons/azure/analytics/03332-icon-service-Power-BI-Embedded.svg deleted file mode 100644 index a0d2c3a..0000000 --- a/assets/icons/azure/analytics/03332-icon-service-Power-BI-Embedded.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/analytics/03335-icon-service-Power-Platform.svg b/assets/icons/azure/analytics/03335-icon-service-Power-Platform.svg deleted file mode 100644 index f3d0dcd..0000000 --- a/assets/icons/azure/analytics/03335-icon-service-Power-Platform.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/analytics/10126-icon-service-Data-Factories.svg b/assets/icons/azure/analytics/10126-icon-service-Data-Factories.svg deleted file mode 100644 index 2237336..0000000 --- a/assets/icons/azure/analytics/10126-icon-service-Data-Factories.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-databases-126 \ No newline at end of file diff --git a/assets/icons/azure/analytics/10142-icon-service-HD-Insight-Clusters.svg b/assets/icons/azure/analytics/10142-icon-service-HD-Insight-Clusters.svg deleted file mode 100644 index dcab326..0000000 --- a/assets/icons/azure/analytics/10142-icon-service-HD-Insight-Clusters.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-analytics-142 \ No newline at end of file diff --git a/assets/icons/azure/analytics/10143-icon-service-Data-Lake-Analytics.svg b/assets/icons/azure/analytics/10143-icon-service-Data-Lake-Analytics.svg deleted file mode 100644 index 1d2ab64..0000000 --- a/assets/icons/azure/analytics/10143-icon-service-Data-Lake-Analytics.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-analytics-143 \ No newline at end of file diff --git a/assets/icons/azure/analytics/10145-icon-service-Azure-Data-Explorer-Clusters.svg b/assets/icons/azure/analytics/10145-icon-service-Azure-Data-Explorer-Clusters.svg deleted file mode 100644 index efc908f..0000000 --- a/assets/icons/azure/analytics/10145-icon-service-Azure-Data-Explorer-Clusters.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-analytics-145 \ No newline at end of file diff --git a/assets/icons/azure/analytics/10148-icon-service-Analysis-Services.svg b/assets/icons/azure/analytics/10148-icon-service-Analysis-Services.svg deleted file mode 100644 index 529fc6a..0000000 --- a/assets/icons/azure/analytics/10148-icon-service-Analysis-Services.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-analytics-148 \ No newline at end of file diff --git a/assets/icons/azure/analytics/10149-icon-service-Event-Hub-Clusters.svg b/assets/icons/azure/analytics/10149-icon-service-Event-Hub-Clusters.svg deleted file mode 100644 index ed0d934..0000000 --- a/assets/icons/azure/analytics/10149-icon-service-Event-Hub-Clusters.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-analytics-149 \ No newline at end of file diff --git a/assets/icons/azure/analytics/10150-icon-service-Data-Lake-Store-Gen1.svg b/assets/icons/azure/analytics/10150-icon-service-Data-Lake-Store-Gen1.svg deleted file mode 100644 index 08ba853..0000000 --- a/assets/icons/azure/analytics/10150-icon-service-Data-Lake-Store-Gen1.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-analytics-150 \ No newline at end of file diff --git a/assets/icons/azure/analytics/10787-icon-service-Azure-Databricks.svg b/assets/icons/azure/analytics/10787-icon-service-Azure-Databricks.svg deleted file mode 100644 index 4210dbd..0000000 --- a/assets/icons/azure/analytics/10787-icon-service-Azure-Databricks.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/app-services/00046-icon-service-App-Service-Plans.svg b/assets/icons/azure/app-services/00046-icon-service-App-Service-Plans.svg deleted file mode 100644 index 11a5093..0000000 --- a/assets/icons/azure/app-services/00046-icon-service-App-Service-Plans.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-web-46 \ No newline at end of file diff --git a/assets/icons/azure/app-services/00049-icon-service-App-Service-Certificates.svg b/assets/icons/azure/app-services/00049-icon-service-App-Service-Certificates.svg deleted file mode 100644 index c2dfd22..0000000 --- a/assets/icons/azure/app-services/00049-icon-service-App-Service-Certificates.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-web-49 \ No newline at end of file diff --git a/assets/icons/azure/app-services/00050-icon-service-App-Service-Domains.svg b/assets/icons/azure/app-services/00050-icon-service-App-Service-Domains.svg deleted file mode 100644 index ab3ca15..0000000 --- a/assets/icons/azure/app-services/00050-icon-service-App-Service-Domains.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-web-50 \ No newline at end of file diff --git a/assets/icons/azure/app-services/00056-icon-service-CDN-Profiles.svg b/assets/icons/azure/app-services/00056-icon-service-CDN-Profiles.svg deleted file mode 100644 index d35a978..0000000 --- a/assets/icons/azure/app-services/00056-icon-service-CDN-Profiles.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-web-43 \ No newline at end of file diff --git a/assets/icons/azure/app-services/10035-icon-service-App-Services.svg b/assets/icons/azure/app-services/10035-icon-service-App-Services.svg deleted file mode 100644 index 022ef80..0000000 --- a/assets/icons/azure/app-services/10035-icon-service-App-Services.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-web-41 \ No newline at end of file diff --git a/assets/icons/azure/app-services/10044-icon-service-Cognitive-Search.svg b/assets/icons/azure/app-services/10044-icon-service-Cognitive-Search.svg deleted file mode 100644 index 53e44a5..0000000 --- a/assets/icons/azure/app-services/10044-icon-service-Cognitive-Search.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-web-44 \ No newline at end of file diff --git a/assets/icons/azure/app-services/10045-icon-service-Notification-Hubs.svg b/assets/icons/azure/app-services/10045-icon-service-Notification-Hubs.svg deleted file mode 100644 index 185df88..0000000 --- a/assets/icons/azure/app-services/10045-icon-service-Notification-Hubs.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-web-45 \ No newline at end of file diff --git a/assets/icons/azure/app-services/10047-icon-service-App-Service-Environments.svg b/assets/icons/azure/app-services/10047-icon-service-App-Service-Environments.svg deleted file mode 100644 index 35e0800..0000000 --- a/assets/icons/azure/app-services/10047-icon-service-App-Service-Environments.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-web-47 \ No newline at end of file diff --git a/assets/icons/azure/azure ecosystem/01038-icon-service-Collaborative-Service.svg b/assets/icons/azure/azure ecosystem/01038-icon-service-Collaborative-Service.svg deleted file mode 100644 index 0154e55..0000000 --- a/assets/icons/azure/azure ecosystem/01038-icon-service-Collaborative-Service.svg +++ /dev/null @@ -1,2 +0,0 @@ -​ - \ No newline at end of file diff --git a/assets/icons/azure/azure ecosystem/02354-icon-service-Applens.svg b/assets/icons/azure/azure ecosystem/02354-icon-service-Applens.svg deleted file mode 100644 index de895f0..0000000 --- a/assets/icons/azure/azure ecosystem/02354-icon-service-Applens.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/azure ecosystem/02573-icon-service-Azure-Hybrid-Center.svg b/assets/icons/azure/azure ecosystem/02573-icon-service-Azure-Hybrid-Center.svg deleted file mode 100644 index 3d5e279..0000000 --- a/assets/icons/azure/azure ecosystem/02573-icon-service-Azure-Hybrid-Center.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/azure stack/00965-icon-service-Multi-Tenancy.svg b/assets/icons/azure/azure stack/00965-icon-service-Multi-Tenancy.svg deleted file mode 100644 index baa0de0..0000000 --- a/assets/icons/azure/azure stack/00965-icon-service-Multi-Tenancy.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/azure stack/10108-icon-service-Infrastructure-Backup.svg b/assets/icons/azure/azure stack/10108-icon-service-Infrastructure-Backup.svg deleted file mode 100644 index 94b3395..0000000 --- a/assets/icons/azure/azure stack/10108-icon-service-Infrastructure-Backup.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/azure stack/10109-icon-service-Capacity.svg b/assets/icons/azure/azure stack/10109-icon-service-Capacity.svg deleted file mode 100644 index 1479052..0000000 --- a/assets/icons/azure/azure stack/10109-icon-service-Capacity.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/azure stack/10110-icon-service-Offers.svg b/assets/icons/azure/azure stack/10110-icon-service-Offers.svg deleted file mode 100644 index cf4d05c..0000000 --- a/assets/icons/azure/azure stack/10110-icon-service-Offers.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-azurestack-3 \ No newline at end of file diff --git a/assets/icons/azure/azure stack/10111-icon-service-User-Subscriptions.svg b/assets/icons/azure/azure stack/10111-icon-service-User-Subscriptions.svg deleted file mode 100644 index 38e9e49..0000000 --- a/assets/icons/azure/azure stack/10111-icon-service-User-Subscriptions.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/azure stack/10113-icon-service-Plans.svg b/assets/icons/azure/azure stack/10113-icon-service-Plans.svg deleted file mode 100644 index 3b91dfe..0000000 --- a/assets/icons/azure/azure stack/10113-icon-service-Plans.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-azurestack-6 \ No newline at end of file diff --git a/assets/icons/azure/azure stack/10115-icon-service-Updates.svg b/assets/icons/azure/azure stack/10115-icon-service-Updates.svg deleted file mode 100644 index e7679f8..0000000 --- a/assets/icons/azure/azure stack/10115-icon-service-Updates.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-azurestack-8 \ No newline at end of file diff --git a/assets/icons/azure/blockchain/10210-icon-service-Blockchain-Applications.svg b/assets/icons/azure/blockchain/10210-icon-service-Blockchain-Applications.svg deleted file mode 100644 index f5836c7..0000000 --- a/assets/icons/azure/blockchain/10210-icon-service-Blockchain-Applications.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-Blockchain-363 \ No newline at end of file diff --git a/assets/icons/azure/blockchain/10364-icon-service-Outbound-Connection.svg b/assets/icons/azure/blockchain/10364-icon-service-Outbound-Connection.svg deleted file mode 100644 index 94aee2f..0000000 --- a/assets/icons/azure/blockchain/10364-icon-service-Outbound-Connection.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-Blockchain-364 \ No newline at end of file diff --git a/assets/icons/azure/blockchain/10366-icon-service-Azure-Blockchain-Service.svg b/assets/icons/azure/blockchain/10366-icon-service-Azure-Blockchain-Service.svg deleted file mode 100644 index c58b9a9..0000000 --- a/assets/icons/azure/blockchain/10366-icon-service-Azure-Blockchain-Service.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-Blockchain-366 \ No newline at end of file diff --git a/assets/icons/azure/blockchain/10367-icon-service-Azure-Token-Service.svg b/assets/icons/azure/blockchain/10367-icon-service-Azure-Token-Service.svg deleted file mode 100644 index 9239905..0000000 --- a/assets/icons/azure/blockchain/10367-icon-service-Azure-Token-Service.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/blockchain/10374-icon-service-ABS-Member.svg b/assets/icons/azure/blockchain/10374-icon-service-ABS-Member.svg deleted file mode 100644 index 7accf34..0000000 --- a/assets/icons/azure/blockchain/10374-icon-service-ABS-Member.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-494-ABS-Member \ No newline at end of file diff --git a/assets/icons/azure/blockchain/10375-icon-service-Consortium.svg b/assets/icons/azure/blockchain/10375-icon-service-Consortium.svg deleted file mode 100644 index e552c7c..0000000 --- a/assets/icons/azure/blockchain/10375-icon-service-Consortium.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/compute/00195-icon-service-Maintenance-Configuration.svg b/assets/icons/azure/compute/00195-icon-service-Maintenance-Configuration.svg deleted file mode 100644 index ad57e7f..0000000 --- a/assets/icons/azure/compute/00195-icon-service-Maintenance-Configuration.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/compute/00328-icon-service-Host-Pools.svg b/assets/icons/azure/compute/00328-icon-service-Host-Pools.svg deleted file mode 100644 index 0f9aa34..0000000 --- a/assets/icons/azure/compute/00328-icon-service-Host-Pools.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/compute/00329-icon-service-Application-Group.svg b/assets/icons/azure/compute/00329-icon-service-Application-Group.svg deleted file mode 100644 index 7463c4b..0000000 --- a/assets/icons/azure/compute/00329-icon-service-Application-Group.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/compute/00330-icon-service-Workspaces.svg b/assets/icons/azure/compute/00330-icon-service-Workspaces.svg deleted file mode 100644 index 7f9410f..0000000 --- a/assets/icons/azure/compute/00330-icon-service-Workspaces.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/compute/00398-icon-service-Disk-Encryption-Sets.svg b/assets/icons/azure/compute/00398-icon-service-Disk-Encryption-Sets.svg deleted file mode 100644 index b2aa975..0000000 --- a/assets/icons/azure/compute/00398-icon-service-Disk-Encryption-Sets.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/compute/00400-icon-service-Workspaces.svg b/assets/icons/azure/compute/00400-icon-service-Workspaces.svg deleted file mode 100644 index 6aa7a81..0000000 --- a/assets/icons/azure/compute/00400-icon-service-Workspaces.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/compute/02112-icon-service-Automanaged-VM.svg b/assets/icons/azure/compute/02112-icon-service-Automanaged-VM.svg deleted file mode 100644 index 480267a..0000000 --- a/assets/icons/azure/compute/02112-icon-service-Automanaged-VM.svg +++ /dev/null @@ -1,2 +0,0 @@ -​ - \ No newline at end of file diff --git a/assets/icons/azure/compute/02370-icon-service-Managed-Service-Fabric.svg b/assets/icons/azure/compute/02370-icon-service-Managed-Service-Fabric.svg deleted file mode 100644 index f3d2bad..0000000 --- a/assets/icons/azure/compute/02370-icon-service-Managed-Service-Fabric.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/compute/02409-icon-service-Metrics-Advisor.svg b/assets/icons/azure/compute/02409-icon-service-Metrics-Advisor.svg deleted file mode 100644 index a657161..0000000 --- a/assets/icons/azure/compute/02409-icon-service-Metrics-Advisor.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/compute/02634-icon-service-Image-Templates.svg b/assets/icons/azure/compute/02634-icon-service-Image-Templates.svg deleted file mode 100644 index 5f313b7..0000000 --- a/assets/icons/azure/compute/02634-icon-service-Image-Templates.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/compute/02817-icon-service-Restore-Points.svg b/assets/icons/azure/compute/02817-icon-service-Restore-Points.svg deleted file mode 100644 index 1b0a138..0000000 --- a/assets/icons/azure/compute/02817-icon-service-Restore-Points.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/compute/02818-icon-service-Restore-Points-Collections.svg b/assets/icons/azure/compute/02818-icon-service-Restore-Points-Collections.svg deleted file mode 100644 index f6671ad..0000000 --- a/assets/icons/azure/compute/02818-icon-service-Restore-Points-Collections.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/compute/02864-icon-service-Azure-Compute-Galleries.svg b/assets/icons/azure/compute/02864-icon-service-Azure-Compute-Galleries.svg deleted file mode 100644 index 5e31473..0000000 --- a/assets/icons/azure/compute/02864-icon-service-Azure-Compute-Galleries.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/compute/030868929-icon-service-Stage-Maps.svg b/assets/icons/azure/compute/030868929-icon-service-Stage-Maps.svg deleted file mode 100644 index a7f14d9..0000000 --- a/assets/icons/azure/compute/030868929-icon-service-Stage-Maps.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/compute/033049334-icon-service-Scheduled-Actions.svg b/assets/icons/azure/compute/033049334-icon-service-Scheduled-Actions.svg deleted file mode 100644 index 63b4281..0000000 --- a/assets/icons/azure/compute/033049334-icon-service-Scheduled-Actions.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/compute/03487-icon-service-Compute-Fleet.svg b/assets/icons/azure/compute/03487-icon-service-Compute-Fleet.svg deleted file mode 100644 index 5f36410..0000000 --- a/assets/icons/azure/compute/03487-icon-service-Compute-Fleet.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/compute/03543-icon-service-AKS-Automatic.svg b/assets/icons/azure/compute/03543-icon-service-AKS-Automatic.svg deleted file mode 100644 index 1edaa46..0000000 --- a/assets/icons/azure/compute/03543-icon-service-AKS-Automatic.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/compute/03645-icon-service-Azure-Linux.svg b/assets/icons/azure/compute/03645-icon-service-Azure-Linux.svg deleted file mode 100644 index 82dcbad..0000000 --- a/assets/icons/azure/compute/03645-icon-service-Azure-Linux.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/compute/10021-icon-service-Virtual-Machine.svg b/assets/icons/azure/compute/10021-icon-service-Virtual-Machine.svg deleted file mode 100644 index 0263f26..0000000 --- a/assets/icons/azure/compute/10021-icon-service-Virtual-Machine.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-compute-21 \ No newline at end of file diff --git a/assets/icons/azure/compute/10023-icon-service-Kubernetes-Services.svg b/assets/icons/azure/compute/10023-icon-service-Kubernetes-Services.svg deleted file mode 100644 index 2e597c4..0000000 --- a/assets/icons/azure/compute/10023-icon-service-Kubernetes-Services.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-compute-23 \ No newline at end of file diff --git a/assets/icons/azure/compute/10024-icon-service-Mesh-Applications.svg b/assets/icons/azure/compute/10024-icon-service-Mesh-Applications.svg deleted file mode 100644 index fc7e329..0000000 --- a/assets/icons/azure/compute/10024-icon-service-Mesh-Applications.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/compute/10025-icon-service-Availability-Sets.svg b/assets/icons/azure/compute/10025-icon-service-Availability-Sets.svg deleted file mode 100644 index 1fef764..0000000 --- a/assets/icons/azure/compute/10025-icon-service-Availability-Sets.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-compute-25 \ No newline at end of file diff --git a/assets/icons/azure/compute/10026-icon-service-Disks-Snapshots.svg b/assets/icons/azure/compute/10026-icon-service-Disks-Snapshots.svg deleted file mode 100644 index b4053af..0000000 --- a/assets/icons/azure/compute/10026-icon-service-Disks-Snapshots.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-compute-26 \ No newline at end of file diff --git a/assets/icons/azure/compute/10027-icon-service-OS-Images-(Classic).svg b/assets/icons/azure/compute/10027-icon-service-OS-Images-(Classic).svg deleted file mode 100644 index c37d878..0000000 --- a/assets/icons/azure/compute/10027-icon-service-OS-Images-(Classic).svg +++ /dev/null @@ -1 +0,0 @@ -Icon-compute-27 \ No newline at end of file diff --git a/assets/icons/azure/compute/10028-icon-service-Virtual-Machines-(Classic).svg b/assets/icons/azure/compute/10028-icon-service-Virtual-Machines-(Classic).svg deleted file mode 100644 index 680e2ac..0000000 --- a/assets/icons/azure/compute/10028-icon-service-Virtual-Machines-(Classic).svg +++ /dev/null @@ -1 +0,0 @@ -Icon-compute-28 \ No newline at end of file diff --git a/assets/icons/azure/compute/10029-icon-service-Function-Apps.svg b/assets/icons/azure/compute/10029-icon-service-Function-Apps.svg deleted file mode 100644 index 1ecacdf..0000000 --- a/assets/icons/azure/compute/10029-icon-service-Function-Apps.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-compute-29 \ No newline at end of file diff --git a/assets/icons/azure/compute/10030-icon-service-Cloud-Services-(Classic).svg b/assets/icons/azure/compute/10030-icon-service-Cloud-Services-(Classic).svg deleted file mode 100644 index 9012ef6..0000000 --- a/assets/icons/azure/compute/10030-icon-service-Cloud-Services-(Classic).svg +++ /dev/null @@ -1 +0,0 @@ -Icon-compute-30 \ No newline at end of file diff --git a/assets/icons/azure/compute/10031-icon-service-Batch-Accounts.svg b/assets/icons/azure/compute/10031-icon-service-Batch-Accounts.svg deleted file mode 100644 index 671d575..0000000 --- a/assets/icons/azure/compute/10031-icon-service-Batch-Accounts.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/compute/10032-icon-service-Disks.svg b/assets/icons/azure/compute/10032-icon-service-Disks.svg deleted file mode 100644 index 519c968..0000000 --- a/assets/icons/azure/compute/10032-icon-service-Disks.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-compute-32 \ No newline at end of file diff --git a/assets/icons/azure/compute/10033-icon-service-Images.svg b/assets/icons/azure/compute/10033-icon-service-Images.svg deleted file mode 100644 index 89e2159..0000000 --- a/assets/icons/azure/compute/10033-icon-service-Images.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-compute-33 \ No newline at end of file diff --git a/assets/icons/azure/compute/10034-icon-service-VM-Scale-Sets.svg b/assets/icons/azure/compute/10034-icon-service-VM-Scale-Sets.svg deleted file mode 100644 index cfb023d..0000000 --- a/assets/icons/azure/compute/10034-icon-service-VM-Scale-Sets.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-compute-34 \ No newline at end of file diff --git a/assets/icons/azure/compute/10035-icon-service-App-Services.svg b/assets/icons/azure/compute/10035-icon-service-App-Services.svg deleted file mode 100644 index 022ef80..0000000 --- a/assets/icons/azure/compute/10035-icon-service-App-Services.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-web-41 \ No newline at end of file diff --git a/assets/icons/azure/compute/10036-icon-service-Service-Fabric-Clusters.svg b/assets/icons/azure/compute/10036-icon-service-Service-Fabric-Clusters.svg deleted file mode 100644 index d938fc2..0000000 --- a/assets/icons/azure/compute/10036-icon-service-Service-Fabric-Clusters.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/compute/10037-icon-service-Image-Definitions.svg b/assets/icons/azure/compute/10037-icon-service-Image-Definitions.svg deleted file mode 100644 index 7645529..0000000 --- a/assets/icons/azure/compute/10037-icon-service-Image-Definitions.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-compute-37 \ No newline at end of file diff --git a/assets/icons/azure/compute/10038-icon-service-Image-Versions.svg b/assets/icons/azure/compute/10038-icon-service-Image-Versions.svg deleted file mode 100644 index a1fb698..0000000 --- a/assets/icons/azure/compute/10038-icon-service-Image-Versions.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-compute-38 \ No newline at end of file diff --git a/assets/icons/azure/compute/10039-icon-service-Shared-Image-Galleries.svg b/assets/icons/azure/compute/10039-icon-service-Shared-Image-Galleries.svg deleted file mode 100644 index 21cc31c..0000000 --- a/assets/icons/azure/compute/10039-icon-service-Shared-Image-Galleries.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-compute-39 \ No newline at end of file diff --git a/assets/icons/azure/compute/10040-icon-service-VM-Images-(Classic).svg b/assets/icons/azure/compute/10040-icon-service-VM-Images-(Classic).svg deleted file mode 100644 index c37d878..0000000 --- a/assets/icons/azure/compute/10040-icon-service-VM-Images-(Classic).svg +++ /dev/null @@ -1 +0,0 @@ -Icon-compute-27 \ No newline at end of file diff --git a/assets/icons/azure/compute/10041-icon-service-Disks-(Classic).svg b/assets/icons/azure/compute/10041-icon-service-Disks-(Classic).svg deleted file mode 100644 index 519c968..0000000 --- a/assets/icons/azure/compute/10041-icon-service-Disks-(Classic).svg +++ /dev/null @@ -1 +0,0 @@ -Icon-compute-32 \ No newline at end of file diff --git a/assets/icons/azure/compute/10049-icon-service-Container-Services-(Deprecated).svg b/assets/icons/azure/compute/10049-icon-service-Container-Services-(Deprecated).svg deleted file mode 100644 index 2e597c4..0000000 --- a/assets/icons/azure/compute/10049-icon-service-Container-Services-(Deprecated).svg +++ /dev/null @@ -1 +0,0 @@ -Icon-compute-23 \ No newline at end of file diff --git a/assets/icons/azure/compute/10104-icon-service-Container-Instances.svg b/assets/icons/azure/compute/10104-icon-service-Container-Instances.svg deleted file mode 100644 index 52567b9..0000000 --- a/assets/icons/azure/compute/10104-icon-service-Container-Instances.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-containers-104 \ No newline at end of file diff --git a/assets/icons/azure/compute/10346-icon-service-Host-Groups.svg b/assets/icons/azure/compute/10346-icon-service-Host-Groups.svg deleted file mode 100644 index 63c343a..0000000 --- a/assets/icons/azure/compute/10346-icon-service-Host-Groups.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-other-346 \ No newline at end of file diff --git a/assets/icons/azure/compute/10347-icon-service-Hosts.svg b/assets/icons/azure/compute/10347-icon-service-Hosts.svg deleted file mode 100644 index 5b305f9..0000000 --- a/assets/icons/azure/compute/10347-icon-service-Hosts.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-other-347 \ No newline at end of file diff --git a/assets/icons/azure/compute/10370-icon-service-Azure-Spring-Apps.svg b/assets/icons/azure/compute/10370-icon-service-Azure-Spring-Apps.svg deleted file mode 100644 index 7dfc2c7..0000000 --- a/assets/icons/azure/compute/10370-icon-service-Azure-Spring-Apps.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/containers/029637767-icon-service-Service-Groups.svg b/assets/icons/azure/containers/029637767-icon-service-Service-Groups.svg deleted file mode 100644 index 1097496..0000000 --- a/assets/icons/azure/containers/029637767-icon-service-Service-Groups.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/containers/032487601-icon-service-AKS-Network-Policy.svg b/assets/icons/azure/containers/032487601-icon-service-AKS-Network-Policy.svg deleted file mode 100644 index f75dbf0..0000000 --- a/assets/icons/azure/containers/032487601-icon-service-AKS-Network-Policy.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/containers/03331-icon-service-Azure-Red-Hat-OpenShift.svg b/assets/icons/azure/containers/03331-icon-service-Azure-Red-Hat-OpenShift.svg deleted file mode 100644 index 0e6109d..0000000 --- a/assets/icons/azure/containers/03331-icon-service-Azure-Red-Hat-OpenShift.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/containers/033545113-icon-service-Kubernetes-Hub.svg b/assets/icons/azure/containers/033545113-icon-service-Kubernetes-Hub.svg deleted file mode 100644 index 22524ec..0000000 --- a/assets/icons/azure/containers/033545113-icon-service-Kubernetes-Hub.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/containers/03401-icon-service-Azure-Container-Storage.svg b/assets/icons/azure/containers/03401-icon-service-Azure-Container-Storage.svg deleted file mode 100644 index 793250f..0000000 --- a/assets/icons/azure/containers/03401-icon-service-Azure-Container-Storage.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/containers/10023-icon-service-Kubernetes-Services.svg b/assets/icons/azure/containers/10023-icon-service-Kubernetes-Services.svg deleted file mode 100644 index 2e597c4..0000000 --- a/assets/icons/azure/containers/10023-icon-service-Kubernetes-Services.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-compute-23 \ No newline at end of file diff --git a/assets/icons/azure/containers/10031-icon-service-Batch-Accounts.svg b/assets/icons/azure/containers/10031-icon-service-Batch-Accounts.svg deleted file mode 100644 index 671d575..0000000 --- a/assets/icons/azure/containers/10031-icon-service-Batch-Accounts.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/containers/10035-icon-service-App-Services.svg b/assets/icons/azure/containers/10035-icon-service-App-Services.svg deleted file mode 100644 index 022ef80..0000000 --- a/assets/icons/azure/containers/10035-icon-service-App-Services.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-web-41 \ No newline at end of file diff --git a/assets/icons/azure/containers/10036-icon-service-Service-Fabric-Clusters.svg b/assets/icons/azure/containers/10036-icon-service-Service-Fabric-Clusters.svg deleted file mode 100644 index d938fc2..0000000 --- a/assets/icons/azure/containers/10036-icon-service-Service-Fabric-Clusters.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/containers/10104-icon-service-Container-Instances.svg b/assets/icons/azure/containers/10104-icon-service-Container-Instances.svg deleted file mode 100644 index 52567b9..0000000 --- a/assets/icons/azure/containers/10104-icon-service-Container-Instances.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-containers-104 \ No newline at end of file diff --git a/assets/icons/azure/containers/10105-icon-service-Container-Registries.svg b/assets/icons/azure/containers/10105-icon-service-Container-Registries.svg deleted file mode 100644 index 10de958..0000000 --- a/assets/icons/azure/containers/10105-icon-service-Container-Registries.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/databases/00036-icon-service-SQL-Data-Warehouses.svg b/assets/icons/azure/databases/00036-icon-service-SQL-Data-Warehouses.svg deleted file mode 100644 index a7e5dc4..0000000 --- a/assets/icons/azure/databases/00036-icon-service-SQL-Data-Warehouses.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-databases-125 \ No newline at end of file diff --git a/assets/icons/azure/databases/00606-icon-service-Azure-Synapse-Analytics.svg b/assets/icons/azure/databases/00606-icon-service-Azure-Synapse-Analytics.svg deleted file mode 100644 index 235283b..0000000 --- a/assets/icons/azure/databases/00606-icon-service-Azure-Synapse-Analytics.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/databases/02390-icon-service-Azure-SQL.svg b/assets/icons/azure/databases/02390-icon-service-Azure-SQL.svg deleted file mode 100644 index be88c9c..0000000 --- a/assets/icons/azure/databases/02390-icon-service-Azure-SQL.svg +++ /dev/null @@ -1,2 +0,0 @@ -​ - \ No newline at end of file diff --git a/assets/icons/azure/databases/02392-icon-service-SSIS-Lift-And-Shift-IR.svg b/assets/icons/azure/databases/02392-icon-service-SSIS-Lift-And-Shift-IR.svg deleted file mode 100644 index b94a064..0000000 --- a/assets/icons/azure/databases/02392-icon-service-SSIS-Lift-And-Shift-IR.svg +++ /dev/null @@ -1,2 +0,0 @@ -​ - \ No newline at end of file diff --git a/assets/icons/azure/databases/02517-icon-service-Azure-Purview-Accounts.svg b/assets/icons/azure/databases/02517-icon-service-Azure-Purview-Accounts.svg deleted file mode 100644 index fb1df63..0000000 --- a/assets/icons/azure/databases/02517-icon-service-Azure-Purview-Accounts.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/databases/02750-icon-service-Azure-SQL-Edge.svg b/assets/icons/azure/databases/02750-icon-service-Azure-SQL-Edge.svg deleted file mode 100644 index 7b5628b..0000000 --- a/assets/icons/azure/databases/02750-icon-service-Azure-SQL-Edge.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/databases/02827-icon-service-Azure-Database-PostgreSQL-Server-Group.svg b/assets/icons/azure/databases/02827-icon-service-Azure-Database-PostgreSQL-Server-Group.svg deleted file mode 100644 index cf6c233..0000000 --- a/assets/icons/azure/databases/02827-icon-service-Azure-Database-PostgreSQL-Server-Group.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/databases/03490-icon-service-Oracle-Database.svg b/assets/icons/azure/databases/03490-icon-service-Oracle-Database.svg deleted file mode 100644 index 4d5181f..0000000 --- a/assets/icons/azure/databases/03490-icon-service-Oracle-Database.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/databases/03565-icon-service-SQL-Database-Fleet-Manager.svg b/assets/icons/azure/databases/03565-icon-service-SQL-Database-Fleet-Manager.svg deleted file mode 100644 index ae1eef0..0000000 --- a/assets/icons/azure/databases/03565-icon-service-SQL-Database-Fleet-Manager.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/databases/035656437-icon-service-Azure-DocumentDB.svg b/assets/icons/azure/databases/035656437-icon-service-Azure-DocumentDB.svg deleted file mode 100644 index c295d8d..0000000 --- a/assets/icons/azure/databases/035656437-icon-service-Azure-DocumentDB.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/databases/03675-icon-service-Azure-Managed-Redis.svg b/assets/icons/azure/databases/03675-icon-service-Azure-Managed-Redis.svg deleted file mode 100644 index 87d61df..0000000 --- a/assets/icons/azure/databases/03675-icon-service-Azure-Managed-Redis.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/databases/10121-icon-service-Azure-Cosmos-DB.svg b/assets/icons/azure/databases/10121-icon-service-Azure-Cosmos-DB.svg deleted file mode 100644 index ecb1792..0000000 --- a/assets/icons/azure/databases/10121-icon-service-Azure-Cosmos-DB.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-databases-121 \ No newline at end of file diff --git a/assets/icons/azure/databases/10122-icon-service-Azure-Database-MySQL-Server.svg b/assets/icons/azure/databases/10122-icon-service-Azure-Database-MySQL-Server.svg deleted file mode 100644 index d1848c0..0000000 --- a/assets/icons/azure/databases/10122-icon-service-Azure-Database-MySQL-Server.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-databases-122 \ No newline at end of file diff --git a/assets/icons/azure/databases/10123-icon-service-Azure-Database-MariaDB-Server.svg b/assets/icons/azure/databases/10123-icon-service-Azure-Database-MariaDB-Server.svg deleted file mode 100644 index 224066c..0000000 --- a/assets/icons/azure/databases/10123-icon-service-Azure-Database-MariaDB-Server.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-databases-123 \ No newline at end of file diff --git a/assets/icons/azure/databases/10124-icon-service-Azure-SQL-VM.svg b/assets/icons/azure/databases/10124-icon-service-Azure-SQL-VM.svg deleted file mode 100644 index fe1416a..0000000 --- a/assets/icons/azure/databases/10124-icon-service-Azure-SQL-VM.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-databases-124 \ No newline at end of file diff --git a/assets/icons/azure/databases/10126-icon-service-Data-Factories.svg b/assets/icons/azure/databases/10126-icon-service-Data-Factories.svg deleted file mode 100644 index 2237336..0000000 --- a/assets/icons/azure/databases/10126-icon-service-Data-Factories.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-databases-126 \ No newline at end of file diff --git a/assets/icons/azure/databases/10127-icon-service-Virtual-Clusters.svg b/assets/icons/azure/databases/10127-icon-service-Virtual-Clusters.svg deleted file mode 100644 index 1d6c70d..0000000 --- a/assets/icons/azure/databases/10127-icon-service-Virtual-Clusters.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-databases-127 \ No newline at end of file diff --git a/assets/icons/azure/databases/10128-icon-service-Elastic-Job-Agents.svg b/assets/icons/azure/databases/10128-icon-service-Elastic-Job-Agents.svg deleted file mode 100644 index 9761f8b..0000000 --- a/assets/icons/azure/databases/10128-icon-service-Elastic-Job-Agents.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-databases-128 \ No newline at end of file diff --git a/assets/icons/azure/databases/10130-icon-service-SQL-Database.svg b/assets/icons/azure/databases/10130-icon-service-SQL-Database.svg deleted file mode 100644 index 4271bd1..0000000 --- a/assets/icons/azure/databases/10130-icon-service-SQL-Database.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-databases-130 \ No newline at end of file diff --git a/assets/icons/azure/databases/10131-icon-service-Azure-Database-PostgreSQL-Server.svg b/assets/icons/azure/databases/10131-icon-service-Azure-Database-PostgreSQL-Server.svg deleted file mode 100644 index 8d3d0dd..0000000 --- a/assets/icons/azure/databases/10131-icon-service-Azure-Database-PostgreSQL-Server.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-databases-131 \ No newline at end of file diff --git a/assets/icons/azure/databases/10132-icon-service-SQL-Server.svg b/assets/icons/azure/databases/10132-icon-service-SQL-Server.svg deleted file mode 100644 index 0edc84a..0000000 --- a/assets/icons/azure/databases/10132-icon-service-SQL-Server.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-databases-132 \ No newline at end of file diff --git a/assets/icons/azure/databases/10133-icon-service-Azure-Database-Migration-Services.svg b/assets/icons/azure/databases/10133-icon-service-Azure-Database-Migration-Services.svg deleted file mode 100644 index f97cc9f..0000000 --- a/assets/icons/azure/databases/10133-icon-service-Azure-Database-Migration-Services.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-databases-133 \ No newline at end of file diff --git a/assets/icons/azure/databases/10134-icon-service-SQL-Elastic-Pools.svg b/assets/icons/azure/databases/10134-icon-service-SQL-Elastic-Pools.svg deleted file mode 100644 index 9c3e94a..0000000 --- a/assets/icons/azure/databases/10134-icon-service-SQL-Elastic-Pools.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-databases-134 \ No newline at end of file diff --git a/assets/icons/azure/databases/10135-icon-service-Managed-Database.svg b/assets/icons/azure/databases/10135-icon-service-Managed-Database.svg deleted file mode 100644 index bf719ea..0000000 --- a/assets/icons/azure/databases/10135-icon-service-Managed-Database.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-databases-135 \ No newline at end of file diff --git a/assets/icons/azure/databases/10136-icon-service-SQL-Managed-Instance.svg b/assets/icons/azure/databases/10136-icon-service-SQL-Managed-Instance.svg deleted file mode 100644 index 4727562..0000000 --- a/assets/icons/azure/databases/10136-icon-service-SQL-Managed-Instance.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-databases-136 \ No newline at end of file diff --git a/assets/icons/azure/databases/10137-icon-service-Azure-SQL-Server-Stretch-Databases.svg b/assets/icons/azure/databases/10137-icon-service-Azure-SQL-Server-Stretch-Databases.svg deleted file mode 100644 index a7e5dc4..0000000 --- a/assets/icons/azure/databases/10137-icon-service-Azure-SQL-Server-Stretch-Databases.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-databases-125 \ No newline at end of file diff --git a/assets/icons/azure/databases/10137-icon-service-Cache-Redis.svg b/assets/icons/azure/databases/10137-icon-service-Cache-Redis.svg deleted file mode 100644 index ded7050..0000000 --- a/assets/icons/azure/databases/10137-icon-service-Cache-Redis.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-databases-137 \ No newline at end of file diff --git a/assets/icons/azure/databases/10139-icon-service-Instance-Pools.svg b/assets/icons/azure/databases/10139-icon-service-Instance-Pools.svg deleted file mode 100644 index c6862a1..0000000 --- a/assets/icons/azure/databases/10139-icon-service-Instance-Pools.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-databases-138 \ No newline at end of file diff --git a/assets/icons/azure/databases/10145-icon-service-Azure-Data-Explorer-Clusters.svg b/assets/icons/azure/databases/10145-icon-service-Azure-Data-Explorer-Clusters.svg deleted file mode 100644 index efc908f..0000000 --- a/assets/icons/azure/databases/10145-icon-service-Azure-Data-Explorer-Clusters.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-analytics-145 \ No newline at end of file diff --git a/assets/icons/azure/databases/10351-icon-service-SQL-Server-Registries.svg b/assets/icons/azure/databases/10351-icon-service-SQL-Server-Registries.svg deleted file mode 100644 index b1efb3d..0000000 --- a/assets/icons/azure/databases/10351-icon-service-SQL-Server-Registries.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-other-351 \ No newline at end of file diff --git a/assets/icons/azure/developer-tools/033805167-icon-service-Azure-App-Testing.svg b/assets/icons/azure/developer-tools/033805167-icon-service-Azure-App-Testing.svg deleted file mode 100644 index ab46e57..0000000 --- a/assets/icons/azure/developer-tools/033805167-icon-service-Azure-App-Testing.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/devops/00012-icon-service-Application-Insights.svg b/assets/icons/azure/devops/00012-icon-service-Application-Insights.svg deleted file mode 100644 index 19f3e69..0000000 --- a/assets/icons/azure/devops/00012-icon-service-Application-Insights.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-manage-310 \ No newline at end of file diff --git a/assets/icons/azure/devops/00563-icon-service-Change-Analysis.svg b/assets/icons/azure/devops/00563-icon-service-Change-Analysis.svg deleted file mode 100644 index 23ce777..0000000 --- a/assets/icons/azure/devops/00563-icon-service-Change-Analysis.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/devops/02373-icon-service-CloudTest.svg b/assets/icons/azure/devops/02373-icon-service-CloudTest.svg deleted file mode 100644 index 22865ff..0000000 --- a/assets/icons/azure/devops/02373-icon-service-CloudTest.svg +++ /dev/null @@ -1,2 +0,0 @@ -​ - \ No newline at end of file diff --git a/assets/icons/azure/devops/02423-icon-service-Load-Testing.svg b/assets/icons/azure/devops/02423-icon-service-Load-Testing.svg deleted file mode 100644 index ecd8212..0000000 --- a/assets/icons/azure/devops/02423-icon-service-Load-Testing.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/devops/02761-icon-service-Lab-Accounts.svg b/assets/icons/azure/devops/02761-icon-service-Lab-Accounts.svg deleted file mode 100644 index 1b3d3fe..0000000 --- a/assets/icons/azure/devops/02761-icon-service-Lab-Accounts.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/devops/03339-icon-service-DevOps-Starter.svg b/assets/icons/azure/devops/03339-icon-service-DevOps-Starter.svg deleted file mode 100644 index 9cef9c8..0000000 --- a/assets/icons/azure/devops/03339-icon-service-DevOps-Starter.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/devops/03393-icon-service-Managed-DevOps-Pools.svg b/assets/icons/azure/devops/03393-icon-service-Managed-DevOps-Pools.svg deleted file mode 100644 index 6892f46..0000000 --- a/assets/icons/azure/devops/03393-icon-service-Managed-DevOps-Pools.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/devops/03455-icon-service-Code-Optimization.svg b/assets/icons/azure/devops/03455-icon-service-Code-Optimization.svg deleted file mode 100644 index 5eb0a02..0000000 --- a/assets/icons/azure/devops/03455-icon-service-Code-Optimization.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/devops/03623-icon-service-Workspace-Gateway.svg b/assets/icons/azure/devops/03623-icon-service-Workspace-Gateway.svg deleted file mode 100644 index 8c30453..0000000 --- a/assets/icons/azure/devops/03623-icon-service-Workspace-Gateway.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/devops/10042-icon-service-API-Management-Services.svg b/assets/icons/azure/devops/10042-icon-service-API-Management-Services.svg deleted file mode 100644 index 69f5998..0000000 --- a/assets/icons/azure/devops/10042-icon-service-API-Management-Services.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-web-42 \ No newline at end of file diff --git a/assets/icons/azure/devops/10048-icon-service-API-Connections.svg b/assets/icons/azure/devops/10048-icon-service-API-Connections.svg deleted file mode 100644 index a170813..0000000 --- a/assets/icons/azure/devops/10048-icon-service-API-Connections.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-web-48 \ No newline at end of file diff --git a/assets/icons/azure/devops/10261-icon-service-Azure-DevOps.svg b/assets/icons/azure/devops/10261-icon-service-Azure-DevOps.svg deleted file mode 100644 index 3d4a462..0000000 --- a/assets/icons/azure/devops/10261-icon-service-Azure-DevOps.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-devops-261 \ No newline at end of file diff --git a/assets/icons/azure/devops/10264-icon-service-DevTest-Labs.svg b/assets/icons/azure/devops/10264-icon-service-DevTest-Labs.svg deleted file mode 100644 index 4e7720a..0000000 --- a/assets/icons/azure/devops/10264-icon-service-DevTest-Labs.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-devops-264 \ No newline at end of file diff --git a/assets/icons/azure/devops/10265-icon-service-Lab-Services.svg b/assets/icons/azure/devops/10265-icon-service-Lab-Services.svg deleted file mode 100644 index f99c6c4..0000000 --- a/assets/icons/azure/devops/10265-icon-service-Lab-Services.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-devops-265 \ No newline at end of file diff --git a/assets/icons/azure/general/00004-icon-service-Cost-Management-and-Billing.svg b/assets/icons/azure/general/00004-icon-service-Cost-Management-and-Billing.svg deleted file mode 100644 index 6785ed2..0000000 --- a/assets/icons/azure/general/00004-icon-service-Cost-Management-and-Billing.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/general/00456-icon-service-Preview-Features.svg b/assets/icons/azure/general/00456-icon-service-Preview-Features.svg deleted file mode 100644 index 90b7c2e..0000000 --- a/assets/icons/azure/general/00456-icon-service-Preview-Features.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/general/10001-icon-service-All-Resources.svg b/assets/icons/azure/general/10001-icon-service-All-Resources.svg deleted file mode 100644 index 15bb43b..0000000 --- a/assets/icons/azure/general/10001-icon-service-All-Resources.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-general-1 \ No newline at end of file diff --git a/assets/icons/azure/general/10002-icon-service-Subscriptions.svg b/assets/icons/azure/general/10002-icon-service-Subscriptions.svg deleted file mode 100644 index 26c11b6..0000000 --- a/assets/icons/azure/general/10002-icon-service-Subscriptions.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-general-2 \ No newline at end of file diff --git a/assets/icons/azure/general/10003-icon-service-Reservations.svg b/assets/icons/azure/general/10003-icon-service-Reservations.svg deleted file mode 100644 index 47bccf9..0000000 --- a/assets/icons/azure/general/10003-icon-service-Reservations.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-general-3 \ No newline at end of file diff --git a/assets/icons/azure/general/10004-icon-service-Service-Health.svg b/assets/icons/azure/general/10004-icon-service-Service-Health.svg deleted file mode 100644 index 228362c..0000000 --- a/assets/icons/azure/general/10004-icon-service-Service-Health.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-general-4 \ No newline at end of file diff --git a/assets/icons/azure/general/10005-icon-service-Information.svg b/assets/icons/azure/general/10005-icon-service-Information.svg deleted file mode 100644 index 4cf5f95..0000000 --- a/assets/icons/azure/general/10005-icon-service-Information.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-general-5 \ No newline at end of file diff --git a/assets/icons/azure/general/10006-icon-service-Recent.svg b/assets/icons/azure/general/10006-icon-service-Recent.svg deleted file mode 100644 index ee0688d..0000000 --- a/assets/icons/azure/general/10006-icon-service-Recent.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-general-6 \ No newline at end of file diff --git a/assets/icons/azure/general/10007-icon-service-Resource-Groups.svg b/assets/icons/azure/general/10007-icon-service-Resource-Groups.svg deleted file mode 100644 index 659900d..0000000 --- a/assets/icons/azure/general/10007-icon-service-Resource-Groups.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-general-7 \ No newline at end of file diff --git a/assets/icons/azure/general/10008-icon-service-Marketplace.svg b/assets/icons/azure/general/10008-icon-service-Marketplace.svg deleted file mode 100644 index f732ca1..0000000 --- a/assets/icons/azure/general/10008-icon-service-Marketplace.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-general-8 \ No newline at end of file diff --git a/assets/icons/azure/general/10009-icon-service-Templates.svg b/assets/icons/azure/general/10009-icon-service-Templates.svg deleted file mode 100644 index 89134d2..0000000 --- a/assets/icons/azure/general/10009-icon-service-Templates.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-general-9 \ No newline at end of file diff --git a/assets/icons/azure/general/10010-icon-service-Quickstart-Center.svg b/assets/icons/azure/general/10010-icon-service-Quickstart-Center.svg deleted file mode 100644 index c76394a..0000000 --- a/assets/icons/azure/general/10010-icon-service-Quickstart-Center.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-general-10 \ No newline at end of file diff --git a/assets/icons/azure/general/10011-icon-service-Management-Groups.svg b/assets/icons/azure/general/10011-icon-service-Management-Groups.svg deleted file mode 100644 index 804dc05..0000000 --- a/assets/icons/azure/general/10011-icon-service-Management-Groups.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-general-11 \ No newline at end of file diff --git a/assets/icons/azure/general/10013-icon-service-Help-and-Support.svg b/assets/icons/azure/general/10013-icon-service-Help-and-Support.svg deleted file mode 100644 index 5343596..0000000 --- a/assets/icons/azure/general/10013-icon-service-Help-and-Support.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-general-13 \ No newline at end of file diff --git a/assets/icons/azure/general/10014-icon-service-Tag.svg b/assets/icons/azure/general/10014-icon-service-Tag.svg deleted file mode 100644 index a0a1585..0000000 --- a/assets/icons/azure/general/10014-icon-service-Tag.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/general/10015-icon-service-Dashboard.svg b/assets/icons/azure/general/10015-icon-service-Dashboard.svg deleted file mode 100644 index c31abfc..0000000 --- a/assets/icons/azure/general/10015-icon-service-Dashboard.svg +++ /dev/null @@ -1,2 +0,0 @@ -​ - \ No newline at end of file diff --git a/assets/icons/azure/general/10016-icon-service-Free-Services.svg b/assets/icons/azure/general/10016-icon-service-Free-Services.svg deleted file mode 100644 index 81daf9a..0000000 --- a/assets/icons/azure/general/10016-icon-service-Free-Services.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-general-16 \ No newline at end of file diff --git a/assets/icons/azure/general/10019-icon-service-Cost-Management.svg b/assets/icons/azure/general/10019-icon-service-Cost-Management.svg deleted file mode 100644 index c5a6aeb..0000000 --- a/assets/icons/azure/general/10019-icon-service-Cost-Management.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-general-19 \ No newline at end of file diff --git a/assets/icons/azure/general/10112-icon-service-Marketplace-Management.svg b/assets/icons/azure/general/10112-icon-service-Marketplace-Management.svg deleted file mode 100644 index e5c9716..0000000 --- a/assets/icons/azure/general/10112-icon-service-Marketplace-Management.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-azurestack-5 \ No newline at end of file diff --git a/assets/icons/azure/general/10116-icon-service-Region-Management.svg b/assets/icons/azure/general/10116-icon-service-Region-Management.svg deleted file mode 100644 index a1adfbb..0000000 --- a/assets/icons/azure/general/10116-icon-service-Region-Management.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/general/10341-icon-service-Troubleshoot.svg b/assets/icons/azure/general/10341-icon-service-Troubleshoot.svg deleted file mode 100644 index 520093b..0000000 --- a/assets/icons/azure/general/10341-icon-service-Troubleshoot.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-intune-341 \ No newline at end of file diff --git a/assets/icons/azure/general/10349-icon-service-Resource-Explorer.svg b/assets/icons/azure/general/10349-icon-service-Resource-Explorer.svg deleted file mode 100644 index fb6b1d0..0000000 --- a/assets/icons/azure/general/10349-icon-service-Resource-Explorer.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/general/10779-icon-service-Biz-Talk.svg b/assets/icons/azure/general/10779-icon-service-Biz-Talk.svg deleted file mode 100644 index 8692adc..0000000 --- a/assets/icons/azure/general/10779-icon-service-Biz-Talk.svg +++ /dev/null @@ -1 +0,0 @@ -MsPortalFx.base.images-6 \ No newline at end of file diff --git a/assets/icons/azure/general/10780-icon-service-Blob-Block.svg b/assets/icons/azure/general/10780-icon-service-Blob-Block.svg deleted file mode 100644 index b1126cd..0000000 --- a/assets/icons/azure/general/10780-icon-service-Blob-Block.svg +++ /dev/null @@ -1 +0,0 @@ -MsPortalFx.base.images-7 \ No newline at end of file diff --git a/assets/icons/azure/general/10781-icon-service-Blob-Page.svg b/assets/icons/azure/general/10781-icon-service-Blob-Page.svg deleted file mode 100644 index 0700357..0000000 --- a/assets/icons/azure/general/10781-icon-service-Blob-Page.svg +++ /dev/null @@ -1 +0,0 @@ -MsPortalFx.base.images-8 \ No newline at end of file diff --git a/assets/icons/azure/general/10782-icon-service-Branch.svg b/assets/icons/azure/general/10782-icon-service-Branch.svg deleted file mode 100644 index 4f05da6..0000000 --- a/assets/icons/azure/general/10782-icon-service-Branch.svg +++ /dev/null @@ -1 +0,0 @@ -MsPortalFx.base.images-14 \ No newline at end of file diff --git a/assets/icons/azure/general/10783-icon-service-Browser.svg b/assets/icons/azure/general/10783-icon-service-Browser.svg deleted file mode 100644 index a160ac7..0000000 --- a/assets/icons/azure/general/10783-icon-service-Browser.svg +++ /dev/null @@ -1 +0,0 @@ -MsPortalFx.base.images-15 \ No newline at end of file diff --git a/assets/icons/azure/general/10784-icon-service-Bug.svg b/assets/icons/azure/general/10784-icon-service-Bug.svg deleted file mode 100644 index 3c28219..0000000 --- a/assets/icons/azure/general/10784-icon-service-Bug.svg +++ /dev/null @@ -1 +0,0 @@ -MsPortalFx.base.images-75 \ No newline at end of file diff --git a/assets/icons/azure/general/10785-icon-service-Builds.svg b/assets/icons/azure/general/10785-icon-service-Builds.svg deleted file mode 100644 index 2e11d93..0000000 --- a/assets/icons/azure/general/10785-icon-service-Builds.svg +++ /dev/null @@ -1 +0,0 @@ -MsPortalFx.base.images-33 \ No newline at end of file diff --git a/assets/icons/azure/general/10786-icon-service-Cache.svg b/assets/icons/azure/general/10786-icon-service-Cache.svg deleted file mode 100644 index 6b040ad..0000000 --- a/assets/icons/azure/general/10786-icon-service-Cache.svg +++ /dev/null @@ -1 +0,0 @@ -MsPortalFx.base.images-34 \ No newline at end of file diff --git a/assets/icons/azure/general/10787-icon-service-Code.svg b/assets/icons/azure/general/10787-icon-service-Code.svg deleted file mode 100644 index cb37ec1..0000000 --- a/assets/icons/azure/general/10787-icon-service-Code.svg +++ /dev/null @@ -1 +0,0 @@ -MsPortalFx.base.images-27 \ No newline at end of file diff --git a/assets/icons/azure/general/10788-icon-service-Commit.svg b/assets/icons/azure/general/10788-icon-service-Commit.svg deleted file mode 100644 index eaf1dee..0000000 --- a/assets/icons/azure/general/10788-icon-service-Commit.svg +++ /dev/null @@ -1 +0,0 @@ -MsPortalFx.base.images-28 \ No newline at end of file diff --git a/assets/icons/azure/general/10789-icon-service-Controls.svg b/assets/icons/azure/general/10789-icon-service-Controls.svg deleted file mode 100644 index b9d6d9c..0000000 --- a/assets/icons/azure/general/10789-icon-service-Controls.svg +++ /dev/null @@ -1 +0,0 @@ -MsPortalFx.base.images-29 \ No newline at end of file diff --git a/assets/icons/azure/general/10790-icon-service-Controls-Horizontal.svg b/assets/icons/azure/general/10790-icon-service-Controls-Horizontal.svg deleted file mode 100644 index 20c2653..0000000 --- a/assets/icons/azure/general/10790-icon-service-Controls-Horizontal.svg +++ /dev/null @@ -1 +0,0 @@ -MsPortalFx.base.images-30 \ No newline at end of file diff --git a/assets/icons/azure/general/10791-icon-service-Cost-Alerts.svg b/assets/icons/azure/general/10791-icon-service-Cost-Alerts.svg deleted file mode 100644 index dd2bbef..0000000 --- a/assets/icons/azure/general/10791-icon-service-Cost-Alerts.svg +++ /dev/null @@ -1 +0,0 @@ -MsPortalFx.base.images-31 \ No newline at end of file diff --git a/assets/icons/azure/general/10792-icon-service-Cost-Analysis.svg b/assets/icons/azure/general/10792-icon-service-Cost-Analysis.svg deleted file mode 100644 index cdc543c..0000000 --- a/assets/icons/azure/general/10792-icon-service-Cost-Analysis.svg +++ /dev/null @@ -1 +0,0 @@ -MsPortalFx.base.images-32 \ No newline at end of file diff --git a/assets/icons/azure/general/10793-icon-service-Cost-Budgets.svg b/assets/icons/azure/general/10793-icon-service-Cost-Budgets.svg deleted file mode 100644 index 5fb9bf8..0000000 --- a/assets/icons/azure/general/10793-icon-service-Cost-Budgets.svg +++ /dev/null @@ -1 +0,0 @@ -MsPortalFx.base.images-11 \ No newline at end of file diff --git a/assets/icons/azure/general/10794-icon-service-Counter.svg b/assets/icons/azure/general/10794-icon-service-Counter.svg deleted file mode 100644 index 1745cdf..0000000 --- a/assets/icons/azure/general/10794-icon-service-Counter.svg +++ /dev/null @@ -1 +0,0 @@ -MsPortalFx.base.images-12 \ No newline at end of file diff --git a/assets/icons/azure/general/10795-icon-service-Cubes.svg b/assets/icons/azure/general/10795-icon-service-Cubes.svg deleted file mode 100644 index 7f016e0..0000000 --- a/assets/icons/azure/general/10795-icon-service-Cubes.svg +++ /dev/null @@ -1 +0,0 @@ -MsPortalFx.base.images-18 \ No newline at end of file diff --git a/assets/icons/azure/general/10796-icon-service-Dev-Console.svg b/assets/icons/azure/general/10796-icon-service-Dev-Console.svg deleted file mode 100644 index d3418a2..0000000 --- a/assets/icons/azure/general/10796-icon-service-Dev-Console.svg +++ /dev/null @@ -1 +0,0 @@ -MsPortalFx.base.images-38 \ No newline at end of file diff --git a/assets/icons/azure/general/10797-icon-service-Download.svg b/assets/icons/azure/general/10797-icon-service-Download.svg deleted file mode 100644 index 539ac46..0000000 --- a/assets/icons/azure/general/10797-icon-service-Download.svg +++ /dev/null @@ -1 +0,0 @@ -MsPortalFx.base.images-16 \ No newline at end of file diff --git a/assets/icons/azure/general/10798-icon-service-Error.svg b/assets/icons/azure/general/10798-icon-service-Error.svg deleted file mode 100644 index 5d54ab1..0000000 --- a/assets/icons/azure/general/10798-icon-service-Error.svg +++ /dev/null @@ -1 +0,0 @@ -MsPortalFx.base.images-17 \ No newline at end of file diff --git a/assets/icons/azure/general/10799-icon-service-Extensions.svg b/assets/icons/azure/general/10799-icon-service-Extensions.svg deleted file mode 100644 index 515e109..0000000 --- a/assets/icons/azure/general/10799-icon-service-Extensions.svg +++ /dev/null @@ -1 +0,0 @@ -MsPortalFx.base.images-21 \ No newline at end of file diff --git a/assets/icons/azure/general/10800-icon-service-File.svg b/assets/icons/azure/general/10800-icon-service-File.svg deleted file mode 100644 index 5b5039d..0000000 --- a/assets/icons/azure/general/10800-icon-service-File.svg +++ /dev/null @@ -1 +0,0 @@ -MsPortalFx.base.images-22 \ No newline at end of file diff --git a/assets/icons/azure/general/10801-icon-service-Files.svg b/assets/icons/azure/general/10801-icon-service-Files.svg deleted file mode 100644 index 54a58c6..0000000 --- a/assets/icons/azure/general/10801-icon-service-Files.svg +++ /dev/null @@ -1 +0,0 @@ -MsPortalFx.base.images-23 \ No newline at end of file diff --git a/assets/icons/azure/general/10802-icon-service-Folder-Blank.svg b/assets/icons/azure/general/10802-icon-service-Folder-Blank.svg deleted file mode 100644 index 3bc49a1..0000000 --- a/assets/icons/azure/general/10802-icon-service-Folder-Blank.svg +++ /dev/null @@ -1 +0,0 @@ -MsPortalFx.base.images-24 \ No newline at end of file diff --git a/assets/icons/azure/general/10803-icon-service-Folder-Website.svg b/assets/icons/azure/general/10803-icon-service-Folder-Website.svg deleted file mode 100644 index f6712cb..0000000 --- a/assets/icons/azure/general/10803-icon-service-Folder-Website.svg +++ /dev/null @@ -1 +0,0 @@ -MsPortalFx.base.images-25 \ No newline at end of file diff --git a/assets/icons/azure/general/10804-icon-service-FTP.svg b/assets/icons/azure/general/10804-icon-service-FTP.svg deleted file mode 100644 index fd065a8..0000000 --- a/assets/icons/azure/general/10804-icon-service-FTP.svg +++ /dev/null @@ -1 +0,0 @@ -MsPortalFx.base.images-67 \ No newline at end of file diff --git a/assets/icons/azure/general/10805-icon-service-Gear.svg b/assets/icons/azure/general/10805-icon-service-Gear.svg deleted file mode 100644 index b73279e..0000000 --- a/assets/icons/azure/general/10805-icon-service-Gear.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/general/10807-icon-service-Globe-Error.svg b/assets/icons/azure/general/10807-icon-service-Globe-Error.svg deleted file mode 100644 index 123e094..0000000 --- a/assets/icons/azure/general/10807-icon-service-Globe-Error.svg +++ /dev/null @@ -1 +0,0 @@ -MsPortalFx.base.images-69 \ No newline at end of file diff --git a/assets/icons/azure/general/10808-icon-service-Globe-Success.svg b/assets/icons/azure/general/10808-icon-service-Globe-Success.svg deleted file mode 100644 index 383bab2..0000000 --- a/assets/icons/azure/general/10808-icon-service-Globe-Success.svg +++ /dev/null @@ -1 +0,0 @@ -MsPortalFx.base.images-70 \ No newline at end of file diff --git a/assets/icons/azure/general/10809-icon-service-Globe-Warning.svg b/assets/icons/azure/general/10809-icon-service-Globe-Warning.svg deleted file mode 100644 index 3c8a651..0000000 --- a/assets/icons/azure/general/10809-icon-service-Globe-Warning.svg +++ /dev/null @@ -1 +0,0 @@ -MsPortalFx.base.images-71 \ No newline at end of file diff --git a/assets/icons/azure/general/10810-icon-service-Guide.svg b/assets/icons/azure/general/10810-icon-service-Guide.svg deleted file mode 100644 index c7d19a1..0000000 --- a/assets/icons/azure/general/10810-icon-service-Guide.svg +++ /dev/null @@ -1 +0,0 @@ -MsPortalFx.base.images-72 \ No newline at end of file diff --git a/assets/icons/azure/general/10811-icon-service-Heart.svg b/assets/icons/azure/general/10811-icon-service-Heart.svg deleted file mode 100644 index b16cba9..0000000 --- a/assets/icons/azure/general/10811-icon-service-Heart.svg +++ /dev/null @@ -1 +0,0 @@ -MsPortalFx.base.images-73 \ No newline at end of file diff --git a/assets/icons/azure/general/10812-icon-service-Image.svg b/assets/icons/azure/general/10812-icon-service-Image.svg deleted file mode 100644 index bf544ea..0000000 --- a/assets/icons/azure/general/10812-icon-service-Image.svg +++ /dev/null @@ -1 +0,0 @@ -MsPortalFx.base.images-74 \ No newline at end of file diff --git a/assets/icons/azure/general/10813-icon-service-Input-Output.svg b/assets/icons/azure/general/10813-icon-service-Input-Output.svg deleted file mode 100644 index b263b48..0000000 --- a/assets/icons/azure/general/10813-icon-service-Input-Output.svg +++ /dev/null @@ -1 +0,0 @@ -MsPortalFx.base.images-64 \ No newline at end of file diff --git a/assets/icons/azure/general/10814-icon-service-Journey-Hub.svg b/assets/icons/azure/general/10814-icon-service-Journey-Hub.svg deleted file mode 100644 index 1cc7331..0000000 --- a/assets/icons/azure/general/10814-icon-service-Journey-Hub.svg +++ /dev/null @@ -1 +0,0 @@ -MsPortalFx.base.images-65 \ No newline at end of file diff --git a/assets/icons/azure/general/10815-icon-service-Launch-Portal.svg b/assets/icons/azure/general/10815-icon-service-Launch-Portal.svg deleted file mode 100644 index 89ad6e1..0000000 --- a/assets/icons/azure/general/10815-icon-service-Launch-Portal.svg +++ /dev/null @@ -1 +0,0 @@ -MsPortalFx.base.images-66 \ No newline at end of file diff --git a/assets/icons/azure/general/10816-icon-service-Learn.svg b/assets/icons/azure/general/10816-icon-service-Learn.svg deleted file mode 100644 index 3b9785b..0000000 --- a/assets/icons/azure/general/10816-icon-service-Learn.svg +++ /dev/null @@ -1 +0,0 @@ -MsPortalFx.base.images-47 \ No newline at end of file diff --git a/assets/icons/azure/general/10817-icon-service-Load-Test.svg b/assets/icons/azure/general/10817-icon-service-Load-Test.svg deleted file mode 100644 index b517dd6..0000000 --- a/assets/icons/azure/general/10817-icon-service-Load-Test.svg +++ /dev/null @@ -1 +0,0 @@ -MsPortalFx.base.images-48 \ No newline at end of file diff --git a/assets/icons/azure/general/10818-icon-service-Location.svg b/assets/icons/azure/general/10818-icon-service-Location.svg deleted file mode 100644 index ab2b58f..0000000 --- a/assets/icons/azure/general/10818-icon-service-Location.svg +++ /dev/null @@ -1 +0,0 @@ -MsPortalFx.base.images-49 \ No newline at end of file diff --git a/assets/icons/azure/general/10819-icon-service-Log-Streaming.svg b/assets/icons/azure/general/10819-icon-service-Log-Streaming.svg deleted file mode 100644 index 1d0fc1a..0000000 --- a/assets/icons/azure/general/10819-icon-service-Log-Streaming.svg +++ /dev/null @@ -1 +0,0 @@ -MsPortalFx.base.images-59 \ No newline at end of file diff --git a/assets/icons/azure/general/10820-icon-service-Management-Portal.svg b/assets/icons/azure/general/10820-icon-service-Management-Portal.svg deleted file mode 100644 index 1eaf13a..0000000 --- a/assets/icons/azure/general/10820-icon-service-Management-Portal.svg +++ /dev/null @@ -1 +0,0 @@ -MsPortalFx.base.images-60 \ No newline at end of file diff --git a/assets/icons/azure/general/10821-icon-service-Media-File.svg b/assets/icons/azure/general/10821-icon-service-Media-File.svg deleted file mode 100644 index 50ac6a6..0000000 --- a/assets/icons/azure/general/10821-icon-service-Media-File.svg +++ /dev/null @@ -1 +0,0 @@ -MsPortalFx.base.images-61 \ No newline at end of file diff --git a/assets/icons/azure/general/10822-icon-service-Mobile.svg b/assets/icons/azure/general/10822-icon-service-Mobile.svg deleted file mode 100644 index 148316c..0000000 --- a/assets/icons/azure/general/10822-icon-service-Mobile.svg +++ /dev/null @@ -1 +0,0 @@ -MsPortalFx.base.images-62 \ No newline at end of file diff --git a/assets/icons/azure/general/10823-icon-service-Mobile-Engagement.svg b/assets/icons/azure/general/10823-icon-service-Mobile-Engagement.svg deleted file mode 100644 index 531c777..0000000 --- a/assets/icons/azure/general/10823-icon-service-Mobile-Engagement.svg +++ /dev/null @@ -1 +0,0 @@ -MsPortalFx.base.images-63 \ No newline at end of file diff --git a/assets/icons/azure/general/10824-icon-service-Power.svg b/assets/icons/azure/general/10824-icon-service-Power.svg deleted file mode 100644 index d8a299f..0000000 --- a/assets/icons/azure/general/10824-icon-service-Power.svg +++ /dev/null @@ -1 +0,0 @@ -MsPortalFx.base.images-37 \ No newline at end of file diff --git a/assets/icons/azure/general/10825-icon-service-Powershell.svg b/assets/icons/azure/general/10825-icon-service-Powershell.svg deleted file mode 100644 index a856ae8..0000000 --- a/assets/icons/azure/general/10825-icon-service-Powershell.svg +++ /dev/null @@ -1 +0,0 @@ -MsPortalFx.base.images-10 \ No newline at end of file diff --git a/assets/icons/azure/general/10826-icon-service-Power-Up.svg b/assets/icons/azure/general/10826-icon-service-Power-Up.svg deleted file mode 100644 index 1f6b228..0000000 --- a/assets/icons/azure/general/10826-icon-service-Power-Up.svg +++ /dev/null @@ -1 +0,0 @@ -MsPortalFx.base.images-39 \ No newline at end of file diff --git a/assets/icons/azure/general/10827-icon-service-Feature-Previews.svg b/assets/icons/azure/general/10827-icon-service-Feature-Previews.svg deleted file mode 100644 index ddb365e..0000000 --- a/assets/icons/azure/general/10827-icon-service-Feature-Previews.svg +++ /dev/null @@ -1 +0,0 @@ -MsPortalFx.base.images-40 \ No newline at end of file diff --git a/assets/icons/azure/general/10828-icon-service-Process-Explorer.svg b/assets/icons/azure/general/10828-icon-service-Process-Explorer.svg deleted file mode 100644 index 3d43ec3..0000000 --- a/assets/icons/azure/general/10828-icon-service-Process-Explorer.svg +++ /dev/null @@ -1 +0,0 @@ -MsPortalFx.base.images-41 \ No newline at end of file diff --git a/assets/icons/azure/general/10829-icon-service-Production-Ready-Database.svg b/assets/icons/azure/general/10829-icon-service-Production-Ready-Database.svg deleted file mode 100644 index 8caa8be..0000000 --- a/assets/icons/azure/general/10829-icon-service-Production-Ready-Database.svg +++ /dev/null @@ -1 +0,0 @@ -MsPortalFx.base.images-51 \ No newline at end of file diff --git a/assets/icons/azure/general/10830-icon-service-Resource-Group-List.svg b/assets/icons/azure/general/10830-icon-service-Resource-Group-List.svg deleted file mode 100644 index 0761e6d..0000000 --- a/assets/icons/azure/general/10830-icon-service-Resource-Group-List.svg +++ /dev/null @@ -1 +0,0 @@ -MsPortalFx.base.images-52 \ No newline at end of file diff --git a/assets/icons/azure/general/10831-icon-service-Resource-Linked.svg b/assets/icons/azure/general/10831-icon-service-Resource-Linked.svg deleted file mode 100644 index 67bdf50..0000000 --- a/assets/icons/azure/general/10831-icon-service-Resource-Linked.svg +++ /dev/null @@ -1 +0,0 @@ -MsPortalFx.base.images-53 \ No newline at end of file diff --git a/assets/icons/azure/general/10833-icon-service-Scheduler.svg b/assets/icons/azure/general/10833-icon-service-Scheduler.svg deleted file mode 100644 index 87cb210..0000000 --- a/assets/icons/azure/general/10833-icon-service-Scheduler.svg +++ /dev/null @@ -1 +0,0 @@ -MsPortalFx.base.images-55 \ No newline at end of file diff --git a/assets/icons/azure/general/10834-icon-service-Search.svg b/assets/icons/azure/general/10834-icon-service-Search.svg deleted file mode 100644 index 2716fa5..0000000 --- a/assets/icons/azure/general/10834-icon-service-Search.svg +++ /dev/null @@ -1 +0,0 @@ -MsPortalFx.base.images-56 \ No newline at end of file diff --git a/assets/icons/azure/general/10835-icon-service-Server-Farm.svg b/assets/icons/azure/general/10835-icon-service-Server-Farm.svg deleted file mode 100644 index 526e6bc..0000000 --- a/assets/icons/azure/general/10835-icon-service-Server-Farm.svg +++ /dev/null @@ -1 +0,0 @@ -MsPortalFx.base.images-57 \ No newline at end of file diff --git a/assets/icons/azure/general/10837-icon-service-SSD.svg b/assets/icons/azure/general/10837-icon-service-SSD.svg deleted file mode 100644 index 523b3e0..0000000 --- a/assets/icons/azure/general/10837-icon-service-SSD.svg +++ /dev/null @@ -1 +0,0 @@ -MsPortalFx.base.images-36 \ No newline at end of file diff --git a/assets/icons/azure/general/10838-icon-service-Storage-Azure-Files.svg b/assets/icons/azure/general/10838-icon-service-Storage-Azure-Files.svg deleted file mode 100644 index a218bca..0000000 --- a/assets/icons/azure/general/10838-icon-service-Storage-Azure-Files.svg +++ /dev/null @@ -1 +0,0 @@ -MsPortalFx.base.images-1 \ No newline at end of file diff --git a/assets/icons/azure/general/10839-icon-service-Storage-Container.svg b/assets/icons/azure/general/10839-icon-service-Storage-Container.svg deleted file mode 100644 index 708e9db..0000000 --- a/assets/icons/azure/general/10839-icon-service-Storage-Container.svg +++ /dev/null @@ -1 +0,0 @@ -MsPortalFx.base.images-2 \ No newline at end of file diff --git a/assets/icons/azure/general/10840-icon-service-Storage-Queue.svg b/assets/icons/azure/general/10840-icon-service-Storage-Queue.svg deleted file mode 100644 index 707220b..0000000 --- a/assets/icons/azure/general/10840-icon-service-Storage-Queue.svg +++ /dev/null @@ -1 +0,0 @@ -MsPortalFx.base.images-3 \ No newline at end of file diff --git a/assets/icons/azure/general/10841-icon-service-Table.svg b/assets/icons/azure/general/10841-icon-service-Table.svg deleted file mode 100644 index 09d1418..0000000 --- a/assets/icons/azure/general/10841-icon-service-Table.svg +++ /dev/null @@ -1 +0,0 @@ -MsPortalFx.base.images-4 \ No newline at end of file diff --git a/assets/icons/azure/general/10842-icon-service-Tags.svg b/assets/icons/azure/general/10842-icon-service-Tags.svg deleted file mode 100644 index 8d37169..0000000 --- a/assets/icons/azure/general/10842-icon-service-Tags.svg +++ /dev/null @@ -1 +0,0 @@ -MsPortalFx.base.images-5 \ No newline at end of file diff --git a/assets/icons/azure/general/10843-icon-service-TFS-VC-Repository.svg b/assets/icons/azure/general/10843-icon-service-TFS-VC-Repository.svg deleted file mode 100644 index a570c0e..0000000 --- a/assets/icons/azure/general/10843-icon-service-TFS-VC-Repository.svg +++ /dev/null @@ -1 +0,0 @@ -MsPortalFx.base.images-13 \ No newline at end of file diff --git a/assets/icons/azure/general/10844-icon-service-Toolbox.svg b/assets/icons/azure/general/10844-icon-service-Toolbox.svg deleted file mode 100644 index 8d23fd2..0000000 --- a/assets/icons/azure/general/10844-icon-service-Toolbox.svg +++ /dev/null @@ -1 +0,0 @@ -MsPortalFx.base.images-78 \ No newline at end of file diff --git a/assets/icons/azure/general/10845-icon-service-Versions.svg b/assets/icons/azure/general/10845-icon-service-Versions.svg deleted file mode 100644 index e957c0f..0000000 --- a/assets/icons/azure/general/10845-icon-service-Versions.svg +++ /dev/null @@ -1 +0,0 @@ -MsPortalFx.base.images-19 \ No newline at end of file diff --git a/assets/icons/azure/general/10847-icon-service-Website-Power.svg b/assets/icons/azure/general/10847-icon-service-Website-Power.svg deleted file mode 100644 index fecdaae..0000000 --- a/assets/icons/azure/general/10847-icon-service-Website-Power.svg +++ /dev/null @@ -1 +0,0 @@ -MsPortalFx.base.images-42 \ No newline at end of file diff --git a/assets/icons/azure/general/10848-icon-service-Website-Staging.svg b/assets/icons/azure/general/10848-icon-service-Website-Staging.svg deleted file mode 100644 index b930f36..0000000 --- a/assets/icons/azure/general/10848-icon-service-Website-Staging.svg +++ /dev/null @@ -1 +0,0 @@ -MsPortalFx.base.images-43 \ No newline at end of file diff --git a/assets/icons/azure/general/10849-icon-service-Web-Slots.svg b/assets/icons/azure/general/10849-icon-service-Web-Slots.svg deleted file mode 100644 index 10f8b9f..0000000 --- a/assets/icons/azure/general/10849-icon-service-Web-Slots.svg +++ /dev/null @@ -1 +0,0 @@ -MsPortalFx.base.images-50 \ No newline at end of file diff --git a/assets/icons/azure/general/10850-icon-service-Web-Test.svg b/assets/icons/azure/general/10850-icon-service-Web-Test.svg deleted file mode 100644 index 77a4c12..0000000 --- a/assets/icons/azure/general/10850-icon-service-Web-Test.svg +++ /dev/null @@ -1 +0,0 @@ -MsPortalFx.base.images-44 \ No newline at end of file diff --git a/assets/icons/azure/general/10851-icon-service-Workbooks.svg b/assets/icons/azure/general/10851-icon-service-Workbooks.svg deleted file mode 100644 index 4726216..0000000 --- a/assets/icons/azure/general/10851-icon-service-Workbooks.svg +++ /dev/null @@ -1 +0,0 @@ -MsPortalFx.base.images-45 \ No newline at end of file diff --git a/assets/icons/azure/general/10852-icon-service-Workflow.svg b/assets/icons/azure/general/10852-icon-service-Workflow.svg deleted file mode 100644 index 33a970e..0000000 --- a/assets/icons/azure/general/10852-icon-service-Workflow.svg +++ /dev/null @@ -1 +0,0 @@ -MsPortalFx.base.images-46 \ No newline at end of file diff --git a/assets/icons/azure/general/10853-icon-service-Backlog.svg b/assets/icons/azure/general/10853-icon-service-Backlog.svg deleted file mode 100644 index 0e66e7a..0000000 --- a/assets/icons/azure/general/10853-icon-service-Backlog.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/general/10854-icon-service-Media.svg b/assets/icons/azure/general/10854-icon-service-Media.svg deleted file mode 100644 index 5742cf9..0000000 --- a/assets/icons/azure/general/10854-icon-service-Media.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/general/10855-icon-service-Module.svg b/assets/icons/azure/general/10855-icon-service-Module.svg deleted file mode 100644 index b9e02b2..0000000 --- a/assets/icons/azure/general/10855-icon-service-Module.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/general/10856-icon-service-Search-Grid.svg b/assets/icons/azure/general/10856-icon-service-Search-Grid.svg deleted file mode 100644 index 0761e6d..0000000 --- a/assets/icons/azure/general/10856-icon-service-Search-Grid.svg +++ /dev/null @@ -1 +0,0 @@ -MsPortalFx.base.images-52 \ No newline at end of file diff --git a/assets/icons/azure/hybrid-multicloud/029109619-icon-service-Workload-Orchestration.svg b/assets/icons/azure/hybrid-multicloud/029109619-icon-service-Workload-Orchestration.svg deleted file mode 100644 index 0700b09..0000000 --- a/assets/icons/azure/hybrid-multicloud/029109619-icon-service-Workload-Orchestration.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/hybrid-multicloud/03248-icon-service-Azure-Operator-5G-Core.svg b/assets/icons/azure/hybrid-multicloud/03248-icon-service-Azure-Operator-5G-Core.svg deleted file mode 100644 index 47bb724..0000000 --- a/assets/icons/azure/hybrid-multicloud/03248-icon-service-Azure-Operator-5G-Core.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/hybrid-multicloud/03324-icon-service-Azure-Operator-Nexus.svg b/assets/icons/azure/hybrid-multicloud/03324-icon-service-Azure-Operator-Nexus.svg deleted file mode 100644 index b2775c9..0000000 --- a/assets/icons/azure/hybrid-multicloud/03324-icon-service-Azure-Operator-Nexus.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/hybrid-multicloud/03333-icon-service-Azure-Operator-Insights.svg b/assets/icons/azure/hybrid-multicloud/03333-icon-service-Azure-Operator-Insights.svg deleted file mode 100644 index ad49f66..0000000 --- a/assets/icons/azure/hybrid-multicloud/03333-icon-service-Azure-Operator-Insights.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/hybrid-multicloud/03334-icon-service-Azure-Operator-Service-Manager.svg b/assets/icons/azure/hybrid-multicloud/03334-icon-service-Azure-Operator-Service-Manager.svg deleted file mode 100644 index cdf111f..0000000 --- a/assets/icons/azure/hybrid-multicloud/03334-icon-service-Azure-Operator-Service-Manager.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/hybrid-multicloud/03347-icon-service-Azure-Programmable-Connectivity.svg b/assets/icons/azure/hybrid-multicloud/03347-icon-service-Azure-Programmable-Connectivity.svg deleted file mode 100644 index f525c4f..0000000 --- a/assets/icons/azure/hybrid-multicloud/03347-icon-service-Azure-Programmable-Connectivity.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/hybrid-multicloud/033745116-icon-service-Azure-Local.svg b/assets/icons/azure/hybrid-multicloud/033745116-icon-service-Azure-Local.svg deleted file mode 100644 index e294309..0000000 --- a/assets/icons/azure/hybrid-multicloud/033745116-icon-service-Azure-Local.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/hybrid-multicloud/03521-icon-service-Edge-Storage-Accelerator.svg b/assets/icons/azure/hybrid-multicloud/03521-icon-service-Edge-Storage-Accelerator.svg deleted file mode 100644 index 1e6d61e..0000000 --- a/assets/icons/azure/hybrid-multicloud/03521-icon-service-Edge-Storage-Accelerator.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/hybrid-multicloud/03585-icon-service-Azure-Monitor-Pipeline.svg b/assets/icons/azure/hybrid-multicloud/03585-icon-service-Azure-Monitor-Pipeline.svg deleted file mode 100644 index 922a9e6..0000000 --- a/assets/icons/azure/hybrid-multicloud/03585-icon-service-Azure-Monitor-Pipeline.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/hybrid-multicloud/03687-icon-service-AI-at-Edge.svg b/assets/icons/azure/hybrid-multicloud/03687-icon-service-AI-at-Edge.svg deleted file mode 100644 index aa08442..0000000 --- a/assets/icons/azure/hybrid-multicloud/03687-icon-service-AI-at-Edge.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/identity/00321-icon-service-Security.svg b/assets/icons/azure/identity/00321-icon-service-Security.svg deleted file mode 100644 index 659e02d..0000000 --- a/assets/icons/azure/identity/00321-icon-service-Security.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/identity/00919-icon-service-Administrative-Units.svg b/assets/icons/azure/identity/00919-icon-service-Administrative-Units.svg deleted file mode 100644 index 7309bf6..0000000 --- a/assets/icons/azure/identity/00919-icon-service-Administrative-Units.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/identity/01084-icon-service-Verifiable-Credentials.svg b/assets/icons/azure/identity/01084-icon-service-Verifiable-Credentials.svg deleted file mode 100644 index 3c31a99..0000000 --- a/assets/icons/azure/identity/01084-icon-service-Verifiable-Credentials.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/identity/02251-icon-service-Azure-AD-Privleged-Identity-Management.svg b/assets/icons/azure/identity/02251-icon-service-Azure-AD-Privleged-Identity-Management.svg deleted file mode 100644 index 5a8dde1..0000000 --- a/assets/icons/azure/identity/02251-icon-service-Azure-AD-Privleged-Identity-Management.svg +++ /dev/null @@ -1,2 +0,0 @@ -​ - \ No newline at end of file diff --git a/assets/icons/azure/identity/02251-icon-service-Entra-Privleged-Identity-Management.svg b/assets/icons/azure/identity/02251-icon-service-Entra-Privleged-Identity-Management.svg deleted file mode 100644 index bc33888..0000000 --- a/assets/icons/azure/identity/02251-icon-service-Entra-Privleged-Identity-Management.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/identity/02386-icon-service-API-Proxy.svg b/assets/icons/azure/identity/02386-icon-service-API-Proxy.svg deleted file mode 100644 index d220ea6..0000000 --- a/assets/icons/azure/identity/02386-icon-service-API-Proxy.svg +++ /dev/null @@ -1,2 +0,0 @@ -​ - \ No newline at end of file diff --git a/assets/icons/azure/identity/02679-icon-service-Tenant-Properties.svg b/assets/icons/azure/identity/02679-icon-service-Tenant-Properties.svg deleted file mode 100644 index d62b657..0000000 --- a/assets/icons/azure/identity/02679-icon-service-Tenant-Properties.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/identity/02680-icon-service-Custom-Azure-AD-Roles.svg b/assets/icons/azure/identity/02680-icon-service-Custom-Azure-AD-Roles.svg deleted file mode 100644 index 1ee8a0b..0000000 --- a/assets/icons/azure/identity/02680-icon-service-Custom-Azure-AD-Roles.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/identity/02680-icon-service-Entra-Identity-Custom-Roles.svg b/assets/icons/azure/identity/02680-icon-service-Entra-Identity-Custom-Roles.svg deleted file mode 100644 index 1ee8a0b..0000000 --- a/assets/icons/azure/identity/02680-icon-service-Entra-Identity-Custom-Roles.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/identity/02681-icon-service-AAD-Licenses.svg b/assets/icons/azure/identity/02681-icon-service-AAD-Licenses.svg deleted file mode 100644 index 59f5b8b..0000000 --- a/assets/icons/azure/identity/02681-icon-service-AAD-Licenses.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/identity/02681-icon-service-Entra-Identity-Licenses.svg b/assets/icons/azure/identity/02681-icon-service-Entra-Identity-Licenses.svg deleted file mode 100644 index 59f5b8b..0000000 --- a/assets/icons/azure/identity/02681-icon-service-Entra-Identity-Licenses.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/identity/02854-icon-service-Entra-Connect.svg b/assets/icons/azure/identity/02854-icon-service-Entra-Connect.svg deleted file mode 100644 index bfdc2c6..0000000 --- a/assets/icons/azure/identity/02854-icon-service-Entra-Connect.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/identity/03143-icon-service-Entra-Verified-ID.svg b/assets/icons/azure/identity/03143-icon-service-Entra-Verified-ID.svg deleted file mode 100644 index a74390f..0000000 --- a/assets/icons/azure/identity/03143-icon-service-Entra-Verified-ID.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/identity/03225-icon-service-Verification-As-A-Service.svg b/assets/icons/azure/identity/03225-icon-service-Verification-As-A-Service.svg deleted file mode 100644 index 0cf2380..0000000 --- a/assets/icons/azure/identity/03225-icon-service-Verification-As-A-Service.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/identity/03256-icon-service-Multi-Factor-Authentication.svg b/assets/icons/azure/identity/03256-icon-service-Multi-Factor-Authentication.svg deleted file mode 100644 index 5ba7b15..0000000 --- a/assets/icons/azure/identity/03256-icon-service-Multi-Factor-Authentication.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/identity/03309-icon-service-Entra-Global-Secure-Access.svg b/assets/icons/azure/identity/03309-icon-service-Entra-Global-Secure-Access.svg deleted file mode 100644 index 49d8536..0000000 --- a/assets/icons/azure/identity/03309-icon-service-Entra-Global-Secure-Access.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/identity/03338-icon-service-External-Identities.svg b/assets/icons/azure/identity/03338-icon-service-External-Identities.svg deleted file mode 100644 index d93286a..0000000 --- a/assets/icons/azure/identity/03338-icon-service-External-Identities.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/identity/03382-icon-service-Entra-Private-Access.svg b/assets/icons/azure/identity/03382-icon-service-Entra-Private-Access.svg deleted file mode 100644 index adaacca..0000000 --- a/assets/icons/azure/identity/03382-icon-service-Entra-Private-Access.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/identity/03383-icon-service-Entra-Internet-Access.svg b/assets/icons/azure/identity/03383-icon-service-Entra-Internet-Access.svg deleted file mode 100644 index b6eaee7..0000000 --- a/assets/icons/azure/identity/03383-icon-service-Entra-Internet-Access.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/identity/03425-icon-service-external-id.svg b/assets/icons/azure/identity/03425-icon-service-external-id.svg deleted file mode 100644 index 4f37c1d..0000000 --- a/assets/icons/azure/identity/03425-icon-service-external-id.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/identity/034251-icon-service-external-id-modified.svg b/assets/icons/azure/identity/034251-icon-service-external-id-modified.svg deleted file mode 100644 index ebba0a2..0000000 --- a/assets/icons/azure/identity/034251-icon-service-external-id-modified.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/identity/03533-icon-service-Entra-Connect-Sync.svg b/assets/icons/azure/identity/03533-icon-service-Entra-Connect-Sync.svg deleted file mode 100644 index 766da8b..0000000 --- a/assets/icons/azure/identity/03533-icon-service-Entra-Connect-Sync.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/identity/10221-icon-service-Azure-Active-Directory.svg b/assets/icons/azure/identity/10221-icon-service-Azure-Active-Directory.svg deleted file mode 100644 index d450b62..0000000 --- a/assets/icons/azure/identity/10221-icon-service-Azure-Active-Directory.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-identity-221 \ No newline at end of file diff --git a/assets/icons/azure/identity/10222-icon-service-Azure-AD-Domain-Services.svg b/assets/icons/azure/identity/10222-icon-service-Azure-AD-Domain-Services.svg deleted file mode 100644 index 3afff52..0000000 --- a/assets/icons/azure/identity/10222-icon-service-Azure-AD-Domain-Services.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-identity-222 \ No newline at end of file diff --git a/assets/icons/azure/identity/10222-icon-service-Entra-Domain-Services.svg b/assets/icons/azure/identity/10222-icon-service-Entra-Domain-Services.svg deleted file mode 100644 index ef709d1..0000000 --- a/assets/icons/azure/identity/10222-icon-service-Entra-Domain-Services.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/identity/10223-icon-service-Groups.svg b/assets/icons/azure/identity/10223-icon-service-Groups.svg deleted file mode 100644 index 6dd2be1..0000000 --- a/assets/icons/azure/identity/10223-icon-service-Groups.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-identity-223 \ No newline at end of file diff --git a/assets/icons/azure/identity/10224-icon-service-Active-Directory-Connect-Health.svg b/assets/icons/azure/identity/10224-icon-service-Active-Directory-Connect-Health.svg deleted file mode 100644 index d81a06f..0000000 --- a/assets/icons/azure/identity/10224-icon-service-Active-Directory-Connect-Health.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-identity-224 \ No newline at end of file diff --git a/assets/icons/azure/identity/10224-icon-service-Entra-Connect-Health.svg b/assets/icons/azure/identity/10224-icon-service-Entra-Connect-Health.svg deleted file mode 100644 index e2f5bce..0000000 --- a/assets/icons/azure/identity/10224-icon-service-Entra-Connect-Health.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/identity/10225-icon-service-Enterprise-Applications.svg b/assets/icons/azure/identity/10225-icon-service-Enterprise-Applications.svg deleted file mode 100644 index d8de57a..0000000 --- a/assets/icons/azure/identity/10225-icon-service-Enterprise-Applications.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-identity-225 \ No newline at end of file diff --git a/assets/icons/azure/identity/10227-icon-service-Entra-Managed-Identities.svg b/assets/icons/azure/identity/10227-icon-service-Entra-Managed-Identities.svg deleted file mode 100644 index a7450d7..0000000 --- a/assets/icons/azure/identity/10227-icon-service-Entra-Managed-Identities.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/identity/10227-icon-service-Managed-Identities.svg b/assets/icons/azure/identity/10227-icon-service-Managed-Identities.svg deleted file mode 100644 index 1be8978..0000000 --- a/assets/icons/azure/identity/10227-icon-service-Managed-Identities.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-identity-227 \ No newline at end of file diff --git a/assets/icons/azure/identity/10228-icon-service-Azure-AD-B2C.svg b/assets/icons/azure/identity/10228-icon-service-Azure-AD-B2C.svg deleted file mode 100644 index 019acc4..0000000 --- a/assets/icons/azure/identity/10228-icon-service-Azure-AD-B2C.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/identity/10229-icon-service-Azure-Information-Protection.svg b/assets/icons/azure/identity/10229-icon-service-Azure-Information-Protection.svg deleted file mode 100644 index b244886..0000000 --- a/assets/icons/azure/identity/10229-icon-service-Azure-Information-Protection.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/identity/10230-icon-service-Users.svg b/assets/icons/azure/identity/10230-icon-service-Users.svg deleted file mode 100644 index bd10542..0000000 --- a/assets/icons/azure/identity/10230-icon-service-Users.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-identity-230 \ No newline at end of file diff --git a/assets/icons/azure/identity/10231-icon-service-Azure-AD-Identity-Protection.svg b/assets/icons/azure/identity/10231-icon-service-Azure-AD-Identity-Protection.svg deleted file mode 100644 index 29fde81..0000000 --- a/assets/icons/azure/identity/10231-icon-service-Azure-AD-Identity-Protection.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-identity-231 \ No newline at end of file diff --git a/assets/icons/azure/identity/10231-icon-service-Entra-ID-Protection.svg b/assets/icons/azure/identity/10231-icon-service-Entra-ID-Protection.svg deleted file mode 100644 index 981dd76..0000000 --- a/assets/icons/azure/identity/10231-icon-service-Entra-ID-Protection.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/identity/10232-icon-service-App-Registrations.svg b/assets/icons/azure/identity/10232-icon-service-App-Registrations.svg deleted file mode 100644 index 78c2ea8..0000000 --- a/assets/icons/azure/identity/10232-icon-service-App-Registrations.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/identity/10234-icon-service-Azure-AD-Privilege-Identity-Management.svg b/assets/icons/azure/identity/10234-icon-service-Azure-AD-Privilege-Identity-Management.svg deleted file mode 100644 index 0eac457..0000000 --- a/assets/icons/azure/identity/10234-icon-service-Azure-AD-Privilege-Identity-Management.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-identity-234 \ No newline at end of file diff --git a/assets/icons/azure/identity/10235-icon-service-Identity-Governance.svg b/assets/icons/azure/identity/10235-icon-service-Identity-Governance.svg deleted file mode 100644 index 288e201..0000000 --- a/assets/icons/azure/identity/10235-icon-service-Identity-Governance.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-identity-235 \ No newline at end of file diff --git a/assets/icons/azure/identity/10340-icon-service-Azure-AD-Roles-and-Administrators.svg b/assets/icons/azure/identity/10340-icon-service-Azure-AD-Roles-and-Administrators.svg deleted file mode 100644 index 097ba41..0000000 --- a/assets/icons/azure/identity/10340-icon-service-Azure-AD-Roles-and-Administrators.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/identity/10340-icon-service-Entra-Identity-Roles-and-Administrators.svg b/assets/icons/azure/identity/10340-icon-service-Entra-Identity-Roles-and-Administrators.svg deleted file mode 100644 index 097ba41..0000000 --- a/assets/icons/azure/identity/10340-icon-service-Entra-Identity-Roles-and-Administrators.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/identity/10433-icon-service-User-Settings.svg b/assets/icons/azure/identity/10433-icon-service-User-Settings.svg deleted file mode 100644 index 4bc9cd9..0000000 --- a/assets/icons/azure/identity/10433-icon-service-User-Settings.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/integration/00036-icon-service-SQL-Data-Warehouses.svg b/assets/icons/azure/integration/00036-icon-service-SQL-Data-Warehouses.svg deleted file mode 100644 index a7e5dc4..0000000 --- a/assets/icons/azure/integration/00036-icon-service-SQL-Data-Warehouses.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-databases-125 \ No newline at end of file diff --git a/assets/icons/azure/integration/00555-icon-service-Integration-Service-Environments.svg b/assets/icons/azure/integration/00555-icon-service-Integration-Service-Environments.svg deleted file mode 100644 index dbd20e2..0000000 --- a/assets/icons/azure/integration/00555-icon-service-Integration-Service-Environments.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-555Artboard 1 \ No newline at end of file diff --git a/assets/icons/azure/integration/00691-icon-service-Azure-Databox-Gateway.svg b/assets/icons/azure/integration/00691-icon-service-Azure-Databox-Gateway.svg deleted file mode 100644 index 90096f3..0000000 --- a/assets/icons/azure/integration/00691-icon-service-Azure-Databox-Gateway.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/integration/02072-icon-service-Partner-Topic.svg b/assets/icons/azure/integration/02072-icon-service-Partner-Topic.svg deleted file mode 100644 index bead3e3..0000000 --- a/assets/icons/azure/integration/02072-icon-service-Partner-Topic.svg +++ /dev/null @@ -1,2 +0,0 @@ -​ - \ No newline at end of file diff --git a/assets/icons/azure/integration/02073-icon-service-System-Topic.svg b/assets/icons/azure/integration/02073-icon-service-System-Topic.svg deleted file mode 100644 index c5536b6..0000000 --- a/assets/icons/azure/integration/02073-icon-service-System-Topic.svg +++ /dev/null @@ -1,2 +0,0 @@ -​ - \ No newline at end of file diff --git a/assets/icons/azure/integration/02265-icon-service-Partner-Registration.svg b/assets/icons/azure/integration/02265-icon-service-Partner-Registration.svg deleted file mode 100644 index 59c0ed2..0000000 --- a/assets/icons/azure/integration/02265-icon-service-Partner-Registration.svg +++ /dev/null @@ -1,2 +0,0 @@ -​ - \ No newline at end of file diff --git a/assets/icons/azure/integration/02266-icon-service-Partner-Namespace.svg b/assets/icons/azure/integration/02266-icon-service-Partner-Namespace.svg deleted file mode 100644 index bf5b906..0000000 --- a/assets/icons/azure/integration/02266-icon-service-Partner-Namespace.svg +++ /dev/null @@ -1,2 +0,0 @@ -​ - \ No newline at end of file diff --git a/assets/icons/azure/integration/02631-icon-service-Logic-Apps.svg b/assets/icons/azure/integration/02631-icon-service-Logic-Apps.svg deleted file mode 100644 index be925da..0000000 --- a/assets/icons/azure/integration/02631-icon-service-Logic-Apps.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/integration/02987-icon-service-pubsub.svg b/assets/icons/azure/integration/02987-icon-service-pubsub.svg deleted file mode 100644 index 307f6ca..0000000 --- a/assets/icons/azure/integration/02987-icon-service-pubsub.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/integration/032130874-icon-service-Logic-Apps-Template.svg b/assets/icons/azure/integration/032130874-icon-service-Logic-Apps-Template.svg deleted file mode 100644 index 4a6cc83..0000000 --- a/assets/icons/azure/integration/032130874-icon-service-Logic-Apps-Template.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/integration/03335-icon-service-Power-Platform.svg b/assets/icons/azure/integration/03335-icon-service-Power-Platform.svg deleted file mode 100644 index f3d0dcd..0000000 --- a/assets/icons/azure/integration/03335-icon-service-Power-Platform.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/integration/03345-icon-service-Integration-Environments.svg b/assets/icons/azure/integration/03345-icon-service-Integration-Environments.svg deleted file mode 100644 index 7ead9fa..0000000 --- a/assets/icons/azure/integration/03345-icon-service-Integration-Environments.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/integration/03637-icon-service-Business-Process-Tracking.svg b/assets/icons/azure/integration/03637-icon-service-Business-Process-Tracking.svg deleted file mode 100644 index 1def8d3..0000000 --- a/assets/icons/azure/integration/03637-icon-service-Business-Process-Tracking.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/integration/10042-icon-service-API-Management-Services.svg b/assets/icons/azure/integration/10042-icon-service-API-Management-Services.svg deleted file mode 100644 index 69f5998..0000000 --- a/assets/icons/azure/integration/10042-icon-service-API-Management-Services.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-web-42 \ No newline at end of file diff --git a/assets/icons/azure/integration/10048-icon-service-API-Connections.svg b/assets/icons/azure/integration/10048-icon-service-API-Connections.svg deleted file mode 100644 index a170813..0000000 --- a/assets/icons/azure/integration/10048-icon-service-API-Connections.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-web-48 \ No newline at end of file diff --git a/assets/icons/azure/integration/10089-icon-service-StorSimple-Device-Managers.svg b/assets/icons/azure/integration/10089-icon-service-StorSimple-Device-Managers.svg deleted file mode 100644 index 32ab6ce..0000000 --- a/assets/icons/azure/integration/10089-icon-service-StorSimple-Device-Managers.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-storage-89 \ No newline at end of file diff --git a/assets/icons/azure/integration/10095-icon-service-Azure-Stack-Edge.svg b/assets/icons/azure/integration/10095-icon-service-Azure-Stack-Edge.svg deleted file mode 100644 index 656850c..0000000 --- a/assets/icons/azure/integration/10095-icon-service-Azure-Stack-Edge.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-storage-95 \ No newline at end of file diff --git a/assets/icons/azure/integration/10126-icon-service-Data-Factories.svg b/assets/icons/azure/integration/10126-icon-service-Data-Factories.svg deleted file mode 100644 index 2237336..0000000 --- a/assets/icons/azure/integration/10126-icon-service-Data-Factories.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-databases-126 \ No newline at end of file diff --git a/assets/icons/azure/integration/10137-icon-service-Azure-SQL-Server-Stretch-Databases.svg b/assets/icons/azure/integration/10137-icon-service-Azure-SQL-Server-Stretch-Databases.svg deleted file mode 100644 index a7e5dc4..0000000 --- a/assets/icons/azure/integration/10137-icon-service-Azure-SQL-Server-Stretch-Databases.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-databases-125 \ No newline at end of file diff --git a/assets/icons/azure/integration/10206-icon-service-Event-Grid-Topics.svg b/assets/icons/azure/integration/10206-icon-service-Event-Grid-Topics.svg deleted file mode 100644 index bbd4725..0000000 --- a/assets/icons/azure/integration/10206-icon-service-Event-Grid-Topics.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-integration-206 \ No newline at end of file diff --git a/assets/icons/azure/integration/10209-icon-service-Relays.svg b/assets/icons/azure/integration/10209-icon-service-Relays.svg deleted file mode 100644 index 3f742f0..0000000 --- a/assets/icons/azure/integration/10209-icon-service-Relays.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-integration-209 \ No newline at end of file diff --git a/assets/icons/azure/integration/10212-icon-service-Azure-API-for-FHIR.svg b/assets/icons/azure/integration/10212-icon-service-Azure-API-for-FHIR.svg deleted file mode 100644 index de13a1d..0000000 --- a/assets/icons/azure/integration/10212-icon-service-Azure-API-for-FHIR.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-integration-212 \ No newline at end of file diff --git a/assets/icons/azure/integration/10213-icon-service-Software-as-a-Service.svg b/assets/icons/azure/integration/10213-icon-service-Software-as-a-Service.svg deleted file mode 100644 index ad79bc9..0000000 --- a/assets/icons/azure/integration/10213-icon-service-Software-as-a-Service.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/integration/10215-icon-service-Event-Grid-Domains.svg b/assets/icons/azure/integration/10215-icon-service-Event-Grid-Domains.svg deleted file mode 100644 index b3d1ad8..0000000 --- a/assets/icons/azure/integration/10215-icon-service-Event-Grid-Domains.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-integration-215 \ No newline at end of file diff --git a/assets/icons/azure/integration/10216-icon-service-Azure-Data-Catalog.svg b/assets/icons/azure/integration/10216-icon-service-Azure-Data-Catalog.svg deleted file mode 100644 index 13ed14e..0000000 --- a/assets/icons/azure/integration/10216-icon-service-Azure-Data-Catalog.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-integration-216 \ No newline at end of file diff --git a/assets/icons/azure/integration/10218-icon-service-Integration-Accounts.svg b/assets/icons/azure/integration/10218-icon-service-Integration-Accounts.svg deleted file mode 100644 index 8dd9cb6..0000000 --- a/assets/icons/azure/integration/10218-icon-service-Integration-Accounts.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-integration-218 \ No newline at end of file diff --git a/assets/icons/azure/integration/10219-icon-service-App-Configuration.svg b/assets/icons/azure/integration/10219-icon-service-App-Configuration.svg deleted file mode 100644 index e032c57..0000000 --- a/assets/icons/azure/integration/10219-icon-service-App-Configuration.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-integration-219 \ No newline at end of file diff --git a/assets/icons/azure/integration/10220-icon-service-SendGrid-Accounts.svg b/assets/icons/azure/integration/10220-icon-service-SendGrid-Accounts.svg deleted file mode 100644 index 4532be2..0000000 --- a/assets/icons/azure/integration/10220-icon-service-SendGrid-Accounts.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-integration-220 \ No newline at end of file diff --git a/assets/icons/azure/integration/10221-icon-service-Event-Grid-Subscriptions.svg b/assets/icons/azure/integration/10221-icon-service-Event-Grid-Subscriptions.svg deleted file mode 100644 index 13e57f8..0000000 --- a/assets/icons/azure/integration/10221-icon-service-Event-Grid-Subscriptions.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-integration-221 \ No newline at end of file diff --git a/assets/icons/azure/integration/10363-icon-service-Logic-Apps-Custom-Connector.svg b/assets/icons/azure/integration/10363-icon-service-Logic-Apps-Custom-Connector.svg deleted file mode 100644 index e7676ea..0000000 --- a/assets/icons/azure/integration/10363-icon-service-Logic-Apps-Custom-Connector.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-integration-210 \ No newline at end of file diff --git a/assets/icons/azure/integration/10836-icon-service-Azure-Service-Bus.svg b/assets/icons/azure/integration/10836-icon-service-Azure-Service-Bus.svg deleted file mode 100644 index 2fc22a7..0000000 --- a/assets/icons/azure/integration/10836-icon-service-Azure-Service-Bus.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/intune/00399-icon-service-Device-Security-Apple.svg b/assets/icons/azure/intune/00399-icon-service-Device-Security-Apple.svg deleted file mode 100644 index 9073c20..0000000 --- a/assets/icons/azure/intune/00399-icon-service-Device-Security-Apple.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/intune/00399-icon-service-Device-Security-Google.svg b/assets/icons/azure/intune/00399-icon-service-Device-Security-Google.svg deleted file mode 100644 index eddc3b0..0000000 --- a/assets/icons/azure/intune/00399-icon-service-Device-Security-Google.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/intune/00399-icon-service-Device-Security-Windows.svg b/assets/icons/azure/intune/00399-icon-service-Device-Security-Windows.svg deleted file mode 100644 index 976e5b8..0000000 --- a/assets/icons/azure/intune/00399-icon-service-Device-Security-Windows.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/intune/029797391-icon-service-Engage-Center-Connect.svg b/assets/icons/azure/intune/029797391-icon-service-Engage-Center-Connect.svg deleted file mode 100644 index 661f261..0000000 --- a/assets/icons/azure/intune/029797391-icon-service-Engage-Center-Connect.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/intune/10329-icon-service-Intune.svg b/assets/icons/azure/intune/10329-icon-service-Intune.svg deleted file mode 100644 index f8d27d0..0000000 --- a/assets/icons/azure/intune/10329-icon-service-Intune.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-intune-329 \ No newline at end of file diff --git a/assets/icons/azure/intune/10330-icon-service-eBooks.svg b/assets/icons/azure/intune/10330-icon-service-eBooks.svg deleted file mode 100644 index 8148379..0000000 --- a/assets/icons/azure/intune/10330-icon-service-eBooks.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-intune-330 \ No newline at end of file diff --git a/assets/icons/azure/intune/10331-icon-service-Client-Apps.svg b/assets/icons/azure/intune/10331-icon-service-Client-Apps.svg deleted file mode 100644 index 0ff640b..0000000 --- a/assets/icons/azure/intune/10331-icon-service-Client-Apps.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-intune-331 \ No newline at end of file diff --git a/assets/icons/azure/intune/10332-icon-service-Devices.svg b/assets/icons/azure/intune/10332-icon-service-Devices.svg deleted file mode 100644 index 3556113..0000000 --- a/assets/icons/azure/intune/10332-icon-service-Devices.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-intune-332 \ No newline at end of file diff --git a/assets/icons/azure/intune/10333-icon-service-Device-Compliance.svg b/assets/icons/azure/intune/10333-icon-service-Device-Compliance.svg deleted file mode 100644 index 54b7a4d..0000000 --- a/assets/icons/azure/intune/10333-icon-service-Device-Compliance.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-intune-333 \ No newline at end of file diff --git a/assets/icons/azure/intune/10335-icon-service-Software-Updates.svg b/assets/icons/azure/intune/10335-icon-service-Software-Updates.svg deleted file mode 100644 index 96c5596..0000000 --- a/assets/icons/azure/intune/10335-icon-service-Software-Updates.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-intune-335 \ No newline at end of file diff --git a/assets/icons/azure/intune/10336-icon-service-Security-Baselines.svg b/assets/icons/azure/intune/10336-icon-service-Security-Baselines.svg deleted file mode 100644 index ef24388..0000000 --- a/assets/icons/azure/intune/10336-icon-service-Security-Baselines.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-intune-336 \ No newline at end of file diff --git a/assets/icons/azure/intune/10337-icon-service-Device-Enrollment.svg b/assets/icons/azure/intune/10337-icon-service-Device-Enrollment.svg deleted file mode 100644 index 1d7a354..0000000 --- a/assets/icons/azure/intune/10337-icon-service-Device-Enrollment.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-intune-337 \ No newline at end of file diff --git a/assets/icons/azure/intune/10338-icon-service-Device-Configuration.svg b/assets/icons/azure/intune/10338-icon-service-Device-Configuration.svg deleted file mode 100644 index dba365c..0000000 --- a/assets/icons/azure/intune/10338-icon-service-Device-Configuration.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-intune-338 \ No newline at end of file diff --git a/assets/icons/azure/intune/10339-icon-service-Exchange-Access.svg b/assets/icons/azure/intune/10339-icon-service-Exchange-Access.svg deleted file mode 100644 index 492e42a..0000000 --- a/assets/icons/azure/intune/10339-icon-service-Exchange-Access.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-intune-339 \ No newline at end of file diff --git a/assets/icons/azure/intune/10340-icon-service-Azure-AD-Roles-and-Administrators.svg b/assets/icons/azure/intune/10340-icon-service-Azure-AD-Roles-and-Administrators.svg deleted file mode 100644 index 097ba41..0000000 --- a/assets/icons/azure/intune/10340-icon-service-Azure-AD-Roles-and-Administrators.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/intune/10340-icon-service-Entra-Identity-Roles-and-Administrators.svg b/assets/icons/azure/intune/10340-icon-service-Entra-Identity-Roles-and-Administrators.svg deleted file mode 100644 index 097ba41..0000000 --- a/assets/icons/azure/intune/10340-icon-service-Entra-Identity-Roles-and-Administrators.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/intune/10342-icon-service-Tenant-Status.svg b/assets/icons/azure/intune/10342-icon-service-Tenant-Status.svg deleted file mode 100644 index 4bf576d..0000000 --- a/assets/icons/azure/intune/10342-icon-service-Tenant-Status.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-intune-342 \ No newline at end of file diff --git a/assets/icons/azure/intune/10343-icon-service-Intune-For-Education.svg b/assets/icons/azure/intune/10343-icon-service-Intune-For-Education.svg deleted file mode 100644 index f8d27d0..0000000 --- a/assets/icons/azure/intune/10343-icon-service-Intune-For-Education.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-intune-329 \ No newline at end of file diff --git a/assets/icons/azure/intune/10344-icon-service-Intune-App-Protection.svg b/assets/icons/azure/intune/10344-icon-service-Intune-App-Protection.svg deleted file mode 100644 index f8d27d0..0000000 --- a/assets/icons/azure/intune/10344-icon-service-Intune-App-Protection.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-intune-329 \ No newline at end of file diff --git a/assets/icons/azure/intune/10350-icon-service-Mindaro.svg b/assets/icons/azure/intune/10350-icon-service-Mindaro.svg deleted file mode 100644 index 648c974..0000000 --- a/assets/icons/azure/intune/10350-icon-service-Mindaro.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-Artboard 1 \ No newline at end of file diff --git a/assets/icons/azure/iot/00030-icon-service-Machine-Learning-Studio-(Classic)-Web-Services.svg b/assets/icons/azure/iot/00030-icon-service-Machine-Learning-Studio-(Classic)-Web-Services.svg deleted file mode 100644 index 047d7dc..0000000 --- a/assets/icons/azure/iot/00030-icon-service-Machine-Learning-Studio-(Classic)-Web-Services.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/iot/00039-icon-service-Event-Hubs.svg b/assets/icons/azure/iot/00039-icon-service-Event-Hubs.svg deleted file mode 100644 index 7bbf33a..0000000 --- a/assets/icons/azure/iot/00039-icon-service-Event-Hubs.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-analytics-144 \ No newline at end of file diff --git a/assets/icons/azure/iot/00042-icon-service-Stream-Analytics-Jobs.svg b/assets/icons/azure/iot/00042-icon-service-Stream-Analytics-Jobs.svg deleted file mode 100644 index 098f62b..0000000 --- a/assets/icons/azure/iot/00042-icon-service-Stream-Analytics-Jobs.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-analytics-147 \ No newline at end of file diff --git a/assets/icons/azure/iot/00691-icon-service-Azure-Databox-Gateway.svg b/assets/icons/azure/iot/00691-icon-service-Azure-Databox-Gateway.svg deleted file mode 100644 index 90096f3..0000000 --- a/assets/icons/azure/iot/00691-icon-service-Azure-Databox-Gateway.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/iot/01030-icon-service-Digital-Twins.svg b/assets/icons/azure/iot/01030-icon-service-Digital-Twins.svg deleted file mode 100644 index a9c7735..0000000 --- a/assets/icons/azure/iot/01030-icon-service-Digital-Twins.svg +++ /dev/null @@ -1,2 +0,0 @@ -​ - \ No newline at end of file diff --git a/assets/icons/azure/iot/02359-icon-service-Industrial-IoT.svg b/assets/icons/azure/iot/02359-icon-service-Industrial-IoT.svg deleted file mode 100644 index e184909..0000000 --- a/assets/icons/azure/iot/02359-icon-service-Industrial-IoT.svg +++ /dev/null @@ -1,2 +0,0 @@ -​ - \ No newline at end of file diff --git a/assets/icons/azure/iot/02631-icon-service-Logic-Apps.svg b/assets/icons/azure/iot/02631-icon-service-Logic-Apps.svg deleted file mode 100644 index be925da..0000000 --- a/assets/icons/azure/iot/02631-icon-service-Logic-Apps.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/iot/03293-icon-service-Azure-Stack-HCI-Sizer.svg b/assets/icons/azure/iot/03293-icon-service-Azure-Stack-HCI-Sizer.svg deleted file mode 100644 index 27f83eb..0000000 --- a/assets/icons/azure/iot/03293-icon-service-Azure-Stack-HCI-Sizer.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/iot/03466-icon-service-Stack-HCI-Premium.svg b/assets/icons/azure/iot/03466-icon-service-Stack-HCI-Premium.svg deleted file mode 100644 index 6ad904a..0000000 --- a/assets/icons/azure/iot/03466-icon-service-Stack-HCI-Premium.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/iot/03485-icon-service-Azure-IoT-Operations.svg b/assets/icons/azure/iot/03485-icon-service-Azure-IoT-Operations.svg deleted file mode 100644 index e16f6b5..0000000 --- a/assets/icons/azure/iot/03485-icon-service-Azure-IoT-Operations.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/iot/03656-icon-service-Hybrid-Connectivity-Hub.svg b/assets/icons/azure/iot/03656-icon-service-Hybrid-Connectivity-Hub.svg deleted file mode 100644 index 5d996f9..0000000 --- a/assets/icons/azure/iot/03656-icon-service-Hybrid-Connectivity-Hub.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/iot/10029-icon-service-Function-Apps.svg b/assets/icons/azure/iot/10029-icon-service-Function-Apps.svg deleted file mode 100644 index 1ecacdf..0000000 --- a/assets/icons/azure/iot/10029-icon-service-Function-Apps.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-compute-29 \ No newline at end of file diff --git a/assets/icons/azure/iot/10045-icon-service-Notification-Hubs.svg b/assets/icons/azure/iot/10045-icon-service-Notification-Hubs.svg deleted file mode 100644 index 185df88..0000000 --- a/assets/icons/azure/iot/10045-icon-service-Notification-Hubs.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-web-45 \ No newline at end of file diff --git a/assets/icons/azure/iot/10053-icon-service-Notification-Hub-Namespaces.svg b/assets/icons/azure/iot/10053-icon-service-Notification-Hub-Namespaces.svg deleted file mode 100644 index 4acc715..0000000 --- a/assets/icons/azure/iot/10053-icon-service-Notification-Hub-Namespaces.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-iot-196 \ No newline at end of file diff --git a/assets/icons/azure/iot/10114-icon-service-Azure-Stack.svg b/assets/icons/azure/iot/10114-icon-service-Azure-Stack.svg deleted file mode 100644 index ab71b10..0000000 --- a/assets/icons/azure/iot/10114-icon-service-Azure-Stack.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/iot/10121-icon-service-Azure-Cosmos-DB.svg b/assets/icons/azure/iot/10121-icon-service-Azure-Cosmos-DB.svg deleted file mode 100644 index ecb1792..0000000 --- a/assets/icons/azure/iot/10121-icon-service-Azure-Cosmos-DB.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-databases-121 \ No newline at end of file diff --git a/assets/icons/azure/iot/10149-icon-service-Event-Hub-Clusters.svg b/assets/icons/azure/iot/10149-icon-service-Event-Hub-Clusters.svg deleted file mode 100644 index ed0d934..0000000 --- a/assets/icons/azure/iot/10149-icon-service-Event-Hub-Clusters.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-analytics-149 \ No newline at end of file diff --git a/assets/icons/azure/iot/10167-icon-service-Machine-Learning-Studio-Workspaces.svg b/assets/icons/azure/iot/10167-icon-service-Machine-Learning-Studio-Workspaces.svg deleted file mode 100644 index d571ed9..0000000 --- a/assets/icons/azure/iot/10167-icon-service-Machine-Learning-Studio-Workspaces.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-machinelearning-167 \ No newline at end of file diff --git a/assets/icons/azure/iot/10168-icon-service-Machine-Learning-Studio-Web-Service-Plans.svg b/assets/icons/azure/iot/10168-icon-service-Machine-Learning-Studio-Web-Service-Plans.svg deleted file mode 100644 index 0cd341c..0000000 --- a/assets/icons/azure/iot/10168-icon-service-Machine-Learning-Studio-Web-Service-Plans.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-machinelearning-168 \ No newline at end of file diff --git a/assets/icons/azure/iot/10181-icon-service-Time-Series-Insights-Environments.svg b/assets/icons/azure/iot/10181-icon-service-Time-Series-Insights-Environments.svg deleted file mode 100644 index a796b28..0000000 --- a/assets/icons/azure/iot/10181-icon-service-Time-Series-Insights-Environments.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/iot/10182-icon-service-IoT-Hub.svg b/assets/icons/azure/iot/10182-icon-service-IoT-Hub.svg deleted file mode 100644 index db013b7..0000000 --- a/assets/icons/azure/iot/10182-icon-service-IoT-Hub.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/iot/10184-icon-service-IoT-Central-Applications.svg b/assets/icons/azure/iot/10184-icon-service-IoT-Central-Applications.svg deleted file mode 100644 index c5c7748..0000000 --- a/assets/icons/azure/iot/10184-icon-service-IoT-Central-Applications.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-iot-184 \ No newline at end of file diff --git a/assets/icons/azure/iot/10185-icon-service-Azure-Maps-Accounts.svg b/assets/icons/azure/iot/10185-icon-service-Azure-Maps-Accounts.svg deleted file mode 100644 index cd35afe..0000000 --- a/assets/icons/azure/iot/10185-icon-service-Azure-Maps-Accounts.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/iot/10186-icon-service-IoT-Edge.svg b/assets/icons/azure/iot/10186-icon-service-IoT-Edge.svg deleted file mode 100644 index 0140aa4..0000000 --- a/assets/icons/azure/iot/10186-icon-service-IoT-Edge.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/iot/10188-icon-service-Time-Series-Insights-Event-Sources.svg b/assets/icons/azure/iot/10188-icon-service-Time-Series-Insights-Event-Sources.svg deleted file mode 100644 index 8c0ebff..0000000 --- a/assets/icons/azure/iot/10188-icon-service-Time-Series-Insights-Event-Sources.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/iot/10192-icon-service-Time-Series-Insights-Access-Policies.svg b/assets/icons/azure/iot/10192-icon-service-Time-Series-Insights-Access-Policies.svg deleted file mode 100644 index 9ccaf91..0000000 --- a/assets/icons/azure/iot/10192-icon-service-Time-Series-Insights-Access-Policies.svg +++ /dev/null @@ -1,2 +0,0 @@ -​ - \ No newline at end of file diff --git a/assets/icons/azure/iot/10198-icon-service-Time-Series-Data-Sets.svg b/assets/icons/azure/iot/10198-icon-service-Time-Series-Data-Sets.svg deleted file mode 100644 index 3fc930b..0000000 --- a/assets/icons/azure/iot/10198-icon-service-Time-Series-Data-Sets.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-iot-198 \ No newline at end of file diff --git a/assets/icons/azure/iot/10203-icon-service-Windows10-Core-Services.svg b/assets/icons/azure/iot/10203-icon-service-Windows10-Core-Services.svg deleted file mode 100644 index 126e20a..0000000 --- a/assets/icons/azure/iot/10203-icon-service-Windows10-Core-Services.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-iot-203 \ No newline at end of file diff --git a/assets/icons/azure/iot/10221-icon-service-Event-Grid-Subscriptions.svg b/assets/icons/azure/iot/10221-icon-service-Event-Grid-Subscriptions.svg deleted file mode 100644 index 13e57f8..0000000 --- a/assets/icons/azure/iot/10221-icon-service-Event-Grid-Subscriptions.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-integration-221 \ No newline at end of file diff --git a/assets/icons/azure/iot/10369-icon-service-Device-Provisioning-Services.svg b/assets/icons/azure/iot/10369-icon-service-Device-Provisioning-Services.svg deleted file mode 100644 index 2c05a3d..0000000 --- a/assets/icons/azure/iot/10369-icon-service-Device-Provisioning-Services.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/management-governance/00001-icon-service-Monitor.svg b/assets/icons/azure/management-governance/00001-icon-service-Monitor.svg deleted file mode 100644 index 548a389..0000000 --- a/assets/icons/azure/management-governance/00001-icon-service-Monitor.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-manage-317 \ No newline at end of file diff --git a/assets/icons/azure/management-governance/00002-icon-service-Alerts.svg b/assets/icons/azure/management-governance/00002-icon-service-Alerts.svg deleted file mode 100644 index add3f7f..0000000 --- a/assets/icons/azure/management-governance/00002-icon-service-Alerts.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/management-governance/00003-icon-service-Advisor.svg b/assets/icons/azure/management-governance/00003-icon-service-Advisor.svg deleted file mode 100644 index 2fcfb26..0000000 --- a/assets/icons/azure/management-governance/00003-icon-service-Advisor.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-manage-301 \ No newline at end of file diff --git a/assets/icons/azure/management-governance/00004-icon-service-Cost-Management-and-Billing.svg b/assets/icons/azure/management-governance/00004-icon-service-Cost-Management-and-Billing.svg deleted file mode 100644 index 6785ed2..0000000 --- a/assets/icons/azure/management-governance/00004-icon-service-Cost-Management-and-Billing.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/management-governance/00006-icon-service-Blueprints.svg b/assets/icons/azure/management-governance/00006-icon-service-Blueprints.svg deleted file mode 100644 index 554c266..0000000 --- a/assets/icons/azure/management-governance/00006-icon-service-Blueprints.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-manage-304 \ No newline at end of file diff --git a/assets/icons/azure/management-governance/00007-icon-service-Activity-Log.svg b/assets/icons/azure/management-governance/00007-icon-service-Activity-Log.svg deleted file mode 100644 index 4c0e6b9..0000000 --- a/assets/icons/azure/management-governance/00007-icon-service-Activity-Log.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-manage-305 \ No newline at end of file diff --git a/assets/icons/azure/management-governance/00008-icon-service-Diagnostics-Settings.svg b/assets/icons/azure/management-governance/00008-icon-service-Diagnostics-Settings.svg deleted file mode 100644 index f656a54..0000000 --- a/assets/icons/azure/management-governance/00008-icon-service-Diagnostics-Settings.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/management-governance/00009-icon-service-Log-Analytics-Workspaces.svg b/assets/icons/azure/management-governance/00009-icon-service-Log-Analytics-Workspaces.svg deleted file mode 100644 index ec1c0ce..0000000 --- a/assets/icons/azure/management-governance/00009-icon-service-Log-Analytics-Workspaces.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-manage-307 \ No newline at end of file diff --git a/assets/icons/azure/management-governance/00010-icon-service-Scheduler-Job-Collections.svg b/assets/icons/azure/management-governance/00010-icon-service-Scheduler-Job-Collections.svg deleted file mode 100644 index 6812efb..0000000 --- a/assets/icons/azure/management-governance/00010-icon-service-Scheduler-Job-Collections.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-manage-308 \ No newline at end of file diff --git a/assets/icons/azure/management-governance/00011-icon-service-Compliance.svg b/assets/icons/azure/management-governance/00011-icon-service-Compliance.svg deleted file mode 100644 index 6cf5692..0000000 --- a/assets/icons/azure/management-governance/00011-icon-service-Compliance.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/management-governance/00012-icon-service-Application-Insights.svg b/assets/icons/azure/management-governance/00012-icon-service-Application-Insights.svg deleted file mode 100644 index 19f3e69..0000000 --- a/assets/icons/azure/management-governance/00012-icon-service-Application-Insights.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-manage-310 \ No newline at end of file diff --git a/assets/icons/azure/management-governance/00014-icon-service-My-Customers.svg b/assets/icons/azure/management-governance/00014-icon-service-My-Customers.svg deleted file mode 100644 index 6dd2be1..0000000 --- a/assets/icons/azure/management-governance/00014-icon-service-My-Customers.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-identity-223 \ No newline at end of file diff --git a/assets/icons/azure/management-governance/00017-icon-service-Recovery-Services-Vaults.svg b/assets/icons/azure/management-governance/00017-icon-service-Recovery-Services-Vaults.svg deleted file mode 100644 index 637a763..0000000 --- a/assets/icons/azure/management-governance/00017-icon-service-Recovery-Services-Vaults.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-manage-315 \ No newline at end of file diff --git a/assets/icons/azure/management-governance/00020-icon-service-Metrics.svg b/assets/icons/azure/management-governance/00020-icon-service-Metrics.svg deleted file mode 100644 index 9255e14..0000000 --- a/assets/icons/azure/management-governance/00020-icon-service-Metrics.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/management-governance/00021-icon-service-Solutions.svg b/assets/icons/azure/management-governance/00021-icon-service-Solutions.svg deleted file mode 100644 index b5f816f..0000000 --- a/assets/icons/azure/management-governance/00021-icon-service-Solutions.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-manage-321 \ No newline at end of file diff --git a/assets/icons/azure/management-governance/00022-icon-service-Automation-Accounts.svg b/assets/icons/azure/management-governance/00022-icon-service-Automation-Accounts.svg deleted file mode 100644 index 03c72dc..0000000 --- a/assets/icons/azure/management-governance/00022-icon-service-Automation-Accounts.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-322Artboard 1 \ No newline at end of file diff --git a/assets/icons/azure/management-governance/00024-icon-service-Operation-Log-(Classic).svg b/assets/icons/azure/management-governance/00024-icon-service-Operation-Log-(Classic).svg deleted file mode 100644 index 94aaba1..0000000 --- a/assets/icons/azure/management-governance/00024-icon-service-Operation-Log-(Classic).svg +++ /dev/null @@ -1 +0,0 @@ -Icon-manage-324 \ No newline at end of file diff --git a/assets/icons/azure/management-governance/00025-icon-service-Service-Providers.svg b/assets/icons/azure/management-governance/00025-icon-service-Service-Providers.svg deleted file mode 100644 index a367971..0000000 --- a/assets/icons/azure/management-governance/00025-icon-service-Service-Providers.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-manage-325 \ No newline at end of file diff --git a/assets/icons/azure/management-governance/00026-icon-service-Education.svg b/assets/icons/azure/management-governance/00026-icon-service-Education.svg deleted file mode 100644 index f0d6038..0000000 --- a/assets/icons/azure/management-governance/00026-icon-service-Education.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-manage-326 \ No newline at end of file diff --git a/assets/icons/azure/management-governance/00027-icon-service-Service-Catalog-MAD.svg b/assets/icons/azure/management-governance/00027-icon-service-Service-Catalog-MAD.svg deleted file mode 100644 index 969edbc..0000000 --- a/assets/icons/azure/management-governance/00027-icon-service-Service-Catalog-MAD.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/management-governance/00408-icon-service-Intune-Trends.svg b/assets/icons/azure/management-governance/00408-icon-service-Intune-Trends.svg deleted file mode 100644 index 3c44d59..0000000 --- a/assets/icons/azure/management-governance/00408-icon-service-Intune-Trends.svg +++ /dev/null @@ -1 +0,0 @@ -Icon408_Intune_Trends \ No newline at end of file diff --git a/assets/icons/azure/management-governance/00471-icon-service-Azure-Lighthouse.svg b/assets/icons/azure/management-governance/00471-icon-service-Azure-Lighthouse.svg deleted file mode 100644 index 98ecd02..0000000 --- a/assets/icons/azure/management-governance/00471-icon-service-Azure-Lighthouse.svg +++ /dev/null @@ -1 +0,0 @@ -Icon471-AzureLighthouse \ No newline at end of file diff --git a/assets/icons/azure/management-governance/00571-icon-service-Universal-Print.svg b/assets/icons/azure/management-governance/00571-icon-service-Universal-Print.svg deleted file mode 100644 index b3afc17..0000000 --- a/assets/icons/azure/management-governance/00571-icon-service-Universal-Print.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/management-governance/00756-icon-service-Azure-Arc.svg b/assets/icons/azure/management-governance/00756-icon-service-Azure-Arc.svg deleted file mode 100644 index 9f9c082..0000000 --- a/assets/icons/azure/management-governance/00756-icon-service-Azure-Arc.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/management-governance/01710-icon-service-Arc-Machines.svg b/assets/icons/azure/management-governance/01710-icon-service-Arc-Machines.svg deleted file mode 100644 index 50f133b..0000000 --- a/assets/icons/azure/management-governance/01710-icon-service-Arc-Machines.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/management-governance/030777508-icon-service-Service-Group-Relationships.svg b/assets/icons/azure/management-governance/030777508-icon-service-Service-Group-Relationships.svg deleted file mode 100644 index a4ec7ad..0000000 --- a/assets/icons/azure/management-governance/030777508-icon-service-Service-Group-Relationships.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/management-governance/03366-icon-service-Resources-Provider.svg b/assets/icons/azure/management-governance/03366-icon-service-Resources-Provider.svg deleted file mode 100644 index 262a262..0000000 --- a/assets/icons/azure/management-governance/03366-icon-service-Resources-Provider.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/management-governance/10303-icon-service-User-Privacy.svg b/assets/icons/azure/management-governance/10303-icon-service-User-Privacy.svg deleted file mode 100644 index 8eec8d4..0000000 --- a/assets/icons/azure/management-governance/10303-icon-service-User-Privacy.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-manage-303 \ No newline at end of file diff --git a/assets/icons/azure/management-governance/10311-icon-service-Managed-Desktop.svg b/assets/icons/azure/management-governance/10311-icon-service-Managed-Desktop.svg deleted file mode 100644 index e8f7ebd..0000000 --- a/assets/icons/azure/management-governance/10311-icon-service-Managed-Desktop.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-manage-311 \ No newline at end of file diff --git a/assets/icons/azure/management-governance/10313-icon-service-Managed-Applications-Center.svg b/assets/icons/azure/management-governance/10313-icon-service-Managed-Applications-Center.svg deleted file mode 100644 index ac1de79..0000000 --- a/assets/icons/azure/management-governance/10313-icon-service-Managed-Applications-Center.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-manage-313 \ No newline at end of file diff --git a/assets/icons/azure/management-governance/10314-icon-service-Customer-Lockbox-for-Microsoft-Azure.svg b/assets/icons/azure/management-governance/10314-icon-service-Customer-Lockbox-for-Microsoft-Azure.svg deleted file mode 100644 index e784435..0000000 --- a/assets/icons/azure/management-governance/10314-icon-service-Customer-Lockbox-for-Microsoft-Azure.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-manage-314 \ No newline at end of file diff --git a/assets/icons/azure/management-governance/10316-icon-service-Policy.svg b/assets/icons/azure/management-governance/10316-icon-service-Policy.svg deleted file mode 100644 index 2720e47..0000000 --- a/assets/icons/azure/management-governance/10316-icon-service-Policy.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-manage-316 \ No newline at end of file diff --git a/assets/icons/azure/management-governance/10318-icon-service-Resource-Graph-Explorer.svg b/assets/icons/azure/management-governance/10318-icon-service-Resource-Graph-Explorer.svg deleted file mode 100644 index 9b3db36..0000000 --- a/assets/icons/azure/management-governance/10318-icon-service-Resource-Graph-Explorer.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/management-governance/10450-icon-service-MachinesAzureArc.svg b/assets/icons/azure/management-governance/10450-icon-service-MachinesAzureArc.svg deleted file mode 100644 index b733b33..0000000 --- a/assets/icons/azure/management-governance/10450-icon-service-MachinesAzureArc.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/menu/00787-icon-service-Keys.svg b/assets/icons/azure/menu/00787-icon-service-Keys.svg deleted file mode 100644 index 7b7cbe4..0000000 --- a/assets/icons/azure/menu/00787-icon-service-Keys.svg +++ /dev/null @@ -1,2 +0,0 @@ -​ - \ No newline at end of file diff --git a/assets/icons/azure/migrate/00004-icon-service-Cost-Management-and-Billing.svg b/assets/icons/azure/migrate/00004-icon-service-Cost-Management-and-Billing.svg deleted file mode 100644 index 6785ed2..0000000 --- a/assets/icons/azure/migrate/00004-icon-service-Cost-Management-and-Billing.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/migrate/00017-icon-service-Recovery-Services-Vaults.svg b/assets/icons/azure/migrate/00017-icon-service-Recovery-Services-Vaults.svg deleted file mode 100644 index 637a763..0000000 --- a/assets/icons/azure/migrate/00017-icon-service-Recovery-Services-Vaults.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-manage-315 \ No newline at end of file diff --git a/assets/icons/azure/migrate/00691-icon-service-Azure-Databox-Gateway.svg b/assets/icons/azure/migrate/00691-icon-service-Azure-Databox-Gateway.svg deleted file mode 100644 index 90096f3..0000000 --- a/assets/icons/azure/migrate/00691-icon-service-Azure-Databox-Gateway.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/migrate/10094-icon-service-Data-Box.svg b/assets/icons/azure/migrate/10094-icon-service-Data-Box.svg deleted file mode 100644 index 4ac24fb..0000000 --- a/assets/icons/azure/migrate/10094-icon-service-Data-Box.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-storage-94 \ No newline at end of file diff --git a/assets/icons/azure/migrate/10095-icon-service-Azure-Stack-Edge.svg b/assets/icons/azure/migrate/10095-icon-service-Azure-Stack-Edge.svg deleted file mode 100644 index 656850c..0000000 --- a/assets/icons/azure/migrate/10095-icon-service-Azure-Stack-Edge.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-storage-95 \ No newline at end of file diff --git a/assets/icons/azure/migrate/10281-icon-service-Azure-Migrate.svg b/assets/icons/azure/migrate/10281-icon-service-Azure-Migrate.svg deleted file mode 100644 index b7408ef..0000000 --- a/assets/icons/azure/migrate/10281-icon-service-Azure-Migrate.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-migrate-281 \ No newline at end of file diff --git a/assets/icons/azure/migration/10133-icon-service-Azure-Database-Migration-Services.svg b/assets/icons/azure/migration/10133-icon-service-Azure-Database-Migration-Services.svg deleted file mode 100644 index f97cc9f..0000000 --- a/assets/icons/azure/migration/10133-icon-service-Azure-Database-Migration-Services.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-databases-133 \ No newline at end of file diff --git a/assets/icons/azure/mixed-reality/00698-icon-service-Remote-Rendering.svg b/assets/icons/azure/mixed-reality/00698-icon-service-Remote-Rendering.svg deleted file mode 100644 index aef8b3a..0000000 --- a/assets/icons/azure/mixed-reality/00698-icon-service-Remote-Rendering.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/mixed-reality/10352-icon-service-Spatial-Anchor-Accounts.svg b/assets/icons/azure/mixed-reality/10352-icon-service-Spatial-Anchor-Accounts.svg deleted file mode 100644 index 4ed16af..0000000 --- a/assets/icons/azure/mixed-reality/10352-icon-service-Spatial-Anchor-Accounts.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-other-352 \ No newline at end of file diff --git a/assets/icons/azure/mobile/03335-icon-service-Power-Platform.svg b/assets/icons/azure/mobile/03335-icon-service-Power-Platform.svg deleted file mode 100644 index f3d0dcd..0000000 --- a/assets/icons/azure/mobile/03335-icon-service-Power-Platform.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/mobile/10035-icon-service-App-Services.svg b/assets/icons/azure/mobile/10035-icon-service-App-Services.svg deleted file mode 100644 index 022ef80..0000000 --- a/assets/icons/azure/mobile/10035-icon-service-App-Services.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-web-41 \ No newline at end of file diff --git a/assets/icons/azure/mobile/10045-icon-service-Notification-Hubs.svg b/assets/icons/azure/mobile/10045-icon-service-Notification-Hubs.svg deleted file mode 100644 index 185df88..0000000 --- a/assets/icons/azure/mobile/10045-icon-service-Notification-Hubs.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-web-45 \ No newline at end of file diff --git a/assets/icons/azure/monitor/00001-icon-service-Monitor.svg b/assets/icons/azure/monitor/00001-icon-service-Monitor.svg deleted file mode 100644 index 548a389..0000000 --- a/assets/icons/azure/monitor/00001-icon-service-Monitor.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-manage-317 \ No newline at end of file diff --git a/assets/icons/azure/monitor/00007-icon-service-Activity-Log.svg b/assets/icons/azure/monitor/00007-icon-service-Activity-Log.svg deleted file mode 100644 index 4c0e6b9..0000000 --- a/assets/icons/azure/monitor/00007-icon-service-Activity-Log.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-manage-305 \ No newline at end of file diff --git a/assets/icons/azure/monitor/00008-icon-service-Diagnostics-Settings.svg b/assets/icons/azure/monitor/00008-icon-service-Diagnostics-Settings.svg deleted file mode 100644 index f656a54..0000000 --- a/assets/icons/azure/monitor/00008-icon-service-Diagnostics-Settings.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/monitor/00009-icon-service-Log-Analytics-Workspaces.svg b/assets/icons/azure/monitor/00009-icon-service-Log-Analytics-Workspaces.svg deleted file mode 100644 index ec1c0ce..0000000 --- a/assets/icons/azure/monitor/00009-icon-service-Log-Analytics-Workspaces.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-manage-307 \ No newline at end of file diff --git a/assets/icons/azure/monitor/00012-icon-service-Application-Insights.svg b/assets/icons/azure/monitor/00012-icon-service-Application-Insights.svg deleted file mode 100644 index 19f3e69..0000000 --- a/assets/icons/azure/monitor/00012-icon-service-Application-Insights.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-manage-310 \ No newline at end of file diff --git a/assets/icons/azure/monitor/00020-icon-service-Metrics.svg b/assets/icons/azure/monitor/00020-icon-service-Metrics.svg deleted file mode 100644 index 9255e14..0000000 --- a/assets/icons/azure/monitor/00020-icon-service-Metrics.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/monitor/00438-icon-service-Azure-Monitors-for-SAP-Solutions.svg b/assets/icons/azure/monitor/00438-icon-service-Azure-Monitors-for-SAP-Solutions.svg deleted file mode 100644 index 2f49b41..0000000 --- a/assets/icons/azure/monitor/00438-icon-service-Azure-Monitors-for-SAP-Solutions.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/monitor/00563-icon-service-Change-Analysis.svg b/assets/icons/azure/monitor/00563-icon-service-Change-Analysis.svg deleted file mode 100644 index 23ce777..0000000 --- a/assets/icons/azure/monitor/00563-icon-service-Change-Analysis.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/monitor/02189-icon-service-Azure-Workbooks.svg b/assets/icons/azure/monitor/02189-icon-service-Azure-Workbooks.svg deleted file mode 100644 index 2082ecc..0000000 --- a/assets/icons/azure/monitor/02189-icon-service-Azure-Workbooks.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/monitor/10066-icon-service-Network-Watcher.svg b/assets/icons/azure/monitor/10066-icon-service-Network-Watcher.svg deleted file mode 100644 index 1b11313..0000000 --- a/assets/icons/azure/monitor/10066-icon-service-Network-Watcher.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-networking-66 \ No newline at end of file diff --git a/assets/icons/azure/monitor/10832-icon-service-Auto-Scale.svg b/assets/icons/azure/monitor/10832-icon-service-Auto-Scale.svg deleted file mode 100644 index 6771bf8..0000000 --- a/assets/icons/azure/monitor/10832-icon-service-Auto-Scale.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/networking/00056-icon-service-CDN-Profiles.svg b/assets/icons/azure/networking/00056-icon-service-CDN-Profiles.svg deleted file mode 100644 index d35a978..0000000 --- a/assets/icons/azure/networking/00056-icon-service-CDN-Profiles.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-web-43 \ No newline at end of file diff --git a/assets/icons/azure/networking/00271-icon-service-Azure-Firewall-Manager.svg b/assets/icons/azure/networking/00271-icon-service-Azure-Firewall-Manager.svg deleted file mode 100644 index a919f5f..0000000 --- a/assets/icons/azure/networking/00271-icon-service-Azure-Firewall-Manager.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/networking/00272-icon-service-Azure-Firewall-Policy.svg b/assets/icons/azure/networking/00272-icon-service-Azure-Firewall-Policy.svg deleted file mode 100644 index 98c289f..0000000 --- a/assets/icons/azure/networking/00272-icon-service-Azure-Firewall-Policy.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/networking/00427-icon-service-Private-Link.svg b/assets/icons/azure/networking/00427-icon-service-Private-Link.svg deleted file mode 100644 index 1edd8d2..0000000 --- a/assets/icons/azure/networking/00427-icon-service-Private-Link.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/networking/00701-icon-service-IP-Groups.svg b/assets/icons/azure/networking/00701-icon-service-IP-Groups.svg deleted file mode 100644 index 4dae98d..0000000 --- a/assets/icons/azure/networking/00701-icon-service-IP-Groups.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/networking/00860-icon-service-Virtual-WAN-Hub.svg b/assets/icons/azure/networking/00860-icon-service-Virtual-WAN-Hub.svg deleted file mode 100644 index f4b3ea3..0000000 --- a/assets/icons/azure/networking/00860-icon-service-Virtual-WAN-Hub.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/networking/01105-icon-service-Private-Link-Service.svg b/assets/icons/azure/networking/01105-icon-service-Private-Link-Service.svg deleted file mode 100644 index 8f05af3..0000000 --- a/assets/icons/azure/networking/01105-icon-service-Private-Link-Service.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/networking/02145-icon-service-Resource-Management-Private-Link.svg b/assets/icons/azure/networking/02145-icon-service-Resource-Management-Private-Link.svg deleted file mode 100644 index fc62f4a..0000000 --- a/assets/icons/azure/networking/02145-icon-service-Resource-Management-Private-Link.svg +++ /dev/null @@ -1,2 +0,0 @@ -​ - \ No newline at end of file diff --git a/assets/icons/azure/networking/02209-icon-service-Private-Link-Services.svg b/assets/icons/azure/networking/02209-icon-service-Private-Link-Services.svg deleted file mode 100644 index d1ba972..0000000 --- a/assets/icons/azure/networking/02209-icon-service-Private-Link-Services.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/networking/02302-icon-service-Load-Balancer-Hub.svg b/assets/icons/azure/networking/02302-icon-service-Load-Balancer-Hub.svg deleted file mode 100644 index 00ccef8..0000000 --- a/assets/icons/azure/networking/02302-icon-service-Load-Balancer-Hub.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/networking/02422-icon-service-Bastions.svg b/assets/icons/azure/networking/02422-icon-service-Bastions.svg deleted file mode 100644 index 8d3530a..0000000 --- a/assets/icons/azure/networking/02422-icon-service-Bastions.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/networking/02496-icon-service-Virtual-Router.svg b/assets/icons/azure/networking/02496-icon-service-Virtual-Router.svg deleted file mode 100644 index 5b4de76..0000000 --- a/assets/icons/azure/networking/02496-icon-service-Virtual-Router.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/networking/02509-icon-service-Connected-Cache.svg b/assets/icons/azure/networking/02509-icon-service-Connected-Cache.svg deleted file mode 100644 index d391a35..0000000 --- a/assets/icons/azure/networking/02509-icon-service-Connected-Cache.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/networking/02692-icon-service-Spot-VMSS.svg b/assets/icons/azure/networking/02692-icon-service-Spot-VMSS.svg deleted file mode 100644 index 5f5cd1f..0000000 --- a/assets/icons/azure/networking/02692-icon-service-Spot-VMSS.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/networking/02695-icon-service-Spot-VM.svg b/assets/icons/azure/networking/02695-icon-service-Spot-VM.svg deleted file mode 100644 index 61ee7e8..0000000 --- a/assets/icons/azure/networking/02695-icon-service-Spot-VM.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/networking/02742-icon-service-Subnet.svg b/assets/icons/azure/networking/02742-icon-service-Subnet.svg deleted file mode 100644 index 2efa0ad..0000000 --- a/assets/icons/azure/networking/02742-icon-service-Subnet.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/networking/02882-icon-service-DNS-Private-Resolver.svg b/assets/icons/azure/networking/02882-icon-service-DNS-Private-Resolver.svg deleted file mode 100644 index 300494d..0000000 --- a/assets/icons/azure/networking/02882-icon-service-DNS-Private-Resolver.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/networking/029029174-icon-service-Load-Balancer-Hub.svg b/assets/icons/azure/networking/029029174-icon-service-Load-Balancer-Hub.svg deleted file mode 100644 index 3a1370a..0000000 --- a/assets/icons/azure/networking/029029174-icon-service-Load-Balancer-Hub.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/networking/029273094-icon-service-Edge-Actions.svg b/assets/icons/azure/networking/029273094-icon-service-Edge-Actions.svg deleted file mode 100644 index fbc3f0c..0000000 --- a/assets/icons/azure/networking/029273094-icon-service-Edge-Actions.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/networking/03311-icon-service-Azure-Communications-Gateway.svg b/assets/icons/azure/networking/03311-icon-service-Azure-Communications-Gateway.svg deleted file mode 100644 index baedab3..0000000 --- a/assets/icons/azure/networking/03311-icon-service-Azure-Communications-Gateway.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/networking/03328-icon-service-Application-Gateway-Containers.svg b/assets/icons/azure/networking/03328-icon-service-Application-Gateway-Containers.svg deleted file mode 100644 index 03102b1..0000000 --- a/assets/icons/azure/networking/03328-icon-service-Application-Gateway-Containers.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/networking/03368-icon-service-DNS-Security-Policy.svg b/assets/icons/azure/networking/03368-icon-service-DNS-Security-Policy.svg deleted file mode 100644 index f32cb2f..0000000 --- a/assets/icons/azure/networking/03368-icon-service-DNS-Security-Policy.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/networking/034412066-icon-service-VNet-Appliance.svg b/assets/icons/azure/networking/034412066-icon-service-VNet-Appliance.svg deleted file mode 100644 index 0702393..0000000 --- a/assets/icons/azure/networking/034412066-icon-service-VNet-Appliance.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/networking/03459-icon-service-DNS-Multistack.svg b/assets/icons/azure/networking/03459-icon-service-DNS-Multistack.svg deleted file mode 100644 index e81b628..0000000 --- a/assets/icons/azure/networking/03459-icon-service-DNS-Multistack.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/networking/03460-icon-service-ATM-Multistack.svg b/assets/icons/azure/networking/03460-icon-service-ATM-Multistack.svg deleted file mode 100644 index c862256..0000000 --- a/assets/icons/azure/networking/03460-icon-service-ATM-Multistack.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/networking/03461-icon-service-IP-Address-manager.svg b/assets/icons/azure/networking/03461-icon-service-IP-Address-manager.svg deleted file mode 100644 index 476c433..0000000 --- a/assets/icons/azure/networking/03461-icon-service-IP-Address-manager.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/networking/03643-icon-service-Network-Foundation-Hub.svg b/assets/icons/azure/networking/03643-icon-service-Network-Foundation-Hub.svg deleted file mode 100644 index f9a14a2..0000000 --- a/assets/icons/azure/networking/03643-icon-service-Network-Foundation-Hub.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/networking/03650-icon-service-Network-Security-Hub.svg b/assets/icons/azure/networking/03650-icon-service-Network-Security-Hub.svg deleted file mode 100644 index c5ca71c..0000000 --- a/assets/icons/azure/networking/03650-icon-service-Network-Security-Hub.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/networking/03694-icon-service-VPNClientWindows.svg b/assets/icons/azure/networking/03694-icon-service-VPNClientWindows.svg deleted file mode 100644 index dbadcb8..0000000 --- a/assets/icons/azure/networking/03694-icon-service-VPNClientWindows.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/networking/10061-icon-service-Virtual-Networks.svg b/assets/icons/azure/networking/10061-icon-service-Virtual-Networks.svg deleted file mode 100644 index ad628af..0000000 --- a/assets/icons/azure/networking/10061-icon-service-Virtual-Networks.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-networking-61 \ No newline at end of file diff --git a/assets/icons/azure/networking/10062-icon-service-Load-Balancers.svg b/assets/icons/azure/networking/10062-icon-service-Load-Balancers.svg deleted file mode 100644 index de76793..0000000 --- a/assets/icons/azure/networking/10062-icon-service-Load-Balancers.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-networking-62 \ No newline at end of file diff --git a/assets/icons/azure/networking/10063-icon-service-Virtual-Network-Gateways.svg b/assets/icons/azure/networking/10063-icon-service-Virtual-Network-Gateways.svg deleted file mode 100644 index 0e4ee74..0000000 --- a/assets/icons/azure/networking/10063-icon-service-Virtual-Network-Gateways.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-networking-63 \ No newline at end of file diff --git a/assets/icons/azure/networking/10064-icon-service-DNS-Zones.svg b/assets/icons/azure/networking/10064-icon-service-DNS-Zones.svg deleted file mode 100644 index 85993d3..0000000 --- a/assets/icons/azure/networking/10064-icon-service-DNS-Zones.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-networking-64 \ No newline at end of file diff --git a/assets/icons/azure/networking/10065-icon-service-Traffic-Manager-Profiles.svg b/assets/icons/azure/networking/10065-icon-service-Traffic-Manager-Profiles.svg deleted file mode 100644 index 6e711e1..0000000 --- a/assets/icons/azure/networking/10065-icon-service-Traffic-Manager-Profiles.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-networking-65 \ No newline at end of file diff --git a/assets/icons/azure/networking/10066-icon-service-Network-Watcher.svg b/assets/icons/azure/networking/10066-icon-service-Network-Watcher.svg deleted file mode 100644 index 1b11313..0000000 --- a/assets/icons/azure/networking/10066-icon-service-Network-Watcher.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-networking-66 \ No newline at end of file diff --git a/assets/icons/azure/networking/10067-icon-service-Network-Security-Groups.svg b/assets/icons/azure/networking/10067-icon-service-Network-Security-Groups.svg deleted file mode 100644 index c583cb9..0000000 --- a/assets/icons/azure/networking/10067-icon-service-Network-Security-Groups.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-networking-67 \ No newline at end of file diff --git a/assets/icons/azure/networking/10068-icon-service-Public-IP-Addresses-(Classic).svg b/assets/icons/azure/networking/10068-icon-service-Public-IP-Addresses-(Classic).svg deleted file mode 100644 index c143913..0000000 --- a/assets/icons/azure/networking/10068-icon-service-Public-IP-Addresses-(Classic).svg +++ /dev/null @@ -1 +0,0 @@ -Icon-networking-68 \ No newline at end of file diff --git a/assets/icons/azure/networking/10069-icon-service-Public-IP-Addresses.svg b/assets/icons/azure/networking/10069-icon-service-Public-IP-Addresses.svg deleted file mode 100644 index a189fc3..0000000 --- a/assets/icons/azure/networking/10069-icon-service-Public-IP-Addresses.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-networking-69 \ No newline at end of file diff --git a/assets/icons/azure/networking/10070-icon-service-On-Premises-Data-Gateways.svg b/assets/icons/azure/networking/10070-icon-service-On-Premises-Data-Gateways.svg deleted file mode 100644 index 2abdeb0..0000000 --- a/assets/icons/azure/networking/10070-icon-service-On-Premises-Data-Gateways.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-networking-70 \ No newline at end of file diff --git a/assets/icons/azure/networking/10071-icon-service-Route-Filters.svg b/assets/icons/azure/networking/10071-icon-service-Route-Filters.svg deleted file mode 100644 index a8fab3f..0000000 --- a/assets/icons/azure/networking/10071-icon-service-Route-Filters.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-networking-71 \ No newline at end of file diff --git a/assets/icons/azure/networking/10072-icon-service-DDoS-Protection-Plans.svg b/assets/icons/azure/networking/10072-icon-service-DDoS-Protection-Plans.svg deleted file mode 100644 index 4a6c0e7..0000000 --- a/assets/icons/azure/networking/10072-icon-service-DDoS-Protection-Plans.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-networking-72 \ No newline at end of file diff --git a/assets/icons/azure/networking/10073-icon-service-Front-Door-and-CDN-Profiles.svg b/assets/icons/azure/networking/10073-icon-service-Front-Door-and-CDN-Profiles.svg deleted file mode 100644 index 15309b8..0000000 --- a/assets/icons/azure/networking/10073-icon-service-Front-Door-and-CDN-Profiles.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-networking-73 \ No newline at end of file diff --git a/assets/icons/azure/networking/10075-icon-service-Virtual-Networks-(Classic).svg b/assets/icons/azure/networking/10075-icon-service-Virtual-Networks-(Classic).svg deleted file mode 100644 index 64b84b5..0000000 --- a/assets/icons/azure/networking/10075-icon-service-Virtual-Networks-(Classic).svg +++ /dev/null @@ -1 +0,0 @@ -Icon-networking-75 \ No newline at end of file diff --git a/assets/icons/azure/networking/10076-icon-service-Application-Gateways.svg b/assets/icons/azure/networking/10076-icon-service-Application-Gateways.svg deleted file mode 100644 index b0b824a..0000000 --- a/assets/icons/azure/networking/10076-icon-service-Application-Gateways.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/networking/10077-icon-service-Local-Network-Gateways.svg b/assets/icons/azure/networking/10077-icon-service-Local-Network-Gateways.svg deleted file mode 100644 index 0964d08..0000000 --- a/assets/icons/azure/networking/10077-icon-service-Local-Network-Gateways.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-networking-77 \ No newline at end of file diff --git a/assets/icons/azure/networking/10079-icon-service-ExpressRoute-Circuits.svg b/assets/icons/azure/networking/10079-icon-service-ExpressRoute-Circuits.svg deleted file mode 100644 index 3fa3479..0000000 --- a/assets/icons/azure/networking/10079-icon-service-ExpressRoute-Circuits.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-networking-79 \ No newline at end of file diff --git a/assets/icons/azure/networking/10080-icon-service-Network-Interfaces.svg b/assets/icons/azure/networking/10080-icon-service-Network-Interfaces.svg deleted file mode 100644 index ebee364..0000000 --- a/assets/icons/azure/networking/10080-icon-service-Network-Interfaces.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-networking-80 \ No newline at end of file diff --git a/assets/icons/azure/networking/10081-icon-service-Connections.svg b/assets/icons/azure/networking/10081-icon-service-Connections.svg deleted file mode 100644 index d3eb6c5..0000000 --- a/assets/icons/azure/networking/10081-icon-service-Connections.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-networking-81 \ No newline at end of file diff --git a/assets/icons/azure/networking/10082-icon-service-Route-Tables.svg b/assets/icons/azure/networking/10082-icon-service-Route-Tables.svg deleted file mode 100644 index 3d9b5eb..0000000 --- a/assets/icons/azure/networking/10082-icon-service-Route-Tables.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-networking-82 \ No newline at end of file diff --git a/assets/icons/azure/networking/10084-icon-service-Firewalls.svg b/assets/icons/azure/networking/10084-icon-service-Firewalls.svg deleted file mode 100644 index 0a97a18..0000000 --- a/assets/icons/azure/networking/10084-icon-service-Firewalls.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-networking-84 \ No newline at end of file diff --git a/assets/icons/azure/networking/10085-icon-service-Service-Endpoint-Policies.svg b/assets/icons/azure/networking/10085-icon-service-Service-Endpoint-Policies.svg deleted file mode 100644 index c72b361..0000000 --- a/assets/icons/azure/networking/10085-icon-service-Service-Endpoint-Policies.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-networking-85 \ No newline at end of file diff --git a/assets/icons/azure/networking/10310-icon-service-NAT.svg b/assets/icons/azure/networking/10310-icon-service-NAT.svg deleted file mode 100644 index 579de2a..0000000 --- a/assets/icons/azure/networking/10310-icon-service-NAT.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/networking/10353-icon-service-Virtual-WANs.svg b/assets/icons/azure/networking/10353-icon-service-Virtual-WANs.svg deleted file mode 100644 index a9d8974..0000000 --- a/assets/icons/azure/networking/10353-icon-service-Virtual-WANs.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/networking/10362-icon-service-Web-Application-Firewall-Policies(WAF).svg b/assets/icons/azure/networking/10362-icon-service-Web-Application-Firewall-Policies(WAF).svg deleted file mode 100644 index 7c6337c..0000000 --- a/assets/icons/azure/networking/10362-icon-service-Web-Application-Firewall-Policies(WAF).svg +++ /dev/null @@ -1 +0,0 @@ -Icon-362Artboard 1 \ No newline at end of file diff --git a/assets/icons/azure/networking/10365-icon-service-Proximity-Placement-Groups.svg b/assets/icons/azure/networking/10365-icon-service-Proximity-Placement-Groups.svg deleted file mode 100644 index 318cf04..0000000 --- a/assets/icons/azure/networking/10365-icon-service-Proximity-Placement-Groups.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/networking/10371-icon-service-Reserved-IP-Addresses-(Classic).svg b/assets/icons/azure/networking/10371-icon-service-Reserved-IP-Addresses-(Classic).svg deleted file mode 100644 index f93cd53..0000000 --- a/assets/icons/azure/networking/10371-icon-service-Reserved-IP-Addresses-(Classic).svg +++ /dev/null @@ -1 +0,0 @@ -Lighthouse-GS-networking-371 \ No newline at end of file diff --git a/assets/icons/azure/networking/10372-icon-service-Public-IP-Prefixes.svg b/assets/icons/azure/networking/10372-icon-service-Public-IP-Prefixes.svg deleted file mode 100644 index 3756785..0000000 --- a/assets/icons/azure/networking/10372-icon-service-Public-IP-Prefixes.svg +++ /dev/null @@ -1 +0,0 @@ -Lighthouse-GS-networking-372 \ No newline at end of file diff --git a/assets/icons/azure/new icons/02573-icon-service-Connected-Vehicle-Platform.svg b/assets/icons/azure/new icons/02573-icon-service-Connected-Vehicle-Platform.svg deleted file mode 100644 index a494ac0..0000000 --- a/assets/icons/azure/new icons/02573-icon-service-Connected-Vehicle-Platform.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/new icons/029109619-icon-service-Toolchain-Orchestrator.svg b/assets/icons/azure/new icons/029109619-icon-service-Toolchain-Orchestrator.svg deleted file mode 100644 index 0700b09..0000000 --- a/assets/icons/azure/new icons/029109619-icon-service-Toolchain-Orchestrator.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/new icons/029636048-icon-service-Landing-Zone.svg b/assets/icons/azure/new icons/029636048-icon-service-Landing-Zone.svg deleted file mode 100644 index ce6a368..0000000 --- a/assets/icons/azure/new icons/029636048-icon-service-Landing-Zone.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/new icons/030777508 -icon-service-Service-Group-Relationships.svg b/assets/icons/azure/new icons/030777508 -icon-service-Service-Group-Relationships.svg deleted file mode 100644 index a4ec7ad..0000000 --- a/assets/icons/azure/new icons/030777508 -icon-service-Service-Group-Relationships.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/new icons/031193085-icon-service-Azure-Access-Point.svg b/assets/icons/azure/new icons/031193085-icon-service-Azure-Access-Point.svg deleted file mode 100644 index 95b9e34..0000000 --- a/assets/icons/azure/new icons/031193085-icon-service-Azure-Access-Point.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/new icons/033740592-icon-service-Azure-Data-Transfer.svg b/assets/icons/azure/new icons/033740592-icon-service-Azure-Data-Transfer.svg deleted file mode 100644 index 0189c7b..0000000 --- a/assets/icons/azure/new icons/033740592-icon-service-Azure-Data-Transfer.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/new icons/03400-icon-service-Entra-Identity.svg b/assets/icons/azure/new icons/03400-icon-service-Entra-Identity.svg deleted file mode 100644 index c326a2c..0000000 --- a/assets/icons/azure/new icons/03400-icon-service-Entra-Identity.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/new icons/034296882-icon-service-Agentic-Web-Apps.svg b/assets/icons/azure/new icons/034296882-icon-service-Agentic-Web-Apps.svg deleted file mode 100644 index 2bf927a..0000000 --- a/assets/icons/azure/new icons/034296882-icon-service-Agentic-Web-Apps.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/new icons/034923704-icon-service-Resiliency.svg b/assets/icons/azure/new icons/034923704-icon-service-Resiliency.svg deleted file mode 100644 index 49771c4..0000000 --- a/assets/icons/azure/new icons/034923704-icon-service-Resiliency.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/new icons/035556610-icon-service-DDoS-Custom-Policy.svg b/assets/icons/azure/new icons/035556610-icon-service-DDoS-Custom-Policy.svg deleted file mode 100644 index b10d190..0000000 --- a/assets/icons/azure/new icons/035556610-icon-service-DDoS-Custom-Policy.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/new icons/035712231-icon-service-Disconnected-Operations.svg b/assets/icons/azure/new icons/035712231-icon-service-Disconnected-Operations.svg deleted file mode 100644 index 98813f5..0000000 --- a/assets/icons/azure/new icons/035712231-icon-service-Disconnected-Operations.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/new icons/035824068-icon-service-Azure-Data-Sharing.svg b/assets/icons/azure/new icons/035824068-icon-service-Azure-Data-Sharing.svg deleted file mode 100644 index c8efad0..0000000 --- a/assets/icons/azure/new icons/035824068-icon-service-Azure-Data-Sharing.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/new icons/037838045-icon-service-AI-Gateway.svg b/assets/icons/azure/new icons/037838045-icon-service-AI-Gateway.svg deleted file mode 100644 index 6e07045..0000000 --- a/assets/icons/azure/new icons/037838045-icon-service-AI-Gateway.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/00327-icon-service-Azure-Virtual-Desktop.svg b/assets/icons/azure/other/00327-icon-service-Azure-Virtual-Desktop.svg deleted file mode 100644 index 52af4ee..0000000 --- a/assets/icons/azure/other/00327-icon-service-Azure-Virtual-Desktop.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/00412-icon-service-SSH-Keys.svg b/assets/icons/azure/other/00412-icon-service-SSH-Keys.svg deleted file mode 100644 index 12cba68..0000000 --- a/assets/icons/azure/other/00412-icon-service-SSH-Keys.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/00469-icon-service-Internet-Analyzer-Profiles.svg b/assets/icons/azure/other/00469-icon-service-Internet-Analyzer-Profiles.svg deleted file mode 100644 index 8774b65..0000000 --- a/assets/icons/azure/other/00469-icon-service-Internet-Analyzer-Profiles.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-469-Internet-Analyzer \ No newline at end of file diff --git a/assets/icons/azure/other/00559-icon-service-Azure-Cloud-Shell.svg b/assets/icons/azure/other/00559-icon-service-Azure-Cloud-Shell.svg deleted file mode 100644 index fdb5857..0000000 --- a/assets/icons/azure/other/00559-icon-service-Azure-Cloud-Shell.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/00712-icon-service-Video-Analyzers.svg b/assets/icons/azure/other/00712-icon-service-Video-Analyzers.svg deleted file mode 100644 index b4fad68..0000000 --- a/assets/icons/azure/other/00712-icon-service-Video-Analyzers.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/00903-icon-service-ExpressRoute-Direct.svg b/assets/icons/azure/other/00903-icon-service-ExpressRoute-Direct.svg deleted file mode 100644 index 90e17c4..0000000 --- a/assets/icons/azure/other/00903-icon-service-ExpressRoute-Direct.svg +++ /dev/null @@ -1,2 +0,0 @@ -​ - \ No newline at end of file diff --git a/assets/icons/azure/other/00913-icon-service-Cost-Export.svg b/assets/icons/azure/other/00913-icon-service-Cost-Export.svg deleted file mode 100644 index 42b9daa..0000000 --- a/assets/icons/azure/other/00913-icon-service-Cost-Export.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/00968-icon-service-Azure-Communication-Services.svg b/assets/icons/azure/other/00968-icon-service-Azure-Communication-Services.svg deleted file mode 100644 index 6c6dbd0..0000000 --- a/assets/icons/azure/other/00968-icon-service-Azure-Communication-Services.svg +++ /dev/null @@ -1,2 +0,0 @@ -​ - \ No newline at end of file diff --git a/assets/icons/azure/other/00970-icon-service-Peering-Service.svg b/assets/icons/azure/other/00970-icon-service-Peering-Service.svg deleted file mode 100644 index 0719a5c..0000000 --- a/assets/icons/azure/other/00970-icon-service-Peering-Service.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/01001-icon-service-Azure-Network-Function-Manager-Functions.svg b/assets/icons/azure/other/01001-icon-service-Azure-Network-Function-Manager-Functions.svg deleted file mode 100644 index c3baa1e..0000000 --- a/assets/icons/azure/other/01001-icon-service-Azure-Network-Function-Manager-Functions.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/01085-icon-service-Log-Analytics-Query-Pack.svg b/assets/icons/azure/other/01085-icon-service-Log-Analytics-Query-Pack.svg deleted file mode 100644 index fb2e926..0000000 --- a/assets/icons/azure/other/01085-icon-service-Log-Analytics-Query-Pack.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/01088-icon-service-Arc-Kubernetes.svg b/assets/icons/azure/other/01088-icon-service-Arc-Kubernetes.svg deleted file mode 100644 index ada17c8..0000000 --- a/assets/icons/azure/other/01088-icon-service-Arc-Kubernetes.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/01219-icon-service-Azure-VMware-Solution.svg b/assets/icons/azure/other/01219-icon-service-Azure-VMware-Solution.svg deleted file mode 100644 index ea9540d..0000000 --- a/assets/icons/azure/other/01219-icon-service-Azure-VMware-Solution.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/01285-icon-service-Peerings.svg b/assets/icons/azure/other/01285-icon-service-Peerings.svg deleted file mode 100644 index bcb2a75..0000000 --- a/assets/icons/azure/other/01285-icon-service-Peerings.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/01757-icon-service-Dashboard-Hub.svg b/assets/icons/azure/other/01757-icon-service-Dashboard-Hub.svg deleted file mode 100644 index 09769df..0000000 --- a/assets/icons/azure/other/01757-icon-service-Dashboard-Hub.svg +++ /dev/null @@ -1,2 +0,0 @@ -​ - \ No newline at end of file diff --git a/assets/icons/azure/other/01800-icon-service-Azure-Video-Indexer.svg b/assets/icons/azure/other/01800-icon-service-Azure-Video-Indexer.svg deleted file mode 100644 index 399c2c6..0000000 --- a/assets/icons/azure/other/01800-icon-service-Azure-Video-Indexer.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/01846-icon-service-AVS-VM.svg b/assets/icons/azure/other/01846-icon-service-AVS-VM.svg deleted file mode 100644 index 7f91954..0000000 --- a/assets/icons/azure/other/01846-icon-service-AVS-VM.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/01848-icon-service-Arc-PostgreSQL .svg b/assets/icons/azure/other/01848-icon-service-Arc-PostgreSQL .svg deleted file mode 100644 index cc0e9a9..0000000 --- a/assets/icons/azure/other/01848-icon-service-Arc-PostgreSQL .svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/01849-icon-service-Arc-SQL-Managed-Instance.svg b/assets/icons/azure/other/01849-icon-service-Arc-SQL-Managed-Instance.svg deleted file mode 100644 index 5016064..0000000 --- a/assets/icons/azure/other/01849-icon-service-Arc-SQL-Managed-Instance.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/01850-icon-service-Arc-SQL-Server.svg b/assets/icons/azure/other/01850-icon-service-Arc-SQL-Server.svg deleted file mode 100644 index cfe3bec..0000000 --- a/assets/icons/azure/other/01850-icon-service-Arc-SQL-Server.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/01857-icon-service-Data-Collection-Rules.svg b/assets/icons/azure/other/01857-icon-service-Data-Collection-Rules.svg deleted file mode 100644 index 5d32891..0000000 --- a/assets/icons/azure/other/01857-icon-service-Data-Collection-Rules.svg +++ /dev/null @@ -1,2 +0,0 @@ -​ - \ No newline at end of file diff --git a/assets/icons/azure/other/02200-icon-service-Resource-Mover.svg b/assets/icons/azure/other/02200-icon-service-Resource-Mover.svg deleted file mode 100644 index 952a81d..0000000 --- a/assets/icons/azure/other/02200-icon-service-Resource-Mover.svg +++ /dev/null @@ -1,2 +0,0 @@ -​ - \ No newline at end of file diff --git a/assets/icons/azure/other/02223-icon-service-Azure-Chaos-Studio.svg b/assets/icons/azure/other/02223-icon-service-Azure-Chaos-Studio.svg deleted file mode 100644 index d6c21d5..0000000 --- a/assets/icons/azure/other/02223-icon-service-Azure-Chaos-Studio.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/02237-icon-service-Network-Managers.svg b/assets/icons/azure/other/02237-icon-service-Network-Managers.svg deleted file mode 100644 index 9918b96..0000000 --- a/assets/icons/azure/other/02237-icon-service-Network-Managers.svg +++ /dev/null @@ -1,2 +0,0 @@ -​ - \ No newline at end of file diff --git a/assets/icons/azure/other/02322-icon-service-Dedicated-HSM.svg b/assets/icons/azure/other/02322-icon-service-Dedicated-HSM.svg deleted file mode 100644 index e0342e8..0000000 --- a/assets/icons/azure/other/02322-icon-service-Dedicated-HSM.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/02323-icon-service-Modular-Data-Center.svg b/assets/icons/azure/other/02323-icon-service-Modular-Data-Center.svg deleted file mode 100644 index 29f34ae..0000000 --- a/assets/icons/azure/other/02323-icon-service-Modular-Data-Center.svg +++ /dev/null @@ -1,2 +0,0 @@ -​ - \ No newline at end of file diff --git a/assets/icons/azure/other/02340-icon-service-Template-Specs.svg b/assets/icons/azure/other/02340-icon-service-Template-Specs.svg deleted file mode 100644 index 7ed1e79..0000000 --- a/assets/icons/azure/other/02340-icon-service-Template-Specs.svg +++ /dev/null @@ -1,2 +0,0 @@ -​ - \ No newline at end of file diff --git a/assets/icons/azure/other/02356-icon-service-Arc-Data-services.svg b/assets/icons/azure/other/02356-icon-service-Arc-Data-services.svg deleted file mode 100644 index f925526..0000000 --- a/assets/icons/azure/other/02356-icon-service-Arc-Data-services.svg +++ /dev/null @@ -1,2 +0,0 @@ -​ - \ No newline at end of file diff --git a/assets/icons/azure/other/02360-icon-service-Azure-Backup-Center.svg b/assets/icons/azure/other/02360-icon-service-Azure-Backup-Center.svg deleted file mode 100644 index 4e1b319..0000000 --- a/assets/icons/azure/other/02360-icon-service-Azure-Backup-Center.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/02361-icon-service-Backup-Vault.svg b/assets/icons/azure/other/02361-icon-service-Backup-Vault.svg deleted file mode 100644 index b219eeb..0000000 --- a/assets/icons/azure/other/02361-icon-service-Backup-Vault.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/02475-icon-service-Device-Update-IoT-Hub.svg b/assets/icons/azure/other/02475-icon-service-Device-Update-IoT-Hub.svg deleted file mode 100644 index f3b90db..0000000 --- a/assets/icons/azure/other/02475-icon-service-Device-Update-IoT-Hub.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/02486-icon-service-Fiji.svg b/assets/icons/azure/other/02486-icon-service-Fiji.svg deleted file mode 100644 index 9ea42a0..0000000 --- a/assets/icons/azure/other/02486-icon-service-Fiji.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/02488-icon-service-Azure-Monitor-Dashboard.svg b/assets/icons/azure/other/02488-icon-service-Azure-Monitor-Dashboard.svg deleted file mode 100644 index 593fea0..0000000 --- a/assets/icons/azure/other/02488-icon-service-Azure-Monitor-Dashboard.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/02503-icon-service-SCVMM-Management-Servers.svg b/assets/icons/azure/other/02503-icon-service-SCVMM-Management-Servers.svg deleted file mode 100644 index da1e675..0000000 --- a/assets/icons/azure/other/02503-icon-service-SCVMM-Management-Servers.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/02505-icon-service-Cloud-Services-(extended-support).svg b/assets/icons/azure/other/02505-icon-service-Cloud-Services-(extended-support).svg deleted file mode 100644 index 20abe8f..0000000 --- a/assets/icons/azure/other/02505-icon-service-Cloud-Services-(extended-support).svg +++ /dev/null @@ -1,2 +0,0 @@ -​ - \ No newline at end of file diff --git a/assets/icons/azure/other/02507-icon-service-Azure-Support-Center-Blue.svg b/assets/icons/azure/other/02507-icon-service-Azure-Support-Center-Blue.svg deleted file mode 100644 index 3b399d6..0000000 --- a/assets/icons/azure/other/02507-icon-service-Azure-Support-Center-Blue.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/02515-icon-service-Web-App-+-Database.svg b/assets/icons/azure/other/02515-icon-service-Web-App-+-Database.svg deleted file mode 100644 index 90a56e3..0000000 --- a/assets/icons/azure/other/02515-icon-service-Web-App-+-Database.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/02518-icon-service-Azure-HPC-Workbenches.svg b/assets/icons/azure/other/02518-icon-service-Azure-HPC-Workbenches.svg deleted file mode 100644 index 42f96d3..0000000 --- a/assets/icons/azure/other/02518-icon-service-Azure-HPC-Workbenches.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/02560-icon-service-Disk-Pool.svg b/assets/icons/azure/other/02560-icon-service-Disk-Pool.svg deleted file mode 100644 index a47f5e3..0000000 --- a/assets/icons/azure/other/02560-icon-service-Disk-Pool.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/02561-icon-service-Bare-Metal-Infrastructure.svg b/assets/icons/azure/other/02561-icon-service-Bare-Metal-Infrastructure.svg deleted file mode 100644 index 4e2a5f1..0000000 --- a/assets/icons/azure/other/02561-icon-service-Bare-Metal-Infrastructure.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/02579-icon-service-Private-Endpoints.svg b/assets/icons/azure/other/02579-icon-service-Private-Endpoints.svg deleted file mode 100644 index 98fe90a..0000000 --- a/assets/icons/azure/other/02579-icon-service-Private-Endpoints.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/02602-icon-service-Open-Supply-Chain-Platform.svg b/assets/icons/azure/other/02602-icon-service-Open-Supply-Chain-Platform.svg deleted file mode 100644 index 6d6cbb3..0000000 --- a/assets/icons/azure/other/02602-icon-service-Open-Supply-Chain-Platform.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/02625-icon-service-Aquila.svg b/assets/icons/azure/other/02625-icon-service-Aquila.svg deleted file mode 100644 index d7f4058..0000000 --- a/assets/icons/azure/other/02625-icon-service-Aquila.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/02638-icon-service-Reserved-Capacity.svg b/assets/icons/azure/other/02638-icon-service-Reserved-Capacity.svg deleted file mode 100644 index 91f75f5..0000000 --- a/assets/icons/azure/other/02638-icon-service-Reserved-Capacity.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/02654-icon-service-Custom-IP-Prefix.svg b/assets/icons/azure/other/02654-icon-service-Custom-IP-Prefix.svg deleted file mode 100644 index 8c80191..0000000 --- a/assets/icons/azure/other/02654-icon-service-Custom-IP-Prefix.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/02658-icon-service-FHIR-Service.svg b/assets/icons/azure/other/02658-icon-service-FHIR-Service.svg deleted file mode 100644 index c9efd9d..0000000 --- a/assets/icons/azure/other/02658-icon-service-FHIR-Service.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/02660-icon-service-MedTech-Service.svg b/assets/icons/azure/other/02660-icon-service-MedTech-Service.svg deleted file mode 100644 index a6cc8ab..0000000 --- a/assets/icons/azure/other/02660-icon-service-MedTech-Service.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/02663-icon-service-Managed-Instance-Apache-Cassandra.svg b/assets/icons/azure/other/02663-icon-service-Managed-Instance-Apache-Cassandra.svg deleted file mode 100644 index b3c8f4a..0000000 --- a/assets/icons/azure/other/02663-icon-service-Managed-Instance-Apache-Cassandra.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/02668-icon-service-Confidential-Ledgers.svg b/assets/icons/azure/other/02668-icon-service-Confidential-Ledgers.svg deleted file mode 100644 index cd2f4f0..0000000 --- a/assets/icons/azure/other/02668-icon-service-Confidential-Ledgers.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/02681-icon-service-AAD-Licenses.svg b/assets/icons/azure/other/02681-icon-service-AAD-Licenses.svg deleted file mode 100644 index 59f5b8b..0000000 --- a/assets/icons/azure/other/02681-icon-service-AAD-Licenses.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/02681-icon-service-Entra-Identity-Licenses.svg b/assets/icons/azure/other/02681-icon-service-Entra-Identity-Licenses.svg deleted file mode 100644 index 59f5b8b..0000000 --- a/assets/icons/azure/other/02681-icon-service-Entra-Identity-Licenses.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/02691-icon-service-Test-Base.svg b/assets/icons/azure/other/02691-icon-service-Test-Base.svg deleted file mode 100644 index ea3dd7c..0000000 --- a/assets/icons/azure/other/02691-icon-service-Test-Base.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/02697-icon-service-Azure-Orbital.svg b/assets/icons/azure/other/02697-icon-service-Azure-Orbital.svg deleted file mode 100644 index 25d3080..0000000 --- a/assets/icons/azure/other/02697-icon-service-Azure-Orbital.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/02732-icon-service-Capacity-Reservation-Groups.svg b/assets/icons/azure/other/02732-icon-service-Capacity-Reservation-Groups.svg deleted file mode 100644 index 58b96d7..0000000 --- a/assets/icons/azure/other/02732-icon-service-Capacity-Reservation-Groups.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/02733-icon-service-Windows-Notification-Services.svg b/assets/icons/azure/other/02733-icon-service-Windows-Notification-Services.svg deleted file mode 100644 index 4b1e788..0000000 --- a/assets/icons/azure/other/02733-icon-service-Windows-Notification-Services.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/02740-icon-service-Azure-Network-Function-Manager.svg b/assets/icons/azure/other/02740-icon-service-Azure-Network-Function-Manager.svg deleted file mode 100644 index 2e3b6ab..0000000 --- a/assets/icons/azure/other/02740-icon-service-Azure-Network-Function-Manager.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/02793-icon-service-Mission-Landing-Zone.svg b/assets/icons/azure/other/02793-icon-service-Mission-Landing-Zone.svg deleted file mode 100644 index 0cbfd99..0000000 --- a/assets/icons/azure/other/02793-icon-service-Mission-Landing-Zone.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/02794-icon-service-Mobile-Networks.svg b/assets/icons/azure/other/02794-icon-service-Mobile-Networks.svg deleted file mode 100644 index dfc7050..0000000 --- a/assets/icons/azure/other/02794-icon-service-Mobile-Networks.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/02805-icon-service-VM-App-Definitions.svg b/assets/icons/azure/other/02805-icon-service-VM-App-Definitions.svg deleted file mode 100644 index 95626ff..0000000 --- a/assets/icons/azure/other/02805-icon-service-VM-App-Definitions.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/02806-icon-service-VM-App-Versions.svg b/assets/icons/azure/other/02806-icon-service-VM-App-Versions.svg deleted file mode 100644 index 3f53c20..0000000 --- a/assets/icons/azure/other/02806-icon-service-VM-App-Versions.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/02810-icon-service-Azure-Edge-Hardware-Center.svg b/assets/icons/azure/other/02810-icon-service-Azure-Edge-Hardware-Center.svg deleted file mode 100644 index 5d01f27..0000000 --- a/assets/icons/azure/other/02810-icon-service-Azure-Edge-Hardware-Center.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/02819-icon-service-Resource-Guard.svg b/assets/icons/azure/other/02819-icon-service-Resource-Guard.svg deleted file mode 100644 index 786a426..0000000 --- a/assets/icons/azure/other/02819-icon-service-Resource-Guard.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/02828-icon-service-Ceres.svg b/assets/icons/azure/other/02828-icon-service-Ceres.svg deleted file mode 100644 index 32be999..0000000 --- a/assets/icons/azure/other/02828-icon-service-Ceres.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/02842-icon-service-Azurite.svg b/assets/icons/azure/other/02842-icon-service-Azurite.svg deleted file mode 100644 index 28068b2..0000000 --- a/assets/icons/azure/other/02842-icon-service-Azurite.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/02846-icon-service-Update-Management-Center.svg b/assets/icons/azure/other/02846-icon-service-Update-Management-Center.svg deleted file mode 100644 index 00b6b83..0000000 --- a/assets/icons/azure/other/02846-icon-service-Update-Management-Center.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/02864-icon-service-Azure-Compute-Galleries.svg b/assets/icons/azure/other/02864-icon-service-Azure-Compute-Galleries.svg deleted file mode 100644 index 5e31473..0000000 --- a/assets/icons/azure/other/02864-icon-service-Azure-Compute-Galleries.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/02865-icon-service-Community-Images.svg b/assets/icons/azure/other/02865-icon-service-Community-Images.svg deleted file mode 100644 index 1911f7c..0000000 --- a/assets/icons/azure/other/02865-icon-service-Community-Images.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/02866-icon-service-VM-Image-Version.svg b/assets/icons/azure/other/02866-icon-service-VM-Image-Version.svg deleted file mode 100644 index 0507f51..0000000 --- a/assets/icons/azure/other/02866-icon-service-VM-Image-Version.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/02880-icon-service-Savings-Plans.svg b/assets/icons/azure/other/02880-icon-service-Savings-Plans.svg deleted file mode 100644 index 250ee32..0000000 --- a/assets/icons/azure/other/02880-icon-service-Savings-Plans.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/02884-icon-service-Worker-Container-App.svg b/assets/icons/azure/other/02884-icon-service-Worker-Container-App.svg deleted file mode 100644 index 3dd3d4d..0000000 --- a/assets/icons/azure/other/02884-icon-service-Worker-Container-App.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/02905-icon-service-Azure-Managed-Grafana.svg b/assets/icons/azure/other/02905-icon-service-Azure-Managed-Grafana.svg deleted file mode 100644 index 7a6a27d..0000000 --- a/assets/icons/azure/other/02905-icon-service-Azure-Managed-Grafana.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/02908-icon-service-Targets-Management.svg b/assets/icons/azure/other/02908-icon-service-Targets-Management.svg deleted file mode 100644 index 9f9e805..0000000 --- a/assets/icons/azure/other/02908-icon-service-Targets-Management.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/02909-icon-service-Storage-Functions.svg b/assets/icons/azure/other/02909-icon-service-Storage-Functions.svg deleted file mode 100644 index 7c355d1..0000000 --- a/assets/icons/azure/other/02909-icon-service-Storage-Functions.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/02909-icon-service-Storage-Tasks.svg b/assets/icons/azure/other/02909-icon-service-Storage-Tasks.svg deleted file mode 100644 index 7c355d1..0000000 --- a/assets/icons/azure/other/02909-icon-service-Storage-Tasks.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/02922-icon-service-Sonic-Dash.svg b/assets/icons/azure/other/02922-icon-service-Sonic-Dash.svg deleted file mode 100644 index e408508..0000000 --- a/assets/icons/azure/other/02922-icon-service-Sonic-Dash.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/02931-icon-service-Compliance-Center.svg b/assets/icons/azure/other/02931-icon-service-Compliance-Center.svg deleted file mode 100644 index 739f64e..0000000 --- a/assets/icons/azure/other/02931-icon-service-Compliance-Center.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/02939-icon-service-Network-Security-Perimeters.svg b/assets/icons/azure/other/02939-icon-service-Network-Security-Perimeters.svg deleted file mode 100644 index 82cac00..0000000 --- a/assets/icons/azure/other/02939-icon-service-Network-Security-Perimeters.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/02944-icon-service-Azure-Load-Testing.svg b/assets/icons/azure/other/02944-icon-service-Azure-Load-Testing.svg deleted file mode 100644 index 54eaef1..0000000 --- a/assets/icons/azure/other/02944-icon-service-Azure-Load-Testing.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/02949-icon-service-Virtual-Visits-Builder.svg b/assets/icons/azure/other/02949-icon-service-Virtual-Visits-Builder.svg deleted file mode 100644 index 60a22ea..0000000 --- a/assets/icons/azure/other/02949-icon-service-Virtual-Visits-Builder.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/02951-icon-service-Azure-Quotas.svg b/assets/icons/azure/other/02951-icon-service-Azure-Quotas.svg deleted file mode 100644 index 216e9cc..0000000 --- a/assets/icons/azure/other/02951-icon-service-Azure-Quotas.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/02989-icon-service-Container-Apps-Environments.svg b/assets/icons/azure/other/02989-icon-service-Container-Apps-Environments.svg deleted file mode 100644 index ef8c70d..0000000 --- a/assets/icons/azure/other/02989-icon-service-Container-Apps-Environments.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/03071-icon-service-App-Compliance-Automation.svg b/assets/icons/azure/other/03071-icon-service-App-Compliance-Automation.svg deleted file mode 100644 index 3a21076..0000000 --- a/assets/icons/azure/other/03071-icon-service-App-Compliance-Automation.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/03089-icon-service-Virtual-Instance-for-SAP.svg b/assets/icons/azure/other/03089-icon-service-Virtual-Instance-for-SAP.svg deleted file mode 100644 index a2c7043..0000000 --- a/assets/icons/azure/other/03089-icon-service-Virtual-Instance-for-SAP.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/03090-icon-service-Azure-Center-for-SAP.svg b/assets/icons/azure/other/03090-icon-service-Azure-Center-for-SAP.svg deleted file mode 100644 index 9efdd14..0000000 --- a/assets/icons/azure/other/03090-icon-service-Azure-Center-for-SAP.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/03091-icon-service-Azure-Storage-Mover.svg b/assets/icons/azure/other/03091-icon-service-Azure-Storage-Mover.svg deleted file mode 100644 index 09d0adf..0000000 --- a/assets/icons/azure/other/03091-icon-service-Azure-Storage-Mover.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/03092-icon-service-Central-Service-Instance-For-SAP.svg b/assets/icons/azure/other/03092-icon-service-Central-Service-Instance-For-SAP.svg deleted file mode 100644 index ef27942..0000000 --- a/assets/icons/azure/other/03092-icon-service-Central-Service-Instance-For-SAP.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/03134-icon-service-Kubernetes-Fleet-Manager.svg b/assets/icons/azure/other/03134-icon-service-Kubernetes-Fleet-Manager.svg deleted file mode 100644 index ccd97d4..0000000 --- a/assets/icons/azure/other/03134-icon-service-Kubernetes-Fleet-Manager.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/03137-icon-service-Express-Route-Traffic-Collector.svg b/assets/icons/azure/other/03137-icon-service-Express-Route-Traffic-Collector.svg deleted file mode 100644 index bdb786b..0000000 --- a/assets/icons/azure/other/03137-icon-service-Express-Route-Traffic-Collector.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/03175-icon-service-Database-Instance-For-SAP.svg b/assets/icons/azure/other/03175-icon-service-Database-Instance-For-SAP.svg deleted file mode 100644 index 3a71436..0000000 --- a/assets/icons/azure/other/03175-icon-service-Database-Instance-For-SAP.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/03190-icon-service-Elastic-SAN.svg b/assets/icons/azure/other/03190-icon-service-Elastic-SAN.svg deleted file mode 100644 index f0c64c9..0000000 --- a/assets/icons/azure/other/03190-icon-service-Elastic-SAN.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/03250-icon-service-Microsoft-Dev-Box.svg b/assets/icons/azure/other/03250-icon-service-Microsoft-Dev-Box.svg deleted file mode 100644 index 3693b1a..0000000 --- a/assets/icons/azure/other/03250-icon-service-Microsoft-Dev-Box.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/03251-icon-service-Azure-Deployment-Environments.svg b/assets/icons/azure/other/03251-icon-service-Azure-Deployment-Environments.svg deleted file mode 100644 index e4212d9..0000000 --- a/assets/icons/azure/other/03251-icon-service-Azure-Deployment-Environments.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/032869111-icon-service-Microsoft-Discovery.svg b/assets/icons/azure/other/032869111-icon-service-Microsoft-Discovery.svg deleted file mode 100644 index 6bf1df4..0000000 --- a/assets/icons/azure/other/032869111-icon-service-Microsoft-Discovery.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/03308-icon-service-Azure-Dev-Tunnels.svg b/assets/icons/azure/other/03308-icon-service-Azure-Dev-Tunnels.svg deleted file mode 100644 index 36dc485..0000000 --- a/assets/icons/azure/other/03308-icon-service-Azure-Dev-Tunnels.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/033112809-icon-service-promethus.svg b/assets/icons/azure/other/033112809-icon-service-promethus.svg deleted file mode 100644 index f131cf3..0000000 --- a/assets/icons/azure/other/033112809-icon-service-promethus.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/03314-icon-service-Azure-Sustainability.svg b/assets/icons/azure/other/03314-icon-service-Azure-Sustainability.svg deleted file mode 100644 index 7b5e5a8..0000000 --- a/assets/icons/azure/other/03314-icon-service-Azure-Sustainability.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/03361-icon-service-IcM-Troubleshooting.svg b/assets/icons/azure/other/03361-icon-service-IcM-Troubleshooting.svg deleted file mode 100644 index c7839fa..0000000 --- a/assets/icons/azure/other/03361-icon-service-IcM-Troubleshooting.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/03365-icon-service-OSConfig.svg b/assets/icons/azure/other/03365-icon-service-OSConfig.svg deleted file mode 100644 index 0025107..0000000 --- a/assets/icons/azure/other/03365-icon-service-OSConfig.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/03394-icon-service-Azure-Enclaves.svg b/assets/icons/azure/other/03394-icon-service-Azure-Enclaves.svg deleted file mode 100644 index 91d7eb3..0000000 --- a/assets/icons/azure/other/03394-icon-service-Azure-Enclaves.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/03399-icon-service-AKS-Istio.svg b/assets/icons/azure/other/03399-icon-service-AKS-Istio.svg deleted file mode 100644 index c5f2901..0000000 --- a/assets/icons/azure/other/03399-icon-service-AKS-Istio.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/03402-icon-service-Defender-CM-Local-Manager.svg b/assets/icons/azure/other/03402-icon-service-Defender-CM-Local-Manager.svg deleted file mode 100644 index 728e7ef..0000000 --- a/assets/icons/azure/other/03402-icon-service-Defender-CM-Local-Manager.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/03403-icon-service-Defender-External-Management.svg b/assets/icons/azure/other/03403-icon-service-Defender-External-Management.svg deleted file mode 100644 index abd5729..0000000 --- a/assets/icons/azure/other/03403-icon-service-Defender-External-Management.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/03404-icon-service-Defender-Freezer-Monitor.svg b/assets/icons/azure/other/03404-icon-service-Defender-Freezer-Monitor.svg deleted file mode 100644 index 1143941..0000000 --- a/assets/icons/azure/other/03404-icon-service-Defender-Freezer-Monitor.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/03405-icon-service-Defender-Historian.svg b/assets/icons/azure/other/03405-icon-service-Defender-Historian.svg deleted file mode 100644 index 70acadd..0000000 --- a/assets/icons/azure/other/03405-icon-service-Defender-Historian.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/03406-icon-service-Defender-HMI.svg b/assets/icons/azure/other/03406-icon-service-Defender-HMI.svg deleted file mode 100644 index 5b7a824..0000000 --- a/assets/icons/azure/other/03406-icon-service-Defender-HMI.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/03407-icon-service-Defender-Marquee.svg b/assets/icons/azure/other/03407-icon-service-Defender-Marquee.svg deleted file mode 100644 index a9c551e..0000000 --- a/assets/icons/azure/other/03407-icon-service-Defender-Marquee.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/03408-icon-service-Defender-Robot-Controller.svg b/assets/icons/azure/other/03408-icon-service-Defender-Robot-Controller.svg deleted file mode 100644 index 7ca9ded..0000000 --- a/assets/icons/azure/other/03408-icon-service-Defender-Robot-Controller.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/03409-icon-service-Defender-Sensor.svg b/assets/icons/azure/other/03409-icon-service-Defender-Sensor.svg deleted file mode 100644 index acfd6e2..0000000 --- a/assets/icons/azure/other/03409-icon-service-Defender-Sensor.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/03410-icon-service-Defender-Slot.svg b/assets/icons/azure/other/03410-icon-service-Defender-Slot.svg deleted file mode 100644 index 3429038..0000000 --- a/assets/icons/azure/other/03410-icon-service-Defender-Slot.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/03411-icon-service-Defender-Web-Guiding-System.svg b/assets/icons/azure/other/03411-icon-service-Defender-Web-Guiding-System.svg deleted file mode 100644 index c2b9b02..0000000 --- a/assets/icons/azure/other/03411-icon-service-Defender-Web-Guiding-System.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/03412-icon-service-Defender-DCS-Controller.svg b/assets/icons/azure/other/03412-icon-service-Defender-DCS-Controller.svg deleted file mode 100644 index 984811b..0000000 --- a/assets/icons/azure/other/03412-icon-service-Defender-DCS-Controller.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/03413-icon-service-Defender-Distributer-Control-System.svg b/assets/icons/azure/other/03413-icon-service-Defender-Distributer-Control-System.svg deleted file mode 100644 index 992cc13..0000000 --- a/assets/icons/azure/other/03413-icon-service-Defender-Distributer-Control-System.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/03414-icon-service-Defender-Engineering-Station.svg b/assets/icons/azure/other/03414-icon-service-Defender-Engineering-Station.svg deleted file mode 100644 index a0957cb..0000000 --- a/assets/icons/azure/other/03414-icon-service-Defender-Engineering-Station.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/03415-icon-service-Defender-Industrial-Packaging-System.svg b/assets/icons/azure/other/03415-icon-service-Defender-Industrial-Packaging-System.svg deleted file mode 100644 index 281c0bf..0000000 --- a/assets/icons/azure/other/03415-icon-service-Defender-Industrial-Packaging-System.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/03416-icon-service-Defender-Industrial-Printer.svg b/assets/icons/azure/other/03416-icon-service-Defender-Industrial-Printer.svg deleted file mode 100644 index 9f9cec1..0000000 --- a/assets/icons/azure/other/03416-icon-service-Defender-Industrial-Printer.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/03417-icon-service-Defender-Industrial-Scale-System.svg b/assets/icons/azure/other/03417-icon-service-Defender-Industrial-Scale-System.svg deleted file mode 100644 index 60a7636..0000000 --- a/assets/icons/azure/other/03417-icon-service-Defender-Industrial-Scale-System.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/03418-icon-service-Defender-Industrial-Robot.svg b/assets/icons/azure/other/03418-icon-service-Defender-Industrial-Robot.svg deleted file mode 100644 index 62cfcea..0000000 --- a/assets/icons/azure/other/03418-icon-service-Defender-Industrial-Robot.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/03419-icon-service-Defender-Meter.svg b/assets/icons/azure/other/03419-icon-service-Defender-Meter.svg deleted file mode 100644 index 958dab9..0000000 --- a/assets/icons/azure/other/03419-icon-service-Defender-Meter.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/03420-icon-service-Defender-PLC.svg b/assets/icons/azure/other/03420-icon-service-Defender-PLC.svg deleted file mode 100644 index 8b2eec0..0000000 --- a/assets/icons/azure/other/03420-icon-service-Defender-PLC.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/03421-icon-service-Defender-Pneumatic-Device.svg b/assets/icons/azure/other/03421-icon-service-Defender-Pneumatic-Device.svg deleted file mode 100644 index 0987420..0000000 --- a/assets/icons/azure/other/03421-icon-service-Defender-Pneumatic-Device.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/03422-icon-service-Defender-Programable-Board.svg b/assets/icons/azure/other/03422-icon-service-Defender-Programable-Board.svg deleted file mode 100644 index f46f78e..0000000 --- a/assets/icons/azure/other/03422-icon-service-Defender-Programable-Board.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/03423-icon-service-Defender-Relay.svg b/assets/icons/azure/other/03423-icon-service-Defender-Relay.svg deleted file mode 100644 index 4d88d89..0000000 --- a/assets/icons/azure/other/03423-icon-service-Defender-Relay.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/03424-icon-service-Defender-RTU.svg b/assets/icons/azure/other/03424-icon-service-Defender-RTU.svg deleted file mode 100644 index 203b700..0000000 --- a/assets/icons/azure/other/03424-icon-service-Defender-RTU.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/03468-icon-service-HDI-AKS-Cluster.svg b/assets/icons/azure/other/03468-icon-service-HDI-AKS-Cluster.svg deleted file mode 100644 index 5acbb44..0000000 --- a/assets/icons/azure/other/03468-icon-service-HDI-AKS-Cluster.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/03495-icon-service-Azure-Consumption-Commitment.svg b/assets/icons/azure/other/03495-icon-service-Azure-Consumption-Commitment.svg deleted file mode 100644 index 98137f4..0000000 --- a/assets/icons/azure/other/03495-icon-service-Azure-Consumption-Commitment.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/03528-icon-service-Monitor-Health-Models.svg b/assets/icons/azure/other/03528-icon-service-Monitor-Health-Models.svg deleted file mode 100644 index b428278..0000000 --- a/assets/icons/azure/other/03528-icon-service-Monitor-Health-Models.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/03563-icon-service-WAC-Installer.svg b/assets/icons/azure/other/03563-icon-service-WAC-Installer.svg deleted file mode 100644 index a51ec2f..0000000 --- a/assets/icons/azure/other/03563-icon-service-WAC-Installer.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/03633-icon-service-Breeze.svg b/assets/icons/azure/other/03633-icon-service-Breeze.svg deleted file mode 100644 index a1d0fef..0000000 --- a/assets/icons/azure/other/03633-icon-service-Breeze.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/10018-icon-service-Azure-A.svg b/assets/icons/azure/other/10018-icon-service-Azure-A.svg deleted file mode 100644 index 49ebfea..0000000 --- a/assets/icons/azure/other/10018-icon-service-Azure-A.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/10077-icon-service-Local-Network-Gateways.svg b/assets/icons/azure/other/10077-icon-service-Local-Network-Gateways.svg deleted file mode 100644 index 0964d08..0000000 --- a/assets/icons/azure/other/10077-icon-service-Local-Network-Gateways.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-networking-77 \ No newline at end of file diff --git a/assets/icons/azure/other/10117-icon-service-Edge-Management.svg b/assets/icons/azure/other/10117-icon-service-Edge-Management.svg deleted file mode 100644 index e16d8df..0000000 --- a/assets/icons/azure/other/10117-icon-service-Edge-Management.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-484-EdgeManagementArtboard 1 \ No newline at end of file diff --git a/assets/icons/azure/other/10139-icon-service-Instance-Pools.svg b/assets/icons/azure/other/10139-icon-service-Instance-Pools.svg deleted file mode 100644 index c6862a1..0000000 --- a/assets/icons/azure/other/10139-icon-service-Instance-Pools.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-databases-138 \ No newline at end of file diff --git a/assets/icons/azure/other/10190-icon-service-Azure-Sphere.svg b/assets/icons/azure/other/10190-icon-service-Azure-Sphere.svg deleted file mode 100644 index b0711de..0000000 --- a/assets/icons/azure/other/10190-icon-service-Azure-Sphere.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/10232-icon-service-App-Registrations.svg b/assets/icons/azure/other/10232-icon-service-App-Registrations.svg deleted file mode 100644 index 78c2ea8..0000000 --- a/assets/icons/azure/other/10232-icon-service-App-Registrations.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/10334-icon-service-Exchange-On-Premises-Access.svg b/assets/icons/azure/other/10334-icon-service-Exchange-On-Premises-Access.svg deleted file mode 100644 index 0c34ce7..0000000 --- a/assets/icons/azure/other/10334-icon-service-Exchange-On-Premises-Access.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-intune-334 \ No newline at end of file diff --git a/assets/icons/azure/other/10355-icon-service-WAC.svg b/assets/icons/azure/other/10355-icon-service-WAC.svg deleted file mode 100644 index f29352e..0000000 --- a/assets/icons/azure/other/10355-icon-service-WAC.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/10422-icon-service-AzureAttestation.svg b/assets/icons/azure/other/10422-icon-service-AzureAttestation.svg deleted file mode 100644 index 6e0c267..0000000 --- a/assets/icons/azure/other/10422-icon-service-AzureAttestation.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/10778-icon-service-RTOS.svg b/assets/icons/azure/other/10778-icon-service-RTOS.svg deleted file mode 100644 index 3661e83..0000000 --- a/assets/icons/azure/other/10778-icon-service-RTOS.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/other/10846-icon-service-Web-Jobs.svg b/assets/icons/azure/other/10846-icon-service-Web-Jobs.svg deleted file mode 100644 index 207f2e6..0000000 --- a/assets/icons/azure/other/10846-icon-service-Web-Jobs.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/security/00378-icon-service-Detonation.svg b/assets/icons/azure/security/00378-icon-service-Detonation.svg deleted file mode 100644 index 9b111a0..0000000 --- a/assets/icons/azure/security/00378-icon-service-Detonation.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/security/02247-icon-service-Microsoft-Defender-for-IoT.svg b/assets/icons/azure/security/02247-icon-service-Microsoft-Defender-for-IoT.svg deleted file mode 100644 index 93e2535..0000000 --- a/assets/icons/azure/security/02247-icon-service-Microsoft-Defender-for-IoT.svg +++ /dev/null @@ -1,2 +0,0 @@ -​ - \ No newline at end of file diff --git a/assets/icons/azure/security/02251-icon-service-Azure-AD-Privleged-Identity-Management.svg b/assets/icons/azure/security/02251-icon-service-Azure-AD-Privleged-Identity-Management.svg deleted file mode 100644 index 5a8dde1..0000000 --- a/assets/icons/azure/security/02251-icon-service-Azure-AD-Privleged-Identity-Management.svg +++ /dev/null @@ -1,2 +0,0 @@ -​ - \ No newline at end of file diff --git a/assets/icons/azure/security/03336-icon-service-Microsoft-Defender-EASM.svg b/assets/icons/azure/security/03336-icon-service-Microsoft-Defender-EASM.svg deleted file mode 100644 index 709d46f..0000000 --- a/assets/icons/azure/security/03336-icon-service-Microsoft-Defender-EASM.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/security/03340-icon-service-Identity-Secure-Score.svg b/assets/icons/azure/security/03340-icon-service-Identity-Secure-Score.svg deleted file mode 100644 index 03e8d42..0000000 --- a/assets/icons/azure/security/03340-icon-service-Identity-Secure-Score.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/security/03341-icon-service-Azure-AD-Risky-Signins.svg b/assets/icons/azure/security/03341-icon-service-Azure-AD-Risky-Signins.svg deleted file mode 100644 index 34fcd00..0000000 --- a/assets/icons/azure/security/03341-icon-service-Azure-AD-Risky-Signins.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/security/03341-icon-service-Entra-Identity-Risky-Signins.svg b/assets/icons/azure/security/03341-icon-service-Entra-Identity-Risky-Signins.svg deleted file mode 100644 index 34fcd00..0000000 --- a/assets/icons/azure/security/03341-icon-service-Entra-Identity-Risky-Signins.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/security/03342-icon-service-Azure-AD-Risky-Users.svg b/assets/icons/azure/security/03342-icon-service-Azure-AD-Risky-Users.svg deleted file mode 100644 index 47a8eb3..0000000 --- a/assets/icons/azure/security/03342-icon-service-Azure-AD-Risky-Users.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/security/03342-icon-service-Entra-Identity-Risky-Users.svg b/assets/icons/azure/security/03342-icon-service-Entra-Identity-Risky-Users.svg deleted file mode 100644 index 47a8eb3..0000000 --- a/assets/icons/azure/security/03342-icon-service-Entra-Identity-Risky-Users.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/security/03343-icon-service-Azure-AD-Authentication-Methods.svg b/assets/icons/azure/security/03343-icon-service-Azure-AD-Authentication-Methods.svg deleted file mode 100644 index e5383c8..0000000 --- a/assets/icons/azure/security/03343-icon-service-Azure-AD-Authentication-Methods.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/security/03344-icon-service-Multifactor-Authentication.svg b/assets/icons/azure/security/03344-icon-service-Multifactor-Authentication.svg deleted file mode 100644 index 386c575..0000000 --- a/assets/icons/azure/security/03344-icon-service-Multifactor-Authentication.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/security/10229-icon-service-Azure-Information-Protection.svg b/assets/icons/azure/security/10229-icon-service-Azure-Information-Protection.svg deleted file mode 100644 index b244886..0000000 --- a/assets/icons/azure/security/10229-icon-service-Azure-Information-Protection.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/security/10231-icon-service-Azure-AD-Identity-Protection.svg b/assets/icons/azure/security/10231-icon-service-Azure-AD-Identity-Protection.svg deleted file mode 100644 index 29fde81..0000000 --- a/assets/icons/azure/security/10231-icon-service-Azure-AD-Identity-Protection.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-identity-231 \ No newline at end of file diff --git a/assets/icons/azure/security/10233-icon-service-Conditional-Access.svg b/assets/icons/azure/security/10233-icon-service-Conditional-Access.svg deleted file mode 100644 index 52fd503..0000000 --- a/assets/icons/azure/security/10233-icon-service-Conditional-Access.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-identity-233 \ No newline at end of file diff --git a/assets/icons/azure/security/10241-icon-service-Microsoft-Defender-for-Cloud.svg b/assets/icons/azure/security/10241-icon-service-Microsoft-Defender-for-Cloud.svg deleted file mode 100644 index 6c2e825..0000000 --- a/assets/icons/azure/security/10241-icon-service-Microsoft-Defender-for-Cloud.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/security/10244-icon-service-Application-Security-Groups.svg b/assets/icons/azure/security/10244-icon-service-Application-Security-Groups.svg deleted file mode 100644 index 19914a7..0000000 --- a/assets/icons/azure/security/10244-icon-service-Application-Security-Groups.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-security-244 \ No newline at end of file diff --git a/assets/icons/azure/security/10245-icon-service-Key-Vaults.svg b/assets/icons/azure/security/10245-icon-service-Key-Vaults.svg deleted file mode 100644 index 53f0135..0000000 --- a/assets/icons/azure/security/10245-icon-service-Key-Vaults.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-security-245 \ No newline at end of file diff --git a/assets/icons/azure/security/10248-icon-service-Azure-Sentinel.svg b/assets/icons/azure/security/10248-icon-service-Azure-Sentinel.svg deleted file mode 100644 index e0781b0..0000000 --- a/assets/icons/azure/security/10248-icon-service-Azure-Sentinel.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-security-248 \ No newline at end of file diff --git a/assets/icons/azure/security/10433-icon-service-User-Settings.svg b/assets/icons/azure/security/10433-icon-service-User-Settings.svg deleted file mode 100644 index 4bc9cd9..0000000 --- a/assets/icons/azure/security/10433-icon-service-User-Settings.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/security/10572-icon-service-ExtendedSecurityUpdates.svg b/assets/icons/azure/security/10572-icon-service-ExtendedSecurityUpdates.svg deleted file mode 100644 index ecaac8c..0000000 --- a/assets/icons/azure/security/10572-icon-service-ExtendedSecurityUpdates.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/storage/00017-icon-service-Recovery-Services-Vaults.svg b/assets/icons/azure/storage/00017-icon-service-Recovery-Services-Vaults.svg deleted file mode 100644 index 637a763..0000000 --- a/assets/icons/azure/storage/00017-icon-service-Recovery-Services-Vaults.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-manage-315 \ No newline at end of file diff --git a/assets/icons/azure/storage/00691-icon-service-Azure-Databox-Gateway.svg b/assets/icons/azure/storage/00691-icon-service-Azure-Databox-Gateway.svg deleted file mode 100644 index 90096f3..0000000 --- a/assets/icons/azure/storage/00691-icon-service-Azure-Databox-Gateway.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/storage/00776-icon-service-Azure-HCP-Cache.svg b/assets/icons/azure/storage/00776-icon-service-Azure-HCP-Cache.svg deleted file mode 100644 index a4fa7fd..0000000 --- a/assets/icons/azure/storage/00776-icon-service-Azure-HCP-Cache.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/storage/029203566-icon-service-Data-Virtualization.svg b/assets/icons/azure/storage/029203566-icon-service-Data-Virtualization.svg deleted file mode 100644 index fd2cb20..0000000 --- a/assets/icons/azure/storage/029203566-icon-service-Data-Virtualization.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/storage/030486388-icon-service-Storage-Hubs.svg b/assets/icons/azure/storage/030486388-icon-service-Storage-Hubs.svg deleted file mode 100644 index f7a6c0f..0000000 --- a/assets/icons/azure/storage/030486388-icon-service-Storage-Hubs.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/storage/03502-icon-service-Storage-Actions.svg b/assets/icons/azure/storage/03502-icon-service-Storage-Actions.svg deleted file mode 100644 index 0f51d2c..0000000 --- a/assets/icons/azure/storage/03502-icon-service-Storage-Actions.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/storage/03549-icon-service-Managed-File-Shares.svg b/assets/icons/azure/storage/03549-icon-service-Managed-File-Shares.svg deleted file mode 100644 index c5c676a..0000000 --- a/assets/icons/azure/storage/03549-icon-service-Managed-File-Shares.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/storage/10086-icon-service-Storage-Accounts.svg b/assets/icons/azure/storage/10086-icon-service-Storage-Accounts.svg deleted file mode 100644 index e2a26a2..0000000 --- a/assets/icons/azure/storage/10086-icon-service-Storage-Accounts.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-storage-86 \ No newline at end of file diff --git a/assets/icons/azure/storage/10087-icon-service-Storage-Accounts-(Classic).svg b/assets/icons/azure/storage/10087-icon-service-Storage-Accounts-(Classic).svg deleted file mode 100644 index d6f0532..0000000 --- a/assets/icons/azure/storage/10087-icon-service-Storage-Accounts-(Classic).svg +++ /dev/null @@ -1 +0,0 @@ -Icon-storage-87 \ No newline at end of file diff --git a/assets/icons/azure/storage/10089-icon-service-StorSimple-Device-Managers.svg b/assets/icons/azure/storage/10089-icon-service-StorSimple-Device-Managers.svg deleted file mode 100644 index 32ab6ce..0000000 --- a/assets/icons/azure/storage/10089-icon-service-StorSimple-Device-Managers.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-storage-89 \ No newline at end of file diff --git a/assets/icons/azure/storage/10090-icon-service-Data-Lake-Storage-Gen1.svg b/assets/icons/azure/storage/10090-icon-service-Data-Lake-Storage-Gen1.svg deleted file mode 100644 index 04596c5..0000000 --- a/assets/icons/azure/storage/10090-icon-service-Data-Lake-Storage-Gen1.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-storage-90 \ No newline at end of file diff --git a/assets/icons/azure/storage/10091-icon-service-Storage-Explorer.svg b/assets/icons/azure/storage/10091-icon-service-Storage-Explorer.svg deleted file mode 100644 index 56e3791..0000000 --- a/assets/icons/azure/storage/10091-icon-service-Storage-Explorer.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-613-Storage-Explorer-blue \ No newline at end of file diff --git a/assets/icons/azure/storage/10092-icon-service-StorSimple-Data-Managers.svg b/assets/icons/azure/storage/10092-icon-service-StorSimple-Data-Managers.svg deleted file mode 100644 index 8cf8f93..0000000 --- a/assets/icons/azure/storage/10092-icon-service-StorSimple-Data-Managers.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-storage-92 \ No newline at end of file diff --git a/assets/icons/azure/storage/10093-icon-service-Storage-Sync-Services.svg b/assets/icons/azure/storage/10093-icon-service-Storage-Sync-Services.svg deleted file mode 100644 index e4a599d..0000000 --- a/assets/icons/azure/storage/10093-icon-service-Storage-Sync-Services.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/storage/10094-icon-service-Data-Box.svg b/assets/icons/azure/storage/10094-icon-service-Data-Box.svg deleted file mode 100644 index 4ac24fb..0000000 --- a/assets/icons/azure/storage/10094-icon-service-Data-Box.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-storage-94 \ No newline at end of file diff --git a/assets/icons/azure/storage/10095-icon-service-Azure-Stack-Edge.svg b/assets/icons/azure/storage/10095-icon-service-Azure-Stack-Edge.svg deleted file mode 100644 index 656850c..0000000 --- a/assets/icons/azure/storage/10095-icon-service-Azure-Stack-Edge.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-storage-95 \ No newline at end of file diff --git a/assets/icons/azure/storage/10096-icon-service-Azure-NetApp-Files.svg b/assets/icons/azure/storage/10096-icon-service-Azure-NetApp-Files.svg deleted file mode 100644 index 6b84083..0000000 --- a/assets/icons/azure/storage/10096-icon-service-Azure-NetApp-Files.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-storage-96 \ No newline at end of file diff --git a/assets/icons/azure/storage/10097-icon-service-Data-Share-Invitations.svg b/assets/icons/azure/storage/10097-icon-service-Data-Share-Invitations.svg deleted file mode 100644 index cb23ee8..0000000 --- a/assets/icons/azure/storage/10097-icon-service-Data-Share-Invitations.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-storage-97 \ No newline at end of file diff --git a/assets/icons/azure/storage/10098-icon-service-Data-Shares.svg b/assets/icons/azure/storage/10098-icon-service-Data-Shares.svg deleted file mode 100644 index b1d3c03..0000000 --- a/assets/icons/azure/storage/10098-icon-service-Data-Shares.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-storage-98 \ No newline at end of file diff --git a/assets/icons/azure/storage/10100-icon-service-Import-Export-Jobs.svg b/assets/icons/azure/storage/10100-icon-service-Import-Export-Jobs.svg deleted file mode 100644 index 610889a..0000000 --- a/assets/icons/azure/storage/10100-icon-service-Import-Export-Jobs.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-storage-100 \ No newline at end of file diff --git a/assets/icons/azure/storage/10400-icon-service-Azure-Fileshares.svg b/assets/icons/azure/storage/10400-icon-service-Azure-Fileshares.svg deleted file mode 100644 index 5dfc5a6..0000000 --- a/assets/icons/azure/storage/10400-icon-service-Azure-Fileshares.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/web/00046-icon-service-App-Service-Plans.svg b/assets/icons/azure/web/00046-icon-service-App-Service-Plans.svg deleted file mode 100644 index 11a5093..0000000 --- a/assets/icons/azure/web/00046-icon-service-App-Service-Plans.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-web-46 \ No newline at end of file diff --git a/assets/icons/azure/web/00049-icon-service-App-Service-Certificates.svg b/assets/icons/azure/web/00049-icon-service-App-Service-Certificates.svg deleted file mode 100644 index c2dfd22..0000000 --- a/assets/icons/azure/web/00049-icon-service-App-Service-Certificates.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-web-49 \ No newline at end of file diff --git a/assets/icons/azure/web/00050-icon-service-App-Service-Domains.svg b/assets/icons/azure/web/00050-icon-service-App-Service-Domains.svg deleted file mode 100644 index ab3ca15..0000000 --- a/assets/icons/azure/web/00050-icon-service-App-Service-Domains.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-web-50 \ No newline at end of file diff --git a/assets/icons/azure/web/01007-icon-service-Static-Apps.svg b/assets/icons/azure/web/01007-icon-service-Static-Apps.svg deleted file mode 100644 index 4c1b2e0..0000000 --- a/assets/icons/azure/web/01007-icon-service-Static-Apps.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/web/03291-icon-service-API-Center.svg b/assets/icons/azure/web/03291-icon-service-API-Center.svg deleted file mode 100644 index 0a21ec3..0000000 --- a/assets/icons/azure/web/03291-icon-service-API-Center.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/web/03335-icon-service-Power-Platform.svg b/assets/icons/azure/web/03335-icon-service-Power-Platform.svg deleted file mode 100644 index f3d0dcd..0000000 --- a/assets/icons/azure/web/03335-icon-service-Power-Platform.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/web/03397-icon-service-App-Space.svg b/assets/icons/azure/web/03397-icon-service-App-Space.svg deleted file mode 100644 index ec68813..0000000 --- a/assets/icons/azure/web/03397-icon-service-App-Space.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/web/03595-icon-service-App-Space-Component.svg b/assets/icons/azure/web/03595-icon-service-App-Space-Component.svg deleted file mode 100644 index 055a89e..0000000 --- a/assets/icons/azure/web/03595-icon-service-App-Space-Component.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/web/10035-icon-service-App-Services.svg b/assets/icons/azure/web/10035-icon-service-App-Services.svg deleted file mode 100644 index 022ef80..0000000 --- a/assets/icons/azure/web/10035-icon-service-App-Services.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-web-41 \ No newline at end of file diff --git a/assets/icons/azure/web/10042-icon-service-API-Management-Services.svg b/assets/icons/azure/web/10042-icon-service-API-Management-Services.svg deleted file mode 100644 index 69f5998..0000000 --- a/assets/icons/azure/web/10042-icon-service-API-Management-Services.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-web-42 \ No newline at end of file diff --git a/assets/icons/azure/web/10044-icon-service-Cognitive-Search.svg b/assets/icons/azure/web/10044-icon-service-Cognitive-Search.svg deleted file mode 100644 index 53e44a5..0000000 --- a/assets/icons/azure/web/10044-icon-service-Cognitive-Search.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-web-44 \ No newline at end of file diff --git a/assets/icons/azure/web/10047-icon-service-App-Service-Environments.svg b/assets/icons/azure/web/10047-icon-service-App-Service-Environments.svg deleted file mode 100644 index 35e0800..0000000 --- a/assets/icons/azure/web/10047-icon-service-App-Service-Environments.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-web-47 \ No newline at end of file diff --git a/assets/icons/azure/web/10048-icon-service-API-Connections.svg b/assets/icons/azure/web/10048-icon-service-API-Connections.svg deleted file mode 100644 index a170813..0000000 --- a/assets/icons/azure/web/10048-icon-service-API-Connections.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-web-48 \ No newline at end of file diff --git a/assets/icons/azure/web/10052-icon-service-SignalR.svg b/assets/icons/azure/web/10052-icon-service-SignalR.svg deleted file mode 100644 index e552664..0000000 --- a/assets/icons/azure/web/10052-icon-service-SignalR.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-web-52 \ No newline at end of file diff --git a/assets/icons/azure/web/10053-icon-service-Notification-Hub-Namespaces.svg b/assets/icons/azure/web/10053-icon-service-Notification-Hub-Namespaces.svg deleted file mode 100644 index 4acc715..0000000 --- a/assets/icons/azure/web/10053-icon-service-Notification-Hub-Namespaces.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-iot-196 \ No newline at end of file diff --git a/assets/icons/azure/web/10073-icon-service-Front-Door-and-CDN-Profiles.svg b/assets/icons/azure/web/10073-icon-service-Front-Door-and-CDN-Profiles.svg deleted file mode 100644 index 15309b8..0000000 --- a/assets/icons/azure/web/10073-icon-service-Front-Door-and-CDN-Profiles.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-networking-73 \ No newline at end of file diff --git a/assets/icons/azure/web/10162-icon-service-Cognitive-Services.svg b/assets/icons/azure/web/10162-icon-service-Cognitive-Services.svg deleted file mode 100644 index 3c47ed9..0000000 --- a/assets/icons/azure/web/10162-icon-service-Cognitive-Services.svg +++ /dev/null @@ -1 +0,0 @@ -Icon-machinelearning-162 \ No newline at end of file diff --git a/assets/icons/azure/web/10309-icon-service-Azure-Media-Service.svg b/assets/icons/azure/web/10309-icon-service-Azure-Media-Service.svg deleted file mode 100644 index 25c0dce..0000000 --- a/assets/icons/azure/web/10309-icon-service-Azure-Media-Service.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/azure/web/10370-icon-service-Azure-Spring-Apps.svg b/assets/icons/azure/web/10370-icon-service-Azure-Spring-Apps.svg deleted file mode 100644 index 7dfc2c7..0000000 --- a/assets/icons/azure/web/10370-icon-service-Azure-Spring-Apps.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/misc/no_image.png b/assets/icons/misc/no_image.png deleted file mode 100644 index ad786e5..0000000 Binary files a/assets/icons/misc/no_image.png and /dev/null differ diff --git a/assets/icons/severity/high.png b/assets/icons/severity/high.png deleted file mode 100644 index 243b7ac..0000000 Binary files a/assets/icons/severity/high.png and /dev/null differ diff --git a/assets/icons/severity/low.png b/assets/icons/severity/low.png deleted file mode 100644 index eb79961..0000000 Binary files a/assets/icons/severity/low.png and /dev/null differ diff --git a/assets/icons/severity/medium.png b/assets/icons/severity/medium.png deleted file mode 100644 index 0c39c6f..0000000 Binary files a/assets/icons/severity/medium.png and /dev/null differ diff --git a/assets/img/logo/favicon.ico b/assets/img/logo/favicon.ico deleted file mode 100644 index 14da279..0000000 Binary files a/assets/img/logo/favicon.ico and /dev/null differ diff --git a/assets/template/egress.html b/assets/template/egress.html deleted file mode 100644 index 5353767..0000000 --- a/assets/template/egress.html +++ /dev/null @@ -1,580 +0,0 @@ - - - - - - - EscapeCloud Community Edition - Data Landscape & Egress Estimation - - - - - - - - - {% set provider_name = "Microsoft Azure" if cloud_service_provider == 1 else "Amazon Web Services" if cloud_service_provider == 2 else "Unknown Provider" %} - {% set assessment_name = "Basic" if assessment_type == 1 else "Standard" if assessment_type == 2 else "Unknown" %} - -
-
-

- EscapeCloud - {{ name }} -

- -
-
-
- -
-
- -
-
-
-
-
-
- - {% if cloud_service_provider == 1 %} - - - - - - - - - - - - - - - - - - - - - - - - {% elif cloud_service_provider == 2 %} - - - - - - - - - - - - - {% else %} - {{ provider_name }} - {% endif %} - -
-
-
Cloud Service Provider
-
{{ provider_name }}
-
-
-
-
- -
-
-
Total Data
-
{{ total_data_display }}
-
-
-
-
- -
-
-
Assessment Type
-
{{ assessment_name }}
-
-
-
-
- -
-
-
Timestamp
-
{{ timestamp }}
-
-
-
-
-
- -
-
-
-
-
-

Estimated Egress Fee

-
{{ total_fee_display }}
-
-
- -
-
Estimated Egress Fee
-
-

- Estimated one-time charge to transfer the identified - in-scope data out of the cloud over the public - internet. Calculated from the detected data volume, - the provider's standard egress price tiers, and - archive retrieval charges where applicable. Excludes - request/transaction fees, cross-region traffic, and - any resources whose size could not be measured. A "+" - means the figure is a lower-bound estimate. -

-
-
-
-
- {% if unknown_count > 0 %} -
- - Excludes {{ unknown_count }} resource{% if unknown_count != 1 %}s{% - endif %} with unavailable size. -
- {% endif %} {% if free_tier %} -
-
- -
-
- Included in Free Tier -
-

- Your scanned footprint ({{ known_data_display }}) falls entirely within the cloud provider's complimentary - monthly internet egress allowance - ({{ free_tier_limit_display }} free tier). -

-
- {% elif has_fees %} -
- -
- {% else %} -
-
- -

No fee estimate available.

-
-
- {% endif %} -
-
-
- -
-
-
-
-
-

Data Allocation

-
{{ total_data_display }}
-
-
- -
-
Data Allocation
-
-

- Breakdown of the identified in-scope data volume by - resource category. Based on the sizes detected during - the assessment and intended to show where the data - footprint sits. A "+" indicates that some resources - could not be fully measured. -

-
-
-
-
- {% if has_allocation %} -
- -
- {% else %} -
-
- -

No data volume available.

-
-
- {% endif %} -
-
-
-
-
-
-
-
-

Data Landscape

-
-
-
- - - - - - - - - - - {% if type_groups %} {% for group in type_groups %} - - - - - - - - - - - {% endfor %} {% else %} - - - - {% endif %} - -
#Resource TypeCategorySize
{{ loop.index }} - - {{ group.label | trim }} - {{ group.label }} - - {{ group.category_label }} - {{ group.size_display }}
-
-
- -
{{ group.fee_display }}
-
-
- -
- {% for resource in group.resources %} -
- {{ resource.name }}{% if resource.fee_display %} - ({{ resource.fee_display }}){% endif %}{% if - resource.detail %} - – {{ resource.detail }}{% endif %} -
- {% endfor %} -
-
-
-
- No data-bearing resources were discovered during the - assessment. -
-
-
-
-
-
- - - - - - {% if has_allocation %} - - {% endif %} - - {% if has_fees %} - - {% endif %} - -
- - diff --git a/assets/template/index.html b/assets/template/index.html deleted file mode 100644 index aabffce..0000000 --- a/assets/template/index.html +++ /dev/null @@ -1,1324 +0,0 @@ - - - - - - - EscapeCloud Community Edition - Cloud Exit Assessment Report - - - - - - - - - {% set provider_name = "Microsoft Azure" if cloud_service_provider == 1 else "Amazon Web Services" if cloud_service_provider == 2 else "Unknown Provider" %} - {% set strategy_name = "Repatriation to On-Premises" if exit_strategy == 1 else "Migration to Alternate Cloud" if exit_strategy == 3 else "Unknown Strategy" %} - {% set assessment_name = "Basic" if assessment_type == 1 else "Standard" if assessment_type == 2 else "Unknown" %} - {% set total_risks = high_risk_count + medium_risk_count + low_risk_count %} - -
-
-

- EscapeCloud - {{ name }} -

- -
-
-
- -
-
-
-
-
-
-
-
- - {% if cloud_service_provider == 1 %} - - - - - - - - - - - - - - - - - - - - - - - - {% elif cloud_service_provider == 2 %} - - - - - - - - - - - - - {% else %} - {{ provider_name }} - {% endif %} - -
-
-
Cloud Service Provider
-
{{ provider_name }}
-
-
-
-
- -
-
-
Exit Strategy
-
{{ strategy_name }}
-
-
-
-
- -
-
-
Assessment Type
-
{{ assessment_name }}
-
-
-
-
- -
-
-
Timestamp
-
{{ timestamp }}
-
-
-
-
-
- -
-
-
-
-
-

Risks

-
{{ total_risks }}
-
-
- {% if total_risks > 0 %} -
- -
- {% else %} -
-
- -

No risk data available.

-
-
- {% endif %} -
-
-
- -
-
-
-
-
-

Costs (last 6 months)

-
- {% if total_cost > 0 %}{{ currency_symbol }}{{ total_cost }}{% else - %}-{% endif %} -
-
-
- {% if total_cost > 0 %} -
- -
- {% else %} -
-
- -

No cost data available.

-
-
- {% endif %} -
-
-
-
- - {% if assessment_type == 2 %} -
-
-
-
-
-
-

Scoring #1 - Exit Readiness

-
-
- -
-
Exit Readiness Grade
-
-

- This grade reflects how ready your environment is to - exit the cloud. It's based on how dependent your setup - is on provider-specific services and whether mature - alternatives exist - the lower the dependency, the - higher the grade. -

A - Seamless -
B - Smooth Transition -
C - Manageable -
D - Challenging -
E - Complex -
F - Very Complex -

-
-
-
-
-
-
- {% if scoring_data %} -
-
-
-
-
- {% else %} -
-
- -

No exit score data available.

-
-
- {% endif %} -
-
- -
-
-
-
-
-

Scoring #2 - Vendor Lock-In Score

-
-
- -
-
Vendor Lock-In Score
-
-

- The radar chart visualizes alternative technologies - across three dimensions: -
- Human (skills availability) -
- Technology (maturity and vendor stability) -
- Operational (ecosystem and support services) -

Only where viable alternatives exist. -

-
-
-
-
-
-
- {% if scoring_data %} -
-
-
- {% else %} -
-
- -

No vendor lock-in score data available.

-
-
- {% endif %} -
-
-
- {% endif %} - -
-
-
-
-

Risks

-
- - -
-
-
- -
- - - - - - - - - - - {% if risks %} {% for risk in risks %} - - - - - - - - - - - {% endfor %} {% else %} - - - - {% endif %} - -
#RiskImpacted ResourcesSeverity
{{ loop.index }} - - {{ risk.name }} - - {% if risk.impacted_resources_count is none %}-{% else %}{{ - risk.impacted_resources_count }}{% endif %} - - {{ risk.severity | capitalize }} -
-
-
- -
{{ risk.description }}
-
- {% if risk.impacted_resources_count %} -
- -
- {{ risk.impacted_resources | join(", ") }} -
-
- {% endif %} -
-
- No risks were identified for this assessment. -
-
-
-
-
- -
-
-
-
-

Resource Inventory ({{ total_resources }})

-
-
-
-
-
- {% if resource_inventory %} {% for resource in resource_inventory %} -
-
- {{ resource.name | trim }} -

{{ resource.name | trim }}

-
- {{ resource.count }} Resource{% if resource.count != 1 %}s{% - endif %} Available -
-
-
- {% endfor %} {% else %} -
-

- No resources were discovered during the assessment. -

-
- {% endif %} -
-
- {% if resource_inventory|length > 8 %} -
-
- -
- {% endif %} -
-
-
-
- -
-
-
-
-

- Alternative Technologies ({{ alternative_technologies | length }}) -

- {% if alternative_technologies %} -
-
- -
-
- - - -
-
- {% endif %} -
-
- -
-
-
- {% if alternative_technologies %} {% for alt_tech in - alternative_technologies %} -
-
-
-
-
- - {{ alt_tech.organization_name }} - {% if alt_tech.organization_url %} - - - - {% endif %} - | - {% if alt_tech.organization_flag %}{{ alt_tech.organization_flag }} {% endif %}{{ alt_tech.organization_country_code }} • {{ alt_tech.organization_region_label }} -
-

{{ alt_tech.product_name }}

-
-
-
-

{{ alt_tech.product_description }}

-
-
-
- {% if alt_tech.open_source %} - - {% else %} - - {% endif %} - Open Source -
-
- {% if alt_tech.support_plan %} - - {% else %} - - {% endif %} - Enterprise Support -
-
-
-
- {% if alt_tech.product_url %} - - - Visit {{ alt_tech.product_name }} - - {% endif %} -
-
-
- {% endfor %} {% else %} -
-
-
- -

- No alternative technologies are available for this - assessment. -

-
-
-
- {% endif %} -
-
-
-
-
-
-
- - - - {% if total_risks > 0 %} - - {% endif %} - - {% if total_cost > 0 %} - - {% endif %} - - {% if scoring_data %} - - {% endif %} -
- - diff --git a/cloudexit/__init__.py b/cloudexit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cloudexit/__main__.py b/cloudexit/__main__.py new file mode 100644 index 0000000..fe29604 --- /dev/null +++ b/cloudexit/__main__.py @@ -0,0 +1,3 @@ +from cloudexit.cli import run + +run() diff --git a/cloudexit/assets/css/driver.css b/cloudexit/assets/css/driver.css new file mode 100644 index 0000000..7ce0bd6 --- /dev/null +++ b/cloudexit/assets/css/driver.css @@ -0,0 +1 @@ +.driver-active .driver-overlay,.driver-active *{pointer-events:none}.driver-active .driver-active-element,.driver-active .driver-active-element *,.driver-popover,.driver-popover *{pointer-events:auto}@keyframes animate-fade-in{0%{opacity:0}to{opacity:1}}.driver-fade .driver-overlay{animation:animate-fade-in .2s ease-in-out}.driver-fade .driver-popover{animation:animate-fade-in .2s}.driver-popover{all:unset;box-sizing:border-box;color:#2d2d2d;margin:0;padding:15px;border-radius:5px;min-width:250px;max-width:300px;box-shadow:0 1px 10px #0006;z-index:1000000000;position:fixed;top:0;right:0;background-color:#fff}.driver-popover *{font-family:Helvetica Neue,Inter,ui-sans-serif,"Apple Color Emoji",Helvetica,Arial,sans-serif}.driver-popover-title{font:19px/normal sans-serif;font-weight:700;display:block;position:relative;line-height:1.5;zoom:1;margin:0}.driver-popover-close-btn{all:unset;position:absolute;top:0;right:0;width:32px;height:28px;cursor:pointer;font-size:18px;font-weight:500;color:#d2d2d2;z-index:1;text-align:center;transition:color;transition-duration:.2s}.driver-popover-close-btn:hover,.driver-popover-close-btn:focus{color:#2d2d2d}.driver-popover-title[style*=block]+.driver-popover-description{margin-top:5px}.driver-popover-description{margin-bottom:0;font:14px/normal sans-serif;line-height:1.5;font-weight:400;zoom:1}.driver-popover-footer{margin-top:15px;text-align:right;zoom:1;display:flex;align-items:center;justify-content:space-between}.driver-popover-progress-text{font-size:13px;font-weight:400;color:#727272;zoom:1}.driver-popover-footer button{all:unset;display:inline-block;box-sizing:border-box;padding:3px 7px;text-decoration:none;text-shadow:1px 1px 0 #fff;background-color:#fff;color:#2d2d2d;font:12px/normal sans-serif;cursor:pointer;outline:0;zoom:1;line-height:1.3;border:1px solid #ccc;border-radius:3px}.driver-popover-footer .driver-popover-btn-disabled{opacity:.5;pointer-events:none}:not(body):has(>.driver-active-element){overflow:hidden!important}.driver-no-interaction,.driver-no-interaction *{pointer-events:none!important}.driver-popover-footer button:hover,.driver-popover-footer button:focus{background-color:#f7f7f7}.driver-popover-navigation-btns{display:flex;flex-grow:1;justify-content:flex-end}.driver-popover-navigation-btns button+button{margin-left:4px}.driver-popover-arrow{content:"";position:absolute;border:5px solid #fff}.driver-popover-arrow-side-over{display:none}.driver-popover-arrow-side-left{left:100%;border-right-color:transparent;border-bottom-color:transparent;border-top-color:transparent}.driver-popover-arrow-side-right{right:100%;border-left-color:transparent;border-bottom-color:transparent;border-top-color:transparent}.driver-popover-arrow-side-top{top:100%;border-right-color:transparent;border-bottom-color:transparent;border-left-color:transparent}.driver-popover-arrow-side-bottom{bottom:100%;border-left-color:transparent;border-top-color:transparent;border-right-color:transparent}.driver-popover-arrow-side-center{display:none}.driver-popover-arrow-side-left.driver-popover-arrow-align-start,.driver-popover-arrow-side-right.driver-popover-arrow-align-start{top:15px}.driver-popover-arrow-side-top.driver-popover-arrow-align-start,.driver-popover-arrow-side-bottom.driver-popover-arrow-align-start{left:15px}.driver-popover-arrow-align-end.driver-popover-arrow-side-left,.driver-popover-arrow-align-end.driver-popover-arrow-side-right{bottom:15px}.driver-popover-arrow-side-top.driver-popover-arrow-align-end,.driver-popover-arrow-side-bottom.driver-popover-arrow-align-end{right:15px}.driver-popover-arrow-side-left.driver-popover-arrow-align-center,.driver-popover-arrow-side-right.driver-popover-arrow-align-center{top:50%;margin-top:-5px}.driver-popover-arrow-side-top.driver-popover-arrow-align-center,.driver-popover-arrow-side-bottom.driver-popover-arrow-align-center{left:50%;margin-left:-5px}.driver-popover-arrow-none{display:none} diff --git a/cloudexit/assets/css/report.css b/cloudexit/assets/css/report.css new file mode 100644 index 0000000..70479be --- /dev/null +++ b/cloudexit/assets/css/report.css @@ -0,0 +1,305 @@ +@font-face { font-family: 'IBM Plex Sans'; font-weight: 400; font-style: normal; font-display: swap; src: url('../fonts/IBMPlexSans-Regular.ttf') format('truetype'); } +@font-face { font-family: 'IBM Plex Sans'; font-weight: 400; font-style: italic; font-display: swap; src: url('../fonts/IBMPlexSans-Italic.ttf') format('truetype'); } +@font-face { font-family: 'IBM Plex Sans'; font-weight: 600; font-style: normal; font-display: swap; src: url('../fonts/IBMPlexSans-SemiBold.ttf') format('truetype'); } +@font-face { font-family: 'IBM Plex Sans'; font-weight: 600; font-style: italic; font-display: swap; src: url('../fonts/IBMPlexSans-SemiBoldItalic.ttf') format('truetype'); } +@font-face { font-family: 'IBM Plex Mono'; font-weight: 400; font-style: normal; font-display: swap; src: url('../fonts/IBMPlexMono-Regular.ttf') format('truetype'); } +@font-face { font-family: 'IBM Plex Mono'; font-weight: 600; font-style: normal; font-display: swap; src: url('../fonts/IBMPlexMono-SemiBold.ttf') format('truetype'); } + +:root { + --primary-50: #f0fdfc; --primary-100: #ccfbf8; --primary-600: #0d948b; --primary-700: #0f766f; --primary-800: #115e59; --primary-900: #134e4a; + --neutral-100: #f3f6f6; --neutral-200: #e5ebeb; --neutral-300: #d1dbdb; --neutral-600: #4b6361; --neutral-900: #112726; + + --surface: #fff; --surface-muted: var(--neutral-100); --border: var(--neutral-200); --border-strong: var(--neutral-300); + --text: var(--neutral-900); --text-secondary: var(--neutral-600); --text-muted: #6B7680; --brand: var(--primary-800); + --thead-bg: #E9F0F2; --thead-rule: #C9D0D6; + + --status-success: #047854; --status-success-bg: #ecfdf8; + --status-pending: #4b6361; --status-pending-bg: #eef2f2; + + --sev-critical: #6E1A14; --sev-high: #B8322A; --sev-medium: #E8A33D; --sev-low: #4A7FB5; --sev-off: #E3E7EA; + + --hotspot-bg: #FDF6EC; --hotspot-ink: #B45309; + + --radius-card: 16px; --radius-control: 8px; --radius-chip: 6px; --radius-pill: 999px; + --shadow-card: 0 1px 4px rgba(0,0,0,.10); --shadow-popover: 0 8px 24px rgba(0,0,0,.12); + --space-1: 4px; --space-2: 8px; --space-3: 12px; --space-4: 16px; --space-5: 20px; --space-6: 24px; --space-8: 32px; + --topbar-h: 64px; +} + +* { box-sizing: border-box; } +html { scroll-padding-top: 120px; } +body { margin: 0; background: var(--surface-muted); color: var(--text-secondary); font-family: 'IBM Plex Sans', system-ui, -apple-system, 'Segoe UI', sans-serif; font-size: 14px; line-height: 1.43; -webkit-font-smoothing: antialiased; } +a { color: var(--brand); } +h1, h2, h3, p { margin: 0; } +img { display: block; } +:focus-visible { outline: 2px solid var(--brand); outline-offset: 2px; border-radius: 4px; } +.visually-hidden { position: absolute !important; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; } +.muted { color: var(--text-muted); } +code, .mono { font-family: 'IBM Plex Mono', ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; } + +.t-title { font-size: 24px; line-height: 32px; font-weight: 500; color: var(--text); } +.t-section { font-size: 20px; line-height: 28px; font-weight: 500; color: var(--text); } +.t-sub { font-size: 16px; line-height: 24px; font-weight: 600; color: var(--text); } +.t-label { font-size: 12px; line-height: 16px; font-weight: 500; letter-spacing: .04em; text-transform: uppercase; color: var(--text-muted); } +.t-value { font-size: 14px; line-height: 20px; font-weight: 500; color: var(--text); } +.t-figure { font-size: 28px; line-height: 32px; font-weight: 500; color: var(--text); font-variant-numeric: tabular-nums; } +.t-caption { font-size: 12px; line-height: 16px; color: var(--text-muted); } + +/* icons: an inline SVG sprite in the page, no icon font */ +.i { width: 16px; height: 16px; flex: none; fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; vertical-align: -3px; } +.i.sm { width: 13px; height: 13px; vertical-align: -2px; } + +/* header: logo, edition tag and Help */ +.app-header { position: relative; z-index: 50; height: var(--topbar-h); background: var(--surface); border-bottom: 1px solid var(--border); } +.app-header .inner { max-width: 1184px; height: 100%; margin: 0 auto; padding: 0 var(--space-4); display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); } +.brand { display: inline-flex; align-items: center; gap: 8px; text-decoration: none; } +.brand img { display: block; height: 28px; width: auto; } +.brand-tag { font-size: 11px; line-height: 16px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; color: var(--brand); background: var(--primary-50); border: 1px solid var(--primary-100); border-radius: 6px; padding: 1px 7px; } +.help-btn { display: inline-flex; align-items: center; gap: 6px; border: 1px solid var(--border-strong); background: var(--surface); color: var(--text-secondary); font: inherit; font-weight: 500; padding: 6px 12px; border-radius: var(--radius-pill); cursor: pointer; } +.help-btn:hover { color: var(--brand); border-color: var(--brand); } +.help-btn .i { width: 16px; height: 16px; } + +/* layout: one column with the sticky section bar; from 1200 px the bar is a sidebar */ +.card { background: var(--surface); border-radius: var(--radius-card); box-shadow: var(--shadow-card); } +.card-head { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); flex-wrap: wrap; padding: var(--space-4) var(--space-6); border-bottom: 1px solid var(--border); } +.card-body { padding: var(--space-5) var(--space-6) var(--space-6); } +@media (max-width: 575.98px) { .card-head, .card-body { padding-left: var(--space-4); padding-right: var(--space-4); } } +.shell { max-width: 1184px; margin: 0 auto; padding: var(--space-5) var(--space-4) 64px; display: grid; grid-template-columns: minmax(0, 1fr); grid-template-areas: "title" "nav" "main"; } +.shell > .titlebar { grid-area: title; } +.shell > .secnav { grid-area: nav; } +.shell > main { grid-area: main; min-width: 0; } +@media (min-width: 1200px) { + .shell { max-width: 1400px; grid-template-columns: 208px minmax(0, 1fr); grid-template-areas: "nav title" "nav main"; column-gap: var(--space-8); } + .shell > .titlebar { margin-bottom: var(--space-6); } + .secnav { grid-row: 1 / span 2; align-self: start; top: var(--space-4); margin: 0; background: none; backdrop-filter: none; border-bottom: 0; } + .secnav ul { flex-direction: column; overflow: visible; gap: 2px; } + .secnav a { display: flex; justify-content: space-between; padding: 8px 12px; border-bottom: 0; border-radius: 8px; white-space: normal; } + .secnav a:hover { background: var(--surface); color: var(--text); } + .secnav a[aria-current="true"] { background: var(--surface); box-shadow: inset 3px 0 0 var(--brand); } + .app-header .inner { max-width: 1400px; } +} + +/* buttons, chips, menus */ +.btn { display: inline-flex; align-items: center; gap: 8px; border-radius: var(--radius-control); border: 1px solid var(--brand); background: var(--surface); color: var(--brand); font: inherit; font-weight: 500; padding: 8px 14px; text-decoration: none; cursor: pointer; white-space: nowrap; } +.chip { display: inline-flex; align-items: center; gap: 6px; border-radius: var(--radius-pill); padding: 2px 10px; font-size: 12px; font-weight: 600; white-space: nowrap; line-height: 20px; } +.chip.success { background: var(--status-success-bg); color: var(--status-success); } +.chip.offline { background: var(--status-pending-bg); color: var(--status-pending); } +.menu { position: relative; } +.menu > summary { list-style: none; } +.menu > summary::-webkit-details-marker { display: none; } +.menu-list { position: absolute; right: 0; top: calc(100% + 6px); z-index: 40; min-width: 260px; background: var(--surface); border: 1px solid var(--border); border-radius: 12px; box-shadow: var(--shadow-popover); padding: 6px; display: grid; } +.menu-list a { display: flex; gap: 10px; align-items: center; padding: 8px 10px; border-radius: 8px; color: var(--text); text-decoration: none; } +.menu-list a:hover, .menu-list a:focus-visible { background: var(--surface-muted); outline: none; } + +/* title bar */ +.titlebar { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); flex-wrap: wrap; padding: var(--space-4) var(--space-6); } +.titlebar .name { display: flex; align-items: center; gap: var(--space-3); flex-wrap: wrap; min-width: 0; } +.titlebar .name img.provider-logo { width: 28px; height: 24px; object-fit: contain; } +.titlebar > div:first-child { flex: 1 1 520px; min-width: 0; } +.titlebar .actions { display: flex; gap: var(--space-3); align-self: flex-start; } +a.platform-link { text-decoration: none; } +a.platform-link:hover { background: var(--primary-100); } +@media (max-width: 575.98px) { .titlebar { padding: var(--space-4); } .titlebar .actions { width: 100%; } .titlebar .actions > * { flex: 1; } .titlebar .actions .btn, .titlebar .actions summary { width: 100%; justify-content: center; } } + +/* section nav */ +.secnav { position: sticky; top: 0; z-index: 20; background: rgba(243,246,246,.94); backdrop-filter: blur(6px); border-bottom: 1px solid var(--border); margin: var(--space-4) 0 var(--space-6); } +.secnav ul { list-style: none; margin: 0; padding: 0; display: flex; gap: 2px; overflow-x: auto; scrollbar-width: none; } +.secnav a { display: block; padding: 12px; color: var(--text-muted); text-decoration: none; white-space: nowrap; font-weight: 500; border-bottom: 2px solid transparent; } +.secnav a[aria-current="true"] { color: var(--brand); border-bottom-color: var(--brand); } +.secnav .n { font-weight: 400; margin-left: 4px; } +.secnav a .i { margin-left: 6px; color: var(--text-muted); } +section.block { margin-bottom: var(--space-6); scroll-margin-top: 64px; } + +/* overview cards */ +.overview { display: grid; grid-template-columns: 1.05fr 1fr 1.15fr; gap: var(--space-6); } +@media (max-width: 1099.98px) { .overview { grid-template-columns: 1fr 1fr; } .overview .costs { grid-column: 1 / -1; } } +@media (max-width: 767.98px) { .overview { grid-template-columns: 1fr; } } +.overview .card { padding: var(--space-5) var(--space-6); } +dl.scope { display: grid; grid-template-columns: auto 1fr; gap: 10px 16px; margin: var(--space-3) 0 0; } +dl.scope dt { color: var(--text-muted); } +dl.scope dd { margin: 0; color: var(--text); font-weight: 500; overflow-wrap: anywhere; } +dl.scope dd small { display: block; font-weight: 400; color: var(--text-muted); font-size: 12px; } +.sev { display: inline-flex; align-items: center; gap: 6px; font-weight: 500; white-space: nowrap; color: var(--text); } +.pips { display: inline-flex; gap: 2px; } +.pips i { width: 8px; height: 8px; border-radius: 1px; background: var(--sev-off); } +.sev.critical .pips i { background: var(--sev-critical); } +.sev.high .pips i:nth-child(-n+3) { background: var(--sev-high); } +.sev.medium .pips i:nth-child(-n+2) { background: var(--sev-medium); } +.sev.low .pips i:nth-child(-n+1) { background: var(--sev-low); } +.sevchart { display: grid; gap: 14px; margin-top: var(--space-5); } +.sevrow { display: grid; grid-template-columns: 96px 1fr 24px; gap: 12px; align-items: center; } +.sevrow b { text-align: right; font-weight: 600; color: var(--text); font-variant-numeric: tabular-nums; } +.sevbar { height: 14px; border-radius: 4px; background: var(--neutral-100); overflow: hidden; } +.sevbar span { display: block; height: 100%; border-radius: 4px; } +.sevbar span.critical { background: var(--sev-critical); } +.sevbar span.high { background: var(--sev-high); } +.sevbar span.medium { background: var(--sev-medium); } +.sevbar span.low { background: var(--sev-low); } +.kpis { display: flex; gap: var(--space-6); flex-wrap: wrap; margin-top: var(--space-2); } +.bars { display: grid; grid-template-columns: repeat(6, 1fr); gap: 10px; align-items: end; height: 132px; margin-top: var(--space-4); } +.bars .col { display: flex; flex-direction: column; justify-content: flex-end; align-items: center; gap: 6px; height: 100%; } +.bars .track { flex: 1; width: 100%; display: flex; align-items: flex-end; justify-content: center; } +.bars .b { width: 100%; max-width: 40px; min-height: 2px; background: var(--primary-700); border-radius: 4px 4px 0 0; } +.bars .b.zero { background: var(--neutral-300); } +.bars .b.prov { background: repeating-linear-gradient(135deg, var(--primary-100) 0 4px, #fff 4px 8px); border: 1.5px dashed var(--primary-700); border-bottom: 0; } +.bars .m { font-size: 12px; color: var(--text-muted); } +.no-costs { display: grid; gap: 8px; margin-top: 6px; } +.no-costs p { color: var(--text-secondary); } + +/* scores: platform-only results, shown without values */ +.scores { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-6); } +@media (max-width: 767.98px) { .scores { grid-template-columns: 1fr; } } +.grade { display: grid; grid-template-columns: auto 1fr; gap: var(--space-6); align-items: center; } +.grade-tile { width: 96px; height: 96px; border: 2px dashed var(--border-strong); border-radius: 12px; display: grid; place-items: center; text-align: center; } +.grade-tile .l { font-size: 44px; line-height: 1; font-weight: 600; color: var(--text-muted); } +.bands { display: flex; gap: 2px; } +.bands span { flex: 1; height: 24px; display: grid; place-items: center; font-size: 11px; font-weight: 600; background: var(--neutral-200); color: var(--text-muted); } +.dims { display: grid; gap: var(--space-3); } +.dim { display: grid; grid-template-columns: 110px 1fr 48px; gap: 12px; align-items: center; color: var(--text); } +.dim .track { height: 10px; border-radius: 999px; background: var(--neutral-200); } +.ghost { opacity: .55; } +.lock-note { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 600; color: var(--brand); background: var(--primary-50); border: 1px solid var(--primary-100); border-radius: var(--radius-pill); padding: 2px 10px; white-space: nowrap; } +button.lock-note { font: inherit; font-size: 12px; font-weight: 600; cursor: pointer; } +button.lock-note:hover, button.lock-note[aria-expanded="true"] { background: var(--primary-100); } +.hint { position: relative; display: inline-flex; } +.hint-pop { position: absolute; right: 0; top: calc(100% + 8px); z-index: 30; width: min(340px, 86vw); padding: 14px 16px; border-radius: 12px; background: var(--surface); border: 1px solid var(--border); box-shadow: var(--shadow-popover); font-size: 13px; line-height: 1.55; color: var(--text-secondary); text-align: left; } +.hint-pop b { display: block; color: var(--text); font-weight: 600; margin-bottom: 4px; } +.hint-pop p + p { margin-top: 8px; } +.hint-pop .foot { font-size: 12px; color: var(--text-muted); } +.hint-pop a { color: var(--brand); } +.hint-pop.flip { right: auto; left: 0; } +@media (max-width: 575.98px) { .hint-pop, .hint-pop.flip { position: fixed; left: 12px; right: 12px; top: auto; bottom: 12px; width: auto; } } + +/* tables */ +.table-wrap { overflow-x: auto; } +table.data { width: 100%; border-collapse: collapse; } +table.data thead th { text-align: left; font-size: 12px; font-weight: 600; color: var(--brand); background: var(--thead-bg); padding: 9px 12px; border-bottom: 2px solid var(--thead-rule); white-space: nowrap; } +table.data th.num, table.data td.num { text-align: right; font-variant-numeric: tabular-nums; } +table.data tbody td { padding: 10px 12px; border-bottom: 1px solid var(--border); vertical-align: top; color: var(--text); } +table.data tr.group td { font-size: 12px; font-weight: 600; color: var(--text-muted); background: #fafbfb; padding: 8px 12px; } +table.data tr.total td { font-weight: 600; border-bottom: 0; } +.share { display: inline-flex; align-items: center; gap: 8px; min-width: 120px; } +.share .bar { flex: 1; height: 6px; background: var(--neutral-200); border-radius: 3px; overflow: hidden; min-width: 60px; } +.share .bar span { display: block; height: 100%; background: var(--primary-700); } +.share b { font-weight: 500; min-width: 36px; text-align: right; font-variant-numeric: tabular-nums; } +.rt { display: flex; gap: 10px; align-items: center; font-weight: 500; } +.rt img { width: 22px; height: 22px; object-fit: contain; flex: none; } +.rt .ph { width: 22px; height: 22px; flex: none; border-radius: 5px; background: var(--neutral-200); color: var(--text-secondary); font-size: 10px; font-weight: 600; display: grid; place-items: center; } +.loc { color: var(--text-muted); font-size: 12px; white-space: nowrap; } +@media (max-width: 575.98px) { .hide-sm { display: none; } } +.empty-state { display: grid; gap: 4px; justify-items: center; text-align: center; padding: 28px 16px; border: 1px dashed var(--border-strong); border-radius: 12px; color: var(--text-muted); } +.empty-state b { color: var(--text); font-weight: 600; } + +/* risks */ +.rowbtn { all: unset; cursor: pointer; display: inline-flex; gap: 8px; align-items: baseline; } +.rowbtn:focus-visible { outline: 2px solid var(--brand); outline-offset: 2px; border-radius: 4px; } +.rowbtn .chev { transition: transform .15s; color: var(--text-muted); } +.rowbtn[aria-expanded="true"] .chev { transform: rotate(90deg); } +.rname { font-weight: 500; } +tr.detail td { background: #fafbfb; color: var(--text-secondary); font-size: 13px; } +tr.detail[hidden] { display: none !important; } +@media (max-width: 575.98px) { + table.risks thead { display: none; } + table.risks tr.r { display: grid; grid-template-columns: auto 1fr; gap: 4px 10px; padding: 12px 0; border-bottom: 1px solid var(--border); } + table.risks tr.r td { border: 0; padding: 0 4px; } + table.risks tr.r td.sevc { grid-row: 1; justify-self: end; } + table.risks tr.r td.name, table.risks tr.r td.aff { grid-column: 1 / -1; } + table.risks tr.group, table.risks tr.detail, table.risks tr.group td, table.risks tr.detail td { display: block; } +} + +/* alternatives */ +.toolbar { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; } +.searchbox { position: relative; display: inline-block; } +.searchbox .i { position: absolute; left: 11px; top: 50%; transform: translateY(-50%); color: var(--text-muted); pointer-events: none; } +.search { border: 1px solid var(--border-strong); border-radius: var(--radius-control); padding: 7px 10px 7px 32px; font: inherit; width: 240px; background: #fff; color: var(--text); } +@media (max-width: 575.98px) { .search, .searchbox { width: 100%; } } +.fchip { border: 1px solid var(--border-strong); border-radius: var(--radius-pill); padding: 6px 12px; background: #fff; font: inherit; font-size: 13px; color: var(--text); cursor: pointer; } +.fchip[aria-pressed="true"] { background: var(--brand); color: #fff; border-color: var(--brand); } +details.grp { border: 1px solid var(--border); border-radius: 12px; margin-bottom: var(--space-3); overflow: hidden; background: #fff; } +details.grp > summary { list-style: none; cursor: pointer; display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 12px 16px; } +details.grp > summary::-webkit-details-marker { display: none; } +details.grp > summary .t { font-weight: 600; color: var(--text); display: flex; gap: 8px; align-items: center; } +details.grp > summary .t img { width: 20px; height: 20px; object-fit: contain; } +details.grp > summary .m { color: var(--text-muted); font-size: 12px; text-align: right; } +details.grp[open] > summary { border-bottom: 1px solid var(--border); } +details.grp > summary .t::before, details.disc > summary::before { content: ""; width: 6px; height: 6px; border-right: 1.6px solid var(--text-muted); border-bottom: 1.6px solid var(--text-muted); transform: rotate(-45deg); transition: transform .15s; flex: none; margin: 0 2px; } +details.grp[open] > summary .t::before, details.disc[open] > summary::before { transform: rotate(45deg); } +details.grp table.data thead th { background: #f6f9fa; } +.alt-name { font-weight: 500; } +.alt-meta { display: block; color: var(--text-muted); font-size: 12px; } +.cc { display: inline-block; font-size: 10px; font-weight: 600; letter-spacing: .04em; color: var(--text-secondary); border: 1px solid var(--border-strong); border-radius: 4px; padding: 0 4px; margin-left: 4px; vertical-align: 1px; } +.more { padding: 10px 16px; font-weight: 500; } +.more a { text-decoration: none; } +.alt-empty { padding: 16px; border: 1px dashed var(--border-strong); border-radius: 12px; color: var(--text-muted); text-align: center; } +table.alts th:nth-child(n+2), table.alts td:nth-child(n+2) { text-align: center; width: 120px; } +table.alts .mark { width: 18px; height: 18px; stroke-width: 2.2; vertical-align: middle; } +table.alts .yes .mark { color: var(--status-success); } +table.alts .no .mark { color: var(--neutral-300); stroke-width: 1.8; } + +/* egress */ +.kpi-row { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--space-4); } +@media (max-width: 767.98px) { .kpi-row { grid-template-columns: 1fr 1fr; } } +.kpi { border: 1px solid var(--border); border-radius: 12px; padding: var(--space-4); } +.kpi.primary { background: var(--primary-50); border-color: var(--primary-100); } +.kpi .t-figure { margin-top: 6px; font-size: 24px; } +.kpi .t-caption { margin-top: 4px; } +.headroom { margin-top: 10px; height: 8px; border-radius: 4px; background: var(--neutral-200); overflow: hidden; } +.headroom span { display: block; height: 100%; background: var(--primary-700); } +.vol { display: flex; height: 14px; gap: 2px; border-radius: 7px; overflow: hidden; margin: var(--space-6) 0 var(--space-2); } +.vol-legend { display: flex; flex-wrap: wrap; gap: 6px 16px; font-size: 12px; color: var(--text); } +.vol-legend i { display: inline-block; width: 10px; height: 10px; border-radius: 2px; margin-right: 6px; vertical-align: -1px; } +.unsized { display: inline-flex; align-items: center; gap: 4px; color: var(--hotspot-ink); font-size: 12px; font-weight: 500; white-space: nowrap; } +.fine { margin-top: var(--space-4); display: grid; gap: 6px; } +details.prices { margin-top: var(--space-4); border: 1px solid var(--border); border-radius: 12px; } +details.prices > summary { cursor: pointer; padding: 10px 14px; font-weight: 500; color: var(--brand); } +details.prices[open] > summary { border-bottom: 1px solid var(--border); } +details.prices .inner { padding: 4px 14px 14px; } + +/* about this report */ +.kv { width: 100%; border-collapse: collapse; } +.kv th { text-align: left; font-weight: 500; color: var(--text-muted); width: 200px; padding: 8px 12px 8px 0; border-bottom: 1px solid var(--border); vertical-align: top; } +.kv td { color: var(--text); padding: 8px 0; border-bottom: 1px solid var(--border); overflow-wrap: anywhere; } +.kv-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--space-6); } +@media (max-width: 991.98px) { .kv-grid { grid-template-columns: 1fr; } } +.kv-grid h3 { margin-bottom: var(--space-2); } +.kv-grid .kv th { width: 44%; } +details.disc { border-top: 1px solid var(--border); } +details.disc:last-child { border-bottom: 1px solid var(--border); } +details.disc > summary { cursor: pointer; list-style: none; display: flex; align-items: center; gap: 8px; padding: 14px 0; font-weight: 600; color: var(--text); } +details.disc > summary::-webkit-details-marker { display: none; } +details.disc > summary .m { margin-left: auto; font-weight: 400; font-size: 12px; color: var(--text-muted); } +details.disc .inner { padding: 0 0 var(--space-5) 20px; display: grid; gap: 10px; color: var(--text-secondary); max-width: 860px; } +details.disc .inner ul { margin: 0; padding-left: 18px; display: grid; gap: 4px; } + +/* disclaimer and legal notices */ +.disclaimer { margin-top: var(--space-8); display: grid; grid-template-columns: 22px 1fr; gap: 12px; background: var(--hotspot-bg); border: 1px solid #F3D9A8; border-radius: 12px; padding: var(--space-4) var(--space-5); color: #5C4A1F; font-size: 13px; line-height: 1.55; } +.disclaimer > .i { width: 20px; height: 20px; color: var(--hotspot-ink); margin-top: 1px; } +.disclaimer h2 { font-size: 14px; font-weight: 600; color: #3D2F10; margin-bottom: 4px; } +.disclaimer p + p { margin-top: 6px; } +.disclaimer a { color: #6B4E12; } +.notices { border-top: 1px solid var(--border); margin-top: var(--space-8); padding-top: var(--space-6); display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--space-6); font-size: 12px; line-height: 1.55; color: var(--text-muted); } +@media (max-width: 899.98px) { .notices { grid-template-columns: 1fr; } } +.notices h2 { font-size: 12px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase; color: var(--text-secondary); margin-bottom: 6px; } +.notices p + p { margin-top: 6px; } +.notices a { color: var(--text-secondary); } +.disclaimer + .notices { margin-top: var(--space-5); border-top: 0; padding-top: 0; } +.copyright { grid-column: 1 / -1; display: flex; justify-content: space-between; gap: var(--space-4); flex-wrap: wrap; padding-top: var(--space-4); border-top: 1px solid var(--border); } + +/* guided tour (driver.js, assets/js) */ +.driver-popover.cx-tour { font-family: 'IBM Plex Sans', system-ui, sans-serif; border-radius: 12px; padding: 16px 18px; max-width: 340px; box-shadow: var(--shadow-popover); color: var(--text-secondary); } +.driver-popover.cx-tour .driver-popover-title { font-family: inherit; font-size: 15px; font-weight: 600; color: var(--text); } +.driver-popover.cx-tour .driver-popover-description { font-family: inherit; font-size: 13px; line-height: 1.55; color: var(--text-secondary); margin-top: 4px; } +.driver-popover.cx-tour .driver-popover-progress-text { font-family: inherit; font-size: 12px; color: var(--text-muted); } +.driver-popover.cx-tour .driver-popover-footer button { font-family: inherit; font-size: 13px; font-weight: 500; text-shadow: none; border: 1px solid var(--brand); color: var(--brand); background: var(--surface); border-radius: var(--radius-control); padding: 5px 12px; } +.driver-popover.cx-tour .driver-popover-footer .driver-popover-next-btn { background: var(--brand); color: #fff; } +.driver-popover.cx-tour .driver-popover-close-btn { color: var(--text-muted); } + +@media print { + body { background: #fff; } + .help-btn, .menu, .secnav, .hint-pop, .toolbar, .more, .driver-popover, .driver-overlay, .driver-active-element { display: none !important; } + .app-header { height: auto; border-bottom: 0; } + .shell { display: block; max-width: none; padding: 0; } + .card { box-shadow: none; border: 1px solid var(--border); } + section.block, details.grp, .kpi { break-inside: avoid; } + tr.detail[hidden], table.alts tr[hidden] { display: table-row !important; } +} diff --git a/cloudexit/assets/fonts/IBMPlexMono-Regular.ttf b/cloudexit/assets/fonts/IBMPlexMono-Regular.ttf new file mode 100644 index 0000000..4254c37 Binary files /dev/null and b/cloudexit/assets/fonts/IBMPlexMono-Regular.ttf differ diff --git a/cloudexit/assets/fonts/IBMPlexMono-SemiBold.ttf b/cloudexit/assets/fonts/IBMPlexMono-SemiBold.ttf new file mode 100644 index 0000000..a48e40a Binary files /dev/null and b/cloudexit/assets/fonts/IBMPlexMono-SemiBold.ttf differ diff --git a/cloudexit/assets/fonts/IBMPlexSans-Italic.ttf b/cloudexit/assets/fonts/IBMPlexSans-Italic.ttf new file mode 100644 index 0000000..efe8a1f Binary files /dev/null and b/cloudexit/assets/fonts/IBMPlexSans-Italic.ttf differ diff --git a/cloudexit/assets/fonts/IBMPlexSans-Regular.ttf b/cloudexit/assets/fonts/IBMPlexSans-Regular.ttf new file mode 100644 index 0000000..bd6817d Binary files /dev/null and b/cloudexit/assets/fonts/IBMPlexSans-Regular.ttf differ diff --git a/cloudexit/assets/fonts/IBMPlexSans-SemiBold.ttf b/cloudexit/assets/fonts/IBMPlexSans-SemiBold.ttf new file mode 100644 index 0000000..09ec8cd Binary files /dev/null and b/cloudexit/assets/fonts/IBMPlexSans-SemiBold.ttf differ diff --git a/cloudexit/assets/fonts/IBMPlexSans-SemiBoldItalic.ttf b/cloudexit/assets/fonts/IBMPlexSans-SemiBoldItalic.ttf new file mode 100644 index 0000000..a9c14fe Binary files /dev/null and b/cloudexit/assets/fonts/IBMPlexSans-SemiBoldItalic.ttf differ diff --git a/cloudexit/assets/fonts/OFL.txt b/cloudexit/assets/fonts/OFL.txt new file mode 100755 index 0000000..c35c4c6 --- /dev/null +++ b/cloudexit/assets/fonts/OFL.txt @@ -0,0 +1,93 @@ +Copyright © 2017 IBM Corp. with Reserved Font Name "Plex" + +This Font Software is licensed under the SIL Open Font License, Version 1.1. + +This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/assets/icons/README.md b/cloudexit/assets/icons/README.md similarity index 100% rename from assets/icons/README.md rename to cloudexit/assets/icons/README.md diff --git a/assets/icons/aws/Analytics/Athena.png b/cloudexit/assets/icons/aws/Analytics/Athena.png similarity index 100% rename from assets/icons/aws/Analytics/Athena.png rename to cloudexit/assets/icons/aws/Analytics/Athena.png diff --git a/assets/icons/aws/Analytics/Clean-Rooms.png b/cloudexit/assets/icons/aws/Analytics/Clean-Rooms.png similarity index 100% rename from assets/icons/aws/Analytics/Clean-Rooms.png rename to cloudexit/assets/icons/aws/Analytics/Clean-Rooms.png diff --git a/assets/icons/aws/Analytics/CloudSearch.png b/cloudexit/assets/icons/aws/Analytics/CloudSearch.png similarity index 100% rename from assets/icons/aws/Analytics/CloudSearch.png rename to cloudexit/assets/icons/aws/Analytics/CloudSearch.png diff --git a/assets/icons/aws/Analytics/Data-Exchange.png b/cloudexit/assets/icons/aws/Analytics/Data-Exchange.png similarity index 100% rename from assets/icons/aws/Analytics/Data-Exchange.png rename to cloudexit/assets/icons/aws/Analytics/Data-Exchange.png diff --git a/assets/icons/aws/Analytics/Data-Firehose.png b/cloudexit/assets/icons/aws/Analytics/Data-Firehose.png similarity index 100% rename from assets/icons/aws/Analytics/Data-Firehose.png rename to cloudexit/assets/icons/aws/Analytics/Data-Firehose.png diff --git a/assets/icons/aws/Analytics/Data-Pipeline.png b/cloudexit/assets/icons/aws/Analytics/Data-Pipeline.png similarity index 100% rename from assets/icons/aws/Analytics/Data-Pipeline.png rename to cloudexit/assets/icons/aws/Analytics/Data-Pipeline.png diff --git a/assets/icons/aws/Analytics/DataZone.png b/cloudexit/assets/icons/aws/Analytics/DataZone.png similarity index 100% rename from assets/icons/aws/Analytics/DataZone.png rename to cloudexit/assets/icons/aws/Analytics/DataZone.png diff --git a/assets/icons/aws/Analytics/EMR.png b/cloudexit/assets/icons/aws/Analytics/EMR.png similarity index 100% rename from assets/icons/aws/Analytics/EMR.png rename to cloudexit/assets/icons/aws/Analytics/EMR.png diff --git a/assets/icons/aws/Analytics/Entity-Resolution.png b/cloudexit/assets/icons/aws/Analytics/Entity-Resolution.png similarity index 100% rename from assets/icons/aws/Analytics/Entity-Resolution.png rename to cloudexit/assets/icons/aws/Analytics/Entity-Resolution.png diff --git a/assets/icons/aws/Analytics/FinSpace.png b/cloudexit/assets/icons/aws/Analytics/FinSpace.png similarity index 100% rename from assets/icons/aws/Analytics/FinSpace.png rename to cloudexit/assets/icons/aws/Analytics/FinSpace.png diff --git a/assets/icons/aws/Analytics/Glue-DataBrew.png b/cloudexit/assets/icons/aws/Analytics/Glue-DataBrew.png similarity index 100% rename from assets/icons/aws/Analytics/Glue-DataBrew.png rename to cloudexit/assets/icons/aws/Analytics/Glue-DataBrew.png diff --git a/assets/icons/aws/Analytics/Glue-Elastic-Views.png b/cloudexit/assets/icons/aws/Analytics/Glue-Elastic-Views.png similarity index 100% rename from assets/icons/aws/Analytics/Glue-Elastic-Views.png rename to cloudexit/assets/icons/aws/Analytics/Glue-Elastic-Views.png diff --git a/assets/icons/aws/Analytics/Glue.png b/cloudexit/assets/icons/aws/Analytics/Glue.png similarity index 100% rename from assets/icons/aws/Analytics/Glue.png rename to cloudexit/assets/icons/aws/Analytics/Glue.png diff --git a/assets/icons/aws/Analytics/Kinesis-Data-Analytics.png b/cloudexit/assets/icons/aws/Analytics/Kinesis-Data-Analytics.png similarity index 100% rename from assets/icons/aws/Analytics/Kinesis-Data-Analytics.png rename to cloudexit/assets/icons/aws/Analytics/Kinesis-Data-Analytics.png diff --git a/assets/icons/aws/Analytics/Kinesis-Data-Streams.png b/cloudexit/assets/icons/aws/Analytics/Kinesis-Data-Streams.png similarity index 100% rename from assets/icons/aws/Analytics/Kinesis-Data-Streams.png rename to cloudexit/assets/icons/aws/Analytics/Kinesis-Data-Streams.png diff --git a/assets/icons/aws/Analytics/Kinesis-Firehose.png b/cloudexit/assets/icons/aws/Analytics/Kinesis-Firehose.png similarity index 100% rename from assets/icons/aws/Analytics/Kinesis-Firehose.png rename to cloudexit/assets/icons/aws/Analytics/Kinesis-Firehose.png diff --git a/assets/icons/aws/Analytics/Kinesis-Video-Streams.png b/cloudexit/assets/icons/aws/Analytics/Kinesis-Video-Streams.png similarity index 100% rename from assets/icons/aws/Analytics/Kinesis-Video-Streams.png rename to cloudexit/assets/icons/aws/Analytics/Kinesis-Video-Streams.png diff --git a/assets/icons/aws/Analytics/Kinesis.png b/cloudexit/assets/icons/aws/Analytics/Kinesis.png similarity index 100% rename from assets/icons/aws/Analytics/Kinesis.png rename to cloudexit/assets/icons/aws/Analytics/Kinesis.png diff --git a/assets/icons/aws/Analytics/Lake-Formation.png b/cloudexit/assets/icons/aws/Analytics/Lake-Formation.png similarity index 100% rename from assets/icons/aws/Analytics/Lake-Formation.png rename to cloudexit/assets/icons/aws/Analytics/Lake-Formation.png diff --git a/assets/icons/aws/Analytics/Managed-Service-for-Apache-Flink.png b/cloudexit/assets/icons/aws/Analytics/Managed-Service-for-Apache-Flink.png similarity index 100% rename from assets/icons/aws/Analytics/Managed-Service-for-Apache-Flink.png rename to cloudexit/assets/icons/aws/Analytics/Managed-Service-for-Apache-Flink.png diff --git a/assets/icons/aws/Analytics/Managed-Streaming-for-Apache-Kafka.png b/cloudexit/assets/icons/aws/Analytics/Managed-Streaming-for-Apache-Kafka.png similarity index 100% rename from assets/icons/aws/Analytics/Managed-Streaming-for-Apache-Kafka.png rename to cloudexit/assets/icons/aws/Analytics/Managed-Streaming-for-Apache-Kafka.png diff --git a/assets/icons/aws/Analytics/OpenSearch-Service.png b/cloudexit/assets/icons/aws/Analytics/OpenSearch-Service.png similarity index 100% rename from assets/icons/aws/Analytics/OpenSearch-Service.png rename to cloudexit/assets/icons/aws/Analytics/OpenSearch-Service.png diff --git a/assets/icons/aws/Analytics/QuickSight.png b/cloudexit/assets/icons/aws/Analytics/QuickSight.png similarity index 100% rename from assets/icons/aws/Analytics/QuickSight.png rename to cloudexit/assets/icons/aws/Analytics/QuickSight.png diff --git a/assets/icons/aws/Analytics/Redshift.png b/cloudexit/assets/icons/aws/Analytics/Redshift.png similarity index 100% rename from assets/icons/aws/Analytics/Redshift.png rename to cloudexit/assets/icons/aws/Analytics/Redshift.png diff --git a/assets/icons/aws/App-Integration/API-Gateway.png b/cloudexit/assets/icons/aws/App-Integration/API-Gateway.png similarity index 100% rename from assets/icons/aws/App-Integration/API-Gateway.png rename to cloudexit/assets/icons/aws/App-Integration/API-Gateway.png diff --git a/assets/icons/aws/App-Integration/AppFlow.png b/cloudexit/assets/icons/aws/App-Integration/AppFlow.png similarity index 100% rename from assets/icons/aws/App-Integration/AppFlow.png rename to cloudexit/assets/icons/aws/App-Integration/AppFlow.png diff --git a/assets/icons/aws/App-Integration/AppSync.png b/cloudexit/assets/icons/aws/App-Integration/AppSync.png similarity index 100% rename from assets/icons/aws/App-Integration/AppSync.png rename to cloudexit/assets/icons/aws/App-Integration/AppSync.png diff --git a/assets/icons/aws/App-Integration/B2B-Data-Interchange.png b/cloudexit/assets/icons/aws/App-Integration/B2B-Data-Interchange.png similarity index 100% rename from assets/icons/aws/App-Integration/B2B-Data-Interchange.png rename to cloudexit/assets/icons/aws/App-Integration/B2B-Data-Interchange.png diff --git a/assets/icons/aws/App-Integration/Console-Mobile-Application.png b/cloudexit/assets/icons/aws/App-Integration/Console-Mobile-Application.png similarity index 100% rename from assets/icons/aws/App-Integration/Console-Mobile-Application.png rename to cloudexit/assets/icons/aws/App-Integration/Console-Mobile-Application.png diff --git a/assets/icons/aws/App-Integration/EventBridge.png b/cloudexit/assets/icons/aws/App-Integration/EventBridge.png similarity index 100% rename from assets/icons/aws/App-Integration/EventBridge.png rename to cloudexit/assets/icons/aws/App-Integration/EventBridge.png diff --git a/assets/icons/aws/App-Integration/Express-Workflows.png b/cloudexit/assets/icons/aws/App-Integration/Express-Workflows.png similarity index 100% rename from assets/icons/aws/App-Integration/Express-Workflows.png rename to cloudexit/assets/icons/aws/App-Integration/Express-Workflows.png diff --git a/assets/icons/aws/App-Integration/MQ.png b/cloudexit/assets/icons/aws/App-Integration/MQ.png similarity index 100% rename from assets/icons/aws/App-Integration/MQ.png rename to cloudexit/assets/icons/aws/App-Integration/MQ.png diff --git a/assets/icons/aws/App-Integration/Managed-Workflows-for-Apache-Airflow.png b/cloudexit/assets/icons/aws/App-Integration/Managed-Workflows-for-Apache-Airflow.png similarity index 100% rename from assets/icons/aws/App-Integration/Managed-Workflows-for-Apache-Airflow.png rename to cloudexit/assets/icons/aws/App-Integration/Managed-Workflows-for-Apache-Airflow.png diff --git a/assets/icons/aws/App-Integration/Simple-Notification-Service.png b/cloudexit/assets/icons/aws/App-Integration/Simple-Notification-Service.png similarity index 100% rename from assets/icons/aws/App-Integration/Simple-Notification-Service.png rename to cloudexit/assets/icons/aws/App-Integration/Simple-Notification-Service.png diff --git a/assets/icons/aws/App-Integration/Simple-Queue-Service.png b/cloudexit/assets/icons/aws/App-Integration/Simple-Queue-Service.png similarity index 100% rename from assets/icons/aws/App-Integration/Simple-Queue-Service.png rename to cloudexit/assets/icons/aws/App-Integration/Simple-Queue-Service.png diff --git a/assets/icons/aws/App-Integration/Step-Functions.png b/cloudexit/assets/icons/aws/App-Integration/Step-Functions.png similarity index 100% rename from assets/icons/aws/App-Integration/Step-Functions.png rename to cloudexit/assets/icons/aws/App-Integration/Step-Functions.png diff --git a/assets/icons/aws/Blockchain/Managed-Blockchain.png b/cloudexit/assets/icons/aws/Blockchain/Managed-Blockchain.png similarity index 100% rename from assets/icons/aws/Blockchain/Managed-Blockchain.png rename to cloudexit/assets/icons/aws/Blockchain/Managed-Blockchain.png diff --git a/assets/icons/aws/Blockchain/Quantum-Ledger-Database.png b/cloudexit/assets/icons/aws/Blockchain/Quantum-Ledger-Database.png similarity index 100% rename from assets/icons/aws/Blockchain/Quantum-Ledger-Database.png rename to cloudexit/assets/icons/aws/Blockchain/Quantum-Ledger-Database.png diff --git a/assets/icons/aws/Business-Applications/Alexa-For-Business.png b/cloudexit/assets/icons/aws/Business-Applications/Alexa-For-Business.png similarity index 100% rename from assets/icons/aws/Business-Applications/Alexa-For-Business.png rename to cloudexit/assets/icons/aws/Business-Applications/Alexa-For-Business.png diff --git a/assets/icons/aws/Business-Applications/AppFabric.png b/cloudexit/assets/icons/aws/Business-Applications/AppFabric.png similarity index 100% rename from assets/icons/aws/Business-Applications/AppFabric.png rename to cloudexit/assets/icons/aws/Business-Applications/AppFabric.png diff --git a/assets/icons/aws/Business-Applications/Chime-SDK.png b/cloudexit/assets/icons/aws/Business-Applications/Chime-SDK.png similarity index 100% rename from assets/icons/aws/Business-Applications/Chime-SDK.png rename to cloudexit/assets/icons/aws/Business-Applications/Chime-SDK.png diff --git a/assets/icons/aws/Business-Applications/Chime-Voice-Connector.png b/cloudexit/assets/icons/aws/Business-Applications/Chime-Voice-Connector.png similarity index 100% rename from assets/icons/aws/Business-Applications/Chime-Voice-Connector.png rename to cloudexit/assets/icons/aws/Business-Applications/Chime-Voice-Connector.png diff --git a/assets/icons/aws/Business-Applications/Chime.png b/cloudexit/assets/icons/aws/Business-Applications/Chime.png similarity index 100% rename from assets/icons/aws/Business-Applications/Chime.png rename to cloudexit/assets/icons/aws/Business-Applications/Chime.png diff --git a/assets/icons/aws/Business-Applications/Connect.png b/cloudexit/assets/icons/aws/Business-Applications/Connect.png similarity index 100% rename from assets/icons/aws/Business-Applications/Connect.png rename to cloudexit/assets/icons/aws/Business-Applications/Connect.png diff --git a/assets/icons/aws/Business-Applications/End-User-Messaging.png b/cloudexit/assets/icons/aws/Business-Applications/End-User-Messaging.png similarity index 100% rename from assets/icons/aws/Business-Applications/End-User-Messaging.png rename to cloudexit/assets/icons/aws/Business-Applications/End-User-Messaging.png diff --git a/assets/icons/aws/Business-Applications/Honeycode.png b/cloudexit/assets/icons/aws/Business-Applications/Honeycode.png similarity index 100% rename from assets/icons/aws/Business-Applications/Honeycode.png rename to cloudexit/assets/icons/aws/Business-Applications/Honeycode.png diff --git a/assets/icons/aws/Business-Applications/Pinpoint-APIs.png b/cloudexit/assets/icons/aws/Business-Applications/Pinpoint-APIs.png similarity index 100% rename from assets/icons/aws/Business-Applications/Pinpoint-APIs.png rename to cloudexit/assets/icons/aws/Business-Applications/Pinpoint-APIs.png diff --git a/assets/icons/aws/Business-Applications/Pinpoint.png b/cloudexit/assets/icons/aws/Business-Applications/Pinpoint.png similarity index 100% rename from assets/icons/aws/Business-Applications/Pinpoint.png rename to cloudexit/assets/icons/aws/Business-Applications/Pinpoint.png diff --git a/assets/icons/aws/Business-Applications/Quick.png b/cloudexit/assets/icons/aws/Business-Applications/Quick.png similarity index 100% rename from assets/icons/aws/Business-Applications/Quick.png rename to cloudexit/assets/icons/aws/Business-Applications/Quick.png diff --git a/assets/icons/aws/Business-Applications/Simple-Email-Service.png b/cloudexit/assets/icons/aws/Business-Applications/Simple-Email-Service.png similarity index 100% rename from assets/icons/aws/Business-Applications/Simple-Email-Service.png rename to cloudexit/assets/icons/aws/Business-Applications/Simple-Email-Service.png diff --git a/assets/icons/aws/Business-Applications/Supply-Chain.png b/cloudexit/assets/icons/aws/Business-Applications/Supply-Chain.png similarity index 100% rename from assets/icons/aws/Business-Applications/Supply-Chain.png rename to cloudexit/assets/icons/aws/Business-Applications/Supply-Chain.png diff --git a/assets/icons/aws/Business-Applications/Wickr.png b/cloudexit/assets/icons/aws/Business-Applications/Wickr.png similarity index 100% rename from assets/icons/aws/Business-Applications/Wickr.png rename to cloudexit/assets/icons/aws/Business-Applications/Wickr.png diff --git a/assets/icons/aws/Business-Applications/WorkDocs-SDK.png b/cloudexit/assets/icons/aws/Business-Applications/WorkDocs-SDK.png similarity index 100% rename from assets/icons/aws/Business-Applications/WorkDocs-SDK.png rename to cloudexit/assets/icons/aws/Business-Applications/WorkDocs-SDK.png diff --git a/assets/icons/aws/Business-Applications/WorkDocs.png b/cloudexit/assets/icons/aws/Business-Applications/WorkDocs.png similarity index 100% rename from assets/icons/aws/Business-Applications/WorkDocs.png rename to cloudexit/assets/icons/aws/Business-Applications/WorkDocs.png diff --git a/assets/icons/aws/Business-Applications/WorkMail.png b/cloudexit/assets/icons/aws/Business-Applications/WorkMail.png similarity index 100% rename from assets/icons/aws/Business-Applications/WorkMail.png rename to cloudexit/assets/icons/aws/Business-Applications/WorkMail.png diff --git a/assets/icons/aws/Cloud-Financial-Management/Application-Cost-Profiler.png b/cloudexit/assets/icons/aws/Cloud-Financial-Management/Application-Cost-Profiler.png similarity index 100% rename from assets/icons/aws/Cloud-Financial-Management/Application-Cost-Profiler.png rename to cloudexit/assets/icons/aws/Cloud-Financial-Management/Application-Cost-Profiler.png diff --git a/assets/icons/aws/Cloud-Financial-Management/Billing-Conductor.png b/cloudexit/assets/icons/aws/Cloud-Financial-Management/Billing-Conductor.png similarity index 100% rename from assets/icons/aws/Cloud-Financial-Management/Billing-Conductor.png rename to cloudexit/assets/icons/aws/Cloud-Financial-Management/Billing-Conductor.png diff --git a/assets/icons/aws/Cloud-Financial-Management/Budgets.png b/cloudexit/assets/icons/aws/Cloud-Financial-Management/Budgets.png similarity index 100% rename from assets/icons/aws/Cloud-Financial-Management/Budgets.png rename to cloudexit/assets/icons/aws/Cloud-Financial-Management/Budgets.png diff --git a/assets/icons/aws/Cloud-Financial-Management/Cost-Explorer.png b/cloudexit/assets/icons/aws/Cloud-Financial-Management/Cost-Explorer.png similarity index 100% rename from assets/icons/aws/Cloud-Financial-Management/Cost-Explorer.png rename to cloudexit/assets/icons/aws/Cloud-Financial-Management/Cost-Explorer.png diff --git a/assets/icons/aws/Cloud-Financial-Management/Cost-and-Usage-Report.png b/cloudexit/assets/icons/aws/Cloud-Financial-Management/Cost-and-Usage-Report.png similarity index 100% rename from assets/icons/aws/Cloud-Financial-Management/Cost-and-Usage-Report.png rename to cloudexit/assets/icons/aws/Cloud-Financial-Management/Cost-and-Usage-Report.png diff --git a/assets/icons/aws/Cloud-Financial-Management/Reserved-Instance-Reporting.png b/cloudexit/assets/icons/aws/Cloud-Financial-Management/Reserved-Instance-Reporting.png similarity index 100% rename from assets/icons/aws/Cloud-Financial-Management/Reserved-Instance-Reporting.png rename to cloudexit/assets/icons/aws/Cloud-Financial-Management/Reserved-Instance-Reporting.png diff --git a/assets/icons/aws/Cloud-Financial-Management/Savings-Plans.png b/cloudexit/assets/icons/aws/Cloud-Financial-Management/Savings-Plans.png similarity index 100% rename from assets/icons/aws/Cloud-Financial-Management/Savings-Plans.png rename to cloudexit/assets/icons/aws/Cloud-Financial-Management/Savings-Plans.png diff --git a/assets/icons/aws/Compute/App-Runner.png b/cloudexit/assets/icons/aws/Compute/App-Runner.png similarity index 100% rename from assets/icons/aws/Compute/App-Runner.png rename to cloudexit/assets/icons/aws/Compute/App-Runner.png diff --git a/assets/icons/aws/Compute/Application-Auto-Scaling.png b/cloudexit/assets/icons/aws/Compute/Application-Auto-Scaling.png similarity index 100% rename from assets/icons/aws/Compute/Application-Auto-Scaling.png rename to cloudexit/assets/icons/aws/Compute/Application-Auto-Scaling.png diff --git a/assets/icons/aws/Compute/Batch.png b/cloudexit/assets/icons/aws/Compute/Batch.png similarity index 100% rename from assets/icons/aws/Compute/Batch.png rename to cloudexit/assets/icons/aws/Compute/Batch.png diff --git a/assets/icons/aws/Compute/Bottlerocket.png b/cloudexit/assets/icons/aws/Compute/Bottlerocket.png similarity index 100% rename from assets/icons/aws/Compute/Bottlerocket.png rename to cloudexit/assets/icons/aws/Compute/Bottlerocket.png diff --git a/assets/icons/aws/Compute/Compute-Optimizer.png b/cloudexit/assets/icons/aws/Compute/Compute-Optimizer.png similarity index 100% rename from assets/icons/aws/Compute/Compute-Optimizer.png rename to cloudexit/assets/icons/aws/Compute/Compute-Optimizer.png diff --git a/assets/icons/aws/Compute/DCV.png b/cloudexit/assets/icons/aws/Compute/DCV.png similarity index 100% rename from assets/icons/aws/Compute/DCV.png rename to cloudexit/assets/icons/aws/Compute/DCV.png diff --git a/assets/icons/aws/Compute/EC2-Auto-Scaling.png b/cloudexit/assets/icons/aws/Compute/EC2-Auto-Scaling.png similarity index 100% rename from assets/icons/aws/Compute/EC2-Auto-Scaling.png rename to cloudexit/assets/icons/aws/Compute/EC2-Auto-Scaling.png diff --git a/assets/icons/aws/Compute/EC2-Image-Builder.png b/cloudexit/assets/icons/aws/Compute/EC2-Image-Builder.png similarity index 100% rename from assets/icons/aws/Compute/EC2-Image-Builder.png rename to cloudexit/assets/icons/aws/Compute/EC2-Image-Builder.png diff --git a/assets/icons/aws/Compute/EC2.png b/cloudexit/assets/icons/aws/Compute/EC2.png similarity index 100% rename from assets/icons/aws/Compute/EC2.png rename to cloudexit/assets/icons/aws/Compute/EC2.png diff --git a/assets/icons/aws/Compute/Elastic-Beanstalk.png b/cloudexit/assets/icons/aws/Compute/Elastic-Beanstalk.png similarity index 100% rename from assets/icons/aws/Compute/Elastic-Beanstalk.png rename to cloudexit/assets/icons/aws/Compute/Elastic-Beanstalk.png diff --git a/assets/icons/aws/Compute/Elastic-Fabric-Adapter.png b/cloudexit/assets/icons/aws/Compute/Elastic-Fabric-Adapter.png similarity index 100% rename from assets/icons/aws/Compute/Elastic-Fabric-Adapter.png rename to cloudexit/assets/icons/aws/Compute/Elastic-Fabric-Adapter.png diff --git a/assets/icons/aws/Compute/Elastic-VMware-Service.png b/cloudexit/assets/icons/aws/Compute/Elastic-VMware-Service.png similarity index 100% rename from assets/icons/aws/Compute/Elastic-VMware-Service.png rename to cloudexit/assets/icons/aws/Compute/Elastic-VMware-Service.png diff --git a/assets/icons/aws/Compute/Fargate.png b/cloudexit/assets/icons/aws/Compute/Fargate.png similarity index 100% rename from assets/icons/aws/Compute/Fargate.png rename to cloudexit/assets/icons/aws/Compute/Fargate.png diff --git a/assets/icons/aws/Compute/Genomics-CLI.png b/cloudexit/assets/icons/aws/Compute/Genomics-CLI.png similarity index 100% rename from assets/icons/aws/Compute/Genomics-CLI.png rename to cloudexit/assets/icons/aws/Compute/Genomics-CLI.png diff --git a/assets/icons/aws/Compute/Lambda.png b/cloudexit/assets/icons/aws/Compute/Lambda.png similarity index 100% rename from assets/icons/aws/Compute/Lambda.png rename to cloudexit/assets/icons/aws/Compute/Lambda.png diff --git a/assets/icons/aws/Compute/Lightsail-for-Research.png b/cloudexit/assets/icons/aws/Compute/Lightsail-for-Research.png similarity index 100% rename from assets/icons/aws/Compute/Lightsail-for-Research.png rename to cloudexit/assets/icons/aws/Compute/Lightsail-for-Research.png diff --git a/assets/icons/aws/Compute/Lightsail.png b/cloudexit/assets/icons/aws/Compute/Lightsail.png similarity index 100% rename from assets/icons/aws/Compute/Lightsail.png rename to cloudexit/assets/icons/aws/Compute/Lightsail.png diff --git a/assets/icons/aws/Compute/Local-Zones.png b/cloudexit/assets/icons/aws/Compute/Local-Zones.png similarity index 100% rename from assets/icons/aws/Compute/Local-Zones.png rename to cloudexit/assets/icons/aws/Compute/Local-Zones.png diff --git a/assets/icons/aws/Compute/NICE-DCV.png b/cloudexit/assets/icons/aws/Compute/NICE-DCV.png similarity index 100% rename from assets/icons/aws/Compute/NICE-DCV.png rename to cloudexit/assets/icons/aws/Compute/NICE-DCV.png diff --git a/assets/icons/aws/Compute/NICE-EnginFrame.png b/cloudexit/assets/icons/aws/Compute/NICE-EnginFrame.png similarity index 100% rename from assets/icons/aws/Compute/NICE-EnginFrame.png rename to cloudexit/assets/icons/aws/Compute/NICE-EnginFrame.png diff --git a/assets/icons/aws/Compute/Nitro-Enclaves.png b/cloudexit/assets/icons/aws/Compute/Nitro-Enclaves.png similarity index 100% rename from assets/icons/aws/Compute/Nitro-Enclaves.png rename to cloudexit/assets/icons/aws/Compute/Nitro-Enclaves.png diff --git a/assets/icons/aws/Compute/Outposts-family.png b/cloudexit/assets/icons/aws/Compute/Outposts-family.png similarity index 100% rename from assets/icons/aws/Compute/Outposts-family.png rename to cloudexit/assets/icons/aws/Compute/Outposts-family.png diff --git a/assets/icons/aws/Compute/Outposts-rack.png b/cloudexit/assets/icons/aws/Compute/Outposts-rack.png similarity index 100% rename from assets/icons/aws/Compute/Outposts-rack.png rename to cloudexit/assets/icons/aws/Compute/Outposts-rack.png diff --git a/assets/icons/aws/Compute/Outposts-servers.png b/cloudexit/assets/icons/aws/Compute/Outposts-servers.png similarity index 100% rename from assets/icons/aws/Compute/Outposts-servers.png rename to cloudexit/assets/icons/aws/Compute/Outposts-servers.png diff --git a/assets/icons/aws/Compute/Parallel-Cluster.png b/cloudexit/assets/icons/aws/Compute/Parallel-Cluster.png similarity index 100% rename from assets/icons/aws/Compute/Parallel-Cluster.png rename to cloudexit/assets/icons/aws/Compute/Parallel-Cluster.png diff --git a/assets/icons/aws/Compute/Parallel-Computing-Service.png b/cloudexit/assets/icons/aws/Compute/Parallel-Computing-Service.png similarity index 100% rename from assets/icons/aws/Compute/Parallel-Computing-Service.png rename to cloudexit/assets/icons/aws/Compute/Parallel-Computing-Service.png diff --git a/assets/icons/aws/Compute/ParallelCluster.png b/cloudexit/assets/icons/aws/Compute/ParallelCluster.png similarity index 100% rename from assets/icons/aws/Compute/ParallelCluster.png rename to cloudexit/assets/icons/aws/Compute/ParallelCluster.png diff --git a/assets/icons/aws/Compute/Serverless-Application-Repository.png b/cloudexit/assets/icons/aws/Compute/Serverless-Application-Repository.png similarity index 100% rename from assets/icons/aws/Compute/Serverless-Application-Repository.png rename to cloudexit/assets/icons/aws/Compute/Serverless-Application-Repository.png diff --git a/assets/icons/aws/Compute/SimSpace-Weaver.png b/cloudexit/assets/icons/aws/Compute/SimSpace-Weaver.png similarity index 100% rename from assets/icons/aws/Compute/SimSpace-Weaver.png rename to cloudexit/assets/icons/aws/Compute/SimSpace-Weaver.png diff --git a/assets/icons/aws/Compute/Thinkbox-Deadline.png b/cloudexit/assets/icons/aws/Compute/Thinkbox-Deadline.png similarity index 100% rename from assets/icons/aws/Compute/Thinkbox-Deadline.png rename to cloudexit/assets/icons/aws/Compute/Thinkbox-Deadline.png diff --git a/assets/icons/aws/Compute/Thinkbox-Frost.png b/cloudexit/assets/icons/aws/Compute/Thinkbox-Frost.png similarity index 100% rename from assets/icons/aws/Compute/Thinkbox-Frost.png rename to cloudexit/assets/icons/aws/Compute/Thinkbox-Frost.png diff --git a/assets/icons/aws/Compute/Thinkbox-Krakatoa.png b/cloudexit/assets/icons/aws/Compute/Thinkbox-Krakatoa.png similarity index 100% rename from assets/icons/aws/Compute/Thinkbox-Krakatoa.png rename to cloudexit/assets/icons/aws/Compute/Thinkbox-Krakatoa.png diff --git a/assets/icons/aws/Compute/Thinkbox-Sequoia.png b/cloudexit/assets/icons/aws/Compute/Thinkbox-Sequoia.png similarity index 100% rename from assets/icons/aws/Compute/Thinkbox-Sequoia.png rename to cloudexit/assets/icons/aws/Compute/Thinkbox-Sequoia.png diff --git a/assets/icons/aws/Compute/Thinkbox-Stoke.png b/cloudexit/assets/icons/aws/Compute/Thinkbox-Stoke.png similarity index 100% rename from assets/icons/aws/Compute/Thinkbox-Stoke.png rename to cloudexit/assets/icons/aws/Compute/Thinkbox-Stoke.png diff --git a/assets/icons/aws/Compute/Thinkbox-XMesh.png b/cloudexit/assets/icons/aws/Compute/Thinkbox-XMesh.png similarity index 100% rename from assets/icons/aws/Compute/Thinkbox-XMesh.png rename to cloudexit/assets/icons/aws/Compute/Thinkbox-XMesh.png diff --git a/assets/icons/aws/Compute/VMware-Cloud-on-AWS.png b/cloudexit/assets/icons/aws/Compute/VMware-Cloud-on-AWS.png similarity index 100% rename from assets/icons/aws/Compute/VMware-Cloud-on-AWS.png rename to cloudexit/assets/icons/aws/Compute/VMware-Cloud-on-AWS.png diff --git a/assets/icons/aws/Compute/Wavelength.png b/cloudexit/assets/icons/aws/Compute/Wavelength.png similarity index 100% rename from assets/icons/aws/Compute/Wavelength.png rename to cloudexit/assets/icons/aws/Compute/Wavelength.png diff --git a/assets/icons/aws/Containers/ECS-Anywhere.png b/cloudexit/assets/icons/aws/Containers/ECS-Anywhere.png similarity index 100% rename from assets/icons/aws/Containers/ECS-Anywhere.png rename to cloudexit/assets/icons/aws/Containers/ECS-Anywhere.png diff --git a/assets/icons/aws/Containers/EKS-Anywhere.png b/cloudexit/assets/icons/aws/Containers/EKS-Anywhere.png similarity index 100% rename from assets/icons/aws/Containers/EKS-Anywhere.png rename to cloudexit/assets/icons/aws/Containers/EKS-Anywhere.png diff --git a/assets/icons/aws/Containers/EKS-Cloud.png b/cloudexit/assets/icons/aws/Containers/EKS-Cloud.png similarity index 100% rename from assets/icons/aws/Containers/EKS-Cloud.png rename to cloudexit/assets/icons/aws/Containers/EKS-Cloud.png diff --git a/assets/icons/aws/Containers/EKS-Distro.png b/cloudexit/assets/icons/aws/Containers/EKS-Distro.png similarity index 100% rename from assets/icons/aws/Containers/EKS-Distro.png rename to cloudexit/assets/icons/aws/Containers/EKS-Distro.png diff --git a/assets/icons/aws/Containers/Elastic-Container-Registry.png b/cloudexit/assets/icons/aws/Containers/Elastic-Container-Registry.png similarity index 100% rename from assets/icons/aws/Containers/Elastic-Container-Registry.png rename to cloudexit/assets/icons/aws/Containers/Elastic-Container-Registry.png diff --git a/assets/icons/aws/Containers/Elastic-Container-Service.png b/cloudexit/assets/icons/aws/Containers/Elastic-Container-Service.png similarity index 100% rename from assets/icons/aws/Containers/Elastic-Container-Service.png rename to cloudexit/assets/icons/aws/Containers/Elastic-Container-Service.png diff --git a/assets/icons/aws/Containers/Elastic-Kubernetes-Service.png b/cloudexit/assets/icons/aws/Containers/Elastic-Kubernetes-Service.png similarity index 100% rename from assets/icons/aws/Containers/Elastic-Kubernetes-Service.png rename to cloudexit/assets/icons/aws/Containers/Elastic-Kubernetes-Service.png diff --git a/assets/icons/aws/Containers/Fargate.png b/cloudexit/assets/icons/aws/Containers/Fargate.png similarity index 100% rename from assets/icons/aws/Containers/Fargate.png rename to cloudexit/assets/icons/aws/Containers/Fargate.png diff --git a/assets/icons/aws/Containers/Red-Hat-OpenShift-Service-on-AWS.png b/cloudexit/assets/icons/aws/Containers/Red-Hat-OpenShift-Service-on-AWS.png similarity index 100% rename from assets/icons/aws/Containers/Red-Hat-OpenShift-Service-on-AWS.png rename to cloudexit/assets/icons/aws/Containers/Red-Hat-OpenShift-Service-on-AWS.png diff --git a/assets/icons/aws/Customer-Enablement/Activate.png b/cloudexit/assets/icons/aws/Customer-Enablement/Activate.png similarity index 100% rename from assets/icons/aws/Customer-Enablement/Activate.png rename to cloudexit/assets/icons/aws/Customer-Enablement/Activate.png diff --git a/assets/icons/aws/Customer-Enablement/IQ.png b/cloudexit/assets/icons/aws/Customer-Enablement/IQ.png similarity index 100% rename from assets/icons/aws/Customer-Enablement/IQ.png rename to cloudexit/assets/icons/aws/Customer-Enablement/IQ.png diff --git a/assets/icons/aws/Customer-Enablement/Managed-Services.png b/cloudexit/assets/icons/aws/Customer-Enablement/Managed-Services.png similarity index 100% rename from assets/icons/aws/Customer-Enablement/Managed-Services.png rename to cloudexit/assets/icons/aws/Customer-Enablement/Managed-Services.png diff --git a/assets/icons/aws/Customer-Enablement/Professional-Services.png b/cloudexit/assets/icons/aws/Customer-Enablement/Professional-Services.png similarity index 100% rename from assets/icons/aws/Customer-Enablement/Professional-Services.png rename to cloudexit/assets/icons/aws/Customer-Enablement/Professional-Services.png diff --git a/assets/icons/aws/Customer-Enablement/Support.png b/cloudexit/assets/icons/aws/Customer-Enablement/Support.png similarity index 100% rename from assets/icons/aws/Customer-Enablement/Support.png rename to cloudexit/assets/icons/aws/Customer-Enablement/Support.png diff --git a/assets/icons/aws/Customer-Enablement/Training-Certification.png b/cloudexit/assets/icons/aws/Customer-Enablement/Training-Certification.png similarity index 100% rename from assets/icons/aws/Customer-Enablement/Training-Certification.png rename to cloudexit/assets/icons/aws/Customer-Enablement/Training-Certification.png diff --git a/assets/icons/aws/Customer-Enablement/rePost-Private.png b/cloudexit/assets/icons/aws/Customer-Enablement/rePost-Private.png similarity index 100% rename from assets/icons/aws/Customer-Enablement/rePost-Private.png rename to cloudexit/assets/icons/aws/Customer-Enablement/rePost-Private.png diff --git a/assets/icons/aws/Customer-Enablement/rePost.png b/cloudexit/assets/icons/aws/Customer-Enablement/rePost.png similarity index 100% rename from assets/icons/aws/Customer-Enablement/rePost.png rename to cloudexit/assets/icons/aws/Customer-Enablement/rePost.png diff --git a/assets/icons/aws/Database/Aurora.png b/cloudexit/assets/icons/aws/Database/Aurora.png similarity index 100% rename from assets/icons/aws/Database/Aurora.png rename to cloudexit/assets/icons/aws/Database/Aurora.png diff --git a/assets/icons/aws/Database/Database-Migration-Service.png b/cloudexit/assets/icons/aws/Database/Database-Migration-Service.png similarity index 100% rename from assets/icons/aws/Database/Database-Migration-Service.png rename to cloudexit/assets/icons/aws/Database/Database-Migration-Service.png diff --git a/assets/icons/aws/Database/DocumentDB.png b/cloudexit/assets/icons/aws/Database/DocumentDB.png similarity index 100% rename from assets/icons/aws/Database/DocumentDB.png rename to cloudexit/assets/icons/aws/Database/DocumentDB.png diff --git a/assets/icons/aws/Database/DynamoDB.png b/cloudexit/assets/icons/aws/Database/DynamoDB.png similarity index 100% rename from assets/icons/aws/Database/DynamoDB.png rename to cloudexit/assets/icons/aws/Database/DynamoDB.png diff --git a/assets/icons/aws/Database/ElastiCache.png b/cloudexit/assets/icons/aws/Database/ElastiCache.png similarity index 100% rename from assets/icons/aws/Database/ElastiCache.png rename to cloudexit/assets/icons/aws/Database/ElastiCache.png diff --git a/assets/icons/aws/Database/Keyspaces.png b/cloudexit/assets/icons/aws/Database/Keyspaces.png similarity index 100% rename from assets/icons/aws/Database/Keyspaces.png rename to cloudexit/assets/icons/aws/Database/Keyspaces.png diff --git a/assets/icons/aws/Database/MemoryDB-for-Redis.png b/cloudexit/assets/icons/aws/Database/MemoryDB-for-Redis.png similarity index 100% rename from assets/icons/aws/Database/MemoryDB-for-Redis.png rename to cloudexit/assets/icons/aws/Database/MemoryDB-for-Redis.png diff --git a/assets/icons/aws/Database/MemoryDB.png b/cloudexit/assets/icons/aws/Database/MemoryDB.png similarity index 100% rename from assets/icons/aws/Database/MemoryDB.png rename to cloudexit/assets/icons/aws/Database/MemoryDB.png diff --git a/assets/icons/aws/Database/Neptune.png b/cloudexit/assets/icons/aws/Database/Neptune.png similarity index 100% rename from assets/icons/aws/Database/Neptune.png rename to cloudexit/assets/icons/aws/Database/Neptune.png diff --git a/assets/icons/aws/Database/Oracle-Database-at-AWS.png b/cloudexit/assets/icons/aws/Database/Oracle-Database-at-AWS.png similarity index 100% rename from assets/icons/aws/Database/Oracle-Database-at-AWS.png rename to cloudexit/assets/icons/aws/Database/Oracle-Database-at-AWS.png diff --git a/assets/icons/aws/Database/RDS-on-VMware.png b/cloudexit/assets/icons/aws/Database/RDS-on-VMware.png similarity index 100% rename from assets/icons/aws/Database/RDS-on-VMware.png rename to cloudexit/assets/icons/aws/Database/RDS-on-VMware.png diff --git a/assets/icons/aws/Database/RDS.png b/cloudexit/assets/icons/aws/Database/RDS.png similarity index 100% rename from assets/icons/aws/Database/RDS.png rename to cloudexit/assets/icons/aws/Database/RDS.png diff --git a/assets/icons/aws/Database/Timestream.png b/cloudexit/assets/icons/aws/Database/Timestream.png similarity index 100% rename from assets/icons/aws/Database/Timestream.png rename to cloudexit/assets/icons/aws/Database/Timestream.png diff --git a/assets/icons/aws/Developer-Tools/Application-Composer.png b/cloudexit/assets/icons/aws/Developer-Tools/Application-Composer.png similarity index 100% rename from assets/icons/aws/Developer-Tools/Application-Composer.png rename to cloudexit/assets/icons/aws/Developer-Tools/Application-Composer.png diff --git a/assets/icons/aws/Developer-Tools/Cloud-Control-API.png b/cloudexit/assets/icons/aws/Developer-Tools/Cloud-Control-API.png similarity index 100% rename from assets/icons/aws/Developer-Tools/Cloud-Control-API.png rename to cloudexit/assets/icons/aws/Developer-Tools/Cloud-Control-API.png diff --git a/assets/icons/aws/Developer-Tools/Cloud-Development-Kit.png b/cloudexit/assets/icons/aws/Developer-Tools/Cloud-Development-Kit.png similarity index 100% rename from assets/icons/aws/Developer-Tools/Cloud-Development-Kit.png rename to cloudexit/assets/icons/aws/Developer-Tools/Cloud-Development-Kit.png diff --git a/assets/icons/aws/Developer-Tools/Cloud9.png b/cloudexit/assets/icons/aws/Developer-Tools/Cloud9.png similarity index 100% rename from assets/icons/aws/Developer-Tools/Cloud9.png rename to cloudexit/assets/icons/aws/Developer-Tools/Cloud9.png diff --git a/assets/icons/aws/Developer-Tools/CloudShell.png b/cloudexit/assets/icons/aws/Developer-Tools/CloudShell.png similarity index 100% rename from assets/icons/aws/Developer-Tools/CloudShell.png rename to cloudexit/assets/icons/aws/Developer-Tools/CloudShell.png diff --git a/assets/icons/aws/Developer-Tools/CodeArtifact.png b/cloudexit/assets/icons/aws/Developer-Tools/CodeArtifact.png similarity index 100% rename from assets/icons/aws/Developer-Tools/CodeArtifact.png rename to cloudexit/assets/icons/aws/Developer-Tools/CodeArtifact.png diff --git a/assets/icons/aws/Developer-Tools/CodeBuild.png b/cloudexit/assets/icons/aws/Developer-Tools/CodeBuild.png similarity index 100% rename from assets/icons/aws/Developer-Tools/CodeBuild.png rename to cloudexit/assets/icons/aws/Developer-Tools/CodeBuild.png diff --git a/assets/icons/aws/Developer-Tools/CodeCatalyst.png b/cloudexit/assets/icons/aws/Developer-Tools/CodeCatalyst.png similarity index 100% rename from assets/icons/aws/Developer-Tools/CodeCatalyst.png rename to cloudexit/assets/icons/aws/Developer-Tools/CodeCatalyst.png diff --git a/assets/icons/aws/Developer-Tools/CodeCommit.png b/cloudexit/assets/icons/aws/Developer-Tools/CodeCommit.png similarity index 100% rename from assets/icons/aws/Developer-Tools/CodeCommit.png rename to cloudexit/assets/icons/aws/Developer-Tools/CodeCommit.png diff --git a/assets/icons/aws/Developer-Tools/CodeDeploy.png b/cloudexit/assets/icons/aws/Developer-Tools/CodeDeploy.png similarity index 100% rename from assets/icons/aws/Developer-Tools/CodeDeploy.png rename to cloudexit/assets/icons/aws/Developer-Tools/CodeDeploy.png diff --git a/assets/icons/aws/Developer-Tools/CodePipeline.png b/cloudexit/assets/icons/aws/Developer-Tools/CodePipeline.png similarity index 100% rename from assets/icons/aws/Developer-Tools/CodePipeline.png rename to cloudexit/assets/icons/aws/Developer-Tools/CodePipeline.png diff --git a/assets/icons/aws/Developer-Tools/CodeStar.png b/cloudexit/assets/icons/aws/Developer-Tools/CodeStar.png similarity index 100% rename from assets/icons/aws/Developer-Tools/CodeStar.png rename to cloudexit/assets/icons/aws/Developer-Tools/CodeStar.png diff --git a/assets/icons/aws/Developer-Tools/Command-Line-Interface.png b/cloudexit/assets/icons/aws/Developer-Tools/Command-Line-Interface.png similarity index 100% rename from assets/icons/aws/Developer-Tools/Command-Line-Interface.png rename to cloudexit/assets/icons/aws/Developer-Tools/Command-Line-Interface.png diff --git a/assets/icons/aws/Developer-Tools/Corretto.png b/cloudexit/assets/icons/aws/Developer-Tools/Corretto.png similarity index 100% rename from assets/icons/aws/Developer-Tools/Corretto.png rename to cloudexit/assets/icons/aws/Developer-Tools/Corretto.png diff --git a/assets/icons/aws/Developer-Tools/Fault-Injection-Service.png b/cloudexit/assets/icons/aws/Developer-Tools/Fault-Injection-Service.png similarity index 100% rename from assets/icons/aws/Developer-Tools/Fault-Injection-Service.png rename to cloudexit/assets/icons/aws/Developer-Tools/Fault-Injection-Service.png diff --git a/assets/icons/aws/Developer-Tools/Infrastructure-Composer.png b/cloudexit/assets/icons/aws/Developer-Tools/Infrastructure-Composer.png similarity index 100% rename from assets/icons/aws/Developer-Tools/Infrastructure-Composer.png rename to cloudexit/assets/icons/aws/Developer-Tools/Infrastructure-Composer.png diff --git a/assets/icons/aws/Developer-Tools/Tools-and-SDKs.png b/cloudexit/assets/icons/aws/Developer-Tools/Tools-and-SDKs.png similarity index 100% rename from assets/icons/aws/Developer-Tools/Tools-and-SDKs.png rename to cloudexit/assets/icons/aws/Developer-Tools/Tools-and-SDKs.png diff --git a/assets/icons/aws/Developer-Tools/X-Ray.png b/cloudexit/assets/icons/aws/Developer-Tools/X-Ray.png similarity index 100% rename from assets/icons/aws/Developer-Tools/X-Ray.png rename to cloudexit/assets/icons/aws/Developer-Tools/X-Ray.png diff --git a/assets/icons/aws/End-User-Computing/AppStream.png b/cloudexit/assets/icons/aws/End-User-Computing/AppStream.png similarity index 100% rename from assets/icons/aws/End-User-Computing/AppStream.png rename to cloudexit/assets/icons/aws/End-User-Computing/AppStream.png diff --git a/assets/icons/aws/End-User-Computing/WorkLink.png b/cloudexit/assets/icons/aws/End-User-Computing/WorkLink.png similarity index 100% rename from assets/icons/aws/End-User-Computing/WorkLink.png rename to cloudexit/assets/icons/aws/End-User-Computing/WorkLink.png diff --git a/assets/icons/aws/End-User-Computing/WorkSpaces-Family.png b/cloudexit/assets/icons/aws/End-User-Computing/WorkSpaces-Family.png similarity index 100% rename from assets/icons/aws/End-User-Computing/WorkSpaces-Family.png rename to cloudexit/assets/icons/aws/End-User-Computing/WorkSpaces-Family.png diff --git a/assets/icons/aws/End-User-Computing/WorkSpaces.png b/cloudexit/assets/icons/aws/End-User-Computing/WorkSpaces.png similarity index 100% rename from assets/icons/aws/End-User-Computing/WorkSpaces.png rename to cloudexit/assets/icons/aws/End-User-Computing/WorkSpaces.png diff --git a/assets/icons/aws/Front-End-Web-Mobile/Amplify.png b/cloudexit/assets/icons/aws/Front-End-Web-Mobile/Amplify.png similarity index 100% rename from assets/icons/aws/Front-End-Web-Mobile/Amplify.png rename to cloudexit/assets/icons/aws/Front-End-Web-Mobile/Amplify.png diff --git a/assets/icons/aws/Front-End-Web-Mobile/Device-Farm.png b/cloudexit/assets/icons/aws/Front-End-Web-Mobile/Device-Farm.png similarity index 100% rename from assets/icons/aws/Front-End-Web-Mobile/Device-Farm.png rename to cloudexit/assets/icons/aws/Front-End-Web-Mobile/Device-Farm.png diff --git a/assets/icons/aws/Front-End-Web-Mobile/Location-Service.png b/cloudexit/assets/icons/aws/Front-End-Web-Mobile/Location-Service.png similarity index 100% rename from assets/icons/aws/Front-End-Web-Mobile/Location-Service.png rename to cloudexit/assets/icons/aws/Front-End-Web-Mobile/Location-Service.png diff --git a/assets/icons/aws/Games/GameKit.png b/cloudexit/assets/icons/aws/Games/GameKit.png similarity index 100% rename from assets/icons/aws/Games/GameKit.png rename to cloudexit/assets/icons/aws/Games/GameKit.png diff --git a/assets/icons/aws/Games/GameLift-Servers.png b/cloudexit/assets/icons/aws/Games/GameLift-Servers.png similarity index 100% rename from assets/icons/aws/Games/GameLift-Servers.png rename to cloudexit/assets/icons/aws/Games/GameLift-Servers.png diff --git a/assets/icons/aws/Games/GameLift-Streams.png b/cloudexit/assets/icons/aws/Games/GameLift-Streams.png similarity index 100% rename from assets/icons/aws/Games/GameLift-Streams.png rename to cloudexit/assets/icons/aws/Games/GameLift-Streams.png diff --git a/assets/icons/aws/Games/GameLift.png b/cloudexit/assets/icons/aws/Games/GameLift.png similarity index 100% rename from assets/icons/aws/Games/GameLift.png rename to cloudexit/assets/icons/aws/Games/GameLift.png diff --git a/assets/icons/aws/Games/GameSparks.png b/cloudexit/assets/icons/aws/Games/GameSparks.png similarity index 100% rename from assets/icons/aws/Games/GameSparks.png rename to cloudexit/assets/icons/aws/Games/GameSparks.png diff --git a/assets/icons/aws/Games/Lumberyard.png b/cloudexit/assets/icons/aws/Games/Lumberyard.png similarity index 100% rename from assets/icons/aws/Games/Lumberyard.png rename to cloudexit/assets/icons/aws/Games/Lumberyard.png diff --git a/assets/icons/aws/Games/Open-3D-Engine.png b/cloudexit/assets/icons/aws/Games/Open-3D-Engine.png similarity index 100% rename from assets/icons/aws/Games/Open-3D-Engine.png rename to cloudexit/assets/icons/aws/Games/Open-3D-Engine.png diff --git a/assets/icons/aws/General-Icons/Marketplace_Dark.png b/cloudexit/assets/icons/aws/General-Icons/Marketplace_Dark.png similarity index 100% rename from assets/icons/aws/General-Icons/Marketplace_Dark.png rename to cloudexit/assets/icons/aws/General-Icons/Marketplace_Dark.png diff --git a/assets/icons/aws/General-Icons/Marketplace_Light.png b/cloudexit/assets/icons/aws/General-Icons/Marketplace_Light.png similarity index 100% rename from assets/icons/aws/General-Icons/Marketplace_Light.png rename to cloudexit/assets/icons/aws/General-Icons/Marketplace_Light.png diff --git a/assets/icons/aws/Internet-of-Things/FreeRTOS.png b/cloudexit/assets/icons/aws/Internet-of-Things/FreeRTOS.png similarity index 100% rename from assets/icons/aws/Internet-of-Things/FreeRTOS.png rename to cloudexit/assets/icons/aws/Internet-of-Things/FreeRTOS.png diff --git a/assets/icons/aws/Internet-of-Things/IoT-1-Click.png b/cloudexit/assets/icons/aws/Internet-of-Things/IoT-1-Click.png similarity index 100% rename from assets/icons/aws/Internet-of-Things/IoT-1-Click.png rename to cloudexit/assets/icons/aws/Internet-of-Things/IoT-1-Click.png diff --git a/assets/icons/aws/Internet-of-Things/IoT-Analytics.png b/cloudexit/assets/icons/aws/Internet-of-Things/IoT-Analytics.png similarity index 100% rename from assets/icons/aws/Internet-of-Things/IoT-Analytics.png rename to cloudexit/assets/icons/aws/Internet-of-Things/IoT-Analytics.png diff --git a/assets/icons/aws/Internet-of-Things/IoT-Button.png b/cloudexit/assets/icons/aws/Internet-of-Things/IoT-Button.png similarity index 100% rename from assets/icons/aws/Internet-of-Things/IoT-Button.png rename to cloudexit/assets/icons/aws/Internet-of-Things/IoT-Button.png diff --git a/assets/icons/aws/Internet-of-Things/IoT-Core.png b/cloudexit/assets/icons/aws/Internet-of-Things/IoT-Core.png similarity index 100% rename from assets/icons/aws/Internet-of-Things/IoT-Core.png rename to cloudexit/assets/icons/aws/Internet-of-Things/IoT-Core.png diff --git a/assets/icons/aws/Internet-of-Things/IoT-Device-Defender.png b/cloudexit/assets/icons/aws/Internet-of-Things/IoT-Device-Defender.png similarity index 100% rename from assets/icons/aws/Internet-of-Things/IoT-Device-Defender.png rename to cloudexit/assets/icons/aws/Internet-of-Things/IoT-Device-Defender.png diff --git a/assets/icons/aws/Internet-of-Things/IoT-Device-Management.png b/cloudexit/assets/icons/aws/Internet-of-Things/IoT-Device-Management.png similarity index 100% rename from assets/icons/aws/Internet-of-Things/IoT-Device-Management.png rename to cloudexit/assets/icons/aws/Internet-of-Things/IoT-Device-Management.png diff --git a/assets/icons/aws/Internet-of-Things/IoT-EduKit.png b/cloudexit/assets/icons/aws/Internet-of-Things/IoT-EduKit.png similarity index 100% rename from assets/icons/aws/Internet-of-Things/IoT-EduKit.png rename to cloudexit/assets/icons/aws/Internet-of-Things/IoT-EduKit.png diff --git a/assets/icons/aws/Internet-of-Things/IoT-Events.png b/cloudexit/assets/icons/aws/Internet-of-Things/IoT-Events.png similarity index 100% rename from assets/icons/aws/Internet-of-Things/IoT-Events.png rename to cloudexit/assets/icons/aws/Internet-of-Things/IoT-Events.png diff --git a/assets/icons/aws/Internet-of-Things/IoT-ExpressLink.png b/cloudexit/assets/icons/aws/Internet-of-Things/IoT-ExpressLink.png similarity index 100% rename from assets/icons/aws/Internet-of-Things/IoT-ExpressLink.png rename to cloudexit/assets/icons/aws/Internet-of-Things/IoT-ExpressLink.png diff --git a/assets/icons/aws/Internet-of-Things/IoT-FleetWise.png b/cloudexit/assets/icons/aws/Internet-of-Things/IoT-FleetWise.png similarity index 100% rename from assets/icons/aws/Internet-of-Things/IoT-FleetWise.png rename to cloudexit/assets/icons/aws/Internet-of-Things/IoT-FleetWise.png diff --git a/assets/icons/aws/Internet-of-Things/IoT-Greengrass.png b/cloudexit/assets/icons/aws/Internet-of-Things/IoT-Greengrass.png similarity index 100% rename from assets/icons/aws/Internet-of-Things/IoT-Greengrass.png rename to cloudexit/assets/icons/aws/Internet-of-Things/IoT-Greengrass.png diff --git a/assets/icons/aws/Internet-of-Things/IoT-RoboRunner.png b/cloudexit/assets/icons/aws/Internet-of-Things/IoT-RoboRunner.png similarity index 100% rename from assets/icons/aws/Internet-of-Things/IoT-RoboRunner.png rename to cloudexit/assets/icons/aws/Internet-of-Things/IoT-RoboRunner.png diff --git a/assets/icons/aws/Internet-of-Things/IoT-SiteWise.png b/cloudexit/assets/icons/aws/Internet-of-Things/IoT-SiteWise.png similarity index 100% rename from assets/icons/aws/Internet-of-Things/IoT-SiteWise.png rename to cloudexit/assets/icons/aws/Internet-of-Things/IoT-SiteWise.png diff --git a/assets/icons/aws/Internet-of-Things/IoT-Things-Graph.png b/cloudexit/assets/icons/aws/Internet-of-Things/IoT-Things-Graph.png similarity index 100% rename from assets/icons/aws/Internet-of-Things/IoT-Things-Graph.png rename to cloudexit/assets/icons/aws/Internet-of-Things/IoT-Things-Graph.png diff --git a/assets/icons/aws/Internet-of-Things/IoT-TwinMaker.png b/cloudexit/assets/icons/aws/Internet-of-Things/IoT-TwinMaker.png similarity index 100% rename from assets/icons/aws/Internet-of-Things/IoT-TwinMaker.png rename to cloudexit/assets/icons/aws/Internet-of-Things/IoT-TwinMaker.png diff --git a/assets/icons/aws/Machine-Learning/Apache-MXNet-on-AWS.png b/cloudexit/assets/icons/aws/Machine-Learning/Apache-MXNet-on-AWS.png similarity index 100% rename from assets/icons/aws/Machine-Learning/Apache-MXNet-on-AWS.png rename to cloudexit/assets/icons/aws/Machine-Learning/Apache-MXNet-on-AWS.png diff --git a/assets/icons/aws/Machine-Learning/App-Studio.png b/cloudexit/assets/icons/aws/Machine-Learning/App-Studio.png similarity index 100% rename from assets/icons/aws/Machine-Learning/App-Studio.png rename to cloudexit/assets/icons/aws/Machine-Learning/App-Studio.png diff --git a/assets/icons/aws/Machine-Learning/Augmented-AI-A2I.png b/cloudexit/assets/icons/aws/Machine-Learning/Augmented-AI-A2I.png similarity index 100% rename from assets/icons/aws/Machine-Learning/Augmented-AI-A2I.png rename to cloudexit/assets/icons/aws/Machine-Learning/Augmented-AI-A2I.png diff --git a/assets/icons/aws/Machine-Learning/Bedrock-AgentCore.png b/cloudexit/assets/icons/aws/Machine-Learning/Bedrock-AgentCore.png similarity index 100% rename from assets/icons/aws/Machine-Learning/Bedrock-AgentCore.png rename to cloudexit/assets/icons/aws/Machine-Learning/Bedrock-AgentCore.png diff --git a/assets/icons/aws/Machine-Learning/Bedrock.png b/cloudexit/assets/icons/aws/Machine-Learning/Bedrock.png similarity index 100% rename from assets/icons/aws/Machine-Learning/Bedrock.png rename to cloudexit/assets/icons/aws/Machine-Learning/Bedrock.png diff --git a/assets/icons/aws/Machine-Learning/CodeGuru.png b/cloudexit/assets/icons/aws/Machine-Learning/CodeGuru.png similarity index 100% rename from assets/icons/aws/Machine-Learning/CodeGuru.png rename to cloudexit/assets/icons/aws/Machine-Learning/CodeGuru.png diff --git a/assets/icons/aws/Machine-Learning/CodeWhisperer.png b/cloudexit/assets/icons/aws/Machine-Learning/CodeWhisperer.png similarity index 100% rename from assets/icons/aws/Machine-Learning/CodeWhisperer.png rename to cloudexit/assets/icons/aws/Machine-Learning/CodeWhisperer.png diff --git a/assets/icons/aws/Machine-Learning/Comprehend-Medical.png b/cloudexit/assets/icons/aws/Machine-Learning/Comprehend-Medical.png similarity index 100% rename from assets/icons/aws/Machine-Learning/Comprehend-Medical.png rename to cloudexit/assets/icons/aws/Machine-Learning/Comprehend-Medical.png diff --git a/assets/icons/aws/Machine-Learning/Comprehend.png b/cloudexit/assets/icons/aws/Machine-Learning/Comprehend.png similarity index 100% rename from assets/icons/aws/Machine-Learning/Comprehend.png rename to cloudexit/assets/icons/aws/Machine-Learning/Comprehend.png diff --git a/assets/icons/aws/Machine-Learning/Deep-Learning-AMIs.png b/cloudexit/assets/icons/aws/Machine-Learning/Deep-Learning-AMIs.png similarity index 100% rename from assets/icons/aws/Machine-Learning/Deep-Learning-AMIs.png rename to cloudexit/assets/icons/aws/Machine-Learning/Deep-Learning-AMIs.png diff --git a/assets/icons/aws/Machine-Learning/Deep-Learning-Containers.png b/cloudexit/assets/icons/aws/Machine-Learning/Deep-Learning-Containers.png similarity index 100% rename from assets/icons/aws/Machine-Learning/Deep-Learning-Containers.png rename to cloudexit/assets/icons/aws/Machine-Learning/Deep-Learning-Containers.png diff --git a/assets/icons/aws/Machine-Learning/DeepComposer.png b/cloudexit/assets/icons/aws/Machine-Learning/DeepComposer.png similarity index 100% rename from assets/icons/aws/Machine-Learning/DeepComposer.png rename to cloudexit/assets/icons/aws/Machine-Learning/DeepComposer.png diff --git a/assets/icons/aws/Machine-Learning/DeepLens.png b/cloudexit/assets/icons/aws/Machine-Learning/DeepLens.png similarity index 100% rename from assets/icons/aws/Machine-Learning/DeepLens.png rename to cloudexit/assets/icons/aws/Machine-Learning/DeepLens.png diff --git a/assets/icons/aws/Machine-Learning/DeepRacer.png b/cloudexit/assets/icons/aws/Machine-Learning/DeepRacer.png similarity index 100% rename from assets/icons/aws/Machine-Learning/DeepRacer.png rename to cloudexit/assets/icons/aws/Machine-Learning/DeepRacer.png diff --git a/assets/icons/aws/Machine-Learning/DevOps-Guru.png b/cloudexit/assets/icons/aws/Machine-Learning/DevOps-Guru.png similarity index 100% rename from assets/icons/aws/Machine-Learning/DevOps-Guru.png rename to cloudexit/assets/icons/aws/Machine-Learning/DevOps-Guru.png diff --git a/assets/icons/aws/Machine-Learning/Elastic-Inference.png b/cloudexit/assets/icons/aws/Machine-Learning/Elastic-Inference.png similarity index 100% rename from assets/icons/aws/Machine-Learning/Elastic-Inference.png rename to cloudexit/assets/icons/aws/Machine-Learning/Elastic-Inference.png diff --git a/assets/icons/aws/Machine-Learning/Elemental-Inference.png b/cloudexit/assets/icons/aws/Machine-Learning/Elemental-Inference.png similarity index 100% rename from assets/icons/aws/Machine-Learning/Elemental-Inference.png rename to cloudexit/assets/icons/aws/Machine-Learning/Elemental-Inference.png diff --git a/assets/icons/aws/Machine-Learning/Forecast.png b/cloudexit/assets/icons/aws/Machine-Learning/Forecast.png similarity index 100% rename from assets/icons/aws/Machine-Learning/Forecast.png rename to cloudexit/assets/icons/aws/Machine-Learning/Forecast.png diff --git a/assets/icons/aws/Machine-Learning/Fraud-Detector.png b/cloudexit/assets/icons/aws/Machine-Learning/Fraud-Detector.png similarity index 100% rename from assets/icons/aws/Machine-Learning/Fraud-Detector.png rename to cloudexit/assets/icons/aws/Machine-Learning/Fraud-Detector.png diff --git a/assets/icons/aws/Machine-Learning/HealthImaging.png b/cloudexit/assets/icons/aws/Machine-Learning/HealthImaging.png similarity index 100% rename from assets/icons/aws/Machine-Learning/HealthImaging.png rename to cloudexit/assets/icons/aws/Machine-Learning/HealthImaging.png diff --git a/assets/icons/aws/Machine-Learning/HealthLake.png b/cloudexit/assets/icons/aws/Machine-Learning/HealthLake.png similarity index 100% rename from assets/icons/aws/Machine-Learning/HealthLake.png rename to cloudexit/assets/icons/aws/Machine-Learning/HealthLake.png diff --git a/assets/icons/aws/Machine-Learning/HealthOmics.png b/cloudexit/assets/icons/aws/Machine-Learning/HealthOmics.png similarity index 100% rename from assets/icons/aws/Machine-Learning/HealthOmics.png rename to cloudexit/assets/icons/aws/Machine-Learning/HealthOmics.png diff --git a/assets/icons/aws/Machine-Learning/HealthScribe.png b/cloudexit/assets/icons/aws/Machine-Learning/HealthScribe.png similarity index 100% rename from assets/icons/aws/Machine-Learning/HealthScribe.png rename to cloudexit/assets/icons/aws/Machine-Learning/HealthScribe.png diff --git a/assets/icons/aws/Machine-Learning/Kendra.png b/cloudexit/assets/icons/aws/Machine-Learning/Kendra.png similarity index 100% rename from assets/icons/aws/Machine-Learning/Kendra.png rename to cloudexit/assets/icons/aws/Machine-Learning/Kendra.png diff --git a/assets/icons/aws/Machine-Learning/Lex.png b/cloudexit/assets/icons/aws/Machine-Learning/Lex.png similarity index 100% rename from assets/icons/aws/Machine-Learning/Lex.png rename to cloudexit/assets/icons/aws/Machine-Learning/Lex.png diff --git a/assets/icons/aws/Machine-Learning/Lookout-for-Equipment.png b/cloudexit/assets/icons/aws/Machine-Learning/Lookout-for-Equipment.png similarity index 100% rename from assets/icons/aws/Machine-Learning/Lookout-for-Equipment.png rename to cloudexit/assets/icons/aws/Machine-Learning/Lookout-for-Equipment.png diff --git a/assets/icons/aws/Machine-Learning/Lookout-for-Metrics.png b/cloudexit/assets/icons/aws/Machine-Learning/Lookout-for-Metrics.png similarity index 100% rename from assets/icons/aws/Machine-Learning/Lookout-for-Metrics.png rename to cloudexit/assets/icons/aws/Machine-Learning/Lookout-for-Metrics.png diff --git a/assets/icons/aws/Machine-Learning/Lookout-for-Vision.png b/cloudexit/assets/icons/aws/Machine-Learning/Lookout-for-Vision.png similarity index 100% rename from assets/icons/aws/Machine-Learning/Lookout-for-Vision.png rename to cloudexit/assets/icons/aws/Machine-Learning/Lookout-for-Vision.png diff --git a/assets/icons/aws/Machine-Learning/Monitron.png b/cloudexit/assets/icons/aws/Machine-Learning/Monitron.png similarity index 100% rename from assets/icons/aws/Machine-Learning/Monitron.png rename to cloudexit/assets/icons/aws/Machine-Learning/Monitron.png diff --git a/assets/icons/aws/Machine-Learning/Neuron.png b/cloudexit/assets/icons/aws/Machine-Learning/Neuron.png similarity index 100% rename from assets/icons/aws/Machine-Learning/Neuron.png rename to cloudexit/assets/icons/aws/Machine-Learning/Neuron.png diff --git a/assets/icons/aws/Machine-Learning/Nova.png b/cloudexit/assets/icons/aws/Machine-Learning/Nova.png similarity index 100% rename from assets/icons/aws/Machine-Learning/Nova.png rename to cloudexit/assets/icons/aws/Machine-Learning/Nova.png diff --git a/assets/icons/aws/Machine-Learning/Omics.png b/cloudexit/assets/icons/aws/Machine-Learning/Omics.png similarity index 100% rename from assets/icons/aws/Machine-Learning/Omics.png rename to cloudexit/assets/icons/aws/Machine-Learning/Omics.png diff --git a/assets/icons/aws/Machine-Learning/Panorama.png b/cloudexit/assets/icons/aws/Machine-Learning/Panorama.png similarity index 100% rename from assets/icons/aws/Machine-Learning/Panorama.png rename to cloudexit/assets/icons/aws/Machine-Learning/Panorama.png diff --git a/assets/icons/aws/Machine-Learning/Personalize.png b/cloudexit/assets/icons/aws/Machine-Learning/Personalize.png similarity index 100% rename from assets/icons/aws/Machine-Learning/Personalize.png rename to cloudexit/assets/icons/aws/Machine-Learning/Personalize.png diff --git a/assets/icons/aws/Machine-Learning/Polly.png b/cloudexit/assets/icons/aws/Machine-Learning/Polly.png similarity index 100% rename from assets/icons/aws/Machine-Learning/Polly.png rename to cloudexit/assets/icons/aws/Machine-Learning/Polly.png diff --git a/assets/icons/aws/Machine-Learning/PyTorch-on-AWS.png b/cloudexit/assets/icons/aws/Machine-Learning/PyTorch-on-AWS.png similarity index 100% rename from assets/icons/aws/Machine-Learning/PyTorch-on-AWS.png rename to cloudexit/assets/icons/aws/Machine-Learning/PyTorch-on-AWS.png diff --git a/assets/icons/aws/Machine-Learning/Q.png b/cloudexit/assets/icons/aws/Machine-Learning/Q.png similarity index 100% rename from assets/icons/aws/Machine-Learning/Q.png rename to cloudexit/assets/icons/aws/Machine-Learning/Q.png diff --git a/assets/icons/aws/Machine-Learning/Rekognition.png b/cloudexit/assets/icons/aws/Machine-Learning/Rekognition.png similarity index 100% rename from assets/icons/aws/Machine-Learning/Rekognition.png rename to cloudexit/assets/icons/aws/Machine-Learning/Rekognition.png diff --git a/assets/icons/aws/Machine-Learning/SageMaker-AI.png b/cloudexit/assets/icons/aws/Machine-Learning/SageMaker-AI.png similarity index 100% rename from assets/icons/aws/Machine-Learning/SageMaker-AI.png rename to cloudexit/assets/icons/aws/Machine-Learning/SageMaker-AI.png diff --git a/assets/icons/aws/Machine-Learning/SageMaker-Ground-Truth.png b/cloudexit/assets/icons/aws/Machine-Learning/SageMaker-Ground-Truth.png similarity index 100% rename from assets/icons/aws/Machine-Learning/SageMaker-Ground-Truth.png rename to cloudexit/assets/icons/aws/Machine-Learning/SageMaker-Ground-Truth.png diff --git a/assets/icons/aws/Machine-Learning/SageMaker-Studio-Lab.png b/cloudexit/assets/icons/aws/Machine-Learning/SageMaker-Studio-Lab.png similarity index 100% rename from assets/icons/aws/Machine-Learning/SageMaker-Studio-Lab.png rename to cloudexit/assets/icons/aws/Machine-Learning/SageMaker-Studio-Lab.png diff --git a/assets/icons/aws/Machine-Learning/SageMaker.png b/cloudexit/assets/icons/aws/Machine-Learning/SageMaker.png similarity index 100% rename from assets/icons/aws/Machine-Learning/SageMaker.png rename to cloudexit/assets/icons/aws/Machine-Learning/SageMaker.png diff --git a/assets/icons/aws/Machine-Learning/TensorFlow-on-AWS.png b/cloudexit/assets/icons/aws/Machine-Learning/TensorFlow-on-AWS.png similarity index 100% rename from assets/icons/aws/Machine-Learning/TensorFlow-on-AWS.png rename to cloudexit/assets/icons/aws/Machine-Learning/TensorFlow-on-AWS.png diff --git a/assets/icons/aws/Machine-Learning/Textract.png b/cloudexit/assets/icons/aws/Machine-Learning/Textract.png similarity index 100% rename from assets/icons/aws/Machine-Learning/Textract.png rename to cloudexit/assets/icons/aws/Machine-Learning/Textract.png diff --git a/assets/icons/aws/Machine-Learning/TorchServe.png b/cloudexit/assets/icons/aws/Machine-Learning/TorchServe.png similarity index 100% rename from assets/icons/aws/Machine-Learning/TorchServe.png rename to cloudexit/assets/icons/aws/Machine-Learning/TorchServe.png diff --git a/assets/icons/aws/Machine-Learning/Transcribe.png b/cloudexit/assets/icons/aws/Machine-Learning/Transcribe.png similarity index 100% rename from assets/icons/aws/Machine-Learning/Transcribe.png rename to cloudexit/assets/icons/aws/Machine-Learning/Transcribe.png diff --git a/assets/icons/aws/Machine-Learning/Translate.png b/cloudexit/assets/icons/aws/Machine-Learning/Translate.png similarity index 100% rename from assets/icons/aws/Machine-Learning/Translate.png rename to cloudexit/assets/icons/aws/Machine-Learning/Translate.png diff --git a/assets/icons/aws/Management-Governance/AppConfig.png b/cloudexit/assets/icons/aws/Management-Governance/AppConfig.png similarity index 100% rename from assets/icons/aws/Management-Governance/AppConfig.png rename to cloudexit/assets/icons/aws/Management-Governance/AppConfig.png diff --git a/assets/icons/aws/Management-Governance/Application-Auto-Scaling.png b/cloudexit/assets/icons/aws/Management-Governance/Application-Auto-Scaling.png similarity index 100% rename from assets/icons/aws/Management-Governance/Application-Auto-Scaling.png rename to cloudexit/assets/icons/aws/Management-Governance/Application-Auto-Scaling.png diff --git a/assets/icons/aws/Management-Governance/Auto-Scaling.png b/cloudexit/assets/icons/aws/Management-Governance/Auto-Scaling.png similarity index 100% rename from assets/icons/aws/Management-Governance/Auto-Scaling.png rename to cloudexit/assets/icons/aws/Management-Governance/Auto-Scaling.png diff --git a/assets/icons/aws/Management-Governance/Backint-Agent.png b/cloudexit/assets/icons/aws/Management-Governance/Backint-Agent.png similarity index 100% rename from assets/icons/aws/Management-Governance/Backint-Agent.png rename to cloudexit/assets/icons/aws/Management-Governance/Backint-Agent.png diff --git a/assets/icons/aws/Management-Governance/Chatbot.png b/cloudexit/assets/icons/aws/Management-Governance/Chatbot.png similarity index 100% rename from assets/icons/aws/Management-Governance/Chatbot.png rename to cloudexit/assets/icons/aws/Management-Governance/Chatbot.png diff --git a/assets/icons/aws/Management-Governance/CloudFormation.png b/cloudexit/assets/icons/aws/Management-Governance/CloudFormation.png similarity index 100% rename from assets/icons/aws/Management-Governance/CloudFormation.png rename to cloudexit/assets/icons/aws/Management-Governance/CloudFormation.png diff --git a/assets/icons/aws/Management-Governance/CloudTrail.png b/cloudexit/assets/icons/aws/Management-Governance/CloudTrail.png similarity index 100% rename from assets/icons/aws/Management-Governance/CloudTrail.png rename to cloudexit/assets/icons/aws/Management-Governance/CloudTrail.png diff --git a/assets/icons/aws/Management-Governance/CloudWatch.png b/cloudexit/assets/icons/aws/Management-Governance/CloudWatch.png similarity index 100% rename from assets/icons/aws/Management-Governance/CloudWatch.png rename to cloudexit/assets/icons/aws/Management-Governance/CloudWatch.png diff --git a/assets/icons/aws/Management-Governance/Config.png b/cloudexit/assets/icons/aws/Management-Governance/Config.png similarity index 100% rename from assets/icons/aws/Management-Governance/Config.png rename to cloudexit/assets/icons/aws/Management-Governance/Config.png diff --git a/assets/icons/aws/Management-Governance/Control-Tower.png b/cloudexit/assets/icons/aws/Management-Governance/Control-Tower.png similarity index 100% rename from assets/icons/aws/Management-Governance/Control-Tower.png rename to cloudexit/assets/icons/aws/Management-Governance/Control-Tower.png diff --git a/assets/icons/aws/Management-Governance/DevOps-Agent.png b/cloudexit/assets/icons/aws/Management-Governance/DevOps-Agent.png similarity index 100% rename from assets/icons/aws/Management-Governance/DevOps-Agent.png rename to cloudexit/assets/icons/aws/Management-Governance/DevOps-Agent.png diff --git a/assets/icons/aws/Management-Governance/Distro-for-OpenTelemetry.png b/cloudexit/assets/icons/aws/Management-Governance/Distro-for-OpenTelemetry.png similarity index 100% rename from assets/icons/aws/Management-Governance/Distro-for-OpenTelemetry.png rename to cloudexit/assets/icons/aws/Management-Governance/Distro-for-OpenTelemetry.png diff --git a/assets/icons/aws/Management-Governance/Fault-Injection-Simulator.png b/cloudexit/assets/icons/aws/Management-Governance/Fault-Injection-Simulator.png similarity index 100% rename from assets/icons/aws/Management-Governance/Fault-Injection-Simulator.png rename to cloudexit/assets/icons/aws/Management-Governance/Fault-Injection-Simulator.png diff --git a/assets/icons/aws/Management-Governance/Health-Dashboard.png b/cloudexit/assets/icons/aws/Management-Governance/Health-Dashboard.png similarity index 100% rename from assets/icons/aws/Management-Governance/Health-Dashboard.png rename to cloudexit/assets/icons/aws/Management-Governance/Health-Dashboard.png diff --git a/assets/icons/aws/Management-Governance/Launch-Wizard.png b/cloudexit/assets/icons/aws/Management-Governance/Launch-Wizard.png similarity index 100% rename from assets/icons/aws/Management-Governance/Launch-Wizard.png rename to cloudexit/assets/icons/aws/Management-Governance/Launch-Wizard.png diff --git a/assets/icons/aws/Management-Governance/License-Manager.png b/cloudexit/assets/icons/aws/Management-Governance/License-Manager.png similarity index 100% rename from assets/icons/aws/Management-Governance/License-Manager.png rename to cloudexit/assets/icons/aws/Management-Governance/License-Manager.png diff --git a/assets/icons/aws/Management-Governance/Managed-Grafana.png b/cloudexit/assets/icons/aws/Management-Governance/Managed-Grafana.png similarity index 100% rename from assets/icons/aws/Management-Governance/Managed-Grafana.png rename to cloudexit/assets/icons/aws/Management-Governance/Managed-Grafana.png diff --git a/assets/icons/aws/Management-Governance/Managed-Service-for-Prometheus.png b/cloudexit/assets/icons/aws/Management-Governance/Managed-Service-for-Prometheus.png similarity index 100% rename from assets/icons/aws/Management-Governance/Managed-Service-for-Prometheus.png rename to cloudexit/assets/icons/aws/Management-Governance/Managed-Service-for-Prometheus.png diff --git a/assets/icons/aws/Management-Governance/Management-Console.png b/cloudexit/assets/icons/aws/Management-Governance/Management-Console.png similarity index 100% rename from assets/icons/aws/Management-Governance/Management-Console.png rename to cloudexit/assets/icons/aws/Management-Governance/Management-Console.png diff --git a/assets/icons/aws/Management-Governance/OpsWorks.png b/cloudexit/assets/icons/aws/Management-Governance/OpsWorks.png similarity index 100% rename from assets/icons/aws/Management-Governance/OpsWorks.png rename to cloudexit/assets/icons/aws/Management-Governance/OpsWorks.png diff --git a/assets/icons/aws/Management-Governance/Organizations.png b/cloudexit/assets/icons/aws/Management-Governance/Organizations.png similarity index 100% rename from assets/icons/aws/Management-Governance/Organizations.png rename to cloudexit/assets/icons/aws/Management-Governance/Organizations.png diff --git a/assets/icons/aws/Management-Governance/Partner-Central.png b/cloudexit/assets/icons/aws/Management-Governance/Partner-Central.png similarity index 100% rename from assets/icons/aws/Management-Governance/Partner-Central.png rename to cloudexit/assets/icons/aws/Management-Governance/Partner-Central.png diff --git a/assets/icons/aws/Management-Governance/Personal-Health-Dashboard.png b/cloudexit/assets/icons/aws/Management-Governance/Personal-Health-Dashboard.png similarity index 100% rename from assets/icons/aws/Management-Governance/Personal-Health-Dashboard.png rename to cloudexit/assets/icons/aws/Management-Governance/Personal-Health-Dashboard.png diff --git a/assets/icons/aws/Management-Governance/Proton.png b/cloudexit/assets/icons/aws/Management-Governance/Proton.png similarity index 100% rename from assets/icons/aws/Management-Governance/Proton.png rename to cloudexit/assets/icons/aws/Management-Governance/Proton.png diff --git a/assets/icons/aws/Management-Governance/Resilience-Hub.png b/cloudexit/assets/icons/aws/Management-Governance/Resilience-Hub.png similarity index 100% rename from assets/icons/aws/Management-Governance/Resilience-Hub.png rename to cloudexit/assets/icons/aws/Management-Governance/Resilience-Hub.png diff --git a/assets/icons/aws/Management-Governance/Resource-Explorer.png b/cloudexit/assets/icons/aws/Management-Governance/Resource-Explorer.png similarity index 100% rename from assets/icons/aws/Management-Governance/Resource-Explorer.png rename to cloudexit/assets/icons/aws/Management-Governance/Resource-Explorer.png diff --git a/assets/icons/aws/Management-Governance/Service-Catalog.png b/cloudexit/assets/icons/aws/Management-Governance/Service-Catalog.png similarity index 100% rename from assets/icons/aws/Management-Governance/Service-Catalog.png rename to cloudexit/assets/icons/aws/Management-Governance/Service-Catalog.png diff --git a/assets/icons/aws/Management-Governance/Service-Management-Connector.png b/cloudexit/assets/icons/aws/Management-Governance/Service-Management-Connector.png similarity index 100% rename from assets/icons/aws/Management-Governance/Service-Management-Connector.png rename to cloudexit/assets/icons/aws/Management-Governance/Service-Management-Connector.png diff --git a/assets/icons/aws/Management-Governance/Sustainability.png b/cloudexit/assets/icons/aws/Management-Governance/Sustainability.png similarity index 100% rename from assets/icons/aws/Management-Governance/Sustainability.png rename to cloudexit/assets/icons/aws/Management-Governance/Sustainability.png diff --git a/assets/icons/aws/Management-Governance/Systems-Manager.png b/cloudexit/assets/icons/aws/Management-Governance/Systems-Manager.png similarity index 100% rename from assets/icons/aws/Management-Governance/Systems-Manager.png rename to cloudexit/assets/icons/aws/Management-Governance/Systems-Manager.png diff --git a/assets/icons/aws/Management-Governance/Telco-Network-Builder.png b/cloudexit/assets/icons/aws/Management-Governance/Telco-Network-Builder.png similarity index 100% rename from assets/icons/aws/Management-Governance/Telco-Network-Builder.png rename to cloudexit/assets/icons/aws/Management-Governance/Telco-Network-Builder.png diff --git a/assets/icons/aws/Management-Governance/Trusted-Advisor.png b/cloudexit/assets/icons/aws/Management-Governance/Trusted-Advisor.png similarity index 100% rename from assets/icons/aws/Management-Governance/Trusted-Advisor.png rename to cloudexit/assets/icons/aws/Management-Governance/Trusted-Advisor.png diff --git a/assets/icons/aws/Management-Governance/User-Notifications.png b/cloudexit/assets/icons/aws/Management-Governance/User-Notifications.png similarity index 100% rename from assets/icons/aws/Management-Governance/User-Notifications.png rename to cloudexit/assets/icons/aws/Management-Governance/User-Notifications.png diff --git a/assets/icons/aws/Management-Governance/Well-Architected-Tool.png b/cloudexit/assets/icons/aws/Management-Governance/Well-Architected-Tool.png similarity index 100% rename from assets/icons/aws/Management-Governance/Well-Architected-Tool.png rename to cloudexit/assets/icons/aws/Management-Governance/Well-Architected-Tool.png diff --git a/assets/icons/aws/Media-Services/Deadline-Cloud.png b/cloudexit/assets/icons/aws/Media-Services/Deadline-Cloud.png similarity index 100% rename from assets/icons/aws/Media-Services/Deadline-Cloud.png rename to cloudexit/assets/icons/aws/Media-Services/Deadline-Cloud.png diff --git a/assets/icons/aws/Media-Services/Elastic-Transcoder.png b/cloudexit/assets/icons/aws/Media-Services/Elastic-Transcoder.png similarity index 100% rename from assets/icons/aws/Media-Services/Elastic-Transcoder.png rename to cloudexit/assets/icons/aws/Media-Services/Elastic-Transcoder.png diff --git a/assets/icons/aws/Media-Services/Elemental-Appliances-&-Software.png b/cloudexit/assets/icons/aws/Media-Services/Elemental-Appliances-&-Software.png similarity index 100% rename from assets/icons/aws/Media-Services/Elemental-Appliances-&-Software.png rename to cloudexit/assets/icons/aws/Media-Services/Elemental-Appliances-&-Software.png diff --git a/assets/icons/aws/Media-Services/Elemental-Conductor.png b/cloudexit/assets/icons/aws/Media-Services/Elemental-Conductor.png similarity index 100% rename from assets/icons/aws/Media-Services/Elemental-Conductor.png rename to cloudexit/assets/icons/aws/Media-Services/Elemental-Conductor.png diff --git a/assets/icons/aws/Media-Services/Elemental-Delta.png b/cloudexit/assets/icons/aws/Media-Services/Elemental-Delta.png similarity index 100% rename from assets/icons/aws/Media-Services/Elemental-Delta.png rename to cloudexit/assets/icons/aws/Media-Services/Elemental-Delta.png diff --git a/assets/icons/aws/Media-Services/Elemental-Link.png b/cloudexit/assets/icons/aws/Media-Services/Elemental-Link.png similarity index 100% rename from assets/icons/aws/Media-Services/Elemental-Link.png rename to cloudexit/assets/icons/aws/Media-Services/Elemental-Link.png diff --git a/assets/icons/aws/Media-Services/Elemental-Live.png b/cloudexit/assets/icons/aws/Media-Services/Elemental-Live.png similarity index 100% rename from assets/icons/aws/Media-Services/Elemental-Live.png rename to cloudexit/assets/icons/aws/Media-Services/Elemental-Live.png diff --git a/assets/icons/aws/Media-Services/Elemental-MediaConnect.png b/cloudexit/assets/icons/aws/Media-Services/Elemental-MediaConnect.png similarity index 100% rename from assets/icons/aws/Media-Services/Elemental-MediaConnect.png rename to cloudexit/assets/icons/aws/Media-Services/Elemental-MediaConnect.png diff --git a/assets/icons/aws/Media-Services/Elemental-MediaConvert.png b/cloudexit/assets/icons/aws/Media-Services/Elemental-MediaConvert.png similarity index 100% rename from assets/icons/aws/Media-Services/Elemental-MediaConvert.png rename to cloudexit/assets/icons/aws/Media-Services/Elemental-MediaConvert.png diff --git a/assets/icons/aws/Media-Services/Elemental-MediaLive.png b/cloudexit/assets/icons/aws/Media-Services/Elemental-MediaLive.png similarity index 100% rename from assets/icons/aws/Media-Services/Elemental-MediaLive.png rename to cloudexit/assets/icons/aws/Media-Services/Elemental-MediaLive.png diff --git a/assets/icons/aws/Media-Services/Elemental-MediaPackage.png b/cloudexit/assets/icons/aws/Media-Services/Elemental-MediaPackage.png similarity index 100% rename from assets/icons/aws/Media-Services/Elemental-MediaPackage.png rename to cloudexit/assets/icons/aws/Media-Services/Elemental-MediaPackage.png diff --git a/assets/icons/aws/Media-Services/Elemental-MediaStore.png b/cloudexit/assets/icons/aws/Media-Services/Elemental-MediaStore.png similarity index 100% rename from assets/icons/aws/Media-Services/Elemental-MediaStore.png rename to cloudexit/assets/icons/aws/Media-Services/Elemental-MediaStore.png diff --git a/assets/icons/aws/Media-Services/Elemental-MediaTailor.png b/cloudexit/assets/icons/aws/Media-Services/Elemental-MediaTailor.png similarity index 100% rename from assets/icons/aws/Media-Services/Elemental-MediaTailor.png rename to cloudexit/assets/icons/aws/Media-Services/Elemental-MediaTailor.png diff --git a/assets/icons/aws/Media-Services/Elemental-Server.png b/cloudexit/assets/icons/aws/Media-Services/Elemental-Server.png similarity index 100% rename from assets/icons/aws/Media-Services/Elemental-Server.png rename to cloudexit/assets/icons/aws/Media-Services/Elemental-Server.png diff --git a/assets/icons/aws/Media-Services/Interactive-Video-Service.png b/cloudexit/assets/icons/aws/Media-Services/Interactive-Video-Service.png similarity index 100% rename from assets/icons/aws/Media-Services/Interactive-Video-Service.png rename to cloudexit/assets/icons/aws/Media-Services/Interactive-Video-Service.png diff --git a/assets/icons/aws/Media-Services/Kinesis-Video-Streams.png b/cloudexit/assets/icons/aws/Media-Services/Kinesis-Video-Streams.png similarity index 100% rename from assets/icons/aws/Media-Services/Kinesis-Video-Streams.png rename to cloudexit/assets/icons/aws/Media-Services/Kinesis-Video-Streams.png diff --git a/assets/icons/aws/Media-Services/Nimble-Studio.png b/cloudexit/assets/icons/aws/Media-Services/Nimble-Studio.png similarity index 100% rename from assets/icons/aws/Media-Services/Nimble-Studio.png rename to cloudexit/assets/icons/aws/Media-Services/Nimble-Studio.png diff --git a/assets/icons/aws/Migration-Transfer/Application-Discovery-Service.png b/cloudexit/assets/icons/aws/Migration-Transfer/Application-Discovery-Service.png similarity index 100% rename from assets/icons/aws/Migration-Transfer/Application-Discovery-Service.png rename to cloudexit/assets/icons/aws/Migration-Transfer/Application-Discovery-Service.png diff --git a/assets/icons/aws/Migration-Transfer/Application-Migration-Service.png b/cloudexit/assets/icons/aws/Migration-Transfer/Application-Migration-Service.png similarity index 100% rename from assets/icons/aws/Migration-Transfer/Application-Migration-Service.png rename to cloudexit/assets/icons/aws/Migration-Transfer/Application-Migration-Service.png diff --git a/assets/icons/aws/Migration-Transfer/Data-Transfer-Terminal.png b/cloudexit/assets/icons/aws/Migration-Transfer/Data-Transfer-Terminal.png similarity index 100% rename from assets/icons/aws/Migration-Transfer/Data-Transfer-Terminal.png rename to cloudexit/assets/icons/aws/Migration-Transfer/Data-Transfer-Terminal.png diff --git a/assets/icons/aws/Migration-Transfer/DataSync.png b/cloudexit/assets/icons/aws/Migration-Transfer/DataSync.png similarity index 100% rename from assets/icons/aws/Migration-Transfer/DataSync.png rename to cloudexit/assets/icons/aws/Migration-Transfer/DataSync.png diff --git a/assets/icons/aws/Migration-Transfer/Mainframe-Modernization.png b/cloudexit/assets/icons/aws/Migration-Transfer/Mainframe-Modernization.png similarity index 100% rename from assets/icons/aws/Migration-Transfer/Mainframe-Modernization.png rename to cloudexit/assets/icons/aws/Migration-Transfer/Mainframe-Modernization.png diff --git a/assets/icons/aws/Migration-Transfer/Migration-Evaluator.png b/cloudexit/assets/icons/aws/Migration-Transfer/Migration-Evaluator.png similarity index 100% rename from assets/icons/aws/Migration-Transfer/Migration-Evaluator.png rename to cloudexit/assets/icons/aws/Migration-Transfer/Migration-Evaluator.png diff --git a/assets/icons/aws/Migration-Transfer/Migration-Hub.png b/cloudexit/assets/icons/aws/Migration-Transfer/Migration-Hub.png similarity index 100% rename from assets/icons/aws/Migration-Transfer/Migration-Hub.png rename to cloudexit/assets/icons/aws/Migration-Transfer/Migration-Hub.png diff --git a/assets/icons/aws/Migration-Transfer/Server-Migration-Service.png b/cloudexit/assets/icons/aws/Migration-Transfer/Server-Migration-Service.png similarity index 100% rename from assets/icons/aws/Migration-Transfer/Server-Migration-Service.png rename to cloudexit/assets/icons/aws/Migration-Transfer/Server-Migration-Service.png diff --git a/assets/icons/aws/Migration-Transfer/Transfer-Family.png b/cloudexit/assets/icons/aws/Migration-Transfer/Transfer-Family.png similarity index 100% rename from assets/icons/aws/Migration-Transfer/Transfer-Family.png rename to cloudexit/assets/icons/aws/Migration-Transfer/Transfer-Family.png diff --git a/assets/icons/aws/Migration-Transfer/Transform.png b/cloudexit/assets/icons/aws/Migration-Transfer/Transform.png similarity index 100% rename from assets/icons/aws/Migration-Transfer/Transform.png rename to cloudexit/assets/icons/aws/Migration-Transfer/Transform.png diff --git a/assets/icons/aws/Networking-Content-Delivery/App-Mesh.png b/cloudexit/assets/icons/aws/Networking-Content-Delivery/App-Mesh.png similarity index 100% rename from assets/icons/aws/Networking-Content-Delivery/App-Mesh.png rename to cloudexit/assets/icons/aws/Networking-Content-Delivery/App-Mesh.png diff --git a/assets/icons/aws/Networking-Content-Delivery/Application-Recovery-Controller.png b/cloudexit/assets/icons/aws/Networking-Content-Delivery/Application-Recovery-Controller.png similarity index 100% rename from assets/icons/aws/Networking-Content-Delivery/Application-Recovery-Controller.png rename to cloudexit/assets/icons/aws/Networking-Content-Delivery/Application-Recovery-Controller.png diff --git a/assets/icons/aws/Networking-Content-Delivery/Client-VPN.png b/cloudexit/assets/icons/aws/Networking-Content-Delivery/Client-VPN.png similarity index 100% rename from assets/icons/aws/Networking-Content-Delivery/Client-VPN.png rename to cloudexit/assets/icons/aws/Networking-Content-Delivery/Client-VPN.png diff --git a/assets/icons/aws/Networking-Content-Delivery/Cloud-Directory.png b/cloudexit/assets/icons/aws/Networking-Content-Delivery/Cloud-Directory.png similarity index 100% rename from assets/icons/aws/Networking-Content-Delivery/Cloud-Directory.png rename to cloudexit/assets/icons/aws/Networking-Content-Delivery/Cloud-Directory.png diff --git a/assets/icons/aws/Networking-Content-Delivery/Cloud-Map.png b/cloudexit/assets/icons/aws/Networking-Content-Delivery/Cloud-Map.png similarity index 100% rename from assets/icons/aws/Networking-Content-Delivery/Cloud-Map.png rename to cloudexit/assets/icons/aws/Networking-Content-Delivery/Cloud-Map.png diff --git a/assets/icons/aws/Networking-Content-Delivery/Cloud-WAN.png b/cloudexit/assets/icons/aws/Networking-Content-Delivery/Cloud-WAN.png similarity index 100% rename from assets/icons/aws/Networking-Content-Delivery/Cloud-WAN.png rename to cloudexit/assets/icons/aws/Networking-Content-Delivery/Cloud-WAN.png diff --git a/assets/icons/aws/Networking-Content-Delivery/CloudFront.png b/cloudexit/assets/icons/aws/Networking-Content-Delivery/CloudFront.png similarity index 100% rename from assets/icons/aws/Networking-Content-Delivery/CloudFront.png rename to cloudexit/assets/icons/aws/Networking-Content-Delivery/CloudFront.png diff --git a/assets/icons/aws/Networking-Content-Delivery/Direct-Connect.png b/cloudexit/assets/icons/aws/Networking-Content-Delivery/Direct-Connect.png similarity index 100% rename from assets/icons/aws/Networking-Content-Delivery/Direct-Connect.png rename to cloudexit/assets/icons/aws/Networking-Content-Delivery/Direct-Connect.png diff --git a/assets/icons/aws/Networking-Content-Delivery/Elastic-Load-Balancing.png b/cloudexit/assets/icons/aws/Networking-Content-Delivery/Elastic-Load-Balancing.png similarity index 100% rename from assets/icons/aws/Networking-Content-Delivery/Elastic-Load-Balancing.png rename to cloudexit/assets/icons/aws/Networking-Content-Delivery/Elastic-Load-Balancing.png diff --git a/assets/icons/aws/Networking-Content-Delivery/Global-Accelerator.png b/cloudexit/assets/icons/aws/Networking-Content-Delivery/Global-Accelerator.png similarity index 100% rename from assets/icons/aws/Networking-Content-Delivery/Global-Accelerator.png rename to cloudexit/assets/icons/aws/Networking-Content-Delivery/Global-Accelerator.png diff --git a/assets/icons/aws/Networking-Content-Delivery/Interconnect.png b/cloudexit/assets/icons/aws/Networking-Content-Delivery/Interconnect.png similarity index 100% rename from assets/icons/aws/Networking-Content-Delivery/Interconnect.png rename to cloudexit/assets/icons/aws/Networking-Content-Delivery/Interconnect.png diff --git a/assets/icons/aws/Networking-Content-Delivery/Private-5G.png b/cloudexit/assets/icons/aws/Networking-Content-Delivery/Private-5G.png similarity index 100% rename from assets/icons/aws/Networking-Content-Delivery/Private-5G.png rename to cloudexit/assets/icons/aws/Networking-Content-Delivery/Private-5G.png diff --git a/assets/icons/aws/Networking-Content-Delivery/PrivateLink.png b/cloudexit/assets/icons/aws/Networking-Content-Delivery/PrivateLink.png similarity index 100% rename from assets/icons/aws/Networking-Content-Delivery/PrivateLink.png rename to cloudexit/assets/icons/aws/Networking-Content-Delivery/PrivateLink.png diff --git a/assets/icons/aws/Networking-Content-Delivery/RTB-Fabric.png b/cloudexit/assets/icons/aws/Networking-Content-Delivery/RTB-Fabric.png similarity index 100% rename from assets/icons/aws/Networking-Content-Delivery/RTB-Fabric.png rename to cloudexit/assets/icons/aws/Networking-Content-Delivery/RTB-Fabric.png diff --git a/assets/icons/aws/Networking-Content-Delivery/Route-53.png b/cloudexit/assets/icons/aws/Networking-Content-Delivery/Route-53.png similarity index 100% rename from assets/icons/aws/Networking-Content-Delivery/Route-53.png rename to cloudexit/assets/icons/aws/Networking-Content-Delivery/Route-53.png diff --git a/assets/icons/aws/Networking-Content-Delivery/Site-to-Site-VPN.png b/cloudexit/assets/icons/aws/Networking-Content-Delivery/Site-to-Site-VPN.png similarity index 100% rename from assets/icons/aws/Networking-Content-Delivery/Site-to-Site-VPN.png rename to cloudexit/assets/icons/aws/Networking-Content-Delivery/Site-to-Site-VPN.png diff --git a/assets/icons/aws/Networking-Content-Delivery/Transit-Gateway.png b/cloudexit/assets/icons/aws/Networking-Content-Delivery/Transit-Gateway.png similarity index 100% rename from assets/icons/aws/Networking-Content-Delivery/Transit-Gateway.png rename to cloudexit/assets/icons/aws/Networking-Content-Delivery/Transit-Gateway.png diff --git a/assets/icons/aws/Networking-Content-Delivery/VPC-Lattice.png b/cloudexit/assets/icons/aws/Networking-Content-Delivery/VPC-Lattice.png similarity index 100% rename from assets/icons/aws/Networking-Content-Delivery/VPC-Lattice.png rename to cloudexit/assets/icons/aws/Networking-Content-Delivery/VPC-Lattice.png diff --git a/assets/icons/aws/Networking-Content-Delivery/Verified-Access.png b/cloudexit/assets/icons/aws/Networking-Content-Delivery/Verified-Access.png similarity index 100% rename from assets/icons/aws/Networking-Content-Delivery/Verified-Access.png rename to cloudexit/assets/icons/aws/Networking-Content-Delivery/Verified-Access.png diff --git a/assets/icons/aws/Networking-Content-Delivery/Virtual-Private-Cloud.png b/cloudexit/assets/icons/aws/Networking-Content-Delivery/Virtual-Private-Cloud.png similarity index 100% rename from assets/icons/aws/Networking-Content-Delivery/Virtual-Private-Cloud.png rename to cloudexit/assets/icons/aws/Networking-Content-Delivery/Virtual-Private-Cloud.png diff --git a/assets/icons/aws/Quantum-Technologies/Braket.png b/cloudexit/assets/icons/aws/Quantum-Technologies/Braket.png similarity index 100% rename from assets/icons/aws/Quantum-Technologies/Braket.png rename to cloudexit/assets/icons/aws/Quantum-Technologies/Braket.png diff --git a/assets/icons/aws/Robotics/RoboMaker.png b/cloudexit/assets/icons/aws/Robotics/RoboMaker.png similarity index 100% rename from assets/icons/aws/Robotics/RoboMaker.png rename to cloudexit/assets/icons/aws/Robotics/RoboMaker.png diff --git a/assets/icons/aws/Satellite/Ground-Station.png b/cloudexit/assets/icons/aws/Satellite/Ground-Station.png similarity index 100% rename from assets/icons/aws/Satellite/Ground-Station.png rename to cloudexit/assets/icons/aws/Satellite/Ground-Station.png diff --git a/assets/icons/aws/Security-Identity-Compliance/Artifact.png b/cloudexit/assets/icons/aws/Security-Identity-Compliance/Artifact.png similarity index 100% rename from assets/icons/aws/Security-Identity-Compliance/Artifact.png rename to cloudexit/assets/icons/aws/Security-Identity-Compliance/Artifact.png diff --git a/assets/icons/aws/Security-Identity-Compliance/Audit-Manager.png b/cloudexit/assets/icons/aws/Security-Identity-Compliance/Audit-Manager.png similarity index 100% rename from assets/icons/aws/Security-Identity-Compliance/Audit-Manager.png rename to cloudexit/assets/icons/aws/Security-Identity-Compliance/Audit-Manager.png diff --git a/assets/icons/aws/Security-Identity-Compliance/Certificate-Manager.png b/cloudexit/assets/icons/aws/Security-Identity-Compliance/Certificate-Manager.png similarity index 100% rename from assets/icons/aws/Security-Identity-Compliance/Certificate-Manager.png rename to cloudexit/assets/icons/aws/Security-Identity-Compliance/Certificate-Manager.png diff --git a/assets/icons/aws/Security-Identity-Compliance/Cloud-Directory.png b/cloudexit/assets/icons/aws/Security-Identity-Compliance/Cloud-Directory.png similarity index 100% rename from assets/icons/aws/Security-Identity-Compliance/Cloud-Directory.png rename to cloudexit/assets/icons/aws/Security-Identity-Compliance/Cloud-Directory.png diff --git a/assets/icons/aws/Security-Identity-Compliance/CloudHSM.png b/cloudexit/assets/icons/aws/Security-Identity-Compliance/CloudHSM.png similarity index 100% rename from assets/icons/aws/Security-Identity-Compliance/CloudHSM.png rename to cloudexit/assets/icons/aws/Security-Identity-Compliance/CloudHSM.png diff --git a/assets/icons/aws/Security-Identity-Compliance/Cognito.png b/cloudexit/assets/icons/aws/Security-Identity-Compliance/Cognito.png similarity index 100% rename from assets/icons/aws/Security-Identity-Compliance/Cognito.png rename to cloudexit/assets/icons/aws/Security-Identity-Compliance/Cognito.png diff --git a/assets/icons/aws/Security-Identity-Compliance/Detective.png b/cloudexit/assets/icons/aws/Security-Identity-Compliance/Detective.png similarity index 100% rename from assets/icons/aws/Security-Identity-Compliance/Detective.png rename to cloudexit/assets/icons/aws/Security-Identity-Compliance/Detective.png diff --git a/assets/icons/aws/Security-Identity-Compliance/Directory-Service.png b/cloudexit/assets/icons/aws/Security-Identity-Compliance/Directory-Service.png similarity index 100% rename from assets/icons/aws/Security-Identity-Compliance/Directory-Service.png rename to cloudexit/assets/icons/aws/Security-Identity-Compliance/Directory-Service.png diff --git a/assets/icons/aws/Security-Identity-Compliance/Firewall-Manager.png b/cloudexit/assets/icons/aws/Security-Identity-Compliance/Firewall-Manager.png similarity index 100% rename from assets/icons/aws/Security-Identity-Compliance/Firewall-Manager.png rename to cloudexit/assets/icons/aws/Security-Identity-Compliance/Firewall-Manager.png diff --git a/assets/icons/aws/Security-Identity-Compliance/GuardDuty.png b/cloudexit/assets/icons/aws/Security-Identity-Compliance/GuardDuty.png similarity index 100% rename from assets/icons/aws/Security-Identity-Compliance/GuardDuty.png rename to cloudexit/assets/icons/aws/Security-Identity-Compliance/GuardDuty.png diff --git a/assets/icons/aws/Security-Identity-Compliance/IAM-Identity-Center.png b/cloudexit/assets/icons/aws/Security-Identity-Compliance/IAM-Identity-Center.png similarity index 100% rename from assets/icons/aws/Security-Identity-Compliance/IAM-Identity-Center.png rename to cloudexit/assets/icons/aws/Security-Identity-Compliance/IAM-Identity-Center.png diff --git a/assets/icons/aws/Security-Identity-Compliance/Identity-and-Access-Management.png b/cloudexit/assets/icons/aws/Security-Identity-Compliance/Identity-and-Access-Management.png similarity index 100% rename from assets/icons/aws/Security-Identity-Compliance/Identity-and-Access-Management.png rename to cloudexit/assets/icons/aws/Security-Identity-Compliance/Identity-and-Access-Management.png diff --git a/assets/icons/aws/Security-Identity-Compliance/Inspector.png b/cloudexit/assets/icons/aws/Security-Identity-Compliance/Inspector.png similarity index 100% rename from assets/icons/aws/Security-Identity-Compliance/Inspector.png rename to cloudexit/assets/icons/aws/Security-Identity-Compliance/Inspector.png diff --git a/assets/icons/aws/Security-Identity-Compliance/Key-Management-Service.png b/cloudexit/assets/icons/aws/Security-Identity-Compliance/Key-Management-Service.png similarity index 100% rename from assets/icons/aws/Security-Identity-Compliance/Key-Management-Service.png rename to cloudexit/assets/icons/aws/Security-Identity-Compliance/Key-Management-Service.png diff --git a/assets/icons/aws/Security-Identity-Compliance/Macie.png b/cloudexit/assets/icons/aws/Security-Identity-Compliance/Macie.png similarity index 100% rename from assets/icons/aws/Security-Identity-Compliance/Macie.png rename to cloudexit/assets/icons/aws/Security-Identity-Compliance/Macie.png diff --git a/assets/icons/aws/Security-Identity-Compliance/Network-Firewall.png b/cloudexit/assets/icons/aws/Security-Identity-Compliance/Network-Firewall.png similarity index 100% rename from assets/icons/aws/Security-Identity-Compliance/Network-Firewall.png rename to cloudexit/assets/icons/aws/Security-Identity-Compliance/Network-Firewall.png diff --git a/assets/icons/aws/Security-Identity-Compliance/Payment-Cryptography.png b/cloudexit/assets/icons/aws/Security-Identity-Compliance/Payment-Cryptography.png similarity index 100% rename from assets/icons/aws/Security-Identity-Compliance/Payment-Cryptography.png rename to cloudexit/assets/icons/aws/Security-Identity-Compliance/Payment-Cryptography.png diff --git a/assets/icons/aws/Security-Identity-Compliance/Private-Certificate-Authority.png b/cloudexit/assets/icons/aws/Security-Identity-Compliance/Private-Certificate-Authority.png similarity index 100% rename from assets/icons/aws/Security-Identity-Compliance/Private-Certificate-Authority.png rename to cloudexit/assets/icons/aws/Security-Identity-Compliance/Private-Certificate-Authority.png diff --git a/assets/icons/aws/Security-Identity-Compliance/Resource-Access-Manager.png b/cloudexit/assets/icons/aws/Security-Identity-Compliance/Resource-Access-Manager.png similarity index 100% rename from assets/icons/aws/Security-Identity-Compliance/Resource-Access-Manager.png rename to cloudexit/assets/icons/aws/Security-Identity-Compliance/Resource-Access-Manager.png diff --git a/assets/icons/aws/Security-Identity-Compliance/Secrets-Manager.png b/cloudexit/assets/icons/aws/Security-Identity-Compliance/Secrets-Manager.png similarity index 100% rename from assets/icons/aws/Security-Identity-Compliance/Secrets-Manager.png rename to cloudexit/assets/icons/aws/Security-Identity-Compliance/Secrets-Manager.png diff --git a/assets/icons/aws/Security-Identity-Compliance/Security-Agent.png b/cloudexit/assets/icons/aws/Security-Identity-Compliance/Security-Agent.png similarity index 100% rename from assets/icons/aws/Security-Identity-Compliance/Security-Agent.png rename to cloudexit/assets/icons/aws/Security-Identity-Compliance/Security-Agent.png diff --git a/assets/icons/aws/Security-Identity-Compliance/Security-Hub.png b/cloudexit/assets/icons/aws/Security-Identity-Compliance/Security-Hub.png similarity index 100% rename from assets/icons/aws/Security-Identity-Compliance/Security-Hub.png rename to cloudexit/assets/icons/aws/Security-Identity-Compliance/Security-Hub.png diff --git a/assets/icons/aws/Security-Identity-Compliance/Security-Incident-Response.png b/cloudexit/assets/icons/aws/Security-Identity-Compliance/Security-Incident-Response.png similarity index 100% rename from assets/icons/aws/Security-Identity-Compliance/Security-Incident-Response.png rename to cloudexit/assets/icons/aws/Security-Identity-Compliance/Security-Incident-Response.png diff --git a/assets/icons/aws/Security-Identity-Compliance/Security-Lake.png b/cloudexit/assets/icons/aws/Security-Identity-Compliance/Security-Lake.png similarity index 100% rename from assets/icons/aws/Security-Identity-Compliance/Security-Lake.png rename to cloudexit/assets/icons/aws/Security-Identity-Compliance/Security-Lake.png diff --git a/assets/icons/aws/Security-Identity-Compliance/Shield.png b/cloudexit/assets/icons/aws/Security-Identity-Compliance/Shield.png similarity index 100% rename from assets/icons/aws/Security-Identity-Compliance/Shield.png rename to cloudexit/assets/icons/aws/Security-Identity-Compliance/Shield.png diff --git a/assets/icons/aws/Security-Identity-Compliance/Signer.png b/cloudexit/assets/icons/aws/Security-Identity-Compliance/Signer.png similarity index 100% rename from assets/icons/aws/Security-Identity-Compliance/Signer.png rename to cloudexit/assets/icons/aws/Security-Identity-Compliance/Signer.png diff --git a/assets/icons/aws/Security-Identity-Compliance/Verified-Permissions.png b/cloudexit/assets/icons/aws/Security-Identity-Compliance/Verified-Permissions.png similarity index 100% rename from assets/icons/aws/Security-Identity-Compliance/Verified-Permissions.png rename to cloudexit/assets/icons/aws/Security-Identity-Compliance/Verified-Permissions.png diff --git a/assets/icons/aws/Security-Identity-Compliance/WAF.png b/cloudexit/assets/icons/aws/Security-Identity-Compliance/WAF.png similarity index 100% rename from assets/icons/aws/Security-Identity-Compliance/WAF.png rename to cloudexit/assets/icons/aws/Security-Identity-Compliance/WAF.png diff --git a/assets/icons/aws/Storage/Backup.png b/cloudexit/assets/icons/aws/Storage/Backup.png similarity index 100% rename from assets/icons/aws/Storage/Backup.png rename to cloudexit/assets/icons/aws/Storage/Backup.png diff --git a/assets/icons/aws/Storage/EFS.png b/cloudexit/assets/icons/aws/Storage/EFS.png similarity index 100% rename from assets/icons/aws/Storage/EFS.png rename to cloudexit/assets/icons/aws/Storage/EFS.png diff --git a/assets/icons/aws/Storage/Elastic-Block-Store.png b/cloudexit/assets/icons/aws/Storage/Elastic-Block-Store.png similarity index 100% rename from assets/icons/aws/Storage/Elastic-Block-Store.png rename to cloudexit/assets/icons/aws/Storage/Elastic-Block-Store.png diff --git a/assets/icons/aws/Storage/Elastic-Disaster-Recovery.png b/cloudexit/assets/icons/aws/Storage/Elastic-Disaster-Recovery.png similarity index 100% rename from assets/icons/aws/Storage/Elastic-Disaster-Recovery.png rename to cloudexit/assets/icons/aws/Storage/Elastic-Disaster-Recovery.png diff --git a/assets/icons/aws/Storage/FSx-for-Lustre.png b/cloudexit/assets/icons/aws/Storage/FSx-for-Lustre.png similarity index 100% rename from assets/icons/aws/Storage/FSx-for-Lustre.png rename to cloudexit/assets/icons/aws/Storage/FSx-for-Lustre.png diff --git a/assets/icons/aws/Storage/FSx-for-NetApp-ONTAP.png b/cloudexit/assets/icons/aws/Storage/FSx-for-NetApp-ONTAP.png similarity index 100% rename from assets/icons/aws/Storage/FSx-for-NetApp-ONTAP.png rename to cloudexit/assets/icons/aws/Storage/FSx-for-NetApp-ONTAP.png diff --git a/assets/icons/aws/Storage/FSx-for-OpenZFS.png b/cloudexit/assets/icons/aws/Storage/FSx-for-OpenZFS.png similarity index 100% rename from assets/icons/aws/Storage/FSx-for-OpenZFS.png rename to cloudexit/assets/icons/aws/Storage/FSx-for-OpenZFS.png diff --git a/assets/icons/aws/Storage/FSx-for-WFS.png b/cloudexit/assets/icons/aws/Storage/FSx-for-WFS.png similarity index 100% rename from assets/icons/aws/Storage/FSx-for-WFS.png rename to cloudexit/assets/icons/aws/Storage/FSx-for-WFS.png diff --git a/assets/icons/aws/Storage/FSx.png b/cloudexit/assets/icons/aws/Storage/FSx.png similarity index 100% rename from assets/icons/aws/Storage/FSx.png rename to cloudexit/assets/icons/aws/Storage/FSx.png diff --git a/assets/icons/aws/Storage/File-Cache.png b/cloudexit/assets/icons/aws/Storage/File-Cache.png similarity index 100% rename from assets/icons/aws/Storage/File-Cache.png rename to cloudexit/assets/icons/aws/Storage/File-Cache.png diff --git a/assets/icons/aws/Storage/S3-on-Outposts.png b/cloudexit/assets/icons/aws/Storage/S3-on-Outposts.png similarity index 100% rename from assets/icons/aws/Storage/S3-on-Outposts.png rename to cloudexit/assets/icons/aws/Storage/S3-on-Outposts.png diff --git a/assets/icons/aws/Storage/Simple-Storage-Service-Glacier.png b/cloudexit/assets/icons/aws/Storage/Simple-Storage-Service-Glacier.png similarity index 100% rename from assets/icons/aws/Storage/Simple-Storage-Service-Glacier.png rename to cloudexit/assets/icons/aws/Storage/Simple-Storage-Service-Glacier.png diff --git a/assets/icons/aws/Storage/Simple-Storage-Service.png b/cloudexit/assets/icons/aws/Storage/Simple-Storage-Service.png similarity index 100% rename from assets/icons/aws/Storage/Simple-Storage-Service.png rename to cloudexit/assets/icons/aws/Storage/Simple-Storage-Service.png diff --git a/assets/icons/aws/Storage/Snowball-Edge.png b/cloudexit/assets/icons/aws/Storage/Snowball-Edge.png similarity index 100% rename from assets/icons/aws/Storage/Snowball-Edge.png rename to cloudexit/assets/icons/aws/Storage/Snowball-Edge.png diff --git a/assets/icons/aws/Storage/Snowball.png b/cloudexit/assets/icons/aws/Storage/Snowball.png similarity index 100% rename from assets/icons/aws/Storage/Snowball.png rename to cloudexit/assets/icons/aws/Storage/Snowball.png diff --git a/assets/icons/aws/Storage/Snowcone.png b/cloudexit/assets/icons/aws/Storage/Snowcone.png similarity index 100% rename from assets/icons/aws/Storage/Snowcone.png rename to cloudexit/assets/icons/aws/Storage/Snowcone.png diff --git a/assets/icons/aws/Storage/Snowmobile.png b/cloudexit/assets/icons/aws/Storage/Snowmobile.png similarity index 100% rename from assets/icons/aws/Storage/Snowmobile.png rename to cloudexit/assets/icons/aws/Storage/Snowmobile.png diff --git a/assets/icons/aws/Storage/Storage-Gateway.png b/cloudexit/assets/icons/aws/Storage/Storage-Gateway.png similarity index 100% rename from assets/icons/aws/Storage/Storage-Gateway.png rename to cloudexit/assets/icons/aws/Storage/Storage-Gateway.png diff --git a/assets/icons/aws/VR-AR/Sumerian.png b/cloudexit/assets/icons/aws/VR-AR/Sumerian.png similarity index 100% rename from assets/icons/aws/VR-AR/Sumerian.png rename to cloudexit/assets/icons/aws/VR-AR/Sumerian.png diff --git a/assets/icons/azure/ai-machine-learning/00028-icon-service-Batch-AI.png b/cloudexit/assets/icons/azure/ai-machine-learning/00028-icon-service-Batch-AI.png similarity index 100% rename from assets/icons/azure/ai-machine-learning/00028-icon-service-Batch-AI.png rename to cloudexit/assets/icons/azure/ai-machine-learning/00028-icon-service-Batch-AI.png diff --git a/assets/icons/azure/ai-machine-learning/00030-icon-service-Machine-Learning-Studio-(Classic)-Web-Services.png b/cloudexit/assets/icons/azure/ai-machine-learning/00030-icon-service-Machine-Learning-Studio-(Classic)-Web-Services.png similarity index 100% rename from assets/icons/azure/ai-machine-learning/00030-icon-service-Machine-Learning-Studio-(Classic)-Web-Services.png rename to cloudexit/assets/icons/azure/ai-machine-learning/00030-icon-service-Machine-Learning-Studio-(Classic)-Web-Services.png diff --git a/assets/icons/azure/ai-machine-learning/00031-icon-service-Genomics.png b/cloudexit/assets/icons/azure/ai-machine-learning/00031-icon-service-Genomics.png similarity index 100% rename from assets/icons/azure/ai-machine-learning/00031-icon-service-Genomics.png rename to cloudexit/assets/icons/azure/ai-machine-learning/00031-icon-service-Genomics.png diff --git a/assets/icons/azure/ai-machine-learning/00792-icon-service-Computer-Vision.png b/cloudexit/assets/icons/azure/ai-machine-learning/00792-icon-service-Computer-Vision.png similarity index 100% rename from assets/icons/azure/ai-machine-learning/00792-icon-service-Computer-Vision.png rename to cloudexit/assets/icons/azure/ai-machine-learning/00792-icon-service-Computer-Vision.png diff --git a/assets/icons/azure/ai-machine-learning/00793-icon-service-Custom-Vision.png b/cloudexit/assets/icons/azure/ai-machine-learning/00793-icon-service-Custom-Vision.png similarity index 100% rename from assets/icons/azure/ai-machine-learning/00793-icon-service-Custom-Vision.png rename to cloudexit/assets/icons/azure/ai-machine-learning/00793-icon-service-Custom-Vision.png diff --git a/assets/icons/azure/ai-machine-learning/00794-icon-service-Face-APIs.png b/cloudexit/assets/icons/azure/ai-machine-learning/00794-icon-service-Face-APIs.png similarity index 100% rename from assets/icons/azure/ai-machine-learning/00794-icon-service-Face-APIs.png rename to cloudexit/assets/icons/azure/ai-machine-learning/00794-icon-service-Face-APIs.png diff --git a/assets/icons/azure/ai-machine-learning/00795-icon-service-Content-Moderators.png b/cloudexit/assets/icons/azure/ai-machine-learning/00795-icon-service-Content-Moderators.png similarity index 100% rename from assets/icons/azure/ai-machine-learning/00795-icon-service-Content-Moderators.png rename to cloudexit/assets/icons/azure/ai-machine-learning/00795-icon-service-Content-Moderators.png diff --git a/assets/icons/azure/ai-machine-learning/00796-icon-service-Personalizers.png b/cloudexit/assets/icons/azure/ai-machine-learning/00796-icon-service-Personalizers.png similarity index 100% rename from assets/icons/azure/ai-machine-learning/00796-icon-service-Personalizers.png rename to cloudexit/assets/icons/azure/ai-machine-learning/00796-icon-service-Personalizers.png diff --git a/assets/icons/azure/ai-machine-learning/00797-icon-service-Speech-Services.png b/cloudexit/assets/icons/azure/ai-machine-learning/00797-icon-service-Speech-Services.png similarity index 100% rename from assets/icons/azure/ai-machine-learning/00797-icon-service-Speech-Services.png rename to cloudexit/assets/icons/azure/ai-machine-learning/00797-icon-service-Speech-Services.png diff --git a/assets/icons/azure/ai-machine-learning/00799-icon-service-QnA-Makers.png b/cloudexit/assets/icons/azure/ai-machine-learning/00799-icon-service-QnA-Makers.png similarity index 100% rename from assets/icons/azure/ai-machine-learning/00799-icon-service-QnA-Makers.png rename to cloudexit/assets/icons/azure/ai-machine-learning/00799-icon-service-QnA-Makers.png diff --git a/assets/icons/azure/ai-machine-learning/00800-icon-service-Translator-Text.png b/cloudexit/assets/icons/azure/ai-machine-learning/00800-icon-service-Translator-Text.png similarity index 100% rename from assets/icons/azure/ai-machine-learning/00800-icon-service-Translator-Text.png rename to cloudexit/assets/icons/azure/ai-machine-learning/00800-icon-service-Translator-Text.png diff --git a/assets/icons/azure/ai-machine-learning/00801-icon-service-Language-Understanding.png b/cloudexit/assets/icons/azure/ai-machine-learning/00801-icon-service-Language-Understanding.png similarity index 100% rename from assets/icons/azure/ai-machine-learning/00801-icon-service-Language-Understanding.png rename to cloudexit/assets/icons/azure/ai-machine-learning/00801-icon-service-Language-Understanding.png diff --git a/assets/icons/azure/ai-machine-learning/00812-icon-service-Immersive-Readers.png b/cloudexit/assets/icons/azure/ai-machine-learning/00812-icon-service-Immersive-Readers.png similarity index 100% rename from assets/icons/azure/ai-machine-learning/00812-icon-service-Immersive-Readers.png rename to cloudexit/assets/icons/azure/ai-machine-learning/00812-icon-service-Immersive-Readers.png diff --git a/assets/icons/azure/ai-machine-learning/00814-icon-service-Anomaly-Detector.png b/cloudexit/assets/icons/azure/ai-machine-learning/00814-icon-service-Anomaly-Detector.png similarity index 100% rename from assets/icons/azure/ai-machine-learning/00814-icon-service-Anomaly-Detector.png rename to cloudexit/assets/icons/azure/ai-machine-learning/00814-icon-service-Anomaly-Detector.png diff --git a/assets/icons/azure/ai-machine-learning/00819-icon-service-Form-Recognizers.png b/cloudexit/assets/icons/azure/ai-machine-learning/00819-icon-service-Form-Recognizers.png similarity index 100% rename from assets/icons/azure/ai-machine-learning/00819-icon-service-Form-Recognizers.png rename to cloudexit/assets/icons/azure/ai-machine-learning/00819-icon-service-Form-Recognizers.png diff --git a/assets/icons/azure/ai-machine-learning/01239-icon-service-Azure-Experimentation-Studio.png b/cloudexit/assets/icons/azure/ai-machine-learning/01239-icon-service-Azure-Experimentation-Studio.png similarity index 100% rename from assets/icons/azure/ai-machine-learning/01239-icon-service-Azure-Experimentation-Studio.png rename to cloudexit/assets/icons/azure/ai-machine-learning/01239-icon-service-Azure-Experimentation-Studio.png diff --git a/assets/icons/azure/ai-machine-learning/01688-icon-service-Azure-Object-Understanding.png b/cloudexit/assets/icons/azure/ai-machine-learning/01688-icon-service-Azure-Object-Understanding.png similarity index 100% rename from assets/icons/azure/ai-machine-learning/01688-icon-service-Azure-Object-Understanding.png rename to cloudexit/assets/icons/azure/ai-machine-learning/01688-icon-service-Azure-Object-Understanding.png diff --git a/assets/icons/azure/ai-machine-learning/02409-icon-service-Metrics-Advisor.png b/cloudexit/assets/icons/azure/ai-machine-learning/02409-icon-service-Metrics-Advisor.png similarity index 100% rename from assets/icons/azure/ai-machine-learning/02409-icon-service-Metrics-Advisor.png rename to cloudexit/assets/icons/azure/ai-machine-learning/02409-icon-service-Metrics-Advisor.png diff --git a/assets/icons/azure/ai-machine-learning/02749-icon-service-Azure-Applied-AI-Services.png b/cloudexit/assets/icons/azure/ai-machine-learning/02749-icon-service-Azure-Applied-AI-Services.png similarity index 100% rename from assets/icons/azure/ai-machine-learning/02749-icon-service-Azure-Applied-AI-Services.png rename to cloudexit/assets/icons/azure/ai-machine-learning/02749-icon-service-Azure-Applied-AI-Services.png diff --git a/assets/icons/azure/ai-machine-learning/02876-icon-service-Language.png b/cloudexit/assets/icons/azure/ai-machine-learning/02876-icon-service-Language.png similarity index 100% rename from assets/icons/azure/ai-machine-learning/02876-icon-service-Language.png rename to cloudexit/assets/icons/azure/ai-machine-learning/02876-icon-service-Language.png diff --git a/assets/icons/azure/ai-machine-learning/029455661-icon-service-FRD-QA.png b/cloudexit/assets/icons/azure/ai-machine-learning/029455661-icon-service-FRD-QA.png similarity index 100% rename from assets/icons/azure/ai-machine-learning/029455661-icon-service-FRD-QA.png rename to cloudexit/assets/icons/azure/ai-machine-learning/029455661-icon-service-FRD-QA.png diff --git a/assets/icons/azure/ai-machine-learning/03173-icon-service-Cognitive-Services-Decisions.png b/cloudexit/assets/icons/azure/ai-machine-learning/03173-icon-service-Cognitive-Services-Decisions.png similarity index 100% rename from assets/icons/azure/ai-machine-learning/03173-icon-service-Cognitive-Services-Decisions.png rename to cloudexit/assets/icons/azure/ai-machine-learning/03173-icon-service-Cognitive-Services-Decisions.png diff --git a/assets/icons/azure/ai-machine-learning/03321-icon-service-Serverless-Search.png b/cloudexit/assets/icons/azure/ai-machine-learning/03321-icon-service-Serverless-Search.png similarity index 100% rename from assets/icons/azure/ai-machine-learning/03321-icon-service-Serverless-Search.png rename to cloudexit/assets/icons/azure/ai-machine-learning/03321-icon-service-Serverless-Search.png diff --git a/assets/icons/azure/ai-machine-learning/03337-icon-service-Bonsai.png b/cloudexit/assets/icons/azure/ai-machine-learning/03337-icon-service-Bonsai.png similarity index 100% rename from assets/icons/azure/ai-machine-learning/03337-icon-service-Bonsai.png rename to cloudexit/assets/icons/azure/ai-machine-learning/03337-icon-service-Bonsai.png diff --git a/assets/icons/azure/ai-machine-learning/03390-icon-service-Content-Safety.png b/cloudexit/assets/icons/azure/ai-machine-learning/03390-icon-service-Content-Safety.png similarity index 100% rename from assets/icons/azure/ai-machine-learning/03390-icon-service-Content-Safety.png rename to cloudexit/assets/icons/azure/ai-machine-learning/03390-icon-service-Content-Safety.png diff --git a/assets/icons/azure/ai-machine-learning/03438-icon-service-Azure-OpenAI.png b/cloudexit/assets/icons/azure/ai-machine-learning/03438-icon-service-Azure-OpenAI.png similarity index 100% rename from assets/icons/azure/ai-machine-learning/03438-icon-service-Azure-OpenAI.png rename to cloudexit/assets/icons/azure/ai-machine-learning/03438-icon-service-Azure-OpenAI.png diff --git a/assets/icons/azure/ai-machine-learning/03513-icon-service-AI-Studio.png b/cloudexit/assets/icons/azure/ai-machine-learning/03513-icon-service-AI-Studio.png similarity index 100% rename from assets/icons/azure/ai-machine-learning/03513-icon-service-AI-Studio.png rename to cloudexit/assets/icons/azure/ai-machine-learning/03513-icon-service-AI-Studio.png diff --git a/assets/icons/azure/ai-machine-learning/035746832-icon-service-AI-Foundry.png b/cloudexit/assets/icons/azure/ai-machine-learning/035746832-icon-service-AI-Foundry.png similarity index 100% rename from assets/icons/azure/ai-machine-learning/035746832-icon-service-AI-Foundry.png rename to cloudexit/assets/icons/azure/ai-machine-learning/035746832-icon-service-AI-Foundry.png diff --git a/assets/icons/azure/ai-machine-learning/036509374-icon-service-Foundry-Application.png b/cloudexit/assets/icons/azure/ai-machine-learning/036509374-icon-service-Foundry-Application.png similarity index 100% rename from assets/icons/azure/ai-machine-learning/036509374-icon-service-Foundry-Application.png rename to cloudexit/assets/icons/azure/ai-machine-learning/036509374-icon-service-Foundry-Application.png diff --git a/assets/icons/azure/ai-machine-learning/036509415-icon-service-Foundry-Project.png b/cloudexit/assets/icons/azure/ai-machine-learning/036509415-icon-service-Foundry-Project.png similarity index 100% rename from assets/icons/azure/ai-machine-learning/036509415-icon-service-Foundry-Project.png rename to cloudexit/assets/icons/azure/ai-machine-learning/036509415-icon-service-Foundry-Project.png diff --git a/assets/icons/azure/ai-machine-learning/03755-icon-service-Planetary-Computer-Pro.png b/cloudexit/assets/icons/azure/ai-machine-learning/03755-icon-service-Planetary-Computer-Pro.png similarity index 100% rename from assets/icons/azure/ai-machine-learning/03755-icon-service-Planetary-Computer-Pro.png rename to cloudexit/assets/icons/azure/ai-machine-learning/03755-icon-service-Planetary-Computer-Pro.png diff --git a/assets/icons/azure/ai-machine-learning/038470497-icon-service-Azure-AI-Foundry-IQ.png b/cloudexit/assets/icons/azure/ai-machine-learning/038470497-icon-service-Azure-AI-Foundry-IQ.png similarity index 100% rename from assets/icons/azure/ai-machine-learning/038470497-icon-service-Azure-AI-Foundry-IQ.png rename to cloudexit/assets/icons/azure/ai-machine-learning/038470497-icon-service-Azure-AI-Foundry-IQ.png diff --git a/assets/icons/azure/ai-machine-learning/038470510-icon-service-Azure-Machine-Learning.png b/cloudexit/assets/icons/azure/ai-machine-learning/038470510-icon-service-Azure-Machine-Learning.png similarity index 100% rename from assets/icons/azure/ai-machine-learning/038470510-icon-service-Azure-Machine-Learning.png rename to cloudexit/assets/icons/azure/ai-machine-learning/038470510-icon-service-Azure-Machine-Learning.png diff --git a/assets/icons/azure/ai-machine-learning/038470523-icon-service-Foundry-Agent-Service.png b/cloudexit/assets/icons/azure/ai-machine-learning/038470523-icon-service-Foundry-Agent-Service.png similarity index 100% rename from assets/icons/azure/ai-machine-learning/038470523-icon-service-Foundry-Agent-Service.png rename to cloudexit/assets/icons/azure/ai-machine-learning/038470523-icon-service-Foundry-Agent-Service.png diff --git a/assets/icons/azure/ai-machine-learning/038470534-icon-service-Foundry-Control-Plane.png b/cloudexit/assets/icons/azure/ai-machine-learning/038470534-icon-service-Foundry-Control-Plane.png similarity index 100% rename from assets/icons/azure/ai-machine-learning/038470534-icon-service-Foundry-Control-Plane.png rename to cloudexit/assets/icons/azure/ai-machine-learning/038470534-icon-service-Foundry-Control-Plane.png diff --git a/assets/icons/azure/ai-machine-learning/038470555-icon-service-Foundry-Labs.png b/cloudexit/assets/icons/azure/ai-machine-learning/038470555-icon-service-Foundry-Labs.png similarity index 100% rename from assets/icons/azure/ai-machine-learning/038470555-icon-service-Foundry-Labs.png rename to cloudexit/assets/icons/azure/ai-machine-learning/038470555-icon-service-Foundry-Labs.png diff --git a/assets/icons/azure/ai-machine-learning/038470593-icon-service-Foundry-Local.png b/cloudexit/assets/icons/azure/ai-machine-learning/038470593-icon-service-Foundry-Local.png similarity index 100% rename from assets/icons/azure/ai-machine-learning/038470593-icon-service-Foundry-Local.png rename to cloudexit/assets/icons/azure/ai-machine-learning/038470593-icon-service-Foundry-Local.png diff --git a/assets/icons/azure/ai-machine-learning/038470614-icon-service-Foundry-Models.png b/cloudexit/assets/icons/azure/ai-machine-learning/038470614-icon-service-Foundry-Models.png similarity index 100% rename from assets/icons/azure/ai-machine-learning/038470614-icon-service-Foundry-Models.png rename to cloudexit/assets/icons/azure/ai-machine-learning/038470614-icon-service-Foundry-Models.png diff --git a/assets/icons/azure/ai-machine-learning/10044-icon-service-Cognitive-Search.png b/cloudexit/assets/icons/azure/ai-machine-learning/10044-icon-service-Cognitive-Search.png similarity index 100% rename from assets/icons/azure/ai-machine-learning/10044-icon-service-Cognitive-Search.png rename to cloudexit/assets/icons/azure/ai-machine-learning/10044-icon-service-Cognitive-Search.png diff --git a/assets/icons/azure/ai-machine-learning/10162-icon-service-Cognitive-Services.png b/cloudexit/assets/icons/azure/ai-machine-learning/10162-icon-service-Cognitive-Services.png similarity index 100% rename from assets/icons/azure/ai-machine-learning/10162-icon-service-Cognitive-Services.png rename to cloudexit/assets/icons/azure/ai-machine-learning/10162-icon-service-Cognitive-Services.png diff --git a/assets/icons/azure/ai-machine-learning/10164-icon-service-Genomics-Accounts.png b/cloudexit/assets/icons/azure/ai-machine-learning/10164-icon-service-Genomics-Accounts.png similarity index 100% rename from assets/icons/azure/ai-machine-learning/10164-icon-service-Genomics-Accounts.png rename to cloudexit/assets/icons/azure/ai-machine-learning/10164-icon-service-Genomics-Accounts.png diff --git a/assets/icons/azure/ai-machine-learning/10165-icon-service-Bot-Services.png b/cloudexit/assets/icons/azure/ai-machine-learning/10165-icon-service-Bot-Services.png similarity index 100% rename from assets/icons/azure/ai-machine-learning/10165-icon-service-Bot-Services.png rename to cloudexit/assets/icons/azure/ai-machine-learning/10165-icon-service-Bot-Services.png diff --git a/assets/icons/azure/ai-machine-learning/10166-icon-service-Machine-Learning.png b/cloudexit/assets/icons/azure/ai-machine-learning/10166-icon-service-Machine-Learning.png similarity index 100% rename from assets/icons/azure/ai-machine-learning/10166-icon-service-Machine-Learning.png rename to cloudexit/assets/icons/azure/ai-machine-learning/10166-icon-service-Machine-Learning.png diff --git a/assets/icons/azure/ai-machine-learning/10167-icon-service-Machine-Learning-Studio-Workspaces.png b/cloudexit/assets/icons/azure/ai-machine-learning/10167-icon-service-Machine-Learning-Studio-Workspaces.png similarity index 100% rename from assets/icons/azure/ai-machine-learning/10167-icon-service-Machine-Learning-Studio-Workspaces.png rename to cloudexit/assets/icons/azure/ai-machine-learning/10167-icon-service-Machine-Learning-Studio-Workspaces.png diff --git a/assets/icons/azure/ai-machine-learning/10168-icon-service-Machine-Learning-Studio-Web-Service-Plans.png b/cloudexit/assets/icons/azure/ai-machine-learning/10168-icon-service-Machine-Learning-Studio-Web-Service-Plans.png similarity index 100% rename from assets/icons/azure/ai-machine-learning/10168-icon-service-Machine-Learning-Studio-Web-Service-Plans.png rename to cloudexit/assets/icons/azure/ai-machine-learning/10168-icon-service-Machine-Learning-Studio-Web-Service-Plans.png diff --git a/assets/icons/azure/analytics/00009-icon-service-Log-Analytics-Workspaces.png b/cloudexit/assets/icons/azure/analytics/00009-icon-service-Log-Analytics-Workspaces.png similarity index 100% rename from assets/icons/azure/analytics/00009-icon-service-Log-Analytics-Workspaces.png rename to cloudexit/assets/icons/azure/analytics/00009-icon-service-Log-Analytics-Workspaces.png diff --git a/assets/icons/azure/analytics/00039-icon-service-Event-Hubs.png b/cloudexit/assets/icons/azure/analytics/00039-icon-service-Event-Hubs.png similarity index 100% rename from assets/icons/azure/analytics/00039-icon-service-Event-Hubs.png rename to cloudexit/assets/icons/azure/analytics/00039-icon-service-Event-Hubs.png diff --git a/assets/icons/azure/analytics/00042-icon-service-Stream-Analytics-Jobs.png b/cloudexit/assets/icons/azure/analytics/00042-icon-service-Stream-Analytics-Jobs.png similarity index 100% rename from assets/icons/azure/analytics/00042-icon-service-Stream-Analytics-Jobs.png rename to cloudexit/assets/icons/azure/analytics/00042-icon-service-Stream-Analytics-Jobs.png diff --git a/assets/icons/azure/analytics/00562-icon-service-Endpoint-Analytics.png b/cloudexit/assets/icons/azure/analytics/00562-icon-service-Endpoint-Analytics.png similarity index 100% rename from assets/icons/azure/analytics/00562-icon-service-Endpoint-Analytics.png rename to cloudexit/assets/icons/azure/analytics/00562-icon-service-Endpoint-Analytics.png diff --git a/assets/icons/azure/analytics/00606-icon-service-Azure-Synapse-Analytics.png b/cloudexit/assets/icons/azure/analytics/00606-icon-service-Azure-Synapse-Analytics.png similarity index 100% rename from assets/icons/azure/analytics/00606-icon-service-Azure-Synapse-Analytics.png rename to cloudexit/assets/icons/azure/analytics/00606-icon-service-Azure-Synapse-Analytics.png diff --git a/assets/icons/azure/analytics/02189-icon-service-Azure-Workbooks.png b/cloudexit/assets/icons/azure/analytics/02189-icon-service-Azure-Workbooks.png similarity index 100% rename from assets/icons/azure/analytics/02189-icon-service-Azure-Workbooks.png rename to cloudexit/assets/icons/azure/analytics/02189-icon-service-Azure-Workbooks.png diff --git a/assets/icons/azure/analytics/02209-icon-service-Private-Link-Services.png b/cloudexit/assets/icons/azure/analytics/02209-icon-service-Private-Link-Services.png similarity index 100% rename from assets/icons/azure/analytics/02209-icon-service-Private-Link-Services.png rename to cloudexit/assets/icons/azure/analytics/02209-icon-service-Private-Link-Services.png diff --git a/assets/icons/azure/analytics/03332-icon-service-Power-BI-Embedded.png b/cloudexit/assets/icons/azure/analytics/03332-icon-service-Power-BI-Embedded.png similarity index 100% rename from assets/icons/azure/analytics/03332-icon-service-Power-BI-Embedded.png rename to cloudexit/assets/icons/azure/analytics/03332-icon-service-Power-BI-Embedded.png diff --git a/assets/icons/azure/analytics/03335-icon-service-Power-Platform.png b/cloudexit/assets/icons/azure/analytics/03335-icon-service-Power-Platform.png similarity index 100% rename from assets/icons/azure/analytics/03335-icon-service-Power-Platform.png rename to cloudexit/assets/icons/azure/analytics/03335-icon-service-Power-Platform.png diff --git a/assets/icons/azure/analytics/10126-icon-service-Data-Factories.png b/cloudexit/assets/icons/azure/analytics/10126-icon-service-Data-Factories.png similarity index 100% rename from assets/icons/azure/analytics/10126-icon-service-Data-Factories.png rename to cloudexit/assets/icons/azure/analytics/10126-icon-service-Data-Factories.png diff --git a/assets/icons/azure/analytics/10142-icon-service-HD-Insight-Clusters.png b/cloudexit/assets/icons/azure/analytics/10142-icon-service-HD-Insight-Clusters.png similarity index 100% rename from assets/icons/azure/analytics/10142-icon-service-HD-Insight-Clusters.png rename to cloudexit/assets/icons/azure/analytics/10142-icon-service-HD-Insight-Clusters.png diff --git a/assets/icons/azure/analytics/10143-icon-service-Data-Lake-Analytics.png b/cloudexit/assets/icons/azure/analytics/10143-icon-service-Data-Lake-Analytics.png similarity index 100% rename from assets/icons/azure/analytics/10143-icon-service-Data-Lake-Analytics.png rename to cloudexit/assets/icons/azure/analytics/10143-icon-service-Data-Lake-Analytics.png diff --git a/assets/icons/azure/analytics/10145-icon-service-Azure-Data-Explorer-Clusters.png b/cloudexit/assets/icons/azure/analytics/10145-icon-service-Azure-Data-Explorer-Clusters.png similarity index 100% rename from assets/icons/azure/analytics/10145-icon-service-Azure-Data-Explorer-Clusters.png rename to cloudexit/assets/icons/azure/analytics/10145-icon-service-Azure-Data-Explorer-Clusters.png diff --git a/assets/icons/azure/analytics/10148-icon-service-Analysis-Services.png b/cloudexit/assets/icons/azure/analytics/10148-icon-service-Analysis-Services.png similarity index 100% rename from assets/icons/azure/analytics/10148-icon-service-Analysis-Services.png rename to cloudexit/assets/icons/azure/analytics/10148-icon-service-Analysis-Services.png diff --git a/assets/icons/azure/analytics/10149-icon-service-Event-Hub-Clusters.png b/cloudexit/assets/icons/azure/analytics/10149-icon-service-Event-Hub-Clusters.png similarity index 100% rename from assets/icons/azure/analytics/10149-icon-service-Event-Hub-Clusters.png rename to cloudexit/assets/icons/azure/analytics/10149-icon-service-Event-Hub-Clusters.png diff --git a/assets/icons/azure/analytics/10150-icon-service-Data-Lake-Store-Gen1.png b/cloudexit/assets/icons/azure/analytics/10150-icon-service-Data-Lake-Store-Gen1.png similarity index 100% rename from assets/icons/azure/analytics/10150-icon-service-Data-Lake-Store-Gen1.png rename to cloudexit/assets/icons/azure/analytics/10150-icon-service-Data-Lake-Store-Gen1.png diff --git a/assets/icons/azure/analytics/10787-icon-service-Azure-Databricks.png b/cloudexit/assets/icons/azure/analytics/10787-icon-service-Azure-Databricks.png similarity index 100% rename from assets/icons/azure/analytics/10787-icon-service-Azure-Databricks.png rename to cloudexit/assets/icons/azure/analytics/10787-icon-service-Azure-Databricks.png diff --git a/assets/icons/azure/app-services/00046-icon-service-App-Service-Plans.png b/cloudexit/assets/icons/azure/app-services/00046-icon-service-App-Service-Plans.png similarity index 100% rename from assets/icons/azure/app-services/00046-icon-service-App-Service-Plans.png rename to cloudexit/assets/icons/azure/app-services/00046-icon-service-App-Service-Plans.png diff --git a/assets/icons/azure/app-services/00049-icon-service-App-Service-Certificates.png b/cloudexit/assets/icons/azure/app-services/00049-icon-service-App-Service-Certificates.png similarity index 100% rename from assets/icons/azure/app-services/00049-icon-service-App-Service-Certificates.png rename to cloudexit/assets/icons/azure/app-services/00049-icon-service-App-Service-Certificates.png diff --git a/assets/icons/azure/app-services/00050-icon-service-App-Service-Domains.png b/cloudexit/assets/icons/azure/app-services/00050-icon-service-App-Service-Domains.png similarity index 100% rename from assets/icons/azure/app-services/00050-icon-service-App-Service-Domains.png rename to cloudexit/assets/icons/azure/app-services/00050-icon-service-App-Service-Domains.png diff --git a/assets/icons/azure/app-services/00056-icon-service-CDN-Profiles.png b/cloudexit/assets/icons/azure/app-services/00056-icon-service-CDN-Profiles.png similarity index 100% rename from assets/icons/azure/app-services/00056-icon-service-CDN-Profiles.png rename to cloudexit/assets/icons/azure/app-services/00056-icon-service-CDN-Profiles.png diff --git a/assets/icons/azure/app-services/10035-icon-service-App-Services.png b/cloudexit/assets/icons/azure/app-services/10035-icon-service-App-Services.png similarity index 100% rename from assets/icons/azure/app-services/10035-icon-service-App-Services.png rename to cloudexit/assets/icons/azure/app-services/10035-icon-service-App-Services.png diff --git a/assets/icons/azure/app-services/10044-icon-service-Cognitive-Search.png b/cloudexit/assets/icons/azure/app-services/10044-icon-service-Cognitive-Search.png similarity index 100% rename from assets/icons/azure/app-services/10044-icon-service-Cognitive-Search.png rename to cloudexit/assets/icons/azure/app-services/10044-icon-service-Cognitive-Search.png diff --git a/assets/icons/azure/app-services/10045-icon-service-Notification-Hubs.png b/cloudexit/assets/icons/azure/app-services/10045-icon-service-Notification-Hubs.png similarity index 100% rename from assets/icons/azure/app-services/10045-icon-service-Notification-Hubs.png rename to cloudexit/assets/icons/azure/app-services/10045-icon-service-Notification-Hubs.png diff --git a/assets/icons/azure/app-services/10047-icon-service-App-Service-Environments.png b/cloudexit/assets/icons/azure/app-services/10047-icon-service-App-Service-Environments.png similarity index 100% rename from assets/icons/azure/app-services/10047-icon-service-App-Service-Environments.png rename to cloudexit/assets/icons/azure/app-services/10047-icon-service-App-Service-Environments.png diff --git a/assets/icons/azure/azure ecosystem/01038-icon-service-Collaborative-Service.png b/cloudexit/assets/icons/azure/azure ecosystem/01038-icon-service-Collaborative-Service.png similarity index 100% rename from assets/icons/azure/azure ecosystem/01038-icon-service-Collaborative-Service.png rename to cloudexit/assets/icons/azure/azure ecosystem/01038-icon-service-Collaborative-Service.png diff --git a/assets/icons/azure/azure ecosystem/02354-icon-service-Applens.png b/cloudexit/assets/icons/azure/azure ecosystem/02354-icon-service-Applens.png similarity index 100% rename from assets/icons/azure/azure ecosystem/02354-icon-service-Applens.png rename to cloudexit/assets/icons/azure/azure ecosystem/02354-icon-service-Applens.png diff --git a/assets/icons/azure/azure ecosystem/02573-icon-service-Azure-Hybrid-Center.png b/cloudexit/assets/icons/azure/azure ecosystem/02573-icon-service-Azure-Hybrid-Center.png similarity index 100% rename from assets/icons/azure/azure ecosystem/02573-icon-service-Azure-Hybrid-Center.png rename to cloudexit/assets/icons/azure/azure ecosystem/02573-icon-service-Azure-Hybrid-Center.png diff --git a/assets/icons/azure/azure stack/00965-icon-service-Multi-Tenancy.png b/cloudexit/assets/icons/azure/azure stack/00965-icon-service-Multi-Tenancy.png similarity index 100% rename from assets/icons/azure/azure stack/00965-icon-service-Multi-Tenancy.png rename to cloudexit/assets/icons/azure/azure stack/00965-icon-service-Multi-Tenancy.png diff --git a/assets/icons/azure/azure stack/10108-icon-service-Infrastructure-Backup.png b/cloudexit/assets/icons/azure/azure stack/10108-icon-service-Infrastructure-Backup.png similarity index 100% rename from assets/icons/azure/azure stack/10108-icon-service-Infrastructure-Backup.png rename to cloudexit/assets/icons/azure/azure stack/10108-icon-service-Infrastructure-Backup.png diff --git a/assets/icons/azure/azure stack/10109-icon-service-Capacity.png b/cloudexit/assets/icons/azure/azure stack/10109-icon-service-Capacity.png similarity index 100% rename from assets/icons/azure/azure stack/10109-icon-service-Capacity.png rename to cloudexit/assets/icons/azure/azure stack/10109-icon-service-Capacity.png diff --git a/assets/icons/azure/azure stack/10110-icon-service-Offers.png b/cloudexit/assets/icons/azure/azure stack/10110-icon-service-Offers.png similarity index 100% rename from assets/icons/azure/azure stack/10110-icon-service-Offers.png rename to cloudexit/assets/icons/azure/azure stack/10110-icon-service-Offers.png diff --git a/assets/icons/azure/azure stack/10111-icon-service-User-Subscriptions.png b/cloudexit/assets/icons/azure/azure stack/10111-icon-service-User-Subscriptions.png similarity index 100% rename from assets/icons/azure/azure stack/10111-icon-service-User-Subscriptions.png rename to cloudexit/assets/icons/azure/azure stack/10111-icon-service-User-Subscriptions.png diff --git a/assets/icons/azure/azure stack/10113-icon-service-Plans.png b/cloudexit/assets/icons/azure/azure stack/10113-icon-service-Plans.png similarity index 100% rename from assets/icons/azure/azure stack/10113-icon-service-Plans.png rename to cloudexit/assets/icons/azure/azure stack/10113-icon-service-Plans.png diff --git a/assets/icons/azure/azure stack/10115-icon-service-Updates.png b/cloudexit/assets/icons/azure/azure stack/10115-icon-service-Updates.png similarity index 100% rename from assets/icons/azure/azure stack/10115-icon-service-Updates.png rename to cloudexit/assets/icons/azure/azure stack/10115-icon-service-Updates.png diff --git a/assets/icons/azure/blockchain/10210-icon-service-Blockchain-Applications.png b/cloudexit/assets/icons/azure/blockchain/10210-icon-service-Blockchain-Applications.png similarity index 100% rename from assets/icons/azure/blockchain/10210-icon-service-Blockchain-Applications.png rename to cloudexit/assets/icons/azure/blockchain/10210-icon-service-Blockchain-Applications.png diff --git a/assets/icons/azure/blockchain/10364-icon-service-Outbound-Connection.png b/cloudexit/assets/icons/azure/blockchain/10364-icon-service-Outbound-Connection.png similarity index 100% rename from assets/icons/azure/blockchain/10364-icon-service-Outbound-Connection.png rename to cloudexit/assets/icons/azure/blockchain/10364-icon-service-Outbound-Connection.png diff --git a/assets/icons/azure/blockchain/10366-icon-service-Azure-Blockchain-Service.png b/cloudexit/assets/icons/azure/blockchain/10366-icon-service-Azure-Blockchain-Service.png similarity index 100% rename from assets/icons/azure/blockchain/10366-icon-service-Azure-Blockchain-Service.png rename to cloudexit/assets/icons/azure/blockchain/10366-icon-service-Azure-Blockchain-Service.png diff --git a/assets/icons/azure/blockchain/10367-icon-service-Azure-Token-Service.png b/cloudexit/assets/icons/azure/blockchain/10367-icon-service-Azure-Token-Service.png similarity index 100% rename from assets/icons/azure/blockchain/10367-icon-service-Azure-Token-Service.png rename to cloudexit/assets/icons/azure/blockchain/10367-icon-service-Azure-Token-Service.png diff --git a/assets/icons/azure/blockchain/10374-icon-service-ABS-Member.png b/cloudexit/assets/icons/azure/blockchain/10374-icon-service-ABS-Member.png similarity index 100% rename from assets/icons/azure/blockchain/10374-icon-service-ABS-Member.png rename to cloudexit/assets/icons/azure/blockchain/10374-icon-service-ABS-Member.png diff --git a/assets/icons/azure/blockchain/10375-icon-service-Consortium.png b/cloudexit/assets/icons/azure/blockchain/10375-icon-service-Consortium.png similarity index 100% rename from assets/icons/azure/blockchain/10375-icon-service-Consortium.png rename to cloudexit/assets/icons/azure/blockchain/10375-icon-service-Consortium.png diff --git a/assets/icons/azure/compute/00195-icon-service-Maintenance-Configuration.png b/cloudexit/assets/icons/azure/compute/00195-icon-service-Maintenance-Configuration.png similarity index 100% rename from assets/icons/azure/compute/00195-icon-service-Maintenance-Configuration.png rename to cloudexit/assets/icons/azure/compute/00195-icon-service-Maintenance-Configuration.png diff --git a/assets/icons/azure/compute/00328-icon-service-Host-Pools.png b/cloudexit/assets/icons/azure/compute/00328-icon-service-Host-Pools.png similarity index 100% rename from assets/icons/azure/compute/00328-icon-service-Host-Pools.png rename to cloudexit/assets/icons/azure/compute/00328-icon-service-Host-Pools.png diff --git a/assets/icons/azure/compute/00329-icon-service-Application-Group.png b/cloudexit/assets/icons/azure/compute/00329-icon-service-Application-Group.png similarity index 100% rename from assets/icons/azure/compute/00329-icon-service-Application-Group.png rename to cloudexit/assets/icons/azure/compute/00329-icon-service-Application-Group.png diff --git a/assets/icons/azure/compute/00330-icon-service-Workspaces.png b/cloudexit/assets/icons/azure/compute/00330-icon-service-Workspaces.png similarity index 100% rename from assets/icons/azure/compute/00330-icon-service-Workspaces.png rename to cloudexit/assets/icons/azure/compute/00330-icon-service-Workspaces.png diff --git a/assets/icons/azure/compute/00398-icon-service-Disk-Encryption-Sets.png b/cloudexit/assets/icons/azure/compute/00398-icon-service-Disk-Encryption-Sets.png similarity index 100% rename from assets/icons/azure/compute/00398-icon-service-Disk-Encryption-Sets.png rename to cloudexit/assets/icons/azure/compute/00398-icon-service-Disk-Encryption-Sets.png diff --git a/assets/icons/azure/compute/00400-icon-service-Workspaces.png b/cloudexit/assets/icons/azure/compute/00400-icon-service-Workspaces.png similarity index 100% rename from assets/icons/azure/compute/00400-icon-service-Workspaces.png rename to cloudexit/assets/icons/azure/compute/00400-icon-service-Workspaces.png diff --git a/assets/icons/azure/compute/02112-icon-service-Automanaged-VM.png b/cloudexit/assets/icons/azure/compute/02112-icon-service-Automanaged-VM.png similarity index 100% rename from assets/icons/azure/compute/02112-icon-service-Automanaged-VM.png rename to cloudexit/assets/icons/azure/compute/02112-icon-service-Automanaged-VM.png diff --git a/assets/icons/azure/compute/02370-icon-service-Managed-Service-Fabric.png b/cloudexit/assets/icons/azure/compute/02370-icon-service-Managed-Service-Fabric.png similarity index 100% rename from assets/icons/azure/compute/02370-icon-service-Managed-Service-Fabric.png rename to cloudexit/assets/icons/azure/compute/02370-icon-service-Managed-Service-Fabric.png diff --git a/assets/icons/azure/compute/02409-icon-service-Metrics-Advisor.png b/cloudexit/assets/icons/azure/compute/02409-icon-service-Metrics-Advisor.png similarity index 100% rename from assets/icons/azure/compute/02409-icon-service-Metrics-Advisor.png rename to cloudexit/assets/icons/azure/compute/02409-icon-service-Metrics-Advisor.png diff --git a/assets/icons/azure/compute/02634-icon-service-Image-Templates.png b/cloudexit/assets/icons/azure/compute/02634-icon-service-Image-Templates.png similarity index 100% rename from assets/icons/azure/compute/02634-icon-service-Image-Templates.png rename to cloudexit/assets/icons/azure/compute/02634-icon-service-Image-Templates.png diff --git a/assets/icons/azure/compute/02817-icon-service-Restore-Points.png b/cloudexit/assets/icons/azure/compute/02817-icon-service-Restore-Points.png similarity index 100% rename from assets/icons/azure/compute/02817-icon-service-Restore-Points.png rename to cloudexit/assets/icons/azure/compute/02817-icon-service-Restore-Points.png diff --git a/assets/icons/azure/compute/02818-icon-service-Restore-Points-Collections.png b/cloudexit/assets/icons/azure/compute/02818-icon-service-Restore-Points-Collections.png similarity index 100% rename from assets/icons/azure/compute/02818-icon-service-Restore-Points-Collections.png rename to cloudexit/assets/icons/azure/compute/02818-icon-service-Restore-Points-Collections.png diff --git a/assets/icons/azure/compute/02864-icon-service-Azure-Compute-Galleries.png b/cloudexit/assets/icons/azure/compute/02864-icon-service-Azure-Compute-Galleries.png similarity index 100% rename from assets/icons/azure/compute/02864-icon-service-Azure-Compute-Galleries.png rename to cloudexit/assets/icons/azure/compute/02864-icon-service-Azure-Compute-Galleries.png diff --git a/assets/icons/azure/compute/030868929-icon-service-Stage-Maps.png b/cloudexit/assets/icons/azure/compute/030868929-icon-service-Stage-Maps.png similarity index 100% rename from assets/icons/azure/compute/030868929-icon-service-Stage-Maps.png rename to cloudexit/assets/icons/azure/compute/030868929-icon-service-Stage-Maps.png diff --git a/assets/icons/azure/compute/033049334-icon-service-Scheduled-Actions.png b/cloudexit/assets/icons/azure/compute/033049334-icon-service-Scheduled-Actions.png similarity index 100% rename from assets/icons/azure/compute/033049334-icon-service-Scheduled-Actions.png rename to cloudexit/assets/icons/azure/compute/033049334-icon-service-Scheduled-Actions.png diff --git a/assets/icons/azure/compute/03487-icon-service-Compute-Fleet.png b/cloudexit/assets/icons/azure/compute/03487-icon-service-Compute-Fleet.png similarity index 100% rename from assets/icons/azure/compute/03487-icon-service-Compute-Fleet.png rename to cloudexit/assets/icons/azure/compute/03487-icon-service-Compute-Fleet.png diff --git a/assets/icons/azure/compute/03543-icon-service-AKS-Automatic.png b/cloudexit/assets/icons/azure/compute/03543-icon-service-AKS-Automatic.png similarity index 100% rename from assets/icons/azure/compute/03543-icon-service-AKS-Automatic.png rename to cloudexit/assets/icons/azure/compute/03543-icon-service-AKS-Automatic.png diff --git a/assets/icons/azure/compute/03645-icon-service-Azure-Linux.png b/cloudexit/assets/icons/azure/compute/03645-icon-service-Azure-Linux.png similarity index 100% rename from assets/icons/azure/compute/03645-icon-service-Azure-Linux.png rename to cloudexit/assets/icons/azure/compute/03645-icon-service-Azure-Linux.png diff --git a/assets/icons/azure/compute/10021-icon-service-Virtual-Machine.png b/cloudexit/assets/icons/azure/compute/10021-icon-service-Virtual-Machine.png similarity index 100% rename from assets/icons/azure/compute/10021-icon-service-Virtual-Machine.png rename to cloudexit/assets/icons/azure/compute/10021-icon-service-Virtual-Machine.png diff --git a/assets/icons/azure/compute/10023-icon-service-Kubernetes-Services.png b/cloudexit/assets/icons/azure/compute/10023-icon-service-Kubernetes-Services.png similarity index 100% rename from assets/icons/azure/compute/10023-icon-service-Kubernetes-Services.png rename to cloudexit/assets/icons/azure/compute/10023-icon-service-Kubernetes-Services.png diff --git a/assets/icons/azure/compute/10024-icon-service-Mesh-Applications.png b/cloudexit/assets/icons/azure/compute/10024-icon-service-Mesh-Applications.png similarity index 100% rename from assets/icons/azure/compute/10024-icon-service-Mesh-Applications.png rename to cloudexit/assets/icons/azure/compute/10024-icon-service-Mesh-Applications.png diff --git a/assets/icons/azure/compute/10025-icon-service-Availability-Sets.png b/cloudexit/assets/icons/azure/compute/10025-icon-service-Availability-Sets.png similarity index 100% rename from assets/icons/azure/compute/10025-icon-service-Availability-Sets.png rename to cloudexit/assets/icons/azure/compute/10025-icon-service-Availability-Sets.png diff --git a/assets/icons/azure/compute/10026-icon-service-Disks-Snapshots.png b/cloudexit/assets/icons/azure/compute/10026-icon-service-Disks-Snapshots.png similarity index 100% rename from assets/icons/azure/compute/10026-icon-service-Disks-Snapshots.png rename to cloudexit/assets/icons/azure/compute/10026-icon-service-Disks-Snapshots.png diff --git a/assets/icons/azure/compute/10027-icon-service-OS-Images-(Classic).png b/cloudexit/assets/icons/azure/compute/10027-icon-service-OS-Images-(Classic).png similarity index 100% rename from assets/icons/azure/compute/10027-icon-service-OS-Images-(Classic).png rename to cloudexit/assets/icons/azure/compute/10027-icon-service-OS-Images-(Classic).png diff --git a/assets/icons/azure/compute/10028-icon-service-Virtual-Machines-(Classic).png b/cloudexit/assets/icons/azure/compute/10028-icon-service-Virtual-Machines-(Classic).png similarity index 100% rename from assets/icons/azure/compute/10028-icon-service-Virtual-Machines-(Classic).png rename to cloudexit/assets/icons/azure/compute/10028-icon-service-Virtual-Machines-(Classic).png diff --git a/assets/icons/azure/compute/10029-icon-service-Function-Apps.png b/cloudexit/assets/icons/azure/compute/10029-icon-service-Function-Apps.png similarity index 100% rename from assets/icons/azure/compute/10029-icon-service-Function-Apps.png rename to cloudexit/assets/icons/azure/compute/10029-icon-service-Function-Apps.png diff --git a/assets/icons/azure/compute/10030-icon-service-Cloud-Services-(Classic).png b/cloudexit/assets/icons/azure/compute/10030-icon-service-Cloud-Services-(Classic).png similarity index 100% rename from assets/icons/azure/compute/10030-icon-service-Cloud-Services-(Classic).png rename to cloudexit/assets/icons/azure/compute/10030-icon-service-Cloud-Services-(Classic).png diff --git a/assets/icons/azure/compute/10031-icon-service-Batch-Accounts.png b/cloudexit/assets/icons/azure/compute/10031-icon-service-Batch-Accounts.png similarity index 100% rename from assets/icons/azure/compute/10031-icon-service-Batch-Accounts.png rename to cloudexit/assets/icons/azure/compute/10031-icon-service-Batch-Accounts.png diff --git a/assets/icons/azure/compute/10032-icon-service-Disks.png b/cloudexit/assets/icons/azure/compute/10032-icon-service-Disks.png similarity index 100% rename from assets/icons/azure/compute/10032-icon-service-Disks.png rename to cloudexit/assets/icons/azure/compute/10032-icon-service-Disks.png diff --git a/assets/icons/azure/compute/10033-icon-service-Images.png b/cloudexit/assets/icons/azure/compute/10033-icon-service-Images.png similarity index 100% rename from assets/icons/azure/compute/10033-icon-service-Images.png rename to cloudexit/assets/icons/azure/compute/10033-icon-service-Images.png diff --git a/assets/icons/azure/compute/10034-icon-service-VM-Scale-Sets.png b/cloudexit/assets/icons/azure/compute/10034-icon-service-VM-Scale-Sets.png similarity index 100% rename from assets/icons/azure/compute/10034-icon-service-VM-Scale-Sets.png rename to cloudexit/assets/icons/azure/compute/10034-icon-service-VM-Scale-Sets.png diff --git a/assets/icons/azure/compute/10035-icon-service-App-Services.png b/cloudexit/assets/icons/azure/compute/10035-icon-service-App-Services.png similarity index 100% rename from assets/icons/azure/compute/10035-icon-service-App-Services.png rename to cloudexit/assets/icons/azure/compute/10035-icon-service-App-Services.png diff --git a/assets/icons/azure/compute/10036-icon-service-Service-Fabric-Clusters.png b/cloudexit/assets/icons/azure/compute/10036-icon-service-Service-Fabric-Clusters.png similarity index 100% rename from assets/icons/azure/compute/10036-icon-service-Service-Fabric-Clusters.png rename to cloudexit/assets/icons/azure/compute/10036-icon-service-Service-Fabric-Clusters.png diff --git a/assets/icons/azure/compute/10037-icon-service-Image-Definitions.png b/cloudexit/assets/icons/azure/compute/10037-icon-service-Image-Definitions.png similarity index 100% rename from assets/icons/azure/compute/10037-icon-service-Image-Definitions.png rename to cloudexit/assets/icons/azure/compute/10037-icon-service-Image-Definitions.png diff --git a/assets/icons/azure/compute/10038-icon-service-Image-Versions.png b/cloudexit/assets/icons/azure/compute/10038-icon-service-Image-Versions.png similarity index 100% rename from assets/icons/azure/compute/10038-icon-service-Image-Versions.png rename to cloudexit/assets/icons/azure/compute/10038-icon-service-Image-Versions.png diff --git a/assets/icons/azure/compute/10039-icon-service-Shared-Image-Galleries.png b/cloudexit/assets/icons/azure/compute/10039-icon-service-Shared-Image-Galleries.png similarity index 100% rename from assets/icons/azure/compute/10039-icon-service-Shared-Image-Galleries.png rename to cloudexit/assets/icons/azure/compute/10039-icon-service-Shared-Image-Galleries.png diff --git a/assets/icons/azure/compute/10040-icon-service-VM-Images-(Classic).png b/cloudexit/assets/icons/azure/compute/10040-icon-service-VM-Images-(Classic).png similarity index 100% rename from assets/icons/azure/compute/10040-icon-service-VM-Images-(Classic).png rename to cloudexit/assets/icons/azure/compute/10040-icon-service-VM-Images-(Classic).png diff --git a/assets/icons/azure/compute/10041-icon-service-Disks-(Classic).png b/cloudexit/assets/icons/azure/compute/10041-icon-service-Disks-(Classic).png similarity index 100% rename from assets/icons/azure/compute/10041-icon-service-Disks-(Classic).png rename to cloudexit/assets/icons/azure/compute/10041-icon-service-Disks-(Classic).png diff --git a/assets/icons/azure/compute/10049-icon-service-Container-Services-(Deprecated).png b/cloudexit/assets/icons/azure/compute/10049-icon-service-Container-Services-(Deprecated).png similarity index 100% rename from assets/icons/azure/compute/10049-icon-service-Container-Services-(Deprecated).png rename to cloudexit/assets/icons/azure/compute/10049-icon-service-Container-Services-(Deprecated).png diff --git a/assets/icons/azure/compute/10104-icon-service-Container-Instances.png b/cloudexit/assets/icons/azure/compute/10104-icon-service-Container-Instances.png similarity index 100% rename from assets/icons/azure/compute/10104-icon-service-Container-Instances.png rename to cloudexit/assets/icons/azure/compute/10104-icon-service-Container-Instances.png diff --git a/assets/icons/azure/compute/10346-icon-service-Host-Groups.png b/cloudexit/assets/icons/azure/compute/10346-icon-service-Host-Groups.png similarity index 100% rename from assets/icons/azure/compute/10346-icon-service-Host-Groups.png rename to cloudexit/assets/icons/azure/compute/10346-icon-service-Host-Groups.png diff --git a/assets/icons/azure/compute/10347-icon-service-Hosts.png b/cloudexit/assets/icons/azure/compute/10347-icon-service-Hosts.png similarity index 100% rename from assets/icons/azure/compute/10347-icon-service-Hosts.png rename to cloudexit/assets/icons/azure/compute/10347-icon-service-Hosts.png diff --git a/assets/icons/azure/compute/10370-icon-service-Azure-Spring-Apps.png b/cloudexit/assets/icons/azure/compute/10370-icon-service-Azure-Spring-Apps.png similarity index 100% rename from assets/icons/azure/compute/10370-icon-service-Azure-Spring-Apps.png rename to cloudexit/assets/icons/azure/compute/10370-icon-service-Azure-Spring-Apps.png diff --git a/assets/icons/azure/containers/029637767-icon-service-Service-Groups.png b/cloudexit/assets/icons/azure/containers/029637767-icon-service-Service-Groups.png similarity index 100% rename from assets/icons/azure/containers/029637767-icon-service-Service-Groups.png rename to cloudexit/assets/icons/azure/containers/029637767-icon-service-Service-Groups.png diff --git a/assets/icons/azure/containers/032487601-icon-service-AKS-Network-Policy.png b/cloudexit/assets/icons/azure/containers/032487601-icon-service-AKS-Network-Policy.png similarity index 100% rename from assets/icons/azure/containers/032487601-icon-service-AKS-Network-Policy.png rename to cloudexit/assets/icons/azure/containers/032487601-icon-service-AKS-Network-Policy.png diff --git a/assets/icons/azure/containers/03331-icon-service-Azure-Red-Hat-OpenShift.png b/cloudexit/assets/icons/azure/containers/03331-icon-service-Azure-Red-Hat-OpenShift.png similarity index 100% rename from assets/icons/azure/containers/03331-icon-service-Azure-Red-Hat-OpenShift.png rename to cloudexit/assets/icons/azure/containers/03331-icon-service-Azure-Red-Hat-OpenShift.png diff --git a/assets/icons/azure/containers/033545113-icon-service-Kubernetes-Hub.png b/cloudexit/assets/icons/azure/containers/033545113-icon-service-Kubernetes-Hub.png similarity index 100% rename from assets/icons/azure/containers/033545113-icon-service-Kubernetes-Hub.png rename to cloudexit/assets/icons/azure/containers/033545113-icon-service-Kubernetes-Hub.png diff --git a/assets/icons/azure/containers/03401-icon-service-Azure-Container-Storage.png b/cloudexit/assets/icons/azure/containers/03401-icon-service-Azure-Container-Storage.png similarity index 100% rename from assets/icons/azure/containers/03401-icon-service-Azure-Container-Storage.png rename to cloudexit/assets/icons/azure/containers/03401-icon-service-Azure-Container-Storage.png diff --git a/assets/icons/azure/containers/10023-icon-service-Kubernetes-Services.png b/cloudexit/assets/icons/azure/containers/10023-icon-service-Kubernetes-Services.png similarity index 100% rename from assets/icons/azure/containers/10023-icon-service-Kubernetes-Services.png rename to cloudexit/assets/icons/azure/containers/10023-icon-service-Kubernetes-Services.png diff --git a/assets/icons/azure/containers/10031-icon-service-Batch-Accounts.png b/cloudexit/assets/icons/azure/containers/10031-icon-service-Batch-Accounts.png similarity index 100% rename from assets/icons/azure/containers/10031-icon-service-Batch-Accounts.png rename to cloudexit/assets/icons/azure/containers/10031-icon-service-Batch-Accounts.png diff --git a/assets/icons/azure/containers/10035-icon-service-App-Services.png b/cloudexit/assets/icons/azure/containers/10035-icon-service-App-Services.png similarity index 100% rename from assets/icons/azure/containers/10035-icon-service-App-Services.png rename to cloudexit/assets/icons/azure/containers/10035-icon-service-App-Services.png diff --git a/assets/icons/azure/containers/10036-icon-service-Service-Fabric-Clusters.png b/cloudexit/assets/icons/azure/containers/10036-icon-service-Service-Fabric-Clusters.png similarity index 100% rename from assets/icons/azure/containers/10036-icon-service-Service-Fabric-Clusters.png rename to cloudexit/assets/icons/azure/containers/10036-icon-service-Service-Fabric-Clusters.png diff --git a/assets/icons/azure/containers/10104-icon-service-Container-Instances.png b/cloudexit/assets/icons/azure/containers/10104-icon-service-Container-Instances.png similarity index 100% rename from assets/icons/azure/containers/10104-icon-service-Container-Instances.png rename to cloudexit/assets/icons/azure/containers/10104-icon-service-Container-Instances.png diff --git a/assets/icons/azure/containers/10105-icon-service-Container-Registries.png b/cloudexit/assets/icons/azure/containers/10105-icon-service-Container-Registries.png similarity index 100% rename from assets/icons/azure/containers/10105-icon-service-Container-Registries.png rename to cloudexit/assets/icons/azure/containers/10105-icon-service-Container-Registries.png diff --git a/assets/icons/azure/databases/00036-icon-service-SQL-Data-Warehouses.png b/cloudexit/assets/icons/azure/databases/00036-icon-service-SQL-Data-Warehouses.png similarity index 100% rename from assets/icons/azure/databases/00036-icon-service-SQL-Data-Warehouses.png rename to cloudexit/assets/icons/azure/databases/00036-icon-service-SQL-Data-Warehouses.png diff --git a/assets/icons/azure/databases/00606-icon-service-Azure-Synapse-Analytics.png b/cloudexit/assets/icons/azure/databases/00606-icon-service-Azure-Synapse-Analytics.png similarity index 100% rename from assets/icons/azure/databases/00606-icon-service-Azure-Synapse-Analytics.png rename to cloudexit/assets/icons/azure/databases/00606-icon-service-Azure-Synapse-Analytics.png diff --git a/assets/icons/azure/databases/02390-icon-service-Azure-SQL.png b/cloudexit/assets/icons/azure/databases/02390-icon-service-Azure-SQL.png similarity index 100% rename from assets/icons/azure/databases/02390-icon-service-Azure-SQL.png rename to cloudexit/assets/icons/azure/databases/02390-icon-service-Azure-SQL.png diff --git a/assets/icons/azure/databases/02392-icon-service-SSIS-Lift-And-Shift-IR.png b/cloudexit/assets/icons/azure/databases/02392-icon-service-SSIS-Lift-And-Shift-IR.png similarity index 100% rename from assets/icons/azure/databases/02392-icon-service-SSIS-Lift-And-Shift-IR.png rename to cloudexit/assets/icons/azure/databases/02392-icon-service-SSIS-Lift-And-Shift-IR.png diff --git a/assets/icons/azure/databases/02517-icon-service-Azure-Purview-Accounts.png b/cloudexit/assets/icons/azure/databases/02517-icon-service-Azure-Purview-Accounts.png similarity index 100% rename from assets/icons/azure/databases/02517-icon-service-Azure-Purview-Accounts.png rename to cloudexit/assets/icons/azure/databases/02517-icon-service-Azure-Purview-Accounts.png diff --git a/assets/icons/azure/databases/02750-icon-service-Azure-SQL-Edge.png b/cloudexit/assets/icons/azure/databases/02750-icon-service-Azure-SQL-Edge.png similarity index 100% rename from assets/icons/azure/databases/02750-icon-service-Azure-SQL-Edge.png rename to cloudexit/assets/icons/azure/databases/02750-icon-service-Azure-SQL-Edge.png diff --git a/assets/icons/azure/databases/02827-icon-service-Azure-Database-PostgreSQL-Server-Group.png b/cloudexit/assets/icons/azure/databases/02827-icon-service-Azure-Database-PostgreSQL-Server-Group.png similarity index 100% rename from assets/icons/azure/databases/02827-icon-service-Azure-Database-PostgreSQL-Server-Group.png rename to cloudexit/assets/icons/azure/databases/02827-icon-service-Azure-Database-PostgreSQL-Server-Group.png diff --git a/assets/icons/azure/databases/03490-icon-service-Oracle-Database.png b/cloudexit/assets/icons/azure/databases/03490-icon-service-Oracle-Database.png similarity index 100% rename from assets/icons/azure/databases/03490-icon-service-Oracle-Database.png rename to cloudexit/assets/icons/azure/databases/03490-icon-service-Oracle-Database.png diff --git a/assets/icons/azure/databases/03565-icon-service-SQL-Database-Fleet-Manager.png b/cloudexit/assets/icons/azure/databases/03565-icon-service-SQL-Database-Fleet-Manager.png similarity index 100% rename from assets/icons/azure/databases/03565-icon-service-SQL-Database-Fleet-Manager.png rename to cloudexit/assets/icons/azure/databases/03565-icon-service-SQL-Database-Fleet-Manager.png diff --git a/assets/icons/azure/databases/035656437-icon-service-Azure-DocumentDB.png b/cloudexit/assets/icons/azure/databases/035656437-icon-service-Azure-DocumentDB.png similarity index 100% rename from assets/icons/azure/databases/035656437-icon-service-Azure-DocumentDB.png rename to cloudexit/assets/icons/azure/databases/035656437-icon-service-Azure-DocumentDB.png diff --git a/assets/icons/azure/databases/03675-icon-service-Azure-Managed-Redis.png b/cloudexit/assets/icons/azure/databases/03675-icon-service-Azure-Managed-Redis.png similarity index 100% rename from assets/icons/azure/databases/03675-icon-service-Azure-Managed-Redis.png rename to cloudexit/assets/icons/azure/databases/03675-icon-service-Azure-Managed-Redis.png diff --git a/assets/icons/azure/databases/10121-icon-service-Azure-Cosmos-DB.png b/cloudexit/assets/icons/azure/databases/10121-icon-service-Azure-Cosmos-DB.png similarity index 100% rename from assets/icons/azure/databases/10121-icon-service-Azure-Cosmos-DB.png rename to cloudexit/assets/icons/azure/databases/10121-icon-service-Azure-Cosmos-DB.png diff --git a/assets/icons/azure/databases/10122-icon-service-Azure-Database-MySQL-Server.png b/cloudexit/assets/icons/azure/databases/10122-icon-service-Azure-Database-MySQL-Server.png similarity index 100% rename from assets/icons/azure/databases/10122-icon-service-Azure-Database-MySQL-Server.png rename to cloudexit/assets/icons/azure/databases/10122-icon-service-Azure-Database-MySQL-Server.png diff --git a/assets/icons/azure/databases/10123-icon-service-Azure-Database-MariaDB-Server.png b/cloudexit/assets/icons/azure/databases/10123-icon-service-Azure-Database-MariaDB-Server.png similarity index 100% rename from assets/icons/azure/databases/10123-icon-service-Azure-Database-MariaDB-Server.png rename to cloudexit/assets/icons/azure/databases/10123-icon-service-Azure-Database-MariaDB-Server.png diff --git a/assets/icons/azure/databases/10124-icon-service-Azure-SQL-VM.png b/cloudexit/assets/icons/azure/databases/10124-icon-service-Azure-SQL-VM.png similarity index 100% rename from assets/icons/azure/databases/10124-icon-service-Azure-SQL-VM.png rename to cloudexit/assets/icons/azure/databases/10124-icon-service-Azure-SQL-VM.png diff --git a/assets/icons/azure/databases/10126-icon-service-Data-Factories.png b/cloudexit/assets/icons/azure/databases/10126-icon-service-Data-Factories.png similarity index 100% rename from assets/icons/azure/databases/10126-icon-service-Data-Factories.png rename to cloudexit/assets/icons/azure/databases/10126-icon-service-Data-Factories.png diff --git a/assets/icons/azure/databases/10127-icon-service-Virtual-Clusters.png b/cloudexit/assets/icons/azure/databases/10127-icon-service-Virtual-Clusters.png similarity index 100% rename from assets/icons/azure/databases/10127-icon-service-Virtual-Clusters.png rename to cloudexit/assets/icons/azure/databases/10127-icon-service-Virtual-Clusters.png diff --git a/assets/icons/azure/databases/10128-icon-service-Elastic-Job-Agents.png b/cloudexit/assets/icons/azure/databases/10128-icon-service-Elastic-Job-Agents.png similarity index 100% rename from assets/icons/azure/databases/10128-icon-service-Elastic-Job-Agents.png rename to cloudexit/assets/icons/azure/databases/10128-icon-service-Elastic-Job-Agents.png diff --git a/assets/icons/azure/databases/10130-icon-service-SQL-Database.png b/cloudexit/assets/icons/azure/databases/10130-icon-service-SQL-Database.png similarity index 100% rename from assets/icons/azure/databases/10130-icon-service-SQL-Database.png rename to cloudexit/assets/icons/azure/databases/10130-icon-service-SQL-Database.png diff --git a/assets/icons/azure/databases/10131-icon-service-Azure-Database-PostgreSQL-Server.png b/cloudexit/assets/icons/azure/databases/10131-icon-service-Azure-Database-PostgreSQL-Server.png similarity index 100% rename from assets/icons/azure/databases/10131-icon-service-Azure-Database-PostgreSQL-Server.png rename to cloudexit/assets/icons/azure/databases/10131-icon-service-Azure-Database-PostgreSQL-Server.png diff --git a/assets/icons/azure/databases/10132-icon-service-SQL-Server.png b/cloudexit/assets/icons/azure/databases/10132-icon-service-SQL-Server.png similarity index 100% rename from assets/icons/azure/databases/10132-icon-service-SQL-Server.png rename to cloudexit/assets/icons/azure/databases/10132-icon-service-SQL-Server.png diff --git a/assets/icons/azure/databases/10133-icon-service-Azure-Database-Migration-Services.png b/cloudexit/assets/icons/azure/databases/10133-icon-service-Azure-Database-Migration-Services.png similarity index 100% rename from assets/icons/azure/databases/10133-icon-service-Azure-Database-Migration-Services.png rename to cloudexit/assets/icons/azure/databases/10133-icon-service-Azure-Database-Migration-Services.png diff --git a/assets/icons/azure/databases/10134-icon-service-SQL-Elastic-Pools.png b/cloudexit/assets/icons/azure/databases/10134-icon-service-SQL-Elastic-Pools.png similarity index 100% rename from assets/icons/azure/databases/10134-icon-service-SQL-Elastic-Pools.png rename to cloudexit/assets/icons/azure/databases/10134-icon-service-SQL-Elastic-Pools.png diff --git a/assets/icons/azure/databases/10135-icon-service-Managed-Database.png b/cloudexit/assets/icons/azure/databases/10135-icon-service-Managed-Database.png similarity index 100% rename from assets/icons/azure/databases/10135-icon-service-Managed-Database.png rename to cloudexit/assets/icons/azure/databases/10135-icon-service-Managed-Database.png diff --git a/assets/icons/azure/databases/10136-icon-service-SQL-Managed-Instance.png b/cloudexit/assets/icons/azure/databases/10136-icon-service-SQL-Managed-Instance.png similarity index 100% rename from assets/icons/azure/databases/10136-icon-service-SQL-Managed-Instance.png rename to cloudexit/assets/icons/azure/databases/10136-icon-service-SQL-Managed-Instance.png diff --git a/assets/icons/azure/databases/10137-icon-service-Azure-SQL-Server-Stretch-Databases.png b/cloudexit/assets/icons/azure/databases/10137-icon-service-Azure-SQL-Server-Stretch-Databases.png similarity index 100% rename from assets/icons/azure/databases/10137-icon-service-Azure-SQL-Server-Stretch-Databases.png rename to cloudexit/assets/icons/azure/databases/10137-icon-service-Azure-SQL-Server-Stretch-Databases.png diff --git a/assets/icons/azure/databases/10137-icon-service-Cache-Redis.png b/cloudexit/assets/icons/azure/databases/10137-icon-service-Cache-Redis.png similarity index 100% rename from assets/icons/azure/databases/10137-icon-service-Cache-Redis.png rename to cloudexit/assets/icons/azure/databases/10137-icon-service-Cache-Redis.png diff --git a/assets/icons/azure/databases/10139-icon-service-Instance-Pools.png b/cloudexit/assets/icons/azure/databases/10139-icon-service-Instance-Pools.png similarity index 100% rename from assets/icons/azure/databases/10139-icon-service-Instance-Pools.png rename to cloudexit/assets/icons/azure/databases/10139-icon-service-Instance-Pools.png diff --git a/assets/icons/azure/databases/10145-icon-service-Azure-Data-Explorer-Clusters.png b/cloudexit/assets/icons/azure/databases/10145-icon-service-Azure-Data-Explorer-Clusters.png similarity index 100% rename from assets/icons/azure/databases/10145-icon-service-Azure-Data-Explorer-Clusters.png rename to cloudexit/assets/icons/azure/databases/10145-icon-service-Azure-Data-Explorer-Clusters.png diff --git a/assets/icons/azure/databases/10351-icon-service-SQL-Server-Registries.png b/cloudexit/assets/icons/azure/databases/10351-icon-service-SQL-Server-Registries.png similarity index 100% rename from assets/icons/azure/databases/10351-icon-service-SQL-Server-Registries.png rename to cloudexit/assets/icons/azure/databases/10351-icon-service-SQL-Server-Registries.png diff --git a/assets/icons/azure/developer-tools/033805167-icon-service-Azure-App-Testing.png b/cloudexit/assets/icons/azure/developer-tools/033805167-icon-service-Azure-App-Testing.png similarity index 100% rename from assets/icons/azure/developer-tools/033805167-icon-service-Azure-App-Testing.png rename to cloudexit/assets/icons/azure/developer-tools/033805167-icon-service-Azure-App-Testing.png diff --git a/assets/icons/azure/devops/00012-icon-service-Application-Insights.png b/cloudexit/assets/icons/azure/devops/00012-icon-service-Application-Insights.png similarity index 100% rename from assets/icons/azure/devops/00012-icon-service-Application-Insights.png rename to cloudexit/assets/icons/azure/devops/00012-icon-service-Application-Insights.png diff --git a/assets/icons/azure/devops/00563-icon-service-Change-Analysis.png b/cloudexit/assets/icons/azure/devops/00563-icon-service-Change-Analysis.png similarity index 100% rename from assets/icons/azure/devops/00563-icon-service-Change-Analysis.png rename to cloudexit/assets/icons/azure/devops/00563-icon-service-Change-Analysis.png diff --git a/assets/icons/azure/devops/02373-icon-service-CloudTest.png b/cloudexit/assets/icons/azure/devops/02373-icon-service-CloudTest.png similarity index 100% rename from assets/icons/azure/devops/02373-icon-service-CloudTest.png rename to cloudexit/assets/icons/azure/devops/02373-icon-service-CloudTest.png diff --git a/assets/icons/azure/devops/02423-icon-service-Load-Testing.png b/cloudexit/assets/icons/azure/devops/02423-icon-service-Load-Testing.png similarity index 100% rename from assets/icons/azure/devops/02423-icon-service-Load-Testing.png rename to cloudexit/assets/icons/azure/devops/02423-icon-service-Load-Testing.png diff --git a/assets/icons/azure/devops/02761-icon-service-Lab-Accounts.png b/cloudexit/assets/icons/azure/devops/02761-icon-service-Lab-Accounts.png similarity index 100% rename from assets/icons/azure/devops/02761-icon-service-Lab-Accounts.png rename to cloudexit/assets/icons/azure/devops/02761-icon-service-Lab-Accounts.png diff --git a/assets/icons/azure/devops/03339-icon-service-DevOps-Starter.png b/cloudexit/assets/icons/azure/devops/03339-icon-service-DevOps-Starter.png similarity index 100% rename from assets/icons/azure/devops/03339-icon-service-DevOps-Starter.png rename to cloudexit/assets/icons/azure/devops/03339-icon-service-DevOps-Starter.png diff --git a/assets/icons/azure/devops/03393-icon-service-Managed-DevOps-Pools.png b/cloudexit/assets/icons/azure/devops/03393-icon-service-Managed-DevOps-Pools.png similarity index 100% rename from assets/icons/azure/devops/03393-icon-service-Managed-DevOps-Pools.png rename to cloudexit/assets/icons/azure/devops/03393-icon-service-Managed-DevOps-Pools.png diff --git a/assets/icons/azure/devops/03455-icon-service-Code-Optimization.png b/cloudexit/assets/icons/azure/devops/03455-icon-service-Code-Optimization.png similarity index 100% rename from assets/icons/azure/devops/03455-icon-service-Code-Optimization.png rename to cloudexit/assets/icons/azure/devops/03455-icon-service-Code-Optimization.png diff --git a/assets/icons/azure/devops/03623-icon-service-Workspace-Gateway.png b/cloudexit/assets/icons/azure/devops/03623-icon-service-Workspace-Gateway.png similarity index 100% rename from assets/icons/azure/devops/03623-icon-service-Workspace-Gateway.png rename to cloudexit/assets/icons/azure/devops/03623-icon-service-Workspace-Gateway.png diff --git a/assets/icons/azure/devops/10042-icon-service-API-Management-Services.png b/cloudexit/assets/icons/azure/devops/10042-icon-service-API-Management-Services.png similarity index 100% rename from assets/icons/azure/devops/10042-icon-service-API-Management-Services.png rename to cloudexit/assets/icons/azure/devops/10042-icon-service-API-Management-Services.png diff --git a/assets/icons/azure/devops/10048-icon-service-API-Connections.png b/cloudexit/assets/icons/azure/devops/10048-icon-service-API-Connections.png similarity index 100% rename from assets/icons/azure/devops/10048-icon-service-API-Connections.png rename to cloudexit/assets/icons/azure/devops/10048-icon-service-API-Connections.png diff --git a/assets/icons/azure/devops/10261-icon-service-Azure-DevOps.png b/cloudexit/assets/icons/azure/devops/10261-icon-service-Azure-DevOps.png similarity index 100% rename from assets/icons/azure/devops/10261-icon-service-Azure-DevOps.png rename to cloudexit/assets/icons/azure/devops/10261-icon-service-Azure-DevOps.png diff --git a/assets/icons/azure/devops/10264-icon-service-DevTest-Labs.png b/cloudexit/assets/icons/azure/devops/10264-icon-service-DevTest-Labs.png similarity index 100% rename from assets/icons/azure/devops/10264-icon-service-DevTest-Labs.png rename to cloudexit/assets/icons/azure/devops/10264-icon-service-DevTest-Labs.png diff --git a/assets/icons/azure/devops/10265-icon-service-Lab-Services.png b/cloudexit/assets/icons/azure/devops/10265-icon-service-Lab-Services.png similarity index 100% rename from assets/icons/azure/devops/10265-icon-service-Lab-Services.png rename to cloudexit/assets/icons/azure/devops/10265-icon-service-Lab-Services.png diff --git a/assets/icons/azure/general/00004-icon-service-Cost-Management-and-Billing.png b/cloudexit/assets/icons/azure/general/00004-icon-service-Cost-Management-and-Billing.png similarity index 100% rename from assets/icons/azure/general/00004-icon-service-Cost-Management-and-Billing.png rename to cloudexit/assets/icons/azure/general/00004-icon-service-Cost-Management-and-Billing.png diff --git a/assets/icons/azure/general/00456-icon-service-Preview-Features.png b/cloudexit/assets/icons/azure/general/00456-icon-service-Preview-Features.png similarity index 100% rename from assets/icons/azure/general/00456-icon-service-Preview-Features.png rename to cloudexit/assets/icons/azure/general/00456-icon-service-Preview-Features.png diff --git a/assets/icons/azure/general/10001-icon-service-All-Resources.png b/cloudexit/assets/icons/azure/general/10001-icon-service-All-Resources.png similarity index 100% rename from assets/icons/azure/general/10001-icon-service-All-Resources.png rename to cloudexit/assets/icons/azure/general/10001-icon-service-All-Resources.png diff --git a/assets/icons/azure/general/10002-icon-service-Subscriptions.png b/cloudexit/assets/icons/azure/general/10002-icon-service-Subscriptions.png similarity index 100% rename from assets/icons/azure/general/10002-icon-service-Subscriptions.png rename to cloudexit/assets/icons/azure/general/10002-icon-service-Subscriptions.png diff --git a/assets/icons/azure/general/10003-icon-service-Reservations.png b/cloudexit/assets/icons/azure/general/10003-icon-service-Reservations.png similarity index 100% rename from assets/icons/azure/general/10003-icon-service-Reservations.png rename to cloudexit/assets/icons/azure/general/10003-icon-service-Reservations.png diff --git a/assets/icons/azure/general/10004-icon-service-Service-Health.png b/cloudexit/assets/icons/azure/general/10004-icon-service-Service-Health.png similarity index 100% rename from assets/icons/azure/general/10004-icon-service-Service-Health.png rename to cloudexit/assets/icons/azure/general/10004-icon-service-Service-Health.png diff --git a/assets/icons/azure/general/10005-icon-service-Information.png b/cloudexit/assets/icons/azure/general/10005-icon-service-Information.png similarity index 100% rename from assets/icons/azure/general/10005-icon-service-Information.png rename to cloudexit/assets/icons/azure/general/10005-icon-service-Information.png diff --git a/assets/icons/azure/general/10006-icon-service-Recent.png b/cloudexit/assets/icons/azure/general/10006-icon-service-Recent.png similarity index 100% rename from assets/icons/azure/general/10006-icon-service-Recent.png rename to cloudexit/assets/icons/azure/general/10006-icon-service-Recent.png diff --git a/assets/icons/azure/general/10007-icon-service-Resource-Groups.png b/cloudexit/assets/icons/azure/general/10007-icon-service-Resource-Groups.png similarity index 100% rename from assets/icons/azure/general/10007-icon-service-Resource-Groups.png rename to cloudexit/assets/icons/azure/general/10007-icon-service-Resource-Groups.png diff --git a/assets/icons/azure/general/10008-icon-service-Marketplace.png b/cloudexit/assets/icons/azure/general/10008-icon-service-Marketplace.png similarity index 100% rename from assets/icons/azure/general/10008-icon-service-Marketplace.png rename to cloudexit/assets/icons/azure/general/10008-icon-service-Marketplace.png diff --git a/assets/icons/azure/general/10009-icon-service-Templates.png b/cloudexit/assets/icons/azure/general/10009-icon-service-Templates.png similarity index 100% rename from assets/icons/azure/general/10009-icon-service-Templates.png rename to cloudexit/assets/icons/azure/general/10009-icon-service-Templates.png diff --git a/assets/icons/azure/general/10010-icon-service-Quickstart-Center.png b/cloudexit/assets/icons/azure/general/10010-icon-service-Quickstart-Center.png similarity index 100% rename from assets/icons/azure/general/10010-icon-service-Quickstart-Center.png rename to cloudexit/assets/icons/azure/general/10010-icon-service-Quickstart-Center.png diff --git a/assets/icons/azure/general/10011-icon-service-Management-Groups.png b/cloudexit/assets/icons/azure/general/10011-icon-service-Management-Groups.png similarity index 100% rename from assets/icons/azure/general/10011-icon-service-Management-Groups.png rename to cloudexit/assets/icons/azure/general/10011-icon-service-Management-Groups.png diff --git a/assets/icons/azure/general/10013-icon-service-Help-and-Support.png b/cloudexit/assets/icons/azure/general/10013-icon-service-Help-and-Support.png similarity index 100% rename from assets/icons/azure/general/10013-icon-service-Help-and-Support.png rename to cloudexit/assets/icons/azure/general/10013-icon-service-Help-and-Support.png diff --git a/assets/icons/azure/general/10014-icon-service-Tag.png b/cloudexit/assets/icons/azure/general/10014-icon-service-Tag.png similarity index 100% rename from assets/icons/azure/general/10014-icon-service-Tag.png rename to cloudexit/assets/icons/azure/general/10014-icon-service-Tag.png diff --git a/assets/icons/azure/general/10015-icon-service-Dashboard.png b/cloudexit/assets/icons/azure/general/10015-icon-service-Dashboard.png similarity index 100% rename from assets/icons/azure/general/10015-icon-service-Dashboard.png rename to cloudexit/assets/icons/azure/general/10015-icon-service-Dashboard.png diff --git a/assets/icons/azure/general/10016-icon-service-Free-Services.png b/cloudexit/assets/icons/azure/general/10016-icon-service-Free-Services.png similarity index 100% rename from assets/icons/azure/general/10016-icon-service-Free-Services.png rename to cloudexit/assets/icons/azure/general/10016-icon-service-Free-Services.png diff --git a/assets/icons/azure/general/10019-icon-service-Cost-Management.png b/cloudexit/assets/icons/azure/general/10019-icon-service-Cost-Management.png similarity index 100% rename from assets/icons/azure/general/10019-icon-service-Cost-Management.png rename to cloudexit/assets/icons/azure/general/10019-icon-service-Cost-Management.png diff --git a/assets/icons/azure/general/10112-icon-service-Marketplace-Management.png b/cloudexit/assets/icons/azure/general/10112-icon-service-Marketplace-Management.png similarity index 100% rename from assets/icons/azure/general/10112-icon-service-Marketplace-Management.png rename to cloudexit/assets/icons/azure/general/10112-icon-service-Marketplace-Management.png diff --git a/assets/icons/azure/general/10116-icon-service-Region-Management.png b/cloudexit/assets/icons/azure/general/10116-icon-service-Region-Management.png similarity index 100% rename from assets/icons/azure/general/10116-icon-service-Region-Management.png rename to cloudexit/assets/icons/azure/general/10116-icon-service-Region-Management.png diff --git a/assets/icons/azure/general/10341-icon-service-Troubleshoot.png b/cloudexit/assets/icons/azure/general/10341-icon-service-Troubleshoot.png similarity index 100% rename from assets/icons/azure/general/10341-icon-service-Troubleshoot.png rename to cloudexit/assets/icons/azure/general/10341-icon-service-Troubleshoot.png diff --git a/assets/icons/azure/general/10349-icon-service-Resource-Explorer.png b/cloudexit/assets/icons/azure/general/10349-icon-service-Resource-Explorer.png similarity index 100% rename from assets/icons/azure/general/10349-icon-service-Resource-Explorer.png rename to cloudexit/assets/icons/azure/general/10349-icon-service-Resource-Explorer.png diff --git a/assets/icons/azure/general/10779-icon-service-Biz-Talk.png b/cloudexit/assets/icons/azure/general/10779-icon-service-Biz-Talk.png similarity index 100% rename from assets/icons/azure/general/10779-icon-service-Biz-Talk.png rename to cloudexit/assets/icons/azure/general/10779-icon-service-Biz-Talk.png diff --git a/assets/icons/azure/general/10780-icon-service-Blob-Block.png b/cloudexit/assets/icons/azure/general/10780-icon-service-Blob-Block.png similarity index 100% rename from assets/icons/azure/general/10780-icon-service-Blob-Block.png rename to cloudexit/assets/icons/azure/general/10780-icon-service-Blob-Block.png diff --git a/assets/icons/azure/general/10781-icon-service-Blob-Page.png b/cloudexit/assets/icons/azure/general/10781-icon-service-Blob-Page.png similarity index 100% rename from assets/icons/azure/general/10781-icon-service-Blob-Page.png rename to cloudexit/assets/icons/azure/general/10781-icon-service-Blob-Page.png diff --git a/assets/icons/azure/general/10782-icon-service-Branch.png b/cloudexit/assets/icons/azure/general/10782-icon-service-Branch.png similarity index 100% rename from assets/icons/azure/general/10782-icon-service-Branch.png rename to cloudexit/assets/icons/azure/general/10782-icon-service-Branch.png diff --git a/assets/icons/azure/general/10783-icon-service-Browser.png b/cloudexit/assets/icons/azure/general/10783-icon-service-Browser.png similarity index 100% rename from assets/icons/azure/general/10783-icon-service-Browser.png rename to cloudexit/assets/icons/azure/general/10783-icon-service-Browser.png diff --git a/assets/icons/azure/general/10784-icon-service-Bug.png b/cloudexit/assets/icons/azure/general/10784-icon-service-Bug.png similarity index 100% rename from assets/icons/azure/general/10784-icon-service-Bug.png rename to cloudexit/assets/icons/azure/general/10784-icon-service-Bug.png diff --git a/assets/icons/azure/general/10785-icon-service-Builds.png b/cloudexit/assets/icons/azure/general/10785-icon-service-Builds.png similarity index 100% rename from assets/icons/azure/general/10785-icon-service-Builds.png rename to cloudexit/assets/icons/azure/general/10785-icon-service-Builds.png diff --git a/assets/icons/azure/general/10786-icon-service-Cache.png b/cloudexit/assets/icons/azure/general/10786-icon-service-Cache.png similarity index 100% rename from assets/icons/azure/general/10786-icon-service-Cache.png rename to cloudexit/assets/icons/azure/general/10786-icon-service-Cache.png diff --git a/assets/icons/azure/general/10787-icon-service-Code.png b/cloudexit/assets/icons/azure/general/10787-icon-service-Code.png similarity index 100% rename from assets/icons/azure/general/10787-icon-service-Code.png rename to cloudexit/assets/icons/azure/general/10787-icon-service-Code.png diff --git a/assets/icons/azure/general/10788-icon-service-Commit.png b/cloudexit/assets/icons/azure/general/10788-icon-service-Commit.png similarity index 100% rename from assets/icons/azure/general/10788-icon-service-Commit.png rename to cloudexit/assets/icons/azure/general/10788-icon-service-Commit.png diff --git a/assets/icons/azure/general/10789-icon-service-Controls.png b/cloudexit/assets/icons/azure/general/10789-icon-service-Controls.png similarity index 100% rename from assets/icons/azure/general/10789-icon-service-Controls.png rename to cloudexit/assets/icons/azure/general/10789-icon-service-Controls.png diff --git a/assets/icons/azure/general/10790-icon-service-Controls-Horizontal.png b/cloudexit/assets/icons/azure/general/10790-icon-service-Controls-Horizontal.png similarity index 100% rename from assets/icons/azure/general/10790-icon-service-Controls-Horizontal.png rename to cloudexit/assets/icons/azure/general/10790-icon-service-Controls-Horizontal.png diff --git a/assets/icons/azure/general/10791-icon-service-Cost-Alerts.png b/cloudexit/assets/icons/azure/general/10791-icon-service-Cost-Alerts.png similarity index 100% rename from assets/icons/azure/general/10791-icon-service-Cost-Alerts.png rename to cloudexit/assets/icons/azure/general/10791-icon-service-Cost-Alerts.png diff --git a/assets/icons/azure/general/10792-icon-service-Cost-Analysis.png b/cloudexit/assets/icons/azure/general/10792-icon-service-Cost-Analysis.png similarity index 100% rename from assets/icons/azure/general/10792-icon-service-Cost-Analysis.png rename to cloudexit/assets/icons/azure/general/10792-icon-service-Cost-Analysis.png diff --git a/assets/icons/azure/general/10793-icon-service-Cost-Budgets.png b/cloudexit/assets/icons/azure/general/10793-icon-service-Cost-Budgets.png similarity index 100% rename from assets/icons/azure/general/10793-icon-service-Cost-Budgets.png rename to cloudexit/assets/icons/azure/general/10793-icon-service-Cost-Budgets.png diff --git a/assets/icons/azure/general/10794-icon-service-Counter.png b/cloudexit/assets/icons/azure/general/10794-icon-service-Counter.png similarity index 100% rename from assets/icons/azure/general/10794-icon-service-Counter.png rename to cloudexit/assets/icons/azure/general/10794-icon-service-Counter.png diff --git a/assets/icons/azure/general/10795-icon-service-Cubes.png b/cloudexit/assets/icons/azure/general/10795-icon-service-Cubes.png similarity index 100% rename from assets/icons/azure/general/10795-icon-service-Cubes.png rename to cloudexit/assets/icons/azure/general/10795-icon-service-Cubes.png diff --git a/assets/icons/azure/general/10796-icon-service-Dev-Console.png b/cloudexit/assets/icons/azure/general/10796-icon-service-Dev-Console.png similarity index 100% rename from assets/icons/azure/general/10796-icon-service-Dev-Console.png rename to cloudexit/assets/icons/azure/general/10796-icon-service-Dev-Console.png diff --git a/assets/icons/azure/general/10797-icon-service-Download.png b/cloudexit/assets/icons/azure/general/10797-icon-service-Download.png similarity index 100% rename from assets/icons/azure/general/10797-icon-service-Download.png rename to cloudexit/assets/icons/azure/general/10797-icon-service-Download.png diff --git a/assets/icons/azure/general/10798-icon-service-Error.png b/cloudexit/assets/icons/azure/general/10798-icon-service-Error.png similarity index 100% rename from assets/icons/azure/general/10798-icon-service-Error.png rename to cloudexit/assets/icons/azure/general/10798-icon-service-Error.png diff --git a/assets/icons/azure/general/10799-icon-service-Extensions.png b/cloudexit/assets/icons/azure/general/10799-icon-service-Extensions.png similarity index 100% rename from assets/icons/azure/general/10799-icon-service-Extensions.png rename to cloudexit/assets/icons/azure/general/10799-icon-service-Extensions.png diff --git a/assets/icons/azure/general/10800-icon-service-File.png b/cloudexit/assets/icons/azure/general/10800-icon-service-File.png similarity index 100% rename from assets/icons/azure/general/10800-icon-service-File.png rename to cloudexit/assets/icons/azure/general/10800-icon-service-File.png diff --git a/assets/icons/azure/general/10801-icon-service-Files.png b/cloudexit/assets/icons/azure/general/10801-icon-service-Files.png similarity index 100% rename from assets/icons/azure/general/10801-icon-service-Files.png rename to cloudexit/assets/icons/azure/general/10801-icon-service-Files.png diff --git a/assets/icons/azure/general/10802-icon-service-Folder-Blank.png b/cloudexit/assets/icons/azure/general/10802-icon-service-Folder-Blank.png similarity index 100% rename from assets/icons/azure/general/10802-icon-service-Folder-Blank.png rename to cloudexit/assets/icons/azure/general/10802-icon-service-Folder-Blank.png diff --git a/assets/icons/azure/general/10803-icon-service-Folder-Website.png b/cloudexit/assets/icons/azure/general/10803-icon-service-Folder-Website.png similarity index 100% rename from assets/icons/azure/general/10803-icon-service-Folder-Website.png rename to cloudexit/assets/icons/azure/general/10803-icon-service-Folder-Website.png diff --git a/assets/icons/azure/general/10804-icon-service-FTP.png b/cloudexit/assets/icons/azure/general/10804-icon-service-FTP.png similarity index 100% rename from assets/icons/azure/general/10804-icon-service-FTP.png rename to cloudexit/assets/icons/azure/general/10804-icon-service-FTP.png diff --git a/assets/icons/azure/general/10805-icon-service-Gear.png b/cloudexit/assets/icons/azure/general/10805-icon-service-Gear.png similarity index 100% rename from assets/icons/azure/general/10805-icon-service-Gear.png rename to cloudexit/assets/icons/azure/general/10805-icon-service-Gear.png diff --git a/assets/icons/azure/general/10807-icon-service-Globe-Error.png b/cloudexit/assets/icons/azure/general/10807-icon-service-Globe-Error.png similarity index 100% rename from assets/icons/azure/general/10807-icon-service-Globe-Error.png rename to cloudexit/assets/icons/azure/general/10807-icon-service-Globe-Error.png diff --git a/assets/icons/azure/general/10808-icon-service-Globe-Success.png b/cloudexit/assets/icons/azure/general/10808-icon-service-Globe-Success.png similarity index 100% rename from assets/icons/azure/general/10808-icon-service-Globe-Success.png rename to cloudexit/assets/icons/azure/general/10808-icon-service-Globe-Success.png diff --git a/assets/icons/azure/general/10809-icon-service-Globe-Warning.png b/cloudexit/assets/icons/azure/general/10809-icon-service-Globe-Warning.png similarity index 100% rename from assets/icons/azure/general/10809-icon-service-Globe-Warning.png rename to cloudexit/assets/icons/azure/general/10809-icon-service-Globe-Warning.png diff --git a/assets/icons/azure/general/10810-icon-service-Guide.png b/cloudexit/assets/icons/azure/general/10810-icon-service-Guide.png similarity index 100% rename from assets/icons/azure/general/10810-icon-service-Guide.png rename to cloudexit/assets/icons/azure/general/10810-icon-service-Guide.png diff --git a/assets/icons/azure/general/10811-icon-service-Heart.png b/cloudexit/assets/icons/azure/general/10811-icon-service-Heart.png similarity index 100% rename from assets/icons/azure/general/10811-icon-service-Heart.png rename to cloudexit/assets/icons/azure/general/10811-icon-service-Heart.png diff --git a/assets/icons/azure/general/10812-icon-service-Image.png b/cloudexit/assets/icons/azure/general/10812-icon-service-Image.png similarity index 100% rename from assets/icons/azure/general/10812-icon-service-Image.png rename to cloudexit/assets/icons/azure/general/10812-icon-service-Image.png diff --git a/assets/icons/azure/general/10813-icon-service-Input-Output.png b/cloudexit/assets/icons/azure/general/10813-icon-service-Input-Output.png similarity index 100% rename from assets/icons/azure/general/10813-icon-service-Input-Output.png rename to cloudexit/assets/icons/azure/general/10813-icon-service-Input-Output.png diff --git a/assets/icons/azure/general/10814-icon-service-Journey-Hub.png b/cloudexit/assets/icons/azure/general/10814-icon-service-Journey-Hub.png similarity index 100% rename from assets/icons/azure/general/10814-icon-service-Journey-Hub.png rename to cloudexit/assets/icons/azure/general/10814-icon-service-Journey-Hub.png diff --git a/assets/icons/azure/general/10815-icon-service-Launch-Portal.png b/cloudexit/assets/icons/azure/general/10815-icon-service-Launch-Portal.png similarity index 100% rename from assets/icons/azure/general/10815-icon-service-Launch-Portal.png rename to cloudexit/assets/icons/azure/general/10815-icon-service-Launch-Portal.png diff --git a/assets/icons/azure/general/10816-icon-service-Learn.png b/cloudexit/assets/icons/azure/general/10816-icon-service-Learn.png similarity index 100% rename from assets/icons/azure/general/10816-icon-service-Learn.png rename to cloudexit/assets/icons/azure/general/10816-icon-service-Learn.png diff --git a/assets/icons/azure/general/10817-icon-service-Load-Test.png b/cloudexit/assets/icons/azure/general/10817-icon-service-Load-Test.png similarity index 100% rename from assets/icons/azure/general/10817-icon-service-Load-Test.png rename to cloudexit/assets/icons/azure/general/10817-icon-service-Load-Test.png diff --git a/assets/icons/azure/general/10818-icon-service-Location.png b/cloudexit/assets/icons/azure/general/10818-icon-service-Location.png similarity index 100% rename from assets/icons/azure/general/10818-icon-service-Location.png rename to cloudexit/assets/icons/azure/general/10818-icon-service-Location.png diff --git a/assets/icons/azure/general/10819-icon-service-Log-Streaming.png b/cloudexit/assets/icons/azure/general/10819-icon-service-Log-Streaming.png similarity index 100% rename from assets/icons/azure/general/10819-icon-service-Log-Streaming.png rename to cloudexit/assets/icons/azure/general/10819-icon-service-Log-Streaming.png diff --git a/assets/icons/azure/general/10820-icon-service-Management-Portal.png b/cloudexit/assets/icons/azure/general/10820-icon-service-Management-Portal.png similarity index 100% rename from assets/icons/azure/general/10820-icon-service-Management-Portal.png rename to cloudexit/assets/icons/azure/general/10820-icon-service-Management-Portal.png diff --git a/assets/icons/azure/general/10821-icon-service-Media-File.png b/cloudexit/assets/icons/azure/general/10821-icon-service-Media-File.png similarity index 100% rename from assets/icons/azure/general/10821-icon-service-Media-File.png rename to cloudexit/assets/icons/azure/general/10821-icon-service-Media-File.png diff --git a/assets/icons/azure/general/10822-icon-service-Mobile.png b/cloudexit/assets/icons/azure/general/10822-icon-service-Mobile.png similarity index 100% rename from assets/icons/azure/general/10822-icon-service-Mobile.png rename to cloudexit/assets/icons/azure/general/10822-icon-service-Mobile.png diff --git a/assets/icons/azure/general/10823-icon-service-Mobile-Engagement.png b/cloudexit/assets/icons/azure/general/10823-icon-service-Mobile-Engagement.png similarity index 100% rename from assets/icons/azure/general/10823-icon-service-Mobile-Engagement.png rename to cloudexit/assets/icons/azure/general/10823-icon-service-Mobile-Engagement.png diff --git a/assets/icons/azure/general/10824-icon-service-Power.png b/cloudexit/assets/icons/azure/general/10824-icon-service-Power.png similarity index 100% rename from assets/icons/azure/general/10824-icon-service-Power.png rename to cloudexit/assets/icons/azure/general/10824-icon-service-Power.png diff --git a/assets/icons/azure/general/10825-icon-service-Powershell.png b/cloudexit/assets/icons/azure/general/10825-icon-service-Powershell.png similarity index 100% rename from assets/icons/azure/general/10825-icon-service-Powershell.png rename to cloudexit/assets/icons/azure/general/10825-icon-service-Powershell.png diff --git a/assets/icons/azure/general/10826-icon-service-Power-Up.png b/cloudexit/assets/icons/azure/general/10826-icon-service-Power-Up.png similarity index 100% rename from assets/icons/azure/general/10826-icon-service-Power-Up.png rename to cloudexit/assets/icons/azure/general/10826-icon-service-Power-Up.png diff --git a/assets/icons/azure/general/10827-icon-service-Feature-Previews.png b/cloudexit/assets/icons/azure/general/10827-icon-service-Feature-Previews.png similarity index 100% rename from assets/icons/azure/general/10827-icon-service-Feature-Previews.png rename to cloudexit/assets/icons/azure/general/10827-icon-service-Feature-Previews.png diff --git a/assets/icons/azure/general/10828-icon-service-Process-Explorer.png b/cloudexit/assets/icons/azure/general/10828-icon-service-Process-Explorer.png similarity index 100% rename from assets/icons/azure/general/10828-icon-service-Process-Explorer.png rename to cloudexit/assets/icons/azure/general/10828-icon-service-Process-Explorer.png diff --git a/assets/icons/azure/general/10829-icon-service-Production-Ready-Database.png b/cloudexit/assets/icons/azure/general/10829-icon-service-Production-Ready-Database.png similarity index 100% rename from assets/icons/azure/general/10829-icon-service-Production-Ready-Database.png rename to cloudexit/assets/icons/azure/general/10829-icon-service-Production-Ready-Database.png diff --git a/assets/icons/azure/general/10830-icon-service-Resource-Group-List.png b/cloudexit/assets/icons/azure/general/10830-icon-service-Resource-Group-List.png similarity index 100% rename from assets/icons/azure/general/10830-icon-service-Resource-Group-List.png rename to cloudexit/assets/icons/azure/general/10830-icon-service-Resource-Group-List.png diff --git a/assets/icons/azure/general/10831-icon-service-Resource-Linked.png b/cloudexit/assets/icons/azure/general/10831-icon-service-Resource-Linked.png similarity index 100% rename from assets/icons/azure/general/10831-icon-service-Resource-Linked.png rename to cloudexit/assets/icons/azure/general/10831-icon-service-Resource-Linked.png diff --git a/assets/icons/azure/general/10833-icon-service-Scheduler.png b/cloudexit/assets/icons/azure/general/10833-icon-service-Scheduler.png similarity index 100% rename from assets/icons/azure/general/10833-icon-service-Scheduler.png rename to cloudexit/assets/icons/azure/general/10833-icon-service-Scheduler.png diff --git a/assets/icons/azure/general/10834-icon-service-Search.png b/cloudexit/assets/icons/azure/general/10834-icon-service-Search.png similarity index 100% rename from assets/icons/azure/general/10834-icon-service-Search.png rename to cloudexit/assets/icons/azure/general/10834-icon-service-Search.png diff --git a/assets/icons/azure/general/10835-icon-service-Server-Farm.png b/cloudexit/assets/icons/azure/general/10835-icon-service-Server-Farm.png similarity index 100% rename from assets/icons/azure/general/10835-icon-service-Server-Farm.png rename to cloudexit/assets/icons/azure/general/10835-icon-service-Server-Farm.png diff --git a/assets/icons/azure/general/10837-icon-service-SSD.png b/cloudexit/assets/icons/azure/general/10837-icon-service-SSD.png similarity index 100% rename from assets/icons/azure/general/10837-icon-service-SSD.png rename to cloudexit/assets/icons/azure/general/10837-icon-service-SSD.png diff --git a/assets/icons/azure/general/10838-icon-service-Storage-Azure-Files.png b/cloudexit/assets/icons/azure/general/10838-icon-service-Storage-Azure-Files.png similarity index 100% rename from assets/icons/azure/general/10838-icon-service-Storage-Azure-Files.png rename to cloudexit/assets/icons/azure/general/10838-icon-service-Storage-Azure-Files.png diff --git a/assets/icons/azure/general/10839-icon-service-Storage-Container.png b/cloudexit/assets/icons/azure/general/10839-icon-service-Storage-Container.png similarity index 100% rename from assets/icons/azure/general/10839-icon-service-Storage-Container.png rename to cloudexit/assets/icons/azure/general/10839-icon-service-Storage-Container.png diff --git a/assets/icons/azure/general/10840-icon-service-Storage-Queue.png b/cloudexit/assets/icons/azure/general/10840-icon-service-Storage-Queue.png similarity index 100% rename from assets/icons/azure/general/10840-icon-service-Storage-Queue.png rename to cloudexit/assets/icons/azure/general/10840-icon-service-Storage-Queue.png diff --git a/assets/icons/azure/general/10841-icon-service-Table.png b/cloudexit/assets/icons/azure/general/10841-icon-service-Table.png similarity index 100% rename from assets/icons/azure/general/10841-icon-service-Table.png rename to cloudexit/assets/icons/azure/general/10841-icon-service-Table.png diff --git a/assets/icons/azure/general/10842-icon-service-Tags.png b/cloudexit/assets/icons/azure/general/10842-icon-service-Tags.png similarity index 100% rename from assets/icons/azure/general/10842-icon-service-Tags.png rename to cloudexit/assets/icons/azure/general/10842-icon-service-Tags.png diff --git a/assets/icons/azure/general/10843-icon-service-TFS-VC-Repository.png b/cloudexit/assets/icons/azure/general/10843-icon-service-TFS-VC-Repository.png similarity index 100% rename from assets/icons/azure/general/10843-icon-service-TFS-VC-Repository.png rename to cloudexit/assets/icons/azure/general/10843-icon-service-TFS-VC-Repository.png diff --git a/assets/icons/azure/general/10844-icon-service-Toolbox.png b/cloudexit/assets/icons/azure/general/10844-icon-service-Toolbox.png similarity index 100% rename from assets/icons/azure/general/10844-icon-service-Toolbox.png rename to cloudexit/assets/icons/azure/general/10844-icon-service-Toolbox.png diff --git a/assets/icons/azure/general/10845-icon-service-Versions.png b/cloudexit/assets/icons/azure/general/10845-icon-service-Versions.png similarity index 100% rename from assets/icons/azure/general/10845-icon-service-Versions.png rename to cloudexit/assets/icons/azure/general/10845-icon-service-Versions.png diff --git a/assets/icons/azure/general/10847-icon-service-Website-Power.png b/cloudexit/assets/icons/azure/general/10847-icon-service-Website-Power.png similarity index 100% rename from assets/icons/azure/general/10847-icon-service-Website-Power.png rename to cloudexit/assets/icons/azure/general/10847-icon-service-Website-Power.png diff --git a/assets/icons/azure/general/10848-icon-service-Website-Staging.png b/cloudexit/assets/icons/azure/general/10848-icon-service-Website-Staging.png similarity index 100% rename from assets/icons/azure/general/10848-icon-service-Website-Staging.png rename to cloudexit/assets/icons/azure/general/10848-icon-service-Website-Staging.png diff --git a/assets/icons/azure/general/10849-icon-service-Web-Slots.png b/cloudexit/assets/icons/azure/general/10849-icon-service-Web-Slots.png similarity index 100% rename from assets/icons/azure/general/10849-icon-service-Web-Slots.png rename to cloudexit/assets/icons/azure/general/10849-icon-service-Web-Slots.png diff --git a/assets/icons/azure/general/10850-icon-service-Web-Test.png b/cloudexit/assets/icons/azure/general/10850-icon-service-Web-Test.png similarity index 100% rename from assets/icons/azure/general/10850-icon-service-Web-Test.png rename to cloudexit/assets/icons/azure/general/10850-icon-service-Web-Test.png diff --git a/assets/icons/azure/general/10851-icon-service-Workbooks.png b/cloudexit/assets/icons/azure/general/10851-icon-service-Workbooks.png similarity index 100% rename from assets/icons/azure/general/10851-icon-service-Workbooks.png rename to cloudexit/assets/icons/azure/general/10851-icon-service-Workbooks.png diff --git a/assets/icons/azure/general/10852-icon-service-Workflow.png b/cloudexit/assets/icons/azure/general/10852-icon-service-Workflow.png similarity index 100% rename from assets/icons/azure/general/10852-icon-service-Workflow.png rename to cloudexit/assets/icons/azure/general/10852-icon-service-Workflow.png diff --git a/assets/icons/azure/general/10853-icon-service-Backlog.png b/cloudexit/assets/icons/azure/general/10853-icon-service-Backlog.png similarity index 100% rename from assets/icons/azure/general/10853-icon-service-Backlog.png rename to cloudexit/assets/icons/azure/general/10853-icon-service-Backlog.png diff --git a/assets/icons/azure/general/10854-icon-service-Media.png b/cloudexit/assets/icons/azure/general/10854-icon-service-Media.png similarity index 100% rename from assets/icons/azure/general/10854-icon-service-Media.png rename to cloudexit/assets/icons/azure/general/10854-icon-service-Media.png diff --git a/assets/icons/azure/general/10855-icon-service-Module.png b/cloudexit/assets/icons/azure/general/10855-icon-service-Module.png similarity index 100% rename from assets/icons/azure/general/10855-icon-service-Module.png rename to cloudexit/assets/icons/azure/general/10855-icon-service-Module.png diff --git a/assets/icons/azure/general/10856-icon-service-Search-Grid.png b/cloudexit/assets/icons/azure/general/10856-icon-service-Search-Grid.png similarity index 100% rename from assets/icons/azure/general/10856-icon-service-Search-Grid.png rename to cloudexit/assets/icons/azure/general/10856-icon-service-Search-Grid.png diff --git a/assets/icons/azure/hybrid-multicloud/029109619-icon-service-Workload-Orchestration.png b/cloudexit/assets/icons/azure/hybrid-multicloud/029109619-icon-service-Workload-Orchestration.png similarity index 100% rename from assets/icons/azure/hybrid-multicloud/029109619-icon-service-Workload-Orchestration.png rename to cloudexit/assets/icons/azure/hybrid-multicloud/029109619-icon-service-Workload-Orchestration.png diff --git a/assets/icons/azure/hybrid-multicloud/03248-icon-service-Azure-Operator-5G-Core.png b/cloudexit/assets/icons/azure/hybrid-multicloud/03248-icon-service-Azure-Operator-5G-Core.png similarity index 100% rename from assets/icons/azure/hybrid-multicloud/03248-icon-service-Azure-Operator-5G-Core.png rename to cloudexit/assets/icons/azure/hybrid-multicloud/03248-icon-service-Azure-Operator-5G-Core.png diff --git a/assets/icons/azure/hybrid-multicloud/03324-icon-service-Azure-Operator-Nexus.png b/cloudexit/assets/icons/azure/hybrid-multicloud/03324-icon-service-Azure-Operator-Nexus.png similarity index 100% rename from assets/icons/azure/hybrid-multicloud/03324-icon-service-Azure-Operator-Nexus.png rename to cloudexit/assets/icons/azure/hybrid-multicloud/03324-icon-service-Azure-Operator-Nexus.png diff --git a/assets/icons/azure/hybrid-multicloud/03333-icon-service-Azure-Operator-Insights.png b/cloudexit/assets/icons/azure/hybrid-multicloud/03333-icon-service-Azure-Operator-Insights.png similarity index 100% rename from assets/icons/azure/hybrid-multicloud/03333-icon-service-Azure-Operator-Insights.png rename to cloudexit/assets/icons/azure/hybrid-multicloud/03333-icon-service-Azure-Operator-Insights.png diff --git a/assets/icons/azure/hybrid-multicloud/03334-icon-service-Azure-Operator-Service-Manager.png b/cloudexit/assets/icons/azure/hybrid-multicloud/03334-icon-service-Azure-Operator-Service-Manager.png similarity index 100% rename from assets/icons/azure/hybrid-multicloud/03334-icon-service-Azure-Operator-Service-Manager.png rename to cloudexit/assets/icons/azure/hybrid-multicloud/03334-icon-service-Azure-Operator-Service-Manager.png diff --git a/assets/icons/azure/hybrid-multicloud/03347-icon-service-Azure-Programmable-Connectivity.png b/cloudexit/assets/icons/azure/hybrid-multicloud/03347-icon-service-Azure-Programmable-Connectivity.png similarity index 100% rename from assets/icons/azure/hybrid-multicloud/03347-icon-service-Azure-Programmable-Connectivity.png rename to cloudexit/assets/icons/azure/hybrid-multicloud/03347-icon-service-Azure-Programmable-Connectivity.png diff --git a/assets/icons/azure/hybrid-multicloud/033745116-icon-service-Azure-Local.png b/cloudexit/assets/icons/azure/hybrid-multicloud/033745116-icon-service-Azure-Local.png similarity index 100% rename from assets/icons/azure/hybrid-multicloud/033745116-icon-service-Azure-Local.png rename to cloudexit/assets/icons/azure/hybrid-multicloud/033745116-icon-service-Azure-Local.png diff --git a/assets/icons/azure/hybrid-multicloud/03521-icon-service-Edge-Storage-Accelerator.png b/cloudexit/assets/icons/azure/hybrid-multicloud/03521-icon-service-Edge-Storage-Accelerator.png similarity index 100% rename from assets/icons/azure/hybrid-multicloud/03521-icon-service-Edge-Storage-Accelerator.png rename to cloudexit/assets/icons/azure/hybrid-multicloud/03521-icon-service-Edge-Storage-Accelerator.png diff --git a/assets/icons/azure/hybrid-multicloud/03585-icon-service-Azure-Monitor-Pipeline.png b/cloudexit/assets/icons/azure/hybrid-multicloud/03585-icon-service-Azure-Monitor-Pipeline.png similarity index 100% rename from assets/icons/azure/hybrid-multicloud/03585-icon-service-Azure-Monitor-Pipeline.png rename to cloudexit/assets/icons/azure/hybrid-multicloud/03585-icon-service-Azure-Monitor-Pipeline.png diff --git a/assets/icons/azure/hybrid-multicloud/03687-icon-service-AI-at-Edge.png b/cloudexit/assets/icons/azure/hybrid-multicloud/03687-icon-service-AI-at-Edge.png similarity index 100% rename from assets/icons/azure/hybrid-multicloud/03687-icon-service-AI-at-Edge.png rename to cloudexit/assets/icons/azure/hybrid-multicloud/03687-icon-service-AI-at-Edge.png diff --git a/assets/icons/azure/identity/00321-icon-service-Security.png b/cloudexit/assets/icons/azure/identity/00321-icon-service-Security.png similarity index 100% rename from assets/icons/azure/identity/00321-icon-service-Security.png rename to cloudexit/assets/icons/azure/identity/00321-icon-service-Security.png diff --git a/assets/icons/azure/identity/00919-icon-service-Administrative-Units.png b/cloudexit/assets/icons/azure/identity/00919-icon-service-Administrative-Units.png similarity index 100% rename from assets/icons/azure/identity/00919-icon-service-Administrative-Units.png rename to cloudexit/assets/icons/azure/identity/00919-icon-service-Administrative-Units.png diff --git a/assets/icons/azure/identity/01084-icon-service-Verifiable-Credentials.png b/cloudexit/assets/icons/azure/identity/01084-icon-service-Verifiable-Credentials.png similarity index 100% rename from assets/icons/azure/identity/01084-icon-service-Verifiable-Credentials.png rename to cloudexit/assets/icons/azure/identity/01084-icon-service-Verifiable-Credentials.png diff --git a/assets/icons/azure/identity/02251-icon-service-Azure-AD-Privleged-Identity-Management.png b/cloudexit/assets/icons/azure/identity/02251-icon-service-Azure-AD-Privleged-Identity-Management.png similarity index 100% rename from assets/icons/azure/identity/02251-icon-service-Azure-AD-Privleged-Identity-Management.png rename to cloudexit/assets/icons/azure/identity/02251-icon-service-Azure-AD-Privleged-Identity-Management.png diff --git a/assets/icons/azure/identity/02251-icon-service-Entra-Privleged-Identity-Management.png b/cloudexit/assets/icons/azure/identity/02251-icon-service-Entra-Privleged-Identity-Management.png similarity index 100% rename from assets/icons/azure/identity/02251-icon-service-Entra-Privleged-Identity-Management.png rename to cloudexit/assets/icons/azure/identity/02251-icon-service-Entra-Privleged-Identity-Management.png diff --git a/assets/icons/azure/identity/02386-icon-service-API-Proxy.png b/cloudexit/assets/icons/azure/identity/02386-icon-service-API-Proxy.png similarity index 100% rename from assets/icons/azure/identity/02386-icon-service-API-Proxy.png rename to cloudexit/assets/icons/azure/identity/02386-icon-service-API-Proxy.png diff --git a/assets/icons/azure/identity/02679-icon-service-Tenant-Properties.png b/cloudexit/assets/icons/azure/identity/02679-icon-service-Tenant-Properties.png similarity index 100% rename from assets/icons/azure/identity/02679-icon-service-Tenant-Properties.png rename to cloudexit/assets/icons/azure/identity/02679-icon-service-Tenant-Properties.png diff --git a/assets/icons/azure/identity/02680-icon-service-Custom-Azure-AD-Roles.png b/cloudexit/assets/icons/azure/identity/02680-icon-service-Custom-Azure-AD-Roles.png similarity index 100% rename from assets/icons/azure/identity/02680-icon-service-Custom-Azure-AD-Roles.png rename to cloudexit/assets/icons/azure/identity/02680-icon-service-Custom-Azure-AD-Roles.png diff --git a/assets/icons/azure/identity/02680-icon-service-Entra-Identity-Custom-Roles.png b/cloudexit/assets/icons/azure/identity/02680-icon-service-Entra-Identity-Custom-Roles.png similarity index 100% rename from assets/icons/azure/identity/02680-icon-service-Entra-Identity-Custom-Roles.png rename to cloudexit/assets/icons/azure/identity/02680-icon-service-Entra-Identity-Custom-Roles.png diff --git a/assets/icons/azure/identity/02681-icon-service-AAD-Licenses.png b/cloudexit/assets/icons/azure/identity/02681-icon-service-AAD-Licenses.png similarity index 100% rename from assets/icons/azure/identity/02681-icon-service-AAD-Licenses.png rename to cloudexit/assets/icons/azure/identity/02681-icon-service-AAD-Licenses.png diff --git a/assets/icons/azure/identity/02681-icon-service-Entra-Identity-Licenses.png b/cloudexit/assets/icons/azure/identity/02681-icon-service-Entra-Identity-Licenses.png similarity index 100% rename from assets/icons/azure/identity/02681-icon-service-Entra-Identity-Licenses.png rename to cloudexit/assets/icons/azure/identity/02681-icon-service-Entra-Identity-Licenses.png diff --git a/assets/icons/azure/identity/02854-icon-service-Entra-Connect.png b/cloudexit/assets/icons/azure/identity/02854-icon-service-Entra-Connect.png similarity index 100% rename from assets/icons/azure/identity/02854-icon-service-Entra-Connect.png rename to cloudexit/assets/icons/azure/identity/02854-icon-service-Entra-Connect.png diff --git a/assets/icons/azure/identity/03143-icon-service-Entra-Verified-ID.png b/cloudexit/assets/icons/azure/identity/03143-icon-service-Entra-Verified-ID.png similarity index 100% rename from assets/icons/azure/identity/03143-icon-service-Entra-Verified-ID.png rename to cloudexit/assets/icons/azure/identity/03143-icon-service-Entra-Verified-ID.png diff --git a/assets/icons/azure/identity/03225-icon-service-Verification-As-A-Service.png b/cloudexit/assets/icons/azure/identity/03225-icon-service-Verification-As-A-Service.png similarity index 100% rename from assets/icons/azure/identity/03225-icon-service-Verification-As-A-Service.png rename to cloudexit/assets/icons/azure/identity/03225-icon-service-Verification-As-A-Service.png diff --git a/assets/icons/azure/identity/03256-icon-service-Multi-Factor-Authentication.png b/cloudexit/assets/icons/azure/identity/03256-icon-service-Multi-Factor-Authentication.png similarity index 100% rename from assets/icons/azure/identity/03256-icon-service-Multi-Factor-Authentication.png rename to cloudexit/assets/icons/azure/identity/03256-icon-service-Multi-Factor-Authentication.png diff --git a/assets/icons/azure/identity/03309-icon-service-Entra-Global-Secure-Access.png b/cloudexit/assets/icons/azure/identity/03309-icon-service-Entra-Global-Secure-Access.png similarity index 100% rename from assets/icons/azure/identity/03309-icon-service-Entra-Global-Secure-Access.png rename to cloudexit/assets/icons/azure/identity/03309-icon-service-Entra-Global-Secure-Access.png diff --git a/assets/icons/azure/identity/03338-icon-service-External-Identities.png b/cloudexit/assets/icons/azure/identity/03338-icon-service-External-Identities.png similarity index 100% rename from assets/icons/azure/identity/03338-icon-service-External-Identities.png rename to cloudexit/assets/icons/azure/identity/03338-icon-service-External-Identities.png diff --git a/assets/icons/azure/identity/03382-icon-service-Entra-Private-Access.png b/cloudexit/assets/icons/azure/identity/03382-icon-service-Entra-Private-Access.png similarity index 100% rename from assets/icons/azure/identity/03382-icon-service-Entra-Private-Access.png rename to cloudexit/assets/icons/azure/identity/03382-icon-service-Entra-Private-Access.png diff --git a/assets/icons/azure/identity/03383-icon-service-Entra-Internet-Access.png b/cloudexit/assets/icons/azure/identity/03383-icon-service-Entra-Internet-Access.png similarity index 100% rename from assets/icons/azure/identity/03383-icon-service-Entra-Internet-Access.png rename to cloudexit/assets/icons/azure/identity/03383-icon-service-Entra-Internet-Access.png diff --git a/assets/icons/azure/identity/03425-icon-service-external-id.png b/cloudexit/assets/icons/azure/identity/03425-icon-service-external-id.png similarity index 100% rename from assets/icons/azure/identity/03425-icon-service-external-id.png rename to cloudexit/assets/icons/azure/identity/03425-icon-service-external-id.png diff --git a/assets/icons/azure/identity/034251-icon-service-external-id-modified.png b/cloudexit/assets/icons/azure/identity/034251-icon-service-external-id-modified.png similarity index 100% rename from assets/icons/azure/identity/034251-icon-service-external-id-modified.png rename to cloudexit/assets/icons/azure/identity/034251-icon-service-external-id-modified.png diff --git a/assets/icons/azure/identity/03533-icon-service-Entra-Connect-Sync.png b/cloudexit/assets/icons/azure/identity/03533-icon-service-Entra-Connect-Sync.png similarity index 100% rename from assets/icons/azure/identity/03533-icon-service-Entra-Connect-Sync.png rename to cloudexit/assets/icons/azure/identity/03533-icon-service-Entra-Connect-Sync.png diff --git a/assets/icons/azure/identity/10221-icon-service-Azure-Active-Directory.png b/cloudexit/assets/icons/azure/identity/10221-icon-service-Azure-Active-Directory.png similarity index 100% rename from assets/icons/azure/identity/10221-icon-service-Azure-Active-Directory.png rename to cloudexit/assets/icons/azure/identity/10221-icon-service-Azure-Active-Directory.png diff --git a/assets/icons/azure/identity/10222-icon-service-Azure-AD-Domain-Services.png b/cloudexit/assets/icons/azure/identity/10222-icon-service-Azure-AD-Domain-Services.png similarity index 100% rename from assets/icons/azure/identity/10222-icon-service-Azure-AD-Domain-Services.png rename to cloudexit/assets/icons/azure/identity/10222-icon-service-Azure-AD-Domain-Services.png diff --git a/assets/icons/azure/identity/10222-icon-service-Entra-Domain-Services.png b/cloudexit/assets/icons/azure/identity/10222-icon-service-Entra-Domain-Services.png similarity index 100% rename from assets/icons/azure/identity/10222-icon-service-Entra-Domain-Services.png rename to cloudexit/assets/icons/azure/identity/10222-icon-service-Entra-Domain-Services.png diff --git a/assets/icons/azure/identity/10223-icon-service-Groups.png b/cloudexit/assets/icons/azure/identity/10223-icon-service-Groups.png similarity index 100% rename from assets/icons/azure/identity/10223-icon-service-Groups.png rename to cloudexit/assets/icons/azure/identity/10223-icon-service-Groups.png diff --git a/assets/icons/azure/identity/10224-icon-service-Active-Directory-Connect-Health.png b/cloudexit/assets/icons/azure/identity/10224-icon-service-Active-Directory-Connect-Health.png similarity index 100% rename from assets/icons/azure/identity/10224-icon-service-Active-Directory-Connect-Health.png rename to cloudexit/assets/icons/azure/identity/10224-icon-service-Active-Directory-Connect-Health.png diff --git a/assets/icons/azure/identity/10224-icon-service-Entra-Connect-Health.png b/cloudexit/assets/icons/azure/identity/10224-icon-service-Entra-Connect-Health.png similarity index 100% rename from assets/icons/azure/identity/10224-icon-service-Entra-Connect-Health.png rename to cloudexit/assets/icons/azure/identity/10224-icon-service-Entra-Connect-Health.png diff --git a/assets/icons/azure/identity/10225-icon-service-Enterprise-Applications.png b/cloudexit/assets/icons/azure/identity/10225-icon-service-Enterprise-Applications.png similarity index 100% rename from assets/icons/azure/identity/10225-icon-service-Enterprise-Applications.png rename to cloudexit/assets/icons/azure/identity/10225-icon-service-Enterprise-Applications.png diff --git a/assets/icons/azure/identity/10227-icon-service-Entra-Managed-Identities.png b/cloudexit/assets/icons/azure/identity/10227-icon-service-Entra-Managed-Identities.png similarity index 100% rename from assets/icons/azure/identity/10227-icon-service-Entra-Managed-Identities.png rename to cloudexit/assets/icons/azure/identity/10227-icon-service-Entra-Managed-Identities.png diff --git a/assets/icons/azure/identity/10227-icon-service-Managed-Identities.png b/cloudexit/assets/icons/azure/identity/10227-icon-service-Managed-Identities.png similarity index 100% rename from assets/icons/azure/identity/10227-icon-service-Managed-Identities.png rename to cloudexit/assets/icons/azure/identity/10227-icon-service-Managed-Identities.png diff --git a/assets/icons/azure/identity/10228-icon-service-Azure-AD-B2C.png b/cloudexit/assets/icons/azure/identity/10228-icon-service-Azure-AD-B2C.png similarity index 100% rename from assets/icons/azure/identity/10228-icon-service-Azure-AD-B2C.png rename to cloudexit/assets/icons/azure/identity/10228-icon-service-Azure-AD-B2C.png diff --git a/assets/icons/azure/identity/10229-icon-service-Azure-Information-Protection.png b/cloudexit/assets/icons/azure/identity/10229-icon-service-Azure-Information-Protection.png similarity index 100% rename from assets/icons/azure/identity/10229-icon-service-Azure-Information-Protection.png rename to cloudexit/assets/icons/azure/identity/10229-icon-service-Azure-Information-Protection.png diff --git a/assets/icons/azure/identity/10230-icon-service-Users.png b/cloudexit/assets/icons/azure/identity/10230-icon-service-Users.png similarity index 100% rename from assets/icons/azure/identity/10230-icon-service-Users.png rename to cloudexit/assets/icons/azure/identity/10230-icon-service-Users.png diff --git a/assets/icons/azure/identity/10231-icon-service-Azure-AD-Identity-Protection.png b/cloudexit/assets/icons/azure/identity/10231-icon-service-Azure-AD-Identity-Protection.png similarity index 100% rename from assets/icons/azure/identity/10231-icon-service-Azure-AD-Identity-Protection.png rename to cloudexit/assets/icons/azure/identity/10231-icon-service-Azure-AD-Identity-Protection.png diff --git a/assets/icons/azure/identity/10231-icon-service-Entra-ID-Protection.png b/cloudexit/assets/icons/azure/identity/10231-icon-service-Entra-ID-Protection.png similarity index 100% rename from assets/icons/azure/identity/10231-icon-service-Entra-ID-Protection.png rename to cloudexit/assets/icons/azure/identity/10231-icon-service-Entra-ID-Protection.png diff --git a/assets/icons/azure/identity/10232-icon-service-App-Registrations.png b/cloudexit/assets/icons/azure/identity/10232-icon-service-App-Registrations.png similarity index 100% rename from assets/icons/azure/identity/10232-icon-service-App-Registrations.png rename to cloudexit/assets/icons/azure/identity/10232-icon-service-App-Registrations.png diff --git a/assets/icons/azure/identity/10234-icon-service-Azure-AD-Privilege-Identity-Management.png b/cloudexit/assets/icons/azure/identity/10234-icon-service-Azure-AD-Privilege-Identity-Management.png similarity index 100% rename from assets/icons/azure/identity/10234-icon-service-Azure-AD-Privilege-Identity-Management.png rename to cloudexit/assets/icons/azure/identity/10234-icon-service-Azure-AD-Privilege-Identity-Management.png diff --git a/assets/icons/azure/identity/10235-icon-service-Identity-Governance.png b/cloudexit/assets/icons/azure/identity/10235-icon-service-Identity-Governance.png similarity index 100% rename from assets/icons/azure/identity/10235-icon-service-Identity-Governance.png rename to cloudexit/assets/icons/azure/identity/10235-icon-service-Identity-Governance.png diff --git a/assets/icons/azure/identity/10340-icon-service-Azure-AD-Roles-and-Administrators.png b/cloudexit/assets/icons/azure/identity/10340-icon-service-Azure-AD-Roles-and-Administrators.png similarity index 100% rename from assets/icons/azure/identity/10340-icon-service-Azure-AD-Roles-and-Administrators.png rename to cloudexit/assets/icons/azure/identity/10340-icon-service-Azure-AD-Roles-and-Administrators.png diff --git a/assets/icons/azure/identity/10340-icon-service-Entra-Identity-Roles-and-Administrators.png b/cloudexit/assets/icons/azure/identity/10340-icon-service-Entra-Identity-Roles-and-Administrators.png similarity index 100% rename from assets/icons/azure/identity/10340-icon-service-Entra-Identity-Roles-and-Administrators.png rename to cloudexit/assets/icons/azure/identity/10340-icon-service-Entra-Identity-Roles-and-Administrators.png diff --git a/assets/icons/azure/identity/10433-icon-service-User-Settings.png b/cloudexit/assets/icons/azure/identity/10433-icon-service-User-Settings.png similarity index 100% rename from assets/icons/azure/identity/10433-icon-service-User-Settings.png rename to cloudexit/assets/icons/azure/identity/10433-icon-service-User-Settings.png diff --git a/assets/icons/azure/integration/00036-icon-service-SQL-Data-Warehouses.png b/cloudexit/assets/icons/azure/integration/00036-icon-service-SQL-Data-Warehouses.png similarity index 100% rename from assets/icons/azure/integration/00036-icon-service-SQL-Data-Warehouses.png rename to cloudexit/assets/icons/azure/integration/00036-icon-service-SQL-Data-Warehouses.png diff --git a/assets/icons/azure/integration/00555-icon-service-Integration-Service-Environments.png b/cloudexit/assets/icons/azure/integration/00555-icon-service-Integration-Service-Environments.png similarity index 100% rename from assets/icons/azure/integration/00555-icon-service-Integration-Service-Environments.png rename to cloudexit/assets/icons/azure/integration/00555-icon-service-Integration-Service-Environments.png diff --git a/assets/icons/azure/integration/00691-icon-service-Azure-Databox-Gateway.png b/cloudexit/assets/icons/azure/integration/00691-icon-service-Azure-Databox-Gateway.png similarity index 100% rename from assets/icons/azure/integration/00691-icon-service-Azure-Databox-Gateway.png rename to cloudexit/assets/icons/azure/integration/00691-icon-service-Azure-Databox-Gateway.png diff --git a/assets/icons/azure/integration/02072-icon-service-Partner-Topic.png b/cloudexit/assets/icons/azure/integration/02072-icon-service-Partner-Topic.png similarity index 100% rename from assets/icons/azure/integration/02072-icon-service-Partner-Topic.png rename to cloudexit/assets/icons/azure/integration/02072-icon-service-Partner-Topic.png diff --git a/assets/icons/azure/integration/02073-icon-service-System-Topic.png b/cloudexit/assets/icons/azure/integration/02073-icon-service-System-Topic.png similarity index 100% rename from assets/icons/azure/integration/02073-icon-service-System-Topic.png rename to cloudexit/assets/icons/azure/integration/02073-icon-service-System-Topic.png diff --git a/assets/icons/azure/integration/02265-icon-service-Partner-Registration.png b/cloudexit/assets/icons/azure/integration/02265-icon-service-Partner-Registration.png similarity index 100% rename from assets/icons/azure/integration/02265-icon-service-Partner-Registration.png rename to cloudexit/assets/icons/azure/integration/02265-icon-service-Partner-Registration.png diff --git a/assets/icons/azure/integration/02266-icon-service-Partner-Namespace.png b/cloudexit/assets/icons/azure/integration/02266-icon-service-Partner-Namespace.png similarity index 100% rename from assets/icons/azure/integration/02266-icon-service-Partner-Namespace.png rename to cloudexit/assets/icons/azure/integration/02266-icon-service-Partner-Namespace.png diff --git a/assets/icons/azure/integration/02631-icon-service-Logic-Apps.png b/cloudexit/assets/icons/azure/integration/02631-icon-service-Logic-Apps.png similarity index 100% rename from assets/icons/azure/integration/02631-icon-service-Logic-Apps.png rename to cloudexit/assets/icons/azure/integration/02631-icon-service-Logic-Apps.png diff --git a/assets/icons/azure/integration/02987-icon-service-pubsub.png b/cloudexit/assets/icons/azure/integration/02987-icon-service-pubsub.png similarity index 100% rename from assets/icons/azure/integration/02987-icon-service-pubsub.png rename to cloudexit/assets/icons/azure/integration/02987-icon-service-pubsub.png diff --git a/assets/icons/azure/integration/032130874-icon-service-Logic-Apps-Template.png b/cloudexit/assets/icons/azure/integration/032130874-icon-service-Logic-Apps-Template.png similarity index 100% rename from assets/icons/azure/integration/032130874-icon-service-Logic-Apps-Template.png rename to cloudexit/assets/icons/azure/integration/032130874-icon-service-Logic-Apps-Template.png diff --git a/assets/icons/azure/integration/03335-icon-service-Power-Platform.png b/cloudexit/assets/icons/azure/integration/03335-icon-service-Power-Platform.png similarity index 100% rename from assets/icons/azure/integration/03335-icon-service-Power-Platform.png rename to cloudexit/assets/icons/azure/integration/03335-icon-service-Power-Platform.png diff --git a/assets/icons/azure/integration/03345-icon-service-Integration-Environments.png b/cloudexit/assets/icons/azure/integration/03345-icon-service-Integration-Environments.png similarity index 100% rename from assets/icons/azure/integration/03345-icon-service-Integration-Environments.png rename to cloudexit/assets/icons/azure/integration/03345-icon-service-Integration-Environments.png diff --git a/assets/icons/azure/integration/03637-icon-service-Business-Process-Tracking.png b/cloudexit/assets/icons/azure/integration/03637-icon-service-Business-Process-Tracking.png similarity index 100% rename from assets/icons/azure/integration/03637-icon-service-Business-Process-Tracking.png rename to cloudexit/assets/icons/azure/integration/03637-icon-service-Business-Process-Tracking.png diff --git a/assets/icons/azure/integration/10042-icon-service-API-Management-Services.png b/cloudexit/assets/icons/azure/integration/10042-icon-service-API-Management-Services.png similarity index 100% rename from assets/icons/azure/integration/10042-icon-service-API-Management-Services.png rename to cloudexit/assets/icons/azure/integration/10042-icon-service-API-Management-Services.png diff --git a/assets/icons/azure/integration/10048-icon-service-API-Connections.png b/cloudexit/assets/icons/azure/integration/10048-icon-service-API-Connections.png similarity index 100% rename from assets/icons/azure/integration/10048-icon-service-API-Connections.png rename to cloudexit/assets/icons/azure/integration/10048-icon-service-API-Connections.png diff --git a/assets/icons/azure/integration/10089-icon-service-StorSimple-Device-Managers.png b/cloudexit/assets/icons/azure/integration/10089-icon-service-StorSimple-Device-Managers.png similarity index 100% rename from assets/icons/azure/integration/10089-icon-service-StorSimple-Device-Managers.png rename to cloudexit/assets/icons/azure/integration/10089-icon-service-StorSimple-Device-Managers.png diff --git a/assets/icons/azure/integration/10095-icon-service-Azure-Stack-Edge.png b/cloudexit/assets/icons/azure/integration/10095-icon-service-Azure-Stack-Edge.png similarity index 100% rename from assets/icons/azure/integration/10095-icon-service-Azure-Stack-Edge.png rename to cloudexit/assets/icons/azure/integration/10095-icon-service-Azure-Stack-Edge.png diff --git a/assets/icons/azure/integration/10126-icon-service-Data-Factories.png b/cloudexit/assets/icons/azure/integration/10126-icon-service-Data-Factories.png similarity index 100% rename from assets/icons/azure/integration/10126-icon-service-Data-Factories.png rename to cloudexit/assets/icons/azure/integration/10126-icon-service-Data-Factories.png diff --git a/assets/icons/azure/integration/10137-icon-service-Azure-SQL-Server-Stretch-Databases.png b/cloudexit/assets/icons/azure/integration/10137-icon-service-Azure-SQL-Server-Stretch-Databases.png similarity index 100% rename from assets/icons/azure/integration/10137-icon-service-Azure-SQL-Server-Stretch-Databases.png rename to cloudexit/assets/icons/azure/integration/10137-icon-service-Azure-SQL-Server-Stretch-Databases.png diff --git a/assets/icons/azure/integration/10206-icon-service-Event-Grid-Topics.png b/cloudexit/assets/icons/azure/integration/10206-icon-service-Event-Grid-Topics.png similarity index 100% rename from assets/icons/azure/integration/10206-icon-service-Event-Grid-Topics.png rename to cloudexit/assets/icons/azure/integration/10206-icon-service-Event-Grid-Topics.png diff --git a/assets/icons/azure/integration/10209-icon-service-Relays.png b/cloudexit/assets/icons/azure/integration/10209-icon-service-Relays.png similarity index 100% rename from assets/icons/azure/integration/10209-icon-service-Relays.png rename to cloudexit/assets/icons/azure/integration/10209-icon-service-Relays.png diff --git a/assets/icons/azure/integration/10212-icon-service-Azure-API-for-FHIR.png b/cloudexit/assets/icons/azure/integration/10212-icon-service-Azure-API-for-FHIR.png similarity index 100% rename from assets/icons/azure/integration/10212-icon-service-Azure-API-for-FHIR.png rename to cloudexit/assets/icons/azure/integration/10212-icon-service-Azure-API-for-FHIR.png diff --git a/assets/icons/azure/integration/10213-icon-service-Software-as-a-Service.png b/cloudexit/assets/icons/azure/integration/10213-icon-service-Software-as-a-Service.png similarity index 100% rename from assets/icons/azure/integration/10213-icon-service-Software-as-a-Service.png rename to cloudexit/assets/icons/azure/integration/10213-icon-service-Software-as-a-Service.png diff --git a/assets/icons/azure/integration/10215-icon-service-Event-Grid-Domains.png b/cloudexit/assets/icons/azure/integration/10215-icon-service-Event-Grid-Domains.png similarity index 100% rename from assets/icons/azure/integration/10215-icon-service-Event-Grid-Domains.png rename to cloudexit/assets/icons/azure/integration/10215-icon-service-Event-Grid-Domains.png diff --git a/assets/icons/azure/integration/10216-icon-service-Azure-Data-Catalog.png b/cloudexit/assets/icons/azure/integration/10216-icon-service-Azure-Data-Catalog.png similarity index 100% rename from assets/icons/azure/integration/10216-icon-service-Azure-Data-Catalog.png rename to cloudexit/assets/icons/azure/integration/10216-icon-service-Azure-Data-Catalog.png diff --git a/assets/icons/azure/integration/10218-icon-service-Integration-Accounts.png b/cloudexit/assets/icons/azure/integration/10218-icon-service-Integration-Accounts.png similarity index 100% rename from assets/icons/azure/integration/10218-icon-service-Integration-Accounts.png rename to cloudexit/assets/icons/azure/integration/10218-icon-service-Integration-Accounts.png diff --git a/assets/icons/azure/integration/10219-icon-service-App-Configuration.png b/cloudexit/assets/icons/azure/integration/10219-icon-service-App-Configuration.png similarity index 100% rename from assets/icons/azure/integration/10219-icon-service-App-Configuration.png rename to cloudexit/assets/icons/azure/integration/10219-icon-service-App-Configuration.png diff --git a/assets/icons/azure/integration/10220-icon-service-SendGrid-Accounts.png b/cloudexit/assets/icons/azure/integration/10220-icon-service-SendGrid-Accounts.png similarity index 100% rename from assets/icons/azure/integration/10220-icon-service-SendGrid-Accounts.png rename to cloudexit/assets/icons/azure/integration/10220-icon-service-SendGrid-Accounts.png diff --git a/assets/icons/azure/integration/10221-icon-service-Event-Grid-Subscriptions.png b/cloudexit/assets/icons/azure/integration/10221-icon-service-Event-Grid-Subscriptions.png similarity index 100% rename from assets/icons/azure/integration/10221-icon-service-Event-Grid-Subscriptions.png rename to cloudexit/assets/icons/azure/integration/10221-icon-service-Event-Grid-Subscriptions.png diff --git a/assets/icons/azure/integration/10363-icon-service-Logic-Apps-Custom-Connector.png b/cloudexit/assets/icons/azure/integration/10363-icon-service-Logic-Apps-Custom-Connector.png similarity index 100% rename from assets/icons/azure/integration/10363-icon-service-Logic-Apps-Custom-Connector.png rename to cloudexit/assets/icons/azure/integration/10363-icon-service-Logic-Apps-Custom-Connector.png diff --git a/assets/icons/azure/integration/10836-icon-service-Azure-Service-Bus.png b/cloudexit/assets/icons/azure/integration/10836-icon-service-Azure-Service-Bus.png similarity index 100% rename from assets/icons/azure/integration/10836-icon-service-Azure-Service-Bus.png rename to cloudexit/assets/icons/azure/integration/10836-icon-service-Azure-Service-Bus.png diff --git a/assets/icons/azure/intune/00399-icon-service-Device-Security-Apple.png b/cloudexit/assets/icons/azure/intune/00399-icon-service-Device-Security-Apple.png similarity index 100% rename from assets/icons/azure/intune/00399-icon-service-Device-Security-Apple.png rename to cloudexit/assets/icons/azure/intune/00399-icon-service-Device-Security-Apple.png diff --git a/assets/icons/azure/intune/00399-icon-service-Device-Security-Google.png b/cloudexit/assets/icons/azure/intune/00399-icon-service-Device-Security-Google.png similarity index 100% rename from assets/icons/azure/intune/00399-icon-service-Device-Security-Google.png rename to cloudexit/assets/icons/azure/intune/00399-icon-service-Device-Security-Google.png diff --git a/assets/icons/azure/intune/00399-icon-service-Device-Security-Windows.png b/cloudexit/assets/icons/azure/intune/00399-icon-service-Device-Security-Windows.png similarity index 100% rename from assets/icons/azure/intune/00399-icon-service-Device-Security-Windows.png rename to cloudexit/assets/icons/azure/intune/00399-icon-service-Device-Security-Windows.png diff --git a/assets/icons/azure/intune/029797391-icon-service-Engage-Center-Connect.png b/cloudexit/assets/icons/azure/intune/029797391-icon-service-Engage-Center-Connect.png similarity index 100% rename from assets/icons/azure/intune/029797391-icon-service-Engage-Center-Connect.png rename to cloudexit/assets/icons/azure/intune/029797391-icon-service-Engage-Center-Connect.png diff --git a/assets/icons/azure/intune/10329-icon-service-Intune.png b/cloudexit/assets/icons/azure/intune/10329-icon-service-Intune.png similarity index 100% rename from assets/icons/azure/intune/10329-icon-service-Intune.png rename to cloudexit/assets/icons/azure/intune/10329-icon-service-Intune.png diff --git a/assets/icons/azure/intune/10330-icon-service-eBooks.png b/cloudexit/assets/icons/azure/intune/10330-icon-service-eBooks.png similarity index 100% rename from assets/icons/azure/intune/10330-icon-service-eBooks.png rename to cloudexit/assets/icons/azure/intune/10330-icon-service-eBooks.png diff --git a/assets/icons/azure/intune/10331-icon-service-Client-Apps.png b/cloudexit/assets/icons/azure/intune/10331-icon-service-Client-Apps.png similarity index 100% rename from assets/icons/azure/intune/10331-icon-service-Client-Apps.png rename to cloudexit/assets/icons/azure/intune/10331-icon-service-Client-Apps.png diff --git a/assets/icons/azure/intune/10332-icon-service-Devices.png b/cloudexit/assets/icons/azure/intune/10332-icon-service-Devices.png similarity index 100% rename from assets/icons/azure/intune/10332-icon-service-Devices.png rename to cloudexit/assets/icons/azure/intune/10332-icon-service-Devices.png diff --git a/assets/icons/azure/intune/10333-icon-service-Device-Compliance.png b/cloudexit/assets/icons/azure/intune/10333-icon-service-Device-Compliance.png similarity index 100% rename from assets/icons/azure/intune/10333-icon-service-Device-Compliance.png rename to cloudexit/assets/icons/azure/intune/10333-icon-service-Device-Compliance.png diff --git a/assets/icons/azure/intune/10335-icon-service-Software-Updates.png b/cloudexit/assets/icons/azure/intune/10335-icon-service-Software-Updates.png similarity index 100% rename from assets/icons/azure/intune/10335-icon-service-Software-Updates.png rename to cloudexit/assets/icons/azure/intune/10335-icon-service-Software-Updates.png diff --git a/assets/icons/azure/intune/10336-icon-service-Security-Baselines.png b/cloudexit/assets/icons/azure/intune/10336-icon-service-Security-Baselines.png similarity index 100% rename from assets/icons/azure/intune/10336-icon-service-Security-Baselines.png rename to cloudexit/assets/icons/azure/intune/10336-icon-service-Security-Baselines.png diff --git a/assets/icons/azure/intune/10337-icon-service-Device-Enrollment.png b/cloudexit/assets/icons/azure/intune/10337-icon-service-Device-Enrollment.png similarity index 100% rename from assets/icons/azure/intune/10337-icon-service-Device-Enrollment.png rename to cloudexit/assets/icons/azure/intune/10337-icon-service-Device-Enrollment.png diff --git a/assets/icons/azure/intune/10338-icon-service-Device-Configuration.png b/cloudexit/assets/icons/azure/intune/10338-icon-service-Device-Configuration.png similarity index 100% rename from assets/icons/azure/intune/10338-icon-service-Device-Configuration.png rename to cloudexit/assets/icons/azure/intune/10338-icon-service-Device-Configuration.png diff --git a/assets/icons/azure/intune/10339-icon-service-Exchange-Access.png b/cloudexit/assets/icons/azure/intune/10339-icon-service-Exchange-Access.png similarity index 100% rename from assets/icons/azure/intune/10339-icon-service-Exchange-Access.png rename to cloudexit/assets/icons/azure/intune/10339-icon-service-Exchange-Access.png diff --git a/assets/icons/azure/intune/10340-icon-service-Azure-AD-Roles-and-Administrators.png b/cloudexit/assets/icons/azure/intune/10340-icon-service-Azure-AD-Roles-and-Administrators.png similarity index 100% rename from assets/icons/azure/intune/10340-icon-service-Azure-AD-Roles-and-Administrators.png rename to cloudexit/assets/icons/azure/intune/10340-icon-service-Azure-AD-Roles-and-Administrators.png diff --git a/assets/icons/azure/intune/10340-icon-service-Entra-Identity-Roles-and-Administrators.png b/cloudexit/assets/icons/azure/intune/10340-icon-service-Entra-Identity-Roles-and-Administrators.png similarity index 100% rename from assets/icons/azure/intune/10340-icon-service-Entra-Identity-Roles-and-Administrators.png rename to cloudexit/assets/icons/azure/intune/10340-icon-service-Entra-Identity-Roles-and-Administrators.png diff --git a/assets/icons/azure/intune/10342-icon-service-Tenant-Status.png b/cloudexit/assets/icons/azure/intune/10342-icon-service-Tenant-Status.png similarity index 100% rename from assets/icons/azure/intune/10342-icon-service-Tenant-Status.png rename to cloudexit/assets/icons/azure/intune/10342-icon-service-Tenant-Status.png diff --git a/assets/icons/azure/intune/10343-icon-service-Intune-For-Education.png b/cloudexit/assets/icons/azure/intune/10343-icon-service-Intune-For-Education.png similarity index 100% rename from assets/icons/azure/intune/10343-icon-service-Intune-For-Education.png rename to cloudexit/assets/icons/azure/intune/10343-icon-service-Intune-For-Education.png diff --git a/assets/icons/azure/intune/10344-icon-service-Intune-App-Protection.png b/cloudexit/assets/icons/azure/intune/10344-icon-service-Intune-App-Protection.png similarity index 100% rename from assets/icons/azure/intune/10344-icon-service-Intune-App-Protection.png rename to cloudexit/assets/icons/azure/intune/10344-icon-service-Intune-App-Protection.png diff --git a/assets/icons/azure/intune/10350-icon-service-Mindaro.png b/cloudexit/assets/icons/azure/intune/10350-icon-service-Mindaro.png similarity index 100% rename from assets/icons/azure/intune/10350-icon-service-Mindaro.png rename to cloudexit/assets/icons/azure/intune/10350-icon-service-Mindaro.png diff --git a/assets/icons/azure/iot/00030-icon-service-Machine-Learning-Studio-(Classic)-Web-Services.png b/cloudexit/assets/icons/azure/iot/00030-icon-service-Machine-Learning-Studio-(Classic)-Web-Services.png similarity index 100% rename from assets/icons/azure/iot/00030-icon-service-Machine-Learning-Studio-(Classic)-Web-Services.png rename to cloudexit/assets/icons/azure/iot/00030-icon-service-Machine-Learning-Studio-(Classic)-Web-Services.png diff --git a/assets/icons/azure/iot/00039-icon-service-Event-Hubs.png b/cloudexit/assets/icons/azure/iot/00039-icon-service-Event-Hubs.png similarity index 100% rename from assets/icons/azure/iot/00039-icon-service-Event-Hubs.png rename to cloudexit/assets/icons/azure/iot/00039-icon-service-Event-Hubs.png diff --git a/assets/icons/azure/iot/00042-icon-service-Stream-Analytics-Jobs.png b/cloudexit/assets/icons/azure/iot/00042-icon-service-Stream-Analytics-Jobs.png similarity index 100% rename from assets/icons/azure/iot/00042-icon-service-Stream-Analytics-Jobs.png rename to cloudexit/assets/icons/azure/iot/00042-icon-service-Stream-Analytics-Jobs.png diff --git a/assets/icons/azure/iot/00691-icon-service-Azure-Databox-Gateway.png b/cloudexit/assets/icons/azure/iot/00691-icon-service-Azure-Databox-Gateway.png similarity index 100% rename from assets/icons/azure/iot/00691-icon-service-Azure-Databox-Gateway.png rename to cloudexit/assets/icons/azure/iot/00691-icon-service-Azure-Databox-Gateway.png diff --git a/assets/icons/azure/iot/01030-icon-service-Digital-Twins.png b/cloudexit/assets/icons/azure/iot/01030-icon-service-Digital-Twins.png similarity index 100% rename from assets/icons/azure/iot/01030-icon-service-Digital-Twins.png rename to cloudexit/assets/icons/azure/iot/01030-icon-service-Digital-Twins.png diff --git a/assets/icons/azure/iot/02359-icon-service-Industrial-IoT.png b/cloudexit/assets/icons/azure/iot/02359-icon-service-Industrial-IoT.png similarity index 100% rename from assets/icons/azure/iot/02359-icon-service-Industrial-IoT.png rename to cloudexit/assets/icons/azure/iot/02359-icon-service-Industrial-IoT.png diff --git a/assets/icons/azure/iot/02631-icon-service-Logic-Apps.png b/cloudexit/assets/icons/azure/iot/02631-icon-service-Logic-Apps.png similarity index 100% rename from assets/icons/azure/iot/02631-icon-service-Logic-Apps.png rename to cloudexit/assets/icons/azure/iot/02631-icon-service-Logic-Apps.png diff --git a/assets/icons/azure/iot/03293-icon-service-Azure-Stack-HCI-Sizer.png b/cloudexit/assets/icons/azure/iot/03293-icon-service-Azure-Stack-HCI-Sizer.png similarity index 100% rename from assets/icons/azure/iot/03293-icon-service-Azure-Stack-HCI-Sizer.png rename to cloudexit/assets/icons/azure/iot/03293-icon-service-Azure-Stack-HCI-Sizer.png diff --git a/assets/icons/azure/iot/03466-icon-service-Stack-HCI-Premium.png b/cloudexit/assets/icons/azure/iot/03466-icon-service-Stack-HCI-Premium.png similarity index 100% rename from assets/icons/azure/iot/03466-icon-service-Stack-HCI-Premium.png rename to cloudexit/assets/icons/azure/iot/03466-icon-service-Stack-HCI-Premium.png diff --git a/assets/icons/azure/iot/03485-icon-service-Azure-IoT-Operations.png b/cloudexit/assets/icons/azure/iot/03485-icon-service-Azure-IoT-Operations.png similarity index 100% rename from assets/icons/azure/iot/03485-icon-service-Azure-IoT-Operations.png rename to cloudexit/assets/icons/azure/iot/03485-icon-service-Azure-IoT-Operations.png diff --git a/assets/icons/azure/iot/03656-icon-service-Hybrid-Connectivity-Hub.png b/cloudexit/assets/icons/azure/iot/03656-icon-service-Hybrid-Connectivity-Hub.png similarity index 100% rename from assets/icons/azure/iot/03656-icon-service-Hybrid-Connectivity-Hub.png rename to cloudexit/assets/icons/azure/iot/03656-icon-service-Hybrid-Connectivity-Hub.png diff --git a/assets/icons/azure/iot/10029-icon-service-Function-Apps.png b/cloudexit/assets/icons/azure/iot/10029-icon-service-Function-Apps.png similarity index 100% rename from assets/icons/azure/iot/10029-icon-service-Function-Apps.png rename to cloudexit/assets/icons/azure/iot/10029-icon-service-Function-Apps.png diff --git a/assets/icons/azure/iot/10045-icon-service-Notification-Hubs.png b/cloudexit/assets/icons/azure/iot/10045-icon-service-Notification-Hubs.png similarity index 100% rename from assets/icons/azure/iot/10045-icon-service-Notification-Hubs.png rename to cloudexit/assets/icons/azure/iot/10045-icon-service-Notification-Hubs.png diff --git a/assets/icons/azure/iot/10053-icon-service-Notification-Hub-Namespaces.png b/cloudexit/assets/icons/azure/iot/10053-icon-service-Notification-Hub-Namespaces.png similarity index 100% rename from assets/icons/azure/iot/10053-icon-service-Notification-Hub-Namespaces.png rename to cloudexit/assets/icons/azure/iot/10053-icon-service-Notification-Hub-Namespaces.png diff --git a/assets/icons/azure/iot/10114-icon-service-Azure-Stack.png b/cloudexit/assets/icons/azure/iot/10114-icon-service-Azure-Stack.png similarity index 100% rename from assets/icons/azure/iot/10114-icon-service-Azure-Stack.png rename to cloudexit/assets/icons/azure/iot/10114-icon-service-Azure-Stack.png diff --git a/assets/icons/azure/iot/10121-icon-service-Azure-Cosmos-DB.png b/cloudexit/assets/icons/azure/iot/10121-icon-service-Azure-Cosmos-DB.png similarity index 100% rename from assets/icons/azure/iot/10121-icon-service-Azure-Cosmos-DB.png rename to cloudexit/assets/icons/azure/iot/10121-icon-service-Azure-Cosmos-DB.png diff --git a/assets/icons/azure/iot/10149-icon-service-Event-Hub-Clusters.png b/cloudexit/assets/icons/azure/iot/10149-icon-service-Event-Hub-Clusters.png similarity index 100% rename from assets/icons/azure/iot/10149-icon-service-Event-Hub-Clusters.png rename to cloudexit/assets/icons/azure/iot/10149-icon-service-Event-Hub-Clusters.png diff --git a/assets/icons/azure/iot/10167-icon-service-Machine-Learning-Studio-Workspaces.png b/cloudexit/assets/icons/azure/iot/10167-icon-service-Machine-Learning-Studio-Workspaces.png similarity index 100% rename from assets/icons/azure/iot/10167-icon-service-Machine-Learning-Studio-Workspaces.png rename to cloudexit/assets/icons/azure/iot/10167-icon-service-Machine-Learning-Studio-Workspaces.png diff --git a/assets/icons/azure/iot/10168-icon-service-Machine-Learning-Studio-Web-Service-Plans.png b/cloudexit/assets/icons/azure/iot/10168-icon-service-Machine-Learning-Studio-Web-Service-Plans.png similarity index 100% rename from assets/icons/azure/iot/10168-icon-service-Machine-Learning-Studio-Web-Service-Plans.png rename to cloudexit/assets/icons/azure/iot/10168-icon-service-Machine-Learning-Studio-Web-Service-Plans.png diff --git a/assets/icons/azure/iot/10181-icon-service-Time-Series-Insights-Environments.png b/cloudexit/assets/icons/azure/iot/10181-icon-service-Time-Series-Insights-Environments.png similarity index 100% rename from assets/icons/azure/iot/10181-icon-service-Time-Series-Insights-Environments.png rename to cloudexit/assets/icons/azure/iot/10181-icon-service-Time-Series-Insights-Environments.png diff --git a/assets/icons/azure/iot/10182-icon-service-IoT-Hub.png b/cloudexit/assets/icons/azure/iot/10182-icon-service-IoT-Hub.png similarity index 100% rename from assets/icons/azure/iot/10182-icon-service-IoT-Hub.png rename to cloudexit/assets/icons/azure/iot/10182-icon-service-IoT-Hub.png diff --git a/assets/icons/azure/iot/10184-icon-service-IoT-Central-Applications.png b/cloudexit/assets/icons/azure/iot/10184-icon-service-IoT-Central-Applications.png similarity index 100% rename from assets/icons/azure/iot/10184-icon-service-IoT-Central-Applications.png rename to cloudexit/assets/icons/azure/iot/10184-icon-service-IoT-Central-Applications.png diff --git a/assets/icons/azure/iot/10185-icon-service-Azure-Maps-Accounts.png b/cloudexit/assets/icons/azure/iot/10185-icon-service-Azure-Maps-Accounts.png similarity index 100% rename from assets/icons/azure/iot/10185-icon-service-Azure-Maps-Accounts.png rename to cloudexit/assets/icons/azure/iot/10185-icon-service-Azure-Maps-Accounts.png diff --git a/assets/icons/azure/iot/10186-icon-service-IoT-Edge.png b/cloudexit/assets/icons/azure/iot/10186-icon-service-IoT-Edge.png similarity index 100% rename from assets/icons/azure/iot/10186-icon-service-IoT-Edge.png rename to cloudexit/assets/icons/azure/iot/10186-icon-service-IoT-Edge.png diff --git a/assets/icons/azure/iot/10188-icon-service-Time-Series-Insights-Event-Sources.png b/cloudexit/assets/icons/azure/iot/10188-icon-service-Time-Series-Insights-Event-Sources.png similarity index 100% rename from assets/icons/azure/iot/10188-icon-service-Time-Series-Insights-Event-Sources.png rename to cloudexit/assets/icons/azure/iot/10188-icon-service-Time-Series-Insights-Event-Sources.png diff --git a/assets/icons/azure/iot/10192-icon-service-Time-Series-Insights-Access-Policies.png b/cloudexit/assets/icons/azure/iot/10192-icon-service-Time-Series-Insights-Access-Policies.png similarity index 100% rename from assets/icons/azure/iot/10192-icon-service-Time-Series-Insights-Access-Policies.png rename to cloudexit/assets/icons/azure/iot/10192-icon-service-Time-Series-Insights-Access-Policies.png diff --git a/assets/icons/azure/iot/10198-icon-service-Time-Series-Data-Sets.png b/cloudexit/assets/icons/azure/iot/10198-icon-service-Time-Series-Data-Sets.png similarity index 100% rename from assets/icons/azure/iot/10198-icon-service-Time-Series-Data-Sets.png rename to cloudexit/assets/icons/azure/iot/10198-icon-service-Time-Series-Data-Sets.png diff --git a/assets/icons/azure/iot/10203-icon-service-Windows10-Core-Services.png b/cloudexit/assets/icons/azure/iot/10203-icon-service-Windows10-Core-Services.png similarity index 100% rename from assets/icons/azure/iot/10203-icon-service-Windows10-Core-Services.png rename to cloudexit/assets/icons/azure/iot/10203-icon-service-Windows10-Core-Services.png diff --git a/assets/icons/azure/iot/10221-icon-service-Event-Grid-Subscriptions.png b/cloudexit/assets/icons/azure/iot/10221-icon-service-Event-Grid-Subscriptions.png similarity index 100% rename from assets/icons/azure/iot/10221-icon-service-Event-Grid-Subscriptions.png rename to cloudexit/assets/icons/azure/iot/10221-icon-service-Event-Grid-Subscriptions.png diff --git a/assets/icons/azure/iot/10369-icon-service-Device-Provisioning-Services.png b/cloudexit/assets/icons/azure/iot/10369-icon-service-Device-Provisioning-Services.png similarity index 100% rename from assets/icons/azure/iot/10369-icon-service-Device-Provisioning-Services.png rename to cloudexit/assets/icons/azure/iot/10369-icon-service-Device-Provisioning-Services.png diff --git a/assets/icons/azure/management-governance/00001-icon-service-Monitor.png b/cloudexit/assets/icons/azure/management-governance/00001-icon-service-Monitor.png similarity index 100% rename from assets/icons/azure/management-governance/00001-icon-service-Monitor.png rename to cloudexit/assets/icons/azure/management-governance/00001-icon-service-Monitor.png diff --git a/assets/icons/azure/management-governance/00002-icon-service-Alerts.png b/cloudexit/assets/icons/azure/management-governance/00002-icon-service-Alerts.png similarity index 100% rename from assets/icons/azure/management-governance/00002-icon-service-Alerts.png rename to cloudexit/assets/icons/azure/management-governance/00002-icon-service-Alerts.png diff --git a/assets/icons/azure/management-governance/00003-icon-service-Advisor.png b/cloudexit/assets/icons/azure/management-governance/00003-icon-service-Advisor.png similarity index 100% rename from assets/icons/azure/management-governance/00003-icon-service-Advisor.png rename to cloudexit/assets/icons/azure/management-governance/00003-icon-service-Advisor.png diff --git a/assets/icons/azure/management-governance/00004-icon-service-Cost-Management-and-Billing.png b/cloudexit/assets/icons/azure/management-governance/00004-icon-service-Cost-Management-and-Billing.png similarity index 100% rename from assets/icons/azure/management-governance/00004-icon-service-Cost-Management-and-Billing.png rename to cloudexit/assets/icons/azure/management-governance/00004-icon-service-Cost-Management-and-Billing.png diff --git a/assets/icons/azure/management-governance/00006-icon-service-Blueprints.png b/cloudexit/assets/icons/azure/management-governance/00006-icon-service-Blueprints.png similarity index 100% rename from assets/icons/azure/management-governance/00006-icon-service-Blueprints.png rename to cloudexit/assets/icons/azure/management-governance/00006-icon-service-Blueprints.png diff --git a/assets/icons/azure/management-governance/00007-icon-service-Activity-Log.png b/cloudexit/assets/icons/azure/management-governance/00007-icon-service-Activity-Log.png similarity index 100% rename from assets/icons/azure/management-governance/00007-icon-service-Activity-Log.png rename to cloudexit/assets/icons/azure/management-governance/00007-icon-service-Activity-Log.png diff --git a/assets/icons/azure/management-governance/00008-icon-service-Diagnostics-Settings.png b/cloudexit/assets/icons/azure/management-governance/00008-icon-service-Diagnostics-Settings.png similarity index 100% rename from assets/icons/azure/management-governance/00008-icon-service-Diagnostics-Settings.png rename to cloudexit/assets/icons/azure/management-governance/00008-icon-service-Diagnostics-Settings.png diff --git a/assets/icons/azure/management-governance/00009-icon-service-Log-Analytics-Workspaces.png b/cloudexit/assets/icons/azure/management-governance/00009-icon-service-Log-Analytics-Workspaces.png similarity index 100% rename from assets/icons/azure/management-governance/00009-icon-service-Log-Analytics-Workspaces.png rename to cloudexit/assets/icons/azure/management-governance/00009-icon-service-Log-Analytics-Workspaces.png diff --git a/assets/icons/azure/management-governance/00010-icon-service-Scheduler-Job-Collections.png b/cloudexit/assets/icons/azure/management-governance/00010-icon-service-Scheduler-Job-Collections.png similarity index 100% rename from assets/icons/azure/management-governance/00010-icon-service-Scheduler-Job-Collections.png rename to cloudexit/assets/icons/azure/management-governance/00010-icon-service-Scheduler-Job-Collections.png diff --git a/assets/icons/azure/management-governance/00011-icon-service-Compliance.png b/cloudexit/assets/icons/azure/management-governance/00011-icon-service-Compliance.png similarity index 100% rename from assets/icons/azure/management-governance/00011-icon-service-Compliance.png rename to cloudexit/assets/icons/azure/management-governance/00011-icon-service-Compliance.png diff --git a/assets/icons/azure/management-governance/00012-icon-service-Application-Insights.png b/cloudexit/assets/icons/azure/management-governance/00012-icon-service-Application-Insights.png similarity index 100% rename from assets/icons/azure/management-governance/00012-icon-service-Application-Insights.png rename to cloudexit/assets/icons/azure/management-governance/00012-icon-service-Application-Insights.png diff --git a/assets/icons/azure/management-governance/00014-icon-service-My-Customers.png b/cloudexit/assets/icons/azure/management-governance/00014-icon-service-My-Customers.png similarity index 100% rename from assets/icons/azure/management-governance/00014-icon-service-My-Customers.png rename to cloudexit/assets/icons/azure/management-governance/00014-icon-service-My-Customers.png diff --git a/assets/icons/azure/management-governance/00017-icon-service-Recovery-Services-Vaults.png b/cloudexit/assets/icons/azure/management-governance/00017-icon-service-Recovery-Services-Vaults.png similarity index 100% rename from assets/icons/azure/management-governance/00017-icon-service-Recovery-Services-Vaults.png rename to cloudexit/assets/icons/azure/management-governance/00017-icon-service-Recovery-Services-Vaults.png diff --git a/assets/icons/azure/management-governance/00020-icon-service-Metrics.png b/cloudexit/assets/icons/azure/management-governance/00020-icon-service-Metrics.png similarity index 100% rename from assets/icons/azure/management-governance/00020-icon-service-Metrics.png rename to cloudexit/assets/icons/azure/management-governance/00020-icon-service-Metrics.png diff --git a/assets/icons/azure/management-governance/00021-icon-service-Solutions.png b/cloudexit/assets/icons/azure/management-governance/00021-icon-service-Solutions.png similarity index 100% rename from assets/icons/azure/management-governance/00021-icon-service-Solutions.png rename to cloudexit/assets/icons/azure/management-governance/00021-icon-service-Solutions.png diff --git a/assets/icons/azure/management-governance/00022-icon-service-Automation-Accounts.png b/cloudexit/assets/icons/azure/management-governance/00022-icon-service-Automation-Accounts.png similarity index 100% rename from assets/icons/azure/management-governance/00022-icon-service-Automation-Accounts.png rename to cloudexit/assets/icons/azure/management-governance/00022-icon-service-Automation-Accounts.png diff --git a/assets/icons/azure/management-governance/00024-icon-service-Operation-Log-(Classic).png b/cloudexit/assets/icons/azure/management-governance/00024-icon-service-Operation-Log-(Classic).png similarity index 100% rename from assets/icons/azure/management-governance/00024-icon-service-Operation-Log-(Classic).png rename to cloudexit/assets/icons/azure/management-governance/00024-icon-service-Operation-Log-(Classic).png diff --git a/assets/icons/azure/management-governance/00025-icon-service-Service-Providers.png b/cloudexit/assets/icons/azure/management-governance/00025-icon-service-Service-Providers.png similarity index 100% rename from assets/icons/azure/management-governance/00025-icon-service-Service-Providers.png rename to cloudexit/assets/icons/azure/management-governance/00025-icon-service-Service-Providers.png diff --git a/assets/icons/azure/management-governance/00026-icon-service-Education.png b/cloudexit/assets/icons/azure/management-governance/00026-icon-service-Education.png similarity index 100% rename from assets/icons/azure/management-governance/00026-icon-service-Education.png rename to cloudexit/assets/icons/azure/management-governance/00026-icon-service-Education.png diff --git a/assets/icons/azure/management-governance/00027-icon-service-Service-Catalog-MAD.png b/cloudexit/assets/icons/azure/management-governance/00027-icon-service-Service-Catalog-MAD.png similarity index 100% rename from assets/icons/azure/management-governance/00027-icon-service-Service-Catalog-MAD.png rename to cloudexit/assets/icons/azure/management-governance/00027-icon-service-Service-Catalog-MAD.png diff --git a/assets/icons/azure/management-governance/00408-icon-service-Intune-Trends.png b/cloudexit/assets/icons/azure/management-governance/00408-icon-service-Intune-Trends.png similarity index 100% rename from assets/icons/azure/management-governance/00408-icon-service-Intune-Trends.png rename to cloudexit/assets/icons/azure/management-governance/00408-icon-service-Intune-Trends.png diff --git a/assets/icons/azure/management-governance/00471-icon-service-Azure-Lighthouse.png b/cloudexit/assets/icons/azure/management-governance/00471-icon-service-Azure-Lighthouse.png similarity index 100% rename from assets/icons/azure/management-governance/00471-icon-service-Azure-Lighthouse.png rename to cloudexit/assets/icons/azure/management-governance/00471-icon-service-Azure-Lighthouse.png diff --git a/assets/icons/azure/management-governance/00571-icon-service-Universal-Print.png b/cloudexit/assets/icons/azure/management-governance/00571-icon-service-Universal-Print.png similarity index 100% rename from assets/icons/azure/management-governance/00571-icon-service-Universal-Print.png rename to cloudexit/assets/icons/azure/management-governance/00571-icon-service-Universal-Print.png diff --git a/assets/icons/azure/management-governance/00756-icon-service-Azure-Arc.png b/cloudexit/assets/icons/azure/management-governance/00756-icon-service-Azure-Arc.png similarity index 100% rename from assets/icons/azure/management-governance/00756-icon-service-Azure-Arc.png rename to cloudexit/assets/icons/azure/management-governance/00756-icon-service-Azure-Arc.png diff --git a/assets/icons/azure/management-governance/01710-icon-service-Arc-Machines.png b/cloudexit/assets/icons/azure/management-governance/01710-icon-service-Arc-Machines.png similarity index 100% rename from assets/icons/azure/management-governance/01710-icon-service-Arc-Machines.png rename to cloudexit/assets/icons/azure/management-governance/01710-icon-service-Arc-Machines.png diff --git a/assets/icons/azure/management-governance/030777508-icon-service-Service-Group-Relationships.png b/cloudexit/assets/icons/azure/management-governance/030777508-icon-service-Service-Group-Relationships.png similarity index 100% rename from assets/icons/azure/management-governance/030777508-icon-service-Service-Group-Relationships.png rename to cloudexit/assets/icons/azure/management-governance/030777508-icon-service-Service-Group-Relationships.png diff --git a/assets/icons/azure/management-governance/03366-icon-service-Resources-Provider.png b/cloudexit/assets/icons/azure/management-governance/03366-icon-service-Resources-Provider.png similarity index 100% rename from assets/icons/azure/management-governance/03366-icon-service-Resources-Provider.png rename to cloudexit/assets/icons/azure/management-governance/03366-icon-service-Resources-Provider.png diff --git a/assets/icons/azure/management-governance/10303-icon-service-User-Privacy.png b/cloudexit/assets/icons/azure/management-governance/10303-icon-service-User-Privacy.png similarity index 100% rename from assets/icons/azure/management-governance/10303-icon-service-User-Privacy.png rename to cloudexit/assets/icons/azure/management-governance/10303-icon-service-User-Privacy.png diff --git a/assets/icons/azure/management-governance/10311-icon-service-Managed-Desktop.png b/cloudexit/assets/icons/azure/management-governance/10311-icon-service-Managed-Desktop.png similarity index 100% rename from assets/icons/azure/management-governance/10311-icon-service-Managed-Desktop.png rename to cloudexit/assets/icons/azure/management-governance/10311-icon-service-Managed-Desktop.png diff --git a/assets/icons/azure/management-governance/10313-icon-service-Managed-Applications-Center.png b/cloudexit/assets/icons/azure/management-governance/10313-icon-service-Managed-Applications-Center.png similarity index 100% rename from assets/icons/azure/management-governance/10313-icon-service-Managed-Applications-Center.png rename to cloudexit/assets/icons/azure/management-governance/10313-icon-service-Managed-Applications-Center.png diff --git a/assets/icons/azure/management-governance/10314-icon-service-Customer-Lockbox-for-Microsoft-Azure.png b/cloudexit/assets/icons/azure/management-governance/10314-icon-service-Customer-Lockbox-for-Microsoft-Azure.png similarity index 100% rename from assets/icons/azure/management-governance/10314-icon-service-Customer-Lockbox-for-Microsoft-Azure.png rename to cloudexit/assets/icons/azure/management-governance/10314-icon-service-Customer-Lockbox-for-Microsoft-Azure.png diff --git a/assets/icons/azure/management-governance/10316-icon-service-Policy.png b/cloudexit/assets/icons/azure/management-governance/10316-icon-service-Policy.png similarity index 100% rename from assets/icons/azure/management-governance/10316-icon-service-Policy.png rename to cloudexit/assets/icons/azure/management-governance/10316-icon-service-Policy.png diff --git a/assets/icons/azure/management-governance/10318-icon-service-Resource-Graph-Explorer.png b/cloudexit/assets/icons/azure/management-governance/10318-icon-service-Resource-Graph-Explorer.png similarity index 100% rename from assets/icons/azure/management-governance/10318-icon-service-Resource-Graph-Explorer.png rename to cloudexit/assets/icons/azure/management-governance/10318-icon-service-Resource-Graph-Explorer.png diff --git a/assets/icons/azure/management-governance/10450-icon-service-MachinesAzureArc.png b/cloudexit/assets/icons/azure/management-governance/10450-icon-service-MachinesAzureArc.png similarity index 100% rename from assets/icons/azure/management-governance/10450-icon-service-MachinesAzureArc.png rename to cloudexit/assets/icons/azure/management-governance/10450-icon-service-MachinesAzureArc.png diff --git a/assets/icons/azure/menu/00787-icon-service-Keys.png b/cloudexit/assets/icons/azure/menu/00787-icon-service-Keys.png similarity index 100% rename from assets/icons/azure/menu/00787-icon-service-Keys.png rename to cloudexit/assets/icons/azure/menu/00787-icon-service-Keys.png diff --git a/assets/icons/azure/migrate/00004-icon-service-Cost-Management-and-Billing.png b/cloudexit/assets/icons/azure/migrate/00004-icon-service-Cost-Management-and-Billing.png similarity index 100% rename from assets/icons/azure/migrate/00004-icon-service-Cost-Management-and-Billing.png rename to cloudexit/assets/icons/azure/migrate/00004-icon-service-Cost-Management-and-Billing.png diff --git a/assets/icons/azure/migrate/00017-icon-service-Recovery-Services-Vaults.png b/cloudexit/assets/icons/azure/migrate/00017-icon-service-Recovery-Services-Vaults.png similarity index 100% rename from assets/icons/azure/migrate/00017-icon-service-Recovery-Services-Vaults.png rename to cloudexit/assets/icons/azure/migrate/00017-icon-service-Recovery-Services-Vaults.png diff --git a/assets/icons/azure/migrate/00691-icon-service-Azure-Databox-Gateway.png b/cloudexit/assets/icons/azure/migrate/00691-icon-service-Azure-Databox-Gateway.png similarity index 100% rename from assets/icons/azure/migrate/00691-icon-service-Azure-Databox-Gateway.png rename to cloudexit/assets/icons/azure/migrate/00691-icon-service-Azure-Databox-Gateway.png diff --git a/assets/icons/azure/migrate/10094-icon-service-Data-Box.png b/cloudexit/assets/icons/azure/migrate/10094-icon-service-Data-Box.png similarity index 100% rename from assets/icons/azure/migrate/10094-icon-service-Data-Box.png rename to cloudexit/assets/icons/azure/migrate/10094-icon-service-Data-Box.png diff --git a/assets/icons/azure/migrate/10095-icon-service-Azure-Stack-Edge.png b/cloudexit/assets/icons/azure/migrate/10095-icon-service-Azure-Stack-Edge.png similarity index 100% rename from assets/icons/azure/migrate/10095-icon-service-Azure-Stack-Edge.png rename to cloudexit/assets/icons/azure/migrate/10095-icon-service-Azure-Stack-Edge.png diff --git a/assets/icons/azure/migrate/10281-icon-service-Azure-Migrate.png b/cloudexit/assets/icons/azure/migrate/10281-icon-service-Azure-Migrate.png similarity index 100% rename from assets/icons/azure/migrate/10281-icon-service-Azure-Migrate.png rename to cloudexit/assets/icons/azure/migrate/10281-icon-service-Azure-Migrate.png diff --git a/assets/icons/azure/migration/10133-icon-service-Azure-Database-Migration-Services.png b/cloudexit/assets/icons/azure/migration/10133-icon-service-Azure-Database-Migration-Services.png similarity index 100% rename from assets/icons/azure/migration/10133-icon-service-Azure-Database-Migration-Services.png rename to cloudexit/assets/icons/azure/migration/10133-icon-service-Azure-Database-Migration-Services.png diff --git a/assets/icons/azure/mixed-reality/00698-icon-service-Remote-Rendering.png b/cloudexit/assets/icons/azure/mixed-reality/00698-icon-service-Remote-Rendering.png similarity index 100% rename from assets/icons/azure/mixed-reality/00698-icon-service-Remote-Rendering.png rename to cloudexit/assets/icons/azure/mixed-reality/00698-icon-service-Remote-Rendering.png diff --git a/assets/icons/azure/mixed-reality/10352-icon-service-Spatial-Anchor-Accounts.png b/cloudexit/assets/icons/azure/mixed-reality/10352-icon-service-Spatial-Anchor-Accounts.png similarity index 100% rename from assets/icons/azure/mixed-reality/10352-icon-service-Spatial-Anchor-Accounts.png rename to cloudexit/assets/icons/azure/mixed-reality/10352-icon-service-Spatial-Anchor-Accounts.png diff --git a/assets/icons/azure/mobile/03335-icon-service-Power-Platform.png b/cloudexit/assets/icons/azure/mobile/03335-icon-service-Power-Platform.png similarity index 100% rename from assets/icons/azure/mobile/03335-icon-service-Power-Platform.png rename to cloudexit/assets/icons/azure/mobile/03335-icon-service-Power-Platform.png diff --git a/assets/icons/azure/mobile/10035-icon-service-App-Services.png b/cloudexit/assets/icons/azure/mobile/10035-icon-service-App-Services.png similarity index 100% rename from assets/icons/azure/mobile/10035-icon-service-App-Services.png rename to cloudexit/assets/icons/azure/mobile/10035-icon-service-App-Services.png diff --git a/assets/icons/azure/mobile/10045-icon-service-Notification-Hubs.png b/cloudexit/assets/icons/azure/mobile/10045-icon-service-Notification-Hubs.png similarity index 100% rename from assets/icons/azure/mobile/10045-icon-service-Notification-Hubs.png rename to cloudexit/assets/icons/azure/mobile/10045-icon-service-Notification-Hubs.png diff --git a/assets/icons/azure/monitor/00001-icon-service-Monitor.png b/cloudexit/assets/icons/azure/monitor/00001-icon-service-Monitor.png similarity index 100% rename from assets/icons/azure/monitor/00001-icon-service-Monitor.png rename to cloudexit/assets/icons/azure/monitor/00001-icon-service-Monitor.png diff --git a/assets/icons/azure/monitor/00007-icon-service-Activity-Log.png b/cloudexit/assets/icons/azure/monitor/00007-icon-service-Activity-Log.png similarity index 100% rename from assets/icons/azure/monitor/00007-icon-service-Activity-Log.png rename to cloudexit/assets/icons/azure/monitor/00007-icon-service-Activity-Log.png diff --git a/assets/icons/azure/monitor/00008-icon-service-Diagnostics-Settings.png b/cloudexit/assets/icons/azure/monitor/00008-icon-service-Diagnostics-Settings.png similarity index 100% rename from assets/icons/azure/monitor/00008-icon-service-Diagnostics-Settings.png rename to cloudexit/assets/icons/azure/monitor/00008-icon-service-Diagnostics-Settings.png diff --git a/assets/icons/azure/monitor/00009-icon-service-Log-Analytics-Workspaces.png b/cloudexit/assets/icons/azure/monitor/00009-icon-service-Log-Analytics-Workspaces.png similarity index 100% rename from assets/icons/azure/monitor/00009-icon-service-Log-Analytics-Workspaces.png rename to cloudexit/assets/icons/azure/monitor/00009-icon-service-Log-Analytics-Workspaces.png diff --git a/assets/icons/azure/monitor/00012-icon-service-Application-Insights.png b/cloudexit/assets/icons/azure/monitor/00012-icon-service-Application-Insights.png similarity index 100% rename from assets/icons/azure/monitor/00012-icon-service-Application-Insights.png rename to cloudexit/assets/icons/azure/monitor/00012-icon-service-Application-Insights.png diff --git a/assets/icons/azure/monitor/00020-icon-service-Metrics.png b/cloudexit/assets/icons/azure/monitor/00020-icon-service-Metrics.png similarity index 100% rename from assets/icons/azure/monitor/00020-icon-service-Metrics.png rename to cloudexit/assets/icons/azure/monitor/00020-icon-service-Metrics.png diff --git a/assets/icons/azure/monitor/00438-icon-service-Azure-Monitors-for-SAP-Solutions.png b/cloudexit/assets/icons/azure/monitor/00438-icon-service-Azure-Monitors-for-SAP-Solutions.png similarity index 100% rename from assets/icons/azure/monitor/00438-icon-service-Azure-Monitors-for-SAP-Solutions.png rename to cloudexit/assets/icons/azure/monitor/00438-icon-service-Azure-Monitors-for-SAP-Solutions.png diff --git a/assets/icons/azure/monitor/00563-icon-service-Change-Analysis.png b/cloudexit/assets/icons/azure/monitor/00563-icon-service-Change-Analysis.png similarity index 100% rename from assets/icons/azure/monitor/00563-icon-service-Change-Analysis.png rename to cloudexit/assets/icons/azure/monitor/00563-icon-service-Change-Analysis.png diff --git a/assets/icons/azure/monitor/02189-icon-service-Azure-Workbooks.png b/cloudexit/assets/icons/azure/monitor/02189-icon-service-Azure-Workbooks.png similarity index 100% rename from assets/icons/azure/monitor/02189-icon-service-Azure-Workbooks.png rename to cloudexit/assets/icons/azure/monitor/02189-icon-service-Azure-Workbooks.png diff --git a/assets/icons/azure/monitor/10066-icon-service-Network-Watcher.png b/cloudexit/assets/icons/azure/monitor/10066-icon-service-Network-Watcher.png similarity index 100% rename from assets/icons/azure/monitor/10066-icon-service-Network-Watcher.png rename to cloudexit/assets/icons/azure/monitor/10066-icon-service-Network-Watcher.png diff --git a/assets/icons/azure/monitor/10832-icon-service-Auto-Scale.png b/cloudexit/assets/icons/azure/monitor/10832-icon-service-Auto-Scale.png similarity index 100% rename from assets/icons/azure/monitor/10832-icon-service-Auto-Scale.png rename to cloudexit/assets/icons/azure/monitor/10832-icon-service-Auto-Scale.png diff --git a/assets/icons/azure/networking/00056-icon-service-CDN-Profiles.png b/cloudexit/assets/icons/azure/networking/00056-icon-service-CDN-Profiles.png similarity index 100% rename from assets/icons/azure/networking/00056-icon-service-CDN-Profiles.png rename to cloudexit/assets/icons/azure/networking/00056-icon-service-CDN-Profiles.png diff --git a/assets/icons/azure/networking/00271-icon-service-Azure-Firewall-Manager.png b/cloudexit/assets/icons/azure/networking/00271-icon-service-Azure-Firewall-Manager.png similarity index 100% rename from assets/icons/azure/networking/00271-icon-service-Azure-Firewall-Manager.png rename to cloudexit/assets/icons/azure/networking/00271-icon-service-Azure-Firewall-Manager.png diff --git a/assets/icons/azure/networking/00272-icon-service-Azure-Firewall-Policy.png b/cloudexit/assets/icons/azure/networking/00272-icon-service-Azure-Firewall-Policy.png similarity index 100% rename from assets/icons/azure/networking/00272-icon-service-Azure-Firewall-Policy.png rename to cloudexit/assets/icons/azure/networking/00272-icon-service-Azure-Firewall-Policy.png diff --git a/assets/icons/azure/networking/00427-icon-service-Private-Link.png b/cloudexit/assets/icons/azure/networking/00427-icon-service-Private-Link.png similarity index 100% rename from assets/icons/azure/networking/00427-icon-service-Private-Link.png rename to cloudexit/assets/icons/azure/networking/00427-icon-service-Private-Link.png diff --git a/assets/icons/azure/networking/00701-icon-service-IP-Groups.png b/cloudexit/assets/icons/azure/networking/00701-icon-service-IP-Groups.png similarity index 100% rename from assets/icons/azure/networking/00701-icon-service-IP-Groups.png rename to cloudexit/assets/icons/azure/networking/00701-icon-service-IP-Groups.png diff --git a/assets/icons/azure/networking/00860-icon-service-Virtual-WAN-Hub.png b/cloudexit/assets/icons/azure/networking/00860-icon-service-Virtual-WAN-Hub.png similarity index 100% rename from assets/icons/azure/networking/00860-icon-service-Virtual-WAN-Hub.png rename to cloudexit/assets/icons/azure/networking/00860-icon-service-Virtual-WAN-Hub.png diff --git a/assets/icons/azure/networking/01105-icon-service-Private-Link-Service.png b/cloudexit/assets/icons/azure/networking/01105-icon-service-Private-Link-Service.png similarity index 100% rename from assets/icons/azure/networking/01105-icon-service-Private-Link-Service.png rename to cloudexit/assets/icons/azure/networking/01105-icon-service-Private-Link-Service.png diff --git a/assets/icons/azure/networking/02145-icon-service-Resource-Management-Private-Link.png b/cloudexit/assets/icons/azure/networking/02145-icon-service-Resource-Management-Private-Link.png similarity index 100% rename from assets/icons/azure/networking/02145-icon-service-Resource-Management-Private-Link.png rename to cloudexit/assets/icons/azure/networking/02145-icon-service-Resource-Management-Private-Link.png diff --git a/assets/icons/azure/networking/02209-icon-service-Private-Link-Services.png b/cloudexit/assets/icons/azure/networking/02209-icon-service-Private-Link-Services.png similarity index 100% rename from assets/icons/azure/networking/02209-icon-service-Private-Link-Services.png rename to cloudexit/assets/icons/azure/networking/02209-icon-service-Private-Link-Services.png diff --git a/assets/icons/azure/networking/02302-icon-service-Load-Balancer-Hub.png b/cloudexit/assets/icons/azure/networking/02302-icon-service-Load-Balancer-Hub.png similarity index 100% rename from assets/icons/azure/networking/02302-icon-service-Load-Balancer-Hub.png rename to cloudexit/assets/icons/azure/networking/02302-icon-service-Load-Balancer-Hub.png diff --git a/assets/icons/azure/networking/02422-icon-service-Bastions.png b/cloudexit/assets/icons/azure/networking/02422-icon-service-Bastions.png similarity index 100% rename from assets/icons/azure/networking/02422-icon-service-Bastions.png rename to cloudexit/assets/icons/azure/networking/02422-icon-service-Bastions.png diff --git a/assets/icons/azure/networking/02496-icon-service-Virtual-Router.png b/cloudexit/assets/icons/azure/networking/02496-icon-service-Virtual-Router.png similarity index 100% rename from assets/icons/azure/networking/02496-icon-service-Virtual-Router.png rename to cloudexit/assets/icons/azure/networking/02496-icon-service-Virtual-Router.png diff --git a/assets/icons/azure/networking/02509-icon-service-Connected-Cache.png b/cloudexit/assets/icons/azure/networking/02509-icon-service-Connected-Cache.png similarity index 100% rename from assets/icons/azure/networking/02509-icon-service-Connected-Cache.png rename to cloudexit/assets/icons/azure/networking/02509-icon-service-Connected-Cache.png diff --git a/assets/icons/azure/networking/02692-icon-service-Spot-VMSS.png b/cloudexit/assets/icons/azure/networking/02692-icon-service-Spot-VMSS.png similarity index 100% rename from assets/icons/azure/networking/02692-icon-service-Spot-VMSS.png rename to cloudexit/assets/icons/azure/networking/02692-icon-service-Spot-VMSS.png diff --git a/assets/icons/azure/networking/02695-icon-service-Spot-VM.png b/cloudexit/assets/icons/azure/networking/02695-icon-service-Spot-VM.png similarity index 100% rename from assets/icons/azure/networking/02695-icon-service-Spot-VM.png rename to cloudexit/assets/icons/azure/networking/02695-icon-service-Spot-VM.png diff --git a/assets/icons/azure/networking/02742-icon-service-Subnet.png b/cloudexit/assets/icons/azure/networking/02742-icon-service-Subnet.png similarity index 100% rename from assets/icons/azure/networking/02742-icon-service-Subnet.png rename to cloudexit/assets/icons/azure/networking/02742-icon-service-Subnet.png diff --git a/assets/icons/azure/networking/02882-icon-service-DNS-Private-Resolver.png b/cloudexit/assets/icons/azure/networking/02882-icon-service-DNS-Private-Resolver.png similarity index 100% rename from assets/icons/azure/networking/02882-icon-service-DNS-Private-Resolver.png rename to cloudexit/assets/icons/azure/networking/02882-icon-service-DNS-Private-Resolver.png diff --git a/assets/icons/azure/networking/029029174-icon-service-Load-Balancer-Hub.png b/cloudexit/assets/icons/azure/networking/029029174-icon-service-Load-Balancer-Hub.png similarity index 100% rename from assets/icons/azure/networking/029029174-icon-service-Load-Balancer-Hub.png rename to cloudexit/assets/icons/azure/networking/029029174-icon-service-Load-Balancer-Hub.png diff --git a/assets/icons/azure/networking/029273094-icon-service-Edge-Actions.png b/cloudexit/assets/icons/azure/networking/029273094-icon-service-Edge-Actions.png similarity index 100% rename from assets/icons/azure/networking/029273094-icon-service-Edge-Actions.png rename to cloudexit/assets/icons/azure/networking/029273094-icon-service-Edge-Actions.png diff --git a/assets/icons/azure/networking/03311-icon-service-Azure-Communications-Gateway.png b/cloudexit/assets/icons/azure/networking/03311-icon-service-Azure-Communications-Gateway.png similarity index 100% rename from assets/icons/azure/networking/03311-icon-service-Azure-Communications-Gateway.png rename to cloudexit/assets/icons/azure/networking/03311-icon-service-Azure-Communications-Gateway.png diff --git a/assets/icons/azure/networking/03328-icon-service-Application-Gateway-Containers.png b/cloudexit/assets/icons/azure/networking/03328-icon-service-Application-Gateway-Containers.png similarity index 100% rename from assets/icons/azure/networking/03328-icon-service-Application-Gateway-Containers.png rename to cloudexit/assets/icons/azure/networking/03328-icon-service-Application-Gateway-Containers.png diff --git a/assets/icons/azure/networking/03368-icon-service-DNS-Security-Policy.png b/cloudexit/assets/icons/azure/networking/03368-icon-service-DNS-Security-Policy.png similarity index 100% rename from assets/icons/azure/networking/03368-icon-service-DNS-Security-Policy.png rename to cloudexit/assets/icons/azure/networking/03368-icon-service-DNS-Security-Policy.png diff --git a/assets/icons/azure/networking/034412066-icon-service-VNet-Appliance.png b/cloudexit/assets/icons/azure/networking/034412066-icon-service-VNet-Appliance.png similarity index 100% rename from assets/icons/azure/networking/034412066-icon-service-VNet-Appliance.png rename to cloudexit/assets/icons/azure/networking/034412066-icon-service-VNet-Appliance.png diff --git a/assets/icons/azure/networking/03459-icon-service-DNS-Multistack.png b/cloudexit/assets/icons/azure/networking/03459-icon-service-DNS-Multistack.png similarity index 100% rename from assets/icons/azure/networking/03459-icon-service-DNS-Multistack.png rename to cloudexit/assets/icons/azure/networking/03459-icon-service-DNS-Multistack.png diff --git a/assets/icons/azure/networking/03460-icon-service-ATM-Multistack.png b/cloudexit/assets/icons/azure/networking/03460-icon-service-ATM-Multistack.png similarity index 100% rename from assets/icons/azure/networking/03460-icon-service-ATM-Multistack.png rename to cloudexit/assets/icons/azure/networking/03460-icon-service-ATM-Multistack.png diff --git a/assets/icons/azure/networking/03461-icon-service-IP-Address-manager.png b/cloudexit/assets/icons/azure/networking/03461-icon-service-IP-Address-manager.png similarity index 100% rename from assets/icons/azure/networking/03461-icon-service-IP-Address-manager.png rename to cloudexit/assets/icons/azure/networking/03461-icon-service-IP-Address-manager.png diff --git a/assets/icons/azure/networking/03643-icon-service-Network-Foundation-Hub.png b/cloudexit/assets/icons/azure/networking/03643-icon-service-Network-Foundation-Hub.png similarity index 100% rename from assets/icons/azure/networking/03643-icon-service-Network-Foundation-Hub.png rename to cloudexit/assets/icons/azure/networking/03643-icon-service-Network-Foundation-Hub.png diff --git a/assets/icons/azure/networking/03650-icon-service-Network-Security-Hub.png b/cloudexit/assets/icons/azure/networking/03650-icon-service-Network-Security-Hub.png similarity index 100% rename from assets/icons/azure/networking/03650-icon-service-Network-Security-Hub.png rename to cloudexit/assets/icons/azure/networking/03650-icon-service-Network-Security-Hub.png diff --git a/assets/icons/azure/networking/03694-icon-service-VPNClientWindows.png b/cloudexit/assets/icons/azure/networking/03694-icon-service-VPNClientWindows.png similarity index 100% rename from assets/icons/azure/networking/03694-icon-service-VPNClientWindows.png rename to cloudexit/assets/icons/azure/networking/03694-icon-service-VPNClientWindows.png diff --git a/assets/icons/azure/networking/10061-icon-service-Virtual-Networks.png b/cloudexit/assets/icons/azure/networking/10061-icon-service-Virtual-Networks.png similarity index 100% rename from assets/icons/azure/networking/10061-icon-service-Virtual-Networks.png rename to cloudexit/assets/icons/azure/networking/10061-icon-service-Virtual-Networks.png diff --git a/assets/icons/azure/networking/10062-icon-service-Load-Balancers.png b/cloudexit/assets/icons/azure/networking/10062-icon-service-Load-Balancers.png similarity index 100% rename from assets/icons/azure/networking/10062-icon-service-Load-Balancers.png rename to cloudexit/assets/icons/azure/networking/10062-icon-service-Load-Balancers.png diff --git a/assets/icons/azure/networking/10063-icon-service-Virtual-Network-Gateways.png b/cloudexit/assets/icons/azure/networking/10063-icon-service-Virtual-Network-Gateways.png similarity index 100% rename from assets/icons/azure/networking/10063-icon-service-Virtual-Network-Gateways.png rename to cloudexit/assets/icons/azure/networking/10063-icon-service-Virtual-Network-Gateways.png diff --git a/assets/icons/azure/networking/10064-icon-service-DNS-Zones.png b/cloudexit/assets/icons/azure/networking/10064-icon-service-DNS-Zones.png similarity index 100% rename from assets/icons/azure/networking/10064-icon-service-DNS-Zones.png rename to cloudexit/assets/icons/azure/networking/10064-icon-service-DNS-Zones.png diff --git a/assets/icons/azure/networking/10065-icon-service-Traffic-Manager-Profiles.png b/cloudexit/assets/icons/azure/networking/10065-icon-service-Traffic-Manager-Profiles.png similarity index 100% rename from assets/icons/azure/networking/10065-icon-service-Traffic-Manager-Profiles.png rename to cloudexit/assets/icons/azure/networking/10065-icon-service-Traffic-Manager-Profiles.png diff --git a/assets/icons/azure/networking/10066-icon-service-Network-Watcher.png b/cloudexit/assets/icons/azure/networking/10066-icon-service-Network-Watcher.png similarity index 100% rename from assets/icons/azure/networking/10066-icon-service-Network-Watcher.png rename to cloudexit/assets/icons/azure/networking/10066-icon-service-Network-Watcher.png diff --git a/assets/icons/azure/networking/10067-icon-service-Network-Security-Groups.png b/cloudexit/assets/icons/azure/networking/10067-icon-service-Network-Security-Groups.png similarity index 100% rename from assets/icons/azure/networking/10067-icon-service-Network-Security-Groups.png rename to cloudexit/assets/icons/azure/networking/10067-icon-service-Network-Security-Groups.png diff --git a/assets/icons/azure/networking/10068-icon-service-Public-IP-Addresses-(Classic).png b/cloudexit/assets/icons/azure/networking/10068-icon-service-Public-IP-Addresses-(Classic).png similarity index 100% rename from assets/icons/azure/networking/10068-icon-service-Public-IP-Addresses-(Classic).png rename to cloudexit/assets/icons/azure/networking/10068-icon-service-Public-IP-Addresses-(Classic).png diff --git a/assets/icons/azure/networking/10069-icon-service-Public-IP-Addresses.png b/cloudexit/assets/icons/azure/networking/10069-icon-service-Public-IP-Addresses.png similarity index 100% rename from assets/icons/azure/networking/10069-icon-service-Public-IP-Addresses.png rename to cloudexit/assets/icons/azure/networking/10069-icon-service-Public-IP-Addresses.png diff --git a/assets/icons/azure/networking/10070-icon-service-On-Premises-Data-Gateways.png b/cloudexit/assets/icons/azure/networking/10070-icon-service-On-Premises-Data-Gateways.png similarity index 100% rename from assets/icons/azure/networking/10070-icon-service-On-Premises-Data-Gateways.png rename to cloudexit/assets/icons/azure/networking/10070-icon-service-On-Premises-Data-Gateways.png diff --git a/assets/icons/azure/networking/10071-icon-service-Route-Filters.png b/cloudexit/assets/icons/azure/networking/10071-icon-service-Route-Filters.png similarity index 100% rename from assets/icons/azure/networking/10071-icon-service-Route-Filters.png rename to cloudexit/assets/icons/azure/networking/10071-icon-service-Route-Filters.png diff --git a/assets/icons/azure/networking/10072-icon-service-DDoS-Protection-Plans.png b/cloudexit/assets/icons/azure/networking/10072-icon-service-DDoS-Protection-Plans.png similarity index 100% rename from assets/icons/azure/networking/10072-icon-service-DDoS-Protection-Plans.png rename to cloudexit/assets/icons/azure/networking/10072-icon-service-DDoS-Protection-Plans.png diff --git a/assets/icons/azure/networking/10073-icon-service-Front-Door-and-CDN-Profiles.png b/cloudexit/assets/icons/azure/networking/10073-icon-service-Front-Door-and-CDN-Profiles.png similarity index 100% rename from assets/icons/azure/networking/10073-icon-service-Front-Door-and-CDN-Profiles.png rename to cloudexit/assets/icons/azure/networking/10073-icon-service-Front-Door-and-CDN-Profiles.png diff --git a/assets/icons/azure/networking/10075-icon-service-Virtual-Networks-(Classic).png b/cloudexit/assets/icons/azure/networking/10075-icon-service-Virtual-Networks-(Classic).png similarity index 100% rename from assets/icons/azure/networking/10075-icon-service-Virtual-Networks-(Classic).png rename to cloudexit/assets/icons/azure/networking/10075-icon-service-Virtual-Networks-(Classic).png diff --git a/assets/icons/azure/networking/10076-icon-service-Application-Gateways.png b/cloudexit/assets/icons/azure/networking/10076-icon-service-Application-Gateways.png similarity index 100% rename from assets/icons/azure/networking/10076-icon-service-Application-Gateways.png rename to cloudexit/assets/icons/azure/networking/10076-icon-service-Application-Gateways.png diff --git a/assets/icons/azure/networking/10077-icon-service-Local-Network-Gateways.png b/cloudexit/assets/icons/azure/networking/10077-icon-service-Local-Network-Gateways.png similarity index 100% rename from assets/icons/azure/networking/10077-icon-service-Local-Network-Gateways.png rename to cloudexit/assets/icons/azure/networking/10077-icon-service-Local-Network-Gateways.png diff --git a/assets/icons/azure/networking/10079-icon-service-ExpressRoute-Circuits.png b/cloudexit/assets/icons/azure/networking/10079-icon-service-ExpressRoute-Circuits.png similarity index 100% rename from assets/icons/azure/networking/10079-icon-service-ExpressRoute-Circuits.png rename to cloudexit/assets/icons/azure/networking/10079-icon-service-ExpressRoute-Circuits.png diff --git a/assets/icons/azure/networking/10080-icon-service-Network-Interfaces.png b/cloudexit/assets/icons/azure/networking/10080-icon-service-Network-Interfaces.png similarity index 100% rename from assets/icons/azure/networking/10080-icon-service-Network-Interfaces.png rename to cloudexit/assets/icons/azure/networking/10080-icon-service-Network-Interfaces.png diff --git a/assets/icons/azure/networking/10081-icon-service-Connections.png b/cloudexit/assets/icons/azure/networking/10081-icon-service-Connections.png similarity index 100% rename from assets/icons/azure/networking/10081-icon-service-Connections.png rename to cloudexit/assets/icons/azure/networking/10081-icon-service-Connections.png diff --git a/assets/icons/azure/networking/10082-icon-service-Route-Tables.png b/cloudexit/assets/icons/azure/networking/10082-icon-service-Route-Tables.png similarity index 100% rename from assets/icons/azure/networking/10082-icon-service-Route-Tables.png rename to cloudexit/assets/icons/azure/networking/10082-icon-service-Route-Tables.png diff --git a/assets/icons/azure/networking/10084-icon-service-Firewalls.png b/cloudexit/assets/icons/azure/networking/10084-icon-service-Firewalls.png similarity index 100% rename from assets/icons/azure/networking/10084-icon-service-Firewalls.png rename to cloudexit/assets/icons/azure/networking/10084-icon-service-Firewalls.png diff --git a/assets/icons/azure/networking/10085-icon-service-Service-Endpoint-Policies.png b/cloudexit/assets/icons/azure/networking/10085-icon-service-Service-Endpoint-Policies.png similarity index 100% rename from assets/icons/azure/networking/10085-icon-service-Service-Endpoint-Policies.png rename to cloudexit/assets/icons/azure/networking/10085-icon-service-Service-Endpoint-Policies.png diff --git a/assets/icons/azure/networking/10310-icon-service-NAT.png b/cloudexit/assets/icons/azure/networking/10310-icon-service-NAT.png similarity index 100% rename from assets/icons/azure/networking/10310-icon-service-NAT.png rename to cloudexit/assets/icons/azure/networking/10310-icon-service-NAT.png diff --git a/assets/icons/azure/networking/10353-icon-service-Virtual-WANs.png b/cloudexit/assets/icons/azure/networking/10353-icon-service-Virtual-WANs.png similarity index 100% rename from assets/icons/azure/networking/10353-icon-service-Virtual-WANs.png rename to cloudexit/assets/icons/azure/networking/10353-icon-service-Virtual-WANs.png diff --git a/assets/icons/azure/networking/10362-icon-service-Web-Application-Firewall-Policies(WAF).png b/cloudexit/assets/icons/azure/networking/10362-icon-service-Web-Application-Firewall-Policies(WAF).png similarity index 100% rename from assets/icons/azure/networking/10362-icon-service-Web-Application-Firewall-Policies(WAF).png rename to cloudexit/assets/icons/azure/networking/10362-icon-service-Web-Application-Firewall-Policies(WAF).png diff --git a/assets/icons/azure/networking/10365-icon-service-Proximity-Placement-Groups.png b/cloudexit/assets/icons/azure/networking/10365-icon-service-Proximity-Placement-Groups.png similarity index 100% rename from assets/icons/azure/networking/10365-icon-service-Proximity-Placement-Groups.png rename to cloudexit/assets/icons/azure/networking/10365-icon-service-Proximity-Placement-Groups.png diff --git a/assets/icons/azure/networking/10371-icon-service-Reserved-IP-Addresses-(Classic).png b/cloudexit/assets/icons/azure/networking/10371-icon-service-Reserved-IP-Addresses-(Classic).png similarity index 100% rename from assets/icons/azure/networking/10371-icon-service-Reserved-IP-Addresses-(Classic).png rename to cloudexit/assets/icons/azure/networking/10371-icon-service-Reserved-IP-Addresses-(Classic).png diff --git a/assets/icons/azure/networking/10372-icon-service-Public-IP-Prefixes.png b/cloudexit/assets/icons/azure/networking/10372-icon-service-Public-IP-Prefixes.png similarity index 100% rename from assets/icons/azure/networking/10372-icon-service-Public-IP-Prefixes.png rename to cloudexit/assets/icons/azure/networking/10372-icon-service-Public-IP-Prefixes.png diff --git a/assets/icons/azure/new icons/02573-icon-service-Connected-Vehicle-Platform.png b/cloudexit/assets/icons/azure/new icons/02573-icon-service-Connected-Vehicle-Platform.png similarity index 100% rename from assets/icons/azure/new icons/02573-icon-service-Connected-Vehicle-Platform.png rename to cloudexit/assets/icons/azure/new icons/02573-icon-service-Connected-Vehicle-Platform.png diff --git a/assets/icons/azure/new icons/029109619-icon-service-Toolchain-Orchestrator.png b/cloudexit/assets/icons/azure/new icons/029109619-icon-service-Toolchain-Orchestrator.png similarity index 100% rename from assets/icons/azure/new icons/029109619-icon-service-Toolchain-Orchestrator.png rename to cloudexit/assets/icons/azure/new icons/029109619-icon-service-Toolchain-Orchestrator.png diff --git a/assets/icons/azure/new icons/029636048-icon-service-Landing-Zone.png b/cloudexit/assets/icons/azure/new icons/029636048-icon-service-Landing-Zone.png similarity index 100% rename from assets/icons/azure/new icons/029636048-icon-service-Landing-Zone.png rename to cloudexit/assets/icons/azure/new icons/029636048-icon-service-Landing-Zone.png diff --git a/assets/icons/azure/new icons/030777508 -icon-service-Service-Group-Relationships.png b/cloudexit/assets/icons/azure/new icons/030777508 -icon-service-Service-Group-Relationships.png similarity index 100% rename from assets/icons/azure/new icons/030777508 -icon-service-Service-Group-Relationships.png rename to cloudexit/assets/icons/azure/new icons/030777508 -icon-service-Service-Group-Relationships.png diff --git a/assets/icons/azure/new icons/031193085-icon-service-Azure-Access-Point.png b/cloudexit/assets/icons/azure/new icons/031193085-icon-service-Azure-Access-Point.png similarity index 100% rename from assets/icons/azure/new icons/031193085-icon-service-Azure-Access-Point.png rename to cloudexit/assets/icons/azure/new icons/031193085-icon-service-Azure-Access-Point.png diff --git a/assets/icons/azure/new icons/033740592-icon-service-Azure-Data-Transfer.png b/cloudexit/assets/icons/azure/new icons/033740592-icon-service-Azure-Data-Transfer.png similarity index 100% rename from assets/icons/azure/new icons/033740592-icon-service-Azure-Data-Transfer.png rename to cloudexit/assets/icons/azure/new icons/033740592-icon-service-Azure-Data-Transfer.png diff --git a/assets/icons/azure/new icons/03400-icon-service-Entra-Identity.png b/cloudexit/assets/icons/azure/new icons/03400-icon-service-Entra-Identity.png similarity index 100% rename from assets/icons/azure/new icons/03400-icon-service-Entra-Identity.png rename to cloudexit/assets/icons/azure/new icons/03400-icon-service-Entra-Identity.png diff --git a/assets/icons/azure/new icons/034296882-icon-service-Agentic-Web-Apps.png b/cloudexit/assets/icons/azure/new icons/034296882-icon-service-Agentic-Web-Apps.png similarity index 100% rename from assets/icons/azure/new icons/034296882-icon-service-Agentic-Web-Apps.png rename to cloudexit/assets/icons/azure/new icons/034296882-icon-service-Agentic-Web-Apps.png diff --git a/assets/icons/azure/new icons/034923704-icon-service-Resiliency.png b/cloudexit/assets/icons/azure/new icons/034923704-icon-service-Resiliency.png similarity index 100% rename from assets/icons/azure/new icons/034923704-icon-service-Resiliency.png rename to cloudexit/assets/icons/azure/new icons/034923704-icon-service-Resiliency.png diff --git a/assets/icons/azure/new icons/035556610-icon-service-DDoS-Custom-Policy.png b/cloudexit/assets/icons/azure/new icons/035556610-icon-service-DDoS-Custom-Policy.png similarity index 100% rename from assets/icons/azure/new icons/035556610-icon-service-DDoS-Custom-Policy.png rename to cloudexit/assets/icons/azure/new icons/035556610-icon-service-DDoS-Custom-Policy.png diff --git a/assets/icons/azure/new icons/035712231-icon-service-Disconnected-Operations.png b/cloudexit/assets/icons/azure/new icons/035712231-icon-service-Disconnected-Operations.png similarity index 100% rename from assets/icons/azure/new icons/035712231-icon-service-Disconnected-Operations.png rename to cloudexit/assets/icons/azure/new icons/035712231-icon-service-Disconnected-Operations.png diff --git a/assets/icons/azure/new icons/035824068-icon-service-Azure-Data-Sharing.png b/cloudexit/assets/icons/azure/new icons/035824068-icon-service-Azure-Data-Sharing.png similarity index 100% rename from assets/icons/azure/new icons/035824068-icon-service-Azure-Data-Sharing.png rename to cloudexit/assets/icons/azure/new icons/035824068-icon-service-Azure-Data-Sharing.png diff --git a/assets/icons/azure/new icons/037838045-icon-service-AI-Gateway.png b/cloudexit/assets/icons/azure/new icons/037838045-icon-service-AI-Gateway.png similarity index 100% rename from assets/icons/azure/new icons/037838045-icon-service-AI-Gateway.png rename to cloudexit/assets/icons/azure/new icons/037838045-icon-service-AI-Gateway.png diff --git a/assets/icons/azure/other/00327-icon-service-Azure-Virtual-Desktop.png b/cloudexit/assets/icons/azure/other/00327-icon-service-Azure-Virtual-Desktop.png similarity index 100% rename from assets/icons/azure/other/00327-icon-service-Azure-Virtual-Desktop.png rename to cloudexit/assets/icons/azure/other/00327-icon-service-Azure-Virtual-Desktop.png diff --git a/assets/icons/azure/other/00412-icon-service-SSH-Keys.png b/cloudexit/assets/icons/azure/other/00412-icon-service-SSH-Keys.png similarity index 100% rename from assets/icons/azure/other/00412-icon-service-SSH-Keys.png rename to cloudexit/assets/icons/azure/other/00412-icon-service-SSH-Keys.png diff --git a/assets/icons/azure/other/00469-icon-service-Internet-Analyzer-Profiles.png b/cloudexit/assets/icons/azure/other/00469-icon-service-Internet-Analyzer-Profiles.png similarity index 100% rename from assets/icons/azure/other/00469-icon-service-Internet-Analyzer-Profiles.png rename to cloudexit/assets/icons/azure/other/00469-icon-service-Internet-Analyzer-Profiles.png diff --git a/assets/icons/azure/other/00559-icon-service-Azure-Cloud-Shell.png b/cloudexit/assets/icons/azure/other/00559-icon-service-Azure-Cloud-Shell.png similarity index 100% rename from assets/icons/azure/other/00559-icon-service-Azure-Cloud-Shell.png rename to cloudexit/assets/icons/azure/other/00559-icon-service-Azure-Cloud-Shell.png diff --git a/assets/icons/azure/other/00712-icon-service-Video-Analyzers.png b/cloudexit/assets/icons/azure/other/00712-icon-service-Video-Analyzers.png similarity index 100% rename from assets/icons/azure/other/00712-icon-service-Video-Analyzers.png rename to cloudexit/assets/icons/azure/other/00712-icon-service-Video-Analyzers.png diff --git a/assets/icons/azure/other/00903-icon-service-ExpressRoute-Direct.png b/cloudexit/assets/icons/azure/other/00903-icon-service-ExpressRoute-Direct.png similarity index 100% rename from assets/icons/azure/other/00903-icon-service-ExpressRoute-Direct.png rename to cloudexit/assets/icons/azure/other/00903-icon-service-ExpressRoute-Direct.png diff --git a/assets/icons/azure/other/00913-icon-service-Cost-Export.png b/cloudexit/assets/icons/azure/other/00913-icon-service-Cost-Export.png similarity index 100% rename from assets/icons/azure/other/00913-icon-service-Cost-Export.png rename to cloudexit/assets/icons/azure/other/00913-icon-service-Cost-Export.png diff --git a/assets/icons/azure/other/00968-icon-service-Azure-Communication-Services.png b/cloudexit/assets/icons/azure/other/00968-icon-service-Azure-Communication-Services.png similarity index 100% rename from assets/icons/azure/other/00968-icon-service-Azure-Communication-Services.png rename to cloudexit/assets/icons/azure/other/00968-icon-service-Azure-Communication-Services.png diff --git a/assets/icons/azure/other/00970-icon-service-Peering-Service.png b/cloudexit/assets/icons/azure/other/00970-icon-service-Peering-Service.png similarity index 100% rename from assets/icons/azure/other/00970-icon-service-Peering-Service.png rename to cloudexit/assets/icons/azure/other/00970-icon-service-Peering-Service.png diff --git a/assets/icons/azure/other/01001-icon-service-Azure-Network-Function-Manager-Functions.png b/cloudexit/assets/icons/azure/other/01001-icon-service-Azure-Network-Function-Manager-Functions.png similarity index 100% rename from assets/icons/azure/other/01001-icon-service-Azure-Network-Function-Manager-Functions.png rename to cloudexit/assets/icons/azure/other/01001-icon-service-Azure-Network-Function-Manager-Functions.png diff --git a/assets/icons/azure/other/01085-icon-service-Log-Analytics-Query-Pack.png b/cloudexit/assets/icons/azure/other/01085-icon-service-Log-Analytics-Query-Pack.png similarity index 100% rename from assets/icons/azure/other/01085-icon-service-Log-Analytics-Query-Pack.png rename to cloudexit/assets/icons/azure/other/01085-icon-service-Log-Analytics-Query-Pack.png diff --git a/assets/icons/azure/other/01088-icon-service-Arc-Kubernetes.png b/cloudexit/assets/icons/azure/other/01088-icon-service-Arc-Kubernetes.png similarity index 100% rename from assets/icons/azure/other/01088-icon-service-Arc-Kubernetes.png rename to cloudexit/assets/icons/azure/other/01088-icon-service-Arc-Kubernetes.png diff --git a/assets/icons/azure/other/01219-icon-service-Azure-VMware-Solution.png b/cloudexit/assets/icons/azure/other/01219-icon-service-Azure-VMware-Solution.png similarity index 100% rename from assets/icons/azure/other/01219-icon-service-Azure-VMware-Solution.png rename to cloudexit/assets/icons/azure/other/01219-icon-service-Azure-VMware-Solution.png diff --git a/assets/icons/azure/other/01285-icon-service-Peerings.png b/cloudexit/assets/icons/azure/other/01285-icon-service-Peerings.png similarity index 100% rename from assets/icons/azure/other/01285-icon-service-Peerings.png rename to cloudexit/assets/icons/azure/other/01285-icon-service-Peerings.png diff --git a/assets/icons/azure/other/01757-icon-service-Dashboard-Hub.png b/cloudexit/assets/icons/azure/other/01757-icon-service-Dashboard-Hub.png similarity index 100% rename from assets/icons/azure/other/01757-icon-service-Dashboard-Hub.png rename to cloudexit/assets/icons/azure/other/01757-icon-service-Dashboard-Hub.png diff --git a/assets/icons/azure/other/01800-icon-service-Azure-Video-Indexer.png b/cloudexit/assets/icons/azure/other/01800-icon-service-Azure-Video-Indexer.png similarity index 100% rename from assets/icons/azure/other/01800-icon-service-Azure-Video-Indexer.png rename to cloudexit/assets/icons/azure/other/01800-icon-service-Azure-Video-Indexer.png diff --git a/assets/icons/azure/other/01846-icon-service-AVS-VM.png b/cloudexit/assets/icons/azure/other/01846-icon-service-AVS-VM.png similarity index 100% rename from assets/icons/azure/other/01846-icon-service-AVS-VM.png rename to cloudexit/assets/icons/azure/other/01846-icon-service-AVS-VM.png diff --git a/assets/icons/azure/other/01848-icon-service-Arc-PostgreSQL .png b/cloudexit/assets/icons/azure/other/01848-icon-service-Arc-PostgreSQL .png similarity index 100% rename from assets/icons/azure/other/01848-icon-service-Arc-PostgreSQL .png rename to cloudexit/assets/icons/azure/other/01848-icon-service-Arc-PostgreSQL .png diff --git a/assets/icons/azure/other/01849-icon-service-Arc-SQL-Managed-Instance.png b/cloudexit/assets/icons/azure/other/01849-icon-service-Arc-SQL-Managed-Instance.png similarity index 100% rename from assets/icons/azure/other/01849-icon-service-Arc-SQL-Managed-Instance.png rename to cloudexit/assets/icons/azure/other/01849-icon-service-Arc-SQL-Managed-Instance.png diff --git a/assets/icons/azure/other/01850-icon-service-Arc-SQL-Server.png b/cloudexit/assets/icons/azure/other/01850-icon-service-Arc-SQL-Server.png similarity index 100% rename from assets/icons/azure/other/01850-icon-service-Arc-SQL-Server.png rename to cloudexit/assets/icons/azure/other/01850-icon-service-Arc-SQL-Server.png diff --git a/assets/icons/azure/other/01857-icon-service-Data-Collection-Rules.png b/cloudexit/assets/icons/azure/other/01857-icon-service-Data-Collection-Rules.png similarity index 100% rename from assets/icons/azure/other/01857-icon-service-Data-Collection-Rules.png rename to cloudexit/assets/icons/azure/other/01857-icon-service-Data-Collection-Rules.png diff --git a/assets/icons/azure/other/02200-icon-service-Resource-Mover.png b/cloudexit/assets/icons/azure/other/02200-icon-service-Resource-Mover.png similarity index 100% rename from assets/icons/azure/other/02200-icon-service-Resource-Mover.png rename to cloudexit/assets/icons/azure/other/02200-icon-service-Resource-Mover.png diff --git a/assets/icons/azure/other/02223-icon-service-Azure-Chaos-Studio.png b/cloudexit/assets/icons/azure/other/02223-icon-service-Azure-Chaos-Studio.png similarity index 100% rename from assets/icons/azure/other/02223-icon-service-Azure-Chaos-Studio.png rename to cloudexit/assets/icons/azure/other/02223-icon-service-Azure-Chaos-Studio.png diff --git a/assets/icons/azure/other/02237-icon-service-Network-Managers.png b/cloudexit/assets/icons/azure/other/02237-icon-service-Network-Managers.png similarity index 100% rename from assets/icons/azure/other/02237-icon-service-Network-Managers.png rename to cloudexit/assets/icons/azure/other/02237-icon-service-Network-Managers.png diff --git a/assets/icons/azure/other/02322-icon-service-Dedicated-HSM.png b/cloudexit/assets/icons/azure/other/02322-icon-service-Dedicated-HSM.png similarity index 100% rename from assets/icons/azure/other/02322-icon-service-Dedicated-HSM.png rename to cloudexit/assets/icons/azure/other/02322-icon-service-Dedicated-HSM.png diff --git a/assets/icons/azure/other/02323-icon-service-Modular-Data-Center.png b/cloudexit/assets/icons/azure/other/02323-icon-service-Modular-Data-Center.png similarity index 100% rename from assets/icons/azure/other/02323-icon-service-Modular-Data-Center.png rename to cloudexit/assets/icons/azure/other/02323-icon-service-Modular-Data-Center.png diff --git a/assets/icons/azure/other/02340-icon-service-Template-Specs.png b/cloudexit/assets/icons/azure/other/02340-icon-service-Template-Specs.png similarity index 100% rename from assets/icons/azure/other/02340-icon-service-Template-Specs.png rename to cloudexit/assets/icons/azure/other/02340-icon-service-Template-Specs.png diff --git a/assets/icons/azure/other/02356-icon-service-Arc-Data-services.png b/cloudexit/assets/icons/azure/other/02356-icon-service-Arc-Data-services.png similarity index 100% rename from assets/icons/azure/other/02356-icon-service-Arc-Data-services.png rename to cloudexit/assets/icons/azure/other/02356-icon-service-Arc-Data-services.png diff --git a/assets/icons/azure/other/02360-icon-service-Azure-Backup-Center.png b/cloudexit/assets/icons/azure/other/02360-icon-service-Azure-Backup-Center.png similarity index 100% rename from assets/icons/azure/other/02360-icon-service-Azure-Backup-Center.png rename to cloudexit/assets/icons/azure/other/02360-icon-service-Azure-Backup-Center.png diff --git a/assets/icons/azure/other/02361-icon-service-Backup-Vault.png b/cloudexit/assets/icons/azure/other/02361-icon-service-Backup-Vault.png similarity index 100% rename from assets/icons/azure/other/02361-icon-service-Backup-Vault.png rename to cloudexit/assets/icons/azure/other/02361-icon-service-Backup-Vault.png diff --git a/assets/icons/azure/other/02475-icon-service-Device-Update-IoT-Hub.png b/cloudexit/assets/icons/azure/other/02475-icon-service-Device-Update-IoT-Hub.png similarity index 100% rename from assets/icons/azure/other/02475-icon-service-Device-Update-IoT-Hub.png rename to cloudexit/assets/icons/azure/other/02475-icon-service-Device-Update-IoT-Hub.png diff --git a/assets/icons/azure/other/02486-icon-service-Fiji.png b/cloudexit/assets/icons/azure/other/02486-icon-service-Fiji.png similarity index 100% rename from assets/icons/azure/other/02486-icon-service-Fiji.png rename to cloudexit/assets/icons/azure/other/02486-icon-service-Fiji.png diff --git a/assets/icons/azure/other/02488-icon-service-Azure-Monitor-Dashboard.png b/cloudexit/assets/icons/azure/other/02488-icon-service-Azure-Monitor-Dashboard.png similarity index 100% rename from assets/icons/azure/other/02488-icon-service-Azure-Monitor-Dashboard.png rename to cloudexit/assets/icons/azure/other/02488-icon-service-Azure-Monitor-Dashboard.png diff --git a/assets/icons/azure/other/02503-icon-service-SCVMM-Management-Servers.png b/cloudexit/assets/icons/azure/other/02503-icon-service-SCVMM-Management-Servers.png similarity index 100% rename from assets/icons/azure/other/02503-icon-service-SCVMM-Management-Servers.png rename to cloudexit/assets/icons/azure/other/02503-icon-service-SCVMM-Management-Servers.png diff --git a/assets/icons/azure/other/02505-icon-service-Cloud-Services-(extended-support).png b/cloudexit/assets/icons/azure/other/02505-icon-service-Cloud-Services-(extended-support).png similarity index 100% rename from assets/icons/azure/other/02505-icon-service-Cloud-Services-(extended-support).png rename to cloudexit/assets/icons/azure/other/02505-icon-service-Cloud-Services-(extended-support).png diff --git a/assets/icons/azure/other/02507-icon-service-Azure-Support-Center-Blue.png b/cloudexit/assets/icons/azure/other/02507-icon-service-Azure-Support-Center-Blue.png similarity index 100% rename from assets/icons/azure/other/02507-icon-service-Azure-Support-Center-Blue.png rename to cloudexit/assets/icons/azure/other/02507-icon-service-Azure-Support-Center-Blue.png diff --git a/assets/icons/azure/other/02515-icon-service-Web-App-+-Database.png b/cloudexit/assets/icons/azure/other/02515-icon-service-Web-App-+-Database.png similarity index 100% rename from assets/icons/azure/other/02515-icon-service-Web-App-+-Database.png rename to cloudexit/assets/icons/azure/other/02515-icon-service-Web-App-+-Database.png diff --git a/assets/icons/azure/other/02518-icon-service-Azure-HPC-Workbenches.png b/cloudexit/assets/icons/azure/other/02518-icon-service-Azure-HPC-Workbenches.png similarity index 100% rename from assets/icons/azure/other/02518-icon-service-Azure-HPC-Workbenches.png rename to cloudexit/assets/icons/azure/other/02518-icon-service-Azure-HPC-Workbenches.png diff --git a/assets/icons/azure/other/02560-icon-service-Disk-Pool.png b/cloudexit/assets/icons/azure/other/02560-icon-service-Disk-Pool.png similarity index 100% rename from assets/icons/azure/other/02560-icon-service-Disk-Pool.png rename to cloudexit/assets/icons/azure/other/02560-icon-service-Disk-Pool.png diff --git a/assets/icons/azure/other/02561-icon-service-Bare-Metal-Infrastructure.png b/cloudexit/assets/icons/azure/other/02561-icon-service-Bare-Metal-Infrastructure.png similarity index 100% rename from assets/icons/azure/other/02561-icon-service-Bare-Metal-Infrastructure.png rename to cloudexit/assets/icons/azure/other/02561-icon-service-Bare-Metal-Infrastructure.png diff --git a/assets/icons/azure/other/02579-icon-service-Private-Endpoints.png b/cloudexit/assets/icons/azure/other/02579-icon-service-Private-Endpoints.png similarity index 100% rename from assets/icons/azure/other/02579-icon-service-Private-Endpoints.png rename to cloudexit/assets/icons/azure/other/02579-icon-service-Private-Endpoints.png diff --git a/assets/icons/azure/other/02602-icon-service-Open-Supply-Chain-Platform.png b/cloudexit/assets/icons/azure/other/02602-icon-service-Open-Supply-Chain-Platform.png similarity index 100% rename from assets/icons/azure/other/02602-icon-service-Open-Supply-Chain-Platform.png rename to cloudexit/assets/icons/azure/other/02602-icon-service-Open-Supply-Chain-Platform.png diff --git a/assets/icons/azure/other/02625-icon-service-Aquila.png b/cloudexit/assets/icons/azure/other/02625-icon-service-Aquila.png similarity index 100% rename from assets/icons/azure/other/02625-icon-service-Aquila.png rename to cloudexit/assets/icons/azure/other/02625-icon-service-Aquila.png diff --git a/assets/icons/azure/other/02638-icon-service-Reserved-Capacity.png b/cloudexit/assets/icons/azure/other/02638-icon-service-Reserved-Capacity.png similarity index 100% rename from assets/icons/azure/other/02638-icon-service-Reserved-Capacity.png rename to cloudexit/assets/icons/azure/other/02638-icon-service-Reserved-Capacity.png diff --git a/assets/icons/azure/other/02654-icon-service-Custom-IP-Prefix.png b/cloudexit/assets/icons/azure/other/02654-icon-service-Custom-IP-Prefix.png similarity index 100% rename from assets/icons/azure/other/02654-icon-service-Custom-IP-Prefix.png rename to cloudexit/assets/icons/azure/other/02654-icon-service-Custom-IP-Prefix.png diff --git a/assets/icons/azure/other/02658-icon-service-FHIR-Service.png b/cloudexit/assets/icons/azure/other/02658-icon-service-FHIR-Service.png similarity index 100% rename from assets/icons/azure/other/02658-icon-service-FHIR-Service.png rename to cloudexit/assets/icons/azure/other/02658-icon-service-FHIR-Service.png diff --git a/assets/icons/azure/other/02660-icon-service-MedTech-Service.png b/cloudexit/assets/icons/azure/other/02660-icon-service-MedTech-Service.png similarity index 100% rename from assets/icons/azure/other/02660-icon-service-MedTech-Service.png rename to cloudexit/assets/icons/azure/other/02660-icon-service-MedTech-Service.png diff --git a/assets/icons/azure/other/02663-icon-service-Managed-Instance-Apache-Cassandra.png b/cloudexit/assets/icons/azure/other/02663-icon-service-Managed-Instance-Apache-Cassandra.png similarity index 100% rename from assets/icons/azure/other/02663-icon-service-Managed-Instance-Apache-Cassandra.png rename to cloudexit/assets/icons/azure/other/02663-icon-service-Managed-Instance-Apache-Cassandra.png diff --git a/assets/icons/azure/other/02668-icon-service-Confidential-Ledgers.png b/cloudexit/assets/icons/azure/other/02668-icon-service-Confidential-Ledgers.png similarity index 100% rename from assets/icons/azure/other/02668-icon-service-Confidential-Ledgers.png rename to cloudexit/assets/icons/azure/other/02668-icon-service-Confidential-Ledgers.png diff --git a/assets/icons/azure/other/02681-icon-service-AAD-Licenses.png b/cloudexit/assets/icons/azure/other/02681-icon-service-AAD-Licenses.png similarity index 100% rename from assets/icons/azure/other/02681-icon-service-AAD-Licenses.png rename to cloudexit/assets/icons/azure/other/02681-icon-service-AAD-Licenses.png diff --git a/assets/icons/azure/other/02681-icon-service-Entra-Identity-Licenses.png b/cloudexit/assets/icons/azure/other/02681-icon-service-Entra-Identity-Licenses.png similarity index 100% rename from assets/icons/azure/other/02681-icon-service-Entra-Identity-Licenses.png rename to cloudexit/assets/icons/azure/other/02681-icon-service-Entra-Identity-Licenses.png diff --git a/assets/icons/azure/other/02691-icon-service-Test-Base.png b/cloudexit/assets/icons/azure/other/02691-icon-service-Test-Base.png similarity index 100% rename from assets/icons/azure/other/02691-icon-service-Test-Base.png rename to cloudexit/assets/icons/azure/other/02691-icon-service-Test-Base.png diff --git a/assets/icons/azure/other/02697-icon-service-Azure-Orbital.png b/cloudexit/assets/icons/azure/other/02697-icon-service-Azure-Orbital.png similarity index 100% rename from assets/icons/azure/other/02697-icon-service-Azure-Orbital.png rename to cloudexit/assets/icons/azure/other/02697-icon-service-Azure-Orbital.png diff --git a/assets/icons/azure/other/02732-icon-service-Capacity-Reservation-Groups.png b/cloudexit/assets/icons/azure/other/02732-icon-service-Capacity-Reservation-Groups.png similarity index 100% rename from assets/icons/azure/other/02732-icon-service-Capacity-Reservation-Groups.png rename to cloudexit/assets/icons/azure/other/02732-icon-service-Capacity-Reservation-Groups.png diff --git a/assets/icons/azure/other/02733-icon-service-Windows-Notification-Services.png b/cloudexit/assets/icons/azure/other/02733-icon-service-Windows-Notification-Services.png similarity index 100% rename from assets/icons/azure/other/02733-icon-service-Windows-Notification-Services.png rename to cloudexit/assets/icons/azure/other/02733-icon-service-Windows-Notification-Services.png diff --git a/assets/icons/azure/other/02740-icon-service-Azure-Network-Function-Manager.png b/cloudexit/assets/icons/azure/other/02740-icon-service-Azure-Network-Function-Manager.png similarity index 100% rename from assets/icons/azure/other/02740-icon-service-Azure-Network-Function-Manager.png rename to cloudexit/assets/icons/azure/other/02740-icon-service-Azure-Network-Function-Manager.png diff --git a/assets/icons/azure/other/02793-icon-service-Mission-Landing-Zone.png b/cloudexit/assets/icons/azure/other/02793-icon-service-Mission-Landing-Zone.png similarity index 100% rename from assets/icons/azure/other/02793-icon-service-Mission-Landing-Zone.png rename to cloudexit/assets/icons/azure/other/02793-icon-service-Mission-Landing-Zone.png diff --git a/assets/icons/azure/other/02794-icon-service-Mobile-Networks.png b/cloudexit/assets/icons/azure/other/02794-icon-service-Mobile-Networks.png similarity index 100% rename from assets/icons/azure/other/02794-icon-service-Mobile-Networks.png rename to cloudexit/assets/icons/azure/other/02794-icon-service-Mobile-Networks.png diff --git a/assets/icons/azure/other/02805-icon-service-VM-App-Definitions.png b/cloudexit/assets/icons/azure/other/02805-icon-service-VM-App-Definitions.png similarity index 100% rename from assets/icons/azure/other/02805-icon-service-VM-App-Definitions.png rename to cloudexit/assets/icons/azure/other/02805-icon-service-VM-App-Definitions.png diff --git a/assets/icons/azure/other/02806-icon-service-VM-App-Versions.png b/cloudexit/assets/icons/azure/other/02806-icon-service-VM-App-Versions.png similarity index 100% rename from assets/icons/azure/other/02806-icon-service-VM-App-Versions.png rename to cloudexit/assets/icons/azure/other/02806-icon-service-VM-App-Versions.png diff --git a/assets/icons/azure/other/02810-icon-service-Azure-Edge-Hardware-Center.png b/cloudexit/assets/icons/azure/other/02810-icon-service-Azure-Edge-Hardware-Center.png similarity index 100% rename from assets/icons/azure/other/02810-icon-service-Azure-Edge-Hardware-Center.png rename to cloudexit/assets/icons/azure/other/02810-icon-service-Azure-Edge-Hardware-Center.png diff --git a/assets/icons/azure/other/02819-icon-service-Resource-Guard.png b/cloudexit/assets/icons/azure/other/02819-icon-service-Resource-Guard.png similarity index 100% rename from assets/icons/azure/other/02819-icon-service-Resource-Guard.png rename to cloudexit/assets/icons/azure/other/02819-icon-service-Resource-Guard.png diff --git a/assets/icons/azure/other/02828-icon-service-Ceres.png b/cloudexit/assets/icons/azure/other/02828-icon-service-Ceres.png similarity index 100% rename from assets/icons/azure/other/02828-icon-service-Ceres.png rename to cloudexit/assets/icons/azure/other/02828-icon-service-Ceres.png diff --git a/assets/icons/azure/other/02842-icon-service-Azurite.png b/cloudexit/assets/icons/azure/other/02842-icon-service-Azurite.png similarity index 100% rename from assets/icons/azure/other/02842-icon-service-Azurite.png rename to cloudexit/assets/icons/azure/other/02842-icon-service-Azurite.png diff --git a/assets/icons/azure/other/02846-icon-service-Update-Management-Center.png b/cloudexit/assets/icons/azure/other/02846-icon-service-Update-Management-Center.png similarity index 100% rename from assets/icons/azure/other/02846-icon-service-Update-Management-Center.png rename to cloudexit/assets/icons/azure/other/02846-icon-service-Update-Management-Center.png diff --git a/assets/icons/azure/other/02864-icon-service-Azure-Compute-Galleries.png b/cloudexit/assets/icons/azure/other/02864-icon-service-Azure-Compute-Galleries.png similarity index 100% rename from assets/icons/azure/other/02864-icon-service-Azure-Compute-Galleries.png rename to cloudexit/assets/icons/azure/other/02864-icon-service-Azure-Compute-Galleries.png diff --git a/assets/icons/azure/other/02865-icon-service-Community-Images.png b/cloudexit/assets/icons/azure/other/02865-icon-service-Community-Images.png similarity index 100% rename from assets/icons/azure/other/02865-icon-service-Community-Images.png rename to cloudexit/assets/icons/azure/other/02865-icon-service-Community-Images.png diff --git a/assets/icons/azure/other/02866-icon-service-VM-Image-Version.png b/cloudexit/assets/icons/azure/other/02866-icon-service-VM-Image-Version.png similarity index 100% rename from assets/icons/azure/other/02866-icon-service-VM-Image-Version.png rename to cloudexit/assets/icons/azure/other/02866-icon-service-VM-Image-Version.png diff --git a/assets/icons/azure/other/02880-icon-service-Savings-Plans.png b/cloudexit/assets/icons/azure/other/02880-icon-service-Savings-Plans.png similarity index 100% rename from assets/icons/azure/other/02880-icon-service-Savings-Plans.png rename to cloudexit/assets/icons/azure/other/02880-icon-service-Savings-Plans.png diff --git a/assets/icons/azure/other/02884-icon-service-Worker-Container-App.png b/cloudexit/assets/icons/azure/other/02884-icon-service-Worker-Container-App.png similarity index 100% rename from assets/icons/azure/other/02884-icon-service-Worker-Container-App.png rename to cloudexit/assets/icons/azure/other/02884-icon-service-Worker-Container-App.png diff --git a/assets/icons/azure/other/02905-icon-service-Azure-Managed-Grafana.png b/cloudexit/assets/icons/azure/other/02905-icon-service-Azure-Managed-Grafana.png similarity index 100% rename from assets/icons/azure/other/02905-icon-service-Azure-Managed-Grafana.png rename to cloudexit/assets/icons/azure/other/02905-icon-service-Azure-Managed-Grafana.png diff --git a/assets/icons/azure/other/02908-icon-service-Targets-Management.png b/cloudexit/assets/icons/azure/other/02908-icon-service-Targets-Management.png similarity index 100% rename from assets/icons/azure/other/02908-icon-service-Targets-Management.png rename to cloudexit/assets/icons/azure/other/02908-icon-service-Targets-Management.png diff --git a/assets/icons/azure/other/02909-icon-service-Storage-Functions.png b/cloudexit/assets/icons/azure/other/02909-icon-service-Storage-Functions.png similarity index 100% rename from assets/icons/azure/other/02909-icon-service-Storage-Functions.png rename to cloudexit/assets/icons/azure/other/02909-icon-service-Storage-Functions.png diff --git a/assets/icons/azure/other/02909-icon-service-Storage-Tasks.png b/cloudexit/assets/icons/azure/other/02909-icon-service-Storage-Tasks.png similarity index 100% rename from assets/icons/azure/other/02909-icon-service-Storage-Tasks.png rename to cloudexit/assets/icons/azure/other/02909-icon-service-Storage-Tasks.png diff --git a/assets/icons/azure/other/02922-icon-service-Sonic-Dash.png b/cloudexit/assets/icons/azure/other/02922-icon-service-Sonic-Dash.png similarity index 100% rename from assets/icons/azure/other/02922-icon-service-Sonic-Dash.png rename to cloudexit/assets/icons/azure/other/02922-icon-service-Sonic-Dash.png diff --git a/assets/icons/azure/other/02931-icon-service-Compliance-Center.png b/cloudexit/assets/icons/azure/other/02931-icon-service-Compliance-Center.png similarity index 100% rename from assets/icons/azure/other/02931-icon-service-Compliance-Center.png rename to cloudexit/assets/icons/azure/other/02931-icon-service-Compliance-Center.png diff --git a/assets/icons/azure/other/02939-icon-service-Network-Security-Perimeters.png b/cloudexit/assets/icons/azure/other/02939-icon-service-Network-Security-Perimeters.png similarity index 100% rename from assets/icons/azure/other/02939-icon-service-Network-Security-Perimeters.png rename to cloudexit/assets/icons/azure/other/02939-icon-service-Network-Security-Perimeters.png diff --git a/assets/icons/azure/other/02944-icon-service-Azure-Load-Testing.png b/cloudexit/assets/icons/azure/other/02944-icon-service-Azure-Load-Testing.png similarity index 100% rename from assets/icons/azure/other/02944-icon-service-Azure-Load-Testing.png rename to cloudexit/assets/icons/azure/other/02944-icon-service-Azure-Load-Testing.png diff --git a/assets/icons/azure/other/02949-icon-service-Virtual-Visits-Builder.png b/cloudexit/assets/icons/azure/other/02949-icon-service-Virtual-Visits-Builder.png similarity index 100% rename from assets/icons/azure/other/02949-icon-service-Virtual-Visits-Builder.png rename to cloudexit/assets/icons/azure/other/02949-icon-service-Virtual-Visits-Builder.png diff --git a/assets/icons/azure/other/02951-icon-service-Azure-Quotas.png b/cloudexit/assets/icons/azure/other/02951-icon-service-Azure-Quotas.png similarity index 100% rename from assets/icons/azure/other/02951-icon-service-Azure-Quotas.png rename to cloudexit/assets/icons/azure/other/02951-icon-service-Azure-Quotas.png diff --git a/assets/icons/azure/other/02989-icon-service-Container-Apps-Environments.png b/cloudexit/assets/icons/azure/other/02989-icon-service-Container-Apps-Environments.png similarity index 100% rename from assets/icons/azure/other/02989-icon-service-Container-Apps-Environments.png rename to cloudexit/assets/icons/azure/other/02989-icon-service-Container-Apps-Environments.png diff --git a/assets/icons/azure/other/03071-icon-service-App-Compliance-Automation.png b/cloudexit/assets/icons/azure/other/03071-icon-service-App-Compliance-Automation.png similarity index 100% rename from assets/icons/azure/other/03071-icon-service-App-Compliance-Automation.png rename to cloudexit/assets/icons/azure/other/03071-icon-service-App-Compliance-Automation.png diff --git a/assets/icons/azure/other/03089-icon-service-Virtual-Instance-for-SAP.png b/cloudexit/assets/icons/azure/other/03089-icon-service-Virtual-Instance-for-SAP.png similarity index 100% rename from assets/icons/azure/other/03089-icon-service-Virtual-Instance-for-SAP.png rename to cloudexit/assets/icons/azure/other/03089-icon-service-Virtual-Instance-for-SAP.png diff --git a/assets/icons/azure/other/03090-icon-service-Azure-Center-for-SAP.png b/cloudexit/assets/icons/azure/other/03090-icon-service-Azure-Center-for-SAP.png similarity index 100% rename from assets/icons/azure/other/03090-icon-service-Azure-Center-for-SAP.png rename to cloudexit/assets/icons/azure/other/03090-icon-service-Azure-Center-for-SAP.png diff --git a/assets/icons/azure/other/03091-icon-service-Azure-Storage-Mover.png b/cloudexit/assets/icons/azure/other/03091-icon-service-Azure-Storage-Mover.png similarity index 100% rename from assets/icons/azure/other/03091-icon-service-Azure-Storage-Mover.png rename to cloudexit/assets/icons/azure/other/03091-icon-service-Azure-Storage-Mover.png diff --git a/assets/icons/azure/other/03092-icon-service-Central-Service-Instance-For-SAP.png b/cloudexit/assets/icons/azure/other/03092-icon-service-Central-Service-Instance-For-SAP.png similarity index 100% rename from assets/icons/azure/other/03092-icon-service-Central-Service-Instance-For-SAP.png rename to cloudexit/assets/icons/azure/other/03092-icon-service-Central-Service-Instance-For-SAP.png diff --git a/assets/icons/azure/other/03134-icon-service-Kubernetes-Fleet-Manager.png b/cloudexit/assets/icons/azure/other/03134-icon-service-Kubernetes-Fleet-Manager.png similarity index 100% rename from assets/icons/azure/other/03134-icon-service-Kubernetes-Fleet-Manager.png rename to cloudexit/assets/icons/azure/other/03134-icon-service-Kubernetes-Fleet-Manager.png diff --git a/assets/icons/azure/other/03137-icon-service-Express-Route-Traffic-Collector.png b/cloudexit/assets/icons/azure/other/03137-icon-service-Express-Route-Traffic-Collector.png similarity index 100% rename from assets/icons/azure/other/03137-icon-service-Express-Route-Traffic-Collector.png rename to cloudexit/assets/icons/azure/other/03137-icon-service-Express-Route-Traffic-Collector.png diff --git a/assets/icons/azure/other/03175-icon-service-Database-Instance-For-SAP.png b/cloudexit/assets/icons/azure/other/03175-icon-service-Database-Instance-For-SAP.png similarity index 100% rename from assets/icons/azure/other/03175-icon-service-Database-Instance-For-SAP.png rename to cloudexit/assets/icons/azure/other/03175-icon-service-Database-Instance-For-SAP.png diff --git a/assets/icons/azure/other/03190-icon-service-Elastic-SAN.png b/cloudexit/assets/icons/azure/other/03190-icon-service-Elastic-SAN.png similarity index 100% rename from assets/icons/azure/other/03190-icon-service-Elastic-SAN.png rename to cloudexit/assets/icons/azure/other/03190-icon-service-Elastic-SAN.png diff --git a/assets/icons/azure/other/03250-icon-service-Microsoft-Dev-Box.png b/cloudexit/assets/icons/azure/other/03250-icon-service-Microsoft-Dev-Box.png similarity index 100% rename from assets/icons/azure/other/03250-icon-service-Microsoft-Dev-Box.png rename to cloudexit/assets/icons/azure/other/03250-icon-service-Microsoft-Dev-Box.png diff --git a/assets/icons/azure/other/03251-icon-service-Azure-Deployment-Environments.png b/cloudexit/assets/icons/azure/other/03251-icon-service-Azure-Deployment-Environments.png similarity index 100% rename from assets/icons/azure/other/03251-icon-service-Azure-Deployment-Environments.png rename to cloudexit/assets/icons/azure/other/03251-icon-service-Azure-Deployment-Environments.png diff --git a/assets/icons/azure/other/032869111-icon-service-Microsoft-Discovery.png b/cloudexit/assets/icons/azure/other/032869111-icon-service-Microsoft-Discovery.png similarity index 100% rename from assets/icons/azure/other/032869111-icon-service-Microsoft-Discovery.png rename to cloudexit/assets/icons/azure/other/032869111-icon-service-Microsoft-Discovery.png diff --git a/assets/icons/azure/other/03308-icon-service-Azure-Dev-Tunnels.png b/cloudexit/assets/icons/azure/other/03308-icon-service-Azure-Dev-Tunnels.png similarity index 100% rename from assets/icons/azure/other/03308-icon-service-Azure-Dev-Tunnels.png rename to cloudexit/assets/icons/azure/other/03308-icon-service-Azure-Dev-Tunnels.png diff --git a/assets/icons/azure/other/033112809-icon-service-promethus.png b/cloudexit/assets/icons/azure/other/033112809-icon-service-promethus.png similarity index 100% rename from assets/icons/azure/other/033112809-icon-service-promethus.png rename to cloudexit/assets/icons/azure/other/033112809-icon-service-promethus.png diff --git a/assets/icons/azure/other/03314-icon-service-Azure-Sustainability.png b/cloudexit/assets/icons/azure/other/03314-icon-service-Azure-Sustainability.png similarity index 100% rename from assets/icons/azure/other/03314-icon-service-Azure-Sustainability.png rename to cloudexit/assets/icons/azure/other/03314-icon-service-Azure-Sustainability.png diff --git a/assets/icons/azure/other/03361-icon-service-IcM-Troubleshooting.png b/cloudexit/assets/icons/azure/other/03361-icon-service-IcM-Troubleshooting.png similarity index 100% rename from assets/icons/azure/other/03361-icon-service-IcM-Troubleshooting.png rename to cloudexit/assets/icons/azure/other/03361-icon-service-IcM-Troubleshooting.png diff --git a/assets/icons/azure/other/03365-icon-service-OSConfig.png b/cloudexit/assets/icons/azure/other/03365-icon-service-OSConfig.png similarity index 100% rename from assets/icons/azure/other/03365-icon-service-OSConfig.png rename to cloudexit/assets/icons/azure/other/03365-icon-service-OSConfig.png diff --git a/assets/icons/azure/other/03394-icon-service-Azure-Enclaves.png b/cloudexit/assets/icons/azure/other/03394-icon-service-Azure-Enclaves.png similarity index 100% rename from assets/icons/azure/other/03394-icon-service-Azure-Enclaves.png rename to cloudexit/assets/icons/azure/other/03394-icon-service-Azure-Enclaves.png diff --git a/assets/icons/azure/other/03399-icon-service-AKS-Istio.png b/cloudexit/assets/icons/azure/other/03399-icon-service-AKS-Istio.png similarity index 100% rename from assets/icons/azure/other/03399-icon-service-AKS-Istio.png rename to cloudexit/assets/icons/azure/other/03399-icon-service-AKS-Istio.png diff --git a/assets/icons/azure/other/03402-icon-service-Defender-CM-Local-Manager.png b/cloudexit/assets/icons/azure/other/03402-icon-service-Defender-CM-Local-Manager.png similarity index 100% rename from assets/icons/azure/other/03402-icon-service-Defender-CM-Local-Manager.png rename to cloudexit/assets/icons/azure/other/03402-icon-service-Defender-CM-Local-Manager.png diff --git a/assets/icons/azure/other/03403-icon-service-Defender-External-Management.png b/cloudexit/assets/icons/azure/other/03403-icon-service-Defender-External-Management.png similarity index 100% rename from assets/icons/azure/other/03403-icon-service-Defender-External-Management.png rename to cloudexit/assets/icons/azure/other/03403-icon-service-Defender-External-Management.png diff --git a/assets/icons/azure/other/03404-icon-service-Defender-Freezer-Monitor.png b/cloudexit/assets/icons/azure/other/03404-icon-service-Defender-Freezer-Monitor.png similarity index 100% rename from assets/icons/azure/other/03404-icon-service-Defender-Freezer-Monitor.png rename to cloudexit/assets/icons/azure/other/03404-icon-service-Defender-Freezer-Monitor.png diff --git a/assets/icons/azure/other/03405-icon-service-Defender-Historian.png b/cloudexit/assets/icons/azure/other/03405-icon-service-Defender-Historian.png similarity index 100% rename from assets/icons/azure/other/03405-icon-service-Defender-Historian.png rename to cloudexit/assets/icons/azure/other/03405-icon-service-Defender-Historian.png diff --git a/assets/icons/azure/other/03406-icon-service-Defender-HMI.png b/cloudexit/assets/icons/azure/other/03406-icon-service-Defender-HMI.png similarity index 100% rename from assets/icons/azure/other/03406-icon-service-Defender-HMI.png rename to cloudexit/assets/icons/azure/other/03406-icon-service-Defender-HMI.png diff --git a/assets/icons/azure/other/03407-icon-service-Defender-Marquee.png b/cloudexit/assets/icons/azure/other/03407-icon-service-Defender-Marquee.png similarity index 100% rename from assets/icons/azure/other/03407-icon-service-Defender-Marquee.png rename to cloudexit/assets/icons/azure/other/03407-icon-service-Defender-Marquee.png diff --git a/assets/icons/azure/other/03408-icon-service-Defender-Robot-Controller.png b/cloudexit/assets/icons/azure/other/03408-icon-service-Defender-Robot-Controller.png similarity index 100% rename from assets/icons/azure/other/03408-icon-service-Defender-Robot-Controller.png rename to cloudexit/assets/icons/azure/other/03408-icon-service-Defender-Robot-Controller.png diff --git a/assets/icons/azure/other/03409-icon-service-Defender-Sensor.png b/cloudexit/assets/icons/azure/other/03409-icon-service-Defender-Sensor.png similarity index 100% rename from assets/icons/azure/other/03409-icon-service-Defender-Sensor.png rename to cloudexit/assets/icons/azure/other/03409-icon-service-Defender-Sensor.png diff --git a/assets/icons/azure/other/03410-icon-service-Defender-Slot.png b/cloudexit/assets/icons/azure/other/03410-icon-service-Defender-Slot.png similarity index 100% rename from assets/icons/azure/other/03410-icon-service-Defender-Slot.png rename to cloudexit/assets/icons/azure/other/03410-icon-service-Defender-Slot.png diff --git a/assets/icons/azure/other/03411-icon-service-Defender-Web-Guiding-System.png b/cloudexit/assets/icons/azure/other/03411-icon-service-Defender-Web-Guiding-System.png similarity index 100% rename from assets/icons/azure/other/03411-icon-service-Defender-Web-Guiding-System.png rename to cloudexit/assets/icons/azure/other/03411-icon-service-Defender-Web-Guiding-System.png diff --git a/assets/icons/azure/other/03412-icon-service-Defender-DCS-Controller.png b/cloudexit/assets/icons/azure/other/03412-icon-service-Defender-DCS-Controller.png similarity index 100% rename from assets/icons/azure/other/03412-icon-service-Defender-DCS-Controller.png rename to cloudexit/assets/icons/azure/other/03412-icon-service-Defender-DCS-Controller.png diff --git a/assets/icons/azure/other/03413-icon-service-Defender-Distributer-Control-System.png b/cloudexit/assets/icons/azure/other/03413-icon-service-Defender-Distributer-Control-System.png similarity index 100% rename from assets/icons/azure/other/03413-icon-service-Defender-Distributer-Control-System.png rename to cloudexit/assets/icons/azure/other/03413-icon-service-Defender-Distributer-Control-System.png diff --git a/assets/icons/azure/other/03414-icon-service-Defender-Engineering-Station.png b/cloudexit/assets/icons/azure/other/03414-icon-service-Defender-Engineering-Station.png similarity index 100% rename from assets/icons/azure/other/03414-icon-service-Defender-Engineering-Station.png rename to cloudexit/assets/icons/azure/other/03414-icon-service-Defender-Engineering-Station.png diff --git a/assets/icons/azure/other/03415-icon-service-Defender-Industrial-Packaging-System.png b/cloudexit/assets/icons/azure/other/03415-icon-service-Defender-Industrial-Packaging-System.png similarity index 100% rename from assets/icons/azure/other/03415-icon-service-Defender-Industrial-Packaging-System.png rename to cloudexit/assets/icons/azure/other/03415-icon-service-Defender-Industrial-Packaging-System.png diff --git a/assets/icons/azure/other/03416-icon-service-Defender-Industrial-Printer.png b/cloudexit/assets/icons/azure/other/03416-icon-service-Defender-Industrial-Printer.png similarity index 100% rename from assets/icons/azure/other/03416-icon-service-Defender-Industrial-Printer.png rename to cloudexit/assets/icons/azure/other/03416-icon-service-Defender-Industrial-Printer.png diff --git a/assets/icons/azure/other/03417-icon-service-Defender-Industrial-Scale-System.png b/cloudexit/assets/icons/azure/other/03417-icon-service-Defender-Industrial-Scale-System.png similarity index 100% rename from assets/icons/azure/other/03417-icon-service-Defender-Industrial-Scale-System.png rename to cloudexit/assets/icons/azure/other/03417-icon-service-Defender-Industrial-Scale-System.png diff --git a/assets/icons/azure/other/03418-icon-service-Defender-Industrial-Robot.png b/cloudexit/assets/icons/azure/other/03418-icon-service-Defender-Industrial-Robot.png similarity index 100% rename from assets/icons/azure/other/03418-icon-service-Defender-Industrial-Robot.png rename to cloudexit/assets/icons/azure/other/03418-icon-service-Defender-Industrial-Robot.png diff --git a/assets/icons/azure/other/03419-icon-service-Defender-Meter.png b/cloudexit/assets/icons/azure/other/03419-icon-service-Defender-Meter.png similarity index 100% rename from assets/icons/azure/other/03419-icon-service-Defender-Meter.png rename to cloudexit/assets/icons/azure/other/03419-icon-service-Defender-Meter.png diff --git a/assets/icons/azure/other/03420-icon-service-Defender-PLC.png b/cloudexit/assets/icons/azure/other/03420-icon-service-Defender-PLC.png similarity index 100% rename from assets/icons/azure/other/03420-icon-service-Defender-PLC.png rename to cloudexit/assets/icons/azure/other/03420-icon-service-Defender-PLC.png diff --git a/assets/icons/azure/other/03421-icon-service-Defender-Pneumatic-Device.png b/cloudexit/assets/icons/azure/other/03421-icon-service-Defender-Pneumatic-Device.png similarity index 100% rename from assets/icons/azure/other/03421-icon-service-Defender-Pneumatic-Device.png rename to cloudexit/assets/icons/azure/other/03421-icon-service-Defender-Pneumatic-Device.png diff --git a/assets/icons/azure/other/03422-icon-service-Defender-Programable-Board.png b/cloudexit/assets/icons/azure/other/03422-icon-service-Defender-Programable-Board.png similarity index 100% rename from assets/icons/azure/other/03422-icon-service-Defender-Programable-Board.png rename to cloudexit/assets/icons/azure/other/03422-icon-service-Defender-Programable-Board.png diff --git a/assets/icons/azure/other/03423-icon-service-Defender-Relay.png b/cloudexit/assets/icons/azure/other/03423-icon-service-Defender-Relay.png similarity index 100% rename from assets/icons/azure/other/03423-icon-service-Defender-Relay.png rename to cloudexit/assets/icons/azure/other/03423-icon-service-Defender-Relay.png diff --git a/assets/icons/azure/other/03424-icon-service-Defender-RTU.png b/cloudexit/assets/icons/azure/other/03424-icon-service-Defender-RTU.png similarity index 100% rename from assets/icons/azure/other/03424-icon-service-Defender-RTU.png rename to cloudexit/assets/icons/azure/other/03424-icon-service-Defender-RTU.png diff --git a/assets/icons/azure/other/03468-icon-service-HDI-AKS-Cluster.png b/cloudexit/assets/icons/azure/other/03468-icon-service-HDI-AKS-Cluster.png similarity index 100% rename from assets/icons/azure/other/03468-icon-service-HDI-AKS-Cluster.png rename to cloudexit/assets/icons/azure/other/03468-icon-service-HDI-AKS-Cluster.png diff --git a/assets/icons/azure/other/03495-icon-service-Azure-Consumption-Commitment.png b/cloudexit/assets/icons/azure/other/03495-icon-service-Azure-Consumption-Commitment.png similarity index 100% rename from assets/icons/azure/other/03495-icon-service-Azure-Consumption-Commitment.png rename to cloudexit/assets/icons/azure/other/03495-icon-service-Azure-Consumption-Commitment.png diff --git a/assets/icons/azure/other/03528-icon-service-Monitor-Health-Models.png b/cloudexit/assets/icons/azure/other/03528-icon-service-Monitor-Health-Models.png similarity index 100% rename from assets/icons/azure/other/03528-icon-service-Monitor-Health-Models.png rename to cloudexit/assets/icons/azure/other/03528-icon-service-Monitor-Health-Models.png diff --git a/assets/icons/azure/other/03563-icon-service-WAC-Installer.png b/cloudexit/assets/icons/azure/other/03563-icon-service-WAC-Installer.png similarity index 100% rename from assets/icons/azure/other/03563-icon-service-WAC-Installer.png rename to cloudexit/assets/icons/azure/other/03563-icon-service-WAC-Installer.png diff --git a/assets/icons/azure/other/03633-icon-service-Breeze.png b/cloudexit/assets/icons/azure/other/03633-icon-service-Breeze.png similarity index 100% rename from assets/icons/azure/other/03633-icon-service-Breeze.png rename to cloudexit/assets/icons/azure/other/03633-icon-service-Breeze.png diff --git a/assets/icons/azure/other/10018-icon-service-Azure-A.png b/cloudexit/assets/icons/azure/other/10018-icon-service-Azure-A.png similarity index 100% rename from assets/icons/azure/other/10018-icon-service-Azure-A.png rename to cloudexit/assets/icons/azure/other/10018-icon-service-Azure-A.png diff --git a/assets/icons/azure/other/10077-icon-service-Local-Network-Gateways.png b/cloudexit/assets/icons/azure/other/10077-icon-service-Local-Network-Gateways.png similarity index 100% rename from assets/icons/azure/other/10077-icon-service-Local-Network-Gateways.png rename to cloudexit/assets/icons/azure/other/10077-icon-service-Local-Network-Gateways.png diff --git a/assets/icons/azure/other/10117-icon-service-Edge-Management.png b/cloudexit/assets/icons/azure/other/10117-icon-service-Edge-Management.png similarity index 100% rename from assets/icons/azure/other/10117-icon-service-Edge-Management.png rename to cloudexit/assets/icons/azure/other/10117-icon-service-Edge-Management.png diff --git a/assets/icons/azure/other/10139-icon-service-Instance-Pools.png b/cloudexit/assets/icons/azure/other/10139-icon-service-Instance-Pools.png similarity index 100% rename from assets/icons/azure/other/10139-icon-service-Instance-Pools.png rename to cloudexit/assets/icons/azure/other/10139-icon-service-Instance-Pools.png diff --git a/assets/icons/azure/other/10190-icon-service-Azure-Sphere.png b/cloudexit/assets/icons/azure/other/10190-icon-service-Azure-Sphere.png similarity index 100% rename from assets/icons/azure/other/10190-icon-service-Azure-Sphere.png rename to cloudexit/assets/icons/azure/other/10190-icon-service-Azure-Sphere.png diff --git a/assets/icons/azure/other/10232-icon-service-App-Registrations.png b/cloudexit/assets/icons/azure/other/10232-icon-service-App-Registrations.png similarity index 100% rename from assets/icons/azure/other/10232-icon-service-App-Registrations.png rename to cloudexit/assets/icons/azure/other/10232-icon-service-App-Registrations.png diff --git a/assets/icons/azure/other/10334-icon-service-Exchange-On-Premises-Access.png b/cloudexit/assets/icons/azure/other/10334-icon-service-Exchange-On-Premises-Access.png similarity index 100% rename from assets/icons/azure/other/10334-icon-service-Exchange-On-Premises-Access.png rename to cloudexit/assets/icons/azure/other/10334-icon-service-Exchange-On-Premises-Access.png diff --git a/assets/icons/azure/other/10355-icon-service-WAC.png b/cloudexit/assets/icons/azure/other/10355-icon-service-WAC.png similarity index 100% rename from assets/icons/azure/other/10355-icon-service-WAC.png rename to cloudexit/assets/icons/azure/other/10355-icon-service-WAC.png diff --git a/assets/icons/azure/other/10422-icon-service-AzureAttestation.png b/cloudexit/assets/icons/azure/other/10422-icon-service-AzureAttestation.png similarity index 100% rename from assets/icons/azure/other/10422-icon-service-AzureAttestation.png rename to cloudexit/assets/icons/azure/other/10422-icon-service-AzureAttestation.png diff --git a/assets/icons/azure/other/10778-icon-service-RTOS.png b/cloudexit/assets/icons/azure/other/10778-icon-service-RTOS.png similarity index 100% rename from assets/icons/azure/other/10778-icon-service-RTOS.png rename to cloudexit/assets/icons/azure/other/10778-icon-service-RTOS.png diff --git a/assets/icons/azure/other/10846-icon-service-Web-Jobs.png b/cloudexit/assets/icons/azure/other/10846-icon-service-Web-Jobs.png similarity index 100% rename from assets/icons/azure/other/10846-icon-service-Web-Jobs.png rename to cloudexit/assets/icons/azure/other/10846-icon-service-Web-Jobs.png diff --git a/assets/icons/azure/security/00378-icon-service-Detonation.png b/cloudexit/assets/icons/azure/security/00378-icon-service-Detonation.png similarity index 100% rename from assets/icons/azure/security/00378-icon-service-Detonation.png rename to cloudexit/assets/icons/azure/security/00378-icon-service-Detonation.png diff --git a/assets/icons/azure/security/02247-icon-service-Microsoft-Defender-for-IoT.png b/cloudexit/assets/icons/azure/security/02247-icon-service-Microsoft-Defender-for-IoT.png similarity index 100% rename from assets/icons/azure/security/02247-icon-service-Microsoft-Defender-for-IoT.png rename to cloudexit/assets/icons/azure/security/02247-icon-service-Microsoft-Defender-for-IoT.png diff --git a/assets/icons/azure/security/02251-icon-service-Azure-AD-Privleged-Identity-Management.png b/cloudexit/assets/icons/azure/security/02251-icon-service-Azure-AD-Privleged-Identity-Management.png similarity index 100% rename from assets/icons/azure/security/02251-icon-service-Azure-AD-Privleged-Identity-Management.png rename to cloudexit/assets/icons/azure/security/02251-icon-service-Azure-AD-Privleged-Identity-Management.png diff --git a/assets/icons/azure/security/03336-icon-service-Microsoft-Defender-EASM.png b/cloudexit/assets/icons/azure/security/03336-icon-service-Microsoft-Defender-EASM.png similarity index 100% rename from assets/icons/azure/security/03336-icon-service-Microsoft-Defender-EASM.png rename to cloudexit/assets/icons/azure/security/03336-icon-service-Microsoft-Defender-EASM.png diff --git a/assets/icons/azure/security/03340-icon-service-Identity-Secure-Score.png b/cloudexit/assets/icons/azure/security/03340-icon-service-Identity-Secure-Score.png similarity index 100% rename from assets/icons/azure/security/03340-icon-service-Identity-Secure-Score.png rename to cloudexit/assets/icons/azure/security/03340-icon-service-Identity-Secure-Score.png diff --git a/assets/icons/azure/security/03341-icon-service-Azure-AD-Risky-Signins.png b/cloudexit/assets/icons/azure/security/03341-icon-service-Azure-AD-Risky-Signins.png similarity index 100% rename from assets/icons/azure/security/03341-icon-service-Azure-AD-Risky-Signins.png rename to cloudexit/assets/icons/azure/security/03341-icon-service-Azure-AD-Risky-Signins.png diff --git a/assets/icons/azure/security/03341-icon-service-Entra-Identity-Risky-Signins.png b/cloudexit/assets/icons/azure/security/03341-icon-service-Entra-Identity-Risky-Signins.png similarity index 100% rename from assets/icons/azure/security/03341-icon-service-Entra-Identity-Risky-Signins.png rename to cloudexit/assets/icons/azure/security/03341-icon-service-Entra-Identity-Risky-Signins.png diff --git a/assets/icons/azure/security/03342-icon-service-Azure-AD-Risky-Users.png b/cloudexit/assets/icons/azure/security/03342-icon-service-Azure-AD-Risky-Users.png similarity index 100% rename from assets/icons/azure/security/03342-icon-service-Azure-AD-Risky-Users.png rename to cloudexit/assets/icons/azure/security/03342-icon-service-Azure-AD-Risky-Users.png diff --git a/assets/icons/azure/security/03342-icon-service-Entra-Identity-Risky-Users.png b/cloudexit/assets/icons/azure/security/03342-icon-service-Entra-Identity-Risky-Users.png similarity index 100% rename from assets/icons/azure/security/03342-icon-service-Entra-Identity-Risky-Users.png rename to cloudexit/assets/icons/azure/security/03342-icon-service-Entra-Identity-Risky-Users.png diff --git a/assets/icons/azure/security/03343-icon-service-Azure-AD-Authentication-Methods.png b/cloudexit/assets/icons/azure/security/03343-icon-service-Azure-AD-Authentication-Methods.png similarity index 100% rename from assets/icons/azure/security/03343-icon-service-Azure-AD-Authentication-Methods.png rename to cloudexit/assets/icons/azure/security/03343-icon-service-Azure-AD-Authentication-Methods.png diff --git a/assets/icons/azure/security/03344-icon-service-Multifactor-Authentication.png b/cloudexit/assets/icons/azure/security/03344-icon-service-Multifactor-Authentication.png similarity index 100% rename from assets/icons/azure/security/03344-icon-service-Multifactor-Authentication.png rename to cloudexit/assets/icons/azure/security/03344-icon-service-Multifactor-Authentication.png diff --git a/assets/icons/azure/security/10229-icon-service-Azure-Information-Protection.png b/cloudexit/assets/icons/azure/security/10229-icon-service-Azure-Information-Protection.png similarity index 100% rename from assets/icons/azure/security/10229-icon-service-Azure-Information-Protection.png rename to cloudexit/assets/icons/azure/security/10229-icon-service-Azure-Information-Protection.png diff --git a/assets/icons/azure/security/10231-icon-service-Azure-AD-Identity-Protection.png b/cloudexit/assets/icons/azure/security/10231-icon-service-Azure-AD-Identity-Protection.png similarity index 100% rename from assets/icons/azure/security/10231-icon-service-Azure-AD-Identity-Protection.png rename to cloudexit/assets/icons/azure/security/10231-icon-service-Azure-AD-Identity-Protection.png diff --git a/assets/icons/azure/security/10233-icon-service-Conditional-Access.png b/cloudexit/assets/icons/azure/security/10233-icon-service-Conditional-Access.png similarity index 100% rename from assets/icons/azure/security/10233-icon-service-Conditional-Access.png rename to cloudexit/assets/icons/azure/security/10233-icon-service-Conditional-Access.png diff --git a/assets/icons/azure/security/10241-icon-service-Microsoft-Defender-for-Cloud.png b/cloudexit/assets/icons/azure/security/10241-icon-service-Microsoft-Defender-for-Cloud.png similarity index 100% rename from assets/icons/azure/security/10241-icon-service-Microsoft-Defender-for-Cloud.png rename to cloudexit/assets/icons/azure/security/10241-icon-service-Microsoft-Defender-for-Cloud.png diff --git a/assets/icons/azure/security/10244-icon-service-Application-Security-Groups.png b/cloudexit/assets/icons/azure/security/10244-icon-service-Application-Security-Groups.png similarity index 100% rename from assets/icons/azure/security/10244-icon-service-Application-Security-Groups.png rename to cloudexit/assets/icons/azure/security/10244-icon-service-Application-Security-Groups.png diff --git a/assets/icons/azure/security/10245-icon-service-Key-Vaults.png b/cloudexit/assets/icons/azure/security/10245-icon-service-Key-Vaults.png similarity index 100% rename from assets/icons/azure/security/10245-icon-service-Key-Vaults.png rename to cloudexit/assets/icons/azure/security/10245-icon-service-Key-Vaults.png diff --git a/assets/icons/azure/security/10248-icon-service-Azure-Sentinel.png b/cloudexit/assets/icons/azure/security/10248-icon-service-Azure-Sentinel.png similarity index 100% rename from assets/icons/azure/security/10248-icon-service-Azure-Sentinel.png rename to cloudexit/assets/icons/azure/security/10248-icon-service-Azure-Sentinel.png diff --git a/assets/icons/azure/security/10433-icon-service-User-Settings.png b/cloudexit/assets/icons/azure/security/10433-icon-service-User-Settings.png similarity index 100% rename from assets/icons/azure/security/10433-icon-service-User-Settings.png rename to cloudexit/assets/icons/azure/security/10433-icon-service-User-Settings.png diff --git a/assets/icons/azure/security/10572-icon-service-ExtendedSecurityUpdates.png b/cloudexit/assets/icons/azure/security/10572-icon-service-ExtendedSecurityUpdates.png similarity index 100% rename from assets/icons/azure/security/10572-icon-service-ExtendedSecurityUpdates.png rename to cloudexit/assets/icons/azure/security/10572-icon-service-ExtendedSecurityUpdates.png diff --git a/assets/icons/azure/storage/00017-icon-service-Recovery-Services-Vaults.png b/cloudexit/assets/icons/azure/storage/00017-icon-service-Recovery-Services-Vaults.png similarity index 100% rename from assets/icons/azure/storage/00017-icon-service-Recovery-Services-Vaults.png rename to cloudexit/assets/icons/azure/storage/00017-icon-service-Recovery-Services-Vaults.png diff --git a/assets/icons/azure/storage/00691-icon-service-Azure-Databox-Gateway.png b/cloudexit/assets/icons/azure/storage/00691-icon-service-Azure-Databox-Gateway.png similarity index 100% rename from assets/icons/azure/storage/00691-icon-service-Azure-Databox-Gateway.png rename to cloudexit/assets/icons/azure/storage/00691-icon-service-Azure-Databox-Gateway.png diff --git a/assets/icons/azure/storage/00776-icon-service-Azure-HCP-Cache.png b/cloudexit/assets/icons/azure/storage/00776-icon-service-Azure-HCP-Cache.png similarity index 100% rename from assets/icons/azure/storage/00776-icon-service-Azure-HCP-Cache.png rename to cloudexit/assets/icons/azure/storage/00776-icon-service-Azure-HCP-Cache.png diff --git a/assets/icons/azure/storage/029203566-icon-service-Data-Virtualization.png b/cloudexit/assets/icons/azure/storage/029203566-icon-service-Data-Virtualization.png similarity index 100% rename from assets/icons/azure/storage/029203566-icon-service-Data-Virtualization.png rename to cloudexit/assets/icons/azure/storage/029203566-icon-service-Data-Virtualization.png diff --git a/assets/icons/azure/storage/030486388-icon-service-Storage-Hubs.png b/cloudexit/assets/icons/azure/storage/030486388-icon-service-Storage-Hubs.png similarity index 100% rename from assets/icons/azure/storage/030486388-icon-service-Storage-Hubs.png rename to cloudexit/assets/icons/azure/storage/030486388-icon-service-Storage-Hubs.png diff --git a/assets/icons/azure/storage/03502-icon-service-Storage-Actions.png b/cloudexit/assets/icons/azure/storage/03502-icon-service-Storage-Actions.png similarity index 100% rename from assets/icons/azure/storage/03502-icon-service-Storage-Actions.png rename to cloudexit/assets/icons/azure/storage/03502-icon-service-Storage-Actions.png diff --git a/assets/icons/azure/storage/03549-icon-service-Managed-File-Shares.png b/cloudexit/assets/icons/azure/storage/03549-icon-service-Managed-File-Shares.png similarity index 100% rename from assets/icons/azure/storage/03549-icon-service-Managed-File-Shares.png rename to cloudexit/assets/icons/azure/storage/03549-icon-service-Managed-File-Shares.png diff --git a/assets/icons/azure/storage/10086-icon-service-Storage-Accounts.png b/cloudexit/assets/icons/azure/storage/10086-icon-service-Storage-Accounts.png similarity index 100% rename from assets/icons/azure/storage/10086-icon-service-Storage-Accounts.png rename to cloudexit/assets/icons/azure/storage/10086-icon-service-Storage-Accounts.png diff --git a/assets/icons/azure/storage/10087-icon-service-Storage-Accounts-(Classic).png b/cloudexit/assets/icons/azure/storage/10087-icon-service-Storage-Accounts-(Classic).png similarity index 100% rename from assets/icons/azure/storage/10087-icon-service-Storage-Accounts-(Classic).png rename to cloudexit/assets/icons/azure/storage/10087-icon-service-Storage-Accounts-(Classic).png diff --git a/assets/icons/azure/storage/10089-icon-service-StorSimple-Device-Managers.png b/cloudexit/assets/icons/azure/storage/10089-icon-service-StorSimple-Device-Managers.png similarity index 100% rename from assets/icons/azure/storage/10089-icon-service-StorSimple-Device-Managers.png rename to cloudexit/assets/icons/azure/storage/10089-icon-service-StorSimple-Device-Managers.png diff --git a/assets/icons/azure/storage/10090-icon-service-Data-Lake-Storage-Gen1.png b/cloudexit/assets/icons/azure/storage/10090-icon-service-Data-Lake-Storage-Gen1.png similarity index 100% rename from assets/icons/azure/storage/10090-icon-service-Data-Lake-Storage-Gen1.png rename to cloudexit/assets/icons/azure/storage/10090-icon-service-Data-Lake-Storage-Gen1.png diff --git a/assets/icons/azure/storage/10091-icon-service-Storage-Explorer.png b/cloudexit/assets/icons/azure/storage/10091-icon-service-Storage-Explorer.png similarity index 100% rename from assets/icons/azure/storage/10091-icon-service-Storage-Explorer.png rename to cloudexit/assets/icons/azure/storage/10091-icon-service-Storage-Explorer.png diff --git a/assets/icons/azure/storage/10092-icon-service-StorSimple-Data-Managers.png b/cloudexit/assets/icons/azure/storage/10092-icon-service-StorSimple-Data-Managers.png similarity index 100% rename from assets/icons/azure/storage/10092-icon-service-StorSimple-Data-Managers.png rename to cloudexit/assets/icons/azure/storage/10092-icon-service-StorSimple-Data-Managers.png diff --git a/assets/icons/azure/storage/10093-icon-service-Storage-Sync-Services.png b/cloudexit/assets/icons/azure/storage/10093-icon-service-Storage-Sync-Services.png similarity index 100% rename from assets/icons/azure/storage/10093-icon-service-Storage-Sync-Services.png rename to cloudexit/assets/icons/azure/storage/10093-icon-service-Storage-Sync-Services.png diff --git a/assets/icons/azure/storage/10094-icon-service-Data-Box.png b/cloudexit/assets/icons/azure/storage/10094-icon-service-Data-Box.png similarity index 100% rename from assets/icons/azure/storage/10094-icon-service-Data-Box.png rename to cloudexit/assets/icons/azure/storage/10094-icon-service-Data-Box.png diff --git a/assets/icons/azure/storage/10095-icon-service-Azure-Stack-Edge.png b/cloudexit/assets/icons/azure/storage/10095-icon-service-Azure-Stack-Edge.png similarity index 100% rename from assets/icons/azure/storage/10095-icon-service-Azure-Stack-Edge.png rename to cloudexit/assets/icons/azure/storage/10095-icon-service-Azure-Stack-Edge.png diff --git a/assets/icons/azure/storage/10096-icon-service-Azure-NetApp-Files.png b/cloudexit/assets/icons/azure/storage/10096-icon-service-Azure-NetApp-Files.png similarity index 100% rename from assets/icons/azure/storage/10096-icon-service-Azure-NetApp-Files.png rename to cloudexit/assets/icons/azure/storage/10096-icon-service-Azure-NetApp-Files.png diff --git a/assets/icons/azure/storage/10097-icon-service-Data-Share-Invitations.png b/cloudexit/assets/icons/azure/storage/10097-icon-service-Data-Share-Invitations.png similarity index 100% rename from assets/icons/azure/storage/10097-icon-service-Data-Share-Invitations.png rename to cloudexit/assets/icons/azure/storage/10097-icon-service-Data-Share-Invitations.png diff --git a/assets/icons/azure/storage/10098-icon-service-Data-Shares.png b/cloudexit/assets/icons/azure/storage/10098-icon-service-Data-Shares.png similarity index 100% rename from assets/icons/azure/storage/10098-icon-service-Data-Shares.png rename to cloudexit/assets/icons/azure/storage/10098-icon-service-Data-Shares.png diff --git a/assets/icons/azure/storage/10100-icon-service-Import-Export-Jobs.png b/cloudexit/assets/icons/azure/storage/10100-icon-service-Import-Export-Jobs.png similarity index 100% rename from assets/icons/azure/storage/10100-icon-service-Import-Export-Jobs.png rename to cloudexit/assets/icons/azure/storage/10100-icon-service-Import-Export-Jobs.png diff --git a/assets/icons/azure/storage/10400-icon-service-Azure-Fileshares.png b/cloudexit/assets/icons/azure/storage/10400-icon-service-Azure-Fileshares.png similarity index 100% rename from assets/icons/azure/storage/10400-icon-service-Azure-Fileshares.png rename to cloudexit/assets/icons/azure/storage/10400-icon-service-Azure-Fileshares.png diff --git a/assets/icons/azure/web/00046-icon-service-App-Service-Plans.png b/cloudexit/assets/icons/azure/web/00046-icon-service-App-Service-Plans.png similarity index 100% rename from assets/icons/azure/web/00046-icon-service-App-Service-Plans.png rename to cloudexit/assets/icons/azure/web/00046-icon-service-App-Service-Plans.png diff --git a/assets/icons/azure/web/00049-icon-service-App-Service-Certificates.png b/cloudexit/assets/icons/azure/web/00049-icon-service-App-Service-Certificates.png similarity index 100% rename from assets/icons/azure/web/00049-icon-service-App-Service-Certificates.png rename to cloudexit/assets/icons/azure/web/00049-icon-service-App-Service-Certificates.png diff --git a/assets/icons/azure/web/00050-icon-service-App-Service-Domains.png b/cloudexit/assets/icons/azure/web/00050-icon-service-App-Service-Domains.png similarity index 100% rename from assets/icons/azure/web/00050-icon-service-App-Service-Domains.png rename to cloudexit/assets/icons/azure/web/00050-icon-service-App-Service-Domains.png diff --git a/assets/icons/azure/web/01007-icon-service-Static-Apps.png b/cloudexit/assets/icons/azure/web/01007-icon-service-Static-Apps.png similarity index 100% rename from assets/icons/azure/web/01007-icon-service-Static-Apps.png rename to cloudexit/assets/icons/azure/web/01007-icon-service-Static-Apps.png diff --git a/assets/icons/azure/web/03291-icon-service-API-Center.png b/cloudexit/assets/icons/azure/web/03291-icon-service-API-Center.png similarity index 100% rename from assets/icons/azure/web/03291-icon-service-API-Center.png rename to cloudexit/assets/icons/azure/web/03291-icon-service-API-Center.png diff --git a/assets/icons/azure/web/03335-icon-service-Power-Platform.png b/cloudexit/assets/icons/azure/web/03335-icon-service-Power-Platform.png similarity index 100% rename from assets/icons/azure/web/03335-icon-service-Power-Platform.png rename to cloudexit/assets/icons/azure/web/03335-icon-service-Power-Platform.png diff --git a/assets/icons/azure/web/03397-icon-service-App-Space.png b/cloudexit/assets/icons/azure/web/03397-icon-service-App-Space.png similarity index 100% rename from assets/icons/azure/web/03397-icon-service-App-Space.png rename to cloudexit/assets/icons/azure/web/03397-icon-service-App-Space.png diff --git a/assets/icons/azure/web/03595-icon-service-App-Space-Component.png b/cloudexit/assets/icons/azure/web/03595-icon-service-App-Space-Component.png similarity index 100% rename from assets/icons/azure/web/03595-icon-service-App-Space-Component.png rename to cloudexit/assets/icons/azure/web/03595-icon-service-App-Space-Component.png diff --git a/assets/icons/azure/web/10035-icon-service-App-Services.png b/cloudexit/assets/icons/azure/web/10035-icon-service-App-Services.png similarity index 100% rename from assets/icons/azure/web/10035-icon-service-App-Services.png rename to cloudexit/assets/icons/azure/web/10035-icon-service-App-Services.png diff --git a/assets/icons/azure/web/10042-icon-service-API-Management-Services.png b/cloudexit/assets/icons/azure/web/10042-icon-service-API-Management-Services.png similarity index 100% rename from assets/icons/azure/web/10042-icon-service-API-Management-Services.png rename to cloudexit/assets/icons/azure/web/10042-icon-service-API-Management-Services.png diff --git a/assets/icons/azure/web/10044-icon-service-Cognitive-Search.png b/cloudexit/assets/icons/azure/web/10044-icon-service-Cognitive-Search.png similarity index 100% rename from assets/icons/azure/web/10044-icon-service-Cognitive-Search.png rename to cloudexit/assets/icons/azure/web/10044-icon-service-Cognitive-Search.png diff --git a/assets/icons/azure/web/10047-icon-service-App-Service-Environments.png b/cloudexit/assets/icons/azure/web/10047-icon-service-App-Service-Environments.png similarity index 100% rename from assets/icons/azure/web/10047-icon-service-App-Service-Environments.png rename to cloudexit/assets/icons/azure/web/10047-icon-service-App-Service-Environments.png diff --git a/assets/icons/azure/web/10048-icon-service-API-Connections.png b/cloudexit/assets/icons/azure/web/10048-icon-service-API-Connections.png similarity index 100% rename from assets/icons/azure/web/10048-icon-service-API-Connections.png rename to cloudexit/assets/icons/azure/web/10048-icon-service-API-Connections.png diff --git a/assets/icons/azure/web/10052-icon-service-SignalR.png b/cloudexit/assets/icons/azure/web/10052-icon-service-SignalR.png similarity index 100% rename from assets/icons/azure/web/10052-icon-service-SignalR.png rename to cloudexit/assets/icons/azure/web/10052-icon-service-SignalR.png diff --git a/assets/icons/azure/web/10053-icon-service-Notification-Hub-Namespaces.png b/cloudexit/assets/icons/azure/web/10053-icon-service-Notification-Hub-Namespaces.png similarity index 100% rename from assets/icons/azure/web/10053-icon-service-Notification-Hub-Namespaces.png rename to cloudexit/assets/icons/azure/web/10053-icon-service-Notification-Hub-Namespaces.png diff --git a/assets/icons/azure/web/10073-icon-service-Front-Door-and-CDN-Profiles.png b/cloudexit/assets/icons/azure/web/10073-icon-service-Front-Door-and-CDN-Profiles.png similarity index 100% rename from assets/icons/azure/web/10073-icon-service-Front-Door-and-CDN-Profiles.png rename to cloudexit/assets/icons/azure/web/10073-icon-service-Front-Door-and-CDN-Profiles.png diff --git a/assets/icons/azure/web/10162-icon-service-Cognitive-Services.png b/cloudexit/assets/icons/azure/web/10162-icon-service-Cognitive-Services.png similarity index 100% rename from assets/icons/azure/web/10162-icon-service-Cognitive-Services.png rename to cloudexit/assets/icons/azure/web/10162-icon-service-Cognitive-Services.png diff --git a/assets/icons/azure/web/10309-icon-service-Azure-Media-Service.png b/cloudexit/assets/icons/azure/web/10309-icon-service-Azure-Media-Service.png similarity index 100% rename from assets/icons/azure/web/10309-icon-service-Azure-Media-Service.png rename to cloudexit/assets/icons/azure/web/10309-icon-service-Azure-Media-Service.png diff --git a/assets/icons/azure/web/10370-icon-service-Azure-Spring-Apps.png b/cloudexit/assets/icons/azure/web/10370-icon-service-Azure-Spring-Apps.png similarity index 100% rename from assets/icons/azure/web/10370-icon-service-Azure-Spring-Apps.png rename to cloudexit/assets/icons/azure/web/10370-icon-service-Azure-Spring-Apps.png diff --git a/cloudexit/assets/img/csp/alibaba.svg b/cloudexit/assets/img/csp/alibaba.svg new file mode 100644 index 0000000..f352ee9 --- /dev/null +++ b/cloudexit/assets/img/csp/alibaba.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/cloudexit/assets/img/csp/aws.svg b/cloudexit/assets/img/csp/aws.svg new file mode 100644 index 0000000..08b8262 --- /dev/null +++ b/cloudexit/assets/img/csp/aws.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/cloudexit/assets/img/csp/azure.svg b/cloudexit/assets/img/csp/azure.svg new file mode 100644 index 0000000..61cb535 --- /dev/null +++ b/cloudexit/assets/img/csp/azure.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/cloudexit/assets/img/csp/gcp.svg b/cloudexit/assets/img/csp/gcp.svg new file mode 100644 index 0000000..bc3b271 --- /dev/null +++ b/cloudexit/assets/img/csp/gcp.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/cloudexit/assets/img/logo/README.md b/cloudexit/assets/img/logo/README.md new file mode 100644 index 0000000..13ca4ea --- /dev/null +++ b/cloudexit/assets/img/logo/README.md @@ -0,0 +1,3 @@ +## EscapeCloud logo files + +The EscapeCloud logo files in this directory are not covered by this project's LICENSE. All rights reserved. diff --git a/assets/img/logo/favicon.png b/cloudexit/assets/img/logo/favicon.png similarity index 100% rename from assets/img/logo/favicon.png rename to cloudexit/assets/img/logo/favicon.png diff --git a/assets/img/logo/logo.png b/cloudexit/assets/img/logo/logo.png similarity index 100% rename from assets/img/logo/logo.png rename to cloudexit/assets/img/logo/logo.png diff --git a/assets/img/logo/report_logo.png b/cloudexit/assets/img/logo/report_logo.png similarity index 100% rename from assets/img/logo/report_logo.png rename to cloudexit/assets/img/logo/report_logo.png diff --git a/cloudexit/assets/js/driver.js.LICENSE.txt b/cloudexit/assets/js/driver.js.LICENSE.txt new file mode 100644 index 0000000..7ceaeef --- /dev/null +++ b/cloudexit/assets/js/driver.js.LICENSE.txt @@ -0,0 +1,21 @@ +The MIT License + +Copyright (c) Kamran Ahmed + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. \ No newline at end of file diff --git a/cloudexit/assets/js/driver.js.iife.js b/cloudexit/assets/js/driver.js.iife.js new file mode 100644 index 0000000..87acd6e --- /dev/null +++ b/cloudexit/assets/js/driver.js.iife.js @@ -0,0 +1,2 @@ +this.driver=this.driver||{};this.driver.js=function(D){"use strict";let F={};function z(e={}){F={animate:!0,allowClose:!0,overlayOpacity:.7,smoothScroll:!1,disableActiveInteraction:!1,showProgress:!1,stagePadding:10,stageRadius:5,popoverOffset:10,showButtons:["next","previous","close"],disableButtons:[],overlayColor:"#000",...e}}function a(e){return e?F[e]:F}function W(e,o,t,i){return(e/=i/2)<1?t/2*e*e+o:-t/2*(--e*(e-2)-1)+o}function q(e){const o='a[href]:not([disabled]), button:not([disabled]), textarea:not([disabled]), input[type="text"]:not([disabled]), input[type="radio"]:not([disabled]), input[type="checkbox"]:not([disabled]), select:not([disabled])';return e.flatMap(t=>{const i=t.matches(o),p=Array.from(t.querySelectorAll(o));return[...i?[t]:[],...p]}).filter(t=>getComputedStyle(t).pointerEvents!=="none"&&ae(t))}function V(e){if(!e||se(e))return;const o=a("smoothScroll");e.scrollIntoView({behavior:!o||re(e)?"auto":"smooth",inline:"center",block:"center"})}function re(e){if(!e||!e.parentElement)return;const o=e.parentElement;return o.scrollHeight>o.clientHeight}function se(e){const o=e.getBoundingClientRect();return o.top>=0&&o.left>=0&&o.bottom<=(window.innerHeight||document.documentElement.clientHeight)&&o.right<=(window.innerWidth||document.documentElement.clientWidth)}function ae(e){return!!(e.offsetWidth||e.offsetHeight||e.getClientRects().length)}let N={};function b(e,o){N[e]=o}function l(e){return e?N[e]:N}function K(){N={}}let E={};function O(e,o){E[e]=o}function _(e){var o;(o=E[e])==null||o.call(E)}function ce(){E={}}function le(e,o,t,i){let p=l("__activeStagePosition");const n=p||t.getBoundingClientRect(),f=i.getBoundingClientRect(),w=W(e,n.x,f.x-n.x,o),r=W(e,n.y,f.y-n.y,o),v=W(e,n.width,f.width-n.width,o),s=W(e,n.height,f.height-n.height,o);p={x:w,y:r,width:v,height:s},Y(p),b("__activeStagePosition",p)}function X(e){if(!e)return;const o=e.getBoundingClientRect(),t={x:o.x,y:o.y,width:o.width,height:o.height};b("__activeStagePosition",t),Y(t)}function de(){const e=l("__activeStagePosition"),o=l("__overlaySvg");if(!e)return;if(!o){console.warn("No stage svg found.");return}const t=window.innerWidth,i=window.innerHeight;o.setAttribute("viewBox",`0 0 ${t} ${i}`)}function pe(e){const o=ue(e);document.body.appendChild(o),G(o,t=>{t.target.tagName==="path"&&_("overlayClick")}),b("__overlaySvg",o)}function Y(e){const o=l("__overlaySvg");if(!o){pe(e);return}const t=o.firstElementChild;if((t==null?void 0:t.tagName)!=="path")throw new Error("no path element found in stage svg");t.setAttribute("d",j(e))}function ue(e){const o=window.innerWidth,t=window.innerHeight,i=document.createElementNS("http://www.w3.org/2000/svg","svg");i.classList.add("driver-overlay","driver-overlay-animated"),i.setAttribute("viewBox",`0 0 ${o} ${t}`),i.setAttribute("xmlSpace","preserve"),i.setAttribute("xmlnsXlink","http://www.w3.org/1999/xlink"),i.setAttribute("version","1.1"),i.setAttribute("preserveAspectRatio","xMinYMin slice"),i.style.fillRule="evenodd",i.style.clipRule="evenodd",i.style.strokeLinejoin="round",i.style.strokeMiterlimit="2",i.style.zIndex="10000",i.style.position="fixed",i.style.top="0",i.style.left="0",i.style.width="100%",i.style.height="100%";const p=document.createElementNS("http://www.w3.org/2000/svg","path");return p.setAttribute("d",j(e)),p.style.fill=a("overlayColor")||"rgb(0,0,0)",p.style.opacity=`${a("overlayOpacity")}`,p.style.pointerEvents="auto",p.style.cursor="auto",i.appendChild(p),i}function j(e){const o=window.innerWidth,t=window.innerHeight,i=a("stagePadding")||0,p=a("stageRadius")||0,n=e.width+i*2,f=e.height+i*2,w=Math.min(p,n/2,f/2),r=Math.floor(Math.max(w,0)),v=e.x-i+r,s=e.y-i,c=n-r*2,d=f-r*2;return`M${o},0L0,0L0,${t}L${o},${t}L${o},0Z + M${v},${s} h${c} a${r},${r} 0 0 1 ${r},${r} v${d} a${r},${r} 0 0 1 -${r},${r} h-${c} a${r},${r} 0 0 1 -${r},-${r} v-${d} a${r},${r} 0 0 1 ${r},-${r} z`}function ve(){const e=l("__overlaySvg");e&&e.remove()}function fe(){const e=document.getElementById("driver-dummy-element");if(e)return e;let o=document.createElement("div");return o.id="driver-dummy-element",o.style.width="0",o.style.height="0",o.style.pointerEvents="none",o.style.opacity="0",o.style.position="fixed",o.style.top="50%",o.style.left="50%",document.body.appendChild(o),o}function Q(e){const{element:o}=e;let t=typeof o=="string"?document.querySelector(o):o;t||(t=fe()),ge(t,e)}function he(){const e=l("__activeElement"),o=l("__activeStep");e&&(X(e),de(),oe(e,o))}function ge(e,o){const i=Date.now(),p=l("__activeStep"),n=l("__activeElement")||e,f=!n||n===e,w=e.id==="driver-dummy-element",r=n.id==="driver-dummy-element",v=a("animate"),s=o.onHighlightStarted||a("onHighlightStarted"),c=(o==null?void 0:o.onHighlighted)||a("onHighlighted"),d=(p==null?void 0:p.onDeselected)||a("onDeselected"),m=a(),g=l();!f&&d&&d(r?void 0:n,p,{config:m,state:g}),s&&s(w?void 0:e,o,{config:m,state:g});const u=!f&&v;let h=!1;xe(),b("previousStep",p),b("previousElement",n),b("activeStep",o),b("activeElement",e);const P=()=>{if(l("__transitionCallback")!==P)return;const x=Date.now()-i,y=400-x<=400/2;o.popover&&y&&!h&&u&&(J(e,o),h=!0),a("animate")&&x<400?le(x,400,n,e):(X(e),c&&c(w?void 0:e,o,{config:a(),state:l()}),b("__transitionCallback",void 0),b("__previousStep",p),b("__previousElement",n),b("__activeStep",o),b("__activeElement",e)),window.requestAnimationFrame(P)};b("__transitionCallback",P),window.requestAnimationFrame(P),V(e),!u&&o.popover&&J(e,o),n.classList.remove("driver-active-element","driver-no-interaction"),n.removeAttribute("aria-haspopup"),n.removeAttribute("aria-expanded"),n.removeAttribute("aria-controls"),a("disableActiveInteraction")&&e.classList.add("driver-no-interaction"),e.classList.add("driver-active-element"),e.setAttribute("aria-haspopup","dialog"),e.setAttribute("aria-expanded","true"),e.setAttribute("aria-controls","driver-popover-content")}function we(){var e;(e=document.getElementById("driver-dummy-element"))==null||e.remove(),document.querySelectorAll(".driver-active-element").forEach(o=>{o.classList.remove("driver-active-element","driver-no-interaction"),o.removeAttribute("aria-haspopup"),o.removeAttribute("aria-expanded"),o.removeAttribute("aria-controls")})}function A(){const e=l("__resizeTimeout");e&&window.cancelAnimationFrame(e),b("__resizeTimeout",window.requestAnimationFrame(he))}function me(e){var r;if(!l("isInitialized")||!(e.key==="Tab"||e.keyCode===9))return;const i=l("__activeElement"),p=(r=l("popover"))==null?void 0:r.wrapper,n=q([...p?[p]:[],...i?[i]:[]]),f=n[0],w=n[n.length-1];if(e.preventDefault(),e.shiftKey){const v=n[n.indexOf(document.activeElement)-1]||w;v==null||v.focus()}else{const v=n[n.indexOf(document.activeElement)+1]||f;v==null||v.focus()}}function Z(e){var t;((t=a("allowKeyboardControl"))==null||t)&&(e.key==="Escape"?_("escapePress"):e.key==="ArrowRight"?_("arrowRightPress"):e.key==="ArrowLeft"&&_("arrowLeftPress"))}function G(e,o,t){const i=(n,f)=>{const w=n.target;e.contains(w)&&((!t||t(w))&&(n.preventDefault(),n.stopPropagation(),n.stopImmediatePropagation()),f==null||f(n))};document.addEventListener("pointerdown",i,!0),document.addEventListener("mousedown",i,!0),document.addEventListener("pointerup",i,!0),document.addEventListener("mouseup",i,!0),document.addEventListener("click",n=>{i(n,o)},!0)}function ye(){window.addEventListener("keyup",Z,!1),window.addEventListener("keydown",me,!1),window.addEventListener("resize",A),window.addEventListener("scroll",A)}function be(){window.removeEventListener("keyup",Z),window.removeEventListener("resize",A),window.removeEventListener("scroll",A)}function xe(){const e=l("popover");e&&(e.wrapper.style.display="none")}function J(e,o){var C,y;let t=l("popover");t&&document.body.removeChild(t.wrapper),t=Pe(),document.body.appendChild(t.wrapper);const{title:i,description:p,showButtons:n,disableButtons:f,showProgress:w,nextBtnText:r=a("nextBtnText")||"Next →",prevBtnText:v=a("prevBtnText")||"← Previous",progressText:s=a("progressText")||"{current} of {total}"}=o.popover||{};t.nextButton.innerHTML=r,t.previousButton.innerHTML=v,t.progress.innerHTML=s,i?(t.title.innerHTML=i,t.title.style.display="block"):t.title.style.display="none",p?(t.description.innerHTML=p,t.description.style.display="block"):t.description.style.display="none";const c=n||a("showButtons"),d=w||a("showProgress")||!1,m=(c==null?void 0:c.includes("next"))||(c==null?void 0:c.includes("previous"))||d;t.closeButton.style.display=c.includes("close")?"block":"none",m?(t.footer.style.display="flex",t.progress.style.display=d?"block":"none",t.nextButton.style.display=c.includes("next")?"block":"none",t.previousButton.style.display=c.includes("previous")?"block":"none"):t.footer.style.display="none";const g=f||a("disableButtons")||[];g!=null&&g.includes("next")&&(t.nextButton.disabled=!0,t.nextButton.classList.add("driver-popover-btn-disabled")),g!=null&&g.includes("previous")&&(t.previousButton.disabled=!0,t.previousButton.classList.add("driver-popover-btn-disabled")),g!=null&&g.includes("close")&&(t.closeButton.disabled=!0,t.closeButton.classList.add("driver-popover-btn-disabled"));const u=t.wrapper;u.style.display="block",u.style.left="",u.style.top="",u.style.bottom="",u.style.right="",u.id="driver-popover-content",u.setAttribute("role","dialog"),u.setAttribute("aria-labelledby","driver-popover-title"),u.setAttribute("aria-describedby","driver-popover-description");const h=t.arrow;h.className="driver-popover-arrow";const P=((C=o.popover)==null?void 0:C.popoverClass)||a("popoverClass")||"";u.className=`driver-popover ${P}`.trim(),G(t.wrapper,k=>{var M,R,I;const T=k.target,H=((M=o.popover)==null?void 0:M.onNextClick)||a("onNextClick"),$=((R=o.popover)==null?void 0:R.onPrevClick)||a("onPrevClick"),B=((I=o.popover)==null?void 0:I.onCloseClick)||a("onCloseClick");if(T.classList.contains("driver-popover-next-btn"))return H?H(e,o,{config:a(),state:l()}):_("nextClick");if(T.classList.contains("driver-popover-prev-btn"))return $?$(e,o,{config:a(),state:l()}):_("prevClick");if(T.classList.contains("driver-popover-close-btn"))return B?B(e,o,{config:a(),state:l()}):_("closeClick")},k=>!(t!=null&&t.description.contains(k))&&!(t!=null&&t.title.contains(k))&&typeof k.className=="string"&&k.className.includes("driver-popover")),b("popover",t);const S=((y=o.popover)==null?void 0:y.onPopoverRender)||a("onPopoverRender");S&&S(t,{config:a(),state:l()}),oe(e,o),V(u);const L=e.classList.contains("driver-dummy-element"),x=q([u,...L?[]:[e]]);x.length>0&&x[0].focus()}function U(){const e=l("popover");if(!(e!=null&&e.wrapper))return;const o=e.wrapper.getBoundingClientRect(),t=a("stagePadding")||0,i=a("popoverOffset")||0;return{width:o.width+t+i,height:o.height+t+i,realWidth:o.width,realHeight:o.height}}function ee(e,o){const{elementDimensions:t,popoverDimensions:i,popoverPadding:p,popoverArrowDimensions:n}=o;return e==="start"?Math.max(Math.min(t.top-p,window.innerHeight-i.realHeight-n.width),n.width):e==="end"?Math.max(Math.min(t.top-(i==null?void 0:i.realHeight)+t.height+p,window.innerHeight-(i==null?void 0:i.realHeight)-n.width),n.width):e==="center"?Math.max(Math.min(t.top+t.height/2-(i==null?void 0:i.realHeight)/2,window.innerHeight-(i==null?void 0:i.realHeight)-n.width),n.width):0}function te(e,o){const{elementDimensions:t,popoverDimensions:i,popoverPadding:p,popoverArrowDimensions:n}=o;return e==="start"?Math.max(Math.min(t.left-p,window.innerWidth-i.realWidth-n.width),n.width):e==="end"?Math.max(Math.min(t.left-(i==null?void 0:i.realWidth)+t.width+p,window.innerWidth-(i==null?void 0:i.realWidth)-n.width),n.width):e==="center"?Math.max(Math.min(t.left+t.width/2-(i==null?void 0:i.realWidth)/2,window.innerWidth-(i==null?void 0:i.realWidth)-n.width),n.width):0}function oe(e,o){const t=l("popover");if(!t)return;const{align:i="start",side:p="left"}=(o==null?void 0:o.popover)||{},n=i,f=e.id==="driver-dummy-element"?"over":p,w=a("stagePadding")||0,r=U(),v=t.arrow.getBoundingClientRect(),s=e.getBoundingClientRect(),c=s.top-r.height;let d=c>=0;const m=window.innerHeight-(s.bottom+r.height);let g=m>=0;const u=s.left-r.width;let h=u>=0;const P=window.innerWidth-(s.right+r.width);let S=P>=0;const L=!d&&!g&&!h&&!S;let x=f;if(f==="top"&&d?S=h=g=!1:f==="bottom"&&g?S=h=d=!1:f==="left"&&h?S=d=g=!1:f==="right"&&S&&(h=d=g=!1),f==="over"){const C=window.innerWidth/2-r.realWidth/2,y=window.innerHeight/2-r.realHeight/2;t.wrapper.style.left=`${C}px`,t.wrapper.style.right="auto",t.wrapper.style.top=`${y}px`,t.wrapper.style.bottom="auto"}else if(L){const C=window.innerWidth/2-(r==null?void 0:r.realWidth)/2,y=10;t.wrapper.style.left=`${C}px`,t.wrapper.style.right="auto",t.wrapper.style.bottom=`${y}px`,t.wrapper.style.top="auto"}else if(h){const C=Math.min(u,window.innerWidth-(r==null?void 0:r.realWidth)-v.width),y=ee(n,{elementDimensions:s,popoverDimensions:r,popoverPadding:w,popoverArrowDimensions:v});t.wrapper.style.left=`${C}px`,t.wrapper.style.top=`${y}px`,t.wrapper.style.bottom="auto",t.wrapper.style.right="auto",x="left"}else if(S){const C=Math.min(P,window.innerWidth-(r==null?void 0:r.realWidth)-v.width),y=ee(n,{elementDimensions:s,popoverDimensions:r,popoverPadding:w,popoverArrowDimensions:v});t.wrapper.style.right=`${C}px`,t.wrapper.style.top=`${y}px`,t.wrapper.style.bottom="auto",t.wrapper.style.left="auto",x="right"}else if(d){const C=Math.min(c,window.innerHeight-r.realHeight-v.width);let y=te(n,{elementDimensions:s,popoverDimensions:r,popoverPadding:w,popoverArrowDimensions:v});t.wrapper.style.top=`${C}px`,t.wrapper.style.left=`${y}px`,t.wrapper.style.bottom="auto",t.wrapper.style.right="auto",x="top"}else if(g){const C=Math.min(m,window.innerHeight-(r==null?void 0:r.realHeight)-v.width);let y=te(n,{elementDimensions:s,popoverDimensions:r,popoverPadding:w,popoverArrowDimensions:v});t.wrapper.style.left=`${y}px`,t.wrapper.style.bottom=`${C}px`,t.wrapper.style.top="auto",t.wrapper.style.right="auto",x="bottom"}L?t.arrow.classList.add("driver-popover-arrow-none"):Ce(n,x,e)}function Ce(e,o,t){const i=l("popover");if(!i)return;const p=t.getBoundingClientRect(),n=U(),f=i.arrow,w=n.width,r=window.innerWidth,v=p.width,s=p.left,c=n.height,d=window.innerHeight,m=p.top,g=p.height;f.className="driver-popover-arrow";let u=o,h=e;o==="top"?(s+v<=0?(u="right",h="end"):s+v-w<=0&&(u="top",h="start"),s>=r?(u="left",h="end"):s+w>=r&&(u="top",h="end")):o==="bottom"?(s+v<=0?(u="right",h="start"):s+v-w<=0&&(u="bottom",h="start"),s>=r?(u="left",h="start"):s+w>=r&&(u="bottom",h="end")):o==="left"?(m+g<=0?(u="bottom",h="end"):m+g-c<=0&&(u="left",h="start"),m>=d?(u="top",h="end"):m+c>=d&&(u="left",h="end")):o==="right"&&(m+g<=0?(u="bottom",h="start"):m+g-c<=0&&(u="right",h="start"),m>=d?(u="top",h="start"):m+c>=d&&(u="right",h="end")),u?(f.classList.add(`driver-popover-arrow-side-${u}`),f.classList.add(`driver-popover-arrow-align-${h}`)):f.classList.add("driver-popover-arrow-none")}function Pe(){const e=document.createElement("div");e.classList.add("driver-popover");const o=document.createElement("div");o.classList.add("driver-popover-arrow");const t=document.createElement("header");t.id="driver-popover-title",t.classList.add("driver-popover-title"),t.style.display="none",t.innerText="Popover Title";const i=document.createElement("div");i.id="driver-popover-description",i.classList.add("driver-popover-description"),i.style.display="none",i.innerText="Popover description is here";const p=document.createElement("button");p.type="button",p.classList.add("driver-popover-close-btn"),p.setAttribute("aria-label","Close"),p.innerHTML="×";const n=document.createElement("footer");n.classList.add("driver-popover-footer");const f=document.createElement("span");f.classList.add("driver-popover-progress-text"),f.innerText="";const w=document.createElement("span");w.classList.add("driver-popover-navigation-btns");const r=document.createElement("button");r.type="button",r.classList.add("driver-popover-prev-btn"),r.innerHTML="← Previous";const v=document.createElement("button");return v.type="button",v.classList.add("driver-popover-next-btn"),v.innerHTML="Next →",w.appendChild(r),w.appendChild(v),n.appendChild(f),n.appendChild(w),e.appendChild(p),e.appendChild(o),e.appendChild(t),e.appendChild(i),e.appendChild(n),{wrapper:e,arrow:o,title:t,description:i,footer:n,previousButton:r,nextButton:v,closeButton:p,footerButtons:w,progress:f}}function Se(){var o;const e=l("popover");e&&((o=e.wrapper.parentElement)==null||o.removeChild(e.wrapper))}const Le="";function ke(e={}){z(e);function o(){a("allowClose")&&v()}function t(){const s=l("activeIndex"),c=a("steps")||[];if(typeof s=="undefined")return;const d=s+1;c[d]?r(d):v()}function i(){const s=l("activeIndex"),c=a("steps")||[];if(typeof s=="undefined")return;const d=s-1;c[d]?r(d):v()}function p(s){(a("steps")||[])[s]?r(s):v()}function n(){var h;if(l("__transitionCallback"))return;const c=l("activeIndex"),d=l("__activeStep"),m=l("__activeElement");if(typeof c=="undefined"||typeof d=="undefined"||typeof l("activeIndex")=="undefined")return;const u=((h=d.popover)==null?void 0:h.onPrevClick)||a("onPrevClick");if(u)return u(m,d,{config:a(),state:l()});i()}function f(){var u;if(l("__transitionCallback"))return;const c=l("activeIndex"),d=l("__activeStep"),m=l("__activeElement");if(typeof c=="undefined"||typeof d=="undefined")return;const g=((u=d.popover)==null?void 0:u.onNextClick)||a("onNextClick");if(g)return g(m,d,{config:a(),state:l()});t()}function w(){l("isInitialized")||(b("isInitialized",!0),document.body.classList.add("driver-active",a("animate")?"driver-fade":"driver-simple"),ye(),O("overlayClick",o),O("escapePress",o),O("arrowLeftPress",n),O("arrowRightPress",f))}function r(s=0){var H,$,B,M,R,I,ie,ne;const c=a("steps");if(!c){console.error("No steps to drive through"),v();return}if(!c[s]){v();return}b("__activeOnDestroyed",document.activeElement),b("activeIndex",s);const d=c[s],m=c[s+1],g=c[s-1],u=((H=d.popover)==null?void 0:H.doneBtnText)||a("doneBtnText")||"Done",h=a("allowClose"),P=typeof(($=d.popover)==null?void 0:$.showProgress)!="undefined"?(B=d.popover)==null?void 0:B.showProgress:a("showProgress"),L=(((M=d.popover)==null?void 0:M.progressText)||a("progressText")||"{{current}} of {{total}}").replace("{{current}}",`${s+1}`).replace("{{total}}",`${c.length}`),x=((R=d.popover)==null?void 0:R.showButtons)||a("showButtons"),C=["next","previous",...h?["close"]:[]].filter(_e=>!(x!=null&&x.length)||x.includes(_e)),y=((I=d.popover)==null?void 0:I.onNextClick)||a("onNextClick"),k=((ie=d.popover)==null?void 0:ie.onPrevClick)||a("onPrevClick"),T=((ne=d.popover)==null?void 0:ne.onCloseClick)||a("onCloseClick");Q({...d,popover:{showButtons:C,nextBtnText:m?void 0:u,disableButtons:[...g?[]:["previous"]],showProgress:P,progressText:L,onNextClick:y||(()=>{m?r(s+1):v()}),onPrevClick:k||(()=>{r(s-1)}),onCloseClick:T||(()=>{v()}),...(d==null?void 0:d.popover)||{}}})}function v(s=!0){const c=l("__activeElement"),d=l("__activeStep"),m=l("__activeOnDestroyed"),g=a("onDestroyStarted");if(s&&g){const P=!c||(c==null?void 0:c.id)==="driver-dummy-element";g(P?void 0:c,d,{config:a(),state:l()});return}const u=(d==null?void 0:d.onDeselected)||a("onDeselected"),h=a("onDestroyed");if(document.body.classList.remove("driver-active","driver-fade","driver-simple"),be(),Se(),we(),ve(),ce(),K(),c&&d){const P=c.id==="driver-dummy-element";u&&u(P?void 0:c,d,{config:a(),state:l()}),h&&h(P?void 0:c,d,{config:a(),state:l()})}m&&m.focus()}return{isActive:()=>l("isInitialized")||!1,refresh:A,drive:(s=0)=>{w(),r(s)},setConfig:z,setSteps:s=>{K(),z({...a(),steps:s})},getConfig:a,getState:l,getActiveIndex:()=>l("activeIndex"),isFirstStep:()=>l("activeIndex")===0,isLastStep:()=>{const s=a("steps")||[],c=l("activeIndex");return c!==void 0&&c===s.length-1},getActiveStep:()=>l("activeStep"),getActiveElement:()=>l("activeElement"),getPreviousElement:()=>l("previousElement"),getPreviousStep:()=>l("previousStep"),moveNext:t,movePrevious:i,moveTo:p,hasNextStep:()=>{const s=a("steps")||[],c=l("activeIndex");return c!==void 0&&s[c+1]},hasPreviousStep:()=>{const s=a("steps")||[],c=l("activeIndex");return c!==void 0&&s[c-1]},highlight:s=>{w(),Q({...s,popover:s.popover?{showButtons:[],showProgress:!1,progressText:"",...s.popover}:void 0})},destroy:()=>{v(!1)}}}return D.driver=ke,Object.defineProperty(D,Symbol.toStringTag,{value:"Module"}),D}({}); diff --git a/cloudexit/assets/template/index.html b/cloudexit/assets/template/index.html new file mode 100644 index 0000000..00c0873 --- /dev/null +++ b/cloudexit/assets/template/index.html @@ -0,0 +1,573 @@ +{%- macro parts(items) -%} +{%- for part in items -%} +{%- if part is string -%}{{ part }} +{%- elif part[1].startswith('#') -%}{{ part[0] }} +{%- else -%}{{ part[0] }} +{%- endif -%} +{%- endfor -%} +{%- endmacro -%} +{%- macro icon(src, initials) -%} +{%- if src -%}{%- else -%}{%- endif -%} +{%- endmacro -%} +{%- macro pill(id, s) -%} +{%- if s.platform_url -%} +{{ s.open_platform }} +{%- else -%} + + + +{%- endif -%} +{%- endmacro -%} +{%- macro mark(on, yes, no) -%} +{{ yes if on else no }} +{%- endmacro -%} + + + + + +{{ page.title }} + + + + + + + +
+
+ EscapeCloud{{ page.header.tag }} + +
+
+ +
+
+
+
+ {% if page.titlebar.logo %}{% endif %} +

{{ page.titlebar.name }}

+ {{ page.titlebar.chip }} +
+
+
+ {% if page.titlebar.platform_url %}{{ page.open_platform }}{% endif %} + +
+
+ + + +
+
+

{{ page.nav[0].label }}

+
+
+ {{ page.overview.scope.label }} +
+ {% for row in page.overview.scope.rows %}
{{ row.label }}
{{ row.value }}{% if row.small %}{{ row.small }}{% endif %}
+ {% endfor %} +
+
+ +
+ {% set r = page.overview.risks %} + {{ r.label }} +
{{ r.total }}
+ {% if r.none %}
{{ r.none }}
{% endif %} + +
+ +
+ {% set c = page.overview.costs %} + {{ c.label }} + {% if c.kind == 'message' %} +
+
{{ c.figure }}
+

{{ c.message }}

+ {% if c.hint %}

{{ c.hint }}

{% endif %} +
+ {% else %} +
+
{{ c.figure }}
{{ c.caption }}
+
{{ c.change }}
{% if c.change_caption %}
{{ c.change_caption }}
{% endif %}
+
+ +

{{ c.footnote }}

+ {% endif %} +
+
+
+ +
+

{{ page.nav[1].label }}

+ {% set s = page.scores %} +
+
+

{{ s.grade.title }}

+ {{ pill('hint-grade', s) }} +
+
+
{{ s.grade.placeholder }}
{{ s.grade.scale }}
+
+ +

{{ s.grade.text }}

+
+
+
+
+

{{ s.lock.title }}

+ {{ pill('hint-lock', s) }} +
+
+ +

{{ s.lock.text }}

+
+
+
+
+ +
+ {% set r = page.risks %} +
+

{{ r.heading }}

{{ r.caption }}

+
+ {% if r.empty %} +
+
{{ r.empty.title }}{{ r.empty.text }}
+
+ {% else %} +
+ + + + {% for group in r.groups %} + + {% for row in group.rows %} + {% if row.affected is none %}{% else %}{% endif %} + + {% endfor %} + {% endfor %} + +
{{ r.headers[0] }}{{ r.headers[1] }}{{ r.headers[2] }}{{ r.headers[3] }}
{{ group.heading }}
{{ row.code }}{{ row.label }}{{ r.scope_wide }}{{ row.affected }}
{{ row.description }}
+

{{ parts(r.footnote) }}

+
+ {% endif %} +
+ +
+ {% set inv = page.inventory %} +
+

{{ inv.heading }}

{{ inv.caption }}

+
+ {% if inv.empty %} +
+
{{ inv.empty.title }}{{ inv.empty.text }}
+
+ {% else %} +
+ + + + {% for row in inv.rows %} + {% endfor %} + + +
{{ inv.headers[0] }}{{ inv.headers[1] }}{{ inv.headers[2] }}{{ inv.headers[3] }}
{{ icon(row.icon, row.initials) }}{{ row.name }}
{{ row.locations }}{{ row.count }}
{{ inv.total[0] }}{{ inv.total[1] }}
+

{{ inv.footnote }}

+
+ {% endif %} +
+ +
+ {% set alt = page.alternatives %} +
+

{{ alt.heading }}

{{ alt.caption }}

+ {% if alt.total %} +
+ + {% for key, label in alt.filters %} + {% endfor %} +
+ {% endif %} +
+ {% if alt.empty %} +
+
{{ alt.empty.title }}{{ alt.empty.text }}
+
+ {% else %} +
+ {% for group in alt.groups %} +
+ {{ icon(group.icon, group.initials) }}{{ group.name }}{{ group.meta }} + {% if group.rows %} +
+ + + {% for row in group.rows %}{{ mark(row.os, alt.yes, alt.no) }}{{ mark(row.sp, alt.yes, alt.no) }} + {% endfor %} + +
{{ alt.headers[0] }}{{ alt.headers[1] }}{{ alt.headers[2] }}
{{ row.name }}{{ row.vendor }}{% if row.cc %} {{ row.cc }}{% endif %}
+ {% if group.more %}{% endif %} + {% endif %} +
+ {% endfor %} + +

{{ alt.footnote }}

+
+ {% endif %} +
+ + {% if page.egress %} + {% set e = page.egress %} +
+
+

{{ e.heading }}

{{ e.caption }}

+
+
+
+ {% for kpi in e.kpis %}
{{ kpi.label }}
{{ kpi.figure }}{% if kpi.unit %} {{ kpi.unit }}{% endif %}
{{ kpi.caption }}
{% if kpi.bar is defined %}{% endif %}
+ {% endfor %} +
+ + {% if e.empty %} +

{{ e.empty }}

+ {% else %} + {% if e.volume %} + +
+ {% for item in e.volume.legend %}{{ item.text }} + {% endfor %} + {% if e.volume.unsized %}{{ e.volume.unsized }}{% endif %} +
+ {% endif %} + +
+ + + + {% for row in e.table.rows %} + {% if row.size is none %} + {% else %} + {% endif %} + {% endfor %} + + +
{{ e.table.headers[0] }}{{ e.table.headers[1] }}{{ e.table.headers[2] }}{{ e.table.headers[3] }}
{{ icon(row.icon, row.initials) }}{{ row.label }}
{{ e.table.not_sized }}{{ page.dash }}{{ page.dash }}
{{ icon(row.icon, row.initials) }}{{ row.label }}
{{ row.size }}{{ row.fee }}
{{ e.table.total[0] }}{{ e.table.total[1] }}{{ e.table.total[2] }}
+
+ {% endif %} + +
+ {% for note in e.notes %}

{{ note }}

+ {% endfor %} +
+ + {% if e.sized.facts %} +
+ {{ e.sized.summary }} +
+ + + + {% for name, note in e.sized.facts.rows %} + {% endfor %} + +
{{ e.sized.facts.headers[0] }}{{ e.sized.facts.headers[1] }}
{{ name }}{{ note }}
+
+
+ {% endif %} + {% if e.prices %} +
+ {{ e.prices.summary }} +
+ + {% for header in e.prices.facts.headers %}{{ header }}{% endfor %} + + {% for row in e.prices.facts.rows %}{% for cell in row %}{{ cell }}{% endfor %} + {% endfor %} + +
+ {% if e.prices.facts.units %}

{{ e.prices.facts.units }}

{% endif %} + {% for template, url in e.prices.facts.sources %}{% set pieces = template.split('{link}') %}

{{ pieces[0] }}{{ url }}{{ pieces[1] }}

+ {% endfor %} + {% if e.prices.facts.no_archive %}

{{ e.prices.facts.no_archive }}

{% endif %} +

{{ e.prices.facts.note }}

+
+
+ {% endif %} +
+
+ {% endif %} + +
+ {% set a = page.about %} +
+

{{ a.heading }}

{{ a.caption }}

+
+
+
+
+

{{ a.document.heading }}

+ + {% for label, value, mono in a.document.rows %}{{ value }} + {% endfor %} +
{{ label }}
+
+
+

{{ a.scope.heading }}

+ + {% for label, value, mono in a.scope.rows %}{{ value }} + {% endfor %} +
{{ label }}
+
+
+

{{ a.provenance.heading }}

+ + {% if a.provenance.run_id %}{% endif %} + + +
{{ a.provenance.run_id[0] }}{{ a.provenance.run_id[1] }}
{{ a.provenance.catalogue[0] }}{{ a.provenance.catalogue[1] }}{% if a.provenance.catalogue[2] %} · {{ a.provenance.catalogue[2] }}{% endif %}
{{ a.provenance.platform[0] }}{{ parts(a.provenance.platform[1]) }}
+
+
+

{{ a.stored }}

+ +
+ {{ a.purpose.summary }}{{ a.purpose.meta }} +
+ {% for paragraph in a.purpose.paragraphs %}

{{ paragraph }}

+ {% endfor %} +
    + {% for name, text in a.purpose.phases %}
  • {{ name }} {{ text }}
  • + {% endfor %} +
+

{{ a.purpose.closing }}

+
+
+
+ {{ a.limitations.summary }} +
+ {% for paragraph in a.limitations.paragraphs %}

{{ paragraph }}

+ {% endfor %} +
+
+
+ {{ a.severity.summary }}{{ a.severity.meta }} +
+ + + + {% for level, label, meaning in a.severity.rows %} + {% endfor %} + +
{{ a.severity.headers[0] }}{{ a.severity.headers[1] }}
{{ label }}{{ meaning }}
+

{{ a.severity.note }}

+
+
+
+ {{ a.glossary.summary }}{{ a.glossary.meta }} +
+ + {% for term, definition in a.glossary.entries %} + {% endfor %} +
{{ term }}{{ definition }}
+
+
+
+
+ + {% set n = page.notices %} + +
+ {% for block in n.blocks %}
+

{{ block.heading }}

+

{{ parts(block.parts) }}

+
+ {% endfor %} + +
+
+
+ + + + + + + + diff --git a/cloudexit/catalogue/__init__.py b/cloudexit/catalogue/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cloudexit/catalogue/db.py b/cloudexit/catalogue/db.py new file mode 100644 index 0000000..4ce4ceb --- /dev/null +++ b/cloudexit/catalogue/db.py @@ -0,0 +1,100 @@ +# cloudexit/catalogue/db.py +import sqlite3 +import logging + +from cloudexit.catalogue.store import active_catalogue_path + +# Configure logger for database operations (level left to the root handlers) +logger = logging.getLogger(__name__) + +ALLOWED_TABLES = { + "resourcetype", + "resourcetype_data", + "resource_inventory", + "egress_inventory", + "egress_inventory_tier", + "cost_inventory", + "risk_inventory", + "scoring_data", + "alternative", + "alternativetechnology", + "alternativetechnologyorganization", + "risk", + "egresspricing", +} + + +class _Connection(sqlite3.Connection): + # `with connect(...)` commits or rolls back like any connection, and also + # closes it: an open handle keeps Windows from removing the report folder. + def __exit__(self, *exc): + try: + return super().__exit__(*exc) + finally: + self.close() + + +def connect(db_path=None): + # No path means the catalogue dataset this run uses + db_path = db_path or active_catalogue_path() + try: + conn = sqlite3.connect(db_path, factory=_Connection) + # Nothing the file's own SQL defines is trusted to run + conn.execute("PRAGMA trusted_schema=OFF") + return conn + except sqlite3.Error as e: + logger.debug(f"Error connecting to database: {e}") + raise + + +def load_data(table_name, db_path=None): + if table_name not in ALLOWED_TABLES: + raise ValueError(f"Disallowed table name: {table_name}") + try: + with connect(db_path) as conn: + cursor = conn.cursor() + cursor.execute(f"SELECT * FROM {table_name}") + columns = [desc[0] for desc in cursor.description] + rows = cursor.fetchall() + return [dict(zip(columns, row)) for row in rows] + except sqlite3.Error as e: + logger.debug(f"Error loading data from table '{table_name}': {e}") + raise + + +def execute_query(query, params=None, db_path=None): + try: + with connect(db_path) as conn: + cursor = conn.cursor() + cursor.execute(query, params or ()) + rowcount = cursor.rowcount + return rowcount + except sqlite3.Error as e: + logger.debug(f"Error executing query: {e}") + raise + + +def fetch_one(query, params=None, db_path=None): + try: + with connect(db_path) as conn: + cursor = conn.cursor() + cursor.execute(query, params or ()) + row = cursor.fetchone() + columns = [desc[0] for desc in cursor.description] + return dict(zip(columns, row)) if row else None + except sqlite3.Error as e: + logger.debug(f"Error fetching data: {e}") + raise + + +def fetch_all(query, params=None, db_path=None): + try: + with connect(db_path) as conn: + cursor = conn.cursor() + cursor.execute(query, params or ()) + rows = cursor.fetchall() + columns = [desc[0] for desc in cursor.description] + return [dict(zip(columns, row)) for row in rows] + except sqlite3.Error as e: + logger.debug(f"Error fetching data: {e}") + raise diff --git a/cloudexit/catalogue/download.py b/cloudexit/catalogue/download.py new file mode 100644 index 0000000..9238023 --- /dev/null +++ b/cloudexit/catalogue/download.py @@ -0,0 +1,284 @@ +# cloudexit/catalogue/download.py +import logging +from datetime import datetime, timezone +from pathlib import Path +from typing import Any +from urllib.parse import unquote, urljoin, urlsplit + +import requests + +from cloudexit.catalogue.store import ( + SUPPORTED_SCHEMA, + CatalogueError, + CatalogueInfo, + CatalogueUnavailable, + EnsureResult, + UpdateResult, + catalogue_dir, + check_checksum, + install_dataset, + installed_catalogue, +) + +logger = logging.getLogger(__name__) + + +MANIFEST_NAME = "cloudexit-manifest.json" + + +LATEST_NAME = "cloudexit-latest.db.gz" + + +MANIFEST_TIMEOUT = 10 + + +DOWNLOAD_TIMEOUT = 30 + + +MISSING_STATUSES = (403, 404, 410) + + +class _AnonymousSession(requests.Session): + # Catalogue requests carry no credentials. Requests would otherwise attach a + # ~/.netrc login for the host, and for the host of a redirect. + def __init__(self) -> None: + super().__init__() + self.auth = lambda request: request + + def rebuild_auth(self, prepared_request: Any, response: Any) -> None: + prepared_request.headers.pop("Authorization", None) + + +def _get(url: str, *, timeout: float, verify: str | bool) -> requests.Response: + try: + with _AnonymousSession() as session: + return session.get(url, timeout=timeout, verify=verify) + except requests.RequestException as exc: + logger.debug("Catalogue request to %s failed: %s", url, exc) + reason = str(exc) if len(str(exc)) <= 80 else type(exc).__name__ + raise CatalogueError( + f"cannot reach the catalogue server at {urlsplit(url).netloc} ({reason})" + ) from exc + + +def _download(url: str, *, verify: str | bool) -> bytes: + response = _get(url, timeout=DOWNLOAD_TIMEOUT, verify=verify) + if not response.ok: + raise CatalogueError( + f"download of {url} failed with HTTP {response.status_code}" + ) + return response.content + + +def _check_manifest(manifest: Any) -> dict[str, Any]: + if not isinstance(manifest, dict): + raise CatalogueError("the manifest is not a JSON object") + schema = manifest.get("dataset_schema") + if isinstance(schema, int) and schema > SUPPORTED_SCHEMA: + raise CatalogueError( + f"the catalogue uses dataset schema {schema}; this cloudexit supports " + f"up to {SUPPORTED_SCHEMA}. Update cloudexit." + ) + latest = manifest.get("latest") + if not isinstance(latest, dict) or not all( + isinstance(latest.get(key), str) + for key in ("catalogue_version", "file", "file_sha256") + ): + raise CatalogueError("the manifest has no usable 'latest' entry") + return manifest + + +def fetch_manifest(manifest_url: str, *, verify: str | bool = True) -> dict | None: + response = _get(manifest_url, timeout=MANIFEST_TIMEOUT, verify=verify) + if response.status_code in MISSING_STATUSES: + return None + if not response.ok: + raise CatalogueError( + f"manifest request failed with HTTP {response.status_code}" + ) + try: + return _check_manifest(response.json()) + except ValueError as exc: + raise CatalogueError("the manifest is not valid JSON") from exc + + +def resolve_file_url(manifest_url: str, file: str) -> str: + url = urljoin(manifest_url, file) + base, target = urlsplit(manifest_url), urlsplit(url) + folder = base.path.rsplit("/", 1)[0] + "/" + # Percent-encoded and backslash forms count as the same climb + parts = unquote(target.path).replace("\\", "/").split("/") + if ( + (target.scheme, target.netloc) != (base.scheme, base.netloc) + or not target.path.startswith(folder) + or ".." in parts + ): + raise CatalogueError( + f"the manifest file {file!r} points outside the catalogue location" + ) + return url + + +def _select_entry(manifest: dict[str, Any], version: str | None) -> dict[str, Any]: + if version is None: + return manifest["latest"] + entries = [manifest["latest"], *manifest.get("previous", [])] + for entry in entries: + if isinstance(entry, dict) and entry.get("catalogue_version") == version: + return entry + available = sorted( + str(entry["catalogue_version"]) + for entry in entries + if isinstance(entry, dict) and "catalogue_version" in entry + ) + raise CatalogueError( + f"version {version} is not in the manifest; available: {', '.join(available)}" + ) + + +def _update_from_latest( + base_url: str, directory: Path, *, force: bool, verify: str | bool +) -> UpdateResult: + base = base_url.rstrip("/") + response = _get( + f"{base}/{LATEST_NAME}.sha256", timeout=MANIFEST_TIMEOUT, verify=verify + ) + if response.status_code in MISSING_STATUSES: + raise CatalogueError(f"no catalogue manifest or latest file found at {base}") + if not response.ok or not response.text.strip(): + raise CatalogueError( + f"checksum request failed with HTTP {response.status_code}" + ) + expected = response.text.split()[0].lower() + + installed = installed_catalogue(directory) + if not force and installed and installed.state.get("sha256") == expected: + return UpdateResult("current", installed.info) + + data = _download(f"{base}/{LATEST_NAME}", verify=verify) + digest = check_checksum(data, expected, LATEST_NAME) + return UpdateResult("updated", install_dataset(data, directory, sha256=digest)) + + +def update_catalogue( + base_url: str, + *, + directory: Path | None = None, + version: str | None = None, + force: bool = False, + verify: str | bool = True, +) -> UpdateResult: + directory = directory or catalogue_dir() + manifest_url = f"{base_url.rstrip('/')}/{MANIFEST_NAME}" + manifest = fetch_manifest(manifest_url, verify=verify) + + if manifest is None: + if version is not None: + raise CatalogueError( + "no manifest is published here, so a specific version cannot be chosen" + ) + return _update_from_latest(base_url, directory, force=force, verify=verify) + + entry = _select_entry(manifest, version) + target = entry["catalogue_version"] + installed = installed_catalogue(directory) + if not force and installed and installed.version == target: + return UpdateResult("current", installed.info) + + data = _download(resolve_file_url(manifest_url, entry["file"]), verify=verify) + digest = check_checksum(data, entry["file_sha256"], entry["file"]) + info = install_dataset( + data, + directory, + sha256=digest, + expect_version=target, + require_meta=True, + unpack_size=entry.get("size_bytes"), + ) + return UpdateResult("updated", info) + + +def ensure_catalogue( + base_url: str, *, directory: Path | None = None, verify: str | bool = True +) -> EnsureResult: + directory = directory or catalogue_dir() + try: + result = update_catalogue(base_url, directory=directory, verify=verify) + except (CatalogueError, OSError) as exc: + installed = installed_catalogue(directory) + if installed is None: + raise CatalogueUnavailable( + f"no catalogue is installed and the update failed: {exc}" + ) from exc + return EnsureResult( + installed.path, + "failed", + installed.version, + installed.date, + str(exc), + ) + + installed = installed_catalogue(directory) + return EnsureResult( + installed.path, + result.status, + installed.version, + installed.date, + None, + ) + + +def _age(date_text: str) -> str: + try: + days = ( + datetime.now(timezone.utc).date() + - datetime.strptime(date_text[:10], "%Y-%m-%d").date() + ).days + except ValueError: + return "age unknown" + if days <= 0: + return "today" + return "1 day ago" if days == 1 else f"{days} days ago" + + +def _describe(info: CatalogueInfo | None) -> str: + if info is None: + return "unknown (the dataset has no catalogue_meta)" + if info.sequence is None: + return info.version + return f"{info.version} (sequence {info.sequence})" + + +def status_lines( + base_url: str, *, directory: Path | None = None, verify: str | bool = True +) -> list[str]: + directory = directory or catalogue_dir() + installed = installed_catalogue(directory) + if installed is None: + lines = [f"installed: none ({directory})"] + else: + lines = [ + f"installed: {_describe(installed.info)}", + f"updated: {installed.date} ({_age(installed.date)})", + f"location: {installed.path}", + ] + + try: + manifest = fetch_manifest( + f"{base_url.rstrip('/')}/{MANIFEST_NAME}", verify=verify + ) + except CatalogueError as exc: + lines.append(f"latest: unavailable ({exc})") + return lines + + if manifest is None: + lines.append("latest: unknown (no manifest is published)") + return lines + + latest = manifest["latest"] + sequence = latest.get("catalogue_sequence") + suffix = f" (sequence {sequence})" if sequence is not None else "" + lines.append(f"latest: {latest['catalogue_version']}{suffix}") + if installed is None or installed.version != latest["catalogue_version"]: + lines.append("an update is available: run `cloudexit catalogue update`") + return lines diff --git a/cloudexit/catalogue/store.py b/cloudexit/catalogue/store.py new file mode 100644 index 0000000..b259144 --- /dev/null +++ b/cloudexit/catalogue/store.py @@ -0,0 +1,431 @@ +from __future__ import annotations + +import atexit +import hashlib +import json +import os +import re +import shutil +import sqlite3 +import sys +import tempfile +import zlib +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Mapping + + +from cloudexit.settings import Settings, SettingsError, normalize_host + +CATALOGUE_BASE_URL = "https://cloudexit-oss-data-eu.fsn1.your-objectstorage.com" +SUPPORTED_SCHEMA = 1 +REQUIRED_TABLES = ("resourcetype", "risk") +PROVIDER_IDS = {"azure": 1, "aws": 2} +GZIP_MAGIC = b"\x1f\x8b" +# A dataset unpacks to a few times its download size. The limit is that ratio +# of the download (or of the size the manifest announces), never below the +# floor and never above the ceiling. +UNPACK_RATIO = 10 +UNPACK_FLOOR = 16 * 1024 * 1024 +UNPACK_CEILING = 512 * 1024 * 1024 + + +class CatalogueError(Exception): + pass + + +class CatalogueUnavailable(CatalogueError): + pass + + +@dataclass(frozen=True) +class CatalogueInfo: + version: str + sequence: int | None = None + sha256: str | None = None + dataset_schema: int | None = None + exported_at: str | None = None + exporter_version: str | None = None + + +@dataclass(frozen=True) +class Installed: + path: Path + info: CatalogueInfo | None + state: dict[str, Any] + + @property + def version(self) -> str | None: + return self.info.version if self.info else None + + @property + def date(self) -> str: + raw = self.state.get("downloaded_at") or ( + self.info.exported_at if self.info else None + ) + if raw: + return str(raw)[:10] + modified = datetime.fromtimestamp(self.path.stat().st_mtime, timezone.utc) + return modified.strftime("%Y-%m-%d") + + +@dataclass(frozen=True) +class UpdateResult: + status: str + info: CatalogueInfo | None + + +@dataclass(frozen=True) +class EnsureResult: + path: Path + status: str + version: str | None + date: str + reason: str | None = None + + +def catalogue_dir( + environ: Mapping[str, str] | None = None, *, platform: str = sys.platform +) -> Path: + environ = os.environ if environ is None else environ + if platform == "win32": + base = environ.get("LOCALAPPDATA") or str(Path.home() / "AppData" / "Local") + else: + xdg = environ.get("XDG_DATA_HOME", "") + base = ( + xdg if xdg and os.path.isabs(xdg) else str(Path.home() / ".local" / "share") + ) + return Path(base) / "cloudexit" / "catalogue" + + +_active_override: Path | None = None + + +def set_active_catalogue(path: str | Path | None) -> None: + global _active_override + _active_override = Path(path) if path else None + + +def active_catalogue_path() -> Path: + return _active_override or catalogue_dir() / "data.db" + + +def catalogue_base_url(settings: Settings) -> str: + value = settings.catalogue_url.value + if not value: + return CATALOGUE_BASE_URL + try: + return normalize_host(value) + except SettingsError as exc: + raise SettingsError( + f"catalogue URL ({settings.catalogue_url.source}): {exc}" + ) from exc + + +def _open_readonly(path: Path) -> sqlite3.Connection: + conn = sqlite3.connect(f"{Path(path).resolve().as_uri()}?mode=ro", uri=True) + # Nothing the file's own SQL defines is trusted to run + conn.execute("PRAGMA trusted_schema=OFF") + return conn + + +def _table_names(conn: sqlite3.Connection) -> set[str]: + rows = conn.execute("SELECT name FROM sqlite_master WHERE type = 'table'") + return {row[0] for row in rows} + + +def _check_schema_objects(conn: sqlite3.Connection) -> None: + # A catalogue holds tables and indexes. A trigger or a view runs SQL of its + # own on the data a run adds to the same database, so none is accepted. + found = conn.execute( + "SELECT type, name FROM sqlite_master " + "WHERE type NOT IN ('table', 'index') ORDER BY type, name" + ).fetchall() + if found: + names = ", ".join(f"{kind} {name}" for kind, name in found[:3]) + more = f" and {len(found) - 3} more" if len(found) > 3 else "" + raise CatalogueError( + f"the catalogue holds {names}{more}, which a catalogue does not have; " + "it was not used" + ) + + +def validate_schema(path: str | Path) -> None: + # For a database that is about to be written to, or that has been + try: + conn = _open_readonly(Path(path)) + try: + _check_schema_objects(conn) + finally: + conn.close() + except sqlite3.DatabaseError as exc: + raise CatalogueError(f"not a valid catalogue database: {exc}") from exc + + +def _read_meta(conn: sqlite3.Connection, tables: set[str]) -> CatalogueInfo | None: + if "catalogue_meta" not in tables: + return None + + cursor = conn.execute("SELECT * FROM catalogue_meta") + columns = [column[0] for column in cursor.description] + rows = cursor.fetchall() + if len(rows) != 1: + raise CatalogueError( + f"catalogue_meta must have exactly one row, found {len(rows)}" + ) + + row = dict(zip(columns, rows[0])) + if not row.get("catalogue_version"): + raise CatalogueError("catalogue_meta has no catalogue_version") + schema = row.get("dataset_schema") + if isinstance(schema, int) and schema > SUPPORTED_SCHEMA: + raise CatalogueError( + f"catalogue uses dataset schema {schema}; this cloudexit supports " + f"up to {SUPPORTED_SCHEMA}. Update cloudexit." + ) + return CatalogueInfo( + version=str(row["catalogue_version"]), + sequence=row.get("catalogue_sequence"), + sha256=row.get("catalogue_sha256"), + dataset_schema=schema, + exported_at=row.get("exported_at"), + exporter_version=row.get("exporter_version"), + ) + + +def catalogue_info(db_path: str | Path | None = None) -> CatalogueInfo | None: + path = Path(db_path) if db_path else active_catalogue_path() + try: + conn = _open_readonly(path) + try: + return _read_meta(conn, _table_names(conn)) + finally: + conn.close() + except sqlite3.Error as exc: + raise CatalogueError(f"cannot read catalogue {path}: {exc}") from exc + + +def active_resource_type_ids( + provider: str | int, db_path: str | Path | None = None +) -> list[int]: + if isinstance(provider, str): + try: + csp = PROVIDER_IDS[provider.lower()] + except KeyError: + raise ValueError(f"unknown cloud provider: {provider!r}") from None + else: + csp = int(provider) + + path = Path(db_path) if db_path else active_catalogue_path() + conn = _open_readonly(path) + try: + rows = conn.execute( + "SELECT id FROM resourcetype WHERE csp = ? AND status = 't' ORDER BY id", + (csp,), + ).fetchall() + finally: + conn.close() + return [row[0] for row in rows] + + +def validate_database( + path: str | Path, + *, + expect_version: str | None = None, + require_meta: bool = False, +) -> CatalogueInfo | None: + try: + conn = _open_readonly(Path(path)) + try: + tables = _table_names(conn) + missing = [name for name in REQUIRED_TABLES if name not in tables] + if missing: + raise CatalogueError( + f"not a catalogue database: missing table(s) {', '.join(missing)}" + ) + _check_schema_objects(conn) + info = _read_meta(conn, tables) + finally: + conn.close() + except sqlite3.DatabaseError as exc: + raise CatalogueError(f"not a valid catalogue database: {exc}") from exc + + if require_meta and info is None: + raise CatalogueError("the catalogue has no catalogue_meta table") + if expect_version is not None and info and info.version != expect_version: + raise CatalogueError( + f"catalogue version {info.version} does not match the expected " + f"{expect_version}" + ) + return info + + +def read_state(directory: Path) -> dict[str, Any]: + try: + data = json.loads((directory / "state.json").read_text(encoding="utf-8")) + except (OSError, ValueError): + return {} + return data if isinstance(data, dict) else {} + + +def _write_state(directory: Path, state: dict[str, Any]) -> None: + temp = directory / "state.json.tmp" + temp.write_text(json.dumps(state, indent=2) + "\n", encoding="utf-8") + os.replace(temp, directory / "state.json") + + +def installed_catalogue(directory: Path | None = None) -> Installed | None: + directory = directory or catalogue_dir() + path = directory / "data.db" + if not path.is_file() or path.stat().st_size == 0: + return None + try: + info = catalogue_info(path) + except CatalogueError: + info = None + return Installed(path, info, read_state(directory)) + + +def _is_gzip(data: bytes) -> bool: + return data[:2] == GZIP_MAGIC + + +def unpack_limit(download_size: int | None) -> int: + size = download_size if isinstance(download_size, int) and download_size > 0 else 0 + return min(UNPACK_CEILING, max(UNPACK_FLOOR, UNPACK_RATIO * size)) + + +def _decompress(data: bytes, limit: int | None = None) -> bytes: + if not _is_gzip(data): + return data + limit = unpack_limit(len(data)) if limit is None else limit + chunks: list[bytes] = [] + total = 0 + rest = data + # Member by member, so a small archive cannot expand past the limit in memory + while rest: + inflater = zlib.decompressobj(zlib.MAX_WBITS | 16) + try: + part = inflater.decompress(rest, limit + 1 - total) + except zlib.error as exc: + raise CatalogueError( + f"the file is not a valid gzip archive: {exc}" + ) from exc + total += len(part) + if total > limit or inflater.unconsumed_tail: + raise CatalogueError( + f"the file unpacks to more than {limit // (1024 * 1024)} MB, " + "which no catalogue does; it was not installed" + ) + if not inflater.eof: + raise CatalogueError( + "the file is not a valid gzip archive: it ends before the " + "end-of-stream marker" + ) + chunks.append(part) + rest = inflater.unused_data + return b"".join(chunks) + + +def _sha256(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def install_dataset( + data: bytes, + directory: Path, + *, + sha256: str | None, + expect_version: str | None = None, + require_meta: bool = False, + unpack_size: int | None = None, +) -> CatalogueInfo | None: + directory.mkdir(parents=True, exist_ok=True) + fd, temp_name = tempfile.mkstemp(dir=directory, prefix="data.db.", suffix=".tmp") + temp = Path(temp_name) + try: + with os.fdopen(fd, "wb") as handle: + handle.write(_decompress(data, unpack_limit(unpack_size or len(data)))) + info = validate_database( + temp, expect_version=expect_version, require_meta=require_meta + ) + + current = directory / "data.db" + if current.exists(): + shutil.copy2(current, directory / "data.db.previous") + os.replace(temp, current) + _write_state( + directory, + { + "version": info.version if info else None, + "sha256": sha256, + "downloaded_at": datetime.now(timezone.utc).isoformat( + timespec="seconds" + ), + }, + ) + return info + finally: + temp.unlink(missing_ok=True) + + +def check_checksum(data: bytes, expected: str, what: str) -> str: + actual = _sha256(data) + if actual != expected.strip().lower(): + raise CatalogueError( + f"checksum mismatch for {what}: expected {expected}, got {actual}" + ) + return actual + + +def import_catalogue( + file: str | Path, *, directory: Path | None = None, sha256: str | None = None +) -> CatalogueInfo | None: + path = Path(file) + try: + data = path.read_bytes() + except OSError as exc: + raise CatalogueError(f"cannot read {path}: {exc}") from exc + digest = _sha256(data) + if sha256 is not None: + check_checksum(data, sha256, str(path)) + return install_dataset(data, directory or catalogue_dir(), sha256=digest) + + +def use_catalogue_file(file: str | Path) -> tuple[Path, CatalogueInfo | None]: + path = Path(file) + try: + data = path.read_bytes() + except OSError as exc: + raise CatalogueError(f"cannot read {path}: {exc}") from exc + + if _is_gzip(data): + temp_dir = tempfile.mkdtemp(prefix="cloudexit-catalogue-") + atexit.register(shutil.rmtree, temp_dir, ignore_errors=True) + path = Path(temp_dir) / "data.db" + path.write_bytes(_decompress(data)) + return path, validate_database(path) + + +CATALOGUE_VERSION_PARTS = re.compile(r"^(\d{4})-(\d{2})-(\d{2})(?:\.(\d+))?$") + + +def parse_catalogue_version(text: str | None) -> tuple[int, int, int, int] | None: + # A date, and an optional numeric suffix that tells releases of one day apart + match = CATALOGUE_VERSION_PARTS.match(str(text or "").strip()) + if not match: + return None + year, month, day, suffix = match.groups() + return int(year), int(month), int(day), int(suffix or 0) + + +def compare_catalogue_versions(local: str | None, other: str | None) -> str | None: + # "older", "same" or "newer": how the local catalogue stands against the + # other one; None when either version cannot be read. + first, second = parse_catalogue_version(local), parse_catalogue_version(other) + if first is None or second is None: + return None + if first == second: + return "same" + return "older" if first < second else "newer" diff --git a/cloudexit/cli/__init__.py b/cloudexit/cli/__init__.py new file mode 100644 index 0000000..aa6b912 --- /dev/null +++ b/cloudexit/cli/__init__.py @@ -0,0 +1,3 @@ +from .app import main, run + +__all__ = ["main", "run"] diff --git a/cloudexit/cli/app.py b/cloudexit/cli/app.py new file mode 100644 index 0000000..7d6f829 --- /dev/null +++ b/cloudexit/cli/app.py @@ -0,0 +1,1790 @@ +# cloudexit/cli/app.py +import logging +import contextlib +import json +import boto3 +import tempfile +import time +import sys +import os +import traceback +from dataclasses import replace +from typing import Any +from urllib.parse import urlsplit +from rich.console import Console +from rich.logging import RichHandler +from datetime import datetime +from botocore.exceptions import NoCredentialsError, ProfileNotFound + +# Import the functions +from cloudexit.engine import ( + verify_credentials, + test_permissions, + create_resource_inventory, + create_cost_inventory, + perform_risk_assessment, + generate_report, +) +from cloudexit.providers.azure.clients import ( + create_resource_client, + create_subscription_client, +) +from cloudexit.egress.estimate import estimate_egress +from cloudexit.platform.client import ApiError, check_key +from cloudexit.platform.run_output import ( + build_run_json, + exit_code_of, + run_json_fallback, +) +from cloudexit.platform.status import StatusReport, host_catalogue_lines, run_status +from cloudexit.platform.spool import ( + AUTO_FLUSH_BUDGET, + DEFAULT_MAX_AGE_DAYS, + Spool, + flush, + format_age, + host_id, + key_fingerprint, + parse_age, +) +from cloudexit.platform.upload import ( + billing_line, + error_failure, + read_sync_files, + report_info, + upload, + write_upload_record, +) +from cloudexit.platform.document import ( + RunContext, + SyncDocumentError, + new_uuid7, + prepare_sync_document, + validate_document, +) +from cloudexit.providers.azure.sdk import ( + AzureSdkError, + azure_import, + require_azure_sdk, +) +from cloudexit.providers.azure.cli_session import ( + select_subscription, + select_resource_group, + is_azure_cli_installed, + is_azure_cli_logged_in, + is_azure_cli_token_expired, +) +from cloudexit.providers.aws.cli_profile import ( + is_aws_cli_installed, + is_aws_profile_valid, +) +from cloudexit.catalogue.store import ( + CatalogueError, + CatalogueUnavailable, + catalogue_base_url, + catalogue_info, + import_catalogue, + installed_catalogue, + set_active_catalogue, + use_catalogue_file, +) +from cloudexit.catalogue.download import ( + ensure_catalogue, + status_lines, + update_catalogue, +) +from cloudexit.cli.parser import parse_arguments +from cloudexit.cli.prompts import ( + ascii_art, + prompt_required_inputs, + print_help_message, +) +from cloudexit.log_redaction import SecretRedactingFilter +from cloudexit.private_files import restrict_tree +from cloudexit.terminal import ask, print_step +from cloudexit.runconfig.loader import ( + build_config, + load_config, + require_env, + require_env_int, +) +from cloudexit.settings import ( + SettingsError, + config_file_path, + describe, + flags_from_args, + load_settings, +) +from cloudexit.runconfig.validate import validate_region, validate_config +from cloudexit import codes + +# Configure the logger (level is left to the handlers, see configure_logging) +logger = logging.getLogger(__name__) + +# Initialize the console object +console = Console() + +# The local report is always the Basic report, whatever depth was requested +LOCAL_REPORT_ASSESSMENT_TYPE = 1 + +# Third-party loggers kept quiet unless the user opts into deep verbosity (-vv) +_THIRD_PARTY_NOISY = ("botocore", "boto3", "azure", "urllib3") + +# Loggers with no diagnostic value ever — pinned to WARNING regardless of verbosity, +# so they never flood the -vv console or run.log. MSAL (behind the Azure +# credentials) writes the client ID in its debug output, botocore's signing and +# credential-resolution internals carry the credentials themselves, and run.log +# is a file users share. +_ALWAYS_QUIET = ("PIL", "msal", "botocore.auth", "botocore.credentials") + +# A last line of defence for the records that do stay on: both handlers drop +# credentials out of whatever reaches them. +_redacting_filter = SecretRedactingFilter() + + +def configure_logging(verbose: int = 0) -> None: + """ + verbose == 0 -> WARNING (default; the Rich step UI is the primary output) + verbose == 1 -> INFO (--verbose: show our interaction narrative) + verbose >= 2 -> DEBUG (-vv: also un-mute third-party libraries) + """ + root = logging.getLogger() + root.setLevel(logging.DEBUG) + root.handlers.clear() + + if verbose == 0: + console_level = logging.WARNING + elif verbose == 1: + console_level = logging.INFO + else: + console_level = logging.DEBUG + + console_handler = RichHandler( + console=console, show_path=False, rich_tracebacks=True + ) + console_handler.setLevel(console_level) + console_handler.addFilter(_redacting_filter) + root.addHandler(console_handler) + + third_party_level = logging.DEBUG if verbose >= 2 else logging.WARNING + for name in _THIRD_PARTY_NOISY: + logging.getLogger(name).setLevel(third_party_level) + + for name in _ALWAYS_QUIET: + logging.getLogger(name).setLevel(logging.WARNING) + + +_run_log_handler = None + + +def close_run_log_handler() -> None: + # An open run.log keeps Windows from moving or deleting the report folder + global _run_log_handler + if _run_log_handler is not None: + logging.getLogger().removeHandler(_run_log_handler) + _run_log_handler.close() + _run_log_handler = None + + +def add_run_log_handler(report_path: str) -> None: + # A logging-setup failure must never abort the assessment, so degrade + # gracefully if run.log cannot be created (e.g. dir missing or read-only). + global _run_log_handler + close_run_log_handler() + try: + file_handler = logging.FileHandler( + os.path.join(report_path, "run.log"), encoding="utf-8" + ) + file_handler.setLevel(logging.DEBUG) + file_handler.setFormatter( + logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") + ) + file_handler.addFilter(_redacting_filter) + logging.getLogger().addHandler(file_handler) + _run_log_handler = file_handler + except OSError as exc: + logger.warning("Could not create run.log in %s: %s", report_path, exc) + + +class ConfigError(Exception): + pass + + +def _reject_egress_with_tfstate(args) -> None: + # --egress needs live API access, so it cannot run against a state file. + if getattr(args, "tfstate", None) and getattr(args, "egress", False): + console.print( + "[red]--egress cannot be combined with --tfstate. Egress estimation " + "requires live cloud API access.[/red]" + ) + sys.exit(codes.CONFIG) + + +def _aws_provider_from_profile(profile: str) -> dict: + if not is_aws_cli_installed(): + console.print( + "[red]AWS CLI is not installed. Install it from https://aws.amazon.com/cli/[/red]" + ) + raise ConfigError + if not is_aws_profile_valid(profile): + console.print( + f"AWS profile '{profile}' is not configured. " + f"Use `aws configure --profile {profile}`.", + style="red", + markup=False, + emoji=False, + ) + raise ConfigError + try: + session = boto3.Session(profile_name=profile) + credentials = session.get_credentials() + if credentials is None: + console.print( + f"AWS profile '{profile}' has no valid credentials. " + f"Use `aws configure --profile {profile}`.", + style="red", + markup=False, + emoji=False, + ) + raise ConfigError + region = session.region_name or "us-east-1" + except (NoCredentialsError, ProfileNotFound) as e: + console.print( + f"AWS profile error: {str(e)}. Use `aws configure` to set up a profile.", + style="red", + markup=False, + emoji=False, + ) + raise ConfigError + + provider_details = { + "accessKey": credentials.access_key, + "secretKey": credentials.secret_key, + "region": region, + } + if getattr(credentials, "token", None): + provider_details["sessionToken"] = credentials.token + return provider_details + + +def _aws_provider_from_env() -> dict: + access_key = require_env("AWS_ACCESS_KEY_ID", "AWS access key") + secret_key = require_env("AWS_SECRET_ACCESS_KEY", "AWS secret key") + region = require_env("AWS_DEFAULT_REGION", "AWS region") + session_token = os.environ.get("AWS_SESSION_TOKEN", "").strip() + try: + validate_region(region) + except ValueError as e: + console.print( + f"AWS_DEFAULT_REGION: {e}", style="red", markup=False, emoji=False + ) + raise ConfigError + + provider_details = { + "accessKey": access_key, + "secretKey": secret_key, + "region": region, + } + if session_token: + provider_details["sessionToken"] = session_token + return provider_details + + +def _aws_provider_from_prompt() -> dict: + try: + access_key = ask("Enter AWS Access Key: ").strip() + secret_key = ask("Enter AWS Secret Key (input hidden): ", secret=True).strip() + + # Validate AWS region input + while True: + region = ask("Enter AWS region: ").strip() + try: + validate_region(region) + break + except ValueError as e: + console.print( + f"{e} Please enter a valid AWS region.", + style="red", + markup=False, + emoji=False, + ) + except Exception as e: + console.print( + f"Error during manual AWS configuration: {e}", + style="red", + markup=False, + emoji=False, + ) + raise ConfigError + + return { + "accessKey": access_key, + "secretKey": secret_key, + "region": region, + } + + +def handle_aws(args): + cloud_provider = 2 + + _reject_egress_with_tfstate(args) + tfstate_path = getattr(args, "tfstate", None) + + if args.config: + config = load_config(args.config) + if not config: + console.print("[red]Invalid or missing AWS configuration file.[/red]") + raise ConfigError + + # Handle name field logic (priority: --name > config name > fallback) + if args.name: + config["name"] = args.name.strip() + if "name" not in config or not config["name"].strip(): + config["name"] = ( + f"Exit Assessment {datetime.now().strftime('%Y%m%d_%H%M%S')}" + ) + + run_assessment( + config, + "aws", + dry_run=args.dry_run, + non_interactive=args.non_interactive, + egress=args.egress, + settings=getattr(args, "settings", None), + emit_payload=getattr(args, "emit_payload", None), + egress_names=getattr(args, "egress_names", "plain"), + spool_on_error=getattr(args, "spool_on_error", False), + flush_spool=not getattr(args, "no_flush", False), + strict=getattr(args, "strict", False), + ) + return + + if args.non_interactive: + exit_strategy = require_env_int( + "ESC_EXIT_STRATEGY", "exit strategy (1, 2 or 3)", {1, 2, 3} + ) + assessment_type = require_env_int( + "ESC_ASSESSMENT_TYPE", "assessment type (1 or 2)", {1, 2} + ) + if tfstate_path: + provider_details = {"tfstatePath": tfstate_path} + elif args.profile: + provider_details = _aws_provider_from_profile(args.profile) + else: + provider_details = _aws_provider_from_env() + elif tfstate_path: + exit_strategy, assessment_type = prompt_required_inputs( + _asks_assessment_type(args) + ) + provider_details = {"tfstatePath": tfstate_path} + elif args.profile: + provider_details = _aws_provider_from_profile(args.profile) + exit_strategy, assessment_type = prompt_required_inputs( + _asks_assessment_type(args) + ) + else: + exit_strategy, assessment_type = prompt_required_inputs( + _asks_assessment_type(args) + ) + provider_details = _aws_provider_from_prompt() + + config = build_config( + cloud_provider, exit_strategy, assessment_type, provider_details, args + ) + run_assessment( + config, + "aws", + dry_run=args.dry_run, + non_interactive=args.non_interactive, + egress=args.egress, + settings=getattr(args, "settings", None), + emit_payload=getattr(args, "emit_payload", None), + egress_names=getattr(args, "egress_names", "plain"), + spool_on_error=getattr(args, "spool_on_error", False), + flush_spool=not getattr(args, "no_flush", False), + strict=getattr(args, "strict", False), + ) + + +def _require_azure_sdk() -> None: + try: + require_azure_sdk() + except AzureSdkError as e: + console.print(f"{e}", style="red", markup=False, emoji=False) + sys.exit(codes.CONFIG) + + +def _azure_cli_credential() -> Any: + if not is_azure_cli_installed(): + console.print( + "[red]Azure CLI is not installed. Install it from https://aka.ms/install-azure-cli.[/red]" + ) + raise ConfigError + if not is_azure_cli_logged_in(): + console.print( + "[red]You are not logged in to Azure CLI. Please run 'az login' and try again.[/red]" + ) + raise ConfigError + if is_azure_cli_token_expired(): + console.print("[red]Your Azure CLI token has expired. Please run:[/red]") + console.print( + "[bold cyan]az login --scope https://management.azure.com/.default[/bold cyan]" + ) + raise ConfigError + DefaultAzureCredential = azure_import("azure.identity", "DefaultAzureCredential") + return DefaultAzureCredential() + + +def _azure_provider_noninteractive(args) -> dict: + subscription_id = require_env("ESC_SUBSCRIPTION_ID", "Azure subscription ID") + resource_group = require_env("ESC_RESOURCE_GROUP", "Azure resource group") + tenant_id = require_env("AZURE_TENANT_ID", "Azure tenant ID") + client_id = os.environ.get("AZURE_CLIENT_ID", "").strip() + client_secret = os.environ.get("AZURE_CLIENT_SECRET", "").strip() + + if args.cli: + credential = _azure_cli_credential() + else: + if client_secret: + if not client_id: + console.print( + "[red]--non-interactive with AZURE_CLIENT_SECRET also requires AZURE_CLIENT_ID.[/red]" + ) + raise ConfigError + ClientSecretCredential = azure_import( + "azure.identity", "ClientSecretCredential" + ) + credential = ClientSecretCredential( + tenant_id=tenant_id, + client_id=client_id, + client_secret=client_secret, + ) + else: + if not client_id: + console.print( + "[red]--non-interactive Azure OIDC requires AZURE_CLIENT_ID when AZURE_CLIENT_SECRET is not set.[/red]" + ) + raise ConfigError + DefaultAzureCredential = azure_import( + "azure.identity", "DefaultAzureCredential" + ) + credential = DefaultAzureCredential() + + provider_details = { + "credential": credential, + "tenantId": tenant_id, + "subscriptionId": subscription_id, + "resourceGroupName": resource_group, + } + if client_id: + provider_details["clientId"] = client_id + if not args.cli and client_secret: + provider_details["clientSecret"] = client_secret + return provider_details + + +def _azure_provider_from_cli() -> dict: + credential = _azure_cli_credential() + try: + tenant_id = ask("Enter Azure Tenant ID: ").strip() + subscription_client = create_subscription_client(credential) + subscriptions = list(subscription_client.subscriptions.list()) + if not subscriptions: + logger.error("No subscriptions found for the provided Azure credentials.") + console.print( + "[red]No subscriptions found for the provided credentials.[/red]" + ) + raise ConfigError + + selected_subscription = select_subscription(subscriptions) + subscription_id = selected_subscription.subscription_id + + resource_client = create_resource_client(credential, subscription_id) + resource_groups = list(resource_client.resource_groups.list()) + if not resource_groups: + logger.error("No resource groups found in the selected subscription.") + console.print( + "[red]No resource groups found in the selected subscription.[/red]" + ) + raise ConfigError + + resource_group_name = select_resource_group(resource_groups) + except ConfigError: + raise + except Exception as e: + logger.error(f"Error during Azure CLI processing: {e}", exc_info=True) + console.print(f"An error occurred: {e}", style="red", markup=False, emoji=False) + raise ConfigError + + return { + "credential": credential, + "tenantId": tenant_id, + "subscriptionId": subscription_id, + "resourceGroupName": resource_group_name, + } + + +def _azure_provider_from_prompt() -> dict: + tenant_id = ask("Enter Azure Tenant ID: ").strip() + client_id = ask("Enter Service Principal / Client ID: ").strip() + client_secret = ask("Enter Client Secret (input hidden): ", secret=True).strip() + + ClientSecretCredential = azure_import("azure.identity", "ClientSecretCredential") + try: + credential = ClientSecretCredential( + tenant_id=tenant_id, client_id=client_id, client_secret=client_secret + ) + subscription_client = create_subscription_client(credential) + + subscriptions = list(subscription_client.subscriptions.list()) + if not subscriptions: + console.print( + "[red]No subscriptions found. Please check your credentials.[/red]" + ) + raise ConfigError + + selected_subscription = select_subscription(subscriptions) + subscription_id = selected_subscription.subscription_id + + resource_client = create_resource_client(credential, subscription_id) + resource_groups = list(resource_client.resource_groups.list()) + if not resource_groups: + console.print( + "[red]No resource groups found in the selected subscription.[/red]" + ) + raise ConfigError + + resource_group_name = select_resource_group(resource_groups) + except ConfigError: + raise + except Exception as e: + logger.error(f"Error during manual Azure configuration: {e}", exc_info=True) + console.print(f"An error occurred: {e}", style="red", markup=False, emoji=False) + raise ConfigError + + return { + "tenantId": tenant_id, + "clientId": client_id, + "clientSecret": client_secret, + "subscriptionId": subscription_id, + "resourceGroupName": resource_group_name, + } + + +def handle_azure(args): + cloud_provider = 1 + + _reject_egress_with_tfstate(args) + tfstate_path = getattr(args, "tfstate", None) + # Fail before any prompt; a config file is checked when the run starts + if not tfstate_path and not args.config: + _require_azure_sdk() + + if args.config: + config = load_config(args.config) + if not config: + console.print("[red]Invalid or missing Azure configuration file.[/red]") + raise ConfigError + + # Handle name field logic (priority: --name > config name > fallback) + if args.name: + config["name"] = args.name.strip() + if "name" not in config or not config["name"].strip(): + config["name"] = ( + f"Exit Assessment {datetime.now().strftime('%Y%m%d_%H%M%S')}" + ) + + run_assessment( + config, + "azure", + dry_run=args.dry_run, + non_interactive=args.non_interactive, + egress=args.egress, + settings=getattr(args, "settings", None), + emit_payload=getattr(args, "emit_payload", None), + egress_names=getattr(args, "egress_names", "plain"), + spool_on_error=getattr(args, "spool_on_error", False), + flush_spool=not getattr(args, "no_flush", False), + strict=getattr(args, "strict", False), + ) + return + + if args.non_interactive: + exit_strategy = require_env_int( + "ESC_EXIT_STRATEGY", "exit strategy (1, 2 or 3)", {1, 2, 3} + ) + assessment_type = require_env_int( + "ESC_ASSESSMENT_TYPE", "assessment type (1 or 2)", {1, 2} + ) + if tfstate_path: + provider_details = {"tfstatePath": tfstate_path} + else: + provider_details = _azure_provider_noninteractive(args) + elif tfstate_path: + exit_strategy, assessment_type = prompt_required_inputs( + _asks_assessment_type(args) + ) + provider_details = {"tfstatePath": tfstate_path} + elif args.cli: + provider_details = _azure_provider_from_cli() + exit_strategy, assessment_type = prompt_required_inputs( + _asks_assessment_type(args) + ) + else: + exit_strategy, assessment_type = prompt_required_inputs( + _asks_assessment_type(args) + ) + provider_details = _azure_provider_from_prompt() + + config = build_config( + cloud_provider, exit_strategy, assessment_type, provider_details, args + ) + run_assessment( + config, + "azure", + dry_run=args.dry_run, + non_interactive=args.non_interactive, + egress=args.egress, + settings=getattr(args, "settings", None), + emit_payload=getattr(args, "emit_payload", None), + egress_names=getattr(args, "egress_names", "plain"), + spool_on_error=getattr(args, "spool_on_error", False), + flush_spool=not getattr(args, "no_flush", False), + strict=getattr(args, "strict", False), + ) + + +def create_directory(base_path="reports"): + # The timestamp keeps the name readable; the random suffix and the exclusive + # creation mean two runs started in the same second never share a folder. + # The folder is for its owner only. + timestamp = datetime.now().strftime("%Y%m%d%H%M%S") + os.makedirs(base_path, mode=0o700, exist_ok=True) + directory_path = tempfile.mkdtemp(prefix=f"{timestamp}-", dir=base_path) + + raw_data_path = os.path.join(directory_path, "raw_data") + os.mkdir(raw_data_path, 0o700) + + return directory_path, raw_data_path + + +RUN = {} + + +def _missing_platform(host, key): + if host and not key: + return "A host is configured, but no API key." + if key and not host: + return "An API key is configured, but no host." + return "No host and no API key are configured." + + +def _host_display(host): + # The origin only: a path, a query or credentials have no place in a status + # line the user reads on every run + parts = urlsplit(host or "") + if not parts.hostname: + return host + origin = parts.hostname if not parts.port else f"{parts.hostname}:{parts.port}" + return f"{parts.scheme}://{origin}" if parts.scheme else origin + + +def _asks_assessment_type(args): + # Standard needs an upload, so the question is only real when one will happen + settings = getattr(args, "settings", None) + if settings is None or getattr(args, "dry_run", False): + return False + if settings.upload.value == "off": + return False + return bool(settings.host.value and settings.api_key.value) + + +def _upload_mode(settings, dry_run): + mode = settings.upload.value if settings else "auto" + if not dry_run: + return mode + # --dry-run is --upload=off; only an explicit flag can contradict it + if mode != "off" and settings and settings.upload.source.startswith("flag"): + print_step( + "Configuration validation failed.", + status="error", + logs=f"--dry-run cannot be combined with --upload={mode}.", + ) + sys.exit(codes.CONFIG) + return "off" + + +def _count(number, noun): + return f"{number} {noun}{'' if number == 1 else 's'}" + + +def _report_flush(result): + if result.uploaded: + print_step( + f"Delivered {_count(len(result.uploaded), 'spooled sync')} from earlier runs.", + status="ok", + ) + if result.rejected: + print_step( + f"{_count(len(result.rejected), 'spooled sync')} will be rejected: " + "too old for the platform.", + status="warning", + logs="see `cloudexit spool list`; `cloudexit spool purge` removes them", + ) + if result.failed: + print_step( + f"{_count(len(result.failed), 'spooled sync')} could not be delivered.", + status="warning", + logs=", ".join(sorted(set(result.failed.values()))), + ) + if result.other_key: + print_step( + f"{_count(len(result.other_key), 'spooled sync')} for another API key " + "left in the spool.", + status="warning", + logs="not delivered with this key; run again with that key, " + "or `cloudexit spool purge` removes them", + ) + if result.pending: + print_step( + f"{_count(len(result.pending), 'spooled sync')} still waiting.", + status="warning", + logs=f"{result.stopped or 'unavailable'}; tried again at the next run", + ) + + +def _flush_earlier_syncs(settings): + # Deliver what earlier runs could not, before the new run starts. Whatever + # goes wrong here never blocks the new run. + try: + spool = Spool.from_settings(settings) + if not spool.entries(host_id(settings.host.value)): + return + result = flush(settings, spool, budget=AUTO_FLUSH_BUDGET) + except ApiError as e: + print_step("Delivering earlier syncs failed.", status="warning", logs=e.message) + return + except Exception as e: + logger.debug("Flushing the spool failed", exc_info=True) + print_step( + "Delivering earlier syncs failed.", + status="warning", + logs=f"{type(e).__name__}", + ) + return + _report_flush(result) + + +def run_assessment( + config, + provider_name, + *, + dry_run=False, + non_interactive=False, + egress=False, + settings=None, + emit_payload=None, + egress_names="plain", + spool_on_error=False, + flush_spool=True, + strict=False, +): + # Record the assessment start time to propagate across stages. The sync id + # is created here, at collection start, and identifies the whole run. + started_at = int(time.time()) + sync_id = new_uuid7() + RUN.clear() + + # Bound up front so the error handler can reference it even if the crash + # happens before the report directory is created. + report_path = None + + try: + # Preliminary Stage: Validate configuration & create directory + console.print("-------------------------------------------") + console.print("Preliminary Stage", style="bold") + try: + validate_config(config) + print_step("Configuration successfully validated.", status="ok") + except ValueError as e: + print_step("Configuration validation failed.", status="error", logs=str(e)) + sys.exit(codes.CONFIG) + + # tfstate mode reads a local state file: no credentials, no permission + # check, no cost data — and no egress estimation, which needs live APIs. + is_tfstate = bool(config["providerDetails"].get("tfstatePath")) + if config["cloudServiceProvider"] == 1 and not is_tfstate: + _require_azure_sdk() + if is_tfstate and egress: + print_step( + "Configuration validation failed.", + status="error", + logs="--egress requires live cloud API access and cannot be used with a Terraform state file.", + ) + sys.exit(codes.CONFIG) + + # Detect a configured platform + mode = _upload_mode(settings, dry_run) + host = settings.host.value if settings else None + key = settings.api_key.value if settings else None + configured = bool(host and key) + if mode == "required" and not configured: + print_step( + "Upload is required, but no platform is configured.", + status="error", + logs=f"{_missing_platform(host, key)} Set both, or use --upload=auto or --upload=off.", + ) + sys.exit(codes.CONFIG) + if mode == "auto" and bool(host) != bool(key): + # Half a configuration is a mistake, not a reason to run offline + print_step( + "The platform configuration is incomplete.", + status="error", + logs=f"{_missing_platform(host, key)} Set both, or use --upload=off to run without a platform.", + ) + sys.exit(codes.CONFIG) + uploading = configured and mode != "off" + signin_failure = None + if dry_run: + print_step("Dry run mode – no remote sync.", status="ok") + console.print( + " ↳ --dry-run is --upload=off plus the sync document", style="dim" + ) + elif mode == "off": + print_step("Upload is off – nothing is sent to a platform.", status="ok") + # Without an upload only the Basic assessment is possible + if config["assessmentType"] != 1: + print_step("Basic assessment: Standard needs an upload.", status="ok") + config["assessmentType"] = 1 + elif configured: + # A key that cannot be right is a failed upload, found before the + # collection starts. It does not drop the run to Basic or discard + # the local report. + try: + check_key(settings.api_key.value) + print_step(f"Platform configured: {_host_display(host)}.", status="ok") + if flush_spool: + _flush_earlier_syncs(settings) + except ApiError as error: + signin_failure = error_failure(error) + print_step( + "Platform configured, but the API key is not usable.", + status="warning", + logs=f"{error.message}. The report is still written locally; the upload counts as failed.", + ) + else: + print_step( + "Offline run: no platform configured; the report is written locally.", + status="ok", + ) + # Without a platform only the Basic assessment is possible + if config["assessmentType"] != 1: + print_step("Basic assessment: Standard needs an upload.", status="ok") + config["assessmentType"] = 1 + RUN["skipped_reason"] = ( + "dry_run" if dry_run else "off" if mode == "off" else "offline" + ) + + # Create directories + try: + report_path, raw_data_path = create_directory() + RUN["report_dir"] = report_path + add_run_log_handler(report_path) + print_step("Directory successfully created.", status="ok") + except RuntimeError as e: + print_step("Directory creation failed.", status="error", logs=str(e)) + sys.exit(codes.CONFIG) + + # Handle the result + provider_name = ( + "Microsoft Azure" + if config["cloudServiceProvider"] == 1 + else "AWS" if config["cloudServiceProvider"] == 2 else "Unknown" + ) + + # Stage 1: Verify Credentials + console.print("-------------------------------------------") + console.print("Stage #1 - Validate Credentials", style="bold") + if is_tfstate: + print_step( + "Skipped - tfstate mode (no cloud credentials used).", status="warning" + ) + else: + # Test Connection + connection_success, logs = verify_credentials( + config["cloudServiceProvider"], config["providerDetails"] + ) + if connection_success: + print_step(f"Connecting to {provider_name}...", status="ok") + else: + print_step(f"Connecting to {provider_name}...", status="error") + console.print(f" ↳ {logs}", style="dim", markup=False, emoji=False) + logger.error(f"Credential verification failed: {logs}") + sys.exit(codes.CREDENTIALS) + console.print("-------------------------------------------") + + # Stage 2: Test Permissions + console.print("Stage #2 - Validate Permissions", style="bold") + + if is_tfstate: + print_step( + "Skipped - tfstate mode (no cloud permissions needed).", + status="warning", + ) + else: + # Labels for permission types + permission_reader_label = ( + "Reader" if config["cloudServiceProvider"] == 1 else "ViewOnlyAccess" + ) + permission_cost_label = ( + "Cost Management Reader" + if config["cloudServiceProvider"] == 1 + else "AWSBillingReadOnlyAccess" + ) + + # Test permissions with spinners + with console.status("Validating permissions...", spinner="dots"): + permission_valid, permission_reader, permission_cost, logs = ( + test_permissions( + config["cloudServiceProvider"], config["providerDetails"] + ) + ) + + # Output results for permission checks + if permission_reader: + print_step(f"Checking {permission_reader_label}...", status="ok") + else: + print_step( + f"Checking {permission_reader_label}...", status="error", logs=logs + ) + + if permission_cost: + print_step(f"Checking {permission_cost_label}...", status="ok") + else: + print_step( + f"Checking {permission_cost_label}...", status="error", logs=logs + ) + + # Exit if permissions are invalid + if not permission_valid: + logger.error(f"Permission validation failed: {logs}") + sys.exit(codes.PERMISSIONS) + + console.print("-------------------------------------------") + + # Stage 3: Build Resource Inventory + console.print("Stage #3 - Build Resource Inventory", style="bold") + + # Use a spinner to indicate progress + with console.status( + f"Building resource inventory for {provider_name}...", spinner="dots" + ): + result = create_resource_inventory( + config["cloudServiceProvider"], + config["providerDetails"], + report_path, + raw_data_path, + ) + + if result["success"]: + # In tfstate mode, resources belonging to another cloud provider are + # dropped silently by design. Report the exclusion at step level so a + # partially-assessed state cannot pass for a complete one. + coverage = result.get("coverage") or {} + excluded = coverage.get("instances_excluded_other_provider", 0) + if excluded: + print_step( + f"Building resource inventory for {provider_name}...", + status="warning", + logs=( + f"Assessed {coverage['instances_counted']} of " + f"{coverage['instances_total']} resources; {excluded} excluded " + f"(other cloud provider). See raw_data/tfstate_manifest.json." + ), + ) + else: + print_step( + f"Building resource inventory for {provider_name}...", status="ok" + ) + else: + print_step( + f"Building resource inventory for {provider_name}...", + status="error", + logs=result["logs"], + ) + sys.exit(codes.RESOURCE_INVENTORY) + + console.print("-------------------------------------------") + + # Stage 4: Build Cost Inventory + console.print("Stage #4 - Build Cost Inventory", style="bold") + + if is_tfstate: + print_step( + "Skipped - tfstate mode (no billing data available).", status="warning" + ) + else: + # Use a spinner to indicate progress + with console.status( + f"Building cost inventory for {provider_name}...", spinner="dots" + ): + cost_result = create_cost_inventory( + config["cloudServiceProvider"], + config["providerDetails"], + report_path, + raw_data_path, + ) + + # Handle the result + if cost_result["success"]: + print_step( + f"Building cost inventory for {provider_name}...", status="ok" + ) + else: + print_step( + f"Building cost inventory for {provider_name}...", + status="error", + logs=cost_result["logs"], + ) + sys.exit(codes.COST_INVENTORY) + + console.print("-------------------------------------------") + + name = ( + config.get("name") + or f"Exit Assessment {datetime.now().strftime('%Y%m%d_%H%M%S')}" + ) + + # Stage 5: Egress Estimation (opt-in via --egress). + # A failure here must not discard the completed assessment. + egress_failed = False + egress_collected = False + egress_json_path = None + if egress: + console.print("Stage #5 – Egress Estimation", style="bold") + + with console.status("Estimating egress data...", spinner="dots"): + egress_result = estimate_egress( + config["cloudServiceProvider"], + config["providerDetails"], + raw_data_path, + report_path=report_path, + name=name, + exit_strategy=config["exitStrategy"], + assessment_type=config["assessmentType"], + ) + + if egress_result["success"]: + print_step("Estimating egress data...", status="ok") + egress_json_path = egress_result.get("json_path") + egress_collected = True + else: + print_step( + "Estimating egress data...", + status="error", + logs=egress_result["logs"], + ) + egress_failed = True + if egress: + console.print("-------------------------------------------") + + # Stage 6: Local risk assessment. Every run computes the Basic results + # locally, connected or not; platform results are never used here. + console.print("Stage #6 – Risk Assessment", style="bold") + + with console.status("Performing risk assessment...", spinner="dots"): + risk_result = perform_risk_assessment( + exit_strategy=config["exitStrategy"], + report_path=report_path, + ) + + status = "ok" if risk_result["success"] else "error" + print_step( + "Performing risk assessment...", status=status, logs=risk_result["logs"] + ) + if not risk_result["success"]: + sys.exit(codes.RISK_ASSESSMENT) + + console.print("-------------------------------------------") + + # Sync document: the run's result in the API v2 shape. Without + # --dry-run or --emit-payload a problem here does not discard the + # finished report. + sync_output = None + sync_failed = False + try: + sync_output = prepare_sync_document( + report_path, + RunContext( + sync_id=sync_id, + started_at=started_at, + cloud_service_provider=config["cloudServiceProvider"], + assessment_type=config["assessmentType"], + provider_details=config["providerDetails"], + egress_collected=egress_collected, + egress_names=egress_names, + ), + emit_dir=emit_payload, + ) + except (SyncDocumentError, OSError) as e: + wanted = bool(dry_run or emit_payload) + console.print("-------------------------------------------") + print_step( + "Writing the sync document...", + status="error" if wanted else "warning", + logs=str(e), + ) + for problem in getattr(e, "problems", [])[:10]: + console.print(f" ↳ {problem}", style="dim", markup=False, emoji=False) + sync_failed = wanted + + RUN["sync_json"] = str(sync_output.path) if sync_output else None + + # Stage 7: Upload, only when a platform is configured. A failure never + # discards the local report: it is written next, with a banner. + upload_result = signin_failure + if signin_failure or (uploading and not dry_run): + console.print("Stage #7 – Upload", style="bold") + if upload_result is None: + with console.status("Uploading to the platform...", spinner="dots"): + upload_result = upload( + sync_output.document if sync_output else None, + settings, + name=name, + meta={ + "cloud_service_provider": config["cloudServiceProvider"], + "exit_strategy": config["exitStrategy"], + "assessment_type": config["assessmentType"], + }, + spool_on_error=spool_on_error, + ) + if upload_result.sync_id is None: + upload_result = replace(upload_result, sync_id=sync_id) + if upload_result.success and not upload_result.pending: + print_step("Uploading to the platform...", status="ok") + elif upload_result.success: + print_step( + "Uploading to the platform...", + status="warning", + logs="Accepted by the platform, still processing.", + ) + else: + print_step( + "Uploading to the platform...", + status="error", + logs=f"{upload_result.error_code or upload_result.reason}: {upload_result.message}", + ) + if upload_result.request_id: + console.print( + f" ↳ request id: {upload_result.request_id}", + style="dim", + markup=False, + emoji=False, + ) + if upload_result.spooled: + print_step( + "Sync saved for a later upload.", + status="warning", + logs="spooled; run `cloudexit spool flush`", + ) + for warning in upload_result.warnings: + print_step("Platform warning", status="warning", logs=warning) + write_upload_record(raw_data_path, upload_result) + RUN["upload"] = upload_result + console.print("-------------------------------------------") + + # Stage 8: Generate the local report (always Basic, connected or not) + console.print("Stage #8 - Generate Report", style="bold") + + # Use a spinner to indicate progress + with console.status("Generating report...", spinner="dots"): + report_status = generate_report( + config["cloudServiceProvider"], + config["providerDetails"], + config["exitStrategy"], + LOCAL_REPORT_ASSESSMENT_TYPE, + name, + report_path, + raw_data_path, + upload=report_info(upload_result), + sync_id=sync_id, + egress_json_path=egress_json_path, + ) + + # Handle the result + if report_status["success"]: + print_step("Generating report...", status="ok") + else: + print_step( + "Generating report...", status="error", logs=report_status["logs"] + ) + 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 + console.print("-------------------------------------------") + console.print("Outputs:", style="bold") + if upload_result and upload_result.success: + console.print( + ( + f"Platform: {upload_result.url}" + if upload_result.url + else "Uploaded to the platform." + ), + style="cyan", + markup=False, + emoji=False, + ) + charge = billing_line(upload_result.billing) + if charge: + console.print(charge, style="cyan") + if sync_output: + console.print( + f"Sync document: {sync_output.path}", + style="cyan", + markup=False, + emoji=False, + ) + if sync_output.emitted: + console.print( + f"Emitted copy: {sync_output.emitted}", + style="cyan", + markup=False, + emoji=False, + ) + html_report_path = report_status.get("reports", {}).get("HTML") + if html_report_path: + console.print( + f"HTML Report: {html_report_path}", + style="cyan", + markup=False, + emoji=False, + ) + pdf_report_path = report_status.get("reports", {}).get("PDF") + if pdf_report_path: + console.print( + f"PDF Report: {pdf_report_path}", + style="cyan", + markup=False, + emoji=False, + ) + json_report_path = report_status.get("reports", {}).get("JSON") + if json_report_path: + console.print( + f"JSON Report: {json_report_path}", + style="cyan", + markup=False, + emoji=False, + ) + if egress_pdf_path: + console.print( + f"Egress PDF: {egress_pdf_path}", + style="cyan", + markup=False, + emoji=False, + ) + console.print("-------------------------------------------") + + try: + RUN["catalogue_version"] = catalogue_info( + os.path.join(report_path, "data", "assessment.db") + ).version + except (CatalogueError, AttributeError): + RUN["catalogue_version"] = None + + # Collection problems win over upload codes + if sync_failed: + sys.exit(codes.REPORT) + if egress_failed: + sys.exit(codes.EGRESS) + if upload_result and upload_result.failed: + sys.exit(upload_result.exit_code) + if strict and upload_result and upload_result.warning_items: + print_step( + "Strict mode: the platform reported warnings.", + status="error", + logs="; ".join( + str(item.get("code") or item.get("message")) + for item in upload_result.warning_items + ), + ) + sys.exit(codes.UPLOAD_REJECTED) + + except Exception as e: + console.print(f"Unexpected error: {e}", style="red", markup=False, emoji=False) + # Persist the full traceback so a one-line error is actionable. Falls + # back to the cwd when the crash happened before the report dir existed. + target_dir = report_path or os.getcwd() + try: + log_file = os.path.join(target_dir, f"error-{int(time.time())}.log") + with open(log_file, "w", encoding="utf-8") as fh: + fh.write(traceback.format_exc()) + console.print( + f"Full traceback written to: {log_file}", + style="yellow", + markup=False, + emoji=False, + ) + except OSError: + console.print( + "[yellow]Could not write a traceback file; see run.log.[/yellow]" + ) + # Also funnel to run.log at DEBUG (kept off the default console). + logger.debug("Unexpected error", exc_info=True) + sys.exit(codes.UNEXPECTED) + finally: + close_run_log_handler() + if report_path: + # Everything the run wrote, however it was written, is for its owner + restrict_tree(report_path) + + +def _catalogue_settings(args=None): + try: + settings = load_settings(flags_from_args(args) if args else None) + return ( + catalogue_base_url(settings), + settings.ca_bundle.value or True, + settings, + ) + except SettingsError as e: + console.print(f"{str(e)}", style="red", markup=False, emoji=False) + sys.exit(codes.CONFIG) + + +def prepare_catalogue(args): + settings = getattr(args, "settings", None) + + if getattr(args, "catalogue", None): + try: + path, info = use_catalogue_file(args.catalogue) + except CatalogueError as e: + console.print(f"{str(e)}", style="red", markup=False, emoji=False) + sys.exit(codes.CONFIG) + set_active_catalogue(path) + print_step( + f"Using catalogue {info.version if info else 'unknown'} from " + f"{os.path.basename(args.catalogue)} (not installed).", + status="ok", + ) + return + + try: + base_url = catalogue_base_url(settings or load_settings()) + result = ensure_catalogue( + base_url, verify=(settings.ca_bundle.value if settings else None) or True + ) + except SettingsError as e: + console.print(f"{str(e)}", style="red", markup=False, emoji=False) + sys.exit(codes.CONFIG) + except CatalogueUnavailable as e: + console.print(f"{str(e)}", style="red", markup=False, emoji=False) + sys.exit(codes.CONFIG) + + version = result.version or "unknown" + if result.status == "failed": + print_step( + f"Catalogue update failed, using {version} from {result.date}.", + status="warning", + logs=result.reason, + ) + elif result.status == "updated": + print_step(f"Catalogue updated to {version}.", status="ok") + else: + print_step(f"Catalogue {version} is up to date.", status="ok") + + +def handle_catalogue(args): + base_url, verify, settings = _catalogue_settings(args) + try: + if args.catalogue_command == "status": + for line in status_lines(base_url, verify=verify): + print(line) + installed = installed_catalogue() + for line in host_catalogue_lines( + settings, installed.version if installed else None + ): + print(line) + elif args.catalogue_command == "update": + result = update_catalogue(base_url, version=args.version, verify=verify) + version = result.info.version if result.info else "unknown" + if result.status == "current": + print(f"Catalogue {version} is already installed.") + else: + print(f"Catalogue updated to {version}.") + else: + info = import_catalogue(args.file, sha256=args.sha256) + print(f"Catalogue {info.version if info else 'unknown'} installed.") + except (CatalogueError, OSError) as e: + console.print(f"{str(e)}", style="red", markup=False, emoji=False) + sys.exit(codes.CONFIG) + + +def _settings_or_exit(args): + try: + return load_settings(flags_from_args(args)) + except SettingsError as e: + console.print(f"{str(e)}", style="red", markup=False, emoji=False) + sys.exit(codes.CONFIG) + + +def _platform_or_exit(settings): + if not (settings.host.value and settings.api_key.value): + console.print( + "[red]No platform is configured: set a host and an API key " + "(see `cloudexit config show`).[/red]" + ) + sys.exit(codes.UPLOAD_AUTH) + + +def handle_upload(args): + settings = _settings_or_exit(args) + _platform_or_exit(settings) + try: + check_key(settings.api_key.value) + except ApiError as e: + console.print(f"{e.message}", style="red", markup=False, emoji=False) + sys.exit(e.exit_code) + + files, problems = read_sync_files(args.paths) + exit_code = codes.SUCCESS + for path, message in problems: + console.print(f"{str(path)}: {message}", style="red", markup=False, emoji=False) + exit_code = exit_code or codes.UPLOAD_REJECTED + for path, document in files: + try: + validate_document(document) + except SyncDocumentError as e: + console.print( + f"{str(path)}: {str(e)}", style="red", markup=False, emoji=False + ) + exit_code = exit_code or codes.UPLOAD_REJECTED + continue + result = upload( + document, + settings, + name=args.name or f"Exit Assessment {document['sync']['id'][:8]}", + meta={"exit_strategy": args.exit_strategy}, + spool_on_error=args.spool_on_error, + ) + shown = str(path) + if result.success: + console.print( + f"{shown}: {result.message}" + + (f" ({result.url})" if result.url else ""), + style="cyan", + markup=False, + emoji=False, + ) + charge = billing_line(result.billing) + if charge: + console.print(f" {charge}", style="cyan", markup=False, emoji=False) + for warning in result.warnings: + console.print( + f" warning: {warning}", style="dim", markup=False, emoji=False + ) + continue + exit_code = exit_code or result.exit_code + console.print( + f"{shown}: {result.error_code or result.reason or ''}: " + f"{result.message}", + style="red", + markup=False, + emoji=False, + ) + if result.request_id: + console.print( + f" request id: {result.request_id}", + style="dim", + markup=False, + emoji=False, + ) + if result.spooled: + console.print(" spooled; run `cloudexit spool flush`", style="dim") + if exit_code: + sys.exit(exit_code) + + +def handle_spool(args): + command = args.spool_command + settings = _settings_or_exit(args) + spool = Spool.from_settings(settings) + flag_host = getattr(args, "host", None) + try: + only = host_id(flag_host) if flag_host else None + except SettingsError as e: + console.print(f"{str(e)}", style="red", markup=False, emoji=False) + sys.exit(codes.CONFIG) + + if command == "list": + entries = spool.entries(only) + if not entries: + print("The spool is empty.") + return + now = time.time() + print(f"{'SYNC ID':<38} {'HOST':<17} {'AGE':<8} {'ATTEMPTS':<9} LAST ERROR") + key_id = key_fingerprint(settings.api_key.value) + for entry in entries: + print( + f"{entry.sync_id:<38} {entry.host_id:<17} " + f"{format_age(now - entry.created):<8} " + f"{entry.meta.get('attempts', 0):<9} " + f"{entry.meta.get('last_error') or '-'}" + + ( + " will be rejected (too old)" + if now - entry.created > DEFAULT_MAX_AGE_DAYS * 86400 + else "" + ) + + ("" if entry.belongs_to(key_id) else " not for the key in use") + ) + return + + if command == "flush": + _platform_or_exit(settings) + try: + check_key(settings.api_key.value) + result = flush(settings, spool, host=flag_host) + except ApiError as e: + console.print(f"{e.message}", style="red", markup=False, emoji=False) + sys.exit(e.exit_code) + except SettingsError as e: + console.print(f"{str(e)}", style="red", markup=False, emoji=False) + sys.exit(codes.CONFIG) + if result.empty: + print("Nothing to deliver for this host.") + return + _report_flush(result) + if result.pending or result.failed: + sys.exit(codes.UPLOAD_UNAVAILABLE) + return + + try: + older_than = parse_age(args.older_than) if args.older_than else None + except ValueError as e: + console.print(f"{str(e)}", style="red", markup=False, emoji=False) + sys.exit(codes.CONFIG) + now = time.time() + due = [ + entry + for entry in spool.entries(only) + if older_than is None or now - entry.created >= older_than + ] + if not due: + print("Nothing to purge.") + return + if not args.yes: + answer = ask( + f"Delete {_count(len(due), 'spooled sync')}? [y/N]: ", + no_terminal_message=( + "Purging asks for confirmation, but no terminal is attached. " + "Add --yes." + ), + ) + if answer.strip().lower() not in ("y", "yes"): + print("Nothing deleted.") + return + for entry in due: + spool.remove(entry) + print(f"Deleted {_count(len(due), 'spooled sync')}.") + + +def handle_status(args): + settings = _settings_or_exit(args) + try: + report = run_status(settings) + except Exception as e: + # Whatever the platform sent, the answer is still a report + logger.debug("The status check failed", exc_info=True) + report = StatusReport() + report.problem(codes.UNEXPECTED, f"the status check failed: {type(e).__name__}") + if args.output == "json": + print(json.dumps(report.as_json(), indent=2)) + else: + for line in report.lines(): + print(line) + if report.exit_code: + sys.exit(report.exit_code) + + +def handle_config(args): + if args.config_command == "path": + print(config_file_path()) + return + + try: + settings = load_settings(flags_from_args(args)) + except SettingsError as e: + console.print(f"{str(e)}", style="red", markup=False, emoji=False) + sys.exit(codes.CONFIG) + for line in describe(settings): + print(line) + + +def _dispatch(args): + # Ctrl+C anywhere below exits with a distinct code, whether the CLI is run + # as `python3 main.py` or through the `cloudexit` console script. + try: + # The config and catalogue helpers print plain text and need no banner. + if args.cloud_provider == "config": + handle_config(args) + return + if args.cloud_provider == "catalogue": + handle_catalogue(args) + return + if args.cloud_provider == "upload": + handle_upload(args) + return + if args.cloud_provider == "spool": + handle_spool(args) + return + if args.cloud_provider == "status": + handle_status(args) + return + + # Print ASCII art + console.print(ascii_art, style="bold cyan") + + # Nothing to do without a subcommand — show help before any dataset download. + if not args.cloud_provider: + print_help_message() + return + + # Resolve platform settings up front so a bad value fails before any work + if args.cloud_provider in ("aws", "azure"): + try: + args.settings = load_settings(flags_from_args(args)) + except SettingsError as e: + console.print(f"{str(e)}", style="red", markup=False, emoji=False) + sys.exit(codes.CONFIG) + + # Make sure a catalogue is available before any work starts + prepare_catalogue(args) + + # Dispatch based on provided arguments + try: + if args.cloud_provider == "aws": + handle_aws(args) + elif args.cloud_provider == "azure": + handle_azure(args) + else: + console.print( + "[red]Invalid command. Use 'aws' or 'azure' as the first argument.[/red]" + ) + console.print( + "[green]Run 'python3 main.py --help' for usage instructions.[/green]" + ) + except ConfigError: + sys.exit(codes.CONFIG) + except KeyboardInterrupt: + console.print( + "\n[bold yellow]Operation cancelled by user (Ctrl+C). Exiting gracefully.[/bold yellow]" + ) + sys.exit(codes.INTERRUPTED) + + +def _absolute(path): + return os.path.abspath(path) if path else None + + +def _emit_run_json(stream, exit_code): + # Absolute, so a script can use them from any directory + try: + summary = build_run_json( + exit_code=exit_code, + report_dir=_absolute(RUN.get("report_dir")), + sync_json=_absolute(RUN.get("sync_json")), + upload=RUN.get("upload"), + skipped_reason=RUN.get("skipped_reason"), + catalogue_version=RUN.get("catalogue_version"), + ) + text = json.dumps(summary, indent=2, allow_nan=False) + except Exception as e: + # One object on stdout whatever happened, so a script can always read it + logger.debug("The run summary could not be built", exc_info=True) + summary = run_json_fallback( + exit_code=exit_code, + report_dir=_absolute(RUN.get("report_dir")), + sync_json=_absolute(RUN.get("sync_json")), + error=f"the run summary could not be built: {type(e).__name__}", + ) + text = json.dumps(summary, indent=2) + stream.write(text + "\n") + stream.flush() + + +def _dispatch_json(args): + # stdout carries the summary and nothing else: the step output, the banner + # and every message go to stderr while the run lasts. + stdout = sys.stdout + exit_code = codes.UNEXPECTED + try: + with contextlib.redirect_stdout(sys.stderr): + try: + _dispatch(args) + exit_code = codes.SUCCESS + except SystemExit as e: + exit_code = exit_code_of(e.code) + raise + except Exception as e: + console.print( + f"Unexpected error: {str(e)}", + style="red", + markup=False, + emoji=False, + ) + sys.exit(codes.UNEXPECTED) + finally: + _emit_run_json(stdout, exit_code) + + +def _use_utf8_output(): + # The steps use arrows and dashes; a pipe or file whose encoding cannot + # hold them (cp1252 on Windows) must not stop a run + for stream in (sys.stdout, sys.stderr): + encoding = getattr(stream, "encoding", None) or "utf-8" + if encoding.lower().replace("-", "") != "utf8": + reconfigure = getattr(stream, "reconfigure", None) + if reconfigure: + reconfigure(encoding="utf-8", errors="replace") + + +def main(): + # Parse arguments first so --version/--help exit before any side effects + # (ASCII art, dataset download). + args = parse_arguments() + _use_utf8_output() + + # Configure logging before any side effects so verbosity applies everywhere. + configure_logging(getattr(args, "verbose", 0)) + + if ( + args.cloud_provider in ("aws", "azure") + and getattr(args, "output", "") == "json" + ): + _dispatch_json(args) + else: + _dispatch(args) + + +def run(): + # `python -m cloudexit` and `python3 main.py` end here; the console script + # calls main() directly and does not get this last-resort message. + try: + main() + except Exception as e: + # logger.error(f"An unexpected error occurred: {e}", exc_info=True) + console.print(f"Unexpected error: {e}", style="red", markup=False, emoji=False) + sys.exit(1) diff --git a/cloudexit/cli/parser.py b/cloudexit/cli/parser.py new file mode 100644 index 0000000..6a31c7c --- /dev/null +++ b/cloudexit/cli/parser.py @@ -0,0 +1,365 @@ +# cloudexit/cli/parser.py +import argparse + +from cloudexit.version import __version__ + + +def parse_arguments(): + parser = argparse.ArgumentParser( + description="EscapeCloud - Community Edition", + epilog=( + "Example usage:\n" + " python3 main.py aws # Use manual input for AWS\n" + " python3 main.py aws --config config.json # Use a configuration file for AWS\n" + " python3 main.py aws --profile PROFILE # Use an AWS CLI profile\n" + " python3 main.py aws --name 'DMS System' # Use a pre-defined assessment name\n" + " python3 main.py azure # Use manual input for Azure\n" + " python3 main.py azure --config config.json # Use a configuration file for Azure\n" + " python3 main.py azure --cli # Use Azure CLI credentials\n" + " python3 main.py azure --name 'DMS System' # Use a pre-defined assessment name\n" + " python3 main.py aws --config config.json --dry-run # Local report, no remote sync\n" + " python3 main.py azure --config config.json --dry-run\n" + " python3 main.py aws --config config.json --egress # Estimate egress data volume\n" + " python3 main.py azure --config config.json --egress\n" + " python3 main.py aws --tfstate infra.tfstate # Assess a Terraform/OpenTofu state file\n" + " python3 main.py azure --tfstate infra.tfstate --dry-run\n" + " python3 main.py config path # Print the config file location\n" + " python3 main.py config show # Show resolved settings and their sources\n" + " python3 main.py status # Check the platform settings, TLS and key\n" + " python3 main.py catalogue status # Show the installed and latest catalogue\n" + " python3 main.py catalogue update # Download the latest catalogue\n" + " python3 main.py catalogue import FILE.db.gz # Install a catalogue offline\n" + " python3 main.py aws --tfstate infra.tfstate --catalogue FILE.db.gz\n" + " python3 main.py aws --config config.json --emit-payload out/ # Copy sync.json to out/\n" + " python3 main.py aws --config config.json --egress --egress-names hashed\n" + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + + parser.add_argument( + "--version", + action="version", + version=f"cloudexit v{__version__}", + help="Show the CLI version and exit.", + ) + + subparsers = parser.add_subparsers( + dest="cloud_provider", + help="Specify the cloud provider (aws or azure), or `upload`, `spool`, `status`, `config` or `catalogue`.", + ) + + # Shared options available on every subcommand (e.g. `aws --verbose`) + common = argparse.ArgumentParser(add_help=False) + common.add_argument( + "-v", + "--verbose", + action="count", + default=0, + help="Increase log verbosity (-v for INFO, -vv for DEBUG + third-party).", + ) + + # Platform options: flags > CLOUDEXIT_* environment > config file + platform = argparse.ArgumentParser(add_help=False) + platform.add_argument( + "--host", type=str, help="Platform host (https://, or http:// for localhost)." + ) + platform.add_argument( + "--api-key-file", + type=str, + help="Path to a file containing the platform API key.", + ) + platform.add_argument( + "--config-profile", + type=str, + help="Profile to use from the cloudexit config file.", + ) + platform.add_argument( + "--assessment-id", type=str, help="Existing platform assessment to use." + ) + platform.add_argument( + "--ca-bundle", type=str, help="Path to a CA bundle for TLS verification." + ) + platform.add_argument( + "--timeout", type=float, help="Platform request timeout in seconds." + ) + platform.add_argument( + "--upload-budget", + type=float, + help="Seconds an upload may spend retrying before it is spooled (default 300).", + ) + + # What happens to an upload that does not get through + delivery = argparse.ArgumentParser(add_help=False) + delivery.add_argument( + "--spool-on-error", + action="store_true", + help="Also spool uploads the platform refused, for a later manual retry.", + ) + modes = argparse.ArgumentParser(add_help=False) + modes.add_argument( + "--upload", + choices=("auto", "required", "off"), + help=( + "auto uploads when a platform is configured (the default), required " + "fails before collecting when none is, off never uploads." + ), + ) + modes.add_argument( + "--strict", + action="store_true", + help="Fail with exit code 10 when the platform reports warnings.", + ) + output = argparse.ArgumentParser(add_help=False) + output.add_argument( + "--output", + choices=("text", "json"), + default="text", + help="json prints one JSON object on stdout at the end; the step output goes to stderr.", + ) + flushing = argparse.ArgumentParser(add_help=False) + flushing.add_argument( + "--no-flush", + action="store_true", + help="Do not deliver spooled syncs of earlier runs before this one.", + ) + + # Run with a catalogue file instead of the installed one (not installed) + catalogue_flag = argparse.ArgumentParser(add_help=False) + catalogue_flag.add_argument( + "--catalogue", + type=str, + help="Use this catalogue file (.db or .db.gz) for the run without installing it.", + ) + + # Options for the sync document every assessment writes + sync_flags = argparse.ArgumentParser(add_help=False) + sync_flags.add_argument( + "--emit-payload", + metavar="DIR", + type=str, + help="Also write a copy of the sync document to DIR/.json.", + ) + sync_flags.add_argument( + "--egress-names", + choices=("plain", "hashed"), + default="plain", + help="Send egress resource names as they are, or as salted hashes.", + ) + + # Subparser for AWS + aws_parser = subparsers.add_parser( + "aws", + parents=[ + common, + platform, + delivery, + flushing, + modes, + output, + catalogue_flag, + sync_flags, + ], + help="Perform an AWS assessment.", + ) + aws_group = aws_parser.add_mutually_exclusive_group(required=False) + aws_group.add_argument( + "--config", type=str, help="Path to the configuration file (JSON format)." + ) + aws_group.add_argument( + "--profile", + type=str, + help="AWS profile name to use credentials from ~/.aws/credentials.", + ) + aws_group.add_argument( + "--tfstate", + type=str, + help=( + "Path to a Terraform/OpenTofu state file. Builds the inventory from " + "the state instead of the AWS APIs; no credentials are used." + ), + ) + aws_parser.add_argument( + "--name", type=str, help="Assessment Name (Optional / Max. 50 characters)." + ) + aws_parser.add_argument( + "--non-interactive", + action="store_true", + help=( + "Run without prompts; read all inputs from environment variables " + "(for CI use), for example ESC_EXIT_STRATEGY (1, 2 or 3) and " + "ESC_ASSESSMENT_TYPE (1 or 2)." + ), + ) + aws_parser.add_argument( + "--dry-run", + action="store_true", + help=( + "Run a local assessment without remote sync. The sync document " + "raw_data/sync.json is written in every run." + ), + ) + aws_parser.add_argument( + "--egress", + action="store_true", + help=( + "Estimate how much data lives in the region and " "would need to move out." + ), + ) + + # Subparser for Azure + azure_parser = subparsers.add_parser( + "azure", + parents=[ + common, + platform, + delivery, + flushing, + modes, + output, + catalogue_flag, + sync_flags, + ], + help="Perform an Azure assessment.", + ) + azure_group = azure_parser.add_mutually_exclusive_group(required=False) + azure_group.add_argument( + "--config", type=str, help="Path to the configuration file (JSON format)." + ) + azure_group.add_argument( + "--cli", + action="store_true", + help="Use Azure CLI credentials for authentication.", + ) + azure_group.add_argument( + "--tfstate", + type=str, + help=( + "Path to a Terraform/OpenTofu state file. Builds the inventory from " + "the state instead of the Azure APIs; no credentials are used." + ), + ) + azure_parser.add_argument( + "--name", type=str, help="Assessment Name (Optional / Max. 50 characters)." + ) + azure_parser.add_argument( + "--non-interactive", + action="store_true", + help=( + "Run without prompts; read all inputs from environment variables " + "(for CI use), for example ESC_EXIT_STRATEGY (1, 2 or 3) and " + "ESC_ASSESSMENT_TYPE (1 or 2)." + ), + ) + azure_parser.add_argument( + "--dry-run", + action="store_true", + help=( + "Run a local assessment without remote sync. The sync document " + "raw_data/sync.json is written in every run." + ), + ) + azure_parser.add_argument( + "--egress", + action="store_true", + help=( + "Estimate how much data lives in the resource group and " + "would need to move out." + ), + ) + + # Subparsers for uploading sync documents and for the spool + upload_parser = subparsers.add_parser( + "upload", + parents=[common, platform, delivery], + help="Upload sync.json files (or folders of them), for example from an air-gapped machine.", + ) + upload_parser.add_argument( + "paths", nargs="+", help="Sync documents, or folders that hold them." + ) + upload_parser.add_argument( + "--name", + type=str, + help="Assessment name when an account key creates the assessment.", + ) + upload_parser.add_argument( + "--exit-strategy", + type=int, + choices=(1, 2, 3), + help="Exit strategy when an account key creates the assessment (1, 2 or 3).", + ) + + spool_parser = subparsers.add_parser( + "spool", help="Inspect and deliver syncs that could not be uploaded." + ) + spool_commands = spool_parser.add_subparsers(dest="spool_command", required=True) + spool_commands.add_parser( + "list", parents=[common, platform], help="List the spooled syncs." + ) + spool_commands.add_parser( + "flush", + parents=[common, platform], + help="Upload the spooled syncs, oldest first.", + ) + purge_parser = spool_commands.add_parser( + "purge", parents=[common, platform], help="Delete spooled syncs." + ) + purge_parser.add_argument( + "--older-than", type=str, help="Only entries older than this, such as 30d." + ) + purge_parser.add_argument( + "--yes", action="store_true", help="Delete without asking." + ) + + status_parser = subparsers.add_parser( + "status", + parents=[common, platform], + help="Check the settings, the TLS chain, the key and the platform.", + ) + status_parser.add_argument( + "--output", + choices=("text", "json"), + default="text", + help="json prints one JSON object on stdout.", + ) + + # Subparser for the config helpers + config_parser = subparsers.add_parser( + "config", help="Inspect where settings come from." + ) + config_commands = config_parser.add_subparsers(dest="config_command", required=True) + config_commands.add_parser( + "path", parents=[common], help="Print the config file location." + ) + config_commands.add_parser( + "show", + parents=[common, platform], + help="Show every resolved setting and where it came from.", + ) + + # Subparser for the catalogue dataset + catalogue_parser = subparsers.add_parser( + "catalogue", help="Manage the catalogue dataset." + ) + catalogue_commands = catalogue_parser.add_subparsers( + dest="catalogue_command", required=True + ) + catalogue_commands.add_parser( + "status", + parents=[common, platform], + help="Show the installed and the latest catalogue.", + ) + update_parser = catalogue_commands.add_parser( + "update", parents=[common], help="Download the latest (or a given) catalogue." + ) + update_parser.add_argument( + "--version", type=str, help="Install this version listed in the manifest." + ) + import_parser = catalogue_commands.add_parser( + "import", parents=[common], help="Install a catalogue file (air-gapped use)." + ) + import_parser.add_argument("file", type=str, help="Path to a .db.gz (or .db) file.") + import_parser.add_argument( + "--sha256", type=str, help="Expected SHA-256 of the file." + ) + + return parser.parse_args() diff --git a/cloudexit/cli/prompts.py b/cloudexit/cli/prompts.py new file mode 100644 index 0000000..8807ff5 --- /dev/null +++ b/cloudexit/cli/prompts.py @@ -0,0 +1,79 @@ +# cloudexit/cli/prompts.py +import logging + +from cloudexit.terminal import ask, console + +logger = logging.getLogger(__name__) + + +def prompt_required_inputs(ask_assessment_type: bool = True) -> tuple[int, int]: + while True: + try: + exit_strategy = int( + ask( + "Enter Exit Strategy (1 for 'Repatriation to On-Premises', 2 for 'Hybrid Cloud Adoption', 3 for 'Migration to Alternate Cloud'): " + ).strip() + ) + if exit_strategy not in [1, 2, 3]: + raise ValueError("Invalid exit strategy.") + # logger.info(f"Exit Strategy selected: {exit_strategy}") + break + except ValueError as e: + logger.debug(f"Invalid exit strategy input: {e}") + console.print( + f"{e} Please enter 1, 2 or 3.", + style="red", + markup=False, + emoji=False, + ) + + # Standard only exists on a platform, so without an upload there is + # nothing to choose + if not ask_assessment_type: + return exit_strategy, 1 + + while True: + try: + assessment_type = int( + ask("Enter Assessment Type (1 for 'Basic', 2 for 'Standard'): ").strip() + ) + if assessment_type not in [1, 2]: + raise ValueError("Invalid assessment type.") + # logger.info(f"Assessment Type selected: {assessment_type}") + break + except ValueError as e: + logger.debug(f"Invalid assessment type input: {e}") + console.print( + f"{e} Please enter 1 or 2.", style="red", markup=False, emoji=False + ) + + return exit_strategy, assessment_type + + +ascii_art = r""" + _ _ _ _ + | | | | (_) | + ___| | ___ _ _ __| | _____ ___| |_ + / __| |/ _ \| | | |/ _` |/ _ \ \/ / | __| +| (__| | (_) | |_| | (_| | __/> <| | |_ + \___|_|\___/ \__,_|\__,_|\___/_/\_\_|\__| + + +""" + + +def print_help_message(): + console.print("EscapeCloud - Community Edition", style="bold cyan") + console.print("[green]Run the script with one of the following options:[/green]\n") + console.print(" python3 main.py aws") + console.print(" python3 main.py aws --config config/aws.json") + console.print(" python3 main.py aws --profile PROFILE") + console.print(" python3 main.py aws --name 'DMS System' ") + console.print(" python3 main.py aws --tfstate infra.tfstate") + console.print(" python3 main.py azure") + console.print(" python3 main.py azure --config config/azure.json") + console.print(" python3 main.py azure --cli") + console.print(" python3 main.py azure --name 'DMS System'") + console.print(" python3 main.py azure --tfstate infra.tfstate --dry-run") + console.print(" python3 main.py config path") + console.print(" python3 main.py config show") diff --git a/cloudexit/codes.py b/cloudexit/codes.py new file mode 100644 index 0000000..9781f6c --- /dev/null +++ b/cloudexit/codes.py @@ -0,0 +1,29 @@ +# cloudexit/codes.py + +SUCCESS = 0 +UNEXPECTED = 1 +CONFIG = 2 +CREDENTIALS = 3 +PERMISSIONS = 4 +RESOURCE_INVENTORY = 5 +COST_INVENTORY = 6 +RISK_ASSESSMENT = 7 +REPORT = 8 +EGRESS = 9 + +# Upload to a platform (API v2) +# 10 the platform rejected the submitted data +# 11 the API key is missing, invalid or not allowed +# 12 the platform is unreachable or temporarily unavailable +# 13 a plan quota or rate limit was reached +# 14 the upload target was not found or is not accessible +# 15 the platform does not support this API version or feature +UPLOAD_REJECTED = 10 +UPLOAD_AUTH = 11 +UPLOAD_UNAVAILABLE = 12 +UPLOAD_QUOTA = 13 +UPLOAD_TARGET = 14 +UPLOAD_UNSUPPORTED = 15 + +# Interrupted by the user (Ctrl+C) +INTERRUPTED = 130 diff --git a/cloudexit/egress/__init__.py b/cloudexit/egress/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cloudexit/egress/estimate.py b/cloudexit/egress/estimate.py new file mode 100644 index 0000000..7d4eed1 --- /dev/null +++ b/cloudexit/egress/estimate.py @@ -0,0 +1,83 @@ +# cloudexit/egress/estimate.py +import json +import logging +import os +from typing import Any +from datetime import datetime, timezone + +from cloudexit.egress.model import ( + compute_totals, + load_egress_registry, + public_row, + write_egress_inventory, +) + +logger = logging.getLogger(__name__) + + +def estimate_egress( + cloud_service_provider: int, + provider_details: dict[str, Any], + raw_data_path: str, + *, + report_path: str, + name: str, + exit_strategy: int, + assessment_type: int, +) -> dict[str, Any]: + try: + if cloud_service_provider == 1: # Azure + from cloudexit.providers.azure.egress import collect_azure_egress + + rows, archive_tiers = collect_azure_egress(provider_details) + elif cloud_service_provider == 2: # AWS + from cloudexit.providers.aws.egress import collect_aws_egress + + rows, archive_tiers = collect_aws_egress(provider_details) + else: + raise ValueError( + f"Unsupported cloud service provider: {cloud_service_provider}" + ) + + resource_type_ids = { + key: entry["resource_type_id"] + for key, entry in load_egress_registry(cloud_service_provider).items() + } + + json_payload = { + "meta": { + "name": name, + "cloud_service_provider": cloud_service_provider, + "exit_strategy": exit_strategy, + "assessment_type": assessment_type, + "timestamp": datetime.now(timezone.utc).strftime( + "%Y-%m-%d %H:%M:%S UTC" + ), + }, + "data": { + "resources": [public_row(row) for row in rows], + "totals": compute_totals(rows, archive_tiers), + }, + } + # The estimate is stored per assessment; the JSON stays as the raw + # artifact alongside the other raw dumps. + write_egress_inventory( + rows, + resource_type_ids, + archive_tiers, + os.path.join(report_path, "data", "assessment.db"), + ) + + json_path = os.path.join(raw_data_path, "egress_inventory_raw_data.json") + with open(json_path, "w", encoding="utf-8") as json_file: + json.dump(json_payload, json_file, indent=4) + + return { + "success": True, + "logs": "Egress estimation completed successfully.", + "json_path": json_path, + } + + except Exception as e: + logger.error(f"Error estimating egress: {str(e)}", exc_info=True) + return {"success": False, "logs": str(e)} diff --git a/cloudexit/egress/model.py b/cloudexit/egress/model.py new file mode 100644 index 0000000..1ec7c3b --- /dev/null +++ b/cloudexit/egress/model.py @@ -0,0 +1,221 @@ +# cloudexit/egress/model.py +import json +import logging +from typing import Any + +from cloudexit.catalogue.db import connect, load_data + +logger = logging.getLogger(__name__) + + +GIB = 1024**3 + + +def _parse_params(raw_params: Any) -> dict[str, Any]: + if isinstance(raw_params, dict): + return raw_params + try: + parsed = json.loads(raw_params or "{}") + except (TypeError, ValueError): + return {} + return parsed if isinstance(parsed, dict) else {} + + +def load_egress_registry(csp: int) -> dict[str, dict[str, Any]]: + # Master data is the only source of truth: a missing table or an empty + # result fails the egress stage rather than producing a wrong estimate. + data_rows = load_data("resourcetype_data") + resource_types = load_data("resourcetype") + + types_by_id = {rt["id"]: rt for rt in resource_types} + + registry: dict[str, dict[str, Any]] = {} + for row in data_rows: + if row["status"] != "t": + continue + resource_type = types_by_id.get(row["resource_type"]) + if resource_type is None or resource_type["csp"] != csp: + continue + + # resourcetype.status is deliberately not filtered. Rows such as EBS + # Snapshots and Managed Disks ship as 'f' so they stay out of the + # inventory catalogue, but they still carry data that has to be egressed + # -- excluding them would silently drop a whole category from the + # estimate. + code = resource_type["code"] + # Azure matches against ARM resource.type, which is case-insensitive; + # AWS codes are handed to the collectors as the row identifier verbatim. + key = code.strip().lower() if csp == 1 else code + + params = _parse_params(row["params"]) + registry[key] = { + **params, + # Carried so the egress_inventory writer has the FK without a + # reverse lookup -- the Azure key is lowercased, the code is not. + "resource_type_id": resource_type["id"], + "category": row["data_category"], + "label": resource_type["name"], + "strategy": row["strategy"], + } + + if not registry: + raise ValueError( + f"No enabled resourcetype_data rows for CSP {csp}; " + "cannot build the egress registry." + ) + + return registry + + +def new_row( + resource_id: str, name: str, resource_type: str, label: str, category: str +) -> dict[str, Any]: + return { + "id": resource_id, + "name": name, + "type": resource_type, + "label": label, + "category": category, + "size_bytes": None, + "size_unknown": False, + "tier_bytes": None, + "flags": [], + "notes": [], + } + + +def format_bytes(size_bytes: int | float | None) -> str: + if size_bytes is None: + return "n/a" + value = float(size_bytes) + for unit in ("B", "KiB", "MiB", "GiB", "TiB"): + if value < 1024: + return f"{value:.1f} {unit}" + value /= 1024 + return f"{value:.1f} PiB" + + +def compute_totals( + rows: list[dict[str, Any]], archive_tiers: set[str] +) -> dict[str, Any]: + known_size_bytes = sum( + row["size_bytes"] for row in rows if row["size_bytes"] is not None + ) + archive_tier_bytes = sum( + size + for row in rows + for tier, size in (row["tier_bytes"] or {}).items() + if tier in archive_tiers + ) + unknown_count = sum(1 for row in rows if row["size_unknown"]) + return { + "known_size_bytes": known_size_bytes, + "archive_tier_bytes": archive_tier_bytes, + "resources_discovered": len(rows), + "resources_with_unknown_size": unknown_count, + } + + +def resource_type_id_for( + resource_type_ids: dict[str, int], row_type: str +) -> int | None: + # AWS rows carry the code verbatim; Azure rows carry the ARM resource.type, + # which the registry keys in lowercase. + return resource_type_ids.get(row_type) or resource_type_ids.get( + row_type.strip().lower() + ) + + +def public_row(row: dict[str, Any]) -> dict[str, Any]: + # The label is dropped: it is resourcetype.name, already in the database. + return { + "id": row["id"], + "name": row["name"], + "code": row["type"], + "category": row["category"], + "size_bytes": row["size_bytes"], + "size_unknown": row["size_unknown"], + "tier_bytes": row["tier_bytes"], + "flags": row["flags"], + "notes": row["notes"], + } + + +def write_egress_inventory( + rows: list[dict[str, Any]], + resource_type_ids: dict[str, int], + archive_tiers: set[str], + db_path: str, +) -> None: + conn = connect(db_path) + try: + cursor = conn.cursor() + for row in rows: + resource_type_id = resource_type_id_for(resource_type_ids, row["type"]) + if resource_type_id is None: + # Should not happen: every row is built from a registry entry. + logger.warning( + "No resourcetype id for %s; row not stored.", row["type"] + ) + continue + cursor.execute( + "INSERT INTO egress_inventory " + "(resource_type, name, size_bytes, size_unknown, flags, notes) " + "VALUES (?, ?, ?, ?, ?, ?)", + ( + resource_type_id, + row["name"], + row["size_bytes"], + int(bool(row["size_unknown"])), + json.dumps(row["flags"]), + json.dumps(row["notes"]), + ), + ) + inventory_id = cursor.lastrowid + for tier, size_bytes in (row["tier_bytes"] or {}).items(): + cursor.execute( + "INSERT INTO egress_inventory_tier " + "(egress_inventory_id, tier, size_bytes, is_archive) " + "VALUES (?, ?, ?, ?)", + (inventory_id, tier, size_bytes, int(tier in archive_tiers)), + ) + conn.commit() + finally: + conn.close() + + +def read_egress_inventory(db_path: str) -> tuple[list[dict[str, Any]], set[str]]: + # Rebuilds the row shape the report already consumes, so everything + # downstream of _load_estimate is unchanged. + resource_types = {rt["id"]: rt for rt in load_data("resourcetype", db_path=db_path)} + categories = { + rtd["resource_type"]: rtd["data_category"] + for rtd in load_data("resourcetype_data", db_path=db_path) + } + + tiers_by_row: dict[int, dict[str, int]] = {} + archive_tiers: set[str] = set() + for tier_row in load_data("egress_inventory_tier", db_path=db_path): + tiers_by_row.setdefault(tier_row["egress_inventory_id"], {})[ + tier_row["tier"] + ] = tier_row["size_bytes"] + if tier_row["is_archive"]: + archive_tiers.add(tier_row["tier"]) + + rows = [] + for record in load_data("egress_inventory", db_path=db_path): + resource_type = resource_types[record["resource_type"]] + row = new_row( + record["id"], + record["name"], + resource_type["code"], + resource_type["name"], + categories.get(record["resource_type"], ""), + ) + row["size_bytes"] = record["size_bytes"] + row["size_unknown"] = bool(record["size_unknown"]) + row["tier_bytes"] = tiers_by_row.get(record["id"]) or None + row["flags"] = json.loads(record["flags"] or "[]") + row["notes"] = json.loads(record["notes"] or "[]") + rows.append(row) + return rows, archive_tiers diff --git a/cloudexit/engine.py b/cloudexit/engine.py new file mode 100644 index 0000000..0871ece --- /dev/null +++ b/cloudexit/engine.py @@ -0,0 +1,566 @@ +# cloudexit/engine.py +import json +import logging +import os +import boto3 +from datetime import datetime, timezone, timedelta +from typing import Any +from botocore.exceptions import NoCredentialsError + +from cloudexit.report.assets import copy_assets +from cloudexit.providers.aws.costs import build_aws_cost_inventory +from cloudexit.providers.aws.inventory import build_aws_resource_inventory +from cloudexit.providers.azure.clients import ( + create_credential, + create_resource_client, +) +from cloudexit.providers.azure.costs import ( + CostRateLimited, + build_azure_cost_inventory, +) +from cloudexit.providers.azure.inventory import build_azure_resource_inventory +from cloudexit.providers.azure.sdk import azure_import +from cloudexit.catalogue.db import connect, load_data +from cloudexit.catalogue.store import CatalogueError +from cloudexit.tfstate import build_tfstate_resource_inventory +from cloudexit.report.context import ( + EGRESS_DOCUMENT_CODE, + build_context, + collect_provenance, +) +from cloudexit.report.egress import load_egress_estimate +from cloudexit.report.egress_report import generate_egress_report +from cloudexit.report.executive_summary import generate_executive_summary +from cloudexit.report.writers import ( + generate_html_report, + generate_json_report, +) + +# Configure the logger +logger = logging.getLogger(__name__) + + +# Stage 1 +def verify_credentials( + cloud_service_provider: int, provider_details: dict[str, Any] +) -> tuple[bool, str]: + connection_success = False + logs = "" + + if cloud_service_provider == 1: # Azure + ClientAuthenticationError = azure_import( + "azure.core.exceptions", "ClientAuthenticationError" + ) + try: + credential = create_credential(provider_details) + resource_client = create_resource_client( + credential, provider_details["subscriptionId"] + ) + list( + resource_client.resource_groups.list() + ) # Benign call to verify credentials + connection_success = True + logs = "Azure connection successful." + except ClientAuthenticationError as e: + logs = f"Azure credentials validation failed: {str(e)}" + # logger.error(logs) + except Exception as e: + logs = f"Azure connection test failed: {str(e)}" + # logger.error(logs) + + elif cloud_service_provider == 2: # AWS + try: + client = boto3.client( + "ec2", + aws_access_key_id=provider_details["accessKey"], + aws_secret_access_key=provider_details["secretKey"], + aws_session_token=provider_details.get("sessionToken"), + region_name=provider_details["region"], + ) + client.describe_regions() # Benign call to verify credentials + connection_success = True + logs = "AWS connection successful." + except NoCredentialsError as e: + logs = f"AWS credentials validation failed: {str(e)}" + # logger.error(logs) + except Exception as e: + logs = f"AWS connection test failed: {str(e)}" + # logger.error(logs) + + return connection_success, logs + + +# Stage 2 +def test_permissions( + cloud_service_provider: int, provider_details: dict[str, Any] +) -> tuple[bool, bool, bool, str]: + permission_valid = False + permission_reader = False + permission_cost = False + logs = "" + + if cloud_service_provider == 1: # Azure + ClientAuthenticationError = azure_import( + "azure.core.exceptions", "ClientAuthenticationError" + ) + AuthorizationManagementClient = azure_import( + "azure.mgmt.authorization", "AuthorizationManagementClient" + ) + try: + credential = create_credential(provider_details) + resource_group_scope = f"/subscriptions/{provider_details['subscriptionId']}/resourceGroups/{provider_details['resourceGroupName']}" + + # Check role assignments + auth_client = AuthorizationManagementClient( + credential, provider_details["subscriptionId"] + ) + role_assignments = auth_client.role_assignments.list_for_scope( + scope=resource_group_scope + ) + + for role_assignment in role_assignments: + role_definition_id = role_assignment.role_definition_id + if role_definition_id.endswith( + "acdd72a7-3385-48ef-bd42-f606fba81ae7" + ): # Reader role + permission_reader = True + if role_definition_id.endswith( + "72fafb9e-0641-4937-9268-a91bfd8191a3" + ): # Cost Management Reader + permission_cost = True + + if permission_reader and permission_cost: + permission_valid = True + logs = "Reader and Cost Management Reader roles validated." + elif permission_reader: + logs = "Reader role validated, but Cost Management Reader role validation failed." + elif permission_cost: + logs = "Cost Management Reader role validated, but Reader role validation failed." + else: + logs = "Both Reader and Cost Management Reader roles validation failed." + + except ClientAuthenticationError as e: + # Expected outcome of a permission test; surfaced to the user via `logs`. + logs = f"Azure credentials validation failed: {str(e)}" + logger.warning(logs) + except Exception as e: + logs = f"Azure permission test failed: {str(e)}" + logger.error(logs) + + elif cloud_service_provider == 2: # AWS + try: + sts_client = boto3.client( + "sts", + aws_access_key_id=provider_details["accessKey"], + aws_secret_access_key=provider_details["secretKey"], + aws_session_token=provider_details.get("sessionToken"), + region_name=provider_details["region"], + ) + identity = sts_client.get_caller_identity() + caller_arn = identity.get("Arn", "") + # The account id is the scope of the sync document + if identity.get("Account"): + provider_details["accountId"] = str(identity["Account"]) + + # IAM users keep existing policy-name validation for backwards compatibility. + if ":user/" in caller_arn: + user_name = caller_arn.split("/")[-1] + iam_client = boto3.client( + "iam", + aws_access_key_id=provider_details["accessKey"], + aws_secret_access_key=provider_details["secretKey"], + aws_session_token=provider_details.get("sessionToken"), + region_name=provider_details["region"], + ) + policies = iam_client.list_attached_user_policies(UserName=user_name) + policy_names = [ + policy["PolicyName"] for policy in policies["AttachedPolicies"] + ] + + permission_reader = "ViewOnlyAccess" in policy_names + permission_cost = "AWSBillingReadOnlyAccess" in policy_names + + if permission_reader and permission_cost: + permission_valid = True + logs = "ViewOnlyAccess and AWSBillingReadOnlyAccess policies validated." + elif permission_reader: + logs = "ViewOnlyAccess policy validated, but AWSBillingReadOnlyAccess policy validation failed." + elif permission_cost: + logs = "AWSBillingReadOnlyAccess policy validated, but ViewOnlyAccess policy validation failed." + else: + logs = "Both ViewOnlyAccess and AWSBillingReadOnlyAccess policy validations failed." + else: + # Assumed roles/federated identities (e.g. GitHub OIDC) are validated by capabilities. + reader_error = "" + cost_error = "" + + try: + ec2_client = boto3.client( + "ec2", + aws_access_key_id=provider_details["accessKey"], + aws_secret_access_key=provider_details["secretKey"], + aws_session_token=provider_details.get("sessionToken"), + region_name=provider_details["region"], + ) + ec2_client.describe_regions() + permission_reader = True + except Exception as e: + reader_error = str(e) + + try: + ce_client = boto3.client( + "ce", + aws_access_key_id=provider_details["accessKey"], + aws_secret_access_key=provider_details["secretKey"], + aws_session_token=provider_details.get("sessionToken"), + region_name="us-east-1", + ) + today = datetime.now(timezone.utc).date() + ce_client.get_cost_and_usage( + TimePeriod={ + "Start": today.replace(day=1).isoformat(), + "End": (today + timedelta(days=1)).isoformat(), + }, + Granularity="MONTHLY", + Metrics=["UnblendedCost"], + ) + permission_cost = True + except Exception as e: + cost_error = str(e) + + if permission_reader and permission_cost: + permission_valid = True + logs = ( + "AWS role/federated capability checks validated " + "(ec2:DescribeRegions and ce:GetCostAndUsage)." + ) + elif permission_reader: + logs = "AWS capability checks: ec2:DescribeRegions validated, but ce:GetCostAndUsage failed." + if cost_error: + logs += f" Details: {cost_error}" + elif permission_cost: + logs = "AWS capability checks: ce:GetCostAndUsage validated, but ec2:DescribeRegions failed." + if reader_error: + logs += f" Details: {reader_error}" + else: + logs = "AWS capability checks failed for both ec2:DescribeRegions and ce:GetCostAndUsage." + details = ", ".join(d for d in [reader_error, cost_error] if d) + if details: + logs += f" Details: {details}" + + except NoCredentialsError as e: + # Expected outcome of a permission test; surfaced to the user via `logs`. + logs = f"AWS credentials validation failed: {str(e)}" + logger.warning(logs) + except Exception as e: + logs = f"AWS permission test failed: {str(e)}" + logger.error(logs) + + permission_valid = permission_reader and permission_cost + + return permission_valid, permission_reader, permission_cost, logs + + +# Stage 3 +def create_resource_inventory( + cloud_service_provider: int, + provider_details: dict[str, Any], + report_path: str, + raw_data_path: str, +) -> dict[str, Any]: + # Copy assets and datasets folders data + try: + copy_assets(report_path, cloud_service_provider) + except CatalogueError as e: + logger.error(f"The catalogue cannot be used: {str(e)}") + return {"success": False, "logs": str(e)} + + try: + + if provider_details.get("tfstatePath"): # Terraform / OpenTofu state file + coverage = build_tfstate_resource_inventory( + cloud_service_provider, provider_details, report_path, raw_data_path + ) + return { + "success": True, + "logs": "Resource inventory created successfully.", + "coverage": coverage, + } + elif cloud_service_provider == 1: # Azure + build_azure_resource_inventory( + cloud_service_provider, provider_details, report_path, raw_data_path + ) + elif cloud_service_provider == 2: # AWS + build_aws_resource_inventory( + cloud_service_provider, provider_details, report_path, raw_data_path + ) + + return {"success": True, "logs": "Resource inventory created successfully."} + + except Exception as e: + logger.error(f"Error creating resource inventory: {str(e)}", exc_info=True) + # Do not raise the exception here; just return the error information + return {"success": False, "logs": str(e)} + + +# Stage 4 +def create_cost_inventory( + cloud_service_provider: int, + provider_details: dict[str, Any], + report_path: str, + raw_data_path: str, +) -> dict[str, Any]: + try: + if cloud_service_provider == 1: # Azure + build_azure_cost_inventory( + cloud_service_provider, provider_details, report_path, raw_data_path + ) + elif cloud_service_provider == 2: # AWS + build_aws_cost_inventory( + cloud_service_provider, provider_details, report_path, raw_data_path + ) + + return {"success": True, "logs": "Cost inventory created successfully."} + + except CostRateLimited as e: + logger.debug("Cost inventory stopped by rate limiting", exc_info=True) + return {"success": False, "logs": str(e)} + except Exception as e: + logger.error(f"Error creating cost inventory: {str(e)}", exc_info=True) + return {"success": False, "logs": str(e)} + + +# Stage 6 - Local risk assessment (every run, connected or not) +def perform_risk_assessment(exit_strategy: int, report_path: str) -> dict[str, Any]: + try: + # Define the database path + db_path = os.path.join(report_path, "data", "assessment.db") + + # Load data from the database + resource_inventory = load_data("resource_inventory", db_path=db_path) + alternatives = load_data("alternative", db_path=db_path) + alternative_technologies = load_data("alternativetechnology", db_path=db_path) + + # Initialize risk inventory + risk_inventory = [] + + # Calculate the total count of resources across all types + total_resource_count = sum(item["count"] for item in resource_inventory) + + # Calculate total number of distinct resource types + distinct_resource_types = set( + item["resource_type"] for item in resource_inventory + ) + total_resource_types = len(distinct_resource_types) + + # Process each resource by `resource_type` + for resource_data in resource_inventory: + resource_type_id = str( + resource_data["resource_type"] + ) # Convert to string for consistent comparison + + # Filter alternatives for the current resource_type and exit strategy + relevant_alternatives = [ + alt + for alt in alternatives + if str(alt["resource_type"]) == resource_type_id + and str(alt["strategy_type"]) == str(exit_strategy) + ] + alternative_count = len(relevant_alternatives) + + # Count alternatives with support + support_count = sum( + 1 + for alt in relevant_alternatives + if any( + tech["id"] == alt["alternative_technology"] + and tech["support_plan"] == "t" + for tech in alternative_technologies + ) + ) + + # Determine risks based on criteria, using resource_type_id in output + if 1 <= alternative_count < 3: + risk_inventory.append({"resource_type": resource_type_id, "risk": "1"}) + if alternative_count == 0: + risk_inventory.append({"resource_type": resource_type_id, "risk": "2"}) + if 1 <= support_count < 3: + risk_inventory.append({"resource_type": resource_type_id, "risk": "3"}) + if support_count == 0: + risk_inventory.append({"resource_type": resource_type_id, "risk": "4"}) + + # Check for risks based on total resource count across all types + if 15 < total_resource_count <= 30: + risk_inventory.append({"resource_type": "null", "risk": "5"}) + elif total_resource_count > 30: + risk_inventory.append({"resource_type": "null", "risk": "6"}) + + # Check for risks based on total number of resource types + if 15 < total_resource_types <= 30: + risk_inventory.append({"resource_type": "null", "risk": "7"}) + elif total_resource_types > 30: + risk_inventory.append({"resource_type": "null", "risk": "8"}) + + # Insert risk inventory into the database + with connect(db_path=db_path) as conn: + cursor = conn.cursor() + cursor.executemany( + """ + INSERT INTO risk_inventory (resource_type, risk) + VALUES (?, ?) + """, + [(entry["resource_type"], entry["risk"]) for entry in risk_inventory], + ) + conn.commit() + + return {"success": True, "logs": "Risk assessment completed successfully."} + + except Exception as e: + logger.error(f"Error performing risk assessment: {str(e)}", exc_info=True) + return {"success": False, "logs": str(e)} + + +def _load_tfstate_scope( + raw_data_path: str, +) -> tuple[dict[str, Any] | None, dict[str, Any] | None]: + # A missing or unreadable manifest must not fail report generation. Older + # manifests have no coverage. + manifest_path = os.path.join(raw_data_path, "tfstate_manifest.json") + try: + with open(manifest_path, "r", encoding="utf-8") as manifest_file: + manifest = json.load(manifest_file) + return manifest.get("scope"), manifest.get("coverage") + except (OSError, json.JSONDecodeError) as e: + logger.warning("Could not read the tfstate manifest for report scope: %s", e) + return None, None + + +# Stage 8 +def generate_report( + cloud_service_provider: int, + provider_details: dict[str, Any], + exit_strategy: int, + assessment_type: int, + name: str, + report_path: str, + 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") + + # Load data + resource_type_mapping = { + str(item["id"]): item for item in load_data("resourcetype", db_path=db_path) + } + risk_definitions = load_data("risk", db_path=db_path) + alternatives = load_data("alternative", db_path=db_path) + alternative_technologies = load_data("alternativetechnology", db_path=db_path) + alternative_technology_organizations = load_data( + "alternativetechnologyorganization", db_path=db_path + ) + resource_inventory = load_data("resource_inventory", db_path=db_path) + cost_data = load_data("cost_inventory", db_path=db_path) + risk_data = load_data("risk_inventory", db_path=db_path) + # The local report is always the Basic report: platform results are + # never rendered here, whatever the database holds. + scoring_data = None + + # Timestamp + timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") + + metadata = { + "name": name, + "cloud_service_provider": cloud_service_provider, + "exit_strategy": exit_strategy, + "assessment_type": assessment_type, + "timestamp": timestamp, + } + if upload: + # Only the link (or why there is none): never platform results + metadata["upload"] = upload + + # Generate Outputs + reports = {} + + # One context feeds all three reports, so they say the same things + tfstate_scope, tfstate_coverage = ( + _load_tfstate_scope(raw_data_path) + if provider_details.get("tfstatePath") + else (None, None) + ) + provenance = collect_provenance(db_path, run_id=sync_id, upload=upload) + context = build_context( + metadata=metadata, + provider_details=provider_details, + resource_type_mapping=resource_type_mapping, + resource_inventory=resource_inventory, + cost_data=cost_data, + risk_data=risk_data, + risk_definitions=risk_definitions, + alternatives=alternatives, + alternative_technologies=alternative_technologies, + alternative_technology_organizations=alternative_technology_organizations, + exit_strategy=exit_strategy, + tfstate_scope=tfstate_scope, + tfstate_coverage=tfstate_coverage, + provenance=provenance, + report_path=report_path, + ) + # The estimate is read once, for the page and for the egress PDF. When + # it cannot be read there is no egress in any report, and the failure + # is reported with the egress PDF's. + egress_estimate, egress_error = None, None + if egress_json_path: + try: + egress_estimate = load_egress_estimate(report_path, egress_json_path) + context.egress_document_id = context.document_id_for( + EGRESS_DOCUMENT_CODE + ) + except Exception as e: + logger.error("Error reading the egress estimate: %s", e, exc_info=True) + egress_error = str(e) + + # Generate HTML report + reports["HTML"] = generate_html_report(context, egress_estimate) + + # Generate PDF report + reports["PDF"] = generate_executive_summary(context) + + # Generate JSON report + reports["JSON"] = generate_json_report( + raw_data_path, + metadata, + resource_type_mapping, + resource_inventory, + cost_data, + scoring_data, + risk_data, + risk_definitions, + alternatives, + alternative_technologies, + exit_strategy, + provenance=provenance.as_dict(), + ) + + 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_error: + result["egress_pdf_logs"] = egress_error + elif egress_estimate is not None: + try: + reports["EGRESS_PDF"] = generate_egress_report( + context, egress_json_path, estimate=egress_estimate + ) + 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/log_redaction.py b/cloudexit/log_redaction.py new file mode 100644 index 0000000..8ea184f --- /dev/null +++ b/cloudexit/log_redaction.py @@ -0,0 +1,51 @@ +import logging +import re + +# botocore builds its request signature from the caller's credentials and logs +# the material at DEBUG, so -vv would otherwise copy the access key ID, the +# session token and the signature into the console and into run.log, which is a +# file users attach to bug reports. Redacting the values keeps the surrounding +# request detail readable. +REDACTED = "REDACTED" + +_SECRET_KEYS = ( + "authorization", + "x-amz-security-token", + "aws_secret_access_key", + "aws_session_token", + "client_secret", +) + +_RULES = [ + ( + re.compile( + rf"(?i)({re.escape(key)}['\"]?\s*[:=]\s*)(b?['\"][^'\"]*['\"]|[^,\r\n]*)" + ), + rf"\1{REDACTED}", + ) + for key in _SECRET_KEYS +] + [ + (re.compile(r"(?i)(credential=)[^/\s,]+(/)"), rf"\1{REDACTED}\2"), + (re.compile(r"(?i)(signature\s*[=:]\s*)[^\s,&'\"]+"), rf"\1{REDACTED}"), +] + + +def redact(text: str) -> str: + for pattern, replacement in _RULES: + text = pattern.sub(replacement, text) + return text + + +class SecretRedactingFilter(logging.Filter): + def filter(self, record: logging.LogRecord) -> bool: + # Redact the formatted text, because the secret often sits in an object + # passed as a %s argument rather than in the format string itself. + try: + message = record.getMessage() + except Exception: + return True + cleaned = redact(message) + if cleaned != message: + record.msg = cleaned + record.args = () + return True diff --git a/cloudexit/platform/__init__.py b/cloudexit/platform/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cloudexit/platform/client.py b/cloudexit/platform/client.py new file mode 100644 index 0000000..81f64be --- /dev/null +++ b/cloudexit/platform/client.py @@ -0,0 +1,952 @@ +from __future__ import annotations + +import hashlib +import json +import logging +import os +import random +import re +import sys +import time +import uuid +import zlib +from dataclasses import dataclass +from datetime import timezone +from email.utils import parsedate_to_datetime +from pathlib import Path +from typing import Any, Callable, Mapping +from urllib.parse import quote, urlsplit + +import requests + +from cloudexit import codes +from cloudexit.settings import ( + DEFAULT_UPLOAD_BUDGET, + KEY_PREFIXES, + Setting, + SettingsError, + normalize_host, +) +from cloudexit.version import __version__ + +from cloudexit.platform.shape import as_dict, as_list +from cloudexit.platform.document import ( + MAX_BODY_BYTES, + MAX_CATALOGUE_TYPE_IDS, + MAX_COST_ITEMS, + MAX_COUNT_PER_ITEM, + MAX_EGRESS_ITEMS, + MAX_FLAGS, + MAX_LOCATION_CHARS, + MAX_NAME_CHARS, + MAX_NOTES, + MAX_RESOURCE_ITEMS, + MAX_TIERS, +) + +logger = logging.getLogger(__name__) + +API_PATH = "/api/v2" +CLIENT_NAME = "cloudexit" + +CONNECT_TIMEOUT = 10.0 +READ_TIMEOUT = 60.0 +PREFER_WAIT = 20 +META_TTL_SECONDS = 300 +POLL_DEFAULT_SECONDS = 2.0 +POLL_MAX_SECONDS = 30.0 +PENDING_STATUSES = ("accepted", "processing") + +# A request that failed in one of these ways, or that the platform marked +# retryable, is tried again; every other failure is final. +RETRY_STATUSES = (408, 425, 429, 500, 502, 503, 504) +MAX_ATTEMPTS = 6 +BACKOFF_BASE = 1.0 +BACKOFF_FACTOR = 2.0 +BACKOFF_CAP = 60.0 + +BASE62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" +KEY_BODY = re.compile(r"[0-9A-Za-z]{54}") +CHECKSUM_CHARS = 6 +# What a platform must say about itself in /meta for a key of each kind. An +# EscapeCloud key works on any host name, address or port and in any region. +KEY_PLATFORMS = { + "esc_sk_": ("escapecloud", None), + "exc_eu_sk_": ("exitcloud", "eu"), + "exc_us_sk_": ("exitcloud", "us"), +} +PRODUCT_NAMES = {"escapecloud": "EscapeCloud", "exitcloud": "ExitCloud"} + +EXIT_STRATEGIES = {1: "repatriation", 2: "hybrid_cloud", 3: "alternate_cloud"} + +DEFAULT_LIMITS = { + "max_body_bytes": MAX_BODY_BYTES, + "max_resource_items": MAX_RESOURCE_ITEMS, + "max_count_per_item": MAX_COUNT_PER_ITEM, + "max_location_chars": MAX_LOCATION_CHARS, + "max_cost_items": MAX_COST_ITEMS, + "max_egress_items": MAX_EGRESS_ITEMS, + "max_name_chars": MAX_NAME_CHARS, + "max_egress_tiers_per_item": MAX_TIERS, + "max_egress_flags_per_item": MAX_FLAGS, + "max_egress_notes_per_item": MAX_NOTES, + "max_catalogue_type_ids": MAX_CATALOGUE_TYPE_IDS, + "clock_skew_seconds": 86400, + "max_collection_age_days": 30, +} + +# Written by hand from docs/api-v2/errors.yaml; a test compares the two. +ERROR_EXIT_CODES = { + "unauthenticated": codes.UPLOAD_AUTH, + "invalid_api_key": codes.UPLOAD_AUTH, + "api_key_expired": codes.UPLOAD_AUTH, + "api_key_revoked": codes.UPLOAD_AUTH, + "wrong_region": codes.UPLOAD_AUTH, + "principal_inactive": codes.UPLOAD_AUTH, + "insufficient_scope": codes.UPLOAD_AUTH, + "key_binding_violation": codes.UPLOAD_AUTH, + "assessment_key_v1": codes.UPLOAD_AUTH, + "idempotency_key_missing": codes.UPLOAD_REJECTED, + "idempotency_key_mismatch": codes.UPLOAD_REJECTED, + "idempotency_key_reused": codes.UPLOAD_REJECTED, + "request_in_progress": codes.UPLOAD_UNAVAILABLE, + "invalid_json": codes.UPLOAD_REJECTED, + "unsupported_media_type": codes.UPLOAD_REJECTED, + "payload_too_large": codes.UPLOAD_REJECTED, + "limit_exceeded": codes.UPLOAD_REJECTED, + "validation_failed": codes.UPLOAD_REJECTED, + "catalogue_mismatch": codes.UPLOAD_REJECTED, + "catalogue_sha256_mismatch": codes.UPLOAD_REJECTED, + "unsupported_schema_version": codes.UPLOAD_UNSUPPORTED, + "client_version_unsupported": codes.UPLOAD_UNSUPPORTED, + "assessment_not_found": codes.UPLOAD_TARGET, + "sync_not_found": codes.UPLOAD_TARGET, + "assessment_deleted": codes.UPLOAD_TARGET, + "assessment_archived": codes.UPLOAD_TARGET, + "target_mismatch": codes.UPLOAD_TARGET, + "insufficient_credits": codes.UPLOAD_QUOTA, + "quota_exceeded": codes.UPLOAD_QUOTA, + "rate_limited": codes.UPLOAD_UNAVAILABLE, + "maintenance": codes.UPLOAD_UNAVAILABLE, + "service_unavailable": codes.UPLOAD_UNAVAILABLE, + "internal_error": codes.UPLOAD_UNAVAILABLE, +} + + +class ApiError(Exception): + def __init__( + self, + code: str, + message: str, + exit_code: int, + *, + status: int | None = None, + request_id: str | None = None, + retryable: bool = False, + ): + super().__init__(message) + self.code = code + self.message = message + self.exit_code = exit_code + self.status = status + self.request_id = request_id + self.retryable = retryable + self.retry_after: float | None = None + self.attempts = 1 + self.exhausted = False + + +@dataclass(frozen=True) +class KeyInfo: + prefix: str + product: str + region: str | None + + +@dataclass(frozen=True) +class PushResult: + status: str + sync_id: str | None + assessment_id: str | None + assessment_name: str | None + assessment_created: bool + url: str | None + replayed: bool + pending: bool + warnings: tuple[str, ...] + request_id: str | None + billing: dict[str, int] | None = None + warning_items: tuple[dict[str, str | None], ...] = () + results: dict[str, Any] | None = None + catalogue: dict[str, Any] | None = None + + +def exit_code_for(code: str | None, status: int | None, retryable: bool) -> int: + if code in ERROR_EXIT_CODES: + return ERROR_EXIT_CODES[code] + # An unknown code is judged by the kind of HTTP status and the retryable flag + if retryable or (status is not None and (status >= 500 or status < 400)): + return codes.UPLOAD_UNAVAILABLE + if status in (401, 403): + return codes.UPLOAD_AUTH + if status == 402: + return codes.UPLOAD_QUOTA + if status in (404, 410): + return codes.UPLOAD_TARGET + return codes.UPLOAD_REJECTED + + +def parse_retry_after(value: Any, now: float) -> float | None: + # Retry-After is a number of seconds or an HTTP date + text = str(value or "").strip() + if not text: + return None + try: + return max(0.0, float(text)) + except ValueError: + pass + try: + when = parsedate_to_datetime(text) + except (TypeError, ValueError): + return None + if when.tzinfo is None: + when = when.replace(tzinfo=timezone.utc) + return max(0.0, when.timestamp() - now) + + +def checksum(text: str) -> str: + value = zlib.crc32(text.encode("ascii")) + digits = [] + for _ in range(CHECKSUM_CHARS): + value, digit = divmod(value, 62) + digits.append(BASE62[digit]) + return "".join(reversed(digits)) + + +def _base_url(host: str) -> str: + try: + return normalize_host(host) + except SettingsError as exc: + raise ApiError("invalid_host", str(exc), codes.UPLOAD_UNAVAILABLE) from None + + +def check_key(key: str | None) -> KeyInfo: + # Local only: a typo is caught before anything is sent, and the key never + # appears in a message. + prefix = next((p for p in KEY_PREFIXES if key and key.startswith(p)), None) + if not prefix: + raise ApiError( + "invalid_api_key", + "the API key does not start with " + ", ".join(KEY_PREFIXES), + codes.UPLOAD_AUTH, + ) + body = key[len(prefix) :] + if ( + not KEY_BODY.fullmatch(body) + or checksum(key[:-CHECKSUM_CHARS]) != key[-CHECKSUM_CHARS:] + ): + raise ApiError( + "invalid_api_key", + "the API key is not valid (check it for typos)", + codes.UPLOAD_AUTH, + ) + product, region = KEY_PLATFORMS[prefix] + return KeyInfo(prefix, product, region) + + +def _describe(product: str, region: str | None) -> str: + name = PRODUCT_NAMES.get(product, product) + return f"{name} {region.upper()}" if region else name + + +def check_platform(key: KeyInfo, meta: Mapping[str, Any]) -> None: + # Catches a configuration mistake (a key for another product or region), + # not a hostile host: the trust anchor is HTTPS to the host the user + # configured. A platform that does not say what it is is left to decide. + host = meta.get("host") + if not isinstance(host, dict): + return + product = host.get("product") + if product not in PRODUCT_NAMES: + return + region = host.get("region") + region = region.lower() if isinstance(region, str) and region else None + if product != key.product or (key.region and region and region != key.region): + raise ApiError( + "wrong_region", + f"This is an {_describe(key.product, key.region)} key, but the " + f"platform reports {_describe(product, region)}.", + codes.UPLOAD_AUTH, + ) + + +def resolve_ca_bundle( + configured: str | None, environ: Mapping[str, str] | None = None +) -> str | bool: + environ = os.environ if environ is None else environ + if configured: + return str(configured) + for name in ("REQUESTS_CA_BUNDLE", "SSL_CERT_FILE"): + value = environ.get(name, "").strip() + if value: + return value + return True + + +def request_timeout(setting: Setting | None) -> tuple[float, float]: + if setting is not None and setting.source != "default" and setting.value: + return float(setting.value), float(setting.value) + return CONNECT_TIMEOUT, READ_TIMEOUT + + +def detect_ci(environ: Mapping[str, str]) -> str: + if environ.get("GITHUB_ACTIONS") == "true": + return "github" + if environ.get("GITLAB_CI"): + return "gitlab" + if environ.get("TF_BUILD"): + return "azure" + return "none" + + +def user_agent(environ: Mapping[str, str] | None = None) -> str: + environ = os.environ if environ is None else environ + system = {"win32": "windows", "darwin": "macos"}.get(sys.platform, sys.platform) + python = f"{sys.version_info.major}.{sys.version_info.minor}" + return f"{CLIENT_NAME}/{__version__} (python/{python}; {system}; ci={detect_ci(environ)})" + + +def cache_dir( + environ: Mapping[str, str] | None = None, *, platform: str = sys.platform +) -> Path: + environ = os.environ if environ is None else environ + if platform == "win32": + base = environ.get("LOCALAPPDATA") or str(Path.home() / "AppData" / "Local") + return Path(base) / "cloudexit" / "cache" + xdg = environ.get("XDG_CACHE_HOME", "") + base = xdg if xdg and os.path.isabs(xdg) else str(Path.home() / ".cache") + return Path(base) / "cloudexit" + + +def _version(text: Any) -> tuple[int, ...] | None: + parts = re.findall(r"\d+", str(text).split("+")[0].split("-")[0])[:3] + return ( + tuple(int(part) for part in parts) + (0,) * (3 - len(parts)) if parts else None + ) + + +def _one_line(text: Any) -> str: + return " ".join(str(text).split()) + + +def client_policy(meta: Mapping[str, Any]) -> tuple[str, str]: + # What the platform says about this version of cloudexit: accepted, + # deprecated or unsupported, and why. + policy = as_dict(meta.get("clients")).get(CLIENT_NAME) + current = _version(__version__) + if not isinstance(policy, dict) or not current: + return "accepted", f"cloudexit {__version__} is accepted" + advice = ( + f"; upgrade to {policy['recommended']}" if policy.get("recommended") else "" + ) + minimum = _version(policy.get("min_supported")) + blocked = [_version(v) for v in as_list(policy.get("blocked"))] + if current in blocked or (minimum and current < minimum): + return ( + "unsupported", + f"cloudexit {__version__} is not accepted by the platform" + + ( + f"; use {policy['recommended']} or newer" + if policy.get("recommended") + else "" + ), + ) + deprecated = _version(policy.get("deprecated_below")) + if deprecated and current < deprecated: + return "deprecated", f"cloudexit {__version__} is deprecated{advice}" + return "accepted", f"cloudexit {__version__} is accepted" + + +def _billing(value: Any) -> dict[str, int] | None: + # What the platform charged for this sync, on hosts that charge + if not isinstance(value, dict): + return None + found = { + key: value[key] + for key in ("charged_credits", "balance") + if isinstance(value.get(key), int) and not isinstance(value.get(key), bool) + } + return found or None + + +def _warning_items(items: Any) -> list[dict[str, str | None]]: + # A warning is never dropped, even one without a message: --strict counts it + found = [] + for item in items if isinstance(items, list) else []: + if not isinstance(item, dict): + continue + code = item.get("code") + message = item.get("message") or code + if not message: + continue + found.append( + { + "code": str(code) if code else None, + "message": _one_line(message), + } + ) + return found + + +def warning_line(item: Mapping[str, Any]) -> str: + return ( + f"{item['message']} ({item['code']})" if item.get("code") else item["message"] + ) + + +def limit_problems( + document: dict[str, Any], limits: Mapping[str, Any], *, size: int, now: float +) -> list[str]: + def limit(name: str) -> int: + value = limits.get(name) + ok = isinstance(value, int) and not isinstance(value, bool) + return value if ok else DEFAULT_LIMITS[name] + + problems: list[str] = [] + + def over(name: str, actual: int, what: str) -> None: + if actual > limit(name): + problems.append(f"{what} is {actual}, above the limit of {limit(name)}") + + def longest(items: list[Any], field: str) -> int: + return max((len(item.get(field) or []) for item in items), default=0) + + inventory = document.get("inventory") or {} + resources = inventory.get("resources") or [] + egress_items = (inventory.get("egress") or {}).get("items") or [] + sync = document.get("sync") or {} + claim = (sync.get("collector") or {}).get("catalogue") or {} + create = (document.get("target") or {}).get("create") or {} + + over("max_body_bytes", size, "the request body (bytes)") + over("max_resource_items", len(resources), "the number of resource items") + over( + "max_count_per_item", + max((item.get("count") or 0 for item in resources), default=0), + "the largest resource count", + ) + over( + "max_location_chars", + max((len(str(item.get("location"))) for item in resources), default=0), + "the longest resource location", + ) + over("max_cost_items", len(inventory.get("costs") or []), "the number of costs") + over("max_egress_items", len(egress_items), "the number of egress items") + over("max_egress_flags_per_item", longest(egress_items, "flags"), "egress flags") + over("max_egress_notes_per_item", longest(egress_items, "notes"), "egress notes") + over("max_egress_tiers_per_item", longest(egress_items, "tiers"), "egress tiers") + over( + "max_catalogue_type_ids", + len(claim.get("resource_type_ids") or []), + "the number of catalogue type ids", + ) + for field in ("name", "group"): + over("max_name_chars", len(create.get(field) or ""), f"the assessment {field}") + + completed_at = sync.get("completed_at") + if isinstance(completed_at, int): + max_age = limit("max_collection_age_days") * 86400 + if now - completed_at > max_age: + problems.append( + f"the collection is older than {limit('max_collection_age_days')} days" + ) + if completed_at - now > limit("clock_skew_seconds"): + problems.append("the collection time is ahead of the clock") + return problems + + +class ApiClient: + def __init__( + self, + host: str, + api_key: str, + *, + ca_bundle: str | None = None, + timeout: tuple[float, float] | None = None, + environ: Mapping[str, str] | None = None, + cache_path: Path | None = None, + sleep: Callable[[float], None] = time.sleep, + clock: Callable[[], float] = time.time, + monotonic: Callable[[], float] = time.monotonic, + budget: float = DEFAULT_UPLOAD_BUDGET, + max_attempts: int = MAX_ATTEMPTS, + rng: random.Random | None = None, + ): + environ = os.environ if environ is None else environ + self.base_url = _base_url(host) + self._key_info = check_key(api_key) + self.verify = resolve_ca_bundle(ca_bundle, environ) + if isinstance(self.verify, str) and not os.path.exists(self.verify): + raise ApiError( + "tls_error", + f"the CA bundle {self.verify} does not exist", + codes.UPLOAD_UNAVAILABLE, + ) + self.timeout = timeout or (CONNECT_TIMEOUT, READ_TIMEOUT) + self._key = api_key + self._cache_dir = cache_path or cache_dir(environ) + self._sleep = sleep + self._clock = clock + self._monotonic = monotonic + self.budget = budget + self.max_attempts = max(1, max_attempts) + self._random = rng or random.Random() + self._deadline: float | None = None + self._meta: dict[str, Any] | None = None + self._platform_verified = False + + self.session = requests.Session() + self.session.headers["User-Agent"] = user_agent(environ) + # A truthy auth stops requests from replacing the Bearer header with a + # ~/.netrc login for the host. + self.session.auth = lambda request: request + + @classmethod + def from_settings(cls, settings: Any, **options: Any) -> ApiClient: + host, key = settings.host.value, settings.api_key.value + if not host or not key: + raise ApiError( + "not_configured", + "no platform host or API key is configured", + codes.UPLOAD_AUTH, + ) + budget = getattr(settings, "upload_budget", None) + if budget is not None: + options.setdefault("budget", budget.value) + return cls( + host, + key, + ca_bundle=settings.ca_bundle.value, + timeout=request_timeout(settings.timeout), + **options, + ) + + def _error(self, response: requests.Response, request_id: str) -> ApiError: + status = response.status_code + request_id = response.headers.get("X-Request-ID") or request_id + try: + body = response.json() + except ValueError: + body = None + error = body.get("error") if isinstance(body, dict) else None + if isinstance(error, dict) and isinstance(error.get("code"), str): + code = error["code"] + message = _one_line(error.get("message") or code) + retryable = bool(error.get("retryable")) + request_id = error.get("request_id") or request_id + for detail in error.get("details") or []: + if isinstance(detail, dict) and detail.get("expected_host"): + message += f" (expected host: {detail['expected_host']})" + if "retryable" not in error: + retryable = ( + ERROR_EXIT_CODES[code] == codes.UPLOAD_UNAVAILABLE + if code in ERROR_EXIT_CODES + else status in RETRY_STATUSES + ) + else: + code, message = f"http_{status}", f"HTTP status {status}" + retryable = status in RETRY_STATUSES + message = message.replace(self._key, "[api key]") + failure = ApiError( + code, + message, + exit_code_for(code, status, retryable), + status=status, + request_id=request_id, + retryable=retryable, + ) + failure.retry_after = parse_retry_after( + response.headers.get("Retry-After"), self._clock() + ) + return failure + + def _delay(self, error: ApiError, attempt: int) -> float: + # Retry-After is honoured as it is; otherwise exponential backoff with + # full jitter, from 1 s, doubling, up to 60 s. + if error.retry_after is not None: + return error.retry_after + ceiling = min(BACKOFF_CAP, BACKOFF_BASE * BACKOFF_FACTOR ** (attempt - 1)) + return self._random.uniform(0.0, ceiling) + + def _call( + self, + method: str, + path: str, + *, + expected: tuple[int, ...], + body: bytes | None = None, + headers: Mapping[str, str] | None = None, + auth: bool = True, + ) -> tuple[dict[str, Any], requests.Response]: + if auth and not self._platform_verified: + # The key is only sent to a platform that is what the key is for + self.meta() + deadline = self._deadline + if deadline is None: + deadline = self._monotonic() + self.budget + attempt = 0 + while True: + attempt += 1 + try: + return self._send( + method, + path, + expected=expected, + body=body, + headers=headers, + auth=auth, + ) + except ApiError as error: + error.attempts = attempt + if not error.retryable: + raise + delay = self._delay(error, attempt) + if attempt >= self.max_attempts or delay > deadline - self._monotonic(): + error.exhausted = True + raise + logger.debug( + "%s %s failed (%s); retrying in %.1f s (attempt %d of %d)", + method, + path, + error.code, + delay, + attempt, + self.max_attempts, + ) + self._sleep(delay) + + def _send( + self, + method: str, + path: str, + *, + expected: tuple[int, ...], + body: bytes | None, + headers: Mapping[str, str] | None, + auth: bool, + ) -> tuple[dict[str, Any], requests.Response]: + request_id = uuid.uuid4().hex + send = {"Accept": "application/json", "X-Request-ID": request_id} + send.update(headers or {}) + if auth: + send["Authorization"] = f"Bearer {self._key}" + logger.debug( + "%s %s request_id=%s body_bytes=%d", + method, + path, + request_id, + len(body or b""), + ) + host = urlsplit(self.base_url).hostname + try: + response = self.session.request( + method, + f"{self.base_url}{API_PATH}{path}", + data=body, + headers=send, + timeout=self.timeout, + verify=self.verify, + allow_redirects=False, + ) + except requests.exceptions.SSLError as exc: + logger.debug("TLS failure: %s", type(exc).__name__) + raise ApiError( + "tls_error", + f"could not verify the certificate of {host}; for a private CA " + "use --ca-bundle or CLOUDEXIT_CA_BUNDLE", + codes.UPLOAD_UNAVAILABLE, + request_id=request_id, + ) from None + except requests.exceptions.Timeout as exc: + logger.debug("Timeout: %s", type(exc).__name__) + raise ApiError( + "timeout", + f"{host} did not answer in time", + codes.UPLOAD_UNAVAILABLE, + request_id=request_id, + retryable=True, + ) from None + except (requests.exceptions.RequestException, OSError) as exc: + logger.debug("Request failure: %s", type(exc).__name__) + raise ApiError( + "connection_failed", + f"could not reach {host} ({type(exc).__name__})", + codes.UPLOAD_UNAVAILABLE, + request_id=request_id, + retryable=True, + ) from None + + logger.debug( + "%s %s -> %d request_id=%s", + method, + path, + response.status_code, + response.headers.get("X-Request-ID") or request_id, + ) + if response.status_code not in expected: + raise self._error(response, request_id) + try: + data = response.json() + except ValueError: + data = None + if not isinstance(data, dict): + raise ApiError( + "invalid_response", + "the platform answered with something that is not a JSON object", + codes.UPLOAD_UNAVAILABLE, + status=response.status_code, + request_id=response.headers.get("X-Request-ID") or request_id, + ) + return data, response + + def _read_meta_cache(self) -> dict[str, Any] | None: + try: + saved = json.loads(self._meta_file().read_text(encoding="utf-8")) + except (OSError, ValueError): + return None + if not isinstance(saved, dict) or saved.get("base_url") != self.base_url: + return None + fetched_at = saved.get("fetched_at") + meta = saved.get("meta") + if not isinstance(fetched_at, (int, float)) or not isinstance(meta, dict): + return None + if not 0 <= self._clock() - fetched_at < META_TTL_SECONDS: + return None + return meta + + def _write_meta_cache(self, meta: dict[str, Any]) -> None: + path = self._meta_file() + saved = { + "base_url": self.base_url, + "fetched_at": int(self._clock()), + "meta": meta, + } + try: + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_suffix(".tmp") + temporary.write_text(json.dumps(saved), encoding="utf-8") + os.replace(temporary, path) + except OSError: + logger.debug("The meta cache could not be written", exc_info=True) + + def _meta_file(self) -> Path: + digest = hashlib.sha256(self.base_url.encode("utf-8")).hexdigest()[:16] + return self._cache_dir / f"meta-{digest}.json" + + def meta(self, *, fresh: bool = False) -> dict[str, Any]: + if fresh: + self._meta = None + elif self._meta is None: + self._meta = self._read_meta_cache() + if self._meta is None: + data, _ = self._call("GET", "/meta", expected=(200,), auth=False) + if data.get("object") != "meta": + raise ApiError( + "invalid_response", + "the platform did not answer with its capabilities", + codes.UPLOAD_UNAVAILABLE, + ) + self._meta = data + self._write_meta_cache(data) + if not self._platform_verified: + check_platform(self._key_info, self._meta) + self._platform_verified = True + return self._meta + + def whoami(self) -> dict[str, Any]: + data, _ = self._call("GET", "/whoami", expected=(200,)) + return data + + def _check_meta( + self, meta: Mapping[str, Any], document: Mapping[str, Any] + ) -> list[dict[str, str | None]]: + versions = as_dict(meta.get("contract")).get("schema_versions") + schema_version = document.get("schema_version") + if isinstance(versions, list) and schema_version not in versions: + raise ApiError( + "unsupported_schema_version", + f"the platform accepts schema version {', '.join(map(str, versions))}" + f", not {schema_version}", + codes.UPLOAD_UNSUPPORTED, + ) + state, message = client_policy(meta) + if state == "unsupported": + raise ApiError( + "client_version_unsupported", message, codes.UPLOAD_UNSUPPORTED + ) + if state == "deprecated": + return [{"code": "client_deprecated", "message": message}] + return [] + + def _target( + self, + whoami: Mapping[str, Any], + name: str, + exit_strategy: int, + assessment_id: str | None, + ) -> dict[str, Any] | None: + if assessment_id: + try: + return {"assessment_id": str(uuid.UUID(str(assessment_id).strip()))} + except ValueError: + raise ApiError( + "invalid_assessment_id", + "the assessment id is not a valid id (expected a UUID)", + codes.UPLOAD_TARGET, + ) from None + if (whoami.get("key") or {}).get("kind") != "account": + return None + strategy = EXIT_STRATEGIES.get(exit_strategy) + if not strategy: + raise ApiError( + "invalid_target", + f"exit strategy {exit_strategy} cannot be sent to the platform", + codes.UPLOAD_REJECTED, + ) + return {"create": {"name": name, "exit_strategy": strategy}} + + def _settle( + self, data: dict[str, Any], response: requests.Response + ) -> tuple[dict[str, Any], bool]: + deadline = self._monotonic() + self.timeout[1] + while data.get("status") in PENDING_STATUSES: + platform_id = data.get("id") + remaining = deadline - self._monotonic() + if not isinstance(platform_id, str) or remaining <= 0: + return data, True + delay = _retry_after(response) + self._sleep(min(delay, remaining)) + data, response = self._call( + "GET", f"/syncs/{quote(platform_id, safe='')}", expected=(200,) + ) + if data.get("status") == "failed": + error = data.get("error") if isinstance(data.get("error"), dict) else {} + code = str(error.get("code") or "sync_failed") + retryable = bool(error.get("retryable")) + raise ApiError( + code, + _one_line(error.get("message") or "the platform could not process it"), + exit_code_for(code, None, retryable), + request_id=error.get("request_id") or data.get("request_id"), + retryable=retryable, + ) + return data, False + + def push( + self, + document: Mapping[str, Any], + *, + name: str, + exit_strategy: int | None, + assessment_id: str | None = None, + ) -> PushResult: + # Every request of this push draws on one budget of time + self._deadline = self._monotonic() + self.budget + try: + return self._push( + document, + name=name, + exit_strategy=exit_strategy, + assessment_id=assessment_id, + ) + finally: + self._deadline = None + + def _push( + self, + document: Mapping[str, Any], + *, + name: str, + exit_strategy: int | None, + assessment_id: str | None, + ) -> PushResult: + sync_id = (document.get("sync") or {}).get("id") + if not isinstance(sync_id, str) or not sync_id: + raise ApiError( + "invalid_document", + "the sync document has no sync id", + codes.UPLOAD_REJECTED, + ) + meta = self.meta() + items = self._check_meta(meta, document) + whoami = self.whoami() + items += _warning_items(whoami.get("warnings")) + + outgoing = {key: value for key, value in document.items() if key != "target"} + target = self._target(whoami, name, exit_strategy, assessment_id) + if target: + outgoing["target"] = target + body = json.dumps(outgoing, separators=(",", ":"), ensure_ascii=False).encode( + "utf-8" + ) + problems = limit_problems( + outgoing, as_dict(meta.get("limits")), size=len(body), now=self._clock() + ) + if problems: + raise ApiError( + "limit_exceeded", "; ".join(problems[:5]), codes.UPLOAD_REJECTED + ) + + data, response = self._call( + "POST", + "/syncs", + expected=(200, 201, 202), + body=body, + headers={ + "Content-Type": "application/json", + "Idempotency-Key": sync_id, + "Prefer": f"wait={PREFER_WAIT}", + }, + ) + replayed = response.headers.get("Idempotent-Replayed", "").lower() == "true" + data, pending = self._settle(data, response) + + items += _warning_items(data.get("warnings")) + assessment = data.get("assessment") + assessment = assessment if isinstance(assessment, dict) else {} + url = assessment.get("url") + return PushResult( + status=str(data.get("status") or ""), + sync_id=data.get("id") if isinstance(data.get("id"), str) else None, + assessment_id=assessment.get("id"), + assessment_name=assessment.get("name"), + assessment_created=assessment.get("created") is True, + url=( + url + if isinstance(url, str) and url.startswith(("https://", "http://")) + else None + ), + replayed=replayed, + pending=pending, + warnings=tuple(warning_line(item) for item in items), + request_id=response.headers.get("X-Request-ID") or data.get("request_id"), + # A replay charges nothing, whatever the stored answer says + billing=None if replayed else _billing(data.get("billing")), + warning_items=tuple(items), + results=( + data.get("results") if isinstance(data.get("results"), dict) else None + ), + catalogue=( + data.get("catalogue") + if isinstance(data.get("catalogue"), dict) + else None + ), + ) + + +def _retry_after(response: requests.Response) -> float: + try: + seconds = float(response.headers.get("Retry-After", "")) + except ValueError: + return POLL_DEFAULT_SECONDS + return max(0.0, min(seconds, POLL_MAX_SECONDS)) diff --git a/cloudexit/platform/document.py b/cloudexit/platform/document.py new file mode 100644 index 0000000..cb9fa50 --- /dev/null +++ b/cloudexit/platform/document.py @@ -0,0 +1,794 @@ +from __future__ import annotations + +import hashlib +import hmac +import json +import logging +import os +import re +import secrets +import sqlite3 +import sys +import time +import uuid +from dataclasses import dataclass +from datetime import datetime, timezone +from decimal import ROUND_HALF_UP, Decimal, InvalidOperation +from pathlib import Path +from typing import Any + +from cloudexit.catalogue.store import ( + CatalogueError, + active_resource_type_ids, + catalogue_dir, + catalogue_info, +) +from cloudexit.private_files import write_private +from cloudexit.version import __version__ + +from cloudexit.catalogue.db import load_data + +logger = logging.getLogger(__name__) + +SCHEMA_VERSION = "2.0" +COLLECTOR_NAME = "cloudexit" +PROVIDERS = {1: "azure", 2: "aws"} + +MAX_BODY_BYTES = 1_000_000 +MAX_RESOURCE_ITEMS = 1000 +MAX_COUNT_PER_ITEM = 10000 +MAX_LOCATION_CHARS = 255 +MAX_TYPE_CODE_CHARS = 255 +MAX_COST_ITEMS = 12 +MAX_EGRESS_ITEMS = 1000 +MAX_NAME_CHARS = 255 +MAX_FLAGS = 10 +MAX_FLAG_CHARS = 64 +MAX_NOTES = 10 +MAX_NOTE_CHARS = 255 +MAX_TIERS = 10 +MAX_TIER_CHARS = 50 +MAX_CATALOGUE_TYPE_IDS = 5000 +MAX_SIZE_BYTES = 2**53 - 1 +MAX_EPOCH = 4102444800 + +UUID = r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}" +UUID_RE = re.compile(f"^{UUID}$") +UUID7_RE = re.compile( + r"^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" +) +SHA256_RE = re.compile(r"^[0-9a-f]{64}$") +MONTH_RE = re.compile(r"^[0-9]{4}-(0[1-9]|1[0-2])$") +AMOUNT_RE = re.compile(r"^-?[0-9]+(\.[0-9]+)?$") +CURRENCY_RE = re.compile(r"^[A-Za-z]{3}$") +CATALOGUE_VERSION_RE = re.compile(r"^[0-9]{4}-[0-9]{2}-[0-9]{2}(\.[0-9]+)?$") +AWS_REGION_RE = re.compile(r"^(\*|[a-z]{2}(-[a-z]+)+-[0-9]+)$") +AZURE_GROUP_RE = re.compile(r"^[A-Za-z0-9_()\-.]{1,90}$") +SCOPE_KEY_MAX = 255 + + +class SyncDocumentError(Exception): + def __init__(self, message: str, problems: list[str] | None = None): + super().__init__(message) + self.problems = problems or [] + + +class ScopeError(ValueError): + def __init__(self, message: str, pointer: str = ""): + super().__init__(message) + self.pointer = pointer + + +@dataclass +class RunContext: + sync_id: str + started_at: int + cloud_service_provider: int + assessment_type: int + provider_details: dict[str, Any] + egress_collected: bool = False + egress_names: str = "plain" + completed_at: int | None = None + target: dict[str, Any] | None = None + ci: dict[str, Any] | None = None + + +@dataclass(frozen=True) +class SyncOutput: + path: Path + size: int + sha256: str + emitted: Path | None + summary: str + document: dict[str, Any] + + +def new_uuid7(now_ms: int | None = None) -> str: + if now_ms is None: + native = getattr(uuid, "uuid7", None) + if native is not None: + return str(native()) + now_ms = time.time_ns() // 1_000_000 + + value = (now_ms & (2**48 - 1)) << 80 + value |= 0x7 << 76 + value |= secrets.randbits(12) << 64 + value |= 0b10 << 62 + value |= secrets.randbits(62) + return str(uuid.UUID(int=value)) + + +def scope_key(scope: dict[str, Any]) -> str: + if not isinstance(scope, dict): + raise ScopeError("the scope must be an object") + provider = scope.get("provider") + if provider not in ("aws", "azure"): + raise ScopeError("the provider must be aws or azure", "/provider") + + if "tfstate" in scope: + for block in ("aws", "azure"): + if block in scope: + raise ScopeError( + "a Terraform state scope has no provider block", f"/{block}" + ) + tfstate = scope["tfstate"] if isinstance(scope["tfstate"], dict) else {} + lineage = tfstate.get("lineage") + if not isinstance(lineage, str) or not UUID_RE.match(lineage): + raise ScopeError("the lineage must be a UUID", "/tfstate/lineage") + key = f"tfstate:{lineage.lower()}" + elif provider == "aws": + key = _aws_key(_block(scope, "aws")) + else: + key = _azure_key(_block(scope, "azure")) + + if len(key) > SCOPE_KEY_MAX: + raise ScopeError("the scope key is too long") + return key + + +def _block(scope: dict[str, Any], name: str) -> dict[str, Any]: + block = scope.get(name) + if not isinstance(block, dict): + raise ScopeError(f"the {name} block is required", f"/{name}") + return block + + +def _aws_key(aws: dict[str, Any]) -> str: + account = aws.get("account_id") + if not isinstance(account, str) or not re.fullmatch(r"[0-9]{12}", account): + raise ScopeError("the account id must be 12 digits", "/aws/account_id") + + regions = aws.get("regions") + if not isinstance(regions, list) or not regions: + raise ScopeError("at least one region is required", "/aws/regions") + + distinct = [] + for index, region in enumerate(regions): + if not isinstance(region, str) or not AWS_REGION_RE.match(region): + raise ScopeError("not a region", f"/aws/regions/{index}") + if region not in distinct: + distinct.append(region) + region = distinct[0] if len(distinct) == 1 else "*" + return f"aws:{account}:{region}" + + +def _azure_key(azure: dict[str, Any]) -> str: + parts = [] + for name in ("tenant_id", "subscription_id"): + value = azure.get(name) + if not isinstance(value, str) or not UUID_RE.match(value): + raise ScopeError("must be a GUID", f"/azure/{name}") + parts.append(value.lower()) + + group = azure.get("resource_group") + if group is None: + group_key = "*" + elif ( + not isinstance(group, str) + or not AZURE_GROUP_RE.match(group) + or group.endswith(".") + ): + raise ScopeError("not a resource group name", "/azure/resource_group") + else: + group_key = group.lower() + return f"azure:{parts[0]}:{parts[1]}:{group_key}" + + +def decimal_amount(value: Any) -> str: + try: + amount = value if isinstance(value, Decimal) else Decimal(str(value)) + except InvalidOperation: + raise SyncDocumentError(f"cost amount {value!r} is not a number") from None + if not amount.is_finite(): + raise SyncDocumentError(f"cost amount {value!r} is not a finite number") + + try: + amount = amount.quantize(Decimal("0.000001"), rounding=ROUND_HALF_UP) + except InvalidOperation: + raise SyncDocumentError(f"cost amount {value!r} is too large") from None + if amount == 0: + return "0" + return format(amount.normalize(), "f") + + +def build_scope(context: RunContext, raw_data_path: str) -> dict[str, Any]: + provider = PROVIDERS.get(context.cloud_service_provider) + if provider is None: + raise SyncDocumentError( + f"unsupported cloud service provider {context.cloud_service_provider}" + ) + details = context.provider_details + + if details.get("tfstatePath"): + manifest_path = os.path.join(raw_data_path, "tfstate_manifest.json") + try: + with open(manifest_path, encoding="utf-8") as handle: + state_scope = json.load(handle).get("scope") or {} + except (OSError, ValueError) as exc: + raise SyncDocumentError( + f"cannot read the Terraform state scope: {exc}" + ) from exc + serial = state_scope.get("serial") + return { + "provider": provider, + "tfstate": { + "lineage": state_scope.get("lineage"), + "serial": serial if isinstance(serial, int) else None, + }, + } + + if provider == "aws": + return { + "provider": "aws", + "aws": { + "account_id": details.get("accountId"), + "regions": [details.get("region")], + }, + } + return { + "provider": "azure", + "azure": { + "tenant_id": details.get("tenantId"), + "subscription_id": details.get("subscriptionId"), + "resource_group": details.get("resourceGroupName") or None, + }, + } + + +def _cut(text: str, limit: int) -> tuple[str, bool]: + return (text[:limit], True) if len(text) > limit else (text, False) + + +def _resources(db_path: str) -> list[dict[str, Any]]: + codes = { + row["id"]: row["code"] for row in load_data("resourcetype", db_path=db_path) + } + rows = sorted( + load_data("resource_inventory", db_path=db_path), + key=lambda row: (int(row["resource_type"]), row["location"] or ""), + ) + + items = [] + for row in rows: + type_id = int(row["resource_type"]) + location = _cut(row["location"] or "unknown", MAX_LOCATION_CHARS)[0] + code = codes.get(type_id) + remaining = int(row["count"]) + # Counts above the per-item limit are split; the host merges items with + # the same type and location by summing their counts. + while True: + chunk = min(remaining, MAX_COUNT_PER_ITEM) + item = {"type_id": type_id} + if code: + item["type_code"] = _cut(code, MAX_TYPE_CODE_CHARS)[0] + item["location"] = location + item["count"] = chunk + items.append(item) + remaining -= chunk + if remaining <= 0: + break + return items + + +def _costs(db_path: str, completed_at: int) -> list[dict[str, Any]]: + current_month = datetime.fromtimestamp(completed_at, timezone.utc).strftime("%Y-%m") + items = [] + for row in load_data("cost_inventory", db_path=db_path): + month = str(row["month"])[:7] + currency = str(row["currency"]) + if not MONTH_RE.match(month) or not CURRENCY_RE.match(currency): + raise SyncDocumentError( + "a stored cost row has an invalid month or currency" + ) + items.append( + { + "month": month, + "amount": decimal_amount(row["cost"]), + "currency": currency.upper(), + "final": month < current_month, + } + ) + + items.sort(key=lambda item: item["month"]) + if len(items) > MAX_COST_ITEMS: + logger.warning( + "Cost data trimmed to the API v2 limit: %d month(s) dropped.", + len(items) - MAX_COST_ITEMS, + ) + items = items[-MAX_COST_ITEMS:] + return items + + +def salt_path(key: str) -> Path: + name = re.sub(r'[<>:"/\\|?*]', "_", key) if sys.platform == "win32" else key + return catalogue_dir().parent / "salts" / name + + +def load_salt(key: str) -> bytes: + path = salt_path(key) + path.parent.mkdir(parents=True, exist_ok=True) + for _ in range(2): + try: + return bytes.fromhex(path.read_text(encoding="ascii").strip()) + except FileNotFoundError: + pass + except (ValueError, OSError) as exc: + raise SyncDocumentError(f"the salt file {path} is unusable: {exc}") from exc + try: + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + except FileExistsError: + continue + with os.fdopen(fd, "w", encoding="ascii") as handle: + handle.write(secrets.token_hex(32) + "\n") + raise SyncDocumentError(f"cannot create the salt file {path}") + + +def hash_name(name: str, salt: bytes) -> str: + return hmac.new(salt, name.encode("utf-8"), hashlib.sha256).hexdigest() + + +def _json_list(raw: Any) -> list[str]: + try: + value = json.loads(raw or "[]") + except (TypeError, ValueError): + return [] + return [str(entry) for entry in value] if isinstance(value, list) else [] + + +def _egress(db_path: str, context: RunContext, key: str) -> dict[str, Any]: + if not context.egress_collected: + return {"collected": False, "truncated": False, "names": "plain", "items": []} + + hashed = context.egress_names == "hashed" + salt = load_salt(key) if hashed else b"" + + tiers_by_row: dict[int, list[dict[str, Any]]] = {} + for tier in load_data("egress_inventory_tier", db_path=db_path): + tiers_by_row.setdefault(tier["egress_inventory_id"], []).append(tier) + + trimmed = { + "items dropped": 0, + "names shortened": 0, + "sizes capped": 0, + "flags dropped": 0, + "notes dropped": 0, + "text shortened": 0, + "tiers dropped": 0, + } + + def size_of(value: Any) -> int | None: + if value is None: + return None + number = max(0, int(value)) + if number > MAX_SIZE_BYTES: + trimmed["sizes capped"] += 1 + return MAX_SIZE_BYTES + return number + + records = load_data("egress_inventory", db_path=db_path) + items = [] + for record in records: + name = str(record["name"]) + if hashed: + name = hash_name(name, salt) + else: + name, cut = _cut(name, MAX_NAME_CHARS) + trimmed["names shortened"] += cut + + size = None if record["size_unknown"] else size_of(record["size_bytes"]) + item: dict[str, Any] = { + "type_id": int(record["resource_type"]), + "name": name, + "size_bytes": size, + } + + for field, limit, chars in ( + ("flags", MAX_FLAGS, MAX_FLAG_CHARS), + ("notes", MAX_NOTES, MAX_NOTE_CHARS), + ): + values = _json_list(record[field]) + trimmed[f"{field} dropped"] += max(0, len(values) - limit) + kept = [] + for value in values[:limit]: + value, cut = _cut(value, chars) + trimmed["text shortened"] += cut + kept.append(value) + item[field] = kept + + tiers = sorted( + tiers_by_row.get(record["id"], []), + key=lambda tier: (-(tier["size_bytes"] or 0), str(tier["tier"])), + ) + trimmed["tiers dropped"] += max(0, len(tiers) - MAX_TIERS) + item["tiers"] = [] + for tier in tiers[:MAX_TIERS]: + label, cut = _cut(str(tier["tier"]), MAX_TIER_CHARS) + trimmed["text shortened"] += cut + item["tiers"].append( + { + "tier": label, + "size_bytes": size_of(tier["size_bytes"]), + "is_archive": bool(tier["is_archive"]), + } + ) + items.append(item) + + items.sort( + key=lambda item: ( + item["size_bytes"] is None, + -(item["size_bytes"] or 0), + item["type_id"], + item["name"], + ) + ) + if len(items) > MAX_EGRESS_ITEMS: + trimmed["items dropped"] = len(items) - MAX_EGRESS_ITEMS + items = items[:MAX_EGRESS_ITEMS] + + counts = {label: count for label, count in trimmed.items() if count} + if counts: + logger.warning( + "Egress data trimmed to the API v2 limits: %s.", + ", ".join(f"{count} {label}" for label, count in counts.items()), + ) + return { + "collected": True, + "truncated": bool(counts), + "names": "hashed" if hashed else "plain", + "items": items, + } + + +def _catalogue_claim(db_path: str, provider: str) -> dict[str, Any] | None: + try: + info = catalogue_info(db_path) + except CatalogueError: + return None + if ( + info is None + or not CATALOGUE_VERSION_RE.match(info.version) + or not isinstance(info.sequence, int) + or not isinstance(info.sha256, str) + or not SHA256_RE.match(info.sha256) + ): + # An older dataset has no usable catalogue_meta: the version is unknown. + return None + return { + "version": info.version, + "sequence": info.sequence, + "sha256": info.sha256, + "resource_type_ids": active_resource_type_ids(provider, db_path)[ + :MAX_CATALOGUE_TYPE_IDS + ], + } + + +def build_sync_document(report_path: str, context: RunContext) -> dict[str, Any]: + db_path = os.path.join(report_path, "data", "assessment.db") + raw_data_path = os.path.join(report_path, "raw_data") + provider = PROVIDERS.get(context.cloud_service_provider, "") + completed_at = context.completed_at or int(time.time()) + + scope = build_scope(context, raw_data_path) + try: + key = scope_key(scope) + except ScopeError as exc: + raise SyncDocumentError( + f"the scope is not valid ({exc.pointer or 'scope'}: {exc})" + ) from exc + + collector: dict[str, Any] = { + "name": COLLECTOR_NAME, + "version": __version__, + "mode": "tfstate" if "tfstate" in scope else "api", + } + try: + claim = _catalogue_claim(db_path, provider) + except sqlite3.Error as exc: + raise SyncDocumentError(f"cannot read the run's catalogue: {exc}") from exc + if claim: + collector["catalogue"] = claim + + sync: dict[str, Any] = { + "id": context.sync_id, + "started_at": context.started_at, + "completed_at": max(completed_at, context.started_at), + "collector": collector, + } + if context.ci: + sync["ci"] = context.ci + + document: dict[str, Any] = {"schema_version": SCHEMA_VERSION, "sync": sync} + if context.target: + document["target"] = context.target + document["assessment_type"] = ( + 3 if context.egress_collected else min(max(context.assessment_type, 1), 3) + ) + document["scope"] = scope + try: + document["inventory"] = { + "resources": _resources(db_path), + "costs": _costs(db_path, sync["completed_at"]), + "egress": _egress(db_path, context, key), + } + except sqlite3.Error as exc: + raise SyncDocumentError(f"cannot read the run's data: {exc}") from exc + return document + + +def _is_int(value: Any, low: int = 0, high: int | None = None) -> bool: + return ( + isinstance(value, int) + and not isinstance(value, bool) + and value >= low + and (high is None or value <= high) + ) + + +def _check_text( + problems: list[str], pointer: str, value: Any, limit: int, low: int = 0 +) -> None: + if not isinstance(value, str) or not low <= len(value) <= limit: + problems.append(f"{pointer}: must be a string of {low} to {limit} characters") + + +def validate_document(document: dict[str, Any]) -> None: + problems: list[str] = [] + + def require(pointer: str, ok: bool, message: str) -> None: + if not ok: + problems.append(f"{pointer}: {message}") + + for name in ("schema_version", "sync", "assessment_type", "scope", "inventory"): + require(f"/{name}", name in document, "is required") + if problems: + raise SyncDocumentError("the sync document is invalid", problems) + + require( + "/schema_version", + isinstance(document["schema_version"], str) + and re.match(r"^2\.[0-9]+$", document["schema_version"]), + "must look like 2.", + ) + require( + "/assessment_type", + _is_int(document["assessment_type"], 1, 3), + "must be 1, 2 or 3", + ) + + sync = document["sync"] + require("/sync/id", bool(UUID7_RE.match(str(sync.get("id")))), "must be a UUIDv7") + started, completed = sync.get("started_at"), sync.get("completed_at") + require("/sync/started_at", _is_int(started, 0, MAX_EPOCH), "must be epoch seconds") + require( + "/sync/completed_at", _is_int(completed, 0, MAX_EPOCH), "must be epoch seconds" + ) + if _is_int(started) and _is_int(completed): + require("/sync/completed_at", completed >= started, "is before started_at") + + collector = sync.get("collector") or {} + _check_text(problems, "/sync/collector/name", collector.get("name"), 64, 1) + _check_text(problems, "/sync/collector/version", collector.get("version"), 64, 1) + require( + "/sync/collector/mode", + collector.get("mode") in ("api", "tfstate"), + "must be api or tfstate", + ) + claim = collector.get("catalogue") + if claim is not None: + ids = claim.get("resource_type_ids") + require( + "/sync/collector/catalogue", + bool(CATALOGUE_VERSION_RE.match(str(claim.get("version")))) + and _is_int(claim.get("sequence")) + and bool(SHA256_RE.match(str(claim.get("sha256")))) + and isinstance(ids, list) + and len(ids) <= MAX_CATALOGUE_TYPE_IDS + and len(set(ids)) == len(ids) + and all(_is_int(entry, 1) for entry in ids), + "needs version, sequence, sha256 and unique resource_type_ids", + ) + + scope = document["scope"] + try: + scope_key(scope) + except ScopeError as exc: + problems.append(f"/scope{exc.pointer}: {exc}") + require( + "/scope", + ("tfstate" in scope) == (collector.get("mode") == "tfstate"), + "tfstate must be sent exactly when the collector mode is tfstate", + ) + require( + "/scope/provider", + scope.get("provider") in ("aws", "azure"), + "must be aws or azure", + ) + + inventory = document["inventory"] + resources = inventory.get("resources") + if not isinstance(resources, list) or len(resources) > MAX_RESOURCE_ITEMS: + problems.append( + f"/inventory/resources: needs at most {MAX_RESOURCE_ITEMS} items" + ) + resources = [] + for index, item in enumerate(resources): + pointer = f"/inventory/resources/{index}" + require(f"{pointer}/type_id", _is_int(item.get("type_id"), 1), "must be >= 1") + require( + f"{pointer}/count", + _is_int(item.get("count"), 0, MAX_COUNT_PER_ITEM), + f"must be 0 to {MAX_COUNT_PER_ITEM}", + ) + _check_text(problems, f"{pointer}/location", item.get("location"), 255) + if "type_code" in item: + _check_text(problems, f"{pointer}/type_code", item["type_code"], 255) + + costs = inventory.get("costs", []) + if not isinstance(costs, list) or len(costs) > MAX_COST_ITEMS: + problems.append(f"/inventory/costs: needs at most {MAX_COST_ITEMS} items") + costs = [] + for index, item in enumerate(costs): + pointer = f"/inventory/costs/{index}" + require( + f"{pointer}/month", + bool(MONTH_RE.match(str(item.get("month")))), + "is not YYYY-MM", + ) + amount = item.get("amount") + require( + f"{pointer}/amount", + isinstance(amount, str) + and len(amount) <= 40 + and bool(AMOUNT_RE.match(amount)), + "must be a decimal string", + ) + require( + f"{pointer}/currency", + bool(CURRENCY_RE.match(str(item.get("currency")))), + "must be three letters", + ) + require( + f"{pointer}/final", + isinstance(item.get("final", False), bool), + "must be a boolean", + ) + + egress = inventory.get("egress") + if egress is not None: + _validate_egress(egress, require, problems) + + body = json.dumps(document, separators=(",", ":"), ensure_ascii=False) + if len(body.encode("utf-8")) > MAX_BODY_BYTES: + problems.append(f"/: the document is larger than {MAX_BODY_BYTES} bytes") + + if problems: + raise SyncDocumentError("the sync document is invalid", problems) + + +def _validate_egress(egress: dict[str, Any], require: Any, problems: list[str]) -> None: + require( + "/inventory/egress/collected", + isinstance(egress.get("collected"), bool), + "must be a boolean", + ) + require( + "/inventory/egress/truncated", + isinstance(egress.get("truncated", False), bool), + "must be a boolean", + ) + require( + "/inventory/egress/names", + egress.get("names", "plain") in ("plain", "hashed"), + "must be plain or hashed", + ) + + items = egress.get("items", []) + if not isinstance(items, list) or len(items) > MAX_EGRESS_ITEMS: + problems.append( + f"/inventory/egress/items: needs at most {MAX_EGRESS_ITEMS} items" + ) + return + for index, item in enumerate(items): + pointer = f"/inventory/egress/items/{index}" + require(f"{pointer}/type_id", _is_int(item.get("type_id"), 1), "must be >= 1") + _check_text(problems, f"{pointer}/name", item.get("name"), MAX_NAME_CHARS) + size = item.get("size_bytes") + require( + f"{pointer}/size_bytes", + size is None or _is_int(size, 0, MAX_SIZE_BYTES), + "must be null or 0 to 2^53-1", + ) + for field, limit, chars in ( + ("flags", MAX_FLAGS, MAX_FLAG_CHARS), + ("notes", MAX_NOTES, MAX_NOTE_CHARS), + ): + values = item.get(field, []) + if not isinstance(values, list) or len(values) > limit: + problems.append(f"{pointer}/{field}: needs at most {limit} items") + continue + for position, value in enumerate(values): + _check_text(problems, f"{pointer}/{field}/{position}", value, chars) + tiers = item.get("tiers", []) + if not isinstance(tiers, list) or len(tiers) > MAX_TIERS: + problems.append(f"{pointer}/tiers: needs at most {MAX_TIERS} items") + continue + for position, tier in enumerate(tiers): + tier_pointer = f"{pointer}/tiers/{position}" + _check_text( + problems, f"{tier_pointer}/tier", tier.get("tier"), MAX_TIER_CHARS, 1 + ) + tier_size = tier.get("size_bytes") + require( + f"{tier_pointer}/size_bytes", + tier_size is None or _is_int(tier_size, 0, MAX_SIZE_BYTES), + "must be null or 0 to 2^53-1", + ) + require( + f"{tier_pointer}/is_archive", + isinstance(tier.get("is_archive", False), bool), + "must be a boolean", + ) + + +def summary_line(document: dict[str, Any], size: int, digest: str) -> str: + inventory = document["inventory"] + egress = inventory["egress"] + return ( + f"Sync document {document['sync']['id']}: " + f"{len(inventory['resources'])} resource item(s), " + f"{len(inventory['costs'])} cost month(s), " + f"egress collected={str(egress['collected']).lower()} " + f"truncated={str(egress['truncated']).lower()} " + f"({len(egress['items'])} item(s)), {size} bytes, sha256 {digest}" + ) + + +def write_sync_document( + document: dict[str, Any], raw_data_path: str, emit_dir: str | None = None +) -> SyncOutput: + data = (json.dumps(document, indent=2, ensure_ascii=False) + "\n").encode("utf-8") + digest = hashlib.sha256(data).hexdigest() + + path = Path(raw_data_path) / "sync.json" + write_private(path, data) + + emitted = None + if emit_dir: + emitted = Path(emit_dir) / f"{document['sync']['id']}.json" + emitted.parent.mkdir(parents=True, exist_ok=True) + write_private(emitted, data) + + summary = summary_line(document, len(data), digest) + logger.info(summary) + return SyncOutput(path, len(data), digest, emitted, summary, document) + + +def prepare_sync_document( + report_path: str, context: RunContext, emit_dir: str | None = None +) -> SyncOutput: + document = build_sync_document(report_path, context) + validate_document(document) + return write_sync_document( + document, os.path.join(report_path, "raw_data"), emit_dir + ) diff --git a/cloudexit/platform/run_output.py b/cloudexit/platform/run_output.py new file mode 100644 index 0000000..72a1097 --- /dev/null +++ b/cloudexit/platform/run_output.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +from typing import Any + +from cloudexit import codes +from cloudexit.catalogue.store import compare_catalogue_versions + +from cloudexit.platform.shape import as_dict, as_text +from cloudexit.platform.upload import UploadResult + + +def upload_status(result: UploadResult | None) -> str: + if result is None or not result.attempted: + return "skipped" + if result.success: + return "uploaded" + return "spooled" if result.spooled else "failed" + + +def catalogue_outdated( + local_version: str | None, result: UploadResult | None +) -> bool | None: + # True when the platform says so, or its catalogue is newer than the one + # used; None when nothing tells. + if result is None or not result.success: + return None + codes_seen = {as_dict(item).get("code") for item in result.warning_items} + if "collector_catalogue_outdated" in codes_seen: + return True + host = as_text(as_dict(as_dict(result.catalogue).get("host")).get("version")) + standing = compare_catalogue_versions(local_version, host) + if standing is None: + return None + return standing == "older" + + +def upload_json( + result: UploadResult | None, skipped_reason: str | None +) -> dict[str, Any]: + status = upload_status(result) + attempted = result is not None and result.attempted + error = None + if attempted and not result.success: + error = { + "code": result.error_code or result.reason, + "message": result.message, + "request_id": result.request_id, + } + return { + "status": status, + "reason": skipped_reason if status == "skipped" else None, + "assessment_id": result.assessment_id if attempted else None, + "sync_id": result.sync_id if attempted else None, + "platform_sync_id": result.platform_sync_id if attempted else None, + "url": result.url if attempted else None, + "warnings": [dict(item) for item in result.warning_items] if attempted else [], + "error": error, + } + + +def build_run_json( + *, + exit_code: int, + report_dir: str | None, + sync_json: str | None, + upload: UploadResult | None, + skipped_reason: str | None, + catalogue_version: str | None, +) -> dict[str, Any]: + delivered = upload if upload is not None and upload.success else None + return { + "exit_code": int(exit_code), + "report_dir": report_dir, + "sync_json": sync_json, + "upload": upload_json(upload, skipped_reason), + # What the platform returned, for CI checks; the report never shows it + "results": delivered.results if delivered else None, + "catalogue": { + "version": catalogue_version, + "outdated": catalogue_outdated(catalogue_version, upload), + }, + } + + +def run_json_fallback( + *, + exit_code: int, + report_dir: str | None, + sync_json: str | None, + error: str, +) -> dict[str, Any]: + # The same keys as the full summary, with what is known and the reason + return { + "exit_code": int(exit_code), + "report_dir": report_dir, + "sync_json": sync_json, + "upload": None, + "results": None, + "catalogue": {"version": None, "outdated": None}, + "error": error, + } + + +def exit_code_of(value: Any) -> int: + if value is None: + return codes.SUCCESS + if isinstance(value, int): + return value + return codes.UNEXPECTED diff --git a/cloudexit/platform/shape.py b/cloudexit/platform/shape.py new file mode 100644 index 0000000..26d218c --- /dev/null +++ b/cloudexit/platform/shape.py @@ -0,0 +1,13 @@ +from typing import Any + + +def as_dict(value: Any) -> dict[str, Any]: + return value if isinstance(value, dict) else {} + + +def as_list(value: Any) -> list[Any]: + return value if isinstance(value, list) else [] + + +def as_text(value: Any) -> str | None: + return value if isinstance(value, str) and value else None diff --git a/cloudexit/platform/spool.py b/cloudexit/platform/spool.py new file mode 100644 index 0000000..ebdc385 --- /dev/null +++ b/cloudexit/platform/spool.py @@ -0,0 +1,367 @@ +from __future__ import annotations + +import hashlib +import json +import logging +import os +import re +import sys +import time +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Callable, Mapping +from urllib.parse import urlsplit + +from cloudexit import codes +from cloudexit.settings import normalize_host + +from cloudexit.platform.client import ApiClient, ApiError + +logger = logging.getLogger(__name__) + +DEFAULT_PORTS = {"https": 443, "http": 80} +DEFAULT_MAX_AGE_DAYS = 30 +AUTO_FLUSH_BUDGET = 60.0 +META_SUFFIX = ".meta.json" +SYNC_ID = re.compile(r"^[0-9A-Za-z-]{8,64}$") +AGE_UNITS = {"s": 1, "m": 60, "h": 3600, "d": 86400} + +# A failure that every other entry would share: stop instead of trying each +STOP_EXIT_CODES = ( + codes.UPLOAD_AUTH, + codes.UPLOAD_UNAVAILABLE, + codes.UPLOAD_QUOTA, + codes.UPLOAD_UNSUPPORTED, +) + + +def host_id(host: str) -> str: + # A hash of the normalised host, so the host name is not in the path + parts = urlsplit(normalize_host(host)) + netloc = (parts.hostname or "").lower() + if parts.port and parts.port != DEFAULT_PORTS.get(parts.scheme): + netloc = f"{netloc}:{parts.port}" + return hashlib.sha256(f"{parts.scheme}://{netloc}".encode("utf-8")).hexdigest()[:16] + + +def key_fingerprint(key: str | None) -> str | None: + # Which API key spooled a sync, without keeping the key: a hash, never the key + if not key: + return None + return hashlib.sha256(b"cloudexit spool key\0" + key.encode("utf-8")).hexdigest()[ + :16 + ] + + +def state_dir( + environ: Mapping[str, str] | None = None, *, platform: str = sys.platform +) -> Path: + environ = os.environ if environ is None else environ + if platform == "win32": + base = environ.get("LOCALAPPDATA") or str(Path.home() / "AppData" / "Local") + return Path(base) / "cloudexit" / "state" + xdg = environ.get("XDG_STATE_HOME", "") + base = xdg if xdg and os.path.isabs(xdg) else str(Path.home() / ".local" / "state") + return Path(base) / "cloudexit" + + +def spool_root(settings: Any = None, environ: Mapping[str, str] | None = None) -> Path: + configured = getattr(getattr(settings, "spool_dir", None), "value", None) + if configured: + return Path(configured) + return state_dir(environ) / "spool" + + +def parse_age(text: str) -> float: + match = re.fullmatch(r"\s*(\d+(?:\.\d+)?)\s*([smhd])\s*", str(text).lower()) + if not match: + raise ValueError(f"{text!r} is not an age such as 30d, 12h or 90m") + return float(match.group(1)) * AGE_UNITS[match.group(2)] + + +def format_age(seconds: float) -> str: + seconds = max(0, int(seconds)) + for unit, size in (("d", 86400), ("h", 3600), ("m", 60)): + if seconds >= size: + return f"{seconds // size}{unit}" + return f"{seconds}s" + + +@dataclass +class SpoolEntry: + sync_id: str + host_id: str + document_path: Path + meta_path: Path + meta: dict[str, Any] = field(default_factory=dict) + + @property + def created(self) -> float: + return float(self.meta.get("created") or 0) + + @property + def key_id(self) -> str | None: + value = self.meta.get("key_id") + return value if isinstance(value, str) and value else None + + def belongs_to(self, key_id: str | None) -> bool: + # An entry without a recorded key cannot be matched to any key + return key_id is not None and self.key_id == key_id + + +@dataclass +class FlushResult: + uploaded: list[str] = field(default_factory=list) + pending: list[str] = field(default_factory=list) + rejected: list[str] = field(default_factory=list) + failed: dict[str, str] = field(default_factory=dict) + stopped: str | None = None + # Spooled with another API key: left where they are + other_key: list[str] = field(default_factory=list) + + @property + def empty(self) -> bool: + return not ( + self.uploaded + or self.pending + or self.rejected + or self.failed + or self.other_key + ) + + +class Spool: + def __init__(self, root: Path, *, clock: Callable[[], float] = time.time): + self.root = Path(root) + self._clock = clock + + @classmethod + def from_settings(cls, settings: Any = None, **options: Any) -> Spool: + return cls(spool_root(settings), **options) + + def _directory(self, host: str) -> Path: + return self.root / host + + def _write(self, path: Path, text: str) -> None: + # Owner only, and never a half-written file under the final name + temporary = path.with_name(f".{path.name}.tmp") + descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(descriptor, "w", encoding="utf-8") as handle: + handle.write(text) + os.chmod(temporary, 0o600) + os.replace(temporary, path) + + def _ensure(self, directory: Path) -> None: + for path in (self.root, directory): + path.mkdir(mode=0o700, parents=True, exist_ok=True) + os.chmod(path, 0o700) + + def add( + self, + host: str, + document: Mapping[str, Any], + *, + error: ApiError | None, + attempts: int, + name: str | None, + exit_strategy: int | None, + assessment_id: str | None, + key_id: str | None = None, + ) -> SpoolEntry: + sync_id = ((document.get("sync") or {}).get("id")) or "" + if not isinstance(sync_id, str) or not SYNC_ID.match(sync_id): + raise ValueError("the sync document has no usable sync id") + hid = host_id(host) + directory = self._directory(hid) + self._ensure(directory) + document_path = directory / f"{sync_id}.json" + meta_path = directory / f"{sync_id}{META_SUFFIX}" + + now = int(self._clock()) + previous = _read_json(meta_path) + meta = { + "sync_id": sync_id, + "host_id": hid, + "created": previous.get("created", now), + "first_attempt": previous.get("first_attempt", now), + "last_attempt": now, + "attempts": int(previous.get("attempts", 0)) + max(0, attempts), + "last_error": error.code if error else None, + "request_id": error.request_id if error else None, + "name": name, + "exit_strategy": exit_strategy, + "assessment_id": assessment_id, + "key_id": key_id, + } + # The document only: never the key, never a response + self._write( + document_path, + json.dumps(document, separators=(",", ":"), ensure_ascii=False), + ) + self._write(meta_path, json.dumps(meta, indent=2) + "\n") + return SpoolEntry(sync_id, hid, document_path, meta_path, meta) + + def entries(self, host: str | None = None) -> list[SpoolEntry]: + # Oldest first. host is a host id; None lists every host. + found = [] + if not self.root.is_dir(): + return found + directories = ( + [self._directory(host)] + if host + else sorted(p for p in self.root.iterdir() if p.is_dir()) + ) + for directory in directories: + if not directory.is_dir(): + continue + for path in sorted(directory.glob("*.json")): + if path.name.endswith(META_SUFFIX) or path.name.startswith("."): + continue + sync_id = path.stem + meta_path = directory / f"{sync_id}{META_SUFFIX}" + meta = _read_json(meta_path) + if not meta: + # A document whose meta file was lost still gets delivered + meta = {"sync_id": sync_id, "created": int(path.stat().st_mtime)} + meta.setdefault("host_id", directory.name) + found.append(SpoolEntry(sync_id, directory.name, path, meta_path, meta)) + return sorted(found, key=lambda entry: (entry.created, entry.sync_id)) + + def read(self, entry: SpoolEntry) -> dict[str, Any]: + return json.loads(entry.document_path.read_text(encoding="utf-8")) + + def update( + self, entry: SpoolEntry, *, error: ApiError | None, attempts: int + ) -> None: + entry.meta.update( + { + "last_attempt": int(self._clock()), + "attempts": int(entry.meta.get("attempts", 0)) + max(0, attempts), + "last_error": error.code if error else None, + "request_id": error.request_id if error else None, + } + ) + entry.meta.setdefault("first_attempt", entry.meta["last_attempt"]) + self._write(entry.meta_path, json.dumps(entry.meta, indent=2) + "\n") + + def remove(self, entry: SpoolEntry) -> None: + for path in (entry.document_path, entry.meta_path): + try: + path.unlink() + except FileNotFoundError: + pass + try: + entry.document_path.parent.rmdir() + except OSError: + pass + + def find(self, host: str, sync_id: str) -> SpoolEntry | None: + return next( + (e for e in self.entries(host_id(host)) if e.sync_id == sync_id), None + ) + + def purge( + self, *, older_than: float | None = None, host: str | None = None + ) -> list[SpoolEntry]: + removed = [] + now = self._clock() + for entry in self.entries(host): + if older_than is not None and now - entry.created < older_than: + continue + self.remove(entry) + removed.append(entry) + return removed + + +def _read_json(path: Path) -> dict[str, Any]: + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + return {} + return data if isinstance(data, dict) else {} + + +def max_collection_age_days(client: ApiClient) -> int: + try: + limits = client.meta().get("limits") or {} + except ApiError: + return DEFAULT_MAX_AGE_DAYS + value = limits.get("max_collection_age_days") + ok = isinstance(value, int) and not isinstance(value, bool) and value > 0 + return value if ok else DEFAULT_MAX_AGE_DAYS + + +def too_old(document: Mapping[str, Any], days: int, now: float) -> bool: + completed_at = (document.get("sync") or {}).get("completed_at") + return isinstance(completed_at, int) and now - completed_at > days * 86400 + + +def flush( + settings: Any, + spool: Spool, + *, + host: str | None = None, + budget: float | None = None, + clock: Callable[[], float] = time.time, + monotonic: Callable[[], float] = time.monotonic, + on_delivered: Callable[[SpoolEntry, Any], None] | None = None, + **client_options: Any, +) -> FlushResult: + # Deliver what an earlier run could not, oldest first. budget is the time + # for the whole flush; without it every entry gets the upload budget. + host = host or settings.host.value + result = FlushResult() + key_id = key_fingerprint(settings.api_key.value) + found = spool.entries(host_id(host)) + # A sync goes out only with the key it was spooled with + entries = [entry for entry in found if entry.belongs_to(key_id)] + result.other_key = [entry.sync_id for entry in found if entry not in entries] + if not entries: + return result + + client = ApiClient.from_settings( + settings, clock=clock, monotonic=monotonic, **client_options + ) + days = max_collection_age_days(client) + deadline = None if budget is None else monotonic() + budget + + for index, entry in enumerate(entries): + if deadline is not None: + remaining = deadline - monotonic() + if remaining <= 0: + result.pending += [e.sync_id for e in entries[index:]] + result.stopped = "budget" + break + client.budget = remaining + try: + document = spool.read(entry) + except (OSError, ValueError): + result.failed[entry.sync_id] = "unreadable" + continue + if too_old(document, days, clock()): + result.rejected.append(entry.sync_id) + continue + try: + pushed = client.push( + document, + name=entry.meta.get("name") or f"Exit Assessment {entry.sync_id[:8]}", + exit_strategy=entry.meta.get("exit_strategy"), + assessment_id=entry.meta.get("assessment_id"), + ) + except ApiError as error: + spool.update(entry, error=error, attempts=error.attempts) + if error.retryable and error.exit_code == codes.UPLOAD_UNAVAILABLE: + result.pending += [e.sync_id for e in entries[index:]] + result.stopped = error.code + break + result.failed[entry.sync_id] = error.code + if error.exit_code in STOP_EXIT_CODES: + result.pending += [e.sync_id for e in entries[index + 1 :]] + result.stopped = error.code + break + continue + spool.remove(entry) + result.uploaded.append(entry.sync_id) + if on_delivered: + on_delivered(entry, pushed) + return result diff --git a/cloudexit/platform/status.py b/cloudexit/platform/status.py new file mode 100644 index 0000000..0360e74 --- /dev/null +++ b/cloudexit/platform/status.py @@ -0,0 +1,570 @@ +from __future__ import annotations + +import os +import socket +import ssl +import time +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Any, Callable, Mapping +from urllib.parse import urlsplit + +import requests + +from cloudexit import codes +from cloudexit.catalogue.store import ( + CatalogueError, + catalogue_dir, + compare_catalogue_versions, + installed_catalogue, +) +from cloudexit.settings import describe + +from cloudexit.platform.client import ( + ApiClient, + ApiError, + check_key, + client_policy, + resolve_ca_bundle, +) +from cloudexit.platform.spool import ( + DEFAULT_MAX_AGE_DAYS, + Spool, + format_age, + host_id, + key_fingerprint, +) +from cloudexit.platform.document import SCHEMA_VERSION +from cloudexit.platform.shape import as_dict, as_list, as_text + +STATUS_BUDGET = 15.0 +KEY_EXPIRY_WARNING_DAYS = 14 +LIMITS_SHOWN = ( + "max_body_bytes", + "max_resource_items", + "max_cost_items", + "max_egress_items", + "max_collection_age_days", + "clock_skew_seconds", +) +# Which problem decides the exit code when there are several +EXIT_PRIORITY = ( + codes.UPLOAD_UNAVAILABLE, + codes.UPLOAD_AUTH, + codes.UPLOAD_UNSUPPORTED, +) +OID_NAMES = { + b"\x55\x04\x03": "CN", + b"\x55\x04\x0a": "O", + b"\x55\x04\x0b": "OU", +} +STANDARD_NAMES = {"commonName": "CN", "organizationName": "O"} + + +@dataclass +class TlsResult: + status: str + bundle: str = "" + message: str | None = None + subject: str | None = None + issuer: str | None = None + expires: str | None = None + + +def status_budget(settings: Any) -> float: + # A budget the user set (--upload-budget) applies here too + budget = getattr(settings, "upload_budget", None) + if budget is not None and budget.source != "default": + return float(budget.value) + return STATUS_BUDGET + + +def ca_bundle_choice( + settings: Any, environ: Mapping[str, str] | None = None +) -> tuple[str, str]: + # The bundle the platform requests verify with, and where it came from + environ = os.environ if environ is None else environ + configured = settings.ca_bundle.value + if configured: + return str(configured), f"{settings.ca_bundle.source}: {configured}" + bundle = resolve_ca_bundle(None, environ) + if isinstance(bundle, str): + return bundle, f"the environment: {bundle}" + return requests.certs.where(), "the default CA bundle (certifi)" + + +def _der(data: bytes, offset: int) -> tuple[int, int, int]: + # One DER element: its tag, where its content starts and where it ends + tag = data[offset] + length = data[offset + 1] + start = offset + 2 + if length & 0x80: + count = length & 0x7F + length = int.from_bytes(data[start : start + count], "big") + start += count + end = start + length + if end > len(data): + raise ValueError("truncated DER") + return tag, start, end + + +def _children(data: bytes, start: int, end: int) -> list[tuple[int, int, int]]: + found = [] + offset = start + while offset < end: + tag, begin, finish = _der(data, offset) + found.append((tag, begin, finish)) + offset = finish + return found + + +def _text(tag: int, raw: bytes) -> str: + if tag == 0x1E: + return raw.decode("utf-16-be", "replace") + return raw.decode("utf-8" if tag == 0x0C else "latin-1", "replace") + + +def _name(data: bytes, start: int, end: int) -> str: + parts = [] + for _, set_start, set_end in _children(data, start, end): + for _, seq_start, seq_end in _children(data, set_start, set_end): + pair = _children(data, seq_start, seq_end) + if len(pair) != 2: + continue + oid = data[pair[0][1] : pair[0][2]] + label = OID_NAMES.get(oid) + if label: + tag, begin, finish = pair[1] + parts.append(f"{label}={_text(tag, data[begin:finish])}") + return ", ".join(parts) + + +def certificate_names(der: bytes) -> dict[str, str]: + # Subject and issuer of a certificate, read from its DER form. Best effort: + # it only decorates the message about why verification failed. + try: + _, start, end = _der(der, 0) + _, tbs_start, tbs_end = _children(der, start, end)[0] + fields = _children(der, tbs_start, tbs_end) + if fields and fields[0][0] == 0xA0: + fields = fields[1:] + # serial, signature, issuer, validity, subject + issuer, subject = fields[2], fields[4] + return { + "issuer": _name(der, issuer[1], issuer[2]), + "subject": _name(der, subject[1], subject[2]), + } + except (IndexError, ValueError): + return {} + + +def _verified_names(cert: Mapping[str, Any]) -> dict[str, str]: + def render(entries: Any) -> str: + parts = [] + for group in entries or (): + for key, value in group: + label = STANDARD_NAMES.get(key) + if label: + parts.append(f"{label}={value}") + return ", ".join(parts) + + return { + "subject": render(cert.get("subject")), + "issuer": render(cert.get("issuer")), + } + + +def _context(bundle: str) -> ssl.SSLContext: + if os.path.isdir(bundle): + return ssl.create_default_context(capath=bundle) + return ssl.create_default_context(cafile=bundle) + + +def _presented( + host: str, port: int, timeout: float, connect: Callable[..., Any] +) -> dict[str, str]: + # What the server showed, looked at without verifying it + context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE + try: + with connect((host, port), timeout) as sock: + with context.wrap_socket(sock, server_hostname=host) as tls: + return certificate_names(tls.getpeercert(binary_form=True) or b"") + except (OSError, ssl.SSLError): + return {} + + +def check_tls( + base_url: str, + bundle: str, + *, + timeout: float = 5.0, + connect: Callable[..., Any] = socket.create_connection, + context_factory: Callable[[str], ssl.SSLContext] = _context, +) -> TlsResult: + parts = urlsplit(base_url) + if parts.scheme != "https": + return TlsResult("not_applicable", bundle, "the host does not use https") + if requests.utils.get_environ_proxies(base_url): + return TlsResult( + "proxy", + bundle, + "a proxy is configured; TLS is checked by the platform request", + ) + host, port = parts.hostname or "", parts.port or 443 + try: + context = context_factory(bundle) + with connect((host, port), timeout) as sock: + with context.wrap_socket(sock, server_hostname=host) as tls: + cert = tls.getpeercert() or {} + names = _verified_names(cert) + return TlsResult( + "ok", + bundle, + subject=names["subject"] or None, + issuer=names["issuer"] or None, + expires=cert.get("notAfter"), + ) + except ssl.SSLCertVerificationError as exc: + names = _presented(host, port, timeout, connect) + return TlsResult( + "failed", + bundle, + exc.verify_message or str(exc), + subject=names.get("subject"), + issuer=names.get("issuer"), + ) + except ssl.SSLError as exc: + return TlsResult("failed", bundle, exc.reason or str(exc)) + except OSError as exc: + return TlsResult( + "unreachable", + bundle, + f"cannot connect to {host}:{port} ({type(exc).__name__})", + ) + + +@dataclass +class StatusReport: + sections: list[tuple[str, list[str]]] = field(default_factory=list) + problems: list[tuple[int, str]] = field(default_factory=list) + warnings: list[str] = field(default_factory=list) + data: dict[str, Any] = field(default_factory=dict) + + def add(self, title: str, lines: list[str]) -> None: + self.sections.append((title, lines)) + + def problem(self, exit_code: int, message: str) -> None: + self.problems.append((exit_code, message)) + + @property + def exit_code(self) -> int: + for code in EXIT_PRIORITY: + if any(found == code for found, _ in self.problems): + return code + return self.problems[0][0] if self.problems else codes.SUCCESS + + def lines(self) -> list[str]: + out = [] + for title, lines in self.sections: + out.append(title) + out.extend(f" {line}" for line in lines) + out.append("") + for message in self.warnings: + out.append(f"warning: {message}") + for _, message in self.problems: + out.append(f"problem: {message}") + if not self.problems: + out.append("Everything checked is fine.") + return out + + def as_json(self) -> dict[str, Any]: + return { + "exit_code": self.exit_code, + "problems": [{"exit_code": c, "message": m} for c, m in self.problems], + "warnings": list(self.warnings), + **self.data, + } + + +def _local_catalogue_version() -> str | None: + try: + installed = installed_catalogue(catalogue_dir()) + except (CatalogueError, OSError): + return None + return installed.version if installed else None + + +def catalogue_standing(local: str | None, host: str | None) -> str: + if not host: + return "the platform reports none" + if not local: + return f"{host} (none is installed here)" + standing = compare_catalogue_versions(local, host) + if standing is None: + return f"{host} (versions cannot be compared)" + wording = { + "older": "the installed one is older: run `cloudexit catalogue update`", + "same": "the same as the installed one", + "newer": "the installed one is newer", + }[standing] + return f"{host} ({wording})" + + +def host_catalogue_lines( + settings: Any, + local: str | None, + make_client: Callable[..., ApiClient] | None = None, +) -> list[str]: + # The platform's catalogue next to the installed one, for `catalogue status` + if not (settings.host.value and settings.api_key.value): + return [] + make_client = make_client or ApiClient.from_settings + try: + client = make_client(settings, budget=status_budget(settings)) + host = as_text(as_dict(client.meta(fresh=True).get("catalogue")).get("version")) + except ApiError as error: + return [f"platform: unavailable ({error.message})"] + return [f"platform: {catalogue_standing(local, host)}"] + + +def _finite(value: Any) -> Any: + # JSON output has no NaN or infinity + if isinstance(value, bool) or not isinstance(value, (int, float)): + return None + return value if value == value and abs(value) != float("inf") else None + + +def _expiry_lines(key: Mapping[str, Any], now: float, report: StatusReport) -> str: + expires = key.get("expires_at") + if isinstance(expires, bool) or not isinstance(expires, (int, float)): + return "unknown" + try: + when = datetime.fromtimestamp(expires, tz=timezone.utc).strftime("%Y-%m-%d") + days = (expires - now) / 86400 + int(days) + except (OverflowError, OSError, ValueError): + # A time no calendar holds, or not a number at all + return "unknown" + if days < KEY_EXPIRY_WARNING_DAYS: + report.warnings.append( + f"the API key expires in {max(0, int(days))} days ({when}); " + f"keys are warned about from {KEY_EXPIRY_WARNING_DAYS} days" + ) + return f"{when} (in {int(days)} days)" + + +def run_status( + settings: Any, + *, + environ: Mapping[str, str] | None = None, + now: Callable[[], float] = time.time, + tls: Callable[..., TlsResult] = check_tls, + make_client: Callable[..., ApiClient] | None = None, + spool: Spool | None = None, +) -> StatusReport: + report = StatusReport() + host, key = settings.host.value, settings.api_key.value + report.add("Settings", describe(settings)) + report.data["configured"] = bool(host and key) + local = _local_catalogue_version() + + def finish_spool(days: int) -> None: + if not host: + return + report.add("Spool", _spool_lines(settings, spool, days, now(), report)) + + if not host and not key: + report.add("Platform", ["not configured: runs stay offline"]) + report.add("Catalogue", [f"installed: {local or 'none'}"]) + report.data["catalogue"] = {"local": local, "platform": None} + return report + if bool(host) != bool(key): + report.problem( + codes.UPLOAD_AUTH, + ( + "a host is configured but no API key" + if host + else "an API key is configured but no host" + ), + ) + return report + try: + check_key(key) + except ApiError as error: + report.problem(error.exit_code, error.message) + return report + + make_client = make_client or ApiClient.from_settings + try: + client = make_client(settings, budget=status_budget(settings), environ=environ) + except ApiError as error: + report.problem(error.exit_code, error.message) + return report + + bundle, label = ca_bundle_choice(settings, environ) + result = tls(client.base_url, bundle) + report.data["tls"] = { + "status": result.status, + "ca_bundle": label, + "message": result.message, + "subject": result.subject, + "issuer": result.issuer, + } + lines = [f"CA bundle: {label}"] + if result.status == "ok": + lines.append("certificate: verified") + if result.subject: + lines.append(f" issued to {result.subject}") + if result.issuer: + lines.append(f" issued by {result.issuer}") + elif result.status in ("failed", "unreachable"): + lines.append(f"certificate: NOT verified: {result.message}") + if result.subject: + lines.append(f" the server presented a certificate for {result.subject}") + if result.issuer: + lines.append(f" issued by {result.issuer} (not trusted by this bundle)") + report.add("TLS", lines) + report.problem( + codes.UPLOAD_UNAVAILABLE, + f"TLS check failed: {result.message}; use --ca-bundle for a private CA", + ) + return report + else: + lines.append(f"certificate: not checked directly ({result.message})") + report.add("TLS", lines) + + try: + meta = client.meta(fresh=True) + except ApiError as error: + report.problem(error.exit_code, f"{error.code}: {error.message}") + return report + + host_info = as_dict(meta.get("host")) + contract = as_dict(meta.get("contract")) + catalogue = as_dict(meta.get("catalogue")) + limits = as_dict(meta.get("limits")) + catalogue_version = as_text(catalogue.get("version")) + state, message = client_policy(meta) + versions = as_list(contract.get("schema_versions")) + report.add( + "Platform (GET /meta)", + [ + f"host: {host_info.get('product')}" + + (f" {host_info.get('region')}" if host_info.get("region") else "") + + ( + f" (version {host_info['app_version']})" + if host_info.get("app_version") + else "" + ), + f"schema: {', '.join(map(str, versions)) or 'unknown'}" + + (f" (current {contract['current']})" if contract.get("current") else ""), + f"catalogue: {catalogue_version or 'unknown'}", + "limits: " + + ", ".join(f"{k}={limits[k]}" for k in LIMITS_SHOWN if k in limits), + f"client: {message}", + ], + ) + report.data["platform"] = { + "product": host_info.get("product"), + "region": host_info.get("region"), + "app_version": host_info.get("app_version"), + "schema_versions": versions, + "catalogue_version": catalogue_version, + "limits": {k: limits[k] for k in LIMITS_SHOWN if k in limits}, + "client_policy": {"status": state, "message": message}, + } + if state == "unsupported": + report.problem(codes.UPLOAD_UNSUPPORTED, message) + elif state == "deprecated": + report.warnings.append(message) + if versions and SCHEMA_VERSION not in versions: + report.problem( + codes.UPLOAD_UNSUPPORTED, + f"the platform accepts schema versions {', '.join(map(str, versions))}, " + f"not {SCHEMA_VERSION}", + ) + + days = limits.get("max_collection_age_days") + days = days if isinstance(days, int) and days > 0 else DEFAULT_MAX_AGE_DAYS + try: + whoami = client.whoami() + except ApiError as error: + report.problem(error.exit_code, f"{error.code}: {error.message}") + finish_spool(days) + return report + key_info = as_dict(whoami.get("key")) + tenant = as_dict(whoami.get("tenant")) + scopes = [str(scope) for scope in as_list(key_info.get("scopes"))] + expiry = _expiry_lines(key_info, now(), report) + report.add( + "Key (GET /whoami)", + [ + f"organisation: {tenant.get('name')} ({tenant.get('type')})", + f"key: {key_info.get('display')} \"{key_info.get('name')}\", " + f"{key_info.get('kind')}", + f"assessment: {key_info.get('assessment_id') or 'any (not bound)'}", + f"scopes: {', '.join(scopes) or 'none'}", + f"expires: {expiry}", + ], + ) + for item in as_list(whoami.get("warnings")): + if isinstance(item, dict) and item.get("message"): + report.warnings.append(str(item["message"])) + report.data["key"] = { + "organisation": tenant.get("name"), + "kind": key_info.get("kind"), + "assessment_id": key_info.get("assessment_id"), + "scopes": scopes, + "expires_at": _finite(key_info.get("expires_at")), + } + + finish_spool(days) + report.add( + "Catalogue", + [ + f"installed: {local or 'none'}", + f"platform: {catalogue_standing(local, catalogue_version)}", + ], + ) + report.data["catalogue"] = { + "local": local, + "platform": catalogue_version, + "standing": compare_catalogue_versions(local, catalogue_version), + } + return report + + +def _spool_lines( + settings: Any, spool: Spool | None, days: int, now: float, report: StatusReport +) -> list[str]: + spool = spool or Spool.from_settings(settings) + on_host = spool.entries(host_id(settings.host.value)) + key_id = key_fingerprint(settings.api_key.value) + mine = [e for e in on_host if e.belongs_to(key_id)] + other_keys = len(on_host) - len(mine) + others = len(spool.entries()) - len(on_host) + stale = [e for e in mine if now - e.created > days * 86400] + report.data["spool"] = { + "pending": len(mine), + "too_old": len(stale), + "other_hosts": others, + "other_keys": other_keys, + } + if not on_host and not others: + return ["empty"] + lines = [] + if mine: + oldest = max(now - e.created for e in mine) + lines.append( + f"{len(mine)} sync{'' if len(mine) == 1 else 's'} waiting for this host " + f"(oldest {format_age(oldest)}): `cloudexit spool flush`" + ) + if stale: + lines.append(f"{len(stale)} will be rejected: older than {days} days") + if other_keys: + lines.append(f"{other_keys} for another API key (not delivered with this one)") + if others: + lines.append(f"{others} for other hosts") + return lines diff --git a/cloudexit/platform/upload.py b/cloudexit/platform/upload.py new file mode 100644 index 0000000..fde9dd2 --- /dev/null +++ b/cloudexit/platform/upload.py @@ -0,0 +1,293 @@ +from __future__ import annotations + +import json +import logging +from dataclasses import dataclass, replace +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from cloudexit import codes +from cloudexit.settings import Settings, SettingsError, load_settings + +from cloudexit.platform.client import ApiClient, ApiError +from cloudexit.platform.spool import Spool, key_fingerprint + +logger = logging.getLogger(__name__) + +REASON_UNAVAILABLE = "upload_unavailable" +REASON_REJECTED = "upload_rejected" +REASONS = { + codes.UPLOAD_REJECTED: REASON_REJECTED, + codes.UPLOAD_AUTH: "upload_auth", + codes.UPLOAD_UNAVAILABLE: REASON_UNAVAILABLE, + codes.UPLOAD_QUOTA: "upload_quota", + codes.UPLOAD_TARGET: "upload_target", + codes.UPLOAD_UNSUPPORTED: "upload_unsupported", +} + + +@dataclass(frozen=True) +class UploadResult: + attempted: bool + success: bool + exit_code: int | None = None + reason: str | None = None + message: str = "" + sync_id: str | None = None + assessment_id: str | None = None + url: str | None = None + platform_sync_id: str | None = None + error_code: str | None = None + request_id: str | None = None + warnings: tuple[str, ...] = () + replayed: bool = False + pending: bool = False + billing: dict[str, int] | None = None + spooled: bool = False + warning_items: tuple[dict[str, str | None], ...] = () + results: dict[str, Any] | None = None + catalogue: dict[str, Any] | None = None + + @property + def failed(self) -> bool: + return self.attempted and not self.success + + +def failure( + reason: str, + message: str, + sync_id: str | None = None, + *, + exit_code: int | None = None, + error_code: str | None = None, + request_id: str | None = None, +) -> UploadResult: + if exit_code is None: + exit_code = ( + codes.UPLOAD_REJECTED + if reason == REASON_REJECTED + else codes.UPLOAD_UNAVAILABLE + ) + return UploadResult( + True, + False, + exit_code, + reason, + message, + sync_id, + error_code=error_code, + request_id=request_id, + ) + + +def error_failure(error: ApiError, sync_id: str | None = None) -> UploadResult: + return failure( + REASONS.get(error.exit_code, REASON_UNAVAILABLE), + error.message, + sync_id, + exit_code=error.exit_code, + error_code=error.code, + request_id=error.request_id, + ) + + +def billing_line(billing: dict[str, int] | None) -> str | None: + if not billing or "charged_credits" not in billing: + return None + charged = billing["charged_credits"] + line = f"Charged {charged} credit{'' if charged == 1 else 's'}" + if "balance" in billing: + line += f"; balance {billing['balance']}" + return line + "." + + +def _spool( + spool: Spool, + settings: Settings, + document: dict[str, Any], + error: ApiError, + name: str, + meta: dict[str, Any], +) -> bool: + try: + spool.add( + settings.host.value, + document, + error=error, + attempts=error.attempts, + name=name, + exit_strategy=meta.get("exit_strategy"), + assessment_id=settings.assessment_id.value, + key_id=key_fingerprint(settings.api_key.value), + ) + return True + except (OSError, ValueError, SettingsError) as exc: + logger.warning("The sync could not be spooled: %s", exc) + return False + + +def _unspool(spool: Spool, settings: Settings, sync_id: str | None) -> None: + # A sync that got through no longer needs its spooled copy + try: + entry = spool.find(settings.host.value, sync_id or "") + if entry: + spool.remove(entry) + except (OSError, SettingsError): + logger.debug("The spooled copy could not be removed", exc_info=True) + + +def upload( + document: dict[str, Any] | None, + settings: Settings | None, + *, + name: str, + meta: dict[str, Any], + spool_on_error: bool = False, + spool: Spool | None = None, + **client_options: Any, +) -> UploadResult: + sync_id = ((document or {}).get("sync") or {}).get("id") + if document is None: + return failure( + REASON_REJECTED, + "the sync document could not be built, so there is nothing to upload", + sync_id, + ) + settings = settings or load_settings() + spool = spool or Spool.from_settings(settings) + + try: + client = ApiClient.from_settings(settings, **client_options) + pushed = client.push( + document, + name=name, + exit_strategy=meta.get("exit_strategy"), + assessment_id=settings.assessment_id.value, + ) + except ApiError as error: + logger.debug("The upload failed: %s", error.code) + result = error_failure(error, sync_id) + # A failure that may pass is kept for later; a final one only on request + temporary = error.retryable and error.exit_code == codes.UPLOAD_UNAVAILABLE + if (temporary or spool_on_error) and _spool( + spool, settings, document, error, name, meta + ): + result = replace(result, spooled=True) + return result + except Exception as exc: + # Whatever goes wrong in the upload must not cost the local report + logger.debug("The upload raised %s", type(exc).__name__, exc_info=True) + return failure( + REASON_UNAVAILABLE, + f"unexpected upload error ({type(exc).__name__})", + sync_id, + ) + + _unspool(spool, settings, sync_id) + if pushed.pending: + message = "accepted, still processing" + elif pushed.replayed: + message = "already uploaded" + else: + message = "uploaded" + return UploadResult( + True, + True, + message=message, + sync_id=sync_id, + assessment_id=pushed.assessment_id, + url=pushed.url, + platform_sync_id=pushed.sync_id, + request_id=pushed.request_id, + warnings=pushed.warnings, + replayed=pushed.replayed, + pending=pushed.pending, + billing=pushed.billing, + warning_items=pushed.warning_items, + results=pushed.results, + catalogue=pushed.catalogue, + ) + + +def _looks_like_sync(data: Any) -> bool: + sync = data.get("sync") if isinstance(data, dict) else None + return bool( + isinstance(sync, dict) and sync.get("id") and data.get("schema_version") + ) + + +def read_sync_files( + paths: list[str], +) -> tuple[list[tuple[Path, dict[str, Any]]], list[tuple[Path, str]]]: + # Files are read as they are; a directory contributes the sync documents + # among its .json files (a report's raw_data folder holds other JSON too). + found: list[tuple[Path, dict[str, Any]]] = [] + problems: list[tuple[Path, str]] = [] + for raw in paths: + path = Path(raw) + if path.is_dir(): + candidates = sorted(path.glob("*.json")) + explicit = False + elif path.is_file(): + candidates, explicit = [path], True + else: + problems.append((path, "no such file or directory")) + continue + before = len(found) + for candidate in candidates: + try: + data = json.loads(candidate.read_text(encoding="utf-8")) + except (OSError, ValueError) as exc: + if explicit: + problems.append((candidate, f"cannot be read as JSON ({exc})")) + continue + if _looks_like_sync(data): + found.append((candidate, data)) + elif explicit: + problems.append((candidate, "is not a sync document")) + if not explicit and len(found) == before: + problems.append((path, "holds no sync documents")) + return found, problems + + +REPORT_KEYS = ("status", "reason", "sync_id", "assessment_id", "url", "spooled") + + +def _record(result: UploadResult | None) -> dict[str, Any] | None: + if result is None or not result.attempted: + return None + info = { + "status": "uploaded" if result.success else "failed", + "reason": result.reason, + "error_code": result.error_code, + "sync_id": result.sync_id, + "platform_sync_id": result.platform_sync_id, + "assessment_id": result.assessment_id, + "url": result.url, + "request_id": result.request_id, + "pending": result.pending, + "spooled": result.spooled, + "billing": result.billing, + "warnings": list(result.warnings), + } + return {key: value for key, value in info.items() if value} + + +def report_info(result: UploadResult | None) -> dict[str, Any] | None: + # What the local report may show: the status, the link and whether a failed + # run was kept, nothing the platform computed. + record = _record(result) + if record is None: + return None + return {key: record[key] for key in REPORT_KEYS if key in record} + + +def write_upload_record(raw_data_path: str, result: UploadResult) -> Path | None: + info = _record(result) + if info is None: + return None + info["recorded_at"] = datetime.now(timezone.utc).isoformat(timespec="seconds") + path = Path(raw_data_path) / "upload.json" + path.write_text(json.dumps(info, indent=2) + "\n", encoding="utf-8") + return path diff --git a/cloudexit/private_files.py b/cloudexit/private_files.py new file mode 100644 index 0000000..8e0b306 --- /dev/null +++ b/cloudexit/private_files.py @@ -0,0 +1,35 @@ +import os +import stat +import sys +from pathlib import Path + + +def write_private(path: str | Path, data: bytes) -> None: + # Readable by the owner only; a stricter umask is never loosened + descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(descriptor, "wb") as handle: + handle.write(data) + + +def restrict_tree(root: str | Path) -> None: + # Remove the group and other permissions of a folder and everything in it; + # whatever the owner has stays as it is + if sys.platform == "win32": + return + root = Path(root) + if not root.is_dir(): + return + for directory, folders, files in os.walk(root): + for name in [*folders, *files]: + _restrict(Path(directory) / name) + _restrict(root) + + +def _restrict(path: Path) -> None: + try: + if path.is_symlink(): + return + mode = stat.S_IMODE(path.stat().st_mode) + path.chmod(mode & 0o700) + except OSError: + pass diff --git a/cloudexit/providers/__init__.py b/cloudexit/providers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cloudexit/providers/aws/__init__.py b/cloudexit/providers/aws/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cloudexit/providers/aws/cli_profile.py b/cloudexit/providers/aws/cli_profile.py new file mode 100644 index 0000000..3ec7363 --- /dev/null +++ b/cloudexit/providers/aws/cli_profile.py @@ -0,0 +1,23 @@ +# cloudexit/providers/aws/cli_profile.py +import logging +import shutil +import subprocess + +logger = logging.getLogger(__name__) + + +def is_aws_cli_installed() -> bool: + return shutil.which("aws") is not None + + +def is_aws_profile_valid(profile: str) -> bool: + try: + subprocess.run( + ["aws", "configure", "list", "--profile", profile], + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + return True + except subprocess.CalledProcessError: + return False diff --git a/cloudexit/providers/aws/client.py b/cloudexit/providers/aws/client.py new file mode 100644 index 0000000..c7968cb --- /dev/null +++ b/cloudexit/providers/aws/client.py @@ -0,0 +1,189 @@ +# cloudexit/providers/aws/client.py +import botocore.session +import json +from typing import Any +from datetime import datetime +from functools import lru_cache +from botocore.config import Config +from botocore.exceptions import ( + ConnectTimeoutError, + EndpointConnectionError, +) + +# Throttling keeps its eight adaptive attempts; an endpoint that cannot be +# reached is given up on much sooner (see limit_connection_retries). +AWS_RETRY_CONFIG = Config( + connect_timeout=5, + read_timeout=30, + retries={"mode": "adaptive", "max_attempts": 8}, +) + + +CONNECTION_ERRORS = (ConnectTimeoutError, EndpointConnectionError) + + +CONNECTION_ATTEMPTS = 2 + + +SERVICE_OK = "ok" + + +SERVICE_NOT_OFFERED = "not_offered" + + +SERVICE_UNKNOWN = "unknown" + + +# Upper bound on the items collected for a single resource type. Some list +# operations (e.g. describe_images, describe_snapshots) return every publicly +# shared resource in the region when called without an owner filter, which would +# otherwise stall the scan and blow up the raw-data file. +MAX_ITEMS_PER_RESOURCE_TYPE = 50_000 + + +def limit_connection_retries( + attempts: int, caught_exception: Any = None, **kwargs: Any +) -> None: + # botocore retries a connection error as often as it retries throttling, + # which makes a dead endpoint cost minutes. Raising from the retry check + # ends the call with the original error after the second attempt. + if attempts >= CONNECTION_ATTEMPTS and isinstance( + caught_exception, CONNECTION_ERRORS + ): + raise caught_exception + + +def create_client(session: Any, service_name: str, region: str) -> Any: + client = session.client(service_name, region_name=region, config=AWS_RETRY_CONFIG) + client.meta.events.register_first("needs-retry", limit_connection_retries) + return client + + +@lru_cache(maxsize=None) +def _service_regions(service_name: str) -> tuple[frozenset[str], bool] | None: + # What the installed botocore knows about where a service is offered; None + # when it does not know the service at all. + session = botocore.session.get_session() + if service_name not in session.get_available_services(): + return None + regional: set[str] = set() + global_endpoint = False + for partition in session.get_available_partitions(): + regions = set(session.get_available_regions(service_name, partition)) + everything = set( + session.get_available_regions( + service_name, partition, allow_non_regional=True + ) + ) + regional |= regions + global_endpoint = global_endpoint or bool(everything - regions) + return frozenset(regional), global_endpoint + + +@lru_cache(maxsize=None) +def _known_regions() -> frozenset[str]: + session = botocore.session.get_session() + return frozenset( + region + for partition in session.get_available_partitions() + for region in session.get_available_regions("ec2", partition) + ) + + +def service_status(service_name: str, region: str) -> str: + info = _service_regions(service_name) + if info is None: + return SERVICE_UNKNOWN + regional, global_endpoint = info + # A global service is called wherever the user is; a region the installed + # botocore has never heard of is left to the call itself. + if global_endpoint or region in regional or region not in _known_regions(): + return SERVICE_OK + return SERVICE_NOT_OFFERED + + +def extract_result_path(container: Any, result_path: list) -> list: + if isinstance(result_path, str): + result_path = [result_path] + value = container + for key in result_path: + if not isinstance(value, dict): + return [] + value = value.get(key) + return value if isinstance(value, list) else [] + + +def paginate( + client: Any, + operation_name: str, + result_path: list, + max_items: int = MAX_ITEMS_PER_RESOURCE_TYPE, + **kwargs: Any, +) -> list: + items: list = [] + for page in client.get_paginator(operation_name).paginate(**kwargs): + items.extend(extract_result_path(page, result_path)) + if len(items) >= max_items: + return items[:max_items] + return items + + +def paginate_or_call( + client: Any, + operation_name: str, + result_path: list, + max_items: int = MAX_ITEMS_PER_RESOURCE_TYPE, + **kwargs: Any, +) -> list: + """paginate() when boto3 supports it for this operation, else a single call.""" + if client.can_paginate(operation_name): + return paginate(client, operation_name, result_path, max_items, **kwargs) + response = getattr(client, operation_name)(**kwargs) + return extract_result_path(response, result_path)[:max_items] + + +def parse_resource_type_params(raw_params: Any) -> dict: + if isinstance(raw_params, dict): + return raw_params + try: + parsed = json.loads(raw_params or "{}") + except (TypeError, ValueError): + return {} + return parsed if isinstance(parsed, dict) else {} + + +def resolve_aws_call_spec(resource_type_code: str, params: dict) -> dict | None: + service = params.get("service") + operation = params.get("operation") + if service and operation: + return { + "source": "params", + "service": service, + "operation": operation, + "result_path": list(params.get("result_path") or []), + "kwargs": dict(params.get("kwargs") or {}), + } + + parts = resource_type_code.split(".") + if len(parts) >= 4 and parts[0] == "AWS": + return { + "source": "code", + "service": parts[1], + "operation": parts[2], + "result_path": [part.strip() for part in parts[3:]], + "kwargs": {}, + } + + return None + + +def convert_datetime(obj: Any) -> Any: + if isinstance(obj, dict): + for k, v in obj.items(): + obj[k] = convert_datetime(v) + elif isinstance(obj, list): + for i in range(len(obj)): + obj[i] = convert_datetime(obj[i]) + elif isinstance(obj, datetime): + return obj.isoformat() + return obj diff --git a/cloudexit/providers/aws/costs.py b/cloudexit/providers/aws/costs.py new file mode 100644 index 0000000..c04b829 --- /dev/null +++ b/cloudexit/providers/aws/costs.py @@ -0,0 +1,133 @@ +# cloudexit/providers/aws/costs.py +import boto3 +import json +import os +import logging +import sqlite3 +from typing import Any +from datetime import date, datetime, timezone +from dateutil.relativedelta import relativedelta + +from cloudexit.catalogue.db import connect +from cloudexit.providers.aws.client import create_client + +logger = logging.getLogger(__name__) + + +def get_missing_months_aws(processed_costs: set[str], max_months: int) -> list[date]: + current_date = datetime.now(timezone.utc).date().replace(day=1) + processed_months = { + datetime.strptime(month_str, "%Y-%m-%d").date().replace(day=1) + for month_str in processed_costs + } + missing_months = [] + + for i in range(max_months): + check_date = current_date - relativedelta(months=i) + if check_date not in processed_months: + missing_months.append(check_date) + + return missing_months + + +def build_aws_cost_inventory( + cloud_service_provider: int, + provider_details: dict[str, Any], + report_path: str, + raw_data_path: str, +) -> None: + try: + session = boto3.Session( + aws_access_key_id=provider_details["accessKey"], + aws_secret_access_key=provider_details["secretKey"], + aws_session_token=provider_details.get("sessionToken"), + region_name=provider_details["region"], + ) + cost_explorer = create_client(session, "ce", "us-east-1") + + db_path = os.path.join(report_path, "data", "assessment.db") + + end_time = date.today().replace(day=1) + relativedelta(months=1) + start_time = end_time - relativedelta(months=6) + + cost_and_usage = cost_explorer.get_cost_and_usage( + TimePeriod={ + "Start": start_time.strftime("%Y-%m-%d"), + "End": end_time.strftime("%Y-%m-%d"), + }, + Granularity="MONTHLY", + Metrics=["UnblendedCost"], + GroupBy=[{"Type": "DIMENSION", "Key": "SERVICE"}], + Filter={ + "Dimensions": {"Key": "REGION", "Values": [provider_details["region"]]} + }, + ) + + cost_inventory_raw_path = os.path.join( + raw_data_path, "cost_inventory_raw_data.json" + ) + with open(cost_inventory_raw_path, "w", encoding="utf-8") as raw_file: + json.dump(cost_and_usage, raw_file, indent=4) + + # Insert structured data into SQLite + currency = "USD" + with connect(db_path=db_path) as conn: + cursor = conn.cursor() + + for result in cost_and_usage["ResultsByTime"]: + month_str = result["TimePeriod"]["Start"] + total_cost = sum( + float(group["Metrics"]["UnblendedCost"]["Amount"]) + for group in result["Groups"] + ) + currency = ( + result["Groups"][0]["Metrics"]["UnblendedCost"]["Unit"] + if result["Groups"] + else "USD" + ) + month_date = ( + datetime.strptime(month_str, "%Y-%m-%d") + .date() + .replace(day=1) + .isoformat() + ) + + # Insert or update the cost data for the month + cursor.execute( + """ + INSERT INTO cost_inventory (month, cost, currency) + VALUES (?, ?, ?) + ON CONFLICT(month) DO UPDATE SET + cost = excluded.cost, + currency = excluded.currency + """, + (month_date, total_cost, currency), + ) + + # Handle missing months + structured_months = { + datetime.strptime(result["TimePeriod"]["Start"], "%Y-%m-%d").date() + for result in cost_and_usage["ResultsByTime"] + } + missing_months = get_missing_months_aws( + {month.isoformat() for month in structured_months}, 6 + ) + + for missing_month in missing_months: + cursor.execute( + """ + INSERT INTO cost_inventory (month, cost, currency) + VALUES (?, 0.00, ?) + ON CONFLICT(month) DO UPDATE SET + currency = excluded.currency + """, + (missing_month.isoformat(), currency), + ) + + conn.commit() + + except sqlite3.Error as e: + logger.error(f"SQLite error: {str(e)}", exc_info=True) + except Exception as e: + logger.error(f"Error creating AWS cost inventory: {str(e)}", exc_info=True) + raise diff --git a/cloudexit/providers/aws/egress.py b/cloudexit/providers/aws/egress.py new file mode 100644 index 0000000..b0ee100 --- /dev/null +++ b/cloudexit/providers/aws/egress.py @@ -0,0 +1,459 @@ +# cloudexit/providers/aws/egress.py +import boto3 +import logging +from typing import Any +from datetime import datetime, timedelta, timezone +from botocore.exceptions import BotoCoreError, ClientError + +from cloudexit.providers.aws.client import ( + create_client, + extract_result_path, + paginate_or_call, +) +from cloudexit.egress.model import GIB, format_bytes, load_egress_registry, new_row + +logger = logging.getLogger(__name__) + +METRIC_DATA_BATCH_SIZE = 500 + +METRICS_LOOKBACK_DAYS = 3 + +ARCHIVE_TIERS = {"Archive", "Glacier", "Deep Archive"} + +S3_STORAGE_TYPE_TIERS = { + "StandardStorage": "Standard", + "ReducedRedundancyStorage": "Standard", + "ExpressOneZone": "Standard", + "StandardIAStorage": "Standard-IA", + "StandardIASizeOverhead": "Standard-IA", + "OneZoneIAStorage": "One Zone-IA", + "OneZoneIASizeOverhead": "One Zone-IA", + "IntelligentTieringFAStorage": "Intelligent-Tiering", + "IntelligentTieringIAStorage": "Intelligent-Tiering", + "IntelligentTieringAIAStorage": "Intelligent-Tiering", + "IntelligentTieringAAStorage": "Archive", + "IntelligentTieringDAAStorage": "Deep Archive", + "GlacierInstantRetrievalStorage": "Glacier Instant Retrieval", + "GlacierInstantRetrievalSizeOverhead": "Glacier Instant Retrieval", + "GlacierStorage": "Glacier", + "GlacierStagingStorage": "Glacier", + "GlacierObjectOverhead": "Glacier", + "GlacierS3ObjectOverhead": "Glacier", + "DeepArchiveStorage": "Deep Archive", + "DeepArchiveObjectOverhead": "Deep Archive", + "DeepArchiveS3ObjectOverhead": "Deep Archive", + "DeepArchiveStagingStorage": "Deep Archive", +} + + +def fetch_latest_metric_values( + cloudwatch: Any, + metric_specs: list[dict[str, Any]], + *, + lookback_days: int = METRICS_LOOKBACK_DAYS, + period: int = 86400, +) -> dict[str, float | None] | None: + if not metric_specs: + return {} + try: + end = datetime.now(timezone.utc) + start = end - timedelta(days=lookback_days) + results: dict[str, float | None] = {spec["id"]: None for spec in metric_specs} + + for batch_start in range(0, len(metric_specs), METRIC_DATA_BATCH_SIZE): + batch = metric_specs[batch_start : batch_start + METRIC_DATA_BATCH_SIZE] + queries = [ + { + "Id": spec["id"], + "MetricStat": { + "Metric": { + "Namespace": spec["namespace"], + "MetricName": spec["metric_name"], + "Dimensions": spec["dimensions"], + }, + "Period": period, + "Stat": spec.get("stat", "Average"), + }, + "ReturnData": True, + } + for spec in batch + ] + kwargs = { + "MetricDataQueries": queries, + "StartTime": start, + "EndTime": end, + "ScanBy": "TimestampDescending", + } + while True: + response = cloudwatch.get_metric_data(**kwargs) + for series in response.get("MetricDataResults", []): + values = series.get("Values") or [] + if values and results.get(series["Id"]) is None: + results[series["Id"]] = float(values[0]) + next_token = response.get("NextToken") + if not next_token: + break + kwargs["NextToken"] = next_token + return results + except (BotoCoreError, ClientError) as e: + logger.debug("CloudWatch metrics request failed: %s", str(e)) + return None + + +SIZE_UNITS = { + "B": 1, + "KiB": 1024, + "MiB": 1024**2, + "GiB": GIB, + "TiB": 1024**4, +} + + +def _nested_get(item: dict[str, Any], path: Any) -> Any: + if isinstance(path, str): + path = [path] + value: Any = item + for key in path: + if not isinstance(value, dict): + return None + value = value.get(key) + return value + + +def _resource_identity(item: dict[str, Any], sizing: dict[str, Any]) -> tuple[str, str]: + # The row id prefers an ARN when the service reports one; the display name + # comes from a tag when the service tags resources, else from a field. + identifier = item[sizing["id_field"]] + name_tag = sizing.get("name_tag") + if name_tag: + name = next( + ( + tag["Value"] + for tag in item.get(sizing.get("tags_field", "Tags"), []) + if tag["Key"] == name_tag + ), + identifier, + ) + else: + name = item.get(sizing.get("name_field") or sizing["id_field"], identifier) + arn_field = sizing.get("arn_field") + row_id = item.get(arn_field, identifier) if arn_field else identifier + return row_id, name + + +def _enumeration_client(session: Any, region: str, entry: dict[str, Any]) -> Any: + return create_client(session, entry["enumeration"]["service"], region) + + +def _enumerate(session: Any, region: str, entry: dict[str, Any]) -> tuple[Any, list]: + # service/operation/result_path/kwargs all come from resourcetype_data, so a + # catalogue change does not need a code change. Sizing stays in the strategy. + enumeration = entry["enumeration"] + client = _enumeration_client(session, region, entry) + items = paginate_or_call( + client, + enumeration["operation"], + enumeration["result_path"], + **enumeration.get("kwargs", {}), + ) + return client, items + + +def _bucket_region(s3_client: Any, bucket_name: str) -> str: + location = s3_client.get_bucket_location(Bucket=bucket_name).get( + "LocationConstraint" + ) + if not location: + return "us-east-1" + if location == "EU": + return "eu-west-1" + return location + + +def _list_buckets_in_region( + s3_client: Any, region: str, enumeration: dict[str, Any] +) -> list[str]: + # The region filter is strategy logic, but the call itself is master data. + list_buckets = getattr(s3_client, enumeration["operation"]) + result_path = enumeration["result_path"] + kwargs = enumeration.get("kwargs", {}) + try: + response = list_buckets(BucketRegion=region, **kwargs) + return [bucket["Name"] for bucket in extract_result_path(response, result_path)] + except (BotoCoreError, ClientError) as e: + logger.debug("ListBuckets with BucketRegion filter failed: %s", str(e)) + + bucket_names = [] + for bucket in extract_result_path(list_buckets(**kwargs), result_path): + name = bucket["Name"] + bucket_region = bucket.get("BucketRegion") + if bucket_region is None: + try: + bucket_region = _bucket_region(s3_client, name) + except (BotoCoreError, ClientError) as e: + logger.debug("Skipping bucket %s: %s", name, str(e)) + continue + if bucket_region == region: + bucket_names.append(name) + return bucket_names + + +def _collect_s3_buckets( + session: Any, region: str, code: str, entry: dict[str, Any] +) -> list[dict[str, Any]]: + s3_client = _enumeration_client(session, region, entry) + cloudwatch = create_client(session, "cloudwatch", region) + + bucket_names = _list_buckets_in_region(s3_client, region, entry["enumeration"]) + + rows = [] + for name in bucket_names: + row = new_row( + f"arn:aws:s3:::{name}", name, code, entry["label"], entry["category"] + ) + + try: + metrics = cloudwatch.list_metrics( + Namespace="AWS/S3", + MetricName="BucketSizeBytes", + Dimensions=[{"Name": "BucketName", "Value": name}], + ).get("Metrics", []) + except (BotoCoreError, ClientError) as e: + logger.debug("Listing metrics failed for bucket %s: %s", name, str(e)) + metrics = [] + + specs = [] + spec_tiers = {} + for index, metric in enumerate(metrics): + storage_type = next( + ( + dimension["Value"] + for dimension in metric.get("Dimensions", []) + if dimension["Name"] == "StorageType" + ), + "Unknown", + ) + spec_id = f"q{index}" + specs.append( + { + "id": spec_id, + "namespace": "AWS/S3", + "metric_name": "BucketSizeBytes", + "dimensions": metric.get("Dimensions", []), + } + ) + spec_tiers[spec_id] = S3_STORAGE_TYPE_TIERS.get(storage_type, storage_type) + + values = fetch_latest_metric_values(cloudwatch, specs) + tier_bytes: dict[str, int] = {} + if values: + for spec_id, value in values.items(): + if value is None: + continue + tier = spec_tiers[spec_id] + tier_bytes[tier] = tier_bytes.get(tier, 0) + int(value) + + if tier_bytes: + row["size_bytes"] = sum(tier_bytes.values()) + row["tier_bytes"] = tier_bytes + archive_bytes = sum( + size for tier, size in tier_bytes.items() if tier in ARCHIVE_TIERS + ) + if archive_bytes: + row["flags"].append( + f"Archive-class: {format_bytes(archive_bytes)} (restore required)" + ) + else: + row["size_unknown"] = True + + try: + s3_client.get_bucket_replication(Bucket=name) + row["notes"].append( + "replication configured (replica buckets are counted separately)" + ) + except (BotoCoreError, ClientError): + pass + + rows.append(row) + return rows + + +def _collect_list_item_size( + session: Any, region: str, code: str, entry: dict[str, Any] +) -> list[dict[str, Any]]: + sizing = entry["sizing"] + multiplier = SIZE_UNITS[sizing.get("size_unit", "GiB")] + _, items = _enumerate(session, region, entry) + rows = [] + for item in items: + row_id, name = _resource_identity(item, sizing) + row = new_row(row_id, name, code, entry["label"], entry["category"]) + size = _nested_get(item, sizing["size_field"]) + if size: + row["size_bytes"] = int(size) * multiplier + row["flags"].extend(sizing.get("flags", [])) + row["notes"].extend(sizing.get("notes", [])) + else: + row["size_unknown"] = True + rows.append(row) + return rows + + +def _collect_not_sizeable( + session: Any, region: str, code: str, entry: dict[str, Any] +) -> list[dict[str, Any]]: + sizing = entry["sizing"] + _, items = _enumerate(session, region, entry) + rows = [] + for item in items: + row_id, name = _resource_identity(item, sizing) + row = new_row(row_id, name, code, entry["label"], entry["category"]) + row["flags"].extend(sizing.get("flags", [])) + count_field = sizing.get("count_field") + count = _nested_get(item, count_field) if count_field else None + if count: + row["notes"].append(sizing["count_note"].format(count=count)) + rows.append(row) + return rows + + +def _collect_rds_instances( + session: Any, region: str, code: str, entry: dict[str, Any] +) -> list[dict[str, Any]]: + _, instances = _enumerate(session, region, entry) + cloudwatch = create_client(session, "cloudwatch", region) + + rows = [] + specs = [] + spec_rows: dict[str, tuple[dict[str, Any], int]] = {} + for index, instance in enumerate(instances): + identifier = instance["DBInstanceIdentifier"] + row = new_row( + instance.get("DBInstanceArn", identifier), + identifier, + code, + entry["label"], + entry["category"], + ) + if (instance.get("Engine") or "").startswith("aurora"): + row["flags"].append("Aurora – cluster-level storage not sized") + rows.append(row) + continue + + allocated_bytes = int(instance.get("AllocatedStorage") or 0) * GIB + spec_id = f"q{index}" + specs.append( + { + "id": spec_id, + "namespace": "AWS/RDS", + "metric_name": "FreeStorageSpace", + "dimensions": [{"Name": "DBInstanceIdentifier", "Value": identifier}], + } + ) + spec_rows[spec_id] = (row, allocated_bytes) + rows.append(row) + + values = fetch_latest_metric_values(cloudwatch, specs, period=300) + for spec_id, (row, allocated_bytes) in spec_rows.items(): + free_bytes = values.get(spec_id) if values else None + if free_bytes is not None and allocated_bytes: + row["size_bytes"] = max(int(allocated_bytes - free_bytes), 0) + row["notes"].append(f"allocated: {format_bytes(allocated_bytes)}") + elif allocated_bytes: + row["size_bytes"] = allocated_bytes + row["flags"].append("allocated (upper bound)") + else: + row["size_unknown"] = True + return rows + + +def _collect_dynamodb_tables( + session: Any, region: str, code: str, entry: dict[str, Any] +) -> list[dict[str, Any]]: + dynamodb_client, table_names = _enumerate(session, region, entry) + rows = [] + for table_name in table_names: + try: + table = dynamodb_client.describe_table(TableName=table_name).get( + "Table", {} + ) + except (BotoCoreError, ClientError) as e: + logger.debug("Describing table %s failed: %s", table_name, str(e)) + row = new_row( + table_name, table_name, code, entry["label"], entry["category"] + ) + row["size_unknown"] = True + rows.append(row) + continue + + row = new_row( + table.get("TableArn", table_name), + table_name, + code, + entry["label"], + entry["category"], + ) + size_bytes = int(table.get("TableSizeBytes") or 0) + size_bytes += sum( + int(index.get("IndexSizeBytes") or 0) + for index in table.get("GlobalSecondaryIndexes", []) + ) + row["size_bytes"] = size_bytes + rows.append(row) + return rows + + +# Strategy names stay stable so master data and engine can be deployed +# independently; several of them share one parameterised collector. The +# service-neutral names are the ones to use for new master-data rows. +_STRATEGY_COLLECTORS = { + "s3_bucket_metrics": _collect_s3_buckets, + "rds_instances": _collect_rds_instances, + "dynamodb_tables": _collect_dynamodb_tables, + "list_item_size": _collect_list_item_size, + "not_sizeable": _collect_not_sizeable, + # Kept so existing rows keep working; prefer the two names above. + "ebs_volumes": _collect_list_item_size, + "ebs_snapshots": _collect_list_item_size, + "backup_vaults": _collect_not_sizeable, +} + + +def collect_aws_egress( + provider_details: dict[str, Any], +) -> tuple[list[dict[str, Any]], set[str]]: + region = provider_details["region"] + session = boto3.Session( + aws_access_key_id=provider_details["accessKey"], + aws_secret_access_key=provider_details["secretKey"], + aws_session_token=provider_details.get("sessionToken"), + region_name=region, + ) + + rows = [] + for code, entry in load_egress_registry(2).items(): + collector = _STRATEGY_COLLECTORS.get(entry["strategy"]) + if collector is None: + # Master data can ship a strategy ahead of the engine; skip that one + # resource type rather than aborting the whole egress run. + logger.warning( + "Unknown egress strategy %r for %s; skipping.", + entry["strategy"], + code, + ) + continue + try: + rows.extend(collector(session, region, code, entry)) + except Exception as e: + # One failing service must not abort the run, but a whole resource + # type dropping out of the estimate has to be visible in run.log. + logger.warning( + "Egress collection failed for %s (%s: %s); " + "this resource type is missing from the estimate.", + code, + type(e).__name__, + str(e), + exc_info=True, + ) + + return rows, ARCHIVE_TIERS diff --git a/cloudexit/providers/aws/inventory.py b/cloudexit/providers/aws/inventory.py new file mode 100644 index 0000000..2663b72 --- /dev/null +++ b/cloudexit/providers/aws/inventory.py @@ -0,0 +1,287 @@ +# cloudexit/providers/aws/inventory.py +import boto3 +import json +import os +import logging +import sqlite3 +from typing import Any +from collections import defaultdict +from botocore.exceptions import ( + UnknownServiceError, +) + +from cloudexit.catalogue.db import connect, load_data +from cloudexit.providers.aws.client import ( + CONNECTION_ERRORS, + MAX_ITEMS_PER_RESOURCE_TYPE, + SERVICE_NOT_OFFERED, + SERVICE_OK, + SERVICE_UNKNOWN, + convert_datetime, + create_client, + paginate_or_call, + parse_resource_type_params, + resolve_aws_call_spec, + service_status, +) + +logger = logging.getLogger(__name__) + + +def _names(services: dict[str, int]) -> str: + return ", ".join(sorted(services)) or "none" + + +def build_aws_resource_inventory( + cloud_service_provider: int, + provider_details: dict[str, Any], + report_path: str, + raw_data_path: str, +) -> None: + try: + access_key = provider_details["accessKey"] + secret_key = provider_details["secretKey"] + region = provider_details["region"] + + session = boto3.Session( + aws_access_key_id=access_key, + aws_secret_access_key=secret_key, + aws_session_token=provider_details.get("sessionToken"), + region_name=region, + ) + + db_path = os.path.join(report_path, "data", "assessment.db") + + # Load the ResourceType mapping + resource_type_mapping = { + item["code"]: { + "id": item["id"], + "name": item["name"], + "params": item.get("params"), + } + for item in load_data("resourcetype") + if item["csp"] == 2 and item["status"] == "t" + } + + # Save raw data for debugging and auditing purposes + raw_data = [] + + # Aggregate resources by type and location + aggregated_resources = defaultdict(int) + + # How each catalogue row was resolved, summarised into run.log once the + # sweep is done. Without it a scan result cannot be traced back to the + # master data that produced it. + spec_sources = defaultdict(int) + + # Calls that were not made or did not work, by reason and service + skipped = { + SERVICE_NOT_OFFERED: defaultdict(int), + SERVICE_UNKNOWN: defaultdict(int), + "failed": defaultdict(int), + } + unreachable: set[str] = set() + + # Iterate through each resource type in the JSON + for idx, (resource_type_code, resource_info) in enumerate( + resource_type_mapping.items(), start=1 + ): + params = parse_resource_type_params(resource_info.get("params")) + spec = resolve_aws_call_spec(resource_type_code, params) + if spec is None: + if len(resource_type_code.split(".")) == 2: + # Service-level placeholder (e.g. AWS.iam) with nothing to + # call yet -- intentional, so keep it off the console. + spec_sources["placeholder"] += 1 + logger.debug( + "No call spec for placeholder resource type %s. Skipping.", + resource_type_code, + ) + else: + spec_sources["invalid"] += 1 + logger.warning( + "Invalid resource type format: %s. Skipping.", + resource_type_code, + ) + continue + + service_name = spec["service"] + operation_name = spec["operation"] + result_path = spec["result_path"] + call_kwargs = spec["kwargs"] + + spec_sources[spec["source"]] += 1 + logger.debug( + "Resolved %s from %s -> %s.%s result_path=%s kwargs=%s", + resource_type_code, + spec["source"], + service_name, + operation_name, + result_path, + call_kwargs, + ) + + if service_name in skipped[SERVICE_UNKNOWN]: + skipped[SERVICE_UNKNOWN][service_name] += 1 + continue + status = service_status(service_name, region) + if status != SERVICE_OK: + if service_name not in skipped[status]: + logger.debug( + "Skipping %s: %s", + service_name, + ( + f"not offered in {region}" + if status == SERVICE_NOT_OFFERED + else "unknown to the installed boto3" + ), + ) + skipped[status][service_name] += 1 + continue + if service_name in unreachable: + logger.debug( + "Skipping %s.%s: the %s endpoint could not be reached", + service_name, + operation_name, + service_name, + ) + skipped["failed"][service_name] += 1 + continue + + try: + client = create_client(session, service_name, region) + if not hasattr(client, operation_name): + # logger.error(f"Operation {operation_name} does not exist for service {service_name}") + continue + + resources = paginate_or_call( + client, + operation_name, + result_path, + MAX_ITEMS_PER_RESOURCE_TYPE, + **call_kwargs, + ) + if len(resources) >= MAX_ITEMS_PER_RESOURCE_TYPE: + logger.warning( + "Item cap of %d reached for %s.%s; results truncated.", + MAX_ITEMS_PER_RESOURCE_TYPE, + service_name, + operation_name, + ) + + # Aggregate the resources + for resource in resources: + aggregated_resources[(resource_type_code, region)] += 1 + + # Store raw data + raw_data.append( + { + "service": service_name, + "operation": operation_name, + "resources": resources, + } + ) + + except UnknownServiceError: + if service_name not in skipped[SERVICE_UNKNOWN]: + logger.debug( + "Skipping %s: unknown to the installed boto3", service_name + ) + skipped[SERVICE_UNKNOWN][service_name] += 1 + continue + except CONNECTION_ERRORS as exc: + unreachable.add(service_name) + skipped["failed"][service_name] += 1 + logger.debug( + "Cannot reach %s in %s, skipping the service: %s", + service_name, + region, + exc, + ) + continue + except Exception as exc: + skipped["failed"][service_name] += 1 + # Expected for services the caller can't access or that aren't + # available in a region. Keep at DEBUG (run.log only) so it never + # floods the console, while still distinguishing a failed service + # from an empty one. The message alone is the useful signal; the + # botocore stack is noise, so no exc_info here. + logger.debug( + "Error processing %s.%s in %s: %s", + service_name, + operation_name, + region, + exc, + ) + continue + + skipped_calls = sum(sum(counts.values()) for counts in skipped.values()) + if skipped_calls: + logger.info( + "%d service calls skipped (not offered in %s: %s; unknown to boto3: " + "%s; failed: %s)", + skipped_calls, + region, + _names(skipped[SERVICE_NOT_OFFERED]), + _names(skipped[SERVICE_UNKNOWN]), + _names(skipped["failed"]), + ) + + logger.info( + "Resource type resolution: %d of %d rows scanned (%d from params, " + "%d from code), %d placeholders skipped, %d invalid.", + spec_sources["params"] + spec_sources["code"], + len(resource_type_mapping), + spec_sources["params"], + spec_sources["code"], + spec_sources["placeholder"], + spec_sources["invalid"], + ) + + # Save raw data to a JSON file + raw_data = convert_datetime(raw_data) + + raw_file_path = os.path.join(raw_data_path, "resource_inventory_raw_data.json") + with open(raw_file_path, "w", encoding="utf-8") as raw_file: + json.dump(raw_data, raw_file, indent=4) + + # Insert aggregated data into SQLite + with connect(db_path=db_path) as conn: + cursor = conn.cursor() + + for ( + resource_type_code, + resource_location, + ), resource_count in aggregated_resources.items(): + try: + # Map resource type code to resource_type_id + resource_info = resource_type_mapping.get(resource_type_code) + if not resource_info: + # logger.warning(f"Resource type {resource_type_code} not found in resourcetype mapping. Skipping.") + continue + + resource_type_id = resource_info["id"] + + cursor.execute( + """ + INSERT INTO resource_inventory (resource_type, location, count) + VALUES (?, ?, ?) + ON CONFLICT(resource_type, location) DO UPDATE SET count = excluded.count + """, + (resource_type_id, resource_location, resource_count), + ) + except sqlite3.Error as e: + logger.error( + f"SQLite error while processing aggregated resource: {e}", + exc_info=True, + ) + except Exception as e: + logger.error( + f"Unexpected error while processing aggregated resource: {e}", + exc_info=True, + ) + + conn.commit() + + except Exception as e: + logger.error(f"Error creating AWS resource inventory: {str(e)}", exc_info=True) diff --git a/cloudexit/providers/azure/__init__.py b/cloudexit/providers/azure/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cloudexit/providers/azure/cli_session.py b/cloudexit/providers/azure/cli_session.py new file mode 100644 index 0000000..d555f8e --- /dev/null +++ b/cloudexit/providers/azure/cli_session.py @@ -0,0 +1,97 @@ +# cloudexit/providers/azure/cli_session.py +import logging +import shutil +import subprocess +from typing import Any +from rich.console import Console + +from cloudexit.terminal import ask +from cloudexit.providers.azure.sdk import azure_import + +logger = logging.getLogger(__name__) + + +console = Console() + + +def is_azure_cli_installed() -> bool: + return shutil.which("az") is not None + + +def is_azure_cli_logged_in() -> bool: + try: + # Run the 'az account show' command to check if the user is logged in + subprocess.run( + ["az", "account", "show"], + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + return True + except subprocess.CalledProcessError: + return False + + +def is_azure_cli_token_expired() -> bool: + AzureCliCredential = azure_import("azure.identity", "AzureCliCredential") + ClientAuthenticationError = azure_import( + "azure.core.exceptions", "ClientAuthenticationError" + ) + credential = AzureCliCredential() + try: + credential.get_token("https://management.azure.com/.default") + return False # Token is valid + except ClientAuthenticationError as e: + if "AADSTS700082" in str(e): + return True # Token expired + return False + + +def select_subscription(subscriptions: list[Any]) -> Any: + # logger.info("Listing available subscriptions for selection.") + console.print("Available Subscriptions:") + for idx, sub in enumerate(subscriptions, start=1): + console.print( + f"{idx}. {sub.display_name} ({sub.subscription_id})", + markup=False, + emoji=False, + ) + while True: + try: + selection = int(ask("Select a subscription by number: ").strip()) + if not (1 <= selection <= len(subscriptions)): + raise ValueError("Invalid subscription selection.") + selected_subscription = subscriptions[selection - 1] + # logger.info(f"Subscription selected: {selected_subscription.display_name} ({selected_subscription.subscription_id})") + return selected_subscription + except ValueError as e: + logger.debug(f"Invalid subscription selection: {e}") + console.print( + f"{e} Please select a valid number.", + style="red", + markup=False, + emoji=False, + ) + + +def select_resource_group(resource_groups: list[Any]) -> str: + # logger.info("Listing available resource groups for selection.") + console.print("Available Resource Groups:") + for idx, rg in enumerate(resource_groups, start=1): + console.print(f"{idx}. {rg.name}", markup=False, emoji=False) + while True: + try: + selection = int(ask("Select a resource group by number: ").strip()) + if not (1 <= selection <= len(resource_groups)): + raise ValueError("Invalid resource group selection.") + selected_resource_group = resource_groups[selection - 1].name + # logger.info(f"Resource Group selected: {selected_resource_group}") + return selected_resource_group + except ValueError as e: + logger.debug(f"Invalid resource group selection: {e}") + console.print( + f"{e} Please select a valid number.", + style="red", + markup=False, + emoji=False, + ) diff --git a/cloudexit/providers/azure/clients.py b/cloudexit/providers/azure/clients.py new file mode 100644 index 0000000..20e7875 --- /dev/null +++ b/cloudexit/providers/azure/clients.py @@ -0,0 +1,44 @@ +# cloudexit/providers/azure/clients.py +import logging +from typing import Any + + +from cloudexit.providers.azure.sdk import azure_import + +logging.getLogger("azure").setLevel(logging.WARNING) + + +MANAGEMENT_URL = "https://management.azure.com" + + +def create_credential(provider_details: dict[str, Any]) -> Any: + # A credential from the Azure CLI or the environment wins; otherwise the + # service principal from the configuration is used. + credential = provider_details.get("credential") + if credential: + return credential + ClientSecretCredential = azure_import("azure.identity", "ClientSecretCredential") + return ClientSecretCredential( + tenant_id=provider_details["tenantId"], + client_id=provider_details["clientId"], + client_secret=provider_details["clientSecret"], + ) + + +def create_resource_client(credential: Any, subscription_id: str) -> Any: + ResourceManagementClient = azure_import( + "azure.mgmt.resource.resources", "ResourceManagementClient" + ) + return ResourceManagementClient(credential, subscription_id) + + +def create_subscription_client(credential: Any) -> Any: + SubscriptionClient = azure_import("azure.mgmt.subscription", "SubscriptionClient") + return SubscriptionClient(credential) + + +def create_cost_client(credential: Any) -> Any: + CostManagementClient = azure_import( + "azure.mgmt.costmanagement", "CostManagementClient" + ) + return CostManagementClient(credential, base_url=MANAGEMENT_URL) diff --git a/cloudexit/providers/azure/costs.py b/cloudexit/providers/azure/costs.py new file mode 100644 index 0000000..bbe36ef --- /dev/null +++ b/cloudexit/providers/azure/costs.py @@ -0,0 +1,228 @@ +# cloudexit/providers/azure/costs.py +import json +import os +import logging +import sqlite3 +import time +from typing import Any +from datetime import date, datetime +from dateutil.relativedelta import relativedelta + +from cloudexit.terminal import print_step + +from cloudexit.catalogue.db import connect +from cloudexit.providers.azure.sdk import azure_import +from cloudexit.providers.azure.clients import create_cost_client, create_credential +from cloudexit.providers.azure.inventory import plain_json + +logger = logging.getLogger(__name__) + + +COST_RETRY_STATUSES = (429, 503) + + +COST_MAX_ATTEMPTS = 4 + + +COST_MAX_WAIT_SECONDS = 180 + + +COST_DEFAULT_WAITS = (20, 40, 60) + + +COST_RATE_LIMITED = ( + "Azure Cost Management kept rate limiting this subscription; " + "try again in a few minutes." +) + + +class CostRateLimited(Exception): + pass + + +def retry_wait(error: Any, attempt: int) -> float: + # Azure says how long to wait in Retry-After or in one of its own + # rate-limit headers; without either, wait a little longer each time. + headers = getattr(getattr(error, "response", None), "headers", None) or {} + waits = [] + for name, value in headers.items(): + lowered = str(name).lower() + if lowered == "retry-after" or ( + lowered.startswith("x-ms-ratelimit-microsoft.costmanagement-") + and lowered.endswith("-retry-after") + ): + try: + waits.append(max(0.0, float(value))) + except (TypeError, ValueError): + continue + if waits: + return max(waits) + return float(COST_DEFAULT_WAITS[min(attempt, len(COST_DEFAULT_WAITS) - 1)]) + + +def query_costs(client: Any, scope: str, query: Any) -> Any: + HttpResponseError = azure_import("azure.core.exceptions", "HttpResponseError") + waited = 0.0 + for attempt in range(COST_MAX_ATTEMPTS): + try: + return client.query.usage(scope, query) + except HttpResponseError as error: + if getattr(error, "status_code", None) not in COST_RETRY_STATUSES: + raise + wait = retry_wait(error, attempt) + if ( + attempt == COST_MAX_ATTEMPTS - 1 + or waited + wait > COST_MAX_WAIT_SECONDS + ): + raise CostRateLimited(COST_RATE_LIMITED) from error + logger.debug( + "Azure cost query answered %s; retrying in %.0f s (attempt %d of %d)", + error.status_code, + wait, + attempt + 1, + COST_MAX_ATTEMPTS, + ) + print_step( + f"Azure cost API is rate limited, retrying in {wait:.0f} s…", + status="warning", + ) + time.sleep(wait) + waited += wait + raise CostRateLimited(COST_RATE_LIMITED) # pragma: no cover + + +def cost_result_dict(result: Any) -> dict[str, Any]: + # The REST shape nests the columns and rows under "properties"; the raw + # file keeps them at the top level. + data = plain_json(result.as_dict()) + properties = data.pop("properties", None) or {} + return {**data, **properties} + + +def get_missing_months_azure(processed_costs: set[str], months_back: int) -> set[date]: + today = date.today() + start_date = today.replace(day=1) - relativedelta(months=months_back - 1) + all_months = { + (start_date + relativedelta(months=i)).replace(day=1) + for i in range(months_back) + } + + processed_months = set() + for month_str in processed_costs: + try: + # Attempt parsing with full timestamp format + month_date = ( + datetime.strptime(month_str, "%Y-%m-%dT%H:%M:%S").date().replace(day=1) + ) + except ValueError: + # Fallback to date-only format if full timestamp fails + month_date = datetime.strptime(month_str, "%Y-%m-%d").date().replace(day=1) + processed_months.add(month_date) + + return all_months - processed_months + + +def build_azure_cost_inventory( + cloud_service_provider: int, + provider_details: dict[str, Any], + report_path: str, + raw_data_path: str, +) -> None: + QueryDefinition = azure_import( + "azure.mgmt.costmanagement.models", "QueryDefinition" + ) + TimeframeType = azure_import("azure.mgmt.costmanagement.models", "TimeframeType") + try: + credential = create_credential(provider_details) + cost_management_client = create_cost_client(credential) + + db_path = os.path.join(report_path, "data", "assessment.db") + + end_time = date.today() + months_back = 6 + start_time = end_time.replace(day=1) - relativedelta(months=months_back - 1) + + query = QueryDefinition( + type="Usage", + timeframe=TimeframeType.CUSTOM, + time_period={ + "from": start_time.strftime("%Y-%m-%dT00:00:00Z"), + "to": end_time.strftime("%Y-%m-%dT00:00:00Z"), + }, + dataset={ + "granularity": "Monthly", + "aggregation": {"totalCost": {"name": "Cost", "function": "Sum"}}, + }, + ) + + cost_data = query_costs( + cost_management_client, + f'/subscriptions/{provider_details["subscriptionId"]}/resourceGroups/{provider_details["resourceGroupName"]}', + query, + ) + + cost_inventory_raw_path = os.path.join( + raw_data_path, "cost_inventory_raw_data.json" + ) + with open(cost_inventory_raw_path, "w", encoding="utf-8") as raw_file: + json.dump(cost_result_dict(cost_data), raw_file, indent=4) + + # Insert structured cost data into SQLite + currency = "USD" + with connect(db_path=db_path) as conn: + cursor = conn.cursor() + + for row in cost_data.rows: + cost, month_str, currency = row + month_date = ( + datetime.strptime(month_str, "%Y-%m-%dT%H:%M:%S") + .date() + .replace(day=1) + .isoformat() + ) + + # Insert or update cost data + cursor.execute( + """ + INSERT INTO cost_inventory (month, cost, currency) + VALUES (?, ?, ?) + ON CONFLICT(month) DO UPDATE SET + cost = excluded.cost, + currency = excluded.currency + """, + (month_date, cost, currency), + ) + + # Extract months already in the cost data + structured_months = { + datetime.strptime(row[1], "%Y-%m-%dT%H:%M:%S").date() + for row in cost_data.rows + } + + # Identify missing months and insert with zero cost + missing_months = get_missing_months_azure( + {month.isoformat() for month in structured_months}, 6 + ) + for missing_month in missing_months: + cursor.execute( + """ + INSERT INTO cost_inventory (month, cost, currency) + VALUES (?, 0.00, ?) + ON CONFLICT(month) DO UPDATE SET + currency = excluded.currency + """, + (missing_month.isoformat(), currency), + ) + + conn.commit() + + except sqlite3.Error as e: + logger.error(f"SQLite error: {str(e)}", exc_info=True) + raise + except CostRateLimited: + # The console gets the one clear line; the traceback stays in run.log + logger.debug("Azure cost query kept being rate limited", exc_info=True) + raise + except Exception as e: + logger.error(f"Error creating Azure cost inventory: {str(e)}", exc_info=True) + raise diff --git a/cloudexit/providers/azure/egress.py b/cloudexit/providers/azure/egress.py new file mode 100644 index 0000000..5c118a8 --- /dev/null +++ b/cloudexit/providers/azure/egress.py @@ -0,0 +1,293 @@ +# cloudexit/providers/azure/egress.py +import logging +import requests +from typing import Any +from datetime import datetime, timedelta, timezone + +from cloudexit.providers.azure.clients import create_credential, create_resource_client +from cloudexit.egress.model import GIB, format_bytes, load_egress_registry, new_row + +logger = logging.getLogger(__name__) + +VAULT_FINDING = ( + "Recovery Services vault detected: backup recovery points cannot be " + "exported from Azure. An exit means losing restore history or retaining " + "the vault until retention expires." +) + +METRICS_API_VERSION = "2018-01-01" +MANAGEMENT_SCOPE = "https://management.azure.com/.default" +MANAGEMENT_BASE_URL = "https://management.azure.com" + +ARCHIVE_TIERS = {"Archive"} + + +def filter_data_bearing_resources( + resources: list[Any], + registry: dict[str, dict[str, Any]], +) -> list[tuple[Any, dict[str, Any]]]: + matched = [] + for resource in resources: + entry = registry.get(resource.type.strip().lower()) + if entry: + matched.append((resource, entry)) + return matched + + +def _latest_average(datapoints: list[dict[str, Any]]) -> float | None: + for point in reversed(datapoints): + value = point.get("average") + if value is not None: + return float(value) + return None + + +def fetch_monitor_metrics( + credential: Any, + resource_id: str, + metric_names: list[str], + *, + dimension: str | None = None, + timeout: int = 30, +) -> dict[str, list[dict[str, Any]]] | None: + try: + token = credential.get_token(MANAGEMENT_SCOPE) + end = datetime.now(timezone.utc) + start = end - timedelta(days=2) + params = { + "api-version": METRICS_API_VERSION, + "metricnames": ",".join(metric_names), + "timespan": ( + f"{start.strftime('%Y-%m-%dT%H:%M:%SZ')}/" + f"{end.strftime('%Y-%m-%dT%H:%M:%SZ')}" + ), + "aggregation": "Average", + "interval": "PT1H", + } + if dimension: + params["$filter"] = f"{dimension} eq '*'" + response = requests.get( + f"{MANAGEMENT_BASE_URL}{resource_id}/providers/Microsoft.Insights/metrics", + headers={"Authorization": f"Bearer {token.token}"}, + params=params, + timeout=timeout, + ) + response.raise_for_status() + payload = response.json() + except requests.RequestException as e: + logger.debug("Metrics request failed for %s: %s", resource_id, str(e)) + return None + except Exception as e: + logger.debug("Metrics lookup failed for %s: %s", resource_id, str(e)) + return None + + result: dict[str, list[dict[str, Any]]] = {name: [] for name in metric_names} + for metric in payload.get("value", []): + name = metric.get("name", {}).get("value", "") + series_values = [] + for series in metric.get("timeseries", []): + dimension_value = None + if dimension: + for metadata in series.get("metadatavalues", []): + metadata_name = metadata.get("name", {}).get("value", "") + if metadata_name.lower() == dimension.lower(): + dimension_value = metadata.get("value") + value = _latest_average(series.get("data", [])) + if value is not None: + series_values.append({"dimension": dimension_value, "value": value}) + result[name] = series_values + return result + + +def _metric_total( + metrics: dict[str, list[dict[str, Any]]] | None, name: str +) -> float | None: + if not metrics or not metrics.get(name): + return None + return sum(point["value"] for point in metrics[name]) + + +def _base_row(resource: Any, entry: dict[str, Any]) -> dict[str, Any]: + row = new_row( + resource.id, resource.name, resource.type, entry["label"], entry["category"] + ) + row["findings"] = [] + return row + + +def _unknown_size_finding(resource: Any) -> dict[str, str]: + return { + "severity": "warning", + "resource": resource.name, + "message": ( + f"{resource.name}: size unknown – no metric datapoints in the last " + "2 days (capacity metrics update roughly daily); excluded from totals." + ), + } + + +def _collect_storage_account( + credential: Any, resource_client: Any, resource: Any, entry: dict[str, Any] +) -> dict[str, Any]: + row = _base_row(resource, entry) + + account_metrics = fetch_monitor_metrics(credential, resource.id, ["UsedCapacity"]) + used_capacity = _metric_total(account_metrics, "UsedCapacity") + + blob_metrics = fetch_monitor_metrics( + credential, + f"{resource.id}/blobServices/default", + ["BlobCapacity"], + dimension="Tier", + ) + tier_bytes: dict[str, int] = {} + if blob_metrics: + for point in blob_metrics.get("BlobCapacity", []): + tier = (point["dimension"] or "Unknown").capitalize() + tier_bytes[tier] = tier_bytes.get(tier, 0) + int(point["value"]) + + if used_capacity is not None: + row["size_bytes"] = int(used_capacity) + elif tier_bytes: + row["size_bytes"] = sum(tier_bytes.values()) + row["tier_bytes"] = tier_bytes or None + + archive_bytes = tier_bytes.get("Archive", 0) + if archive_bytes: + row["flags"].append( + f"Archive: {format_bytes(archive_bytes)} (rehydration required)" + ) + + sku_name = getattr(getattr(resource, "sku", None), "name", None) + if sku_name and any(geo in sku_name.upper() for geo in ("GRS", "GZRS")): + row["notes"].append(f"geo-replicated ({sku_name})") + row["findings"].append( + { + "severity": "info", + "resource": resource.name, + "message": ( + f"{resource.name}: SKU {sku_name} is geo-replicated; the " + "geo-secondary copy is not additional data to egress " + "(not double-counted)." + ), + } + ) + + if row["size_bytes"] is None: + row["size_unknown"] = True + row["findings"].append(_unknown_size_finding(resource)) + return row + + +def _collect_allocated_size( + credential: Any, resource_client: Any, resource: Any, entry: dict[str, Any] +) -> dict[str, Any]: + row = _base_row(resource, entry) + row["flags"].append("allocated (upper bound)") + + full_resource = resource_client.resources.get_by_id( + resource.id, api_version=entry["api_version"] + ) + size_gb = (full_resource.properties or {}).get(entry["size_property"]) + if size_gb: + row["size_bytes"] = int(size_gb) * GIB + else: + row["size_unknown"] = True + row["findings"].append(_unknown_size_finding(resource)) + return row + + +def _collect_monitor_metric( + credential: Any, resource_client: Any, resource: Any, entry: dict[str, Any] +) -> dict[str, Any]: + row = _base_row(resource, entry) + + metrics = fetch_monitor_metrics(credential, resource.id, entry["metrics"]) + values = [ + total + for name in entry["metrics"] + if (total := _metric_total(metrics, name)) is not None + ] + if values: + row["size_bytes"] = int(sum(values)) + else: + row["size_unknown"] = True + row["findings"].append(_unknown_size_finding(resource)) + return row + + +def _collect_vault( + credential: Any, resource_client: Any, resource: Any, entry: dict[str, Any] +) -> dict[str, Any]: + row = _base_row(resource, entry) + row["flags"].append("backup vault – not sized") + row["findings"].append( + { + "severity": "warning", + "resource": resource.name, + "message": f"{resource.name}: {VAULT_FINDING}", + } + ) + return row + + +_STRATEGY_COLLECTORS = { + "storage_account_metrics": _collect_storage_account, + "allocated_size_property": _collect_allocated_size, + "monitor_metric": _collect_monitor_metric, + "vault_warning": _collect_vault, +} + + +def build_egress_inventory( + credential: Any, resource_client: Any, resources: list[Any] +) -> tuple[list[dict[str, Any]], list[dict[str, str]]]: + rows = [] + findings = [] + registry = load_egress_registry(1) + for resource, entry in filter_data_bearing_resources(resources, registry): + collector = _STRATEGY_COLLECTORS.get(entry["strategy"]) + if collector is None: + # Master data can ship a strategy ahead of the engine; skip that one + # resource type rather than aborting the whole egress run. + logger.warning( + "Unknown egress strategy %r for %s; skipping.", + entry["strategy"], + resource.type, + ) + continue + try: + row = collector(credential, resource_client, resource, entry) + except Exception as e: + logger.debug( + "Egress sizing failed for %s: %s", resource.id, str(e), exc_info=True + ) + row = _base_row(resource, entry) + row["size_unknown"] = True + row["flags"].append("size unavailable") + row["findings"].append( + { + "severity": "warning", + "resource": resource.name, + "message": f"{resource.name}: size lookup failed ({str(e)}).", + } + ) + findings.extend(row.pop("findings")) + rows.append(row) + return rows, findings + + +def collect_azure_egress( + provider_details: dict[str, Any], +) -> tuple[list[dict[str, Any]], set[str]]: + credential = create_credential(provider_details) + subscription_id = provider_details["subscriptionId"] + resource_group_name = provider_details["resourceGroupName"] + + resource_client = create_resource_client(credential, subscription_id) + resources = list( + resource_client.resources.list_by_resource_group(resource_group_name) + ) + + rows, _ = build_egress_inventory(credential, resource_client, resources) + return rows, ARCHIVE_TIERS diff --git a/cloudexit/providers/azure/inventory.py b/cloudexit/providers/azure/inventory.py new file mode 100644 index 0000000..ea3244f --- /dev/null +++ b/cloudexit/providers/azure/inventory.py @@ -0,0 +1,146 @@ +# cloudexit/providers/azure/inventory.py +import json +import os +import logging +import sqlite3 +from typing import Any +from datetime import date, datetime +from collections import defaultdict + + +from cloudexit.catalogue.db import connect, load_data +from cloudexit.providers.azure.sdk import azure_import +from cloudexit.providers.azure.clients import create_credential, create_resource_client + +logger = logging.getLogger(__name__) + + +def plain_json(value: Any) -> Any: + # SDK models are mappings (and datetimes appear in some of them); the raw + # data files hold plain JSON. + if hasattr(value, "keys"): + return {key: plain_json(value[key]) for key in value.keys()} + if isinstance(value, (list, tuple)): + return [plain_json(item) for item in value] + if isinstance(value, (datetime, date)): + return value.isoformat() + return value + + +def is_resource_inventory_empty( + credential: Any, subscription_id: str, resource_group_name: str +) -> bool: + AzureError = azure_import("azure.core.exceptions", "AzureError") + try: + resource_client = create_resource_client(credential, subscription_id) + # logger.info("Checking Azure resource inventory...") + resources = list( + resource_client.resources.list_by_resource_group(resource_group_name) + ) + if not resources: + # logger.info("No resources found in the resource group.") + return True + else: + # logger.info("Resources found in the resource group.") + return False + except AzureError as e: + # Re-raised to build_azure_resource_inventory, which logs at ERROR with + # the traceback; keep this at DEBUG to avoid double-logging. + logger.debug( + f"Error checking Azure resource inventory: {str(e)}", exc_info=True + ) + raise + + +def build_azure_resource_inventory( + cloud_service_provider: int, + provider_details: dict[str, Any], + report_path: str, + raw_data_path: str, +) -> None: + ClientAuthenticationError = azure_import( + "azure.core.exceptions", "ClientAuthenticationError" + ) + try: + credential = create_credential(provider_details) + subscription_id = provider_details["subscriptionId"] + resource_group_name = provider_details["resourceGroupName"] + + db_path = os.path.join(report_path, "data", "assessment.db") + + # Check if resource inventory is empty + if is_resource_inventory_empty( + credential, subscription_id, resource_group_name + ): + logger.warning( + "The selected resource group does not contain any resources." + ) + return + + resource_client = create_resource_client(credential, subscription_id) + + # Fetch resources and serialize to raw JSON + resources = list( + resource_client.resources.list_by_resource_group(resource_group_name) + ) + raw_data = [plain_json(resource) for resource in resources] + + # Save raw data to a JSON file + raw_file_path = os.path.join(raw_data_path, "resource_inventory_raw_data.json") + with open(raw_file_path, "w", encoding="utf-8") as raw_file: + json.dump(raw_data, raw_file, indent=4) + + # Load resource type mapping from the assessment database + resource_type_mapping = getattr( + build_azure_resource_inventory, "_resource_type_cache", None + ) + if resource_type_mapping is None: + resource_type_mapping = { + item["code"].strip().lower(): {"id": item["id"], "name": item["name"]} + for item in load_data("resourcetype", db_path=db_path) + if item["csp"] == 1 and item["status"] == "t" + } + build_azure_resource_inventory._resource_type_cache = resource_type_mapping + + # Aggregate resources by type and location + aggregated_resources = defaultdict(int) + for resource in resources: + resource_type_code = resource.type.strip().lower() + # Non-regional resources have no location; Azure calls them "global" + resource_location = (resource.location or "").strip().lower() or "global" + aggregated_resources[(resource_type_code, resource_location)] += 1 + + # Insert data into SQLite + with connect(db_path=db_path) as conn: + cursor = conn.cursor() + data_to_insert = [ + ( + resource_type_mapping[resource_type_code]["id"], + resource_location, + resource_count, + ) + for ( + resource_type_code, + resource_location, + ), resource_count in aggregated_resources.items() + if resource_type_code in resource_type_mapping + ] + cursor.executemany( + """ + INSERT INTO resource_inventory (resource_type, location, count) + VALUES (?, ?, ?) + ON CONFLICT(resource_type, location) DO UPDATE SET count = excluded.count + """, + data_to_insert, + ) + conn.commit() + + except ClientAuthenticationError as e: + logger.error(f"Azure authentication error: {str(e)}", exc_info=True) + raise + except sqlite3.Error as e: + logger.error(f"SQLite error: {str(e)}", exc_info=True) + raise + except Exception as e: + logger.error(f"Error fetching Azure resources: {str(e)}", exc_info=True) + raise diff --git a/cloudexit/providers/azure/sdk.py b/cloudexit/providers/azure/sdk.py new file mode 100644 index 0000000..eec9d56 --- /dev/null +++ b/cloudexit/providers/azure/sdk.py @@ -0,0 +1,80 @@ +# cloudexit/providers/azure/sdk.py +import importlib +import logging +from importlib.metadata import PackageNotFoundError, requires +from typing import Any + +logger = logging.getLogger(__name__) + + +# The SDK classes the Azure paths use, and the distribution that provides each +SDK_SYMBOLS = ( + ("azure.identity", "AzureCliCredential"), + ("azure.identity", "ClientSecretCredential"), + ("azure.identity", "DefaultAzureCredential"), + ("azure.core.exceptions", "AzureError"), + ("azure.core.exceptions", "ClientAuthenticationError"), + ("azure.mgmt.resource.resources", "ResourceManagementClient"), + ("azure.mgmt.subscription", "SubscriptionClient"), + ("azure.mgmt.costmanagement", "CostManagementClient"), + ("azure.mgmt.costmanagement.models", "QueryDefinition"), + ("azure.mgmt.costmanagement.models", "TimeframeType"), + ("azure.mgmt.authorization", "AuthorizationManagementClient"), +) + + +SDK_DISTRIBUTIONS = { + "azure.identity": "azure-identity", + "azure.core": "azure-core", + "azure.mgmt.resource": "azure-mgmt-resource", + "azure.mgmt.subscription": "azure-mgmt-subscription", + "azure.mgmt.costmanagement": "azure-mgmt-costmanagement", + "azure.mgmt.authorization": "azure-mgmt-authorization", +} + + +class AzureSdkError(Exception): + def __init__(self, module: str, name: str): + self.module = module + self.name = name + super().__init__( + f"The Azure SDK in this environment is incomplete (cannot import " + f"{module}.{name}). Reinstall it with: pip install --force-reinstall " + f"--no-deps {_requirement(module)}, or install cloudexit into a new " + "virtual environment." + ) + + +def _requirement(module: str) -> str: + distribution = next( + ( + name + for prefix, name in SDK_DISTRIBUTIONS.items() + if module == prefix or module.startswith(f"{prefix}.") + ), + module, + ) + try: + # The pinned version this installation of cloudexit asks for + for line in requires("cloudexit") or []: + requirement = line.split(";")[0].replace(" ", "") + if requirement.lower().startswith(f"{distribution}=="): + return requirement + except PackageNotFoundError: + pass + return distribution + + +def azure_import(module: str, name: str) -> Any: + # The Azure SDK is imported where it is used, so a broken or missing SDK + # cannot stop commands that never touch Azure. + try: + return getattr(importlib.import_module(module), name) + except (ImportError, AttributeError) as exc: + logger.debug("Cannot import %s.%s: %s", module, name, exc) + raise AzureSdkError(module, name) from None + + +def require_azure_sdk() -> None: + for module, name in SDK_SYMBOLS: + azure_import(module, name) diff --git a/cloudexit/report/__init__.py b/cloudexit/report/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cloudexit/report/aggregate.py b/cloudexit/report/aggregate.py new file mode 100644 index 0000000..ea24404 --- /dev/null +++ b/cloudexit/report/aggregate.py @@ -0,0 +1,241 @@ +from collections import defaultdict +from datetime import datetime +from typing import Any + +CURRENCY_SYMBOLS = { + "USD": "$", + "GBP": "£", + "EUR": "€", +} + +EU_COUNTRY_CODES = { + "AT", + "BE", + "BG", + "HR", + "CY", + "CZ", + "DK", + "EE", + "FI", + "FR", + "DE", + "GR", + "HU", + "IE", + "IT", + "LV", + "LT", + "LU", + "MT", + "NL", + "PL", + "PT", + "RO", + "SK", + "SI", + "ES", + "SE", +} + +REGION_LABELS = { + "european-union": "European Union", + "united-kingdom": "United Kingdom", + "switzerland": "Switzerland", + "united-states": "United States", + "other": "Other", +} + + +def normalize_country_code(country_code: Any) -> str | None: + if not isinstance(country_code, str): + return None + normalized = country_code.strip().upper() + return normalized if len(normalized) == 2 and normalized.isalpha() else None + + +def country_code_to_region(country_code: Any) -> str: + normalized = normalize_country_code(country_code) + if not normalized: + return "other" + if normalized in EU_COUNTRY_CODES: + return "european-union" + if normalized == "GB": + return "united-kingdom" + if normalized == "CH": + return "switzerland" + if normalized == "US": + return "united-states" + return "other" + + +def country_code_to_flag(country_code: Any) -> str: + normalized = normalize_country_code(country_code) + if not normalized: + return "" + return chr(127397 + ord(normalized[0])) + chr(127397 + ord(normalized[1])) + + +def sort_cost_data(cost_data: list[dict[str, Any]]) -> list[dict[str, Any]]: + return sorted(cost_data, key=lambda x: datetime.strptime(x["month"], "%Y-%m-%d")) + + +def summarize_risks( + risk_data: list[dict[str, Any]], + risk_definitions: list[dict[str, Any]], + *, + resource_name_map: dict[str, str] | None = None, + resource_id_map: dict[str, int] | None = None, +) -> tuple[list[dict[str, Any]], dict[str, int]]: + risk_def_map = {rd["id"]: rd for rd in risk_definitions} + severity_counts = {"high": 0, "medium": 0, "low": 0} + + grouped_risks = defaultdict( + lambda: { + "impacted_resource_types": set(), + "impacted_resources_count": 0, + "has_overall_risk": False, + } + ) + + for entry in risk_data: + risk_code = entry["risk"] + resource_type = entry["resource_type"] + + if resource_type is None or resource_type == "null": + grouped_risks[risk_code]["has_overall_risk"] = True + continue + + resource_type = str(resource_type) + grouped_risks[risk_code]["impacted_resource_types"].add(resource_type) + grouped_risks[risk_code]["impacted_resources_count"] += 1 + + summarized_risks = [] + for risk_code, risk_info in grouped_risks.items(): + risk_definition = risk_def_map.get(risk_code) + if not risk_definition: + continue + + severity = risk_definition["severity"] + if severity in severity_counts: + severity_counts[severity] += 1 + + resource_types = sorted(risk_info["impacted_resource_types"]) + resource_names = None + if resource_name_map is not None: + resource_names = [ + resource_name_map.get(resource_type, "Unknown Resource") + for resource_type in resource_types + ] + + resource_ids = None + if resource_id_map is not None: + resource_ids = [ + resource_id_map[resource_type] + for resource_type in resource_types + if resource_type in resource_id_map + ] + + impacted_resources_count = ( + None + if risk_info["has_overall_risk"] + else risk_info["impacted_resources_count"] + ) + + summarized_risks.append( + { + "id": risk_code, + "name": risk_definition["name"], + "description": risk_definition["description"], + "severity": severity, + "impacted_resource_types": resource_types, + "impacted_resources": resource_names, + "impacted_resource_ids": resource_ids, + "impacted_resources_count": impacted_resources_count, + } + ) + + return summarized_risks, severity_counts + + +def summarize_alternative_technologies( + resource_inventory: list[dict[str, Any]], + alternatives: list[dict[str, Any]], + alternative_technologies: list[dict[str, Any]], + exit_strategy: int, + alternative_technology_organizations: list[dict[str, Any]] | None = None, +) -> dict[str, list[dict[str, Any]]]: + active_technologies = { + tech["id"]: tech + for tech in alternative_technologies + if tech.get("status") == "t" + } + + grouped_alt_tech: dict[str, list[dict[str, Any]]] = { + str(resource["resource_type"]): [] for resource in resource_inventory + } + organization_by_id = { + org["id"]: org for org in (alternative_technology_organizations or []) + } + + for alt in alternatives: + if str(alt["strategy_type"]) != str(exit_strategy): + continue + + resource_type = str(alt["resource_type"]) + tech = active_technologies.get(alt["alternative_technology"]) + if not tech or resource_type not in grouped_alt_tech: + continue + organization = organization_by_id.get(tech.get("organization_id")) + organization_country_code = normalize_country_code( + organization.get("country_code") if organization else None + ) + organization_region = country_code_to_region(organization_country_code) + + grouped_alt_tech[resource_type].append( + { + "product_name": tech["product_name"], + "product_description": tech["product_description"], + "product_url": tech["product_url"], + "open_source": tech["open_source"] == "t", + "support_plan": tech["support_plan"] == "t", + "status": tech["status"] == "t", + "organization_name": ( + organization.get("name") if organization else "Unknown Organization" + ), + "organization_url": organization.get("url") if organization else None, + "organization_country_code": organization_country_code or "N/A", + "organization_region": organization_region, + "organization_region_label": REGION_LABELS.get( + organization_region, "Other" + ), + "organization_flag": country_code_to_flag(organization_country_code), + } + ) + + return grouped_alt_tech + + +def enrich_resource_inventory( + resource_inventory: list[dict[str, Any]], + resource_type_mapping: dict[str, dict[str, Any]], +) -> list[dict[str, Any]]: + enriched_resources = [] + for idx, resource in enumerate(resource_inventory): + resource_type = str(resource["resource_type"]) + resource_info = resource_type_mapping.get(resource_type, {}) + icon = resource_info.get("icon") or "" + + entry = { + "id": idx + 1, + "resource_type": resource_type, + "code": resource_info.get("code", "N/A"), + "resource_name": resource_info.get("name", "Unknown Resource"), + "icon": icon, + "location": resource.get("location", "Unknown"), + "count": resource.get("count", 0), + } + + enriched_resources.append(entry) + + return enriched_resources diff --git a/cloudexit/report/assets.py b/cloudexit/report/assets.py new file mode 100644 index 0000000..e08be54 --- /dev/null +++ b/cloudexit/report/assets.py @@ -0,0 +1,84 @@ +# cloudexit/report/assets.py +import os +import shutil +import logging + +from cloudexit.catalogue.store import ( + CatalogueError, + active_catalogue_path, + validate_schema, +) + +from cloudexit.resources import asset_path + +logger = logging.getLogger(__name__) + +# Icon folder per cloud service provider id +PROVIDER_ICON_DIRS = {1: "azure", 2: "aws"} + + +def _png_only(src: str, names: list[str]) -> list[str]: + # Keep directories so copytree still walks the whole tree, drop every + # file that isn't a PNG. The renderers never load an icon SVG. + return [ + name + for name in names + if not os.path.isdir(os.path.join(src, name)) + and not name.lower().endswith(".png") + ] + + +def _icon_dirs(cloud_service_provider: int) -> list[str]: + provider_dir = PROVIDER_ICON_DIRS.get(cloud_service_provider) + + if provider_dir is None: + logger.warning( + "Unknown cloud service provider %s, copying every provider icon set", + cloud_service_provider, + ) + return list(PROVIDER_ICON_DIRS.values()) + + return [provider_dir] + + +def copy_assets(report_path: str, cloud_service_provider: int) -> None: + assets_path = os.path.join(report_path, "assets") + + # Create the 'assets' directory if it doesn't exist + os.makedirs(assets_path, exist_ok=True) + + for folder in ("css", "fonts", "img", "js"): + src_path = asset_path(folder) + dest_path = os.path.join(assets_path, folder) + + # Only copy if the destination doesn't already exist + if not os.path.exists(dest_path): + shutil.copytree(src_path, dest_path, dirs_exist_ok=True) + + # Only the assessed provider's icons travel with the report + for folder in _icon_dirs(cloud_service_provider): + src_path = asset_path("icons", folder) + dest_path = os.path.join(assets_path, "icons", folder) + + # Only copy if the destination doesn't already exist + if not os.path.exists(dest_path): + shutil.copytree(src_path, dest_path, ignore=_png_only, dirs_exist_ok=True) + + # Copy the catalogue this run used to data/assessment.db + db_src_path = str(active_catalogue_path()) + db_dest_dir = os.path.join(report_path, "data") + db_dest_path = os.path.join(db_dest_dir, "assessment.db") + + # Create the 'data' directory if it doesn't exist + os.makedirs(db_dest_dir, exist_ok=True) + + # Only copy if the destination doesn't already exist + if not os.path.exists(db_dest_path): + shutil.copyfile(db_src_path, db_dest_path) + # The run writes its results into this copy, so what it holds is checked + # here too: a copy with a trigger or a view is not kept + try: + validate_schema(db_dest_path) + except CatalogueError: + os.unlink(db_dest_path) + raise diff --git a/cloudexit/report/base.py b/cloudexit/report/base.py new file mode 100644 index 0000000..a7ad1c9 --- /dev/null +++ b/cloudexit/report/base.py @@ -0,0 +1,221 @@ +from reportlab.lib.colors import HexColor +from reportlab.lib.pagesizes import A4 +from reportlab.lib.units import cm +from reportlab.pdfgen.canvas import Canvas +from reportlab.platypus.doctemplate import BaseDocTemplate, PageTemplate +from reportlab.platypus.frames import Frame + +from .cover import fit_logo, logo_reader +from .flow import KeepWithNext +from .fonts import FONT, FONT_BOLD, FONT_MONO +from .palette import MUTED, RULE, SUBTITLE +from .provenance import attach_metadata + +MARGIN = 2.5 * cm +HEADER_TOP = 1.15 * cm +HEADER_LOGO_WIDTH = 3.4 * cm +HEADER_LOGO_HEIGHT = 0.75 * cm +HEADER_TEXT_WIDTH = 16 * cm - HEADER_LOGO_WIDTH - 0.5 * cm +FOOTER_BASELINE = 1.15 * cm +# The body spans the same 2.5–18.5 cm as the header and footer lines; the +# vertical padding keeps the first heading clear of the header rule. +BODY_SIDE_PADDING = 0 +BODY_VERTICAL_PADDING = 6 + +OUTLINE_STYLES = {"Heading1": 0, "Heading2": 1} + + +class ReportDocTemplate(BaseDocTemplate): + def __init__(self, filename, context, cover=None, **kw): + self.context = context + self.cover = cover + self.allowSplitting = 0 + # Every page starts in this font; the default adds Helvetica to the PDF. + kw.setdefault("initialFontName", FONT) + kw.setdefault("keepTogetherClass", KeepWithNext) + BaseDocTemplate.__init__(self, filename, **kw) + self.normal_frame = Frame( + 2.5 * cm, + 2.5 * cm, + 16 * cm, + 25 * cm, + leftPadding=BODY_SIDE_PADDING, + rightPadding=BODY_SIDE_PADDING, + topPadding=BODY_VERTICAL_PADDING, + bottomPadding=BODY_VERTICAL_PADDING, + id="F1", + ) + self.cover_frame = Frame(0, 0, 21 * cm, 29.7 * cm, id="F1") + template = PageTemplate( + "normal", [self.normal_frame], onPage=self.header_and_footer + ) + self.addPageTemplates(template) + + def beforeDocument(self): + # Each multiBuild pass draws on a new canvas; the last one is saved. + attach_metadata(self.canv, self.context.provenance) + + def handle_pageBegin(self): + # The cover page uses the whole sheet. + if self.page == 0: + self.pageTemplates[0].frames[0] = self.cover_frame + else: + self.pageTemplates[0].frames[0] = self.normal_frame + BaseDocTemplate.handle_pageBegin(self) + + def afterFlowable(self, flowable): + # The outline is what readers navigate by and what the structure test + # compares. It is added when the pages are written, see NumberedCanvas. + if flowable.__class__.__name__ != "Paragraph": + return + level = OUTLINE_STYLES.get(flowable.style.name) + if level is not None: + self.canv.outline_entries.append( + (self.page, flowable.getPlainText(), level) + ) + + def header_and_footer(self, canvas, doc): + if canvas.getPageNumber() == 1: + if self.cover: + self.cover(canvas, doc) + elif self.cover: + draw_header(canvas, self.cover) + draw_footer(canvas, self.cover) + + +def fit_text(canvas, text, font, size, width): + text = " ".join((text or "").split()) + if canvas.stringWidth(text, font, size) <= width: + return text + while text and canvas.stringWidth(f"{text}…", font, size) > width: + text = text[:-1].rstrip() + return f"{text}…" + + +def draw_header(canvas, running): + canvas.saveState() + width, height = A4 + top = height - HEADER_TOP + + canvas.setFillColor(HexColor(SUBTITLE)) + canvas.setFont(FONT_BOLD, 7.5) + canvas.drawString( + MARGIN, + top - 8, + fit_text(canvas, running.title, FONT_BOLD, 7.5, HEADER_TEXT_WIDTH), + ) + canvas.setFillColor(HexColor(MUTED)) + canvas.setFont(FONT, 7.5) + canvas.drawString( + MARGIN, + top - 18, + fit_text(canvas, running.project_name, FONT, 7.5, HEADER_TEXT_WIDTH), + ) + + logo = logo_reader(running.report_path) + logo_width, logo_height = fit_logo( + *logo.getSize(), HEADER_LOGO_WIDTH, HEADER_LOGO_HEIGHT + ) + canvas.drawImage( + logo, + width - MARGIN - logo_width, + top + 1 - logo_height, + width=logo_width, + height=logo_height, + mask="auto", + ) + + canvas.setStrokeColor(HexColor(running.appearance.header_footer_color)) + canvas.setLineWidth(0.75) + canvas.line(MARGIN, top - 25, width - MARGIN, top - 25) + canvas.restoreState() + + +def draw_footer(canvas, running): + canvas.saveState() + width, _height = A4 + + canvas.setStrokeColor(HexColor(RULE)) + canvas.setLineWidth(0.4) + canvas.line(MARGIN, FOOTER_BASELINE + 11, width - MARGIN, FOOTER_BASELINE + 11) + + canvas.setFillColor(HexColor(MUTED)) + canvas.setFont(FONT_MONO, 6.8) + canvas.drawString(MARGIN, FOOTER_BASELINE, running.document_id) + + edition = running.b["footer_edition"].upper() + edition_width = canvas.stringWidth(edition, FONT, 6.8) + canvas.setFont(FONT, 6.8) + canvas.drawString((width - edition_width) / 2, FOOTER_BASELINE, edition) + canvas.restoreState() + + +class NumberedCanvas(Canvas): + # The page total is only known once the last page is laid out, so pages + # are kept until save() and the counter is drawn then. Each multiBuild + # pass uses a new canvas, so it is drawn once per page. + def __init__(self, *args, page_of, **kwargs): + super().__init__(*args, **kwargs) + self._page_of = page_of + self._page_states = [] + # (page, text, level), collected while the document is laid out + self.outline_entries = [] + + def showPage(self): + self._page_states.append(dict(self.__dict__)) + self._startPage() + + def save(self): + total = len(self._page_states) + by_page = {} + for page, text, level in self.outline_entries: + by_page.setdefault(page, []).append((text, level)) + for state in self._page_states: + self.__dict__.update(state) + if self._pageNumber > 1: + self._draw_page_count(total) + # Bookmarks name the page being written, so they are added here and + # not while the document is laid out. + for index, (text, level) in enumerate(by_page.get(self._pageNumber, [])): + key = f"outline-{self._pageNumber}-{index}" + self.bookmarkPage(key) + self.addOutlineEntry(text, key, level=level) + super().showPage() + super().save() + + def _draw_page_count(self, total): + width, _height = A4 + self.saveState() + self.setFillColor(HexColor(MUTED)) + self.setFont(FONT, 7) + self.drawRightString( + width - MARGIN, + FOOTER_BASELINE, + self._page_of.format(page=self._pageNumber, total=total), + ) + self.restoreState() + + +def set_pdf_metadata(doc, context, b): + # What PDF viewers and document management systems show; it follows the + # cover. + provenance = context.provenance + doc.title = f"{doc.cover.title} – {context.name}" + doc.subject = b["pdf_subject"].format( + document_id=doc.cover.document_id, version=provenance.collector_version + ) + doc.author = "cloudexit" + doc.creator = f"cloudexit {provenance.collector_version}" + doc.keywords = [ + keyword + for keyword in ( + doc.cover.document_id, + provenance.run_id, + ( + f"catalogue {provenance.catalogue_version}" + if provenance.catalogue_version + else None + ), + ) + if keyword + ] diff --git a/cloudexit/report/brief_summary.py b/cloudexit/report/brief_summary.py new file mode 100644 index 0000000..c172d5c --- /dev/null +++ b/cloudexit/report/brief_summary.py @@ -0,0 +1,378 @@ +from dataclasses import dataclass, field +from itertools import pairwise +from xml.sax.saxutils import escape + +from reportlab.lib.colors import HexColor +from reportlab.lib.units import cm +from reportlab.platypus import Paragraph + +from .fonts import FONT_MONO +from .helpers import ( + cost_problem, + format_change, + format_money, + severity_label, + strategy_name, +) +from .palette import ( + HOTSPOT, + HOTSPOT_BACKGROUND, + HOTSPOT_COUNT, + INK, + MUTED, + SEVERITIES, +) +from .table_style import data_table, summary_strip, text_style + +# List lengths are capped so the page never runs onto a second one. +MAX_HOTSPOTS = 3 +STRIP_WIDTHS = [2.6 * cm, 2.9 * cm, 3.9 * cm, 2.9 * cm, 3.7 * cm] + +FINDING_AREA = text_style("BriefFindingArea", 8.5, 11, bold=True) +FINDING_TEXT = text_style("BriefFindingText", 8.5, 12) +FINDING_DETAILS = text_style("BriefFindingDetails", 7.5, 10, MUTED) +CAPTION = text_style("BriefCaption", 7.5, 10, MUTED) + + +@dataclass +class BriefFacts: + scope_kind: str + scope: str + provider: str + strategy: str + resources: int + types: int + regions: int + cost_problem: str | None = None + costs: list = field(default_factory=list) + currency: str = "" + severity_counts: dict = field(default_factory=dict) + most_severe: tuple | None = None + types_with_alternatives: int = 0 + alternatives: int = 0 + hotspots: list = field(default_factory=list) + version: str = "" + completed: str = "" + + @property + def risks(self): + return sum(self.severity_counts.values()) + + @property + def critical_high(self): + return self.severity_counts.get("critical", 0) + self.severity_counts.get( + "high", 0 + ) + + +def collect_brief_facts(context, b): + severity_counts = {} + for risk in context.risks: + severity_counts[risk.severity] = severity_counts.get(risk.severity, 0) + 1 + most_severe = None + if context.risks: + top = context.risks[0] + most_severe = (top.code, top.name, severity_label(top.severity, b)) + + with_alternatives = [entry for entry in context.alternatives if entry.alternatives] + hotspots = sorted( + (entry for entry in context.alternatives if not entry.alternatives), + key=lambda entry: (-entry.resources, entry.name.lower()), + ) + problem = cost_problem(context, b) + + scope = context.scope + if scope.kind == "aws": + scope_text = scope.region or b["ev_dash"] + elif scope.kind == "azure": + scope_text = scope.resource_group or b["ev_dash"] + else: + scope_text = "" + + return BriefFacts( + scope_kind=scope.kind, + scope=scope_text, + provider=context.provider, + strategy=strategy_name(context.strategy_code, b), + resources=context.total_resources, + types=len(context.resources), + regions=len(context.regions), + cost_problem=problem, + costs=( + [(entry.month, entry.amount) for entry in context.costs] + if problem is None + else [] + ), + currency=context.costs[0].currency if problem is None else "", + severity_counts=severity_counts, + most_severe=most_severe, + types_with_alternatives=len(with_alternatives), + alternatives=sum(len(entry.alternatives) for entry in with_alternatives), + hotspots=[entry.name for entry in hotspots], + version=context.provenance.run_short_id or b["ev_dash"], + completed=context.completed, + ) + + +def _count(b, key, n): + return b[f"{key}_one"] if n == 1 else b[f"{key}_many"].format(n=n) + + +def _join(items, b): + if len(items) < 2: + return "".join(items) + return f"{', '.join(items[:-1])}{b['bs_and']}{items[-1]}" + + +def _capitalise(text): + return text[:1].upper() + text[1:] + + +def outlook_text(facts, b): + parts = [] + if facts.critical_high: + parts.append(_count(b, "bs_part_risks", facts.critical_high)) + if facts.hotspots: + parts.append(_count(b, "bs_part_hotspots", len(facts.hotspots))) + if not parts: + return b["bs_outlook_good"] + single = len(parts) == 1 and (facts.critical_high or len(facts.hotspots)) == 1 + key = "bs_outlook_attention_one" if single else "bs_outlook_attention" + return _capitalise(b[key].format(parts=b["bs_and"].join(parts))) + + +def lead_text(facts, b): + if facts.scope_kind == "tfstate": + scope = b["bs_scope_tfstate"] + else: + scope = f"{escape(facts.scope)}" + lead = b["bs_lead"].format( + scope=scope, + provider=escape(facts.provider), + strategy=f"{escape(facts.strategy)}", + ) + return f"{lead} {outlook_text(facts, b)}" + + +def severity_breakdown(facts, b): + # "1 Critical, 3 High": non-zero counts, most severe first. + return ", ".join( + f"{facts.severity_counts[level]} {escape(severity_label(level, b))}" + for level in SEVERITIES + if facts.severity_counts.get(level) + ) + + +def risk_finding(facts, b): + if not facts.risks: + return b["bs_find_risks_none"] + code, name, severity = facts.most_severe + if facts.risks == 1: + text = b["bs_find_risks_one"].format( + severity=escape(severity), code=escape(code), name=escape(name) + ) + else: + text = b["bs_find_risks_many"].format( + n=facts.risks, + counts=severity_breakdown(facts, b), + code=escape(code), + name=escape(name), + severity=escape(severity), + ) + if not facts.critical_high: + text = f"{text} {b['bs_find_risks_no_severe']}" + return text + + +def _type_list(names, b): + shown = [f"{escape(name)}" for name in names[:MAX_HOTSPOTS]] + if len(names) > MAX_HOTSPOTS: + return f"{', '.join(shown)}{b['bs_more'].format(k=len(names) - MAX_HOTSPOTS)}" + return _join(shown, b) + + +def alternatives_finding(facts, b): + if not facts.types: + return b["alt_no_resources"] + if not facts.hotspots: + key = "bs_find_alts_all_one" if facts.types == 1 else "bs_find_alts_all" + return b[key].format(n=facts.types, total=facts.alternatives) + if not facts.types_with_alternatives: + return b["bs_find_alts_none"].format(types=_type_list(facts.hotspots, b)) + key = ( + "bs_find_alts_some_one" + if facts.types_with_alternatives == 1 + else "bs_find_alts_some" + ) + return " ".join( + ( + b[key].format( + a=facts.types_with_alternatives, + n=facts.types, + total=facts.alternatives, + ), + b["bs_find_alts_missing"].format(types=_type_list(facts.hotspots, b)), + ) + ) + + +def cost_trend(values): + # Month-on-month changes counted back from the latest month. + steps = [float(current) - float(previous) for previous, current in pairwise(values)] + if not steps or steps[-1] == 0: + return None, 0 + rising = steps[-1] > 0 + count = 0 + for step in reversed(steps): + if step == 0 or (step > 0) != rising: + break + count += 1 + if count < 2: + return None, 0 + return ("rising" if rising else "falling"), count + + +def cost_finding(facts, b): + if facts.cost_problem: + return escape(facts.cost_problem) + values = [cost for _month, cost in facts.costs] + total = sum(values) + latest_month, latest = facts.costs[-1] + parts = [ + b["bs_find_costs"].format( + total=format_money(total, facts.currency), + average=format_money(total / len(values), facts.currency), + ) + ] + if len(facts.costs) > 1: + previous_month, previous = facts.costs[-2] + parts.append( + b["bs_find_costs_latest"].format( + month=latest_month, + latest=format_money(latest, facts.currency), + change=format_change(previous, latest), + previous=previous_month, + ) + ) + else: + parts.append( + b["bs_find_costs_latest_only"].format( + month=latest_month, latest=format_money(latest, facts.currency) + ) + ) + direction, months = cost_trend(values) + if direction: + parts.append(b[f"bs_find_costs_{direction}"].format(k=months)) + return " ".join(parts) + + +def footprint_finding(facts, b): + if not facts.resources: + return b["tbl_res_no_resources"] + return b["bs_find_footprint"].format( + resources=_count(b, "bs_count_resources", facts.resources), + types=_count(b, "bs_count_types", facts.types), + regions=_count(b, "bs_count_regions", facts.regions), + ) + + +def finding_rows(facts, b): + # (area, finding markup, details section, attention marker) + return [ + ( + b["bs_area_risks"], + risk_finding(facts, b), + b["es_heading_risk_inventory"], + facts.critical_high > 0, + ), + ( + b["bs_area_alternatives"], + alternatives_finding(facts, b), + b["es_heading_alternative_technologies"], + bool(facts.hotspots), + ), + ( + b["bs_area_costs"], + cost_finding(facts, b), + b["es_heading_costs_overview"], + False, + ), + ( + b["bs_area_footprint"], + footprint_finding(facts, b), + b["es_heading_resource_inventory"], + False, + ), + ] + + +def _headline_figures(facts, b): + costs = ( + format_money(sum(cost for _month, cost in facts.costs), facts.currency) + if facts.costs + else b["ev_dash"] + ) + alternatives = ( + b["alt_stat_types_value"].format(a=facts.types_with_alternatives, n=facts.types) + if facts.types + else b["ev_dash"] + ) + return summary_strip( + [ + (b["res_stat_resources"], str(facts.resources), INK), + (b["res_stat_types"], str(facts.types), INK), + (b["bs_stat_costs"], costs, INK), + (b["risk_stat_unique"], str(facts.risks), INK), + ( + b["alt_stat_types"], + alternatives, + HOTSPOT_COUNT if facts.hotspots else INK, + ), + ], + widths=STRIP_WIDTHS, + ) + + +def _findings_table(facts, b, appearance): + rows = [[b["bs_th_area"], b["bs_th_finding"], b["bs_th_details"]]] + extra = [] + for area, finding, details, attention in finding_rows(facts, b): + index = len(rows) + rows.append( + [ + Paragraph(escape(area), FINDING_AREA), + Paragraph(finding, FINDING_TEXT), + Paragraph(escape(details), FINDING_DETAILS), + ] + ) + if attention: + extra.extend( + [ + ("LINEBEFORE", (0, index), (0, index), 3, HexColor(HOTSPOT)), + ( + "BACKGROUND", + (0, index), + (-1, index), + HexColor(HOTSPOT_BACKGROUND), + ), + ] + ) + return data_table(rows, [2.9 * cm, 10.1 * cm, 3.0 * cm], appearance, extra) + + +def build_brief_summary(facts, styles, appearance, b): + lead_style = styles["ContentStyle"].clone( + "BriefLead", fontSize=10, leading=15.5, spaceAfter=12 + ) + version = f'{escape(facts.version)}' + return [ + Paragraph(b["bs_heading"], styles["Heading1"]), + Paragraph(lead_text(facts, b), lead_style), + _headline_figures(facts, b), + Paragraph(b["bs_findings_heading"], styles["Heading2"]), + _findings_table(facts, b, appearance), + Paragraph( + b["bs_caption"].format(version=version, completed=escape(facts.completed)), + CAPTION, + ), + ] diff --git a/cloudexit/report/context.py b/cloudexit/report/context.py new file mode 100644 index 0000000..2de9e54 --- /dev/null +++ b/cloudexit/report/context.py @@ -0,0 +1,435 @@ +import os +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Any + +from cloudexit.resources import find_asset +from cloudexit.report.aggregate import summarize_alternative_technologies +from cloudexit.catalogue.store import CatalogueError, catalogue_info +from cloudexit.version import __version__ + +from .palette import DEFAULT_APPEARANCE, SEVERITIES, Appearance + +PROVIDER_NAMES = {1: "Microsoft Azure", 2: "Amazon Web Services"} +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) +class Provenance: + collector_version: str + collected_at: datetime + generated_at: datetime + run_id: str | None = None + catalogue_version: str | None = None + catalogue_sequence: int | None = None + catalogue_sha256: str | None = None + uploaded: bool = False + platform_url: str | None = None + assessment_id: str | None = None + # A requested upload that failed, why, and whether the run was kept + upload_failed: bool = False + upload_reason: str | None = None + upload_spooled: bool = False + + @property + def run_short_id(self): + return short_run_id(self.run_id) + + @property + def collected_date(self): + return self.collected_at.strftime("%Y-%m-%d") + + def as_dict(self): + return { + "collector_version": self.collector_version, + "catalogue_version": self.catalogue_version, + "catalogue_sequence": self.catalogue_sequence, + "catalogue_sha256": self.catalogue_sha256, + "sync_id": self.run_id, + "uploaded": self.uploaded, + "platform_url": self.platform_url, + } + + +def collect_provenance(db_path, *, run_id=None, upload=None, collected_at=None): + try: + info = catalogue_info(db_path) + except CatalogueError: + info = None + now = datetime.now(timezone.utc) + upload = upload or {} + return Provenance( + collector_version=__version__, + collected_at=collected_at or now, + generated_at=now, + run_id=run_id or upload.get("sync_id"), + catalogue_version=info.version if info else None, + catalogue_sequence=info.sequence if info else None, + catalogue_sha256=info.sha256 if info else None, + uploaded=upload.get("status") == "uploaded", + platform_url=upload.get("url") if upload.get("status") == "uploaded" else None, + assessment_id=upload.get("assessment_id"), + upload_failed=upload.get("status") == "failed", + upload_reason=( + upload.get("reason") if upload.get("status") == "failed" else None + ), + upload_spooled=bool(upload.get("spooled")) and upload.get("status") == "failed", + ) + + +@dataclass +class ResourceTypeEntry: + resource_type_id: str + name: str + icon: str | None + resources: int = 0 + locations: set = field(default_factory=set) + location_counts: dict = field(default_factory=dict) + # The icon as the catalogue names it, relative to the report's assets + icon_rel: str = "" + + +@dataclass(frozen=True) +class CostEntry: + month: str + amount: float + currency: str + final: bool + + +@dataclass +class RiskEntry: + risk_id: int + code: str + name: str + description: str + severity: str + resource_type_ids: set = field(default_factory=set) + + @property + def scope_wide(self): + return not self.resource_type_ids + + +@dataclass(frozen=True) +class AlternativeEntry: + name: str + vendor: str + country: str | None + open_source: bool + support_plan: bool + + +@dataclass +class AlternativesEntry: + resource_type_id: str + name: str + icon: str | None + resources: int + alternatives: list = field(default_factory=list) + icon_rel: str = "" + + +@dataclass(frozen=True) +class ScopeFacts: + kind: str + provider: str + rows: tuple = () + region: str = "" + resource_group: str = "" + file_name: str = "" + locations: tuple = () + # State file instances left out: of other providers, of types not in the + # data catalogue + not_assessed_other: int = 0 + not_assessed_unmapped: int = 0 + + @property + def not_assessed(self): + return self.not_assessed_other + self.not_assessed_unmapped + + +@dataclass +class ReportContext: + name: str + cloud_service_provider: int + provider: str + exit_strategy: int + strategy_code: str + scope: ScopeFacts + provenance: Provenance + resources: list + costs: list + risks: list + 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 self.document_id_for(DOCUMENT_CODE) + + @property + def total_resources(self): + return sum(entry.resources for entry in self.resources) + + @property + def regions(self): + return {location for entry in self.resources for location in entry.locations} + + @property + def completed(self): + return self.provenance.collected_at.strftime("%Y-%m-%d %H:%M UTC") + + +def _icon_path(report_path, icon): + # None when the resource type has no icon file; the report draws a + # placeholder for it. + found = find_asset(report_path, str(icon or "")) + return str(found) if found is not None else None + + +def _resource_entries(resource_inventory, resource_type_mapping, report_path): + entries = {} + for row in resource_inventory: + key = str(row["resource_type"]) + info = resource_type_mapping.get(key, {}) + entry = entries.setdefault( + key, + ResourceTypeEntry( + key, + (info.get("name") or "").strip() or "–", + _icon_path(report_path, info.get("icon")), + icon_rel=str(info.get("icon") or "").lstrip("/"), + ), + ) + count = int(row.get("count") or 0) + entry.resources += count + if row.get("location"): + entry.locations.add(row["location"]) + entry.location_counts[row["location"]] = ( + entry.location_counts.get(row["location"], 0) + count + ) + return sorted( + entries.values(), key=lambda entry: (-entry.resources, entry.name.lower()) + ) + + +def _cost_entries(cost_data, collected_at): + current = collected_at.strftime("%Y-%m") + rows = sorted(cost_data, key=lambda row: str(row["month"]))[-COST_MONTHS:] + return [ + CostEntry( + str(row["month"])[:7], + float(row["cost"] or 0), + str(row["currency"] or "").upper(), + str(row["month"])[:7] < current, + ) + for row in rows + ] + + +def _severity(definition): + for key in ("catalogue_severity", "severity"): + value = str(definition.get(key) or "").lower() + if value in SEVERITIES: + return value + return None + + +def _risk_entries(risk_data, risk_definitions): + definitions = {definition["id"]: definition for definition in risk_definitions} + entries = {} + for row in risk_data: + definition = definitions.get(row["risk"]) + severity = _severity(definition) if definition else None + if severity is None: + continue + entry = entries.setdefault( + row["risk"], + RiskEntry( + row["risk"], + str(definition.get("code") or f"R{row['risk']}"), + definition["name"], + definition.get("description") or "", + severity, + ), + ) + if row["resource_type"] not in (None, "null", ""): + entry.resource_type_ids.add(str(row["resource_type"])) + return sorted( + entries.values(), + key=lambda entry: (SEVERITIES.index(entry.severity), entry.risk_id), + ) + + +def _vendor(name): + # The shared summary names a missing vendor "Unknown Organization"; the + # report leaves it out. + return "" if name == "Unknown Organization" else name + + +def _alternative_entries( + resources, resource_inventory, alternatives, technologies, organizations, strategy +): + grouped = summarize_alternative_technologies( + resource_inventory, + alternatives, + technologies, + strategy, + alternative_technology_organizations=organizations, + ) + entries = [] + for resource in resources: + found = [ + AlternativeEntry( + tech["product_name"], + _vendor(tech["organization_name"]), + ( + tech["organization_country_code"] + if tech["organization_country_code"] != "N/A" + else None + ), + tech["open_source"], + tech["support_plan"], + ) + for tech in grouped.get(resource.resource_type_id, []) + ] + # The report does not rank them: alphabetical, then the vendor. + found.sort(key=lambda alt: (alt.name.lower(), alt.vendor.lower())) + entries.append( + AlternativesEntry( + resource.resource_type_id, + resource.name, + resource.icon, + resource.resources, + found, + icon_rel=resource.icon_rel, + ) + ) + return entries + + +def _excluded(coverage, key): + value = (coverage or {}).get(key) + return value if isinstance(value, int) and value > 0 else 0 + + +def scope_facts( + provider_details, cloud_service_provider, tfstate_scope, tfstate_coverage=None +): + provider = PROVIDER_NAMES.get(cloud_service_provider, "–") + if provider_details.get("tfstatePath"): + scope = tfstate_scope or { + "file": os.path.basename(provider_details["tfstatePath"]) + } + locations = tuple(scope.get("locations") or ()) + rows = [("file", scope.get("file") or "–")] + if scope.get("sha256"): + rows.append(("sha256", scope["sha256"])) + if scope.get("lineage"): + rows.append(("lineage", scope["lineage"])) + if scope.get("serial") is not None: + rows.append(("serial", str(scope["serial"]))) + if cloud_service_provider == 1: + rows.append(("subscriptions", ", ".join(scope.get("subscriptions") or []))) + rows.append( + ("resource_groups", ", ".join(scope.get("resource_groups") or [])) + ) + else: + rows.append(("regions", ", ".join(locations))) + return ScopeFacts( + "tfstate", + provider, + tuple(rows), + file_name=scope.get("file") or "", + locations=locations, + not_assessed_other=_excluded( + tfstate_coverage, "instances_excluded_other_provider" + ), + not_assessed_unmapped=_excluded( + tfstate_coverage, "instances_excluded_unmapped" + ), + ) + if cloud_service_provider == 1: + rows = ( + ("tenant", provider_details.get("tenantId") or ""), + ("subscription", provider_details.get("subscriptionId") or ""), + ("resource_group", provider_details.get("resourceGroupName") or ""), + ) + return ScopeFacts( + "azure", + provider, + rows, + resource_group=provider_details.get("resourceGroupName") or "", + ) + rows = ( + ("account", provider_details.get("accountId") or ""), + ("region", provider_details.get("region") or ""), + ) + return ScopeFacts( + "aws", provider, rows, region=provider_details.get("region") or "" + ) + + +def build_context( + *, + metadata: dict[str, Any], + provider_details: dict[str, Any], + resource_type_mapping: dict[str, dict[str, Any]], + resource_inventory: list[dict[str, Any]], + cost_data: list[dict[str, Any]], + risk_data: list[dict[str, Any]], + risk_definitions: list[dict[str, Any]], + alternatives: list[dict[str, Any]], + alternative_technologies: list[dict[str, Any]], + alternative_technology_organizations: list[dict[str, Any]], + exit_strategy: int, + tfstate_scope: dict[str, Any] | None, + tfstate_coverage: dict[str, Any] | None = None, + provenance: Provenance, + report_path: str, +) -> ReportContext: + csp = int(metadata["cloud_service_provider"]) + resources = _resource_entries( + resource_inventory, resource_type_mapping, report_path + ) + return ReportContext( + name=metadata["name"], + cloud_service_provider=csp, + provider=PROVIDER_NAMES.get(csp, "–"), + exit_strategy=exit_strategy, + strategy_code=STRATEGY_CODES.get(int(exit_strategy), ""), + scope=scope_facts(provider_details, csp, tfstate_scope, tfstate_coverage), + provenance=provenance, + resources=resources, + costs=_cost_entries(cost_data, provenance.collected_at), + risks=_risk_entries(risk_data, risk_definitions), + alternatives=_alternative_entries( + resources, + resource_inventory, + alternatives, + alternative_technologies, + alternative_technology_organizations, + exit_strategy, + ), + report_path=report_path, + ) diff --git a/cloudexit/report/cover.py b/cloudexit/report/cover.py new file mode 100644 index 0000000..74ab6d4 --- /dev/null +++ b/cloudexit/report/cover.py @@ -0,0 +1,200 @@ +import logging +from dataclasses import dataclass +from xml.sax.saxutils import escape + +from reportlab.lib.colors import HexColor +from reportlab.lib.pagesizes import A4 +from reportlab.lib.styles import ParagraphStyle +from reportlab.lib.units import cm +from reportlab.lib.utils import ImageReader +from reportlab.platypus import Paragraph + +from cloudexit.resources import find_asset + +from .fonts import FONT, FONT_BOLD, FONT_MONO +from .helpers import strategy_name +from .palette import INK, MUTED, RULE, SUBTITLE + +logger = logging.getLogger(__name__) + +BAND_WIDTH = 0.9 * cm +MARGIN = 2.5 * cm +CONTENT_WIDTH = A4[0] - 2 * MARGIN +COLUMN_WIDTH = 8 * cm +CELL_WIDTH = COLUMN_WIDTH - 12 +LOGO_MAX_WIDTH = 5.5 * cm +LOGO_MAX_HEIGHT = 1.6 * cm +LOGO_TOP = 2.2 * cm +TITLE_TOP = 8.1 * cm +FOOTER_Y = 1.5 * cm +ROW_SPACING = 14 +# Written with forward slashes on every system: asset paths are not file system paths +LOGO_FILE = "img/logo/report_logo.png" + +TITLE_STYLE = ParagraphStyle( + "CoverTitle", + fontName=FONT_BOLD, + fontSize=30, + leading=34, + textColor=HexColor(INK), +) +PROJECT_STYLE = ParagraphStyle( + "CoverProject", + fontName=FONT, + fontSize=15, + leading=19, + textColor=HexColor(SUBTITLE), +) +LABEL_STYLE = ParagraphStyle( + "CoverLabel", + fontName=FONT_BOLD, + fontSize=6.8, + leading=8.5, + textColor=HexColor(MUTED), +) +VALUE_STYLE = ParagraphStyle( + "CoverValue", + fontName=FONT, + fontSize=9.5, + leading=12.5, + textColor=HexColor(INK), +) +MONO_VALUE_STYLE = ParagraphStyle( + "CoverMonoValue", parent=VALUE_STYLE, fontName=FONT_MONO +) + + +@dataclass(frozen=True) +class CoverCell: + label: str + value: str + style: ParagraphStyle = VALUE_STYLE + + +def fit_logo(width, height, max_width=LOGO_MAX_WIDTH, max_height=LOGO_MAX_HEIGHT): + scale = min(max_width / width, max_height / height) + return width * scale, height * scale + + +def logo_reader(report_path): + path = find_asset(report_path, LOGO_FILE) + if path is None: + raise FileNotFoundError(f"report logo not found under {report_path}/assets") + return ImageReader(str(path)) + + +def _paragraph(value, style): + return Paragraph(escape(str(value)), style) + + +class RecordCover: + def __init__(self, title, context, b, document_id=None): + self.title = title + self.project_name = context.name + self.document_id = document_id or context.document_id + self.appearance = context.appearance + self.report_path = context.report_path + self.provenance = context.provenance + self.b = b + self.rows = self._rows(context, b) + + def _rows(self, context, b): + provenance = context.provenance + run = b["cover_run_value"].format( + run=provenance.run_short_id or b["ev_dash"], completed=context.completed + ) + left = [ + CoverCell(b["cover_doc_id"], self.document_id, MONO_VALUE_STYLE), + CoverCell(b["cover_edition"], b["cover_edition_value"]), + CoverCell( + b["cover_exit_strategy"], strategy_name(context.strategy_code, b) + ), + ] + right = [ + CoverCell(b["cover_run"], run), + CoverCell( + b["cover_generated"], provenance.generated_at.strftime("%Y-%m-%d") + ), + CoverCell(b["ev_language"], b["language_name"]), + ] + return list(zip(left, right)) + + def __call__(self, canvas, doc): + canvas.saveState() + width, height = A4 + + canvas.setFillColor(HexColor(self.appearance.header_footer_color)) + canvas.rect(0, 0, BAND_WIDTH, height, stroke=0, fill=1) + + self._draw_logo(canvas, width, height) + y = self._draw_title_block(canvas, height - TITLE_TOP) + self._draw_details(canvas, y - 34) + self._draw_footer(canvas, width) + + canvas.restoreState() + + def _draw_logo(self, canvas, width, height): + logo = logo_reader(self.report_path) + logo_width, logo_height = fit_logo(*logo.getSize()) + canvas.drawImage( + logo, + width - MARGIN - logo_width, + height - LOGO_TOP - logo_height, + width=logo_width, + height=logo_height, + mask="auto", + ) + + def _draw_title_block(self, canvas, top): + y = self._draw_paragraph(canvas, self.title, TITLE_STYLE, MARGIN, top) + return self._draw_paragraph( + canvas, self.project_name, PROJECT_STYLE, MARGIN, y - 8 + ) + + def _draw_details(self, canvas, top): + canvas.setStrokeColor(HexColor(RULE)) + canvas.setLineWidth(0.5) + canvas.line(MARGIN, top, MARGIN + CONTENT_WIDTH, top) + + y = top + for row in self.rows: + y -= ROW_SPACING + bottoms = [ + self._draw_cell(canvas, cell, MARGIN + index * COLUMN_WIDTH, y) + for index, cell in enumerate(row) + if cell is not None + ] + y = min(bottoms) + y -= ROW_SPACING + + canvas.setStrokeColor(HexColor(RULE)) + canvas.setLineWidth(0.5) + canvas.line(MARGIN, y, MARGIN + CONTENT_WIDTH, y) + + def _draw_cell(self, canvas, cell, x, top): + y = self._draw_paragraph( + canvas, cell.label.upper(), LABEL_STYLE, x, top, CELL_WIDTH + ) + return self._draw_paragraph( + canvas, cell.value, cell.style, x, y - 3, CELL_WIDTH + ) + + def _draw_paragraph(self, canvas, text, style, x, top, width=CONTENT_WIDTH): + paragraph = _paragraph(text, style) + _w, h = paragraph.wrap(width, A4[1]) + paragraph.drawOn(canvas, x, top - h) + return top - h + + def _draw_footer(self, canvas, width): + canvas.setFillColor(HexColor(MUTED)) + canvas.setFont(FONT, 7) + canvas.drawString( + MARGIN, + FOOTER_Y, + self.b["cover_collected_with"].format( + version=self.provenance.collector_version, + date=self.provenance.collected_date, + ), + ) + canvas.setFont(FONT_MONO, 7) + canvas.drawRightString(width - MARGIN, FOOTER_Y, self.document_id) diff --git a/cloudexit/report/egress.py b/cloudexit/report/egress.py new file mode 100644 index 0000000..0c2bf12 --- /dev/null +++ b/cloudexit/report/egress.py @@ -0,0 +1,700 @@ +# cloudexit/report/egress.py +import json +import logging +import os +import sqlite3 +from collections import Counter +from typing import Any + +from cloudexit.report.helpers import format_gib, gib_number, initials, share_label +from cloudexit.catalogue.db import load_data +from cloudexit.egress.model import ( + GIB, + compute_totals, + 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 PROVIDER_NAMES +from cloudexit.report.wording import BLOCKS + +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} + +PRICING_SOURCE_LINKS = { + 1: { + "internet_egress": "https://azure.microsoft.com/pricing/details/bandwidth/", + "archive_retrieval": ( + "https://azure.microsoft.com/pricing/details/storage/blobs/" + ), + }, + 2: { + "internet_egress": "https://aws.amazon.com/ec2/pricing/on-demand/", + "archive_retrieval": "https://aws.amazon.com/s3/pricing/", + }, +} + +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": ( + "/icons/azure/compute/10026-icon-service-Disks-Snapshots.png" + ), + "aws.ec2.describe_volumes.volumes": "/icons/aws/Storage/Elastic-Block-Store.png", + "aws.ec2.describe_snapshots.snapshots": ( + "/icons/aws/Storage/Elastic-Block-Store.png" + ), + "aws.backup.list_backup_vaults.backupvaultlist": "/icons/aws/Storage/Backup.png", +} + + +def _unit_divisor(unit: str) -> float: + return UNIT_DIVISORS.get(unit, GIB) + + +def load_pricing(report_path: str) -> list[dict[str, Any]]: + db_path = os.path.join(report_path, "data", "assessment.db") + try: + rows = load_data("egresspricing", db_path=db_path) + except sqlite3.Error: + # Runs created before the dataset shipped the egresspricing table; + # fall back to the master dataset download. + logger.debug("egresspricing missing from %s; using master dataset", db_path) + rows = load_data("egresspricing") + return [row for row in rows if not row.get("valid_to")] + + +def calculate_tiered_cost(total_units: float, tiers: list[dict[str, Any]]) -> float: + cost = 0.0 + for tier in sorted(tiers, key=lambda tier: tier["tier_from"]): + lower = tier["tier_from"] + upper = tier["tier_to"] + if total_units <= lower: + break + covered = total_units if upper is None else min(total_units, upper) + cost += (covered - lower) * tier["price_per_unit"] + return cost + + +def build_fee_estimate( + rows: list[dict[str, Any]], + totals: dict[str, Any], + cloud_service_provider: int, + prices: list[dict[str, Any]], +) -> tuple[float, dict[str, float | None]]: + active = [ + price + for price in prices + if price["csp"] == cloud_service_provider + and price["zone"] == DEFAULT_PRICING_ZONE + ] + internet_tiers = [ + price for price in active if price["component"] == "internet_egress" + ] + if not internet_tiers: + raise ValueError( + "No active internet egress pricing for cloud service provider " + f"{cloud_service_provider} in the egresspricing dataset." + ) + retrieval_prices = [ + price for price in active if price["component"] == "archive_retrieval" + ] + retrieval_rate_per_byte = 0.0 + if retrieval_prices: + retrieval = retrieval_prices[0] + retrieval_rate_per_byte = retrieval["price_per_unit"] / _unit_divisor( + retrieval["unit"] + ) + archive_tiers = ARCHIVE_TIERS_BY_CSP.get(cloud_service_provider, set()) + + total_bytes = totals["known_size_bytes"] + internet_divisor = _unit_divisor(internet_tiers[0]["unit"]) + internet_fee = calculate_tiered_cost(total_bytes / internet_divisor, internet_tiers) + retrieval_fee = totals["archive_tier_bytes"] * retrieval_rate_per_byte + total_fee = internet_fee + retrieval_fee + + fees_by_id: dict[str, float | None] = {} + for row in rows: + if row["size_bytes"] is None: + fees_by_id[row["id"]] = None + continue + share = (row["size_bytes"] / total_bytes) if total_bytes else 0.0 + row_archive_bytes = sum( + size + for tier, size in (row["tier_bytes"] or {}).items() + if tier in archive_tiers + ) + fees_by_id[row["id"]] = ( + internet_fee * share + row_archive_bytes * retrieval_rate_per_byte + ) + return total_fee, fees_by_id + + +def format_fee(fee: float | None) -> str: + if fee is None: + return "n/a" + return f"${fee_number(fee)}" + + +def fee_number(fee: float) -> str: + return f"{fee:,.2f}" + + +def _free_tier( + prices: list[dict[str, Any]], cloud_service_provider: int +) -> dict[str, Any] | None: + tiers = sorted( + ( + price + for price in prices + if price["csp"] == cloud_service_provider + and price["component"] == "internet_egress" + and price["zone"] == DEFAULT_PRICING_ZONE + ), + key=lambda tier: tier["tier_from"], + ) + if tiers and tiers[0]["price_per_unit"] == 0.0 and tiers[0]["tier_to"]: + 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 free_tier_headroom( + prices: list[dict[str, Any]], cloud_service_provider: int, known_bytes: int +) -> dict[str, Any] | None: + tier = _free_tier(prices, cloud_service_provider) + if tier is None: + return None + limit = tier["tier_to"] + used = known_bytes / _unit_divisor(tier["unit"]) + return { + "unit": tier["unit"], + "limit": limit, + "used": used, + "left": max(0.0, limit - used), + "used_share": min(100.0, used / limit * 100) if limit else 100.0, + } + + +def _summarize_totals( + totals: dict[str, Any], + rows: list[dict[str, Any]], + total_fee: float, + prices: list[dict[str, Any]], + cloud_service_provider: int, + b: dict[str, Any] = BLOCKS, +) -> dict[str, Any]: + free_tier_limit = free_tier_limit_display(prices, cloud_service_provider) + free_tier = ( + total_fee == 0 + and totals["known_size_bytes"] > 0 + and free_tier_limit is not None + ) + unknown_count = totals["resources_with_unknown_size"] + # Nothing was measured, so there is no lower bound to show: "0.0 GiB+" would + # claim one of zero. A resource counts as sized when it has a size, the same + # way the "n of m sized" strip counts it. + nothing_sized = bool(rows) and all(row["size_bytes"] is None for row in rows) + total_fee_display = format_fee(total_fee) + total_data_display = format_gib(totals["known_size_bytes"]) + if nothing_sized: + total_fee_display = b["ev_dash"] + total_data_display = b["ev_dash"] + elif unknown_count > 0: + total_data_display = f"{total_data_display}+" + if not free_tier: + total_fee_display = f"{total_fee_display}+" + return { + "free_tier": free_tier, + "free_tier_limit": free_tier_limit, + "unknown_count": unknown_count, + "nothing_sized": nothing_sized, + "total_fee_display": total_fee_display, + "total_data_display": total_data_display, + } + + +def _build_icon_lookup(report_path: str) -> dict[str, str]: + db_path = os.path.join(report_path, "data", "assessment.db") + try: + return { + item["code"].strip().lower(): item["icon"] + for item in load_data("resourcetype", db_path=db_path) + if item.get("icon") + } + except Exception as e: + logger.debug("Resource type icon lookup unavailable: %s", str(e)) + return {} + + +def _resolve_icon(resource_type: str, icon_lookup: dict[str, str]) -> str | None: + code = resource_type.strip().lower() + candidate = code + while candidate: + if candidate in icon_lookup: + return icon_lookup[candidate] + if "/" not in candidate: + break + candidate = candidate.rsplit("/", 1)[0] + return FALLBACK_ICONS.get(code) + + +def _build_type_groups( + rows: list[dict[str, Any]], + fees_by_id: dict[str, float | None], + icon_lookup: dict[str, str], +) -> list[dict[str, Any]]: + groups: dict[str, dict[str, Any]] = {} + for row in rows: + group = groups.setdefault( + row["label"], + { + "label": row["label"], + "icon": _resolve_icon(row["type"], icon_lookup), + "initials": initials(row["label"]), + "size_bytes": None, + "fee": None, + "resources": [], + }, + ) + if row["size_bytes"] is not None: + group["size_bytes"] = (group["size_bytes"] or 0) + row["size_bytes"] + fee = fees_by_id.get(row["id"]) + if fee is not None: + group["fee"] = (group["fee"] or 0.0) + fee + group["resources"].append( + { + "name": row["name"], + "fee_display": format_fee(fee) if fee is not None else None, + "detail": "; ".join(row["flags"] + row["notes"]), + } + ) + + for group in groups.values(): + group["size_display"] = format_gib(group["size_bytes"]) + group["fee_display"] = format_fee(group["fee"]) + + return sorted( + groups.values(), + key=lambda group: ( + group["fee"] is None, + -(group["fee"] or 0.0), + -(group["size_bytes"] or 0), + ), + ) + + +def _load_estimate( + report_path: str, json_path: str +) -> tuple[dict, list, dict, dict, float, list]: + with open(json_path, "r", encoding="utf-8") as json_file: + payload = json.load(json_file) + + # Run metadata stays in the raw artifact; the resources come from the + # assessment database, which is the store of record for the estimate. + meta = payload["meta"] + db_path = os.path.join(report_path, "data", "assessment.db") + rows, archive_tiers = read_egress_inventory(db_path) + totals = compute_totals(rows, archive_tiers) + + pricing = load_pricing(report_path) + total_fee, fees_by_id = build_fee_estimate( + rows, totals, meta["cloud_service_provider"], pricing + ) + + icon_lookup = _build_icon_lookup(report_path) + type_groups = _build_type_groups(rows, fees_by_id, icon_lookup) + 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, rows, total_fee, pricing, meta["cloud_service_provider"] + ), + } + + +def _share(part, whole): + return part / whole * 100 if whole else 0 + + +def _plus(text, estimate, b=BLOCKS): + # Resources without a size are left out, so the figure is a lower bound + summary = estimate["summary"] + if summary["nothing_sized"]: + return b["ev_dash"] + return f"{text}+" if summary["unknown_count"] > 0 else text + + +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 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 coverage_facts(cloud_service_provider, b=BLOCKS): + key = COVERAGE_NOTES_KEYS.get(cloud_service_provider) + if not key: + return None + 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 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"], + ) + top = groups[0] if groups else None + + 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: + 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)), + ) + ) + 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) + + 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 + ) + 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))})" + ) + + +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) + elif summary["nothing_sized"]: + volume_stat = (b["eg_st_billable"], b["ev_dash"], 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": ( + [ + { + "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 + ] + 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, b): + unit = row["unit"] + if row["tier_from"] == 0 and row["tier_to"] is None: + return b["eg_tier_all_volumes"] + if row["tier_from"] == 0 and row["price_per_unit"] == 0: + return b["eg_tier_first"].format(to=f"{row['tier_to']:,.0f}", unit=unit) + if row["tier_to"] is None: + 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, b): + if row["price_per_unit"] == 0: + return b["eg_price_free"] + return f"{row['price_per_unit']:g} / {row['unit']}" + + +def pricing_facts(estimate, b=BLOCKS): + csp = estimate["meta"]["cloud_service_provider"] + rows = sorted( + ( + price + for price in estimate["pricing"] + if price["csp"] == csp + and price["zone"] == DEFAULT_PRICING_ZONE + and price["component"] in COMPONENTS + ), + key=lambda price: (COMPONENTS.index(price["component"]), price["tier_from"]), + ) + if not rows: + return None + + 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: + headers.append(b["eg_th_valid_from"]) + + table = [] + for row in rows: + table_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.append(table_row) + + unit = next( + (row["unit"] for row in rows if row["component"] == "internet_egress"), None + ) + 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(sources) == 1: + source_lines = [(b["eg_pricing_source"], sources[0][1])] + else: + 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() + ) + ], + } diff --git a/cloudexit/report/egress_report.py b/cloudexit/report/egress_report.py new file mode 100644 index 0000000..a72abf0 --- /dev/null +++ b/cloudexit/report/egress_report.py @@ -0,0 +1,73 @@ +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, +) +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(chapter_break()) + content += legal_notices(context, styles, b, egress=True) + return content + + +def generate_egress_report(context, json_path, path=None, estimate=None): + path = path or os.path.join(context.report_path, "egress.pdf") + b = BLOCKS + estimate = estimate or 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..9a01519 --- /dev/null +++ b/cloudexit/report/egress_sections.py @@ -0,0 +1,285 @@ +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, + pricing_facts, +) +from .evidence import link_markup +from .flow import section +from .palette import HAIRLINE, HOTSPOT_COUNT, INK, MUTED +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 diff --git a/cloudexit/report/evidence.py b/cloudexit/report/evidence.py new file mode 100644 index 0000000..7c81168 --- /dev/null +++ b/cloudexit/report/evidence.py @@ -0,0 +1,202 @@ +from xml.sax.saxutils import escape, quoteattr + +from reportlab.platypus import Paragraph, Spacer + +from .flow import chapter_break +from .table_style import FOOTNOTE, KV_MONO, KV_VALUE, key_value_table, mono, text_style + + +def _short(value, head=4, tail=4): + if not isinstance(value, str) or len(value) <= head + tail + 1: + return value or "" + return f"{value[:head]}…{value[-tail:]}" + + +def _section_heading(appearance): + # Not an outline heading: the style name is not Heading1 or Heading2. + return text_style( + "EvidenceSectionHeading", + 10.5, + 14, + appearance.heading2_color, + bold=True, + ).clone("EvidenceSectionHeading", spaceBefore=12, spaceAfter=4, keepWithNext=1) + + +def _mono_value(text): + return Paragraph(escape(text), KV_MONO) if text else None + + +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(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"], + b["ev_collected_by_value"].format(version=provenance.collector_version), + ), + (b["ev_collected_at"], provenance.collected_date), + (b["ev_generated_at"], provenance.generated_at.strftime("%Y-%m-%d %H:%M UTC")), + ] + return key_value_table(rows, context.appearance) + + +def _not_assessed_text(scope, b): + parts = [ + _counted(b, key, n) + for key, n in ( + ("ev_not_assessed_other", scope.not_assessed_other), + ("ev_not_assessed_unmapped", scope.not_assessed_unmapped), + ) + if n + ] + return ", ".join(parts) + + +def _counted(b, key, n): + return b[f"{key}_one"] if n == 1 else b[f"{key}_many"].format(n=n) + + +def scope_rows(context, b): + # (label, text, monospaced), the same for the PDF and the HTML report + scope = context.scope + values = dict(scope.rows) + rows = [(b["ev_cloud_provider"], scope.provider or b["ev_dash"], False)] + if scope.kind == "aws": + if values.get("account"): + rows.append((b["ev_account_id"], values["account"], True)) + rows.append((b["ev_region"], values.get("region") or b["ev_dash"], False)) + elif scope.kind == "azure": + rows.append((b["ev_tenant_id"], values.get("tenant") or b["ev_dash"], True)) + rows.append( + (b["ev_subscription_id"], values.get("subscription") or b["ev_dash"], True) + ) + rows.append( + ( + b["ev_resource_group"], + values.get("resource_group") or b["ev_dash"], + False, + ) + ) + else: + file_text = values.get("file") or b["ev_dash"] + if values.get("sha256"): + file_text = f"{file_text} (SHA-256 {_short(values['sha256'])})" + rows.append((b["ev_file"], file_text, False)) + if values.get("lineage"): + state = values["lineage"] + if values.get("serial") is not None: + state = f"{state} (serial {values['serial']})" + rows.append((b["ev_state"], state, True)) + for key, label in ( + ("subscriptions", "ev_subscriptions"), + ("resource_groups", "ev_resource_groups"), + ("regions", "ev_regions"), + ): + if key in values: + rows.append((b[label], values[key] or b["ev_not_determinable"], False)) + if scope.not_assessed: + rows.append((b["ev_not_assessed"], _not_assessed_text(scope, b), False)) + source = b["ev_source_tfstate" if scope.kind == "tfstate" else "ev_source_live"] + rows.append( + ( + b["ev_source_label"], + b["ev_source_value"].format(source=source, completed=context.completed), + False, + ) + ) + return rows + + +def _scope_rows(context, b): + return [ + (label, _mono_value(text) if mono and text != b["ev_dash"] else text) + for label, text, mono in scope_rows(context, b) + ] + + +def build_scope_table(context, b): + return key_value_table(_scope_rows(context, b), context.appearance) + + +def link_markup(url, color): + return f'{escape(url)}' + + +def platform_failure(provenance, b): + # What happened to an upload that failed, as it was true when the report was written + if provenance.upload_reason: + text = b["ev_platform_failed"].format( + reason=provenance.upload_reason.rstrip(". ") + ) + else: + text = b["ev_platform_failed_no_reason"] + if provenance.upload_spooled: + text = f"{text} {b['ev_platform_kept']}" + return text + + +def build_provenance_table(context, b): + provenance = context.provenance + if provenance.catalogue_version and provenance.catalogue_sha256: + catalogue = b["ev_catalogue_value"].format( + version=escape(provenance.catalogue_version), + sha=mono(escape(provenance.catalogue_sha256[:12])), + ) + elif provenance.catalogue_version: + catalogue = escape(provenance.catalogue_version) + else: + catalogue = escape(b["ev_catalogue_unknown"]) + + rows = [] + if provenance.run_id: + rows.append((b["ev_run_id"], Paragraph(escape(provenance.run_id), KV_MONO))) + rows.append((b["ev_catalogue_label"], Paragraph(catalogue, KV_VALUE))) + if provenance.uploaded: + text = escape(b["ev_platform_uploaded"]) + if provenance.platform_url: + text = f"{text}: {link_markup(provenance.platform_url, context.appearance.heading2_color)}" + elif provenance.assessment_id: + text = f"{text} · {escape(b['fa_uploaded_id'].format(assessment_id=provenance.assessment_id))}" + rows.append((b["ev_platform_label"], Paragraph(text, KV_VALUE))) + elif provenance.upload_failed: + rows.append((b["ev_platform_label"], platform_failure(provenance, b))) + else: + rows.append((b["ev_platform_label"], b["ev_platform_none"])) + return key_value_table(rows, context.appearance) + + +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, document_id, document_type, companion_id), + Paragraph(b["ev_assessment_scope_heading"], heading), + build_scope_table(context, b), + Paragraph(b["ev_provenance_heading"], heading), + build_provenance_table(context, b), + Spacer(1, 6), + Paragraph( + escape( + b["ev_stored_footnote"].format( + run=context.provenance.run_short_id or b["ev_dash"] + ) + ), + FOOTNOTE, + ), + chapter_break(), + ] diff --git a/cloudexit/report/executive_summary.py b/cloudexit/report/executive_summary.py new file mode 100644 index 0000000..b36e939 --- /dev/null +++ b/cloudexit/report/executive_summary.py @@ -0,0 +1,70 @@ +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 .brief_summary import build_brief_summary, collect_brief_facts +from .cover import RecordCover +from .evidence import build_document_information_page +from .flow import chapter_break +from .notices import legal_notices +from .sections import ( + alternative_technologies, + costs_overview, + glossary, + introduction, + resource_inventory, + risk_inventory, + severity_levels, +) +from .styles import load_styles +from .wording import BLOCKS + + +def build_content(context, styles, b): + facts = collect_brief_facts(context, b) + content = [Spacer(1, 0), chapter_break()] + content += build_document_information_page(context, b) + content += build_brief_summary(facts, styles, context.appearance, b) + content.append(chapter_break()) + content += introduction(context, styles, b) + content.append(chapter_break()) + content += costs_overview(context, facts, styles, b) + content.append(chapter_break()) + content += resource_inventory(context, styles, b) + content.append(chapter_break()) + content += risk_inventory(context, styles, b) + content.append(chapter_break()) + content += alternative_technologies(context, styles, b) + content.append(chapter_break()) + content += severity_levels(context, styles, b) + content.append(Spacer(1, 18)) + content += glossary(context, styles, b) + content.append(chapter_break()) + content += legal_notices(context, styles, b) + return content + + +def generate_executive_summary(context, path=None): + path = path or os.path.join(context.report_path, "report.pdf") + b = BLOCKS + cover = RecordCover(b["cover_title_executive_summary"], context, b) + 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_content(context, load_styles(context.appearance), b), + canvasmaker=partial(NumberedCanvas, page_of=b["footer_page_of"]), + ) + return path diff --git a/cloudexit/report/flow.py b/cloudexit/report/flow.py new file mode 100644 index 0000000..b5fd59f --- /dev/null +++ b/cloudexit/report/flow.py @@ -0,0 +1,115 @@ +from reportlab.graphics.shapes import Drawing +from reportlab.lib.units import cm +from reportlab.platypus import ( + CondPageBreak, + KeepTogether, + PageBreak, + Paragraph, + Spacer, + Table, +) + +# Room a section needs at the foot of a page before it starts there: its +# heading, lead and opening block (summary strip, bar) or first table rows. +SECTION_MIN = 6 * cm +# A Heading2 subsection that opens with text needs less. +SUBSECTION_MIN = 4 * cm +# A table with fewer rows moves with its caption; longer tables split. +SHORT_TABLE_ROWS = 8 + +CAPTION_STYLES = ("AltCaption", "EgressCaption") +OPENING_CAPTION_STYLES = ("ResBarCaption",) + + +class KeepWithNext(KeepTogether): + # The group keepWithNext builds from a heading and what follows it. When it + # does not fit but there is room for the heading and a few lines, it starts + # here and splits, instead of moving a long paragraph or table whole. + def split(self, aW, aH): + if getattr(self, "_wrapInfo", None) != (aW, aH): + self.wrap(aW, aH) + if self._H > aH >= SUBSECTION_MIN: + return self._content[:] + return super().split(aW, aH) + + +def chapter_break(): + # The only hard page break in the reports: cover, page 2, chapters and + # appendices. + return PageBreak() + + +def section_break(min_height=SECTION_MIN): + return CondPageBreak(min_height) + + +def section_head(*flowables): + return KeepTogether(list(flowables)) + + +def _opens(item): + # Summary strips (one-row tables), bars and their captions open a section. + if isinstance(item, Table): + return len(item._cellvalues) == 1 + if isinstance(item, Paragraph): + return item.style.name in OPENING_CAPTION_STYLES + return isinstance(item, (Drawing, Spacer)) + + +def split_opening(items): + index = 0 + while index < len(items) and _opens(items[index]): + index += 1 + return items[:index], items[index:] + + +def _is_caption(item): + return isinstance(item, Paragraph) and item.style.name in CAPTION_STYLES + + +def keep_captions(items): + # A short table and the caption under it move as one; a long table may + # split, with its header repeated. + result = [] + index = 0 + while index < len(items): + item = items[index] + if isinstance(item, Table) and len(item._cellvalues) < SHORT_TABLE_ROWS: + end = index + 1 + while end < len(items) and isinstance(items[end], Spacer): + end += 1 + if end < len(items) and _is_caption(items[end]): + while end + 1 < len(items) and ( + _is_caption(items[end + 1]) + or isinstance(items[end + 1], Spacer) + and end + 2 < len(items) + and _is_caption(items[end + 2]) + ): + end += 1 + result.append(KeepTogether(items[index : end + 1])) + index = end + 1 + continue + result.append(item) + index += 1 + return result + + +def _is_short_table(item): + if isinstance(item, KeepTogether): + return any(_is_short_table(child) for child in item._content) + return isinstance(item, Table) and len(item._cellvalues) < SHORT_TABLE_ROWS + + +def section(head, items): + # The heading, lead and opening block move as one unit; the rest flows. + # Without a strip or bar, a short first table joins the lead, so the + # heading never waits alone at the foot of a page for it. + opening, rest = split_opening(list(items)) + rest = keep_captions(rest) + if not opening and rest and _is_short_table(rest[0]): + first = rest[0] + # Flattened: a KeepTogether inside another always reports itself as + # too tall and would push the whole section to the next page. + opening = first._content if isinstance(first, KeepTogether) else [first] + rest = rest[1:] + return [section_head(*head, *opening), *rest] diff --git a/cloudexit/report/fonts.py b/cloudexit/report/fonts.py new file mode 100644 index 0000000..17896fe --- /dev/null +++ b/cloudexit/report/fonts.py @@ -0,0 +1,73 @@ +from reportlab.graphics.shapes import STATE_DEFAULTS +from reportlab.lib.fonts import addMapping +from reportlab.pdfbase import pdfmetrics +from reportlab.pdfbase.ttfonts import TTFont +from reportlab.platypus.tables import CellStyle + +from cloudexit.resources import asset_path + +FONT_DIR = asset_path("fonts") + + +class MissingFontError(FileNotFoundError): + pass + + +FONT = "IBMPlexSans" +# SemiBold is the report's bold: it reads cleaner than Bold at small sizes. +FONT_BOLD = "IBMPlexSans-SemiBold" +FONT_ITALIC = "IBMPlexSans-Italic" +FONT_BOLD_ITALIC = "IBMPlexSans-SemiBoldItalic" +FONT_MONO = "IBMPlexMono" +FONT_MONO_BOLD = "IBMPlexMono-SemiBold" + +FONT_FILES = { + FONT: "IBMPlexSans-Regular.ttf", + FONT_BOLD: "IBMPlexSans-SemiBold.ttf", + FONT_ITALIC: "IBMPlexSans-Italic.ttf", + FONT_BOLD_ITALIC: "IBMPlexSans-SemiBoldItalic.ttf", + FONT_MONO: "IBMPlexMono-Regular.ttf", + FONT_MONO_BOLD: "IBMPlexMono-SemiBold.ttf", +} + + +def register_fonts(): + if set(FONT_FILES) <= set(pdfmetrics.getRegisteredFontNames()): + return + # A missing file raises: a report must never switch typeface without + # anyone noticing. + for name, filename in FONT_FILES.items(): + path = FONT_DIR / filename + if not path.is_file(): + raise MissingFontError( + f"the font file {filename} is missing from {FONT_DIR}; " + "the cloudexit installation is incomplete" + ) + pdfmetrics.registerFont(TTFont(name, str(path))) + # Families let and in Paragraph markup find the right face. + pdfmetrics.registerFontFamily( + FONT, + normal=FONT, + bold=FONT_BOLD, + italic=FONT_ITALIC, + boldItalic=FONT_BOLD_ITALIC, + ) + pdfmetrics.registerFontFamily( + FONT_MONO, + normal=FONT_MONO, + bold=FONT_MONO_BOLD, + italic=FONT_MONO, + boldItalic=FONT_MONO_BOLD, + ) + # Mono has no italic, so the upright faces fill the italic slots. Map them + # back as upright, or markup inside mono text would count as italic. + addMapping(FONT_MONO, 0, 0, FONT_MONO) + addMapping(FONT_MONO, 1, 0, FONT_MONO_BOLD) + # Table cells and drawings select ReportLab's base fonts (Helvetica, + # Times-Roman) even when they draw no text with them, which still lists + # those fonts in every PDF. This package is the only user of them here. + CellStyle.fontname = FONT + STATE_DEFAULTS["fontName"] = FONT + + +register_fonts() diff --git a/cloudexit/report/helpers.py b/cloudexit/report/helpers.py new file mode 100644 index 0000000..a7221ea --- /dev/null +++ b/cloudexit/report/helpers.py @@ -0,0 +1,97 @@ +from pathlib import Path + +from cloudexit.resources import resolve_inside, safe_relative + +CURRENCY_SYMBOLS = {"USD": "$", "EUR": "€", "GBP": "£"} + + +def strategy_name(code, b): + return b.get(f"strategy_name_{code}") or b["ev_dash"] + + +def strategy_definition(code, b): + return b.get(f"strategy_def_{code}") or "" + + +def format_amount(value): + return f"{float(value or 0):,.2f}" + + +def format_money(value, currency): + amount = format_amount(value) + symbol = CURRENCY_SYMBOLS.get(currency) + return f"{symbol}{amount}" if symbol else f"{currency} {amount}" + + +def gib_number(size_bytes, places=1): + # A size that is not zero never reads as 0.0 + gib = size_bytes / 2**30 + smallest = 10**-places + if 0 < gib < smallest: + return f"<{smallest:.{places}f}" + return f"{gib:,.{places}f}" + + +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 "–" + change = (float(current) - float(previous)) / float(previous) * 100 + return f"{'+' if change >= 0 else '−'}{abs(change):.1f} %" + + +def severity_label(severity, b): + return b.get(f"risk_sev_{severity}", severity) + + +def cost_problem(context, b): + # A message in place of the figures and the table, or None when the data + # is usable. + if not context.costs: + if context.scope.kind == "tfstate": + return b["cost_tfstate"] + return b["tbl_cost_no_data"] + if len({entry.currency for entry in context.costs}) > 1: + return b["cost_mixed_currency"] + return None + + +def initials(name): + # The first letter of each of the first two words; "?" when there is none. + letters = [] + for word in str(name or "").split(): + first = next((char for char in word if char.isalnum()), "") + if first: + letters.append(first.upper()) + if len(letters) == 2: + break + return "".join(letters) or "?" + + +REPORT_ASSETS = "assets" + + +def report_icon(report_path, icon_rel): + # The page only points at files in its own folder: an icon the report + # folder does not hold is left out, and the page draws a placeholder + found = resolve_inside(Path(report_path) / REPORT_ASSETS, icon_rel) + if found is not None and found.is_file(): + return f"{REPORT_ASSETS}/{safe_relative(icon_rel).as_posix()}" + return None + + +def percent(share): + # With a no-break space, so a figure and its sign stay together + return share_label(share).replace(" ", " ") diff --git a/cloudexit/report/html_egress.py b/cloudexit/report/html_egress.py new file mode 100644 index 0000000..5048995 --- /dev/null +++ b/cloudexit/report/html_egress.py @@ -0,0 +1,264 @@ +from .egress import ( + DEFAULT_PRICING_ZONE, + coverage_facts, + fee_number, + free_tier_headroom, + pricing_facts, +) +from .context import EGRESS_DOCUMENT_CODE, PROVIDER_NAMES +from .helpers import gib_number, percent, report_icon +from .wording import BLOCKS + +SEGMENT_COLORS = ( + "var(--primary-800)", + "var(--primary-600)", + "#2dd4c8", + "#5eeae1", + "#99f6f0", +) +OTHER_COLOR = "var(--neutral-300)" + + +def _share(part, whole): + return part / whole * 100 if whole else 0 + + +def _internet_unit(prices, csp): + return next( + ( + price["unit"] + for price in prices + if price["csp"] == csp + and price["component"] == "internet_egress" + and price["zone"] == DEFAULT_PRICING_ZONE + ), + "GiB", + ) + + +def _kpis(estimate, sized, unsized, groups, b): + summary = estimate["summary"] + csp = estimate["meta"]["cloud_service_provider"] + total = estimate["totals"]["known_size_bytes"] + nothing_sized = summary["nothing_sized"] + # Nothing was measured: no volume to show, and no headroom to claim against + headroom = ( + None if nothing_sized else free_tier_headroom(estimate["pricing"], csp, total) + ) + plus = "+" if summary["unknown_count"] > 0 else "" + volume = b["ev_dash"] if nothing_sized else gib_number(total) + + kpis = [ + { + "label": b["html_eg_kpi_fee"], + "figure": summary["total_fee_display"], + "unit": "", + "caption": b[ + "html_eg_fee_free" if summary["free_tier"] else "html_eg_fee_paid" + ], + "primary": True, + }, + { + "label": b["eg_st_volume"], + "figure": volume, + "unit": "" if nothing_sized else f"GiB{plus}", + "caption": ( + b["html_eg_types_sized"].format(sized=len(sized), types=len(groups)) + if groups + else "" + ), + }, + ] + if headroom: + kpis.append( + { + "label": b["html_eg_kpi_headroom"], + "figure": f"{headroom['left']:,.1f}", + "unit": headroom["unit"], + "caption": b["html_eg_headroom_left"].format( + limit=f"{headroom['limit']:g} {headroom['unit']}" + ), + "bar": round(headroom["used_share"]), + "aria": b["html_eg_headroom_aria"].format( + share=percent(headroom["used_share"]) + ), + } + ) + else: + kpis.append( + { + "label": b["eg_st_billable"], + "figure": volume, + "unit": "" if nothing_sized else "GiB", + # With nothing sized the prices were never applied, so the + # caption would claim something the run did not establish + "caption": "" if nothing_sized else b["html_eg_billable_caption"], + } + ) + kpis.append( + { + "label": b["eg_st_not_sized"], + "figure": str(len(unsized)), + "unit": "", + "caption": b["html_eg_not_sized_caption"], + } + ) + return kpis, headroom + + +def _volume(sized, unsized, total, b): + if not sized or total <= 0: + return None + shown, rest = sized[: len(SEGMENT_COLORS)], sized[len(SEGMENT_COLORS) :] + segments = [ + ( + group["label"], + _share(group["size_bytes"], total), + SEGMENT_COLORS[index], + ) + for index, group in enumerate(shown) + ] + if rest: + segments.append( + ( + b["html_eg_vol_other"], + _share(sum(group["size_bytes"] for group in rest), total), + OTHER_COLOR, + ) + ) + legend = [ + {"text": f"{label} {percent(share)}", "color": color} + for label, share, color in segments + ] + return { + "segments": [ + {"flex": round(share, 1), "color": color} for _, share, color in segments + ], + "legend": legend, + "unsized": ( + b["html_eg_vol_unsized"].format( + types=", ".join(group["label"] for group in unsized) + ) + if unsized + else None + ), + "aria": b["html_eg_vol_aria"].format( + parts=", ".join(item["text"] for item in legend) + ), + } + + +def _table(context, estimate, sized, unsized, total, b): + summary = estimate["summary"] + plus = "+" if summary["unknown_count"] > 0 else "" + + def row(group): + icon = group["icon"].lstrip("/") if group["icon"] else "" + return { + "label": group["label"], + "initials": group["initials"], + "icon": report_icon(context.report_path, icon), + } + + rows = [ + { + **row(group), + "size": gib_number(group["size_bytes"], 2), + "share": percent(_share(group["size_bytes"], total)), + "share_value": round(_share(group["size_bytes"], total)), + "fee": fee_number(group["fee"] or 0.0), + } + for group in sized + ] + rows += [ + {**row(group), "size": None, "share": None, "share_value": 0, "fee": None} + for group in unsized + ] + return { + "headers": ( + b["eg_th_resource_type"], + b["eg_th_size_gib"], + b["eg_th_share"], + b["eg_th_fee"], + ), + "rows": rows, + "not_sized": b["html_eg_not_sized"], + "total": ( + b["eg_total"], + ( + b["ev_dash"] + if summary["nothing_sized"] + else f"{gib_number(total, 2)}{plus}" + ), + ( + b["ev_dash"] + if summary["nothing_sized"] + else fee_number(estimate["total_fee"]) + ), + ), + } + + +def _notes(context, estimate, headroom, b): + csp = estimate["meta"]["cloud_service_provider"] + total = estimate["totals"]["known_size_bytes"] + provider = PROVIDER_NAMES.get(csp) or b["eg_provider_fallback"] + unit = _internet_unit(estimate["pricing"], csp) + if unit == "GB" and headroom: + used_gb = total / 10**9 + units = b["html_eg_units_gb"].format( + provider=provider, + gib=gib_number(total), + gb=f"{used_gb:,.1f}", + limit=f"{headroom['limit']:g} {headroom['unit']}", + ) + elif unit == "GB": + units = b["html_eg_units_gb_plain"].format(provider=provider) + else: + units = b["html_eg_units_gib"].format(provider=provider) + + currencies = {entry.currency for entry in context.costs} + if len(currencies) == 1: + amounts = b["html_eg_amounts_costs"].format(currency=next(iter(currencies))) + else: + amounts = b["html_eg_amounts"] + return [f"{units} {amounts}", b["html_eg_scope_note"]] + + +def egress_page(context, estimate, b=BLOCKS): + csp = estimate["meta"]["cloud_service_provider"] + total = estimate["totals"]["known_size_bytes"] + groups = estimate["type_groups"] + sized = sorted( + (group for group in groups if group["size_bytes"] is not None), + key=lambda group: -group["size_bytes"], + ) + unsized = [group for group in groups if group["size_bytes"] is None] + kpis, headroom = _kpis(estimate, sized, unsized, groups, b) + prices = pricing_facts(estimate, b) + return { + "heading": b["ev_egress_report"], + "caption": b["html_eg_caption"].format( + document_id=context.egress_document_id + or context.document_id_for(EGRESS_DOCUMENT_CODE) + ), + "empty": b["eg_no_data_notice"] if not estimate["rows"] else None, + "kpis": kpis, + "volume": _volume(sized, unsized, total, b), + "table": _table(context, estimate, sized, unsized, total, b), + "notes": _notes(context, estimate, headroom, b), + "sized": { + "summary": b["html_eg_sized_summary"], + "facts": coverage_facts(csp, b), + }, + "prices": ( + { + "summary": b["html_eg_prices_summary"].format( + provider=PROVIDER_NAMES.get(csp) or b["eg_provider_fallback"] + ), + "facts": prices, + } + if prices + else None + ), + } diff --git a/cloudexit/report/html_sections.py b/cloudexit/report/html_sections.py new file mode 100644 index 0000000..9cd6146 --- /dev/null +++ b/cloudexit/report/html_sections.py @@ -0,0 +1,845 @@ +from markupsafe import Markup + +from .evidence import platform_failure, scope_rows +from .helpers import ( + format_change, + initials, + format_money, + percent, + report_icon, + severity_label, + strategy_name, +) +from .html_egress import egress_page +from .notices import icon_notice, text_parts +from .palette import SEVERITIES +from .sections import ( + PRODUCT_SITES, + REPOSITORY_URL, +) +from .wording import BLOCKS + +ALT_VISIBLE = 8 +ALT_OPEN = 2 +OPEN_RISKS_UP_TO = 3 +AFFECTED_SHOWN = 3 +REGIONS_SHOWN = 5 +PROVIDER_LOGOS = {1: "azure", 2: "aws"} +ABOUT_URL = "#about" + + +def _count(b, key, n): + return b[f"{key}_one"] if n == 1 else b[f"{key}_many"].format(n=n) + + +def _share(part, whole): + return part / whole * 100 if whole else 0 + + +def _month(month, b): + return b["html_months"][int(month[5:7]) - 1] + + +def _mono(text): + return Markup('{}').format(text) + + +def _regions_line(context, b): + if not context.total_resources: + return b["html_scope_no_resources"] + names = sorted(context.regions) + return f"{_count(b, 'scope_regions', len(names))} · {', '.join(names)}" + + +def _title(context, has_egress, b): + provenance = context.provenance + platform_url = provenance.platform_url if provenance.uploaded else None + logo = PROVIDER_LOGOS.get(context.cloud_service_provider) + downloads = [("report.pdf", b["html_download_summary"])] + if has_egress: + downloads.append(("egress.pdf", b["html_download_egress"])) + return { + "logo": f"assets/img/csp/{logo}.svg" if logo else None, + "provider": context.provider, + "name": context.name, + "uploaded": provenance.uploaded, + "chip": b["html_chip_uploaded" if provenance.uploaded else "html_chip_self"], + "chip_title": b[ + ( + "html_chip_uploaded_title" + if provenance.uploaded + else "html_chip_self_title" + ) + ], + "platform_url": platform_url, + "downloads": downloads, + } + + +def _scope_card(context, b): + scope = context.scope + values = dict(scope.rows) + regions = _regions_line(context, b) + rows = [{"label": b["ev_cloud_provider"], "value": scope.provider or b["ev_dash"]}] + if scope.kind == "azure": + rows.append( + { + "label": b["ev_resource_group"], + "value": scope.resource_group or b["ev_dash"], + "small": regions, + } + ) + elif scope.kind == "aws": + rows.append( + { + "label": b["ev_region"], + "value": scope.region or b["ev_dash"], + "small": regions, + } + ) + else: + serial = values.get("serial") + small = " · ".join( + part + for part in ( + b["html_scope_serial"].format(serial=serial) if serial else "", + regions, + ) + if part + ) + rows.append( + { + "label": b["ev_file"], + "value": scope.file_name or b["ev_dash"], + "small": small, + } + ) + rows.append( + { + "label": b["cover_exit_strategy"], + "value": strategy_name(context.strategy_code, b), + } + ) + provenance = context.provenance + source = b["html_run_tfstate" if scope.kind == "tfstate" else "html_run_live"] + rows.append( + { + "label": b["html_run_label"], + "value": context.completed, + "small": Markup(b["html_run_small"]).format( + run=_mono(provenance.run_short_id or b["ev_dash"]), source=source + ), + } + ) + sha = provenance.catalogue_sha256 + rows.append( + { + "label": b["ev_catalogue_label"], + "value": provenance.catalogue_version or b["ev_catalogue_unknown"], + "small": b["html_catalogue_sha"].format(sha=sha[:12]) if sha else None, + "small_mono": True, + } + ) + return {"label": b["html_scope_label"], "rows": rows} + + +def _risks_card(context, b): + risks = context.risks + counts = { + severity: sum(1 for risk in risks if risk.severity == severity) + for severity in SEVERITIES + } + return { + "label": b["html_risks_label"], + "total": len(risks), + "none": b["bs_find_risks_none"] if not risks else None, + "aria": b["html_sev_aria"].format( + counts=", ".join( + f"{counts[severity]} {severity_label(severity, b).lower()}" + for severity in SEVERITIES + ) + ), + "rows": [ + { + "severity": severity, + "label": severity_label(severity, b), + "count": counts[severity], + "width": round(_share(counts[severity], len(risks))), + } + for severity in SEVERITIES + ], + } + + +def _costs_card(context, b): + if context.scope.kind == "tfstate": + return { + "kind": "message", + "label": b["html_costs_label_short"], + "figure": b["ev_dash"], + "message": b["cost_tfstate"], + "hint": b["html_costs_tfstate_hint"], + } + entries = context.costs + if not entries or len({entry.currency for entry in entries}) > 1: + return { + "kind": "message", + "label": b["html_costs_label"], + "figure": b["ev_dash"], + "message": (b["cost_mixed_currency"] if entries else b["tbl_cost_no_data"]), + "hint": None, + } + + currency = entries[-1].currency + total = sum(entry.amount for entry in entries) + latest = entries[-1] + before = entries[-2] if len(entries) > 1 else None + biggest = max(entry.amount for entry in entries) + provisional = [entry for entry in entries if not entry.final] + + def label(entry): + return _month(entry.month, b) + ("" if entry.final else "*") + + parts = [ + b[ + "html_costs_aria_part" if entry.final else "html_costs_aria_provisional" + ].format( + month=_month(entry.month, b), amount=format_money(entry.amount, currency) + ) + for entry in entries + ] + return { + "kind": "figures", + "label": b["html_costs_label"], + "figure": format_money(total, currency), + "caption": ( + b["html_costs_none"] + if total == 0 + else b["html_costs_average"].format( + amount=format_money(total / len(entries), currency) + ) + ), + "change": format_change( + before.amount if before else None, latest.amount + ).replace(" %", " %"), + "change_caption": ( + b["html_costs_change"].format( + latest=_month(latest.month, b), previous=_month(before.month, b) + ) + if before + else "" + ), + "bars": [ + { + "month": label(entry), + "height": round(_share(entry.amount, biggest)) if biggest else 0, + "zero": entry.amount <= 0, + "provisional": not entry.final, + } + for entry in entries + ], + "aria": b["html_costs_aria"].format(parts=", ".join(parts)), + "footnote": ( + b["html_costs_provisional"].format(month=_month(provisional[0].month, b)) + if provisional + else b["html_costs_context"] + ), + } + + +def _hint(b): + return { + "title": b["html_hint_title"], + "text": b["html_hint_text"], + "sites": [ + (b["html_site_exitcloud"], dict(PRODUCT_SITES)["exitcloud"]), + (b["html_site_escapecloud"], dict(PRODUCT_SITES)["escapecloud"]), + ], + } + + +def _scores(alternatives_total, platform_url, b): + on_platform = f" {b['html_scores_on_platform']}" if platform_url else "" + lock_text = ( + b["html_scores_lock_text"].format( + alternatives=_count(b, "html_alt_n", alternatives_total) + ) + if alternatives_total + else b["html_scores_lock_text_plain"] + ) + return { + "platform_url": platform_url, + "open_platform": b["html_open_platform"], + "pill": b["html_scores_pill"], + "hint": _hint(b), + "grade": { + "title": b["html_scores_grade"], + "placeholder": b["ev_dash"], + "scale": b["html_scores_scale"], + "bands": b["html_scores_bands"], + "text": b["html_scores_grade_text"] + on_platform, + }, + "lock": { + "title": b["html_scores_lock"], + "dims": [ + (name, b["html_scores_dim_value"].format(dash=b["ev_dash"])) + for name in b["html_scores_dims"] + ], + "text": lock_text + on_platform, + }, + } + + +def _risks(context, b): + risks = context.risks + names = {entry.resource_type_id: entry.name for entry in context.resources} + wide = sum(1 for risk in risks if risk.scope_wide) + if not risks: + caption = b["html_risks_none_caption"] + else: + count = _count(b, "html_risks_count", len(risks)) + if not wide: + caption = b["html_risks_caption_only_count"].format(count=count) + else: + if wide == len(risks): + key = "html_risks_wide_sole" if wide == 1 else "html_risks_wide_all" + else: + key = "html_risks_wide_one" if wide == 1 else "html_risks_wide_many" + caption = b["html_risks_caption"].format( + count=count, scope=b[key].format(n=wide) + ) + + def affected(risk): + if risk.scope_wide: + return None + listed = sorted( + names.get(type_id, b["ev_dash"]) for type_id in risk.resource_type_ids + ) + text = ", ".join(listed[:AFFECTED_SHOWN]) + if len(listed) > AFFECTED_SHOWN: + text += " " + b["html_risks_more"].format(n=len(listed) - AFFECTED_SHOWN) + return text + + groups, position = [], 0 + for severity in SEVERITIES: + members = [risk for risk in risks if risk.severity == severity] + rows = [] + for risk in members: + position += 1 + rows.append( + { + "id": f"risk-{position}", + "code": risk.code, + "name": risk.name, + "description": risk.description, + "severity": severity, + "label": severity_label(severity, b), + "affected": affected(risk), + } + ) + if rows: + groups.append( + { + "heading": b["risk_group"].format( + severity=severity_label(severity, b), n=len(rows) + ), + "rows": rows, + } + ) + return { + "heading": b["html_nav_risks"], + "caption": caption, + "headers": ( + b["risk_th_code"], + b["risk_th_risk"], + b["risk_th_severity"], + b["html_risks_th_affected"], + ), + "scope_wide": b["risk_scope_wide"], + "groups": groups, + "open": len(risks) <= OPEN_RISKS_UP_TO, + "footnote": text_parts( + b["html_risks_footnote"], {"link": (b["html_nav_about"], ABOUT_URL)} + ), + "empty": ( + { + "title": b["bs_find_risks_none"], + "text": b[ + ( + "html_risks_empty_clean" + if context.total_resources + else "html_risks_empty_scope" + ) + ], + } + if not risks + else None + ), + } + + +def _inventory(context, b): + total = context.total_resources + scope = context.scope + regions = {} + for entry in context.resources: + for location, count in entry.location_counts.items(): + regions[location] = regions.get(location, 0) + count + ranked = sorted(regions.items(), key=lambda item: (-item[1], item[0])) + shown = [ + f"{name} {percent(_share(count, total))}" + for name, count in ranked[:REGIONS_SHOWN] + ] + if len(ranked) > REGIONS_SHOWN: + shown.append(b["html_risks_more"].format(n=len(ranked) - REGIONS_SHOWN)) + caption_parts = [_count(b, "scope_resources", total)] + if total: + caption_parts += [ + _count(b, "scope_types", len(context.resources)), + b["html_inv_regions"].format( + regions=_count(b, "scope_regions", len(regions)), + shares=", ".join(shown), + ), + ] + if scope.kind == "tfstate": + caption_parts.append(b["html_inv_not_managed"]) + if scope.not_assessed: + key = ( + "html_inv_not_assessed_one" + if scope.not_assessed == 1 + else "html_inv_not_assessed_many" + ) + caption_parts.append( + b[key].format(n=scope.not_assessed, about=b["html_nav_about"]) + ) + + if scope.kind == "azure": + empty = b["html_inv_empty_resource_group"].format( + scope=scope.resource_group or b["ev_dash"] + ) + elif scope.kind == "aws": + empty = b["html_inv_empty_region"].format(scope=scope.region or b["ev_dash"]) + else: + empty = b["html_inv_empty_tfstate"].format( + scope=scope.file_name or b["ev_dash"] + ) + + return { + "heading": b["es_heading_resource_inventory"], + "caption": " · ".join(caption_parts), + "headers": ( + b["res_th_resource_type"], + b["html_inv_th_locations"], + b["res_th_share"], + b["res_th_resources"], + ), + "rows": [ + { + "name": entry.name, + "initials": initials(entry.name), + "icon": report_icon(context.report_path, entry.icon_rel), + "locations": ", ".join( + f"{name} · {count}" + for name, count in sorted( + entry.location_counts.items(), + key=lambda item: (-item[1], item[0]), + ) + ), + "share": percent(_share(entry.resources, total)), + "share_value": round(_share(entry.resources, total)), + "count": entry.resources, + } + for entry in context.resources + ], + "total": (b["res_total"], total), + "footnote": b["es_res_caption"], + "empty": ( + {"title": b["html_inv_empty_title"], "text": empty} if not total else None + ), + } + + +def _alternatives(context, b): + entries = sorted( + context.alternatives, + key=lambda entry: ( + not entry.alternatives, + -len(entry.alternatives), + -entry.resources, + entry.name.lower(), + ), + ) + total = sum(len(entry.alternatives) for entry in entries) + with_alternatives = sum(1 for entry in entries if entry.alternatives) + provenance = context.provenance + + groups = [] + for position, entry in enumerate(entries): + rows = [ + { + "name": alt.name, + "vendor": alt.vendor or b["ev_dash"], + "cc": alt.country or "", + "os": alt.open_source, + "sp": alt.support_plan, + "extra": index >= ALT_VISIBLE, + } + for index, alt in enumerate(entry.alternatives) + ] + resources = _count(b, "scope_resources", entry.resources) + hidden = max(0, len(rows) - ALT_VISIBLE) + groups.append( + { + "name": entry.name, + "initials": initials(entry.name), + "icon": report_icon(context.report_path, entry.icon_rel), + "meta": ( + b["html_alt_meta"].format( + resources=resources, + alternatives=_count(b, "html_alt_n", len(rows)), + ) + if rows + else b["html_alt_meta_none"].format(resources=resources) + ), + "rows": rows, + "open": position < ALT_OPEN and bool(rows), + "hidden": hidden, + "more": (_count_more(b, hidden) if hidden else None), + } + ) + + if not entries: + caption = _count(b, "html_alt_n", 0) + else: + key = "html_alt_caption" if len(entries) != 1 else "html_alt_caption_one_type" + caption = b[key].format( + alternatives=_count(b, "html_alt_n", total), + with_alternatives=with_alternatives, + types=len(entries), + ) + return { + "heading": b["es_heading_alternative_technologies"], + "caption": caption, + "total": total, + "search": b["html_alt_search"], + "filters": [ + ("os", b["html_filter_os"]), + ("sp", b["html_filter_sp"]), + ("eu", b["html_filter_eu"]), + ], + "headers": (b["html_alt_th_name"], b["html_filter_os"], b["html_filter_sp"]), + "yes": b["html_yes"], + "no": b["html_no"], + "no_match": b["html_alt_no_match"], + "groups": groups, + "footnote": b["html_alt_footnote"].format( + version=provenance.catalogue_version or b["ev_catalogue_unknown"] + ), + "empty": ( + {"title": b["html_alt_empty_title"], "text": b["html_alt_empty_text"]} + if not entries + else None + ), + } + + +def _count_more(b, n): + return b["html_alt_more_one"] if n == 1 else b["html_alt_more_many"].format(n=n) + + +def _platform_cell(context, b): + provenance = context.provenance + if not provenance.uploaded: + if provenance.upload_failed: + return [platform_failure(provenance, b)] + return [b["ev_platform_none"]] + parts = [b["ev_platform_uploaded"]] + if provenance.platform_url: + label = provenance.platform_url.split("://", 1)[-1] + if len(label) > 40: + label = label[:40] + "…" + return [f"{parts[0]}: ", (label, provenance.platform_url)] + if provenance.assessment_id: + parts.append(b["fa_uploaded_id"].format(assessment_id=provenance.assessment_id)) + return [" · ".join(parts)] + + +def _scope_table(context, b): + rows = scope_rows(context, b) + if context.scope.kind != "tfstate": + return rows + # A state file names no tenant, subscription or resource group of a run: its + # own file and state, and the regions of what it holds + dropped = {b["ev_subscriptions"], b["ev_resource_groups"]} + rows = [row for row in rows if row[0] not in dropped] + if not any(row[0] == b["ev_regions"] for row in rows): + regions = ", ".join(sorted(context.regions)) or b["ev_not_determinable"] + rows.insert(-1, (b["ev_regions"], regions, False)) + return rows + + +def _about(context, egress, b): + provenance = context.provenance + tfstate = context.scope.kind == "tfstate" + sha = provenance.catalogue_sha256 + glossary = [ + entry + for entry in b["html_glossary"] + if (entry["id"] != "aws" or context.cloud_service_provider == 2) + and (entry["id"] not in ("egress", "free_tier", "gib_gb") or egress is not None) + ] + phases = [ + ( + name, + b["html_method_discovery_tfstate"] if tfstate and index == 0 else text, + ) + for index, (name, text) in enumerate(b["es_method_phases"]) + ] + return { + "heading": b["html_nav_about"], + "caption": b["html_about_caption"], + "document": { + "heading": b["html_about_document"], + "rows": [ + (b["ev_document_id"], context.document_id, True), + (b["ev_document_type"], b["ev_executive_report"], False), + (b["ev_language"], b["language_name"], False), + ( + b["ev_collected_by"], + b["ev_collected_by_value"].format( + version=provenance.collector_version + ), + False, + ), + ( + b["ev_generated_at"], + provenance.generated_at.strftime("%Y-%m-%d %H:%M UTC"), + False, + ), + ], + }, + "scope": { + "heading": b["ev_assessment_scope_heading"], + "rows": _scope_table(context, b), + }, + "provenance": { + "heading": b["ev_provenance_heading"], + "run_id": ( + (b["ev_run_id"], provenance.run_id) if provenance.run_id else None + ), + "catalogue": ( + b["ev_catalogue_label"], + provenance.catalogue_version or b["ev_catalogue_unknown"], + b["html_catalogue_sha"].format(sha=sha[:12]) if sha else None, + ), + "platform": (b["ev_platform_label"], _platform_cell(context, b)), + }, + "stored": b["ev_stored_footnote"].format( + run=provenance.run_short_id or b["ev_dash"] + ), + "purpose": { + "summary": b["html_about_purpose"], + "meta": b["html_about_phases_meta"].format(n=len(phases)), + "paragraphs": [b["es_purpose_block"], b["html_method_lead"]], + "phases": phases, + "closing": b["html_method_catalogue"], + }, + "limitations": { + "summary": b["es_heading_limitations"], + "paragraphs": [b["err_limitations"], b["html_limitations_data"]], + }, + "severity": { + "summary": b["es_heading_severity"], + "meta": b["html_about_levels_meta"].format(n=len(SEVERITIES)), + "headers": (b["risk_th_severity"], b["es_severity_th_meaning"]), + "rows": [ + (level, severity_label(level, b), b[f"es_severity_{level}"]) + for level in SEVERITIES + ], + "note": b["es_severity_note"], + }, + "glossary": { + "summary": b["es_heading_glossary"], + "meta": b["html_about_terms_meta"].format(n=len(glossary)), + "entries": [ + (entry["term"], entry["definition"]) + for entry in sorted(glossary, key=lambda e: e["term"].lower()) + ], + }, + } + + +def _notices(context, egress, b): + provenance = context.provenance + icons = icon_notice(context.cloud_service_provider, b) + if context.scope.kind == "tfstate": + figures = b["html_figures_tfstate"] + else: + figures = b["html_figures_egress" if egress is not None else "html_figures"] + return { + "disclaimer": { + "heading": b["notices_disclaimer_heading"], + "paragraphs": [ + [b["notices_disclaimer"]], + [f"{b['notices_generated']} {b['html_notice_advice']}"], + *([icons] if icons else []), + ], + }, + "blocks": [ + { + "heading": b["notices_trademarks_heading"], + "parts": [ + f"{b['notices_trademark_own']} {b['notices_trademark_others']}" + ], + }, + {"heading": b["html_figures_heading"], "parts": [figures]}, + { + "heading": b["notices_software_heading"], + "parts": text_parts( + b["html_software"], + {"repository_link": (b["html_label_repository"], REPOSITORY_URL)}, + ), + }, + ], + "copyright": b["notices_copyright"].format(year=provenance.generated_at.year), + "ids": b["html_footer_ids"].format( + document_id=context.document_id, version=provenance.collector_version + ), + } + + +def _tour(context, egress, linked, b): + tfstate = context.scope.kind == "tfstate" + sections = [b["html_tour_sections"]] + if not linked: + sections.append(b["html_tour_sections_lock"]) + report = [b["html_tour_report"]] + if linked: + report.append(b["html_tour_report_platform"]) + if egress is not None: + report.append( + b[ + ( + "html_tour_download_both_platform" + if linked + else "html_tour_download_both" + ) + ] + ) + else: + report.append(b["html_tour_download_one"]) + scores = ( + b["html_tour_scores_platform"] + if linked + else f"{b['html_tour_scores_available']} {b['html_hint_text']}" + ) + steps = [ + (".secnav", b["html_tour_sections_title"], " ".join(sections), "right"), + (".titlebar", b["html_tour_report_title"], " ".join(report), "bottom"), + ( + "#overview .overview", + b["html_tour_overview_title"], + b["html_tour_overview_tfstate" if tfstate else "html_tour_overview"], + "bottom", + ), + ("#scores .scores", b["html_tour_scores_title"], scores, "bottom"), + ( + "#risks .card-head", + b["html_tour_risks_title"], + b["html_tour_risks"], + "bottom", + ), + ( + "#inventory .card-head", + b["html_tour_inventory_title"], + b["html_tour_inventory"], + "bottom", + ), + ( + "#alternatives .card-head", + b["html_tour_alternatives_title"], + b["html_tour_alternatives"], + "bottom", + ), + ] + if egress is not None: + steps.append( + ( + "#egress .kpi-row", + b["html_tour_egress_title"], + b["html_tour_egress"], + "bottom", + ) + ) + steps.append( + ("#about .kv-grid", b["html_tour_about_title"], b["html_tour_about"], "top") + ) + return [ + {"element": element, "title": title, "description": text, "side": side} + for element, title, text, side in steps + ] + + +def html_sections(context, egress=None, b=BLOCKS): + # Everything the page says, as plain data; the template only lays it out + provenance = context.provenance + platform_url = provenance.platform_url if provenance.uploaded else None + alternatives = _alternatives(context, b) + nav = [ + {"id": "overview", "label": b["html_nav_overview"]}, + { + "id": "scores", + "label": b["html_nav_scores"], + "lock": b["html_lock_label"] if not platform_url else None, + }, + {"id": "risks", "label": b["html_nav_risks"], "count": len(context.risks)}, + { + "id": "inventory", + "label": b["es_heading_resource_inventory"], + "count": context.total_resources, + }, + { + "id": "alternatives", + "label": b["es_heading_alternative_technologies"], + "count": alternatives["total"], + }, + ] + if egress is not None: + nav.append({"id": "egress", "label": b["ev_egress_report"]}) + nav.append({"id": "about", "label": b["html_nav_about"]}) + + return { + "title": f"{b['ev_executive_report']} – {context.name}", + "language": "en", + "dash": b["ev_dash"], + "header": { + "tag": b["cover_edition_value"], + "logo_link": b["html_logo_link"], + "help": b["html_help"], + "help_aria": b["html_help_aria"], + "tour": { + "next": b["html_tour_next"], + "back": b["html_tour_back"], + "done": b["html_tour_done"], + "progress": b["html_tour_progress"], + }, + }, + "titlebar": _title(context, egress is not None, b), + "download": b["html_download"], + "open_platform": b["html_open_platform"], + "nav": nav, + "nav_label": b["html_nav_label"], + "overview": { + "scope": _scope_card(context, b), + "risks": _risks_card(context, b), + "costs": _costs_card(context, b), + }, + "scores": _scores(alternatives["total"], platform_url, b), + "risks": _risks(context, b), + "inventory": _inventory(context, b), + "alternatives": alternatives, + "egress": egress_page(context, egress, b) if egress is not None else None, + "about": _about(context, egress, b), + "notices": _notices(context, egress, b), + "tour": _tour(context, egress, bool(platform_url), b), + } diff --git a/cloudexit/report/json_data.py b/cloudexit/report/json_data.py new file mode 100644 index 0000000..ea59f34 --- /dev/null +++ b/cloudexit/report/json_data.py @@ -0,0 +1,107 @@ +# cloudexit/report/json_data.py +import logging +from typing import Any + +from cloudexit.report.aggregate import ( + enrich_resource_inventory, + sort_cost_data, + summarize_alternative_technologies, + summarize_risks, +) + +# Configure logger +logger = logging.getLogger(__name__) + + +def transform_resource_inventory_for_json( + resource_inventory: list[dict[str, Any]], + resource_type_mapping: dict[str, dict[str, Any]], +) -> list[dict[str, Any]]: + enriched_resources = enrich_resource_inventory( + resource_inventory, resource_type_mapping + ) + return [ + { + "id": resource["id"], + "code": resource["code"], + "resource_name": resource["resource_name"], + "location": resource["location"], + "count": resource["count"], + } + for resource in enriched_resources + ] + + +def transform_cost_inventory_for_json( + cost_data: list[dict[str, Any]], +) -> list[dict[str, Any]]: + sorted_cost_data = sort_cost_data(cost_data) + + cost_inventory = [ + { + "month": item["month"], + "cost": round(item["cost"], 2), + "currency": item["currency"], + } + for item in sorted_cost_data + ] + return cost_inventory + + +def transform_risk_inventory_for_json( + risk_data: list[dict[str, Any]], + risk_definitions: list[dict[str, Any]], + resource_inventory: list[dict[str, Any]], +) -> list[dict[str, Any]]: + resource_id_map = { + str(value["resource_type"]): key + 1 + for key, value in enumerate(resource_inventory) + } + risks, _ = summarize_risks( + risk_data, + risk_definitions, + resource_id_map=resource_id_map, + ) + return [ + { + "id": risk["id"], + "name": risk["name"], + "description": risk["description"], + "severity": risk["severity"], + "impacted_resources": risk["impacted_resource_ids"] or [], + "impacted_resources_count": risk["impacted_resources_count"], + } + for risk in risks + ] + + +def transform_alt_tech_for_json( + resource_inventory: list[dict[str, Any]], + alternatives: list[dict[str, Any]], + alternative_technologies: list[dict[str, Any]], + exit_strategy: int, +) -> dict[int, list[dict[str, Any]]]: + resource_id_map = { + str(value["resource_type"]): key + 1 + for key, value in enumerate(resource_inventory) + } + grouped_alt_tech = summarize_alternative_technologies( + resource_inventory, + alternatives, + alternative_technologies, + exit_strategy, + ) + grouped_alt_tech_data = { + resource_id: [] for resource_id in resource_id_map.values() + } + for resource_type, technologies in grouped_alt_tech.items(): + resource_id = resource_id_map.get(resource_type) + if not resource_id: + continue + grouped_alt_tech_data[resource_id] = [ + {"id": idx + 1, **tech} for idx, tech in enumerate(technologies) + ] + + return { + key: grouped_alt_tech_data[key] for key in sorted(grouped_alt_tech_data.keys()) + } diff --git a/cloudexit/report/notices.py b/cloudexit/report/notices.py new file mode 100644 index 0000000..2f81551 --- /dev/null +++ b/cloudexit/report/notices.py @@ -0,0 +1,144 @@ +import re +from xml.sax.saxutils import escape + +from reportlab.lib.styles import ParagraphStyle +from reportlab.platypus import Paragraph, Spacer + +from .evidence import link_markup +from .fonts import FONT_BOLD +from .sections import ( + AWS_ICONS_URL, + AWS_TRADEMARK_URL, + AZURE_ICONS_URL, + LICENCE_URL, + REPOSITORY_URL, +) +from .wording import BLOCKS + +NOTICE_BODY = "NoticeBody" +NOTICE_LABEL = "NoticeLabel" +FIELD = re.compile(r"({[a-z_]+})") + + +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 = [] + for piece in FIELD.split(template): + field = piece[1:-1] + if field in links: + link = links[field] + # A (label, url) pair shows the label; a bare url shows itself + parts.append(link if isinstance(link, tuple) else (link, link)) + elif values and field in values: + parts.append(str(values[field])) + elif piece: + parts.append(piece) + return parts + + +def icon_notice(provider, b): + # The names are the links: the HTML shows the name, the PDF the name and, in + # brackets, the address + if provider == 2: + return text_parts( + b["notices_icons_aws"], + { + "icons": (b["notices_label_aws_icons"], AWS_ICONS_URL), + "guidelines": (b["notices_label_aws_guidelines"], AWS_TRADEMARK_URL), + }, + ) + if provider == 1: + return text_parts( + b["notices_icons_azure"], + {"icons": (b["notices_label_azure_icons"], AZURE_ICONS_URL)}, + ) + return None + + +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: + trademarks.append(icons) + return { + "heading": b["notices_heading"], + "blocks": [ + { + "heading": b["notices_disclaimer_heading"], + "paragraphs": disclaimer, + }, + {"heading": b["notices_trademarks_heading"], "paragraphs": trademarks}, + { + "heading": b["notices_software_heading"], + "paragraphs": [ + text_parts( + b["notices_software"], + {"repository_url": REPOSITORY_URL, "licence_url": LICENCE_URL}, + ) + ], + }, + ], + "copyright": b["notices_copyright"].format(year=generated_at.year), + } + + +def _link_markup(label, url, color): + # A link that is not its own address prints the address too, for print + if label == url: + return link_markup(url, color) + return f"{escape(label)} ({link_markup(url, color)})" + + +def parts_markup(parts, color): + return "".join( + ( + _link_markup(part[0], part[1], color) + if isinstance(part, tuple) + else escape(part) + ) + for part in parts + ) + + +def _styles(styles): + body = styles["ContentStyle"] + return ( + ParagraphStyle(NOTICE_BODY, parent=body, fontSize=8.5, leading=12.5), + ParagraphStyle( + NOTICE_LABEL, + parent=body, + fontName=FONT_BOLD, + fontSize=9, + leading=13, + spaceBefore=8, + spaceAfter=2, + textColor=styles["Heading2"].textColor, + ), + ) + + +def legal_notices(context, styles, b, egress=False): + notices = notices_html( + context.cloud_service_provider, context.provenance.generated_at, b, egress + ) + color = context.appearance.heading2_color + body, label = _styles(styles) + content = [Paragraph(notices["heading"], styles["Heading1"])] + for block in notices["blocks"]: + content.append(Paragraph(escape(block["heading"]), label)) + for parts in block["paragraphs"]: + 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/palette.py b/cloudexit/report/palette.py new file mode 100644 index 0000000..f941e47 --- /dev/null +++ b/cloudexit/report/palette.py @@ -0,0 +1,105 @@ +import re +from dataclasses import dataclass + +WHITE = "#FFFFFF" +INK = "#1A1F24" +SUBTITLE = "#4A5560" +MUTED = "#6B7680" +RULE = "#C9D0D6" +HAIRLINE = "#E3E7EA" +HOTSPOT = "#E8A33D" +HOTSPOT_BACKGROUND = "#FDF6EC" +HOTSPOT_COUNT = "#B45309" +PROVENANCE_BACKGROUND = "#F5F7F8" +PLACEHOLDER_BACKGROUND = HAIRLINE + +# Fixed: the pip count and the word carry the severity without colour. +SEVERITIES = ("critical", "high", "medium", "low") +SEVERITY_COLORS = { + "critical": "#6E1A14", + "high": "#B8322A", + "medium": "#E8A33D", + "low": "#4A7FB5", +} +SEVERITY_PIPS = {"critical": 4, "high": 3, "medium": 2, "low": 1} + +# Header row text sits on the colour's own 0.9 tint; checking the slightly +# darker 0.88 tint covers it. +OWN_TINT_AMOUNT = 0.88 + + +@dataclass(frozen=True) +class Appearance: + header_footer_color: str = "#005470" + header_row_color: str = "#005470" + heading1_color: str = "#005470" + heading2_color: str = "#005470" + + +DEFAULT_APPEARANCE = Appearance() + +HEX_PATTERN = re.compile(r"^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$") + + +def normalize_hex(value): + match = HEX_PATTERN.match(value.strip()) + if not match: + raise ValueError(f"{value} is not a valid hex colour") + digits = match.group(1) + if len(digits) == 3: + digits = "".join(digit * 2 for digit in digits) + return f"#{digits.upper()}" + + +def _channels(value): + digits = normalize_hex(value)[1:] + return [int(digits[index : index + 2], 16) / 255 for index in (0, 2, 4)] + + +def relative_luminance(value): + def linear(channel): + if channel <= 0.04045: + return channel / 12.92 + return ((channel + 0.055) / 1.055) ** 2.4 + + red, green, blue = (linear(channel) for channel in _channels(value)) + return 0.2126 * red + 0.7152 * green + 0.0722 * blue + + +def contrast_ratio(first, second): + lighter, darker = sorted( + (relative_luminance(first), relative_luminance(second)), reverse=True + ) + return (lighter + 0.05) / (darker + 0.05) + + +def tint(value, amount): + channels = (channel + (1 - channel) * amount for channel in _channels(value)) + return "#" + "".join(f"{round(channel * 255):02X}" for channel in channels) + + +def contrast_pairs(appearance=DEFAULT_APPEARANCE): + # (what, text colour, background, minimum ratio): WCAG 2.x asks 4.5:1 for + # normal text and 3:1 for large text. + own = tint(appearance.header_row_color, OWN_TINT_AMOUNT) + return ( + ("body text", INK, WHITE, 4.5), + ("secondary text", SUBTITLE, WHITE, 4.5), + ("muted text", MUTED, WHITE, 4.5), + ("chapter heading", appearance.heading1_color, WHITE, 3.0), + ("section heading", appearance.heading2_color, WHITE, 4.5), + ("table header", appearance.header_row_color, own, 4.5), + ("page number and footer", MUTED, WHITE, 4.5), + ("attention count", HOTSPOT_COUNT, HOTSPOT_BACKGROUND, 4.5), + ("provenance note", SUBTITLE, PROVENANCE_BACKGROUND, 4.5), + ("icon placeholder", SUBTITLE, PLACEHOLDER_BACKGROUND, 4.5), + ("band and rules", appearance.header_footer_color, WHITE, 3.0), + ) + + +def contrast_failures(appearance=DEFAULT_APPEARANCE): + return [ + (what, round(contrast_ratio(text, background), 2), minimum) + for what, text, background, minimum in contrast_pairs(appearance) + if contrast_ratio(text, background) < minimum + ] diff --git a/cloudexit/report/placeholder.py b/cloudexit/report/placeholder.py new file mode 100644 index 0000000..73f71da --- /dev/null +++ b/cloudexit/report/placeholder.py @@ -0,0 +1,61 @@ +from reportlab.lib.colors import HexColor +from reportlab.platypus import Flowable, Paragraph + +from .fonts import FONT_BOLD +from .helpers import initials +from .palette import PLACEHOLDER_BACKGROUND, SUBTITLE + +CORNER = 0.22 + + +class IconPlaceholder(Flowable): + # Stands in for a resource type's icon: a neutral rounded square with the + # type's initials. + def __init__(self, name, size=11): + super().__init__() + self.initials = initials(name) + self.size = size + self.width = self.height = size + + def wrap(self, available_width, available_height): + return self.size, self.size + + def draw(self): + canvas, size = self.canv, self.size + canvas.setFillColor(HexColor(PLACEHOLDER_BACKGROUND)) + canvas.roundRect(0, 0, size, size, size * CORNER, stroke=0, fill=1) + font_size = size * (0.5 if len(self.initials) > 1 else 0.6) + canvas.setFillColor(HexColor(SUBTITLE)) + canvas.setFont(FONT_BOLD, font_size) + canvas.drawCentredString(size / 2, (size - font_size * 0.72) / 2, self.initials) + + +class LabelWithPlaceholder(Flowable): + # A text label with the placeholder in front of its first line, where a + # table cell would otherwise show an inline icon. + def __init__(self, name, markup, style, size=11, gap=2): + super().__init__() + self.placeholder = IconPlaceholder(name, size) + self.paragraph = Paragraph(markup, style) + self.style = style + self.size = size + self.gap = gap + + def wrap(self, available_width, available_height): + offset = self.size + self.gap + _, text_height = self.paragraph.wrap(available_width - offset, available_height) + self.width = available_width + self.height = max(text_height, self.size) + return self.width, self.height + + def draw(self): + offset = self.size + self.gap + text_height = self.paragraph.height + top = self.height + self.paragraph.drawOn(self.canv, offset, top - text_height) + middle = top - self.style.leading / 2 + self.placeholder.canv = self.canv + self.canv.saveState() + self.canv.translate(0, middle - self.size / 2) + self.placeholder.draw() + self.canv.restoreState() diff --git a/cloudexit/report/provenance.py b/cloudexit/report/provenance.py new file mode 100644 index 0000000..e5dc208 --- /dev/null +++ b/cloudexit/report/provenance.py @@ -0,0 +1,80 @@ +import re +from datetime import timezone +from xml.sax.saxutils import escape + +from reportlab.pdfbase.pdfdoc import XMP + +CLOUDEXIT_NS = "https://cloudexit.escapecloud.io/ns/report/1.0/" +RDF_NS = "http://www.w3.org/1999/02/22-rdf-syntax-ns#" +# XML 1.0 has no escape for these, so a name carrying one would break the packet. +XML_INVALID = re.compile( + "[\x00-\x08\x0b\x0c\x0e-\x1f" + chr(0xFFFE) + chr(0xFFFF) + "]" +) + + +def _utc(value): + return value.astimezone(timezone.utc) + + +def _xml(value): + return escape(XML_INVALID.sub("", str(value if value is not None else ""))) + + +def attach_metadata(canvas, provenance): + generated_at = _utc(provenance.generated_at) + # The Info dict dates follow the generation time, not the render clock. + canvas.setDateFormatter( + lambda *_local: generated_at.strftime("D:%Y%m%d%H%M%S+00'00'") + ) + # Bytes, because ReportLab would encode a str as PDFDocEncoding, which has + # no BOM and no non-Latin text; XMP is UTF-8. + xmp = XMP(creator=lambda document: xmp_packet(document.info, provenance).encode()) + # An empty filter list keeps the packet uncompressed, so tools that scan + # the raw file for XMP still find it. + xmp.filters = () + canvas._doc.Catalog.Metadata = xmp + + +def xmp_packet(info, provenance): + generated_at = _utc(provenance.generated_at) + stamp = generated_at.strftime("%Y-%m-%dT%H:%M:%SZ") + fields = { + "RunID": provenance.run_id, + "CollectorVersion": provenance.collector_version, + "CatalogueVersion": provenance.catalogue_version, + "CatalogueSHA256": provenance.catalogue_sha256, + "Uploaded": str(provenance.uploaded).lower(), + "GeneratedAt": generated_at.isoformat(), + } + custom = "".join( + f"{_xml(value)}" + for field, value in fields.items() + if value + ) + return ( + '' + '' + f'' + '' + "application/pdf" + "" + f'{_xml(info.title)}' + "" + "" + f'{_xml(info.subject)}' + "" + f"{_xml(info.keywords)}" + f"{_xml(info.creator)}" + f"{stamp}" + f"{stamp}" + f"{stamp}" + f"{custom}" + "" + "" + "" + '' + ) diff --git a/cloudexit/report/sections.py b/cloudexit/report/sections.py new file mode 100644 index 0000000..56c155d --- /dev/null +++ b/cloudexit/report/sections.py @@ -0,0 +1,755 @@ +from xml.sax.saxutils import escape, quoteattr + +from reportlab.graphics.shapes import Drawing, Rect, String +from reportlab.lib.colors import HexColor, white +from reportlab.lib.enums import TA_RIGHT +from reportlab.lib.units import cm +from reportlab.pdfbase.pdfmetrics import stringWidth +from reportlab.platypus import ( + Flowable, + ListFlowable, + ListItem, + Paragraph, + Spacer, + Table, + TableStyle, +) + +from .brief_summary import cost_finding +from .flow import section +from .fonts import FONT, FONT_BOLD, FONT_MONO +from .placeholder import LabelWithPlaceholder +from .helpers import ( + cost_problem, + format_amount, + format_change, + format_money, + severity_label, + strategy_definition, + strategy_name, +) +from .palette import ( + HAIRLINE, + HOTSPOT, + HOTSPOT_BACKGROUND, + HOTSPOT_COUNT, + INK, + MUTED, + RULE, + SEVERITIES, + SEVERITY_COLORS, + SEVERITY_PIPS, +) +from .table_style import SECONDARY, data_table, header_cell, summary_strip, text_style + +ALT_BODY = text_style("AltBody", 8.5, 11) +ALT_BODY_BOLD = text_style("AltBodyBold", 8.5, 11, bold=True) +ALT_BODY_RIGHT = text_style("AltBodyRight", 8.5, 11, alignment=TA_RIGHT) +ALT_MUTED = text_style("AltMuted", 8.5, 11, MUTED) +ALT_SMALL = text_style("AltSmall", 7.5, 9.5, MUTED) +ALT_CAPTION = text_style("AltCaption", 7.5, 10, MUTED) +RES_TOTAL_RIGHT = text_style("ResTotalRight", 8.5, 11, bold=True, alignment=TA_RIGHT) +RES_GROUP = text_style("ResGroup", 7.6, 9.5, SECONDARY, bold=True) +RISK_CODE = text_style("RiskCode", 8, 11, SECONDARY).clone( + "RiskCode", fontName=FONT_MONO +) +RISK_MUTED_RIGHT = text_style("RiskMutedRight", 8.5, 11, MUTED, alignment=TA_RIGHT) +GLOSSARY_TERM_WIDTH = 4.2 * cm +RISK_BAR_MIN_SEGMENT = 62 +ALT_EXAMPLES = 3 +PRODUCT_SITES = ( + ("escapecloud", "https://escapecloud.io"), + ("exitcloud", "https://exitcloud.io"), +) +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/" +REPOSITORY_URL = "https://github.com/escapecloud/cloudexit/" +LICENCE_URL = "https://github.com/escapecloud/cloudexit/blob/main/LICENSE" + + +def _text(value, style=ALT_BODY): + return Paragraph(escape(str(value)), style) + + +def _percent(part, total): + return f"{(part / total * 100) if total else 0:.0f} %" + + +def _count_phrase(b, key, n): + return b[f"{key}_one"] if n == 1 else b[f"{key}_many"].format(n=n) + + +def _no_rows_table(header, col_widths, appearance, message): + message = [_text(message, ALT_MUTED)] + [""] * (len(header) - 1) + return data_table( + [header, message], col_widths, appearance, [("SPAN", (0, 1), (-1, 1))] + ) + + +def _group_row(rows, extra, label, columns): + # A spanned label row that never ends up alone at the bottom of a page. + index = len(rows) + rows.append([_text(label, RES_GROUP)] + [""] * (columns - 1)) + extra.extend( + [ + ("SPAN", (0, index), (-1, index)), + ("TOPPADDING", (0, index), (-1, index), 9), + ("BOTTOMPADDING", (0, index), (-1, index), 2), + ("LINEBELOW", (0, index), (-1, index), 0.6, HexColor(RULE)), + ("NOSPLIT", (0, index), (-1, index + 1)), + ] + ) + + +def _type_name_cell(entry): + if not entry.icon: + return LabelWithPlaceholder( + entry.name, f"{escape(entry.name)}", ALT_BODY + ) + return Paragraph( + f' {escape(entry.name)}', + ALT_BODY, + ) + + +# Introduction +def scope_boundary(context, b): + scope = context.scope + counts = b["scope_counts"].format( + resources=_count_phrase(b, "scope_resources", context.total_resources), + types=_count_phrase(b, "scope_types", len(context.resources)), + ) + values = { + "counts": f"{counts}", + "regions": _count_phrase(b, "scope_regions", len(context.regions)), + } + if scope.kind == "azure": + return b["scope_boundary_azure"].format( + resource_group=f"{escape(scope.resource_group or b['ev_dash'])}", + **values, + ) + if scope.kind == "aws": + return b["scope_boundary_aws"].format( + region=f"{escape(scope.region or b['ev_dash'])}", **values + ) + return b["scope_boundary_tfstate"].format( + file=f"{escape(scope.file_name or b['ev_dash'])}", + provider=escape(context.provider), + **values, + ) + + +def scope_strategy(context, b): + strategy = f"{escape(strategy_name(context.strategy_code, b))}" + definition = strategy_definition(context.strategy_code, b) + if not definition: + return b["scope_strategy_plain"].format(strategy=strategy) + return b["scope_strategy"].format(strategy=strategy, definition=escape(definition)) + + +def _method_list(b, styles): + items = [ + ListItem( + Paragraph(f"{escape(name)} {escape(text)}", styles["ContentStyle"]), + leftIndent=14, + ) + for name, text in b["es_method_phases"] + ] + return ListFlowable( + items, + bulletType="bullet", + start="•", + bulletFontName=FONT, + bulletFontSize=9, + leftIndent=14, + ) + + +def introduction(context, styles, b): + body = styles["ContentStyle"] + return [ + Paragraph(b["es_heading_introduction"], styles["Heading1"]), + Paragraph(b["es_heading_purpose"], styles["Heading2"]), + Paragraph(b["es_purpose_block"], body), + Paragraph(b["es_heading_scope"], styles["Heading2"]), + Paragraph(scope_boundary(context, b), body), + Spacer(1, 6), + Paragraph(scope_strategy(context, b), body), + Paragraph(b["es_heading_method"], styles["Heading2"]), + Paragraph(b["es_method_lead"], body), + Paragraph(b["es_method_phases_lead"], body), + _method_list(b, styles), + Spacer(1, 6), + Paragraph(b["es_method_catalogue"], body), + Paragraph(b["es_heading_limitations"], styles["Heading2"]), + Paragraph(b["err_limitations"], body), + Spacer(1, 6), + Paragraph(b["err_limitations_data"], body), + ] + + +# Costs +def _cost_table(context, b): + currency = context.costs[0].currency + appearance = context.appearance + rows = [ + [ + b["cost_th_month"], + header_cell( + b["cost_th_cost"].format(currency=escape(currency)), + appearance, + TA_RIGHT, + ), + header_cell(b["cost_th_change"], appearance, TA_RIGHT), + ] + ] + previous = None + for entry in context.costs: + month = entry.month + if not entry.final: + month += f' · {escape(b["cost_provisional"])}' + rows.append( + [ + Paragraph(month, ALT_BODY), + _text(format_amount(entry.amount), ALT_BODY_RIGHT), + _text(format_change(previous, entry.amount), ALT_BODY_RIGHT), + ] + ) + previous = entry.amount + caption = b["cost_caption"].format(currency=escape(currency)) + if not all(entry.final for entry in context.costs): + caption += " " + b["cost_caption_provisional"] + total_row = len(rows) + rows.append( + [ + _text(b["cost_total"], ALT_BODY_BOLD), + _text( + format_amount(sum(entry.amount for entry in context.costs)), + RES_TOTAL_RIGHT, + ), + "", + ] + ) + return [ + data_table( + rows, + [5.0 * cm, 5.5 * cm, 5.5 * cm], + appearance, + [ + ("LINEABOVE", (0, total_row), (-1, total_row), 0.8, HexColor(INK)), + ("NOSPLIT", (0, total_row - 1), (-1, total_row)), + ], + ), + Spacer(1, 6), + Paragraph(caption, ALT_CAPTION), + ] + + +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, closing) + entries = context.costs + total = sum(entry.amount for entry in entries) + latest = entries[-1] + before = entries[-2] if len(entries) > 1 else None + currency = latest.currency + items = [ + summary_strip( + [ + (b["cost_stat_total"], format_money(total, currency), INK), + ( + b["cost_stat_average"], + format_money(total / len(entries), currency), + INK, + ), + ( + b["cost_stat_latest"].format(month=latest.month), + format_money(latest.amount, currency), + INK, + ), + ( + b["cost_stat_change"].format( + month=before.month if before else b["ev_dash"] + ), + format_change(before.amount if before else None, latest.amount), + INK, + ), + ] + ), + Spacer(1, 12), + *_cost_table(context, b), + Spacer(1, 12), + Paragraph(b["es_cost_note"], styles["ContentStyle"]), + *closing, + ] + return section(head, items) + + +# Resource inventory +def resource_inventory(context, styles, b): + appearance = context.appearance + head = [ + Paragraph(b["es_heading_resource_inventory"], styles["Heading1"]), + Paragraph(b["es_res_lead"], styles["ContentStyle"]), + Spacer(1, 12), + ] + header = [ + b["res_th_resource_type"], + header_cell(b["res_th_regions"], appearance, TA_RIGHT), + header_cell(b["res_th_resources"], appearance, TA_RIGHT), + header_cell(b["res_th_share"], appearance, TA_RIGHT), + ] + widths = [9.4 * cm, 2.1 * cm, 2.3 * cm, 2.2 * cm] + if not context.resources: + return section( + head, + [_no_rows_table(header, widths, appearance, b["tbl_res_no_resources"])], + ) + + total = context.total_resources + rows = [header] + for entry in context.resources: + rows.append( + [ + _type_name_cell(entry), + _text(len(entry.locations), ALT_BODY_RIGHT), + _text(entry.resources, ALT_BODY_RIGHT), + _text(_percent(entry.resources, total), ALT_BODY_RIGHT), + ] + ) + total_row = len(rows) + rows.append( + [ + _text(b["res_total"], ALT_BODY_BOLD), + _text(len(context.regions), RES_TOTAL_RIGHT), + _text(total, RES_TOTAL_RIGHT), + _text(_percent(total, total), RES_TOTAL_RIGHT), + ] + ) + table = data_table( + rows, + widths, + appearance, + [ + ("LINEABOVE", (0, total_row), (-1, total_row), 0.8, HexColor(INK)), + ("NOSPLIT", (0, total_row - 1), (-1, total_row)), + ], + ) + caption = b["es_res_caption"] + skipped = context.scope.not_assessed + if skipped: + key = "res_not_assessed_one" if skipped == 1 else "res_not_assessed_many" + caption = f"{caption} {b[key].format(n=skipped)}" + return section( + head, + [ + summary_strip( + [ + (b["res_stat_resources"], str(total), INK), + (b["res_stat_types"], str(len(context.resources)), INK), + (b["res_stat_regions"], str(len(context.regions)), INK), + ] + ), + Spacer(1, 12), + table, + Spacer(1, 6), + Paragraph(caption, ALT_CAPTION), + ], + ) + + +# Risk inventory +class SeverityBadge(Flowable): + def __init__(self, severity, label): + Flowable.__init__(self) + self.severity = severity + self.label = label + self.width = 2.3 * cm + self.height = 10 + + def wrap(self, availWidth, availHeight): + return min(self.width, availWidth), self.height + + def draw(self): + canvas = self.canv + size, gap = 4.6, 1.4 + color = HexColor(SEVERITY_COLORS.get(self.severity, MUTED)) + pips = SEVERITY_PIPS.get(self.severity, 0) + y = (self.height - size) / 2 + for index in range(4): + x = index * (size + gap) + if index < pips: + canvas.setFillColor(color) + canvas.rect(x, y, size, size, stroke=0, fill=1) + else: + canvas.setStrokeColor(HexColor(RULE)) + canvas.setLineWidth(0.6) + canvas.rect(x, y, size, size, stroke=1, fill=0) + canvas.setFillColor(HexColor(INK)) + canvas.setFont(FONT, 8.2) + canvas.drawString(4 * size + 3 * gap + 4, 2, self.label) + + +def _segment_widths(counts, available, minimum): + # Proportional, but no non-empty segment narrower than the minimum. + fixed = {} + while True: + free = [key for key in counts if key not in fixed] + if not free: + return fixed + rest = available - sum(fixed.values()) + total = sum(counts[key] for key in free) + widths = {key: rest * counts[key] / total for key in free} + small = [key for key, width in widths.items() if width < minimum] + if not small: + return {**fixed, **widths} + for key in small: + fixed[key] = minimum + + +def _severity_bar(risks, b): + width, bar_height, gap = 16 * cm, 15, 1.5 + counts = { + severity: sum(1 for risk in risks if risk.severity == severity) + for severity in SEVERITIES + } + counts = {severity: count for severity, count in counts.items() if count} + drawing = Drawing(width, 36) + if counts: + widths = _segment_widths( + counts, width - gap * (len(counts) - 1), RISK_BAR_MIN_SEGMENT + ) + x = 0 + for severity, count in counts.items(): + segment = widths[severity] + drawing.add( + Rect( + x, + 21, + segment, + bar_height, + fillColor=HexColor(SEVERITY_COLORS[severity]), + strokeColor=None, + ) + ) + label = f"{count} {severity_label(severity, b)}" + if stringWidth(label, FONT_BOLD, 7.8) + 8 > segment: + label = str(count) + drawing.add( + String( + x + segment / 2, + 25.7, + label, + fontName=FONT_BOLD, + fontSize=7.8, + fillColor=HexColor(INK) if severity == "medium" else white, + textAnchor="middle", + ) + ) + x += segment + gap + drawing.add( + String( + 0, + 8, + b["risk_bar_caption"], + fontName=FONT, + fontSize=7, + fillColor=HexColor(MUTED), + ) + ) + return drawing + + +def _risk_overview(context, b): + risks = context.risks + scope = {entry.resource_type_id for entry in context.resources} + affected = {type_id for risk in risks for type_id in risk.resource_type_ids} + return [ + summary_strip( + [ + (b["risk_stat_unique"], str(len(risks)), INK), + ( + b["risk_stat_critical_high"], + str( + sum( + 1 for risk in risks if risk.severity in ("critical", "high") + ) + ), + SEVERITY_COLORS["high"], + ), + ( + b["risk_stat_affected"], + b["risk_stat_affected_value"].format( + a=len(affected & scope), n=len(scope) + ), + INK, + ), + ( + b["risk_stat_scope_wide"], + str(sum(1 for risk in risks if risk.scope_wide)), + INK, + ), + ] + ), + Spacer(1, 12), + _severity_bar(risks, b), + Spacer(1, 12), + ] + + +def risk_inventory(context, styles, b): + appearance = context.appearance + head = [ + Paragraph(b["es_heading_risk_inventory"], styles["Heading1"]), + Paragraph(b["es_risk_lead"], styles["ContentStyle"]), + Spacer(1, 12), + ] + header = [ + b["risk_th_code"], + b["risk_th_risk"], + b["risk_th_severity"], + header_cell(b["risk_th_affected"], appearance, TA_RIGHT), + ] + widths = [1.6 * cm, 9.5 * cm, 2.6 * cm, 2.3 * cm] + if not context.risks: + return section( + head, [_no_rows_table(header, widths, appearance, b["tbl_risk_no_risks"])] + ) + + rows = [header] + extra = [] + for severity in SEVERITIES: + members = [risk for risk in context.risks if risk.severity == severity] + if not members: + continue + _group_row( + rows, + extra, + b["risk_group"].format( + severity=severity_label(severity, b), n=len(members) + ), + len(header), + ) + for risk in members: + rows.append( + [ + _text(risk.code, RISK_CODE), + _text(risk.name, ALT_BODY_BOLD), + SeverityBadge(risk.severity, severity_label(risk.severity, b)), + ( + _text(b["risk_scope_wide"], RISK_MUTED_RIGHT) + if risk.scope_wide + else _text(len(risk.resource_type_ids), ALT_BODY_RIGHT) + ), + ] + ) + return section( + head, + [ + *_risk_overview(context, b), + data_table(rows, widths, appearance, extra), + Spacer(1, 6), + Paragraph(b["es_risk_caption"], ALT_CAPTION), + ], + ) + + +# Alternative technologies +def _examples_cell(entry, b): + cell = [] + for alt in entry.alternatives[:ALT_EXAMPLES]: + vendor = f' · {escape(alt.vendor or b["ev_dash"])}' + cell.append(Paragraph(f"{escape(alt.name)}{vendor}", ALT_BODY)) + flags = [ + b[key] + for key, present in ( + ("alt_flag_open_source", alt.open_source), + ("alt_flag_support_plan", alt.support_plan), + ) + if present + ] + if flags: + cell.append(Paragraph(" · ".join(flags), ALT_SMALL)) + if len(entry.alternatives) > ALT_EXAMPLES: + cell.append( + Paragraph( + b["alt_more"].format(k=len(entry.alternatives) - ALT_EXAMPLES), + ALT_SMALL, + ) + ) + return cell + + +def alternative_technologies(context, styles, b): + appearance = context.appearance + head = [ + Paragraph(b["es_heading_alternative_technologies"], styles["Heading1"]), + Paragraph( + b["es_alttech_lead"].format( + strategy=escape(strategy_name(context.strategy_code, b)) + ), + styles["ContentStyle"], + ), + Spacer(1, 8), + ] + header = [ + b["alt_th_resource_type"], + header_cell(b["alt_th_resources"], appearance, TA_RIGHT), + header_cell(b["alt_th_alternatives"], appearance, TA_RIGHT), + b["alt_th_examples"], + ] + widths = [4.6 * cm, 2.05 * cm, 2.15 * cm, 7.2 * cm] + if not context.alternatives: + return section( + head, [_no_rows_table(header, widths, appearance, b["alt_no_resources"])] + ) + + # Lock-in hot-spots first, then by resource count and name. + entries = sorted( + context.alternatives, + key=lambda entry: ( + bool(entry.alternatives), + -entry.resources, + entry.name.lower(), + ), + ) + hotspots = sum(1 for entry in entries if not entry.alternatives) + items = [] + if not any(entry.alternatives for entry in entries): + items += [Paragraph(b["alt_empty_set"], styles["ContentStyle"]), Spacer(1, 6)] + items += [ + summary_strip( + [ + ( + b["alt_stat_types"], + b["alt_stat_types_value"].format( + a=len(entries) - hotspots, n=len(entries) + ), + INK, + ), + ( + b["alt_stat_alternatives"], + str(sum(len(entry.alternatives) for entry in entries)), + INK, + ), + (b["alt_stat_hotspots"], str(hotspots), HOTSPOT_COUNT), + ] + ), + Spacer(1, 12), + ] + + rows = [header] + extra = [] + for entry in entries: + index = len(rows) + row = [ + _type_name_cell(entry), + _text(entry.resources, ALT_BODY_RIGHT), + _text(len(entry.alternatives), ALT_BODY_RIGHT), + ] + if entry.alternatives: + row.append(_examples_cell(entry, b)) + else: + row.append(Paragraph(b["alt_hotspot_short"], ALT_BODY)) + extra.extend( + [ + ( + "BACKGROUND", + (0, index), + (-1, index), + HexColor(HOTSPOT_BACKGROUND), + ), + ("LINEBEFORE", (0, index), (0, index), 3, HexColor(HOTSPOT)), + ] + ) + rows.append(row) + items += [ + data_table(rows, widths, appearance, extra), + Spacer(1, 6), + Paragraph(b["es_alttech_caption"], ALT_CAPTION), + ] + return section(head, items) + + +# Appendices +def severity_levels(context, styles, b): + rows = [[b["risk_th_severity"], b["es_severity_th_meaning"]]] + for severity in SEVERITIES: + rows.append( + [ + SeverityBadge(severity, severity_label(severity, b)), + _text(b[f"es_severity_{severity}"], ALT_BODY), + ] + ) + return [ + Paragraph(b["es_heading_severity"], styles["Heading1"]), + Paragraph(b["es_severity_lead"], styles["ContentStyle"]), + Paragraph(b["es_severity_note"], styles["ContentStyle"]), + Spacer(1, 10), + data_table(rows, [3.6 * cm, 12.4 * cm], context.appearance), + ] + + +def glossary_entries(context, b): + # The AWS entry is only listed for AWS runs. + return [ + entry + for entry in b["glossary"] + if entry["id"] != "aws" or context.cloud_service_provider == 2 + ] + + +def glossary(context, styles, b): + entries = sorted( + glossary_entries(context, b), key=lambda entry: entry["term"].lower() + ) + table = Table( + [ + [_text(entry["term"], ALT_BODY_BOLD), _text(entry["definition"])] + for entry in entries + ], + colWidths=[GLOSSARY_TERM_WIDTH, 16 * cm - GLOSSARY_TERM_WIDTH], + hAlign="LEFT", + ) + table.setStyle( + TableStyle( + [ + ( + "LINEABOVE", + (0, 0), + (-1, 0), + 1, + HexColor(context.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 [ + Paragraph(b["es_heading_glossary"], styles["Heading1"]), + Paragraph(b["es_glossary_block"], styles["ContentStyle"]), + Spacer(1, 12), + table, + ] diff --git a/cloudexit/report/styles.py b/cloudexit/report/styles.py new file mode 100644 index 0000000..23db8f2 --- /dev/null +++ b/cloudexit/report/styles.py @@ -0,0 +1,80 @@ +from reportlab.lib import colors +from reportlab.lib.colors import HexColor +from reportlab.lib.enums import TA_CENTER, TA_LEFT +from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet + +from .fonts import FONT, FONT_BOLD +from .palette import DEFAULT_APPEARANCE + + +def load_styles(appearance=DEFAULT_APPEARANCE): + styles = getSampleStyleSheet() + + for name in ("Normal", "BodyText", "Bullet"): + styles[name].fontName = FONT + for name in ("Title", "Heading1", "Heading2", "Heading3"): + styles[name].fontName = FONT_BOLD + + # A heading never ends a page on its own. + for name in ("Heading1", "Heading2", "Heading3"): + styles[name].keepWithNext = 1 + + styles["Heading1"].leading = 1.5 * styles["Heading1"].fontSize + # Space above a chapter that follows another section on the same page; + # ReportLab drops it at the top of a page. + styles["Heading1"].spaceBefore = 18 + styles["Heading1"].textColor = HexColor(appearance.heading1_color) + styles["Heading2"].leading = 1.5 * styles["Heading2"].fontSize + styles["Heading2"].textColor = HexColor(appearance.heading2_color) + styles["Heading3"].leading = 1.5 * styles["Heading3"].fontSize + styles["Heading3"].textColor = HexColor(appearance.heading2_color) + + content_style = ParagraphStyle( + "ContentStyle", + parent=styles["BodyText"], + fontName=FONT, + fontSize=9.5, + leading=14.5, + # Left-aligned: justified text leaves uneven gaps in a 16 cm column. + alignment=TA_LEFT, + firstLineIndent=0, + # No single line of a paragraph alone at the top or foot of a page. + allowWidows=0, + allowOrphans=0, + ) + reference_style = ParagraphStyle( + "ReferenceStyle", + parent=styles["BodyText"], + fontName=FONT, + fontSize=8, + leading=12, + alignment=TA_CENTER, + textColor=colors.grey, + firstLineIndent=0, + ) + tableheader_style = ParagraphStyle( + "TableHeader", + parent=styles["BodyText"], + fontName=FONT_BOLD, + fontSize=9.5, + leading=14.5, + alignment=TA_LEFT, + firstLineIndent=0, + textColor=colors.HexColor("#FFFFFF"), + ) + table_style = ParagraphStyle( + "TableStyle", + parent=styles["BodyText"], + fontName=FONT, + fontSize=8, + leading=12, + alignment=TA_LEFT, + firstLineIndent=0, + ) + + styles.add(content_style) + styles.add(reference_style) + styles.add(tableheader_style) + styles.add(table_style) + + return styles diff --git a/cloudexit/report/table_style.py b/cloudexit/report/table_style.py new file mode 100644 index 0000000..5333dd7 --- /dev/null +++ b/cloudexit/report/table_style.py @@ -0,0 +1,198 @@ +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 +from reportlab.lib.units import cm +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 + + +def mix_with_white(color, amount): + return HexColor(tint(color, amount)) + + +def text_style(name, size, leading, color=INK, bold=False, alignment=TA_LEFT): + return ParagraphStyle( + name, + fontName=FONT_BOLD if bold else FONT, + fontSize=size, + leading=leading, + textColor=HexColor(color), + alignment=alignment, + ) + + +def header_cell(text, appearance, alignment=TA_LEFT, split_long_words=True): + style = text_style( + "DataTableHeader", + 7.8, + 9.5, + appearance.header_row_color, + bold=True, + alignment=alignment, + ) + # In narrow columns a word slightly wider than the cell runs into the + # padding instead of breaking mid-word. + style.splitLongWords = int(split_long_words) + return Paragraph(text, style) + + +def data_table(rows, col_widths, appearance, extra=()): + header_color = HexColor(appearance.header_row_color) + rows = [ + [ + header_cell(cell, appearance) if isinstance(cell, str) else cell + for cell in rows[0] + ], + *rows[1:], + ] + table = Table(rows, colWidths=col_widths, repeatRows=1, hAlign="LEFT") + table.setStyle( + TableStyle( + [ + ( + "BACKGROUND", + (0, 0), + (-1, 0), + mix_with_white(appearance.header_row_color, 0.9), + ), + ("LINEBELOW", (0, 0), (-1, 0), 1, header_color), + ("LINEBELOW", (0, 1), (-1, -1), 0.4, HexColor(HAIRLINE)), + ("LEFTPADDING", (0, 0), (-1, -1), 5), + ("RIGHTPADDING", (0, 0), (-1, -1), 5), + ("TOPPADDING", (0, 0), (-1, -1), 4.5), + ("BOTTOMPADDING", (0, 0), (-1, -1), 4.5), + ("VALIGN", (0, 0), (-1, -1), "TOP"), + ("VALIGN", (0, 0), (-1, 0), "BOTTOM"), + *extra, + ] + ) + ) + return table + + +KV_LABEL = text_style("KeyValueLabel", 8.2, 10.5, SECONDARY, bold=True) +KV_VALUE = text_style("KeyValueValue", 8.2, 10.5) +KV_MONO = ParagraphStyle( + "KeyValueMono", parent=KV_VALUE, fontName=FONT_MONO, fontSize=7.8 +) +FOOTNOTE = text_style("Footnote", 7.5, 9.5, MUTED).clone( + "Footnote", fontName=FONT_ITALIC +) + + +def mono(text): + # Inline monospace for identifiers inside a key-value value. + return f'{text}' + + +def key_value_table(rows, appearance, label_width=4.6 * cm): + # rows: (label, value); a string value is escaped markup-free text, any + # other value (a Paragraph) is used as it is. + table = Table( + [ + [ + Paragraph(escape(label), KV_LABEL), + ( + Paragraph(escape(value), KV_VALUE) + if isinstance(value, str) + else value + ), + ] + for label, value in rows + ], + colWidths=[label_width, 16 * cm - label_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 + + +STAT_LABEL = text_style("AltStatLabel", 7.2, 9, MUTED, bold=True) + + +def summary_strip(stats, widths=None): + # stats: (label, value, colour); values are plain text. Equal columns + # unless widths are given. + cells = [ + [ + Paragraph(escape(str(label).upper()), STAT_LABEL), + Paragraph( + escape(str(value)), + text_style("AltStatValue", 15, 18, color, bold=True), + ), + ] + for label, value, color in stats + ] + strip = Table( + [cells], + colWidths=widths or [16 * cm / len(stats)] * len(stats), + hAlign="LEFT", + ) + strip.setStyle( + TableStyle( + [ + ("LINEABOVE", (0, 0), (-1, 0), 0.8, HexColor(INK)), + ("VALIGN", (0, 0), (-1, -1), "TOP"), + ("LEFTPADDING", (0, 0), (-1, -1), 0), + ("RIGHTPADDING", (0, 0), (-1, -1), 6), + ("TOPPADDING", (0, 0), (-1, -1), 6), + ("BOTTOMPADDING", (0, 0), (-1, -1), 0), + ] + ) + ) + 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 new file mode 100644 index 0000000..d9db971 --- /dev/null +++ b/cloudexit/report/wording.py @@ -0,0 +1,1152 @@ +BLOCKS = { + "language_name": "English", + "pdf_subject": "{document_id} · Basic assessment collected by cloudexit {version}", + "ev_language": "Language", + "ev_dash": "–", + "ev_document_information_heading": "Document Information", + "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", + "ev_generated_at": "Generated at", + "ev_cloud_provider": "Cloud Provider", + "ev_account_id": "Account ID", + "ev_tenant_id": "Tenant ID", + "ev_subscription_id": "Subscription ID", + "ev_resource_group": "Resource Group", + "ev_region": "Region", + "ev_file": "State File", + "ev_state": "State", + "ev_subscriptions": "Subscriptions", + "ev_resource_groups": "Resource Groups", + "ev_regions": "Regions", + "ev_not_determinable": "Not determinable from the state", + "ev_not_assessed": "Not Assessed", + "ev_not_assessed_other_one": "1 resource of another provider", + "ev_not_assessed_other_many": "{n} resources of other providers", + "ev_not_assessed_unmapped_one": "1 resource of a type not in the data catalogue", + "ev_not_assessed_unmapped_many": ( + "{n} resources of types not in the data catalogue" + ), + "res_not_assessed_one": ( + "1 resource in the state file was not assessed (see page 2)." + ), + "res_not_assessed_many": ( + "{n} resources in the state file were not assessed (see page 2)." + ), + "html_inv_not_assessed_one": ( + "1 resource in the state file was not assessed (see {about})" + ), + "html_inv_not_assessed_many": ( + "{n} resources in the state file were not assessed (see {about})" + ), + "ev_source_label": "Source", + "ev_source_live": "Provider APIs", + "ev_source_tfstate": "Terraform State", + "ev_source_value": "{source} (data as of {completed})", + "ev_catalogue_label": "Data Catalogue", + "ev_catalogue_value": "{version} · (sha256 {sha})", + "ev_catalogue_unknown": "Unknown (the dataset records no version)", + "ev_run_id": "Run ID", + "ev_platform_label": "Platform", + "ev_platform_none": "Self-collected data: no platform evaluated this report.", + "ev_platform_uploaded": "Uploaded to the platform", + "ev_platform_failed": "Upload failed: {reason}.", + "ev_platform_failed_no_reason": "Upload failed.", + "ev_platform_kept": "The run was kept for a later upload.", + "ev_stored_footnote": ( + "This document was generated when run {run} completed and is stored " + "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", + "cover_exit_strategy": "Exit Strategy", + "cover_run": "Assessment Run", + "cover_run_value": "{run} · data as of {completed}", + "cover_generated": "Generated", + "cover_collected_with": "Collected by cloudexit {version} on {date}", + "footer_page_of": "Page {page} of {total}", + "footer_edition": "Community Edition", + "strategy_name_repatriation": "Repatriation to On-Premises", + "strategy_name_hybrid_cloud": "Hybrid Cloud Adoption", + "strategy_name_alternate_cloud": "Migration to Alternate Cloud", + "strategy_def_repatriation": ( + "moving all resources and data back to a data centre or on-premises " + "environment" + ), + "strategy_def_hybrid_cloud": ( + "a mixture of on-premises and alternative cloud deployment models" + ), + "strategy_def_alternate_cloud": ( + "transitioning to a different cloud provider from the current one" + ), + "scope_counts": "{resources} of {types}", + "scope_resources_one": "1 resource", + "scope_resources_many": "{n} resources", + "scope_types_one": "1 type", + "scope_types_many": "{n} types", + "scope_regions_one": "1 region", + "scope_regions_many": "{n} regions", + "scope_boundary_azure": ( + "This assessment covers {counts} in Microsoft Azure resource group " + "{resource_group} ({regions}). Resources outside this resource " + "group are not assessed." + ), + "scope_boundary_aws": ( + "This assessment covers {counts} in AWS region {region}. Other " + "regions are not assessed." + ), + "scope_boundary_tfstate": ( + "This assessment covers {counts} in the Terraform state file {file} " + "({provider}, {regions}). Resources that Terraform does not manage " + "are not assessed." + ), + "scope_strategy": ( + "The scope is assessed for the {strategy} exit strategy: {definition}." + ), + "scope_strategy_plain": "The scope is assessed for the {strategy} exit strategy.", + "es_heading_introduction": "Introduction", + "es_heading_purpose": "Purpose", + "es_heading_scope": "Scope", + "es_heading_method": "Method", + "es_heading_limitations": "Limitations", + "es_purpose_block": ( + "This Executive Summary gives decision-makers an overview of the " + "cloud exit assessment: the cloud resources in use and their costs, " + "the exit risks identified and the available alternative " + "technologies for the defined scope and the selected exit strategy." + ), + "es_method_lead": ( + "The assessment was collected and evaluated locally with cloudexit. " + "It combines automated discovery, structured data " + "processing and rule-based evaluation, so that results are " + "consistent and repeatable across cloud providers." + ), + "es_method_phases_lead": ( + "Once the scope is defined, the assessment runs three phases:" + ), + "es_method_phases": [ + ( + "Discovery", + "identifies the active resources in the scope and classifies them by type, region and cost.", + ), + ( + "Risk assessment", + "evaluates exit risks by comparing the discovered resource types with the availability and enterprise support of alternative technologies.", + ), + ( + "Reporting", + "compiles the results into this Executive Summary.", + ), + ], + "es_method_catalogue": ( + "Risks and alternatives are taken from the data catalogue version " + "recorded on page 2, so the results of this run can be reproduced." + ), + "err_limitations": ( + "This report gives directional guidance on the selected exit " + "strategy and its risks. It is not a migration roadmap and not an " + "audit. Business or technical circumstances specific to the " + "organisation may require further validation." + ), + "err_limitations_data": ( + "Results reflect the data of the run shown on page 2. The analysis " + "assumes that the discovered resources are correctly configured and " + "accessible. Dependencies outside the defined scope, such as " + "on-premises systems or other cloud accounts, are not covered; " + "results are indicative, not exhaustive." + ), + "bs_heading": "Brief Summary", + "bs_lead": ( + "This summary condenses the findings of this report for {scope} " + "({provider}), assessed for the {strategy} exit strategy." + ), + "bs_scope_tfstate": "the Terraform state file", + "bs_outlook_good": ( + "No critical or high risks were found, and every resource type has " + "at least one alternative." + ), + "bs_outlook_attention": "{parts} need attention before an exit.", + "bs_outlook_attention_one": "{parts} needs attention before an exit.", + "bs_part_risks_one": "1 critical or high risk", + "bs_part_risks_many": "{n} critical or high risks", + "bs_part_hotspots_one": "1 resource type without an alternative", + "bs_part_hotspots_many": "{n} resource types without an alternative", + "bs_and": " and ", + "bs_more": " and {k} more", + "bs_stat_costs": "Costs, 6 Months", + "bs_findings_heading": "Key Findings", + "bs_th_area": "Area", + "bs_th_finding": "Finding", + "bs_th_details": "Details", + "bs_area_risks": "Risks", + "bs_area_alternatives": "Alternatives", + "bs_area_costs": "Costs", + "bs_area_footprint": "Footprint", + "bs_find_risks_none": "No exit risks were identified.", + "bs_find_risks_one": "1 unique exit risk, {severity}: {code} {name}.", + "bs_find_risks_many": "{n} unique exit risks: {counts}. Most severe: {code} {name} ({severity}).", + "bs_find_risks_no_severe": "No critical or high risks.", + "bs_find_alts_all": "All {n} resource types have at least one alternative ({total} in total); no lock-in hot-spots.", + "bs_find_alts_all_one": "The only resource type has at least one alternative ({total} in total); no lock-in hot-spots.", + "bs_find_alts_some": "{a} of {n} resource types have at least one alternative ({total} in total).", + "bs_find_alts_some_one": "1 of {n} resource types has at least one alternative ({total} in total).", + "bs_find_alts_none": "No resource type has an alternative in the data catalogue: {types}.", + "bs_find_alts_missing": "No alternative in the data catalogue: {types}.", + "bs_find_costs": "{total} over six months ({average} per month on average).", + "bs_find_costs_latest": "The latest month ({month}) was {latest}, {change} on {previous}.", + "bs_find_costs_latest_only": "The latest month ({month}) was {latest}.", + "bs_find_costs_rising": "Costs have risen for {k} consecutive months.", + "bs_find_costs_falling": "Costs have fallen for {k} consecutive months.", + "bs_find_footprint": "{resources} of {types} in {regions}.", + "bs_count_resources_one": "1 resource", + "bs_count_resources_many": "{n} resources", + "bs_count_types_one": "1 type", + "bs_count_types_many": "{n} types", + "bs_count_regions_one": "1 region", + "bs_count_regions_many": "{n} regions", + "bs_caption": "Amber marker = critical or high risks or resource types without an alternative. All figures are taken from the sections of this report and refer to run {version} (data as of {completed}); the summary adds no findings of its own.", + "es_heading_costs_overview": "Costs Overview", + "tbl_cost_no_data": "No cost data available for this assessment.", + "cost_stat_total": "Total, 6 Months", + "cost_stat_average": "Monthly Average", + "cost_stat_latest": "Latest Month ({month})", + "cost_stat_change": "Change vs. {month}", + "cost_th_month": "Month", + "cost_th_cost": "Cost ({currency})", + "cost_th_change": "vs. Previous Month", + "cost_total": "Total (6 months)", + "cost_caption": "Source: provider billing data for the scope, currency {currency}. – = no cost in the previous month.", + "cost_mixed_currency": "The cost data contains more than one currency and is not totalled.", + "cost_provisional": "provisional", + "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 " + "appears once; regions are counted, not repeated." + ), + "tbl_res_no_resources": ( + "There are no resources to be found in the resource inventory." + ), + "res_stat_resources": "Resources", + "res_stat_types": "Resource Types", + "res_stat_regions": "Regions", + "res_th_resource_type": "Resource Type", + "res_th_regions": "Regions", + "res_th_resources": "Resources", + "res_th_share": "Share", + "res_total": "Total", + "es_res_caption": ( + "Share = resources of the type as a share of all resources in scope. " + "Region names as reported by the provider." + ), + "es_heading_risk_inventory": "Risk Inventory", + "es_risk_lead": ( + "Unique exit risks identified in the defined scope, ordered by " + "severity. Risk codes are stable across assessments, so a finding can " + "be followed from one run to the next." + ), + "tbl_risk_no_risks": "There are no risks identified in this assessment.", + "risk_sev_critical": "Critical", + "risk_sev_high": "High", + "risk_sev_medium": "Medium", + "risk_sev_low": "Low", + "risk_stat_unique": "Unique Risks", + "risk_stat_critical_high": "Critical + High", + "risk_stat_affected": "Resource Types Affected", + "risk_stat_affected_value": "{a} of {n}", + "risk_stat_scope_wide": "Scope-wide Risks", + "risk_bar_caption": ( + "Unique risks by severity · colour, pip count and label carry the " + "same meaning" + ), + "risk_th_code": "Code", + "risk_th_risk": "Risk", + "risk_th_severity": "Severity", + "risk_th_affected": "Affected Types", + "risk_scope_wide": "Scope-wide", + "risk_group": "{severity} · {n}", + "es_risk_caption": ( + "Affected Types = number of discovered resource types the risk applies " + "to. Severity levels: see the Risk Severity Levels chapter." + ), + "es_heading_alternative_technologies": "Alternative Technologies", + "es_alttech_lead": ( + "Replacement options for the discovered resource types under the " + "{strategy} exit strategy. Rows without an alternative are potential " + "lock-in hot-spots and are listed first." + ), + "alt_stat_types": "Types with Alternatives", + "alt_stat_types_value": "{a} of {n}", + "alt_stat_alternatives": "Alternatives", + "alt_stat_hotspots": "Lock-in Hot-spots", + "alt_th_resource_type": "Resource Type", + "alt_th_resources": "Resources", + "alt_th_alternatives": "Alternatives", + "alt_th_examples": "Examples", + "alt_hotspot_short": "No alternative in the data catalogue — lock-in hot-spot", + "alt_more": "+{k} more", + "alt_flag_open_source": "Open source", + "alt_flag_support_plan": "Enterprise support", + "alt_no_resources": "No resources were discovered in this assessment, so there are no alternatives to show.", + "alt_empty_set": "No alternative technologies were found for the discovered resource types.", + "es_alttech_caption": "Examples are listed alphabetically; this report does not rank the alternatives. Open source and enterprise support as recorded in the data catalogue.", + "es_heading_severity": "Risk Severity Levels", + "es_severity_lead": ( + "Severity expresses the expected impact of a risk on the effort and " + "complexity of an exit under the selected exit strategy. It is " + "defined for each risk in the risk catalogue and is the same in " + "every assessment in which the risk occurs. Where a risk depends on " + "a threshold, the threshold is stated in the risk description (for " + "example ‘>30 resources’)." + ), + "es_severity_note": ( + "Severities and thresholds are expert-based benchmarks; they are not " + "derived from an industry standard. Severity is not a probability " + "and does not mean that an exit is impossible." + ), + "es_severity_th_meaning": "Meaning", + "es_severity_critical": ( + "Major expected impact on the exit: large parts of the scope are " + "affected, or a significant redesign, rebuild or specialist effort " + "is likely. Should be addressed early in exit planning." + ), + "es_severity_high": ( + "Significant additional effort, cost or time for the exit; a " + "specific mitigation should be planned." + ), + "es_severity_medium": ( + "Moderate additional effort or complexity; manageable within normal " + "exit planning." + ), + "es_severity_low": ( + "Minor expected impact; usually handled during regular migration work." + ), + "es_heading_glossary": "Glossary", + "es_glossary_block": "Abbreviations and terms used in this summary.", + "glossary": [ + {"id": "aws", "term": "AWS", "definition": "Amazon Web Services"}, + { + "id": "data_catalogue", + "term": "Data catalogue", + "definition": "The catalogue of resource types, exit risks and alternative technologies used for this assessment; its version is recorded on page 2.", + }, + { + "id": "hotspot", + "term": "Lock-in hot-spot", + "definition": "A discovered resource type for which the data catalogue lists no alternative.", + }, + { + "id": "run", + "term": "Run", + "definition": "One completed assessment of the defined scope, identified by the run ID on page 2.", + }, + ], + "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.", + }, + ], + "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": ( + "EscapeCloud Community Edition - This report is provided 'As Is,' " + "without any warranty of any kind. EscapeCloud makes no warranty that " + "the information contained in this report is complete or error-free." + ), + "notices_generated": ( + "This report was generated by cloudexit from data collected with the " + "organisation's own credentials. Its content was not reviewed or " + "verified by EscapeCloud." + ), + "notices_purpose": ( + "It is provided for information and planning purposes. It is not " + "legal, financial or investment advice, an audit or an exit plan, and " + "it does not replace independent technical validation. The " + "organisation remains responsible for its own exit planning." + ), + "notices_figures": ( + "Costs are taken from the provider's billing data for the scope. " + "Results reflect the data of the run shown on page 2 and may change as " + "the environment changes. Alternatives are entries from the data " + "catalogue, not recommendations." + ), + "notices_trademarks_heading": "Trademarks", + "notices_trademark_own": ( + "EscapeCloud® is a registered trademark in the European Union and the " + "United States. The AGPL-3.0 licence applies solely to the cloudexit " + "software, not to the EscapeCloud name, logo, or brand assets. You may " + "not use EscapeCloud trademarks in connection with modified versions or " + "downstream distributions in any manner that implies endorsement, " + "affiliation, or origin with EscapeCloud." + ), + "notices_trademark_others": ( + "Other product and company names and logos are trademarks of their " + "respective owners; naming them does not imply endorsement or " + "affiliation." + ), + "notices_icons_aws": ( + "AWS service icons are taken from the {icons}; their use is subject to " + "the terms published there and in the {guidelines}. Amazon Web Services " + "and AWS are trademarks of Amazon.com, Inc. or its affiliates. This " + "report is not endorsed by or affiliated with Amazon Web Services." + ), + "notices_icons_azure": ( + "Azure service icons are taken from the {icons}; their use is subject " + "to Microsoft's icon terms published there. Microsoft and Azure are " + "trademarks of the Microsoft group of companies. This report is not " + "endorsed by or affiliated with Microsoft." + ), + "notices_label_aws_icons": "AWS Architecture Icons", + "notices_label_aws_guidelines": "AWS Trademark Guidelines", + "notices_label_azure_icons": "Azure Architecture Icons", + "notices_software_heading": "Software", + "notices_software": ( + "cloudexit is free software, licensed under the GNU Affero General " + "Public License v3.0 (AGPL-3.0). Source code: {repository_url}. " + "Licence: {licence_url}. The provider icons are not covered by this " + "licence." + ), + "notices_copyright": "© 2024–{year} EscapeCloud", + "html_help": "Help", + "html_help_aria": "Help: how to read this report", + "html_logo_link": "EscapeCloud website (opens in a new tab)", + "html_chip_self": "Self-collected", + "html_chip_self_title": "Collected and evaluated on this machine; not uploaded", + "html_chip_uploaded": "Uploaded", + "html_chip_uploaded_title": "Collected on this machine and uploaded to the platform", + "html_download": "Download", + "html_download_summary": "Executive Summary (PDF)", + "html_download_egress": "Egress & Data Landscape (PDF)", + "html_open_platform": "Open on the platform", + "html_nav_label": "Sections", + "html_nav_overview": "Overview", + "html_nav_scores": "Scores", + "html_nav_risks": "Risks", + "html_nav_about": "About This Report", + "html_lock_label": "Light and Enterprise", + "html_scope_label": "Scope", + "html_run_label": "Run", + "html_run_small": "run {run} · {source}", + "html_run_live": "provider APIs", + "html_run_tfstate": "Terraform state", + "html_scope_no_resources": "no resources found", + "html_scope_serial": "serial {serial}", + "html_catalogue_sha": "sha256 {sha}", + "html_risks_label": "Unique Risks", + "html_sev_aria": "Risks by severity: {counts}", + "html_costs_label": "Costs, Last 6 Months", + "html_costs_label_short": "Costs", + "html_costs_average": "{amount} per month on average", + "html_costs_none": "no costs in the last six months", + "html_costs_change": "{latest} vs. {previous}", + "html_costs_aria": "Monthly costs: {parts}", + "html_costs_aria_part": "{month} {amount}", + "html_costs_aria_provisional": "{month} {amount} provisional", + "html_costs_provisional": ( + "* {month} is provisional: the month was still accruing. Costs are " + "context only; they are not an exit-cost estimate." + ), + "html_costs_context": "Costs are context only; they are not an exit-cost estimate.", + "html_costs_tfstate_hint": ( + "Run cloudexit against the provider APIs to include the last six " + "months of costs." + ), + "html_months": [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", + ], + "html_scores_grade": "Exit Readiness Grade", + "html_scores_lock": "Vendor Lock-in Score", + "html_scores_pill": "Light & Enterprise", + "html_scores_scale": "of 100", + "html_scores_bands": ["F", "E", "D", "C", "B", "A"], + "html_scores_dims": ["Human", "Technology", "Operational"], + "html_scores_dim_value": "{dash} / 5", + "html_scores_grade_text": ( + "A grade from A to F for how ready this scope is for an exit, computed " + "from the risks in this report." + ), + "html_scores_lock_text": ( + "How easily each resource type can be replaced, in skills, technology " + "and operations, with the {alternatives} rated and ranked." + ), + "html_scores_lock_text_plain": ( + "How easily each resource type can be replaced, in skills, technology " + "and operations, with the alternatives rated and ranked." + ), + "html_scores_on_platform": "For this run it is on the platform.", + "html_hint_title": "EscapeCloud Light & Enterprise", + "html_hint_text": ( + "EscapeCloud's scoring engine offers several scoring methods to assess " + "how feasible an exit is, and tracks the continuously changing vendor " + "landscape of alternative technologies." + ), + "html_site_exitcloud": "exitcloud.io", + "html_site_escapecloud": "escapecloud.io", + "html_risks_caption": "{count} · {scope} · ordered by severity", + "html_risks_caption_only_count": "{count} · ordered by severity", + "html_risks_count_one": "1 unique risk", + "html_risks_count_many": "{n} unique risks", + "html_risks_wide_sole": "applies to the scope as a whole", + "html_risks_wide_all": "all apply to the scope as a whole", + "html_risks_wide_one": "1 applies to the scope as a whole", + "html_risks_wide_many": "{n} apply to the scope as a whole", + "html_risks_none_caption": "No exit risks", + "html_risks_th_affected": "Affected Resource Types", + "html_risks_more": "+{n} more", + "html_risks_footnote": ( + "Risk codes are stable across runs. Severity levels and their meaning: " + "{link}." + ), + "html_risks_empty_scope": ( + "With no resources in the scope, no risk from the data catalogue applies." + ), + "html_risks_empty_clean": ( + "Every resource type has enough alternatives with enterprise support, " + "and the size of the scope is below the thresholds." + ), + "html_inv_caption": "{resources} · {types} · {regions}", + "html_inv_regions": "{regions} ({shares})", + "html_inv_not_managed": "resources that Terraform does not manage are not assessed", + "html_inv_th_locations": "Locations", + "html_inv_empty_title": "No resources were found in the defined scope.", + "html_inv_empty_resource_group": ( + "Check that the resource group {scope} is the intended scope and that " + "the credentials can read it. Resource types not in the data catalogue " + "are not counted." + ), + "html_inv_empty_region": ( + "Check that the region {scope} is the intended scope and that the " + "credentials can read it. Resource types not in the data catalogue are " + "not counted." + ), + "html_inv_empty_tfstate": ( + "Check that the state file {scope} holds resources of this provider. " + "Resource types not in the data catalogue are not counted." + ), + "html_alt_caption": "{alternatives} for {with_alternatives} of {types} resource types", + "html_alt_caption_one_type": "{alternatives} for {with_alternatives} of 1 resource type", + "html_alt_n_one": "1 alternative", + "html_alt_n_many": "{n} alternatives", + "html_alt_search": "Search alternatives", + "html_filter_os": "Open Source", + "html_filter_sp": "Enterprise Support", + "html_filter_eu": "European Vendors", + "html_alt_th_name": "Alternative", + "html_alt_meta": "{resources} · {alternatives}", + "html_alt_meta_none": "{resources} · no alternative in the data catalogue", + "html_alt_more_one": "Show 1 more alternative", + "html_alt_more_many": "Show {n} more alternatives", + "html_alt_no_match": "No alternative matches the search and filters.", + "html_alt_footnote": ( + "Alternatives are listed alphabetically from the data catalogue " + "({version}); this report doesn't rank them. Open source and " + "enterprise support as recorded in the data catalogue. Country = " + "vendor's registered country." + ), + "html_alt_empty_title": "No resource types to list alternatives for.", + "html_alt_empty_text": ( + "Alternatives appear here for each resource type found in the scope." + ), + "html_yes": "Yes", + "html_no": "No", + "html_eg_caption": ( + "Data held by the data-bearing resources, and the one-off fee to " + "transfer it out at list prices · also as PDF {document_id}" + ), + "html_eg_kpi_fee": "Estimated Transfer Fee", + "html_eg_kpi_headroom": "Free-Tier Headroom", + "html_eg_fee_free": "within the monthly free tier · USD list prices", + "html_eg_fee_paid": "at list prices · USD", + "html_eg_types_sized": "{sized} of {types} types sized", + "html_eg_headroom_left": "of {limit} per month left", + "html_eg_headroom_aria": "{share} of the free tier used", + "html_eg_billable_caption": "no free tier in the prices used", + "html_eg_not_sized_caption": "listed, not in the fee", + "html_eg_not_sized": "not sized", + "html_eg_vol_aria": "Share of data volume: {parts}", + "html_eg_vol_part": "{type} {share}", + "html_eg_vol_other": "Other types", + "html_eg_vol_unsized": "{types} not sized", + "html_eg_units_gb": ( + "Data is shown in GiB; {provider} prices egress per GB (1 GiB = 1.074 " + "GB), so {gib} GiB is {gb} GB of the {limit} monthly free tier." + ), + "html_eg_units_gb_plain": ( + "Data is shown in GiB; {provider} prices egress per GB (1 GiB = 1.074 GB)." + ), + "html_eg_units_gib": "Data is shown in GiB; {provider} prices egress per GiB.", + "html_eg_amounts": "Amounts in USD list prices; not converted.", + "html_eg_amounts_costs": ( + "Amounts in USD list prices; not converted and not added to the costs " + "above ({currency}, monthly)." + ), + "html_eg_scope_note": ( + "Network transfer fees only: request charges, cross-region traffic, " + "private connections and negotiated discounts are not included. Some " + "types report provisioned rather than used capacity. Not an estimate " + "of the full cost of an exit." + ), + "html_eg_sized_summary": "What is sized, and how", + "html_eg_prices_summary": "Prices used ({provider}, zone 1, public internet routing)", + "html_about_caption": ( + "Document, scope and provenance · method, limitations, severity levels " + "and terms" + ), + "html_about_document": "Document", + "html_about_purpose": "Purpose and Method", + "html_about_phases_meta": "{n} phases", + "html_about_levels_meta": "{n} levels", + "html_about_terms_meta": "{n} terms", + "html_method_lead": ( + "The assessment was collected and evaluated locally with cloudexit. " + "Once the scope is defined, it runs three phases:" + ), + "html_method_discovery_tfstate": ( + "reads the resources managed in the Terraform state and classifies " + "them by type and region; a state file holds no costs." + ), + "html_method_catalogue": ( + "Risks and alternatives are taken from the data catalogue version shown " + "above, so the results of this run can be reproduced." + ), + "html_limitations_data": ( + "The analysis assumes that the discovered resources are correctly " + "configured and accessible. Dependencies outside the defined scope, " + "such as on-premises systems or other cloud accounts, are not covered; " + "results are indicative, not exhaustive." + ), + "html_glossary": [ + { + "id": "aws", + "term": "AWS", + "definition": "Amazon Web Services", + }, + { + "id": "data_catalogue", + "term": "Data catalogue", + "definition": "The catalogue of resource types, exit risks and alternative technologies used for this assessment.", + }, + { + "id": "hotspot", + "term": "Lock-in hot-spot", + "definition": "A discovered resource type for which the data catalogue lists no alternative.", + }, + { + "id": "run", + "term": "Run", + "definition": "One completed assessment of the defined scope, identified by its run ID.", + }, + { + "id": "egress", + "term": "Egress", + "definition": "Data transferred out of the provider's network, which the provider bills per unit of volume.", + }, + { + "id": "free_tier", + "term": "Free tier", + "definition": "The volume of internet egress per month that the provider does not bill.", + }, + { + "id": "gib_gb", + "term": "GiB / GB", + "definition": "Gibibyte (2³⁰ bytes) and gigabyte (10⁹ bytes); 1 GiB = 1.074 GB.", + }, + ], + "html_notice_advice": ( + "It is not legal, financial or investment advice, an audit or an exit plan." + ), + "html_label_repository": "github.com/escapecloud/cloudexit", + "html_figures_heading": "Figures", + "html_figures": ( + "Costs come from the provider's billing data for the scope. " + "Alternatives are entries from the data catalogue, not recommendations." + ), + "html_figures_egress": ( + "Costs come from the provider's billing data for the scope; egress " + "prices are public list prices at the time of the run. Alternatives " + "are entries from the data catalogue, not recommendations." + ), + "html_figures_tfstate": ( + "Resources are read from the Terraform state file; a state file holds " + "no billing data, so this report shows no costs. Alternatives are " + "entries from the data catalogue, not recommendations." + ), + "html_software": ( + "cloudexit is free software, licensed under the GNU Affero General " + "Public License v3.0 (AGPL-3.0). Source code and issues: " + "{repository_link}. The provider icons are not covered by this " + "licence. Fonts: IBM Plex, SIL Open Font License 1.1. Guided tour: " + "driver.js, MIT License, included in this report's assets." + ), + "html_footer_ids": "{document_id} · cloudexit {version}", + "html_tour_next": "Next", + "html_tour_back": "Back", + "html_tour_done": "Done", + "html_tour_progress": "{{current}} of {{total}}", + "html_tour_sections_title": "Sections", + "html_tour_sections": ( + "Jump to any part of the report; the section in view is highlighted." + ), + "html_tour_sections_lock": ( + "Scores, marked with a lock, are available in EscapeCloud Light & " + "Enterprise." + ), + "html_tour_report_title": "This Report", + "html_tour_report": "The assessed scope and its cloud provider.", + "html_tour_report_platform": ( + "This run was uploaded: Open on the platform shows its full results." + ), + "html_tour_download_both": ( + "Download has the same results as PDF: the Executive Summary and the " + "Egress & Data Landscape." + ), + "html_tour_download_both_platform": ( + "Download has the Executive Summary and the Egress & Data Landscape as " "PDF." + ), + "html_tour_download_one": "Download has the Executive Summary as PDF.", + "html_tour_overview_title": "Overview", + "html_tour_overview": ( + "Scope, unique risks by severity and the last six months of costs. The " + "current month is provisional while it is still accruing; costs are " + "context, not an exit-cost estimate." + ), + "html_tour_overview_tfstate": ( + "Scope and unique risks by severity. A Terraform state holds no " + "billing data, so there are no costs; resources that Terraform does " + "not manage are not assessed." + ), + "html_tour_scores_title": "Exit Readiness Grade & Vendor Lock-in Score", + "html_tour_scores_available": "Available in EscapeCloud Light & Enterprise.", + "html_tour_scores_platform": ( + "This run was uploaded. Its Exit Readiness Grade and Vendor Lock-in " + "Score are on the platform: use Open on the platform. This local " + "report never shows platform scores." + ), + "html_tour_risks_title": "Risks", + "html_tour_risks": ( + "Exit risks found in the scope, ordered by severity. Each risk has a " + "stable code, so a finding can be followed from one run to the next. " + "Open a row for its description." + ), + "html_tour_inventory_title": "Resource Inventory", + "html_tour_inventory": ( + "One row per resource type, with its regions and its share of all " + "resources in the scope." + ), + "html_tour_alternatives_title": "Alternative Technologies", + "html_tour_alternatives": ( + "Replacement options from the data catalogue for each resource type, " + "listed alphabetically and not ranked. Search, or filter by open " + "source, enterprise support or European vendors." + ), + "html_tour_egress_title": "Egress & Data Landscape", + "html_tour_egress": ( + "Data held in the scope and the one-off fee to transfer it out at " + "public list prices. Data is shown in GiB; the provider prices per GB. " + "Types marked 'not sized' are listed but not in the fee." + ), + "html_tour_about_title": "About This Report", + "html_tour_about": ( + "Document ID, scope and provenance. The run ID and the data catalogue " + "version make the result reproducible; method, limitations, severity " + "levels and terms are below." + ), +} + +# Labels and headings that follow English Title Case, for the wording test. +TITLE_CASE_KEYS = ( + "ev_document_information_heading", + "ev_assessment_scope_heading", + "ev_provenance_heading", + "ev_document_id", + "ev_document_type", + "ev_collected_by", + "ev_collected_at", + "ev_generated_at", + "ev_cloud_provider", + "ev_catalogue_label", + "ev_run_id", + "ev_platform_label", + "ev_not_assessed", + "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_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", + "footer_edition", + "notices_heading", + "notices_disclaimer_heading", + "notices_trademarks_heading", + "notices_software_heading", + "cover_exit_strategy", + "cover_run", + "es_heading_introduction", + "es_heading_purpose", + "es_heading_scope", + "es_heading_method", + "es_heading_limitations", + "bs_heading", + "bs_findings_heading", + "bs_th_area", + "bs_th_finding", + "bs_th_details", + "bs_area_risks", + "bs_area_alternatives", + "bs_area_costs", + "bs_area_footprint", + "bs_stat_costs", + "es_heading_costs_overview", + "cost_stat_total", + "cost_stat_average", + "cost_stat_latest", + "cost_stat_change", + "cost_th_month", + "cost_th_cost", + "cost_th_change", + "es_heading_resource_inventory", + "res_stat_resources", + "res_stat_types", + "res_stat_regions", + "res_th_resource_type", + "res_th_regions", + "res_th_resources", + "res_th_share", + "res_total", + "es_heading_risk_inventory", + "risk_stat_unique", + "risk_stat_critical_high", + "risk_stat_affected", + "risk_stat_scope_wide", + "risk_th_code", + "risk_th_risk", + "risk_th_severity", + "risk_th_affected", + "es_heading_alternative_technologies", + "alt_stat_types", + "alt_stat_alternatives", + "alt_stat_hotspots", + "alt_th_resource_type", + "alt_th_resources", + "alt_th_alternatives", + "alt_th_examples", + "es_heading_severity", + "es_severity_th_meaning", + "es_heading_glossary", + "html_download", + "html_download_summary", + "html_download_egress", + "html_nav_overview", + "html_nav_scores", + "html_nav_risks", + "html_nav_about", + "html_scope_label", + "html_risks_label", + "html_costs_label", + "html_costs_label_short", + "html_scores_grade", + "html_scores_lock", + "html_scores_pill", + "html_risks_th_affected", + "html_inv_th_locations", + "html_filter_os", + "html_filter_sp", + "html_filter_eu", + "html_alt_th_name", + "html_eg_kpi_fee", + "html_eg_kpi_headroom", + "html_about_document", + "html_about_purpose", + "html_figures_heading", + "html_tour_sections_title", + "html_tour_report_title", + "html_tour_overview_title", + "html_tour_scores_title", + "html_tour_risks_title", + "html_tour_inventory_title", + "html_tour_alternatives_title", + "html_tour_egress_title", + "html_tour_about_title", + "html_nav_label", + "html_run_label", + "html_help", +) diff --git a/cloudexit/report/writers.py b/cloudexit/report/writers.py new file mode 100644 index 0000000..6f55445 --- /dev/null +++ b/cloudexit/report/writers.py @@ -0,0 +1,94 @@ +# cloudexit/report/writers.py +import os +import json +import logging +from typing import Any +from jinja2 import Environment + +from cloudexit.report.context import ReportContext +from cloudexit.report.html_sections import html_sections +from cloudexit.resources import asset_path +from cloudexit.report.json_data import ( + transform_resource_inventory_for_json, + transform_cost_inventory_for_json, + transform_risk_inventory_for_json, + transform_alt_tech_for_json, +) + +# Configure logger +logger = logging.getLogger(__name__) + + +def generate_html_report( + context: ReportContext, egress_estimate: dict[str, Any] | None = None +) -> str: + template_content = asset_path("template", "index.html").read_text(encoding="utf-8") + + env = Environment(autoescape=True) + template = env.from_string(template_content) + html_content = template.render(page=html_sections(context, egress_estimate)) + + html_path = os.path.join(context.report_path, "index.html") + with open(html_path, "w", encoding="utf-8") as report_file: + report_file.write(html_content) + + return html_path + + +def generate_json_report( + raw_data_path: str, + metadata: dict[str, Any], + resource_type_mapping: dict[str, dict[str, Any]], + resource_inventory: list[dict[str, Any]], + cost_data: list[dict[str, Any]], + scoring_data: dict[str, Any] | None, + risk_data: list[dict[str, Any]], + risk_definitions: list[dict[str, Any]], + alternatives: list[dict[str, Any]], + alternative_technologies: list[dict[str, Any]], + exit_strategy: int, + provenance: dict[str, Any] | None = None, +) -> str: + # Transform data for JSON + transformed_resource_inventory = transform_resource_inventory_for_json( + resource_inventory, resource_type_mapping + ) + transformed_cost_inventory = transform_cost_inventory_for_json(cost_data) + transformed_risk_inventory = transform_risk_inventory_for_json( + risk_data, risk_definitions, resource_inventory + ) + transformed_alt_tech = transform_alt_tech_for_json( + resource_inventory, alternatives, alternative_technologies, exit_strategy + ) + + # Build the JSON structure + report_json = { + "meta": metadata, + "data": { + "resource_inventory": transformed_resource_inventory, + "cost_inventory": transformed_cost_inventory, + "risk_inventory": transformed_risk_inventory, + }, + } + + if provenance: + report_json["provenance"] = provenance + + # Add scoring_data only if present + if scoring_data: + report_json["data"]["scoring_data"] = { + "exit_score": scoring_data.get("exit_score", 0), + "human_score": scoring_data.get("human_score", 0), + "technology_score": scoring_data.get("technology_score", 0), + "operational_score": scoring_data.get("operational_score", 0), + } + + # Add alternative technologies + report_json["data"]["alternative_technologies"] = transformed_alt_tech + + # Save JSON to file + json_path = os.path.join(raw_data_path, "assessment_result.json") + with open(json_path, "w", encoding="utf-8") as json_file: + json.dump(report_json, json_file, indent=4) + + return json_path diff --git a/cloudexit/resources.py b/cloudexit/resources.py new file mode 100644 index 0000000..9dcdff1 --- /dev/null +++ b/cloudexit/resources.py @@ -0,0 +1,56 @@ +from importlib.resources import files +from pathlib import Path, PurePosixPath + + +def asset_path(*parts: str) -> Path: + root = files("cloudexit") + if not isinstance(root, Path): + raise RuntimeError( + "the cloudexit assets are not on the file system " + f"({type(root).__name__}); install the package into a directory" + ) + return root.joinpath("assets", *parts) + + +def safe_relative(relative: object) -> PurePosixPath | None: + # A path a catalogue names for an asset, as a relative path inside the + # assets folder, or None. One leading slash is how the catalogue writes its + # icons ("/icons/aws/..."): it means the assets folder, not the file system. + text = str(relative or "") + if text.startswith("/"): + text = text[1:] + if not text or text.startswith(("/", "\\")): + return None + # No backslash (UNC and Windows separators), no colon (drive letters and + # stream names) and no NUL; nothing that could leave the folder + if any(char in text for char in ("\\", ":", "\x00")): + return None + parts = PurePosixPath(text).parts + if not parts or ".." in parts: + return None + return PurePosixPath(*parts) + + +def resolve_inside(base: str | Path, relative: object) -> Path | None: + # base/relative when it stays inside base, whatever links lie on the way; + # nothing outside base is looked at + safe = safe_relative(relative) + if safe is None: + return None + root = Path(base) + candidate = root.joinpath(*safe.parts) + try: + inside = candidate.resolve().is_relative_to(root.resolve()) + except (OSError, RuntimeError, ValueError): + return None + return candidate if inside else None + + +def find_asset(report_path: str | Path, relative: str) -> Path | None: + # The report folder carries its own copy of the assets; the installed + # package is the fallback. + for base in (Path(report_path) / "assets", asset_path()): + candidate = resolve_inside(base, relative) + if candidate is not None and candidate.is_file(): + return candidate + return None diff --git a/cloudexit/runconfig/__init__.py b/cloudexit/runconfig/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cloudexit/runconfig/choices.py b/cloudexit/runconfig/choices.py new file mode 100644 index 0000000..d8107f4 --- /dev/null +++ b/cloudexit/runconfig/choices.py @@ -0,0 +1,33 @@ +# cloudexit/runconfig/choices.py +REGION_CHOICES = [ + ("us-east-1", "us-east-1 (N. Virginia)"), + ("us-east-2", "us-east-2 (Ohio)"), + ("us-west-1", "us-west-1 (N. California)"), + ("us-west-2", "us-west-2 (Oregon)"), + ("af-south-1", "af-south-1 (Cape Town)"), + ("ap-east-1", "ap-east-1 (Hong Kong)"), + ("ap-south-1", "ap-south-1 (Mumbai)"), + ("ap-northeast-1", "ap-northeast-1 (Tokyo)"), + ("ap-northeast-2", "ap-northeast-2 (Seoul)"), + ("ap-northeast-3", "ap-northeast-3 (Osaka)"), + ("ap-southeast-1", "ap-southeast-1 (Singapore)"), + ("ap-southeast-2", "ap-southeast-2 (Sydney)"), + ("ca-central-1", "ca-central-1 (Central)"), + ("eu-central-1", "eu-central-1 (Frankfurt)"), + ("eu-west-1", "eu-west-1 (Ireland)"), + ("eu-west-2", "eu-west-2 (London)"), + ("eu-west-3", "eu-west-3 (Paris)"), + ("eu-south-1", "eu-south-1 (Milan)"), + ("eu-north-1", "eu-north-1 (Stockholm)"), + ("me-south-1", "me-south-1 (Bahrain)"), + ("sa-east-1", "sa-east-1 (São Paulo)"), +] + +REQUIRED_FIELDS_AZURE = [ + "clientId", + "clientSecret", + "tenantId", + "subscriptionId", + "resourceGroupName", +] +REQUIRED_FIELDS_AWS = ["accessKey", "secretKey", "region"] diff --git a/cloudexit/runconfig/loader.py b/cloudexit/runconfig/loader.py new file mode 100644 index 0000000..887b704 --- /dev/null +++ b/cloudexit/runconfig/loader.py @@ -0,0 +1,89 @@ +import json +import logging +import os +import sys +from datetime import datetime +from typing import Any + +from rich.console import Console + +from cloudexit import codes + +logger = logging.getLogger(__name__) +console = Console() + + +def load_config(file_path: str) -> dict[str, Any] | None: + try: + # logger.info(f"Attempting to load config file from {file_path}") + with open(file_path, "r", encoding="utf-8-sig") as f: + config = json.load(f) + # logger.info("Config file loaded successfully.") + return config + except Exception as e: + logger.error(f"Error loading config file: {e}", exc_info=True) + console.print( + f"Error loading config file: {e}", style="red", markup=False, emoji=False + ) + return None + + +def resolve_name(args) -> str: + if getattr(args, "name", None) and args.name.strip(): + return args.name.strip() + return f"Exit Assessment {datetime.now().strftime('%Y%m%d_%H%M%S')}" + + +def build_config( + cloud_provider: int, + exit_strategy: int, + assessment_type: int, + provider_details: dict, + args, +) -> dict: + return { + "name": resolve_name(args), + "cloudServiceProvider": cloud_provider, + "exitStrategy": exit_strategy, + "assessmentType": assessment_type, + "providerDetails": provider_details, + } + + +def require_env(var: str, description: str) -> str: + """Return the value of an env var, or exit with CONFIG if it is unset/empty.""" + value = os.environ.get(var, "").strip() + if not value: + console.print( + f"--non-interactive requires {description}. " + f"Set the {var} environment variable.", + style="red", + markup=False, + emoji=False, + ) + sys.exit(codes.CONFIG) + return value + + +def require_env_int(var: str, description: str, valid: set) -> int: + """Return an env var parsed as int, validated against an allowed set.""" + raw = require_env(var, description) + try: + value = int(raw) + except ValueError: + console.print( + f"{var} must be an integer, got: {raw!r}", + style="red", + markup=False, + emoji=False, + ) + sys.exit(codes.CONFIG) + if value not in valid: + console.print( + f"{var} must be one of {sorted(valid)}, got: {value}", + style="red", + markup=False, + emoji=False, + ) + sys.exit(codes.CONFIG) + return value diff --git a/utils/validate.py b/cloudexit/runconfig/validate.py similarity index 97% rename from utils/validate.py rename to cloudexit/runconfig/validate.py index 8d8ade4..fbc8d0b 100644 --- a/utils/validate.py +++ b/cloudexit/runconfig/validate.py @@ -1,7 +1,7 @@ -# utils/validate.py +# cloudexit/runconfig/validate.py import os from typing import Any -from .constants import REGION_CHOICES, REQUIRED_FIELDS_AZURE, REQUIRED_FIELDS_AWS +from .choices import REGION_CHOICES, REQUIRED_FIELDS_AZURE, REQUIRED_FIELDS_AWS # Fields that only mean something when connecting to a live account. tfstate # mode derives all of them from the state file, so accepting them alongside diff --git a/cloudexit/settings.py b/cloudexit/settings.py new file mode 100644 index 0000000..d1a0d99 --- /dev/null +++ b/cloudexit/settings.py @@ -0,0 +1,461 @@ +from __future__ import annotations + +import logging +import os +import stat +import sys +import tomllib +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Mapping +from urllib.parse import urlsplit + +logger = logging.getLogger(__name__) + +DEFAULT_TIMEOUT = 10.0 +DEFAULT_UPLOAD_BUDGET = 300.0 +LOCAL_HTTP_HOSTS = ("localhost", "127.0.0.1") +KEY_PREFIXES = ("esc_sk_", "exc_eu_sk_", "exc_us_sk_") +UPLOAD_MODES = ("auto", "required", "off") + +ENV_VARS = { + "host": "CLOUDEXIT_HOST", + "api_key": "CLOUDEXIT_API_KEY", + "api_key_file": "CLOUDEXIT_API_KEY_FILE", + "profile": "CLOUDEXIT_PROFILE", + "assessment_id": "CLOUDEXIT_ASSESSMENT_ID", + "upload": "CLOUDEXIT_UPLOAD", + "ca_bundle": "CLOUDEXIT_CA_BUNDLE", + "timeout": "CLOUDEXIT_TIMEOUT", + "upload_budget": "CLOUDEXIT_UPLOAD_BUDGET", + "spool_dir": "CLOUDEXIT_SPOOL_DIR", + "catalogue_url": "CLOUDEXIT_CATALOGUE_URL", +} + +FLAGS = { + "host": "--host", + "api_key_file": "--api-key-file", + "profile": "--config-profile", + "assessment_id": "--assessment-id", + "ca_bundle": "--ca-bundle", + "timeout": "--timeout", + "upload_budget": "--upload-budget", + "upload": "--upload", +} + +FILE_KEYS = ("host", "api_key", "api_key_file", "assessment_id", "ca_bundle", "upload") +PATH_KEYS = ("api_key_file", "ca_bundle", "spool_dir") + +LEGACY_ENV_WARNING = ( + "HOST and KEY are no longer read (removed in 2.0). Use CLOUDEXIT_HOST and " + "CLOUDEXIT_API_KEY_FILE (or CLOUDEXIT_API_KEY)." +) +LEGACY_CONFIG_WARNING = ( + "config.py is no longer read (removed in 2.0). Move HOST and KEY to the " + "config file (see `cloudexit config path`) or to CLOUDEXIT_* environment " + "variables." +) + +TRUE_WORDS = ("1", "true", "yes", "on") +FALSE_WORDS = ("0", "false", "no", "off") + + +class SettingsError(Exception): + pass + + +@dataclass(frozen=True) +class Setting: + value: Any = field(default=None, repr=False) + source: str = "unset" + + +@dataclass(frozen=True) +class Settings: + host: Setting = field(default_factory=Setting) + api_key: Setting = field(default_factory=Setting) + api_key_file: Setting = field(default_factory=Setting) + profile: Setting = field(default_factory=Setting) + assessment_id: Setting = field(default_factory=Setting) + upload: Setting = field(default_factory=lambda: Setting("auto", "default")) + ca_bundle: Setting = field(default_factory=Setting) + timeout: Setting = field( + default_factory=lambda: Setting(DEFAULT_TIMEOUT, "default") + ) + upload_budget: Setting = field( + default_factory=lambda: Setting(DEFAULT_UPLOAD_BUDGET, "default") + ) + spool_dir: Setting = field(default_factory=Setting) + catalogue_url: Setting = field(default_factory=Setting) + config_path: Path | None = None + config_found: bool = False + + +def config_file_path( + environ: Mapping[str, str] | None = None, *, platform: str = sys.platform +) -> Path: + environ = os.environ if environ is None else environ + if platform == "win32": + base = environ.get("APPDATA") or str(Path.home() / "AppData" / "Roaming") + else: + xdg = environ.get("XDG_CONFIG_HOME", "") + base = xdg if xdg and os.path.isabs(xdg) else str(Path.home() / ".config") + return Path(base) / "cloudexit" / "config.toml" + + +def normalize_host(raw: str) -> str: + host = raw.strip() + if not host: + raise SettingsError("host is empty") + if "://" not in host: + host = f"https://{host}" + + parts = urlsplit(host) + if not parts.hostname: + raise SettingsError(f"host {raw!r} has no host name") + if parts.scheme == "http": + if parts.hostname not in LOCAL_HTTP_HOSTS: + raise SettingsError( + f"plain http:// is not allowed for {parts.hostname}; use https:// " + "(http is only accepted for localhost and 127.0.0.1)" + ) + elif parts.scheme != "https": + raise SettingsError(f"unsupported scheme {parts.scheme}:// in host {raw!r}") + return host.rstrip("/") + + +def mask_key(key: str | None) -> str: + if not key: + return "-" + if len(key) < 12: + return "****" + for prefix in KEY_PREFIXES: + if key.startswith(prefix): + return f"{prefix}...{key[-4:]}" + return f"{key[:4]}...{key[-4:]}" + + +def flags_from_args(args: Any) -> dict[str, Any]: + return { + name: getattr(args, attr, None) + for name, attr in ( + ("host", "host"), + ("api_key_file", "api_key_file"), + ("profile", "config_profile"), + ("assessment_id", "assessment_id"), + ("ca_bundle", "ca_bundle"), + ("timeout", "timeout"), + ("upload_budget", "upload_budget"), + ("upload", "upload"), + ) + if getattr(args, attr, None) is not None + } + + +def _expand(raw: str, base: Path | None) -> str: + path = Path(raw).expanduser() + if base is not None and not path.is_absolute(): + path = base / path + return str(path) + + +def _warn_if_readable(path: Path, what: str) -> None: + if os.name != "posix": + return + try: + mode = path.stat().st_mode + except OSError: + return + if mode & (stat.S_IRGRP | stat.S_IROTH): + logger.warning( + "%s %s is readable by group or others; restrict it with: chmod 600 %s", + what, + path, + path, + ) + + +def _flag_layer(flags: Mapping[str, Any]) -> dict[str, Setting]: + layer = {} + for name, flag in FLAGS.items(): + value = flags.get(name) + if isinstance(value, str): + value = value.strip() + if value is None or value == "": + continue + if name in PATH_KEYS: + value = _expand(value, None) + layer[name] = Setting(value, f"flag {flag}") + return layer + + +def _env_layer(environ: Mapping[str, str]) -> dict[str, Setting]: + layer = {} + for name, var in ENV_VARS.items(): + value = environ.get(var, "").strip() + if not value: + continue + if name in PATH_KEYS: + value = _expand(value, None) + layer[name] = Setting(value, f"env {var}") + return layer + + +def _read_config_file(path: Path) -> dict[str, Any] | None: + if not path.is_file(): + return None + _warn_if_readable(path, "config file") + try: + with path.open("rb") as handle: + return tomllib.load(handle) + except (OSError, tomllib.TOMLDecodeError) as exc: + raise SettingsError(f"cannot read config file {path}: {exc}") from exc + + +def _table_layer(table: Any, label: str, path: Path) -> dict[str, Setting]: + if not isinstance(table, dict): + raise SettingsError(f"{label} in {path} must be a table") + + layer = {} + for key, value in table.items(): + if key not in FILE_KEYS: + logger.warning("Unknown key %r in %s of %s is ignored.", key, label, path) + continue + if key == "upload": + if not isinstance(value, (bool, str)): + raise SettingsError( + f"{key} in {label} of {path} must be auto, required or off" + ) + elif not isinstance(value, str): + raise SettingsError(f"{key} in {label} of {path} must be a string") + elif not value.strip(): + continue + else: + value = value.strip() + if key in PATH_KEYS: + value = _expand(value, path.parent) + layer[key] = Setting(value, f"config file {label}") + return layer + + +def _file_layers( + data: dict[str, Any], path: Path, profile: str | None +) -> list[dict[str, Setting]]: + for key in data: + if key not in ("default", "profiles"): + logger.warning("Unknown table %r in %s is ignored.", key, path) + + profiles = data.get("profiles", {}) + if not isinstance(profiles, dict): + raise SettingsError(f"[profiles] in {path} must be a table of tables") + + layers = [] + if profile: + if profile not in profiles: + raise SettingsError(f"profile {profile!r} not found in {path}") + layers.append(_table_layer(profiles[profile], f"[profiles.{profile}]", path)) + layers.append(_table_layer(data.get("default", {}), "[default]", path)) + return layers + + +def _read_key_file(setting: Setting) -> str: + path = Path(setting.value) + if not path.is_file(): + raise SettingsError(f"API key file {path} ({setting.source}) not found") + _warn_if_readable(path, "API key file") + try: + key = path.read_text(encoding="utf-8").strip() + except (OSError, UnicodeDecodeError) as exc: + raise SettingsError(f"cannot read API key file {path}: {exc}") from exc + if not key: + raise SettingsError(f"API key file {path} is empty") + return key + + +def _resolve_key(layers: list[dict[str, Setting]]) -> tuple[Setting, Setting]: + for layer in layers: + key_file = layer.get("api_key_file") + if key_file is not None: + return Setting(_read_key_file(key_file), key_file.source), key_file + if "api_key" in layer: + return layer["api_key"], Setting() + return Setting(), Setting() + + +def _first(layers: list[dict[str, Setting]], name: str) -> Setting: + for layer in layers: + if name in layer: + return layer[name] + return Setting() + + +def _parse_timeout(setting: Setting) -> Setting: + try: + seconds = float(setting.value) + except (TypeError, ValueError): + seconds = 0.0 + if seconds <= 0: + raise SettingsError( + f"timeout ({setting.source}) must be a positive number of seconds" + ) + return Setting(seconds, setting.source) + + +def _parse_budget(setting: Setting) -> Setting: + try: + seconds = float(setting.value) + except (TypeError, ValueError): + seconds = -1.0 + if seconds < 0: + raise SettingsError( + f"upload budget ({setting.source}) must be a number of seconds, " + "0 or more" + ) + return Setting(seconds, setting.source) + + +def _parse_upload(setting: Setting) -> Setting: + # true and false from earlier versions still mean auto and off + value = setting.value + if isinstance(value, bool): + value = "auto" if value else "off" + elif isinstance(value, str): + word = value.strip().lower() + if word in UPLOAD_MODES: + value = word + elif word in TRUE_WORDS: + value = "auto" + elif word in FALSE_WORDS: + value = "off" + if value not in UPLOAD_MODES: + raise SettingsError(f"upload ({setting.source}) must be auto, required or off") + return Setting(value, setting.source) + + +def _check_host(setting: Setting) -> Setting: + try: + return Setting(normalize_host(setting.value), setting.source) + except SettingsError as exc: + raise SettingsError(f"host ({setting.source}): {exc}") from exc + + +def _check_ca_bundle(setting: Setting) -> Setting: + if not Path(setting.value).is_file(): + raise SettingsError( + f"CA bundle {setting.value} ({setting.source}) is not a file" + ) + return setting + + +def _warn_legacy_sources(settings: Settings, environ: Mapping[str, str]) -> None: + # Only for users still relying on the sources 2.0 dropped, so it stays quiet + # once anything else supplies a host or a key. Presence is all that is + # checked: config.py is never imported, opened or parsed, and the values of + # HOST and KEY are never logged or used. + if settings.host.value or settings.api_key.value: + return + notes = [] + if environ.get("HOST", "").strip() and environ.get("KEY", "").strip(): + notes.append(LEGACY_ENV_WARNING) + if Path("config.py").is_file(): + notes.append(LEGACY_CONFIG_WARNING) + if notes: + logger.warning(" ".join(notes)) + + +def load_settings( + flags: Mapping[str, Any] | None = None, + *, + environ: Mapping[str, str] | None = None, + platform: str = sys.platform, +) -> Settings: + environ = os.environ if environ is None else environ + top = [_flag_layer(flags or {}), _env_layer(environ)] + profile = _first(top, "profile") + + path = config_file_path(environ, platform=platform) + data = _read_config_file(path) + if data is None: + if profile.value: + raise SettingsError( + f"profile {profile.value!r} ({profile.source}) requested but " + f"there is no config file at {path}" + ) + file_layers = [] + else: + file_layers = _file_layers(data, path, profile.value) + + layers = [*top, *file_layers] + + api_key, api_key_file = _resolve_key(layers) + host = _first(layers, "host") + ca_bundle = _first(layers, "ca_bundle") + timeout = _first(layers, "timeout") + budget = _first(layers, "upload_budget") + upload = _first(layers, "upload") + + settings = Settings( + host=_check_host(host) if host.value else host, + api_key=api_key, + api_key_file=api_key_file, + profile=profile, + assessment_id=_first(layers, "assessment_id"), + upload=( + _parse_upload(upload) if upload.value is not None else Settings().upload + ), + ca_bundle=_check_ca_bundle(ca_bundle) if ca_bundle.value else ca_bundle, + timeout=( + _parse_timeout(timeout) if timeout.value is not None else Settings().timeout + ), + upload_budget=( + _parse_budget(budget) + if budget.value is not None + else Settings().upload_budget + ), + spool_dir=_first(layers, "spool_dir"), + catalogue_url=_first(layers, "catalogue_url"), + config_path=path, + config_found=data is not None, + ) + _warn_legacy_sources(settings, environ) + return settings + + +def describe(settings: Settings) -> list[str]: + found = "found" if settings.config_found else "not found" + rows = [] + for name in ( + "host", + "api_key", + "api_key_file", + "profile", + "assessment_id", + "upload", + "ca_bundle", + "timeout", + "upload_budget", + "spool_dir", + "catalogue_url", + ): + setting = getattr(settings, name) + value = setting.value + if name == "api_key": + shown = mask_key(value) + elif value is None: + shown = "-" + elif isinstance(value, bool): + shown = "true" if value else "false" + elif isinstance(value, float): + shown = f"{value:g}" + else: + shown = str(value) + rows.append((name, shown, setting.source)) + + name_width = max(len(row[0]) for row in rows) + value_width = max(len(row[1]) for row in rows) + lines = [f"config file: {settings.config_path} ({found})"] + lines.extend( + f"{name:<{name_width}} {shown:<{value_width}} {source}" + for name, shown, source in rows + ) + return lines diff --git a/cloudexit/terminal.py b/cloudexit/terminal.py new file mode 100644 index 0000000..101b84e --- /dev/null +++ b/cloudexit/terminal.py @@ -0,0 +1,72 @@ +# cloudexit/terminal.py +import getpass +import sys +from rich.console import Console +from rich.style import Style + +from cloudexit import codes + +console = Console() + + +NO_TERMINAL_MESSAGE = ( + "This run needs answers to prompts, but no terminal is attached. " + "Use --non-interactive and set ESC_EXIT_STRATEGY and ESC_ASSESSMENT_TYPE " + "(see --help)." +) + + +def ask( + prompt: str, *, secret: bool = False, no_terminal_message: str | None = None +) -> str: + # Every question goes through here, so a run without a terminal (CI, a + # pipe, < /dev/null) ends with a message instead of an EOFError traceback. + try: + return getpass.getpass(prompt) if secret else input(prompt) + except EOFError: + console.print(f"[red]{no_terminal_message or NO_TERMINAL_MESSAGE}[/red]") + sys.exit(codes.CONFIG) + + +def print_step( + description: str, status: str = "pending", logs: str | None = None +) -> None: + # Define styles for statuses + ok_style = Style(color="green", bold=True) + error_style = Style(color="red", bold=True) + warning_style = Style(color="yellow", bold=True) + # Map statuses to their visual representation + status_map = { + "ok": "[ ok ]", + "error": "[ error ]", + "warning": "[ warn ]", + "pending": "[ ... ]", + } + + if status == "pending": + print_step(description, status="ok") + elif status == "ok": + console.print( + f"{description:<50} {status_map['ok']}", + style=ok_style, + markup=False, + emoji=False, + ) + elif status == "warning": + console.print( + f"{description:<50} {status_map['warning']}", + style=warning_style, + markup=False, + emoji=False, + ) + if logs: + console.print(f" ↳ {logs}", style="dim", markup=False, emoji=False) + elif status == "error": + console.print( + f"{description:<50} {status_map['error']}", + style=error_style, + markup=False, + emoji=False, + ) + if logs: + console.print(f" ↳ {logs}", style="dim", markup=False, emoji=False) diff --git a/cloudexit/tfstate.py b/cloudexit/tfstate.py new file mode 100644 index 0000000..22d0786 --- /dev/null +++ b/cloudexit/tfstate.py @@ -0,0 +1,392 @@ +# cloudexit/tfstate.py +import hashlib +import json +import os +import logging +import re +import sqlite3 +from typing import Any +from collections import defaultdict + +from cloudexit.catalogue.db import connect, load_data + +logger = logging.getLogger(__name__) + +# Terraform >= 0.12 and every OpenTofu release write state format version 4. +SUPPORTED_STATE_VERSION = 4 + +# Attribute keys that carry the region/location of an instance, in lookup order. +# AWS providers expose "region", Azure providers expose "location". +LOCATION_KEYS = ("region", "location") + +ARN_KEY = "arn" + +# arn:partition:service:region:account-id:resource +ARN_REGION_INDEX = 3 +ARN_MIN_FIELDS = 6 + +# Placeholder used when an instance carries no usable location attribute. +UNKNOWN_LOCATION = "unknown" + +# Terraform type prefixes per CSP. A state file can hold resources from any +# number of providers, so these decide which ones belong to the assessment. +CSP_TYPE_PREFIXES = {1: "azurerm_", 2: "aws_"} +CSP_NAMES = {1: "Azure", 2: "AWS"} +CSP_SUBCOMMANDS = {1: "azure", 2: "aws"} + +# Azure resource ids carry the subscription and resource group that define the +# assessed boundary: /subscriptions//resourceGroups//providers/... +AZURE_RESOURCE_ID_PATTERN = re.compile( + r"^/subscriptions/([^/]+)/resourceGroups/([^/]+)/", re.IGNORECASE +) + + +def parse_tfstate(path: str) -> dict[str, Any]: + try: + with open(path, "r", encoding="utf-8") as state_file: + state = json.load(state_file) + except OSError as e: + raise ValueError(f"Could not read Terraform state file '{path}': {e}") + except json.JSONDecodeError as e: + raise ValueError( + f"Terraform state file '{path}' is not valid JSON: {e}. " + "For a remote backend, export it first: " + "`terraform state pull > infra.tfstate`." + ) + + if not isinstance(state, dict): + raise ValueError( + f"Terraform state file '{path}' is not a state document " + "(expected a JSON object at the top level)." + ) + + version = state.get("version") + if version != SUPPORTED_STATE_VERSION: + raise ValueError( + f"Unsupported Terraform state version {version!r} in '{path}'. " + f"Only state format version {SUPPORTED_STATE_VERSION} is supported " + "(Terraform >= 0.12 and all OpenTofu versions produce version 4). " + "For a remote backend, export the current state with " + "`terraform state pull > infra.tfstate`." + ) + + return state + + +def _region_from_arn(arn: Any) -> str: + if not isinstance(arn, str) or not arn.startswith("arn:"): + return "" + + fields = arn.split(":") + if len(fields) < ARN_MIN_FIELDS: + return "" + + return fields[ARN_REGION_INDEX].strip().lower() + + +def _instance_location(attributes: Any) -> str: + if not isinstance(attributes, dict): + return UNKNOWN_LOCATION + + for key in LOCATION_KEYS: + value = attributes.get(key) + if isinstance(value, str): + location = value.strip().lower() + if location: + return location + + return _region_from_arn(attributes.get(ARN_KEY)) or UNKNOWN_LOCATION + + +def _instance_address( + module: str | None, resource_type: str, name: str, index_key: Any +) -> str: + address = f"{resource_type}.{name}" + if module: + address = f"{module}.{address}" + if index_key is not None: + address = f"{address}[{index_key}]" + return address + + +def extract_managed_resources(state: dict[str, Any]) -> list[dict[str, str]]: + records: list[dict[str, str]] = [] + + for resource in state.get("resources") or []: + if not isinstance(resource, dict): + continue + if resource.get("mode") != "managed": + continue + + resource_type = resource.get("type") + name = resource.get("name") + if not resource_type or not name: + logger.debug("Skipping state entry without a type/name: %r", resource) + continue + + module = resource.get("module") + + for instance in resource.get("instances") or []: + if not isinstance(instance, dict): + continue + records.append( + { + "address": _instance_address( + module, resource_type, name, instance.get("index_key") + ), + "type": resource_type, + "location": _instance_location(instance.get("attributes")), + } + ) + + return records + + +def file_sha256(path: str) -> str | None: + digest = hashlib.sha256() + try: + with open(path, "rb") as state_file: + for chunk in iter(lambda: state_file.read(65536), b""): + digest.update(chunk) + except OSError as e: + logger.warning("Could not hash the Terraform state file: %s", e) + return None + return digest.hexdigest() + + +def extract_state_scope( + state: dict[str, Any], cloud_service_provider: int +) -> dict[str, list[str]]: + subscriptions: set[str] = set() + resource_groups: set[str] = set() + + if cloud_service_provider == 1: # Azure + for resource in state.get("resources") or []: + if not isinstance(resource, dict) or resource.get("mode") != "managed": + continue + for instance in resource.get("instances") or []: + if not isinstance(instance, dict): + continue + attributes = instance.get("attributes") + if not isinstance(attributes, dict): + continue + resource_id = attributes.get("id") + if not isinstance(resource_id, str): + continue + match = AZURE_RESOURCE_ID_PATTERN.match(resource_id) + if match: + subscriptions.add(match.group(1)) + resource_groups.add(match.group(2)) + + return { + "subscriptions": sorted(subscriptions), + "resource_groups": sorted(resource_groups), + } + + +def _build_tf_code_mapping( + cloud_service_provider: int, db_path: str +) -> dict[str, dict[str, Any]]: + mapping: dict[str, dict[str, Any]] = {} + duplicates: set[str] = set() + + for item in load_data("resourcetype", db_path=db_path): + if item["csp"] != cloud_service_provider or item["status"] != "t": + continue + + tf_code = (item.get("tf_code") or "").strip() + if not tf_code: + continue + + existing = mapping.get(tf_code) + if existing is None: + mapping[tf_code] = {"id": item["id"], "name": item["name"]} + continue + + if tf_code not in duplicates: + duplicates.add(tf_code) + logger.warning( + "Terraform type '%s' maps to multiple resource types; " + "using the lowest id.", + tf_code, + ) + if item["id"] < existing["id"]: + mapping[tf_code] = {"id": item["id"], "name": item["name"]} + + return mapping + + +def _foreign_provider_summary(foreign_types: dict[str, int]) -> str: + for csp, prefix in CSP_TYPE_PREFIXES.items(): + count = sum(n for t, n in foreign_types.items() if t.startswith(prefix)) + if count: + return f"{count} {CSP_NAMES[csp]} resources" + + total = sum(foreign_types.values()) + sample = ", ".join(sorted(foreign_types)[:3]) + return f"{total} resources from other providers ({sample})" + + +def _no_matching_provider_error( + cloud_service_provider: int, tfstate_path: str, foreign_types: dict[str, int] +) -> ValueError: + own_name = CSP_NAMES.get(cloud_service_provider, "matching") + message = ( + f"No {own_name} resources found in '{os.path.basename(tfstate_path)}'. " + f"The state contains {_foreign_provider_summary(foreign_types)}." + ) + + # Point at the other subcommand only when we could actually assess it. + for csp, prefix in CSP_TYPE_PREFIXES.items(): + if csp == cloud_service_provider: + continue + if any(t.startswith(prefix) for t in foreign_types): + message += ( + f" Did you mean: python3 main.py {CSP_SUBCOMMANDS[csp]} " + f"--tfstate {tfstate_path}" + ) + break + + return ValueError(message) + + +def build_tfstate_resource_inventory( + cloud_service_provider: int, + provider_details: dict[str, Any], + report_path: str, + raw_data_path: str, +) -> dict[str, Any]: + tfstate_path = provider_details["tfstatePath"] + state = parse_tfstate(tfstate_path) + instances = extract_managed_resources(state) + + db_path = os.path.join(report_path, "data", "assessment.db") + resource_type_mapping = _build_tf_code_mapping(cloud_service_provider, db_path) + + # A state file may hold resources from any number of providers. Split them + # up front so a foreign resource is never silently lumped in with the + # same-provider glue types that legitimately have no dataset row. + own_prefix = CSP_TYPE_PREFIXES.get(cloud_service_provider, "") + foreign_types: defaultdict[str, int] = defaultdict(int) + own_instances = [] + for instance in instances: + if own_prefix and not instance["type"].startswith(own_prefix): + foreign_types[instance["type"]] += 1 + else: + own_instances.append(instance) + + # Nothing for the selected provider, but resources for another one: the + # wrong subcommand or the wrong file. An empty state is left alone. + if instances and not own_instances: + raise _no_matching_provider_error( + cloud_service_provider, tfstate_path, dict(foreign_types) + ) + + # Aggregate matched instances, and count the types we have no mapping for. + aggregated_resources: defaultdict[tuple[int, str], int] = defaultdict(int) + unmapped_types: defaultdict[str, int] = defaultdict(int) + counted: list[dict[str, Any]] = [] + + for instance in own_instances: + resource_info = resource_type_mapping.get(instance["type"]) + if not resource_info: + unmapped_types[instance["type"]] += 1 + continue + + resource_type_id = resource_info["id"] + aggregated_resources[(resource_type_id, instance["location"])] += 1 + counted.append( + { + "address": instance["address"], + "type": instance["type"], + "resource_type_id": resource_type_id, + "location": instance["location"], + } + ) + + # Insert aggregated data into SQLite + try: + with connect(db_path=db_path) as conn: + cursor = conn.cursor() + for ( + resource_type_id, + resource_location, + ), resource_count in aggregated_resources.items(): + try: + cursor.execute( + """ + INSERT INTO resource_inventory (resource_type, location, count) + VALUES (?, ?, ?) + ON CONFLICT(resource_type, location) DO UPDATE SET count = excluded.count + """, + (resource_type_id, resource_location, resource_count), + ) + except sqlite3.Error as e: + logger.error( + f"SQLite error while processing aggregated resource: {e}", + exc_info=True, + ) + conn.commit() + except sqlite3.Error as e: + logger.error( + f"Error writing the tfstate resource inventory: {e}", exc_info=True + ) + raise + + # Counts are per instance, not per type: "9 types skipped" can hide any + # number of dropped resources, which is exactly what needs surfacing. + excluded_foreign = sum(foreign_types.values()) + excluded_unmapped = sum(unmapped_types.values()) + coverage = { + "instances_total": len(instances), + "instances_counted": len(counted), + "instances_excluded_other_provider": excluded_foreign, + "instances_excluded_unmapped": excluded_unmapped, + } + + # What the report's Scope of Assessment section renders from. The hash ties + # a report back to the exact file it was produced from. + scope = { + "file": os.path.basename(tfstate_path), + "sha256": file_sha256(tfstate_path), + "lineage": state.get("lineage"), + "serial": state.get("serial"), + "locations": sorted({entry["location"] for entry in counted}), + **extract_state_scope(state, cloud_service_provider), + } + + # Manifest of what was counted. Instance attributes carry secrets + # (passwords, connection strings, keys) and are deliberately never written. + manifest = { + "source_file": os.path.basename(tfstate_path), + "terraform_version": state.get("terraform_version"), + "state_serial": state.get("serial"), + "scope": scope, + "coverage": coverage, + "counted": counted, + "unmapped_types": dict(unmapped_types), + "other_provider_types": dict(foreign_types), + } + + manifest_path = os.path.join(raw_data_path, "tfstate_manifest.json") + try: + with open(manifest_path, "w", encoding="utf-8") as manifest_file: + json.dump(manifest, manifest_file, indent=4) + except OSError as e: + logger.error(f"Could not write the tfstate manifest: {e}", exc_info=True) + + if unmapped_types: + logger.warning( + "%d Terraform resource type(s) had no matching resource type and were " + "skipped; see raw_data/tfstate_manifest.json for details.", + len(unmapped_types), + ) + + if excluded_foreign: + logger.warning( + "%d resource(s) in the state belong to another cloud provider and were " + "not assessed.", + excluded_foreign, + ) + + return coverage diff --git a/cloudexit/version.py b/cloudexit/version.py new file mode 100644 index 0000000..8cc9434 --- /dev/null +++ b/cloudexit/version.py @@ -0,0 +1 @@ +__version__ = "2.0.0-rc.1" diff --git a/config.example.toml b/config.example.toml new file mode 100644 index 0000000..d9c1aef --- /dev/null +++ b/config.example.toml @@ -0,0 +1,40 @@ +# Copy this file to the location printed by `cloudexit config path` +# (default: ~/.config/cloudexit/config.toml, or $XDG_CONFIG_HOME/cloudexit/config.toml; +# %APPDATA%\cloudexit\config.toml on Windows) and restrict it: +# +# chmod 600 ~/.config/cloudexit/config.toml +# +# Every setting is optional. Without any configuration cloudexit runs fully +# offline. Precedence, highest first: +# 1. command-line flags (--host, --api-key-file, --config-profile, ...) +# 2. CLOUDEXIT_* environment variables +# 3. this file ([profiles.] first, then [default]) +# Run `cloudexit config show` to see each value and where it came from. + +[default] +# Platform host. https:// is assumed when no scheme is given; http:// is only +# accepted for localhost and 127.0.0.1. +# host = "platform.example.com" + +# Prefer a key file over a key in this file. The path may be relative to this +# file, and `~` is expanded. Keep the key file readable by you only. +# api_key_file = "~/.config/cloudexit/api-key" + +# Only if you cannot use a key file. If both are set, api_key_file wins. +# api_key = "..." + +# Use an existing platform assessment. +# assessment_id = "..." + +# CA bundle for platforms that use a private certificate authority. +# ca_bundle = "/etc/ssl/certs/company-ca.pem" + +# What a run does with the platform: "auto" (upload when a host and key are +# set), "required" (fail before collecting when they are not) or "off" (never). +# upload = "auto" + +# Select a profile with --config-profile NAME or CLOUDEXIT_PROFILE=NAME. +# Values in a profile override [default]. +# [profiles.staging] +# host = "staging.platform.example.com" +# api_key_file = "~/.config/cloudexit/staging-key" diff --git a/config.py b/config.py deleted file mode 100644 index 7192d34..0000000 --- a/config.py +++ /dev/null @@ -1,19 +0,0 @@ -# config.py -""" -Configuration for integrating the local 'cloudexit' tool with the ExitCloud Platform (exitcloud.io). -This enables assessment extension and secure result storage in your selected data region. - -HOST: - EU → "eu.exitcloud.io" - US → "us.exitcloud.io" - -KEY: -To generate a key: - 1. Log in to your regional portal (https://eu.exitcloud.io or https://us.exitcloud.io). - 2. Click your user profile (top right corner). - 3. Select 'Keys' from the menu. - 4. Click 'New Key' and copy the provided key. -""" - -HOST = "" -KEY = "" diff --git a/config/aws_example.json b/config/aws_example.json index 1647581..01df95c 100644 --- a/config/aws_example.json +++ b/config/aws_example.json @@ -3,7 +3,7 @@ "exitStrategy": 3, "assessmentType": 1, "providerDetails":{ - "accessKey":"AKIAXASFHMTLKD6YQLHA", + "accessKey":"AKIAIOSFODNN7EXAMPLE", "secretKey":"", "region":"eu-central-1" } diff --git a/config/azure_example.json b/config/azure_example.json index 6c72a09..7081b78 100644 --- a/config/azure_example.json +++ b/config/azure_example.json @@ -3,9 +3,9 @@ "exitStrategy": 3, "assessmentType": 1, "providerDetails":{ - "clientId":"57344955-1579-4058-8604-5bb8724002de", + "clientId":"00000000-0000-0000-0000-000000000000", "clientSecret":"", - "tenantId":"38997009-9dad-42b2-b187-53f1cb71560e", + "tenantId":"00000000-0000-0000-0000-000000000000", "subscriptionId":"", "resourceGroupName":"" } diff --git a/core/__init__.py b/core/__init__.py deleted file mode 100644 index 50203e3..0000000 --- a/core/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Core assessment and reporting modules.""" diff --git a/core/engine.py b/core/engine.py deleted file mode 100644 index 3910a65..0000000 --- a/core/engine.py +++ /dev/null @@ -1,632 +0,0 @@ -# core/engine.py -import json -import logging -import os -import boto3 -from datetime import datetime, timezone, timedelta -from typing import Any -from azure.identity import ClientSecretCredential -from azure.mgmt.resource import ResourceManagementClient -from azure.core.exceptions import ClientAuthenticationError -from azure.mgmt.authorization import AuthorizationManagementClient -from botocore.exceptions import NoCredentialsError - -from .utils import copy_assets -from .utils_aws import build_aws_resource_inventory, build_aws_cost_inventory -from .utils_azure import build_azure_resource_inventory, build_azure_cost_inventory -from .utils_db import connect, load_data -from .utils_tfstate import build_tfstate_resource_inventory -from .utils_report import ( - generate_html_report, - generate_pdf_report, - generate_json_report, -) -from .utils_sync import post_assessment - -# Configure the logger -logger = logging.getLogger("core.engine") - - -# Stage 1 -def verify_credentials( - cloud_service_provider: int, provider_details: dict[str, Any] -) -> tuple[bool, str]: - connection_success = False - logs = "" - - if cloud_service_provider == 1: # Azure - try: - # Use DefaultAzureCredential if provided, else use client secrets - credential = provider_details.get("credential") or ClientSecretCredential( - tenant_id=provider_details["tenantId"], - client_id=provider_details["clientId"], - client_secret=provider_details["clientSecret"], - ) - resource_client = ResourceManagementClient( - credential, provider_details["subscriptionId"] - ) - list( - resource_client.resource_groups.list() - ) # Benign call to verify credentials - connection_success = True - logs = "Azure connection successful." - except ClientAuthenticationError as e: - logs = f"Azure credentials validation failed: {str(e)}" - # logger.error(logs) - except Exception as e: - logs = f"Azure connection test failed: {str(e)}" - # logger.error(logs) - - elif cloud_service_provider == 2: # AWS - try: - client = boto3.client( - "ec2", - aws_access_key_id=provider_details["accessKey"], - aws_secret_access_key=provider_details["secretKey"], - aws_session_token=provider_details.get("sessionToken"), - region_name=provider_details["region"], - ) - client.describe_regions() # Benign call to verify credentials - connection_success = True - logs = "AWS connection successful." - except NoCredentialsError as e: - logs = f"AWS credentials validation failed: {str(e)}" - # logger.error(logs) - except Exception as e: - logs = f"AWS connection test failed: {str(e)}" - # logger.error(logs) - - return connection_success, logs - - -# Stage 2 -def test_permissions( - cloud_service_provider: int, provider_details: dict[str, Any] -) -> tuple[bool, bool, bool, str]: - permission_valid = False - permission_reader = False - permission_cost = False - logs = "" - - if cloud_service_provider == 1: # Azure - try: - # Use DefaultAzureCredential if provided, else use client secrets - credential = provider_details.get("credential") or ClientSecretCredential( - tenant_id=provider_details["tenantId"], - client_id=provider_details["clientId"], - client_secret=provider_details["clientSecret"], - ) - resource_group_scope = f"/subscriptions/{provider_details['subscriptionId']}/resourceGroups/{provider_details['resourceGroupName']}" - - # Check role assignments - auth_client = AuthorizationManagementClient( - credential, provider_details["subscriptionId"] - ) - role_assignments = auth_client.role_assignments.list_for_scope( - scope=resource_group_scope - ) - - for role_assignment in role_assignments: - role_definition_id = role_assignment.role_definition_id - if role_definition_id.endswith( - "acdd72a7-3385-48ef-bd42-f606fba81ae7" - ): # Reader role - permission_reader = True - if role_definition_id.endswith( - "72fafb9e-0641-4937-9268-a91bfd8191a3" - ): # Cost Management Reader - permission_cost = True - - if permission_reader and permission_cost: - permission_valid = True - logs = "Reader and Cost Management Reader roles validated." - elif permission_reader: - logs = "Reader role validated, but Cost Management Reader role validation failed." - elif permission_cost: - logs = "Cost Management Reader role validated, but Reader role validation failed." - else: - logs = "Both Reader and Cost Management Reader roles validation failed." - - except ClientAuthenticationError as e: - # Expected outcome of a permission test; surfaced to the user via `logs`. - logs = f"Azure credentials validation failed: {str(e)}" - logger.warning(logs) - except Exception as e: - logs = f"Azure permission test failed: {str(e)}" - logger.error(logs) - - elif cloud_service_provider == 2: # AWS - try: - sts_client = boto3.client( - "sts", - aws_access_key_id=provider_details["accessKey"], - aws_secret_access_key=provider_details["secretKey"], - aws_session_token=provider_details.get("sessionToken"), - region_name=provider_details["region"], - ) - identity = sts_client.get_caller_identity() - caller_arn = identity.get("Arn", "") - - # IAM users keep existing policy-name validation for backwards compatibility. - if ":user/" in caller_arn: - user_name = caller_arn.split("/")[-1] - iam_client = boto3.client( - "iam", - aws_access_key_id=provider_details["accessKey"], - aws_secret_access_key=provider_details["secretKey"], - aws_session_token=provider_details.get("sessionToken"), - region_name=provider_details["region"], - ) - policies = iam_client.list_attached_user_policies(UserName=user_name) - policy_names = [ - policy["PolicyName"] for policy in policies["AttachedPolicies"] - ] - - permission_reader = "ViewOnlyAccess" in policy_names - permission_cost = "AWSBillingReadOnlyAccess" in policy_names - - if permission_reader and permission_cost: - permission_valid = True - logs = "ViewOnlyAccess and AWSBillingReadOnlyAccess policies validated." - elif permission_reader: - logs = "ViewOnlyAccess policy validated, but AWSBillingReadOnlyAccess policy validation failed." - elif permission_cost: - logs = "AWSBillingReadOnlyAccess policy validated, but ViewOnlyAccess policy validation failed." - else: - logs = "Both ViewOnlyAccess and AWSBillingReadOnlyAccess policy validations failed." - else: - # Assumed roles/federated identities (e.g. GitHub OIDC) are validated by capabilities. - reader_error = "" - cost_error = "" - - try: - ec2_client = boto3.client( - "ec2", - aws_access_key_id=provider_details["accessKey"], - aws_secret_access_key=provider_details["secretKey"], - aws_session_token=provider_details.get("sessionToken"), - region_name=provider_details["region"], - ) - ec2_client.describe_regions() - permission_reader = True - except Exception as e: - reader_error = str(e) - - try: - ce_client = boto3.client( - "ce", - aws_access_key_id=provider_details["accessKey"], - aws_secret_access_key=provider_details["secretKey"], - aws_session_token=provider_details.get("sessionToken"), - region_name="us-east-1", - ) - today = datetime.now(timezone.utc).date() - ce_client.get_cost_and_usage( - TimePeriod={ - "Start": today.replace(day=1).isoformat(), - "End": (today + timedelta(days=1)).isoformat(), - }, - Granularity="MONTHLY", - Metrics=["UnblendedCost"], - ) - permission_cost = True - except Exception as e: - cost_error = str(e) - - if permission_reader and permission_cost: - permission_valid = True - logs = ( - "AWS role/federated capability checks validated " - "(ec2:DescribeRegions and ce:GetCostAndUsage)." - ) - elif permission_reader: - logs = "AWS capability checks: ec2:DescribeRegions validated, but ce:GetCostAndUsage failed." - if cost_error: - logs += f" Details: {cost_error}" - elif permission_cost: - logs = "AWS capability checks: ce:GetCostAndUsage validated, but ec2:DescribeRegions failed." - if reader_error: - logs += f" Details: {reader_error}" - else: - logs = "AWS capability checks failed for both ec2:DescribeRegions and ce:GetCostAndUsage." - details = ", ".join(d for d in [reader_error, cost_error] if d) - if details: - logs += f" Details: {details}" - - except NoCredentialsError as e: - # Expected outcome of a permission test; surfaced to the user via `logs`. - logs = f"AWS credentials validation failed: {str(e)}" - logger.warning(logs) - except Exception as e: - logs = f"AWS permission test failed: {str(e)}" - logger.error(logs) - - permission_valid = permission_reader and permission_cost - - return permission_valid, permission_reader, permission_cost, logs - - -# Stage 3 -def create_resource_inventory( - cloud_service_provider: int, - provider_details: dict[str, Any], - report_path: str, - raw_data_path: str, -) -> dict[str, Any]: - # Copy assets and datasets folders data - copy_assets(report_path, cloud_service_provider) - - try: - - if provider_details.get("tfstatePath"): # Terraform / OpenTofu state file - coverage = build_tfstate_resource_inventory( - cloud_service_provider, provider_details, report_path, raw_data_path - ) - return { - "success": True, - "logs": "Resource inventory created successfully.", - "coverage": coverage, - } - elif cloud_service_provider == 1: # Azure - build_azure_resource_inventory( - cloud_service_provider, provider_details, report_path, raw_data_path - ) - elif cloud_service_provider == 2: # AWS - build_aws_resource_inventory( - cloud_service_provider, provider_details, report_path, raw_data_path - ) - - return {"success": True, "logs": "Resource inventory created successfully."} - - except Exception as e: - logger.error(f"Error creating resource inventory: {str(e)}", exc_info=True) - # Do not raise the exception here; just return the error information - return {"success": False, "logs": str(e)} - - -# Stage 4 -def create_cost_inventory( - cloud_service_provider: int, - provider_details: dict[str, Any], - report_path: str, - raw_data_path: str, -) -> dict[str, Any]: - try: - if cloud_service_provider == 1: # Azure - build_azure_cost_inventory( - cloud_service_provider, provider_details, report_path, raw_data_path - ) - elif cloud_service_provider == 2: # AWS - build_aws_cost_inventory( - cloud_service_provider, provider_details, report_path, raw_data_path - ) - - return {"success": True, "logs": "Cost inventory created successfully."} - - except Exception as e: - logger.error(f"Error creating cost inventory: {str(e)}", exc_info=True) - return {"success": False, "logs": str(e)} - - -# Stage 5 - Online -def sync_assessment( - report_path: str, - name: str, - started_at: int, - metadata: dict[str, Any], - mode: str, - token: str | None, -) -> dict[str, Any]: - if mode != "online" or not token: - return { - "success": True, - "online": False, - "payload": None, - "logs": "offline – sync skipped.", - } - - result = post_assessment( - name=name, - started_at=started_at, - report_path=report_path, - meta=metadata, - token=token, - ) - - if not result.get("success"): - return { - "success": False, - "online": True, - "payload": None, - "logs": f"Assessment sync failed: {result.get('logs')}", - } - - logger.debug(result) - - try: - payload = result["payload"].get("data", {}) - server_risks = payload.get("risk_inventory", []) - - rows = [] - for entry in server_risks: - rid = entry["id"] - impacted = entry.get("impacted_resources", []) - if impacted: - for rt in impacted: - rows.append((rt, rid)) - else: - rows.append(("null", rid)) - - db_path = os.path.join(report_path, "data", "assessment.db") - with connect(db_path=db_path) as conn: - cursor = conn.cursor() - cursor.executemany( - """ - INSERT INTO risk_inventory (resource_type, risk) - VALUES (?, ?) - """, - rows, - ) - conn.commit() - - except Exception as e: - logger.error("Error saving server risks to local DB: %s", str(e), exc_info=True) - return { - "success": False, - "online": True, - "payload": None, - "logs": f"Failed to store server risks: {str(e)}", - } - - try: - scoring = payload.get("scoring_data") - if scoring: - db_path = os.path.join(report_path, "data", "assessment.db") - with connect(db_path=db_path) as conn: - cursor = conn.cursor() - cursor.execute( - """ - INSERT INTO scoring_data (exit_score, human_score, technology_score, operational_score) - VALUES (?, ?, ?, ?) - """, - ( - int(scoring["exit_score"]), - int(scoring["human_score"]), - int(scoring["technology_score"]), - int(scoring["operational_score"]), - ), - ) - conn.commit() - logger.debug("Scoring data saved to local DB.") - - except Exception as e: - logger.error("Error saving scoring data to local DB: %s", str(e), exc_info=True) - return { - "success": False, - "online": True, - "payload": None, - "logs": f"Failed to store scoring data: {str(e)}", - } - - return result - - -# Stage 5 - Offline -def perform_risk_assessment( - exit_strategy: int, report_path: str, mode: str -) -> dict[str, Any]: - - if mode != "offline": - logger.debug("Online mode – skipping local risk assessment.") - return {"success": True, "logs": "online mode – local risk skipped."} - - try: - # Define the database path - db_path = os.path.join(report_path, "data", "assessment.db") - - # Load data from the database - resource_inventory = load_data("resource_inventory", db_path=db_path) - alternatives = load_data("alternative", db_path=db_path) - alternative_technologies = load_data("alternativetechnology", db_path=db_path) - - # Initialize risk inventory - risk_inventory = [] - - # Calculate the total count of resources across all types - total_resource_count = sum(item["count"] for item in resource_inventory) - - # Calculate total number of distinct resource types - distinct_resource_types = set( - item["resource_type"] for item in resource_inventory - ) - total_resource_types = len(distinct_resource_types) - - # Process each resource by `resource_type` - for resource_data in resource_inventory: - resource_type_id = str( - resource_data["resource_type"] - ) # Convert to string for consistent comparison - - # Filter alternatives for the current resource_type and exit strategy - relevant_alternatives = [ - alt - for alt in alternatives - if str(alt["resource_type"]) == resource_type_id - and str(alt["strategy_type"]) == str(exit_strategy) - ] - alternative_count = len(relevant_alternatives) - - # Count alternatives with support - support_count = sum( - 1 - for alt in relevant_alternatives - if any( - tech["id"] == alt["alternative_technology"] - and tech["support_plan"] == "t" - for tech in alternative_technologies - ) - ) - - # Determine risks based on criteria, using resource_type_id in output - if 1 <= alternative_count < 3: - risk_inventory.append({"resource_type": resource_type_id, "risk": "1"}) - if alternative_count == 0: - risk_inventory.append({"resource_type": resource_type_id, "risk": "2"}) - if 1 <= support_count < 3: - risk_inventory.append({"resource_type": resource_type_id, "risk": "3"}) - if support_count == 0: - risk_inventory.append({"resource_type": resource_type_id, "risk": "4"}) - - # Check for risks based on total resource count across all types - if 15 < total_resource_count <= 30: - risk_inventory.append({"resource_type": "null", "risk": "5"}) - elif total_resource_count > 30: - risk_inventory.append({"resource_type": "null", "risk": "6"}) - - # Check for risks based on total number of resource types - if 15 < total_resource_types <= 30: - risk_inventory.append({"resource_type": "null", "risk": "7"}) - elif total_resource_types > 30: - risk_inventory.append({"resource_type": "null", "risk": "8"}) - - # Insert risk inventory into the database - with connect(db_path=db_path) as conn: - cursor = conn.cursor() - cursor.executemany( - """ - INSERT INTO risk_inventory (resource_type, risk) - VALUES (?, ?) - """, - [(entry["resource_type"], entry["risk"]) for entry in risk_inventory], - ) - conn.commit() - - return {"success": True, "logs": "Risk assessment completed successfully."} - - except Exception as e: - logger.error(f"Error performing risk assessment: {str(e)}", exc_info=True) - return {"success": False, "logs": str(e)} - - -def _load_tfstate_scope(raw_data_path: str) -> dict[str, Any] | None: - # A missing or unreadable manifest must not fail report generation. - manifest_path = os.path.join(raw_data_path, "tfstate_manifest.json") - try: - with open(manifest_path, "r", encoding="utf-8") as manifest_file: - return json.load(manifest_file).get("scope") - except (OSError, json.JSONDecodeError) as e: - logger.warning("Could not read the tfstate manifest for report scope: %s", e) - return None - - -# Stage 6 -def generate_report( - cloud_service_provider: int, - provider_details: dict[str, Any], - exit_strategy: int, - assessment_type: int, - name: str, - report_path: str, - raw_data_path: str, -) -> dict[str, Any]: - try: - db_path = os.path.join(report_path, "data", "assessment.db") - - # Load data - resource_type_mapping = { - str(item["id"]): item for item in load_data("resourcetype", db_path=db_path) - } - risk_definitions = load_data("risk", db_path=db_path) - alternatives = load_data("alternative", db_path=db_path) - alternative_technologies = load_data("alternativetechnology", db_path=db_path) - alternative_technology_organizations = load_data( - "alternativetechnologyorganization", db_path=db_path - ) - resource_inventory = load_data("resource_inventory", db_path=db_path) - cost_data = load_data("cost_inventory", db_path=db_path) - risk_data = load_data("risk_inventory", db_path=db_path) - scoring_data = load_data("scoring_data", db_path=db_path) - - # Timestamp - timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") - - metadata = { - "name": name, - "cloud_service_provider": cloud_service_provider, - "exit_strategy": exit_strategy, - "assessment_type": assessment_type, - "timestamp": timestamp, - } - - # Handle scoring_data - if isinstance(scoring_data, list): - if len(scoring_data) == 1: - scoring_data = scoring_data[0] - elif len(scoring_data) == 0: - scoring_data = None - else: - logger.warning( - "Unexpected multiple rows in scoring_data: %d", len(scoring_data) - ) - scoring_data = scoring_data[0] - - # Generate Outputs - reports = {} - - # Generate HTML report - reports["HTML"] = generate_html_report( - report_path, - metadata, - resource_type_mapping, - resource_inventory, - cost_data, - scoring_data, - risk_data, - risk_definitions, - alternatives, - alternative_technologies, - exit_strategy, - alternative_technology_organizations, - ) - - # Generate PDF report - tfstate_scope = ( - _load_tfstate_scope(raw_data_path) - if provider_details.get("tfstatePath") - else None - ) - reports["PDF"] = generate_pdf_report( - provider_details, - report_path, - metadata, - resource_type_mapping, - resource_inventory, - cost_data, - scoring_data, - risk_data, - risk_definitions, - alternatives, - alternative_technologies, - exit_strategy, - tfstate_scope=tfstate_scope, - ) - - # Generate JSON report - reports["JSON"] = generate_json_report( - raw_data_path, - metadata, - resource_type_mapping, - resource_inventory, - cost_data, - scoring_data, - risk_data, - risk_definitions, - alternatives, - alternative_technologies, - exit_strategy, - ) - - return {"success": True, "reports": reports} - - except Exception as e: - return {"success": False, "logs": f"Error generating report: {str(e)}"} diff --git a/core/utils.py b/core/utils.py deleted file mode 100644 index 6d8ea38..0000000 --- a/core/utils.py +++ /dev/null @@ -1,72 +0,0 @@ -# core/utils.py -import os -import shutil -import logging - -logger = logging.getLogger("core.engine.utils") - -# Icon folder per cloud service provider id -PROVIDER_ICON_DIRS = {1: "azure", 2: "aws"} - -# Icon folders every report needs, whichever provider was assessed -SHARED_ICON_DIRS = ("severity", "misc") - - -def _png_only(src: str, names: list[str]) -> list[str]: - # Keep directories so copytree still walks the whole tree, drop every - # file that isn't a PNG. The renderers never load an icon SVG. - return [ - name - for name in names - if not os.path.isdir(os.path.join(src, name)) - and not name.lower().endswith(".png") - ] - - -def _icon_dirs(cloud_service_provider: int) -> list[str]: - provider_dir = PROVIDER_ICON_DIRS.get(cloud_service_provider) - - if provider_dir is None: - logger.warning( - "Unknown cloud service provider %s, copying every provider icon set", - cloud_service_provider, - ) - return [*PROVIDER_ICON_DIRS.values(), *SHARED_ICON_DIRS] - - return [provider_dir, *SHARED_ICON_DIRS] - - -def copy_assets(report_path: str, cloud_service_provider: int) -> None: - assets_path = os.path.join(report_path, "assets") - - # Create the 'assets' directory if it doesn't exist - os.makedirs(assets_path, exist_ok=True) - - for folder in ("css", "img"): - src_path = os.path.join("assets", folder) - dest_path = os.path.join(assets_path, folder) - - # Only copy if the destination doesn't already exist - if not os.path.exists(dest_path): - shutil.copytree(src_path, dest_path, dirs_exist_ok=True) - - # Only the assessed provider's icons travel with the report - for folder in _icon_dirs(cloud_service_provider): - src_path = os.path.join("assets", "icons", folder) - dest_path = os.path.join(assets_path, "icons", folder) - - # Only copy if the destination doesn't already exist - if not os.path.exists(dest_path): - shutil.copytree(src_path, dest_path, ignore=_png_only, dirs_exist_ok=True) - - # Copy datasets/data.db to data/assessment.db - db_src_path = "datasets/data.db" - db_dest_dir = os.path.join(report_path, "data") - db_dest_path = os.path.join(db_dest_dir, "assessment.db") - - # Create the 'data' directory if it doesn't exist - os.makedirs(db_dest_dir, exist_ok=True) - - # Only copy if the destination doesn't already exist - if not os.path.exists(db_dest_path): - shutil.copyfile(db_src_path, db_dest_path) diff --git a/core/utils_aws.py b/core/utils_aws.py deleted file mode 100644 index cb151a4..0000000 --- a/core/utils_aws.py +++ /dev/null @@ -1,423 +0,0 @@ -# core/utils_aws.py -import boto3 -import json -import os -import logging -import sqlite3 -from typing import Any -from datetime import date, datetime, timezone -from collections import defaultdict -from dateutil.relativedelta import relativedelta -from botocore.config import Config - -from .utils_db import connect, load_data - -logger = logging.getLogger("core.engine.aws") - -AWS_RETRY_CONFIG = Config(retries={"mode": "adaptive", "max_attempts": 8}) - -# Upper bound on the items collected for a single resource type. Some list -# operations (e.g. describe_images, describe_snapshots) return every publicly -# shared resource in the region when called without an owner filter, which would -# otherwise stall the scan and blow up the raw-data file. -MAX_ITEMS_PER_RESOURCE_TYPE = 50_000 - - -def extract_result_path(container: Any, result_path: list) -> list: - if isinstance(result_path, str): - result_path = [result_path] - value = container - for key in result_path: - if not isinstance(value, dict): - return [] - value = value.get(key) - return value if isinstance(value, list) else [] - - -def paginate( - client: Any, - operation_name: str, - result_path: list, - max_items: int = MAX_ITEMS_PER_RESOURCE_TYPE, - **kwargs: Any, -) -> list: - items: list = [] - for page in client.get_paginator(operation_name).paginate(**kwargs): - items.extend(extract_result_path(page, result_path)) - if len(items) >= max_items: - return items[:max_items] - return items - - -def paginate_or_call( - client: Any, - operation_name: str, - result_path: list, - max_items: int = MAX_ITEMS_PER_RESOURCE_TYPE, - **kwargs: Any, -) -> list: - """paginate() when boto3 supports it for this operation, else a single call.""" - if client.can_paginate(operation_name): - return paginate(client, operation_name, result_path, max_items, **kwargs) - response = getattr(client, operation_name)(**kwargs) - return extract_result_path(response, result_path)[:max_items] - - -def parse_resource_type_params(raw_params: Any) -> dict: - if isinstance(raw_params, dict): - return raw_params - try: - parsed = json.loads(raw_params or "{}") - except (TypeError, ValueError): - return {} - return parsed if isinstance(parsed, dict) else {} - - -def resolve_aws_call_spec(resource_type_code: str, params: dict) -> dict | None: - service = params.get("service") - operation = params.get("operation") - if service and operation: - return { - "source": "params", - "service": service, - "operation": operation, - "result_path": list(params.get("result_path") or []), - "kwargs": dict(params.get("kwargs") or {}), - } - - parts = resource_type_code.split(".") - if len(parts) >= 4 and parts[0] == "AWS": - return { - "source": "code", - "service": parts[1], - "operation": parts[2], - "result_path": [part.strip() for part in parts[3:]], - "kwargs": {}, - } - - return None - - -def convert_datetime(obj: Any) -> Any: - if isinstance(obj, dict): - for k, v in obj.items(): - obj[k] = convert_datetime(v) - elif isinstance(obj, list): - for i in range(len(obj)): - obj[i] = convert_datetime(obj[i]) - elif isinstance(obj, datetime): - return obj.isoformat() - return obj - - -def build_aws_resource_inventory( - cloud_service_provider: int, - provider_details: dict[str, Any], - report_path: str, - raw_data_path: str, -) -> None: - try: - access_key = provider_details["accessKey"] - secret_key = provider_details["secretKey"] - region = provider_details["region"] - - session = boto3.Session( - aws_access_key_id=access_key, - aws_secret_access_key=secret_key, - aws_session_token=provider_details.get("sessionToken"), - region_name=region, - ) - - db_path = os.path.join(report_path, "data", "assessment.db") - - # Load the ResourceType mapping - resource_type_mapping = { - item["code"]: { - "id": item["id"], - "name": item["name"], - "params": item.get("params"), - } - for item in load_data("resourcetype") - if item["csp"] == 2 and item["status"] == "t" - } - - # Save raw data for debugging and auditing purposes - raw_data = [] - - # Aggregate resources by type and location - aggregated_resources = defaultdict(int) - - # How each catalogue row was resolved, summarised into run.log once the - # sweep is done. Without it a scan result cannot be traced back to the - # master data that produced it. - spec_sources = defaultdict(int) - - # Iterate through each resource type in the JSON - for idx, (resource_type_code, resource_info) in enumerate( - resource_type_mapping.items(), start=1 - ): - params = parse_resource_type_params(resource_info.get("params")) - spec = resolve_aws_call_spec(resource_type_code, params) - if spec is None: - if len(resource_type_code.split(".")) == 2: - # Service-level placeholder (e.g. AWS.iam) with nothing to - # call yet -- intentional, so keep it off the console. - spec_sources["placeholder"] += 1 - logger.debug( - "No call spec for placeholder resource type %s. Skipping.", - resource_type_code, - ) - else: - spec_sources["invalid"] += 1 - logger.warning( - "Invalid resource type format: %s. Skipping.", - resource_type_code, - ) - continue - - service_name = spec["service"] - operation_name = spec["operation"] - result_path = spec["result_path"] - call_kwargs = spec["kwargs"] - - spec_sources[spec["source"]] += 1 - logger.debug( - "Resolved %s from %s -> %s.%s result_path=%s kwargs=%s", - resource_type_code, - spec["source"], - service_name, - operation_name, - result_path, - call_kwargs, - ) - - try: - client = session.client( - service_name, region_name=region, config=AWS_RETRY_CONFIG - ) - if not hasattr(client, operation_name): - # logger.error(f"Operation {operation_name} does not exist for service {service_name}") - continue - - resources = paginate_or_call( - client, - operation_name, - result_path, - MAX_ITEMS_PER_RESOURCE_TYPE, - **call_kwargs, - ) - if len(resources) >= MAX_ITEMS_PER_RESOURCE_TYPE: - logger.warning( - "Item cap of %d reached for %s.%s; results truncated.", - MAX_ITEMS_PER_RESOURCE_TYPE, - service_name, - operation_name, - ) - - # Aggregate the resources - for resource in resources: - aggregated_resources[(resource_type_code, region)] += 1 - - # Store raw data - raw_data.append( - { - "service": service_name, - "operation": operation_name, - "resources": resources, - } - ) - - except Exception as exc: - # Expected for services the caller can't access or that aren't - # available in a region. Keep at DEBUG (run.log only) so it never - # floods the console, while still distinguishing a failed service - # from an empty one. The message alone is the useful signal; the - # botocore stack is noise, so no exc_info here. - logger.debug( - "Error processing %s.%s in %s: %s", - service_name, - operation_name, - region, - exc, - ) - continue - - logger.info( - "Resource type resolution: %d of %d rows scanned (%d from params, " - "%d from code), %d placeholders skipped, %d invalid.", - spec_sources["params"] + spec_sources["code"], - len(resource_type_mapping), - spec_sources["params"], - spec_sources["code"], - spec_sources["placeholder"], - spec_sources["invalid"], - ) - - # Save raw data to a JSON file - raw_data = convert_datetime(raw_data) - - raw_file_path = os.path.join(raw_data_path, "resource_inventory_raw_data.json") - with open(raw_file_path, "w", encoding="utf-8") as raw_file: - json.dump(raw_data, raw_file, indent=4) - - # Insert aggregated data into SQLite - with connect(db_path=db_path) as conn: - cursor = conn.cursor() - - for ( - resource_type_code, - resource_location, - ), resource_count in aggregated_resources.items(): - try: - # Map resource type code to resource_type_id - resource_info = resource_type_mapping.get(resource_type_code) - if not resource_info: - # logger.warning(f"Resource type {resource_type_code} not found in resourcetype mapping. Skipping.") - continue - - resource_type_id = resource_info["id"] - - cursor.execute( - """ - INSERT INTO resource_inventory (resource_type, location, count) - VALUES (?, ?, ?) - ON CONFLICT(resource_type, location) DO UPDATE SET count = excluded.count - """, - (resource_type_id, resource_location, resource_count), - ) - except sqlite3.Error as e: - logger.error( - f"SQLite error while processing aggregated resource: {e}", - exc_info=True, - ) - except Exception as e: - logger.error( - f"Unexpected error while processing aggregated resource: {e}", - exc_info=True, - ) - - conn.commit() - - except Exception as e: - logger.error(f"Error creating AWS resource inventory: {str(e)}", exc_info=True) - - -def get_missing_months_aws(processed_costs: set[str], max_months: int) -> list[date]: - current_date = datetime.now(timezone.utc).date().replace(day=1) - processed_months = { - datetime.strptime(month_str, "%Y-%m-%d").date().replace(day=1) - for month_str in processed_costs - } - missing_months = [] - - for i in range(max_months): - check_date = current_date - relativedelta(months=i) - if check_date not in processed_months: - missing_months.append(check_date) - - return missing_months - - -def build_aws_cost_inventory( - cloud_service_provider: int, - provider_details: dict[str, Any], - report_path: str, - raw_data_path: str, -) -> None: - try: - session = boto3.Session( - aws_access_key_id=provider_details["accessKey"], - aws_secret_access_key=provider_details["secretKey"], - aws_session_token=provider_details.get("sessionToken"), - region_name=provider_details["region"], - ) - cost_explorer = session.client( - "ce", region_name="us-east-1", config=AWS_RETRY_CONFIG - ) - - db_path = os.path.join(report_path, "data", "assessment.db") - - end_time = date.today().replace(day=1) + relativedelta(months=1) - start_time = end_time - relativedelta(months=6) - - cost_and_usage = cost_explorer.get_cost_and_usage( - TimePeriod={ - "Start": start_time.strftime("%Y-%m-%d"), - "End": end_time.strftime("%Y-%m-%d"), - }, - Granularity="MONTHLY", - Metrics=["UnblendedCost"], - GroupBy=[{"Type": "DIMENSION", "Key": "SERVICE"}], - Filter={ - "Dimensions": {"Key": "REGION", "Values": [provider_details["region"]]} - }, - ) - - cost_inventory_raw_path = os.path.join( - raw_data_path, "cost_inventory_raw_data.json" - ) - with open(cost_inventory_raw_path, "w", encoding="utf-8") as raw_file: - json.dump(cost_and_usage, raw_file, indent=4) - - # Insert structured data into SQLite - currency = "USD" - with connect(db_path=db_path) as conn: - cursor = conn.cursor() - - for result in cost_and_usage["ResultsByTime"]: - month_str = result["TimePeriod"]["Start"] - total_cost = sum( - float(group["Metrics"]["UnblendedCost"]["Amount"]) - for group in result["Groups"] - ) - currency = ( - result["Groups"][0]["Metrics"]["UnblendedCost"]["Unit"] - if result["Groups"] - else "USD" - ) - month_date = ( - datetime.strptime(month_str, "%Y-%m-%d") - .date() - .replace(day=1) - .isoformat() - ) - - # Insert or update the cost data for the month - cursor.execute( - """ - INSERT INTO cost_inventory (month, cost, currency) - VALUES (?, ?, ?) - ON CONFLICT(month) DO UPDATE SET - cost = excluded.cost, - currency = excluded.currency - """, - (month_date, total_cost, currency), - ) - - # Handle missing months - structured_months = { - datetime.strptime(result["TimePeriod"]["Start"], "%Y-%m-%d").date() - for result in cost_and_usage["ResultsByTime"] - } - missing_months = get_missing_months_aws( - {month.isoformat() for month in structured_months}, 6 - ) - - for missing_month in missing_months: - cursor.execute( - """ - INSERT INTO cost_inventory (month, cost, currency) - VALUES (?, 0.00, ?) - ON CONFLICT(month) DO UPDATE SET - currency = excluded.currency - """, - (missing_month.isoformat(), currency), - ) - - conn.commit() - - except sqlite3.Error as e: - logger.error(f"SQLite error: {str(e)}", exc_info=True) - except Exception as e: - logger.error(f"Error creating AWS cost inventory: {str(e)}", exc_info=True) - raise diff --git a/core/utils_azure.py b/core/utils_azure.py deleted file mode 100644 index 2820568..0000000 --- a/core/utils_azure.py +++ /dev/null @@ -1,265 +0,0 @@ -# core/utils_azure.py -import json -import os -import logging -import sqlite3 -from typing import Any -from datetime import date, datetime -from dateutil.relativedelta import relativedelta -from collections import defaultdict -from azure.identity import ClientSecretCredential -from azure.mgmt.resource import ResourceManagementClient -from azure.mgmt.costmanagement import CostManagementClient -from azure.mgmt.costmanagement.models import QueryDefinition, TimeframeType -from azure.core.exceptions import AzureError, ClientAuthenticationError - -from .utils_db import connect, load_data - -logger = logging.getLogger("core.engine.azure") -logging.getLogger("azure").setLevel(logging.WARNING) - - -def is_resource_inventory_empty( - credential: Any, subscription_id: str, resource_group_name: str -) -> bool: - try: - resource_client = ResourceManagementClient(credential, subscription_id) - # logger.info("Checking Azure resource inventory...") - resources = list( - resource_client.resources.list_by_resource_group(resource_group_name) - ) - if not resources: - # logger.info("No resources found in the resource group.") - return True - else: - # logger.info("Resources found in the resource group.") - return False - except AzureError as e: - # Re-raised to build_azure_resource_inventory, which logs at ERROR with - # the traceback; keep this at DEBUG to avoid double-logging. - logger.debug( - f"Error checking Azure resource inventory: {str(e)}", exc_info=True - ) - raise - - -def build_azure_resource_inventory( - cloud_service_provider: int, - provider_details: dict[str, Any], - report_path: str, - raw_data_path: str, -) -> None: - try: - # Use DefaultAzureCredential if provided, otherwise fall back to ClientSecretCredential - credential = provider_details.get("credential") or ClientSecretCredential( - tenant_id=provider_details["tenantId"], - client_id=provider_details["clientId"], - client_secret=provider_details["clientSecret"], - ) - subscription_id = provider_details["subscriptionId"] - resource_group_name = provider_details["resourceGroupName"] - - db_path = os.path.join(report_path, "data", "assessment.db") - - # Check if resource inventory is empty - if is_resource_inventory_empty( - credential, subscription_id, resource_group_name - ): - logger.warning( - "The selected resource group does not contain any resources." - ) - return - - resource_client = ResourceManagementClient(credential, subscription_id) - - # Fetch resources and serialize to raw JSON - resources = list( - resource_client.resources.list_by_resource_group(resource_group_name) - ) - raw_data = [resource.serialize(True) for resource in resources] - - # Save raw data to a JSON file - raw_file_path = os.path.join(raw_data_path, "resource_inventory_raw_data.json") - with open(raw_file_path, "w", encoding="utf-8") as raw_file: - json.dump(raw_data, raw_file, indent=4) - - # Load resource type mapping from the assessment database - resource_type_mapping = getattr( - build_azure_resource_inventory, "_resource_type_cache", None - ) - if resource_type_mapping is None: - resource_type_mapping = { - item["code"].strip().lower(): {"id": item["id"], "name": item["name"]} - for item in load_data("resourcetype", db_path=db_path) - if item["csp"] == 1 and item["status"] == "t" - } - build_azure_resource_inventory._resource_type_cache = resource_type_mapping - - # Aggregate resources by type and location - aggregated_resources = defaultdict(int) - for resource in resources: - resource_type_code = resource.type.strip().lower() - resource_location = resource.location.strip().lower() - aggregated_resources[(resource_type_code, resource_location)] += 1 - - # Insert data into SQLite - with connect(db_path=db_path) as conn: - cursor = conn.cursor() - data_to_insert = [ - ( - resource_type_mapping[resource_type_code]["id"], - resource_location, - resource_count, - ) - for ( - resource_type_code, - resource_location, - ), resource_count in aggregated_resources.items() - if resource_type_code in resource_type_mapping - ] - cursor.executemany( - """ - INSERT INTO resource_inventory (resource_type, location, count) - VALUES (?, ?, ?) - ON CONFLICT(resource_type, location) DO UPDATE SET count = excluded.count - """, - data_to_insert, - ) - conn.commit() - - except ClientAuthenticationError as e: - logger.error(f"Azure authentication error: {str(e)}", exc_info=True) - raise - except sqlite3.Error as e: - logger.error(f"SQLite error: {str(e)}", exc_info=True) - raise - except Exception as e: - logger.error(f"Error fetching Azure resources: {str(e)}", exc_info=True) - raise - - -def get_missing_months_azure(processed_costs: set[str], months_back: int) -> set[date]: - today = date.today() - start_date = today.replace(day=1) - relativedelta(months=months_back - 1) - all_months = { - (start_date + relativedelta(months=i)).replace(day=1) - for i in range(months_back) - } - - processed_months = set() - for month_str in processed_costs: - try: - # Attempt parsing with full timestamp format - month_date = ( - datetime.strptime(month_str, "%Y-%m-%dT%H:%M:%S").date().replace(day=1) - ) - except ValueError: - # Fallback to date-only format if full timestamp fails - month_date = datetime.strptime(month_str, "%Y-%m-%d").date().replace(day=1) - processed_months.add(month_date) - - return all_months - processed_months - - -def build_azure_cost_inventory( - cloud_service_provider: int, - provider_details: dict[str, Any], - report_path: str, - raw_data_path: str, -) -> None: - try: - # Use DefaultAzureCredential if provided, otherwise fall back to ClientSecretCredential - credential = provider_details.get("credential") or ClientSecretCredential( - tenant_id=provider_details["tenantId"], - client_id=provider_details["clientId"], - client_secret=provider_details["clientSecret"], - ) - cost_management_client = CostManagementClient( - credential, base_url="https://management.azure.com" - ) - - db_path = os.path.join(report_path, "data", "assessment.db") - - end_time = date.today() - months_back = 6 - start_time = end_time.replace(day=1) - relativedelta(months=months_back - 1) - - query = QueryDefinition( - type="Usage", - timeframe=TimeframeType.CUSTOM, - time_period={ - "from": start_time.strftime("%Y-%m-%dT00:00:00Z"), - "to": end_time.strftime("%Y-%m-%dT00:00:00Z"), - }, - dataset={ - "granularity": "Monthly", - "aggregation": {"totalCost": {"name": "Cost", "function": "Sum"}}, - }, - ) - - cost_data = cost_management_client.query.usage( - f'/subscriptions/{provider_details["subscriptionId"]}/resourceGroups/{provider_details["resourceGroupName"]}', - query, - ) - - cost_inventory_raw_path = os.path.join( - raw_data_path, "cost_inventory_raw_data.json" - ) - with open(cost_inventory_raw_path, "w", encoding="utf-8") as raw_file: - json.dump(cost_data.as_dict(), raw_file, indent=4) - - # Insert structured cost data into SQLite - currency = "USD" - with connect(db_path=db_path) as conn: - cursor = conn.cursor() - - for row in cost_data.rows: - cost, month_str, currency = row - month_date = ( - datetime.strptime(month_str, "%Y-%m-%dT%H:%M:%S") - .date() - .replace(day=1) - .isoformat() - ) - - # Insert or update cost data - cursor.execute( - """ - INSERT INTO cost_inventory (month, cost, currency) - VALUES (?, ?, ?) - ON CONFLICT(month) DO UPDATE SET - cost = excluded.cost, - currency = excluded.currency - """, - (month_date, cost, currency), - ) - - # Extract months already in the cost data - structured_months = { - datetime.strptime(row[1], "%Y-%m-%dT%H:%M:%S").date() - for row in cost_data.rows - } - - # Identify missing months and insert with zero cost - missing_months = get_missing_months_azure( - {month.isoformat() for month in structured_months}, 6 - ) - for missing_month in missing_months: - cursor.execute( - """ - INSERT INTO cost_inventory (month, cost, currency) - VALUES (?, 0.00, ?) - ON CONFLICT(month) DO UPDATE SET - currency = excluded.currency - """, - (missing_month.isoformat(), currency), - ) - - conn.commit() - - except sqlite3.Error as e: - logger.error(f"SQLite error: {str(e)}", exc_info=True) - raise - except Exception as e: - logger.error(f"Error creating Azure cost inventory: {str(e)}", exc_info=True) - raise diff --git a/core/utils_db.py b/core/utils_db.py deleted file mode 100644 index 2ef045d..0000000 --- a/core/utils_db.py +++ /dev/null @@ -1,92 +0,0 @@ -# core/utils_db.py -import sqlite3 -import logging - -# Configure logger for database operations (level left to the root handlers) -logger = logging.getLogger("core.engine.db") - -# Default master database -MASTER_DATABASE = "datasets/data.db" - -ALLOWED_TABLES = { - "resourcetype", - "resourcetype_data", - "resource_inventory", - "egress_inventory", - "egress_inventory_tier", - "cost_inventory", - "risk_inventory", - "scoring_data", - "alternative", - "alternativetechnology", - "alternativetechnologyorganization", - "risk", - "egresspricing", -} - - -def connect(db_path=MASTER_DATABASE): - try: - conn = sqlite3.connect(db_path) - return conn - except sqlite3.Error as e: - logger.debug(f"Error connecting to database: {e}") - raise - - -def load_data(table_name, db_path=MASTER_DATABASE): - if table_name not in ALLOWED_TABLES: - raise ValueError(f"Disallowed table name: {table_name}") - try: - conn = connect(db_path) - cursor = conn.cursor() - cursor.execute(f"SELECT * FROM {table_name}") - columns = [desc[0] for desc in cursor.description] - rows = cursor.fetchall() - conn.close() - return [dict(zip(columns, row)) for row in rows] - except sqlite3.Error as e: - logger.debug(f"Error loading data from table '{table_name}': {e}") - raise - - -def execute_query(query, params=None, db_path=MASTER_DATABASE): - try: - conn = connect(db_path) - cursor = conn.cursor() - cursor.execute(query, params or ()) - conn.commit() - rowcount = cursor.rowcount - conn.close() - return rowcount - except sqlite3.Error as e: - logger.debug(f"Error executing query: {e}") - raise - - -def fetch_one(query, params=None, db_path=MASTER_DATABASE): - try: - conn = connect(db_path) - cursor = conn.cursor() - cursor.execute(query, params or ()) - row = cursor.fetchone() - columns = [desc[0] for desc in cursor.description] - conn.close() - return dict(zip(columns, row)) if row else None - except sqlite3.Error as e: - logger.debug(f"Error fetching data: {e}") - raise - - -def fetch_all(query, params=None, db_path=MASTER_DATABASE): - try: - conn = connect(db_path) - cursor = conn.cursor() - cursor.execute(query, params or ()) - rows = cursor.fetchall() - columns = [desc[0] for desc in cursor.description] - conn.close() - return [dict(zip(columns, row)) for row in rows] - except sqlite3.Error as e: - logger.debug(f"Error fetching data: {e}") - raise diff --git a/core/utils_egress.py b/core/utils_egress.py deleted file mode 100644 index afb1ae2..0000000 --- a/core/utils_egress.py +++ /dev/null @@ -1,290 +0,0 @@ -# core/utils_egress.py -import json -import logging -import os -from typing import Any -from datetime import datetime, timezone - -from .utils_db import connect, load_data - -logger = logging.getLogger("core.engine.egress") - -GIB = 1024**3 - - -def _parse_params(raw_params: Any) -> dict[str, Any]: - if isinstance(raw_params, dict): - return raw_params - try: - parsed = json.loads(raw_params or "{}") - except (TypeError, ValueError): - return {} - return parsed if isinstance(parsed, dict) else {} - - -def load_egress_registry(csp: int) -> dict[str, dict[str, Any]]: - # Master data is the only source of truth: a missing table or an empty - # result fails the egress stage rather than producing a wrong estimate. - data_rows = load_data("resourcetype_data") - resource_types = load_data("resourcetype") - - types_by_id = {rt["id"]: rt for rt in resource_types} - - registry: dict[str, dict[str, Any]] = {} - for row in data_rows: - if row["status"] != "t": - continue - resource_type = types_by_id.get(row["resource_type"]) - if resource_type is None or resource_type["csp"] != csp: - continue - - # resourcetype.status is deliberately not filtered. Rows such as EBS - # Snapshots and Managed Disks ship as 'f' so they stay out of the - # inventory catalogue, but they still carry data that has to be egressed - # -- excluding them would silently drop a whole category from the - # estimate. - code = resource_type["code"] - # Azure matches against ARM resource.type, which is case-insensitive; - # AWS codes are handed to the collectors as the row identifier verbatim. - key = code.strip().lower() if csp == 1 else code - - params = _parse_params(row["params"]) - registry[key] = { - **params, - # Carried so the egress_inventory writer has the FK without a - # reverse lookup -- the Azure key is lowercased, the code is not. - "resource_type_id": resource_type["id"], - "category": row["data_category"], - "label": resource_type["name"], - "strategy": row["strategy"], - } - - if not registry: - raise ValueError( - f"No enabled resourcetype_data rows for CSP {csp}; " - "cannot build the egress registry." - ) - - return registry - - -def new_row( - resource_id: str, name: str, resource_type: str, label: str, category: str -) -> dict[str, Any]: - return { - "id": resource_id, - "name": name, - "type": resource_type, - "label": label, - "category": category, - "size_bytes": None, - "size_unknown": False, - "tier_bytes": None, - "flags": [], - "notes": [], - } - - -def format_bytes(size_bytes: int | float | None) -> str: - if size_bytes is None: - return "n/a" - value = float(size_bytes) - for unit in ("B", "KiB", "MiB", "GiB", "TiB"): - if value < 1024: - return f"{value:.1f} {unit}" - value /= 1024 - return f"{value:.1f} PiB" - - -def compute_totals( - rows: list[dict[str, Any]], archive_tiers: set[str] -) -> dict[str, Any]: - known_size_bytes = sum( - row["size_bytes"] for row in rows if row["size_bytes"] is not None - ) - archive_tier_bytes = sum( - size - for row in rows - for tier, size in (row["tier_bytes"] or {}).items() - if tier in archive_tiers - ) - unknown_count = sum(1 for row in rows if row["size_unknown"]) - return { - "known_size_bytes": known_size_bytes, - "archive_tier_bytes": archive_tier_bytes, - "resources_discovered": len(rows), - "resources_with_unknown_size": unknown_count, - } - - -def resource_type_id_for( - resource_type_ids: dict[str, int], row_type: str -) -> int | None: - # AWS rows carry the code verbatim; Azure rows carry the ARM resource.type, - # which the registry keys in lowercase. - return resource_type_ids.get(row_type) or resource_type_ids.get( - row_type.strip().lower() - ) - - -def public_row(row: dict[str, Any]) -> dict[str, Any]: - # The label is dropped: it is resourcetype.name, already in the database. - return { - "id": row["id"], - "name": row["name"], - "code": row["type"], - "category": row["category"], - "size_bytes": row["size_bytes"], - "size_unknown": row["size_unknown"], - "tier_bytes": row["tier_bytes"], - "flags": row["flags"], - "notes": row["notes"], - } - - -def write_egress_inventory( - rows: list[dict[str, Any]], - resource_type_ids: dict[str, int], - archive_tiers: set[str], - db_path: str, -) -> None: - conn = connect(db_path) - try: - cursor = conn.cursor() - for row in rows: - resource_type_id = resource_type_id_for(resource_type_ids, row["type"]) - if resource_type_id is None: - # Should not happen: every row is built from a registry entry. - logger.warning( - "No resourcetype id for %s; row not stored.", row["type"] - ) - continue - cursor.execute( - "INSERT INTO egress_inventory " - "(resource_type, name, size_bytes, size_unknown, flags, notes) " - "VALUES (?, ?, ?, ?, ?, ?)", - ( - resource_type_id, - row["name"], - row["size_bytes"], - int(bool(row["size_unknown"])), - json.dumps(row["flags"]), - json.dumps(row["notes"]), - ), - ) - inventory_id = cursor.lastrowid - for tier, size_bytes in (row["tier_bytes"] or {}).items(): - cursor.execute( - "INSERT INTO egress_inventory_tier " - "(egress_inventory_id, tier, size_bytes, is_archive) " - "VALUES (?, ?, ?, ?)", - (inventory_id, tier, size_bytes, int(tier in archive_tiers)), - ) - conn.commit() - finally: - conn.close() - - -def read_egress_inventory(db_path: str) -> tuple[list[dict[str, Any]], set[str]]: - # Rebuilds the row shape the report already consumes, so everything - # downstream of _load_estimate is unchanged. - resource_types = {rt["id"]: rt for rt in load_data("resourcetype", db_path=db_path)} - categories = { - rtd["resource_type"]: rtd["data_category"] - for rtd in load_data("resourcetype_data", db_path=db_path) - } - - tiers_by_row: dict[int, dict[str, int]] = {} - archive_tiers: set[str] = set() - for tier_row in load_data("egress_inventory_tier", db_path=db_path): - tiers_by_row.setdefault(tier_row["egress_inventory_id"], {})[ - tier_row["tier"] - ] = tier_row["size_bytes"] - if tier_row["is_archive"]: - archive_tiers.add(tier_row["tier"]) - - rows = [] - for record in load_data("egress_inventory", db_path=db_path): - resource_type = resource_types[record["resource_type"]] - row = new_row( - record["id"], - record["name"], - resource_type["code"], - resource_type["name"], - categories.get(record["resource_type"], ""), - ) - row["size_bytes"] = record["size_bytes"] - row["size_unknown"] = bool(record["size_unknown"]) - row["tier_bytes"] = tiers_by_row.get(record["id"]) or None - row["flags"] = json.loads(record["flags"] or "[]") - row["notes"] = json.loads(record["notes"] or "[]") - rows.append(row) - return rows, archive_tiers - - -def estimate_egress( - cloud_service_provider: int, - provider_details: dict[str, Any], - raw_data_path: str, - *, - report_path: str, - name: str, - exit_strategy: int, - assessment_type: int, -) -> dict[str, Any]: - try: - if cloud_service_provider == 1: # Azure - from .utils_egress_azure import collect_azure_egress - - rows, archive_tiers = collect_azure_egress(provider_details) - elif cloud_service_provider == 2: # AWS - from .utils_egress_aws import collect_aws_egress - - rows, archive_tiers = collect_aws_egress(provider_details) - else: - raise ValueError( - f"Unsupported cloud service provider: {cloud_service_provider}" - ) - - resource_type_ids = { - key: entry["resource_type_id"] - for key, entry in load_egress_registry(cloud_service_provider).items() - } - - json_payload = { - "meta": { - "name": name, - "cloud_service_provider": cloud_service_provider, - "exit_strategy": exit_strategy, - "assessment_type": assessment_type, - "timestamp": datetime.now(timezone.utc).strftime( - "%Y-%m-%d %H:%M:%S UTC" - ), - }, - "data": { - "resources": [public_row(row) for row in rows], - "totals": compute_totals(rows, archive_tiers), - }, - } - # The estimate is stored per assessment; the JSON stays as the raw - # artifact alongside the other raw dumps. - write_egress_inventory( - rows, - resource_type_ids, - archive_tiers, - os.path.join(report_path, "data", "assessment.db"), - ) - - json_path = os.path.join(raw_data_path, "egress_inventory_raw_data.json") - with open(json_path, "w", encoding="utf-8") as json_file: - json.dump(json_payload, json_file, indent=4) - - return { - "success": True, - "logs": "Egress estimation completed successfully.", - "json_path": json_path, - } - - except Exception as e: - logger.error(f"Error estimating egress: {str(e)}", exc_info=True) - return {"success": False, "logs": str(e)} diff --git a/core/utils_egress_aws.py b/core/utils_egress_aws.py deleted file mode 100644 index 0f253fa..0000000 --- a/core/utils_egress_aws.py +++ /dev/null @@ -1,465 +0,0 @@ -# core/utils_egress_aws.py -import boto3 -import logging -from typing import Any -from datetime import datetime, timedelta, timezone -from botocore.exceptions import BotoCoreError, ClientError - -from .utils_aws import ( - AWS_RETRY_CONFIG, - extract_result_path, - paginate_or_call, -) -from .utils_egress import GIB, format_bytes, load_egress_registry, new_row - -logger = logging.getLogger("core.engine.egress.aws") - -METRIC_DATA_BATCH_SIZE = 500 - -METRICS_LOOKBACK_DAYS = 3 - -ARCHIVE_TIERS = {"Archive", "Glacier", "Deep Archive"} - -S3_STORAGE_TYPE_TIERS = { - "StandardStorage": "Standard", - "ReducedRedundancyStorage": "Standard", - "ExpressOneZone": "Standard", - "StandardIAStorage": "Standard-IA", - "StandardIASizeOverhead": "Standard-IA", - "OneZoneIAStorage": "One Zone-IA", - "OneZoneIASizeOverhead": "One Zone-IA", - "IntelligentTieringFAStorage": "Intelligent-Tiering", - "IntelligentTieringIAStorage": "Intelligent-Tiering", - "IntelligentTieringAIAStorage": "Intelligent-Tiering", - "IntelligentTieringAAStorage": "Archive", - "IntelligentTieringDAAStorage": "Deep Archive", - "GlacierInstantRetrievalStorage": "Glacier Instant Retrieval", - "GlacierInstantRetrievalSizeOverhead": "Glacier Instant Retrieval", - "GlacierStorage": "Glacier", - "GlacierStagingStorage": "Glacier", - "GlacierObjectOverhead": "Glacier", - "GlacierS3ObjectOverhead": "Glacier", - "DeepArchiveStorage": "Deep Archive", - "DeepArchiveObjectOverhead": "Deep Archive", - "DeepArchiveS3ObjectOverhead": "Deep Archive", - "DeepArchiveStagingStorage": "Deep Archive", -} - - -def fetch_latest_metric_values( - cloudwatch: Any, - metric_specs: list[dict[str, Any]], - *, - lookback_days: int = METRICS_LOOKBACK_DAYS, - period: int = 86400, -) -> dict[str, float | None] | None: - if not metric_specs: - return {} - try: - end = datetime.now(timezone.utc) - start = end - timedelta(days=lookback_days) - results: dict[str, float | None] = {spec["id"]: None for spec in metric_specs} - - for batch_start in range(0, len(metric_specs), METRIC_DATA_BATCH_SIZE): - batch = metric_specs[batch_start : batch_start + METRIC_DATA_BATCH_SIZE] - queries = [ - { - "Id": spec["id"], - "MetricStat": { - "Metric": { - "Namespace": spec["namespace"], - "MetricName": spec["metric_name"], - "Dimensions": spec["dimensions"], - }, - "Period": period, - "Stat": spec.get("stat", "Average"), - }, - "ReturnData": True, - } - for spec in batch - ] - kwargs = { - "MetricDataQueries": queries, - "StartTime": start, - "EndTime": end, - "ScanBy": "TimestampDescending", - } - while True: - response = cloudwatch.get_metric_data(**kwargs) - for series in response.get("MetricDataResults", []): - values = series.get("Values") or [] - if values and results.get(series["Id"]) is None: - results[series["Id"]] = float(values[0]) - next_token = response.get("NextToken") - if not next_token: - break - kwargs["NextToken"] = next_token - return results - except (BotoCoreError, ClientError) as e: - logger.debug("CloudWatch metrics request failed: %s", str(e)) - return None - - -SIZE_UNITS = { - "B": 1, - "KiB": 1024, - "MiB": 1024**2, - "GiB": GIB, - "TiB": 1024**4, -} - - -def _nested_get(item: dict[str, Any], path: Any) -> Any: - if isinstance(path, str): - path = [path] - value: Any = item - for key in path: - if not isinstance(value, dict): - return None - value = value.get(key) - return value - - -def _resource_identity(item: dict[str, Any], sizing: dict[str, Any]) -> tuple[str, str]: - # The row id prefers an ARN when the service reports one; the display name - # comes from a tag when the service tags resources, else from a field. - identifier = item[sizing["id_field"]] - name_tag = sizing.get("name_tag") - if name_tag: - name = next( - ( - tag["Value"] - for tag in item.get(sizing.get("tags_field", "Tags"), []) - if tag["Key"] == name_tag - ), - identifier, - ) - else: - name = item.get(sizing.get("name_field") or sizing["id_field"], identifier) - arn_field = sizing.get("arn_field") - row_id = item.get(arn_field, identifier) if arn_field else identifier - return row_id, name - - -def _enumeration_client(session: Any, region: str, entry: dict[str, Any]) -> Any: - return session.client( - entry["enumeration"]["service"], region_name=region, config=AWS_RETRY_CONFIG - ) - - -def _enumerate(session: Any, region: str, entry: dict[str, Any]) -> tuple[Any, list]: - # service/operation/result_path/kwargs all come from resourcetype_data, so a - # catalogue change does not need a code change. Sizing stays in the strategy. - enumeration = entry["enumeration"] - client = _enumeration_client(session, region, entry) - items = paginate_or_call( - client, - enumeration["operation"], - enumeration["result_path"], - **enumeration.get("kwargs", {}), - ) - return client, items - - -def _bucket_region(s3_client: Any, bucket_name: str) -> str: - location = s3_client.get_bucket_location(Bucket=bucket_name).get( - "LocationConstraint" - ) - if not location: - return "us-east-1" - if location == "EU": - return "eu-west-1" - return location - - -def _list_buckets_in_region( - s3_client: Any, region: str, enumeration: dict[str, Any] -) -> list[str]: - # The region filter is strategy logic, but the call itself is master data. - list_buckets = getattr(s3_client, enumeration["operation"]) - result_path = enumeration["result_path"] - kwargs = enumeration.get("kwargs", {}) - try: - response = list_buckets(BucketRegion=region, **kwargs) - return [bucket["Name"] for bucket in extract_result_path(response, result_path)] - except (BotoCoreError, ClientError) as e: - logger.debug("ListBuckets with BucketRegion filter failed: %s", str(e)) - - bucket_names = [] - for bucket in extract_result_path(list_buckets(**kwargs), result_path): - name = bucket["Name"] - bucket_region = bucket.get("BucketRegion") - if bucket_region is None: - try: - bucket_region = _bucket_region(s3_client, name) - except (BotoCoreError, ClientError) as e: - logger.debug("Skipping bucket %s: %s", name, str(e)) - continue - if bucket_region == region: - bucket_names.append(name) - return bucket_names - - -def _collect_s3_buckets( - session: Any, region: str, code: str, entry: dict[str, Any] -) -> list[dict[str, Any]]: - s3_client = _enumeration_client(session, region, entry) - cloudwatch = session.client( - "cloudwatch", region_name=region, config=AWS_RETRY_CONFIG - ) - - bucket_names = _list_buckets_in_region(s3_client, region, entry["enumeration"]) - - rows = [] - for name in bucket_names: - row = new_row( - f"arn:aws:s3:::{name}", name, code, entry["label"], entry["category"] - ) - - try: - metrics = cloudwatch.list_metrics( - Namespace="AWS/S3", - MetricName="BucketSizeBytes", - Dimensions=[{"Name": "BucketName", "Value": name}], - ).get("Metrics", []) - except (BotoCoreError, ClientError) as e: - logger.debug("Listing metrics failed for bucket %s: %s", name, str(e)) - metrics = [] - - specs = [] - spec_tiers = {} - for index, metric in enumerate(metrics): - storage_type = next( - ( - dimension["Value"] - for dimension in metric.get("Dimensions", []) - if dimension["Name"] == "StorageType" - ), - "Unknown", - ) - spec_id = f"q{index}" - specs.append( - { - "id": spec_id, - "namespace": "AWS/S3", - "metric_name": "BucketSizeBytes", - "dimensions": metric.get("Dimensions", []), - } - ) - spec_tiers[spec_id] = S3_STORAGE_TYPE_TIERS.get(storage_type, storage_type) - - values = fetch_latest_metric_values(cloudwatch, specs) - tier_bytes: dict[str, int] = {} - if values: - for spec_id, value in values.items(): - if value is None: - continue - tier = spec_tiers[spec_id] - tier_bytes[tier] = tier_bytes.get(tier, 0) + int(value) - - if tier_bytes: - row["size_bytes"] = sum(tier_bytes.values()) - row["tier_bytes"] = tier_bytes - archive_bytes = sum( - size for tier, size in tier_bytes.items() if tier in ARCHIVE_TIERS - ) - if archive_bytes: - row["flags"].append( - f"Archive-class: {format_bytes(archive_bytes)} (restore required)" - ) - else: - row["size_unknown"] = True - - try: - s3_client.get_bucket_replication(Bucket=name) - row["notes"].append( - "replication configured (replica buckets are counted separately)" - ) - except (BotoCoreError, ClientError): - pass - - rows.append(row) - return rows - - -def _collect_list_item_size( - session: Any, region: str, code: str, entry: dict[str, Any] -) -> list[dict[str, Any]]: - sizing = entry["sizing"] - multiplier = SIZE_UNITS[sizing.get("size_unit", "GiB")] - _, items = _enumerate(session, region, entry) - rows = [] - for item in items: - row_id, name = _resource_identity(item, sizing) - row = new_row(row_id, name, code, entry["label"], entry["category"]) - size = _nested_get(item, sizing["size_field"]) - if size: - row["size_bytes"] = int(size) * multiplier - row["flags"].extend(sizing.get("flags", [])) - row["notes"].extend(sizing.get("notes", [])) - else: - row["size_unknown"] = True - rows.append(row) - return rows - - -def _collect_not_sizeable( - session: Any, region: str, code: str, entry: dict[str, Any] -) -> list[dict[str, Any]]: - sizing = entry["sizing"] - _, items = _enumerate(session, region, entry) - rows = [] - for item in items: - row_id, name = _resource_identity(item, sizing) - row = new_row(row_id, name, code, entry["label"], entry["category"]) - row["flags"].extend(sizing.get("flags", [])) - count_field = sizing.get("count_field") - count = _nested_get(item, count_field) if count_field else None - if count: - row["notes"].append(sizing["count_note"].format(count=count)) - rows.append(row) - return rows - - -def _collect_rds_instances( - session: Any, region: str, code: str, entry: dict[str, Any] -) -> list[dict[str, Any]]: - _, instances = _enumerate(session, region, entry) - cloudwatch = session.client( - "cloudwatch", region_name=region, config=AWS_RETRY_CONFIG - ) - - rows = [] - specs = [] - spec_rows: dict[str, tuple[dict[str, Any], int]] = {} - for index, instance in enumerate(instances): - identifier = instance["DBInstanceIdentifier"] - row = new_row( - instance.get("DBInstanceArn", identifier), - identifier, - code, - entry["label"], - entry["category"], - ) - if (instance.get("Engine") or "").startswith("aurora"): - row["flags"].append("Aurora – cluster-level storage not sized") - rows.append(row) - continue - - allocated_bytes = int(instance.get("AllocatedStorage") or 0) * GIB - spec_id = f"q{index}" - specs.append( - { - "id": spec_id, - "namespace": "AWS/RDS", - "metric_name": "FreeStorageSpace", - "dimensions": [{"Name": "DBInstanceIdentifier", "Value": identifier}], - } - ) - spec_rows[spec_id] = (row, allocated_bytes) - rows.append(row) - - values = fetch_latest_metric_values(cloudwatch, specs, period=300) - for spec_id, (row, allocated_bytes) in spec_rows.items(): - free_bytes = values.get(spec_id) if values else None - if free_bytes is not None and allocated_bytes: - row["size_bytes"] = max(int(allocated_bytes - free_bytes), 0) - row["notes"].append(f"allocated: {format_bytes(allocated_bytes)}") - elif allocated_bytes: - row["size_bytes"] = allocated_bytes - row["flags"].append("allocated (upper bound)") - else: - row["size_unknown"] = True - return rows - - -def _collect_dynamodb_tables( - session: Any, region: str, code: str, entry: dict[str, Any] -) -> list[dict[str, Any]]: - dynamodb_client, table_names = _enumerate(session, region, entry) - rows = [] - for table_name in table_names: - try: - table = dynamodb_client.describe_table(TableName=table_name).get( - "Table", {} - ) - except (BotoCoreError, ClientError) as e: - logger.debug("Describing table %s failed: %s", table_name, str(e)) - row = new_row( - table_name, table_name, code, entry["label"], entry["category"] - ) - row["size_unknown"] = True - rows.append(row) - continue - - row = new_row( - table.get("TableArn", table_name), - table_name, - code, - entry["label"], - entry["category"], - ) - size_bytes = int(table.get("TableSizeBytes") or 0) - size_bytes += sum( - int(index.get("IndexSizeBytes") or 0) - for index in table.get("GlobalSecondaryIndexes", []) - ) - row["size_bytes"] = size_bytes - rows.append(row) - return rows - - -# Strategy names stay stable so master data and engine can be deployed -# independently; several of them share one parameterised collector. The -# service-neutral names are the ones to use for new master-data rows. -_STRATEGY_COLLECTORS = { - "s3_bucket_metrics": _collect_s3_buckets, - "rds_instances": _collect_rds_instances, - "dynamodb_tables": _collect_dynamodb_tables, - "list_item_size": _collect_list_item_size, - "not_sizeable": _collect_not_sizeable, - # Kept so existing rows keep working; prefer the two names above. - "ebs_volumes": _collect_list_item_size, - "ebs_snapshots": _collect_list_item_size, - "backup_vaults": _collect_not_sizeable, -} - - -def collect_aws_egress( - provider_details: dict[str, Any], -) -> tuple[list[dict[str, Any]], set[str]]: - region = provider_details["region"] - session = boto3.Session( - aws_access_key_id=provider_details["accessKey"], - aws_secret_access_key=provider_details["secretKey"], - aws_session_token=provider_details.get("sessionToken"), - region_name=region, - ) - - rows = [] - for code, entry in load_egress_registry(2).items(): - collector = _STRATEGY_COLLECTORS.get(entry["strategy"]) - if collector is None: - # Master data can ship a strategy ahead of the engine; skip that one - # resource type rather than aborting the whole egress run. - logger.warning( - "Unknown egress strategy %r for %s; skipping.", - entry["strategy"], - code, - ) - continue - try: - rows.extend(collector(session, region, code, entry)) - except Exception as e: - # One failing service must not abort the run, but a whole resource - # type dropping out of the estimate has to be visible in run.log. - logger.warning( - "Egress collection failed for %s (%s: %s); " - "this resource type is missing from the estimate.", - code, - type(e).__name__, - str(e), - exc_info=True, - ) - - return rows, ARCHIVE_TIERS diff --git a/core/utils_egress_azure.py b/core/utils_egress_azure.py deleted file mode 100644 index fb360bb..0000000 --- a/core/utils_egress_azure.py +++ /dev/null @@ -1,298 +0,0 @@ -# core/utils_egress_azure.py -import logging -import requests -from typing import Any -from datetime import datetime, timedelta, timezone -from azure.identity import ClientSecretCredential -from azure.mgmt.resource import ResourceManagementClient - -from .utils_egress import GIB, format_bytes, load_egress_registry, new_row - -logger = logging.getLogger("core.engine.egress.azure") - -VAULT_FINDING = ( - "Recovery Services vault detected: backup recovery points cannot be " - "exported from Azure. An exit means losing restore history or retaining " - "the vault until retention expires." -) - -METRICS_API_VERSION = "2018-01-01" -MANAGEMENT_SCOPE = "https://management.azure.com/.default" -MANAGEMENT_BASE_URL = "https://management.azure.com" - -ARCHIVE_TIERS = {"Archive"} - - -def filter_data_bearing_resources( - resources: list[Any], - registry: dict[str, dict[str, Any]], -) -> list[tuple[Any, dict[str, Any]]]: - matched = [] - for resource in resources: - entry = registry.get(resource.type.strip().lower()) - if entry: - matched.append((resource, entry)) - return matched - - -def _latest_average(datapoints: list[dict[str, Any]]) -> float | None: - for point in reversed(datapoints): - value = point.get("average") - if value is not None: - return float(value) - return None - - -def fetch_monitor_metrics( - credential: Any, - resource_id: str, - metric_names: list[str], - *, - dimension: str | None = None, - timeout: int = 30, -) -> dict[str, list[dict[str, Any]]] | None: - try: - token = credential.get_token(MANAGEMENT_SCOPE) - end = datetime.now(timezone.utc) - start = end - timedelta(days=2) - params = { - "api-version": METRICS_API_VERSION, - "metricnames": ",".join(metric_names), - "timespan": ( - f"{start.strftime('%Y-%m-%dT%H:%M:%SZ')}/" - f"{end.strftime('%Y-%m-%dT%H:%M:%SZ')}" - ), - "aggregation": "Average", - "interval": "PT1H", - } - if dimension: - params["$filter"] = f"{dimension} eq '*'" - response = requests.get( - f"{MANAGEMENT_BASE_URL}{resource_id}/providers/Microsoft.Insights/metrics", - headers={"Authorization": f"Bearer {token.token}"}, - params=params, - timeout=timeout, - ) - response.raise_for_status() - payload = response.json() - except requests.RequestException as e: - logger.debug("Metrics request failed for %s: %s", resource_id, str(e)) - return None - except Exception as e: - logger.debug("Metrics lookup failed for %s: %s", resource_id, str(e)) - return None - - result: dict[str, list[dict[str, Any]]] = {name: [] for name in metric_names} - for metric in payload.get("value", []): - name = metric.get("name", {}).get("value", "") - series_values = [] - for series in metric.get("timeseries", []): - dimension_value = None - if dimension: - for metadata in series.get("metadatavalues", []): - metadata_name = metadata.get("name", {}).get("value", "") - if metadata_name.lower() == dimension.lower(): - dimension_value = metadata.get("value") - value = _latest_average(series.get("data", [])) - if value is not None: - series_values.append({"dimension": dimension_value, "value": value}) - result[name] = series_values - return result - - -def _metric_total( - metrics: dict[str, list[dict[str, Any]]] | None, name: str -) -> float | None: - if not metrics or not metrics.get(name): - return None - return sum(point["value"] for point in metrics[name]) - - -def _base_row(resource: Any, entry: dict[str, Any]) -> dict[str, Any]: - row = new_row( - resource.id, resource.name, resource.type, entry["label"], entry["category"] - ) - row["findings"] = [] - return row - - -def _unknown_size_finding(resource: Any) -> dict[str, str]: - return { - "severity": "warning", - "resource": resource.name, - "message": ( - f"{resource.name}: size unknown – no metric datapoints in the last " - "2 days (capacity metrics update roughly daily); excluded from totals." - ), - } - - -def _collect_storage_account( - credential: Any, resource_client: Any, resource: Any, entry: dict[str, Any] -) -> dict[str, Any]: - row = _base_row(resource, entry) - - account_metrics = fetch_monitor_metrics(credential, resource.id, ["UsedCapacity"]) - used_capacity = _metric_total(account_metrics, "UsedCapacity") - - blob_metrics = fetch_monitor_metrics( - credential, - f"{resource.id}/blobServices/default", - ["BlobCapacity"], - dimension="Tier", - ) - tier_bytes: dict[str, int] = {} - if blob_metrics: - for point in blob_metrics.get("BlobCapacity", []): - tier = (point["dimension"] or "Unknown").capitalize() - tier_bytes[tier] = tier_bytes.get(tier, 0) + int(point["value"]) - - if used_capacity is not None: - row["size_bytes"] = int(used_capacity) - elif tier_bytes: - row["size_bytes"] = sum(tier_bytes.values()) - row["tier_bytes"] = tier_bytes or None - - archive_bytes = tier_bytes.get("Archive", 0) - if archive_bytes: - row["flags"].append( - f"Archive: {format_bytes(archive_bytes)} (rehydration required)" - ) - - sku_name = getattr(getattr(resource, "sku", None), "name", None) - if sku_name and any(geo in sku_name.upper() for geo in ("GRS", "GZRS")): - row["notes"].append(f"geo-replicated ({sku_name})") - row["findings"].append( - { - "severity": "info", - "resource": resource.name, - "message": ( - f"{resource.name}: SKU {sku_name} is geo-replicated; the " - "geo-secondary copy is not additional data to egress " - "(not double-counted)." - ), - } - ) - - if row["size_bytes"] is None: - row["size_unknown"] = True - row["findings"].append(_unknown_size_finding(resource)) - return row - - -def _collect_allocated_size( - credential: Any, resource_client: Any, resource: Any, entry: dict[str, Any] -) -> dict[str, Any]: - row = _base_row(resource, entry) - row["flags"].append("allocated (upper bound)") - - full_resource = resource_client.resources.get_by_id( - resource.id, entry["api_version"] - ) - size_gb = (full_resource.properties or {}).get(entry["size_property"]) - if size_gb: - row["size_bytes"] = int(size_gb) * GIB - else: - row["size_unknown"] = True - row["findings"].append(_unknown_size_finding(resource)) - return row - - -def _collect_monitor_metric( - credential: Any, resource_client: Any, resource: Any, entry: dict[str, Any] -) -> dict[str, Any]: - row = _base_row(resource, entry) - - metrics = fetch_monitor_metrics(credential, resource.id, entry["metrics"]) - values = [ - total - for name in entry["metrics"] - if (total := _metric_total(metrics, name)) is not None - ] - if values: - row["size_bytes"] = int(sum(values)) - else: - row["size_unknown"] = True - row["findings"].append(_unknown_size_finding(resource)) - return row - - -def _collect_vault( - credential: Any, resource_client: Any, resource: Any, entry: dict[str, Any] -) -> dict[str, Any]: - row = _base_row(resource, entry) - row["flags"].append("backup vault – not sized") - row["findings"].append( - { - "severity": "warning", - "resource": resource.name, - "message": f"{resource.name}: {VAULT_FINDING}", - } - ) - return row - - -_STRATEGY_COLLECTORS = { - "storage_account_metrics": _collect_storage_account, - "allocated_size_property": _collect_allocated_size, - "monitor_metric": _collect_monitor_metric, - "vault_warning": _collect_vault, -} - - -def build_egress_inventory( - credential: Any, resource_client: Any, resources: list[Any] -) -> tuple[list[dict[str, Any]], list[dict[str, str]]]: - rows = [] - findings = [] - registry = load_egress_registry(1) - for resource, entry in filter_data_bearing_resources(resources, registry): - collector = _STRATEGY_COLLECTORS.get(entry["strategy"]) - if collector is None: - # Master data can ship a strategy ahead of the engine; skip that one - # resource type rather than aborting the whole egress run. - logger.warning( - "Unknown egress strategy %r for %s; skipping.", - entry["strategy"], - resource.type, - ) - continue - try: - row = collector(credential, resource_client, resource, entry) - except Exception as e: - logger.debug( - "Egress sizing failed for %s: %s", resource.id, str(e), exc_info=True - ) - row = _base_row(resource, entry) - row["size_unknown"] = True - row["flags"].append("size unavailable") - row["findings"].append( - { - "severity": "warning", - "resource": resource.name, - "message": f"{resource.name}: size lookup failed ({str(e)}).", - } - ) - findings.extend(row.pop("findings")) - rows.append(row) - return rows, findings - - -def collect_azure_egress( - provider_details: dict[str, Any], -) -> tuple[list[dict[str, Any]], set[str]]: - credential = provider_details.get("credential") or ClientSecretCredential( - tenant_id=provider_details["tenantId"], - client_id=provider_details["clientId"], - client_secret=provider_details["clientSecret"], - ) - subscription_id = provider_details["subscriptionId"] - resource_group_name = provider_details["resourceGroupName"] - - resource_client = ResourceManagementClient(credential, subscription_id) - resources = list( - resource_client.resources.list_by_resource_group(resource_group_name) - ) - - rows, _ = build_egress_inventory(credential, resource_client, resources) - return rows, ARCHIVE_TIERS diff --git a/core/utils_report.py b/core/utils_report.py deleted file mode 100644 index 0777439..0000000 --- a/core/utils_report.py +++ /dev/null @@ -1,841 +0,0 @@ -# core/utils_report.py -import os -import json -import logging -from typing import Any -from jinja2 import Environment - -# ReportLab -from reportlab.lib.pagesizes import A4 -from reportlab.lib.units import cm -from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle -from reportlab.lib import colors -from reportlab.lib.colors import HexColor -from reportlab.platypus import ( - SimpleDocTemplate, - Paragraph, - Spacer, - PageBreak, - Image, - Table, - TableStyle, -) - -# Utils -from core.utils_report_html import ( - transform_cost_inventory_for_html, - transform_risk_inventory_for_html, - transform_alt_tech_for_html, -) -from core.utils_report_json import ( - transform_resource_inventory_for_json, - transform_cost_inventory_for_json, - transform_risk_inventory_for_json, - transform_alt_tech_for_json, -) -from core.utils_report_pdf import ( - transform_resource_inventory_for_pdf, - transform_cost_inventory_for_pdf, - transform_risk_inventory_for_pdf, - transform_alt_tech_for_pdf, - draw_header_footer, - draw_risk_chart, - draw_cost_chart, - draw_vendor_lockin_radar_chart, - draw_exitscore_pie_chart, -) - -# Configure logger -logger = logging.getLogger("core.engine.report") - - -def anonymize_string(s: str, num_visible: int = 4) -> str: - if not isinstance(s, str): - return "N/A" - - if len(s) <= 2 * num_visible: - return "*" * len(s) - - middle_length = len(s) - 2 * num_visible - return f"{s[:num_visible]}{'*' * middle_length}{s[-num_visible:]}" - - -def generate_html_report( - report_path: str, - metadata: dict[str, Any], - resource_type_mapping: dict[str, dict[str, Any]], - resource_inventory: list[dict[str, Any]], - cost_data: list[dict[str, Any]], - scoring_data: dict[str, Any] | None, - risk_data: list[dict[str, Any]], - risk_definitions: list[dict[str, Any]], - alternatives: list[dict[str, Any]], - alternative_technologies: list[dict[str, Any]], - exit_strategy: int, - alternative_technology_organizations: list[dict[str, Any]] | None = None, -) -> str: - - # Transform resource inventory - resource_inventory_dict = { - str(item["resource_type"]): { - **item, - "name": resource_type_mapping.get(str(item["resource_type"]), {}).get( - "name", "Unknown Resource" - ), - "icon": "/assets" - + resource_type_mapping.get(str(item["resource_type"]), {}).get( - "icon", "/icons/default.png" - ), - } - for item in resource_inventory - } - - # Transform risks - risks, severity_counts = transform_risk_inventory_for_html( - risk_data, risk_definitions, resource_inventory_dict - ) - - # Transform costs - months, cost_values, total_cost, currency, currency_symbol = ( - transform_cost_inventory_for_html(cost_data) - ) - - # Transform resource data with names and icons - resource_counts = [] - for resource_type, resource in resource_inventory_dict.items(): - count = resource.get("count", 0) - resource_info = resource_type_mapping.get(str(resource_type), {}) - name = resource_info.get("name", "Unknown Resource") - icon = resource_info.get("icon", "assets/icons/default.png").lstrip("/") - - resource_counts.append( - {"resource_type": resource_type, "name": name, "icon": icon, "count": count} - ) - - # Calculate total resources - total_resources = sum(item["count"] for item in resource_counts) - - # Transform alternative technologies - alternative_technologies_data = transform_alt_tech_for_html( - resource_inventory, - alternatives, - alternative_technologies, - exit_strategy, - alternative_technology_organizations=alternative_technology_organizations, - ) - - # Scoring Data - scoring_context = { - "scoring_data": bool(scoring_data), - "exit_score": scoring_data.get("exit_score", 0) if scoring_data else 0, - "human": scoring_data.get("human_score", 0) if scoring_data else 0, - "technology": scoring_data.get("technology_score", 0) if scoring_data else 0, - "operational": scoring_data.get("operational_score", 0) if scoring_data else 0, - } - - # Render the HTML template - template_path = os.path.join("assets", "template", "index.html") - with open(template_path, "r") as file: - template_content = file.read() - - env = Environment(autoescape=True) - template = env.from_string(template_content) - html_content = template.render( - **metadata, - **scoring_context, - risks=risks, - high_risk_count=severity_counts["high"], - medium_risk_count=severity_counts["medium"], - low_risk_count=severity_counts["low"], - total_cost=total_cost, - months_json=json.dumps(months), - costs_json=json.dumps(cost_values), - currency_symbol=currency_symbol, - total_resources=total_resources, - resource_inventory=resource_counts, - alternative_technologies=alternative_technologies_data, - ) - - # Save HTML report - html_path = os.path.join(report_path, "index.html") - with open(html_path, "w") as report_file: - report_file.write(html_content) - - return html_path - - -def generate_json_report( - raw_data_path: str, - metadata: dict[str, Any], - resource_type_mapping: dict[str, dict[str, Any]], - resource_inventory: list[dict[str, Any]], - cost_data: list[dict[str, Any]], - scoring_data: dict[str, Any] | None, - risk_data: list[dict[str, Any]], - risk_definitions: list[dict[str, Any]], - alternatives: list[dict[str, Any]], - alternative_technologies: list[dict[str, Any]], - exit_strategy: int, -) -> str: - # Transform data for JSON - transformed_resource_inventory = transform_resource_inventory_for_json( - resource_inventory, resource_type_mapping - ) - transformed_cost_inventory = transform_cost_inventory_for_json(cost_data) - transformed_risk_inventory = transform_risk_inventory_for_json( - risk_data, risk_definitions, resource_inventory - ) - transformed_alt_tech = transform_alt_tech_for_json( - resource_inventory, alternatives, alternative_technologies, exit_strategy - ) - - # Build the JSON structure - report_json = { - "meta": metadata, - "data": { - "resource_inventory": transformed_resource_inventory, - "cost_inventory": transformed_cost_inventory, - "risk_inventory": transformed_risk_inventory, - }, - } - - # Add scoring_data only if present - if scoring_data: - report_json["data"]["scoring_data"] = { - "exit_score": scoring_data.get("exit_score", 0), - "human_score": scoring_data.get("human_score", 0), - "technology_score": scoring_data.get("technology_score", 0), - "operational_score": scoring_data.get("operational_score", 0), - } - - # Add alternative technologies - report_json["data"]["alternative_technologies"] = transformed_alt_tech - - # Save JSON to file - json_path = os.path.join(raw_data_path, "assessment_result.json") - with open(json_path, "w") as json_file: - json.dump(report_json, json_file, indent=4) - - return json_path - - -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)) - 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 _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 credential rows that are all 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"] == 1: # Azure - scope_data.extend( - [ - ["Tenant ID", provider_details.get("tenantId", "N/A")], - ["Client ID", provider_details.get("clientId", "N/A")], - [ - "Client Secret", - anonymize_string(provider_details.get("clientSecret", "N/A")), - ], - ["Subscription ID", provider_details.get("subscriptionId", "N/A")], - [ - "Resource Group Name", - provider_details.get("resourceGroupName", "N/A"), - ], - ] - ) - elif metadata["cloud_service_provider"] == 2: # AWS - scope_data.extend( - [ - ["Access Key", provider_details.get("accessKey", "N/A")], - [ - "Secret Key", - anonymize_string(provider_details.get("secretKey", "N/A")), - ], - ["Region", provider_details.get("region", "N/A")], - ] - ) - 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 _build_cost_section(cost_data, styles, content_style): - """Page 1: Cost chart and table.""" - content = [] - content.append(Paragraph("Costs", styles["Heading2"])) - - tablecontent_style = styles["BodyText"] - costs_block = "Examining the costs reveals the financial impact of the transition, allowing for more informed decision-making and strategic planning." - costs_paragraph = Paragraph(costs_block, tablecontent_style) - - months, costs, currency_symbol = transform_cost_inventory_for_pdf(cost_data) - cost_chart = draw_cost_chart(months, costs) - - costcharts_table_data = [ - [costs_paragraph, "", "", cost_chart, "", ""], - months, - [f"{currency_symbol} {cost:.2f}" for cost in costs], - ] - - costcharts_table = Table(costcharts_table_data, colWidths=[2.58333333333 * cm] * 6) - costcharts_table_style = TableStyle( - [ - ("SPAN", (0, 0), (2, 0)), - ("SPAN", (3, 0), (5, 0)), - ("VALIGN", (0, 0), (2, 0), "TOP"), - ("ALIGN", (0, 0), (2, 0), "LEFT"), - ("LEFTPADDING", (0, 0), (2, 0), 0), - ("RIGHTPADDING", (0, 0), (2, 0), 0), - ("TOPPADDING", (0, 0), (2, 0), 0), - ("BOTTOMPADDING", (0, 0), (2, 0), 0), - ("BACKGROUND", (0, 1), (-1, 1), HexColor("#115e59")), - ("TEXTCOLOR", (0, 1), (-1, 1), colors.white), - ("FONTNAME", (0, 1), (-1, 1), "Helvetica-Bold"), - ("ALIGN", (0, 1), (-1, 1), "CENTER"), - ("FONTNAME", (0, 2), (-1, 2), "Helvetica"), - ("ALIGN", (0, 2), (-1, 2), "CENTER"), - ("GRID", (0, 1), (-1, 2), 1, colors.black), - ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), - ("VALIGN", (0, 0), (2, 0), "TOP"), - ] - ) - costcharts_table.setStyle(costcharts_table_style) - content.append(costcharts_table) - content.append(PageBreak()) - return content - - -def _build_risk_section( - risk_data, risk_definitions, resource_inventory, report_path, styles, content_style -): - """Page 2: Risk Assessment chart and table.""" - content = [] - tablecontent_style = styles["BodyText"] - - content.append(Spacer(1, 12)) - content.append(Paragraph("Risk Assessment", styles["Heading1"])) - content.append( - Paragraph( - "The Risk Assessment provides a thorough evaluation of potential risks " - "associated with the cloud resources utilized in the project and the " - "alternative technologies available in the market:", - content_style, - ) - ) - content.append(Spacer(1, 12)) - - risks, severity_counts = transform_risk_inventory_for_pdf( - risk_data, risk_definitions, resource_inventory - ) - - risk_chart_data = { - "high": severity_counts["high"], - "medium": severity_counts["medium"], - "low": severity_counts["low"], - } - content.append(draw_risk_chart(risk_chart_data)) - content.append(Spacer(1, 12)) - - severity_order = {"high": 1, "medium": 2, "low": 3} - risks.sort(key=lambda r: severity_order[r["severity"]]) - - severity_icon_map = { - "high": (os.path.join(report_path, "assets/icons/severity/high.png"), 22.5, 12), - "medium": ( - os.path.join(report_path, "assets/icons/severity/medium.png"), - 39, - 12, - ), - "low": (os.path.join(report_path, "assets/icons/severity/low.png"), 20.5, 12), - } - - risk_table_data = [["#", "Risk name", "Impacted", "Severity"]] - for i, risk in enumerate(risks): - impacted_str = ( - str(risk["impacted_resources_count"]) - if risk["impacted_resources_count"] > 0 - else "-" - ) - severity_level = risk["severity"].lower() - icon_details = severity_icon_map.get(severity_level, None) - - if icon_details: - icon_path, icon_width, icon_height = icon_details - if os.path.exists(icon_path): - severity_icon = Image(icon_path, width=icon_width, height=icon_height) - else: - severity_icon = Paragraph("N/A", tablecontent_style) - else: - severity_icon = Paragraph("N/A", tablecontent_style) - - risk_table_data.append([str(i + 1), risk["name"], impacted_str, severity_icon]) - - total_risks = len(risks) - risk_table_data.append(["Total Risks", "", "", str(total_risks)]) - - risk_table = Table(risk_table_data, colWidths=[0.5 * cm, 10 * cm, 3 * cm, 2 * cm]) - risk_table_style_commands = [ - ("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), (0, -2), "LEFT"), - ("VALIGN", (0, 1), (0, -2), "MIDDLE"), - ("ALIGN", (1, 1), (1, -2), "LEFT"), - ("VALIGN", (1, 1), (1, -2), "MIDDLE"), - ("ALIGN", (2, 1), (2, -2), "CENTER"), - ("VALIGN", (2, 1), (2, -2), "MIDDLE"), - ("ALIGN", (3, 1), (3, -2), "CENTER"), - ("VALIGN", (3, 1), (3, -2), "MIDDLE"), - ("ALIGN", (-1, 0), (-1, 0), "CENTER"), - ("VALIGN", (-1, 0), (-1, 0), "MIDDLE"), - ("ALIGN", (-1, -1), (-1, -1), "CENTER"), - ("VALIGN", (-1, -1), (-1, -1), "MIDDLE"), - ] - risk_table.setStyle(TableStyle(risk_table_style_commands)) - content.append(risk_table) - content.append(PageBreak()) - return content - - -def _build_scoring_section(scoring_data, report_path, styles, content_style): - """Page 3: EscapeCloud Scoring (exit score gauge + vendor lock-in radar).""" - content = [] - content.append(Spacer(1, 12)) - content.append(Paragraph("EscapeCloud Scoring", styles["Heading1"])) - content.append(Paragraph("Scoring #1 - Exit Readiness", styles["Heading2"])) - content.append( - Paragraph( - "The Exit Readiness Grade provides a consolidated indicator of how prepared " - "the assessed environment is for a potential cloud exit or migration scenario:", - content_style, - ) - ) - content.append(Spacer(1, 12)) - - exit_score = scoring_data.get("exit_score", 0) if scoring_data else 0 - exit_score_chart = draw_exitscore_pie_chart(exit_score, size=5 * cm) - - exitscore_table_data = [ - ["", ""], - ["A - Seamless", ""], - ["B - Smooth Transition", ""], - ["C - Manageable", ""], - ["D - Challenging", ""], - ["E - Complex", ""], - ["F - Very Complex", ""], - ] - exitscore_table_data[1][1] = exit_score_chart - - exitscore_table = Table(exitscore_table_data, colWidths=[5 * cm, 10.5 * cm]) - exitscore_table_style = TableStyle( - [ - ("SPAN", (0, 0), (1, 0)), - ("BACKGROUND", (0, 0), (1, 0), HexColor("#115e59")), - ("TEXTCOLOR", (0, 0), (1, 0), colors.white), - ("FONTNAME", (0, 0), (1, 0), "Helvetica-Bold"), - ("ALIGN", (0, 0), (1, 0), "CENTER"), - ("VALIGN", (0, 0), (1, 0), "MIDDLE"), - ("SPAN", (1, 1), (1, 6)), - ("GRID", (0, 0), (-1, -1), 1, colors.black), - ("ALIGN", (0, 1), (0, 6), "LEFT"), - ("VALIGN", (0, 1), (0, 6), "MIDDLE"), - ("ALIGN", (1, 1), (1, 1), "CENTER"), - ("VALIGN", (1, 1), (1, 1), "MIDDLE"), - ] - ) - exitscore_table.setStyle(exitscore_table_style) - content.append(exitscore_table) - content.append(Spacer(1, 12)) - - # Vendor Lock-In Score - content.append(Paragraph("Scoring #2 - Vendor Lock-In Score", styles["Heading2"])) - content.append( - Paragraph( - "The following radar chart visualizes the assessment of alternative " - "technologies across three dimensions: Human (skills availability), " - "Technology (maturity and vendor stability), and Operational (ecosystem " - "and support services) — only where viable alternatives exist:", - content_style, - ) - ) - content.append(Spacer(1, 12)) - - human_score = scoring_data.get("human_score", 0) if scoring_data else 0 - technology_score = scoring_data.get("technology_score", 0) if scoring_data else 0 - operational_score = scoring_data.get("operational_score", 0) if scoring_data else 0 - - content.append( - draw_vendor_lockin_radar_chart(human_score, technology_score, operational_score) - ) - - vendor_lockin_table = Table( - [ - ["Human", "Technology", "Operational"], - [human_score, technology_score, operational_score], - ], - colWidths=[5 * cm, 5 * cm, 5 * cm], - ) - vendor_lockin_table_style = TableStyle( - [ - ("BACKGROUND", (0, 0), (-1, 0), HexColor("#115e59")), - ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), - ("ALIGN", (0, 0), (-1, 0), "CENTER"), - ("VALIGN", (0, 0), (-1, 0), "MIDDLE"), - ("ALIGN", (0, 0), (-1, -1), "CENTER"), - ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"), - ("GRID", (0, 0), (-1, -1), 1, colors.black), - ] - ) - vendor_lockin_table.setStyle(vendor_lockin_table_style) - content.append(vendor_lockin_table) - content.append(PageBreak()) - return content - - -def _build_resource_section( - resource_inventory, resource_type_mapping, report_path, styles, content_style -): - """Page 4: Resource Inventory table.""" - content = [] - content.append(Spacer(1, 12)) - content.append(Paragraph("Resource Inventory", styles["Heading1"])) - content.append( - Paragraph( - "The Resource Inventory provides a summary of the cloud resources " - "provisioned within the defined scope:", - content_style, - ) - ) - content.append(Spacer(1, 12)) - - resources = transform_resource_inventory_for_pdf( - resource_inventory, resource_type_mapping, report_path - ) - total_resources = sum(res["count"] for res in resources) - - resource_data = [["#", "Resource type", "", "No."]] - for res in resources: - resource_data.append( - [ - str(res["id"]), - res["resource_name"], - Image(res["icon_url"], width=20, height=20), - str(res["count"]), - ] - ) - resource_data.append(["Total Resources", "", "", str(total_resources)]) - - res_table = Table(resource_data, colWidths=[1 * cm, 11.5 * cm, 1.5 * cm, 1.5 * cm]) - res_table_style_commands = [ - ("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), (0, -2), "LEFT"), - ("VALIGN", (0, 1), (0, -2), "MIDDLE"), - ("ALIGN", (1, 1), (1, -2), "LEFT"), - ("VALIGN", (1, 1), (1, -2), "MIDDLE"), - ("ALIGN", (2, 1), (2, -2), "CENTER"), - ("VALIGN", (2, 1), (2, -2), "MIDDLE"), - ("ALIGN", (3, 1), (3, -2), "CENTER"), - ("VALIGN", (3, 1), (3, -2), "MIDDLE"), - ("ALIGN", (-1, 0), (-1, 0), "CENTER"), - ("VALIGN", (-1, 0), (-1, 0), "MIDDLE"), - ("ALIGN", (-1, -1), (-1, -1), "CENTER"), - ("VALIGN", (-1, -1), (-1, -1), "MIDDLE"), - ] - res_table.setStyle(TableStyle(res_table_style_commands)) - content.append(res_table) - content.append(PageBreak()) - return content - - -def _build_alt_tech_section( - resource_inventory, - resource_type_mapping, - alternatives, - alternative_technologies, - exit_strategy, - report_path, - styles, - content_style, -): - """Page 5: Alternative Technologies table.""" - content = [] - content.append(Spacer(1, 12)) - content.append(Paragraph("Alternative Technologies", styles["Heading1"])) - content.append( - Paragraph( - "The Alternative Technology provides a summary of the alternative technology " - "landscape for each identified resource in the Resource Inventory, based on " - "our dataset and market research. It also includes a count of the available " - "alternative technologies for each resource:", - content_style, - ) - ) - content.append(Spacer(1, 12)) - - alttech = transform_alt_tech_for_pdf( - resource_inventory, - resource_type_mapping, - alternatives, - alternative_technologies, - exit_strategy, - report_path, - ) - - alttech_data = [["#", "Resource type", "", "No."]] - for res in alttech: - alttech_data.append( - [ - str(res["id"]), - res["resource_name"], - Image(res["icon_url"], width=20, height=20) if res["icon_url"] else "", - str(res["count"]), - ] - ) - - alttech_table = Table( - alttech_data, colWidths=[1 * cm, 11.5 * cm, 1.5 * cm, 1.5 * cm] - ) - alttech_table_style_commands = [ - ("BACKGROUND", (0, 0), (-1, 0), HexColor("#115e59")), - ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), - ("BOX", (0, 0), (-1, -1), 1, HexColor("#000000")), - ("BOTTOMPADDING", (0, 1), (-1, -1), 6), - ("TOPPADDING", (0, 1), (-1, -1), 6), - ("ALIGN", (2, 0), (2, -1), "CENTER"), - ("VALIGN", (2, 0), (2, -1), "MIDDLE"), - ("ALIGN", (0, 1), (0, -1), "LEFT"), - ("VALIGN", (0, 1), (0, -1), "MIDDLE"), - ("ALIGN", (1, 1), (1, -1), "LEFT"), - ("VALIGN", (1, 1), (1, -1), "MIDDLE"), - ("ALIGN", (2, 1), (2, -1), "CENTER"), - ("VALIGN", (2, 1), (2, -1), "MIDDLE"), - ("ALIGN", (3, 1), (3, -1), "CENTER"), - ("VALIGN", (3, 1), (3, -1), "MIDDLE"), - ("ALIGN", (-1, 0), (-1, 0), "CENTER"), - ("VALIGN", (-1, 0), (-1, 0), "MIDDLE"), - ] - alttech_table.setStyle(TableStyle(alttech_table_style_commands)) - content.append(alttech_table) - content.append(PageBreak()) - return content - - -def generate_pdf_report( - provider_details: dict[str, Any], - report_path: str, - metadata: dict[str, Any], - resource_type_mapping: dict[str, Any], - resource_inventory: list[dict[str, Any]], - cost_data: list[dict[str, Any]], - scoring_data: dict[str, Any] | None, - risk_data: list[dict[str, Any]], - risk_definitions: list[dict[str, Any]], - alternatives: list[dict[str, Any]], - alternative_technologies: list[dict[str, Any]], - exit_strategy: int, - tfstate_scope: dict[str, Any] | None = None, -) -> str: - pdf_path = os.path.join(report_path, "report.pdf") - - def header_footer(canvas, doc): - draw_header_footer(report_path, canvas, doc) - - doc = SimpleDocTemplate( - pdf_path, pagesize=A4, title="EscapeCloud_-_Cloud_Exit_Assessment" - ) - 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(metadata, styles, content_style) - content += _build_scope_section( - metadata, provider_details, styles, content_style, tfstate_scope - ) - content += _build_cost_section(cost_data, styles, content_style) - content += _build_risk_section( - risk_data, - risk_definitions, - resource_inventory, - report_path, - styles, - content_style, - ) - if metadata.get("assessment_type") == 2: - content += _build_scoring_section( - scoring_data, - report_path, - styles, - content_style, - ) - content += _build_resource_section( - resource_inventory, - resource_type_mapping, - report_path, - styles, - content_style, - ) - content += _build_alt_tech_section( - resource_inventory, - resource_type_mapping, - alternatives, - alternative_technologies, - exit_strategy, - report_path, - styles, - content_style, - ) - - logger.debug("Building the PDF document...") - doc.build(content, onFirstPage=header_footer, onLaterPages=header_footer) - - return pdf_path diff --git a/core/utils_report_common.py b/core/utils_report_common.py deleted file mode 100644 index 8766613..0000000 --- a/core/utils_report_common.py +++ /dev/null @@ -1,269 +0,0 @@ -from collections import defaultdict -from datetime import datetime -from typing import Any - -CURRENCY_SYMBOLS = { - "USD": "$", - "GBP": "£", - "EUR": "€", -} - -EU_COUNTRY_CODES = { - "AT", - "BE", - "BG", - "HR", - "CY", - "CZ", - "DK", - "EE", - "FI", - "FR", - "DE", - "GR", - "HU", - "IE", - "IT", - "LV", - "LT", - "LU", - "MT", - "NL", - "PL", - "PT", - "RO", - "SK", - "SI", - "ES", - "SE", -} - -REGION_LABELS = { - "european-union": "European Union", - "united-kingdom": "United Kingdom", - "switzerland": "Switzerland", - "united-states": "United States", - "other": "Other", -} - - -def normalize_country_code(country_code: Any) -> str | None: - if not isinstance(country_code, str): - return None - normalized = country_code.strip().upper() - return normalized if len(normalized) == 2 and normalized.isalpha() else None - - -def country_code_to_region(country_code: Any) -> str: - normalized = normalize_country_code(country_code) - if not normalized: - return "other" - if normalized in EU_COUNTRY_CODES: - return "european-union" - if normalized == "GB": - return "united-kingdom" - if normalized == "CH": - return "switzerland" - if normalized == "US": - return "united-states" - return "other" - - -def country_code_to_flag(country_code: Any) -> str: - normalized = normalize_country_code(country_code) - if not normalized: - return "" - return chr(127397 + ord(normalized[0])) + chr(127397 + ord(normalized[1])) - - -def sort_cost_data(cost_data: list[dict[str, Any]]) -> list[dict[str, Any]]: - return sorted(cost_data, key=lambda x: datetime.strptime(x["month"], "%Y-%m-%d")) - - -def summarize_costs( - cost_data: list[dict[str, Any]], *, last_n: int | None = None -) -> tuple[list[str], list[float], float, str, str]: - sorted_costs = sort_cost_data(cost_data) - if last_n is not None: - sorted_costs = sorted_costs[-last_n:] - - months = [ - datetime.strptime(item["month"], "%Y-%m-%d").strftime("%b") - for item in sorted_costs - ] - values = [item["cost"] for item in sorted_costs] - total_cost = round(sum(values), 2) - - if sorted_costs: - currency_code = sorted_costs[0].get("currency", "USD") - else: - currency_code = "USD" - currency_symbol = CURRENCY_SYMBOLS.get(currency_code, currency_code) - - return months, values, total_cost, currency_code, currency_symbol - - -def summarize_risks( - risk_data: list[dict[str, Any]], - risk_definitions: list[dict[str, Any]], - *, - resource_name_map: dict[str, str] | None = None, - resource_id_map: dict[str, int] | None = None, -) -> tuple[list[dict[str, Any]], dict[str, int]]: - risk_def_map = {rd["id"]: rd for rd in risk_definitions} - severity_counts = {"high": 0, "medium": 0, "low": 0} - - grouped_risks = defaultdict( - lambda: { - "impacted_resource_types": set(), - "impacted_resources_count": 0, - "has_overall_risk": False, - } - ) - - for entry in risk_data: - risk_code = entry["risk"] - resource_type = entry["resource_type"] - - if resource_type is None or resource_type == "null": - grouped_risks[risk_code]["has_overall_risk"] = True - continue - - resource_type = str(resource_type) - grouped_risks[risk_code]["impacted_resource_types"].add(resource_type) - grouped_risks[risk_code]["impacted_resources_count"] += 1 - - summarized_risks = [] - for risk_code, risk_info in grouped_risks.items(): - risk_definition = risk_def_map.get(risk_code) - if not risk_definition: - continue - - severity = risk_definition["severity"] - if severity in severity_counts: - severity_counts[severity] += 1 - - resource_types = sorted(risk_info["impacted_resource_types"]) - resource_names = None - if resource_name_map is not None: - resource_names = [ - resource_name_map.get(resource_type, "Unknown Resource") - for resource_type in resource_types - ] - - resource_ids = None - if resource_id_map is not None: - resource_ids = [ - resource_id_map[resource_type] - for resource_type in resource_types - if resource_type in resource_id_map - ] - - impacted_resources_count = ( - None - if risk_info["has_overall_risk"] - else risk_info["impacted_resources_count"] - ) - - summarized_risks.append( - { - "id": risk_code, - "name": risk_definition["name"], - "description": risk_definition["description"], - "severity": severity, - "impacted_resource_types": resource_types, - "impacted_resources": resource_names, - "impacted_resource_ids": resource_ids, - "impacted_resources_count": impacted_resources_count, - } - ) - - return summarized_risks, severity_counts - - -def summarize_alternative_technologies( - resource_inventory: list[dict[str, Any]], - alternatives: list[dict[str, Any]], - alternative_technologies: list[dict[str, Any]], - exit_strategy: int, - alternative_technology_organizations: list[dict[str, Any]] | None = None, -) -> dict[str, list[dict[str, Any]]]: - active_technologies = { - tech["id"]: tech - for tech in alternative_technologies - if tech.get("status") == "t" - } - - grouped_alt_tech: dict[str, list[dict[str, Any]]] = { - str(resource["resource_type"]): [] for resource in resource_inventory - } - organization_by_id = { - org["id"]: org for org in (alternative_technology_organizations or []) - } - - for alt in alternatives: - if str(alt["strategy_type"]) != str(exit_strategy): - continue - - resource_type = str(alt["resource_type"]) - tech = active_technologies.get(alt["alternative_technology"]) - if not tech or resource_type not in grouped_alt_tech: - continue - organization = organization_by_id.get(tech.get("organization_id")) - organization_country_code = normalize_country_code( - organization.get("country_code") if organization else None - ) - organization_region = country_code_to_region(organization_country_code) - - grouped_alt_tech[resource_type].append( - { - "product_name": tech["product_name"], - "product_description": tech["product_description"], - "product_url": tech["product_url"], - "open_source": tech["open_source"] == "t", - "support_plan": tech["support_plan"] == "t", - "status": tech["status"] == "t", - "organization_name": ( - organization.get("name") if organization else "Unknown Organization" - ), - "organization_url": organization.get("url") if organization else None, - "organization_country_code": organization_country_code or "N/A", - "organization_region": organization_region, - "organization_region_label": REGION_LABELS.get( - organization_region, "Other" - ), - "organization_flag": country_code_to_flag(organization_country_code), - } - ) - - return grouped_alt_tech - - -def enrich_resource_inventory( - resource_inventory: list[dict[str, Any]], - resource_type_mapping: dict[str, dict[str, Any]], - *, - report_path: str | None = None, -) -> list[dict[str, Any]]: - enriched_resources = [] - for idx, resource in enumerate(resource_inventory): - resource_type = str(resource["resource_type"]) - resource_info = resource_type_mapping.get(resource_type, {}) - icon = resource_info.get("icon", "/icons/default.png") - - entry = { - "id": idx + 1, - "resource_type": resource_type, - "code": resource_info.get("code", "N/A"), - "resource_name": resource_info.get("name", "Unknown Resource"), - "icon": icon, - "location": resource.get("location", "Unknown"), - "count": resource.get("count", 0), - } - - if report_path is not None: - entry["icon_url"] = f"{report_path}/assets{icon}" - - enriched_resources.append(entry) - - return enriched_resources diff --git a/core/utils_report_egress.py b/core/utils_report_egress.py deleted file mode 100644 index 5b67e00..0000000 --- a/core/utils_report_egress.py +++ /dev/null @@ -1,953 +0,0 @@ -# core/utils_report_egress.py -import json -import logging -import os -import sqlite3 -from typing import Any -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 core.utils_db import load_data -from core.utils_egress import ( - GIB, - compute_totals, - format_bytes, - read_egress_inventory, -) -from core.utils_egress_aws import ARCHIVE_TIERS as AWS_ARCHIVE_TIERS -from core.utils_egress_azure import ARCHIVE_TIERS as AZURE_ARCHIVE_TIERS -from core.utils_report import ( - _build_scope_section, - _build_summary_section, - _default_table_style, -) -from core.utils_report_pdf import draw_header_footer - -PDF_HEADER_TITLE = "EscapeCloud Community Edition - Data & Egress" - -logger = logging.getLogger("core.engine.report_egress") - -DEFAULT_PRICING_ZONE = "zone1" -UNIT_DIVISORS = {"GB": 10**9, "GiB": 2**30} - -ARCHIVE_TIERS_BY_CSP = {1: AZURE_ARCHIVE_TIERS, 2: AWS_ARCHIVE_TIERS} - -CATEGORIES = { - "object": { - "label": "Object Storage", - "badge": "Object", - "color": "rgba(34, 197, 94, 1)", - "in_allocation": True, - }, - "block": { - "label": "Block (allocated)", - "badge": "Block", - "color": "rgba(83, 155, 255, 1)", - "in_allocation": True, - }, - "file": { - "label": "File Storage", - "badge": "File", - "color": "rgba(245, 158, 11, 1)", - "in_allocation": True, - }, - "database": { - "label": "Databases", - "badge": "Database", - "color": "rgba(168, 85, 247, 1)", - "in_allocation": True, - }, - "backup": { - "label": None, - "badge": "Backup", - "color": None, - "in_allocation": False, - }, -} -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: { - "internet_egress": "https://azure.microsoft.com/pricing/details/bandwidth/", - "archive_retrieval": ( - "https://azure.microsoft.com/pricing/details/storage/blobs/" - ), - }, - 2: { - "internet_egress": "https://aws.amazon.com/ec2/pricing/on-demand/", - "archive_retrieval": "https://aws.amazon.com/s3/pricing/", - }, -} - -FALLBACK_ICONS = { - "microsoft.compute/disks": "/icons/azure/compute/10032-icon-service-Disks.png", - "microsoft.compute/snapshots": ( - "/icons/azure/compute/10026-icon-service-Disks-Snapshots.png" - ), - "aws.ec2.describe_volumes.volumes": "/icons/aws/Storage/Elastic-Block-Store.png", - "aws.ec2.describe_snapshots.snapshots": ( - "/icons/aws/Storage/Elastic-Block-Store.png" - ), - "aws.backup.list_backup_vaults.backupvaultlist": "/icons/aws/Storage/Backup.png", -} -DEFAULT_ICON = "/icons/misc/no_image.png" - - -def _unit_divisor(unit: str) -> float: - return UNIT_DIVISORS.get(unit, GIB) - - -def load_pricing(report_path: str) -> list[dict[str, Any]]: - db_path = os.path.join(report_path, "data", "assessment.db") - try: - rows = load_data("egresspricing", db_path=db_path) - except sqlite3.Error: - # Runs created before the dataset shipped the egresspricing table; - # fall back to the master dataset download. - logger.debug("egresspricing missing from %s; using master dataset", db_path) - rows = load_data("egresspricing") - return [row for row in rows if not row.get("valid_to")] - - -def calculate_tiered_cost(total_units: float, tiers: list[dict[str, Any]]) -> float: - cost = 0.0 - for tier in sorted(tiers, key=lambda tier: tier["tier_from"]): - lower = tier["tier_from"] - upper = tier["tier_to"] - if total_units <= lower: - break - covered = total_units if upper is None else min(total_units, upper) - cost += (covered - lower) * tier["price_per_unit"] - return cost - - -def build_fee_estimate( - rows: list[dict[str, Any]], - totals: dict[str, Any], - cloud_service_provider: int, - prices: list[dict[str, Any]], -) -> tuple[float, dict[str, float | None]]: - active = [ - price - for price in prices - if price["csp"] == cloud_service_provider - and price["zone"] == DEFAULT_PRICING_ZONE - ] - internet_tiers = [ - price for price in active if price["component"] == "internet_egress" - ] - if not internet_tiers: - raise ValueError( - "No active internet egress pricing for cloud service provider " - f"{cloud_service_provider} in the egresspricing dataset." - ) - retrieval_prices = [ - price for price in active if price["component"] == "archive_retrieval" - ] - retrieval_rate_per_byte = 0.0 - if retrieval_prices: - retrieval = retrieval_prices[0] - retrieval_rate_per_byte = retrieval["price_per_unit"] / _unit_divisor( - retrieval["unit"] - ) - archive_tiers = ARCHIVE_TIERS_BY_CSP.get(cloud_service_provider, set()) - - total_bytes = totals["known_size_bytes"] - internet_divisor = _unit_divisor(internet_tiers[0]["unit"]) - internet_fee = calculate_tiered_cost(total_bytes / internet_divisor, internet_tiers) - retrieval_fee = totals["archive_tier_bytes"] * retrieval_rate_per_byte - total_fee = internet_fee + retrieval_fee - - fees_by_id: dict[str, float | None] = {} - for row in rows: - if row["size_bytes"] is None: - fees_by_id[row["id"]] = None - continue - share = (row["size_bytes"] / total_bytes) if total_bytes else 0.0 - row_archive_bytes = sum( - size - for tier, size in (row["tier_bytes"] or {}).items() - if tier in archive_tiers - ) - fees_by_id[row["id"]] = ( - internet_fee * share + row_archive_bytes * retrieval_rate_per_byte - ) - return total_fee, fees_by_id - - -def format_fee(fee: float | None) -> str: - if fee is None: - return "n/a" - return f"${fee:,.2f}" - - -def free_tier_limit_display( - prices: list[dict[str, Any]], cloud_service_provider: int -) -> str | None: - tiers = sorted( - ( - price - for price in prices - if price["csp"] == cloud_service_provider - and price["component"] == "internet_egress" - and price["zone"] == DEFAULT_PRICING_ZONE - ), - 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 None - - -def _summarize_totals( - totals: dict[str, Any], - total_fee: float, - prices: list[dict[str, Any]], - cloud_service_provider: int, -) -> dict[str, Any]: - free_tier_limit = free_tier_limit_display(prices, cloud_service_provider) - free_tier = ( - total_fee == 0 - and totals["known_size_bytes"] > 0 - 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"]) - if unknown_count > 0: - total_data_display = f"{total_data_display}+" - if not free_tier: - total_fee_display = f"{total_fee_display}+" - return { - "free_tier": free_tier, - "free_tier_limit": free_tier_limit, - "unknown_count": unknown_count, - "total_fee_display": total_fee_display, - "total_data_display": total_data_display, - } - - -def _build_icon_lookup(report_path: str) -> dict[str, str]: - db_path = os.path.join(report_path, "data", "assessment.db") - try: - return { - item["code"].strip().lower(): item["icon"] - for item in load_data("resourcetype", db_path=db_path) - if item.get("icon") - } - except Exception as e: - logger.debug("Resource type icon lookup unavailable: %s", str(e)) - return {} - - -def _resolve_icon(resource_type: str, icon_lookup: dict[str, str]) -> str: - code = resource_type.strip().lower() - candidate = code - while candidate: - if candidate in icon_lookup: - return icon_lookup[candidate] - if "/" not in candidate: - break - candidate = candidate.rsplit("/", 1)[0] - return FALLBACK_ICONS.get(code, DEFAULT_ICON) - - -def _build_allocation(rows: list[dict[str, Any]]) -> tuple[list, list, list]: - allocation_categories = { - key: info for key, info in CATEGORIES.items() if info["in_allocation"] - } - totals_by_category: dict[str, int] = {} - for row in rows: - if row["size_bytes"] is not None: - category = row.get("category") - if category in allocation_categories: - totals_by_category[category] = ( - totals_by_category.get(category, 0) + row["size_bytes"] - ) - - labels, values, colors = [], [], [] - for category, info in allocation_categories.items(): - size_bytes = totals_by_category.get(category, 0) - if size_bytes > 0: - labels.append(info["label"]) - values.append(size_bytes) - colors.append(info["color"]) - return labels, values, colors - - -def _build_type_groups( - rows: list[dict[str, Any]], - fees_by_id: dict[str, float | None], - icon_lookup: dict[str, str], -) -> list[dict[str, Any]]: - groups: dict[str, dict[str, Any]] = {} - for row in rows: - group = groups.setdefault( - row["label"], - { - "label": row["label"], - "icon": _resolve_icon(row["type"], icon_lookup), - "category": row["category"], - "category_label": ( - CATEGORIES.get(row["category"], {}).get("badge") or row["category"] - ), - "size_bytes": None, - "fee": None, - "resources": [], - }, - ) - if row["size_bytes"] is not None: - group["size_bytes"] = (group["size_bytes"] or 0) + row["size_bytes"] - fee = fees_by_id.get(row["id"]) - if fee is not None: - group["fee"] = (group["fee"] or 0.0) + fee - group["resources"].append( - { - "name": row["name"], - "fee_display": format_fee(fee) if fee is not None else None, - "detail": "; ".join(row["flags"] + row["notes"]), - } - ) - - for group in groups.values(): - group["size_display"] = format_bytes(group["size_bytes"]) - group["fee_display"] = format_fee(group["fee"]) - - return sorted( - groups.values(), - key=lambda group: ( - group["fee"] is None, - -(group["fee"] or 0.0), - -(group["size_bytes"] or 0), - ), - ) - - -def _load_estimate( - report_path: str, json_path: str -) -> tuple[dict, list, dict, dict, float, list]: - with open(json_path, "r", encoding="utf-8") as json_file: - payload = json.load(json_file) - - # Run metadata stays in the raw artifact; the resources come from the - # assessment database, which is the store of record for the estimate. - meta = payload["meta"] - db_path = os.path.join(report_path, "data", "assessment.db") - rows, archive_tiers = read_egress_inventory(db_path) - totals = compute_totals(rows, archive_tiers) - - pricing = load_pricing(report_path) - total_fee, fees_by_id = build_fee_estimate( - rows, totals, meta["cloud_service_provider"], pricing - ) - - icon_lookup = _build_icon_lookup(report_path) - type_groups = _build_type_groups(rows, fees_by_id, icon_lookup) - return meta, rows, totals, pricing, total_fee, type_groups - - -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 - ) - - summary = _summarize_totals( - totals, total_fee, pricing, meta["cloud_service_provider"] - ) - - allocation_labels, allocation_values, allocation_colors = _build_allocation( - rows - ) - - 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_path = os.path.join("assets", "template", "egress.html") - with open(template_path, "r") as file: - template_content = file.read() - - 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, - ) - - html_path = os.path.join(report_path, "egress.html") - with open(html_path, "w") 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) -> Any: - icon_path = os.path.join(report_path, "assets") + icon - if not os.path.exists(icon_path): - icon_path = os.path.join(report_path, "assets") + DEFAULT_ICON - if not os.path.exists(icon_path): - return "" - return Image(icon_path, width=20, height=20) - - -def _fee_breakdown_display(group: dict[str, Any]) -> str: - if group["fee"] is None: - return "n/a (not sized)" - return group["fee_display"] - - -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: - 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 - - -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, - ) - ) - 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 - - -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)." - ) - 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, - ) - ] - ) - 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)) - - 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), - ] - ) - ) - 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, - ) - ) - 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["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( - [ - ("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"), - ] - ) - ) - content.append(landscape_table) - content.append(PageBreak()) - return content - - -def _tier_display(row: dict[str, Any]) -> str: - unit = row["unit"] - if row["tier_from"] == 0 and row["tier_to"] is None: - return "all volumes" - if row["tier_from"] == 0 and row["price_per_unit"] == 0: - return f"first {row['tier_to']:,.0f} {unit} / month" - 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}" - - -def _price_display(row: dict[str, Any]) -> str: - if row["price_per_unit"] == 0: - return "Free" - return f"${row['price_per_unit']:g} / {row['unit']}" - - -def _build_pricing_basis_section(prices, cloud_service_provider, styles, content_style): - rows = sorted( - ( - price - for price in prices - if price["csp"] == cloud_service_provider - and price["zone"] == DEFAULT_PRICING_ZONE - and price["component"] in COMPONENT_LABELS - ), - key=lambda price: ( - list(COMPONENT_LABELS).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)) - - show_valid_from = len({row["valid_from"] for row in rows}) > 1 - header = ["#", "Component", "Tier", "Price"] - if show_valid_from: - header.append("Valid from") - table_data = [header] - for index, row in enumerate(rows, start=1): - table_row = [ - str(index), - COMPONENT_LABELS[row["component"]], - _tier_display(row), - _price_display(row), - ] - if show_valid_from: - table_row.append(row["valid_from"]) - table_data.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"), - ] - ) - ) - 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 - ] - if len(source_lines) == 1: - content.append(Paragraph(f"Source: {source_lines[0][1]}", footnote_style)) - 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 generate_egress_pdf_report( - report_path: str, json_path: str, provider_details: dict[str, Any] -) -> dict[str, Any]: - try: - meta, rows, totals, pricing, total_fee, type_groups = _load_estimate( - report_path, json_path - ) - - summary = _summarize_totals( - totals, total_fee, pricing, meta["cloud_service_provider"] - ) - - 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) - - 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, - ) - content += _build_pricing_basis_section( - pricing, meta["cloud_service_provider"], styles, content_style - ) - - doc.build(content, onFirstPage=header_footer, onLaterPages=header_footer) - - return { - "success": True, - "logs": "Egress PDF report generated successfully.", - "pdf_path": pdf_path, - } - - except Exception as e: - logger.error(f"Error generating egress PDF report: {str(e)}", exc_info=True) - return {"success": False, "logs": str(e)} diff --git a/core/utils_report_html.py b/core/utils_report_html.py deleted file mode 100644 index 56fc1ba..0000000 --- a/core/utils_report_html.py +++ /dev/null @@ -1,63 +0,0 @@ -# core/utils_report_html.py -import logging -from typing import Any - -from core.utils_report_common import ( - summarize_alternative_technologies, - summarize_costs, - summarize_risks, -) - -# Configure logger -logger = logging.getLogger("core.engine.report_html") - - -def transform_cost_inventory_for_html( - cost_data: list[dict[str, Any]], -) -> tuple[list[str], list[float], float, str, str]: - return summarize_costs(cost_data) - - -def transform_risk_inventory_for_html( - risk_data: list[dict[str, Any]], - risk_definitions: list[dict[str, Any]], - resource_inventory: dict[str, dict[str, Any]], -) -> tuple[list[dict[str, Any]], dict[str, int]]: - severity_order = {"high": 1, "medium": 2, "low": 3} - resource_name_map = { - str(key): value["name"] for key, value in resource_inventory.items() - } - risks, severity_counts = summarize_risks( - risk_data, - risk_definitions, - resource_name_map=resource_name_map, - ) - risks.sort(key=lambda x: severity_order.get(x["severity"], 4)) - return risks, severity_counts - - -def transform_alt_tech_for_html( - resource_inventory: list[dict[str, Any]], - alternatives: list[dict[str, Any]], - alternative_technologies: list[dict[str, Any]], - exit_strategy: int, - alternative_technology_organizations: list[dict[str, Any]] | None = None, -) -> list[dict[str, Any]]: - alt_tech_data = [] - grouped_alt_tech = summarize_alternative_technologies( - resource_inventory, - alternatives, - alternative_technologies, - exit_strategy, - alternative_technology_organizations=alternative_technology_organizations, - ) - for resource in resource_inventory: - resource_type = str(resource.get("resource_type")) - for tech in grouped_alt_tech.get(resource_type, []): - alt_tech_data.append( - { - "resource_type_id": resource.get("resource_type"), - **tech, - } - ) - return alt_tech_data diff --git a/core/utils_report_json.py b/core/utils_report_json.py deleted file mode 100644 index 93a7a7e..0000000 --- a/core/utils_report_json.py +++ /dev/null @@ -1,107 +0,0 @@ -# core/utils_report_json.py -import logging -from typing import Any - -from core.utils_report_common import ( - enrich_resource_inventory, - sort_cost_data, - summarize_alternative_technologies, - summarize_risks, -) - -# Configure logger -logger = logging.getLogger("core.engine.report_json") - - -def transform_resource_inventory_for_json( - resource_inventory: list[dict[str, Any]], - resource_type_mapping: dict[str, dict[str, Any]], -) -> list[dict[str, Any]]: - enriched_resources = enrich_resource_inventory( - resource_inventory, resource_type_mapping - ) - return [ - { - "id": resource["id"], - "code": resource["code"], - "resource_name": resource["resource_name"], - "location": resource["location"], - "count": resource["count"], - } - for resource in enriched_resources - ] - - -def transform_cost_inventory_for_json( - cost_data: list[dict[str, Any]], -) -> list[dict[str, Any]]: - sorted_cost_data = sort_cost_data(cost_data) - - cost_inventory = [ - { - "month": item["month"], - "cost": round(item["cost"], 2), - "currency": item["currency"], - } - for item in sorted_cost_data - ] - return cost_inventory - - -def transform_risk_inventory_for_json( - risk_data: list[dict[str, Any]], - risk_definitions: list[dict[str, Any]], - resource_inventory: list[dict[str, Any]], -) -> list[dict[str, Any]]: - resource_id_map = { - str(value["resource_type"]): key + 1 - for key, value in enumerate(resource_inventory) - } - risks, _ = summarize_risks( - risk_data, - risk_definitions, - resource_id_map=resource_id_map, - ) - return [ - { - "id": risk["id"], - "name": risk["name"], - "description": risk["description"], - "severity": risk["severity"], - "impacted_resources": risk["impacted_resource_ids"] or [], - "impacted_resources_count": risk["impacted_resources_count"], - } - for risk in risks - ] - - -def transform_alt_tech_for_json( - resource_inventory: list[dict[str, Any]], - alternatives: list[dict[str, Any]], - alternative_technologies: list[dict[str, Any]], - exit_strategy: int, -) -> dict[int, list[dict[str, Any]]]: - resource_id_map = { - str(value["resource_type"]): key + 1 - for key, value in enumerate(resource_inventory) - } - grouped_alt_tech = summarize_alternative_technologies( - resource_inventory, - alternatives, - alternative_technologies, - exit_strategy, - ) - grouped_alt_tech_data = { - resource_id: [] for resource_id in resource_id_map.values() - } - for resource_type, technologies in grouped_alt_tech.items(): - resource_id = resource_id_map.get(resource_type) - if not resource_id: - continue - grouped_alt_tech_data[resource_id] = [ - {"id": idx + 1, **tech} for idx, tech in enumerate(technologies) - ] - - return { - key: grouped_alt_tech_data[key] for key in sorted(grouped_alt_tech_data.keys()) - } diff --git a/core/utils_report_pdf.py b/core/utils_report_pdf.py deleted file mode 100644 index 48d86a1..0000000 --- a/core/utils_report_pdf.py +++ /dev/null @@ -1,537 +0,0 @@ -# core/utils_report_pdf.py -import math -import logging -from datetime import datetime -from typing import Any -from math import cos, sin, radians - -# ReportLab -from reportlab.lib.pagesizes import A4 -from reportlab.lib.units import cm -from reportlab.lib.styles import ParagraphStyle -from reportlab.lib import colors -from reportlab.lib.colors import HexColor -from reportlab.platypus import Paragraph, Image, Table, TableStyle -from reportlab.graphics.shapes import Drawing, Polygon, Line, Circle, Wedge, String -from reportlab.graphics.charts.legends import Legend -from reportlab.graphics.charts.piecharts import Pie -from reportlab.graphics.charts.barcharts import VerticalBarChart - -from core.utils_report_common import ( - enrich_resource_inventory, - summarize_alternative_technologies, - summarize_costs, - summarize_risks, -) - -# Configure logger -logger = logging.getLogger("core.engine.report_pdf") - - -def transform_resource_inventory_for_pdf( - resource_inventory: list, resource_type_mapping: dict[str, Any], report_path: str -) -> list[dict[str, Any]]: - enriched_resources = enrich_resource_inventory( - resource_inventory, - resource_type_mapping, - report_path=report_path, - ) - return [ - { - "id": resource["id"], - "resource_name": resource["resource_name"], - "icon_url": resource["icon_url"], - "location": resource["location"], - "count": resource["count"], - } - for resource in enriched_resources - ] - - -def transform_cost_inventory_for_pdf( - cost_data: list, -) -> tuple[list[str], list[float], str]: - months, costs, _, _, currency_symbol = summarize_costs(cost_data, last_n=6) - return months, costs, currency_symbol - - -def transform_risk_inventory_for_pdf( - risk_data: list, risk_definitions: list, resource_inventory: list -) -> tuple[list[dict[str, Any]], dict[str, int]]: - risks, severity_counts = summarize_risks(risk_data, risk_definitions) - return [ - { - "name": risk["name"], - "severity": risk["severity"], - "impacted_resources_count": risk["impacted_resources_count"] or 0, - } - for risk in risks - ], severity_counts - - -def transform_alt_tech_for_pdf( - resource_inventory: list, - resource_type_mapping: dict[str, Any], - alternatives: list, - alternative_technologies: list, - exit_strategy: int, - report_path: str, -) -> list[dict[str, Any]]: - grouped_alt_tech = summarize_alternative_technologies( - resource_inventory, - alternatives, - alternative_technologies, - exit_strategy, - ) - alt_tech = [] - for idx, resource in enumerate(resource_inventory): - rtype_str = str(resource["resource_type"]) - rtype_info = resource_type_mapping.get(rtype_str, {}) - resource_name = rtype_info.get("name", "Unknown Resource") - - icon_path = "/assets" + rtype_info.get("icon", "/icons/default.png") - icon_url = f"{report_path}{icon_path}" - - count = len(grouped_alt_tech.get(rtype_str, [])) - - alt_tech.append( - { - "id": idx + 1, - "resource_name": resource_name, - "icon_url": icon_url, - "count": count, - } - ) - - return alt_tech - - -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), "", ""], - ] - - # 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 - ] - ) - ) - # 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 = f"{report_path}/assets/img/logo/report_logo.png" - logo = Image(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", - ) - - # Restore the state of the canvas - canvas.restoreState() - - -def draw_risk_chart(risk_chart_data: dict[str, int]) -> Drawing: - # Define colors for each severity and their border colors - severity_colors = { - "high": HexColor("#991b1b"), - "medium": HexColor("#ffae1f"), - "low": HexColor("#539bff"), - } - - # Border colors - border_colors = { - "high": HexColor("#991b1b"), - "medium": HexColor("#ffae1f"), - "low": HexColor("#539bff"), - } - - # Create a drawing for the Doughnut chart - d = Drawing(300, 200) - - # Create the Pie (Doughnut) chart - pie = Pie() - pie.x = 100 - pie.y = 25 - pie.width = 150 - pie.height = 150 - pie.data = list(risk_chart_data.values()) - pie.innerRadiusFraction = 0.5 - - # Assign colors and borders for each severity level - for i, severity in enumerate(risk_chart_data.keys()): - pie.slices[i].fillColor = severity_colors[severity] - pie.slices[i].strokeColor = border_colors[severity] - pie.slices[i].strokeWidth = 1 # Set the border width - - # Add the Pie chart to the drawing - d.add(pie) - - # Create a Legend with headers - legend = Legend() - legend.x = 280 - legend.y = 130 - legend.dxTextSpace = 10 - legend.columnMaximum = 6 - legend.alignment = "right" - legend.subCols[0].minWidth = 60 - legend.subCols[1].minWidth = 30 - legend.colorNamePairs = [ - (severity_colors[severity], (severity, str(risk_chart_data[severity]))) - for severity in risk_chart_data.keys() - ] - - # Configure sub-columns for the legend - legend.subCols[0].align = "left" - legend.subCols[1].align = "right" - - # Add the Legend to the drawing - d.add(legend) - - # Create a Legend Header - legend_header = Legend() - legend_header.x = 280 - legend_header.y = 150 - legend_header.dxTextSpace = 10 - legend_header.colorNamePairs = [ - (HexColor("#FFFFFF"), ("Severity", "No.")) - ] # Corrected line - legend_header.alignment = "right" - legend_header.subCols[0].align = "left" - legend_header.subCols[0].minWidth = 60 - legend_header.subCols[1].align = "right" - legend_header.subCols[1].minWidth = 30 - - # Add the Legend Header to the drawing - d.add(legend_header) - - return d - - -def draw_cost_chart(months: list[str], costs: list[float]) -> Drawing: - # Create a drawing for the bar chart - d = Drawing(7.5 * cm, 5 * cm) - - # Create a Vertical Bar Chart - bar_chart = VerticalBarChart() - bar_chart.x = 20 - bar_chart.y = 20 - bar_chart.width = 6.5 * cm - bar_chart.height = 4 * cm - bar_chart.data = [costs] - bar_chart.barWidth = 0.8 * cm - - # Style the bars - bar_chart.bars[0].fillColor = HexColor("#055160") - bar_chart.bars[0].strokeColor = HexColor("#055160") - - # Set the categories (months) - bar_chart.categoryAxis.categoryNames = months - - # Calculate valueMax - max_cost = max(costs) if costs else 0 - bar_chart.valueAxis.valueMax = ( - math.ceil(max_cost / 10.0) * 10 if max_cost > 0 else 10 - ) - bar_chart.valueAxis.valueMin = 0 - - # Add the bar chart to the drawing - d.add(bar_chart) - - return d - - -def draw_exitscore_pie_chart( - exit_score: int, - size: float = 7.5 * cm, - show_title: bool = False, - ring_bg_color: str = "#E9ECEF", - value_color: str = "#000000", -) -> Drawing: - # Clamp value - val = max(0, min(100, int(exit_score))) - - def score_to_grade(s: int) -> str: - if s > 83: - return "A" - if s > 67: - return "B" - if s > 50: - return "C" - if s > 33: - return "D" - if s > 17: - return "E" - return "F" - - # Band palette (matches the 6-grade gauge segments) - bands = [ - (0, 17, "#ba1c1d", "F"), - (17, 33, "#d44000", "E"), - (33, 50, "#ff9533", "D"), - (50, 67, "#f1ca00", "C"), - (67, 83, "#76c31d", "B"), - (83, 100, "#065f43", "A"), - ] - - def band_color(score: int): - s = max(0, min(100, int(score))) - for a, b, hexcol, _ in bands: - if a <= s <= b: - return HexColor(hexcol) - return HexColor("#4a5568") - - # Canvas: a touch taller when showing a title - d = Drawing(size, size + (20 if show_title else 0)) - w, h = d.width, d.height - cx = w / 2.0 - cy = (h / 2.0) - (8 if show_title else 0) - - # Geometry - r_outer = min(w, h) * 0.40 - r_inner = r_outer * 0.62 - - # Optional title - if show_title: - d.add( - String( - cx, - h - 8, - "Exit Score", - textAnchor="middle", - fontSize=10, - fillColor=colors.black, - ) - ) - - # Background ring - d.add(Circle(cx, cy, r_outer, fillColor=HexColor(ring_bg_color), strokeColor=None)) - - # Progress arc (start at -90 deg to sweep from top clockwise) - if val > 0: - start_deg = -90.0 - sweep_deg = 360.0 * (val / 100.0) - d.add( - Wedge( - cx, - cy, - r_outer, - start_deg, - start_deg + sweep_deg, - fillColor=band_color(val), - strokeColor=None, - ) - ) - - # Punch the inner hole to make a donut - d.add(Circle(cx, cy, r_inner, fillColor=HexColor("#FFFFFF"), strokeColor=None)) - - # Grade label - d.add( - String( - cx, - cy - 7, - score_to_grade(val), - textAnchor="middle", - fontSize=22, - fontName="Helvetica-Bold", - fillColor=HexColor(value_color), - ) - ) - - return d - - -def draw_vendor_lockin_radar_chart( - human: int, technology: int, operational: int -) -> Drawing: - # Create a drawing for the radar chart - d = Drawing(350, 250) - - # Define the labels and data - labels = ["Human", "Technology", "Operational"] - data = [human, technology, operational] - - # Define your hex color with alpha - bg_color = HexColor("#4BC0C0") - bg_color.alpha = 0.2 # Set alpha for the fill color - border_color = HexColor("#4BC0C0") # Border color with default alpha (1) - - # Normalize data - max_value = 5 - normalized_data = [i / max_value for i in data] - - # Define the number of facets and calculate the angle of each facet - num_facets = len(labels) - angle = 360 / num_facets - - # Adjust the starting angle for pyramid orientation - start_angle = -30 - - # Define the center and radius of the radar chart - cx = 230 - cy = 125 - radius = 100 # Radius - - # Draw concentric polygons - for level in range(1, int(max_value) + 1): - points = [] - for i in range(num_facets): - x = cx + radius * cos(radians(start_angle + i * angle)) - y = cy + radius * sin(radians(start_angle + i * angle)) - points.extend([x, y]) - d.add(Polygon(points, fillColor=None, strokeColor=colors.grey)) - - # Draw lines connecting the vertices of concentric polygons - for level in range(1, int(max_value)): - prev_x = None - prev_y = None - first_x = None - first_y = None - for i in range(num_facets): - x = cx + (radius * level / max_value) * cos( - radians(start_angle + i * angle) - ) - y = cy + (radius * level / max_value) * sin( - radians(start_angle + i * angle) - ) - - # Store the first x and y coordinates to close the triangle later - if i == 0: - first_x = x - first_y = y - - # If not the first vertex, draw a line from the previous vertex to the current vertex - if prev_x is not None and prev_y is not None: - d.add(Line(prev_x, prev_y, x, y, strokeColor=colors.grey)) - - prev_x = x - prev_y = y - - # Close the triangle by drawing a line from the last vertex to the first vertex - d.add(Line(prev_x, prev_y, first_x, first_y, strokeColor=colors.grey)) - - # Draw the data polygon - points = [] - for i in range(num_facets): - x = cx + radius * normalized_data[i] * cos(radians(start_angle + i * angle)) - y = cy + radius * normalized_data[i] * sin(radians(start_angle + i * angle)) - points.extend([x, y]) - d.add(Polygon(points, fillColor=bg_color, strokeColor=border_color)) - - # Draw labels - for i in range(num_facets): - x = cx + radius * cos(radians(start_angle + i * angle)) - y = cy + radius * sin(radians(start_angle + i * angle)) - d.add(Line(cx, cy, x, y, strokeColor=colors.grey)) - - # Adjust label position and anchor based on quadrant - anchor = "middle" # Default text anchor - - label_text = labels[i] - - # Adjust padding and anchor for different quadrants if necessary - if i * angle > 90 and i * angle < 270: - anchor = "end" if i * angle < 180 else "start" # Adjust text anchor - - label_padding = ( - 10 # Additional padding to move the label slightly outward from max_value - ) - label_x = cx + (radius + label_padding) * cos(radians(start_angle + i * angle)) - label_y = cy + (radius + label_padding) * sin(radians(start_angle + i * angle)) - - # Adjustments based on label - if label_text == "Technology": - label_x += 25 - label_y -= 10 - elif label_text == "Operational": - label_x -= 50 - label_y -= 10 - elif label_text == "Human": - label_y += 5 - label_x += 10 - - d.add( - String( - label_x, - label_y, - label_text, - fontSize=10, - fillColor=colors.black, - textAnchor=anchor, - ) - ) - - return d diff --git a/core/utils_sync.py b/core/utils_sync.py deleted file mode 100644 index 177f4f6..0000000 --- a/core/utils_sync.py +++ /dev/null @@ -1,152 +0,0 @@ -# core/utils_sync.py -from __future__ import annotations - -import json -import logging -import os -import time -import config -import requests -from typing import Any - -from core.utils_db import load_data -from utils.version import __version__ - -# Configure logger -logger = logging.getLogger("core.engine.sync") - -_ASSESS_PATH = "/api/v1/assessments/" - - -def _assess_url(host: str) -> str: - host = host.strip().rstrip("/") - if host.startswith("http://"): - host = "https://" + host[len("http://") :] - elif not host.startswith("https://"): - host = f"https://{host}" - return f"{host}{_ASSESS_PATH}" - - -def _build_payload( - *, - report_path: str, - name: str, - started_at: int, - exit_strategy: int, - cloud_service_provider: int, - assessment_type: int, -) -> dict[str, Any]: - db_path = os.path.join(report_path, "data", "assessment.db") - - resource_rows: list[dict[str, Any]] = load_data( - "resource_inventory", db_path=db_path - ) - cost_rows: list[dict[str, Any]] = load_data("cost_inventory", db_path=db_path) - - res_payload = [ - { - "id": int(r["resource_type"]), - "location": r.get("location") or "unknown", - "count": int(r.get("count", 0)), - } - for r in resource_rows - ] - cost_payload = [ - { - "month": c["month"], - "cost": float(c["cost"]), - "currency": c["currency"], - } - for c in cost_rows - ] - - engine_version = f"v{__version__}" - now = int(time.time()) - - payload: dict[str, Any] = { - "id": os.urandom(16).hex(), - "object": "event", - "cli_version": engine_version, - "created": now, - "type": "local.assessment.succeeded", - "data": { - "name": name, - "exit_strategy": exit_strategy, - "cloud_service_provider": cloud_service_provider, - "assessmentType": assessment_type, - "started_at": started_at, - "completed_at": now, - "success": True, - "resource_inventory": res_payload, - "cost_inventory": cost_payload, - }, - } - - logger.debug("Outgoing payload:\n%s", json.dumps(payload, indent=2)) - return payload - - -def write_assessment_payload( - raw_data_path: str, - *, - report_path: str, - name: str, - started_at: int, - exit_strategy: int, - cloud_service_provider: int, - assessment_type: int, -) -> str: - payload = _build_payload( - report_path=report_path, - name=name, - started_at=started_at, - exit_strategy=exit_strategy, - cloud_service_provider=cloud_service_provider, - assessment_type=assessment_type, - ) - payload_path = os.path.join(raw_data_path, "payload.json") - with open(payload_path, "w", encoding="utf-8") as payload_file: - json.dump(payload, payload_file, indent=2) - payload_file.write("\n") - return payload_path - - -def post_assessment( - *, - name: str, - started_at: int, - report_path: str, - meta: dict[str, int], - token: str, - timeout: int = 10, -) -> dict[str, Any]: - host = os.environ.get("HOST", "").strip() or getattr(config, "HOST", "").strip() - if not host: - return { - "success": False, - "payload": None, - "logs": "HOST missing in environment and config.py", - } - - url = _assess_url(host) - headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} - - payload = _build_payload( - report_path=report_path, - started_at=started_at, - name=name, - exit_strategy=meta["exit_strategy"], - cloud_service_provider=meta["cloud_service_provider"], - assessment_type=meta["assessment_type"], - ) - - try: - resp = requests.post(url, headers=headers, json=payload, timeout=timeout) - ok = resp.ok - return { - "success": ok, - "payload": resp.json() if ok else None, - "logs": f"server responded {resp.status_code}", - } - except requests.RequestException as exc: - return {"success": False, "payload": None, "logs": f"POST failed: {exc}"} diff --git a/core/utils_tfstate.py b/core/utils_tfstate.py deleted file mode 100644 index de32486..0000000 --- a/core/utils_tfstate.py +++ /dev/null @@ -1,392 +0,0 @@ -# core/utils_tfstate.py -import hashlib -import json -import os -import logging -import re -import sqlite3 -from typing import Any -from collections import defaultdict - -from .utils_db import connect, load_data - -logger = logging.getLogger("core.engine.tfstate") - -# Terraform >= 0.12 and every OpenTofu release write state format version 4. -SUPPORTED_STATE_VERSION = 4 - -# Attribute keys that carry the region/location of an instance, in lookup order. -# AWS providers expose "region", Azure providers expose "location". -LOCATION_KEYS = ("region", "location") - -ARN_KEY = "arn" - -# arn:partition:service:region:account-id:resource -ARN_REGION_INDEX = 3 -ARN_MIN_FIELDS = 6 - -# Placeholder used when an instance carries no usable location attribute. -UNKNOWN_LOCATION = "unknown" - -# Terraform type prefixes per CSP. A state file can hold resources from any -# number of providers, so these decide which ones belong to the assessment. -CSP_TYPE_PREFIXES = {1: "azurerm_", 2: "aws_"} -CSP_NAMES = {1: "Azure", 2: "AWS"} -CSP_SUBCOMMANDS = {1: "azure", 2: "aws"} - -# Azure resource ids carry the subscription and resource group that define the -# assessed boundary: /subscriptions//resourceGroups//providers/... -AZURE_RESOURCE_ID_PATTERN = re.compile( - r"^/subscriptions/([^/]+)/resourceGroups/([^/]+)/", re.IGNORECASE -) - - -def parse_tfstate(path: str) -> dict[str, Any]: - try: - with open(path, "r", encoding="utf-8") as state_file: - state = json.load(state_file) - except OSError as e: - raise ValueError(f"Could not read Terraform state file '{path}': {e}") - except json.JSONDecodeError as e: - raise ValueError( - f"Terraform state file '{path}' is not valid JSON: {e}. " - "For a remote backend, export it first: " - "`terraform state pull > infra.tfstate`." - ) - - if not isinstance(state, dict): - raise ValueError( - f"Terraform state file '{path}' is not a state document " - "(expected a JSON object at the top level)." - ) - - version = state.get("version") - if version != SUPPORTED_STATE_VERSION: - raise ValueError( - f"Unsupported Terraform state version {version!r} in '{path}'. " - f"Only state format version {SUPPORTED_STATE_VERSION} is supported " - "(Terraform >= 0.12 and all OpenTofu versions produce version 4). " - "For a remote backend, export the current state with " - "`terraform state pull > infra.tfstate`." - ) - - return state - - -def _region_from_arn(arn: Any) -> str: - if not isinstance(arn, str) or not arn.startswith("arn:"): - return "" - - fields = arn.split(":") - if len(fields) < ARN_MIN_FIELDS: - return "" - - return fields[ARN_REGION_INDEX].strip().lower() - - -def _instance_location(attributes: Any) -> str: - if not isinstance(attributes, dict): - return UNKNOWN_LOCATION - - for key in LOCATION_KEYS: - value = attributes.get(key) - if isinstance(value, str): - location = value.strip().lower() - if location: - return location - - return _region_from_arn(attributes.get(ARN_KEY)) or UNKNOWN_LOCATION - - -def _instance_address( - module: str | None, resource_type: str, name: str, index_key: Any -) -> str: - address = f"{resource_type}.{name}" - if module: - address = f"{module}.{address}" - if index_key is not None: - address = f"{address}[{index_key}]" - return address - - -def extract_managed_resources(state: dict[str, Any]) -> list[dict[str, str]]: - records: list[dict[str, str]] = [] - - for resource in state.get("resources") or []: - if not isinstance(resource, dict): - continue - if resource.get("mode") != "managed": - continue - - resource_type = resource.get("type") - name = resource.get("name") - if not resource_type or not name: - logger.debug("Skipping state entry without a type/name: %r", resource) - continue - - module = resource.get("module") - - for instance in resource.get("instances") or []: - if not isinstance(instance, dict): - continue - records.append( - { - "address": _instance_address( - module, resource_type, name, instance.get("index_key") - ), - "type": resource_type, - "location": _instance_location(instance.get("attributes")), - } - ) - - return records - - -def file_sha256(path: str) -> str | None: - digest = hashlib.sha256() - try: - with open(path, "rb") as state_file: - for chunk in iter(lambda: state_file.read(65536), b""): - digest.update(chunk) - except OSError as e: - logger.warning("Could not hash the Terraform state file: %s", e) - return None - return digest.hexdigest() - - -def extract_state_scope( - state: dict[str, Any], cloud_service_provider: int -) -> dict[str, list[str]]: - subscriptions: set[str] = set() - resource_groups: set[str] = set() - - if cloud_service_provider == 1: # Azure - for resource in state.get("resources") or []: - if not isinstance(resource, dict) or resource.get("mode") != "managed": - continue - for instance in resource.get("instances") or []: - if not isinstance(instance, dict): - continue - attributes = instance.get("attributes") - if not isinstance(attributes, dict): - continue - resource_id = attributes.get("id") - if not isinstance(resource_id, str): - continue - match = AZURE_RESOURCE_ID_PATTERN.match(resource_id) - if match: - subscriptions.add(match.group(1)) - resource_groups.add(match.group(2)) - - return { - "subscriptions": sorted(subscriptions), - "resource_groups": sorted(resource_groups), - } - - -def _build_tf_code_mapping( - cloud_service_provider: int, db_path: str -) -> dict[str, dict[str, Any]]: - mapping: dict[str, dict[str, Any]] = {} - duplicates: set[str] = set() - - for item in load_data("resourcetype", db_path=db_path): - if item["csp"] != cloud_service_provider or item["status"] != "t": - continue - - tf_code = (item.get("tf_code") or "").strip() - if not tf_code: - continue - - existing = mapping.get(tf_code) - if existing is None: - mapping[tf_code] = {"id": item["id"], "name": item["name"]} - continue - - if tf_code not in duplicates: - duplicates.add(tf_code) - logger.warning( - "Terraform type '%s' maps to multiple resource types; " - "using the lowest id.", - tf_code, - ) - if item["id"] < existing["id"]: - mapping[tf_code] = {"id": item["id"], "name": item["name"]} - - return mapping - - -def _foreign_provider_summary(foreign_types: dict[str, int]) -> str: - for csp, prefix in CSP_TYPE_PREFIXES.items(): - count = sum(n for t, n in foreign_types.items() if t.startswith(prefix)) - if count: - return f"{count} {CSP_NAMES[csp]} resources" - - total = sum(foreign_types.values()) - sample = ", ".join(sorted(foreign_types)[:3]) - return f"{total} resources from other providers ({sample})" - - -def _no_matching_provider_error( - cloud_service_provider: int, tfstate_path: str, foreign_types: dict[str, int] -) -> ValueError: - own_name = CSP_NAMES.get(cloud_service_provider, "matching") - message = ( - f"No {own_name} resources found in '{os.path.basename(tfstate_path)}'. " - f"The state contains {_foreign_provider_summary(foreign_types)}." - ) - - # Point at the other subcommand only when we could actually assess it. - for csp, prefix in CSP_TYPE_PREFIXES.items(): - if csp == cloud_service_provider: - continue - if any(t.startswith(prefix) for t in foreign_types): - message += ( - f" Did you mean: python3 main.py {CSP_SUBCOMMANDS[csp]} " - f"--tfstate {tfstate_path}" - ) - break - - return ValueError(message) - - -def build_tfstate_resource_inventory( - cloud_service_provider: int, - provider_details: dict[str, Any], - report_path: str, - raw_data_path: str, -) -> dict[str, Any]: - tfstate_path = provider_details["tfstatePath"] - state = parse_tfstate(tfstate_path) - instances = extract_managed_resources(state) - - db_path = os.path.join(report_path, "data", "assessment.db") - resource_type_mapping = _build_tf_code_mapping(cloud_service_provider, db_path) - - # A state file may hold resources from any number of providers. Split them - # up front so a foreign resource is never silently lumped in with the - # same-provider glue types that legitimately have no dataset row. - own_prefix = CSP_TYPE_PREFIXES.get(cloud_service_provider, "") - foreign_types: defaultdict[str, int] = defaultdict(int) - own_instances = [] - for instance in instances: - if own_prefix and not instance["type"].startswith(own_prefix): - foreign_types[instance["type"]] += 1 - else: - own_instances.append(instance) - - # Nothing for the selected provider, but resources for another one: the - # wrong subcommand or the wrong file. An empty state is left alone. - if instances and not own_instances: - raise _no_matching_provider_error( - cloud_service_provider, tfstate_path, dict(foreign_types) - ) - - # Aggregate matched instances, and count the types we have no mapping for. - aggregated_resources: defaultdict[tuple[int, str], int] = defaultdict(int) - unmapped_types: defaultdict[str, int] = defaultdict(int) - counted: list[dict[str, Any]] = [] - - for instance in own_instances: - resource_info = resource_type_mapping.get(instance["type"]) - if not resource_info: - unmapped_types[instance["type"]] += 1 - continue - - resource_type_id = resource_info["id"] - aggregated_resources[(resource_type_id, instance["location"])] += 1 - counted.append( - { - "address": instance["address"], - "type": instance["type"], - "resource_type_id": resource_type_id, - "location": instance["location"], - } - ) - - # Insert aggregated data into SQLite - try: - with connect(db_path=db_path) as conn: - cursor = conn.cursor() - for ( - resource_type_id, - resource_location, - ), resource_count in aggregated_resources.items(): - try: - cursor.execute( - """ - INSERT INTO resource_inventory (resource_type, location, count) - VALUES (?, ?, ?) - ON CONFLICT(resource_type, location) DO UPDATE SET count = excluded.count - """, - (resource_type_id, resource_location, resource_count), - ) - except sqlite3.Error as e: - logger.error( - f"SQLite error while processing aggregated resource: {e}", - exc_info=True, - ) - conn.commit() - except sqlite3.Error as e: - logger.error( - f"Error writing the tfstate resource inventory: {e}", exc_info=True - ) - raise - - # Counts are per instance, not per type: "9 types skipped" can hide any - # number of dropped resources, which is exactly what needs surfacing. - excluded_foreign = sum(foreign_types.values()) - excluded_unmapped = sum(unmapped_types.values()) - coverage = { - "instances_total": len(instances), - "instances_counted": len(counted), - "instances_excluded_other_provider": excluded_foreign, - "instances_excluded_unmapped": excluded_unmapped, - } - - # What the report's Scope of Assessment section renders from. The hash ties - # a report back to the exact file it was produced from. - scope = { - "file": os.path.basename(tfstate_path), - "sha256": file_sha256(tfstate_path), - "lineage": state.get("lineage"), - "serial": state.get("serial"), - "locations": sorted({entry["location"] for entry in counted}), - **extract_state_scope(state, cloud_service_provider), - } - - # Manifest of what was counted. Instance attributes carry secrets - # (passwords, connection strings, keys) and are deliberately never written. - manifest = { - "source_file": os.path.basename(tfstate_path), - "terraform_version": state.get("terraform_version"), - "state_serial": state.get("serial"), - "scope": scope, - "coverage": coverage, - "counted": counted, - "unmapped_types": dict(unmapped_types), - "other_provider_types": dict(foreign_types), - } - - manifest_path = os.path.join(raw_data_path, "tfstate_manifest.json") - try: - with open(manifest_path, "w", encoding="utf-8") as manifest_file: - json.dump(manifest, manifest_file, indent=4) - except OSError as e: - logger.error(f"Could not write the tfstate manifest: {e}", exc_info=True) - - if unmapped_types: - logger.warning( - "%d Terraform resource type(s) had no matching resource type and were " - "skipped; see raw_data/tfstate_manifest.json for details.", - len(unmapped_types), - ) - - if excluded_foreign: - logger.warning( - "%d resource(s) in the state belong to another cloud provider and were " - "not assessed.", - excluded_foreign, - ) - - return coverage diff --git a/docs/api-v2/MANIFEST.sha256 b/docs/api-v2/MANIFEST.sha256 new file mode 100644 index 0000000..6d9102b --- /dev/null +++ b/docs/api-v2/MANIFEST.sha256 @@ -0,0 +1,59 @@ +119ef59f3225d40f64d1f9e030cee3ccc821768d82ca14bbea5bd9895b932739 README.md +c28fcca53637bc88e124af1725df13cb98c69dedefd62fb3cdbe1cdb6b760624 VERSION +adb8ff4e053be47dba564c849c9c2b4d10b3f39bf8a8284eca4e5d2764e2cf3a errors.yaml +14e3a8113d17528a1262eaf0362d5f9ad0378d97d6bd0d03b2b37d9463741c02 examples/01-meta.json +ad1b134a5fccea08ea219ffab57e666abc96687ace57e37acba49fc39c6fd76c examples/02-whoami_assessment_key.json +7110af9481d5f48cd84550ca5e34a88557a15cb034070ca37dd4ba1ea38e5bcd examples/03-whoami_account_key.json +de49ac858af2034de1f413ae68deb0a1916162a36466742c013c77c152aedf3a examples/04-request_id_replaced.json +863cde2521fc182049cb00cdab5ecdb46e794db9fd9a8398e214941153819f53 examples/05-first_sync_assessment_key.json +372796913b846ad64747b997adc66e4abcb20a0fab35f79e575b9852a3dcfe71 examples/06-second_sync_appended.json +9158eadc340c7fe08b02152fd7d86b87a60431d68ef80a5b0ee85e688e33dab7 examples/07-idempotent_replay.json +faeddd68c2abbe4fb3d83f96a1a57b43352d8a29886fe262aad32f59d856c995 examples/08-idempotency_key_reused.json +fe206334e0a138e44724e6839b34fc1b03c1e6d709b8c41e0eab8b2cb8a45444 examples/09-idempotency_key_missing.json +ff0c6d17a3ab5b402327f95a6719fab0d14776b59b039fb3ad41b653d517dbf8 examples/10-idempotency_key_mismatch.json +48e54d947243062912a02115c204d6803a2422c7a9dbc4a6eb09cb7568c7baf0 examples/11-scope_mismatch.json +8bc7b5ec8108eb9baa398e848d55288c249c5107ddb3c90bb438c921fa4e3eb4 examples/12-provider_mismatch.json +d4aa8cb874783f7b3c092b290fc54247623f7b9c688689f6391b4a904a226c78 examples/13-depth_too_deep.json +90823cf5874830853aaabacc85daa3a0a0e6ca12938f856b769077062f526de0 examples/14-depth_three_without_egress.json +fe1be812a9e516fb39f94cf6d9bc03934804c18ac99a33a9cbf4e5e51993aab4 examples/15-depth_three_with_egress.json +75fd6f19ca47f3d4c7c6c19229f3191fa5bf7de114b9fb84ad73a7015d88bc16 examples/16-unknown_resource_types.json +d125333273504e8beb0699ad51d8b05facfad2cc3e5e6922ad84e78b11f0cf56 examples/17-inactive_resource_types.json +7a065e6e721b239e48818aa35bc3977c1e5909cb565e27ff9165e7b61d133069 examples/18-all_resource_types_unknown.json +280f6b4bce363fb967b0e07cd664bb5adaec451f0a4ee8c5ea236f4a71452b54 examples/19-duplicate_items_merged.json +ae3ec39e597111545a26793ce414f2384a0c26007fe89654f146a2eb1ed57777 examples/20-catalogue_collector_older.json +5b58b3e06356b03dc9658deb4e6de541de3240da30df09740df4b3edfe7d4011 examples/21-catalogue_collector_newer.json +7dba2037e7c781d4a8a8518933ec8d76e42e61a232eb655b2e5d7704ddf1e4f4 examples/22-catalogue_versions_compared_as_numbers.json +234e5f435bfdaa112b22d96da52a2c11b8ffb4c677866452dc7a71d909c55757 examples/23-catalogue_missing.json +704f22ccedfa0c0989ec01363c7e09be93430848b878fc79c97810e534c80b5f examples/24-catalogue_sha256_mismatch.json +9b5a66c7d26b3fd38e543fec74ad405a4c69a00112591f95b744d6219a199975 examples/25-account_key_creates_assessment.json +391c5368d3e41729452d538de4d32d3274dd734fca72dd0b5d6623f7e54b5df3 examples/26-account_key_without_create_scope.json +b14ce21dcec8ec2eeb2f533922fc7f43224c7168f9da776e3591a8c2252f790c examples/27-account_key_without_target.json +edeaafa1a820d451706060731b2181df7c351ec192c8403ba406f9adc4f9939c examples/28-account_key_appends_to_named_assessment.json +bc101c21838bdfcce2611e65f9fd555d098dc8378a271fe31df06aa5611dc9da examples/29-account_key_unknown_assessment.json +f4370df95a03499ddb7e060abf83226240844963140952ac803ebb0006f982e0 examples/30-assessment_key_for_another_assessment.json +fb5151a3cfc9adcff90adc2f49f1ffe1493f1fd508f16467e4d6b56eb245c1d7 examples/31-key_revoked.json +71fe13b2307c572cef5f16ca358e295154beab9a52cd60c43977dc97e157f28a examples/32-key_expired.json +8cf143b65858d9bb31c3b6f76d2d28dec573fe9a9c5349c5db8977adaa751028 examples/33-key_malformed.json +44bd3e413e29ce06b8607f5d2532b5a8c663d2f9d6a489814fe5ccfe0cf44570 examples/34-key_bad_checksum.json +c77740bee9234b96212e873d4f73b3a0f58266bed8ed2eb4396d3d82ebef8c3a examples/35-authorization_missing.json +fff0522b3f17ae006cd1fbffd7c4f906e0a1022cebde724c09e30b44c6110ac2 examples/36-payload_too_large.json +813db769cde25a4974c021518ff33853525ed84ba1982db736a589a1cd5e35b3 examples/37-validation_errors.json +0dbd114cf078bbacc7104e284b287f3df5cfebd72ea798136b62f0c6a2c6807f examples/38-collection_too_old.json +513b03ba3c8dc171b0fda7262131f5ac84e408b91f275e0e6888b6efbeda00a4 examples/39-invalid_json.json +52079cf6503494d8b559573fa4f5847958c8cb7ba228cf4c6ff7d71f27e99e4f examples/40-unsupported_media_type.json +f3bd0d876ac4bf9ce03fa3b21bc53d8744d324ec58e7512a7daad2255970737e examples/41-unsupported_schema_version.json +0e6cafc93491bf2da0ba5e83b256b640ce7fc2bed61265771f2521abee497033 examples/42-get_sync.json +7b8ed52a8a5991b3ba54d97a36883ae0b9deaeb6de34d2c185967cfbf4560946 examples/43-get_sync_not_found.json +674249eff8de98090eef137278e37c575d4bf94dbddd5092fe28f17d460583e1 examples/44-get_assessment.json +8bc9d7efd965c993abfad73b243ee7f3c81163f73ea7bd09a76e4b94cac54e02 examples/45-get_assessment_not_found.json +353b6e7c02921356f969b5c02b2a186a5001f705be75db93d320a0abdcff9590 examples/46-list_assessments_second_page.json +4ca56306854c5277612af4fb72bd9386ed54d0fa7f12d21b3cca998735369c8f examples/47-list_assessments_by_provider.json +bf53352b6483364653d6c9bef5c85e061f316ef19c0a47540d27e4fe524c0555 examples/48-list_assessments_needs_read_scope.json +e4c838a8113cecf12dac5c7e25e13c68d2d4e7b7ff46f1f5f2eaa7748a401d66 examples/49-list_assessments_bad_cursor.json +cf11e67f0a2620ceed63f683f099acd2014f012848ab1ce7f8afbee693395ae2 examples/50-list_assessment_syncs.json +44c5925c07a88d4381f70216f6d33f3fcac68e5c4f1215b9d02e638fdb686687 examples/51-insufficient_credits.json +fff61bd1caa19aeaa793b5982f907cbe1b3e6c0aa4fe75e692430e07da1dbd72 fixtures/catalogue.json +99226f1eda12219a8a3d35409d13cb0afdfd31c90793068ebd27eda77881e849 make_manifest.py +43556abba1039b12902bf9deebd7541986d7f4a7b44b52d9130ce31a6caa3c55 openapi.yaml +a4acd6933651cd77cb80a67421db9739991bc450ca2b02a0db57a048dc50197a vectors/content_sha256.json +712f70b59424a85f31ab359e29a66b25cb942784549ce9f5514d1e3c56a4fb75 vectors/scope_key.json diff --git a/docs/api-v2/README.md b/docs/api-v2/README.md new file mode 100644 index 0000000..46038e5 --- /dev/null +++ b/docs/api-v2/README.md @@ -0,0 +1,68 @@ +# API v2 definition + +The definition of `/api/v2`, the contract between the cloudexit CLI and the hosts (EscapeCloud and ExitCloud). It is the single source of truth: both hosts and the CLI carry an identical copy. + +| File | What it is | +| --- | --- | +| `VERSION` | The version of the definition. | +| `openapi.yaml` | The API (OpenAPI 3.1): endpoints, headers, request and response schemas, limits. | +| `errors.yaml` | The error and warning code registry, with HTTP status, retry flag and the CLI exit code. | +| `vectors/scope_key.json` | Test vectors for the canonical `scope_key`. | +| `vectors/content_sha256.json` | Test vectors for the canonical content form and `content_sha256`. | +| `fixtures/catalogue.json` | The catalogue the examples run against. | +| `examples/*.json` | One scenario per file: the fixtures it needs, a request and what to expect. | +| `make_manifest.py`, `MANIFEST.sha256` | The file hashes that keep copies identical. | + +## Versions + +- `VERSION` is `MAJOR.MINOR.PATCH`. The major is the path (`/api/v2`). +- Additive changes (new optional request fields, new response fields, new enum values, new error or warning codes, new endpoints) raise the **minor** version. Fixes to wording and examples raise the patch. +- A breaking change is a new major: `/api/v3` paths and a new definition. +- `schema_version` in a request body is `2.`. `GET /meta` lists the minors a host accepts. + +## Keeping copies identical + +**Every copy must keep the same `VERSION` and the same files.** After any change run + +```bash +python docs/api-v2/make_manifest.py +``` + +and commit `MANIFEST.sha256` with the change. A copy whose files and manifest disagree is not a faithful copy. + +## Examples + +Each file in `examples/` is one scenario that a harness replays against a host. + +### An example file + +```json +{ + "name": "…", "description": "…", + "host": "escapecloud | exitcloud", + "fixtures": {"catalogue": "standard", "assessments": [], "keys": [], "credits": 0}, + "before": [{"request": {}, "expect": {}, "save": {"name": "/json/pointer"}}], + "request": {"method": "POST", "path": "/api/v2/syncs", "headers": {}, "body": {}}, + "expect": {"status": 201} +} +``` + +- `host` is optional. Without it the example runs on every host. +- `before` steps run first. A step may `save` values of its response (JSON pointers) as placeholders. +- `request` may instead have `raw_body` (a string sent as it is), or `pad_bytes_to` (the harness adds an unknown field so the body is that many bytes long). +- `expect` has `status`, `headers` (exact), `headers_present`, `headers_differ`, `body_subset`, `exists` (JSON pointers that must exist), `absent` and `lengths` (JSON pointer to list length). +- `body_subset` matches objects by their keys and lists by containment: every expected item must equal or be a subset of some actual item, in any order. + +### Fixtures + +- `catalogue`: `"standard"` (`fixtures/catalogue.json`), or `{"use": "standard", "version": "…"}` to change fields. The host installs it as its active catalogue. +- `assessments`: `{name, provider, exit_strategy, assessment_type, scope_key}`. `scope_key` is what the first sync already recorded (null before it). The assessments are created in order. +- `keys`: `{name, kind, assessment?, scopes?, state?}`. `kind` is `assessment` (with the `assessment` it is bound to; scope `syncs:write`) or `account`. `state` is `active` (default), `revoked` or `expired`. +- `credits`: the credit balance of the tenant (ExitCloud only). + +### Placeholders + +- A key is `{{}}`, the raw key. `{{|corrupt}}` is the same key with a wrong checksum. +- An assessment is `{{_id}}`. +- `{{now}}`, `{{now-300}}`, `{{now+60}}`: the current time in epoch seconds. A value that is only a placeholder keeps its type, so these become integers. +- Names saved by a `before` step. diff --git a/docs/api-v2/VERSION b/docs/api-v2/VERSION new file mode 100644 index 0000000..227cea2 --- /dev/null +++ b/docs/api-v2/VERSION @@ -0,0 +1 @@ +2.0.0 diff --git a/docs/api-v2/errors.yaml b/docs/api-v2/errors.yaml new file mode 100644 index 0000000..99304c1 --- /dev/null +++ b/docs/api-v2/errors.yaml @@ -0,0 +1,277 @@ +version: "2.0" +envelope: + example: + object: error + api_version: "2" + error: + code: validation_failed + message: 2 fields are invalid. + retryable: false + request_id: 9a3f0c1d2e4b5a67 + details: + - pointer: /inventory/resources/3/count + code: out_of_range + message: must be between 0 and 10000 + docs_url: https://cloudexit.escapecloud.io/errors/validation_failed + rules: + - Messages are safe to show to a user and never contain exception text or secrets. + - Clients branch on error.code. For an unknown code they fall back to the HTTP status class and error.retryable. + - details is always a list. An item has a code and a message, and a pointer (a JSON pointer into the request body) or a parameter (a query or header name) when it points at one input. Code-specific values are further keys of an item; the details entries below name them. + - request_id is the same value as the X-Request-ID response header. +detail_codes: + required: A required field is missing. + invalid_type: The value has the wrong type. + invalid_format: The value does not match the expected format or pattern. + out_of_range: A number is outside its allowed range. + too_long: A string is longer than allowed. + too_many_items: A list is longer than allowed. + not_allowed: The value is not one of the allowed values, or the combination of fields is not allowed. + before_started_at: completed_at is earlier than started_at. + too_old: completed_at is older than limits.max_collection_age_days. + in_the_future: A time is further in the future than limits.clock_skew_seconds allows. +exit_codes: + 10: The request was rejected as invalid. Fix the input; retrying the same request will not help. + 11: Authentication or authorisation failed. Check the key, its scopes and its binding. + 12: A temporary condition. Retrying with the same Idempotency-Key is safe. + 13: Credits or a quota ran out. + 14: The target assessment is missing, deleted, archived or does not match the sync. + 15: The client version, schema version or catalogue is not accepted. +errors: + unauthenticated: + http: 401 + retryable: false + cli_exit: 11 + hosts: [escapecloud, exitcloud] + meaning: The Authorization header is missing or not a Bearer credential. + invalid_api_key: + http: 401 + retryable: false + cli_exit: 11 + hosts: [escapecloud, exitcloud] + meaning: The key is malformed, has a bad checksum or is unknown (also for keys of another host). + api_key_expired: + http: 401 + retryable: false + cli_exit: 11 + hosts: [escapecloud, exitcloud] + meaning: The key is past its expiry date. + api_key_revoked: + http: 401 + retryable: false + cli_exit: 11 + hosts: [escapecloud, exitcloud] + meaning: The key was revoked, or the assessment it was bound to was deleted. + wrong_region: + http: 403 + retryable: false + cli_exit: 11 + hosts: [exitcloud] + meaning: The key belongs to the other ExitCloud region. + details: expected_host, the base URL of the region the key belongs to. + principal_inactive: + http: 403 + retryable: false + cli_exit: 11 + hosts: [escapecloud, exitcloud] + meaning: The service account or user behind the key is deactivated. + insufficient_scope: + http: 403 + retryable: false + cli_exit: 11 + hosts: [escapecloud, exitcloud] + meaning: The key lacks the scope the request needs. + details: required, the list of accepted scopes. + key_binding_violation: + http: 403 + retryable: false + cli_exit: 11 + hosts: [escapecloud, exitcloud] + meaning: An assessment key was used for an assessment other than its own. + assessment_key_v1: + http: 403 + retryable: false + cli_exit: 11 + hosts: [escapecloud, exitcloud] + api: v1 + meaning: An assessment key was used on the v1 API, which accepts account keys only. Returned by /api/v1 in the v1 error envelope, never by /api/v2. + idempotency_key_missing: + http: 400 + retryable: false + cli_exit: 10 + hosts: [escapecloud, exitcloud] + meaning: A POST without an Idempotency-Key header, or with one that does not match ^[A-Za-z0-9_-]{16,64}$. + idempotency_key_mismatch: + http: 422 + retryable: false + cli_exit: 10 + hosts: [escapecloud, exitcloud] + meaning: The Idempotency-Key header differs from sync.id. + idempotency_key_reused: + http: 422 + retryable: false + cli_exit: 10 + hosts: [escapecloud, exitcloud] + meaning: The Idempotency-Key was used before with a different request body. + request_in_progress: + http: 409 + retryable: true + cli_exit: 12 + hosts: [escapecloud, exitcloud] + meaning: A request with the same Idempotency-Key is still being processed. Retry after Retry-After. + invalid_json: + http: 400 + retryable: false + cli_exit: 10 + hosts: [escapecloud, exitcloud] + meaning: The body is not valid UTF-8 JSON. + unsupported_media_type: + http: 415 + retryable: false + cli_exit: 10 + hosts: [escapecloud, exitcloud] + meaning: The Content-Type is not application/json. + payload_too_large: + http: 413 + retryable: false + cli_exit: 10 + hosts: [escapecloud, exitcloud] + meaning: The body is larger than limits.max_body_bytes. + details: limit, the name of the limit (max_body_bytes). + limit_exceeded: + http: 422 + retryable: false + cli_exit: 10 + hosts: [escapecloud, exitcloud] + meaning: A list or value is beyond a limit announced in /meta. + details: limit, the name of the limit, from limits in /meta. + validation_failed: + http: 422 + retryable: false + cli_exit: 10 + hosts: [escapecloud, exitcloud] + meaning: The body does not match the schema, or a value is out of range. details lists every problem with a JSON pointer. + catalogue_mismatch: + http: 422 + retryable: false + cli_exit: 10 + hosts: [escapecloud, exitcloud] + meaning: Every resource in the sync is of a type the host does not know. + catalogue_sha256_mismatch: + http: 422 + retryable: false + cli_exit: 10 + hosts: [escapecloud, exitcloud] + meaning: The collector's catalogue has the same version as the host's but a different sha256 (a corrupt or tampered dataset). + details: host_version and host_sha256, the catalogue the host has. + unsupported_schema_version: + http: 400 + retryable: false + cli_exit: 15 + hosts: [escapecloud, exitcloud] + meaning: schema_version is not one the host accepts. + details: supported, the list from /meta.contract.schema_versions. + client_version_unsupported: + http: 400 + retryable: false + cli_exit: 15 + hosts: [escapecloud, exitcloud] + meaning: The collector version is below the host's minimum or is blocked. + details: min_supported and recommended, from /meta.clients. + assessment_not_found: + http: 404 + retryable: false + cli_exit: 14 + hosts: [escapecloud, exitcloud] + meaning: There is no such assessment for this tenant (also for assessments of another tenant). + sync_not_found: + http: 404 + retryable: false + cli_exit: 14 + hosts: [escapecloud, exitcloud] + meaning: There is no such sync for this tenant (also for syncs of another tenant). + assessment_deleted: + http: 410 + retryable: false + cli_exit: 14 + hosts: [escapecloud, exitcloud] + meaning: The assessment was deleted. Deleting an assessment also revokes its keys, so a bound key normally gets api_key_revoked. + assessment_archived: + http: 409 + retryable: false + cli_exit: 14 + hosts: [escapecloud, exitcloud] + meaning: An archived assessment does not accept new syncs. + target_mismatch: + http: 409 + retryable: false + cli_exit: 14 + hosts: [escapecloud, exitcloud] + meaning: The sync does not fit the assessment. + details: field (provider, exit_strategy, scope_key or assessment_type), expected (the assessment's value) and actual (the sync's value). + insufficient_credits: + http: 402 + retryable: false + cli_exit: 13 + hosts: [exitcloud] + meaning: The organization has too few credits for this sync. + details: required (the credits the sync costs) and balance, both credit amounts. + quota_exceeded: + http: 429 + retryable: false + cli_exit: 13 + hosts: [escapecloud, exitcloud] + meaning: A daily or monthly quota of the tenant is used up. + details: resets_at, when the quota resets, in epoch seconds. + rate_limited: + http: 429 + retryable: true + cli_exit: 12 + hosts: [escapecloud, exitcloud] + meaning: Too many requests. Retry after Retry-After. + maintenance: + http: 503 + retryable: true + cli_exit: 12 + hosts: [escapecloud, exitcloud] + meaning: The host is in maintenance. Retry after Retry-After. + service_unavailable: + http: 503 + retryable: true + cli_exit: 12 + hosts: [escapecloud, exitcloud] + meaning: A dependency of the host is unavailable. + internal_error: + http: 500 + retryable: true + cli_exit: 12 + hosts: [escapecloud, exitcloud] + meaning: Something failed on the host. Retrying with the same Idempotency-Key is safe. +warnings: + unknown_resource_types: + meaning: Resources of types unknown to the host's catalogue were stored but not scored. + details: type_ids lists the types. + resource_type_inactive: + meaning: Resources of types the host has deactivated were stored but not scored. + details: type_ids lists the types. + resource_type_code_mismatch: + meaning: A resource's type_code differs from the host's code for that type_id. The type_id wins. + details: type_ids lists the types. + duplicate_items_merged: + meaning: Resource items with the same type_id and location were merged by summing their counts. + tier_downgraded: + meaning: Depth 3 was requested without collected egress data, so the sync was recorded as depth 2. + name_ignored: + meaning: A name was sent for an existing assessment and differs from its name. Names are set at creation. + key_expiring: + meaning: The key expires within 14 days. + details: expires_at is the expiry, in epoch seconds. + client_version_deprecated: + meaning: The collector version is deprecated (below clients.cloudexit.deprecated_below). + collector_catalogue_outdated: + meaning: The collector's catalogue is older than the host's, so types added since were not collected. + details: missing_resource_types lists the host's active types for the provider that are not in the collector's resource_type_ids. + host_catalogue_outdated: + meaning: The collector's catalogue is newer than the host's, or it names types the host does not know yet. + details: unknown_type_ids lists the ids the host does not know. + collector_catalogue_unknown: + meaning: The sync carries no catalogue block, so the catalogues cannot be compared. diff --git a/docs/api-v2/examples/01-meta.json b/docs/api-v2/examples/01-meta.json new file mode 100644 index 0000000..00f8c1a --- /dev/null +++ b/docs/api-v2/examples/01-meta.json @@ -0,0 +1,77 @@ +{ + "name": "meta", + "description": "Capabilities need no key. In 2.0 processing is inline, upsert by scope is off and no tenant policy rejects old catalogues.", + "fixtures": { + "catalogue": "standard", + "assessments": [], + "keys": [] + }, + "request": { + "method": "GET", + "path": "/api/v2/meta", + "headers": {} + }, + "expect": { + "status": 200, + "headers_present": [ + "X-Request-ID" + ], + "body_subset": { + "object": "meta", + "api_version": "2", + "contract": { + "schema_versions": [ + "2.0" + ], + "current": "2.0" + }, + "catalogue": { + "version": "2026-11-02", + "sha256": "9f1c5b7a3e2d4c6b8a0f1e2d3c4b5a69788796a5b4c3d2e1f0a9b8c7d6e5f4a3", + "collector_min_version": null + }, + "limits": { + "max_body_bytes": 1000000, + "max_resource_items": 1000, + "max_count_per_item": 10000, + "max_cost_items": 12, + "max_egress_items": 1000, + "clock_skew_seconds": 86400, + "max_collection_age_days": 30, + "idempotency_replay_hours": 168 + }, + "processing": { + "modes": [ + "inline" + ] + }, + "features": { + "upsert_by_scope_key": false, + "content_dedupe": false, + "oidc": { + "enabled": false + } + }, + "assessment_types": [ + { + "id": 1, + "code": "basic" + }, + { + "id": 2, + "code": "standard" + }, + { + "id": 3, + "code": "premium" + } + ] + }, + "exists": [ + "/host/product", + "/host/base_url", + "/host/app_version", + "/rate_limits/per_key/requests_per_minute" + ] + } +} diff --git a/docs/api-v2/examples/02-whoami_assessment_key.json b/docs/api-v2/examples/02-whoami_assessment_key.json new file mode 100644 index 0000000..6659df2 --- /dev/null +++ b/docs/api-v2/examples/02-whoami_assessment_key.json @@ -0,0 +1,57 @@ +{ + "name": "whoami assessment key", + "description": "An assessment key is bound to its assessment and can only write syncs.", + "fixtures": { + "catalogue": "standard", + "assessments": [ + { + "name": "main", + "provider": "aws", + "exit_strategy": "repatriation", + "assessment_type": 2, + "scope_key": null + } + ], + "keys": [ + { + "name": "assessment_key", + "kind": "assessment", + "assessment": "main" + } + ] + }, + "request": { + "method": "GET", + "path": "/api/v2/whoami", + "headers": { + "Authorization": "Bearer {{assessment_key}}" + } + }, + "expect": { + "status": 200, + "headers_present": [ + "X-Request-ID" + ], + "body_subset": { + "object": "whoami", + "api_version": "2", + "key": { + "name": "assessment_key", + "kind": "assessment", + "scopes": [ + "syncs:write" + ], + "assessment_id": "{{main_id}}" + } + }, + "exists": [ + "/tenant/type", + "/tenant/id", + "/tenant/name", + "/key/id", + "/key/display", + "/key/expires_at", + "/warnings" + ] + } +} diff --git a/docs/api-v2/examples/03-whoami_account_key.json b/docs/api-v2/examples/03-whoami_account_key.json new file mode 100644 index 0000000..6aa43f2 --- /dev/null +++ b/docs/api-v2/examples/03-whoami_account_key.json @@ -0,0 +1,44 @@ +{ + "name": "whoami account key", + "description": "An account key belongs to the whole tenant and has no binding.", + "fixtures": { + "catalogue": "standard", + "assessments": [], + "keys": [ + { + "name": "account_key", + "kind": "account", + "scopes": [ + "assessments:create", + "assessments:read" + ] + } + ] + }, + "request": { + "method": "GET", + "path": "/api/v2/whoami", + "headers": { + "Authorization": "Bearer {{account_key}}" + } + }, + "expect": { + "status": 200, + "body_subset": { + "object": "whoami", + "key": { + "kind": "account", + "scopes": [ + "assessments:create", + "assessments:read" + ], + "assessment_id": null + } + }, + "exists": [ + "/tenant/id", + "/key/display", + "/key/expires_at" + ] + } +} diff --git a/docs/api-v2/examples/04-request_id_replaced.json b/docs/api-v2/examples/04-request_id_replaced.json new file mode 100644 index 0000000..4193969 --- /dev/null +++ b/docs/api-v2/examples/04-request_id_replaced.json @@ -0,0 +1,43 @@ +{ + "name": "request id replaced", + "description": "A client X-Request-ID that does not match the pattern is replaced, and the response always carries one.", + "fixtures": { + "catalogue": "standard", + "assessments": [ + { + "name": "main", + "provider": "aws", + "exit_strategy": "repatriation", + "assessment_type": 2, + "scope_key": null + } + ], + "keys": [ + { + "name": "assessment_key", + "kind": "assessment", + "assessment": "main" + } + ] + }, + "request": { + "method": "GET", + "path": "/api/v2/whoami", + "headers": { + "Authorization": "Bearer {{assessment_key}}", + "X-Request-ID": "bad id!" + } + }, + "expect": { + "status": 200, + "headers_present": [ + "X-Request-ID" + ], + "headers_differ": { + "X-Request-ID": "bad id!" + }, + "body_subset": { + "object": "whoami" + } + } +} diff --git a/docs/api-v2/examples/05-first_sync_assessment_key.json b/docs/api-v2/examples/05-first_sync_assessment_key.json new file mode 100644 index 0000000..db7fe1f --- /dev/null +++ b/docs/api-v2/examples/05-first_sync_assessment_key.json @@ -0,0 +1,146 @@ +{ + "name": "first sync assessment key", + "description": "The first sync of an assessment key's assessment records the scope key. The catalogue claim equals the host's, so there are no warnings.", + "fixtures": { + "catalogue": "standard", + "assessments": [ + { + "name": "main", + "provider": "aws", + "exit_strategy": "repatriation", + "assessment_type": 2, + "scope_key": null + } + ], + "keys": [ + { + "name": "assessment_key", + "kind": "assessment", + "assessment": "main" + } + ] + }, + "request": { + "method": "POST", + "path": "/api/v2/syncs", + "headers": { + "Authorization": "Bearer {{assessment_key}}", + "Idempotency-Key": "01933b9e-7c2a-7d41-9a51-3c8f0a6e0001", + "Content-Type": "application/json", + "X-Request-ID": "ci-run-20261103-01", + "Prefer": "wait=20" + }, + "body": { + "schema_version": "2.0", + "sync": { + "id": "01933b9e-7c2a-7d41-9a51-3c8f0a6e0001", + "started_at": "{{now-300}}", + "completed_at": "{{now-60}}", + "collector": { + "name": "cloudexit", + "version": "2.0.0", + "mode": "api", + "catalogue": { + "version": "2026-11-02", + "sequence": 41, + "sha256": "9f1c5b7a3e2d4c6b8a0f1e2d3c4b5a69788796a5b4c3d2e1f0a9b8c7d6e5f4a3", + "resource_type_ids": [ + 12, + 40, + 41, + 55 + ] + } + } + }, + "assessment_type": 2, + "scope": { + "provider": "aws", + "aws": { + "account_id": "123456789012", + "regions": [ + "eu-central-1" + ] + } + }, + "inventory": { + "resources": [ + { + "type_id": 12, + "type_code": "AWS::S3::Bucket", + "location": "eu-central-1", + "count": 36 + }, + { + "type_id": 40, + "type_code": "AWS::EC2::Instance", + "location": "eu-central-1", + "count": 8 + } + ], + "costs": [ + { + "month": "2026-10", + "amount": "1234.56", + "currency": "USD", + "final": true + } + ] + } + } + }, + "expect": { + "status": 201, + "headers": { + "X-Request-ID": "ci-run-20261103-01" + }, + "headers_present": [ + "Location" + ], + "body_subset": { + "object": "sync", + "api_version": "2", + "status": "completed", + "client_sync_id": "01933b9e-7c2a-7d41-9a51-3c8f0a6e0001", + "request_id": "ci-run-20261103-01", + "assessment": { + "id": "{{main_id}}", + "created": false, + "scope_key": "aws:123456789012:eu-central-1" + }, + "ordering": { + "latest": true, + "late": false + }, + "content": { + "unchanged_from": null + }, + "assessment_type": { + "requested": 2, + "granted": 2 + }, + "catalogue": { + "host": { + "version": "2026-11-02" + }, + "unknown_resource_types": [], + "inactive_resource_types": [] + } + }, + "exists": [ + "/id", + "/request_id", + "/timestamps/received_at", + "/content/payload_sha256", + "/content/content_sha256", + "/results/scoring_version", + "/links/self", + "/links/assessment", + "/results/scores/exit", + "/results/risks" + ], + "lengths": { + "/warnings": 0 + } + } +} diff --git a/docs/api-v2/examples/06-second_sync_appended.json b/docs/api-v2/examples/06-second_sync_appended.json new file mode 100644 index 0000000..87a867e --- /dev/null +++ b/docs/api-v2/examples/06-second_sync_appended.json @@ -0,0 +1,182 @@ +{ + "name": "second sync appended", + "description": "A second push with the same key appends a new sync to the same assessment.", + "fixtures": { + "catalogue": "standard", + "assessments": [ + { + "name": "main", + "provider": "aws", + "exit_strategy": "repatriation", + "assessment_type": 2, + "scope_key": null + } + ], + "keys": [ + { + "name": "assessment_key", + "kind": "assessment", + "assessment": "main" + } + ] + }, + "before": [ + { + "request": { + "method": "POST", + "path": "/api/v2/syncs", + "headers": { + "Authorization": "Bearer {{assessment_key}}", + "Idempotency-Key": "01933b9e-7c2a-7d41-9a51-3c8f0a6e0002", + "Content-Type": "application/json" + }, + "body": { + "schema_version": "2.0", + "sync": { + "id": "01933b9e-7c2a-7d41-9a51-3c8f0a6e0002", + "started_at": "{{now-700}}", + "completed_at": "{{now-600}}", + "collector": { + "name": "cloudexit", + "version": "2.0.0", + "mode": "api", + "catalogue": { + "version": "2026-11-02", + "sequence": 41, + "sha256": "9f1c5b7a3e2d4c6b8a0f1e2d3c4b5a69788796a5b4c3d2e1f0a9b8c7d6e5f4a3", + "resource_type_ids": [ + 12, + 40, + 41, + 55 + ] + } + } + }, + "assessment_type": 2, + "scope": { + "provider": "aws", + "aws": { + "account_id": "123456789012", + "regions": [ + "eu-central-1" + ] + } + }, + "inventory": { + "resources": [ + { + "type_id": 12, + "type_code": "AWS::S3::Bucket", + "location": "eu-central-1", + "count": 36 + }, + { + "type_id": 40, + "type_code": "AWS::EC2::Instance", + "location": "eu-central-1", + "count": 8 + } + ], + "costs": [ + { + "month": "2026-10", + "amount": "1234.56", + "currency": "USD", + "final": true + } + ] + } + } + }, + "expect": { + "status": 201 + } + } + ], + "request": { + "method": "POST", + "path": "/api/v2/syncs", + "headers": { + "Authorization": "Bearer {{assessment_key}}", + "Idempotency-Key": "01933b9e-7c2a-7d41-9a51-3c8f0a6e0003", + "Content-Type": "application/json" + }, + "body": { + "schema_version": "2.0", + "sync": { + "id": "01933b9e-7c2a-7d41-9a51-3c8f0a6e0003", + "started_at": "{{now-200}}", + "completed_at": "{{now-20}}", + "collector": { + "name": "cloudexit", + "version": "2.0.0", + "mode": "api", + "catalogue": { + "version": "2026-11-02", + "sequence": 41, + "sha256": "9f1c5b7a3e2d4c6b8a0f1e2d3c4b5a69788796a5b4c3d2e1f0a9b8c7d6e5f4a3", + "resource_type_ids": [ + 12, + 40, + 41, + 55 + ] + } + } + }, + "assessment_type": 2, + "scope": { + "provider": "aws", + "aws": { + "account_id": "123456789012", + "regions": [ + "eu-central-1" + ] + } + }, + "inventory": { + "resources": [ + { + "type_id": 12, + "type_code": "AWS::S3::Bucket", + "location": "eu-central-1", + "count": 36 + }, + { + "type_id": 40, + "type_code": "AWS::EC2::Instance", + "location": "eu-central-1", + "count": 8 + } + ], + "costs": [ + { + "month": "2026-10", + "amount": "1234.56", + "currency": "USD", + "final": true + } + ] + } + } + }, + "expect": { + "status": 201, + "body_subset": { + "client_sync_id": "01933b9e-7c2a-7d41-9a51-3c8f0a6e0003", + "assessment": { + "id": "{{main_id}}", + "created": false, + "scope_key": "aws:123456789012:eu-central-1" + }, + "ordering": { + "latest": true, + "late": false + } + }, + "exists": [ + "/id" + ] + } +} diff --git a/docs/api-v2/examples/07-idempotent_replay.json b/docs/api-v2/examples/07-idempotent_replay.json new file mode 100644 index 0000000..c79288a --- /dev/null +++ b/docs/api-v2/examples/07-idempotent_replay.json @@ -0,0 +1,178 @@ +{ + "name": "idempotent replay", + "description": "The same key and body again returns 200 with Idempotent-Replayed and the original sync. A replay never charges, notifies or scores again.", + "fixtures": { + "catalogue": "standard", + "assessments": [ + { + "name": "main", + "provider": "aws", + "exit_strategy": "repatriation", + "assessment_type": 2, + "scope_key": null + } + ], + "keys": [ + { + "name": "assessment_key", + "kind": "assessment", + "assessment": "main" + } + ] + }, + "before": [ + { + "request": { + "method": "POST", + "path": "/api/v2/syncs", + "headers": { + "Authorization": "Bearer {{assessment_key}}", + "Idempotency-Key": "01933b9e-7c2a-7d41-9a51-3c8f0a6e0004", + "Content-Type": "application/json" + }, + "body": { + "schema_version": "2.0", + "sync": { + "id": "01933b9e-7c2a-7d41-9a51-3c8f0a6e0004", + "started_at": "{{now-300}}", + "completed_at": "{{now-60}}", + "collector": { + "name": "cloudexit", + "version": "2.0.0", + "mode": "api", + "catalogue": { + "version": "2026-11-02", + "sequence": 41, + "sha256": "9f1c5b7a3e2d4c6b8a0f1e2d3c4b5a69788796a5b4c3d2e1f0a9b8c7d6e5f4a3", + "resource_type_ids": [ + 12, + 40, + 41, + 55 + ] + } + } + }, + "assessment_type": 2, + "scope": { + "provider": "aws", + "aws": { + "account_id": "123456789012", + "regions": [ + "eu-central-1" + ] + } + }, + "inventory": { + "resources": [ + { + "type_id": 12, + "type_code": "AWS::S3::Bucket", + "location": "eu-central-1", + "count": 36 + }, + { + "type_id": 40, + "type_code": "AWS::EC2::Instance", + "location": "eu-central-1", + "count": 8 + } + ], + "costs": [ + { + "month": "2026-10", + "amount": "1234.56", + "currency": "USD", + "final": true + } + ] + } + } + }, + "expect": { + "status": 201 + }, + "save": { + "first_sync_id": "/id" + } + } + ], + "request": { + "method": "POST", + "path": "/api/v2/syncs", + "headers": { + "Authorization": "Bearer {{assessment_key}}", + "Idempotency-Key": "01933b9e-7c2a-7d41-9a51-3c8f0a6e0004", + "Content-Type": "application/json" + }, + "body": { + "schema_version": "2.0", + "sync": { + "id": "01933b9e-7c2a-7d41-9a51-3c8f0a6e0004", + "started_at": "{{now-300}}", + "completed_at": "{{now-60}}", + "collector": { + "name": "cloudexit", + "version": "2.0.0", + "mode": "api", + "catalogue": { + "version": "2026-11-02", + "sequence": 41, + "sha256": "9f1c5b7a3e2d4c6b8a0f1e2d3c4b5a69788796a5b4c3d2e1f0a9b8c7d6e5f4a3", + "resource_type_ids": [ + 12, + 40, + 41, + 55 + ] + } + } + }, + "assessment_type": 2, + "scope": { + "provider": "aws", + "aws": { + "account_id": "123456789012", + "regions": [ + "eu-central-1" + ] + } + }, + "inventory": { + "resources": [ + { + "type_id": 12, + "type_code": "AWS::S3::Bucket", + "location": "eu-central-1", + "count": 36 + }, + { + "type_id": 40, + "type_code": "AWS::EC2::Instance", + "location": "eu-central-1", + "count": 8 + } + ], + "costs": [ + { + "month": "2026-10", + "amount": "1234.56", + "currency": "USD", + "final": true + } + ] + } + } + }, + "expect": { + "status": 200, + "headers": { + "Idempotent-Replayed": "true" + }, + "body_subset": { + "object": "sync", + "id": "{{first_sync_id}}", + "client_sync_id": "01933b9e-7c2a-7d41-9a51-3c8f0a6e0004" + } + } +} diff --git a/docs/api-v2/examples/08-idempotency_key_reused.json b/docs/api-v2/examples/08-idempotency_key_reused.json new file mode 100644 index 0000000..950de2c --- /dev/null +++ b/docs/api-v2/examples/08-idempotency_key_reused.json @@ -0,0 +1,181 @@ +{ + "name": "idempotency key reused", + "description": "The same Idempotency-Key with a different body is refused.", + "fixtures": { + "catalogue": "standard", + "assessments": [ + { + "name": "main", + "provider": "aws", + "exit_strategy": "repatriation", + "assessment_type": 2, + "scope_key": null + } + ], + "keys": [ + { + "name": "assessment_key", + "kind": "assessment", + "assessment": "main" + } + ] + }, + "before": [ + { + "request": { + "method": "POST", + "path": "/api/v2/syncs", + "headers": { + "Authorization": "Bearer {{assessment_key}}", + "Idempotency-Key": "01933b9e-7c2a-7d41-9a51-3c8f0a6e0005", + "Content-Type": "application/json" + }, + "body": { + "schema_version": "2.0", + "sync": { + "id": "01933b9e-7c2a-7d41-9a51-3c8f0a6e0005", + "started_at": "{{now-300}}", + "completed_at": "{{now-60}}", + "collector": { + "name": "cloudexit", + "version": "2.0.0", + "mode": "api", + "catalogue": { + "version": "2026-11-02", + "sequence": 41, + "sha256": "9f1c5b7a3e2d4c6b8a0f1e2d3c4b5a69788796a5b4c3d2e1f0a9b8c7d6e5f4a3", + "resource_type_ids": [ + 12, + 40, + 41, + 55 + ] + } + } + }, + "assessment_type": 2, + "scope": { + "provider": "aws", + "aws": { + "account_id": "123456789012", + "regions": [ + "eu-central-1" + ] + } + }, + "inventory": { + "resources": [ + { + "type_id": 12, + "type_code": "AWS::S3::Bucket", + "location": "eu-central-1", + "count": 36 + }, + { + "type_id": 40, + "type_code": "AWS::EC2::Instance", + "location": "eu-central-1", + "count": 8 + } + ], + "costs": [ + { + "month": "2026-10", + "amount": "1234.56", + "currency": "USD", + "final": true + } + ] + } + } + }, + "expect": { + "status": 201 + } + } + ], + "request": { + "method": "POST", + "path": "/api/v2/syncs", + "headers": { + "Authorization": "Bearer {{assessment_key}}", + "Idempotency-Key": "01933b9e-7c2a-7d41-9a51-3c8f0a6e0005", + "Content-Type": "application/json" + }, + "body": { + "schema_version": "2.0", + "sync": { + "id": "01933b9e-7c2a-7d41-9a51-3c8f0a6e0005", + "started_at": "{{now-300}}", + "completed_at": "{{now-60}}", + "collector": { + "name": "cloudexit", + "version": "2.0.0", + "mode": "api", + "catalogue": { + "version": "2026-11-02", + "sequence": 41, + "sha256": "9f1c5b7a3e2d4c6b8a0f1e2d3c4b5a69788796a5b4c3d2e1f0a9b8c7d6e5f4a3", + "resource_type_ids": [ + 12, + 40, + 41, + 55 + ] + } + } + }, + "assessment_type": 2, + "scope": { + "provider": "aws", + "aws": { + "account_id": "123456789012", + "regions": [ + "eu-central-1" + ] + } + }, + "inventory": { + "resources": [ + { + "type_id": 12, + "type_code": "AWS::S3::Bucket", + "location": "eu-central-1", + "count": 99 + }, + { + "type_id": 40, + "type_code": "AWS::EC2::Instance", + "location": "eu-central-1", + "count": 8 + } + ], + "costs": [ + { + "month": "2026-10", + "amount": "1234.56", + "currency": "USD", + "final": true + } + ] + } + } + }, + "expect": { + "status": 422, + "headers_present": [ + "X-Request-ID" + ], + "body_subset": { + "object": "error", + "api_version": "2", + "error": { + "code": "idempotency_key_reused" + } + }, + "exists": [ + "/error/message", + "/error/request_id" + ] + } +} diff --git a/docs/api-v2/examples/09-idempotency_key_missing.json b/docs/api-v2/examples/09-idempotency_key_missing.json new file mode 100644 index 0000000..2c38ce3 --- /dev/null +++ b/docs/api-v2/examples/09-idempotency_key_missing.json @@ -0,0 +1,106 @@ +{ + "name": "idempotency key missing", + "description": "Every POST needs an Idempotency-Key.", + "fixtures": { + "catalogue": "standard", + "assessments": [ + { + "name": "main", + "provider": "aws", + "exit_strategy": "repatriation", + "assessment_type": 2, + "scope_key": null + } + ], + "keys": [ + { + "name": "assessment_key", + "kind": "assessment", + "assessment": "main" + } + ] + }, + "request": { + "method": "POST", + "path": "/api/v2/syncs", + "headers": { + "Authorization": "Bearer {{assessment_key}}", + "Content-Type": "application/json" + }, + "body": { + "schema_version": "2.0", + "sync": { + "id": "01933b9e-7c2a-7d41-9a51-3c8f0a6e0006", + "started_at": "{{now-300}}", + "completed_at": "{{now-60}}", + "collector": { + "name": "cloudexit", + "version": "2.0.0", + "mode": "api", + "catalogue": { + "version": "2026-11-02", + "sequence": 41, + "sha256": "9f1c5b7a3e2d4c6b8a0f1e2d3c4b5a69788796a5b4c3d2e1f0a9b8c7d6e5f4a3", + "resource_type_ids": [ + 12, + 40, + 41, + 55 + ] + } + } + }, + "assessment_type": 2, + "scope": { + "provider": "aws", + "aws": { + "account_id": "123456789012", + "regions": [ + "eu-central-1" + ] + } + }, + "inventory": { + "resources": [ + { + "type_id": 12, + "type_code": "AWS::S3::Bucket", + "location": "eu-central-1", + "count": 36 + }, + { + "type_id": 40, + "type_code": "AWS::EC2::Instance", + "location": "eu-central-1", + "count": 8 + } + ], + "costs": [ + { + "month": "2026-10", + "amount": "1234.56", + "currency": "USD", + "final": true + } + ] + } + } + }, + "expect": { + "status": 400, + "headers_present": [ + "X-Request-ID" + ], + "body_subset": { + "object": "error", + "api_version": "2", + "error": { + "code": "idempotency_key_missing" + } + }, + "exists": [ + "/error/message", + "/error/request_id" + ] + } +} diff --git a/docs/api-v2/examples/10-idempotency_key_mismatch.json b/docs/api-v2/examples/10-idempotency_key_mismatch.json new file mode 100644 index 0000000..37eabb1 --- /dev/null +++ b/docs/api-v2/examples/10-idempotency_key_mismatch.json @@ -0,0 +1,107 @@ +{ + "name": "idempotency key mismatch", + "description": "The Idempotency-Key must equal sync.id.", + "fixtures": { + "catalogue": "standard", + "assessments": [ + { + "name": "main", + "provider": "aws", + "exit_strategy": "repatriation", + "assessment_type": 2, + "scope_key": null + } + ], + "keys": [ + { + "name": "assessment_key", + "kind": "assessment", + "assessment": "main" + } + ] + }, + "request": { + "method": "POST", + "path": "/api/v2/syncs", + "headers": { + "Authorization": "Bearer {{assessment_key}}", + "Idempotency-Key": "01933b9e-7c2a-7d41-9a51-3c8f0a6e0008", + "Content-Type": "application/json" + }, + "body": { + "schema_version": "2.0", + "sync": { + "id": "01933b9e-7c2a-7d41-9a51-3c8f0a6e0007", + "started_at": "{{now-300}}", + "completed_at": "{{now-60}}", + "collector": { + "name": "cloudexit", + "version": "2.0.0", + "mode": "api", + "catalogue": { + "version": "2026-11-02", + "sequence": 41, + "sha256": "9f1c5b7a3e2d4c6b8a0f1e2d3c4b5a69788796a5b4c3d2e1f0a9b8c7d6e5f4a3", + "resource_type_ids": [ + 12, + 40, + 41, + 55 + ] + } + } + }, + "assessment_type": 2, + "scope": { + "provider": "aws", + "aws": { + "account_id": "123456789012", + "regions": [ + "eu-central-1" + ] + } + }, + "inventory": { + "resources": [ + { + "type_id": 12, + "type_code": "AWS::S3::Bucket", + "location": "eu-central-1", + "count": 36 + }, + { + "type_id": 40, + "type_code": "AWS::EC2::Instance", + "location": "eu-central-1", + "count": 8 + } + ], + "costs": [ + { + "month": "2026-10", + "amount": "1234.56", + "currency": "USD", + "final": true + } + ] + } + } + }, + "expect": { + "status": 422, + "headers_present": [ + "X-Request-ID" + ], + "body_subset": { + "object": "error", + "api_version": "2", + "error": { + "code": "idempotency_key_mismatch" + } + }, + "exists": [ + "/error/message", + "/error/request_id" + ] + } +} diff --git a/docs/api-v2/examples/11-scope_mismatch.json b/docs/api-v2/examples/11-scope_mismatch.json new file mode 100644 index 0000000..25780c8 --- /dev/null +++ b/docs/api-v2/examples/11-scope_mismatch.json @@ -0,0 +1,114 @@ +{ + "name": "scope mismatch", + "description": "Once the first sync recorded the scope, a sync of another account is refused.", + "fixtures": { + "catalogue": "standard", + "assessments": [ + { + "name": "main", + "provider": "aws", + "exit_strategy": "repatriation", + "assessment_type": 2, + "scope_key": "aws:123456789012:eu-central-1" + } + ], + "keys": [ + { + "name": "assessment_key", + "kind": "assessment", + "assessment": "main" + } + ] + }, + "request": { + "method": "POST", + "path": "/api/v2/syncs", + "headers": { + "Authorization": "Bearer {{assessment_key}}", + "Idempotency-Key": "01933b9e-7c2a-7d41-9a51-3c8f0a6e0009", + "Content-Type": "application/json" + }, + "body": { + "schema_version": "2.0", + "sync": { + "id": "01933b9e-7c2a-7d41-9a51-3c8f0a6e0009", + "started_at": "{{now-300}}", + "completed_at": "{{now-60}}", + "collector": { + "name": "cloudexit", + "version": "2.0.0", + "mode": "api", + "catalogue": { + "version": "2026-11-02", + "sequence": 41, + "sha256": "9f1c5b7a3e2d4c6b8a0f1e2d3c4b5a69788796a5b4c3d2e1f0a9b8c7d6e5f4a3", + "resource_type_ids": [ + 12, + 40, + 41, + 55 + ] + } + } + }, + "assessment_type": 2, + "scope": { + "provider": "aws", + "aws": { + "account_id": "999999999999", + "regions": [ + "eu-central-1" + ] + } + }, + "inventory": { + "resources": [ + { + "type_id": 12, + "type_code": "AWS::S3::Bucket", + "location": "eu-central-1", + "count": 36 + }, + { + "type_id": 40, + "type_code": "AWS::EC2::Instance", + "location": "eu-central-1", + "count": 8 + } + ], + "costs": [ + { + "month": "2026-10", + "amount": "1234.56", + "currency": "USD", + "final": true + } + ] + } + } + }, + "expect": { + "status": 409, + "headers_present": [ + "X-Request-ID" + ], + "body_subset": { + "object": "error", + "api_version": "2", + "error": { + "code": "target_mismatch", + "details": [ + { + "field": "scope_key", + "expected": "aws:123456789012:eu-central-1", + "actual": "aws:999999999999:eu-central-1" + } + ] + } + }, + "exists": [ + "/error/message", + "/error/request_id" + ] + } +} diff --git a/docs/api-v2/examples/12-provider_mismatch.json b/docs/api-v2/examples/12-provider_mismatch.json new file mode 100644 index 0000000..d2e8901 --- /dev/null +++ b/docs/api-v2/examples/12-provider_mismatch.json @@ -0,0 +1,114 @@ +{ + "name": "provider mismatch", + "description": "A sync of another provider than the assessment's is refused.", + "fixtures": { + "catalogue": "standard", + "assessments": [ + { + "name": "main", + "provider": "azure", + "exit_strategy": "repatriation", + "assessment_type": 2, + "scope_key": null + } + ], + "keys": [ + { + "name": "assessment_key", + "kind": "assessment", + "assessment": "main" + } + ] + }, + "request": { + "method": "POST", + "path": "/api/v2/syncs", + "headers": { + "Authorization": "Bearer {{assessment_key}}", + "Idempotency-Key": "01933b9e-7c2a-7d41-9a51-3c8f0a6e000a", + "Content-Type": "application/json" + }, + "body": { + "schema_version": "2.0", + "sync": { + "id": "01933b9e-7c2a-7d41-9a51-3c8f0a6e000a", + "started_at": "{{now-300}}", + "completed_at": "{{now-60}}", + "collector": { + "name": "cloudexit", + "version": "2.0.0", + "mode": "api", + "catalogue": { + "version": "2026-11-02", + "sequence": 41, + "sha256": "9f1c5b7a3e2d4c6b8a0f1e2d3c4b5a69788796a5b4c3d2e1f0a9b8c7d6e5f4a3", + "resource_type_ids": [ + 12, + 40, + 41, + 55 + ] + } + } + }, + "assessment_type": 2, + "scope": { + "provider": "aws", + "aws": { + "account_id": "123456789012", + "regions": [ + "eu-central-1" + ] + } + }, + "inventory": { + "resources": [ + { + "type_id": 12, + "type_code": "AWS::S3::Bucket", + "location": "eu-central-1", + "count": 36 + }, + { + "type_id": 40, + "type_code": "AWS::EC2::Instance", + "location": "eu-central-1", + "count": 8 + } + ], + "costs": [ + { + "month": "2026-10", + "amount": "1234.56", + "currency": "USD", + "final": true + } + ] + } + } + }, + "expect": { + "status": 409, + "headers_present": [ + "X-Request-ID" + ], + "body_subset": { + "object": "error", + "api_version": "2", + "error": { + "code": "target_mismatch", + "details": [ + { + "field": "provider", + "expected": "azure", + "actual": "aws" + } + ] + } + }, + "exists": [ + "/error/message", + "/error/request_id" + ] + } +} diff --git a/docs/api-v2/examples/13-depth_too_deep.json b/docs/api-v2/examples/13-depth_too_deep.json new file mode 100644 index 0000000..aef6b0c --- /dev/null +++ b/docs/api-v2/examples/13-depth_too_deep.json @@ -0,0 +1,118 @@ +{ + "name": "depth too deep", + "description": "A sync deeper than its assessment is refused.", + "fixtures": { + "catalogue": "standard", + "assessments": [ + { + "name": "main", + "provider": "aws", + "exit_strategy": "repatriation", + "assessment_type": 2, + "scope_key": null + } + ], + "keys": [ + { + "name": "assessment_key", + "kind": "assessment", + "assessment": "main" + } + ] + }, + "request": { + "method": "POST", + "path": "/api/v2/syncs", + "headers": { + "Authorization": "Bearer {{assessment_key}}", + "Idempotency-Key": "01933b9e-7c2a-7d41-9a51-3c8f0a6e000b", + "Content-Type": "application/json" + }, + "body": { + "schema_version": "2.0", + "sync": { + "id": "01933b9e-7c2a-7d41-9a51-3c8f0a6e000b", + "started_at": "{{now-300}}", + "completed_at": "{{now-60}}", + "collector": { + "name": "cloudexit", + "version": "2.0.0", + "mode": "api", + "catalogue": { + "version": "2026-11-02", + "sequence": 41, + "sha256": "9f1c5b7a3e2d4c6b8a0f1e2d3c4b5a69788796a5b4c3d2e1f0a9b8c7d6e5f4a3", + "resource_type_ids": [ + 12, + 40, + 41, + 55 + ] + } + } + }, + "assessment_type": 3, + "scope": { + "provider": "aws", + "aws": { + "account_id": "123456789012", + "regions": [ + "eu-central-1" + ] + } + }, + "inventory": { + "resources": [ + { + "type_id": 12, + "type_code": "AWS::S3::Bucket", + "location": "eu-central-1", + "count": 36 + }, + { + "type_id": 40, + "type_code": "AWS::EC2::Instance", + "location": "eu-central-1", + "count": 8 + } + ], + "costs": [ + { + "month": "2026-10", + "amount": "1234.56", + "currency": "USD", + "final": true + } + ], + "egress": { + "collected": true, + "items": [] + } + } + } + }, + "expect": { + "status": 409, + "headers_present": [ + "X-Request-ID" + ], + "body_subset": { + "object": "error", + "api_version": "2", + "error": { + "code": "target_mismatch", + "details": [ + { + "field": "assessment_type", + "expected": 2, + "actual": 3 + } + ] + } + }, + "exists": [ + "/error/message", + "/error/request_id" + ] + } +} diff --git a/docs/api-v2/examples/14-depth_three_without_egress.json b/docs/api-v2/examples/14-depth_three_without_egress.json new file mode 100644 index 0000000..595bf00 --- /dev/null +++ b/docs/api-v2/examples/14-depth_three_without_egress.json @@ -0,0 +1,108 @@ +{ + "name": "depth three without egress", + "description": "Depth 3 without collected egress data is recorded as depth 2.", + "fixtures": { + "catalogue": "standard", + "assessments": [ + { + "name": "main", + "provider": "aws", + "exit_strategy": "repatriation", + "assessment_type": 3, + "scope_key": null + } + ], + "keys": [ + { + "name": "assessment_key", + "kind": "assessment", + "assessment": "main" + } + ] + }, + "request": { + "method": "POST", + "path": "/api/v2/syncs", + "headers": { + "Authorization": "Bearer {{assessment_key}}", + "Idempotency-Key": "01933b9e-7c2a-7d41-9a51-3c8f0a6e000c", + "Content-Type": "application/json" + }, + "body": { + "schema_version": "2.0", + "sync": { + "id": "01933b9e-7c2a-7d41-9a51-3c8f0a6e000c", + "started_at": "{{now-300}}", + "completed_at": "{{now-60}}", + "collector": { + "name": "cloudexit", + "version": "2.0.0", + "mode": "api", + "catalogue": { + "version": "2026-11-02", + "sequence": 41, + "sha256": "9f1c5b7a3e2d4c6b8a0f1e2d3c4b5a69788796a5b4c3d2e1f0a9b8c7d6e5f4a3", + "resource_type_ids": [ + 12, + 40, + 41, + 55 + ] + } + } + }, + "assessment_type": 3, + "scope": { + "provider": "aws", + "aws": { + "account_id": "123456789012", + "regions": [ + "eu-central-1" + ] + } + }, + "inventory": { + "resources": [ + { + "type_id": 12, + "type_code": "AWS::S3::Bucket", + "location": "eu-central-1", + "count": 36 + }, + { + "type_id": 40, + "type_code": "AWS::EC2::Instance", + "location": "eu-central-1", + "count": 8 + } + ], + "costs": [ + { + "month": "2026-10", + "amount": "1234.56", + "currency": "USD", + "final": true + } + ], + "egress": { + "collected": false + } + } + } + }, + "expect": { + "status": 201, + "body_subset": { + "status": "completed_with_warnings", + "assessment_type": { + "requested": 3, + "granted": 2 + }, + "warnings": [ + { + "code": "tier_downgraded" + } + ] + } + } +} diff --git a/docs/api-v2/examples/15-depth_three_with_egress.json b/docs/api-v2/examples/15-depth_three_with_egress.json new file mode 100644 index 0000000..50b31e8 --- /dev/null +++ b/docs/api-v2/examples/15-depth_three_with_egress.json @@ -0,0 +1,124 @@ +{ + "name": "depth three with egress", + "description": "Depth 3 with collected egress data keeps depth 3.", + "fixtures": { + "catalogue": "standard", + "assessments": [ + { + "name": "main", + "provider": "aws", + "exit_strategy": "repatriation", + "assessment_type": 3, + "scope_key": null + } + ], + "keys": [ + { + "name": "assessment_key", + "kind": "assessment", + "assessment": "main" + } + ] + }, + "request": { + "method": "POST", + "path": "/api/v2/syncs", + "headers": { + "Authorization": "Bearer {{assessment_key}}", + "Idempotency-Key": "01933b9e-7c2a-7d41-9a51-3c8f0a6e000d", + "Content-Type": "application/json" + }, + "body": { + "schema_version": "2.0", + "sync": { + "id": "01933b9e-7c2a-7d41-9a51-3c8f0a6e000d", + "started_at": "{{now-300}}", + "completed_at": "{{now-60}}", + "collector": { + "name": "cloudexit", + "version": "2.0.0", + "mode": "api", + "catalogue": { + "version": "2026-11-02", + "sequence": 41, + "sha256": "9f1c5b7a3e2d4c6b8a0f1e2d3c4b5a69788796a5b4c3d2e1f0a9b8c7d6e5f4a3", + "resource_type_ids": [ + 12, + 40, + 41, + 55 + ] + } + } + }, + "assessment_type": 3, + "scope": { + "provider": "aws", + "aws": { + "account_id": "123456789012", + "regions": [ + "eu-central-1" + ] + } + }, + "inventory": { + "resources": [ + { + "type_id": 12, + "type_code": "AWS::S3::Bucket", + "location": "eu-central-1", + "count": 36 + }, + { + "type_id": 40, + "type_code": "AWS::EC2::Instance", + "location": "eu-central-1", + "count": 8 + } + ], + "costs": [ + { + "month": "2026-10", + "amount": "1234.56", + "currency": "USD", + "final": true + } + ], + "egress": { + "collected": true, + "truncated": false, + "names": "plain", + "items": [ + { + "type_id": 12, + "name": "acme-logs", + "size_bytes": 5368709120, + "flags": [], + "notes": [], + "tiers": [ + { + "tier": "STANDARD", + "size_bytes": 5368709120, + "is_archive": false + } + ] + } + ] + } + } + } + }, + "expect": { + "status": 201, + "body_subset": { + "status": "completed", + "assessment_type": { + "requested": 3, + "granted": 3 + } + }, + "lengths": { + "/warnings": 0 + } + } +} diff --git a/docs/api-v2/examples/16-unknown_resource_types.json b/docs/api-v2/examples/16-unknown_resource_types.json new file mode 100644 index 0000000..30c666f --- /dev/null +++ b/docs/api-v2/examples/16-unknown_resource_types.json @@ -0,0 +1,114 @@ +{ + "name": "unknown resource types", + "description": "Resources of a type the host does not know are stored, excluded from scoring and reported as a warning.", + "fixtures": { + "catalogue": "standard", + "assessments": [ + { + "name": "main", + "provider": "aws", + "exit_strategy": "repatriation", + "assessment_type": 2, + "scope_key": null + } + ], + "keys": [ + { + "name": "assessment_key", + "kind": "assessment", + "assessment": "main" + } + ] + }, + "request": { + "method": "POST", + "path": "/api/v2/syncs", + "headers": { + "Authorization": "Bearer {{assessment_key}}", + "Idempotency-Key": "01933b9e-7c2a-7d41-9a51-3c8f0a6e000e", + "Content-Type": "application/json" + }, + "body": { + "schema_version": "2.0", + "sync": { + "id": "01933b9e-7c2a-7d41-9a51-3c8f0a6e000e", + "started_at": "{{now-300}}", + "completed_at": "{{now-60}}", + "collector": { + "name": "cloudexit", + "version": "2.0.0", + "mode": "api", + "catalogue": { + "version": "2026-11-02", + "sequence": 41, + "sha256": "9f1c5b7a3e2d4c6b8a0f1e2d3c4b5a69788796a5b4c3d2e1f0a9b8c7d6e5f4a3", + "resource_type_ids": [ + 12, + 40, + 41, + 55 + ] + } + } + }, + "assessment_type": 2, + "scope": { + "provider": "aws", + "aws": { + "account_id": "123456789012", + "regions": [ + "eu-central-1" + ] + } + }, + "inventory": { + "resources": [ + { + "type_id": 12, + "type_code": "AWS::S3::Bucket", + "location": "eu-central-1", + "count": 36 + }, + { + "type_id": 981, + "type_code": "AWS::Foo::Bar", + "location": "eu-central-1", + "count": 3 + } + ], + "costs": [ + { + "month": "2026-10", + "amount": "1234.56", + "currency": "USD", + "final": true + } + ] + } + } + }, + "expect": { + "status": 201, + "body_subset": { + "status": "completed_with_warnings", + "warnings": [ + { + "code": "unknown_resource_types", + "details": { + "type_ids": [ + 981 + ] + } + } + ], + "catalogue": { + "unknown_resource_types": [ + { + "type_id": 981, + "count": 3 + } + ] + } + } + } +} diff --git a/docs/api-v2/examples/17-inactive_resource_types.json b/docs/api-v2/examples/17-inactive_resource_types.json new file mode 100644 index 0000000..0181ff8 --- /dev/null +++ b/docs/api-v2/examples/17-inactive_resource_types.json @@ -0,0 +1,114 @@ +{ + "name": "inactive resource types", + "description": "Resources of a type the host has deactivated are stored and excluded from scoring.", + "fixtures": { + "catalogue": "standard", + "assessments": [ + { + "name": "main", + "provider": "aws", + "exit_strategy": "repatriation", + "assessment_type": 2, + "scope_key": null + } + ], + "keys": [ + { + "name": "assessment_key", + "kind": "assessment", + "assessment": "main" + } + ] + }, + "request": { + "method": "POST", + "path": "/api/v2/syncs", + "headers": { + "Authorization": "Bearer {{assessment_key}}", + "Idempotency-Key": "01933b9e-7c2a-7d41-9a51-3c8f0a6e000f", + "Content-Type": "application/json" + }, + "body": { + "schema_version": "2.0", + "sync": { + "id": "01933b9e-7c2a-7d41-9a51-3c8f0a6e000f", + "started_at": "{{now-300}}", + "completed_at": "{{now-60}}", + "collector": { + "name": "cloudexit", + "version": "2.0.0", + "mode": "api", + "catalogue": { + "version": "2026-11-02", + "sequence": 41, + "sha256": "9f1c5b7a3e2d4c6b8a0f1e2d3c4b5a69788796a5b4c3d2e1f0a9b8c7d6e5f4a3", + "resource_type_ids": [ + 12, + 40, + 41, + 55 + ] + } + } + }, + "assessment_type": 2, + "scope": { + "provider": "aws", + "aws": { + "account_id": "123456789012", + "regions": [ + "eu-central-1" + ] + } + }, + "inventory": { + "resources": [ + { + "type_id": 12, + "type_code": "AWS::S3::Bucket", + "location": "eu-central-1", + "count": 36 + }, + { + "type_id": 77, + "type_code": "AWS::SimpleDB::Domain", + "location": "eu-central-1", + "count": 2 + } + ], + "costs": [ + { + "month": "2026-10", + "amount": "1234.56", + "currency": "USD", + "final": true + } + ] + } + } + }, + "expect": { + "status": 201, + "body_subset": { + "status": "completed_with_warnings", + "warnings": [ + { + "code": "resource_type_inactive", + "details": { + "type_ids": [ + 77 + ] + } + } + ], + "catalogue": { + "inactive_resource_types": [ + { + "type_id": 77, + "count": 2 + } + ] + } + } + } +} diff --git a/docs/api-v2/examples/18-all_resource_types_unknown.json b/docs/api-v2/examples/18-all_resource_types_unknown.json new file mode 100644 index 0000000..36fad72 --- /dev/null +++ b/docs/api-v2/examples/18-all_resource_types_unknown.json @@ -0,0 +1,105 @@ +{ + "name": "all resource types unknown", + "description": "Only a sync whose resources are all unknown is refused.", + "fixtures": { + "catalogue": "standard", + "assessments": [ + { + "name": "main", + "provider": "aws", + "exit_strategy": "repatriation", + "assessment_type": 2, + "scope_key": null + } + ], + "keys": [ + { + "name": "assessment_key", + "kind": "assessment", + "assessment": "main" + } + ] + }, + "request": { + "method": "POST", + "path": "/api/v2/syncs", + "headers": { + "Authorization": "Bearer {{assessment_key}}", + "Idempotency-Key": "01933b9e-7c2a-7d41-9a51-3c8f0a6e0010", + "Content-Type": "application/json" + }, + "body": { + "schema_version": "2.0", + "sync": { + "id": "01933b9e-7c2a-7d41-9a51-3c8f0a6e0010", + "started_at": "{{now-300}}", + "completed_at": "{{now-60}}", + "collector": { + "name": "cloudexit", + "version": "2.0.0", + "mode": "api", + "catalogue": { + "version": "2026-11-02", + "sequence": 41, + "sha256": "9f1c5b7a3e2d4c6b8a0f1e2d3c4b5a69788796a5b4c3d2e1f0a9b8c7d6e5f4a3", + "resource_type_ids": [ + 12, + 40, + 41, + 55 + ] + } + } + }, + "assessment_type": 2, + "scope": { + "provider": "aws", + "aws": { + "account_id": "123456789012", + "regions": [ + "eu-central-1" + ] + } + }, + "inventory": { + "resources": [ + { + "type_id": 981, + "location": "eu-central-1", + "count": 3 + }, + { + "type_id": 982, + "location": "eu-central-1", + "count": 1 + } + ], + "costs": [ + { + "month": "2026-10", + "amount": "1234.56", + "currency": "USD", + "final": true + } + ] + } + } + }, + "expect": { + "status": 422, + "headers_present": [ + "X-Request-ID" + ], + "body_subset": { + "object": "error", + "api_version": "2", + "error": { + "code": "catalogue_mismatch" + } + }, + "exists": [ + "/error/message", + "/error/request_id" + ] + } +} diff --git a/docs/api-v2/examples/19-duplicate_items_merged.json b/docs/api-v2/examples/19-duplicate_items_merged.json new file mode 100644 index 0000000..9c94160 --- /dev/null +++ b/docs/api-v2/examples/19-duplicate_items_merged.json @@ -0,0 +1,99 @@ +{ + "name": "duplicate items merged", + "description": "Items with the same type and location are merged by summing their counts.", + "fixtures": { + "catalogue": "standard", + "assessments": [ + { + "name": "main", + "provider": "aws", + "exit_strategy": "repatriation", + "assessment_type": 2, + "scope_key": null + } + ], + "keys": [ + { + "name": "assessment_key", + "kind": "assessment", + "assessment": "main" + } + ] + }, + "request": { + "method": "POST", + "path": "/api/v2/syncs", + "headers": { + "Authorization": "Bearer {{assessment_key}}", + "Idempotency-Key": "01933b9e-7c2a-7d41-9a51-3c8f0a6e0011", + "Content-Type": "application/json" + }, + "body": { + "schema_version": "2.0", + "sync": { + "id": "01933b9e-7c2a-7d41-9a51-3c8f0a6e0011", + "started_at": "{{now-300}}", + "completed_at": "{{now-60}}", + "collector": { + "name": "cloudexit", + "version": "2.0.0", + "mode": "api", + "catalogue": { + "version": "2026-11-02", + "sequence": 41, + "sha256": "9f1c5b7a3e2d4c6b8a0f1e2d3c4b5a69788796a5b4c3d2e1f0a9b8c7d6e5f4a3", + "resource_type_ids": [ + 12, + 40, + 41, + 55 + ] + } + } + }, + "assessment_type": 2, + "scope": { + "provider": "aws", + "aws": { + "account_id": "123456789012", + "regions": [ + "eu-central-1" + ] + } + }, + "inventory": { + "resources": [ + { + "type_id": 12, + "location": "eu-central-1", + "count": 30 + }, + { + "type_id": 12, + "location": "eu-central-1", + "count": 6 + } + ], + "costs": [ + { + "month": "2026-10", + "amount": "1234.56", + "currency": "USD", + "final": true + } + ] + } + } + }, + "expect": { + "status": 201, + "body_subset": { + "status": "completed_with_warnings", + "warnings": [ + { + "code": "duplicate_items_merged" + } + ] + } + } +} diff --git a/docs/api-v2/examples/20-catalogue_collector_older.json b/docs/api-v2/examples/20-catalogue_collector_older.json new file mode 100644 index 0000000..99259c1 --- /dev/null +++ b/docs/api-v2/examples/20-catalogue_collector_older.json @@ -0,0 +1,114 @@ +{ + "name": "catalogue collector older", + "description": "A collector with an older catalogue gets a warning listing the host's active types it could not collect.", + "fixtures": { + "catalogue": "standard", + "assessments": [ + { + "name": "main", + "provider": "aws", + "exit_strategy": "repatriation", + "assessment_type": 2, + "scope_key": null + } + ], + "keys": [ + { + "name": "assessment_key", + "kind": "assessment", + "assessment": "main" + } + ] + }, + "request": { + "method": "POST", + "path": "/api/v2/syncs", + "headers": { + "Authorization": "Bearer {{assessment_key}}", + "Idempotency-Key": "01933b9e-7c2a-7d41-9a51-3c8f0a6e0012", + "Content-Type": "application/json" + }, + "body": { + "schema_version": "2.0", + "sync": { + "id": "01933b9e-7c2a-7d41-9a51-3c8f0a6e0012", + "started_at": "{{now-300}}", + "completed_at": "{{now-60}}", + "collector": { + "name": "cloudexit", + "version": "2.0.0", + "mode": "api", + "catalogue": { + "version": "2026-10-12", + "sequence": 38, + "sha256": "c0ffee0000000000000000000000000000000000000000000000000000000000", + "resource_type_ids": [ + 12, + 40 + ] + } + } + }, + "assessment_type": 2, + "scope": { + "provider": "aws", + "aws": { + "account_id": "123456789012", + "regions": [ + "eu-central-1" + ] + } + }, + "inventory": { + "resources": [ + { + "type_id": 12, + "type_code": "AWS::S3::Bucket", + "location": "eu-central-1", + "count": 36 + }, + { + "type_id": 40, + "type_code": "AWS::EC2::Instance", + "location": "eu-central-1", + "count": 8 + } + ], + "costs": [ + { + "month": "2026-10", + "amount": "1234.56", + "currency": "USD", + "final": true + } + ] + } + } + }, + "expect": { + "status": 201, + "body_subset": { + "status": "completed_with_warnings", + "warnings": [ + { + "code": "collector_catalogue_outdated", + "details": { + "missing_resource_types": [ + 41, + 55 + ] + } + } + ], + "catalogue": { + "client": { + "version": "2026-10-12", + "sequence": 38 + }, + "host": { + "version": "2026-11-02" + } + } + } + } +} diff --git a/docs/api-v2/examples/21-catalogue_collector_newer.json b/docs/api-v2/examples/21-catalogue_collector_newer.json new file mode 100644 index 0000000..0885f21 --- /dev/null +++ b/docs/api-v2/examples/21-catalogue_collector_newer.json @@ -0,0 +1,107 @@ +{ + "name": "catalogue collector newer", + "description": "A collector with a newer catalogue gets a warning listing the type ids the host does not know.", + "fixtures": { + "catalogue": "standard", + "assessments": [ + { + "name": "main", + "provider": "aws", + "exit_strategy": "repatriation", + "assessment_type": 2, + "scope_key": null + } + ], + "keys": [ + { + "name": "assessment_key", + "kind": "assessment", + "assessment": "main" + } + ] + }, + "request": { + "method": "POST", + "path": "/api/v2/syncs", + "headers": { + "Authorization": "Bearer {{assessment_key}}", + "Idempotency-Key": "01933b9e-7c2a-7d41-9a51-3c8f0a6e0013", + "Content-Type": "application/json" + }, + "body": { + "schema_version": "2.0", + "sync": { + "id": "01933b9e-7c2a-7d41-9a51-3c8f0a6e0013", + "started_at": "{{now-300}}", + "completed_at": "{{now-60}}", + "collector": { + "name": "cloudexit", + "version": "2.0.0", + "mode": "api", + "catalogue": { + "version": "2026-12-01", + "sequence": 45, + "sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "resource_type_ids": [ + 12, + 40, + 41, + 55, + 981 + ] + } + } + }, + "assessment_type": 2, + "scope": { + "provider": "aws", + "aws": { + "account_id": "123456789012", + "regions": [ + "eu-central-1" + ] + } + }, + "inventory": { + "resources": [ + { + "type_id": 12, + "type_code": "AWS::S3::Bucket", + "location": "eu-central-1", + "count": 36 + }, + { + "type_id": 40, + "type_code": "AWS::EC2::Instance", + "location": "eu-central-1", + "count": 8 + } + ], + "costs": [ + { + "month": "2026-10", + "amount": "1234.56", + "currency": "USD", + "final": true + } + ] + } + } + }, + "expect": { + "status": 201, + "body_subset": { + "status": "completed_with_warnings", + "warnings": [ + { + "code": "host_catalogue_outdated", + "details": { + "unknown_type_ids": [ + 981 + ] + } + } + ] + } + } +} diff --git a/docs/api-v2/examples/22-catalogue_versions_compared_as_numbers.json b/docs/api-v2/examples/22-catalogue_versions_compared_as_numbers.json new file mode 100644 index 0000000..3215d98 --- /dev/null +++ b/docs/api-v2/examples/22-catalogue_versions_compared_as_numbers.json @@ -0,0 +1,103 @@ +{ + "name": "catalogue versions compared as numbers", + "description": "Versions are compared as (date, N), not as text: 2026-11-02.9 is older than 2026-11-02.10.", + "fixtures": { + "catalogue": { + "use": "standard", + "version": "2026-11-02.10" + }, + "assessments": [ + { + "name": "main", + "provider": "aws", + "exit_strategy": "repatriation", + "assessment_type": 2, + "scope_key": null + } + ], + "keys": [ + { + "name": "assessment_key", + "kind": "assessment", + "assessment": "main" + } + ] + }, + "request": { + "method": "POST", + "path": "/api/v2/syncs", + "headers": { + "Authorization": "Bearer {{assessment_key}}", + "Idempotency-Key": "01933b9e-7c2a-7d41-9a51-3c8f0a6e0014", + "Content-Type": "application/json" + }, + "body": { + "schema_version": "2.0", + "sync": { + "id": "01933b9e-7c2a-7d41-9a51-3c8f0a6e0014", + "started_at": "{{now-300}}", + "completed_at": "{{now-60}}", + "collector": { + "name": "cloudexit", + "version": "2.0.0", + "mode": "api", + "catalogue": { + "version": "2026-11-02.9", + "sequence": 40, + "sha256": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "resource_type_ids": [ + 12, + 40, + 41, + 55 + ] + } + } + }, + "assessment_type": 2, + "scope": { + "provider": "aws", + "aws": { + "account_id": "123456789012", + "regions": [ + "eu-central-1" + ] + } + }, + "inventory": { + "resources": [ + { + "type_id": 12, + "type_code": "AWS::S3::Bucket", + "location": "eu-central-1", + "count": 36 + }, + { + "type_id": 40, + "type_code": "AWS::EC2::Instance", + "location": "eu-central-1", + "count": 8 + } + ], + "costs": [ + { + "month": "2026-10", + "amount": "1234.56", + "currency": "USD", + "final": true + } + ] + } + } + }, + "expect": { + "status": 201, + "body_subset": { + "warnings": [ + { + "code": "collector_catalogue_outdated" + } + ] + } + } +} diff --git a/docs/api-v2/examples/23-catalogue_missing.json b/docs/api-v2/examples/23-catalogue_missing.json new file mode 100644 index 0000000..fd07047 --- /dev/null +++ b/docs/api-v2/examples/23-catalogue_missing.json @@ -0,0 +1,93 @@ +{ + "name": "catalogue missing", + "description": "Without a catalogue block the catalogues cannot be compared.", + "fixtures": { + "catalogue": "standard", + "assessments": [ + { + "name": "main", + "provider": "aws", + "exit_strategy": "repatriation", + "assessment_type": 2, + "scope_key": null + } + ], + "keys": [ + { + "name": "assessment_key", + "kind": "assessment", + "assessment": "main" + } + ] + }, + "request": { + "method": "POST", + "path": "/api/v2/syncs", + "headers": { + "Authorization": "Bearer {{assessment_key}}", + "Idempotency-Key": "01933b9e-7c2a-7d41-9a51-3c8f0a6e0015", + "Content-Type": "application/json" + }, + "body": { + "schema_version": "2.0", + "sync": { + "id": "01933b9e-7c2a-7d41-9a51-3c8f0a6e0015", + "started_at": "{{now-300}}", + "completed_at": "{{now-60}}", + "collector": { + "name": "cloudexit", + "version": "2.0.0", + "mode": "api" + } + }, + "assessment_type": 2, + "scope": { + "provider": "aws", + "aws": { + "account_id": "123456789012", + "regions": [ + "eu-central-1" + ] + } + }, + "inventory": { + "resources": [ + { + "type_id": 12, + "type_code": "AWS::S3::Bucket", + "location": "eu-central-1", + "count": 36 + }, + { + "type_id": 40, + "type_code": "AWS::EC2::Instance", + "location": "eu-central-1", + "count": 8 + } + ], + "costs": [ + { + "month": "2026-10", + "amount": "1234.56", + "currency": "USD", + "final": true + } + ] + } + } + }, + "expect": { + "status": 201, + "body_subset": { + "status": "completed_with_warnings", + "warnings": [ + { + "code": "collector_catalogue_unknown" + } + ], + "catalogue": { + "client": null + } + } + } +} diff --git a/docs/api-v2/examples/24-catalogue_sha256_mismatch.json b/docs/api-v2/examples/24-catalogue_sha256_mismatch.json new file mode 100644 index 0000000..13d2d81 --- /dev/null +++ b/docs/api-v2/examples/24-catalogue_sha256_mismatch.json @@ -0,0 +1,113 @@ +{ + "name": "catalogue sha256 mismatch", + "description": "The same version with a different hash means a corrupt or tampered dataset.", + "fixtures": { + "catalogue": "standard", + "assessments": [ + { + "name": "main", + "provider": "aws", + "exit_strategy": "repatriation", + "assessment_type": 2, + "scope_key": null + } + ], + "keys": [ + { + "name": "assessment_key", + "kind": "assessment", + "assessment": "main" + } + ] + }, + "request": { + "method": "POST", + "path": "/api/v2/syncs", + "headers": { + "Authorization": "Bearer {{assessment_key}}", + "Idempotency-Key": "01933b9e-7c2a-7d41-9a51-3c8f0a6e0016", + "Content-Type": "application/json" + }, + "body": { + "schema_version": "2.0", + "sync": { + "id": "01933b9e-7c2a-7d41-9a51-3c8f0a6e0016", + "started_at": "{{now-300}}", + "completed_at": "{{now-60}}", + "collector": { + "name": "cloudexit", + "version": "2.0.0", + "mode": "api", + "catalogue": { + "version": "2026-11-02", + "sequence": 41, + "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "resource_type_ids": [ + 12, + 40, + 41, + 55 + ] + } + } + }, + "assessment_type": 2, + "scope": { + "provider": "aws", + "aws": { + "account_id": "123456789012", + "regions": [ + "eu-central-1" + ] + } + }, + "inventory": { + "resources": [ + { + "type_id": 12, + "type_code": "AWS::S3::Bucket", + "location": "eu-central-1", + "count": 36 + }, + { + "type_id": 40, + "type_code": "AWS::EC2::Instance", + "location": "eu-central-1", + "count": 8 + } + ], + "costs": [ + { + "month": "2026-10", + "amount": "1234.56", + "currency": "USD", + "final": true + } + ] + } + } + }, + "expect": { + "status": 422, + "headers_present": [ + "X-Request-ID" + ], + "body_subset": { + "object": "error", + "api_version": "2", + "error": { + "code": "catalogue_sha256_mismatch", + "details": [ + { + "host_version": "2026-11-02", + "host_sha256": "9f1c5b7a3e2d4c6b8a0f1e2d3c4b5a69788796a5b4c3d2e1f0a9b8c7d6e5f4a3" + } + ] + } + }, + "exists": [ + "/error/message", + "/error/request_id" + ] + } +} diff --git a/docs/api-v2/examples/25-account_key_creates_assessment.json b/docs/api-v2/examples/25-account_key_creates_assessment.json new file mode 100644 index 0000000..8037162 --- /dev/null +++ b/docs/api-v2/examples/25-account_key_creates_assessment.json @@ -0,0 +1,109 @@ +{ + "name": "account key creates assessment", + "description": "An account key with assessments:create creates a new assessment with this sync as its first one.", + "fixtures": { + "catalogue": "standard", + "assessments": [], + "keys": [ + { + "name": "account_key", + "kind": "account", + "scopes": [ + "assessments:create" + ] + } + ] + }, + "request": { + "method": "POST", + "path": "/api/v2/syncs", + "headers": { + "Authorization": "Bearer {{account_key}}", + "Idempotency-Key": "01933b9e-7c2a-7d41-9a51-3c8f0a6e0017", + "Content-Type": "application/json" + }, + "body": { + "schema_version": "2.0", + "sync": { + "id": "01933b9e-7c2a-7d41-9a51-3c8f0a6e0017", + "started_at": "{{now-300}}", + "completed_at": "{{now-60}}", + "collector": { + "name": "cloudexit", + "version": "2.0.0", + "mode": "api", + "catalogue": { + "version": "2026-11-02", + "sequence": 41, + "sha256": "9f1c5b7a3e2d4c6b8a0f1e2d3c4b5a69788796a5b4c3d2e1f0a9b8c7d6e5f4a3", + "resource_type_ids": [ + 12, + 40, + 41, + 55 + ] + } + } + }, + "assessment_type": 2, + "scope": { + "provider": "aws", + "aws": { + "account_id": "123456789012", + "regions": [ + "eu-central-1" + ] + } + }, + "inventory": { + "resources": [ + { + "type_id": 12, + "type_code": "AWS::S3::Bucket", + "location": "eu-central-1", + "count": 36 + }, + { + "type_id": 40, + "type_code": "AWS::EC2::Instance", + "location": "eu-central-1", + "count": 8 + } + ], + "costs": [ + { + "month": "2026-10", + "amount": "1234.56", + "currency": "USD", + "final": true + } + ] + }, + "target": { + "create": { + "name": "Payments prod (eu-central-1)", + "exit_strategy": "repatriation" + } + } + } + }, + "expect": { + "status": 201, + "body_subset": { + "status": "completed", + "assessment": { + "created": true, + "name": "Payments prod (eu-central-1)", + "scope_key": "aws:123456789012:eu-central-1" + }, + "assessment_type": { + "requested": 2, + "granted": 2 + } + }, + "exists": [ + "/assessment/id", + "/assessment/url" + ] + } +} diff --git a/docs/api-v2/examples/26-account_key_without_create_scope.json b/docs/api-v2/examples/26-account_key_without_create_scope.json new file mode 100644 index 0000000..394d402 --- /dev/null +++ b/docs/api-v2/examples/26-account_key_without_create_scope.json @@ -0,0 +1,114 @@ +{ + "name": "account key without create scope", + "description": "An account key without assessments:create cannot create an assessment.", + "fixtures": { + "catalogue": "standard", + "assessments": [], + "keys": [ + { + "name": "account_key", + "kind": "account", + "scopes": [ + "assessments:read" + ] + } + ] + }, + "request": { + "method": "POST", + "path": "/api/v2/syncs", + "headers": { + "Authorization": "Bearer {{account_key}}", + "Idempotency-Key": "01933b9e-7c2a-7d41-9a51-3c8f0a6e0018", + "Content-Type": "application/json" + }, + "body": { + "schema_version": "2.0", + "sync": { + "id": "01933b9e-7c2a-7d41-9a51-3c8f0a6e0018", + "started_at": "{{now-300}}", + "completed_at": "{{now-60}}", + "collector": { + "name": "cloudexit", + "version": "2.0.0", + "mode": "api", + "catalogue": { + "version": "2026-11-02", + "sequence": 41, + "sha256": "9f1c5b7a3e2d4c6b8a0f1e2d3c4b5a69788796a5b4c3d2e1f0a9b8c7d6e5f4a3", + "resource_type_ids": [ + 12, + 40, + 41, + 55 + ] + } + } + }, + "assessment_type": 2, + "scope": { + "provider": "aws", + "aws": { + "account_id": "123456789012", + "regions": [ + "eu-central-1" + ] + } + }, + "inventory": { + "resources": [ + { + "type_id": 12, + "type_code": "AWS::S3::Bucket", + "location": "eu-central-1", + "count": 36 + }, + { + "type_id": 40, + "type_code": "AWS::EC2::Instance", + "location": "eu-central-1", + "count": 8 + } + ], + "costs": [ + { + "month": "2026-10", + "amount": "1234.56", + "currency": "USD", + "final": true + } + ] + }, + "target": { + "create": { + "name": "Nope", + "exit_strategy": "repatriation" + } + } + } + }, + "expect": { + "status": 403, + "headers_present": [ + "X-Request-ID" + ], + "body_subset": { + "object": "error", + "api_version": "2", + "error": { + "code": "insufficient_scope", + "details": [ + { + "required": [ + "assessments:create" + ] + } + ] + } + }, + "exists": [ + "/error/message", + "/error/request_id" + ] + } +} diff --git a/docs/api-v2/examples/27-account_key_without_target.json b/docs/api-v2/examples/27-account_key_without_target.json new file mode 100644 index 0000000..7dc5e21 --- /dev/null +++ b/docs/api-v2/examples/27-account_key_without_target.json @@ -0,0 +1,107 @@ +{ + "name": "account key without target", + "description": "An account key must say where the sync goes.", + "fixtures": { + "catalogue": "standard", + "assessments": [], + "keys": [ + { + "name": "account_key", + "kind": "account", + "scopes": [ + "assessments:create" + ] + } + ] + }, + "request": { + "method": "POST", + "path": "/api/v2/syncs", + "headers": { + "Authorization": "Bearer {{account_key}}", + "Idempotency-Key": "01933b9e-7c2a-7d41-9a51-3c8f0a6e0019", + "Content-Type": "application/json" + }, + "body": { + "schema_version": "2.0", + "sync": { + "id": "01933b9e-7c2a-7d41-9a51-3c8f0a6e0019", + "started_at": "{{now-300}}", + "completed_at": "{{now-60}}", + "collector": { + "name": "cloudexit", + "version": "2.0.0", + "mode": "api", + "catalogue": { + "version": "2026-11-02", + "sequence": 41, + "sha256": "9f1c5b7a3e2d4c6b8a0f1e2d3c4b5a69788796a5b4c3d2e1f0a9b8c7d6e5f4a3", + "resource_type_ids": [ + 12, + 40, + 41, + 55 + ] + } + } + }, + "assessment_type": 2, + "scope": { + "provider": "aws", + "aws": { + "account_id": "123456789012", + "regions": [ + "eu-central-1" + ] + } + }, + "inventory": { + "resources": [ + { + "type_id": 12, + "type_code": "AWS::S3::Bucket", + "location": "eu-central-1", + "count": 36 + }, + { + "type_id": 40, + "type_code": "AWS::EC2::Instance", + "location": "eu-central-1", + "count": 8 + } + ], + "costs": [ + { + "month": "2026-10", + "amount": "1234.56", + "currency": "USD", + "final": true + } + ] + } + } + }, + "expect": { + "status": 422, + "headers_present": [ + "X-Request-ID" + ], + "body_subset": { + "object": "error", + "api_version": "2", + "error": { + "code": "validation_failed", + "details": [ + { + "pointer": "/target", + "code": "required" + } + ] + } + }, + "exists": [ + "/error/message", + "/error/request_id" + ] + } +} diff --git a/docs/api-v2/examples/28-account_key_appends_to_named_assessment.json b/docs/api-v2/examples/28-account_key_appends_to_named_assessment.json new file mode 100644 index 0000000..bb0d906 --- /dev/null +++ b/docs/api-v2/examples/28-account_key_appends_to_named_assessment.json @@ -0,0 +1,104 @@ +{ + "name": "account key appends to named assessment", + "description": "An account key with syncs:write may name an existing assessment.", + "fixtures": { + "catalogue": "standard", + "assessments": [ + { + "name": "main", + "provider": "aws", + "exit_strategy": "repatriation", + "assessment_type": 2, + "scope_key": null + } + ], + "keys": [ + { + "name": "account_key", + "kind": "account", + "scopes": [ + "syncs:write" + ] + } + ] + }, + "request": { + "method": "POST", + "path": "/api/v2/syncs", + "headers": { + "Authorization": "Bearer {{account_key}}", + "Idempotency-Key": "01933b9e-7c2a-7d41-9a51-3c8f0a6e001a", + "Content-Type": "application/json" + }, + "body": { + "schema_version": "2.0", + "sync": { + "id": "01933b9e-7c2a-7d41-9a51-3c8f0a6e001a", + "started_at": "{{now-300}}", + "completed_at": "{{now-60}}", + "collector": { + "name": "cloudexit", + "version": "2.0.0", + "mode": "api", + "catalogue": { + "version": "2026-11-02", + "sequence": 41, + "sha256": "9f1c5b7a3e2d4c6b8a0f1e2d3c4b5a69788796a5b4c3d2e1f0a9b8c7d6e5f4a3", + "resource_type_ids": [ + 12, + 40, + 41, + 55 + ] + } + } + }, + "assessment_type": 2, + "scope": { + "provider": "aws", + "aws": { + "account_id": "123456789012", + "regions": [ + "eu-central-1" + ] + } + }, + "inventory": { + "resources": [ + { + "type_id": 12, + "type_code": "AWS::S3::Bucket", + "location": "eu-central-1", + "count": 36 + }, + { + "type_id": 40, + "type_code": "AWS::EC2::Instance", + "location": "eu-central-1", + "count": 8 + } + ], + "costs": [ + { + "month": "2026-10", + "amount": "1234.56", + "currency": "USD", + "final": true + } + ] + }, + "target": { + "assessment_id": "{{main_id}}" + } + } + }, + "expect": { + "status": 201, + "body_subset": { + "assessment": { + "id": "{{main_id}}", + "created": false + } + } + } +} diff --git a/docs/api-v2/examples/29-account_key_unknown_assessment.json b/docs/api-v2/examples/29-account_key_unknown_assessment.json new file mode 100644 index 0000000..51d36ab --- /dev/null +++ b/docs/api-v2/examples/29-account_key_unknown_assessment.json @@ -0,0 +1,104 @@ +{ + "name": "account key unknown assessment", + "description": "An unknown assessment id (or one of another tenant) is not found.", + "fixtures": { + "catalogue": "standard", + "assessments": [], + "keys": [ + { + "name": "account_key", + "kind": "account", + "scopes": [ + "syncs:write" + ] + } + ] + }, + "request": { + "method": "POST", + "path": "/api/v2/syncs", + "headers": { + "Authorization": "Bearer {{account_key}}", + "Idempotency-Key": "01933b9e-7c2a-7d41-9a51-3c8f0a6e001b", + "Content-Type": "application/json" + }, + "body": { + "schema_version": "2.0", + "sync": { + "id": "01933b9e-7c2a-7d41-9a51-3c8f0a6e001b", + "started_at": "{{now-300}}", + "completed_at": "{{now-60}}", + "collector": { + "name": "cloudexit", + "version": "2.0.0", + "mode": "api", + "catalogue": { + "version": "2026-11-02", + "sequence": 41, + "sha256": "9f1c5b7a3e2d4c6b8a0f1e2d3c4b5a69788796a5b4c3d2e1f0a9b8c7d6e5f4a3", + "resource_type_ids": [ + 12, + 40, + 41, + 55 + ] + } + } + }, + "assessment_type": 2, + "scope": { + "provider": "aws", + "aws": { + "account_id": "123456789012", + "regions": [ + "eu-central-1" + ] + } + }, + "inventory": { + "resources": [ + { + "type_id": 12, + "type_code": "AWS::S3::Bucket", + "location": "eu-central-1", + "count": 36 + }, + { + "type_id": 40, + "type_code": "AWS::EC2::Instance", + "location": "eu-central-1", + "count": 8 + } + ], + "costs": [ + { + "month": "2026-10", + "amount": "1234.56", + "currency": "USD", + "final": true + } + ] + }, + "target": { + "assessment_id": "00000000-0000-4000-8000-000000000000" + } + } + }, + "expect": { + "status": 404, + "headers_present": [ + "X-Request-ID" + ], + "body_subset": { + "object": "error", + "api_version": "2", + "error": { + "code": "assessment_not_found" + } + }, + "exists": [ + "/error/message", + "/error/request_id" + ] + } +} diff --git a/docs/api-v2/examples/30-assessment_key_for_another_assessment.json b/docs/api-v2/examples/30-assessment_key_for_another_assessment.json new file mode 100644 index 0000000..5d91456 --- /dev/null +++ b/docs/api-v2/examples/30-assessment_key_for_another_assessment.json @@ -0,0 +1,117 @@ +{ + "name": "assessment key for another assessment", + "description": "An assessment key cannot push to another assessment.", + "fixtures": { + "catalogue": "standard", + "assessments": [ + { + "name": "main", + "provider": "aws", + "exit_strategy": "repatriation", + "assessment_type": 2, + "scope_key": null + }, + { + "name": "other", + "provider": "aws", + "exit_strategy": "repatriation", + "assessment_type": 2, + "scope_key": null + } + ], + "keys": [ + { + "name": "assessment_key", + "kind": "assessment", + "assessment": "main" + } + ] + }, + "request": { + "method": "POST", + "path": "/api/v2/syncs", + "headers": { + "Authorization": "Bearer {{assessment_key}}", + "Idempotency-Key": "01933b9e-7c2a-7d41-9a51-3c8f0a6e001c", + "Content-Type": "application/json" + }, + "body": { + "schema_version": "2.0", + "sync": { + "id": "01933b9e-7c2a-7d41-9a51-3c8f0a6e001c", + "started_at": "{{now-300}}", + "completed_at": "{{now-60}}", + "collector": { + "name": "cloudexit", + "version": "2.0.0", + "mode": "api", + "catalogue": { + "version": "2026-11-02", + "sequence": 41, + "sha256": "9f1c5b7a3e2d4c6b8a0f1e2d3c4b5a69788796a5b4c3d2e1f0a9b8c7d6e5f4a3", + "resource_type_ids": [ + 12, + 40, + 41, + 55 + ] + } + } + }, + "assessment_type": 2, + "scope": { + "provider": "aws", + "aws": { + "account_id": "123456789012", + "regions": [ + "eu-central-1" + ] + } + }, + "inventory": { + "resources": [ + { + "type_id": 12, + "type_code": "AWS::S3::Bucket", + "location": "eu-central-1", + "count": 36 + }, + { + "type_id": 40, + "type_code": "AWS::EC2::Instance", + "location": "eu-central-1", + "count": 8 + } + ], + "costs": [ + { + "month": "2026-10", + "amount": "1234.56", + "currency": "USD", + "final": true + } + ] + }, + "target": { + "assessment_id": "{{other_id}}" + } + } + }, + "expect": { + "status": 403, + "headers_present": [ + "X-Request-ID" + ], + "body_subset": { + "object": "error", + "api_version": "2", + "error": { + "code": "key_binding_violation" + } + }, + "exists": [ + "/error/message", + "/error/request_id" + ] + } +} diff --git a/docs/api-v2/examples/31-key_revoked.json b/docs/api-v2/examples/31-key_revoked.json new file mode 100644 index 0000000..edb1d40 --- /dev/null +++ b/docs/api-v2/examples/31-key_revoked.json @@ -0,0 +1,50 @@ +{ + "name": "key revoked", + "description": "A revoked key is refused.", + "fixtures": { + "catalogue": "standard", + "assessments": [ + { + "name": "main", + "provider": "aws", + "exit_strategy": "repatriation", + "assessment_type": 2, + "scope_key": null + } + ], + "keys": [ + { + "name": "revoked_key", + "kind": "account", + "scopes": [ + "assessments:read" + ], + "state": "revoked" + } + ] + }, + "request": { + "method": "GET", + "path": "/api/v2/whoami", + "headers": { + "Authorization": "Bearer {{revoked_key}}" + } + }, + "expect": { + "status": 401, + "headers_present": [ + "X-Request-ID" + ], + "body_subset": { + "object": "error", + "api_version": "2", + "error": { + "code": "api_key_revoked" + } + }, + "exists": [ + "/error/message", + "/error/request_id" + ] + } +} diff --git a/docs/api-v2/examples/32-key_expired.json b/docs/api-v2/examples/32-key_expired.json new file mode 100644 index 0000000..a9b36ae --- /dev/null +++ b/docs/api-v2/examples/32-key_expired.json @@ -0,0 +1,50 @@ +{ + "name": "key expired", + "description": "An expired key is refused.", + "fixtures": { + "catalogue": "standard", + "assessments": [ + { + "name": "main", + "provider": "aws", + "exit_strategy": "repatriation", + "assessment_type": 2, + "scope_key": null + } + ], + "keys": [ + { + "name": "expired_key", + "kind": "account", + "scopes": [ + "assessments:read" + ], + "state": "expired" + } + ] + }, + "request": { + "method": "GET", + "path": "/api/v2/whoami", + "headers": { + "Authorization": "Bearer {{expired_key}}" + } + }, + "expect": { + "status": 401, + "headers_present": [ + "X-Request-ID" + ], + "body_subset": { + "object": "error", + "api_version": "2", + "error": { + "code": "api_key_expired" + } + }, + "exists": [ + "/error/message", + "/error/request_id" + ] + } +} diff --git a/docs/api-v2/examples/33-key_malformed.json b/docs/api-v2/examples/33-key_malformed.json new file mode 100644 index 0000000..e762758 --- /dev/null +++ b/docs/api-v2/examples/33-key_malformed.json @@ -0,0 +1,47 @@ +{ + "name": "key malformed", + "description": "A value that is not a key is refused without a lookup.", + "fixtures": { + "catalogue": "standard", + "assessments": [ + { + "name": "main", + "provider": "aws", + "exit_strategy": "repatriation", + "assessment_type": 2, + "scope_key": null + } + ], + "keys": [ + { + "name": "assessment_key", + "kind": "assessment", + "assessment": "main" + } + ] + }, + "request": { + "method": "GET", + "path": "/api/v2/whoami", + "headers": { + "Authorization": "Bearer not-a-key" + } + }, + "expect": { + "status": 401, + "headers_present": [ + "X-Request-ID" + ], + "body_subset": { + "object": "error", + "api_version": "2", + "error": { + "code": "invalid_api_key" + } + }, + "exists": [ + "/error/message", + "/error/request_id" + ] + } +} diff --git a/docs/api-v2/examples/34-key_bad_checksum.json b/docs/api-v2/examples/34-key_bad_checksum.json new file mode 100644 index 0000000..1030d02 --- /dev/null +++ b/docs/api-v2/examples/34-key_bad_checksum.json @@ -0,0 +1,47 @@ +{ + "name": "key bad checksum", + "description": "A key with a wrong checksum is refused without a lookup.", + "fixtures": { + "catalogue": "standard", + "assessments": [ + { + "name": "main", + "provider": "aws", + "exit_strategy": "repatriation", + "assessment_type": 2, + "scope_key": null + } + ], + "keys": [ + { + "name": "assessment_key", + "kind": "assessment", + "assessment": "main" + } + ] + }, + "request": { + "method": "GET", + "path": "/api/v2/whoami", + "headers": { + "Authorization": "Bearer {{assessment_key|corrupt}}" + } + }, + "expect": { + "status": 401, + "headers_present": [ + "X-Request-ID" + ], + "body_subset": { + "object": "error", + "api_version": "2", + "error": { + "code": "invalid_api_key" + } + }, + "exists": [ + "/error/message", + "/error/request_id" + ] + } +} diff --git a/docs/api-v2/examples/35-authorization_missing.json b/docs/api-v2/examples/35-authorization_missing.json new file mode 100644 index 0000000..17fecff --- /dev/null +++ b/docs/api-v2/examples/35-authorization_missing.json @@ -0,0 +1,45 @@ +{ + "name": "authorization missing", + "description": "A request without credentials is refused.", + "fixtures": { + "catalogue": "standard", + "assessments": [ + { + "name": "main", + "provider": "aws", + "exit_strategy": "repatriation", + "assessment_type": 2, + "scope_key": null + } + ], + "keys": [ + { + "name": "assessment_key", + "kind": "assessment", + "assessment": "main" + } + ] + }, + "request": { + "method": "GET", + "path": "/api/v2/whoami", + "headers": {} + }, + "expect": { + "status": 401, + "headers_present": [ + "X-Request-ID" + ], + "body_subset": { + "object": "error", + "api_version": "2", + "error": { + "code": "unauthenticated" + } + }, + "exists": [ + "/error/message", + "/error/request_id" + ] + } +} diff --git a/docs/api-v2/examples/36-payload_too_large.json b/docs/api-v2/examples/36-payload_too_large.json new file mode 100644 index 0000000..e4e6c5a --- /dev/null +++ b/docs/api-v2/examples/36-payload_too_large.json @@ -0,0 +1,113 @@ +{ + "name": "payload too large", + "description": "A body beyond limits.max_body_bytes is refused before it is parsed. The harness pads the body with an unknown field.", + "fixtures": { + "catalogue": "standard", + "assessments": [ + { + "name": "main", + "provider": "aws", + "exit_strategy": "repatriation", + "assessment_type": 2, + "scope_key": null + } + ], + "keys": [ + { + "name": "assessment_key", + "kind": "assessment", + "assessment": "main" + } + ] + }, + "request": { + "method": "POST", + "path": "/api/v2/syncs", + "headers": { + "Authorization": "Bearer {{assessment_key}}", + "Idempotency-Key": "01933b9e-7c2a-7d41-9a51-3c8f0a6e001d", + "Content-Type": "application/json" + }, + "body": { + "schema_version": "2.0", + "sync": { + "id": "01933b9e-7c2a-7d41-9a51-3c8f0a6e001d", + "started_at": "{{now-300}}", + "completed_at": "{{now-60}}", + "collector": { + "name": "cloudexit", + "version": "2.0.0", + "mode": "api", + "catalogue": { + "version": "2026-11-02", + "sequence": 41, + "sha256": "9f1c5b7a3e2d4c6b8a0f1e2d3c4b5a69788796a5b4c3d2e1f0a9b8c7d6e5f4a3", + "resource_type_ids": [ + 12, + 40, + 41, + 55 + ] + } + } + }, + "assessment_type": 2, + "scope": { + "provider": "aws", + "aws": { + "account_id": "123456789012", + "regions": [ + "eu-central-1" + ] + } + }, + "inventory": { + "resources": [ + { + "type_id": 12, + "type_code": "AWS::S3::Bucket", + "location": "eu-central-1", + "count": 36 + }, + { + "type_id": 40, + "type_code": "AWS::EC2::Instance", + "location": "eu-central-1", + "count": 8 + } + ], + "costs": [ + { + "month": "2026-10", + "amount": "1234.56", + "currency": "USD", + "final": true + } + ] + } + }, + "pad_bytes_to": 1100000 + }, + "expect": { + "status": 413, + "headers_present": [ + "X-Request-ID" + ], + "body_subset": { + "object": "error", + "api_version": "2", + "error": { + "code": "payload_too_large", + "details": [ + { + "limit": "max_body_bytes" + } + ] + } + }, + "exists": [ + "/error/message", + "/error/request_id" + ] + } +} diff --git a/docs/api-v2/examples/37-validation_errors.json b/docs/api-v2/examples/37-validation_errors.json new file mode 100644 index 0000000..2bcb1f5 --- /dev/null +++ b/docs/api-v2/examples/37-validation_errors.json @@ -0,0 +1,125 @@ +{ + "name": "validation errors", + "description": "Every problem is listed with a JSON pointer, and nothing is stored.", + "fixtures": { + "catalogue": "standard", + "assessments": [ + { + "name": "main", + "provider": "aws", + "exit_strategy": "repatriation", + "assessment_type": 2, + "scope_key": null + } + ], + "keys": [ + { + "name": "assessment_key", + "kind": "assessment", + "assessment": "main" + } + ] + }, + "request": { + "method": "POST", + "path": "/api/v2/syncs", + "headers": { + "Authorization": "Bearer {{assessment_key}}", + "Idempotency-Key": "01933b9e-7c2a-7d41-9a51-3c8f0a6e001e", + "Content-Type": "application/json" + }, + "body": { + "schema_version": "2.0", + "sync": { + "id": "01933b9e-7c2a-7d41-9a51-3c8f0a6e001e", + "started_at": "{{now-300}}", + "completed_at": "{{now-600}}", + "collector": { + "name": "cloudexit", + "version": "2.0.0", + "mode": "api", + "catalogue": { + "version": "2026-11-02", + "sequence": 41, + "sha256": "9f1c5b7a3e2d4c6b8a0f1e2d3c4b5a69788796a5b4c3d2e1f0a9b8c7d6e5f4a3", + "resource_type_ids": [ + 12, + 40, + 41, + 55 + ] + } + } + }, + "assessment_type": 4, + "scope": { + "provider": "aws", + "aws": { + "account_id": "1234", + "regions": [ + "eu-central-1" + ] + } + }, + "inventory": { + "resources": [ + { + "type_id": 12, + "type_code": "AWS::S3::Bucket", + "location": "eu-central-1", + "count": -1 + }, + { + "type_id": 40, + "type_code": "AWS::EC2::Instance", + "location": "eu-central-1", + "count": 8 + } + ], + "costs": [ + { + "month": "2026-10", + "amount": "1234.56", + "currency": "USD", + "final": true + } + ] + } + } + }, + "expect": { + "status": 422, + "headers_present": [ + "X-Request-ID" + ], + "body_subset": { + "object": "error", + "api_version": "2", + "error": { + "code": "validation_failed", + "details": [ + { + "pointer": "/inventory/resources/0/count", + "code": "out_of_range" + }, + { + "pointer": "/sync/completed_at", + "code": "before_started_at" + }, + { + "pointer": "/assessment_type", + "code": "out_of_range" + }, + { + "pointer": "/scope/aws/account_id", + "code": "invalid_format" + } + ] + } + }, + "exists": [ + "/error/message", + "/error/request_id" + ] + } +} diff --git a/docs/api-v2/examples/38-collection_too_old.json b/docs/api-v2/examples/38-collection_too_old.json new file mode 100644 index 0000000..5774f2b --- /dev/null +++ b/docs/api-v2/examples/38-collection_too_old.json @@ -0,0 +1,113 @@ +{ + "name": "collection too old", + "description": "A sync collected more than limits.max_collection_age_days ago is refused.", + "fixtures": { + "catalogue": "standard", + "assessments": [ + { + "name": "main", + "provider": "aws", + "exit_strategy": "repatriation", + "assessment_type": 2, + "scope_key": null + } + ], + "keys": [ + { + "name": "assessment_key", + "kind": "assessment", + "assessment": "main" + } + ] + }, + "request": { + "method": "POST", + "path": "/api/v2/syncs", + "headers": { + "Authorization": "Bearer {{assessment_key}}", + "Idempotency-Key": "01933b9e-7c2a-7d41-9a51-3c8f0a6e001f", + "Content-Type": "application/json" + }, + "body": { + "schema_version": "2.0", + "sync": { + "id": "01933b9e-7c2a-7d41-9a51-3c8f0a6e001f", + "started_at": "{{now-4000000}}", + "completed_at": "{{now-3000000}}", + "collector": { + "name": "cloudexit", + "version": "2.0.0", + "mode": "api", + "catalogue": { + "version": "2026-11-02", + "sequence": 41, + "sha256": "9f1c5b7a3e2d4c6b8a0f1e2d3c4b5a69788796a5b4c3d2e1f0a9b8c7d6e5f4a3", + "resource_type_ids": [ + 12, + 40, + 41, + 55 + ] + } + } + }, + "assessment_type": 2, + "scope": { + "provider": "aws", + "aws": { + "account_id": "123456789012", + "regions": [ + "eu-central-1" + ] + } + }, + "inventory": { + "resources": [ + { + "type_id": 12, + "type_code": "AWS::S3::Bucket", + "location": "eu-central-1", + "count": 36 + }, + { + "type_id": 40, + "type_code": "AWS::EC2::Instance", + "location": "eu-central-1", + "count": 8 + } + ], + "costs": [ + { + "month": "2026-10", + "amount": "1234.56", + "currency": "USD", + "final": true + } + ] + } + } + }, + "expect": { + "status": 422, + "headers_present": [ + "X-Request-ID" + ], + "body_subset": { + "object": "error", + "api_version": "2", + "error": { + "code": "validation_failed", + "details": [ + { + "pointer": "/sync/completed_at", + "code": "too_old" + } + ] + } + }, + "exists": [ + "/error/message", + "/error/request_id" + ] + } +} diff --git a/docs/api-v2/examples/39-invalid_json.json b/docs/api-v2/examples/39-invalid_json.json new file mode 100644 index 0000000..253c1a0 --- /dev/null +++ b/docs/api-v2/examples/39-invalid_json.json @@ -0,0 +1,50 @@ +{ + "name": "invalid json", + "description": "A body that is not JSON is refused.", + "fixtures": { + "catalogue": "standard", + "assessments": [ + { + "name": "main", + "provider": "aws", + "exit_strategy": "repatriation", + "assessment_type": 2, + "scope_key": null + } + ], + "keys": [ + { + "name": "assessment_key", + "kind": "assessment", + "assessment": "main" + } + ] + }, + "request": { + "method": "POST", + "path": "/api/v2/syncs", + "headers": { + "Authorization": "Bearer {{assessment_key}}", + "Idempotency-Key": "01933b9e-7c2a-7d41-9a51-3c8f0a6e0020", + "Content-Type": "application/json" + }, + "raw_body": "{not json" + }, + "expect": { + "status": 400, + "headers_present": [ + "X-Request-ID" + ], + "body_subset": { + "object": "error", + "api_version": "2", + "error": { + "code": "invalid_json" + } + }, + "exists": [ + "/error/message", + "/error/request_id" + ] + } +} diff --git a/docs/api-v2/examples/40-unsupported_media_type.json b/docs/api-v2/examples/40-unsupported_media_type.json new file mode 100644 index 0000000..60af04d --- /dev/null +++ b/docs/api-v2/examples/40-unsupported_media_type.json @@ -0,0 +1,50 @@ +{ + "name": "unsupported media type", + "description": "Only application/json is accepted.", + "fixtures": { + "catalogue": "standard", + "assessments": [ + { + "name": "main", + "provider": "aws", + "exit_strategy": "repatriation", + "assessment_type": 2, + "scope_key": null + } + ], + "keys": [ + { + "name": "assessment_key", + "kind": "assessment", + "assessment": "main" + } + ] + }, + "request": { + "method": "POST", + "path": "/api/v2/syncs", + "headers": { + "Authorization": "Bearer {{assessment_key}}", + "Idempotency-Key": "01933b9e-7c2a-7d41-9a51-3c8f0a6e0021", + "Content-Type": "text/plain" + }, + "raw_body": "hello" + }, + "expect": { + "status": 415, + "headers_present": [ + "X-Request-ID" + ], + "body_subset": { + "object": "error", + "api_version": "2", + "error": { + "code": "unsupported_media_type" + } + }, + "exists": [ + "/error/message", + "/error/request_id" + ] + } +} diff --git a/docs/api-v2/examples/41-unsupported_schema_version.json b/docs/api-v2/examples/41-unsupported_schema_version.json new file mode 100644 index 0000000..6d091b0 --- /dev/null +++ b/docs/api-v2/examples/41-unsupported_schema_version.json @@ -0,0 +1,114 @@ +{ + "name": "unsupported schema version", + "description": "A schema version the host does not list in /meta is refused.", + "fixtures": { + "catalogue": "standard", + "assessments": [ + { + "name": "main", + "provider": "aws", + "exit_strategy": "repatriation", + "assessment_type": 2, + "scope_key": null + } + ], + "keys": [ + { + "name": "assessment_key", + "kind": "assessment", + "assessment": "main" + } + ] + }, + "request": { + "method": "POST", + "path": "/api/v2/syncs", + "headers": { + "Authorization": "Bearer {{assessment_key}}", + "Idempotency-Key": "01933b9e-7c2a-7d41-9a51-3c8f0a6e0022", + "Content-Type": "application/json" + }, + "body": { + "schema_version": "2.7", + "sync": { + "id": "01933b9e-7c2a-7d41-9a51-3c8f0a6e0022", + "started_at": "{{now-300}}", + "completed_at": "{{now-60}}", + "collector": { + "name": "cloudexit", + "version": "2.0.0", + "mode": "api", + "catalogue": { + "version": "2026-11-02", + "sequence": 41, + "sha256": "9f1c5b7a3e2d4c6b8a0f1e2d3c4b5a69788796a5b4c3d2e1f0a9b8c7d6e5f4a3", + "resource_type_ids": [ + 12, + 40, + 41, + 55 + ] + } + } + }, + "assessment_type": 2, + "scope": { + "provider": "aws", + "aws": { + "account_id": "123456789012", + "regions": [ + "eu-central-1" + ] + } + }, + "inventory": { + "resources": [ + { + "type_id": 12, + "type_code": "AWS::S3::Bucket", + "location": "eu-central-1", + "count": 36 + }, + { + "type_id": 40, + "type_code": "AWS::EC2::Instance", + "location": "eu-central-1", + "count": 8 + } + ], + "costs": [ + { + "month": "2026-10", + "amount": "1234.56", + "currency": "USD", + "final": true + } + ] + } + } + }, + "expect": { + "status": 400, + "headers_present": [ + "X-Request-ID" + ], + "body_subset": { + "object": "error", + "api_version": "2", + "error": { + "code": "unsupported_schema_version", + "details": [ + { + "supported": [ + "2.0" + ] + } + ] + } + }, + "exists": [ + "/error/message", + "/error/request_id" + ] + } +} diff --git a/docs/api-v2/examples/42-get_sync.json b/docs/api-v2/examples/42-get_sync.json new file mode 100644 index 0000000..257ba00 --- /dev/null +++ b/docs/api-v2/examples/42-get_sync.json @@ -0,0 +1,126 @@ +{ + "name": "get sync", + "description": "A sync can be fetched by the host's id.", + "fixtures": { + "catalogue": "standard", + "assessments": [ + { + "name": "main", + "provider": "aws", + "exit_strategy": "repatriation", + "assessment_type": 2, + "scope_key": null + } + ], + "keys": [ + { + "name": "assessment_key", + "kind": "assessment", + "assessment": "main" + } + ] + }, + "before": [ + { + "request": { + "method": "POST", + "path": "/api/v2/syncs", + "headers": { + "Authorization": "Bearer {{assessment_key}}", + "Idempotency-Key": "01933b9e-7c2a-7d41-9a51-3c8f0a6e0023", + "Content-Type": "application/json" + }, + "body": { + "schema_version": "2.0", + "sync": { + "id": "01933b9e-7c2a-7d41-9a51-3c8f0a6e0023", + "started_at": "{{now-300}}", + "completed_at": "{{now-60}}", + "collector": { + "name": "cloudexit", + "version": "2.0.0", + "mode": "api", + "catalogue": { + "version": "2026-11-02", + "sequence": 41, + "sha256": "9f1c5b7a3e2d4c6b8a0f1e2d3c4b5a69788796a5b4c3d2e1f0a9b8c7d6e5f4a3", + "resource_type_ids": [ + 12, + 40, + 41, + 55 + ] + } + } + }, + "assessment_type": 2, + "scope": { + "provider": "aws", + "aws": { + "account_id": "123456789012", + "regions": [ + "eu-central-1" + ] + } + }, + "inventory": { + "resources": [ + { + "type_id": 12, + "type_code": "AWS::S3::Bucket", + "location": "eu-central-1", + "count": 36 + }, + { + "type_id": 40, + "type_code": "AWS::EC2::Instance", + "location": "eu-central-1", + "count": 8 + } + ], + "costs": [ + { + "month": "2026-10", + "amount": "1234.56", + "currency": "USD", + "final": true + } + ] + } + } + }, + "expect": { + "status": 201 + }, + "save": { + "first_sync_id": "/id" + } + } + ], + "request": { + "method": "GET", + "path": "/api/v2/syncs/{{first_sync_id}}", + "headers": { + "Authorization": "Bearer {{assessment_key}}" + } + }, + "expect": { + "status": 200, + "body_subset": { + "object": "sync", + "id": "{{first_sync_id}}", + "client_sync_id": "01933b9e-7c2a-7d41-9a51-3c8f0a6e0023", + "status": "completed" + }, + "exists": [ + "/id", + "/request_id", + "/timestamps/received_at", + "/content/payload_sha256", + "/content/content_sha256", + "/results/scoring_version", + "/links/self", + "/links/assessment" + ] + } +} diff --git a/docs/api-v2/examples/43-get_sync_not_found.json b/docs/api-v2/examples/43-get_sync_not_found.json new file mode 100644 index 0000000..6b724bc --- /dev/null +++ b/docs/api-v2/examples/43-get_sync_not_found.json @@ -0,0 +1,47 @@ +{ + "name": "get sync not found", + "description": "An unknown sync id is not found.", + "fixtures": { + "catalogue": "standard", + "assessments": [ + { + "name": "main", + "provider": "aws", + "exit_strategy": "repatriation", + "assessment_type": 2, + "scope_key": null + } + ], + "keys": [ + { + "name": "assessment_key", + "kind": "assessment", + "assessment": "main" + } + ] + }, + "request": { + "method": "GET", + "path": "/api/v2/syncs/00000000-0000-4000-8000-000000000000", + "headers": { + "Authorization": "Bearer {{assessment_key}}" + } + }, + "expect": { + "status": 404, + "headers_present": [ + "X-Request-ID" + ], + "body_subset": { + "object": "error", + "api_version": "2", + "error": { + "code": "sync_not_found" + } + }, + "exists": [ + "/error/message", + "/error/request_id" + ] + } +} diff --git a/docs/api-v2/examples/44-get_assessment.json b/docs/api-v2/examples/44-get_assessment.json new file mode 100644 index 0000000..5ade8db --- /dev/null +++ b/docs/api-v2/examples/44-get_assessment.json @@ -0,0 +1,134 @@ +{ + "name": "get assessment", + "description": "An assessment shows its source, depth, recorded scope and latest sync.", + "fixtures": { + "catalogue": "standard", + "assessments": [ + { + "name": "main", + "provider": "aws", + "exit_strategy": "repatriation", + "assessment_type": 2, + "scope_key": null + } + ], + "keys": [ + { + "name": "assessment_key", + "kind": "assessment", + "assessment": "main" + }, + { + "name": "reader_key", + "kind": "account", + "scopes": [ + "assessments:read" + ] + } + ] + }, + "before": [ + { + "request": { + "method": "POST", + "path": "/api/v2/syncs", + "headers": { + "Authorization": "Bearer {{assessment_key}}", + "Idempotency-Key": "01933b9e-7c2a-7d41-9a51-3c8f0a6e0024", + "Content-Type": "application/json" + }, + "body": { + "schema_version": "2.0", + "sync": { + "id": "01933b9e-7c2a-7d41-9a51-3c8f0a6e0024", + "started_at": "{{now-300}}", + "completed_at": "{{now-60}}", + "collector": { + "name": "cloudexit", + "version": "2.0.0", + "mode": "api", + "catalogue": { + "version": "2026-11-02", + "sequence": 41, + "sha256": "9f1c5b7a3e2d4c6b8a0f1e2d3c4b5a69788796a5b4c3d2e1f0a9b8c7d6e5f4a3", + "resource_type_ids": [ + 12, + 40, + 41, + 55 + ] + } + } + }, + "assessment_type": 2, + "scope": { + "provider": "aws", + "aws": { + "account_id": "123456789012", + "regions": [ + "eu-central-1" + ] + } + }, + "inventory": { + "resources": [ + { + "type_id": 12, + "type_code": "AWS::S3::Bucket", + "location": "eu-central-1", + "count": 36 + }, + { + "type_id": 40, + "type_code": "AWS::EC2::Instance", + "location": "eu-central-1", + "count": 8 + } + ], + "costs": [ + { + "month": "2026-10", + "amount": "1234.56", + "currency": "USD", + "final": true + } + ] + } + } + }, + "expect": { + "status": 201 + } + } + ], + "request": { + "method": "GET", + "path": "/api/v2/assessments/{{main_id}}", + "headers": { + "Authorization": "Bearer {{reader_key}}" + } + }, + "expect": { + "status": 200, + "body_subset": { + "object": "assessment", + "id": "{{main_id}}", + "source": "cli", + "provider": "aws", + "exit_strategy": "repatriation", + "assessment_type": 2, + "scope_key": "aws:123456789012:eu-central-1", + "state": "active", + "latest_sync": { + "object": "sync", + "client_sync_id": "01933b9e-7c2a-7d41-9a51-3c8f0a6e0024", + "status": "completed" + } + }, + "exists": [ + "/name", + "/created", + "/url" + ] + } +} diff --git a/docs/api-v2/examples/45-get_assessment_not_found.json b/docs/api-v2/examples/45-get_assessment_not_found.json new file mode 100644 index 0000000..c31f7f9 --- /dev/null +++ b/docs/api-v2/examples/45-get_assessment_not_found.json @@ -0,0 +1,49 @@ +{ + "name": "get assessment not found", + "description": "An unknown assessment id is not found.", + "fixtures": { + "catalogue": "standard", + "assessments": [ + { + "name": "main", + "provider": "aws", + "exit_strategy": "repatriation", + "assessment_type": 2, + "scope_key": null + } + ], + "keys": [ + { + "name": "reader_key", + "kind": "account", + "scopes": [ + "assessments:read" + ] + } + ] + }, + "request": { + "method": "GET", + "path": "/api/v2/assessments/00000000-0000-4000-8000-000000000000", + "headers": { + "Authorization": "Bearer {{reader_key}}" + } + }, + "expect": { + "status": 404, + "headers_present": [ + "X-Request-ID" + ], + "body_subset": { + "object": "error", + "api_version": "2", + "error": { + "code": "assessment_not_found" + } + }, + "exists": [ + "/error/message", + "/error/request_id" + ] + } +} diff --git a/docs/api-v2/examples/46-list_assessments_second_page.json b/docs/api-v2/examples/46-list_assessments_second_page.json new file mode 100644 index 0000000..fbd445b --- /dev/null +++ b/docs/api-v2/examples/46-list_assessments_second_page.json @@ -0,0 +1,94 @@ +{ + "name": "list assessments second page", + "description": "Assessments are listed newest first. The cursor of the first page gives the rest.", + "fixtures": { + "catalogue": "standard", + "assessments": [ + { + "name": "a1", + "provider": "aws", + "exit_strategy": "repatriation", + "assessment_type": 2, + "scope_key": null + }, + { + "name": "a2", + "provider": "aws", + "exit_strategy": "repatriation", + "assessment_type": 2, + "scope_key": null + }, + { + "name": "a3", + "provider": "aws", + "exit_strategy": "repatriation", + "assessment_type": 2, + "scope_key": null + } + ], + "keys": [ + { + "name": "reader_key", + "kind": "account", + "scopes": [ + "assessments:read" + ] + } + ] + }, + "before": [ + { + "request": { + "method": "GET", + "path": "/api/v2/assessments?limit=2", + "headers": { + "Authorization": "Bearer {{reader_key}}" + } + }, + "expect": { + "status": 200, + "body_subset": { + "has_more": true, + "data": [ + { + "id": "{{a3_id}}" + }, + { + "id": "{{a2_id}}" + } + ] + }, + "lengths": { + "/data": 2 + } + }, + "save": { + "cursor": "/next_cursor" + } + } + ], + "request": { + "method": "GET", + "path": "/api/v2/assessments?limit=2&cursor={{cursor}}", + "headers": { + "Authorization": "Bearer {{reader_key}}" + } + }, + "expect": { + "status": 200, + "body_subset": { + "object": "list", + "has_more": false, + "next_cursor": null, + "data": [ + { + "id": "{{a1_id}}", + "object": "assessment" + } + ] + }, + "lengths": { + "/data": 1 + } + } +} diff --git a/docs/api-v2/examples/47-list_assessments_by_provider.json b/docs/api-v2/examples/47-list_assessments_by_provider.json new file mode 100644 index 0000000..08dbf1f --- /dev/null +++ b/docs/api-v2/examples/47-list_assessments_by_provider.json @@ -0,0 +1,53 @@ +{ + "name": "list assessments by provider", + "description": "The list can be filtered by provider.", + "fixtures": { + "catalogue": "standard", + "assessments": [ + { + "name": "on_aws", + "provider": "aws", + "exit_strategy": "repatriation", + "assessment_type": 2, + "scope_key": null + }, + { + "name": "on_azure", + "provider": "azure", + "exit_strategy": "repatriation", + "assessment_type": 2, + "scope_key": null + } + ], + "keys": [ + { + "name": "reader_key", + "kind": "account", + "scopes": [ + "assessments:read" + ] + } + ] + }, + "request": { + "method": "GET", + "path": "/api/v2/assessments?provider=azure", + "headers": { + "Authorization": "Bearer {{reader_key}}" + } + }, + "expect": { + "status": 200, + "body_subset": { + "data": [ + { + "id": "{{on_azure_id}}", + "provider": "azure" + } + ] + }, + "lengths": { + "/data": 1 + } + } +} diff --git a/docs/api-v2/examples/48-list_assessments_needs_read_scope.json b/docs/api-v2/examples/48-list_assessments_needs_read_scope.json new file mode 100644 index 0000000..6a23ea8 --- /dev/null +++ b/docs/api-v2/examples/48-list_assessments_needs_read_scope.json @@ -0,0 +1,56 @@ +{ + "name": "list assessments needs read scope", + "description": "Listing needs assessments:read.", + "fixtures": { + "catalogue": "standard", + "assessments": [ + { + "name": "main", + "provider": "aws", + "exit_strategy": "repatriation", + "assessment_type": 2, + "scope_key": null + } + ], + "keys": [ + { + "name": "account_key", + "kind": "account", + "scopes": [ + "assessments:create" + ] + } + ] + }, + "request": { + "method": "GET", + "path": "/api/v2/assessments", + "headers": { + "Authorization": "Bearer {{account_key}}" + } + }, + "expect": { + "status": 403, + "headers_present": [ + "X-Request-ID" + ], + "body_subset": { + "object": "error", + "api_version": "2", + "error": { + "code": "insufficient_scope", + "details": [ + { + "required": [ + "assessments:read" + ] + } + ] + } + }, + "exists": [ + "/error/message", + "/error/request_id" + ] + } +} diff --git a/docs/api-v2/examples/49-list_assessments_bad_cursor.json b/docs/api-v2/examples/49-list_assessments_bad_cursor.json new file mode 100644 index 0000000..2dfe05e --- /dev/null +++ b/docs/api-v2/examples/49-list_assessments_bad_cursor.json @@ -0,0 +1,55 @@ +{ + "name": "list assessments bad cursor", + "description": "A cursor the host did not issue is refused.", + "fixtures": { + "catalogue": "standard", + "assessments": [ + { + "name": "main", + "provider": "aws", + "exit_strategy": "repatriation", + "assessment_type": 2, + "scope_key": null + } + ], + "keys": [ + { + "name": "reader_key", + "kind": "account", + "scopes": [ + "assessments:read" + ] + } + ] + }, + "request": { + "method": "GET", + "path": "/api/v2/assessments?cursor=not-a-cursor", + "headers": { + "Authorization": "Bearer {{reader_key}}" + } + }, + "expect": { + "status": 422, + "headers_present": [ + "X-Request-ID" + ], + "body_subset": { + "object": "error", + "api_version": "2", + "error": { + "code": "validation_failed", + "details": [ + { + "parameter": "cursor", + "code": "invalid_format" + } + ] + } + }, + "exists": [ + "/error/message", + "/error/request_id" + ] + } +} diff --git a/docs/api-v2/examples/50-list_assessment_syncs.json b/docs/api-v2/examples/50-list_assessment_syncs.json new file mode 100644 index 0000000..36b4298 --- /dev/null +++ b/docs/api-v2/examples/50-list_assessment_syncs.json @@ -0,0 +1,202 @@ +{ + "name": "list assessment syncs", + "description": "The history is ordered by collection time, newest first, whatever the arrival order.", + "fixtures": { + "catalogue": "standard", + "assessments": [ + { + "name": "main", + "provider": "aws", + "exit_strategy": "repatriation", + "assessment_type": 2, + "scope_key": null + } + ], + "keys": [ + { + "name": "assessment_key", + "kind": "assessment", + "assessment": "main" + }, + { + "name": "reader_key", + "kind": "account", + "scopes": [ + "assessments:read" + ] + } + ] + }, + "before": [ + { + "request": { + "method": "POST", + "path": "/api/v2/syncs", + "headers": { + "Authorization": "Bearer {{assessment_key}}", + "Idempotency-Key": "01933b9e-7c2a-7d41-9a51-3c8f0a6e0026", + "Content-Type": "application/json" + }, + "body": { + "schema_version": "2.0", + "sync": { + "id": "01933b9e-7c2a-7d41-9a51-3c8f0a6e0026", + "started_at": "{{now-200}}", + "completed_at": "{{now-100}}", + "collector": { + "name": "cloudexit", + "version": "2.0.0", + "mode": "api", + "catalogue": { + "version": "2026-11-02", + "sequence": 41, + "sha256": "9f1c5b7a3e2d4c6b8a0f1e2d3c4b5a69788796a5b4c3d2e1f0a9b8c7d6e5f4a3", + "resource_type_ids": [ + 12, + 40, + 41, + 55 + ] + } + } + }, + "assessment_type": 2, + "scope": { + "provider": "aws", + "aws": { + "account_id": "123456789012", + "regions": [ + "eu-central-1" + ] + } + }, + "inventory": { + "resources": [ + { + "type_id": 12, + "type_code": "AWS::S3::Bucket", + "location": "eu-central-1", + "count": 36 + }, + { + "type_id": 40, + "type_code": "AWS::EC2::Instance", + "location": "eu-central-1", + "count": 8 + } + ], + "costs": [ + { + "month": "2026-10", + "amount": "1234.56", + "currency": "USD", + "final": true + } + ] + } + } + }, + "expect": { + "status": 201 + } + }, + { + "request": { + "method": "POST", + "path": "/api/v2/syncs", + "headers": { + "Authorization": "Bearer {{assessment_key}}", + "Idempotency-Key": "01933b9e-7c2a-7d41-9a51-3c8f0a6e0025", + "Content-Type": "application/json" + }, + "body": { + "schema_version": "2.0", + "sync": { + "id": "01933b9e-7c2a-7d41-9a51-3c8f0a6e0025", + "started_at": "{{now-700}}", + "completed_at": "{{now-600}}", + "collector": { + "name": "cloudexit", + "version": "2.0.0", + "mode": "api", + "catalogue": { + "version": "2026-11-02", + "sequence": 41, + "sha256": "9f1c5b7a3e2d4c6b8a0f1e2d3c4b5a69788796a5b4c3d2e1f0a9b8c7d6e5f4a3", + "resource_type_ids": [ + 12, + 40, + 41, + 55 + ] + } + } + }, + "assessment_type": 2, + "scope": { + "provider": "aws", + "aws": { + "account_id": "123456789012", + "regions": [ + "eu-central-1" + ] + } + }, + "inventory": { + "resources": [ + { + "type_id": 12, + "type_code": "AWS::S3::Bucket", + "location": "eu-central-1", + "count": 36 + }, + { + "type_id": 40, + "type_code": "AWS::EC2::Instance", + "location": "eu-central-1", + "count": 8 + } + ], + "costs": [ + { + "month": "2026-10", + "amount": "1234.56", + "currency": "USD", + "final": true + } + ] + } + } + }, + "expect": { + "status": 201 + } + } + ], + "request": { + "method": "GET", + "path": "/api/v2/assessments/{{main_id}}/syncs?limit=1", + "headers": { + "Authorization": "Bearer {{reader_key}}" + } + }, + "expect": { + "status": 200, + "body_subset": { + "object": "list", + "has_more": true, + "data": [ + { + "object": "sync", + "client_sync_id": "01933b9e-7c2a-7d41-9a51-3c8f0a6e0026" + } + ] + }, + "lengths": { + "/data": 1 + }, + "exists": [ + "/next_cursor" + ] + } +} diff --git a/docs/api-v2/examples/51-insufficient_credits.json b/docs/api-v2/examples/51-insufficient_credits.json new file mode 100644 index 0000000..b2c0a6d --- /dev/null +++ b/docs/api-v2/examples/51-insufficient_credits.json @@ -0,0 +1,111 @@ +{ + "name": "insufficient credits", + "description": "ExitCloud only: a sync that costs more credits than the organization has is refused, and nothing is stored.", + "host": "exitcloud", + "fixtures": { + "catalogue": "standard", + "assessments": [ + { + "name": "main", + "provider": "aws", + "exit_strategy": "repatriation", + "assessment_type": 2, + "scope_key": null + } + ], + "keys": [ + { + "name": "assessment_key", + "kind": "assessment", + "assessment": "main" + } + ], + "credits": 0 + }, + "request": { + "method": "POST", + "path": "/api/v2/syncs", + "headers": { + "Authorization": "Bearer {{assessment_key}}", + "Idempotency-Key": "01933b9e-7c2a-7d41-9a51-3c8f0a6e0027", + "Content-Type": "application/json" + }, + "body": { + "schema_version": "2.0", + "sync": { + "id": "01933b9e-7c2a-7d41-9a51-3c8f0a6e0027", + "started_at": "{{now-300}}", + "completed_at": "{{now-60}}", + "collector": { + "name": "cloudexit", + "version": "2.0.0", + "mode": "api", + "catalogue": { + "version": "2026-11-02", + "sequence": 41, + "sha256": "9f1c5b7a3e2d4c6b8a0f1e2d3c4b5a69788796a5b4c3d2e1f0a9b8c7d6e5f4a3", + "resource_type_ids": [ + 12, + 40, + 41, + 55 + ] + } + } + }, + "assessment_type": 2, + "scope": { + "provider": "aws", + "aws": { + "account_id": "123456789012", + "regions": [ + "eu-central-1" + ] + } + }, + "inventory": { + "resources": [ + { + "type_id": 12, + "type_code": "AWS::S3::Bucket", + "location": "eu-central-1", + "count": 36 + }, + { + "type_id": 40, + "type_code": "AWS::EC2::Instance", + "location": "eu-central-1", + "count": 8 + } + ], + "costs": [ + { + "month": "2026-10", + "amount": "1234.56", + "currency": "USD", + "final": true + } + ] + } + } + }, + "expect": { + "status": 402, + "headers_present": [ + "X-Request-ID" + ], + "body_subset": { + "object": "error", + "api_version": "2", + "error": { + "code": "insufficient_credits" + } + }, + "exists": [ + "/error/message", + "/error/request_id", + "/error/details/0/required", + "/error/details/0/balance" + ] + } +} diff --git a/docs/api-v2/fixtures/catalogue.json b/docs/api-v2/fixtures/catalogue.json new file mode 100644 index 0000000..5f27fb2 --- /dev/null +++ b/docs/api-v2/fixtures/catalogue.json @@ -0,0 +1,48 @@ +{ + "name": "standard", + "version": "2026-11-02", + "sequence": 41, + "sha256": "9f1c5b7a3e2d4c6b8a0f1e2d3c4b5a69788796a5b4c3d2e1f0a9b8c7d6e5f4a3", + "loaded_at": 1762070400, + "resource_types": { + "aws": [ + { + "id": 12, + "code": "AWS::S3::Bucket", + "active": true + }, + { + "id": 40, + "code": "AWS::EC2::Instance", + "active": true + }, + { + "id": 41, + "code": "AWS::Lambda::Function", + "active": true + }, + { + "id": 55, + "code": "AWS::RDS::DBInstance", + "active": true + }, + { + "id": 77, + "code": "AWS::SimpleDB::Domain", + "active": false + } + ], + "azure": [ + { + "id": 201, + "code": "Microsoft.Storage/storageAccounts", + "active": true + }, + { + "id": 202, + "code": "Microsoft.Compute/virtualMachines", + "active": true + } + ] + } +} diff --git a/docs/api-v2/make_manifest.py b/docs/api-v2/make_manifest.py new file mode 100644 index 0000000..b079111 --- /dev/null +++ b/docs/api-v2/make_manifest.py @@ -0,0 +1,40 @@ +import hashlib +import sys +from pathlib import Path + +DIRECTORY = Path(__file__).resolve().parent +MANIFEST = DIRECTORY / "MANIFEST.sha256" + + +def definition_files(): + return sorted( + path + for path in DIRECTORY.rglob("*") + if path.is_file() + and path != MANIFEST + and "__pycache__" not in path.parts + and path.suffix != ".pyc" + ) + + +def manifest_text(): + lines = [] + for path in definition_files(): + digest = hashlib.sha256(path.read_bytes()).hexdigest() + lines.append(f"{digest} {path.relative_to(DIRECTORY).as_posix()}") + return "\n".join(lines) + "\n" + + +def main(argv): + if "--check" in argv: + if MANIFEST.read_text() != manifest_text(): + print("MANIFEST.sha256 is out of date; run make_manifest.py") + return 1 + return 0 + MANIFEST.write_text(manifest_text()) + print(f"wrote {MANIFEST.name} ({len(definition_files())} files)") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/docs/api-v2/openapi.yaml b/docs/api-v2/openapi.yaml new file mode 100644 index 0000000..4b2f3c4 --- /dev/null +++ b/docs/api-v2/openapi.yaml @@ -0,0 +1,1109 @@ +openapi: 3.1.0 +info: + title: cloudexit platform API + version: 2.0.0 + summary: Push syncs from the cloudexit CLI to EscapeCloud or ExitCloud. + description: | + One contract for two hosts. A sync is one pushed result: the CLI collects an inventory of a cloud scope and the + host evaluates it and appends it to an assessment. + + - The API key itself is the credential (`Authorization: Bearer `). There is no token exchange. + - The key decides the target. An assessment key appends to its own assessment. An account key with + `assessments:create` creates an assessment with `target.create`, and with `syncs:write` it may name + `target.assessment_id`. + - Every `POST` carries an `Idempotency-Key` (the sync's own id), so retries never duplicate or double-charge. + - Changes inside this major version are additive only. A breaking change is a new `/api/v3`. + + Fields marked `x-host: exitcloud` or `x-host: escapecloud` appear on that host only. Everything else is the same + on both. Error and warning codes are in `errors.yaml`; hash and key rules have test vectors in `vectors/`. + license: + name: AGPL-3.0 + identifier: AGPL-3.0-only +servers: + - url: https://{host}/api/v2 + description: A host of this contract (EscapeCloud on a customer's own host, or an ExitCloud region). + variables: + host: + default: eu.exitcloud.io +security: + - apiKey: [] +tags: + - name: discovery + - name: syncs + - name: assessments +paths: + /meta: + get: + operationId: getMeta + tags: [discovery] + summary: Capabilities, limits and policies of the host. + description: Needs no key and contains nothing tenant-specific. Cacheable for 5 minutes. + security: [] + responses: + "200": + description: The host's capabilities. + headers: + X-Request-ID: {$ref: "#/components/headers/RequestId"} + Cache-Control: + description: public, max-age=300 + schema: {type: string} + content: + application/json: + schema: {$ref: "#/components/schemas/Meta"} + "429": {$ref: "#/components/responses/RateLimited"} + "503": {$ref: "#/components/responses/Unavailable"} + /whoami: + get: + operationId: getWhoami + tags: [discovery] + summary: What this key is and what it may do. + parameters: + - {$ref: "#/components/parameters/RequestIdHeader"} + responses: + "200": + description: The principal behind the key. + headers: + X-Request-ID: {$ref: "#/components/headers/RequestId"} + RateLimit: {$ref: "#/components/headers/RateLimit"} + RateLimit-Policy: {$ref: "#/components/headers/RateLimitPolicy"} + content: + application/json: + schema: {$ref: "#/components/schemas/Whoami"} + "401": {$ref: "#/components/responses/Unauthorized"} + "403": {$ref: "#/components/responses/Forbidden"} + "429": {$ref: "#/components/responses/RateLimited"} + "503": {$ref: "#/components/responses/Unavailable"} + /syncs: + post: + operationId: createSync + tags: [syncs] + summary: Push a sync. + description: | + The single push endpoint. Needs `syncs:write` (an assessment key, or an account key naming + `target.assessment_id`) or `assessments:create` (an account key with `target.create`). + + The host answers `201` in 2.0 (processing is inline, `meta.processing.modes` is `["inline"]`). `202` plus + polling `GET /syncs/{sync_id}` is defined for later and clients must handle it. + + A replay of a completed request returns `200` with `Idempotent-Replayed: true` and the original body. It never + charges, notifies or scores again. `Prefer: wait=N` is accepted and ignored in 2.0. + parameters: + - {$ref: "#/components/parameters/IdempotencyKey"} + - {$ref: "#/components/parameters/RequestIdHeader"} + - {$ref: "#/components/parameters/Prefer"} + requestBody: + required: true + content: + application/json: + schema: {$ref: "#/components/schemas/SyncRequest"} + responses: + "200": + description: A replay of a completed request. + headers: + X-Request-ID: {$ref: "#/components/headers/RequestId"} + Idempotent-Replayed: {$ref: "#/components/headers/IdempotentReplayed"} + RateLimit: {$ref: "#/components/headers/RateLimit"} + RateLimit-Policy: {$ref: "#/components/headers/RateLimitPolicy"} + content: + application/json: + schema: {$ref: "#/components/schemas/Sync"} + "201": + description: The sync was processed and stored. + headers: + X-Request-ID: {$ref: "#/components/headers/RequestId"} + Location: {$ref: "#/components/headers/Location"} + RateLimit: {$ref: "#/components/headers/RateLimit"} + RateLimit-Policy: {$ref: "#/components/headers/RateLimitPolicy"} + content: + application/json: + schema: {$ref: "#/components/schemas/Sync"} + "202": + description: | + Accepted and still processing. Not sent by 2.0 hosts. The body has `status: processing` and no `results`. + headers: + X-Request-ID: {$ref: "#/components/headers/RequestId"} + Location: {$ref: "#/components/headers/Location"} + Retry-After: {$ref: "#/components/headers/RetryAfter"} + content: + application/json: + schema: {$ref: "#/components/schemas/Sync"} + "400": {$ref: "#/components/responses/BadRequest"} + "401": {$ref: "#/components/responses/Unauthorized"} + "402": {$ref: "#/components/responses/PaymentRequired"} + "403": {$ref: "#/components/responses/Forbidden"} + "404": {$ref: "#/components/responses/NotFound"} + "409": {$ref: "#/components/responses/Conflict"} + "410": {$ref: "#/components/responses/Gone"} + "413": {$ref: "#/components/responses/PayloadTooLarge"} + "415": {$ref: "#/components/responses/UnsupportedMediaType"} + "422": {$ref: "#/components/responses/Unprocessable"} + "429": {$ref: "#/components/responses/RateLimited"} + "500": {$ref: "#/components/responses/InternalError"} + "503": {$ref: "#/components/responses/Unavailable"} + /syncs/{sync_id}: + get: + operationId: getSync + tags: [syncs] + summary: A sync's status and result. + description: "Needs `syncs:write` or `assessments:read`. Polled after a `202`. A failed sync has `status: failed` and an `error`." + parameters: + - {$ref: "#/components/parameters/SyncId"} + - {$ref: "#/components/parameters/RequestIdHeader"} + responses: + "200": + description: The sync. + headers: + X-Request-ID: {$ref: "#/components/headers/RequestId"} + RateLimit: {$ref: "#/components/headers/RateLimit"} + RateLimit-Policy: {$ref: "#/components/headers/RateLimitPolicy"} + content: + application/json: + schema: {$ref: "#/components/schemas/Sync"} + "401": {$ref: "#/components/responses/Unauthorized"} + "403": {$ref: "#/components/responses/Forbidden"} + "404": {$ref: "#/components/responses/NotFound"} + "429": {$ref: "#/components/responses/RateLimited"} + "503": {$ref: "#/components/responses/Unavailable"} + /assessments: + get: + operationId: listAssessments + tags: [assessments] + summary: List assessments. + description: Needs an account key with `assessments:read`. Newest first, cursor pagination. + parameters: + - {$ref: "#/components/parameters/Limit"} + - {$ref: "#/components/parameters/Cursor"} + - name: provider + in: query + schema: {$ref: "#/components/schemas/Provider"} + - name: state + in: query + schema: {type: string, enum: [active, archived], default: active} + - {$ref: "#/components/parameters/RequestIdHeader"} + responses: + "200": + description: A page of assessments. + headers: + X-Request-ID: {$ref: "#/components/headers/RequestId"} + RateLimit: {$ref: "#/components/headers/RateLimit"} + RateLimit-Policy: {$ref: "#/components/headers/RateLimitPolicy"} + content: + application/json: + schema: {$ref: "#/components/schemas/AssessmentList"} + "401": {$ref: "#/components/responses/Unauthorized"} + "403": {$ref: "#/components/responses/Forbidden"} + "422": {$ref: "#/components/responses/Unprocessable"} + "429": {$ref: "#/components/responses/RateLimited"} + "503": {$ref: "#/components/responses/Unavailable"} + /assessments/{assessment_id}: + get: + operationId: getAssessment + tags: [assessments] + summary: One assessment with its latest sync. + description: Needs an account key with `assessments:read`. + parameters: + - {$ref: "#/components/parameters/AssessmentId"} + - {$ref: "#/components/parameters/RequestIdHeader"} + responses: + "200": + description: The assessment. + headers: + X-Request-ID: {$ref: "#/components/headers/RequestId"} + RateLimit: {$ref: "#/components/headers/RateLimit"} + RateLimit-Policy: {$ref: "#/components/headers/RateLimitPolicy"} + content: + application/json: + schema: {$ref: "#/components/schemas/Assessment"} + "401": {$ref: "#/components/responses/Unauthorized"} + "403": {$ref: "#/components/responses/Forbidden"} + "404": {$ref: "#/components/responses/NotFound"} + "429": {$ref: "#/components/responses/RateLimited"} + "503": {$ref: "#/components/responses/Unavailable"} + /assessments/{assessment_id}/syncs: + get: + operationId: listAssessmentSyncs + tags: [assessments] + summary: The sync history of an assessment. + description: Needs an account key with `assessments:read`. Ordered by collection time, newest first, then by id. + parameters: + - {$ref: "#/components/parameters/AssessmentId"} + - {$ref: "#/components/parameters/Limit"} + - {$ref: "#/components/parameters/Cursor"} + - {$ref: "#/components/parameters/RequestIdHeader"} + responses: + "200": + description: A page of syncs. + headers: + X-Request-ID: {$ref: "#/components/headers/RequestId"} + RateLimit: {$ref: "#/components/headers/RateLimit"} + RateLimit-Policy: {$ref: "#/components/headers/RateLimitPolicy"} + content: + application/json: + schema: {$ref: "#/components/schemas/SyncList"} + "401": {$ref: "#/components/responses/Unauthorized"} + "403": {$ref: "#/components/responses/Forbidden"} + "404": {$ref: "#/components/responses/NotFound"} + "422": {$ref: "#/components/responses/Unprocessable"} + "429": {$ref: "#/components/responses/RateLimited"} + "503": {$ref: "#/components/responses/Unavailable"} +components: + securitySchemes: + apiKey: + type: http + scheme: bearer + bearerFormat: "esc_sk_… (EscapeCloud), exc_eu_sk_… or exc_us_sk_… (ExitCloud)" + description: | + The API key as issued, `_sk_` + 16 characters key id + 32 characters secret + 6 characters CRC32 + checksum (all base62). A key with a wrong prefix, length or checksum is refused without a lookup. + parameters: + IdempotencyKey: + name: Idempotency-Key + in: header + required: true + description: The sync's own id (`sync.id`). A different value gives `422 idempotency_key_mismatch`. + schema: {type: string, pattern: "^[A-Za-z0-9_-]{16,64}$"} + RequestIdHeader: + name: X-Request-ID + in: header + required: false + description: Echoed back if it matches the pattern, otherwise replaced by a generated value. Always returned. + schema: {type: string, pattern: "^[A-Za-z0-9._-]{8,64}$"} + Prefer: + name: Prefer + in: header + required: false + description: "`wait=` (RFC 7240). Accepted and ignored in 2.0, where processing is inline." + schema: {type: string, pattern: "^wait=[0-9]{1,3}$"} + SyncId: + name: sync_id + in: path + required: true + description: The host's id of the sync (`Sync ID` in the UI), not the client's `sync.id`. + schema: {type: string, format: uuid} + AssessmentId: + name: assessment_id + in: path + required: true + schema: {type: string, format: uuid} + Limit: + name: limit + in: query + schema: {type: integer, minimum: 1, maximum: 100, default: 25} + Cursor: + name: cursor + in: query + description: The `next_cursor` of the previous page. Opaque. + schema: {type: string, minLength: 1, maxLength: 512} + headers: + RequestId: + description: The request id, also in `error.request_id` of an error body. + schema: {type: string, pattern: "^[A-Za-z0-9._-]{8,64}$"} + IdempotentReplayed: + description: "`true` on a replayed response." + schema: {type: string, enum: ["true"]} + Location: + description: Where to fetch the sync, `/api/v2/syncs/{id}`. + schema: {type: string} + RetryAfter: + description: Seconds to wait before retrying. + schema: {type: integer, minimum: 0} + RateLimit: + description: "IETF httpapi ratelimit header, for example `limit=60, remaining=12, reset=31`." + schema: {type: string} + RateLimitPolicy: + description: "For example `60;w=60`." + schema: {type: string} + Deprecation: + description: Sent for a deprecated contract major or client version (RFC 9745). + schema: {type: string} + Sunset: + description: When a deprecated major or client version stops working (RFC 8594). + schema: {type: string} + Link: + description: "`<…>; rel=\"deprecation\"` pointing at the migration notes." + schema: {type: string} + responses: + BadRequest: + description: Malformed request (`invalid_json`, `idempotency_key_missing`, `unsupported_schema_version`, `client_version_unsupported`). + headers: &errorHeaders + X-Request-ID: {$ref: "#/components/headers/RequestId"} + content: + application/json: + schema: {$ref: "#/components/schemas/ErrorResponse"} + Unauthorized: + description: The key is missing, malformed, unknown, expired or revoked. + headers: *errorHeaders + content: + application/json: + schema: {$ref: "#/components/schemas/ErrorResponse"} + PaymentRequired: + description: Not enough credits (`insufficient_credits`). + x-host: exitcloud + headers: *errorHeaders + content: + application/json: + schema: {$ref: "#/components/schemas/ErrorResponse"} + Forbidden: + description: The key may not do this (`insufficient_scope`, `key_binding_violation`, `principal_inactive`, `wrong_region`). + headers: *errorHeaders + content: + application/json: + schema: {$ref: "#/components/schemas/ErrorResponse"} + NotFound: + description: No such assessment or sync for this tenant. + headers: *errorHeaders + content: + application/json: + schema: {$ref: "#/components/schemas/ErrorResponse"} + Conflict: + description: The sync does not fit the assessment (`target_mismatch`), the assessment is archived, or the same request is in progress. + headers: + X-Request-ID: {$ref: "#/components/headers/RequestId"} + Retry-After: {$ref: "#/components/headers/RetryAfter"} + content: + application/json: + schema: {$ref: "#/components/schemas/ErrorResponse"} + Gone: + description: The assessment was deleted (`assessment_deleted`). + headers: *errorHeaders + content: + application/json: + schema: {$ref: "#/components/schemas/ErrorResponse"} + PayloadTooLarge: + description: The body is beyond `limits.max_body_bytes`. + headers: *errorHeaders + content: + application/json: + schema: {$ref: "#/components/schemas/ErrorResponse"} + UnsupportedMediaType: + description: The Content-Type is not `application/json`. + headers: *errorHeaders + content: + application/json: + schema: {$ref: "#/components/schemas/ErrorResponse"} + Unprocessable: + description: Validation failed, a limit was exceeded, the idempotency key was reused or mismatched, or the catalogue does not fit. + headers: *errorHeaders + content: + application/json: + schema: {$ref: "#/components/schemas/ErrorResponse"} + RateLimited: + description: Too many requests (`rate_limited`) or a quota is used up (`quota_exceeded`). + headers: + X-Request-ID: {$ref: "#/components/headers/RequestId"} + Retry-After: {$ref: "#/components/headers/RetryAfter"} + content: + application/json: + schema: {$ref: "#/components/schemas/ErrorResponse"} + InternalError: + description: Something failed on the host. Retrying with the same Idempotency-Key is safe. + headers: *errorHeaders + content: + application/json: + schema: {$ref: "#/components/schemas/ErrorResponse"} + Unavailable: + description: The host is in maintenance or a dependency is down. + headers: + X-Request-ID: {$ref: "#/components/headers/RequestId"} + Retry-After: {$ref: "#/components/headers/RetryAfter"} + content: + application/json: + schema: {$ref: "#/components/schemas/ErrorResponse"} + schemas: + ApiVersion: + type: string + const: "2" + Provider: + type: string + enum: [aws, azure] + description: The cloud providers a sync can carry. More are added additively. + ExitStrategy: + type: string + enum: [repatriation, hybrid_cloud, alternate_cloud] + AssessmentType: + type: integer + minimum: 1 + maximum: 3 + description: The depth. 1 = inventory, cost and risks; 2 = plus scores and alternatives; 3 = plus detailed collection and egress. + EpochSeconds: + type: integer + minimum: 0 + maximum: 4102444800 + Sha256: + type: string + pattern: "^[0-9a-f]{64}$" + CatalogueVersion: + type: string + pattern: "^[0-9]{4}-[0-9]{2}-[0-9]{2}(\\.[0-9]+)?$" + description: "`YYYY-MM-DD[.N]`. Compare as (date, N), never as text." + ErrorCode: + type: string + description: See errors.yaml. Unknown codes must be handled by HTTP class and `retryable`. + enum: + - unauthenticated + - invalid_api_key + - api_key_expired + - api_key_revoked + - wrong_region + - principal_inactive + - insufficient_scope + - key_binding_violation + - assessment_key_v1 + - idempotency_key_missing + - idempotency_key_mismatch + - idempotency_key_reused + - request_in_progress + - invalid_json + - unsupported_media_type + - payload_too_large + - limit_exceeded + - validation_failed + - catalogue_mismatch + - catalogue_sha256_mismatch + - unsupported_schema_version + - client_version_unsupported + - assessment_not_found + - sync_not_found + - assessment_deleted + - assessment_archived + - target_mismatch + - insufficient_credits + - quota_exceeded + - rate_limited + - maintenance + - service_unavailable + - internal_error + WarningCode: + type: string + description: See errors.yaml. Clients must tolerate unknown codes. + enum: + - unknown_resource_types + - resource_type_inactive + - resource_type_code_mismatch + - duplicate_items_merged + - tier_downgraded + - name_ignored + - key_expiring + - client_version_deprecated + - collector_catalogue_outdated + - host_catalogue_outdated + - collector_catalogue_unknown + ErrorDetail: + type: object + description: | + One problem. `pointer` or `parameter` says where. Code-specific values are further keys of the item, for + example `field`, `expected` and `actual` on `target_mismatch`; they are listed per code in errors.yaml. + required: [code, message] + additionalProperties: true + properties: + pointer: {type: string, description: A JSON pointer into the request body.} + parameter: {type: string, description: A query parameter or header name.} + code: {type: string} + message: {type: string} + field: {type: string} + expected: {} + actual: {} + required: {type: array, items: {type: string}} + limit: {type: string} + balance: {type: integer} + resets_at: {$ref: "#/components/schemas/EpochSeconds"} + supported: {type: array, items: {type: string}} + expected_host: {type: string, format: uri} + min_supported: {type: string} + recommended: {type: string} + host_version: {$ref: "#/components/schemas/CatalogueVersion"} + host_sha256: {$ref: "#/components/schemas/Sha256"} + Error: + type: object + required: [code, message, retryable, request_id, details] + properties: + code: {$ref: "#/components/schemas/ErrorCode"} + message: {type: string} + retryable: {type: boolean} + request_id: {type: string} + details: + type: array + items: {$ref: "#/components/schemas/ErrorDetail"} + docs_url: {type: string, format: uri} + ErrorResponse: + type: object + required: [object, api_version, error] + properties: + object: {const: error} + api_version: {$ref: "#/components/schemas/ApiVersion"} + error: {$ref: "#/components/schemas/Error"} + Warning: + type: object + required: [code, message] + properties: + code: {$ref: "#/components/schemas/WarningCode"} + message: {type: string} + details: + type: object + additionalProperties: true + AwsScope: + type: object + required: [provider, aws] + properties: + provider: {const: aws} + aws: + type: object + required: [account_id, regions] + properties: + account_id: {type: string, pattern: "^[0-9]{12}$"} + regions: + type: array + minItems: 1 + maxItems: 40 + items: + type: string + pattern: "^(\\*|[a-z]{2}(-[a-z]+)+-[0-9]+)$" + AzureScope: + type: object + required: [provider, azure] + properties: + provider: {const: azure} + azure: + type: object + required: [tenant_id, subscription_id] + properties: + tenant_id: {type: string, format: uuid} + subscription_id: {type: string, format: uuid} + resource_group: + type: [string, "null"] + minLength: 1 + maxLength: 90 + description: Missing or null means the whole subscription. + TfstateScope: + type: object + required: [provider, tfstate] + properties: + provider: {$ref: "#/components/schemas/Provider"} + tfstate: + type: object + required: [lineage] + properties: + lineage: {type: string, format: uuid} + serial: {type: [integer, "null"], minimum: 0} + Scope: + description: | + What the sync covers. The host derives the `scope_key` from it (rules and vectors in `vectors/scope_key.json`) + and checks that `provider` matches the assessment. `collector.mode` is `tfstate` exactly when `tfstate` is sent. + oneOf: + - {$ref: "#/components/schemas/AwsScope"} + - {$ref: "#/components/schemas/AzureScope"} + - {$ref: "#/components/schemas/TfstateScope"} + CatalogueClaim: + type: object + description: The catalogue the collector ran with, from its dataset's `catalogue_meta`. + required: [version, sequence, sha256, resource_type_ids] + properties: + version: {$ref: "#/components/schemas/CatalogueVersion"} + sequence: {type: integer, minimum: 0} + sha256: {$ref: "#/components/schemas/Sha256"} + resource_type_ids: + type: array + description: The sorted ids of the dataset's active resource types for the sync's provider. + maxItems: 5000 + uniqueItems: true + items: {type: integer, minimum: 1} + Collector: + type: object + required: [name, version, mode] + properties: + name: {type: string, minLength: 1, maxLength: 64} + version: {type: string, minLength: 1, maxLength: 64} + mode: {type: string, enum: [api, tfstate]} + catalogue: {$ref: "#/components/schemas/CatalogueClaim"} + CiContext: + type: object + properties: + provider: {type: string, maxLength: 64} + run_url: {type: string, format: uri, maxLength: 2048} + SyncInfo: + type: object + required: [id, started_at, completed_at, collector] + properties: + id: + type: string + pattern: "^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + description: A UUIDv7 created at collection time. Also the Idempotency-Key. + started_at: {$ref: "#/components/schemas/EpochSeconds"} + completed_at: + allOf: + - {$ref: "#/components/schemas/EpochSeconds"} + description: Not before `started_at`, not older than `limits.max_collection_age_days`, not more than `limits.clock_skew_seconds` in the future. + collector: {$ref: "#/components/schemas/Collector"} + ci: {$ref: "#/components/schemas/CiContext"} + Target: + type: object + description: | + Optional with an assessment key (the binding decides; a different `assessment_id` gives `403 key_binding_violation`). + With an account key send exactly one of `assessment_id` (needs `syncs:write`) or `create` (needs `assessments:create`). + properties: + assessment_id: {type: string, format: uuid} + create: + type: object + required: [name, exit_strategy] + properties: + name: {type: string, minLength: 1, maxLength: 255} + exit_strategy: {$ref: "#/components/schemas/ExitStrategy"} + group: {type: string, minLength: 1, maxLength: 255} + not: + required: [assessment_id, create] + ResourceItem: + type: object + required: [type_id, location, count] + properties: + type_id: {type: integer, minimum: 1} + type_code: {type: string, maxLength: 255, description: A cross-check label. A mismatch with the host gives the warning `resource_type_code_mismatch`.} + location: {type: string, maxLength: 255} + count: {type: integer, minimum: 0, maximum: 10000} + CostItem: + type: object + required: [month, amount, currency] + properties: + month: {type: string, pattern: "^[0-9]{4}-(0[1-9]|1[0-2])$"} + amount: {type: string, pattern: "^-?[0-9]+(\\.[0-9]+)?$", maxLength: 40, description: A decimal string, never a float.} + currency: {type: string, pattern: "^[A-Za-z]{3}$"} + final: {type: boolean, default: false} + EgressTier: + type: object + required: [tier] + properties: + tier: {type: string, minLength: 1, maxLength: 50} + size_bytes: {type: [integer, "null"], minimum: 0, maximum: 9007199254740991} + is_archive: {type: boolean, default: false} + EgressItem: + type: object + required: [type_id, name] + properties: + type_id: {type: integer, minimum: 1} + name: {type: string, maxLength: 255} + size_bytes: {type: [integer, "null"], minimum: 0, maximum: 9007199254740991} + flags: + type: array + maxItems: 10 + items: {type: string, maxLength: 64} + notes: + type: array + maxItems: 10 + items: {type: string, maxLength: 255} + tiers: + type: array + maxItems: 10 + items: {$ref: "#/components/schemas/EgressTier"} + Egress: + type: object + required: [collected] + properties: + collected: {type: boolean} + truncated: {type: boolean, default: false} + names: {type: string, enum: [plain, hashed], default: plain} + items: + type: array + maxItems: 1000 + items: {$ref: "#/components/schemas/EgressItem"} + Inventory: + type: object + required: [resources] + properties: + resources: + type: array + maxItems: 1000 + items: {$ref: "#/components/schemas/ResourceItem"} + costs: + type: array + maxItems: 12 + items: {$ref: "#/components/schemas/CostItem"} + egress: {$ref: "#/components/schemas/Egress"} + SyncRequest: + type: object + description: | + Unknown fields are ignored within a major version. The body is at most `limits.max_body_bytes`. + `size_bytes` values are capped at 2^53-1 so that hashes are exact. + required: [schema_version, sync, assessment_type, scope, inventory] + properties: + schema_version: {type: string, pattern: "^2\\.[0-9]+$"} + sync: {$ref: "#/components/schemas/SyncInfo"} + target: {$ref: "#/components/schemas/Target"} + assessment_type: {$ref: "#/components/schemas/AssessmentType"} + scope: {$ref: "#/components/schemas/Scope"} + inventory: {$ref: "#/components/schemas/Inventory"} + SyncStatus: + type: string + enum: [accepted, processing, completed, completed_with_warnings, failed] + AssessmentRef: + type: object + required: [id, name, created, scope_key, url] + properties: + id: {type: string, format: uuid} + name: {type: string} + created: {type: boolean, description: "`true` when this request created the assessment."} + scope_key: {type: [string, "null"], maxLength: 255} + url: {type: string, format: uri} + Timestamps: + type: object + required: [collected_at, received_at] + properties: + collected_at: {$ref: "#/components/schemas/EpochSeconds"} + received_at: {$ref: "#/components/schemas/EpochSeconds"} + processed_at: {anyOf: [{$ref: "#/components/schemas/EpochSeconds"}, {type: "null"}]} + Ordering: + type: object + required: [latest, late] + properties: + latest: {type: boolean, description: This sync is now the assessment's latest.} + late: {type: boolean, description: A newer sync (by collection time) was already stored.} + ContentInfo: + type: object + required: [payload_sha256, content_sha256] + properties: + payload_sha256: {$ref: "#/components/schemas/Sha256"} + content_sha256: {$ref: "#/components/schemas/Sha256"} + unchanged_from: + type: [string, "null"] + format: uuid + description: Always null in 2.0, where an unchanged sync is stored like any other. + AssessmentTypeResult: + type: object + required: [requested, granted] + properties: + requested: {$ref: "#/components/schemas/AssessmentType"} + granted: {$ref: "#/components/schemas/AssessmentType"} + ResourceTypeCount: + type: object + required: [type_id, count] + properties: + type_id: {type: integer} + type_code: {type: string} + count: {type: integer, minimum: 0} + CatalogueResult: + type: object + required: [client, host, unknown_resource_types, inactive_resource_types] + properties: + client: + description: What the collector claimed. Null without a catalogue block. + oneOf: + - type: "null" + - type: object + required: [version, sequence] + properties: + version: {$ref: "#/components/schemas/CatalogueVersion"} + sequence: {type: integer} + host: + type: object + required: [version, sha256] + properties: + version: {$ref: "#/components/schemas/CatalogueVersion"} + sha256: {$ref: "#/components/schemas/Sha256"} + unknown_resource_types: + type: array + items: {$ref: "#/components/schemas/ResourceTypeCount"} + inactive_resource_types: + type: array + items: {$ref: "#/components/schemas/ResourceTypeCount"} + Billing: + type: object + x-host: exitcloud + required: [class, charged_credits, balance] + properties: + class: {type: string, enum: [new]} + charged_credits: {type: integer, minimum: 0} + balance: {type: integer} + transaction_id: {type: [string, "null"]} + ReviewState: + type: object + x-host: escapecloud + properties: + pending: {type: boolean} + approved_sync_id: {type: [string, "null"], format: uuid} + Severity: + type: string + enum: [low, medium, high, critical] + Results: + type: object + description: Only risks that have a severity are returned, identified by code. + required: [scoring_version, risks] + properties: + scoring_version: {type: string} + scores: + description: Present from depth 2. + type: object + required: [exit, human, technology, operational] + properties: + exit: {type: integer, minimum: 0, maximum: 100} + human: {type: integer, minimum: 0, maximum: 100} + technology: {type: integer, minimum: 0, maximum: 100} + operational: {type: integer, minimum: 0, maximum: 100} + risks: + type: array + items: + type: object + required: [code, severity, impacted_resource_type_ids] + properties: + code: {type: string} + severity: {$ref: "#/components/schemas/Severity"} + impacted_resource_type_ids: + type: array + items: {type: integer} + SyncLinks: + type: object + required: [self, assessment] + properties: + self: {type: string} + assessment: {type: string} + Sync: + type: object + required: [object, api_version, id, client_sync_id, request_id, status, assessment, timestamps, ordering, content, assessment_type, catalogue, warnings, links] + properties: + object: {const: sync} + api_version: {$ref: "#/components/schemas/ApiVersion"} + id: {type: string, format: uuid, description: "The host's id (`Sync.order`, `Sync ID` in the UI)."} + client_sync_id: {type: [string, "null"], description: "The CLI's `sync.id`. Null for syncs the host collected itself."} + request_id: {type: [string, "null"]} + status: {$ref: "#/components/schemas/SyncStatus"} + assessment: {$ref: "#/components/schemas/AssessmentRef"} + timestamps: {$ref: "#/components/schemas/Timestamps"} + ordering: {$ref: "#/components/schemas/Ordering"} + content: {$ref: "#/components/schemas/ContentInfo"} + assessment_type: {$ref: "#/components/schemas/AssessmentTypeResult"} + catalogue: {$ref: "#/components/schemas/CatalogueResult"} + warnings: + type: array + items: {$ref: "#/components/schemas/Warning"} + billing: {$ref: "#/components/schemas/Billing"} + review_state: {$ref: "#/components/schemas/ReviewState"} + results: {$ref: "#/components/schemas/Results"} + error: + description: Present when `status` is `failed`. + allOf: + - {$ref: "#/components/schemas/Error"} + links: {$ref: "#/components/schemas/SyncLinks"} + AssessmentSource: + type: string + enum: [engine, cli] + Assessment: + type: object + required: [object, api_version, id, name, source, provider, exit_strategy, assessment_type, scope_key, state, created, latest_sync, url] + properties: + object: {const: assessment} + api_version: {$ref: "#/components/schemas/ApiVersion"} + id: {type: string, format: uuid} + name: {type: string} + source: {$ref: "#/components/schemas/AssessmentSource"} + provider: {type: string, description: The provider code, for example `aws` or `azure`.} + exit_strategy: {$ref: "#/components/schemas/ExitStrategy"} + assessment_type: {$ref: "#/components/schemas/AssessmentType"} + scope_key: {type: [string, "null"], maxLength: 255, description: Recorded by the first sync.} + state: {type: string, enum: [active, archived]} + created: {$ref: "#/components/schemas/EpochSeconds"} + latest_sync: + description: The newest sync by collection time. Null before the first sync. + oneOf: + - type: "null" + - {$ref: "#/components/schemas/Sync"} + url: {type: string, format: uri} + ListPage: + type: object + required: [object, api_version, data, has_more, next_cursor] + properties: + object: {const: list} + api_version: {$ref: "#/components/schemas/ApiVersion"} + has_more: {type: boolean} + next_cursor: {type: [string, "null"], maxLength: 512} + AssessmentList: + allOf: + - {$ref: "#/components/schemas/ListPage"} + - type: object + properties: + data: + type: array + items: {$ref: "#/components/schemas/Assessment"} + SyncList: + allOf: + - {$ref: "#/components/schemas/ListPage"} + - type: object + properties: + data: + type: array + items: {$ref: "#/components/schemas/Sync"} + Tenant: + type: object + required: [type, id, name] + properties: + type: {type: string, enum: [organization, user]} + id: {type: string} + name: {type: string} + WhoamiKey: + type: object + required: [id, name, display, kind, scopes, assessment_id, expires_at] + properties: + id: {type: string, format: uuid} + name: {type: string} + display: {type: string, description: "The prefix and the last four characters, for example `esc_sk_…a1b2`."} + kind: {type: string, enum: [assessment, account]} + scopes: + type: array + items: {type: string, enum: [assessments:create, assessments:read, syncs:write]} + assessment_id: {type: [string, "null"], format: uuid, description: The binding of an assessment key.} + expires_at: {$ref: "#/components/schemas/EpochSeconds"} + last_used_at: {anyOf: [{$ref: "#/components/schemas/EpochSeconds"}, {type: "null"}]} + Whoami: + type: object + required: [object, api_version, request_id, tenant, key, warnings] + properties: + object: {const: whoami} + api_version: {$ref: "#/components/schemas/ApiVersion"} + request_id: {type: string} + tenant: {$ref: "#/components/schemas/Tenant"} + key: {$ref: "#/components/schemas/WhoamiKey"} + warnings: + type: array + items: {$ref: "#/components/schemas/Warning"} + billing: + type: object + x-host: exitcloud + required: [balance] + properties: + balance: {type: integer} + licensing: + type: object + x-host: escapecloud + description: Informational. Never blocks a sync. + properties: + active_assessments: {type: integer} + cloud_providers: {type: integer} + resources: {type: integer} + MetaHost: + type: object + required: [product, region, app_version, base_url] + properties: + product: {type: string, enum: [escapecloud, exitcloud]} + region: {type: [string, "null"], description: "`eu` or `us` on ExitCloud, null on EscapeCloud."} + app_version: {type: string} + base_url: {type: string, format: uri} + MetaLimits: + type: object + required: [max_body_bytes, max_resource_items, max_count_per_item, max_location_chars, max_cost_items, max_egress_items, max_name_chars, clock_skew_seconds, max_collection_age_days, idempotency_replay_hours] + properties: + max_body_bytes: {type: integer, const: 1000000} + max_resource_items: {type: integer, const: 1000} + max_count_per_item: {type: integer, const: 10000} + max_location_chars: {type: integer, const: 255} + max_cost_items: {type: integer, const: 12} + max_egress_items: {type: integer, const: 1000} + max_name_chars: {type: integer, const: 255} + max_egress_tiers_per_item: {type: integer, const: 10} + max_egress_flags_per_item: {type: integer, const: 10} + max_egress_notes_per_item: {type: integer, const: 10} + max_catalogue_type_ids: {type: integer, const: 5000} + clock_skew_seconds: {type: integer, const: 86400} + max_collection_age_days: {type: integer, const: 30} + idempotency_replay_hours: {type: integer, const: 168} + description: The values are the defaults of both hosts; a host may announce others. + AssessmentTypeInfo: + type: object + required: [id, code, name] + properties: + id: {$ref: "#/components/schemas/AssessmentType"} + code: {type: string, enum: [basic, standard, premium]} + name: {type: string, description: The host's own display name.} + requires: {type: array, items: {type: string}} + downgrade_to: {type: integer} + Meta: + type: object + required: [object, api_version, host, contract, clients, catalogue, limits, rate_limits, processing, features, assessment_types] + properties: + object: {const: meta} + api_version: {$ref: "#/components/schemas/ApiVersion"} + host: {$ref: "#/components/schemas/MetaHost"} + contract: + type: object + required: [schema_versions, current] + properties: + schema_versions: {type: array, items: {type: string, pattern: "^2\\.[0-9]+$"}} + current: {type: string, pattern: "^2\\.[0-9]+$"} + legacy: + type: object + additionalProperties: + type: object + properties: + status: {type: string} + sunset: {type: string, format: date} + clients: + type: object + additionalProperties: + type: object + properties: + min_supported: {type: string} + deprecated_below: {type: [string, "null"]} + recommended: {type: string} + blocked: {type: array, items: {type: string}} + catalogue: + type: object + required: [version, sha256] + properties: + version: {$ref: "#/components/schemas/CatalogueVersion"} + sha256: {$ref: "#/components/schemas/Sha256"} + loaded_at: {$ref: "#/components/schemas/EpochSeconds"} + collector_min_version: {type: [string, "null"], description: Always null in 2.0, where no tenant policy rejects old catalogues.} + collector_warn_after_days: {type: integer} + limits: {$ref: "#/components/schemas/MetaLimits"} + rate_limits: + type: object + properties: + per_key: + type: object + properties: + requests_per_minute: {type: integer} + syncs_per_hour: {type: integer} + per_tenant: + type: object + properties: + syncs_per_day: {type: integer} + processing: + type: object + required: [modes] + properties: + modes: + type: array + items: {type: string, enum: [inline, queued]} + description: '`["inline"]` in 2.0.' + max_wait_seconds: {type: integer} + features: + type: object + properties: + upsert_by_scope_key: {type: boolean, const: false} + content_dedupe: {type: boolean, const: false} + results_in_response: {type: boolean} + egress_inventory: {type: boolean} + reviews: {type: boolean, x-host: escapecloud} + oidc: + type: object + properties: + enabled: {type: boolean, const: false} + issuers: {type: array, items: {type: string}} + assessment_types: + type: array + items: {$ref: "#/components/schemas/AssessmentTypeInfo"} + billing: + type: [object, "null"] + x-host: exitcloud + properties: + model: {type: string, enum: [credits]} + prices: + type: object + properties: + new: + type: object + additionalProperties: {type: integer} + licensing: + type: [object, "null"] + x-host: escapecloud + properties: + model: {type: string, enum: [licence]} + enforcement: {type: string, enum: [none]} + reported: + type: array + items: {type: string} diff --git a/docs/api-v2/vectors/content_sha256.json b/docs/api-v2/vectors/content_sha256.json new file mode 100644 index 0000000..8f0a7cd --- /dev/null +++ b/docs/api-v2/vectors/content_sha256.json @@ -0,0 +1,1234 @@ +{ + "function": "content_sha256(body) -> str", + "description": "SHA-256 (lower-case hex) of the UTF-8 bytes of the RFC 8785 canonical JSON of the content form below. It identifies what was collected: it ignores the sync id, the timestamps, the collector, the catalogue block and anything else outside scope, inventory and assessment_type. 'body' is the request body of POST /syncs; only the fields used here need to be present.", + "content_form": { + "assessment_type": "the requested depth as sent", + "scope": "provider plus the provider block as in the request, with these normalisations: AWS regions sorted and de-duplicated; Azure ids and resource group lower-cased, a null resource group left out; the Terraform lineage lower-cased. Unknown fields are left out.", + "inventory.resources": "{count, location, type_id} only (type_code is a label and is left out). Items with the same type_id and location are merged by summing counts. Sorted by type_id, then location.", + "inventory.costs": "{amount, currency, final, month}. amount is normalised as a decimal (no trailing zeros, no exponent, '0' for zero), currency is upper-cased, final defaults to false. Sorted by month, then currency. Omitted costs equal an empty list.", + "inventory.egress": "{collected, items, names, truncated}. A missing egress block equals {collected: false, items: [], names: 'plain', truncated: false}. Items are {flags, name, notes, size_bytes, tiers, type_id}, with flags and notes sorted, tiers {is_archive, size_bytes, tier} sorted by tier, and the items sorted by type_id, then name." + }, + "serialisation": [ + "Object keys sorted, no whitespace, strings in UTF-8 without escaping beyond RFC 8785 (non-ASCII characters are written as they are).", + "Numbers are integers in the I-JSON safe range (at most 9007199254740991 in absolute value). Floats are not allowed." + ], + "cases": [ + { + "name": "minimal inventory", + "body": { + "schema_version": "2.0", + "sync": { + "id": "01933b9e-7c2a-7d41-9a51-3c8f0a6e21d4", + "started_at": 1762149604, + "completed_at": 1762149831, + "collector": { + "name": "cloudexit", + "version": "2.0.0", + "mode": "api" + } + }, + "assessment_type": 2, + "scope": { + "provider": "aws", + "aws": { + "account_id": "123456789012", + "regions": [ + "eu-central-1" + ] + } + }, + "inventory": { + "resources": [ + { + "type_id": 12, + "type_code": "AWS::S3::Bucket", + "location": "eu-central-1", + "count": 36 + }, + { + "type_id": 40, + "type_code": "AWS::EC2::Instance", + "location": "eu-central-1", + "count": 8 + } + ], + "costs": [ + { + "month": "2026-10", + "amount": "1234.56", + "currency": "USD", + "final": true + } + ] + } + }, + "canonical_json": "{\"assessment_type\":2,\"inventory\":{\"costs\":[{\"amount\":\"1234.56\",\"currency\":\"USD\",\"final\":true,\"month\":\"2026-10\"}],\"egress\":{\"collected\":false,\"items\":[],\"names\":\"plain\",\"truncated\":false},\"resources\":[{\"count\":36,\"location\":\"eu-central-1\",\"type_id\":12},{\"count\":8,\"location\":\"eu-central-1\",\"type_id\":40}]},\"scope\":{\"aws\":{\"account_id\":\"123456789012\",\"regions\":[\"eu-central-1\"]},\"provider\":\"aws\"}}", + "sha256": "27ab581110b19cf629481e644386ded634aa3238e243eddb169e1763246562b8" + }, + { + "name": "resources in another order", + "body": { + "schema_version": "2.0", + "sync": { + "id": "01933b9e-7c2a-7d41-9a51-3c8f0a6e21d4", + "started_at": 1762149604, + "completed_at": 1762149831, + "collector": { + "name": "cloudexit", + "version": "2.0.0", + "mode": "api" + } + }, + "assessment_type": 2, + "scope": { + "provider": "aws", + "aws": { + "account_id": "123456789012", + "regions": [ + "eu-central-1" + ] + } + }, + "inventory": { + "resources": [ + { + "type_id": 40, + "type_code": "AWS::EC2::Instance", + "location": "eu-central-1", + "count": 8 + }, + { + "type_id": 12, + "type_code": "AWS::S3::Bucket", + "location": "eu-central-1", + "count": 36 + } + ], + "costs": [ + { + "month": "2026-10", + "amount": "1234.56", + "currency": "USD", + "final": true + } + ] + } + }, + "same_as": "minimal inventory" + }, + { + "name": "another sync id, timestamps, collector, catalogue and CI context", + "body": { + "schema_version": "2.0", + "sync": { + "id": "01933bff-0000-7000-8000-000000000001", + "started_at": 1762500000, + "completed_at": 1762500100, + "collector": { + "name": "cloudexit", + "version": "2.3.1", + "mode": "api", + "catalogue": { + "version": "2026-11-02", + "sequence": 41, + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "resource_type_ids": [ + 12, + 40 + ] + } + }, + "ci": { + "provider": "github", + "run_url": "https://github.com/acme/infra/actions/runs/1" + } + }, + "assessment_type": 2, + "scope": { + "provider": "aws", + "aws": { + "account_id": "123456789012", + "regions": [ + "eu-central-1" + ] + } + }, + "inventory": { + "resources": [ + { + "type_id": 12, + "type_code": "AWS::S3::Bucket", + "location": "eu-central-1", + "count": 36 + }, + { + "type_id": 40, + "type_code": "AWS::EC2::Instance", + "location": "eu-central-1", + "count": 8 + } + ], + "costs": [ + { + "month": "2026-10", + "amount": "1234.56", + "currency": "USD", + "final": true + } + ] + } + }, + "same_as": "minimal inventory" + }, + { + "name": "cost amount with a trailing zero and a lower-case currency", + "body": { + "schema_version": "2.0", + "sync": { + "id": "01933b9e-7c2a-7d41-9a51-3c8f0a6e21d4", + "started_at": 1762149604, + "completed_at": 1762149831, + "collector": { + "name": "cloudexit", + "version": "2.0.0", + "mode": "api" + } + }, + "assessment_type": 2, + "scope": { + "provider": "aws", + "aws": { + "account_id": "123456789012", + "regions": [ + "eu-central-1" + ] + } + }, + "inventory": { + "resources": [ + { + "type_id": 12, + "type_code": "AWS::S3::Bucket", + "location": "eu-central-1", + "count": 36 + }, + { + "type_id": 40, + "type_code": "AWS::EC2::Instance", + "location": "eu-central-1", + "count": 8 + } + ], + "costs": [ + { + "month": "2026-10", + "amount": "1234.560", + "currency": "usd", + "final": true + } + ] + } + }, + "same_as": "minimal inventory" + }, + { + "name": "costs omitted equal costs empty", + "body": { + "schema_version": "2.0", + "sync": { + "id": "01933b9e-7c2a-7d41-9a51-3c8f0a6e21d4", + "started_at": 1762149604, + "completed_at": 1762149831, + "collector": { + "name": "cloudexit", + "version": "2.0.0", + "mode": "api" + } + }, + "assessment_type": 2, + "scope": { + "provider": "aws", + "aws": { + "account_id": "123456789012", + "regions": [ + "eu-central-1" + ] + } + }, + "inventory": { + "resources": [ + { + "type_id": 12, + "type_code": "AWS::S3::Bucket", + "location": "eu-central-1", + "count": 36 + }, + { + "type_id": 40, + "type_code": "AWS::EC2::Instance", + "location": "eu-central-1", + "count": 8 + } + ] + } + }, + "same_as": "empty costs" + }, + { + "name": "empty costs", + "body": { + "schema_version": "2.0", + "sync": { + "id": "01933b9e-7c2a-7d41-9a51-3c8f0a6e21d4", + "started_at": 1762149604, + "completed_at": 1762149831, + "collector": { + "name": "cloudexit", + "version": "2.0.0", + "mode": "api" + } + }, + "assessment_type": 2, + "scope": { + "provider": "aws", + "aws": { + "account_id": "123456789012", + "regions": [ + "eu-central-1" + ] + } + }, + "inventory": { + "resources": [ + { + "type_id": 12, + "type_code": "AWS::S3::Bucket", + "location": "eu-central-1", + "count": 36 + }, + { + "type_id": 40, + "type_code": "AWS::EC2::Instance", + "location": "eu-central-1", + "count": 8 + } + ], + "costs": [] + } + }, + "canonical_json": "{\"assessment_type\":2,\"inventory\":{\"costs\":[],\"egress\":{\"collected\":false,\"items\":[],\"names\":\"plain\",\"truncated\":false},\"resources\":[{\"count\":36,\"location\":\"eu-central-1\",\"type_id\":12},{\"count\":8,\"location\":\"eu-central-1\",\"type_id\":40}]},\"scope\":{\"aws\":{\"account_id\":\"123456789012\",\"regions\":[\"eu-central-1\"]},\"provider\":\"aws\"}}", + "sha256": "104f1ac96760970a2b0363e7e745b111fae92627bdb93cd95b7e8df042b36d8d" + }, + { + "name": "egress omitted equals egress not collected", + "body": { + "schema_version": "2.0", + "sync": { + "id": "01933b9e-7c2a-7d41-9a51-3c8f0a6e21d4", + "started_at": 1762149604, + "completed_at": 1762149831, + "collector": { + "name": "cloudexit", + "version": "2.0.0", + "mode": "api" + } + }, + "assessment_type": 2, + "scope": { + "provider": "aws", + "aws": { + "account_id": "123456789012", + "regions": [ + "eu-central-1" + ] + } + }, + "inventory": { + "resources": [ + { + "type_id": 12, + "type_code": "AWS::S3::Bucket", + "location": "eu-central-1", + "count": 36 + }, + { + "type_id": 40, + "type_code": "AWS::EC2::Instance", + "location": "eu-central-1", + "count": 8 + } + ], + "costs": [ + { + "month": "2026-10", + "amount": "1234.56", + "currency": "USD", + "final": true + } + ], + "egress": { + "collected": false + } + } + }, + "same_as": "egress omitted" + }, + { + "name": "egress omitted", + "body": { + "schema_version": "2.0", + "sync": { + "id": "01933b9e-7c2a-7d41-9a51-3c8f0a6e21d4", + "started_at": 1762149604, + "completed_at": 1762149831, + "collector": { + "name": "cloudexit", + "version": "2.0.0", + "mode": "api" + } + }, + "assessment_type": 2, + "scope": { + "provider": "aws", + "aws": { + "account_id": "123456789012", + "regions": [ + "eu-central-1" + ] + } + }, + "inventory": { + "resources": [ + { + "type_id": 12, + "type_code": "AWS::S3::Bucket", + "location": "eu-central-1", + "count": 36 + }, + { + "type_id": 40, + "type_code": "AWS::EC2::Instance", + "location": "eu-central-1", + "count": 8 + } + ], + "costs": [ + { + "month": "2026-10", + "amount": "1234.56", + "currency": "USD", + "final": true + } + ] + } + }, + "canonical_json": "{\"assessment_type\":2,\"inventory\":{\"costs\":[{\"amount\":\"1234.56\",\"currency\":\"USD\",\"final\":true,\"month\":\"2026-10\"}],\"egress\":{\"collected\":false,\"items\":[],\"names\":\"plain\",\"truncated\":false},\"resources\":[{\"count\":36,\"location\":\"eu-central-1\",\"type_id\":12},{\"count\":8,\"location\":\"eu-central-1\",\"type_id\":40}]},\"scope\":{\"aws\":{\"account_id\":\"123456789012\",\"regions\":[\"eu-central-1\"]},\"provider\":\"aws\"}}", + "sha256": "27ab581110b19cf629481e644386ded634aa3238e243eddb169e1763246562b8" + }, + { + "name": "duplicate resource items are merged", + "body": { + "schema_version": "2.0", + "sync": { + "id": "01933b9e-7c2a-7d41-9a51-3c8f0a6e21d4", + "started_at": 1762149604, + "completed_at": 1762149831, + "collector": { + "name": "cloudexit", + "version": "2.0.0", + "mode": "api" + } + }, + "assessment_type": 2, + "scope": { + "provider": "aws", + "aws": { + "account_id": "123456789012", + "regions": [ + "eu-central-1" + ] + } + }, + "inventory": { + "resources": [ + { + "type_id": 40, + "location": "eu-central-1", + "count": 3 + }, + { + "type_id": 12, + "location": "eu-central-1", + "count": 30 + }, + { + "type_id": 40, + "type_code": "AWS::EC2::Instance", + "location": "eu-central-1", + "count": 5 + }, + { + "type_id": 12, + "location": "eu-central-1", + "count": 6 + } + ], + "costs": [ + { + "month": "2026-10", + "amount": "1234.56", + "currency": "USD", + "final": true + } + ] + } + }, + "same_as": "merged resources" + }, + { + "name": "merged resources", + "body": { + "schema_version": "2.0", + "sync": { + "id": "01933b9e-7c2a-7d41-9a51-3c8f0a6e21d4", + "started_at": 1762149604, + "completed_at": 1762149831, + "collector": { + "name": "cloudexit", + "version": "2.0.0", + "mode": "api" + } + }, + "assessment_type": 2, + "scope": { + "provider": "aws", + "aws": { + "account_id": "123456789012", + "regions": [ + "eu-central-1" + ] + } + }, + "inventory": { + "resources": [ + { + "type_id": 12, + "location": "eu-central-1", + "count": 36 + }, + { + "type_id": 40, + "location": "eu-central-1", + "count": 8 + } + ], + "costs": [ + { + "month": "2026-10", + "amount": "1234.56", + "currency": "USD", + "final": true + } + ] + } + }, + "canonical_json": "{\"assessment_type\":2,\"inventory\":{\"costs\":[{\"amount\":\"1234.56\",\"currency\":\"USD\",\"final\":true,\"month\":\"2026-10\"}],\"egress\":{\"collected\":false,\"items\":[],\"names\":\"plain\",\"truncated\":false},\"resources\":[{\"count\":36,\"location\":\"eu-central-1\",\"type_id\":12},{\"count\":8,\"location\":\"eu-central-1\",\"type_id\":40}]},\"scope\":{\"aws\":{\"account_id\":\"123456789012\",\"regions\":[\"eu-central-1\"]},\"provider\":\"aws\"}}", + "sha256": "27ab581110b19cf629481e644386ded634aa3238e243eddb169e1763246562b8" + }, + { + "name": "unknown fields are ignored", + "body": { + "schema_version": "2.0", + "sync": { + "id": "01933b9e-7c2a-7d41-9a51-3c8f0a6e21d4", + "started_at": 1762149604, + "completed_at": 1762149831, + "collector": { + "name": "cloudexit", + "version": "2.0.0", + "mode": "api" + } + }, + "assessment_type": 2, + "scope": { + "provider": "aws", + "aws": { + "account_id": "123456789012", + "regions": [ + "eu-central-1" + ], + "profile": "ci" + } + }, + "inventory": { + "resources": [ + { + "type_id": 12, + "type_code": "AWS::S3::Bucket", + "location": "eu-central-1", + "count": 36, + "note": "ignored" + }, + { + "type_id": 40, + "type_code": "AWS::EC2::Instance", + "location": "eu-central-1", + "count": 8 + } + ], + "costs": [ + { + "month": "2026-10", + "amount": "1234.56", + "currency": "USD", + "final": true + } + ], + "extra": 1 + } + }, + "same_as": "minimal inventory" + }, + { + "name": "regions in another order and repeated", + "body": { + "schema_version": "2.0", + "sync": { + "id": "01933b9e-7c2a-7d41-9a51-3c8f0a6e21d4", + "started_at": 1762149604, + "completed_at": 1762149831, + "collector": { + "name": "cloudexit", + "version": "2.0.0", + "mode": "api" + } + }, + "assessment_type": 2, + "scope": { + "provider": "aws", + "aws": { + "account_id": "123456789012", + "regions": [ + "us-east-1", + "eu-west-1", + "us-east-1" + ] + } + }, + "inventory": { + "resources": [ + { + "type_id": 12, + "type_code": "AWS::S3::Bucket", + "location": "eu-central-1", + "count": 36 + }, + { + "type_id": 40, + "type_code": "AWS::EC2::Instance", + "location": "eu-central-1", + "count": 8 + } + ], + "costs": [ + { + "month": "2026-10", + "amount": "1234.56", + "currency": "USD", + "final": true + } + ] + } + }, + "same_as": "regions sorted" + }, + { + "name": "regions sorted", + "body": { + "schema_version": "2.0", + "sync": { + "id": "01933b9e-7c2a-7d41-9a51-3c8f0a6e21d4", + "started_at": 1762149604, + "completed_at": 1762149831, + "collector": { + "name": "cloudexit", + "version": "2.0.0", + "mode": "api" + } + }, + "assessment_type": 2, + "scope": { + "provider": "aws", + "aws": { + "account_id": "123456789012", + "regions": [ + "eu-west-1", + "us-east-1" + ] + } + }, + "inventory": { + "resources": [ + { + "type_id": 12, + "type_code": "AWS::S3::Bucket", + "location": "eu-central-1", + "count": 36 + }, + { + "type_id": 40, + "type_code": "AWS::EC2::Instance", + "location": "eu-central-1", + "count": 8 + } + ], + "costs": [ + { + "month": "2026-10", + "amount": "1234.56", + "currency": "USD", + "final": true + } + ] + } + }, + "canonical_json": "{\"assessment_type\":2,\"inventory\":{\"costs\":[{\"amount\":\"1234.56\",\"currency\":\"USD\",\"final\":true,\"month\":\"2026-10\"}],\"egress\":{\"collected\":false,\"items\":[],\"names\":\"plain\",\"truncated\":false},\"resources\":[{\"count\":36,\"location\":\"eu-central-1\",\"type_id\":12},{\"count\":8,\"location\":\"eu-central-1\",\"type_id\":40}]},\"scope\":{\"aws\":{\"account_id\":\"123456789012\",\"regions\":[\"eu-west-1\",\"us-east-1\"]},\"provider\":\"aws\"}}", + "sha256": "a30728218d49aa16271a64dcfd8a10b51bfa5491b63fa8490f667a5984f3fdba" + }, + { + "name": "azure ids and resource group in another case", + "body": { + "schema_version": "2.0", + "sync": { + "id": "01933b9e-7c2a-7d41-9a51-3c8f0a6e21d4", + "started_at": 1762149604, + "completed_at": 1762149831, + "collector": { + "name": "cloudexit", + "version": "2.0.0", + "mode": "api" + } + }, + "assessment_type": 2, + "scope": { + "provider": "azure", + "azure": { + "tenant_id": "0B5F9A5E-7A64-4C0E-9A3A-5F5F1B8D2C11", + "subscription_id": "6F1C2F1E-92D1-4A6E-8C1E-0A3C1D2E4B5A", + "resource_group": "Prod-RG" + } + }, + "inventory": { + "resources": [ + { + "type_id": 12, + "type_code": "AWS::S3::Bucket", + "location": "eu-central-1", + "count": 36 + }, + { + "type_id": 40, + "type_code": "AWS::EC2::Instance", + "location": "eu-central-1", + "count": 8 + } + ], + "costs": [ + { + "month": "2026-10", + "amount": "1234.56", + "currency": "USD", + "final": true + } + ] + } + }, + "same_as": "azure lower case" + }, + { + "name": "azure lower case", + "body": { + "schema_version": "2.0", + "sync": { + "id": "01933b9e-7c2a-7d41-9a51-3c8f0a6e21d4", + "started_at": 1762149604, + "completed_at": 1762149831, + "collector": { + "name": "cloudexit", + "version": "2.0.0", + "mode": "api" + } + }, + "assessment_type": 2, + "scope": { + "provider": "azure", + "azure": { + "tenant_id": "0b5f9a5e-7a64-4c0e-9a3a-5f5f1b8d2c11", + "subscription_id": "6f1c2f1e-92d1-4a6e-8c1e-0a3c1d2e4b5a", + "resource_group": "prod-rg" + } + }, + "inventory": { + "resources": [ + { + "type_id": 12, + "type_code": "AWS::S3::Bucket", + "location": "eu-central-1", + "count": 36 + }, + { + "type_id": 40, + "type_code": "AWS::EC2::Instance", + "location": "eu-central-1", + "count": 8 + } + ], + "costs": [ + { + "month": "2026-10", + "amount": "1234.56", + "currency": "USD", + "final": true + } + ] + } + }, + "canonical_json": "{\"assessment_type\":2,\"inventory\":{\"costs\":[{\"amount\":\"1234.56\",\"currency\":\"USD\",\"final\":true,\"month\":\"2026-10\"}],\"egress\":{\"collected\":false,\"items\":[],\"names\":\"plain\",\"truncated\":false},\"resources\":[{\"count\":36,\"location\":\"eu-central-1\",\"type_id\":12},{\"count\":8,\"location\":\"eu-central-1\",\"type_id\":40}]},\"scope\":{\"azure\":{\"resource_group\":\"prod-rg\",\"subscription_id\":\"6f1c2f1e-92d1-4a6e-8c1e-0a3c1d2e4b5a\",\"tenant_id\":\"0b5f9a5e-7a64-4c0e-9a3a-5f5f1b8d2c11\"},\"provider\":\"azure\"}}", + "sha256": "5298b115143a7790880726569bcc8bc7eb03a4752c9f29a6b1d16eb7ca900af4" + }, + { + "name": "egress with names, tiers and unicode", + "body": { + "schema_version": "2.0", + "sync": { + "id": "01933b9e-7c2a-7d41-9a51-3c8f0a6e21d4", + "started_at": 1762149604, + "completed_at": 1762149831, + "collector": { + "name": "cloudexit", + "version": "2.0.0", + "mode": "api" + } + }, + "assessment_type": 3, + "scope": { + "provider": "aws", + "aws": { + "account_id": "123456789012", + "regions": [ + "eu-central-1" + ] + } + }, + "inventory": { + "resources": [ + { + "type_id": 12, + "type_code": "AWS::S3::Bucket", + "location": "eu-central-1", + "count": 36 + }, + { + "type_id": 40, + "type_code": "AWS::EC2::Instance", + "location": "eu-central-1", + "count": 8 + } + ], + "costs": [ + { + "month": "2026-10", + "amount": "1234.56", + "currency": "USD", + "final": true + } + ], + "egress": { + "collected": true, + "truncated": false, + "names": "plain", + "items": [ + { + "type_id": 12, + "name": "acme-logs", + "size_bytes": 5368709120, + "flags": [ + "b-flag", + "a-flag" + ], + "notes": [], + "tiers": [ + { + "tier": "STANDARD", + "size_bytes": 5368709000, + "is_archive": false + }, + { + "tier": "GLACIER", + "size_bytes": 120, + "is_archive": true + } + ] + }, + { + "type_id": 12, + "name": "Ärchiv 日本 🙂", + "size_bytes": null, + "flags": [], + "notes": [ + "shared blocks" + ] + } + ] + } + } + }, + "canonical_json": "{\"assessment_type\":3,\"inventory\":{\"costs\":[{\"amount\":\"1234.56\",\"currency\":\"USD\",\"final\":true,\"month\":\"2026-10\"}],\"egress\":{\"collected\":true,\"items\":[{\"flags\":[\"a-flag\",\"b-flag\"],\"name\":\"acme-logs\",\"notes\":[],\"size_bytes\":5368709120,\"tiers\":[{\"is_archive\":true,\"size_bytes\":120,\"tier\":\"GLACIER\"},{\"is_archive\":false,\"size_bytes\":5368709000,\"tier\":\"STANDARD\"}],\"type_id\":12},{\"flags\":[],\"name\":\"Ärchiv 日本 🙂\",\"notes\":[\"shared blocks\"],\"size_bytes\":null,\"tiers\":[],\"type_id\":12}],\"names\":\"plain\",\"truncated\":false},\"resources\":[{\"count\":36,\"location\":\"eu-central-1\",\"type_id\":12},{\"count\":8,\"location\":\"eu-central-1\",\"type_id\":40}]},\"scope\":{\"aws\":{\"account_id\":\"123456789012\",\"regions\":[\"eu-central-1\"]},\"provider\":\"aws\"}}", + "sha256": "c016a343affea33e1fb77e91082949d6436dc29155ee5c245775215d3717d211" + }, + { + "name": "egress items, flags and tiers in another order", + "body": { + "schema_version": "2.0", + "sync": { + "id": "01933b9e-7c2a-7d41-9a51-3c8f0a6e21d4", + "started_at": 1762149604, + "completed_at": 1762149831, + "collector": { + "name": "cloudexit", + "version": "2.0.0", + "mode": "api" + } + }, + "assessment_type": 3, + "scope": { + "provider": "aws", + "aws": { + "account_id": "123456789012", + "regions": [ + "eu-central-1" + ] + } + }, + "inventory": { + "resources": [ + { + "type_id": 12, + "type_code": "AWS::S3::Bucket", + "location": "eu-central-1", + "count": 36 + }, + { + "type_id": 40, + "type_code": "AWS::EC2::Instance", + "location": "eu-central-1", + "count": 8 + } + ], + "costs": [ + { + "month": "2026-10", + "amount": "1234.56", + "currency": "USD", + "final": true + } + ], + "egress": { + "collected": true, + "truncated": false, + "names": "plain", + "items": [ + { + "type_id": 12, + "name": "Ärchiv 日本 🙂", + "size_bytes": null, + "flags": [], + "notes": [ + "shared blocks" + ] + }, + { + "type_id": 12, + "name": "acme-logs", + "size_bytes": 5368709120, + "flags": [ + "a-flag", + "b-flag" + ], + "notes": [], + "tiers": [ + { + "tier": "GLACIER", + "size_bytes": 120, + "is_archive": true + }, + { + "tier": "STANDARD", + "size_bytes": 5368709000, + "is_archive": false + } + ] + } + ] + } + } + }, + "same_as": "egress with names, tiers and unicode" + }, + { + "name": "a different count", + "body": { + "schema_version": "2.0", + "sync": { + "id": "01933b9e-7c2a-7d41-9a51-3c8f0a6e21d4", + "started_at": 1762149604, + "completed_at": 1762149831, + "collector": { + "name": "cloudexit", + "version": "2.0.0", + "mode": "api" + } + }, + "assessment_type": 2, + "scope": { + "provider": "aws", + "aws": { + "account_id": "123456789012", + "regions": [ + "eu-central-1" + ] + } + }, + "inventory": { + "resources": [ + { + "type_id": 12, + "type_code": "AWS::S3::Bucket", + "location": "eu-central-1", + "count": 37 + }, + { + "type_id": 40, + "type_code": "AWS::EC2::Instance", + "location": "eu-central-1", + "count": 8 + } + ], + "costs": [ + { + "month": "2026-10", + "amount": "1234.56", + "currency": "USD", + "final": true + } + ] + } + }, + "differs_from": "minimal inventory" + }, + { + "name": "a different assessment type", + "body": { + "schema_version": "2.0", + "sync": { + "id": "01933b9e-7c2a-7d41-9a51-3c8f0a6e21d4", + "started_at": 1762149604, + "completed_at": 1762149831, + "collector": { + "name": "cloudexit", + "version": "2.0.0", + "mode": "api" + } + }, + "assessment_type": 3, + "scope": { + "provider": "aws", + "aws": { + "account_id": "123456789012", + "regions": [ + "eu-central-1" + ] + } + }, + "inventory": { + "resources": [ + { + "type_id": 12, + "type_code": "AWS::S3::Bucket", + "location": "eu-central-1", + "count": 36 + }, + { + "type_id": 40, + "type_code": "AWS::EC2::Instance", + "location": "eu-central-1", + "count": 8 + } + ], + "costs": [ + { + "month": "2026-10", + "amount": "1234.56", + "currency": "USD", + "final": true + } + ] + } + }, + "differs_from": "minimal inventory" + }, + { + "name": "a different scope", + "body": { + "schema_version": "2.0", + "sync": { + "id": "01933b9e-7c2a-7d41-9a51-3c8f0a6e21d4", + "started_at": 1762149604, + "completed_at": 1762149831, + "collector": { + "name": "cloudexit", + "version": "2.0.0", + "mode": "api" + } + }, + "assessment_type": 2, + "scope": { + "provider": "aws", + "aws": { + "account_id": "123456789012", + "regions": [ + "eu-west-1" + ] + } + }, + "inventory": { + "resources": [ + { + "type_id": 12, + "type_code": "AWS::S3::Bucket", + "location": "eu-central-1", + "count": 36 + }, + { + "type_id": 40, + "type_code": "AWS::EC2::Instance", + "location": "eu-central-1", + "count": 8 + } + ], + "costs": [ + { + "month": "2026-10", + "amount": "1234.56", + "currency": "USD", + "final": true + } + ] + } + }, + "differs_from": "minimal inventory" + }, + { + "name": "a different cost amount", + "body": { + "schema_version": "2.0", + "sync": { + "id": "01933b9e-7c2a-7d41-9a51-3c8f0a6e21d4", + "started_at": 1762149604, + "completed_at": 1762149831, + "collector": { + "name": "cloudexit", + "version": "2.0.0", + "mode": "api" + } + }, + "assessment_type": 2, + "scope": { + "provider": "aws", + "aws": { + "account_id": "123456789012", + "regions": [ + "eu-central-1" + ] + } + }, + "inventory": { + "resources": [ + { + "type_id": 12, + "type_code": "AWS::S3::Bucket", + "location": "eu-central-1", + "count": 36 + }, + { + "type_id": 40, + "type_code": "AWS::EC2::Instance", + "location": "eu-central-1", + "count": 8 + } + ], + "costs": [ + { + "month": "2026-10", + "amount": "1234.57", + "currency": "USD", + "final": true + } + ] + } + }, + "differs_from": "minimal inventory" + }, + { + "name": "an estimated instead of a final cost", + "body": { + "schema_version": "2.0", + "sync": { + "id": "01933b9e-7c2a-7d41-9a51-3c8f0a6e21d4", + "started_at": 1762149604, + "completed_at": 1762149831, + "collector": { + "name": "cloudexit", + "version": "2.0.0", + "mode": "api" + } + }, + "assessment_type": 2, + "scope": { + "provider": "aws", + "aws": { + "account_id": "123456789012", + "regions": [ + "eu-central-1" + ] + } + }, + "inventory": { + "resources": [ + { + "type_id": 12, + "type_code": "AWS::S3::Bucket", + "location": "eu-central-1", + "count": 36 + }, + { + "type_id": 40, + "type_code": "AWS::EC2::Instance", + "location": "eu-central-1", + "count": 8 + } + ], + "costs": [ + { + "month": "2026-10", + "amount": "1234.56", + "currency": "USD", + "final": false + } + ] + } + }, + "differs_from": "minimal inventory" + } + ] +} diff --git a/docs/api-v2/vectors/scope_key.json b/docs/api-v2/vectors/scope_key.json new file mode 100644 index 0000000..723fa08 --- /dev/null +++ b/docs/api-v2/vectors/scope_key.json @@ -0,0 +1,509 @@ +{ + "function": "scope_key(scope) -> str", + "description": "The canonical scope key the host derives from a request's scope. Invalid input raises ValueError (optionally with a pointer attribute, a JSON pointer into scope).", + "rules": [ + "Everything is lower case, without whitespace; a key is at most 255 characters.", + "AWS: aws::. The account id is exactly 12 digits (kept as sent). Regions are lower case and distinct; one region gives that region, two or more regions or '*' give '*'.", + "Azure: azure:::. Tenant and subscription are GUIDs (any case in, lower case out). A missing or null resource group means the whole subscription: '*'. Resource groups are 1 to 90 characters of letters, digits, '_', '(', ')', '-' and '.', cannot end with '.', and are lower-cased.", + "Terraform state: tfstate:. The lineage is a UUID (lower-cased). A tfstate scope has no provider block; the provider is still required.", + "Fields that are not part of the scope are ignored." + ], + "cases": [ + { + "name": "aws, one region", + "scope": { + "provider": "aws", + "aws": { + "account_id": "123456789012", + "regions": [ + "eu-central-1" + ] + } + }, + "expect": { + "scope_key": "aws:123456789012:eu-central-1" + } + }, + { + "name": "aws, two regions are all regions", + "scope": { + "provider": "aws", + "aws": { + "account_id": "123456789012", + "regions": [ + "us-east-1", + "eu-west-1" + ] + } + }, + "expect": { + "scope_key": "aws:123456789012:*" + } + }, + { + "name": "aws, explicit all regions", + "scope": { + "provider": "aws", + "aws": { + "account_id": "123456789012", + "regions": [ + "*" + ] + } + }, + "expect": { + "scope_key": "aws:123456789012:*" + } + }, + { + "name": "aws, the same region twice", + "scope": { + "provider": "aws", + "aws": { + "account_id": "123456789012", + "regions": [ + "eu-central-1", + "eu-central-1" + ] + } + }, + "expect": { + "scope_key": "aws:123456789012:eu-central-1" + } + }, + { + "name": "aws, gov-cloud region", + "scope": { + "provider": "aws", + "aws": { + "account_id": "210987654321", + "regions": [ + "us-gov-west-1" + ] + } + }, + "expect": { + "scope_key": "aws:210987654321:us-gov-west-1" + } + }, + { + "name": "aws, leading zeros in the account id are kept", + "scope": { + "provider": "aws", + "aws": { + "account_id": "000123456789", + "regions": [ + "ap-southeast-2" + ] + } + }, + "expect": { + "scope_key": "aws:000123456789:ap-southeast-2" + } + }, + { + "name": "aws, unknown fields are ignored", + "scope": { + "provider": "aws", + "aws": { + "account_id": "123456789012", + "regions": [ + "eu-central-1" + ] + }, + "note": "ignored" + }, + "expect": { + "scope_key": "aws:123456789012:eu-central-1" + } + }, + { + "name": "azure, resource group in mixed case", + "scope": { + "provider": "azure", + "azure": { + "tenant_id": "0b5f9a5e-7a64-4c0e-9a3a-5f5f1b8d2c11", + "subscription_id": "6f1c2f1e-92d1-4a6e-8c1e-0a3c1d2e4b5a", + "resource_group": "Prod-RG_Core" + } + }, + "expect": { + "scope_key": "azure:0b5f9a5e-7a64-4c0e-9a3a-5f5f1b8d2c11:6f1c2f1e-92d1-4a6e-8c1e-0a3c1d2e4b5a:prod-rg_core" + } + }, + { + "name": "azure, whole subscription (no resource group)", + "scope": { + "provider": "azure", + "azure": { + "tenant_id": "0b5f9a5e-7a64-4c0e-9a3a-5f5f1b8d2c11", + "subscription_id": "6f1c2f1e-92d1-4a6e-8c1e-0a3c1d2e4b5a" + } + }, + "expect": { + "scope_key": "azure:0b5f9a5e-7a64-4c0e-9a3a-5f5f1b8d2c11:6f1c2f1e-92d1-4a6e-8c1e-0a3c1d2e4b5a:*" + } + }, + { + "name": "azure, whole subscription (null resource group)", + "scope": { + "provider": "azure", + "azure": { + "tenant_id": "0b5f9a5e-7a64-4c0e-9a3a-5f5f1b8d2c11", + "subscription_id": "6f1c2f1e-92d1-4a6e-8c1e-0a3c1d2e4b5a", + "resource_group": null + } + }, + "expect": { + "scope_key": "azure:0b5f9a5e-7a64-4c0e-9a3a-5f5f1b8d2c11:6f1c2f1e-92d1-4a6e-8c1e-0a3c1d2e4b5a:*" + } + }, + { + "name": "azure, upper-case GUIDs are lowered", + "scope": { + "provider": "azure", + "azure": { + "tenant_id": "0B5F9A5E-7A64-4C0E-9A3A-5F5F1B8D2C11", + "subscription_id": "6F1C2F1E-92D1-4A6E-8C1E-0A3C1D2E4B5A", + "resource_group": "RG" + } + }, + "expect": { + "scope_key": "azure:0b5f9a5e-7a64-4c0e-9a3a-5f5f1b8d2c11:6f1c2f1e-92d1-4a6e-8c1e-0a3c1d2e4b5a:rg" + } + }, + { + "name": "azure, resource group with parentheses and a period", + "scope": { + "provider": "azure", + "azure": { + "tenant_id": "0b5f9a5e-7a64-4c0e-9a3a-5f5f1b8d2c11", + "subscription_id": "6f1c2f1e-92d1-4a6e-8c1e-0a3c1d2e4b5a", + "resource_group": "App.Rg(1)" + } + }, + "expect": { + "scope_key": "azure:0b5f9a5e-7a64-4c0e-9a3a-5f5f1b8d2c11:6f1c2f1e-92d1-4a6e-8c1e-0a3c1d2e4b5a:app.rg(1)" + } + }, + { + "name": "tfstate, lineage in upper case", + "scope": { + "provider": "aws", + "tfstate": { + "lineage": "7B1E5C0A-3D2F-4A8B-9C6D-1E2F3A4B5C6D", + "serial": 42 + } + }, + "expect": { + "scope_key": "tfstate:7b1e5c0a-3d2f-4a8b-9c6d-1e2f3a4b5c6d" + } + }, + { + "name": "tfstate, azure provider", + "scope": { + "provider": "azure", + "tfstate": { + "lineage": "7b1e5c0a-3d2f-4a8b-9c6d-1e2f3a4b5c6d" + } + }, + "expect": { + "scope_key": "tfstate:7b1e5c0a-3d2f-4a8b-9c6d-1e2f3a4b5c6d" + } + }, + { + "name": "not an object", + "scope": "aws:123456789012:eu-central-1", + "expect": { + "error": { + "pointer": "" + } + } + }, + { + "name": "missing provider", + "scope": { + "aws": { + "account_id": "123456789012", + "regions": [ + "eu-central-1" + ] + } + }, + "expect": { + "error": { + "pointer": "/provider" + } + } + }, + { + "name": "unsupported provider", + "scope": { + "provider": "gcp", + "gcp": {} + }, + "expect": { + "error": { + "pointer": "/provider" + } + } + }, + { + "name": "aws, provider block missing", + "scope": { + "provider": "aws" + }, + "expect": { + "error": { + "pointer": "/aws" + } + } + }, + { + "name": "aws, block of the other provider", + "scope": { + "provider": "aws", + "azure": { + "tenant_id": "0b5f9a5e-7a64-4c0e-9a3a-5f5f1b8d2c11", + "subscription_id": "6f1c2f1e-92d1-4a6e-8c1e-0a3c1d2e4b5a" + } + }, + "expect": { + "error": { + "pointer": "/aws" + } + } + }, + { + "name": "aws, account id too short", + "scope": { + "provider": "aws", + "aws": { + "account_id": "12345678901", + "regions": [ + "eu-central-1" + ] + } + }, + "expect": { + "error": { + "pointer": "/aws/account_id" + } + } + }, + { + "name": "aws, account id with letters", + "scope": { + "provider": "aws", + "aws": { + "account_id": "12345678901a", + "regions": [ + "eu-central-1" + ] + } + }, + "expect": { + "error": { + "pointer": "/aws/account_id" + } + } + }, + { + "name": "aws, account id as a number", + "scope": { + "provider": "aws", + "aws": { + "account_id": 123456789012, + "regions": [ + "eu-central-1" + ] + } + }, + "expect": { + "error": { + "pointer": "/aws/account_id" + } + } + }, + { + "name": "aws, no regions", + "scope": { + "provider": "aws", + "aws": { + "account_id": "123456789012", + "regions": [] + } + }, + "expect": { + "error": { + "pointer": "/aws/regions" + } + } + }, + { + "name": "aws, region in upper case", + "scope": { + "provider": "aws", + "aws": { + "account_id": "123456789012", + "regions": [ + "EU-CENTRAL-1" + ] + } + }, + "expect": { + "error": { + "pointer": "/aws/regions/0" + } + } + }, + { + "name": "aws, second region invalid", + "scope": { + "provider": "aws", + "aws": { + "account_id": "123456789012", + "regions": [ + "eu-central-1", + "mars" + ] + } + }, + "expect": { + "error": { + "pointer": "/aws/regions/1" + } + } + }, + { + "name": "azure, tenant is not a GUID", + "scope": { + "provider": "azure", + "azure": { + "tenant_id": "not-a-guid", + "subscription_id": "6f1c2f1e-92d1-4a6e-8c1e-0a3c1d2e4b5a", + "resource_group": "rg" + } + }, + "expect": { + "error": { + "pointer": "/azure/tenant_id" + } + } + }, + { + "name": "azure, subscription is not a GUID", + "scope": { + "provider": "azure", + "azure": { + "tenant_id": "0b5f9a5e-7a64-4c0e-9a3a-5f5f1b8d2c11", + "subscription_id": "1234", + "resource_group": "rg" + } + }, + "expect": { + "error": { + "pointer": "/azure/subscription_id" + } + } + }, + { + "name": "azure, resource group with a space", + "scope": { + "provider": "azure", + "azure": { + "tenant_id": "0b5f9a5e-7a64-4c0e-9a3a-5f5f1b8d2c11", + "subscription_id": "6f1c2f1e-92d1-4a6e-8c1e-0a3c1d2e4b5a", + "resource_group": "my rg" + } + }, + "expect": { + "error": { + "pointer": "/azure/resource_group" + } + } + }, + { + "name": "azure, resource group ends with a period", + "scope": { + "provider": "azure", + "azure": { + "tenant_id": "0b5f9a5e-7a64-4c0e-9a3a-5f5f1b8d2c11", + "subscription_id": "6f1c2f1e-92d1-4a6e-8c1e-0a3c1d2e4b5a", + "resource_group": "rg." + } + }, + "expect": { + "error": { + "pointer": "/azure/resource_group" + } + } + }, + { + "name": "azure, resource group longer than 90 characters", + "scope": { + "provider": "azure", + "azure": { + "tenant_id": "0b5f9a5e-7a64-4c0e-9a3a-5f5f1b8d2c11", + "subscription_id": "6f1c2f1e-92d1-4a6e-8c1e-0a3c1d2e4b5a", + "resource_group": "rrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr" + } + }, + "expect": { + "error": { + "pointer": "/azure/resource_group" + } + } + }, + { + "name": "azure, empty resource group", + "scope": { + "provider": "azure", + "azure": { + "tenant_id": "0b5f9a5e-7a64-4c0e-9a3a-5f5f1b8d2c11", + "subscription_id": "6f1c2f1e-92d1-4a6e-8c1e-0a3c1d2e4b5a", + "resource_group": "" + } + }, + "expect": { + "error": { + "pointer": "/azure/resource_group" + } + } + }, + { + "name": "tfstate, lineage is not a UUID", + "scope": { + "provider": "aws", + "tfstate": { + "lineage": "lineage-1" + } + }, + "expect": { + "error": { + "pointer": "/tfstate/lineage" + } + } + }, + { + "name": "tfstate together with a provider block", + "scope": { + "provider": "aws", + "aws": { + "account_id": "123456789012", + "regions": [ + "eu-central-1" + ] + }, + "tfstate": { + "lineage": "7b1e5c0a-3d2f-4a8b-9c6d-1e2f3a4b5c6d" + } + }, + "expect": { + "error": { + "pointer": "/aws" + } + } + } + ] +} diff --git a/docs/images/AWS_Config.png b/docs/images/AWS_Config.png deleted file mode 100644 index ef10ed9..0000000 Binary files a/docs/images/AWS_Config.png and /dev/null differ diff --git a/docs/images/AWS_Profile.png b/docs/images/AWS_Profile.png deleted file mode 100644 index 59a4e10..0000000 Binary files a/docs/images/AWS_Profile.png and /dev/null differ diff --git a/docs/images/Azure_CLI.png b/docs/images/Azure_CLI.png deleted file mode 100644 index d3d7c3d..0000000 Binary files a/docs/images/Azure_CLI.png and /dev/null differ diff --git a/docs/images/Azure_Config.png b/docs/images/Azure_Config.png deleted file mode 100644 index 10d0839..0000000 Binary files a/docs/images/Azure_Config.png and /dev/null differ diff --git a/docs/images/Main.png b/docs/images/Main.png deleted file mode 100644 index 5488d50..0000000 Binary files a/docs/images/Main.png and /dev/null differ diff --git a/docs/images/Report.png b/docs/images/Report.png deleted file mode 100644 index 5c6daf9..0000000 Binary files a/docs/images/Report.png and /dev/null differ diff --git a/docs/migration-2.0.md b/docs/migration-2.0.md new file mode 100644 index 0000000..bf74561 --- /dev/null +++ b/docs/migration-2.0.md @@ -0,0 +1,254 @@ +# Migrating from 1.x to 2.0 + +2.0 changes where settings come from, how uploads work and what a run writes. +Nothing about your cloud credentials or the required read-only permissions +changes. A run with no platform configured still works fully offline. + +Read [Before you start](#before-you-start), then work through the steps that +apply to you. The full list of changes is in [CHANGELOG.md](../CHANGELOG.md). + +## Before you start + +- 2.0 needs Python 3.12 or newer. +- Nothing is read from `config.py` or from the `HOST` / `KEY` environment + variables any more. If you use either, do [step 2](#2-move-your-settings) + before your first 2.0 run. +- If you upload to a platform, you need a new API key: see + [step 5](#5-get-a-new-platform-key). + +## 1. Install into a new virtual environment + +**Recommended.** 1.x and 2.0 pin different dependency versions, and `pip` leaves +the old ones in place when it upgrades, which can leave a partially upgraded +Azure SDK behind. + +```bash +git clone --branch v2.0.0 https://github.com/escapecloud/cloudexit.git cloudexit-2.0 +cd cloudexit-2.0 +python3 -m venv .venv && source .venv/bin/activate +pip install . +cloudexit --version +``` + +To run from the checkout without installing, use +`pip install -r requirements.txt` and `python3 main.py` instead. + +### Upgrading in place + +If you upgrade over an existing environment and a run stops with + +``` +The Azure SDK in this environment is incomplete (cannot import azure.mgmt..). +``` + +reinstall the package the message names, pinned as this installation asks for +it: + +```bash +pip install --force-reinstall --no-deps 'azure-mgmt-resource==' +``` + +The message always names the exact package and version. If several are affected, +a new virtual environment is faster than repairing them one at a time. + +## 2. Move your settings + +`config.py` is **removed** in 2.0, not deprecated: it is never imported, opened +or parsed. The same is true of the `HOST` and `KEY` environment variables. When +one of them is still present and nothing else supplies a host or a key, the run +logs one warning telling you where to move the values — their contents are never +read or logged. + +A 1.x `config.py` looked like this: + +```python +HOST = "platform.example.com" +KEY = "your-platform-key" +``` + +Pick one of the two replacements. + +**A config file** (`cloudexit config path` prints the location — +`~/.config/cloudexit/config.toml` on Linux and macOS, +`%APPDATA%\cloudexit\config.toml` on Windows): + +```toml +[default] +host = "platform.example.com" +api_key_file = "~/.config/cloudexit/api-key" +upload = "auto" +``` + +Keep the key in its own file rather than in the config file: + +```bash +mkdir -p ~/.config/cloudexit +install -m 600 /dev/null ~/.config/cloudexit/api-key +printf '%s' 'YOUR-NEW-KEY' > ~/.config/cloudexit/api-key +chmod 600 ~/.config/cloudexit/config.toml +``` + +**Environment variables**, which suit CI better: + +```bash +export CLOUDEXIT_HOST=platform.example.com +export CLOUDEXIT_API_KEY_FILE=/run/secrets/cloudexit-key +# or, when a file is not possible: +export CLOUDEXIT_API_KEY=… +``` + +Precedence is flags, then `CLOUDEXIT_*`, then the config file. Check the result +without running an assessment: + +```bash +cloudexit config show # every value and where it came from; keys are masked +``` + +Then delete the old `config.py` and unset `HOST` and `KEY`, so the warning stops +and nothing is left lying around with a key in it. + +> `HOST` and `KEY` are generic names that other tools also set. In 1.x, having +> both set was enough to make cloudexit treat them as a platform and upload to +> it. That is the main reason they are gone. + +## 3. Review your exit codes + +1.x used a small range of exit codes. 2.0 reserves 10–15 for upload outcomes and +130 for Ctrl+C, and the local report is always written before an upload code is +returned. + +| Code | Meaning | +| --- | --- | +| 0 | Success. | +| 1 | An unexpected error. | +| 2 | A configuration problem, including a half-configured platform. | +| 3–9 | Credentials, permissions, inventory, costs, risk, report, egress. | +| 10–15 | Upload outcomes: rejected, key, unreachable, quota, target, unsupported. | +| 130 | Interrupted with Ctrl+C. | + +The full table is in the +[documentation](https://cloudexit.escapecloud.io/reference/exit-codes.html). Two +consequences for pipelines: + +- A job that treated any non-zero exit as "the assessment failed" now also fails + on an upload problem, even though the report exists. Decide per code which + ones should fail the job. +- A job that must upload should pass `--upload required`, so a missing key fails + it before any cloud API is called rather than producing a quiet offline report. + +## 4. Expect the Basic report locally + +The local report is always the **Basic** report, computed on your machine, +whether or not a platform is configured. Scoring and the richer reports live on +the platform; after an upload the local report records the link to them and +nothing else. + +Each run writes: + +| File | What it is | +| --- | --- | +| `index.html` | One self-contained HTML report. Fonts, icons and styles are bundled and nothing is fetched from the network. | +| `report.pdf` | The Basic Executive Summary. | +| `egress.pdf` | Written when `--egress` ran. | +| `raw_data/sync.json` | The sync document (see [step 6](#6-payloadjson-becomes-syncjson)). | +| `raw_data/assessment_result.json` | The local results as JSON. | +| `raw_data/upload.json` | Written when an upload was attempted. | +| `run.log` | The run's log. | + +There is no separate `egress.html` any more: the Egress & Data Landscape section +is part of `index.html`, and `egress.pdf` is its printable form. If you archived +`egress.html`, archive `index.html` instead. + +## 5. Get a new platform key + +Uploads use API v2, and 1.x keys do not work with it. Create an **assessment +key** on the assessment's page in the platform and use it for every run of that +scope — one key per assessment. + +An **account key** is the other kind: it can create assessments, so +`cloudexit upload` with one needs `--assessment-id`, or `--name` and +`--exit-strategy`, to say where a sync belongs. + +Check a key before you rely on it: + +```bash +cloudexit status # key kind, organisation, assessment, scopes, expiry, TLS chain +``` + +`cloudexit status` warns from 14 days before a key expires. If your platform +uses a private certificate authority, point `--ca-bundle` or +`CLOUDEXIT_CA_BUNDLE` at the bundle; verification cannot be turned off. + +## 6. `payload.json` becomes `sync.json` + +1.x wrote `payload.json`. 2.0 writes `raw_data/sync.json` in the shape defined by +the vendored API v2 definition in [`docs/api-v2/`](./api-v2/). It holds the +scope, the resource inventory, the monthly costs and, when `--egress` ran, the +egress estimate. It never contains credentials and is built with no network +connection, so you can read it before anything is uploaded. + +```bash +cloudexit aws --config config.json --dry-run # local report plus sync.json, no upload +cloudexit aws --config config.json --emit-payload out/ # also write out/.json +cloudexit upload raw_data/sync.json # upload it later, or from another machine +``` + +If you had a script that read `payload.json`, point it at `raw_data/sync.json` +and check the field names against the definition in `docs/api-v2/` — the shape +is not the 1.x one. + +## 7. Stop updating the dataset by hand + +In 1.x the assessment dataset was a file in the checkout, under `datasets/`, that +you refreshed by pulling the repository. + +2.0 keeps a **catalogue** in your user data directory and checks for a newer one +at the start of each run, using a signed manifest. If the check fails, the run +continues with the installed catalogue and says so. The previous version is kept +alongside the current one. + +| Where | Path | +| --- | --- | +| Linux | `$XDG_DATA_HOME/cloudexit/catalogue/`, else `~/.local/share/cloudexit/catalogue/` | +| macOS | `~/.local/share/cloudexit/catalogue/` | +| Windows | `%LOCALAPPDATA%\cloudexit\catalogue\` | + +```bash +cloudexit catalogue status # installed and latest +cloudexit catalogue update # download the latest, or --version V +cloudexit catalogue import FILE.db.gz # install a file, with --sha256 to verify +``` + +Your old `datasets/data.db` is **not** picked up automatically. If you want to +keep using it, install it on purpose: + +```bash +cloudexit catalogue import datasets/data.db +``` + +On a machine with no outbound access, either import a file as above or pass +`--catalogue FILE` to use one for a single run without installing it. Set +`CLOUDEXIT_CATALOGUE_URL` to download from a mirror of your own. + +## 8. Run it once and compare + +```bash +cloudexit config show +cloudexit status # only meaningful with a platform configured +cloudexit aws --profile default --dry-run +``` + +`--dry-run` makes a complete local report and the sync document without +contacting a platform, which is the safest first 2.0 run. When the report looks +right, drop `--dry-run`. + +## Still stuck? + +- [Troubleshooting](https://cloudexit.escapecloud.io/troubleshooting/common-errors.html) + covers TLS, proxies, timeouts and the common exit codes. +- `-v` adds the interaction narrative and `-vv` adds debug output, including + third-party libraries. Neither prints credentials, and `run.log` in the report + folder holds the same lines. +- [Open an issue](https://github.com/escapecloud/cloudexit/issues) with the + command you ran, the exit code, and the request ID if the platform returned + one. diff --git a/main.py b/main.py index 790cafd..f1e0073 100644 --- a/main.py +++ b/main.py @@ -1,1086 +1,4 @@ -# main.py -import logging -import argparse -import boto3 -import time -import sys -import os -import getpass -import traceback -from rich.console import Console -from rich.logging import RichHandler -from datetime import datetime -from botocore.exceptions import NoCredentialsError, ProfileNotFound -from azure.identity import DefaultAzureCredential, ClientSecretCredential -from azure.mgmt.resource import SubscriptionClient, ResourceManagementClient - -# Import the functions -from core.engine import ( - verify_credentials, - test_permissions, - create_resource_inventory, - create_cost_inventory, - perform_risk_assessment, - sync_assessment, - generate_report, -) -from core.utils_egress import estimate_egress -from core.utils_report_egress import ( - generate_egress_html_report, - generate_egress_pdf_report, -) -from core.utils_sync import write_assessment_payload -from utils.azure import ( - select_subscription, - select_resource_group, - is_azure_cli_installed, - is_azure_cli_logged_in, - is_azure_cli_token_expired, -) -from utils.aws import is_aws_cli_installed, is_aws_profile_valid -from utils.connection import resolve_mode -from utils.data import initialize_dataset -from utils.utils import ( - ascii_art, - build_config, - create_directory, - load_config, - prompt_required_inputs, - print_help_message, - print_step, - require_env, - require_env_int, -) -from utils.validate import validate_region, validate_config -from utils import codes -from utils.version import __version__ - -# Configure the logger (level is left to the handlers, see configure_logging) -logger = logging.getLogger(__name__) - -# Initialize the console object -console = Console() - -# Third-party loggers kept quiet unless the user opts into deep verbosity (-vv) -_THIRD_PARTY_NOISY = ("botocore", "boto3", "azure") - -# Loggers with no diagnostic value ever — pinned to WARNING regardless of verbosity, -# so they never flood the -vv console or run.log. -_ALWAYS_QUIET = ("PIL",) - - -def configure_logging(verbose: int = 0) -> None: - """ - verbose == 0 -> WARNING (default; the Rich step UI is the primary output) - verbose == 1 -> INFO (--verbose: show our interaction narrative) - verbose >= 2 -> DEBUG (-vv: also un-mute third-party libraries) - """ - root = logging.getLogger() - root.setLevel(logging.DEBUG) - root.handlers.clear() - - if verbose == 0: - console_level = logging.WARNING - elif verbose == 1: - console_level = logging.INFO - else: - console_level = logging.DEBUG - - console_handler = RichHandler( - console=console, show_path=False, rich_tracebacks=True - ) - console_handler.setLevel(console_level) - root.addHandler(console_handler) - - third_party_level = logging.DEBUG if verbose >= 2 else logging.WARNING - for name in _THIRD_PARTY_NOISY: - logging.getLogger(name).setLevel(third_party_level) - - for name in _ALWAYS_QUIET: - logging.getLogger(name).setLevel(logging.WARNING) - - -def add_run_log_handler(report_path: str) -> None: - # A logging-setup failure must never abort the assessment, so degrade - # gracefully if run.log cannot be created (e.g. dir missing or read-only). - try: - file_handler = logging.FileHandler(os.path.join(report_path, "run.log")) - file_handler.setLevel(logging.DEBUG) - file_handler.setFormatter( - logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") - ) - logging.getLogger().addHandler(file_handler) - except OSError as exc: - logger.warning("Could not create run.log in %s: %s", report_path, exc) - - -class ConfigError(Exception): - pass - - -def _reject_egress_with_tfstate(args) -> None: - # --egress needs live API access, so it cannot run against a state file. - if getattr(args, "tfstate", None) and getattr(args, "egress", False): - console.print( - "[red]--egress cannot be combined with --tfstate. Egress estimation " - "requires live cloud API access.[/red]" - ) - sys.exit(codes.CONFIG) - - -def _aws_provider_from_profile(profile: str) -> dict: - if not is_aws_cli_installed(): - console.print( - "[red]AWS CLI is not installed. Install it from https://aws.amazon.com/cli/[/red]" - ) - raise ConfigError - if not is_aws_profile_valid(profile): - console.print( - f"[red]AWS profile '{profile}' is not configured. " - f"Use `aws configure --profile {profile}`.[/red]" - ) - raise ConfigError - try: - session = boto3.Session(profile_name=profile) - credentials = session.get_credentials() - if credentials is None: - console.print( - f"[red]AWS profile '{profile}' has no valid credentials. " - f"Use `aws configure --profile {profile}`.[/red]" - ) - raise ConfigError - region = session.region_name or "us-east-1" - except (NoCredentialsError, ProfileNotFound) as e: - console.print( - f"[red]AWS profile error: {str(e)}. Use `aws configure` to set up a profile.[/red]" - ) - raise ConfigError - - provider_details = { - "accessKey": credentials.access_key, - "secretKey": credentials.secret_key, - "region": region, - } - if getattr(credentials, "token", None): - provider_details["sessionToken"] = credentials.token - return provider_details - - -def _aws_provider_from_env() -> dict: - access_key = require_env("AWS_ACCESS_KEY_ID", "AWS access key") - secret_key = require_env("AWS_SECRET_ACCESS_KEY", "AWS secret key") - region = require_env("AWS_DEFAULT_REGION", "AWS region") - session_token = os.environ.get("AWS_SESSION_TOKEN", "").strip() - try: - validate_region(region) - except ValueError as e: - console.print(f"[red]AWS_DEFAULT_REGION: {e}[/red]") - raise ConfigError - - provider_details = { - "accessKey": access_key, - "secretKey": secret_key, - "region": region, - } - if session_token: - provider_details["sessionToken"] = session_token - return provider_details - - -def _aws_provider_from_prompt() -> dict: - try: - access_key = input("Enter AWS Access Key: ").strip() - secret_key = getpass.getpass("Enter AWS Secret Key (input hidden): ").strip() - - # Validate AWS region input - while True: - region = input("Enter AWS region: ").strip() - try: - validate_region(region) - break - except ValueError as e: - console.print(f"[red]{e} Please enter a valid AWS region.[/red]") - except Exception as e: - console.print(f"[red]Error during manual AWS configuration: {e}[/red]") - raise ConfigError - - return { - "accessKey": access_key, - "secretKey": secret_key, - "region": region, - } - - -def handle_aws(args): - cloud_provider = 2 - - _reject_egress_with_tfstate(args) - tfstate_path = getattr(args, "tfstate", None) - - if args.config: - config = load_config(args.config) - if not config: - console.print("[red]Invalid or missing AWS configuration file.[/red]") - raise ConfigError - - # Handle name field logic (priority: --name > config name > fallback) - if args.name: - config["name"] = args.name.strip() - if "name" not in config or not config["name"].strip(): - config["name"] = ( - f"Exit Assessment {datetime.now().strftime('%Y%m%d_%H%M%S')}" - ) - - run_assessment( - config, - "aws", - dry_run=args.dry_run, - non_interactive=args.non_interactive, - egress=args.egress, - ) - return - - if args.non_interactive: - exit_strategy = require_env_int( - "ESC_EXIT_STRATEGY", "exit strategy (1 or 3)", {1, 3} - ) - assessment_type = require_env_int( - "ESC_ASSESSMENT_TYPE", "assessment type (1 or 2)", {1, 2} - ) - if tfstate_path: - provider_details = {"tfstatePath": tfstate_path} - elif args.profile: - provider_details = _aws_provider_from_profile(args.profile) - else: - provider_details = _aws_provider_from_env() - elif tfstate_path: - exit_strategy, assessment_type = prompt_required_inputs() - provider_details = {"tfstatePath": tfstate_path} - elif args.profile: - provider_details = _aws_provider_from_profile(args.profile) - exit_strategy, assessment_type = prompt_required_inputs() - else: - exit_strategy, assessment_type = prompt_required_inputs() - provider_details = _aws_provider_from_prompt() - - config = build_config( - cloud_provider, exit_strategy, assessment_type, provider_details, args - ) - run_assessment( - config, - "aws", - dry_run=args.dry_run, - non_interactive=args.non_interactive, - egress=args.egress, - ) - - -def _azure_cli_credential() -> DefaultAzureCredential: - if not is_azure_cli_installed(): - console.print( - "[red]Azure CLI is not installed. Install it from https://aka.ms/install-azure-cli.[/red]" - ) - raise ConfigError - if not is_azure_cli_logged_in(): - console.print( - "[red]You are not logged in to Azure CLI. Please run 'az login' and try again.[/red]" - ) - raise ConfigError - if is_azure_cli_token_expired(): - console.print("[red]Your Azure CLI token has expired. Please run:[/red]") - console.print( - "[bold cyan]az login --scope https://management.azure.com/.default[/bold cyan]" - ) - raise ConfigError - return DefaultAzureCredential() - - -def _azure_provider_noninteractive(args) -> dict: - subscription_id = require_env("ESC_SUBSCRIPTION_ID", "Azure subscription ID") - resource_group = require_env("ESC_RESOURCE_GROUP", "Azure resource group") - tenant_id = require_env("AZURE_TENANT_ID", "Azure tenant ID") - client_id = os.environ.get("AZURE_CLIENT_ID", "").strip() - client_secret = os.environ.get("AZURE_CLIENT_SECRET", "").strip() - - if args.cli: - credential = _azure_cli_credential() - else: - if client_secret: - if not client_id: - console.print( - "[red]--non-interactive with AZURE_CLIENT_SECRET also requires AZURE_CLIENT_ID.[/red]" - ) - raise ConfigError - credential = ClientSecretCredential( - tenant_id=tenant_id, - client_id=client_id, - client_secret=client_secret, - ) - else: - if not client_id: - console.print( - "[red]--non-interactive Azure OIDC requires AZURE_CLIENT_ID when AZURE_CLIENT_SECRET is not set.[/red]" - ) - raise ConfigError - credential = DefaultAzureCredential() - - provider_details = { - "credential": credential, - "tenantId": tenant_id, - "subscriptionId": subscription_id, - "resourceGroupName": resource_group, - } - if client_id: - provider_details["clientId"] = client_id - if not args.cli and client_secret: - provider_details["clientSecret"] = client_secret - return provider_details - - -def _azure_provider_from_cli() -> dict: - credential = _azure_cli_credential() - try: - tenant_id = input("Enter Azure Tenant ID: ").strip() - subscription_client = SubscriptionClient(credential) - subscriptions = list(subscription_client.subscriptions.list()) - if not subscriptions: - logger.error("No subscriptions found for the provided Azure credentials.") - console.print( - "[red]No subscriptions found for the provided credentials.[/red]" - ) - raise ConfigError - - selected_subscription = select_subscription(subscriptions) - subscription_id = selected_subscription.subscription_id - - resource_client = ResourceManagementClient(credential, subscription_id) - resource_groups = list(resource_client.resource_groups.list()) - if not resource_groups: - logger.error("No resource groups found in the selected subscription.") - console.print( - "[red]No resource groups found in the selected subscription.[/red]" - ) - raise ConfigError - - resource_group_name = select_resource_group(resource_groups) - except ConfigError: - raise - except Exception as e: - logger.error(f"Error during Azure CLI processing: {e}", exc_info=True) - console.print(f"[red]An error occurred: {e}[/red]") - raise ConfigError - - return { - "credential": credential, - "tenantId": tenant_id, - "subscriptionId": subscription_id, - "resourceGroupName": resource_group_name, - } - - -def _azure_provider_from_prompt() -> dict: - tenant_id = input("Enter Azure Tenant ID: ").strip() - client_id = input("Enter Service Principal / Client ID: ").strip() - client_secret = getpass.getpass("Enter Client Secret (input hidden): ").strip() - - try: - credential = ClientSecretCredential( - tenant_id=tenant_id, client_id=client_id, client_secret=client_secret - ) - subscription_client = SubscriptionClient(credential) - - subscriptions = list(subscription_client.subscriptions.list()) - if not subscriptions: - console.print( - "[red]No subscriptions found. Please check your credentials.[/red]" - ) - raise ConfigError - - selected_subscription = select_subscription(subscriptions) - subscription_id = selected_subscription.subscription_id - - resource_client = ResourceManagementClient(credential, subscription_id) - resource_groups = list(resource_client.resource_groups.list()) - if not resource_groups: - console.print( - "[red]No resource groups found in the selected subscription.[/red]" - ) - raise ConfigError - - resource_group_name = select_resource_group(resource_groups) - except ConfigError: - raise - except Exception as e: - logger.error(f"Error during manual Azure configuration: {e}", exc_info=True) - console.print(f"[red]An error occurred: {e}[/red]") - raise ConfigError - - return { - "tenantId": tenant_id, - "clientId": client_id, - "clientSecret": client_secret, - "subscriptionId": subscription_id, - "resourceGroupName": resource_group_name, - } - - -def handle_azure(args): - cloud_provider = 1 - - _reject_egress_with_tfstate(args) - tfstate_path = getattr(args, "tfstate", None) - - if args.config: - config = load_config(args.config) - if not config: - console.print("[red]Invalid or missing Azure configuration file.[/red]") - raise ConfigError - - # Handle name field logic (priority: --name > config name > fallback) - if args.name: - config["name"] = args.name.strip() - if "name" not in config or not config["name"].strip(): - config["name"] = ( - f"Exit Assessment {datetime.now().strftime('%Y%m%d_%H%M%S')}" - ) - - run_assessment( - config, - "azure", - dry_run=args.dry_run, - non_interactive=args.non_interactive, - egress=args.egress, - ) - return - - if args.non_interactive: - exit_strategy = require_env_int( - "ESC_EXIT_STRATEGY", "exit strategy (1 or 3)", {1, 3} - ) - assessment_type = require_env_int( - "ESC_ASSESSMENT_TYPE", "assessment type (1 or 2)", {1, 2} - ) - if tfstate_path: - provider_details = {"tfstatePath": tfstate_path} - else: - provider_details = _azure_provider_noninteractive(args) - elif tfstate_path: - exit_strategy, assessment_type = prompt_required_inputs() - provider_details = {"tfstatePath": tfstate_path} - elif args.cli: - provider_details = _azure_provider_from_cli() - exit_strategy, assessment_type = prompt_required_inputs() - else: - exit_strategy, assessment_type = prompt_required_inputs() - provider_details = _azure_provider_from_prompt() - - config = build_config( - cloud_provider, exit_strategy, assessment_type, provider_details, args - ) - run_assessment( - config, - "azure", - dry_run=args.dry_run, - non_interactive=args.non_interactive, - egress=args.egress, - ) - - -def run_assessment( - config, provider_name, *, dry_run=False, non_interactive=False, egress=False -): - # Record the assessment start time to propagate across stages - started_at = int(time.time()) - - # Bound up front so the error handler can reference it even if the crash - # happens before the report directory is created. - report_path = None - - try: - # Preliminary Stage: Validate configuration & create directory - console.print("-------------------------------------------") - console.print("Preliminary Stage", style="bold") - try: - validate_config(config) - print_step("Configuration successfully validated.", status="ok") - except ValueError as e: - print_step("Configuration validation failed.", status="error", logs=str(e)) - sys.exit(codes.CONFIG) - - # tfstate mode reads a local state file: no credentials, no permission - # check, no cost data — and no egress estimation, which needs live APIs. - is_tfstate = bool(config["providerDetails"].get("tfstatePath")) - if is_tfstate and egress: - print_step( - "Configuration validation failed.", - status="error", - logs="--egress requires live cloud API access and cannot be used with a Terraform state file.", - ) - sys.exit(codes.CONFIG) - - # Detect ExitCloud Integration - mode, jwt = resolve_mode() - if dry_run: - print_step("Dry run mode – no remote sync.", status="ok") - elif mode == "online": - print_step("ExitCloud integration configured.", status="ok") - else: - print_step("ExitCloud integration not configured.", status="warning") - # Overwrite assessment type to basic - if config["assessmentType"] != 1: - print_step( - "Forcing Basic Assessment due to offline mode.", status="warning" - ) - config["assessmentType"] = 1 - - # Create directories - try: - report_path, raw_data_path = create_directory() - add_run_log_handler(report_path) - print_step("Directory successfully created.", status="ok") - except RuntimeError as e: - print_step("Directory creation failed.", status="error", logs=str(e)) - sys.exit(codes.CONFIG) - - # Handle the result - provider_name = ( - "Microsoft Azure" - if config["cloudServiceProvider"] == 1 - else "AWS" if config["cloudServiceProvider"] == 2 else "Unknown" - ) - - # Stage 1: Verify Credentials - console.print("-------------------------------------------") - console.print("Stage #1 - Validate Credentials", style="bold") - if is_tfstate: - print_step( - "Skipped - tfstate mode (no cloud credentials used).", status="warning" - ) - else: - # Test Connection - connection_success, logs = verify_credentials( - config["cloudServiceProvider"], config["providerDetails"] - ) - if connection_success: - print_step(f"Connecting to {provider_name}...", status="ok") - else: - print_step(f"Connecting to {provider_name}...", status="error") - console.print(f" ↳ {logs}", style="dim") - logger.error(f"Credential verification failed: {logs}") - sys.exit(codes.CREDENTIALS) - console.print("-------------------------------------------") - - # Stage 2: Test Permissions - console.print("Stage #2 - Validate Permissions", style="bold") - - if is_tfstate: - print_step( - "Skipped - tfstate mode (no cloud permissions needed).", - status="warning", - ) - else: - # Labels for permission types - permission_reader_label = ( - "Reader" if config["cloudServiceProvider"] == 1 else "ViewOnlyAccess" - ) - permission_cost_label = ( - "Cost Management Reader" - if config["cloudServiceProvider"] == 1 - else "AWSBillingReadOnlyAccess" - ) - - # Test permissions with spinners - with console.status("Validating permissions...", spinner="dots"): - permission_valid, permission_reader, permission_cost, logs = ( - test_permissions( - config["cloudServiceProvider"], config["providerDetails"] - ) - ) - - # Output results for permission checks - if permission_reader: - print_step(f"Checking {permission_reader_label}...", status="ok") - else: - print_step( - f"Checking {permission_reader_label}...", status="error", logs=logs - ) - - if permission_cost: - print_step(f"Checking {permission_cost_label}...", status="ok") - else: - print_step( - f"Checking {permission_cost_label}...", status="error", logs=logs - ) - - # Exit if permissions are invalid - if not permission_valid: - logger.error(f"Permission validation failed: {logs}") - sys.exit(codes.PERMISSIONS) - - console.print("-------------------------------------------") - - # Stage 3: Build Resource Inventory - console.print("Stage #3 - Build Resource Inventory", style="bold") - - # Use a spinner to indicate progress - with console.status( - f"Building resource inventory for {provider_name}...", spinner="dots" - ): - result = create_resource_inventory( - config["cloudServiceProvider"], - config["providerDetails"], - report_path, - raw_data_path, - ) - - if result["success"]: - # In tfstate mode, resources belonging to another cloud provider are - # dropped silently by design. Report the exclusion at step level so a - # partially-assessed state cannot pass for a complete one. - coverage = result.get("coverage") or {} - excluded = coverage.get("instances_excluded_other_provider", 0) - if excluded: - print_step( - f"Building resource inventory for {provider_name}...", - status="warning", - logs=( - f"Assessed {coverage['instances_counted']} of " - f"{coverage['instances_total']} resources; {excluded} excluded " - f"(other cloud provider). See raw_data/tfstate_manifest.json." - ), - ) - else: - print_step( - f"Building resource inventory for {provider_name}...", status="ok" - ) - else: - print_step( - f"Building resource inventory for {provider_name}...", - status="error", - logs=result["logs"], - ) - sys.exit(codes.RESOURCE_INVENTORY) - - console.print("-------------------------------------------") - - # Stage 4: Build Cost Inventory - console.print("Stage #4 - Build Cost Inventory", style="bold") - - if is_tfstate: - print_step( - "Skipped - tfstate mode (no billing data available).", status="warning" - ) - else: - # Use a spinner to indicate progress - with console.status( - f"Building cost inventory for {provider_name}...", spinner="dots" - ): - cost_result = create_cost_inventory( - config["cloudServiceProvider"], - config["providerDetails"], - report_path, - raw_data_path, - ) - - # Handle the result - if cost_result["success"]: - print_step( - f"Building cost inventory for {provider_name}...", status="ok" - ) - else: - print_step( - f"Building cost inventory for {provider_name}...", - status="error", - logs=cost_result["logs"], - ) - sys.exit(codes.COST_INVENTORY) - - console.print("-------------------------------------------") - - name = ( - config.get("name") - or f"Exit Assessment {datetime.now().strftime('%Y%m%d_%H%M%S')}" - ) - - payload_path = None - if dry_run: - payload_path = write_assessment_payload( - raw_data_path, - report_path=report_path, - name=name, - started_at=started_at, - exit_strategy=config["exitStrategy"], - cloud_service_provider=config["cloudServiceProvider"], - assessment_type=config["assessmentType"], - ) - - # Stage 5 – Online / Offline Risk Assessment - if dry_run or mode == "offline": - console.print("Stage #5 – Offline Risk Assessment", style="bold") - - with console.status("Performing risk assessment...", spinner="dots"): - risk_result = perform_risk_assessment( - exit_strategy=config["exitStrategy"], - report_path=report_path, - mode="offline", - ) - - status = "ok" if risk_result["success"] else "error" - print_step( - "Performing risk assessment...", status=status, logs=risk_result["logs"] - ) - if not risk_result["success"]: - sys.exit(codes.RISK_ASSESSMENT) - - elif mode == "online": - console.print("Stage #5 – Online Risk Assessment", style="bold") - - sync_result = sync_assessment( - name=name, - started_at=started_at, - report_path=report_path, - metadata={ - "cloud_service_provider": config["cloudServiceProvider"], - "exit_strategy": config["exitStrategy"], - "assessment_type": config["assessmentType"], - }, - mode=mode, - token=jwt, - ) - - status = "ok" if sync_result["success"] else "error" - print_step("Sync assessment...", status=status, logs=sync_result["logs"]) - if not sync_result["success"]: - sys.exit(codes.RISK_ASSESSMENT) - - console.print("-------------------------------------------") - - # Stage 6: Generate Report - console.print("Stage #6 - Generate Report", style="bold") - - # Use a spinner to indicate progress - with console.status("Generating report...", spinner="dots"): - report_status = generate_report( - config["cloudServiceProvider"], - config["providerDetails"], - config["exitStrategy"], - config["assessmentType"], - name, - report_path, - raw_data_path, - ) - - # Handle the result - if report_status["success"]: - print_step("Generating report...", status="ok") - else: - print_step( - "Generating report...", status="error", logs=report_status["logs"] - ) - sys.exit(codes.REPORT) - - # Stage 7: Egress Estimation (opt-in via --egress). - # A failure here must not discard the completed assessment. - egress_failed = False - egress_json_path = None - egress_html_path = None - egress_pdf_path = None - if egress: - console.print("-------------------------------------------") - console.print("Stage #7 – Egress Estimation", style="bold") - - with console.status("Estimating egress data...", spinner="dots"): - egress_result = estimate_egress( - config["cloudServiceProvider"], - config["providerDetails"], - raw_data_path, - report_path=report_path, - name=name, - exit_strategy=config["exitStrategy"], - assessment_type=config["assessmentType"], - ) - - if egress_result["success"]: - print_step("Estimating egress data...", status="ok") - egress_json_path = egress_result.get("json_path") - - with console.status("Generating egress report...", spinner="dots"): - egress_report_result = generate_egress_html_report( - report_path, egress_json_path - ) - - if egress_report_result["success"]: - print_step("Generating egress report...", status="ok") - egress_html_path = egress_report_result.get("html_path") - else: - print_step( - "Generating egress report...", - status="error", - logs=egress_report_result["logs"], - ) - egress_failed = True - - 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...", - status="error", - logs=egress_result["logs"], - ) - egress_failed = True - - # Output the report path after the separator - if not (dry_run or non_interactive): - console.print("-------------------------------------------") - console.print( - "Need to see how a regulatory-aligned report looks (DORA / FINMA / UK PRA evidence support)?" - ) - console.print( - "We can generate one from your --dry-run output and share a sample with you -- or forward this to your risk or compliance team." - ) - console.print( - "Point-in-time evidence only; not a compliance certification or a complete exit strategy." - ) - console.print("Contact: request_report@escapecloud.io") - console.print("-------------------------------------------") - console.print("Outputs:", style="bold") - if payload_path: - console.print(f"Payload: {payload_path}", style="cyan") - html_report_path = report_status.get("reports", {}).get("HTML") - if html_report_path: - console.print(f"HTML Report: {html_report_path}", style="cyan") - pdf_report_path = report_status.get("reports", {}).get("PDF") - if pdf_report_path: - console.print(f"PDF Report: {pdf_report_path}", style="cyan") - json_report_path = report_status.get("reports", {}).get("JSON") - if json_report_path: - console.print(f"JSON Report: {json_report_path}", style="cyan") - if egress_html_path: - console.print(f"Egress Report: {egress_html_path}", style="cyan") - if egress_pdf_path: - console.print(f"Egress PDF: {egress_pdf_path}", style="cyan") - console.print("-------------------------------------------") - - if egress_failed: - sys.exit(codes.EGRESS) - - except Exception as e: - console.print(f"[red]Unexpected error: {e}[/red]") - # Persist the full traceback so a one-line error is actionable. Falls - # back to the cwd when the crash happened before the report dir existed. - target_dir = report_path or os.getcwd() - try: - log_file = os.path.join(target_dir, f"error-{int(time.time())}.log") - with open(log_file, "w", encoding="utf-8") as fh: - fh.write(traceback.format_exc()) - console.print(f"[yellow]Full traceback written to: {log_file}[/yellow]") - except OSError: - console.print( - "[yellow]Could not write a traceback file; see run.log.[/yellow]" - ) - # Also funnel to run.log at DEBUG (kept off the default console). - logger.debug("Unexpected error", exc_info=True) - sys.exit(codes.UNEXPECTED) - - -def parse_arguments(): - parser = argparse.ArgumentParser( - description="EscapeCloud - Community Edition", - epilog=( - "Example usage:\n" - " python3 main.py aws # Use manual input for AWS\n" - " python3 main.py aws --config config.json # Use a configuration file for AWS\n" - " python3 main.py aws --profile PROFILE # Use an AWS CLI profile\n" - " python3 main.py aws --name 'DMS System' # Use a pre-defined assessment name\n" - " python3 main.py azure # Use manual input for Azure\n" - " python3 main.py azure --config config.json # Use a configuration file for Azure\n" - " python3 main.py azure --cli # Use Azure CLI credentials\n" - " python3 main.py azure --name 'DMS System' # Use a pre-defined assessment name\n" - " python3 main.py aws --config config.json --dry-run # Local report + payload.json, no remote sync\n" - " python3 main.py azure --config config.json --dry-run\n" - " python3 main.py aws --config config.json --egress # Estimate egress data volume\n" - " python3 main.py azure --config config.json --egress\n" - " python3 main.py aws --tfstate infra.tfstate # Assess a Terraform/OpenTofu state file\n" - " python3 main.py azure --tfstate infra.tfstate --dry-run\n" - ), - formatter_class=argparse.RawDescriptionHelpFormatter, - ) - - parser.add_argument( - "--version", - action="version", - version=f"cloudexit v{__version__}", - help="Show the CLI version and exit.", - ) - - subparsers = parser.add_subparsers( - dest="cloud_provider", help="Specify the cloud provider (aws or azure)." - ) - - # Shared options available on every subcommand (e.g. `aws --verbose`) - common = argparse.ArgumentParser(add_help=False) - common.add_argument( - "-v", - "--verbose", - action="count", - default=0, - help="Increase log verbosity (-v for INFO, -vv for DEBUG + third-party).", - ) - - # Subparser for AWS - aws_parser = subparsers.add_parser( - "aws", parents=[common], help="Perform an AWS assessment." - ) - aws_group = aws_parser.add_mutually_exclusive_group(required=False) - aws_group.add_argument( - "--config", type=str, help="Path to the configuration file (JSON format)." - ) - aws_group.add_argument( - "--profile", - type=str, - help="AWS profile name to use credentials from ~/.aws/credentials.", - ) - aws_group.add_argument( - "--tfstate", - type=str, - help=( - "Path to a Terraform/OpenTofu state file. Builds the inventory from " - "the state instead of the AWS APIs; no credentials are used." - ), - ) - aws_parser.add_argument( - "--name", type=str, help="Assessment Name (Optional / Max. 50 characters)." - ) - aws_parser.add_argument( - "--non-interactive", - action="store_true", - help="Run without prompts; read all inputs from environment variables (for CI use).", - ) - aws_parser.add_argument( - "--dry-run", - action="store_true", - help=( - "Run a local assessment and also write raw_data/payload.json without " - "remote sync." - ), - ) - aws_parser.add_argument( - "--egress", - action="store_true", - help=( - "Estimate how much data lives in the region and " "would need to move out." - ), - ) - - # Subparser for Azure - azure_parser = subparsers.add_parser( - "azure", parents=[common], help="Perform an Azure assessment." - ) - azure_group = azure_parser.add_mutually_exclusive_group(required=False) - azure_group.add_argument( - "--config", type=str, help="Path to the configuration file (JSON format)." - ) - azure_group.add_argument( - "--cli", - action="store_true", - help="Use Azure CLI credentials for authentication.", - ) - azure_group.add_argument( - "--tfstate", - type=str, - help=( - "Path to a Terraform/OpenTofu state file. Builds the inventory from " - "the state instead of the Azure APIs; no credentials are used." - ), - ) - azure_parser.add_argument( - "--name", type=str, help="Assessment Name (Optional / Max. 50 characters)." - ) - azure_parser.add_argument( - "--non-interactive", - action="store_true", - help="Run without prompts; read all inputs from environment variables (for CI use).", - ) - azure_parser.add_argument( - "--dry-run", - action="store_true", - help=( - "Run a local assessment and also write raw_data/payload.json without " - "remote sync." - ), - ) - azure_parser.add_argument( - "--egress", - action="store_true", - help=( - "Estimate how much data lives in the resource group and " - "would need to move out." - ), - ) - - return parser.parse_args() - - -def main(): - # Parse arguments first so --version/--help exit before any side effects - # (ASCII art, dataset download). - args = parse_arguments() - - # Configure logging before any side effects so verbosity applies everywhere. - configure_logging(getattr(args, "verbose", 0)) - - # Print ASCII art - console.print(ascii_art, style="bold cyan") - - # Nothing to do without a subcommand — show help before any dataset download. - if not args.cloud_provider: - print_help_message() - return - - # Ensure latest dataset is available before proceeding - initialize_dataset() - - # Dispatch based on provided arguments - try: - if args.cloud_provider == "aws": - handle_aws(args) - elif args.cloud_provider == "azure": - handle_azure(args) - else: - console.print( - "[red]Invalid command. Use 'aws' or 'azure' as the first argument.[/red]" - ) - console.print( - "[green]Run 'python3 main.py --help' for usage instructions.[/green]" - ) - except ConfigError: - sys.exit(codes.CONFIG) - +from cloudexit.cli import run if __name__ == "__main__": - try: - main() - except KeyboardInterrupt: - console.print( - "\n[bold yellow]Operation cancelled by user (Ctrl+C). Exiting gracefully.[/bold yellow]" - ) - # logger.warning("Process interrupted by user via KeyboardInterrupt.") - sys.exit(0) - except Exception as e: - # logger.error(f"An unexpected error occurred: {e}", exc_info=True) - console.print(f"[red]Unexpected error: {e}[/red]") - sys.exit(1) + run() diff --git a/publiccode.yml b/publiccode.yml index c076146..2b0c465 100644 --- a/publiccode.yml +++ b/publiccode.yml @@ -1,4 +1,4 @@ -publiccodeYmlVersion: "0.4.0" +publiccodeYmlVersion: "0.8.0" name: cloudexit url: https://github.com/escapecloud/cloudexit landingURL: https://escapecloud.io @@ -8,19 +8,29 @@ platforms: - windows categories: - cloud-management -developmentStatus: beta +developmentStatus: stable softwareType: standalone/backend +softwareVersion: 2.0.0 description: en: shortDescription: Open-source tool for cloud exit assessments - longDescription: Open-source tool for cloud exit assessments that helps - organizations evaluate risks, dependencies, and alternative strategies - before leaving a cloud provider. + longDescription: Open-source command-line tool for cloud exit assessments + that helps organizations evaluate risks, dependencies, and alternative + strategies before leaving a cloud provider. It inspects an AWS or Azure + scope with read-only credentials, or a Terraform/OpenTofu state file, and + writes a self-contained HTML report and a PDF executive summary on the + machine that ran it. It can also estimate the one-time data egress fee of + moving the data out, at the provider's list prices. It runs fully offline + and needs no account; results can optionally be uploaded to a platform. features: - cloud exit assessment - risk management - alternative technology analysis + - resource and cost inventory + - data egress estimation + - infrastructure-as-code state scan + - offline operation legal: license: AGPL-3.0-only diff --git a/pyproject.toml b/pyproject.toml index aa1d5ad..6629955 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,34 +9,47 @@ description = "Open-source cloud exit assessment CLI for evaluating cloud lock-i readme = "README.md" requires-python = ">=3.12" dependencies = [ - "azure-identity==1.25.3", - "azure-mgmt-resource==24.0.0", + "azure-identity==1.26.0", + "azure-mgmt-resource==26.0.0", + "azure-mgmt-subscription==3.1.1", "azure-mgmt-authorization==4.0.0", - "azure-mgmt-costmanagement==4.0.1", - "boto3==1.43.5", - "botocore==1.43.5", + "azure-mgmt-costmanagement==5.0.0", + "boto3==1.43.118", + "botocore==1.43.118", "rich==15.0.0", "Jinja2==3.1.6", - "reportlab==4.5.0", - "pillow==12.3.0", - "requests==2.33.1", + "reportlab==5.1.0", + "pillow==13.0.0", + "requests==2.34.2", ] [project.optional-dependencies] dev = [ "black", "ruff", + "jsonschema", + "PyYAML", + "pypdf", + "coverage", ] [project.scripts] -cloudexit = "main:main" +cloudexit = "cloudexit.cli:main" -[tool.setuptools.dynamic] -version = {attr = "utils.version.__version__"} +[tool.ruff.lint] +# ruff 0.16 enlarged its default rule set; keep the previous defaults until +# the new rules are adopted deliberately +select = ["E4", "E7", "E9", "F"] + +[tool.coverage.run] +source = ["cloudexit"] -[tool.setuptools] -py-modules = ["main", "config"] +[tool.setuptools.dynamic] +version = {attr = "cloudexit.version.__version__"} [tool.setuptools.packages.find] where = ["."] -include = ["core", "core.*", "utils", "utils.*"] +include = ["cloudexit", "cloudexit.*"] + +[tool.setuptools.package-data] +cloudexit = ["assets/**/*"] diff --git a/requirements-dev.txt b/requirements-dev.txt index 8ff1d00..46ecc36 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,3 +1,7 @@ -r requirements.txt -black==26.5.1 -ruff==0.15.21 +black==26.10.1 +ruff==0.17.2 +jsonschema==4.26.0 +PyYAML==6.0.3 +pypdf==6.21.2 +coverage==7.17.0 diff --git a/requirements.txt b/requirements.txt index eb67a0f..fde0901 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,11 +1,12 @@ -azure-identity==1.25.3 -azure-mgmt-resource==24.0.0 +azure-identity==1.26.0 +azure-mgmt-resource==26.0.0 +azure-mgmt-subscription==3.1.1 azure-mgmt-authorization==4.0.0 -azure-mgmt-costmanagement==4.0.1 -boto3==1.43.5 -botocore==1.43.5 +azure-mgmt-costmanagement==5.0.0 +boto3==1.43.118 +botocore==1.43.118 rich==15.0.0 Jinja2==3.1.6 -reportlab==4.5.0 -pillow==12.3.0 -requests==2.33.1 +reportlab==5.1.0 +pillow==13.0.0 +requests==2.34.2 diff --git a/tests/__init__.py b/tests/__init__.py index 8b13789..d39a7fb 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1 +1,14 @@ +import atexit +import os +import shutil +import tempfile +# No test may read or write the real home, config, data or cache directories +_home = tempfile.mkdtemp(prefix="cloudexit-tests-") +atexit.register(shutil.rmtree, _home, ignore_errors=True) +os.environ.update( + HOME=_home, + USERPROFILE=_home, + APPDATA=os.path.join(_home, "AppData", "Roaming"), + LOCALAPPDATA=os.path.join(_home, "AppData", "Local"), +) diff --git a/tests/catalogue/__init__.py b/tests/catalogue/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/catalogue/test_catalogue.py b/tests/catalogue/test_catalogue.py new file mode 100644 index 0000000..d7dadef --- /dev/null +++ b/tests/catalogue/test_catalogue.py @@ -0,0 +1,894 @@ +import contextlib +import gzip +import io +import json +import os +import sqlite3 +import tempfile +import unittest +from argparse import Namespace +from pathlib import Path +from unittest.mock import patch + +import requests + +from cloudexit.cli import app +from cloudexit.cli.parser import parse_arguments +from cloudexit.catalogue import db as catalogue_db +from cloudexit.catalogue.db import connect +from cloudexit.engine import create_resource_inventory +from cloudexit.report.assets import copy_assets +from tests.support.isolation import isolated_environ +from tests.support.catalogue import ( + build_catalogue, + catalogue_bytes, + catalogue_gz, + manifest, + manifest_entry, + sha256, +) +from tests.support.http import fake_http, make_response +from cloudexit import codes +from cloudexit.catalogue.store import ( + CATALOGUE_BASE_URL, + CatalogueError, + CatalogueUnavailable, + EnsureResult, + active_catalogue_path, + active_resource_type_ids, + catalogue_base_url, + catalogue_dir, + catalogue_info, + import_catalogue, + installed_catalogue, + read_state, + set_active_catalogue, + use_catalogue_file, + _open_readonly, +) +from cloudexit.catalogue.download import ( + ensure_catalogue, + resolve_file_url, + status_lines, + update_catalogue, +) +from cloudexit.settings import Setting, Settings, SettingsError + +BASE = "https://catalogue.example.test/mirror" +MANIFEST_URL = f"{BASE}/cloudexit-manifest.json" +V1, V2, V3 = "2026-10-26", "2026-11-02", "2026-11-09" + + +class CatalogueTestCase(unittest.TestCase): + def setUp(self): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.tmp = Path(tmp.name) + self.dir = self.tmp / "catalogue" + self.addCleanup(set_active_catalogue, None) + + def install(self, version=V1, **kwargs): + gz = catalogue_gz(version=version, **kwargs) + path = self.tmp / f"{version}.db.gz" + path.write_bytes(gz) + import_catalogue(path, directory=self.dir) + return gz + + def release(self, version, **kwargs): + gz = catalogue_gz(version=version, **kwargs) + return gz, manifest_entry(version, gz) + + def manifest_reply(self, latest, previous=(), **kwargs): + return make_response(json_body=manifest(latest, previous, **kwargs)) + + def version(self): + installed = installed_catalogue(self.dir) + return installed.version if installed else None + + +class ManifestUpdateTests(CatalogueTestCase): + def test_installs_the_latest_listed_in_the_manifest_below_the_base_path(self): + gz, entry = self.release(V2) + with fake_http(self.manifest_reply(entry), make_response(content=gz)) as fake: + result = update_catalogue(BASE, directory=self.dir) + + self.assertEqual(result.status, "updated") + self.assertEqual(result.info.version, V2) + self.assertEqual(self.version(), V2) + self.assertEqual( + [request.url for request in fake.requests], + [MANIFEST_URL, f"{BASE}/releases/cloudexit-{V2}.db.gz"], + ) + self.assertEqual(fake.requests[0].kwargs["timeout"], 10) + state = read_state(self.dir) + self.assertEqual(state["version"], V2) + self.assertEqual(state["sha256"], entry["file_sha256"]) + self.assertIn("downloaded_at", state) + + def test_unchanged_version_downloads_nothing(self): + self.install(V2) + _, entry = self.release(V2) + with fake_http(self.manifest_reply(entry)) as fake: + result = update_catalogue(BASE, directory=self.dir) + + self.assertEqual(result.status, "current") + self.assertEqual(len(fake.requests), 1) + + def test_checksum_mismatch_is_rejected_and_the_old_dataset_kept(self): + self.install(V1) + before = (self.dir / "data.db").read_bytes() + gz, entry = self.release(V2) + entry["file_sha256"] = "0" * 64 + with fake_http(self.manifest_reply(entry), make_response(content=gz)): + with self.assertRaises(CatalogueError) as ctx: + update_catalogue(BASE, directory=self.dir) + + self.assertIn("checksum mismatch", str(ctx.exception)) + self.assertEqual((self.dir / "data.db").read_bytes(), before) + self.assertEqual(self.version(), V1) + self.assertEqual( + sorted(path.name for path in self.dir.iterdir()), + ["data.db", "state.json"], + ) + + def test_a_version_that_differs_from_the_manifest_is_rejected(self): + self.install(V1) + gz = catalogue_gz(version=V3) + entry = manifest_entry(V2, gz) + with fake_http(self.manifest_reply(entry), make_response(content=gz)): + with self.assertRaises(CatalogueError): + update_catalogue(BASE, directory=self.dir) + self.assertEqual(self.version(), V1) + + def test_a_download_without_catalogue_meta_is_rejected(self): + gz = catalogue_gz(meta=False) + entry = manifest_entry(V2, gz) + with fake_http(self.manifest_reply(entry), make_response(content=gz)): + with self.assertRaises(CatalogueError): + update_catalogue(BASE, directory=self.dir) + self.assertIsNone(installed_catalogue(self.dir)) + + def test_catalogue_meta_must_have_exactly_one_row(self): + gz = catalogue_gz(version=V2, meta_rows=2) + entry = manifest_entry(V2, gz) + with fake_http(self.manifest_reply(entry), make_response(content=gz)): + with self.assertRaises(CatalogueError) as ctx: + update_catalogue(BASE, directory=self.dir) + self.assertIn("exactly one row", str(ctx.exception)) + + def test_the_swap_keeps_the_previous_dataset(self): + self.install(V1) + old = (self.dir / "data.db").read_bytes() + gz, entry = self.release(V2) + with fake_http(self.manifest_reply(entry), make_response(content=gz)): + update_catalogue(BASE, directory=self.dir) + + self.assertEqual((self.dir / "data.db.previous").read_bytes(), old) + self.assertEqual(catalogue_info(self.dir / "data.db.previous").version, V1) + self.assertEqual(self.version(), V2) + + def test_a_listed_older_version_can_be_installed(self): + self.install(V3) + gz1, entry1 = self.release(V1) + _, entry3 = self.release(V3) + with fake_http( + self.manifest_reply(entry3, [entry1]), make_response(content=gz1) + ): + result = update_catalogue(BASE, directory=self.dir, version=V1) + + self.assertEqual(result.info.version, V1) + + def test_an_unlisted_version_lists_the_available_ones(self): + _, entry = self.release(V2) + with fake_http(self.manifest_reply(entry)): + with self.assertRaises(CatalogueError) as ctx: + update_catalogue(BASE, directory=self.dir, version="2020-01-01") + self.assertIn(V2, str(ctx.exception)) + + def test_a_file_outside_the_catalogue_location_is_refused(self): + for file in ( + "https://elsewhere.example.test/x.db.gz", + "../other/x.db.gz", + "//elsewhere.example.test/x.db.gz", + ): + with self.subTest(file=file): + with self.assertRaises(CatalogueError): + resolve_file_url(MANIFEST_URL, file) + + def test_a_newer_dataset_schema_asks_for_a_cloudexit_update(self): + _, entry = self.release(V2) + with fake_http(self.manifest_reply(entry, schema=2)): + with self.assertRaises(CatalogueError) as ctx: + update_catalogue(BASE, directory=self.dir) + self.assertIn("Update cloudexit", str(ctx.exception)) + + def test_an_unreachable_server_is_an_error_not_a_fallback(self): + with fake_http(requests.ConnectionError("offline")) as fake: + with self.assertRaises(CatalogueError): + update_catalogue(BASE, directory=self.dir) + self.assertEqual(len(fake.requests), 1) + + +class LatestFallbackTests(CatalogueTestCase): + def test_without_a_manifest_the_latest_file_is_used(self): + gz = catalogue_gz(version=V2) + with fake_http( + make_response(status=404), + make_response(text=f"{sha256(gz)} cloudexit-latest.db.gz\n"), + make_response(content=gz), + ) as fake: + result = update_catalogue(BASE, directory=self.dir) + + self.assertEqual(result.status, "updated") + self.assertEqual( + [request.url for request in fake.requests], + [ + MANIFEST_URL, + f"{BASE}/cloudexit-latest.db.gz.sha256", + f"{BASE}/cloudexit-latest.db.gz", + ], + ) + self.assertEqual(self.version(), V2) + + def test_an_unchanged_checksum_downloads_nothing(self): + gz = catalogue_gz(version=V2) + reply = make_response(text=sha256(gz)) + with fake_http(make_response(status=404), reply, make_response(content=gz)): + update_catalogue(BASE, directory=self.dir) + with fake_http(make_response(status=404), make_response(text=sha256(gz))): + result = update_catalogue(BASE, directory=self.dir) + self.assertEqual(result.status, "current") + + def test_a_dataset_without_catalogue_meta_installs_as_version_unknown(self): + gz = catalogue_gz(meta=False) + with fake_http( + make_response(status=404), + make_response(text=sha256(gz)), + make_response(content=gz), + ): + result = update_catalogue(BASE, directory=self.dir) + + self.assertIsNone(result.info) + installed = installed_catalogue(self.dir) + self.assertIsNotNone(installed) + self.assertIsNone(installed.version) + self.assertEqual(active_resource_type_ids("aws", installed.path), [1, 4]) + + def test_nothing_published_is_an_error(self): + with fake_http(make_response(status=404), make_response(status=404)): + with self.assertRaises(CatalogueError) as ctx: + update_catalogue(BASE, directory=self.dir) + self.assertIn("no catalogue manifest or latest file", str(ctx.exception)) + + def test_a_version_cannot_be_chosen_without_a_manifest(self): + with fake_http(make_response(status=404)): + with self.assertRaises(CatalogueError): + update_catalogue(BASE, directory=self.dir, version=V1) + + def test_a_checksum_mismatch_is_rejected(self): + gz = catalogue_gz(version=V2) + with fake_http( + make_response(status=404), + make_response(text="0" * 64), + make_response(content=gz), + ): + with self.assertRaises(CatalogueError): + update_catalogue(BASE, directory=self.dir) + self.assertIsNone(installed_catalogue(self.dir)) + + +class CatalogueInfoTests(CatalogueTestCase): + def test_reads_catalogue_meta(self): + path = self.tmp / "data.db" + build_catalogue(path, version=V2, sequence=41) + + info = catalogue_info(path) + + self.assertEqual(info.version, V2) + self.assertEqual(info.sequence, 41) + self.assertEqual(info.sha256, "c" * 64) + self.assertEqual(info.dataset_schema, 1) + + def test_an_old_dataset_has_no_info(self): + path = self.tmp / "data.db" + build_catalogue(path, meta=False) + self.assertIsNone(catalogue_info(path)) + + def test_extra_tables_and_columns_are_tolerated(self): + path = self.tmp / "data.db" + build_catalogue(path, extras=True) + self.assertEqual(catalogue_info(path).version, V2) + self.assertEqual(active_resource_type_ids("azure", path), [3]) + + def test_active_resource_type_ids_filter_by_provider_and_status(self): + path = self.tmp / "data.db" + build_catalogue(path) + + self.assertEqual(active_resource_type_ids("aws", path), [1, 4]) + self.assertEqual(active_resource_type_ids("azure", path), [3]) + self.assertEqual(active_resource_type_ids(2, path), [1, 4]) + self.assertEqual(active_resource_type_ids("AWS", path), [1, 4]) + with self.assertRaises(ValueError): + active_resource_type_ids("gcp", path) + + def test_the_active_dataset_is_the_default(self): + path = self.tmp / "data.db" + build_catalogue(path, version=V3) + set_active_catalogue(path) + + self.assertEqual(active_catalogue_path(), path) + self.assertEqual(catalogue_info().version, V3) + self.assertEqual(active_resource_type_ids("azure"), [3]) + self.assertEqual(catalogue_db.load_data("risk")[0]["name"], "Example risk") + + +class ImportTests(CatalogueTestCase): + def test_imports_a_good_file(self): + path = self.tmp / "catalogue.db.gz" + gz = catalogue_gz(version=V2) + path.write_bytes(gz) + + info = import_catalogue(path, directory=self.dir, sha256=sha256(gz)) + + self.assertEqual(info.version, V2) + self.assertEqual(self.version(), V2) + self.assertEqual(read_state(self.dir)["sha256"], sha256(gz)) + + def test_imports_a_plain_database_and_an_old_one(self): + plain = self.tmp / "plain.db" + plain.write_bytes(catalogue_bytes(meta=False)) + + self.assertIsNone(import_catalogue(plain, directory=self.dir)) + self.assertIsNone(self.version()) + self.assertIsNotNone(installed_catalogue(self.dir)) + + def test_a_wrong_checksum_is_rejected(self): + path = self.tmp / "catalogue.db.gz" + path.write_bytes(catalogue_gz()) + with self.assertRaises(CatalogueError): + import_catalogue(path, directory=self.dir, sha256="0" * 64) + self.assertIsNone(installed_catalogue(self.dir)) + + def test_corrupt_files_are_rejected_and_nothing_is_installed(self): + self.install(V1) + before = (self.dir / "data.db").read_bytes() + good = catalogue_gz(version=V2) + samples = { + "truncated gzip": good[: len(good) // 2], + "not a database": gzip.compress(b"this is not sqlite"), + "random bytes": os.urandom(256), + "missing tables": gzip.compress(catalogue_bytes(core_tables=False)), + "two meta rows": catalogue_gz(meta_rows=2), + "newer schema": catalogue_gz(schema=2), + } + for name, data in samples.items(): + with self.subTest(name): + path = self.tmp / "bad.db.gz" + path.write_bytes(data) + with self.assertRaises(CatalogueError): + import_catalogue(path, directory=self.dir) + self.assertEqual((self.dir / "data.db").read_bytes(), before) + + def test_a_missing_file_is_an_error(self): + with self.assertRaises(CatalogueError): + import_catalogue(self.tmp / "missing.db.gz", directory=self.dir) + + +class UseCatalogueFileTests(CatalogueTestCase): + def test_a_file_is_used_without_touching_the_installed_dataset(self): + self.install(V1) + before = (self.dir / "data.db").read_bytes() + state = read_state(self.dir) + path = self.tmp / "other.db.gz" + path.write_bytes(catalogue_gz(version=V3)) + + used, info = use_catalogue_file(path) + + self.assertEqual(info.version, V3) + self.assertNotEqual(used, self.dir / "data.db") + self.assertEqual(catalogue_info(used).version, V3) + self.assertEqual((self.dir / "data.db").read_bytes(), before) + self.assertEqual(read_state(self.dir), state) + self.assertFalse((self.dir / "data.db.previous").exists()) + + def test_a_plain_database_is_used_in_place(self): + path = self.tmp / "plain.db" + build_catalogue(path, meta=False) + used, info = use_catalogue_file(path) + self.assertEqual(used, path) + self.assertIsNone(info) + + def test_an_invalid_file_is_an_error(self): + path = self.tmp / "bad.db" + path.write_bytes(b"nope") + with self.assertRaises(CatalogueError): + use_catalogue_file(path) + + +class LegacyDatasetTests(CatalogueTestCase): + def legacy(self, **kwargs): + path = self.tmp / "datasets" / "data.db" + path.parent.mkdir() + build_catalogue(path, **kwargs) + return path + + def run_in_tmp(self, action): + previous = os.getcwd() + os.chdir(self.tmp) + try: + return action() + finally: + os.chdir(previous) + + def test_a_datasets_folder_is_never_installed_on_its_own(self): + self.legacy(version=V2) + + def ensure(): + with fake_http(requests.ConnectionError("offline")): + return ensure_catalogue(BASE, directory=self.dir) + + with self.assertRaises(CatalogueUnavailable): + self.run_in_tmp(ensure) + + self.assertFalse((self.dir / "data.db").exists()) + self.assertIsNone(installed_catalogue(self.dir)) + + def test_it_is_installed_when_it_is_imported_on_purpose(self): + legacy = self.legacy(version=V2) + + info = import_catalogue(legacy, directory=self.dir) + + self.assertEqual(info.version, V2) + self.assertEqual(self.version(), V2) + + +class HardeningTests(CatalogueTestCase): + def catalogue_with(self, sql, name="hostile.db"): + path = self.tmp / name + build_catalogue(path) + conn = sqlite3.connect(path) + conn.executescript(sql) + conn.commit() + conn.close() + return path + + TRIGGER = """ + CREATE TABLE resource_inventory (resource_type INTEGER, count INTEGER); + CREATE TRIGGER rewrite AFTER INSERT ON resource_inventory + BEGIN UPDATE resource_inventory SET count = 999999; END; + """ + VIEW = "CREATE VIEW everything AS SELECT * FROM resourcetype;" + + def test_a_catalogue_with_a_trigger_or_a_view_is_refused_at_install(self): + for what, sql in (("trigger", self.TRIGGER), ("view", self.VIEW)): + with self.subTest(what): + path = self.catalogue_with(sql, f"{what}.db") + + with self.assertRaises(CatalogueError) as raised: + import_catalogue(path, directory=self.dir) + + self.assertIn(what, str(raised.exception)) + self.assertIn("it was not used", str(raised.exception)) + self.assertFalse((self.dir / "data.db").exists()) + + def test_a_gzipped_catalogue_with_a_trigger_is_refused_too(self): + path = self.catalogue_with(self.TRIGGER) + packed = self.tmp / "hostile.db.gz" + packed.write_bytes(gzip.compress(path.read_bytes())) + + with self.assertRaises(CatalogueError): + import_catalogue(packed, directory=self.dir) + with self.assertRaises(CatalogueError): + use_catalogue_file(packed) + self.assertIsNone(installed_catalogue(self.dir)) + + def test_a_catalogue_file_given_on_the_command_line_is_refused(self): + path = self.catalogue_with(self.VIEW) + + with self.assertRaises(CatalogueError): + use_catalogue_file(path) + + def test_extra_tables_and_indexes_are_still_accepted(self): + path = self.catalogue_with( + "CREATE TABLE extra (a TEXT, b TEXT);" + "CREATE INDEX extra_a ON extra (a);" + "CREATE INDEX resourcetype_code ON resourcetype (code);" + ) + + info = import_catalogue(path, directory=self.dir) + + self.assertEqual(info.version, "2026-11-02") + + def test_a_trigger_that_got_past_the_install_is_stopped_before_the_copy_is_used( + self, + ): + path = self.catalogue_with(self.TRIGGER) + set_active_catalogue(path) + report = self.tmp / "report" + (report / "data").mkdir(parents=True) + + with patch("cloudexit.report.assets.shutil.copytree"): + with self.assertRaises(CatalogueError): + copy_assets(str(report), 2) + + self.assertFalse((report / "data" / "assessment.db").exists()) + + def test_the_resource_inventory_stage_fails_without_running_the_triggers(self): + path = self.catalogue_with(self.TRIGGER) + set_active_catalogue(path) + report = self.tmp / "report" + raw = report / "raw_data" + raw.mkdir(parents=True) + + with ( + patch("cloudexit.report.assets.shutil.copytree"), + patch("cloudexit.engine.build_aws_resource_inventory") as collected, + ): + result = create_resource_inventory(2, {}, str(report), str(raw)) + + self.assertFalse(result["success"]) + self.assertIn("trigger", result["logs"]) + collected.assert_not_called() + self.assertFalse((report / "data" / "assessment.db").exists()) + + def test_the_connections_do_not_trust_the_schema(self): + path = self.tmp / "plain.db" + build_catalogue(path) + + with connect(path) as conn: + self.assertEqual(conn.execute("PRAGMA trusted_schema").fetchone()[0], 0) + read_only = _open_readonly(path) + try: + self.assertEqual( + read_only.execute("PRAGMA trusted_schema").fetchone()[0], 0 + ) + finally: + read_only.close() + + +class EnsureCatalogueTests(CatalogueTestCase): + def test_a_failed_update_keeps_the_local_dataset(self): + self.install(V1) + with fake_http(requests.ConnectionError("offline")): + result = ensure_catalogue(BASE, directory=self.dir) + + self.assertEqual(result.status, "failed") + self.assertEqual(result.version, V1) + self.assertEqual(result.path, self.dir / "data.db") + self.assertIn("offline", result.reason) + self.assertRegex(result.date, r"^\d{4}-\d\d-\d\d$") + + def test_a_failed_update_with_an_unknown_version_dataset(self): + self.install(meta=False) + with fake_http(requests.ConnectionError("offline")): + result = ensure_catalogue(BASE, directory=self.dir) + self.assertEqual(result.status, "failed") + self.assertIsNone(result.version) + + def test_a_failed_update_without_any_dataset_is_unavailable(self): + with fake_http(requests.ConnectionError("offline")): + with self.assertRaises(CatalogueUnavailable): + ensure_catalogue(BASE, directory=self.dir) + + def test_a_server_error_does_not_stop_a_run_with_a_dataset(self): + self.install(V1) + with fake_http(make_response(status=503)): + result = ensure_catalogue(BASE, directory=self.dir) + self.assertEqual(result.status, "failed") + + def test_updates_and_reports_the_new_version(self): + self.install(V1) + gz, entry = self.release(V2) + with fake_http(self.manifest_reply(entry), make_response(content=gz)): + result = ensure_catalogue(BASE, directory=self.dir) + self.assertEqual((result.status, result.version), ("updated", V2)) + + +class StatusTests(CatalogueTestCase): + def test_shows_installed_and_latest(self): + self.install(V1) + _, entry = self.release(V2) + with fake_http(self.manifest_reply(entry)): + text = "\n".join(status_lines(BASE, directory=self.dir)) + + self.assertIn(f"installed: {V1} (sequence 41)", text) + self.assertIn("(today)", text) + self.assertIn(f"latest: {V2} (sequence 41)", text) + self.assertIn("an update is available", text) + + def test_shows_how_old_the_installed_dataset_is(self): + self.install(V1) + state = read_state(self.dir) + state["downloaded_at"] = "2020-01-01T00:00:00+00:00" + (self.dir / "state.json").write_text(json.dumps(state)) + with fake_http(make_response(status=404)): + text = "\n".join(status_lines(BASE, directory=self.dir)) + self.assertIn("2020-01-01 (", text) + self.assertIn("days ago)", text) + + def test_nothing_installed_and_no_manifest(self): + with fake_http(make_response(status=404)): + text = "\n".join(status_lines(BASE, directory=self.dir)) + self.assertIn("installed: none", text) + self.assertIn("no manifest is published", text) + + def test_unknown_version_and_unreachable_server(self): + self.install(meta=False) + with fake_http(requests.ConnectionError("offline")): + text = "\n".join(status_lines(BASE, directory=self.dir)) + self.assertIn("unknown (the dataset has no catalogue_meta)", text) + self.assertIn("unavailable", text) + + +class LocationTests(unittest.TestCase): + @unittest.skipIf(os.name == "nt", "absolute /xdg paths are POSIX paths") + def test_xdg_data_home_and_defaults(self): + self.assertEqual( + catalogue_dir({"XDG_DATA_HOME": "/xdg"}, platform="linux"), + Path("/xdg/cloudexit/catalogue"), + ) + self.assertEqual( + catalogue_dir({}, platform="linux"), + Path.home() / ".local" / "share" / "cloudexit" / "catalogue", + ) + self.assertEqual( + catalogue_dir({"XDG_DATA_HOME": "relative"}, platform="linux"), + Path.home() / ".local" / "share" / "cloudexit" / "catalogue", + ) + + def test_localappdata_on_windows(self): + self.assertEqual( + catalogue_dir({"LOCALAPPDATA": "/local"}, platform="win32"), + Path("/local/cloudexit/catalogue"), + ) + + def test_the_base_url_defaults_and_can_be_overridden(self): + self.assertEqual(catalogue_base_url(Settings()), CATALOGUE_BASE_URL) + override = Settings( + catalogue_url=Setting(BASE + "/", "env CLOUDEXIT_CATALOGUE_URL") + ) + self.assertEqual(catalogue_base_url(override), BASE) + + def test_a_plain_http_base_url_is_refused_except_for_localhost(self): + remote = Settings(catalogue_url=Setting("http://example.com", "env X")) + with self.assertRaises(SettingsError): + catalogue_base_url(remote) + local = Settings(catalogue_url=Setting("http://localhost:9000/m", "env X")) + self.assertEqual(catalogue_base_url(local), "http://localhost:9000/m") + + +class RunIntegrationTests(CatalogueTestCase): + def args(self, **kwargs): + return Namespace(settings=Settings(), catalogue=None, **kwargs) + + def ensure(self, **kwargs): + defaults = dict( + path=Path("/x/data.db"), status="current", version=V2, date="2026-11-03" + ) + defaults.update(kwargs) + return patch( + "cloudexit.cli.app.ensure_catalogue", return_value=EnsureResult(**defaults) + ) + + def test_a_failed_update_is_a_visible_step_result(self): + with ( + self.ensure(status="failed", version=V1, reason="cannot reach server"), + patch("cloudexit.cli.app.print_step") as mock_step, + ): + app.prepare_catalogue(self.args()) + + mock_step.assert_called_once_with( + f"Catalogue update failed, using {V1} from 2026-11-03.", + status="warning", + logs="cannot reach server", + ) + + def test_a_failed_update_with_an_unknown_version_says_so(self): + with ( + self.ensure(status="failed", version=None, reason="offline"), + patch("cloudexit.cli.app.print_step") as mock_step, + ): + app.prepare_catalogue(self.args()) + self.assertIn("using unknown from 2026-11-03", mock_step.call_args.args[0]) + + def test_up_to_date_and_updated_steps(self): + for status, text in ( + ("current", f"Catalogue {V2} is up to date."), + ("updated", f"Catalogue updated to {V2}."), + ): + with self.subTest(status): + with ( + self.ensure(status=status), + patch("cloudexit.cli.app.print_step") as step, + ): + app.prepare_catalogue(self.args()) + step.assert_called_once_with(text, status="ok") + + def test_without_any_dataset_the_run_stops_with_the_config_code(self): + with ( + patch( + "cloudexit.cli.app.ensure_catalogue", + side_effect=CatalogueUnavailable("no dataset"), + ), + patch("cloudexit.cli.app.console.print") as mock_print, + ): + with self.assertRaises(SystemExit) as ctx: + app.prepare_catalogue(self.args()) + + self.assertEqual(ctx.exception.code, codes.CONFIG) + self.assertIn("no dataset", mock_print.call_args.args[0]) + + def test_catalogue_flag_uses_the_file_without_updating_or_installing(self): + self.install(V1) + before = (self.dir / "data.db").read_bytes() + path = self.tmp / "run.db.gz" + path.write_bytes(catalogue_gz(version=V3)) + args = Namespace(settings=Settings(), catalogue=str(path)) + + with ( + patch("cloudexit.cli.app.ensure_catalogue") as mock_ensure, + patch("cloudexit.cli.app.print_step") as mock_step, + ): + app.prepare_catalogue(args) + + mock_ensure.assert_not_called() + self.assertIn(V3, mock_step.call_args.args[0]) + self.assertIn("not installed", mock_step.call_args.args[0]) + self.assertEqual(catalogue_info().version, V3) + self.assertEqual((self.dir / "data.db").read_bytes(), before) + + def test_a_bad_catalogue_flag_stops_the_run(self): + args = Namespace(settings=Settings(), catalogue=str(self.tmp / "missing.db")) + with ( + patch("cloudexit.cli.app.console.print"), + self.assertRaises(SystemExit) as ctx, + ): + app.prepare_catalogue(args) + self.assertEqual(ctx.exception.code, codes.CONFIG) + + def test_the_report_copy_uses_the_dataset_the_run_used(self): + path = self.tmp / "run.db" + build_catalogue(path, version=V3) + set_active_catalogue(path) + report = self.tmp / "report" + (report / "data").mkdir(parents=True) + + with patch("cloudexit.report.assets.shutil.copytree"): + from cloudexit.report.assets import copy_assets + + copy_assets(str(report), 2) + + copied = report / "data" / "assessment.db" + self.assertEqual(catalogue_info(copied).version, V3) + + +class CatalogueCommandTests(CatalogueTestCase): + def run_command(self, *argv, replies=()): + out = io.StringIO() + environ = isolated_environ(self.tmp) + with ( + patch("sys.argv", ["cloudexit", "catalogue", *argv]), + patch.dict(os.environ, environ, clear=True), + patch( + "cloudexit.cli.app.load_settings", + return_value=Settings( + catalogue_url=Setting(BASE, "env CLOUDEXIT_CATALOGUE_URL") + ), + ), + patch("cloudexit.cli.app.prepare_catalogue") as mock_prepare, + patch("cloudexit.cli.app.console.print") as mock_print, + fake_http(*replies) as fake, + contextlib.redirect_stdout(out), + ): + try: + app.main() + code = None + except SystemExit as exc: + code = exc.code + mock_prepare.assert_not_called() + return out.getvalue(), code, fake, mock_print + + @property + def command_dir(self): + return self.tmp / "data" / "cloudexit" / "catalogue" + + def test_update_installs_the_latest(self): + gz, entry = self.release(V2) + out, code, fake, _ = self.run_command( + "update", + replies=[self.manifest_reply(entry), make_response(content=gz)], + ) + + self.assertIsNone(code) + self.assertIn(f"Catalogue updated to {V2}.", out) + self.assertEqual(catalogue_info(self.command_dir / "data.db").version, V2) + + def test_update_with_a_version(self): + gz1, entry1 = self.release(V1) + _, entry2 = self.release(V2) + out, code, _, _ = self.run_command( + "update", + "--version", + V1, + replies=[ + self.manifest_reply(entry2, [entry1]), + make_response(content=gz1), + ], + ) + self.assertIsNone(code) + self.assertIn(V1, out) + + def test_update_failure_exits_with_the_config_code(self): + out, code, _, mock_print = self.run_command( + "update", replies=[requests.ConnectionError("offline")] + ) + self.assertEqual(code, codes.CONFIG) + self.assertIn("offline", mock_print.call_args.args[0]) + + def test_status_prints_the_installed_and_latest_catalogue(self): + _, entry = self.release(V2) + out, code, _, _ = self.run_command( + "status", replies=[self.manifest_reply(entry)] + ) + self.assertIsNone(code) + self.assertIn("installed: none", out) + self.assertIn(f"latest: {V2}", out) + + def test_import_installs_a_file(self): + path = self.tmp / "offline.db.gz" + gz = catalogue_gz(version=V2) + path.write_bytes(gz) + out, code, fake, _ = self.run_command( + "import", str(path), "--sha256", sha256(gz) + ) + + self.assertIsNone(code) + self.assertEqual(fake.requests, []) + self.assertIn(f"Catalogue {V2} installed.", out) + + def test_import_of_a_corrupt_file_fails(self): + path = self.tmp / "bad.db.gz" + path.write_bytes(b"\x1f\x8b broken") + _, code, _, _ = self.run_command("import", str(path)) + self.assertEqual(code, codes.CONFIG) + self.assertFalse((self.command_dir / "data.db").exists()) + + def test_the_subcommand_is_required(self): + with ( + patch("sys.argv", ["cloudexit", "catalogue"]), + contextlib.redirect_stderr(io.StringIO()), + self.assertRaises(SystemExit), + ): + parse_arguments() + + def test_the_run_flag_is_accepted_on_aws_and_azure(self): + for provider in ("aws", "azure"): + with patch("sys.argv", ["cloudexit", provider, "--catalogue", "f.db.gz"]): + self.assertEqual(parse_arguments().catalogue, "f.db.gz") + + +class DatabaseContractTests(CatalogueTestCase): + def test_a_corrupt_state_file_is_ignored(self): + self.install(V1) + (self.dir / "state.json").write_text("not json") + self.assertEqual(read_state(self.dir), {}) + self.assertEqual(self.version(), V1) + + def test_installed_catalogue_ignores_an_empty_file(self): + self.dir.mkdir() + (self.dir / "data.db").write_bytes(b"") + self.assertIsNone(installed_catalogue(self.dir)) + + def test_the_catalogue_database_is_opened_read_only_for_info(self): + path = self.tmp / "data.db" + build_catalogue(path) + before = path.read_bytes() + catalogue_info(path) + active_resource_type_ids("aws", path) + self.assertEqual(path.read_bytes(), before) + with contextlib.closing(sqlite3.connect(path)) as conn: + self.assertEqual(conn.execute("PRAGMA integrity_check").fetchone()[0], "ok") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/catalogue/test_dataset_shapes.py b/tests/catalogue/test_dataset_shapes.py new file mode 100644 index 0000000..be5f09b --- /dev/null +++ b/tests/catalogue/test_dataset_shapes.py @@ -0,0 +1,490 @@ +import gzip +import json +import os +import re +import subprocess +import sys +import tempfile +import threading +import unittest +import zlib +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from unittest.mock import patch + +from tests.catalogue import test_catalogue as cat +from tests.support.catalogue import catalogue_gz, manifest, manifest_entry, sha256 +from tests.support.dataset_shapes import VERSION, build_shape +from tests.support.http import fake_http, make_response +from tests.support.isolation import isolated_environ +from tests.support.sync import schema_errors +from cloudexit import codes +from cloudexit.catalogue.store import ( + UNPACK_FLOOR, + CatalogueError, + read_state, + unpack_limit, +) +from cloudexit.catalogue.download import resolve_file_url, update_catalogue +from tests.support.paths import ROOT + +SMOKE_STATE = ROOT / "tests" / "fixtures" / "smoke" / "aws.tfstate" + + +class RunResult: + def __init__(self, directory, process): + self.dir = Path(directory) + self.code = process.returncode + self.output = " ".join((process.stdout + process.stderr).split()) + + def one(self, pattern): + found = list(self.dir.glob(pattern)) + assert len(found) == 1, (pattern, found) + return found[0] + + def html(self): + return self.one("reports/*/index.html").read_text(encoding="utf-8") + + def text(self): + return " ".join(re.sub(r"<[^>]+>", " ", self.html()).split()) + + def report_json(self): + return json.loads( + self.one("reports/*/raw_data/assessment_result.json").read_text() + ) + + def payload(self): + return json.loads(self.one("payload/*.json").read_text()) + + +class ShapeTestCase(unittest.TestCase): + def setUp(self): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.tmp = Path(tmp.name) + self.env = { + name: value + for name, value in os.environ.items() + if not name.startswith(("ESC_", "CLOUDEXIT_")) + } + self.env.update( + { + **isolated_environ(self.tmp), + "ESC_EXIT_STRATEGY": "1", + "ESC_ASSESSMENT_TYPE": "1", + "COLUMNS": "200", + } + ) + + def run_shape(self, shape): + # The offline pipeline end to end: tfstate in, report and sync document out + directory = self.tmp / shape + directory.mkdir() + dataset = build_shape(self.tmp, shape) + process = subprocess.run( + [ + sys.executable, + str(ROOT / "main.py"), + "aws", + "--tfstate", + str(SMOKE_STATE), + "--catalogue", + str(dataset), + "--non-interactive", + "--emit-payload", + "payload", + ], + cwd=directory, + env=self.env, + stdin=subprocess.DEVNULL, + capture_output=True, + text=True, + timeout=180, + ) + return RunResult(directory, process) + + def assert_a_complete_offline_run(self, result): + self.assertEqual(result.code, 0, result.output) + report = result.one("reports/*") + self.assertTrue((report / "index.html").is_file()) + self.assertEqual((report / "report.pdf").read_bytes()[:5], b"%PDF-") + self.assertGreater((report / "report.pdf").stat().st_size, 10000) + self.assertTrue((report / "raw_data" / "sync.json").is_file()) + self.assertEqual(schema_errors(result.payload()), []) + + +class AcceptedShapeTests(ShapeTestCase): + def test_a_legacy_dataset_runs_with_an_unknown_version_and_numbered_risks(self): + result = self.run_shape("legacy") + + self.assert_a_complete_offline_run(result) + self.assertRegex(result.text(), r"R2 No alternatives High") + self.assertIn("High · 1", result.text()) + self.assertIn("unknown", result.html().lower()) + self.assertIsNone(result.report_json()["provenance"]["catalogue_version"]) + # Without catalogue_meta the sync document makes no claim about a version + self.assertNotIn("catalogue", result.payload()["sync"]["collector"]) + + def test_the_current_dataset_gives_real_codes_and_the_version(self): + result = self.run_shape("current") + + self.assert_a_complete_offline_run(result) + text = result.text() + # catalogue_severity wins over severity, and risk.code over R + self.assertRegex(text, r"LOCK-002 No alternatives Critical") + self.assertIn("Critical · 1", text) + self.assertNotIn("R2 No alternatives", text) + self.assertIn(VERSION, text) + provenance = result.report_json()["provenance"] + self.assertEqual(provenance["catalogue_version"], VERSION) + claim = result.payload()["sync"]["collector"]["catalogue"] + self.assertEqual(claim["version"], VERSION) + self.assertEqual(claim["sequence"], 41) + self.assertEqual(len(claim["sha256"]), 64) + self.assertEqual(claim["resource_type_ids"], [1, 2, 3, 4]) + + def test_a_future_additive_dataset_works_exactly_like_the_current_one(self): + current = self.run_shape("current") + future = self.run_shape("future") + + self.assert_a_complete_offline_run(future) + self.assertEqual( + future.payload()["sync"]["collector"]["catalogue"], + current.payload()["sync"]["collector"]["catalogue"], + ) + self.assertEqual(future.payload()["inventory"], current.payload()["inventory"]) + self.assertEqual( + future.report_json()["data"]["risk_inventory"], + current.report_json()["data"]["risk_inventory"], + ) + self.assertEqual( + future.report_json()["data"]["resource_inventory"], + current.report_json()["data"]["resource_inventory"], + ) + self.assertRegex(future.text(), r"LOCK-002 No alternatives Critical") + + +class RejectedShapeTests(ShapeTestCase): + def assert_refused(self, shape, message): + result = self.run_shape(shape) + + self.assertEqual(result.code, codes.CONFIG, result.output) + self.assertIn(message, result.output) + self.assertNotIn("Traceback", result.output) + # Nothing is written as if the run had worked + self.assertFalse((result.dir / "reports").exists()) + self.assertFalse((result.dir / "payload").exists()) + + def test_a_newer_dataset_schema_asks_for_an_update(self): + self.assert_refused("refused", "Update cloudexit") + + def test_a_catalogue_meta_with_two_rows_is_refused(self): + self.assert_refused("two_meta_rows", "exactly one row, found 2") + + def test_a_truncated_download_is_refused(self): + self.assert_refused("truncated", "not a valid gzip archive") + + def test_a_file_that_is_not_sqlite_is_refused(self): + self.assert_refused("not_sqlite", "not a valid catalogue database") + + +class BrokenDownloadTests(cat.CatalogueTestCase): + def installed_bytes(self): + return (self.dir / "data.db").read_bytes() + + def test_a_checksum_mismatch_keeps_the_installed_dataset(self): + self.install(cat.V1) + before = self.installed_bytes() + gz, entry = self.release(cat.V2) + tampered = gz[:-1] + bytes([gz[-1] ^ 1]) + + with fake_http(self.manifest_reply(entry), make_response(content=tampered)): + with self.assertRaises(CatalogueError) as ctx: + update_catalogue(cat.BASE, directory=self.dir) + + self.assertIn("checksum mismatch", str(ctx.exception)) + self.assertEqual(self.installed_bytes(), before) + self.assertEqual(self.version(), cat.V1) + + def test_every_broken_shape_is_refused_and_the_installed_dataset_stays(self): + self.install(cat.V1) + before = self.installed_bytes() + shapes = ("two_meta_rows", "truncated", "not_sqlite", "refused") + for shape in shapes: + with self.subTest(shape): + path = build_shape(self.tmp, shape) + data = path.read_bytes() + if path.suffix != ".gz": + data = gzip.compress(data) + entry = manifest_entry(cat.V2, data) + reply = self.manifest_reply(entry) + with fake_http(reply, make_response(content=data)): + with self.assertRaises(CatalogueError): + update_catalogue(cat.BASE, directory=self.dir) + self.assertEqual(self.installed_bytes(), before) + self.assertEqual(read_state(self.dir)["version"], cat.V1) + self.assertEqual(list(self.dir.glob("*.tmp")), []) + + +class HostileManifestTests(cat.CatalogueTestCase): + def update_with_file(self, file): + gz, entry = self.release(cat.V2) + entry["file"] = file + with fake_http(self.manifest_reply(entry)) as fake: + with self.assertRaises(CatalogueError) as ctx: + update_catalogue(cat.BASE, directory=self.dir) + return fake, ctx.exception + + def test_a_file_on_another_host_is_refused_before_anything_is_fetched(self): + for file in ( + "https://elsewhere.example.test/releases/x.db.gz", + "http://elsewhere.example.test/x.db.gz", + "//elsewhere.example.test/x.db.gz", + "https://catalogue.example.test.elsewhere.example.test/mirror/x.db.gz", + "https://catalogue.example.test:8443/mirror/x.db.gz", + "https://user@elsewhere.example.test/mirror/x.db.gz", + ): + with self.subTest(file=file): + fake, error = self.update_with_file(file) + self.assertIn("outside the catalogue location", str(error)) + self.assertEqual([r.url for r in fake.requests], [cat.MANIFEST_URL]) + + def test_a_file_that_climbs_out_of_the_folder_is_refused(self): + for file in ( + "../other/x.db.gz", + "releases/../../other/x.db.gz", + "releases/%2e%2e/%2e%2e/other/x.db.gz", + "releases/..%2f..%2fother/x.db.gz", + "releases\\..\\..\\other\\x.db.gz", + "/other/x.db.gz", + "/mirror/../other/x.db.gz", + ): + with self.subTest(file=file): + fake, _ = self.update_with_file(file) + self.assertEqual([r.url for r in fake.requests], [cat.MANIFEST_URL]) + + def test_a_file_below_the_manifest_is_still_accepted(self): + for file in ("x.db.gz", "releases/x.db.gz", "./releases/x.db.gz"): + with self.subTest(file=file): + url = resolve_file_url(cat.MANIFEST_URL, file) + self.assertTrue(url.startswith(cat.BASE + "/")) + + def test_the_manifest_never_decides_the_host_even_with_a_version_choice(self): + _, entry = self.release(cat.V2) + entry["file"] = "https://elsewhere.example.test/x.db.gz" + with fake_http(self.manifest_reply(entry)) as fake: + with self.assertRaises(CatalogueError): + update_catalogue(cat.BASE, directory=self.dir, version=cat.V2) + self.assertEqual(len(fake.requests), 1) + + +def zeros_gz(size): + inflater = zlib.compressobj(9, zlib.DEFLATED, zlib.MAX_WBITS | 16) + return inflater.compress(bytes(size)) + inflater.flush() + + +class DecompressionBombTests(cat.CatalogueTestCase): + def test_the_limit_follows_the_download_between_a_floor_and_a_ceiling(self): + self.assertEqual(unpack_limit(None), UNPACK_FLOOR) + self.assertEqual(unpack_limit(1000), UNPACK_FLOOR) + self.assertEqual(unpack_limit(10 * 1024 * 1024), 100 * 1024 * 1024) + self.assertEqual(unpack_limit(10**12), 512 * 1024 * 1024) + + def test_a_small_archive_that_unpacks_far_beyond_the_limit_is_refused(self): + self.install(cat.V1) + before = (self.dir / "data.db").read_bytes() + bomb = zeros_gz(UNPACK_FLOOR + 4 * 1024 * 1024) + self.assertLess(len(bomb), 100 * 1024) + entry = manifest_entry(cat.V2, bomb) + + with fake_http(self.manifest_reply(entry), make_response(content=bomb)): + with self.assertRaises(CatalogueError) as ctx: + update_catalogue(cat.BASE, directory=self.dir) + + self.assertIn("unpacks to more than", str(ctx.exception)) + self.assertEqual((self.dir / "data.db").read_bytes(), before) + self.assertEqual(self.version(), cat.V1) + self.assertEqual(list(self.dir.glob("*.tmp")), []) + + def test_the_bomb_is_stopped_while_unpacking_not_after(self): + bomb = zeros_gz(UNPACK_FLOOR + 4 * 1024 * 1024) + entry = manifest_entry(cat.V2, bomb) + written = [] + real_write = Path.write_bytes + + def watch(path, data): + written.append(len(data)) + return real_write(path, data) + + with ( + fake_http(self.manifest_reply(entry), make_response(content=bomb)), + patch.object(Path, "write_bytes", watch), + self.assertRaises(CatalogueError), + ): + update_catalogue(cat.BASE, directory=self.dir) + self.assertTrue(all(size < UNPACK_FLOOR for size in written)) + + def test_many_small_members_cannot_get_past_the_limit(self): + member = zeros_gz(UNPACK_FLOOR // 4) + bomb = member * 8 + entry = manifest_entry(cat.V2, bomb) + + with fake_http(self.manifest_reply(entry), make_response(content=bomb)): + with self.assertRaises(CatalogueError) as ctx: + update_catalogue(cat.BASE, directory=self.dir) + self.assertIn("unpacks to more than", str(ctx.exception)) + + def test_an_announced_size_cannot_widen_the_limit_past_the_ceiling(self): + bomb = zeros_gz(UNPACK_FLOOR + 4 * 1024 * 1024) + entry = manifest_entry(cat.V2, bomb) + entry["size_bytes"] = 10**12 + + with ( + patch("cloudexit.catalogue.store.UNPACK_CEILING", UNPACK_FLOOR), + fake_http(self.manifest_reply(entry), make_response(content=bomb)), + self.assertRaises(CatalogueError), + ): + update_catalogue(cat.BASE, directory=self.dir) + + def test_the_latest_file_fallback_and_an_imported_file_have_the_same_limit(self): + bomb = zeros_gz(UNPACK_FLOOR + 4 * 1024 * 1024) + digest = sha256(bomb) + with fake_http( + make_response(404), + make_response(text=f"{digest} cloudexit-latest.db.gz\n"), + make_response(content=bomb), + ): + with self.assertRaises(CatalogueError) as ctx: + update_catalogue(cat.BASE, directory=self.dir) + self.assertIn("unpacks to more than", str(ctx.exception)) + + path = self.tmp / "bomb.db.gz" + path.write_bytes(bomb) + with self.assertRaises(CatalogueError): + cat.import_catalogue(path, directory=self.dir) + with self.assertRaises(CatalogueError): + cat.use_catalogue_file(path) + + def test_a_real_dataset_is_far_below_the_limit(self): + gz = catalogue_gz(version=cat.V2) + self.assertLess(len(gzip.decompress(gz)), UNPACK_FLOOR) + entry = manifest_entry(cat.V2, gz) + with fake_http(self.manifest_reply(entry), make_response(content=gz)): + result = update_catalogue(cat.BASE, directory=self.dir) + self.assertEqual(result.status, "updated") + + +class RecordingServer: + # Two servers on localhost: the first redirects the dataset to the second + # and both record what they were sent. + def __init__(self, gz, entry_for): + self.received = {"first": [], "second": []} + outer = self + + class Second(BaseHTTPRequestHandler): + def do_GET(self): + outer.received["second"].append( + (self.path, {k.lower(): v for k, v in self.headers.items()}) + ) + self.send_response(200) + self.send_header("Content-Length", str(len(gz))) + self.end_headers() + self.wfile.write(gz) + + def log_message(self, *args): + pass + + self.second = ThreadingHTTPServer(("127.0.0.1", 0), Second) + second_url = f"http://127.0.0.1:{self.second.server_port}/stolen.db.gz" + + class First(BaseHTTPRequestHandler): + def do_GET(self): + outer.received["first"].append( + (self.path, {k.lower(): v for k, v in self.headers.items()}) + ) + if self.path.endswith("cloudexit-manifest.json"): + body = json.dumps( + manifest(entry_for(f"http://127.0.0.1:{outer.first_port}")) + ).encode() + self.send_response(200) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + else: + self.send_response(302) + self.send_header("Location", second_url) + self.send_header("Content-Length", "0") + self.end_headers() + + def log_message(self, *args): + pass + + self.first = ThreadingHTTPServer(("127.0.0.1", 0), First) + self.first_port = self.first.server_port + self.url = f"http://127.0.0.1:{self.first_port}" + self.threads = [ + threading.Thread(target=server.serve_forever, daemon=True) + for server in (self.first, self.second) + ] + + def __enter__(self): + for thread in self.threads: + thread.start() + return self + + def __exit__(self, *exc): + for server in (self.first, self.second): + server.shutdown() + server.server_close() + for thread in self.threads: + thread.join() + + +class RedirectTests(cat.CatalogueTestCase): + def test_a_redirect_to_another_host_carries_no_credentials(self): + home = self.tmp / "home" + home.mkdir() + # A login the user has for that host must not be sent along either + (home / ".netrc").write_text( + "machine 127.0.0.1 login someone password hunter2\n", encoding="utf-8" + ) + (home / "_netrc").write_text( + "machine 127.0.0.1 login someone password hunter2\n", encoding="utf-8" + ) + gz = catalogue_gz(version=cat.V2) + + def entry(base): + return manifest_entry(cat.V2, gz, file="releases/cloudexit.db.gz") + + env = { + "HOME": str(home), + "USERPROFILE": str(home), + "NETRC": str(home / ".netrc"), + "NO_PROXY": "*", + "no_proxy": "*", + } + with RecordingServer(gz, entry) as servers, patch.dict(os.environ, env): + update_catalogue(servers.url, directory=self.dir) + + seen = servers.received["first"] + servers.received["second"] + self.assertEqual(len(servers.received["second"]), 1) + for path, headers in seen: + self.assertNotIn("authorization", headers, path) + self.assertNotIn("cookie", headers, path) + self.assertNotIn("proxy-authorization", headers, path) + self.assertNotIn("hunter2", json.dumps(headers)) + self.assertEqual(self.version(), cat.V2) + + def test_the_request_itself_has_no_headers_cookies_or_body(self): + gz, entry = self.release(cat.V2) + with fake_http(self.manifest_reply(entry), make_response(content=gz)) as fake: + update_catalogue(cat.BASE, directory=self.dir) + + for request in fake.requests: + for name in ("headers", "cookies", "data", "json"): + self.assertIsNone(request.kwargs.get(name), name) + self.assertIs(request.kwargs["verify"], True) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/cli/__init__.py b/tests/cli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/cli/test_app.py b/tests/cli/test_app.py new file mode 100644 index 0000000..5b12a55 --- /dev/null +++ b/tests/cli/test_app.py @@ -0,0 +1,1236 @@ +import json +import os +import tempfile +import unittest +from argparse import Namespace +from pathlib import Path +from unittest.mock import ANY, MagicMock, patch + +from cloudexit.cli import app +from cloudexit.cli.parser import parse_arguments +from cloudexit.platform.document import SyncOutput +from cloudexit import codes +from cloudexit.settings import Settings + +VALID_CONFIG = { + "name": "Test Assessment", + "cloudServiceProvider": 2, + "exitStrategy": 1, + "assessmentType": 1, + "providerDetails": { + "accessKey": "AKIAIOSFODNN7EXAMPLE", + "secretKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "region": "eu-central-1", + }, +} + + +def _base_patches(): + return [ + patch("cloudexit.cli.app.console.print"), + patch("cloudexit.cli.app.print_step"), + patch( + "cloudexit.cli.app.create_directory", + return_value=("/tmp/report", "/tmp/report/raw"), + ), + patch("cloudexit.cli.app.verify_credentials", return_value=(True, "ok")), + patch( + "cloudexit.cli.app.test_permissions", + return_value=(True, True, True, "ok"), + ), + patch( + "cloudexit.cli.app.create_resource_inventory", + return_value={"success": True, "logs": ""}, + ), + patch( + "cloudexit.cli.app.create_cost_inventory", + return_value={"success": True, "logs": ""}, + ), + patch( + "cloudexit.cli.app.perform_risk_assessment", + return_value={"success": True, "logs": ""}, + ), + patch( + "cloudexit.cli.app.generate_report", + return_value={ + "success": True, + "reports": {"HTML": "/tmp/report/index.html"}, + }, + ), + ] + + +class RunAssessmentExitCodeTests(unittest.TestCase): + def _run_with_patches(self, overrides: dict): + patches = _base_patches() + for p in patches: + p.start() + for target, kwargs in overrides.items(): + patch(target, **kwargs).start() + try: + app.run_assessment(VALID_CONFIG.copy(), "aws") + finally: + patch.stopall() + + def test_config_validation_failure_exits_2(self): + with self.assertRaises(SystemExit) as ctx: + with ( + patch( + "cloudexit.cli.app.validate_config", + side_effect=ValueError("bad config"), + ), + patch("cloudexit.cli.app.create_directory"), + patch("cloudexit.cli.app.verify_credentials"), + patch("cloudexit.cli.app.print_step"), + patch("cloudexit.cli.app.console.print"), + ): + app.run_assessment(VALID_CONFIG.copy(), "aws") + self.assertEqual(ctx.exception.code, codes.CONFIG) + + def test_directory_creation_failure_exits_2(self): + with self.assertRaises(SystemExit) as ctx: + with ( + patch("cloudexit.cli.app.validate_config"), + patch( + "cloudexit.cli.app.create_directory", + side_effect=RuntimeError("disk full"), + ), + patch("cloudexit.cli.app.verify_credentials"), + patch("cloudexit.cli.app.print_step"), + patch("cloudexit.cli.app.console.print"), + ): + app.run_assessment(VALID_CONFIG.copy(), "aws") + self.assertEqual(ctx.exception.code, codes.CONFIG) + + def test_credential_failure_exits_3(self): + with self.assertRaises(SystemExit) as ctx: + with ( + patch("cloudexit.cli.app.validate_config"), + patch( + "cloudexit.cli.app.create_directory", + return_value=("/tmp/r", "/tmp/r/raw"), + ), + patch( + "cloudexit.cli.app.verify_credentials", + return_value=(False, "bad creds"), + ), + patch("cloudexit.cli.app.print_step"), + patch("cloudexit.cli.app.console.print"), + ): + app.run_assessment(VALID_CONFIG.copy(), "aws") + self.assertEqual(ctx.exception.code, codes.CREDENTIALS) + + def test_permission_failure_exits_4(self): + with self.assertRaises(SystemExit) as ctx: + with ( + patch("cloudexit.cli.app.validate_config"), + patch( + "cloudexit.cli.app.create_directory", + return_value=("/tmp/r", "/tmp/r/raw"), + ), + patch( + "cloudexit.cli.app.verify_credentials", return_value=(True, "ok") + ), + patch( + "cloudexit.cli.app.test_permissions", + return_value=(False, False, False, "no perms"), + ), + patch("cloudexit.cli.app.print_step"), + patch("cloudexit.cli.app.console.print"), + ): + app.run_assessment(VALID_CONFIG.copy(), "aws") + self.assertEqual(ctx.exception.code, codes.PERMISSIONS) + + def test_resource_inventory_failure_exits_5(self): + with self.assertRaises(SystemExit) as ctx: + with ( + patch("cloudexit.cli.app.validate_config"), + patch( + "cloudexit.cli.app.create_directory", + return_value=("/tmp/r", "/tmp/r/raw"), + ), + patch( + "cloudexit.cli.app.verify_credentials", return_value=(True, "ok") + ), + patch( + "cloudexit.cli.app.test_permissions", + return_value=(True, True, True, "ok"), + ), + patch( + "cloudexit.cli.app.create_resource_inventory", + return_value={"success": False, "logs": "api error"}, + ), + patch("cloudexit.cli.app.print_step"), + patch("cloudexit.cli.app.console.print"), + ): + app.run_assessment(VALID_CONFIG.copy(), "aws") + self.assertEqual(ctx.exception.code, codes.RESOURCE_INVENTORY) + + def test_cost_inventory_failure_exits_6(self): + with self.assertRaises(SystemExit) as ctx: + with ( + patch("cloudexit.cli.app.validate_config"), + patch( + "cloudexit.cli.app.create_directory", + return_value=("/tmp/r", "/tmp/r/raw"), + ), + patch( + "cloudexit.cli.app.verify_credentials", return_value=(True, "ok") + ), + patch( + "cloudexit.cli.app.test_permissions", + return_value=(True, True, True, "ok"), + ), + patch( + "cloudexit.cli.app.create_resource_inventory", + return_value={"success": True, "logs": ""}, + ), + patch( + "cloudexit.cli.app.create_cost_inventory", + return_value={"success": False, "logs": "billing error"}, + ), + patch("cloudexit.cli.app.print_step"), + patch("cloudexit.cli.app.console.print"), + ): + app.run_assessment(VALID_CONFIG.copy(), "aws") + self.assertEqual(ctx.exception.code, codes.COST_INVENTORY) + + def test_risk_assessment_failure_exits_7(self): + with self.assertRaises(SystemExit) as ctx: + with ( + patch("cloudexit.cli.app.validate_config"), + patch( + "cloudexit.cli.app.create_directory", + return_value=("/tmp/r", "/tmp/r/raw"), + ), + patch( + "cloudexit.cli.app.verify_credentials", return_value=(True, "ok") + ), + patch( + "cloudexit.cli.app.test_permissions", + return_value=(True, True, True, "ok"), + ), + patch( + "cloudexit.cli.app.create_resource_inventory", + return_value={"success": True, "logs": ""}, + ), + patch( + "cloudexit.cli.app.create_cost_inventory", + return_value={"success": True, "logs": ""}, + ), + patch( + "cloudexit.cli.app.perform_risk_assessment", + return_value={"success": False, "logs": "risk error"}, + ), + patch("cloudexit.cli.app.print_step"), + patch("cloudexit.cli.app.console.print"), + ): + app.run_assessment(VALID_CONFIG.copy(), "aws") + self.assertEqual(ctx.exception.code, codes.RISK_ASSESSMENT) + + def test_report_generation_failure_exits_8(self): + with self.assertRaises(SystemExit) as ctx: + with ( + patch("cloudexit.cli.app.validate_config"), + patch( + "cloudexit.cli.app.create_directory", + return_value=("/tmp/r", "/tmp/r/raw"), + ), + patch( + "cloudexit.cli.app.verify_credentials", return_value=(True, "ok") + ), + patch( + "cloudexit.cli.app.test_permissions", + return_value=(True, True, True, "ok"), + ), + patch( + "cloudexit.cli.app.create_resource_inventory", + return_value={"success": True, "logs": ""}, + ), + patch( + "cloudexit.cli.app.create_cost_inventory", + return_value={"success": True, "logs": ""}, + ), + patch( + "cloudexit.cli.app.perform_risk_assessment", + return_value={"success": True, "logs": ""}, + ), + patch( + "cloudexit.cli.app.generate_report", + return_value={"success": False, "logs": "render error"}, + ), + patch("cloudexit.cli.app.print_step"), + patch("cloudexit.cli.app.console.print"), + ): + app.run_assessment(VALID_CONFIG.copy(), "aws") + self.assertEqual(ctx.exception.code, codes.REPORT) + + def test_unexpected_exception_exits_1(self): + with self.assertRaises(SystemExit) as ctx: + with ( + patch( + "cloudexit.cli.app.validate_config", + side_effect=RuntimeError("boom"), + ), + patch("cloudexit.cli.app.print_step"), + patch("cloudexit.cli.app.console.print"), + ): + app.run_assessment(VALID_CONFIG.copy(), "aws") + self.assertEqual(ctx.exception.code, codes.UNEXPECTED) + + def test_unexpected_error_writes_traceback_file(self): + with tempfile.TemporaryDirectory() as tmp_dir: + raw_data_path = os.path.join(tmp_dir, "raw") + os.makedirs(raw_data_path, exist_ok=True) + with self.assertRaises(SystemExit) as ctx: + with ( + patch("cloudexit.cli.app.validate_config"), + patch( + "cloudexit.cli.app.create_directory", + return_value=(tmp_dir, raw_data_path), + ), + patch( + "cloudexit.cli.app.verify_credentials", + side_effect=RuntimeError("boom-unexpected"), + ), + patch("cloudexit.cli.app.print_step"), + patch("cloudexit.cli.app.console.print"), + ): + app.run_assessment(VALID_CONFIG.copy(), "aws") + + self.assertEqual(ctx.exception.code, codes.UNEXPECTED) + logs = [f for f in os.listdir(tmp_dir) if f.startswith("error-")] + self.assertEqual(len(logs), 1) + body = Path(tmp_dir, logs[0]).read_text(encoding="utf-8") + self.assertIn("Traceback", body) + self.assertIn("boom-unexpected", body) + + def test_full_success_exits_0(self): + with ( + patch("cloudexit.cli.app.validate_config"), + patch( + "cloudexit.cli.app.create_directory", + return_value=("/tmp/r", "/tmp/r/raw"), + ), + patch("cloudexit.cli.app.verify_credentials", return_value=(True, "ok")), + patch( + "cloudexit.cli.app.test_permissions", + return_value=(True, True, True, "ok"), + ), + patch( + "cloudexit.cli.app.create_resource_inventory", + return_value={"success": True, "logs": ""}, + ), + patch( + "cloudexit.cli.app.create_cost_inventory", + return_value={"success": True, "logs": ""}, + ), + patch( + "cloudexit.cli.app.perform_risk_assessment", + return_value={"success": True, "logs": ""}, + ), + patch( + "cloudexit.cli.app.generate_report", + return_value={"success": True, "reports": {}}, + ), + patch("cloudexit.cli.app.print_step"), + patch("cloudexit.cli.app.console.print"), + ): + result = app.run_assessment(VALID_CONFIG.copy(), "aws") + + self.assertIsNone(result) + + def test_dry_run_writes_the_sync_document_generates_report_and_skips_sync(self): + with tempfile.TemporaryDirectory() as tmp_dir: + raw_data_path = os.path.join(tmp_dir, "raw_data") + os.makedirs(raw_data_path, exist_ok=True) + config = VALID_CONFIG.copy() + config["assessmentType"] = 2 + output = SyncOutput( + Path(raw_data_path) / "sync.json", 10, "0" * 64, None, "summary", {} + ) + + with ( + patch("cloudexit.cli.app.validate_config"), + patch( + "cloudexit.cli.app.create_directory", + return_value=(tmp_dir, raw_data_path), + ), + patch( + "cloudexit.cli.app.verify_credentials", return_value=(True, "ok") + ), + patch( + "cloudexit.cli.app.test_permissions", + return_value=(True, True, True, "ok"), + ), + patch( + "cloudexit.cli.app.create_resource_inventory", + return_value={"success": True, "logs": ""}, + ), + patch( + "cloudexit.cli.app.create_cost_inventory", + return_value={"success": True, "logs": ""}, + ), + patch( + "cloudexit.cli.app.prepare_sync_document", return_value=output + ) as mock_write, + patch( + "cloudexit.cli.app.perform_risk_assessment", + return_value={"success": True, "logs": ""}, + ) as mock_risk, + patch( + "cloudexit.cli.app.generate_report", + return_value={ + "success": True, + "reports": { + "HTML": f"{tmp_dir}/index.html", + "PDF": f"{tmp_dir}/report.pdf", + }, + }, + ) as mock_report, + patch("cloudexit.cli.app.upload") as mock_upload, + patch("cloudexit.cli.app.print_step"), + patch("cloudexit.cli.app.console.print"), + ): + app.run_assessment(config, "aws", dry_run=True) + + report_path, context = mock_write.call_args.args + self.assertEqual(report_path, tmp_dir) + self.assertEqual(context.assessment_type, 2) + self.assertEqual(context.cloud_service_provider, 2) + self.assertFalse(context.egress_collected) + self.assertEqual(context.egress_names, "plain") + self.assertIsNone(mock_write.call_args.kwargs["emit_dir"]) + mock_risk.assert_called_once_with( + exit_strategy=config["exitStrategy"], report_path=tmp_dir + ) + mock_report.assert_called_once() + mock_upload.assert_not_called() + + def test_dry_run_flag_passed_from_handle_aws(self): + with ( + patch.dict(os.environ, NonInteractiveAWSTests._BASE_ENV, clear=False), + patch("cloudexit.cli.app.validate_region"), + patch("cloudexit.cli.app.run_assessment") as mock_run, + patch("cloudexit.cli.app.console.print"), + ): + app.handle_aws(_ni_aws_args(dry_run=True)) + + mock_run.assert_called_once_with( + ANY, + "aws", + dry_run=True, + non_interactive=True, + egress=False, + settings=None, + emit_payload=None, + egress_names="plain", + spool_on_error=False, + flush_spool=True, + strict=False, + ) + + +def _ni_aws_args(**kwargs): + defaults = dict( + config=None, + profile=None, + name=None, + non_interactive=True, + dry_run=False, + egress=False, + ) + defaults.update(kwargs) + return Namespace(**defaults) + + +def _ni_azure_args(**kwargs): + defaults = dict( + config=None, + cli=False, + name=None, + non_interactive=True, + dry_run=False, + egress=False, + ) + defaults.update(kwargs) + return Namespace(**defaults) + + +class NonInteractiveAWSTests(unittest.TestCase): + _BASE_ENV = { + "AWS_ACCESS_KEY_ID": "AKIAIOSFODNN7EXAMPLE", + "AWS_SECRET_ACCESS_KEY": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "AWS_DEFAULT_REGION": "eu-central-1", + "ESC_EXIT_STRATEGY": "1", + "ESC_ASSESSMENT_TYPE": "1", + } + + def test_builds_config_from_env_and_calls_run_assessment(self): + with ( + patch.dict(os.environ, self._BASE_ENV, clear=False), + patch("cloudexit.cli.app.validate_region"), + patch("cloudexit.cli.app.run_assessment") as mock_run, + patch("cloudexit.cli.app.console.print"), + ): + app.handle_aws(_ni_aws_args()) + + mock_run.assert_called_once() + config_arg = mock_run.call_args[0][0] + self.assertEqual(config_arg["exitStrategy"], 1) + self.assertEqual(config_arg["assessmentType"], 1) + self.assertEqual( + config_arg["providerDetails"]["accessKey"], "AKIAIOSFODNN7EXAMPLE" + ) + self.assertEqual(config_arg["providerDetails"]["region"], "eu-central-1") + + def test_includes_optional_session_token_from_env(self): + env = {**self._BASE_ENV, "AWS_SESSION_TOKEN": "sts-session-token"} + with ( + patch.dict(os.environ, env, clear=False), + patch("cloudexit.cli.app.validate_region"), + patch("cloudexit.cli.app.run_assessment") as mock_run, + patch("cloudexit.cli.app.console.print"), + ): + app.handle_aws(_ni_aws_args()) + + config_arg = mock_run.call_args[0][0] + self.assertEqual( + config_arg["providerDetails"]["sessionToken"], "sts-session-token" + ) + + def test_missing_exit_strategy_exits_config(self): + env = {k: v for k, v in self._BASE_ENV.items() if k != "ESC_EXIT_STRATEGY"} + with ( + patch.dict(os.environ, env, clear=False), + patch("cloudexit.cli.app.console.print"), + ): + with self.assertRaises(SystemExit) as ctx: + app.handle_aws(_ni_aws_args()) + self.assertEqual(ctx.exception.code, codes.CONFIG) + + def test_invalid_exit_strategy_exits_config(self): + for value in ("4", "9"): + with self.subTest(value=value): + env = {**self._BASE_ENV, "ESC_EXIT_STRATEGY": value} + with ( + patch.dict(os.environ, env, clear=False), + patch("cloudexit.cli.app.console.print"), + ): + with self.assertRaises(SystemExit) as ctx: + app.handle_aws(_ni_aws_args()) + self.assertEqual(ctx.exception.code, codes.CONFIG) + + def test_the_hybrid_exit_strategy_reaches_the_assessment(self): + env = {**self._BASE_ENV, "ESC_EXIT_STRATEGY": "2"} + with ( + patch.dict(os.environ, env, clear=False), + patch("cloudexit.cli.app.validate_region"), + patch("cloudexit.cli.app.run_assessment") as mock_run, + patch("cloudexit.cli.app.console.print"), + ): + app.handle_aws(_ni_aws_args()) + + self.assertEqual(mock_run.call_args[0][0]["exitStrategy"], 2) + + def test_missing_aws_region_exits_config(self): + env = {k: v for k, v in self._BASE_ENV.items() if k != "AWS_DEFAULT_REGION"} + with ( + patch.dict(os.environ, env, clear=False), + patch("cloudexit.cli.app.console.print"), + ): + with self.assertRaises(SystemExit) as ctx: + app.handle_aws(_ni_aws_args()) + self.assertEqual(ctx.exception.code, codes.CONFIG) + + +class NonInteractiveAzureTests(unittest.TestCase): + _BASE_ENV = { + "AZURE_TENANT_ID": "tenant-id-123", + "AZURE_CLIENT_ID": "client-id-456", + "AZURE_CLIENT_SECRET": "super-secret", + "ESC_SUBSCRIPTION_ID": "sub-id-789", + "ESC_RESOURCE_GROUP": "my-rg", + "ESC_EXIT_STRATEGY": "1", + "ESC_ASSESSMENT_TYPE": "1", + } + + def test_builds_config_from_env_and_calls_run_assessment(self): + mock_credential = MagicMock() + with ( + patch.dict(os.environ, self._BASE_ENV, clear=False), + patch( + "azure.identity.ClientSecretCredential", return_value=mock_credential + ), + patch("cloudexit.cli.app.run_assessment") as mock_run, + patch("cloudexit.cli.app.console.print"), + ): + app.handle_azure(_ni_azure_args()) + + mock_run.assert_called_once() + config_arg = mock_run.call_args[0][0] + self.assertEqual(config_arg["exitStrategy"], 1) + self.assertEqual(config_arg["providerDetails"]["tenantId"], "tenant-id-123") + self.assertEqual(config_arg["providerDetails"]["subscriptionId"], "sub-id-789") + self.assertEqual(config_arg["providerDetails"]["resourceGroupName"], "my-rg") + + def test_the_hybrid_exit_strategy_reaches_the_assessment(self): + env = {**self._BASE_ENV, "ESC_EXIT_STRATEGY": "2"} + with ( + patch.dict(os.environ, env, clear=False), + patch("azure.identity.ClientSecretCredential", return_value=MagicMock()), + patch("cloudexit.cli.app.run_assessment") as mock_run, + patch("cloudexit.cli.app.console.print"), + ): + app.handle_azure(_ni_azure_args()) + + self.assertEqual(mock_run.call_args[0][0]["exitStrategy"], 2) + + def test_uses_default_credential_when_client_secret_missing(self): + env = {k: v for k, v in self._BASE_ENV.items() if k != "AZURE_CLIENT_SECRET"} + mock_credential = MagicMock() + with ( + patch.dict(os.environ, env, clear=False), + patch( + "azure.identity.DefaultAzureCredential", return_value=mock_credential + ), + patch("azure.identity.ClientSecretCredential") as mock_client_secret_cred, + patch("cloudexit.cli.app.run_assessment") as mock_run, + patch("cloudexit.cli.app.console.print"), + ): + app.handle_azure(_ni_azure_args()) + + mock_client_secret_cred.assert_not_called() + mock_run.assert_called_once() + config_arg = mock_run.call_args[0][0] + self.assertEqual(config_arg["providerDetails"]["credential"], mock_credential) + self.assertEqual(config_arg["providerDetails"]["tenantId"], "tenant-id-123") + self.assertEqual(config_arg["providerDetails"]["subscriptionId"], "sub-id-789") + self.assertEqual(config_arg["providerDetails"]["resourceGroupName"], "my-rg") + self.assertEqual(config_arg["providerDetails"]["clientId"], "client-id-456") + self.assertNotIn("clientSecret", config_arg["providerDetails"]) + + def test_missing_client_id_for_oidc_raises_config_error(self): + env = { + k: v + for k, v in self._BASE_ENV.items() + if k not in ("AZURE_CLIENT_SECRET", "AZURE_CLIENT_ID") + } + with ( + patch.dict(os.environ, env, clear=False), + patch("cloudexit.cli.app.console.print"), + ): + with self.assertRaises(app.ConfigError): + app.handle_azure(_ni_azure_args()) + + def test_missing_subscription_id_exits_config(self): + env = {k: v for k, v in self._BASE_ENV.items() if k != "ESC_SUBSCRIPTION_ID"} + with ( + patch.dict(os.environ, env, clear=False), + patch("cloudexit.cli.app.console.print"), + ): + with self.assertRaises(SystemExit) as ctx: + app.handle_azure(_ni_azure_args()) + self.assertEqual(ctx.exception.code, codes.CONFIG) + + def test_missing_resource_group_exits_config(self): + env = {k: v for k, v in self._BASE_ENV.items() if k != "ESC_RESOURCE_GROUP"} + with ( + patch.dict(os.environ, env, clear=False), + patch("cloudexit.cli.app.console.print"), + ): + with self.assertRaises(SystemExit) as ctx: + app.handle_azure(_ni_azure_args()) + self.assertEqual(ctx.exception.code, codes.CONFIG) + + +class EgressStageTests(unittest.TestCase): + _ESTIMATE_OK = { + "success": True, + "logs": "", + "json_path": "/tmp/report/raw/egress_inventory_raw_data.json", + } + # The egress PDF is written with the report, from the same context + _WITH_PDF = { + "success": True, + "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 + def _azure_config(): + return { + "name": "Azure Egress Test", + "cloudServiceProvider": 1, + "exitStrategy": 1, + "assessmentType": 1, + "providerDetails": { + "credential": MagicMock(), + "tenantId": "tenant-id", + "subscriptionId": "sub-id", + "resourceGroupName": "my-rg", + }, + } + + def _run(self, config, provider, report=None, estimate=None, **kwargs): + patches = _base_patches() + for p in patches: + p.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(), + "print": patch("cloudexit.cli.app.console.print").start(), + "step": patch("cloudexit.cli.app.print_step").start(), + } + try: + app.run_assessment(config, provider, **kwargs) + finally: + patch.stopall() + return mocks + + def _steps(self, mocks): + return [call.args[0] for call in mocks["step"].call_args_list] + + def _printed(self, mocks): + return [str(call) for call in mocks["print"].call_args_list] + + def test_egress_module_not_invoked_without_flag(self): + mocks = self._run(self._azure_config(), "azure") + + mocks["estimate"].assert_not_called() + self.assertIsNone(mocks["report"].call_args.kwargs["egress_json_path"]) + + def test_the_estimate_comes_before_the_report_and_goes_into_it(self): + manager = MagicMock() + patches = _base_patches() + for p in patches: + p.start() + mock_report = patch( + "cloudexit.cli.app.generate_report", return_value=self._WITH_PDF + ).start() + mock_estimate = patch( + "cloudexit.cli.app.estimate_egress", return_value=self._ESTIMATE_OK + ).start() + manager.attach_mock(mock_report, "generate_report") + manager.attach_mock(mock_estimate, "estimate_egress") + config = self._azure_config() + try: + app.run_assessment(config, "azure", egress=True) + finally: + patch.stopall() + + mock_estimate.assert_called_once_with( + 1, + config["providerDetails"], + "/tmp/report/raw", + report_path="/tmp/report", + name=config["name"], + exit_strategy=config["exitStrategy"], + assessment_type=config["assessmentType"], + ) + self.assertEqual( + mock_report.call_args.kwargs["egress_json_path"], + "/tmp/report/raw/egress_inventory_raw_data.json", + ) + call_names = [name for name, _, _ in manager.mock_calls] + self.assertLess( + call_names.index("estimate_egress"), call_names.index("generate_report") + ) + + def test_there_is_no_separate_egress_report_step_any_more(self): + mocks = self._run(self._azure_config(), "azure", egress=True) + + steps = self._steps(mocks) + self.assertNotIn("Generating egress report...", steps) + self.assertIn("Estimating egress data...", steps) + self.assertIn("Generating egress PDF...", steps) + printed = self._printed(mocks) + self.assertFalse(any("Egress Report:" in line for line in printed)) + self.assertFalse(any("egress.html" in line for line in printed)) + + def test_the_egress_pdf_path_is_listed(self): + mocks = self._run(self._azure_config(), "azure", egress=True) + + printed = self._printed(mocks) + self.assertTrue( + any("Egress PDF: /tmp/report/egress.pdf" in line for line in printed) + ) + self.assertTrue(any("HTML Report:" in line for line in printed)) + + def test_egress_failure_still_prints_outputs_and_exits_egress(self): + patches = _base_patches() + for p in patches: + p.start() + patch( + "cloudexit.cli.app.estimate_egress", + return_value={"success": False, "logs": "metrics api error"}, + ).start() + report = patch("cloudexit.cli.app.generate_report").start() + report.return_value = {"success": True, "reports": {"HTML": "/tmp/index.html"}} + mock_print = patch("cloudexit.cli.app.console.print").start() + try: + with self.assertRaises(SystemExit) as ctx: + app.run_assessment(self._azure_config(), "azure", egress=True) + finally: + patch.stopall() + + 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.assertIsNone(report.call_args.kwargs["egress_json_path"]) + + def test_egress_pdf_failure_still_prints_outputs_and_exits_egress(self): + patches = _base_patches() + for p in patches: + p.start() + patch( + "cloudexit.cli.app.estimate_egress", return_value=self._ESTIMATE_OK + ).start() + patch( + "cloudexit.cli.app.generate_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: + app.run_assessment(self._azure_config(), "azure", egress=True) + 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.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): + with self.assertRaises(SystemExit) as ctx: + self._run( + self._azure_config(), + "azure", + report={"success": False, "logs": "boom"}, + egress=True, + ) + + self.assertEqual(ctx.exception.code, codes.REPORT) + + def test_egress_invoked_for_aws_with_provider_code(self): + patches = _base_patches() + for p in patches: + p.start() + mock_estimate = patch( + "cloudexit.cli.app.estimate_egress", return_value=self._ESTIMATE_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) + finally: + patch.stopall() + + mock_estimate.assert_called_once_with( + 2, + config["providerDetails"], + "/tmp/report/raw", + report_path="/tmp/report", + name=config["name"], + exit_strategy=config["exitStrategy"], + assessment_type=config["assessmentType"], + ) + + def test_handle_azure_passes_egress_flag(self): + mock_credential = MagicMock() + with ( + patch.dict(os.environ, NonInteractiveAzureTests._BASE_ENV, clear=False), + patch( + "azure.identity.ClientSecretCredential", return_value=mock_credential + ), + patch("cloudexit.cli.app.run_assessment") as mock_run, + patch("cloudexit.cli.app.console.print"), + ): + app.handle_azure(_ni_azure_args(egress=True)) + + mock_run.assert_called_once_with( + ANY, + "azure", + dry_run=False, + non_interactive=True, + egress=True, + settings=None, + emit_payload=None, + egress_names="plain", + spool_on_error=False, + flush_spool=True, + strict=False, + ) + + def test_handle_aws_passes_egress_flag(self): + with ( + patch.dict(os.environ, NonInteractiveAWSTests._BASE_ENV, clear=False), + patch("cloudexit.cli.app.validate_region"), + patch("cloudexit.cli.app.run_assessment") as mock_run, + patch("cloudexit.cli.app.console.print"), + ): + app.handle_aws(_ni_aws_args(egress=True)) + + mock_run.assert_called_once_with( + ANY, + "aws", + dry_run=False, + non_interactive=True, + egress=True, + settings=None, + emit_payload=None, + egress_names="plain", + spool_on_error=False, + flush_spool=True, + strict=False, + ) + + +class MainExitCodeTests(unittest.TestCase): + def setUp(self): + patcher = patch("cloudexit.cli.app.load_settings", return_value=Settings()) + patcher.start() + self.addCleanup(patcher.stop) + + def test_config_error_from_handler_exits_config(self): + with ( + patch("cloudexit.cli.app.prepare_catalogue"), + patch("cloudexit.cli.app.console.print"), + patch( + "cloudexit.cli.app.parse_arguments", + return_value=Namespace(cloud_provider="aws"), + ), + patch("cloudexit.cli.app.handle_aws", side_effect=app.ConfigError), + ): + with self.assertRaises(SystemExit) as ctx: + app.main() + self.assertEqual(ctx.exception.code, codes.CONFIG) + + def test_ctrl_c_in_handler_exits_interrupted(self): + with ( + patch("cloudexit.cli.app.prepare_catalogue"), + patch("cloudexit.cli.app.console.print"), + patch( + "cloudexit.cli.app.parse_arguments", + return_value=Namespace(cloud_provider="aws"), + ), + patch("cloudexit.cli.app.handle_aws", side_effect=KeyboardInterrupt), + ): + with self.assertRaises(SystemExit) as ctx: + app.main() + self.assertEqual(ctx.exception.code, codes.INTERRUPTED) + self.assertEqual(codes.INTERRUPTED, 130) + + def test_ctrl_c_during_dataset_download_exits_interrupted(self): + with ( + patch("cloudexit.cli.app.prepare_catalogue", side_effect=KeyboardInterrupt), + patch("cloudexit.cli.app.console.print"), + patch( + "cloudexit.cli.app.parse_arguments", + return_value=Namespace(cloud_provider="aws"), + ), + patch("cloudexit.cli.app.handle_aws") as mock_handle, + ): + with self.assertRaises(SystemExit) as ctx: + app.main() + self.assertEqual(ctx.exception.code, codes.INTERRUPTED) + mock_handle.assert_not_called() + + def test_bad_config_file_exits_config_end_to_end(self): + args = Namespace( + cloud_provider="aws", + config="/nonexistent/aws.json", + profile=None, + name=None, + non_interactive=False, + dry_run=False, + egress=False, + ) + with ( + patch("cloudexit.cli.app.prepare_catalogue"), + patch("cloudexit.cli.app.console.print"), + patch("cloudexit.cli.app.parse_arguments", return_value=args), + patch("cloudexit.cli.app.load_config", return_value=None), + patch("cloudexit.cli.app.run_assessment") as mock_run, + ): + with self.assertRaises(SystemExit) as ctx: + app.main() + self.assertEqual(ctx.exception.code, codes.CONFIG) + mock_run.assert_not_called() + + +TFSTATE_CONFIG = { + "name": "Tfstate Assessment", + "cloudServiceProvider": 2, + "exitStrategy": 1, + "assessmentType": 1, + "providerDetails": {"tfstatePath": "config/aws-01.tfstate"}, +} + + +class TfstateCliTests(unittest.TestCase): + _ENV = {"ESC_EXIT_STRATEGY": "3", "ESC_ASSESSMENT_TYPE": "1"} + + def test_egress_with_tfstate_exits_config_for_aws(self): + with patch("cloudexit.cli.app.console.print"): + with self.assertRaises(SystemExit) as ctx: + app.handle_aws(_ni_aws_args(tfstate="infra.tfstate", egress=True)) + self.assertEqual(ctx.exception.code, codes.CONFIG) + + def test_egress_with_tfstate_exits_config_for_azure(self): + with patch("cloudexit.cli.app.console.print"): + with self.assertRaises(SystemExit) as ctx: + app.handle_azure(_ni_azure_args(tfstate="infra.tfstate", egress=True)) + self.assertEqual(ctx.exception.code, codes.CONFIG) + + def test_aws_non_interactive_needs_no_aws_env_vars(self): + env = { + k: v + for k, v in os.environ.items() + if not k.startswith("AWS_") and not k.startswith("ESC_") + } + env.update(self._ENV) + with ( + patch.dict(os.environ, env, clear=True), + patch("cloudexit.cli.app.run_assessment") as mock_run, + patch("cloudexit.cli.app.console.print"), + ): + app.handle_aws(_ni_aws_args(tfstate="infra.tfstate")) + + mock_run.assert_called_once() + config_arg = mock_run.call_args[0][0] + self.assertEqual(config_arg["exitStrategy"], 3) + self.assertEqual(config_arg["assessmentType"], 1) + self.assertEqual( + config_arg["providerDetails"], {"tfstatePath": "infra.tfstate"} + ) + + def test_azure_non_interactive_needs_no_azure_env_vars(self): + env = { + k: v + for k, v in os.environ.items() + if not k.startswith("AZURE_") and not k.startswith("ESC_") + } + env.update(self._ENV) + with ( + patch.dict(os.environ, env, clear=True), + patch("cloudexit.cli.app.run_assessment") as mock_run, + patch("cloudexit.cli.app.console.print"), + ): + app.handle_azure(_ni_azure_args(tfstate="infra.tfstate")) + + mock_run.assert_called_once() + config_arg = mock_run.call_args[0][0] + self.assertEqual(config_arg["cloudServiceProvider"], 1) + self.assertEqual(config_arg["exitStrategy"], 3) + self.assertEqual( + config_arg["providerDetails"], {"tfstatePath": "infra.tfstate"} + ) + + def test_config_file_carries_tfstate_path_through_handle_aws(self): + with tempfile.TemporaryDirectory() as tmp_dir: + state_path = Path(tmp_dir) / "infra.tfstate" + state_path.write_text( + json.dumps({"version": 4, "resources": []}), encoding="utf-8" + ) + config_path = Path(tmp_dir) / "config.json" + config_path.write_text( + json.dumps( + { + "cloudServiceProvider": 2, + "exitStrategy": 1, + "assessmentType": 1, + "providerDetails": {"tfstatePath": str(state_path)}, + } + ), + encoding="utf-8", + ) + + with ( + patch("cloudexit.cli.app.run_assessment") as mock_run, + patch("cloudexit.cli.app.console.print"), + ): + app.handle_aws(_ni_aws_args(config=str(config_path))) + + config_arg = mock_run.call_args[0][0] + self.assertEqual( + config_arg["providerDetails"], {"tfstatePath": str(state_path)} + ) + # The same config must survive validation without credential fields. + self.assertTrue(app.validate_config(config_arg)) + + def test_tfstate_parses_from_both_subcommands(self): + with patch("sys.argv", ["main.py", "aws", "--tfstate", "infra.tfstate"]): + args = parse_arguments() + self.assertEqual(args.tfstate, "infra.tfstate") + + with patch( + "sys.argv", ["main.py", "azure", "--tfstate", "infra.tfstate", "--dry-run"] + ): + args = parse_arguments() + self.assertEqual(args.tfstate, "infra.tfstate") + self.assertTrue(args.dry_run) + + def test_tfstate_is_mutually_exclusive_with_config(self): + with patch( + "sys.argv", ["main.py", "aws", "--config", "c.json", "--tfstate", "s"] + ): + with self.assertRaises(SystemExit): + parse_arguments() + + def test_run_assessment_skips_credential_permission_and_cost_stages(self): + with tempfile.TemporaryDirectory() as tmp_dir: + raw_data_path = os.path.join(tmp_dir, "raw_data") + os.makedirs(raw_data_path, exist_ok=True) + + with ( + patch("cloudexit.cli.app.validate_config"), + patch( + "cloudexit.cli.app.create_directory", + return_value=(tmp_dir, raw_data_path), + ), + patch("cloudexit.cli.app.verify_credentials") as mock_creds, + patch("cloudexit.cli.app.test_permissions") as mock_perms, + patch("cloudexit.cli.app.create_cost_inventory") as mock_cost, + patch( + "cloudexit.cli.app.create_resource_inventory", + return_value={"success": True, "logs": ""}, + ) as mock_inventory, + patch( + "cloudexit.cli.app.perform_risk_assessment", + return_value={"success": True, "logs": ""}, + ) as mock_risk, + patch( + "cloudexit.cli.app.generate_report", + return_value={"success": True, "reports": {}}, + ) as mock_report, + patch("cloudexit.cli.app.print_step"), + patch("cloudexit.cli.app.console.print"), + ): + app.run_assessment(TFSTATE_CONFIG.copy(), "aws") + + mock_creds.assert_not_called() + mock_perms.assert_not_called() + mock_cost.assert_not_called() + mock_inventory.assert_called_once_with( + 2, {"tfstatePath": "config/aws-01.tfstate"}, tmp_dir, raw_data_path + ) + mock_risk.assert_called_once() + mock_report.assert_called_once() + + def _run_tfstate_stage3(self, inventory_result): + with tempfile.TemporaryDirectory() as tmp_dir: + raw_data_path = os.path.join(tmp_dir, "raw_data") + os.makedirs(raw_data_path, exist_ok=True) + + with ( + patch("cloudexit.cli.app.validate_config"), + patch( + "cloudexit.cli.app.create_directory", + return_value=(tmp_dir, raw_data_path), + ), + patch( + "cloudexit.cli.app.create_resource_inventory", + return_value=inventory_result, + ), + patch( + "cloudexit.cli.app.perform_risk_assessment", + return_value={"success": True, "logs": ""}, + ), + patch( + "cloudexit.cli.app.generate_report", + return_value={"success": True, "reports": {}}, + ), + patch("cloudexit.cli.app.print_step") as mock_step, + patch("cloudexit.cli.app.console.print"), + ): + app.run_assessment(TFSTATE_CONFIG.copy(), "aws") + + return [ + call + for call in mock_step.call_args_list + if "resource inventory" in call.args[0] + ] + + def test_excluded_foreign_resources_downgrade_stage3_to_warning(self): + calls = self._run_tfstate_stage3( + { + "success": True, + "logs": "", + "coverage": { + "instances_total": 31, + "instances_counted": 3, + "instances_excluded_other_provider": 27, + "instances_excluded_unmapped": 1, + }, + } + ) + + self.assertEqual(len(calls), 1) + self.assertEqual(calls[0].kwargs["status"], "warning") + logs = calls[0].kwargs["logs"] + self.assertIn("Assessed 3 of 31 resources", logs) + self.assertIn("27 excluded", logs) + + def test_clean_tfstate_run_keeps_stage3_ok(self): + calls = self._run_tfstate_stage3( + { + "success": True, + "logs": "", + "coverage": { + "instances_total": 8, + "instances_counted": 5, + "instances_excluded_other_provider": 0, + "instances_excluded_unmapped": 3, + }, + } + ) + + self.assertEqual(len(calls), 1) + self.assertEqual(calls[0].kwargs["status"], "ok") + + def test_live_mode_result_without_coverage_keeps_stage3_ok(self): + calls = self._run_tfstate_stage3({"success": True, "logs": ""}) + + self.assertEqual(len(calls), 1) + self.assertEqual(calls[0].kwargs["status"], "ok") + + def test_run_assessment_rejects_egress_from_tfstate_config_file(self): + with ( + patch("cloudexit.cli.app.validate_config"), + patch("cloudexit.cli.app.create_directory") as mock_dir, + patch("cloudexit.cli.app.print_step"), + patch("cloudexit.cli.app.console.print"), + ): + with self.assertRaises(SystemExit) as ctx: + app.run_assessment(TFSTATE_CONFIG.copy(), "aws", egress=True) + + self.assertEqual(ctx.exception.code, codes.CONFIG) + mock_dir.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/cli/test_billing_and_outputs.py b/tests/cli/test_billing_and_outputs.py new file mode 100644 index 0000000..7a3bded --- /dev/null +++ b/tests/cli/test_billing_and_outputs.py @@ -0,0 +1,293 @@ +import json +import os +import tempfile +import unittest +from pathlib import Path +from unittest.mock import MagicMock, patch + +from cloudexit.cli import app +from cloudexit.platform.client import ApiClient +from cloudexit.platform.upload import ( + UploadResult, + billing_line, + report_info, + upload, + write_upload_record, +) +from tests.support.isolation import isolated_environ +from tests.support.api import ( + HOST, + KEY, + created_reply, + meta_reply, + minimal_document, + reply, + sync_body, + whoami_reply, +) +from tests.support.http import fake_http +from cloudexit.settings import Setting, Settings + +SYNC_ID = "01933b9e-7c2a-7d41-9a51-3c8f0a6e0001" +BILLING = { + "class": "new", + "charged_credits": 20, + "balance": 380, + "transaction_id": "tx-1", +} +CONFIGURED = Settings( + host=Setting(HOST, "env CLOUDEXIT_HOST"), + api_key=Setting(KEY, "env CLOUDEXIT_API_KEY"), +) + + +class BillingLineTests(unittest.TestCase): + def test_the_line_names_the_charge_and_the_balance(self): + self.assertEqual( + billing_line({"charged_credits": 20, "balance": 380}), + "Charged 20 credits; balance 380.", + ) + + def test_one_credit_is_singular_and_zero_is_plural(self): + self.assertEqual( + billing_line({"charged_credits": 1, "balance": 9}), + "Charged 1 credit; balance 9.", + ) + self.assertEqual( + billing_line({"charged_credits": 0, "balance": 9}), + "Charged 0 credits; balance 9.", + ) + + def test_a_missing_balance_is_left_out(self): + self.assertEqual(billing_line({"charged_credits": 5}), "Charged 5 credits.") + + def test_without_a_charge_there_is_no_line(self): + for billing in (None, {}, {"balance": 3}): + with self.subTest(billing=billing): + self.assertIsNone(billing_line(billing)) + + +class PushBillingTests(unittest.TestCase): + def setUp(self): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.cache = Path(tmp.name) + + def push(self, *replies): + client = ApiClient(HOST, KEY, cache_path=self.cache, environ={}) + with fake_http(meta_reply(), whoami_reply(), *replies): + return client.push( + minimal_document(SYNC_ID), name="Billing", exit_strategy=1 + ) + + def test_the_charge_and_the_balance_come_back_with_the_result(self): + result = self.push(created_reply(billing=BILLING)) + self.assertEqual(result.billing, {"charged_credits": 20, "balance": 380}) + + def test_a_platform_that_does_not_charge_sends_no_billing_block(self): + self.assertIsNone(self.push(created_reply()).billing) + + def test_a_replay_charged_nothing_whatever_the_stored_answer_says(self): + replayed = reply( + 200, sync_body(billing=BILLING), **{"Idempotent-Replayed": "true"} + ) + self.assertIsNone(self.push(replayed).billing) + + def test_odd_values_are_ignored(self): + for billing in ( + "20 credits", + [], + {"charged_credits": "20", "balance": None}, + {"charged_credits": True}, + ): + with self.subTest(billing=billing): + cache = self.cache / str(abs(hash(str(billing)))) + client = ApiClient(HOST, KEY, cache_path=cache, environ={}) + with fake_http( + meta_reply(), whoami_reply(), created_reply(billing=billing) + ): + result = client.push( + minimal_document(SYNC_ID), name="Billing", exit_strategy=1 + ) + self.assertIsNone(result.billing) + + +class UploadBillingTests(unittest.TestCase): + def setUp(self): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.tmp = Path(tmp.name) + + def upload(self, billing): + with patch.dict( + os.environ, + isolated_environ(self.tmp), + ): + with fake_http(meta_reply(), whoami_reply(), created_reply(**billing)): + return upload( + minimal_document(SYNC_ID), + CONFIGURED, + name="Billing", + meta={"exit_strategy": 1}, + ) + + def test_the_result_carries_the_charge(self): + result = self.upload({"billing": BILLING}) + self.assertEqual(result.billing, {"charged_credits": 20, "balance": 380}) + + def test_the_record_file_holds_the_billing_but_the_report_does_not(self): + result = self.upload({"billing": BILLING}) + + path = write_upload_record(str(self.tmp), result) + record = json.loads(path.read_text(encoding="utf-8")) + self.assertEqual(record["billing"], {"charged_credits": 20, "balance": 380}) + self.assertNotIn("billing", report_info(result)) + + def test_without_billing_the_record_has_no_such_key(self): + result = self.upload({}) + + self.assertIsNone(result.billing) + path = write_upload_record(str(self.tmp), result) + self.assertNotIn("billing", json.loads(path.read_text(encoding="utf-8"))) + + +class ClosingBlockTests(unittest.TestCase): + AWS = ( + "aws", + 2, + {"accessKey": "a", "secretKey": "b", "region": "eu-central-1"}, + ) + AZURE = ( + "azure", + 1, + { + "credential": MagicMock(), + "tenantId": "t", + "subscriptionId": "00000000-0000-0000-0000-000000000000", + "resourceGroupName": "rg", + }, + ) + URL = "https://platform.example.com/assessments/a-1" + + def lines(self, provider, uploaded=None, **kwargs): + name, number, details = provider + printed = [] + config = { + "name": "Outputs", + "cloudServiceProvider": number, + "exitStrategy": 1, + "assessmentType": 1, + "providerDetails": details, + } + with tempfile.TemporaryDirectory() as tmp: + report = os.path.join(tmp, "r") + raw = os.path.join(tmp, "raw") + os.makedirs(os.path.join(report, "data")) + os.makedirs(raw) + patches = [ + patch("cloudexit.cli.app.validate_config"), + patch("cloudexit.cli.app.require_azure_sdk"), + patch("cloudexit.cli.app.create_directory", return_value=(report, raw)), + patch("cloudexit.cli.app.add_run_log_handler"), + patch( + "cloudexit.cli.app.verify_credentials", return_value=(True, "ok") + ), + patch( + "cloudexit.cli.app.test_permissions", + return_value=(True, True, True, "ok"), + ), + patch( + "cloudexit.cli.app.create_resource_inventory", + return_value={"success": True, "logs": ""}, + ), + patch( + "cloudexit.cli.app.create_cost_inventory", + return_value={"success": True, "logs": ""}, + ), + patch( + "cloudexit.cli.app.perform_risk_assessment", + return_value={"success": True, "logs": ""}, + ), + patch( + "cloudexit.cli.app.generate_report", + return_value={ + "success": True, + "reports": {"HTML": "h.html", "PDF": "p.pdf", "JSON": "j.json"}, + }, + ), + patch("cloudexit.cli.app.prepare_sync_document", return_value=None), + patch("cloudexit.cli.app.print_step"), + patch("cloudexit.cli.app.console.status"), + patch( + "cloudexit.cli.app.console.print", + side_effect=lambda *a, **k: printed.append(str(a[0]) if a else ""), + ), + ] + if uploaded is not None: + patches.append(patch("cloudexit.cli.app.upload", return_value=uploaded)) + for item in patches: + item.start() + try: + app.run_assessment( + config, + name, + non_interactive=True, + settings=CONFIGURED if uploaded is not None else None, + **kwargs, + ) + finally: + for item in reversed(patches): + item.stop() + return printed + + def uploaded(self, **fields): + return UploadResult( + True, + True, + message="uploaded", + sync_id=SYNC_ID, + assessment_id="a-1", + url=self.URL, + **fields, + ) + + def test_the_closing_block_is_printed_once_for_every_provider_and_state(self): + for provider in (self.AWS, self.AZURE): + for uploaded in (None, self.uploaded()): + with self.subTest(provider=provider[0], uploaded=bool(uploaded)): + printed = self.lines(provider, uploaded) + + self.assertEqual(printed.count("Outputs:"), 1) + at = printed.index("Outputs:") + self.assertEqual(printed[at - 1], "-" * 43) + self.assertEqual(printed[at - 2 :].count("-" * 43), 2) + self.assertEqual(printed[-1], "-" * 43) + + def test_the_charge_is_printed_once_under_the_platform_line(self): + for provider in (self.AWS, self.AZURE): + with self.subTest(provider=provider[0]): + printed = self.lines( + provider, + self.uploaded(billing={"charged_credits": 20, "balance": 380}), + ) + + at = printed.index(f"Platform: {self.URL}") + self.assertEqual(printed[at + 1], "Charged 20 credits; balance 380.") + self.assertEqual(printed.count("Charged 20 credits; balance 380."), 1) + + def test_a_platform_that_does_not_charge_prints_no_charge_line(self): + printed = self.lines(self.AWS, self.uploaded()) + + self.assertIn(f"Platform: {self.URL}", printed) + self.assertFalse(any(line.startswith("Charged") for line in printed)) + + def test_an_offline_run_prints_no_platform_or_charge_line(self): + printed = self.lines(self.AZURE, None) + + self.assertFalse( + any(line.startswith(("Platform", "Charged")) for line in printed) + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/cli/test_entry_points.py b/tests/cli/test_entry_points.py new file mode 100644 index 0000000..cea4a8e --- /dev/null +++ b/tests/cli/test_entry_points.py @@ -0,0 +1,105 @@ +import io +import subprocess +import sys +import unittest +from contextlib import redirect_stdout +from unittest.mock import patch + +from cloudexit import codes +from cloudexit.cli import app +from cloudexit.version import __version__ +from tests.support.paths import ROOT + + +class EntryPointTests(unittest.TestCase): + def run_cli(self, *argv): + return subprocess.run( + [sys.executable, *argv], + cwd=ROOT, + capture_output=True, + text=True, + timeout=120, + ) + + def test_the_module_and_the_root_script_print_the_same_version(self): + by_module = self.run_cli("-m", "cloudexit", "--version") + by_script = self.run_cli("main.py", "--version") + + self.assertEqual(by_module.returncode, 0, by_module.stderr) + self.assertEqual(by_script.returncode, 0, by_script.stderr) + self.assertEqual(by_module.stdout, f"cloudexit v{__version__}\n") + self.assertEqual(by_script.stdout, by_module.stdout) + + def test_the_root_script_is_only_a_shim(self): + text = (ROOT / "main.py").read_text(encoding="utf-8") + self.assertEqual( + text, + 'from cloudexit.cli import run\n\nif __name__ == "__main__":\n run()\n', + ) + + def test_the_package_exposes_the_two_entry_functions(self): + from cloudexit import cli + + self.assertIs(cli.main, app.main) + self.assertIs(cli.run, app.run) + with self.assertRaises(AttributeError): + cli.nothing_of_the_kind + + def test_the_console_script_target_resolves(self): + from importlib.metadata import EntryPoint + + entry = EntryPoint("cloudexit", "cloudexit.cli:main", "console_scripts") + self.assertIs(entry.load(), app.main) + + +class ImportOrderTests(unittest.TestCase): + MODULES = ( + "cloudexit.engine", + "cloudexit.cli.prompts", + "cloudexit.terminal", + "cloudexit.cli.app", + "cloudexit.providers.azure.costs", + "cloudexit.providers.azure.cli_session", + "cloudexit.platform.status", + "cloudexit.report.egress", + "cloudexit.egress.estimate", + ) + + def test_each_module_can_be_the_first_thing_imported(self): + for name in self.MODULES: + with self.subTest(name): + result = subprocess.run( + [sys.executable, "-c", f"import {name}"], + cwd=ROOT, + capture_output=True, + text=True, + timeout=120, + ) + self.assertEqual(result.returncode, 0, result.stderr[-600:]) + + +class LastResortMessageTests(unittest.TestCase): + def test_run_turns_an_unexpected_error_into_a_message_and_exit_1(self): + out = io.StringIO() + with ( + patch.object(app, "main", side_effect=RuntimeError("boom")), + patch.object( + app.console, "print", side_effect=lambda *a, **k: out.write(a[0]) + ), + redirect_stdout(io.StringIO()), + self.assertRaises(SystemExit) as ctx, + ): + app.run() + + self.assertEqual(ctx.exception.code, codes.UNEXPECTED) + self.assertIn("Unexpected error: boom", out.getvalue()) + + def test_main_alone_has_no_such_guard(self): + # The console script calls main() directly, as it always did + with patch.object(app, "parse_arguments", side_effect=RuntimeError("boom")): + with self.assertRaises(RuntimeError): + app.main() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/cli/test_lazy_azure.py b/tests/cli/test_lazy_azure.py new file mode 100644 index 0000000..1e90519 --- /dev/null +++ b/tests/cli/test_lazy_azure.py @@ -0,0 +1,325 @@ +import argparse +import json +import os +import re +import shutil +import subprocess +import sys +import tempfile +import unittest +from importlib.metadata import PackageNotFoundError +from pathlib import Path +from unittest import mock + +from cloudexit.cli import app +from tests.support.isolation import isolated_environ +from tests.make_smoke_dataset import main as make_smoke_dataset +from tests.support.paths import ROOT +from cloudexit import codes +from cloudexit.providers.azure import sdk as azure_utils +from cloudexit.providers.azure.sdk import AzureSdkError, azure_import, require_azure_sdk + +SMOKE_STATE = ROOT / "tests" / "fixtures" / "smoke" / "aws.tfstate" + +DRIVER = """ +import json, runpy, sys +root, mode, *argv = sys.argv[1:] +sys.path.insert(0, root) +if mode == "broken": + sys.modules["azure"] = None +sys.argv = ["cloudexit", *argv] +code = 0 +try: + runpy.run_module("cloudexit", run_name="__main__") +except SystemExit as exc: + code = exc.code or 0 +loaded = sorted( + name + for name, module in sys.modules.items() + if (name == "azure" or name.startswith("azure.")) and module is not None +) +print("AZURE_MODULES=" + json.dumps(loaded)) +sys.exit(code) +""" + + +class SubprocessTestCase(unittest.TestCase): + def setUp(self): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.tmp = Path(tmp.name) + self.work = self.tmp / "work" + self.work.mkdir() + self.env = { + **os.environ, + **isolated_environ(self.tmp), + "CLOUDEXIT_CATALOGUE_URL": "http://127.0.0.1:9", + "ESC_EXIT_STRATEGY": "1", + "ESC_ASSESSMENT_TYPE": "1", + "COLUMNS": "200", + } + + def dataset(self): + path = self.tmp / "smoke.db" + if not path.exists(): + make_smoke_dataset(str(path)) + return str(path) + + def run_cli(self, mode, *argv): + result = subprocess.run( + [sys.executable, "-c", DRIVER, str(ROOT), mode, *argv], + cwd=self.work, + env=self.env, + capture_output=True, + text=True, + timeout=120, + ) + output = result.stdout + result.stderr + match = re.search(r"AZURE_MODULES=(\[.*\])", result.stdout) + loaded = json.loads(match.group(1)) if match else None + return result.returncode, loaded, " ".join(output.split()) + + +class NonAzureCommandsTests(SubprocessTestCase): + COMMANDS = ( + ("--version",), + ("config", "path"), + ("catalogue", "status"), + ) + + def test_no_azure_module_is_imported_for_commands_that_never_use_azure(self): + for argv in self.COMMANDS: + with self.subTest(argv=" ".join(argv)): + code, loaded, output = self.run_cli("open", *argv) + self.assertEqual(code, 0, output) + self.assertEqual(loaded, []) + + def test_a_broken_azure_sdk_does_not_stop_those_commands(self): + for argv in self.COMMANDS: + with self.subTest(argv=" ".join(argv)): + code, loaded, output = self.run_cli("broken", *argv) + self.assertEqual(code, 0, output) + self.assertEqual(loaded, []) + self.assertNotIn("Traceback", output) + + def test_an_offline_aws_run_needs_no_azure_sdk(self): + argv = ( + "aws", + "--tfstate", + str(SMOKE_STATE), + "--catalogue", + self.dataset(), + "--non-interactive", + "--dry-run", + ) + for mode in ("open", "broken"): + with self.subTest(mode=mode): + code, loaded, output = self.run_cli(mode, *argv) + self.assertEqual(code, 0, output) + self.assertEqual(loaded, []) + self.assertTrue(list(self.work.glob("reports/*/index.html"))) + + +class BrokenSdkOnAzurePathsTests(SubprocessTestCase): + def assert_guard_message(self, code, output): + self.assertEqual(code, codes.CONFIG, output) + self.assertNotIn("Traceback", output) + self.assertIn("The Azure SDK in this environment is incomplete", output) + self.assertRegex( + output, r"pip install --force-reinstall --no-deps azure-[a-z-]+" + ) + self.assertIn("or install cloudexit into a new virtual environment", output) + + def test_a_live_azure_run_stops_with_the_repair_message_before_any_prompt(self): + for argv in ( + ("azure", "--non-interactive"), + ("azure", "--non-interactive", "--cli"), + ): + with self.subTest(argv=" ".join(argv)): + code, _, output = self.run_cli( + "broken", *argv, "--catalogue", self.dataset() + ) + self.assert_guard_message(code, output) + + def test_a_config_file_run_stops_after_the_configuration_is_validated(self): + config = self.work / "azure.json" + shutil.copy(ROOT / "config" / "azure_example.json", config) + + code, _, output = self.run_cli( + "broken", + "azure", + "--config", + str(config), + "--non-interactive", + "--catalogue", + self.dataset(), + ) + + self.assert_guard_message(code, output) + self.assertIn("Configuration successfully validated", output) + self.assertFalse(list(self.work.glob("reports/*"))) + + +class AzureImportTests(unittest.TestCase): + def test_the_symbols_the_azure_paths_use_import_in_a_complete_environment(self): + require_azure_sdk() + self.assertEqual( + azure_import("azure.identity", "ClientSecretCredential").__name__, + "ClientSecretCredential", + ) + + def test_a_missing_symbol_names_what_could_not_be_imported(self): + with self.assertRaises(AzureSdkError) as ctx: + azure_import("azure.mgmt.resource.resources", "NoSuchClient") + + self.assertEqual(ctx.exception.module, "azure.mgmt.resource.resources") + self.assertIn( + "cannot import azure.mgmt.resource.resources.NoSuchClient", + str(ctx.exception), + ) + self.assertIsNone(ctx.exception.__cause__) + + def test_a_missing_package_is_the_same_error(self): + with mock.patch.dict(sys.modules, {"azure.mgmt.subscription": None}): + with self.assertRaises(AzureSdkError): + azure_import("azure.mgmt.subscription", "SubscriptionClient") + + def test_the_repair_command_names_the_package_and_the_pinned_version(self): + pins = [ + "azure-identity==1.26.0", + "azure-mgmt-resource==26.0.0", + "azure-mgmt-subscription==3.1.1", + "boto3==1.43.118", + "black ; extra == 'dev'", + ] + cases = { + "azure.identity": "azure-identity==1.26.0", + "azure.mgmt.resource.resources": "azure-mgmt-resource==26.0.0", + "azure.mgmt.subscription": "azure-mgmt-subscription==3.1.1", + "azure.core.exceptions": "azure-core", + "azure.mgmt.costmanagement.models": "azure-mgmt-costmanagement", + } + with mock.patch.object(azure_utils, "requires", return_value=pins): + for module, expected in cases.items(): + with self.subTest(module=module): + message = str(AzureSdkError(module, "Thing")) + self.assertIn(f"--force-reinstall --no-deps {expected},", message) + + def test_without_an_installed_cloudexit_the_package_name_alone_is_given(self): + with mock.patch.object( + azure_utils, "requires", side_effect=PackageNotFoundError("cloudexit") + ): + message = str( + AzureSdkError("azure.mgmt.subscription", "SubscriptionClient") + ) + self.assertIn("--no-deps azure-mgmt-subscription,", message) + + def test_every_symbol_has_a_distribution_the_message_can_name(self): + for module, _ in azure_utils.SDK_SYMBOLS: + with self.subTest(module=module): + self.assertTrue( + any( + module == prefix or module.startswith(f"{prefix}.") + for prefix in azure_utils.SDK_DISTRIBUTIONS + ) + ) + + +class GuardPlacementTests(unittest.TestCase): + def args(self, **overrides): + values = { + "tfstate": None, + "config": None, + "cli": False, + "non_interactive": True, + "name": None, + "egress": False, + "dry_run": False, + } + values.update(overrides) + return argparse.Namespace(**values) + + def run_handle(self, args): + calls = [] + with ( + mock.patch( + "cloudexit.cli.app._require_azure_sdk", + side_effect=lambda: calls.append("sdk"), + ), + mock.patch("cloudexit.cli.app.load_config", return_value={"name": "n"}), + mock.patch("cloudexit.cli.app.require_env_int", return_value=1), + mock.patch( + "cloudexit.cli.app._azure_provider_noninteractive", + side_effect=lambda a: calls.append("provider") or {}, + ), + mock.patch("cloudexit.cli.app.build_config", return_value={}), + mock.patch("cloudexit.cli.app.run_assessment"), + ): + app.handle_azure(args) + return calls + + def test_a_live_run_checks_the_sdk_before_it_asks_for_anything(self): + self.assertEqual(self.run_handle(self.args()), ["sdk", "provider"]) + + def test_a_terraform_state_run_does_not_need_the_sdk(self): + self.assertEqual(self.run_handle(self.args(tfstate="state.tfstate")), []) + + def test_a_config_file_run_is_checked_when_the_run_starts(self): + self.assertEqual(self.run_handle(self.args(config="azure.json")), []) + + +class RunAssessmentGuardTests(unittest.TestCase): + CONFIG = { + "name": "Guard", + "cloudServiceProvider": 1, + "exitStrategy": 1, + "assessmentType": 1, + "providerDetails": { + "tenantId": "t", + "clientId": "c", + "clientSecret": "s", + "subscriptionId": "00000000-0000-0000-0000-000000000000", + "resourceGroupName": "rg", + }, + } + + def run_with(self, config, error=None): + with ( + mock.patch("cloudexit.cli.app.validate_config"), + mock.patch( + "cloudexit.cli.app.require_azure_sdk", side_effect=error + ) as guard, + mock.patch("cloudexit.cli.app.console.print") as printed, + mock.patch("cloudexit.cli.app.print_step"), + mock.patch( + "cloudexit.cli.app.create_directory", side_effect=RuntimeError("stop") + ) as made, + ): + with self.assertRaises(SystemExit) as ctx: + app.run_assessment(config, "azure") + return ctx.exception.code, guard, printed, made + + def test_an_incomplete_sdk_ends_the_run_with_the_configuration_code(self): + error = AzureSdkError("azure.identity", "ClientSecretCredential") + code, _, printed, made = self.run_with(dict(self.CONFIG), error) + + self.assertEqual(code, codes.CONFIG) + made.assert_not_called() + self.assertIn("incomplete", str(printed.call_args_list)) + + def test_a_terraform_state_run_never_asks_for_the_sdk(self): + config = dict(self.CONFIG, providerDetails={"tfstatePath": "state.tfstate"}) + code, guard, _, made = self.run_with(config) + + guard.assert_not_called() + made.assert_called_once() + self.assertEqual(code, codes.CONFIG) + + def test_an_aws_run_never_asks_for_the_sdk(self): + config = dict(self.CONFIG, cloudServiceProvider=2) + _, guard, _, _ = self.run_with(config) + guard.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/cli/test_prompts.py b/tests/cli/test_prompts.py new file mode 100644 index 0000000..bfde679 --- /dev/null +++ b/tests/cli/test_prompts.py @@ -0,0 +1,257 @@ +import io +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from cloudexit.cli import app +from tests.support.isolation import isolated_environ +from tests.make_smoke_dataset import main as make_smoke_dataset +from cloudexit import codes +from cloudexit.providers.azure.cli_session import ( + select_resource_group, + select_subscription, +) +from cloudexit.cli.prompts import prompt_required_inputs +from cloudexit.terminal import NO_TERMINAL_MESSAGE, ask +from tests.support.paths import ROOT + +SMOKE_STATE = ROOT / "tests" / "fixtures" / "smoke" / "aws.tfstate" + + +class Entry: + display_name = "Demo" + subscription_id = "00000000-0000-0000-0000-000000000000" + name = "rg" + + +class AskTests(unittest.TestCase): + def test_an_answer_is_returned(self): + with patch("builtins.input", return_value="1") as asked: + self.assertEqual(ask("Question: "), "1") + asked.assert_called_once_with("Question: ") + + def test_a_secret_is_read_without_echo(self): + with patch( + "cloudexit.terminal.getpass.getpass", return_value="s3cret" + ) as asked: + self.assertEqual(ask("Secret: ", secret=True), "s3cret") + asked.assert_called_once_with("Secret: ") + + def test_no_terminal_ends_with_the_message_and_the_configuration_code(self): + for secret in (False, True): + with self.subTest(secret=secret): + with ( + patch("builtins.input", side_effect=EOFError), + patch("cloudexit.terminal.getpass.getpass", side_effect=EOFError), + patch("cloudexit.terminal.console.print") as printed, + self.assertRaises(SystemExit) as ctx, + ): + ask("Question: ", secret=secret) + + self.assertEqual(ctx.exception.code, codes.CONFIG) + self.assertIn(NO_TERMINAL_MESSAGE, str(printed.call_args_list)) + + def test_the_message_says_what_to_do(self): + self.assertIn("--non-interactive", NO_TERMINAL_MESSAGE) + self.assertIn("ESC_EXIT_STRATEGY", NO_TERMINAL_MESSAGE) + self.assertIn("ESC_ASSESSMENT_TYPE", NO_TERMINAL_MESSAGE) + self.assertIn("no terminal is attached", NO_TERMINAL_MESSAGE) + + def test_ctrl_c_is_not_swallowed(self): + with patch("builtins.input", side_effect=KeyboardInterrupt): + with self.assertRaises(KeyboardInterrupt): + ask("Question: ") + + +class EveryPromptTests(unittest.TestCase): + def assert_ends_with_the_message(self, call): + with ( + patch("builtins.input", side_effect=EOFError), + patch("cloudexit.terminal.getpass.getpass", side_effect=EOFError), + patch("cloudexit.terminal.console.print") as printed, + patch("cloudexit.cli.app.console.print"), + patch("cloudexit.providers.azure.cli_session.console.print"), + self.assertRaises(SystemExit) as ctx, + ): + call() + self.assertEqual(ctx.exception.code, codes.CONFIG) + self.assertIn(NO_TERMINAL_MESSAGE, str(printed.call_args_list)) + + def test_the_exit_strategy_and_assessment_type_prompts(self): + self.assert_ends_with_the_message(prompt_required_inputs) + + def test_the_assessment_type_prompt_after_an_answer(self): + def run(): + with patch("builtins.input", side_effect=["1", EOFError]): + prompt_required_inputs() + + with ( + patch("cloudexit.terminal.console.print") as printed, + self.assertRaises(SystemExit) as ctx, + ): + run() + self.assertEqual(ctx.exception.code, codes.CONFIG) + self.assertIn(NO_TERMINAL_MESSAGE, str(printed.call_args_list)) + + def test_the_subscription_and_resource_group_selection(self): + self.assert_ends_with_the_message(lambda: select_subscription([Entry()])) + self.assert_ends_with_the_message(lambda: select_resource_group([Entry()])) + + def test_the_manual_aws_credentials(self): + self.assert_ends_with_the_message(app._aws_provider_from_prompt) + + def test_the_manual_aws_secret_key(self): + with ( + patch("builtins.input", return_value="AKIAEXAMPLE"), + patch("cloudexit.terminal.getpass.getpass", side_effect=EOFError), + patch("cloudexit.terminal.console.print") as printed, + self.assertRaises(SystemExit) as ctx, + ): + app._aws_provider_from_prompt() + self.assertEqual(ctx.exception.code, codes.CONFIG) + self.assertIn(NO_TERMINAL_MESSAGE, str(printed.call_args_list)) + + def test_the_manual_azure_credentials(self): + self.assert_ends_with_the_message(app._azure_provider_from_prompt) + + def test_the_azure_tenant_prompt_of_the_cli_login(self): + with patch("cloudexit.cli.app._azure_cli_credential"): + self.assert_ends_with_the_message(app._azure_provider_from_cli) + + +class WithoutATerminalTests(unittest.TestCase): + def setUp(self): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.tmp = Path(tmp.name) + self.dataset = self.tmp / "smoke.db" + make_smoke_dataset(str(self.dataset)) + self.env = { + key: value + for key, value in os.environ.items() + if not key.startswith(("ESC_", "CLOUDEXIT_")) + } + self.env.update({**isolated_environ(self.tmp), "COLUMNS": "200"}) + + def run_cli(self, stdin, *extra): + result = subprocess.run( + [ + sys.executable, + str(ROOT / "main.py"), + "aws", + "--tfstate", + str(SMOKE_STATE), + "--catalogue", + str(self.dataset), + "--dry-run", + *extra, + ], + cwd=self.tmp, + env=self.env, + input=stdin, + capture_output=True, + text=True, + timeout=120, + ) + return result.returncode, " ".join((result.stdout + result.stderr).split()) + + def test_a_closed_stdin_ends_with_the_message_and_no_traceback(self): + with open(os.devnull) as closed: + result = subprocess.run( + [ + sys.executable, + str(ROOT / "main.py"), + "aws", + "--tfstate", + str(SMOKE_STATE), + "--catalogue", + str(self.dataset), + ], + cwd=self.tmp, + env=self.env, + stdin=closed, + capture_output=True, + text=True, + timeout=120, + ) + + output = " ".join((result.stdout + result.stderr).split()) + self.assertEqual(result.returncode, codes.CONFIG, output) + self.assertIn(NO_TERMINAL_MESSAGE, output) + self.assertNotIn("Traceback", output) + self.assertNotIn("EOFError", output) + + def test_answers_piped_in_still_work(self): + code, output = self.run_cli("1\n1\n") + + self.assertEqual(code, 0, output) + self.assertNotIn("no terminal is attached", output) + self.assertTrue(list(self.tmp.glob("reports/*/index.html"))) + + def test_non_interactive_needs_no_terminal(self): + self.env["ESC_EXIT_STRATEGY"] = "1" + self.env["ESC_ASSESSMENT_TYPE"] = "1" + code, output = self.run_cli("", "--non-interactive") + + self.assertEqual(code, 0, output) + + def test_the_stdin_helper_works_with_an_in_memory_stream(self): + with patch("sys.stdin", io.StringIO("1\n3\n")): + self.assertEqual(ask("x"), "1") + self.assertEqual(ask("y"), "3") + with ( + patch("cloudexit.terminal.console.print"), + self.assertRaises(SystemExit), + ): + ask("z") + + +if __name__ == "__main__": + unittest.main() + + +class AssessmentTypePromptTests(unittest.TestCase): + def test_the_type_is_asked_when_an_upload_will_happen(self): + with patch("cloudexit.cli.prompts.ask", side_effect=["1", "2"]) as asked: + self.assertEqual(prompt_required_inputs(True), (1, 2)) + + self.assertEqual(len(asked.call_args_list), 2) + self.assertIn("Assessment Type", str(asked.call_args_list)) + + def test_without_an_upload_the_type_is_basic_and_never_asked(self): + with patch("cloudexit.cli.prompts.ask", side_effect=["3"]) as asked: + self.assertEqual(prompt_required_inputs(False), (3, 1)) + + self.assertEqual(len(asked.call_args_list), 1) + self.assertNotIn("Assessment Type", str(asked.call_args_list)) + + +class ExitStrategyPromptTests(unittest.TestCase): + def test_the_hybrid_strategy_can_be_chosen(self): + with patch("cloudexit.cli.prompts.ask", side_effect=["2", "2"]): + self.assertEqual(prompt_required_inputs(True), (2, 2)) + + def test_the_prompt_names_all_three_strategies(self): + with patch("cloudexit.cli.prompts.ask", side_effect=["1"]) as asked: + prompt_required_inputs(False) + + question = asked.call_args_list[0][0][0] + for label in ( + "1 for 'Repatriation to On-Premises'", + "2 for 'Hybrid Cloud Adoption'", + "3 for 'Migration to Alternate Cloud'", + ): + self.assertIn(label, question) + + def test_an_out_of_range_strategy_is_refused_and_asked_again(self): + with ( + patch("cloudexit.cli.prompts.ask", side_effect=["4", "2"]), + patch("cloudexit.cli.prompts.console.print") as printed, + ): + self.assertEqual(prompt_required_inputs(False), (2, 1)) + + self.assertIn("Please enter 1, 2 or 3.", str(printed.call_args_list)) diff --git a/tests/cli/test_report_folder.py b/tests/cli/test_report_folder.py new file mode 100644 index 0000000..9efadd8 --- /dev/null +++ b/tests/cli/test_report_folder.py @@ -0,0 +1,138 @@ +import os +import stat +import sys +import tempfile +import unittest +from datetime import datetime +from pathlib import Path +from unittest.mock import patch + +from cloudexit.cli import app +from cloudexit.private_files import restrict_tree, write_private + +MOMENT = datetime(2026, 11, 12, 9, 30, 0) +POSIX_ONLY = unittest.skipIf(sys.platform == "win32", "POSIX file modes") + + +def frozen(): + class FrozenDatetime(datetime): + @classmethod + def now(cls, tz=None): + return MOMENT + + return FrozenDatetime + + +def mode(path): + return stat.S_IMODE(os.stat(path).st_mode) + + +class FolderTestCase(unittest.TestCase): + def setUp(self): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.base = Path(tmp.name) / "reports" + + +class UniqueFolderTests(FolderTestCase): + def test_two_runs_in_the_same_second_get_different_folders(self): + with patch("cloudexit.cli.app.datetime", frozen()): + first, first_raw = app.create_directory(str(self.base)) + second, second_raw = app.create_directory(str(self.base)) + + self.assertNotEqual(first, second) + self.assertNotEqual(first_raw, second_raw) + for folder, raw in ((first, first_raw), (second, second_raw)): + self.assertTrue(Path(raw).is_dir()) + self.assertEqual(Path(raw).parent, Path(folder)) + self.assertTrue(Path(folder).name.startswith("20261112093000-")) + self.assertEqual(len(list(self.base.iterdir())), 2) + + def test_a_folder_that_exists_is_never_reused(self): + seen = set() + with patch("cloudexit.cli.app.datetime", frozen()): + for _ in range(25): + folder, _raw = app.create_directory(str(self.base)) + self.assertNotIn(folder, seen) + seen.add(folder) + self.assertEqual(len(seen), 25) + + def test_what_an_earlier_run_left_behind_is_untouched(self): + old = self.base / "20261112093000-abcdefgh" + (old / "raw_data").mkdir(parents=True) + (old / "raw_data" / "sync.json").write_text("earlier") + + with patch("cloudexit.cli.app.datetime", frozen()): + folder, _raw = app.create_directory(str(self.base)) + + self.assertNotEqual(Path(folder), old) + self.assertEqual((old / "raw_data" / "sync.json").read_text(), "earlier") + self.assertEqual(list(Path(folder).glob("**/*.json")), []) + + +@POSIX_ONLY +class PrivateFolderTests(FolderTestCase): + def setUp(self): + super().setUp() + previous = os.umask(0) + self.addCleanup(os.umask, previous) + + def test_the_folder_and_its_raw_data_are_for_the_owner_only(self): + folder, raw = app.create_directory(str(self.base)) + + self.assertEqual(mode(folder), 0o700) + self.assertEqual(mode(raw), 0o700) + self.assertEqual(mode(self.base), 0o700) + + def test_a_private_file_is_not_readable_by_others_and_keeps_a_stricter_umask(self): + path = self.base.parent / "a.json" + write_private(path, b"{}") + self.assertEqual(mode(path), 0o600) + + os.umask(0o277) + stricter = self.base.parent / "b.json" + write_private(stricter, b"{}") + self.assertEqual(mode(stricter), 0o400) + + def test_restricting_a_tree_removes_group_and_other_and_never_loosens(self): + root = self.base / "run" + (root / "assets").mkdir(parents=True) + files = { + root / "index.html": 0o644, + root / "assets" / "logo.svg": 0o664, + root / "report.pdf": 0o600, + root / "read-only.json": 0o400, + } + for path, wanted in files.items(): + path.write_text("x") + path.chmod(wanted) + os.chmod(root, 0o755) + os.chmod(root / "assets", 0o775) + + restrict_tree(root) + + self.assertEqual(mode(root), 0o700) + self.assertEqual(mode(root / "assets"), 0o700) + self.assertEqual(mode(root / "index.html"), 0o600) + self.assertEqual(mode(root / "assets" / "logo.svg"), 0o600) + self.assertEqual(mode(root / "report.pdf"), 0o600) + self.assertEqual(mode(root / "read-only.json"), 0o400) + + def test_a_link_is_left_alone(self): + root = self.base / "run" + root.mkdir(parents=True) + outside = self.base.parent / "outside.txt" + outside.write_text("x") + outside.chmod(0o644) + (root / "link").symlink_to(outside) + + restrict_tree(root) + + self.assertEqual(mode(outside), 0o644) + + def test_a_missing_folder_is_not_an_error(self): + restrict_tree(self.base / "nothing") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/cli/test_status.py b/tests/cli/test_status.py new file mode 100644 index 0000000..dcf0e12 --- /dev/null +++ b/tests/cli/test_status.py @@ -0,0 +1,739 @@ +import contextlib +import io +import json +import os +import ssl +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +import requests + +from cloudexit.cli import app +from cloudexit.platform.client import ApiClient +from cloudexit.platform.spool import Spool, key_fingerprint +from cloudexit.platform.status import ( + TlsResult, + catalogue_standing, + certificate_names, + check_tls, + host_catalogue_lines, + run_status, + status_budget, + STATUS_BUDGET, +) +from tests.support.api import ( + EU_KEY, + KEY, + error_body, + meta_reply, + minimal_document, + reply, + whoami_reply, +) +from tests.support.http import fake_http +from tests.support.isolation import isolated_environ +from tests.support.sync import SYNC_ID +from cloudexit import codes +from cloudexit.catalogue.store import ( + compare_catalogue_versions, + parse_catalogue_version, +) +from cloudexit.settings import Setting, Settings + +NOW = 1_800_000_000.0 +REAL_CLIENT = ApiClient.from_settings +HOST = "https://platform.example.com" +GOOD = TlsResult( + "ok", + "/etc/ssl/ca.pem", + subject="CN=platform.example.com", + issuer="O=Example CA, CN=Example Issuing CA", +) + + +def configured(**changes): + fields = dict( + host=Setting(HOST, "env CLOUDEXIT_HOST"), + api_key=Setting(KEY, "env CLOUDEXIT_API_KEY"), + ) + fields.update(changes) + return Settings(**fields) + + +def der(tag, content): + if len(content) < 128: + length = bytes([len(content)]) + else: + raw = len(content).to_bytes(2, "big") + length = bytes([0x82]) + raw + return bytes([tag]) + length + content + + +def der_name(**parts): + oids = {"CN": b"\x55\x04\x03", "O": b"\x55\x04\x0a"} + sets = b"" + for label, value in parts.items(): + pair = der(0x06, oids[label]) + der(0x0C, value.encode()) + sets += der(0x31, der(0x30, pair)) + return der(0x30, sets) + + +def der_certificate(issuer, subject): + tbs = der( + 0x30, + der(0xA0, der(0x02, b"\x02")) + + der(0x02, b"\x01") + + der(0x30, der(0x06, b"\x2a\x03")) + + issuer + + der(0x30, der(0x17, b"260101000000Z") + der(0x17, b"270101000000Z")) + + subject, + ) + return der(0x30, tbs + der(0x30, der(0x06, b"\x2a\x03")) + der(0x03, b"\x00")) + + +class StatusTestCase(unittest.TestCase): + def setUp(self): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.tmp = Path(tmp.name) + self.clients = 0 + self.bundle = self.tmp / "ca.pem" + self.bundle.write_text("bundle") + self.other = self.tmp / "other.pem" + self.other.write_text("other") + patcher = patch.dict( + "os.environ", + isolated_environ(self.tmp), + ) + patcher.start() + self.addCleanup(patcher.stop) + + def make_client(self, settings, **options): + self.clients += 1 + options["budget"] = 0.0 + options["environ"] = {} + options["cache_path"] = self.tmp / f"cache-{self.clients}" + options["sleep"] = lambda seconds: None + return REAL_CLIENT(settings, **options) + + def status(self, settings, replies=None, tls=GOOD, **options): + options.setdefault("environ", {}) + with fake_http(*(replies or [])) as http: + report = run_status( + settings, + now=lambda: NOW, + tls=lambda url, bundle: tls, + make_client=self.make_client, + spool=Spool(self.tmp / "spool"), + **options, + ) + self.http = http + return report + + def text(self, report): + return "\n".join(report.lines()) + + +class ExitCodeTests(StatusTestCase): + def test_nothing_configured_is_fine_and_offline(self): + report = self.status(Settings()) + + self.assertEqual(report.exit_code, codes.SUCCESS) + self.assertEqual(self.http.requests, []) + self.assertIn("not configured", self.text(report)) + + def test_all_good(self): + report = self.status(configured(), [meta_reply(), whoami_reply()]) + + self.assertEqual(report.exit_code, codes.SUCCESS) + text = self.text(report) + self.assertIn("Everything checked is fine.", text) + self.assertIn("certificate: verified", text) + self.assertIn("CA bundle: ", text) + self.assertIn("host: escapecloud", text) + self.assertIn("catalogue: 2026-11-02", text) + self.assertIn("max_body_bytes=1000000", text) + self.assertIn("organisation: Org (organization)", text) + self.assertIn("assessment: 0193a000-0000-7000-8000-000000000001", text) + self.assertIn("scopes: syncs:write", text) + self.assertIn("is accepted", text) + self.assertEqual([r.method for r in self.http.requests], ["GET", "GET"]) + + def test_a_rejected_key_is_an_auth_problem(self): + report = self.status( + configured(), + [meta_reply(), reply(401, error_body("api_key_revoked"))], + ) + + self.assertEqual(report.exit_code, codes.UPLOAD_AUTH) + self.assertIn("api_key_revoked", self.text(report)) + + def test_a_key_that_cannot_be_right_is_an_auth_problem_without_a_request(self): + settings = configured(api_key=Setting("esc_sk_typo", "env CLOUDEXIT_API_KEY")) + + report = self.status(settings) + + self.assertEqual(report.exit_code, codes.UPLOAD_AUTH) + self.assertEqual(self.http.requests, []) + + def test_half_a_configuration_is_an_auth_problem(self): + for settings in ( + Settings(host=Setting(HOST, "env CLOUDEXIT_HOST")), + Settings(api_key=Setting(KEY, "env CLOUDEXIT_API_KEY")), + ): + with self.subTest(settings=settings.host.value): + report = self.status(settings) + self.assertEqual(report.exit_code, codes.UPLOAD_AUTH) + self.assertEqual(self.http.requests, []) + + def test_a_key_for_another_region_is_an_auth_problem(self): + settings = configured(api_key=Setting(EU_KEY, "env CLOUDEXIT_API_KEY")) + replies = [meta_reply(host={"product": "escapecloud", "region": "us"})] + + report = self.status(settings, replies) + + self.assertEqual(report.exit_code, codes.UPLOAD_AUTH) + self.assertIn("key", self.text(report)) + + def test_an_unreachable_host_is_exit_12(self): + report = self.status(configured(), [requests.ConnectionError("refused")]) + + self.assertEqual(report.exit_code, codes.UPLOAD_UNAVAILABLE) + + def test_a_client_the_platform_does_not_accept_is_exit_15(self): + replies = [ + meta_reply( + clients={ + "cloudexit": {"min_supported": "99.0.0", "recommended": "99.1"} + } + ) + ] + + report = self.status(configured(), replies + [whoami_reply()]) + + self.assertEqual(report.exit_code, codes.UPLOAD_UNSUPPORTED) + self.assertIn("is not accepted by the platform", self.text(report)) + + def test_a_schema_version_the_platform_does_not_accept_is_exit_15(self): + replies = [meta_reply(contract={"schema_versions": ["9.0"], "current": "9.0"})] + + report = self.status(configured(), replies + [whoami_reply()]) + + self.assertEqual(report.exit_code, codes.UPLOAD_UNSUPPORTED) + + def test_unreachable_wins_over_auth_and_auth_over_unsupported(self): + report = self.status(configured(), [meta_reply(), whoami_reply()]) + report.problem(codes.UPLOAD_UNSUPPORTED, "a") + self.assertEqual(report.exit_code, codes.UPLOAD_UNSUPPORTED) + report.problem(codes.UPLOAD_AUTH, "b") + self.assertEqual(report.exit_code, codes.UPLOAD_AUTH) + report.problem(codes.UPLOAD_UNAVAILABLE, "c") + self.assertEqual(report.exit_code, codes.UPLOAD_UNAVAILABLE) + + +class TlsFailureTests(StatusTestCase): + def test_a_failed_chain_names_the_bundle_and_the_failing_certificate(self): + failed = TlsResult( + "failed", + str(self.bundle), + "self-signed certificate in certificate chain", + subject="CN=platform.example.com", + issuer="O=Corp, CN=Corp Root CA", + ) + settings = configured(ca_bundle=Setting(str(self.bundle), "flag --ca-bundle")) + + report = self.status(settings, [], tls=failed) + + self.assertEqual(report.exit_code, codes.UPLOAD_UNAVAILABLE) + self.assertEqual(self.http.requests, []) + text = self.text(report) + self.assertIn(f"CA bundle: flag --ca-bundle: {self.bundle}", text) + self.assertIn( + "NOT verified: self-signed certificate in certificate chain", text + ) + self.assertIn("CN=platform.example.com", text) + self.assertIn( + "issued by O=Corp, CN=Corp Root CA (not trusted by this bundle)", text + ) + self.assertIn("--ca-bundle", text) + + def test_an_unreachable_port_is_exit_12(self): + down = TlsResult("unreachable", "x", "cannot connect to h:443 (OSError)") + self.assertEqual( + self.status(configured(), [], tls=down).exit_code, codes.UPLOAD_UNAVAILABLE + ) + + def test_a_proxy_leaves_the_check_to_the_request(self): + proxied = TlsResult("proxy", "x", "a proxy is configured") + + report = self.status(configured(), [meta_reply(), whoami_reply()], tls=proxied) + + self.assertEqual(report.exit_code, codes.SUCCESS) + self.assertIn("not checked directly", self.text(report)) + + def test_the_configured_bundle_beats_the_environment(self): + settings = configured( + ca_bundle=Setting(str(self.bundle), "config file [default]") + ) + report = self.status( + settings, + [meta_reply(), whoami_reply()], + environ={"REQUESTS_CA_BUNDLE": str(self.other)}, + ) + self.assertIn( + f"CA bundle: config file [default]: {self.bundle}", self.text(report) + ) + + report = self.status( + configured(), + [meta_reply(), whoami_reply()], + environ={"REQUESTS_CA_BUNDLE": str(self.other)}, + ) + self.assertIn(f"the environment: {self.other}", self.text(report)) + + def test_nothing_in_the_output_holds_the_key(self): + report = self.status(configured(), [meta_reply(), whoami_reply()]) + self.assertNotIn(KEY, self.text(report)) + self.assertNotIn(KEY, json.dumps(report.as_json())) + + +class CheckTlsTests(unittest.TestCase): + def setUp(self): + # A proxy in the developer's environment would change what is checked + names = [n for n in os.environ if n.lower().endswith("_proxy")] + patcher = patch.dict(os.environ) + patcher.start() + self.addCleanup(patcher.stop) + for name in names: + del os.environ[name] + + class Socket: + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + class Context: + def __init__(self, error=None, cert=None): + self.error = error + self.cert = cert or {} + + def wrap_socket(self, sock, server_hostname=None): + if self.error: + raise self.error + outer = self + + class Wrapped: + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + def getpeercert(self, binary_form=False): + return outer.cert + + return Wrapped() + + def check(self, url="https://platform.example.com", **context): + return check_tls( + url, + "/ca.pem", + connect=lambda address, timeout: self.Socket(), + context_factory=lambda bundle: self.Context(**context), + ) + + def test_a_verified_certificate(self): + cert = { + "subject": ((("commonName", "platform.example.com"),),), + "issuer": ( + (("organizationName", "Example CA"), ("commonName", "Issuing")), + ), + "notAfter": "Jan 1 00:00:00 2027 GMT", + } + result = self.check(cert=cert) + + self.assertEqual(result.status, "ok") + self.assertEqual(result.subject, "CN=platform.example.com") + self.assertEqual(result.issuer, "O=Example CA, CN=Issuing") + self.assertEqual(result.expires, "Jan 1 00:00:00 2027 GMT") + + def test_a_chain_that_does_not_verify_says_why_and_what_was_presented(self): + error = ssl.SSLCertVerificationError(1, "verify failed") + error.verify_message = "unable to get local issuer certificate" + names = {"subject": "CN=platform.example.com", "issuer": "CN=Unknown CA"} + + with patch("cloudexit.platform.status._presented", return_value=names): + result = self.check(error=error) + + self.assertEqual(result.status, "failed") + self.assertEqual(result.message, "unable to get local issuer certificate") + self.assertEqual(result.issuer, "CN=Unknown CA") + self.assertEqual(result.subject, "CN=platform.example.com") + + def test_another_tls_error_is_a_failure_with_its_reason(self): + error = ssl.SSLError(1, "handshake") + error.reason = "TLSV1_ALERT_PROTOCOL_VERSION" + + result = self.check(error=error) + + self.assertEqual(result.status, "failed") + self.assertEqual(result.message, "TLSV1_ALERT_PROTOCOL_VERSION") + + def test_a_refused_connection_is_unreachable(self): + result = self.check(error=ConnectionRefusedError()) + + self.assertEqual(result.status, "unreachable") + self.assertIn("platform.example.com:443", result.message) + + def test_a_plain_http_host_has_no_tls_to_check(self): + self.assertEqual( + self.check(url="http://localhost:8000").status, "not_applicable" + ) + + def test_a_proxy_is_left_to_the_request(self): + with patch.dict("os.environ", {"HTTPS_PROXY": "http://proxy.example.com:3128"}): + self.assertEqual(self.check().status, "proxy") + + def test_the_names_of_a_certificate_are_read_from_its_der_form(self): + issuer = der_name(O="Example CA", CN="Example Issuing CA") + subject = der_name(CN="platform.example.com") + + names = certificate_names(der_certificate(issuer, subject)) + + self.assertEqual(names["issuer"], "O=Example CA, CN=Example Issuing CA") + self.assertEqual(names["subject"], "CN=platform.example.com") + + def test_garbage_gives_no_names_instead_of_an_error(self): + for data in (b"", b"\x30", b"\x30\x05abc", b"not a certificate"): + with self.subTest(data=data): + self.assertEqual(certificate_names(data), {}) + + +class ReportContentTests(StatusTestCase): + def test_a_key_that_expires_soon_warns_and_a_later_one_does_not(self): + soon = whoami_reply(key={"expires_at": int(NOW + 5 * 86400)}) + report = self.status(configured(), [meta_reply(), soon]) + + self.assertEqual(report.exit_code, codes.SUCCESS) + self.assertIn("expires in 5 days", self.text(report)) + + later = whoami_reply(key={"expires_at": int(NOW + 14 * 86400 + 60)}) + report = self.status(configured(), [meta_reply(), later]) + self.assertEqual(report.warnings, []) + + def test_a_deprecated_client_warns(self): + meta = meta_reply(clients={"cloudexit": {"deprecated_below": "99.0.0"}}) + + report = self.status(configured(), [meta, whoami_reply()]) + + self.assertEqual(report.exit_code, codes.SUCCESS) + self.assertTrue(any("deprecated" in w for w in report.warnings)) + + def test_the_spool_is_summarised(self): + settings = configured() + spool = Spool(self.tmp / "spool") + spool.add( + HOST, + minimal_document(SYNC_ID, int(NOW) - 100), + error=None, + attempts=1, + name=None, + exit_strategy=None, + assessment_id=None, + key_id=key_fingerprint(KEY), + ) + + with fake_http(meta_reply(), whoami_reply()): + report = run_status( + settings, + now=lambda: NOW + 3600, + tls=lambda url, bundle: GOOD, + make_client=self.make_client, + spool=spool, + ) + + self.assertIn("1 sync waiting for this host", self.text(report)) + self.assertEqual(report.data["spool"]["pending"], 1) + + def test_an_empty_spool_says_so(self): + report = self.status(configured(), [meta_reply(), whoami_reply()]) + self.assertEqual(report.data["spool"]["pending"], 0) + self.assertIn("empty", self.text(report)) + + def test_the_catalogue_is_compared_with_the_platforms(self): + with patch( + "cloudexit.platform.status._local_catalogue_version", + return_value="2026-10-01", + ): + report = self.status(configured(), [meta_reply(), whoami_reply()]) + + self.assertIn("installed: 2026-10-01", self.text(report)) + self.assertIn("the installed one is older", self.text(report)) + self.assertEqual(report.data["catalogue"]["standing"], "older") + + def test_the_json_form_carries_the_exit_code_and_the_problems(self): + report = self.status(configured(), [requests.ConnectionError("refused")]) + + data = report.as_json() + + self.assertEqual(data["exit_code"], codes.UPLOAD_UNAVAILABLE) + self.assertEqual(data["problems"][0]["exit_code"], codes.UPLOAD_UNAVAILABLE) + self.assertTrue(data["configured"]) + + +class StatusBudgetTests(unittest.TestCase): + def test_a_budget_the_user_set_applies_and_otherwise_the_default_does(self): + self.assertEqual(status_budget(Settings()), STATUS_BUDGET) + chosen = Settings(upload_budget=Setting(2.5, "flag --upload-budget")) + self.assertEqual(status_budget(chosen), 2.5) + + +class CatalogueComparisonTests(unittest.TestCase): + def test_versions_are_a_date_and_an_optional_suffix(self): + self.assertEqual(parse_catalogue_version("2026-11-02"), (2026, 11, 2, 0)) + self.assertEqual(parse_catalogue_version("2026-11-02.3"), (2026, 11, 2, 3)) + for bad in (None, "", "latest", "2026-11", "2026-11-02-1", "v2026-11-02"): + with self.subTest(bad=bad): + self.assertIsNone(parse_catalogue_version(bad)) + + def test_older_same_newer(self): + cases = ( + ("2026-11-01", "2026-11-02", "older"), + ("2026-11-02", "2026-11-02", "same"), + ("2026-11-03", "2026-11-02", "newer"), + ("2026-11-02", "2026-11-02.1", "older"), + ("2026-11-02.2", "2026-11-02.1", "newer"), + ("2026-11-02.10", "2026-11-02.9", "newer"), + ("2026-11-02", "2026-11-02.0", "same"), + ("2025-12-31", "2026-01-01", "older"), + ) + for local, other, expected in cases: + with self.subTest(local=local, other=other): + self.assertEqual(compare_catalogue_versions(local, other), expected) + + def test_an_unreadable_version_gives_no_answer(self): + self.assertIsNone(compare_catalogue_versions("x", "2026-11-02")) + self.assertIsNone(compare_catalogue_versions("2026-11-02", None)) + + def test_the_wording(self): + self.assertIn("older", catalogue_standing("2026-11-01", "2026-11-02")) + self.assertIn("same", catalogue_standing("2026-11-02", "2026-11-02")) + self.assertIn("newer", catalogue_standing("2026-11-03", "2026-11-02")) + self.assertIn("none is installed", catalogue_standing(None, "2026-11-02")) + self.assertIn("cannot be compared", catalogue_standing("x", "2026-11-02")) + self.assertIn("reports none", catalogue_standing("2026-11-02", None)) + + +class HostCatalogueLinesTests(StatusTestCase): + def lines(self, settings, local, replies): + with fake_http(*replies) as http: + lines = host_catalogue_lines(settings, local, make_client=self.make_client) + self.http = http + return lines + + def test_without_a_key_nothing_is_asked(self): + for settings in (Settings(), Settings(host=Setting(HOST, "env"))): + self.assertEqual(self.lines(settings, "2026-11-01", []), []) + + def test_the_hosts_version_is_compared(self): + for local, word in ( + ("2026-11-01", "older"), + ("2026-11-02", "same"), + ("2026-11-03", "newer"), + ): + with self.subTest(local=local): + lines = self.lines(configured(), local, [meta_reply()]) + self.assertEqual(len(lines), 1) + self.assertTrue(lines[0].startswith("platform: 2026-11-02")) + self.assertIn(word, lines[0]) + + def test_an_unreachable_platform_is_reported_not_hidden(self): + lines = self.lines( + configured(), "2026-11-01", [requests.ConnectionError("refused")] + ) + self.assertEqual(len(lines), 1) + self.assertIn("unavailable", lines[0]) + + +class ShapeTests(StatusTestCase): + def test_a_platform_answer_of_the_wrong_shape_never_crashes_status(self): + for field in ("host", "contract", "catalogue", "limits", "clients"): + for value in ("escapecloud", ["x"], 7, None, True): + with self.subTest(field=field, value=value): + report = self.status( + configured(), + [meta_reply(**{field: value}), whoami_reply()], + ) + + self.assertEqual(report.exit_code, codes.SUCCESS) + self.assertIn("Platform (GET /meta)", self.text(report)) + json.dumps(report.as_json(), allow_nan=False) + + def test_a_whoami_of_the_wrong_shape_never_crashes_status(self): + for field, value in ( + ("key", "esc_sk"), + ("tenant", ["Org"]), + ("warnings", "careful"), + ("key", {"scopes": "syncs:write", "expires_at": "soon"}), + ("key", {"scopes": 5, "expires_at": True}), + ): + with self.subTest(field=field, value=value): + report = self.status( + configured(), + [meta_reply(), whoami_reply(**{field: value})], + ) + + self.assertEqual(report.exit_code, codes.SUCCESS) + self.assertIn("Key (GET /whoami)", self.text(report)) + json.dumps(report.as_json(), allow_nan=False) + + def test_an_expiry_no_calendar_holds_is_unknown(self): + for expires in (1e20, -1e20, float("inf"), float("nan"), 2**80): + with self.subTest(expires=expires): + report = self.status( + configured(), + [meta_reply(), whoami_reply(key={"expires_at": expires})], + ) + + self.assertEqual(report.exit_code, codes.SUCCESS) + self.assertIn("expires: unknown", self.text(report)) + data = json.loads(json.dumps(report.as_json(), allow_nan=False)) + if expires != expires or abs(expires) == float("inf"): + self.assertIsNone(data["key"]["expires_at"]) + + def test_a_catalogue_of_the_wrong_shape_is_unknown_on_the_catalogue_command(self): + for catalogue in ("2026-11-02", ["2026-11-02"], {"version": 20261102}): + with self.subTest(catalogue=catalogue): + with ( + patch( + "cloudexit.platform.status.ApiClient.from_settings", + side_effect=self.make_client, + ), + fake_http(meta_reply(catalogue=catalogue)), + ): + lines = host_catalogue_lines(configured(), "2026-11-01") + + self.assertEqual(lines, ["platform: the platform reports none"]) + + def test_the_status_command_answers_even_when_the_check_itself_fails(self): + for output in ("text", "json"): + with self.subTest(output=output): + out = io.StringIO() + with ( + patch("sys.argv", ["cloudexit", "status", "--output", output]), + patch("cloudexit.cli.app.load_settings", return_value=configured()), + patch( + "cloudexit.cli.app.run_status", + side_effect=AttributeError("boom"), + ), + contextlib.redirect_stdout(out), + ): + with self.assertRaises(SystemExit) as raised: + app.main() + + self.assertEqual(raised.exception.code, codes.UNEXPECTED) + self.assertNotIn("boom", out.getvalue()) + if output == "json": + data = json.loads(out.getvalue()) + self.assertEqual(data["exit_code"], codes.UNEXPECTED) + self.assertIn("AttributeError", data["problems"][0]["message"]) + + +class CommandTests(StatusTestCase): + def run_command(self, argv, replies=None, settings=None): + out = io.StringIO() + + def checked(found): + return run_status( + found, + now=lambda: NOW, + tls=lambda url, bundle: GOOD, + make_client=self.make_client, + spool=Spool(self.tmp / "spool"), + ) + + with ( + patch("sys.argv", ["cloudexit", *argv]), + patch( + "cloudexit.cli.app.load_settings", return_value=settings or configured() + ), + patch("cloudexit.cli.app.run_status", side_effect=checked), + contextlib.redirect_stdout(out), + fake_http(*(replies or [])), + ): + try: + app.main() + code = 0 + except SystemExit as e: + code = e.code + return code, out.getvalue() + + def test_status_prints_the_report_and_exits_zero(self): + code, out = self.run_command(["status"], [meta_reply(), whoami_reply()]) + + self.assertEqual(code, 0) + self.assertIn("Everything checked is fine.", out) + self.assertNotIn(KEY, out) + + def test_status_exits_with_the_problem_code(self): + code, out = self.run_command(["status"], [requests.ConnectionError("x")]) + + self.assertEqual(code, codes.UPLOAD_UNAVAILABLE) + self.assertIn("problem:", out) + + def test_status_as_json(self): + code, out = self.run_command( + ["status", "--output", "json"], [meta_reply(), whoami_reply()] + ) + + data = json.loads(out) + self.assertEqual(code, 0) + self.assertEqual(data["exit_code"], 0) + self.assertEqual(data["platform"]["catalogue_version"], "2026-11-02") + self.assertEqual(data["key"]["kind"], "assessment") + + def test_status_needs_no_platform_to_run(self): + code, out = self.run_command(["status"], settings=Settings()) + + self.assertEqual(code, 0) + self.assertIn("not configured", out) + + def test_catalogue_status_compares_with_the_platform_when_a_key_is_set(self): + out = io.StringIO() + with ( + patch("sys.argv", ["cloudexit", "catalogue", "status"]), + patch("cloudexit.cli.app.load_settings", return_value=configured()), + patch("cloudexit.cli.app.status_lines", return_value=["installed: none"]), + patch("cloudexit.cli.app.installed_catalogue", return_value=None), + patch( + "cloudexit.platform.status.ApiClient.from_settings", + side_effect=self.make_client, + ), + contextlib.redirect_stdout(out), + fake_http(meta_reply()), + ): + app.main() + + self.assertIn("installed: none", out.getvalue()) + self.assertIn("platform: 2026-11-02 (", out.getvalue()) + + def test_catalogue_status_without_a_key_asks_the_platform_nothing(self): + out = io.StringIO() + with ( + patch("sys.argv", ["cloudexit", "catalogue", "status"]), + patch("cloudexit.cli.app.load_settings", return_value=Settings()), + patch("cloudexit.cli.app.status_lines", return_value=["installed: none"]), + contextlib.redirect_stdout(out), + fake_http(), + ): + app.main() + + self.assertNotIn("platform:", out.getvalue()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/cli/test_stub_platform.py b/tests/cli/test_stub_platform.py new file mode 100644 index 0000000..8bf096a --- /dev/null +++ b/tests/cli/test_stub_platform.py @@ -0,0 +1,365 @@ +import json +import os +import socket +import subprocess +import sys +import tempfile +import threading +import unittest +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +from tests.support.api import ( + KEY, + error_body, + meta_body, + sync_body, + whoami_body, +) +from tests.support.isolation import isolated_environ +from tests.make_smoke_dataset import main as make_smoke_dataset +from cloudexit import codes +from tests.support.paths import ROOT + +SMOKE_STATE = ROOT / "tests" / "fixtures" / "smoke" / "aws.tfstate" +WARNING = { + "code": "collector_catalogue_outdated", + "message": "The catalogue used is older than the platform's.", +} + + +class StubPlatform: + # A platform on localhost that answers from canned bodies and counts calls + def __init__(self): + self.routes = { + ("GET", "/api/v2/meta"): (200, meta_body()), + ("GET", "/api/v2/whoami"): (200, whoami_body()), + ("POST", "/api/v2/syncs"): (201, sync_body()), + } + self.requests = [] + stub = self + + class Handler(BaseHTTPRequestHandler): + def handle_request(self): + length = int(self.headers.get("Content-Length") or 0) + if length: + self.rfile.read(length) + stub.requests.append((self.command, self.path)) + status, body = stub.routes.get( + (self.command, self.path.split("?")[0]), + (404, error_body("not_found")), + ) + data = json.dumps(body).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("X-Request-ID", "req-stub-0001") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + do_GET = do_POST = handle_request + + def log_message(self, *args): + pass + + self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + self.url = f"http://127.0.0.1:{self.server.server_port}" + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) + + def __enter__(self): + self.thread.start() + return self + + def __exit__(self, *exc): + self.server.shutdown() + self.server.server_close() + self.thread.join() + + +def closed_port_url(): + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + return f"http://127.0.0.1:{sock.getsockname()[1]}" + + +class PlatformCliTestCase(unittest.TestCase): + @classmethod + def setUpClass(cls): + tmp = tempfile.TemporaryDirectory() + cls.addClassCleanup(tmp.cleanup) + cls.dataset = Path(tmp.name) / "smoke.db" + make_smoke_dataset(str(cls.dataset)) + + def setUp(self): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.tmp = Path(tmp.name) + self.env = { + name: value + for name, value in os.environ.items() + if not name.startswith(("ESC_", "CLOUDEXIT_")) + and name.lower() not in ("http_proxy", "https_proxy", "all_proxy") + } + self.env.update( + { + **isolated_environ(self.tmp), + "NO_PROXY": "*", + "COLUMNS": "200", + "ESC_EXIT_STRATEGY": "1", + "ESC_ASSESSMENT_TYPE": "2", + } + ) + + def connect(self, platform): + self.env["CLOUDEXIT_HOST"] = platform.url + self.env["CLOUDEXIT_API_KEY"] = KEY + + def cli(self, *args): + return subprocess.run( + [sys.executable, str(ROOT / "main.py"), *args], + cwd=self.tmp, + env=self.env, + stdin=subprocess.DEVNULL, + capture_output=True, + text=True, + timeout=180, + ) + + def run_aws(self, *extra): + return self.cli( + "aws", + "--tfstate", + str(SMOKE_STATE), + "--catalogue", + str(self.dataset), + "--non-interactive", + *extra, + ) + + def run_json(self, *extra): + result = self.run_aws("--output", "json", *extra) + # Stdout is exactly one JSON object, whatever happened + summary = json.loads(result.stdout) + self.assertNotIn(KEY, result.stdout + result.stderr) + return result, summary + + def reports(self): + return list(self.tmp.glob("reports/*/index.html")) + + +class RunModeCliTests(PlatformCliTestCase): + def test_auto_uploads_and_stdout_is_only_the_json_summary(self): + with StubPlatform() as platform: + self.connect(platform) + result, summary = self.run_json() + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(summary["exit_code"], 0) + self.assertEqual(summary["upload"]["status"], "uploaded") + self.assertEqual(summary["upload"]["url"], sync_body()["assessment"]["url"]) + self.assertEqual(summary["results"]["scoring_version"], "1") + self.assertEqual(summary["catalogue"]["outdated"], False) + self.assertTrue(Path(summary["report_dir"], "index.html").is_file()) + self.assertTrue(Path(summary["sync_json"]).is_file()) + self.assertEqual( + [r[0] for r in platform.requests], ["GET", "GET", "POST"], platform.requests + ) + self.assertIn("Stage #7", result.stderr) + self.assertNotIn("Stage #7", result.stdout) + self.assertNotIn("Preliminary Stage", result.stdout) + + def test_a_refused_key_still_writes_the_report_and_exits_11(self): + with StubPlatform() as platform: + platform.routes[("GET", "/api/v2/whoami")] = ( + 401, + error_body("api_key_revoked"), + ) + self.connect(platform) + result, summary = self.run_json() + + self.assertEqual(result.returncode, codes.UPLOAD_AUTH, result.stderr) + self.assertEqual(summary["exit_code"], codes.UPLOAD_AUTH) + self.assertEqual(summary["upload"]["status"], "failed") + self.assertEqual(summary["upload"]["error"]["code"], "api_key_revoked") + self.assertEqual(summary["upload"]["error"]["request_id"], "req-fake-0001") + self.assertEqual(len(self.reports()), 1) + + def test_off_sends_nothing_even_with_a_key(self): + with StubPlatform() as platform: + self.connect(platform) + result, summary = self.run_json("--upload", "off") + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(platform.requests, []) + self.assertEqual(summary["upload"]["status"], "skipped") + self.assertEqual(summary["upload"]["reason"], "off") + self.assertIn("Basic assessment: Standard needs an upload.", result.stderr) + self.assertEqual(len(self.reports()), 1) + + def test_the_mode_can_come_from_the_environment(self): + with StubPlatform() as platform: + self.connect(platform) + self.env["CLOUDEXIT_UPLOAD"] = "off" + result, summary = self.run_json() + + self.assertEqual(platform.requests, []) + self.assertEqual(summary["upload"]["reason"], "off") + + def test_required_without_a_platform_fails_before_collecting(self): + result, summary = self.run_json("--upload", "required") + + self.assertEqual(result.returncode, codes.CONFIG, result.stderr) + self.assertEqual(summary["exit_code"], codes.CONFIG) + self.assertIsNone(summary["report_dir"]) + self.assertEqual(self.reports(), []) + self.assertFalse((self.tmp / "reports").exists()) + self.assertIn("Upload is required", " ".join(result.stderr.split())) + + def test_auto_without_a_platform_runs_offline(self): + result, summary = self.run_json() + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(summary["upload"]["status"], "skipped") + self.assertEqual(summary["upload"]["reason"], "offline") + self.assertIn( + "Basic assessment: Standard needs an upload.", + " ".join(result.stderr.split()), + ) + + def test_half_a_configuration_is_not_an_offline_run(self): + self.env["CLOUDEXIT_HOST"] = "http://127.0.0.1:9" + result, summary = self.run_json() + + self.assertEqual(result.returncode, codes.CONFIG, result.stderr) + self.assertEqual(self.reports(), []) + + def test_strict_fails_after_the_report_when_the_platform_warns(self): + with StubPlatform() as platform: + platform.routes[("POST", "/api/v2/syncs")] = ( + 201, + sync_body(warnings=[WARNING]), + ) + self.connect(platform) + result, summary = self.run_json("--strict") + + self.assertEqual(result.returncode, codes.UPLOAD_REJECTED, result.stderr) + self.assertEqual(summary["exit_code"], codes.UPLOAD_REJECTED) + self.assertEqual(summary["upload"]["status"], "uploaded") + self.assertEqual(summary["upload"]["warnings"], [WARNING]) + self.assertIs(summary["catalogue"]["outdated"], True) + self.assertEqual(len(self.reports()), 1) + + def test_the_same_warning_passes_without_strict(self): + with StubPlatform() as platform: + platform.routes[("POST", "/api/v2/syncs")] = ( + 201, + sync_body(warnings=[WARNING]), + ) + self.connect(platform) + result, summary = self.run_json() + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(summary["upload"]["warnings"], [WARNING]) + + def test_a_dry_run_is_upload_off_with_the_sync_document(self): + with StubPlatform() as platform: + self.connect(platform) + result, summary = self.run_json("--dry-run") + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(platform.requests, []) + self.assertEqual(summary["upload"]["reason"], "dry_run") + self.assertTrue(Path(summary["sync_json"]).is_file()) + self.assertIn( + "--dry-run is --upload=off plus the sync document", + " ".join(result.stderr.split()), + ) + + def test_text_output_is_unchanged_on_stdout(self): + result = self.run_aws() + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("Preliminary Stage", result.stdout) + self.assertNotIn('"exit_code"', result.stdout) + + +class StatusCliTests(PlatformCliTestCase): + def test_a_healthy_platform(self): + with StubPlatform() as platform: + self.connect(platform) + result = self.cli("status", "--output", "json") + + self.assertEqual(result.returncode, 0, result.stderr) + data = json.loads(result.stdout) + self.assertEqual(data["exit_code"], 0) + self.assertEqual(data["key"]["kind"], "assessment") + self.assertEqual(data["tls"]["status"], "not_applicable") + self.assertNotIn(KEY, result.stdout + result.stderr) + + def test_the_text_report(self): + with StubPlatform() as platform: + self.connect(platform) + result = self.cli("status") + + self.assertEqual(result.returncode, 0, result.stderr) + for expected in ( + "Settings", + "Platform (GET /meta)", + "Key (GET /whoami)", + "Spool", + "Catalogue", + "Everything checked is fine.", + ): + self.assertIn(expected, result.stdout) + self.assertNotIn(KEY, result.stdout + result.stderr) + + def test_a_host_that_does_not_answer_is_exit_12(self): + self.env["CLOUDEXIT_HOST"] = closed_port_url() + self.env["CLOUDEXIT_API_KEY"] = KEY + self.env["CLOUDEXIT_UPLOAD_BUDGET"] = "0" + + result = self.cli("status") + + self.assertEqual(result.returncode, codes.UPLOAD_UNAVAILABLE, result.stdout) + + def test_a_refused_key_is_exit_11(self): + with StubPlatform() as platform: + platform.routes[("GET", "/api/v2/whoami")] = ( + 401, + error_body("api_key_revoked"), + ) + self.connect(platform) + result = self.cli("status") + + self.assertEqual(result.returncode, codes.UPLOAD_AUTH, result.stdout) + self.assertIn("api_key_revoked", result.stdout) + + def test_a_client_the_platform_refuses_is_exit_15(self): + with StubPlatform() as platform: + platform.routes[("GET", "/api/v2/meta")] = ( + 200, + meta_body(clients={"cloudexit": {"min_supported": "99.0.0"}}), + ) + self.connect(platform) + result = self.cli("status") + + self.assertEqual(result.returncode, codes.UPLOAD_UNSUPPORTED, result.stdout) + + def test_nothing_configured_is_exit_0(self): + result = self.cli("status") + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("not configured", result.stdout) + + def test_catalogue_status_names_the_platforms_catalogue(self): + with StubPlatform() as platform: + self.connect(platform) + self.env["CLOUDEXIT_CATALOGUE_URL"] = closed_port_url() + result = self.cli("catalogue", "status") + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("platform: 2026-11-02", result.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/cli/test_upload_modes.py b/tests/cli/test_upload_modes.py new file mode 100644 index 0000000..dacf7dd --- /dev/null +++ b/tests/cli/test_upload_modes.py @@ -0,0 +1,697 @@ +import io +import json +import sys +import unittest +from argparse import Namespace +from contextlib import redirect_stderr +from pathlib import Path +from unittest.mock import patch + +from cloudexit.cli import app +from cloudexit.platform.run_output import ( + build_run_json, + catalogue_outdated, + upload_status, +) +from cloudexit.platform.upload import UploadResult +from tests.support.api import ( + ASSESSMENT_ID, + KEY, + URL, + meta_reply, + success_replies, + whoami_reply, + created_reply, +) +from tests.report import test_report_flow as flow +from cloudexit import codes +from cloudexit.settings import Setting, Settings, load_settings +from tests.support.isolation import isolated_environ + +OUTDATED = { + "code": "collector_catalogue_outdated", + "message": "The catalogue used is older than the platform's.", +} +UNKNOWN_TYPES = {"code": "unknown_resource_types", "message": "Some types are unknown."} + + +def with_mode(mode, source="flag --upload"): + return Settings( + host=flow.CONFIGURED.host, + api_key=flow.CONFIGURED.api_key, + upload_budget=flow.CONFIGURED.upload_budget, + upload=Setting(mode, source), + ) + + +class ModeTestCase(flow.FlowTestCase): + run_assessment = flow.RunFlowTests.run_assessment + sync = flow.RunFlowTests.sync + assert_local_report = flow.RunFlowTests.assert_local_report + + def messages(self): + return [args[0] for args, _ in self.steps] + + def forced(self): + return [m for m in self.messages() if m.startswith("Basic assessment")] + + def summary(self, exit_code=0): + out = io.StringIO() + app._emit_run_json(out, exit_code) + return json.loads(out.getvalue()) + + +class UploadModeMatrixTests(ModeTestCase): + def test_auto_without_a_platform_runs_offline_and_succeeds(self): + report = self.report() + with patch("cloudexit.cli.app.upload") as mock_upload: + self.run_assessment(report, settings=Settings()) + + mock_upload.assert_not_called() + self.assert_local_report(report) + self.assertEqual(self.forced(), ["Basic assessment: Standard needs an upload."]) + + def test_the_legacy_host_and_key_environment_variables_upload_nothing(self): + report = self.report() + environ = { + **isolated_environ(self.tmp), + "HOST": "platform.test", + "KEY": "fixture-key-0123456789", + } + with self.assertLogs("cloudexit.settings", level="WARNING") as logs: + settings = load_settings(None, environ=environ) + + self.assertIsNone(settings.host.value) + self.assertIsNone(settings.api_key.value) + self.assertNotIn("fixture-key-0123456789", logs.output[0]) + + with patch("cloudexit.cli.app.upload") as mock_upload: + self.run_assessment(report, settings=settings, assessment_type=2) + + mock_upload.assert_not_called() + self.assert_local_report(report) + self.assertEqual(self.sync(report)["assessment_type"], 1) + + def test_auto_with_a_platform_uploads(self): + report = self.report() + self.run_assessment(report, settings=flow.CONFIGURED, replies=success_replies()) + + self.assert_local_report(report) + self.assertEqual(self.sync(report)["assessment_type"], 2) + self.assertEqual(self.forced(), []) + self.assertEqual(app.RUN["upload"].url, URL) + + def test_auto_with_a_platform_that_refuses_the_key_writes_the_report_first(self): + report = self.report() + with self.assertRaises(SystemExit) as ctx: + self.run_assessment( + report, + settings=flow.CONFIGURED, + replies=flow.failed_replies(401, "api_key_revoked", False), + ) + + self.assertEqual(ctx.exception.code, codes.UPLOAD_AUTH) + self.assert_local_report(report) + self.assertIn("Upload failed", self.html(report)) + + def test_required_without_a_platform_fails_before_collecting_anything(self): + report = self.report() + for settings in ( + Settings(upload=Setting("required", "flag --upload")), + Settings( + host=flow.CONFIGURED.host, upload=Setting("required", "flag --upload") + ), + ): + with self.subTest(settings=settings.host.value): + with ( + patch("cloudexit.cli.app.upload") as mock_upload, + self.assertRaises(SystemExit) as ctx, + ): + self.run_assessment(report, settings=settings) + + self.assertEqual(ctx.exception.code, codes.CONFIG) + mock_upload.assert_not_called() + self.assertNotIn("Directory successfully created.", self.messages()) + self.assertFalse((Path(report) / "index.html").exists()) + self.assertIn("Upload is required", self.messages()[-1]) + + def test_required_with_a_platform_uploads(self): + report = self.report() + self.run_assessment( + report, settings=with_mode("required"), replies=success_replies() + ) + self.assertEqual(app.RUN["upload"].url, URL) + + def test_off_with_a_platform_makes_no_request_and_forces_basic(self): + report = self.report() + with patch("cloudexit.cli.app.upload") as mock_upload: + self.run_assessment(report, settings=with_mode("off"), replies=[]) + + mock_upload.assert_not_called() + self.assert_local_report(report) + self.assertEqual(self.sync(report)["assessment_type"], 1) + self.assertEqual(self.forced(), ["Basic assessment: Standard needs an upload."]) + self.assertFalse(self.raw(report, "upload.json").exists()) + + def test_off_does_not_even_check_a_key_that_cannot_be_right(self): + report = self.report() + broken = Settings( + host=flow.CONFIGURED.host, + api_key=Setting("esc_sk_typo", "env CLOUDEXIT_API_KEY"), + upload=Setting("off", "env CLOUDEXIT_UPLOAD"), + ) + self.run_assessment(report, settings=broken, replies=[]) + self.assert_local_report(report) + + def test_half_a_configuration_is_an_error_not_an_offline_run(self): + for settings in ( + Settings(host=flow.CONFIGURED.host), + Settings(api_key=flow.CONFIGURED.api_key), + ): + with self.subTest(settings=settings): + report = self.report() + with self.assertRaises(SystemExit) as ctx: + self.run_assessment(report, settings=settings, replies=[]) + + self.assertEqual(ctx.exception.code, codes.CONFIG) + self.assertFalse((Path(report) / "index.html").exists()) + self.assertIn("incomplete", self.messages()[-1]) + + def test_half_a_configuration_is_fine_when_uploads_are_off(self): + report = self.report() + settings = Settings( + host=flow.CONFIGURED.host, upload=Setting("off", "flag --upload") + ) + self.run_assessment(report, settings=settings) + self.assert_local_report(report) + + def test_an_unusable_key_is_a_failed_upload_not_an_offline_run(self): + report = self.report() + broken = Settings( + host=flow.CONFIGURED.host, + api_key=Setting("esc_sk_typo", "env CLOUDEXIT_API_KEY"), + ) + with self.assertRaises(SystemExit) as ctx: + self.run_assessment(report, settings=broken, replies=[]) + + self.assertEqual(ctx.exception.code, codes.UPLOAD_AUTH) + self.assert_local_report(report) + self.assertEqual(self.forced(), []) + self.assertEqual(self.sync(report)["assessment_type"], 2) + + +class DryRunTests(ModeTestCase): + def test_a_dry_run_is_upload_off_plus_the_sync_document(self): + report = self.report() + with patch("cloudexit.cli.app.upload") as mock_upload: + self.run_assessment( + report, settings=flow.CONFIGURED, replies=[], dry_run=True + ) + + mock_upload.assert_not_called() + self.assertTrue(self.raw(report, "sync.json").is_file()) + self.assertEqual(self.sync(report)["assessment_type"], 2) + said = " ".join(str(call.args[0]) for call in self.printed.call_args_list) + self.assertIn("--dry-run is --upload=off plus the sync document", said) + self.assertEqual(self.forced(), []) + + def test_a_dry_run_cannot_be_combined_with_a_flag_that_uploads(self): + for mode in ("auto", "required"): + with self.subTest(mode=mode): + report = self.report() + with self.assertRaises(SystemExit) as ctx: + self.run_assessment( + report, + settings=with_mode(mode), + replies=[], + dry_run=True, + ) + self.assertEqual(ctx.exception.code, codes.CONFIG) + + def test_a_dry_run_accepts_an_upload_mode_from_the_environment(self): + report = self.report() + self.run_assessment( + report, + settings=with_mode("required", "env CLOUDEXIT_UPLOAD"), + replies=[], + dry_run=True, + ) + self.assertTrue(self.raw(report, "sync.json").is_file()) + + def test_a_dry_run_with_upload_off_is_fine(self): + report = self.report() + self.run_assessment(report, settings=with_mode("off"), replies=[], dry_run=True) + self.assertTrue(self.raw(report, "sync.json").is_file()) + + +class StrictTests(ModeTestCase): + def replies_with(self, *warnings): + return [meta_reply(), whoami_reply(), created_reply(warnings=list(warnings))] + + def test_a_platform_warning_fails_the_run_after_the_report_is_written(self): + for warning in (OUTDATED, UNKNOWN_TYPES): + with self.subTest(code=warning["code"]): + report = self.report() + with self.assertRaises(SystemExit) as ctx: + self.run_assessment( + report, + settings=flow.CONFIGURED, + replies=self.replies_with(warning), + strict=True, + ) + + self.assertEqual(ctx.exception.code, codes.UPLOAD_REJECTED) + self.assert_local_report(report) + self.assertTrue(self.raw(report, "upload.json").is_file()) + + def test_the_same_warning_does_not_fail_a_run_without_strict(self): + report = self.report() + self.run_assessment( + report, settings=flow.CONFIGURED, replies=self.replies_with(OUTDATED) + ) + self.assert_local_report(report) + + def test_a_clean_upload_passes_strict(self): + report = self.report() + self.run_assessment( + report, settings=flow.CONFIGURED, replies=success_replies(), strict=True + ) + self.assert_local_report(report) + + def test_a_deprecated_client_is_a_warning_that_strict_fails_on(self): + report = self.report() + deprecated = meta_reply(clients={"cloudexit": {"deprecated_below": "99.0.0"}}) + with self.assertRaises(SystemExit) as ctx: + self.run_assessment( + report, + settings=flow.CONFIGURED, + replies=[deprecated, whoami_reply(), created_reply()], + strict=True, + ) + self.assertEqual(ctx.exception.code, codes.UPLOAD_REJECTED) + self.assert_local_report(report) + self.assertEqual( + [i["code"] for i in app.RUN["upload"].warning_items], ["client_deprecated"] + ) + + def test_an_upload_failure_keeps_its_own_code_under_strict(self): + report = self.report() + with self.assertRaises(SystemExit) as ctx: + self.run_assessment( + report, + settings=flow.CONFIGURED, + replies=flow.failed_replies(401, "api_key_revoked", False), + strict=True, + ) + self.assertEqual(ctx.exception.code, codes.UPLOAD_AUTH) + + def test_strict_without_a_platform_has_nothing_to_fail_on(self): + report = self.report() + self.run_assessment(report, settings=Settings(), strict=True) + self.assert_local_report(report) + + +class RunJsonTests(ModeTestCase): + def test_a_successful_upload_is_summarised(self): + report = self.report() + self.run_assessment(report, settings=flow.CONFIGURED, replies=success_replies()) + + summary = self.summary() + + self.assertEqual( + set(summary), + {"exit_code", "report_dir", "sync_json", "upload", "results", "catalogue"}, + ) + self.assertEqual(summary["exit_code"], 0) + self.assertEqual(summary["report_dir"], report) + self.assertEqual(summary["sync_json"], str(self.raw(report, "sync.json"))) + upload = summary["upload"] + self.assertEqual( + set(upload), + { + "status", + "reason", + "assessment_id", + "sync_id", + "platform_sync_id", + "url", + "warnings", + "error", + }, + ) + self.assertEqual(upload["status"], "uploaded") + self.assertEqual(upload["assessment_id"], ASSESSMENT_ID) + self.assertEqual(upload["sync_id"], self.sync(report)["sync"]["id"]) + self.assertEqual(upload["url"], URL) + self.assertEqual(upload["warnings"], []) + self.assertIsNone(upload["error"]) + self.assertEqual(summary["results"]["scoring_version"], "1") + self.assertEqual(set(summary["catalogue"]), {"version", "outdated"}) + + def test_the_platform_results_are_in_the_summary_and_not_in_the_report(self): + report = self.report() + self.run_assessment(report, settings=flow.CONFIGURED, replies=success_replies()) + + results = self.summary()["results"] + + self.assertIn("scores", results) + self.assertNotIn("scores", self.json_report(report)["data"]) + self.assertEqual(self.rows(report, "scoring_data"), []) + + def test_warnings_carry_their_code_and_message(self): + report = self.report() + self.run_assessment( + report, + settings=flow.CONFIGURED, + replies=[ + meta_reply(), + whoami_reply(), + created_reply(warnings=[OUTDATED]), + ], + ) + + summary = self.summary() + + self.assertEqual(summary["upload"]["warnings"], [OUTDATED]) + self.assertIs(summary["catalogue"]["outdated"], True) + + def test_an_offline_run_says_why_nothing_was_uploaded(self): + for settings, kwargs, reason in ( + (Settings(), {}, "offline"), + (with_mode("off"), {}, "off"), + (flow.CONFIGURED, {"dry_run": True}, "dry_run"), + ): + with self.subTest(reason=reason): + report = self.report() + self.run_assessment(report, settings=settings, replies=[], **kwargs) + + summary = self.summary() + + self.assertEqual(summary["upload"]["status"], "skipped") + self.assertEqual(summary["upload"]["reason"], reason) + self.assertIsNone(summary["upload"]["url"]) + self.assertIsNone(summary["results"]) + self.assertIsNone(summary["catalogue"]["outdated"]) + + def test_a_failed_upload_carries_the_error(self): + report = self.report() + with self.assertRaises(SystemExit): + self.run_assessment( + report, + settings=flow.CONFIGURED, + replies=flow.failed_replies(401, "api_key_revoked", False), + ) + + summary = self.summary(codes.UPLOAD_AUTH) + + self.assertEqual(summary["exit_code"], codes.UPLOAD_AUTH) + self.assertEqual(summary["upload"]["status"], "failed") + error = summary["upload"]["error"] + self.assertEqual(error["code"], "api_key_revoked") + self.assertEqual(error["request_id"], "req-fake-0001") + self.assertTrue(error["message"]) + self.assertIsNone(summary["results"]) + + def test_an_upload_kept_for_later_is_spooled(self): + report = self.report() + with self.assertRaises(SystemExit) as ctx: + self.run_assessment( + report, + settings=flow.CONFIGURED, + replies=flow.failed_replies(503, "service_unavailable", True), + ) + + summary = self.summary(ctx.exception.code) + + self.assertEqual(summary["upload"]["status"], "spooled") + self.assertEqual(summary["upload"]["error"]["code"], "service_unavailable") + + def test_the_summary_never_holds_the_key(self): + report = self.report() + self.run_assessment(report, settings=flow.CONFIGURED, replies=success_replies()) + self.assertNotIn(KEY, json.dumps(self.summary())) + + with self.assertRaises(SystemExit): + self.run_assessment( + self.report(), + settings=flow.CONFIGURED, + replies=flow.failed_replies(401, "api_key_revoked", False), + ) + self.assertNotIn(KEY, json.dumps(self.summary(codes.UPLOAD_AUTH))) + + def test_a_new_run_does_not_inherit_the_previous_summary(self): + self.run_assessment( + self.report(), settings=flow.CONFIGURED, replies=success_replies() + ) + self.run_assessment(self.report(), settings=Settings()) + + self.assertEqual(self.summary()["upload"]["status"], "skipped") + + +class RunSummaryUnitTests(unittest.TestCase): + def result(self, **changes): + fields = dict(attempted=True, success=True, sync_id="s-1") + fields.update(changes) + return UploadResult(**fields) + + def test_the_status_names(self): + self.assertEqual(upload_status(None), "skipped") + self.assertEqual(upload_status(UploadResult(False, False)), "skipped") + self.assertEqual(upload_status(self.result()), "uploaded") + self.assertEqual(upload_status(self.result(success=False)), "failed") + self.assertEqual( + upload_status(self.result(success=False, spooled=True)), "spooled" + ) + + def test_outdated_follows_the_warning_or_the_platform_version(self): + warned = self.result(warning_items=(OUTDATED,)) + self.assertIs(catalogue_outdated("2026-11-02", warned), True) + + def host(version): + return self.result(catalogue={"host": {"version": version}}) + + self.assertIs(catalogue_outdated("2026-11-01", host("2026-11-02")), True) + self.assertIs(catalogue_outdated("2026-11-02", host("2026-11-02")), False) + self.assertIs(catalogue_outdated("2026-11-03", host("2026-11-02")), False) + self.assertIs(catalogue_outdated("2026-11-02", host("2026-11-02.1")), True) + self.assertIsNone(catalogue_outdated(None, host("2026-11-02"))) + self.assertIsNone(catalogue_outdated("2026-11-02", self.result())) + self.assertIsNone(catalogue_outdated("2026-11-02", None)) + + def test_a_platform_catalogue_of_the_wrong_shape_is_no_answer(self): + for catalogue in ( + {"host": "2026-11-02"}, + {"host": ["2026-11-02"]}, + {"host": {"version": 20261102}}, + {"host": {"version": ["x"]}}, + {"host": None}, + {}, + ): + with self.subTest(catalogue=catalogue): + result = self.result(catalogue=catalogue) + self.assertIsNone(catalogue_outdated("2026-11-02", result)) + warned = self.result(warning_items=("not a dict", OUTDATED)) + self.assertIs(catalogue_outdated("2026-11-02", warned), True) + + def test_a_summary_with_a_platform_answer_of_the_wrong_shape_is_complete(self): + summary = build_run_json( + exit_code=0, + report_dir=None, + sync_json=None, + upload=self.result(catalogue={"host": "escapecloud"}), + skipped_reason=None, + catalogue_version="2026-11-02", + ) + + self.assertEqual(summary["catalogue"]["outdated"], None) + self.assertEqual(summary["upload"]["status"], "uploaded") + + def test_a_missing_upload_gives_a_complete_summary(self): + summary = build_run_json( + exit_code=2, + report_dir=None, + sync_json=None, + upload=None, + skipped_reason=None, + catalogue_version=None, + ) + self.assertEqual(summary["exit_code"], 2) + self.assertEqual(summary["upload"]["status"], "skipped") + self.assertEqual(summary["upload"]["warnings"], []) + + +class StdoutSeparationTests(unittest.TestCase): + def run_json(self, dispatch): + out, err = io.StringIO(), io.StringIO() + app.RUN.clear() + with ( + patch.object(sys, "stdout", out), + redirect_stderr(err), + patch("cloudexit.cli.app._dispatch", side_effect=dispatch), + patch( + "cloudexit.cli.app.console.print", + side_effect=lambda *a, **k: err.write(str(a[0])), + ), + ): + try: + app._dispatch_json(object()) + code = 0 + except SystemExit as e: + code = e.code + return code, out.getvalue(), err.getvalue() + + def test_stdout_holds_one_json_object_and_the_steps_go_to_stderr(self): + def dispatch(args): + print("Preliminary Stage") + sys.stdout.write("a step\n") + sys.exit(codes.UPLOAD_UNAVAILABLE) + + code, out, err = self.run_json(dispatch) + + self.assertEqual(code, codes.UPLOAD_UNAVAILABLE) + self.assertEqual(json.loads(out)["exit_code"], codes.UPLOAD_UNAVAILABLE) + self.assertEqual(out.count("\n"), len(out.splitlines())) + self.assertIn("Preliminary Stage", err) + self.assertIn("a step", err) + self.assertNotIn("Preliminary Stage", out) + + def test_a_platform_catalogue_of_the_wrong_shape_still_gives_one_summary(self): + def dispatch(args): + app.RUN["upload"] = UploadResult( + True, True, sync_id="s", catalogue={"host": "escapecloud"} + ) + app.RUN["catalogue_version"] = "2026-11-02" + + code, out, _ = self.run_json(dispatch) + + data = json.loads(out) + self.assertEqual(code, 0) + self.assertEqual(data["exit_code"], 0) + self.assertIsNone(data["catalogue"]["outdated"]) + self.assertNotIn("error", data) + + def test_a_summary_that_cannot_be_built_is_still_one_object_with_an_error(self): + def dispatch(args): + sys.exit(codes.UPLOAD_REJECTED) + + with patch( + "cloudexit.cli.app.build_run_json", side_effect=AttributeError("boom") + ): + code, out, _ = self.run_json(dispatch) + + data = json.loads(out) + self.assertEqual(code, codes.UPLOAD_REJECTED) + self.assertEqual(data["exit_code"], codes.UPLOAD_REJECTED) + self.assertIn("AttributeError", data["error"]) + self.assertNotIn("boom", out) + self.assertEqual(out.count("\n"), len(out.splitlines())) + for key in ("report_dir", "sync_json", "upload", "results", "catalogue"): + self.assertIn(key, data) + + def test_a_clean_run_reports_exit_code_zero(self): + code, out, _ = self.run_json(lambda args: None) + self.assertEqual(code, 0) + self.assertEqual(json.loads(out)["exit_code"], 0) + + def test_an_unexpected_error_is_a_summary_with_code_1_and_a_message_on_stderr(self): + def dispatch(args): + raise RuntimeError("boom") + + code, out, err = self.run_json(dispatch) + + self.assertEqual(code, codes.UNEXPECTED) + self.assertEqual(json.loads(out)["exit_code"], codes.UNEXPECTED) + self.assertIn("boom", err) + self.assertNotIn("boom", out) + + +if __name__ == "__main__": + unittest.main() + + +class TerminalOutputTests(ModeTestCase): + # Nothing in the terminal sells an edition, a contact or a framework + MARKETING = ( + "regulatory-aligned", + "DORA", + "FINMA", + "PRA", + "request_report", + "ExitCloud", + "Light", + "Enterprise", + ) + + def steps_starting(self, prefix): + return [(a, k) for a, k in self.steps if a and a[0].startswith(prefix)] + + def test_an_offline_run_says_what_it_did_and_sells_nothing(self): + report = self.report() + self.run_assessment(report, settings=Settings(), real_terminal=True) + text = self.output.getvalue() + + self.assertIn("Offline run: no platform configured", text) + self.assertIn("Outputs:", text) + for banned in self.MARKETING: + with self.subTest(banned): + self.assertNotIn(banned, text) + + def test_an_offline_run_is_not_a_warning(self): + report = self.report() + self.run_assessment(report, settings=Settings(), assessment_type=1) + + offline = self.steps_starting("Offline run:") + self.assertEqual(len(offline), 1) + self.assertEqual(offline[0][1]["status"], "ok") + self.assertEqual( + [k for _a, k in self.steps if k.get("status") == "warning"], [] + ) + + def test_an_explicit_standard_type_offline_is_reduced_without_a_warning(self): + report = self.report() + self.run_assessment(report, settings=Settings(), assessment_type=2) + + reduced = self.steps_starting("Basic assessment") + self.assertEqual(len(reduced), 1) + self.assertEqual( + reduced[0][0][0], "Basic assessment: Standard needs an upload." + ) + self.assertEqual(reduced[0][1]["status"], "ok") + + def test_a_configured_run_names_the_platform_by_host(self): + report = self.report() + self.run_assessment(report, settings=flow.CONFIGURED, replies=success_replies()) + + self.assertEqual( + [a[0] for a, _k in self.steps_starting("Platform configured")], + ["Platform configured: https://platform.test."], + ) + + def test_the_host_line_carries_no_path_query_or_credentials(self): + for raw, shown in ( + ("https://platform.test", "https://platform.test"), + ("https://platform.test/api/v2?token=abc", "https://platform.test"), + ("https://user:pw@platform.test:8443/x", "https://platform.test:8443"), + ("http://127.0.0.1:8000/path", "http://127.0.0.1:8000"), + ): + with self.subTest(raw): + self.assertEqual(app._host_display(raw), shown) + + +class AssessmentTypeQuestionTests(unittest.TestCase): + def args(self, settings, dry_run=False): + return Namespace(settings=settings, dry_run=dry_run) + + def test_a_configured_platform_that_will_upload_gets_the_question(self): + self.assertTrue(app._asks_assessment_type(self.args(flow.CONFIGURED))) + + def test_an_offline_run_does_not_get_the_question(self): + for settings in (None, Settings(), Settings(host=flow.CONFIGURED.host)): + with self.subTest(settings=settings): + self.assertFalse(app._asks_assessment_type(self.args(settings))) + + def test_upload_off_or_a_dry_run_does_not_get_the_question(self): + self.assertFalse(app._asks_assessment_type(self.args(with_mode("off")))) + self.assertFalse( + app._asks_assessment_type(self.args(flow.CONFIGURED, dry_run=True)) + ) diff --git a/tests/egress/__init__.py b/tests/egress/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/egress/test_estimate.py b/tests/egress/test_estimate.py new file mode 100644 index 0000000..accfef0 --- /dev/null +++ b/tests/egress/test_estimate.py @@ -0,0 +1,300 @@ +# tests/egress/test_estimate.py +import json +import os +import sqlite3 +from contextlib import closing +import tempfile +import unittest +from unittest.mock import patch + +from cloudexit.egress.estimate import estimate_egress +from cloudexit.egress.model import ( + GIB, + compute_totals, + format_bytes, + new_row, + read_egress_inventory, + write_egress_inventory, +) + +# Same envelope as the assessment JSON report (see generate_json_report). +EXPECTED_META_KEYS = [ + "assessment_type", + "cloud_service_provider", + "exit_strategy", + "name", + "timestamp", +] +EXPECTED_DATA_KEYS = ["resources", "totals"] + + +def _sample_rows(): + storage = new_row("/sa1", "sa1", "some/type", "Storage", "object") + storage["size_bytes"] = 60 * GIB + storage["tier_bytes"] = {"Hot": 50 * GIB, "Archive": 10 * GIB} + disk = new_row("/disk1", "disk1", "some/type", "Disk", "block") + disk["size_bytes"] = 40 * GIB + unknown = new_row("/db1", "db1", "some/type", "Database", "database") + unknown["size_unknown"] = True + return [storage, disk, unknown] + + +class FormatBytesTests(unittest.TestCase): + def test_formats_binary_units_and_none(self): + self.assertEqual(format_bytes(None), "n/a") + self.assertEqual(format_bytes(512), "512.0 B") + self.assertEqual(format_bytes(GIB), "1.0 GiB") + self.assertEqual(format_bytes(1536 * GIB), "1.5 TiB") + + +class ComputeTotalsTests(unittest.TestCase): + def test_sums_known_sizes_archive_tiers_and_unknowns(self): + totals = compute_totals(_sample_rows(), archive_tiers={"Archive"}) + + self.assertEqual(totals["known_size_bytes"], 100 * GIB) + self.assertEqual(totals["archive_tier_bytes"], 10 * GIB) + self.assertEqual(totals["resources_discovered"], 3) + self.assertEqual(totals["resources_with_unknown_size"], 1) + + def test_archive_tier_set_controls_what_counts_as_archive(self): + rows = [new_row("/b1", "b1", "some/type", "Bucket", "object")] + rows[0]["size_bytes"] = 30 * GIB + rows[0]["tier_bytes"] = { + "Standard": 10 * GIB, + "Glacier": 15 * GIB, + "Deep Archive": 5 * GIB, + } + + totals = compute_totals(rows, archive_tiers={"Glacier", "Deep Archive"}) + + self.assertEqual(totals["archive_tier_bytes"], 20 * GIB) + + +# The sample rows all share one type; one catalogue entry covers them. +_SAMPLE_REGISTRY = { + "some/type": { + "resource_type_id": 1, + "category": "object", + "label": "Storage", + "strategy": "whatever", + } +} + + +_EGRESS_TABLES = """ +CREATE TABLE egress_inventory ( + id INTEGER PRIMARY KEY AUTOINCREMENT, resource_type INTEGER NOT NULL, + name TEXT NOT NULL, size_bytes INTEGER, + size_unknown INTEGER NOT NULL DEFAULT 0, flags TEXT, notes TEXT); +CREATE TABLE egress_inventory_tier ( + id INTEGER PRIMARY KEY AUTOINCREMENT, egress_inventory_id INTEGER NOT NULL, + tier TEXT NOT NULL, size_bytes INTEGER NOT NULL, + is_archive INTEGER NOT NULL DEFAULT 0); +""" + + +class EgressInventoryRoundTripTests(unittest.TestCase): + _CATALOGUE = """ +CREATE TABLE resourcetype ( + id INTEGER PRIMARY KEY, csp INTEGER NOT NULL, code TEXT NOT NULL, + name TEXT NOT NULL, icon TEXT NOT NULL, status TEXT NOT NULL); +CREATE TABLE resourcetype_data ( + id INTEGER PRIMARY KEY, resource_type INTEGER NOT NULL, + data_category TEXT NOT NULL, strategy TEXT NOT NULL, params TEXT, + status TEXT NOT NULL); +""" + + def setUp(self): + self._dir = tempfile.TemporaryDirectory() + self.addCleanup(self._dir.cleanup) + self.db_path = os.path.join(self._dir.name, "assessment.db") + conn = sqlite3.connect(self.db_path) + conn.executescript(self._CATALOGUE + _EGRESS_TABLES) + conn.execute( + "INSERT INTO resourcetype VALUES (7, 1, 'some/type', 'Storage', 'i', 't')" + ) + conn.execute( + "INSERT INTO resourcetype_data VALUES (1, 7, 'object', 's', '{}', 't')" + ) + conn.commit() + conn.close() + + def _round_trip(self, rows, archive_tiers): + write_egress_inventory(rows, {"some/type": 7}, archive_tiers, self.db_path) + return read_egress_inventory(self.db_path) + + def test_rows_survive_storage_unchanged(self): + original = _sample_rows() + + stored, _ = self._round_trip(original, {"Archive"}) + + self.assertEqual(len(stored), len(original)) + for before, after in zip(original, stored): + for field in ( + "name", + "size_bytes", + "size_unknown", + "tier_bytes", + "flags", + "notes", + ): + self.assertEqual(after[field], before[field], field) + + def test_label_and_category_come_back_from_the_catalogue(self): + stored, _ = self._round_trip(_sample_rows(), set()) + + self.assertEqual(stored[0]["label"], "Storage") + self.assertEqual(stored[0]["category"], "object") + self.assertEqual(stored[0]["type"], "some/type") + + def test_totals_are_identical_before_and_after_storage(self): + original = _sample_rows() + archive_tiers = {"Archive"} + + stored, stored_tiers = self._round_trip(original, archive_tiers) + + # What the report headlines show must not change with the store. + self.assertEqual( + compute_totals(stored, stored_tiers), + compute_totals(original, archive_tiers), + ) + + def test_is_archive_marks_only_the_archive_tiers(self): + _, stored_tiers = self._round_trip(_sample_rows(), {"Archive"}) + + self.assertEqual(stored_tiers, {"Archive"}) + + def test_row_without_a_catalogue_entry_warns_and_is_skipped(self): + orphan = new_row("/x", "x", "unmapped/type", "Nope", "object") + + with self.assertLogs("cloudexit.egress", level="WARNING") as captured: + write_egress_inventory([orphan], {"some/type": 7}, set(), self.db_path) + + rows, _ = read_egress_inventory(self.db_path) + self.assertEqual(rows, []) + self.assertIn("unmapped/type", captured.output[0]) + + +class EstimateEgressDispatchTests(unittest.TestCase): + def _run(self, cloud_service_provider, provider_details): + with tempfile.TemporaryDirectory() as tmp_dir: + db_dir = os.path.join(tmp_dir, "data") + os.makedirs(db_dir) + conn = sqlite3.connect(os.path.join(db_dir, "assessment.db")) + conn.executescript(_EGRESS_TABLES) + conn.commit() + conn.close() + + with patch( + "cloudexit.egress.estimate.load_egress_registry", + return_value=_SAMPLE_REGISTRY, + ): + result = estimate_egress( + cloud_service_provider, + provider_details, + tmp_dir, + report_path=tmp_dir, + name="Exit Assessment Test", + exit_strategy=3, + assessment_type=1, + ) + payload = None + if result["success"]: + with open(result["json_path"], encoding="utf-8") as json_file: + payload = json.load(json_file) + self.assertEqual( + result["json_path"], + os.path.join(tmp_dir, "egress_inventory_raw_data.json"), + ) + with closing( + sqlite3.connect(os.path.join(tmp_dir, "data", "assessment.db")) + ) as conn: + stored = conn.execute( + "SELECT COUNT(*) FROM egress_inventory" + ).fetchone()[0] + self.assertEqual(stored, len(payload["data"]["resources"])) + return result, payload + + @patch("cloudexit.providers.azure.egress.collect_azure_egress") + def test_azure_dispatch_and_json_schema(self, mock_collect): + mock_collect.return_value = (_sample_rows(), {"Archive"}) + + result, payload = self._run(1, {"any": "details"}) + + self.assertTrue(result["success"]) + mock_collect.assert_called_once_with({"any": "details"}) + # Same meta/data envelope as the assessment JSON report; cost + # scenarios and findings belong to the Platform offering and must + # not leak into the JSON output. + self.assertEqual(sorted(payload.keys()), ["data", "meta"]) + self.assertEqual(sorted(payload["meta"].keys()), EXPECTED_META_KEYS) + self.assertEqual(sorted(payload["data"].keys()), EXPECTED_DATA_KEYS) + self.assertEqual(payload["meta"]["name"], "Exit Assessment Test") + self.assertEqual(payload["meta"]["cloud_service_provider"], 1) + self.assertEqual(payload["meta"]["exit_strategy"], 3) + self.assertEqual(payload["meta"]["assessment_type"], 1) + self.assertEqual(payload["data"]["totals"]["known_size_bytes"], 100 * GIB) + self.assertEqual(payload["data"]["totals"]["archive_tier_bytes"], 10 * GIB) + + @patch("cloudexit.providers.azure.egress.collect_azure_egress") + def test_json_row_shape(self, mock_collect): + rows = _sample_rows() + mock_collect.return_value = (rows, {"Archive"}) + + _, payload = self._run(1, {"any": "details"}) + + resource = payload["data"]["resources"][0] + self.assertEqual( + list(resource), + [ + "id", + "name", + "code", + "category", + "size_bytes", + "size_unknown", + "tier_bytes", + "flags", + "notes", + ], + ) + self.assertEqual(resource["id"], "/sa1") + self.assertEqual(resource["name"], "sa1") + # "type" is published as "code"; the label lives in the catalogue. + self.assertEqual(resource["code"], "some/type") + self.assertNotIn("label", resource) + self.assertNotIn("type", resource) + self.assertEqual(resource["tier_bytes"], {"Hot": 50 * GIB, "Archive": 10 * GIB}) + + @patch("cloudexit.providers.aws.egress.collect_aws_egress") + def test_aws_dispatch_and_json_schema(self, mock_collect): + mock_collect.return_value = (_sample_rows(), {"Archive"}) + + result, payload = self._run(2, {"region": "eu-central-1"}) + + self.assertTrue(result["success"]) + mock_collect.assert_called_once_with({"region": "eu-central-1"}) + self.assertEqual(sorted(payload.keys()), ["data", "meta"]) + self.assertEqual(sorted(payload["meta"].keys()), EXPECTED_META_KEYS) + self.assertEqual(sorted(payload["data"].keys()), EXPECTED_DATA_KEYS) + self.assertEqual(payload["meta"]["cloud_service_provider"], 2) + + def test_unsupported_provider_fails_without_raising(self): + result, payload = self._run(3, {}) + + self.assertFalse(result["success"]) + self.assertIn("Unsupported cloud service provider", result["logs"]) + self.assertIsNone(payload) + + @patch("cloudexit.providers.azure.egress.collect_azure_egress") + def test_collection_error_returns_failure(self, mock_collect): + mock_collect.side_effect = RuntimeError("enumeration failed") + + result, payload = self._run(1, {}) + + self.assertFalse(result["success"]) + self.assertEqual(result["logs"], "enumeration failed") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/egress/test_layering.py b/tests/egress/test_layering.py new file mode 100644 index 0000000..d174b6a --- /dev/null +++ b/tests/egress/test_layering.py @@ -0,0 +1,71 @@ +import ast +import unittest + +from tests.support.paths import ROOT + +PACKAGE = ROOT / "cloudexit" + + +def imports(path): + # (module name, inside a function) for every import in the file + tree = ast.parse(path.read_text(encoding="utf-8")) + found = [] + + def walk(node, in_function): + for child in ast.iter_child_nodes(node): + inside = in_function or isinstance( + child, (ast.FunctionDef, ast.AsyncFunctionDef) + ) + if isinstance(child, ast.Import): + found.extend((alias.name, inside) for alias in child.names) + elif isinstance(child, ast.ImportFrom): + found.append((child.module or "", inside)) + walk(child, inside) + + walk(tree, False) + return found + + +class EgressLayeringTests(unittest.TestCase): + def test_the_model_is_a_leaf_that_knows_no_provider(self): + for name, _ in imports(PACKAGE / "egress" / "model.py"): + self.assertFalse(name.startswith("cloudexit.providers"), name) + + def test_the_estimate_reaches_the_providers_only_inside_a_function(self): + providers = [ + (name, inside) + for name, inside in imports(PACKAGE / "egress" / "estimate.py") + if name.startswith("cloudexit.providers") + ] + self.assertTrue(providers) + for name, inside in providers: + self.assertTrue(inside, f"{name} is imported at module level") + + def test_no_provider_imports_the_estimate(self): + for path in sorted((PACKAGE / "providers").rglob("*.py")): + for name, _ in imports(path): + self.assertNotEqual( + name, "cloudexit.egress.estimate", path.relative_to(ROOT) + ) + + def test_the_provider_egress_modules_use_the_model(self): + for provider in ("aws", "azure"): + names = [ + n for n, _ in imports(PACKAGE / "providers" / provider / "egress.py") + ] + self.assertIn("cloudexit.egress.model", names) + + def test_the_checker_sees_module_level_and_function_level_imports(self): + import tempfile + from pathlib import Path + + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "m.py" + path.write_text( + "import a\ndef f():\n from b import c\n", encoding="utf-8" + ) + self.assertEqual(imports(path), [("a", False), ("b", True)]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/egress/test_model.py b/tests/egress/test_model.py new file mode 100644 index 0000000..a08f956 --- /dev/null +++ b/tests/egress/test_model.py @@ -0,0 +1,282 @@ +# tests/egress/test_model.py +import os +import sqlite3 +import tempfile +import unittest +from unittest.mock import MagicMock, patch + +from cloudexit.catalogue.db import load_data +from cloudexit.egress.estimate import estimate_egress +from cloudexit.egress.model import load_egress_registry +from cloudexit.providers.azure.egress import filter_data_bearing_resources + +# (id, csp, code, name, icon, status) +RESOURCE_TYPES = [ + (1, 1, "Microsoft.Storage/storageAccounts", "Storage Account", "sa.png", "t"), + # Parent status 'f': stays out of the inventory catalogue but still holds + # data that has to be egressed. + (2, 1, "Microsoft.Compute/disks", "Managed Disks", "disk.png", "f"), + (3, 1, "Microsoft.Network/virtualNetworks", "Virtual Network", "vnet.png", "t"), + (4, 2, "AWS.s3.list_buckets.Buckets", "S3 (Simple Storage Service)", "s3.png", "t"), + (5, 2, "AWS.backup.list_backup_plans.BackupPlansList", "Backup", "bk.png", "t"), +] + +# (id, resource_type, data_category, strategy, params, status) +RESOURCE_TYPE_DATA = [ + (1, 1, "object", "storage_account_metrics", "{}", "t"), + ( + 2, + 2, + "block", + "allocated_size_property", + '{"api_version": "2024-03-02", "size_property": "diskSizeGB"}', + "t", + ), + # Own status 'f': switched off, must never reach the registry. + (3, 3, "network", "should_never_load", "{}", "f"), + (4, 4, "object", "s3_bucket_metrics", "{}", "t"), + ( + 5, + 5, + "backup", + "backup_vaults", + '{"enumeration": {"service": "backup", ' + '"operation": "list_backup_vaults", "result_path": ["BackupVaultList"]}}', + "t", + ), +] + +_SCHEMA = """ +CREATE TABLE resourcetype ( + id INTEGER PRIMARY KEY, + csp INTEGER NOT NULL, + code TEXT NOT NULL, + name TEXT NOT NULL, + icon TEXT NOT NULL, + status TEXT NOT NULL +); +CREATE TABLE resourcetype_data ( + id INTEGER PRIMARY KEY, + resource_type INTEGER NOT NULL, + data_category TEXT NOT NULL, + strategy TEXT NOT NULL, + params TEXT, + status TEXT NOT NULL +); +""" + + +def _make_db(path, *, with_data_table=True, data_rows=RESOURCE_TYPE_DATA): + conn = sqlite3.connect(path) + schema = _SCHEMA + if not with_data_table: + schema = _SCHEMA[: _SCHEMA.index("CREATE TABLE resourcetype_data")] + conn.executescript(schema) + conn.executemany("INSERT INTO resourcetype VALUES (?,?,?,?,?,?)", RESOURCE_TYPES) + if with_data_table: + conn.executemany( + "INSERT INTO resourcetype_data VALUES (?,?,?,?,?,?)", data_rows + ) + conn.commit() + conn.close() + + +class _FixtureDbTests(unittest.TestCase): + WITH_DATA_TABLE = True + DATA_ROWS = RESOURCE_TYPE_DATA + + def setUp(self): + handle, self.db_path = tempfile.mkstemp(suffix=".db") + os.close(handle) + self.addCleanup(os.unlink, self.db_path) + _make_db( + self.db_path, + with_data_table=self.WITH_DATA_TABLE, + data_rows=self.DATA_ROWS, + ) + patcher = patch( + "cloudexit.egress.model.load_data", + side_effect=lambda table: load_data(table, db_path=self.db_path), + ) + self.addCleanup(patcher.stop) + patcher.start() + + +class RegistryConstructionTests(_FixtureDbTests): + def test_azure_registry_joins_and_keys_by_lowercased_code(self): + registry = load_egress_registry(1) + + self.assertEqual( + sorted(registry), + ["microsoft.compute/disks", "microsoft.storage/storageaccounts"], + ) + + def test_aws_registry_keeps_code_verbatim(self): + registry = load_egress_registry(2) + + self.assertEqual( + sorted(registry), + [ + "AWS.backup.list_backup_plans.BackupPlansList", + "AWS.s3.list_buckets.Buckets", + ], + ) + + def test_params_are_merged_into_the_entry(self): + entry = load_egress_registry(1)["microsoft.compute/disks"] + + self.assertEqual(entry["api_version"], "2024-03-02") + self.assertEqual(entry["size_property"], "diskSizeGB") + + def test_label_comes_from_resourcetype_name(self): + registry = load_egress_registry(2) + + self.assertEqual( + registry["AWS.s3.list_buckets.Buckets"]["label"], + "S3 (Simple Storage Service)", + ) + + def test_category_and_strategy_come_from_resourcetype_data(self): + entry = load_egress_registry(1)["microsoft.storage/storageaccounts"] + + self.assertEqual(entry["category"], "object") + self.assertEqual(entry["strategy"], "storage_account_metrics") + + def test_enumeration_params_survive_the_merge(self): + registry = load_egress_registry(2) + entry = registry["AWS.backup.list_backup_plans.BackupPlansList"] + + self.assertEqual( + entry["enumeration"], + { + "service": "backup", + "operation": "list_backup_vaults", + "result_path": ["BackupVaultList"], + }, + ) + + def test_other_csp_rows_are_not_included(self): + self.assertNotIn("AWS.s3.list_buckets.Buckets", load_egress_registry(1)) + self.assertNotIn("microsoft.compute/disks", load_egress_registry(2)) + + +class ParamsCannotShadowCanonicalKeysTests(_FixtureDbTests): + DATA_ROWS = [ + ( + 1, + 1, + "object", + "storage_account_metrics", + '{"label": "hijacked", "category": "hijacked", "strategy": "hijacked"}', + "t", + ) + ] + + def test_canonical_keys_win_over_params(self): + entry = load_egress_registry(1)["microsoft.storage/storageaccounts"] + + self.assertEqual(entry["label"], "Storage Account") + self.assertEqual(entry["category"], "object") + self.assertEqual(entry["strategy"], "storage_account_metrics") + + +class StatusFilteringTests(_FixtureDbTests): + def test_row_with_disabled_parent_resourcetype_is_included(self): + # 513/514/515 ship with resourcetype.status = 'f' on purpose; filtering + # on it would drop the whole block category from the estimate. + registry = load_egress_registry(1) + + self.assertIn("microsoft.compute/disks", registry) + self.assertEqual(registry["microsoft.compute/disks"]["label"], "Managed Disks") + + def test_row_with_disabled_resourcetype_data_is_excluded(self): + registry = load_egress_registry(1) + + self.assertNotIn("microsoft.network/virtualnetworks", registry) + strategies = [entry["strategy"] for entry in registry.values()] + self.assertNotIn("should_never_load", strategies) + + +class AzureKeyMatchingTests(_FixtureDbTests): + def test_lowercased_key_matches_an_arm_resource_type(self): + registry = load_egress_registry(1) + resource = MagicMock() + # ARM returns the provider namespace in its own casing. + resource.type = "Microsoft.Storage/storageAccounts" + resource.name = "sa1" + + matched = filter_data_bearing_resources([resource], registry) + + self.assertEqual(len(matched), 1) + self.assertEqual(matched[0][1]["strategy"], "storage_account_metrics") + self.assertEqual(matched[0][1]["label"], "Storage Account") + + +class MissingTableTests(_FixtureDbTests): + WITH_DATA_TABLE = False + + def test_missing_table_fails_the_stage(self): + # There is no built-in fallback: without master data the estimate would + # be silently wrong, so the egress stage has to fail instead. + with self.assertRaises(sqlite3.Error): + load_egress_registry(1) + + def test_estimate_egress_reports_the_failure_instead_of_raising(self): + with patch( + "cloudexit.providers.azure.egress.collect_azure_egress", + side_effect=sqlite3.OperationalError("no such table: resourcetype_data"), + ): + result = estimate_egress( + 1, + {}, + tempfile.gettempdir(), + report_path=tempfile.gettempdir(), + name="acme", + exit_strategy=1, + assessment_type=1, + ) + + self.assertFalse(result["success"]) + self.assertIn("resourcetype_data", result["logs"]) + + +class EmptyRegistryTests(_FixtureDbTests): + DATA_ROWS = [] + + def test_no_matching_rows_raises(self): + with self.assertRaises(ValueError) as ctx: + load_egress_registry(2) + + self.assertIn("CSP 2", str(ctx.exception)) + + +class OtherCspOnlyTests(_FixtureDbTests): + DATA_ROWS = [(1, 1, "object", "storage_account_metrics", "{}", "t")] + + def test_csp_without_enabled_rows_raises_even_when_another_csp_has_them(self): + self.assertIn("microsoft.storage/storageaccounts", load_egress_registry(1)) + with self.assertRaises(ValueError): + load_egress_registry(2) + + +class MalformedParamsTests(_FixtureDbTests): + DATA_ROWS = [ + (1, 1, "object", "storage_account_metrics", "{not json", "t"), + (2, 2, "block", "allocated_size_property", None, "t"), + ] + + def test_bad_params_degrade_to_empty_without_dropping_the_row(self): + registry = load_egress_registry(1) + + self.assertEqual( + sorted(registry), + ["microsoft.compute/disks", "microsoft.storage/storageaccounts"], + ) + self.assertEqual( + registry["microsoft.storage/storageaccounts"]["strategy"], + "storage_account_metrics", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/fixtures/samples/aws_no_egress.json b/tests/fixtures/samples/aws_no_egress.json new file mode 100644 index 0000000..1d056cb --- /dev/null +++ b/tests/fixtures/samples/aws_no_egress.json @@ -0,0 +1,97 @@ +{ + "name": "Sample – Internal Billing Platform", + "purpose": "The same estate without egress: no egress document, no egress sentence.", + "provider": "aws", + "exit_strategy": 3, + "scope": { + "accountId": "123456789012", + "region": "eu-central-1" + }, + "resources": [ + { + "type": "AWS.ec2.describe_instances.Reservations", + "region": "eu-central-1", + "count": 14 + }, + { + "type": "AWS.ec2.describe_instances.Reservations", + "region": "eu-west-1", + "count": 6 + }, + { + "type": "AWS.s3.list_buckets.Buckets", + "region": "eu-central-1", + "count": 9 + }, + { + "type": "AWS.s3.list_buckets.Buckets", + "region": "eu-west-1", + "count": 3 + }, + { + "type": "AWS.ec2.describe_volumes.Volumes", + "region": "eu-central-1", + "count": 22 + }, + { + "type": "AWS.ec2.describe_volumes.Volumes", + "region": "eu-west-1", + "count": 8 + }, + { + "type": "AWS.rds.describe_db_instances.DBInstances", + "region": "eu-central-1", + "count": 3 + }, + { + "type": "AWS.rds.describe_db_instances.DBInstances", + "region": "eu-west-1", + "count": 1 + }, + { + "type": "AWS.dynamodb.list_tables.TableNames", + "region": "eu-central-1", + "count": 5 + }, + { + "type": "AWS.lambda.list_functions.Functions", + "region": "eu-central-1", + "count": 18 + }, + { + "type": "AWS.lambda.list_functions.Functions", + "region": "eu-west-1", + "count": 6 + }, + { + "type": "AWS.eks.list_clusters.clusters", + "region": "eu-central-1", + "count": 2 + }, + { + "type": "AWS.sqs.list_queues.QueueUrls", + "region": "eu-central-1", + "count": 7 + }, + { + "type": "AWS.sns.list_topics.Topics", + "region": "eu-central-1", + "count": 4 + } + ], + "costs": { + "currency": "USD", + "months": { + "2026-06": 8420.35, + "2026-07": 8791.1, + "2026-08": 9054.8, + "2026-09": 8632.45, + "2026-10": 9310.2, + "2026-11": 4120.75 + } + }, + "egress": null, + "upload": null, + "clock": "2026-11-12T09:30:00Z", + "run_id": "01939b8e-7c2a-7d41-9a51-3c8f0a6e0002" +} diff --git a/tests/fixtures/samples/aws_typical.json b/tests/fixtures/samples/aws_typical.json new file mode 100644 index 0000000..74ac827 --- /dev/null +++ b/tests/fixtures/samples/aws_typical.json @@ -0,0 +1,157 @@ +{ + "name": "Sample – Customer Self-Service Portal", + "purpose": "Several risk severities, USD costs with a provisional month, egress with archive tiers.", + "provider": "aws", + "exit_strategy": 3, + "scope": { + "accountId": "123456789012", + "region": "eu-central-1" + }, + "resources": [ + { + "type": "AWS.ec2.describe_instances.Reservations", + "region": "eu-central-1", + "count": 14 + }, + { + "type": "AWS.ec2.describe_instances.Reservations", + "region": "eu-west-1", + "count": 6 + }, + { + "type": "AWS.s3.list_buckets.Buckets", + "region": "eu-central-1", + "count": 9 + }, + { + "type": "AWS.s3.list_buckets.Buckets", + "region": "eu-west-1", + "count": 3 + }, + { + "type": "AWS.ec2.describe_volumes.Volumes", + "region": "eu-central-1", + "count": 22 + }, + { + "type": "AWS.ec2.describe_volumes.Volumes", + "region": "eu-west-1", + "count": 8 + }, + { + "type": "AWS.rds.describe_db_instances.DBInstances", + "region": "eu-central-1", + "count": 3 + }, + { + "type": "AWS.rds.describe_db_instances.DBInstances", + "region": "eu-west-1", + "count": 1 + }, + { + "type": "AWS.dynamodb.list_tables.TableNames", + "region": "eu-central-1", + "count": 5 + }, + { + "type": "AWS.lambda.list_functions.Functions", + "region": "eu-central-1", + "count": 18 + }, + { + "type": "AWS.lambda.list_functions.Functions", + "region": "eu-west-1", + "count": 6 + }, + { + "type": "AWS.eks.list_clusters.clusters", + "region": "eu-central-1", + "count": 2 + }, + { + "type": "AWS.sqs.list_queues.QueueUrls", + "region": "eu-central-1", + "count": 7 + }, + { + "type": "AWS.sns.list_topics.Topics", + "region": "eu-central-1", + "count": 4 + } + ], + "costs": { + "currency": "USD", + "months": { + "2026-06": 8420.35, + "2026-07": 8791.1, + "2026-08": 9054.8, + "2026-09": 8632.45, + "2026-10": 9310.2, + "2026-11": 4120.75 + } + }, + "egress": { + "archive_tiers": [ + "Archive", + "Glacier", + "Deep Archive" + ], + "resources": [ + { + "type": "AWS.s3.list_buckets.Buckets", + "name": "sample-portal-assets", + "size_gib": 1800, + "tiers": { + "Standard": 1200, + "Standard-IA": 300, + "Glacier": 300 + } + }, + { + "type": "AWS.s3.list_buckets.Buckets", + "name": "sample-portal-archive", + "size_gib": 5200, + "tiers": { + "Standard": 400, + "Glacier": 3800, + "Deep Archive": 1000 + } + }, + { + "type": "AWS.ec2.describe_volumes.Volumes", + "name": "vol-0a1b2c3d4e5f60789", + "size_gib": 500, + "flags": [ + "allocated (upper bound)" + ] + }, + { + "type": "AWS.ec2.describe_volumes.Volumes", + "name": "vol-0123456789abcdef0", + "size_gib": 250, + "flags": [ + "allocated (upper bound)" + ] + }, + { + "type": "AWS.rds.describe_db_instances.DBInstances", + "name": "sample-portal-db", + "size_gib": 320, + "flags": [ + "allocated (upper bound)" + ], + "notes": [ + "allocated: 320.0 GiB" + ] + }, + { + "type": "AWS.dynamodb.list_tables.TableNames", + "name": "sample-sessions", + "size_gib": 12 + } + ] + }, + "upload": null, + "clock": "2026-11-12T09:30:00Z", + "run_id": "01939b8e-7c2a-7d41-9a51-3c8f0a6e0001" +} diff --git a/tests/fixtures/samples/azure_typical.json b/tests/fixtures/samples/azure_typical.json new file mode 100644 index 0000000..45d861f --- /dev/null +++ b/tests/fixtures/samples/azure_typical.json @@ -0,0 +1,133 @@ +{ + "name": "Sample – Document Archive", + "purpose": "EUR costs, a type without alternatives, egress with unsized types so the totals carry a +.", + "provider": "azure", + "exit_strategy": 1, + "scope": { + "tenantId": "00000000-0000-4000-8000-0000000000a1", + "subscriptionId": "00000000-0000-4000-8000-0000000000a2", + "resourceGroupName": "rg-sample-archive" + }, + "resources": [ + { + "type": "Microsoft.Compute/virtualMachines", + "region": "westeurope", + "count": 12 + }, + { + "type": "Microsoft.Compute/virtualMachines", + "region": "northeurope", + "count": 4 + }, + { + "type": "Microsoft.ContainerService/managedClusters", + "region": "westeurope", + "count": 2 + }, + { + "type": "Microsoft.Storage/storageAccounts", + "region": "westeurope", + "count": 6 + }, + { + "type": "Microsoft.Storage/storageAccounts", + "region": "northeurope", + "count": 2 + }, + { + "type": "Microsoft.Sql/servers/databases", + "region": "westeurope", + "count": 5 + }, + { + "type": "Microsoft.DocumentDB/databaseAccounts", + "region": "westeurope", + "count": 1 + }, + { + "type": "Microsoft.DBforPostgreSQL/flexibleServers", + "region": "northeurope", + "count": 2 + }, + { + "type": "Microsoft.RecoveryServices/vaults", + "region": "westeurope", + "count": 1 + }, + { + "type": "Microsoft.ContainerRegistry/registries", + "region": "westeurope", + "count": 1 + } + ], + "costs": { + "currency": "EUR", + "months": { + "2026-06": 5120.4, + "2026-07": 5388.15, + "2026-08": 5290.0, + "2026-09": 5602.75, + "2026-10": 5711.3, + "2026-11": 2204.9 + } + }, + "egress": { + "archive_tiers": [ + "Archive" + ], + "resources": [ + { + "type": "Microsoft.Storage/storageAccounts", + "name": "sampledocsprod", + "size_gib": 2048, + "tiers": { + "Hot": 1500, + "Cool": 300, + "Archive": 248 + } + }, + { + "type": "Microsoft.Storage/storageAccounts", + "name": "sampledocsbackup", + "size_gib": 900, + "tiers": { + "Cool": 400, + "Archive": 500 + } + }, + { + "type": "Microsoft.Compute/disks", + "name": "sample-docs-vm-osdisk", + "size_gib": 256, + "flags": [ + "allocated (upper bound)" + ] + }, + { + "type": "Microsoft.Sql/servers/databases", + "name": "sample-docs-metadata", + "size_gib": 140 + }, + { + "type": "Microsoft.Sql/servers/databases", + "name": "sample-docs-reporting", + "size_gib": null, + "unknown": true + }, + { + "type": "Microsoft.DocumentDB/databaseAccounts", + "name": "sample-docs-search", + "size_gib": null, + "unknown": true + }, + { + "type": "Microsoft.RecoveryServices/vaults", + "name": "sample-docs-vault", + "size_gib": null + } + ] + }, + "upload": null, + "clock": "2026-11-12T09:30:00Z", + "run_id": "01939b8e-7c2a-7d41-9a51-3c8f0a6e0003" +} diff --git a/tests/fixtures/samples/azure_uploaded.json b/tests/fixtures/samples/azure_uploaded.json new file mode 100644 index 0000000..1131ce0 --- /dev/null +++ b/tests/fixtures/samples/azure_uploaded.json @@ -0,0 +1,138 @@ +{ + "name": "Sample – Document Archive", + "purpose": "Uploaded with a link: the link on page 2 of both PDFs and in the HTML.", + "provider": "azure", + "exit_strategy": 1, + "scope": { + "tenantId": "00000000-0000-4000-8000-0000000000a1", + "subscriptionId": "00000000-0000-4000-8000-0000000000a2", + "resourceGroupName": "rg-sample-archive" + }, + "resources": [ + { + "type": "Microsoft.Compute/virtualMachines", + "region": "westeurope", + "count": 12 + }, + { + "type": "Microsoft.Compute/virtualMachines", + "region": "northeurope", + "count": 4 + }, + { + "type": "Microsoft.ContainerService/managedClusters", + "region": "westeurope", + "count": 2 + }, + { + "type": "Microsoft.Storage/storageAccounts", + "region": "westeurope", + "count": 6 + }, + { + "type": "Microsoft.Storage/storageAccounts", + "region": "northeurope", + "count": 2 + }, + { + "type": "Microsoft.Sql/servers/databases", + "region": "westeurope", + "count": 5 + }, + { + "type": "Microsoft.DocumentDB/databaseAccounts", + "region": "westeurope", + "count": 1 + }, + { + "type": "Microsoft.DBforPostgreSQL/flexibleServers", + "region": "northeurope", + "count": 2 + }, + { + "type": "Microsoft.RecoveryServices/vaults", + "region": "westeurope", + "count": 1 + }, + { + "type": "Microsoft.ContainerRegistry/registries", + "region": "westeurope", + "count": 1 + } + ], + "costs": { + "currency": "EUR", + "months": { + "2026-06": 5120.4, + "2026-07": 5388.15, + "2026-08": 5290.0, + "2026-09": 5602.75, + "2026-10": 5711.3, + "2026-11": 2204.9 + } + }, + "egress": { + "archive_tiers": [ + "Archive" + ], + "resources": [ + { + "type": "Microsoft.Storage/storageAccounts", + "name": "sampledocsprod", + "size_gib": 2048, + "tiers": { + "Hot": 1500, + "Cool": 300, + "Archive": 248 + } + }, + { + "type": "Microsoft.Storage/storageAccounts", + "name": "sampledocsbackup", + "size_gib": 900, + "tiers": { + "Cool": 400, + "Archive": 500 + } + }, + { + "type": "Microsoft.Compute/disks", + "name": "sample-docs-vm-osdisk", + "size_gib": 256, + "flags": [ + "allocated (upper bound)" + ] + }, + { + "type": "Microsoft.Sql/servers/databases", + "name": "sample-docs-metadata", + "size_gib": 140 + }, + { + "type": "Microsoft.Sql/servers/databases", + "name": "sample-docs-reporting", + "size_gib": null, + "unknown": true + }, + { + "type": "Microsoft.DocumentDB/databaseAccounts", + "name": "sample-docs-search", + "size_gib": null, + "unknown": true + }, + { + "type": "Microsoft.RecoveryServices/vaults", + "name": "sample-docs-vault", + "size_gib": null + } + ] + }, + "upload": { + "status": "uploaded", + "url": "https://platform.example.test/assessments/00000000-0000-4000-8000-0000000000b1", + "assessment_id": "00000000-0000-4000-8000-0000000000b1", + "sync_id": "01939b8e-7c2a-7d41-9a51-3c8f0a6e0004" + }, + "clock": "2026-11-12T09:30:00Z", + "run_id": "01939b8e-7c2a-7d41-9a51-3c8f0a6e0004" +} diff --git a/tests/fixtures/samples/egress_free_tier.json b/tests/fixtures/samples/egress_free_tier.json new file mode 100644 index 0000000..ede6e4a --- /dev/null +++ b/tests/fixtures/samples/egress_free_tier.json @@ -0,0 +1,66 @@ +{ + "name": "Sample – Small Data Platform", + "purpose": "Egress below the free tier: a fee of $0.00 and the headroom sentence.", + "provider": "aws", + "exit_strategy": 3, + "scope": { + "accountId": "123456789012", + "region": "eu-central-1" + }, + "resources": [ + { + "type": "AWS.s3.list_buckets.Buckets", + "region": "eu-central-1", + "count": 3 + }, + { + "type": "AWS.ec2.describe_instances.Reservations", + "region": "eu-central-1", + "count": 2 + }, + { + "type": "AWS.rds.describe_db_instances.DBInstances", + "region": "eu-central-1", + "count": 1 + } + ], + "costs": { + "currency": "USD", + "months": { + "2026-06": 220.45, + "2026-07": 231.1, + "2026-08": 228.6, + "2026-09": 240.05, + "2026-10": 236.9, + "2026-11": 98.4 + } + }, + "egress": { + "archive_tiers": [ + "Archive", + "Glacier", + "Deep Archive" + ], + "resources": [ + { + "type": "AWS.s3.list_buckets.Buckets", + "name": "sample-data-landing", + "size_gib": 14, + "tiers": { + "Standard": 14 + } + }, + { + "type": "AWS.rds.describe_db_instances.DBInstances", + "name": "sample-data-db", + "size_gib": 6, + "flags": [ + "allocated (upper bound)" + ] + } + ] + }, + "upload": null, + "clock": "2026-11-12T09:30:00Z", + "run_id": "01939b8e-7c2a-7d41-9a51-3c8f0a6e0010" +} diff --git a/tests/fixtures/samples/egress_multi_tib.json b/tests/fixtures/samples/egress_multi_tib.json new file mode 100644 index 0000000..a539a66 --- /dev/null +++ b/tests/fixtures/samples/egress_multi_tib.json @@ -0,0 +1,80 @@ +{ + "name": "Sample – Media Library", + "purpose": "Egress of tens of TiB: several price tiers and a large fee.", + "provider": "azure", + "exit_strategy": 3, + "scope": { + "tenantId": "00000000-0000-4000-8000-0000000000a1", + "subscriptionId": "00000000-0000-4000-8000-0000000000a2", + "resourceGroupName": "rg-sample-media" + }, + "resources": [ + { + "type": "Microsoft.Storage/storageAccounts", + "region": "westeurope", + "count": 14 + }, + { + "type": "Microsoft.Compute/virtualMachines", + "region": "westeurope", + "count": 6 + }, + { + "type": "Microsoft.Sql/servers/databases", + "region": "westeurope", + "count": 2 + } + ], + "costs": { + "currency": "EUR", + "months": { + "2026-06": 18200.0, + "2026-07": 18950.5, + "2026-08": 19120.75, + "2026-09": 19810.2, + "2026-10": 20344.0, + "2026-11": 8120.6 + } + }, + "egress": { + "archive_tiers": [ + "Archive" + ], + "resources": [ + { + "type": "Microsoft.Storage/storageAccounts", + "name": "samplemediaoriginals", + "size_gib": 36864, + "tiers": { + "Hot": 12000, + "Cool": 14864, + "Archive": 10000 + } + }, + { + "type": "Microsoft.Storage/storageAccounts", + "name": "samplemediaproxies", + "size_gib": 12288, + "tiers": { + "Hot": 12288 + } + }, + { + "type": "Microsoft.Compute/disks", + "name": "sample-media-render-disk", + "size_gib": 8192, + "flags": [ + "allocated (upper bound)" + ] + }, + { + "type": "Microsoft.Sql/servers/databases", + "name": "sample-media-metadata", + "size_gib": 1024 + } + ] + }, + "upload": null, + "clock": "2026-11-12T09:30:00Z", + "run_id": "01939b8e-7c2a-7d41-9a51-3c8f0a6e0011" +} diff --git a/tests/fixtures/samples/egress_unsized.json b/tests/fixtures/samples/egress_unsized.json new file mode 100644 index 0000000..7c3c17e --- /dev/null +++ b/tests/fixtures/samples/egress_unsized.json @@ -0,0 +1,83 @@ +{ + "name": "Sample – Archive Migration Staging", + "purpose": "Egress where no data store has a size: fee and totals without a figure, every type not sized, no free-tier headroom.", + "provider": "azure", + "exit_strategy": 1, + "scope": { + "tenantId": "00000000-0000-4000-8000-0000000000a1", + "subscriptionId": "00000000-0000-4000-8000-0000000000a2", + "resourceGroupName": "rg-sample-staging" + }, + "resources": [ + { + "type": "Microsoft.Storage/storageAccounts", + "region": "westeurope", + "count": 3 + }, + { + "type": "Microsoft.Sql/servers/databases", + "region": "westeurope", + "count": 2 + }, + { + "type": "Microsoft.DocumentDB/databaseAccounts", + "region": "westeurope", + "count": 1 + }, + { + "type": "Microsoft.RecoveryServices/vaults", + "region": "westeurope", + "count": 1 + } + ], + "costs": { + "currency": "EUR", + "months": { + "2026-06": 830.4, + "2026-07": 842.15, + "2026-08": 851.0, + "2026-09": 860.3, + "2026-10": 855.75, + "2026-11": 351.2 + } + }, + "egress": { + "archive_tiers": [ + "Archive" + ], + "resources": [ + { + "type": "Microsoft.Storage/storageAccounts", + "name": "samplestagingblobs", + "size_gib": null, + "unknown": true + }, + { + "type": "Microsoft.Storage/storageAccounts", + "name": "samplestagingexports", + "size_gib": null, + "unknown": true + }, + { + "type": "Microsoft.Sql/servers/databases", + "name": "sample-staging-orders", + "size_gib": null, + "unknown": true + }, + { + "type": "Microsoft.DocumentDB/databaseAccounts", + "name": "sample-staging-search", + "size_gib": null, + "unknown": true + }, + { + "type": "Microsoft.RecoveryServices/vaults", + "name": "sample-staging-vault", + "size_gib": null + } + ] + }, + "upload": null, + "clock": "2026-11-12T09:30:00Z", + "run_id": "01939b8e-7c2a-7d41-9a51-3c8f0a6e0014" +} diff --git a/tests/fixtures/samples/empty.json b/tests/fixtures/samples/empty.json new file mode 100644 index 0000000..761edce --- /dev/null +++ b/tests/fixtures/samples/empty.json @@ -0,0 +1,26 @@ +{ + "name": "Sample – Empty Scope", + "purpose": "No resources and no costs.", + "provider": "aws", + "exit_strategy": 1, + "scope": { + "accountId": "123456789012", + "region": "eu-central-1" + }, + "resources": [], + "costs": { + "currency": "USD", + "months": { + "2026-06": 0, + "2026-07": 0, + "2026-08": 0, + "2026-09": 0, + "2026-10": 0, + "2026-11": 0 + } + }, + "egress": null, + "upload": null, + "clock": "2026-11-12T09:30:00Z", + "run_id": "01939b8e-7c2a-7d41-9a51-3c8f0a6e0009" +} diff --git a/tests/fixtures/samples/empty_with_costs.json b/tests/fixtures/samples/empty_with_costs.json new file mode 100644 index 0000000..cadea8b --- /dev/null +++ b/tests/fixtures/samples/empty_with_costs.json @@ -0,0 +1,27 @@ +{ + "name": "Sample – Decommissioned Test Environment", + "purpose": "An empty resource group that still shows six months of small costs: empty states next to real figures.", + "provider": "azure", + "exit_strategy": 1, + "scope": { + "tenantId": "00000000-0000-4000-8000-0000000000a1", + "subscriptionId": "00000000-0000-4000-8000-0000000000a2", + "resourceGroupName": "rg-sample-decommissioned" + }, + "resources": [], + "costs": { + "currency": "EUR", + "months": { + "2026-06": 38.2, + "2026-07": 31.75, + "2026-08": 24.4, + "2026-09": 12.9, + "2026-10": 6.35, + "2026-11": 1.8 + } + }, + "egress": null, + "upload": null, + "clock": "2026-11-12T09:30:00Z", + "run_id": "01939b8e-7c2a-7d41-9a51-3c8f0a6e0015" +} diff --git a/tests/fixtures/samples/legacy_catalogue.json b/tests/fixtures/samples/legacy_catalogue.json new file mode 100644 index 0000000..2254651 --- /dev/null +++ b/tests/fixtures/samples/legacy_catalogue.json @@ -0,0 +1,43 @@ +{ + "name": "Sample – Internal Wiki", + "purpose": "A dataset without catalogue_meta: the catalogue version is unknown on page 2 and in About This Report.", + "provider": "aws", + "exit_strategy": 2, + "scope": { + "accountId": "123456789012", + "region": "eu-central-1" + }, + "resources": [ + { + "type": "AWS.ec2.describe_instances.Reservations", + "region": "eu-central-1", + "count": 2 + }, + { + "type": "AWS.s3.list_buckets.Buckets", + "region": "eu-central-1", + "count": 1 + }, + { + "type": "AWS.rds.describe_db_instances.DBInstances", + "region": "eu-central-1", + "count": 1 + } + ], + "costs": { + "currency": "USD", + "months": { + "2026-06": 96.4, + "2026-07": 98.1, + "2026-08": 97.25, + "2026-09": 99.6, + "2026-10": 98.8, + "2026-11": 40.3 + } + }, + "egress": null, + "upload": null, + "clock": "2026-11-12T09:30:00Z", + "run_id": "01939b8e-7c2a-7d41-9a51-3c8f0a6e0016", + "catalogue": "legacy" +} diff --git a/tests/fixtures/samples/no_risks.json b/tests/fixtures/samples/no_risks.json new file mode 100644 index 0000000..5a7feae --- /dev/null +++ b/tests/fixtures/samples/no_risks.json @@ -0,0 +1,47 @@ +{ + "name": "Sample – Static Website", + "purpose": "Every type has enough supported alternatives, so the Risk Inventory is empty.", + "provider": "aws", + "exit_strategy": 2, + "scope": { + "accountId": "123456789012", + "region": "eu-central-1" + }, + "resources": [ + { + "type": "AWS.apigateway.get_rest_apis.items", + "region": "eu-central-1", + "count": 2 + }, + { + "type": "AWS.acm.list_certificates.CertificateSummaryList", + "region": "eu-central-1", + "count": 3 + }, + { + "type": "AWS.cloudwatch.describe_alarms.MetricAlarms", + "region": "eu-central-1", + "count": 6 + }, + { + "type": "AWS.backup.list_backup_plans.BackupPlansList", + "region": "eu-central-1", + "count": 1 + } + ], + "costs": { + "currency": "USD", + "months": { + "2026-06": 42.1, + "2026-07": 44.35, + "2026-08": 41.9, + "2026-09": 45.2, + "2026-10": 43.75, + "2026-11": 19.1 + } + }, + "egress": null, + "upload": null, + "clock": "2026-11-12T09:30:00Z", + "run_id": "01939b8e-7c2a-7d41-9a51-3c8f0a6e0008" +} diff --git a/tests/fixtures/samples/stress.json b/tests/fixtures/samples/stress.json new file mode 100644 index 0000000..17c7217 --- /dev/null +++ b/tests/fixtures/samples/stress.json @@ -0,0 +1,211 @@ +{ + "name": "Sample – A Deliberately Long Project Name That Keeps Going To Check How Titles, Headers And The Cover Wrap Over Several Lines", + "purpose": "Many types, long names, big counts, cent-level amounts and mixed currencies.", + "provider": "aws", + "exit_strategy": 1, + "scope": { + "accountId": "123456789012", + "region": "eu-central-1" + }, + "resources": [ + { + "type": "AWS.acm-pca", + "region": "eu-central-1", + "count": 1 + }, + { + "type": "AWS.acm.list_certificates.CertificateSummaryList", + "region": "eu-west-1", + "count": 8 + }, + { + "type": "AWS.amplify.list_apps.apps", + "region": "us-east-1", + "count": 15 + }, + { + "type": "AWS.apigateway.get_rest_apis.items", + "region": "eu-central-1", + "count": 22 + }, + { + "type": "AWS.appconfig.list_applications.Items", + "region": "eu-west-1", + "count": 29 + }, + { + "type": "AWS.appflow.list_flows.flows", + "region": "us-east-1", + "count": 36 + }, + { + "type": "AWS.application-autoscaling", + "region": "eu-central-1", + "count": 2 + }, + { + "type": "AWS.appmesh.list_meshes.meshes", + "region": "eu-west-1", + "count": 9 + }, + { + "type": "AWS.apprunner.list_services.ServiceSummaryList", + "region": "us-east-1", + "count": 16 + }, + { + "type": "AWS.appstream.describe_images.Images", + "region": "eu-central-1", + "count": 23 + }, + { + "type": "AWS.appsync.list_graphql_apis.graphqlApis", + "region": "eu-west-1", + "count": 30 + }, + { + "type": "AWS.athena.list_databases.DatabaseList", + "region": "us-east-1", + "count": 37 + }, + { + "type": "AWS.auditmanager.list_assessments.assessmentMetadata", + "region": "eu-central-1", + "count": 3 + }, + { + "type": "AWS.backup.list_backup_plans.BackupPlansList", + "region": "eu-west-1", + "count": 10 + }, + { + "type": "AWS.batch.describe_job_queues.jobQueues", + "region": "us-east-1", + "count": 17 + }, + { + "type": "AWS.cloud9.list_environments.environmentIds", + "region": "eu-central-1", + "count": 24 + }, + { + "type": "AWS.cloudcontrol.list_resources.ResourceDescriptions", + "region": "eu-west-1", + "count": 31 + }, + { + "type": "AWS.cloudformation.list_stacks.StackSummaries", + "region": "us-east-1", + "count": 38 + }, + { + "type": "AWS.cloudfront.list_distributions.DistributionList.Items", + "region": "eu-central-1", + "count": 4 + }, + { + "type": "AWS.cloudtrail.describe_trails.trailList", + "region": "eu-west-1", + "count": 11 + }, + { + "type": "AWS.cloudwatch.describe_alarms.MetricAlarms", + "region": "us-east-1", + "count": 18 + }, + { + "type": "AWS.codeartifact.list_repositories.repositories", + "region": "eu-central-1", + "count": 25 + }, + { + "type": "AWS.codebuild.list_projects.projects", + "region": "eu-west-1", + "count": 32 + }, + { + "type": "AWS.codecommit.list_repositories.repositories", + "region": "us-east-1", + "count": 39 + }, + { + "type": "AWS.dynamodb.list_tables.TableNames", + "region": "eu-central-1", + "count": 5 + }, + { + "type": "AWS.ec2.describe_volumes.Volumes", + "region": "eu-west-1", + "count": 12 + }, + { + "type": "AWS.ec2.describe_instances.Reservations", + "region": "us-east-1", + "count": 19 + }, + { + "type": "AWS.eks.list_clusters.clusters", + "region": "eu-central-1", + "count": 26 + }, + { + "type": "AWS.elasticache.describe_cache_clusters.CacheClusters", + "region": "eu-west-1", + "count": 33 + }, + { + "type": "AWS.kinesis.list_streams.StreamNames", + "region": "us-east-1", + "count": 40 + }, + { + "type": "AWS.lambda.list_functions.Functions", + "region": "eu-central-1", + "count": 6 + }, + { + "type": "AWS.rds.describe_db_instances.DBInstances", + "region": "eu-west-1", + "count": 13 + }, + { + "type": "AWS.route53.list_hosted_zones.HostedZones", + "region": "us-east-1", + "count": 20 + }, + { + "type": "AWS.s3.list_buckets.Buckets", + "region": "eu-central-1", + "count": 27 + }, + { + "type": "AWS.sns.list_topics.Topics", + "region": "eu-west-1", + "count": 34 + }, + { + "type": "AWS.sqs.list_queues.QueueUrls", + "region": "us-east-1", + "count": 41 + }, + { + "type": "AWS.ec2.describe_instances.Reservations", + "region": "ap-southeast-2", + "count": 123 + } + ], + "costs": { + "currency": "USD", + "months": { + "2026-10": 0.07, + "2026-11": 0.03 + }, + "currencies": { + "2026-11": "EUR" + } + }, + "egress": null, + "upload": null, + "clock": "2026-11-12T09:30:00Z", + "run_id": "01939b8e-7c2a-7d41-9a51-3c8f0a6e0012" +} diff --git a/tests/fixtures/samples/tfstate/sample_aws.tfstate b/tests/fixtures/samples/tfstate/sample_aws.tfstate new file mode 100644 index 0000000..23ec7c1 --- /dev/null +++ b/tests/fixtures/samples/tfstate/sample_aws.tfstate @@ -0,0 +1,104 @@ +{ + "version": 4, + "terraform_version": "1.14.6", + "serial": 12, + "lineage": "00000000-0000-4000-8000-0000000000c1", + "outputs": {}, + "resources": [ + { + "mode": "managed", + "type": "aws_instance", + "name": "portal", + "provider": "provider[\"registry.terraform.io/hashicorp/aws\"]", + "instances": [ + { + "schema_version": 0, + "attributes": { + "availability_zone": "eu-central-1a", + "arn": "arn:aws:ec2:eu-central-1:123456789012:instance/i-0123456789abcdef0" + } + }, + { + "schema_version": 0, + "attributes": { + "availability_zone": "eu-central-1b", + "arn": "arn:aws:ec2:eu-central-1:123456789012:instance/i-0123456789abcdef1" + } + }, + { + "schema_version": 0, + "attributes": { + "availability_zone": "eu-west-1a", + "arn": "arn:aws:ec2:eu-west-1:123456789012:instance/i-0123456789abcdef2" + } + } + ] + }, + { + "mode": "managed", + "type": "aws_s3_bucket", + "name": "assets", + "provider": "provider[\"registry.terraform.io/hashicorp/aws\"]", + "instances": [ + { + "schema_version": 0, + "attributes": { + "region": "eu-central-1", + "bucket": "sample-portal-assets", + "arn": "arn:aws:s3:::sample-portal-assets" + } + }, + { + "schema_version": 0, + "attributes": { + "bucket": "sample-portal-exports", + "arn": "arn:aws:s3:::sample-portal-exports" + } + } + ] + }, + { + "mode": "managed", + "type": "aws_lambda_function", + "name": "worker", + "provider": "provider[\"registry.terraform.io/hashicorp/aws\"]", + "instances": [ + { + "schema_version": 0, + "attributes": { + "arn": "arn:aws:lambda:eu-central-1:123456789012:function:sample-worker" + } + } + ] + }, + { + "mode": "managed", + "type": "aws_sqs_queue", + "name": "jobs", + "provider": "provider[\"registry.terraform.io/hashicorp/aws\"]", + "instances": [ + { + "schema_version": 0, + "attributes": { + "region": "eu-central-1", + "arn": "arn:aws:sqs:eu-central-1:123456789012:sample-jobs" + } + } + ] + }, + { + "mode": "managed", + "type": "aws_iam_role", + "name": "worker", + "provider": "provider[\"registry.terraform.io/hashicorp/aws\"]", + "instances": [ + { + "schema_version": 0, + "attributes": { + "arn": "arn:aws:iam::123456789012:role/sample-worker" + } + } + ] + } + ] +} diff --git a/tests/fixtures/samples/tfstate/sample_aws_skipped.tfstate b/tests/fixtures/samples/tfstate/sample_aws_skipped.tfstate new file mode 100644 index 0000000..2724066 --- /dev/null +++ b/tests/fixtures/samples/tfstate/sample_aws_skipped.tfstate @@ -0,0 +1,126 @@ +{ + "version": 4, + "terraform_version": "1.14.6", + "serial": 31, + "lineage": "00000000-0000-4000-8000-0000000000c3", + "outputs": {}, + "resources": [ + { + "mode": "managed", + "type": "aws_instance", + "name": "app", + "provider": "provider[\"registry.terraform.io/hashicorp/aws\"]", + "instances": [ + { + "schema_version": 0, + "attributes": { + "availability_zone": "eu-central-1a", + "arn": "arn:aws:ec2:eu-central-1:123456789012:instance/i-0123456789abcdef0" + } + }, + { + "schema_version": 0, + "attributes": { + "availability_zone": "eu-central-1b", + "arn": "arn:aws:ec2:eu-central-1:123456789012:instance/i-0123456789abcdef1" + } + } + ] + }, + { + "mode": "managed", + "type": "aws_s3_bucket", + "name": "assets", + "provider": "provider[\"registry.terraform.io/hashicorp/aws\"]", + "instances": [ + { + "schema_version": 0, + "attributes": { + "region": "eu-central-1", + "bucket": "sample-landing-assets", + "arn": "arn:aws:s3:::sample-landing-assets" + } + } + ] + }, + { + "mode": "managed", + "type": "aws_iam_role", + "name": "deploy", + "provider": "provider[\"registry.terraform.io/hashicorp/aws\"]", + "instances": [ + { + "schema_version": 0, + "attributes": { + "arn": "arn:aws:iam::123456789012:role/sample-deploy" + } + }, + { + "schema_version": 0, + "attributes": { + "arn": "arn:aws:iam::123456789012:role/sample-readonly" + } + } + ] + }, + { + "mode": "managed", + "type": "aws_cloudwatch_log_group", + "name": "app", + "provider": "provider[\"registry.terraform.io/hashicorp/aws\"]", + "instances": [ + { + "schema_version": 0, + "attributes": { + "arn": "arn:aws:logs:eu-central-1:123456789012:log-group:/sample/app" + } + } + ] + }, + { + "mode": "managed", + "type": "azurerm_resource_group", + "name": "shared", + "provider": "provider[\"registry.terraform.io/hashicorp/azurerm\"]", + "instances": [ + { + "schema_version": 0, + "attributes": { + "location": "westeurope", + "name": "rg-sample-shared" + } + } + ] + }, + { + "mode": "managed", + "type": "azurerm_storage_account", + "name": "backup", + "provider": "provider[\"registry.terraform.io/hashicorp/azurerm\"]", + "instances": [ + { + "schema_version": 0, + "attributes": { + "location": "westeurope", + "name": "samplebackupstore" + } + } + ] + }, + { + "mode": "managed", + "type": "google_storage_bucket", + "name": "exports", + "provider": "provider[\"registry.terraform.io/hashicorp/google\"]", + "instances": [ + { + "schema_version": 0, + "attributes": { + "location": "EU", + "name": "sample-exports" + } + } + ] + } + ] +} diff --git a/tests/fixtures/samples/tfstate/sample_azure.tfstate b/tests/fixtures/samples/tfstate/sample_azure.tfstate new file mode 100644 index 0000000..b5d5b52 --- /dev/null +++ b/tests/fixtures/samples/tfstate/sample_azure.tfstate @@ -0,0 +1,76 @@ +{ + "version": 4, + "terraform_version": "1.14.6", + "serial": 12, + "lineage": "00000000-0000-4000-8000-0000000000c2", + "outputs": {}, + "resources": [ + { + "mode": "managed", + "type": "azurerm_virtual_machine", + "name": "app", + "provider": "provider[\"registry.terraform.io/hashicorp/azurerm\"]", + "instances": [ + { + "schema_version": 0, + "attributes": { + "id": "/subscriptions/00000000-0000-4000-8000-0000000000a2/resourceGroups/rg-sample-archive/providers/Microsoft.Compute/virtualMachines/sample-app-1", + "location": "westeurope" + } + }, + { + "schema_version": 0, + "attributes": { + "id": "/subscriptions/00000000-0000-4000-8000-0000000000a2/resourceGroups/rg-sample-archive/providers/Microsoft.Compute/virtualMachines/sample-app-2", + "location": "northeurope" + } + } + ] + }, + { + "mode": "managed", + "type": "azurerm_storage_account", + "name": "docs", + "provider": "provider[\"registry.terraform.io/hashicorp/azurerm\"]", + "instances": [ + { + "schema_version": 0, + "attributes": { + "id": "/subscriptions/00000000-0000-4000-8000-0000000000a2/resourceGroups/rg-sample-archive/providers/Microsoft.Storage/storageAccounts/sampledocsprod", + "location": "westeurope" + } + } + ] + }, + { + "mode": "managed", + "type": "azurerm_mssql_database", + "name": "metadata", + "provider": "provider[\"registry.terraform.io/hashicorp/azurerm\"]", + "instances": [ + { + "schema_version": 0, + "attributes": { + "id": "/subscriptions/00000000-0000-4000-8000-0000000000a2/resourceGroups/rg-sample-archive/providers/Microsoft.Sql/servers/sample-sql/databases/metadata", + "location": "westeurope" + } + } + ] + }, + { + "mode": "managed", + "type": "azurerm_resource_group", + "name": "main", + "provider": "provider[\"registry.terraform.io/hashicorp/azurerm\"]", + "instances": [ + { + "schema_version": 0, + "attributes": { + "id": "/subscriptions/00000000-0000-4000-8000-0000000000a2/resourceGroups/rg-sample-archive", + "location": "westeurope" + } + } + ] + } + ] +} diff --git a/tests/fixtures/samples/tfstate_aws.json b/tests/fixtures/samples/tfstate_aws.json new file mode 100644 index 0000000..97561ba --- /dev/null +++ b/tests/fixtures/samples/tfstate_aws.json @@ -0,0 +1,15 @@ +{ + "name": "Sample – Customer Self-Service Portal (Terraform)", + "purpose": "A Terraform state: no costs, a resource without a region, never egress.", + "provider": "aws", + "exit_strategy": 1, + "scope": { + "tfstate": "sample_aws.tfstate" + }, + "resources": [], + "costs": null, + "egress": null, + "upload": null, + "clock": "2026-11-12T09:30:00Z", + "run_id": "01939b8e-7c2a-7d41-9a51-3c8f0a6e0006" +} diff --git a/tests/fixtures/samples/tfstate_azure.json b/tests/fixtures/samples/tfstate_azure.json new file mode 100644 index 0000000..3956583 --- /dev/null +++ b/tests/fixtures/samples/tfstate_azure.json @@ -0,0 +1,15 @@ +{ + "name": "Sample – Document Archive (Terraform)", + "purpose": "A Terraform state of an Azure resource group: the state scope rows.", + "provider": "azure", + "exit_strategy": 1, + "scope": { + "tfstate": "sample_azure.tfstate" + }, + "resources": [], + "costs": null, + "egress": null, + "upload": null, + "clock": "2026-11-12T09:30:00Z", + "run_id": "01939b8e-7c2a-7d41-9a51-3c8f0a6e0007" +} diff --git a/tests/fixtures/samples/tfstate_skipped.json b/tests/fixtures/samples/tfstate_skipped.json new file mode 100644 index 0000000..c156841 --- /dev/null +++ b/tests/fixtures/samples/tfstate_skipped.json @@ -0,0 +1,15 @@ +{ + "name": "Sample – Multi-Cloud Landing Zone (Terraform)", + "purpose": "A state file with instances of another provider and of types not in the data catalogue: the Not Assessed row on page 2 and in About This Report.", + "provider": "aws", + "exit_strategy": 1, + "scope": { + "tfstate": "sample_aws_skipped.tfstate" + }, + "resources": [], + "costs": null, + "egress": null, + "upload": null, + "clock": "2026-11-12T09:30:00Z", + "run_id": "01939b8e-7c2a-7d41-9a51-3c8f0a6e0017" +} diff --git a/tests/fixtures/samples/upload_failed.json b/tests/fixtures/samples/upload_failed.json new file mode 100644 index 0000000..5ebd294 --- /dev/null +++ b/tests/fixtures/samples/upload_failed.json @@ -0,0 +1,52 @@ +{ + "name": "Sample – Order Tracking Service", + "purpose": "A failed upload that was kept for a later upload: the wording on page 2 and in About This Report.", + "provider": "aws", + "exit_strategy": 1, + "scope": { + "accountId": "123456789012", + "region": "eu-central-1" + }, + "resources": [ + { + "type": "AWS.ec2.describe_instances.Reservations", + "region": "eu-central-1", + "count": 5 + }, + { + "type": "AWS.s3.list_buckets.Buckets", + "region": "eu-central-1", + "count": 3 + }, + { + "type": "AWS.rds.describe_db_instances.DBInstances", + "region": "eu-central-1", + "count": 1 + }, + { + "type": "AWS.lambda.list_functions.Functions", + "region": "eu-central-1", + "count": 6 + } + ], + "costs": { + "currency": "USD", + "months": { + "2026-06": 412.5, + "2026-07": 398.2, + "2026-08": 405.75, + "2026-09": 421.1, + "2026-10": 418.9, + "2026-11": 171.6 + } + }, + "egress": null, + "upload": { + "status": "failed", + "reason": "The platform did not answer in time", + "spooled": true, + "sync_id": "01939b8e-7c2a-7d41-9a51-3c8f0a6e0013" + }, + "clock": "2026-11-12T09:30:00Z", + "run_id": "01939b8e-7c2a-7d41-9a51-3c8f0a6e0013" +} diff --git a/tests/fixtures/samples/uploaded_no_link.json b/tests/fixtures/samples/uploaded_no_link.json new file mode 100644 index 0000000..742bf06 --- /dev/null +++ b/tests/fixtures/samples/uploaded_no_link.json @@ -0,0 +1,46 @@ +{ + "name": "Sample – Field Service Scheduler", + "purpose": "Uploaded with an assessment ID and no link.", + "provider": "aws", + "exit_strategy": 2, + "scope": { + "accountId": "123456789012", + "region": "eu-central-1" + }, + "resources": [ + { + "type": "AWS.ec2.describe_instances.Reservations", + "region": "eu-central-1", + "count": 3 + }, + { + "type": "AWS.s3.list_buckets.Buckets", + "region": "eu-central-1", + "count": 2 + }, + { + "type": "AWS.lambda.list_functions.Functions", + "region": "eu-central-1", + "count": 4 + } + ], + "costs": { + "currency": "USD", + "months": { + "2026-06": 310.2, + "2026-07": 295.85, + "2026-08": 322.4, + "2026-09": 301.1, + "2026-10": 318.65, + "2026-11": 140.3 + } + }, + "egress": null, + "upload": { + "status": "uploaded", + "assessment_id": "sample-assessment-0001", + "sync_id": "01939b8e-7c2a-7d41-9a51-3c8f0a6e0005" + }, + "clock": "2026-11-12T09:30:00Z", + "run_id": "01939b8e-7c2a-7d41-9a51-3c8f0a6e0005" +} diff --git a/tests/fixtures/smoke/aws.tfstate b/tests/fixtures/smoke/aws.tfstate new file mode 100644 index 0000000..879619b --- /dev/null +++ b/tests/fixtures/smoke/aws.tfstate @@ -0,0 +1,74 @@ +{ + "version": 4, + "terraform_version": "1.14.6", + "serial": 1, + "lineage": "00000000-0000-0000-0000-000000000000", + "resources": [ + { + "mode": "managed", + "type": "aws_s3_bucket", + "name": "assets", + "provider": "provider[\"registry.terraform.io/hashicorp/aws\"]", + "instances": [ + { + "schema_version": 0, + "attributes": { + "region": "eu-central-1", + "bucket": "example-assets", + "arn": "arn:aws:s3:::example-assets" + } + } + ] + }, + { + "mode": "managed", + "type": "aws_instance", + "name": "web", + "provider": "provider[\"registry.terraform.io/hashicorp/aws\"]", + "instances": [ + { + "schema_version": 0, + "attributes": { + "availability_zone": "eu-central-1a", + "arn": "arn:aws:ec2:eu-central-1:123456789012:instance/i-0123456789abcdef0" + } + }, + { + "schema_version": 0, + "attributes": { + "availability_zone": "eu-central-1b", + "arn": "arn:aws:ec2:eu-central-1:123456789012:instance/i-0123456789abcdef1" + } + } + ] + }, + { + "mode": "managed", + "type": "aws_lambda_function", + "name": "worker", + "provider": "provider[\"registry.terraform.io/hashicorp/aws\"]", + "instances": [ + { + "schema_version": 0, + "attributes": { + "arn": "arn:aws:lambda:eu-central-1:123456789012:function:example-worker" + } + } + ] + }, + { + "mode": "managed", + "type": "aws_dynamodb_table", + "name": "items", + "provider": "provider[\"registry.terraform.io/hashicorp/aws\"]", + "instances": [ + { + "schema_version": 0, + "attributes": { + "arn": "arn:aws:dynamodb:eu-central-1:123456789012:table/example-items" + } + } + ] + } + ] +} diff --git a/tests/make_smoke_dataset.py b/tests/make_smoke_dataset.py new file mode 100644 index 0000000..1187add --- /dev/null +++ b/tests/make_smoke_dataset.py @@ -0,0 +1,106 @@ +import sqlite3 +import sys + +from tests.support.catalogue import build_catalogue + +RESOURCE_TYPES = [ + ( + 1, + 2, + "AWS.EC2.DescribeInstances", + "EC2 Instance", + "/icons/aws/Compute/EC2.png", + "aws_instance", + ), + ( + 2, + 2, + "AWS.S3.ListBuckets", + "S3 Bucket", + "/icons/aws/Storage/S3-on-Outposts.png", + "aws_s3_bucket", + ), + ( + 3, + 2, + "AWS.Lambda.ListFunctions", + "Lambda Function", + "/icons/aws/Compute/Lambda.png", + "aws_lambda_function", + ), + ( + 4, + 2, + "AWS.DynamoDB.ListTables", + "DynamoDB Table", + "/icons/aws/Database/DynamoDB.png", + "aws_dynamodb_table", + ), +] + +TABLES = """ +DROP TABLE risk; +CREATE TABLE risk ( + id INTEGER PRIMARY KEY, name TEXT NOT NULL, description TEXT NOT NULL, + severity TEXT NOT NULL +); +CREATE TABLE risk_inventory ( + id INTEGER PRIMARY KEY AUTOINCREMENT, resource_type INTEGER, risk INTEGER +); +CREATE TABLE alternative ( + id INTEGER PRIMARY KEY, resource_type INTEGER NOT NULL, + alternative_technology INTEGER NOT NULL, strategy_type INTEGER NOT NULL +); +CREATE TABLE alternativetechnology ( + id INTEGER PRIMARY KEY, product_name TEXT NOT NULL, product_description TEXT, + product_url TEXT, open_source TEXT NOT NULL, support_plan TEXT NOT NULL, + status TEXT NOT NULL, organization_id INTEGER NULL +); +CREATE TABLE resource_inventory ( + id INTEGER PRIMARY KEY AUTOINCREMENT, resource_type INTEGER NOT NULL, + location TEXT NOT NULL, count INTEGER NOT NULL, UNIQUE(resource_type, location) +); +CREATE TABLE cost_inventory ( + id INTEGER PRIMARY KEY AUTOINCREMENT, month TEXT NOT NULL, cost REAL NOT NULL, + currency TEXT NOT NULL, UNIQUE(month) +); +CREATE TABLE egress_inventory ( + id INTEGER PRIMARY KEY AUTOINCREMENT, resource_type INTEGER NOT NULL, + name TEXT NOT NULL, size_bytes INTEGER, size_unknown INTEGER NOT NULL DEFAULT 0, + flags TEXT, notes TEXT +); +CREATE TABLE egress_inventory_tier ( + id INTEGER PRIMARY KEY AUTOINCREMENT, egress_inventory_id INTEGER NOT NULL, + tier TEXT NOT NULL, size_bytes INTEGER NOT NULL, + is_archive INTEGER NOT NULL DEFAULT 0 +); +CREATE TABLE alternativetechnologyorganization ( + id INTEGER PRIMARY KEY, name TEXT NOT NULL, url TEXT NULL, + stability_tier INTEGER NULL, country_code TEXT NULL, + years_in_business INTEGER NULL +); +""" + +RISKS = [ + (1, "Few alternatives", "There are few alternatives.", "medium"), + (2, "No alternatives", "There are no alternatives.", "high"), +] + + +def main(path): + build_catalogue(path, version="2026-11-02") + conn = sqlite3.connect(path) + conn.executescript(TABLES) + conn.execute("DELETE FROM resourcetype") + conn.executemany( + "INSERT INTO resourcetype (id, csp, code, name, icon, status, tf_code) " + "VALUES (?, ?, ?, ?, ?, 't', ?)", + RESOURCE_TYPES, + ) + conn.executemany("INSERT INTO risk VALUES (?, ?, ?, ?)", RISKS) + conn.commit() + conn.close() + + +if __name__ == "__main__": + main(sys.argv[1]) diff --git a/tests/platform/__init__.py b/tests/platform/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/platform/test_client.py b/tests/platform/test_client.py new file mode 100644 index 0000000..a9122ea --- /dev/null +++ b/tests/platform/test_client.py @@ -0,0 +1,1495 @@ +import json +import logging +import os +import re +import tempfile +import unittest +import uuid +from pathlib import Path +from unittest.mock import patch + +import requests +import yaml + +from cloudexit.platform import client as platform_client +from cloudexit.platform.client import ( + ERROR_EXIT_CODES, + EXIT_STRATEGIES, + ApiClient, + ApiError, + check_key, + exit_code_for, + resolve_ca_bundle, +) +from tests.support.api import ( + ASSESSMENT_ID, + EU_KEY, + HOST, + KEY, + PLATFORM_SYNC_ID, + REQUEST_ID, + US_KEY, + component_errors, + corrupt, + created_reply, + error_body, + make_key, + merged, + meta_body, + meta_reply, + minimal_document, + reply, + success_replies, + sync_body, + whoami_body, + whoami_reply, +) +from tests.support.http import fake_http, make_response +from tests.support.sync import DEFINITION, schema_errors +from cloudexit import codes +from cloudexit.settings import Setting + +SYNC_ID = "01933b9e-7c2a-7d41-9a51-3c8f0a6e0001" +ERRORS = yaml.safe_load((DEFINITION / "errors.yaml").read_text(encoding="utf-8")) + + +class ClientTestCase(unittest.TestCase): + def setUp(self): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.tmp = Path(tmp.name) + self.now = 1_800_000_000.0 + self.slept = [] + self.clock = [0.0] + self.clients = 0 + self.cache = self.tmp / "cache" + + def client(self, key=KEY, host=HOST, **options): + self.clients += 1 + options.setdefault("cache_path", self.tmp / f"cache-{self.clients}") + options.setdefault("environ", {}) + # One attempt: the tests that exercise the retries ask for a budget + options.setdefault("budget", 0.0) + options.setdefault("clock", lambda: self.now) + options.setdefault("sleep", self.sleep) + options.setdefault("monotonic", lambda: self.clock[0]) + return ApiClient(host, key, **options) + + def sleep(self, seconds): + self.slept.append(seconds) + self.clock[0] += max(seconds, 0.001) + + def document(self, **changes): + document = minimal_document(SYNC_ID, int(self.now) - 60) + return merged(document, changes) + + def push(self, replies, client=None, **kwargs): + kwargs.setdefault("name", "Flow Test") + kwargs.setdefault("exit_strategy", 1) + with fake_http(*replies) as fake: + result = (client or self.client()).push(self.document(), **kwargs) + return result, fake + + def failure(self, replies, client=None, **kwargs): + kwargs.setdefault("name", "Flow Test") + kwargs.setdefault("exit_strategy", 1) + with fake_http(*replies) as fake: + with self.assertRaises(ApiError) as ctx: + (client or self.client()).push(self.document(), **kwargs) + return ctx.exception, fake + + def body_of(self, request): + return json.loads(request.kwargs["data"].decode("utf-8")) + + +class KeyTests(ClientTestCase): + def test_every_issued_prefix_is_accepted_and_names_its_platform(self): + for key, product, region in ( + (KEY, "escapecloud", None), + (EU_KEY, "exitcloud", "eu"), + (US_KEY, "exitcloud", "us"), + ): + with self.subTest(product=product, region=region): + info = check_key(key) + self.assertEqual((info.product, info.region), (product, region)) + self.assertEqual(info.prefix, key[: key.index("sk_") + 3]) + + def test_a_key_that_cannot_be_right_is_refused_with_exit_11(self): + bad = { + "none": None, + "empty": "", + "not a key": "not-a-key", + "wrong prefix": "abc_sk_" + KEY[7:], + "too short": KEY[:-1], + "too long": KEY + "A", + "bad checksum": corrupt(KEY), + "typo in the body": KEY[:10] + ("B" if KEY[10] != "B" else "C") + KEY[11:], + "symbol": KEY[:20] + "-" + KEY[21:], + "non ascii": KEY[:20] + "é" + KEY[21:], + "trailing newline": KEY + "\n", + "prefix only": "esc_sk_", + } + for label, key in bad.items(): + with self.subTest(label): + with self.assertRaises(ApiError) as ctx: + check_key(key) + self.assertEqual(ctx.exception.exit_code, codes.UPLOAD_AUTH) + self.assertEqual(ctx.exception.code, "invalid_api_key") + self.assertNotIn(KEY[8:], ctx.exception.message) + + def test_the_checksum_is_the_crc32_of_everything_before_it_in_base62(self): + self.assertEqual(platform_client.checksum("esc_sk_" + "A" * 48), KEY[-6:]) + self.assertEqual(len(platform_client.checksum("x")), 6) + self.assertEqual(platform_client.checksum(""), "000000") + + def test_a_typo_never_reaches_the_network(self): + with fake_http() as fake: + for key in (corrupt(KEY), corrupt(EU_KEY), "not-a-key"): + with self.assertRaises(ApiError): + ApiClient("https://platform.example.com", key, environ={}) + self.assertEqual(fake.requests, []) + + def sent(self, key, url, **meta_changes): + client = self.client(key=key, host=url) + with fake_http(meta_reply(**meta_changes), whoami_reply()) as fake: + client.whoami() + return fake + + def refused(self, key, url, **meta_changes): + client = self.client(key=key, host=url) + with fake_http(meta_reply(**meta_changes)) as fake: + with self.assertRaises(ApiError) as ctx: + client.whoami() + # Only /meta was asked, and the key was not sent anywhere + self.assertEqual([r.method for r in fake.requests], ["GET"]) + for request in fake.requests: + self.assertNotIn("Authorization", request.kwargs["headers"]) + self.assertEqual(ctx.exception.exit_code, codes.UPLOAD_AUTH) + self.assertEqual(ctx.exception.code, "wrong_region") + self.assertNotIn(key, ctx.exception.message) + return ctx.exception + + def test_an_escapecloud_key_works_on_any_host_that_says_it_is_escapecloud(self): + hosts = ( + "https://escape.corp.internal", + "https://10.0.4.17:8443", + "http://localhost:8000", + "https://platform.example.com", + "platform.example.com", + ) + for host in hosts: + for region in (None, "eu", "us"): + with self.subTest(host=host, region=region): + fake = self.sent( + KEY, host, host={"product": "escapecloud", "region": region} + ) + self.assertEqual( + fake.requests[1].kwargs["headers"]["Authorization"], + f"Bearer {KEY}", + ) + + def test_a_regional_key_works_on_a_host_of_its_region_whatever_its_name(self): + for key, region in ((EU_KEY, "eu"), (US_KEY, "us")): + for host in ( + f"https://{region}.platform.example.com", + "https://staging.example.net", + "https://10.0.4.17:8443", + "http://localhost:8000", + ): + for reported in (region, region.upper()): + with self.subTest(region=region, host=host, reported=reported): + self.sent( + key, host, host={"product": "exitcloud", "region": reported} + ) + + def test_a_key_of_another_region_is_refused_naming_what_the_host_reports(self): + error = self.refused( + EU_KEY, + "https://platform.example.com", + host={"product": "exitcloud", "region": "us"}, + ) + self.assertEqual( + error.message, + "This is an ExitCloud EU key, but the platform reports ExitCloud US.", + ) + error = self.refused( + US_KEY, + "https://10.0.4.17:8443", + host={"product": "exitcloud", "region": "eu"}, + ) + self.assertEqual( + error.message, + "This is an ExitCloud US key, but the platform reports ExitCloud EU.", + ) + + def test_a_key_of_the_other_product_is_refused(self): + error = self.refused( + KEY, + "https://platform.example.com", + host={"product": "exitcloud", "region": "eu"}, + ) + self.assertEqual( + error.message, + "This is an EscapeCloud key, but the platform reports ExitCloud EU.", + ) + error = self.refused( + EU_KEY, + "https://escape.corp.internal", + host={"product": "escapecloud", "region": None}, + ) + self.assertEqual( + error.message, + "This is an ExitCloud EU key, but the platform reports EscapeCloud.", + ) + + def test_a_platform_that_does_not_say_what_it_is_decides_for_itself(self): + no_host = meta_body() + del no_host["host"] + cases = { + "no host block": reply(200, no_host), + "no product": reply(200, meta_body(host={"product": None})), + "an unknown product": reply(200, meta_body(host={"product": "other"})), + "host is not an object": reply(200, merged(meta_body(), {"host": "x"})), + } + for label, answer in cases.items(): + for key in (KEY, EU_KEY, US_KEY): + with self.subTest(label, key=key[:6]): + client = self.client(key=key, host="https://platform.example.com") + with fake_http(answer, whoami_reply()): + client.whoami() + + def test_a_regional_key_on_a_host_without_a_region_is_left_to_the_platform(self): + self.sent( + EU_KEY, + "https://platform.example.com", + host={"product": "exitcloud", "region": None}, + ) + + def test_the_key_is_not_sent_by_a_push_to_the_wrong_platform_either(self): + client = self.client(key=US_KEY, host="https://platform.example.com") + error, fake = self.failure( + [meta_reply(host={"product": "exitcloud", "region": "eu"})], client=client + ) + + self.assertEqual((error.exit_code, error.code), (11, "wrong_region")) + self.assertEqual(len(fake.requests), 1) + self.assertNotIn("Authorization", fake.requests[0].kwargs["headers"]) + + def test_the_platform_is_checked_once_per_client(self): + client = self.client(key=EU_KEY, host="https://platform.example.com") + eu = {"host": {"product": "exitcloud", "region": "eu"}} + with fake_http(meta_reply(**eu), whoami_reply(), whoami_reply()) as fake: + client.whoami() + client.whoami() + self.assertEqual( + [r.url.rsplit("/", 1)[1] for r in fake.requests], + ["meta", "whoami", "whoami"], + ) + + def test_a_missing_host_or_key_is_not_configured(self): + class Settings: + host = Setting(None) + api_key = Setting(KEY) + + with self.assertRaises(ApiError) as ctx: + ApiClient.from_settings(Settings()) + self.assertEqual(ctx.exception.exit_code, codes.UPLOAD_AUTH) + + +class RequestShapeTests(ClientTestCase): + def test_every_call_carries_the_agreed_headers(self): + client = self.client() + with fake_http(meta_reply(), whoami_reply(), whoami_reply()) as fake: + client.meta() + client.whoami() + client.whoami() + + meta_call, whoami_call, _ = fake.requests + self.assertNotIn("Authorization", meta_call.kwargs["headers"]) + self.assertEqual( + whoami_call.kwargs["headers"]["Authorization"], f"Bearer {KEY}" + ) + ids = [r.kwargs["headers"]["X-Request-ID"] for r in fake.requests] + self.assertEqual(len(set(ids)), len(ids)) + for request_id in ids: + self.assertRegex(request_id, r"^[A-Za-z0-9._-]{8,64}$") + for request in fake.requests: + self.assertEqual(request.kwargs["headers"]["Accept"], "application/json") + self.assertIs(request.kwargs["allow_redirects"], False) + self.assertEqual(request.kwargs["timeout"], (10.0, 60.0)) + self.assertIs(request.kwargs["verify"], True) + + def test_the_user_agent_names_the_client_python_system_and_ci(self): + pattern = r"^cloudexit/\S+ \(python/3\.\d+; [a-z0-9]+; ci=%s\)$" + for environ, ci in ( + ({}, "none"), + ({"GITHUB_ACTIONS": "true"}, "github"), + ({"GITLAB_CI": "true"}, "gitlab"), + ({"TF_BUILD": "True"}, "azure"), + ({"GITHUB_ACTIONS": "false"}, "none"), + ): + with self.subTest(ci=ci): + agent = self.client(environ=environ).session.headers["User-Agent"] + self.assertRegex(agent, pattern % ci) + + def test_a_push_sends_the_idempotency_key_and_the_document_as_json(self): + _, fake = self.push(success_replies()) + + post = fake.requests[2] + headers = post.kwargs["headers"] + self.assertEqual((post.method, post.url), ("POST", f"{HOST}/api/v2/syncs")) + self.assertEqual(headers["Idempotency-Key"], SYNC_ID) + self.assertEqual(headers["Content-Type"], "application/json") + self.assertEqual(headers["Prefer"], "wait=20") + self.assertEqual(self.body_of(post), self.document()) + self.assertNotIn(b" ", post.kwargs["data"].split(b'"sync"')[0]) + for request in fake.requests[:2]: + self.assertNotIn("Idempotency-Key", request.kwargs["headers"]) + + def test_the_body_is_utf8_without_escaping(self): + document = self.document(inventory={"resources": [], "note": "é"}) + with fake_http(*success_replies()) as fake: + self.client().push(document, name="n", exit_strategy=1) + self.assertIn("é".encode(), fake.requests[2].kwargs["data"]) + + def test_a_redirect_is_never_followed(self): + error, _ = self.failure( + [ + meta_reply(), + make_response(302, headers={"Location": "https://elsewhere.test/"}), + ] + ) + self.assertEqual(error.exit_code, codes.UPLOAD_UNAVAILABLE) + self.assertEqual(error.code, "http_302") + + def test_the_netrc_cannot_replace_the_bearer_header(self): + session = self.client().session + self.assertTrue(session.auth) + prepared = session.prepare_request( + requests.Request("GET", HOST, headers={"Authorization": "Bearer x"}) + ) + self.assertEqual(prepared.headers["Authorization"], "Bearer x") + + +class TransportTests(ClientTestCase): + def test_ca_bundle_precedence(self): + environ = {"REQUESTS_CA_BUNDLE": "/r.pem", "SSL_CERT_FILE": "/s.pem"} + self.assertEqual(resolve_ca_bundle("/flag.pem", environ), "/flag.pem") + self.assertEqual(resolve_ca_bundle(None, environ), "/r.pem") + self.assertEqual(resolve_ca_bundle("", environ), "/r.pem") + self.assertEqual(resolve_ca_bundle(None, {"SSL_CERT_FILE": "/s.pem"}), "/s.pem") + self.assertEqual(resolve_ca_bundle(None, {"REQUESTS_CA_BUNDLE": " "}), True) + self.assertIs(resolve_ca_bundle(None, {}), True) + + def test_the_chosen_bundle_is_what_every_request_verifies_with(self): + flag, requests_bundle, ssl_file = ( + self.tmp / "flag.pem", + self.tmp / "requests.pem", + self.tmp / "ssl.pem", + ) + for path in (flag, requests_bundle, ssl_file): + path.write_text("pem") + environ = { + "REQUESTS_CA_BUNDLE": str(requests_bundle), + "SSL_CERT_FILE": str(ssl_file), + } + cases = ( + (str(flag), environ, str(flag)), + (None, environ, str(requests_bundle)), + (None, {"SSL_CERT_FILE": str(ssl_file)}, str(ssl_file)), + (None, {}, True), + ) + for configured, env, expected in cases: + with self.subTest(expected=str(expected)[-10:]): + client = self.client(ca_bundle=configured, environ=env) + with fake_http(meta_reply(), whoami_reply()) as fake: + client.whoami() + for request in fake.requests: + self.assertEqual(request.kwargs["verify"], expected) + + def test_verification_cannot_be_switched_off(self): + for configured, env in ( + ("false", {}), + (None, {"REQUESTS_CA_BUNDLE": "0"}), + (None, {"CURL_CA_BUNDLE": ""}), + ): + with self.subTest(configured=configured, env=env): + try: + client = self.client(ca_bundle=configured, environ=env) + except ApiError: + continue + self.assertIsNot(client.verify, False) + self.assertTrue(client.verify is True or isinstance(client.verify, str)) + source = Path(platform_client.__file__).read_text(encoding="utf-8") + self.assertNotRegex(source, r"verify\s*=\s*False") + + def test_a_ca_bundle_that_does_not_exist_is_a_clear_failure(self): + with self.assertRaises(ApiError) as ctx: + self.client(ca_bundle=str(self.tmp / "missing.pem")) + self.assertEqual(ctx.exception.exit_code, codes.UPLOAD_UNAVAILABLE) + self.assertEqual(ctx.exception.code, "tls_error") + + def test_the_standard_proxy_variables_are_honoured(self): + session = self.client().session + self.assertTrue(session.trust_env) + environ = { + "HTTPS_PROXY": "http://proxy.test:3128", + "NO_PROXY": "internal.test", + } + with patch.dict(os.environ, environ): + proxied = session.merge_environment_settings( + f"{HOST}/api/v2/meta", {}, None, True, None + )["proxies"] + direct = session.merge_environment_settings( + "https://internal.test/api/v2/meta", {}, None, True, None + )["proxies"] + self.assertEqual(proxied["https"], "http://proxy.test:3128") + self.assertNotIn("https", direct) + + def test_timeouts_default_to_10_and_60_and_the_setting_overrides_both(self): + self.assertEqual( + platform_client.request_timeout(Setting(10.0, "default")), (10.0, 60.0) + ) + self.assertEqual(platform_client.request_timeout(None), (10.0, 60.0)) + self.assertEqual( + platform_client.request_timeout(Setting(5.0, "flag --timeout")), (5.0, 5.0) + ) + + client = self.client(timeout=(2.0, 3.0)) + with fake_http(meta_reply(), whoami_reply()) as fake: + client.whoami() + for request in fake.requests: + self.assertEqual(request.kwargs["timeout"], (2.0, 3.0)) + + def test_transport_failures_are_exit_12(self): + cases = ( + (requests.ConnectionError("refused"), "connection_failed"), + (requests.ConnectTimeout("slow"), "timeout"), + (requests.ReadTimeout("slow"), "timeout"), + (requests.exceptions.SSLError("bad cert"), "tls_error"), + (requests.exceptions.ProxyError("proxy"), "connection_failed"), + ( + OSError("Could not find a suitable TLS CA certificate bundle"), + "connection_failed", + ), + ) + for exception, code in cases: + with self.subTest(code=code, exception=type(exception).__name__): + with fake_http(exception): + with self.assertRaises(ApiError) as ctx: + self.client().whoami() + self.assertEqual(ctx.exception.exit_code, codes.UPLOAD_UNAVAILABLE) + self.assertEqual(ctx.exception.code, code) + self.assertIn("platform.test", ctx.exception.message) + self.assertNotIn(KEY, ctx.exception.message) + + def test_a_tls_failure_points_at_the_ca_bundle_options(self): + with fake_http(requests.exceptions.SSLError("x")): + with self.assertRaises(ApiError) as ctx: + self.client().whoami() + self.assertIn("--ca-bundle", ctx.exception.message) + self.assertIn("CLOUDEXIT_CA_BUNDLE", ctx.exception.message) + + +class MetaTests(ClientTestCase): + def test_meta_is_cached_for_five_minutes(self): + with fake_http(meta_reply()) as fake: + first = self.client(cache_path=self.cache).meta() + self.now += 299 + second = self.client(cache_path=self.cache).meta() + self.assertEqual(first, second) + self.assertEqual(len(fake.requests), 1) + + self.now += 2 + with fake_http(meta_reply()) as fake: + self.client(cache_path=self.cache).meta() + self.assertEqual(len(fake.requests), 1) + + def test_one_client_asks_once(self): + client = self.client(cache_path=self.cache) + with fake_http(meta_reply()) as fake: + client.meta() + client.meta() + self.assertEqual(len(fake.requests), 1) + + def test_the_cache_is_per_host_and_holds_no_key(self): + with fake_http(meta_reply(), meta_reply()) as fake: + self.client(cache_path=self.cache).meta() + self.client(host="https://other.test", cache_path=self.cache).meta() + self.assertEqual(len(fake.requests), 2) + + files = list(self.cache.glob("meta-*.json")) + self.assertEqual(len(files), 2) + for path in files: + self.assertNotIn(KEY, path.read_text(encoding="utf-8")) + self.assertNotIn(KEY[8:], path.read_text(encoding="utf-8")) + + def test_a_broken_cache_is_ignored_and_replaced(self): + path = self.client(cache_path=self.cache)._meta_file() + path.parent.mkdir(parents=True) + for content in ("{", "[]", json.dumps({"base_url": HOST}), "null"): + with self.subTest(content=content): + path.write_text(content) + with fake_http(meta_reply()) as fake: + self.client(cache_path=self.cache).meta() + self.assertEqual(len(fake.requests), 1) + self.assertEqual(json.loads(path.read_text())["base_url"], HOST) + + def test_a_cache_from_the_future_is_not_trusted(self): + with fake_http(meta_reply(), meta_reply()) as fake: + self.client(cache_path=self.cache).meta() + self.now -= 1000 + self.client(cache_path=self.cache).meta() + self.assertEqual(len(fake.requests), 2) + + def test_an_unwritable_cache_does_not_stop_the_run(self): + blocker = self.tmp / "blocked" + blocker.write_text("a file, not a directory") + with fake_http(meta_reply()): + meta = self.client(cache_path=blocker / "sub").meta() + self.assertEqual(meta["object"], "meta") + + def test_something_that_is_not_meta_is_refused(self): + with fake_http(reply(200, {"object": "whoami"})): + with self.assertRaises(ApiError) as ctx: + self.client(cache_path=self.cache).meta() + self.assertEqual(ctx.exception.exit_code, codes.UPLOAD_UNAVAILABLE) + + @unittest.skipIf(os.name == "nt", "absolute /xdg paths are POSIX paths") + def test_the_cache_directory_follows_the_platform_conventions(self): + self.assertEqual( + platform_client.cache_dir({"XDG_CACHE_HOME": "/xdg"}, platform="linux"), + Path("/xdg/cloudexit"), + ) + self.assertEqual( + platform_client.cache_dir({"XDG_CACHE_HOME": "relative"}, platform="linux"), + Path.home() / ".cache" / "cloudexit", + ) + self.assertEqual( + platform_client.cache_dir({"LOCALAPPDATA": "C:/L"}, platform="win32"), + Path("C:/L") / "cloudexit" / "cache", + ) + + @unittest.skipIf(os.name == "nt", "Windows keeps the cache under LOCALAPPDATA") + def test_the_default_cache_is_in_the_users_cache_directory(self): + with patch.dict(os.environ, {"XDG_CACHE_HOME": str(self.tmp / "xdg")}): + client = ApiClient(HOST, KEY) + self.assertEqual(client._cache_dir, self.tmp / "xdg" / "cloudexit") + + +class PolicyTests(ClientTestCase): + def setUp(self): + super().setUp() + # The policies below are written against this client version, not the + # one in cloudexit/version.py, which changes with every release + self.enterContext(patch.object(platform_client, "__version__", "1.1.7")) + + def test_a_blocked_or_too_old_client_is_refused_before_the_push(self): + for label, clients in ( + ("blocked", {"blocked": ["1.1.7"]}), + ("blocked with a tag", {"blocked": ["v1.1.7"]}), + ("min supported", {"min_supported": "2.0.0"}), + ): + with self.subTest(label): + body = meta_body() + body["clients"]["cloudexit"].update(clients) + error, fake = self.failure([reply(200, body)]) + self.assertEqual(error.exit_code, codes.UPLOAD_UNSUPPORTED) + self.assertEqual(error.code, "client_version_unsupported") + self.assertEqual(len(fake.requests), 1) + self.assertIn("2.0.0", error.message) + + def test_a_deprecated_client_gets_one_warning_and_still_uploads(self): + body = meta_body() + body["clients"]["cloudexit"]["deprecated_below"] = "2.0.0" + result, _ = self.push([reply(200, body), whoami_reply(), created_reply()]) + (warning,) = result.warnings + self.assertIn("deprecated", warning) + self.assertIn("2.0.0", warning) + + def test_a_current_client_and_a_host_without_a_policy_have_no_warning(self): + body = meta_body() + body["clients"] = {} + result, _ = self.push([reply(200, body), whoami_reply(), created_reply()]) + self.assertEqual(result.warnings, ()) + result, _ = self.push( + success_replies(), client=self.client(cache_path=self.tmp / "c2") + ) + self.assertEqual(result.warnings, ()) + + def test_version_parsing(self): + self.assertEqual(platform_client._version("v2.0.0rc1"), (2, 0, 0)) + self.assertEqual(platform_client._version("2.1"), (2, 1, 0)) + self.assertEqual(platform_client._version("1.1.7-dev+abc"), (1, 1, 7)) + self.assertIsNone(platform_client._version(None)) + self.assertIsNone(platform_client._version("latest")) + + def test_a_schema_version_the_host_does_not_list_is_exit_15_without_a_push(self): + with fake_http(meta_reply()) as fake: + with self.assertRaises(ApiError) as ctx: + self.client().push( + self.document(schema_version="2.7"), name="n", exit_strategy=1 + ) + self.assertEqual(ctx.exception.exit_code, codes.UPLOAD_UNSUPPORTED) + self.assertEqual(ctx.exception.code, "unsupported_schema_version") + self.assertEqual(len(fake.requests), 1) + + def test_a_newer_schema_version_the_host_lists_is_sent(self): + body = meta_body(contract={"schema_versions": ["2.0", "2.1"]}) + result, _ = self.push([reply(200, body), whoami_reply(), created_reply()]) + self.assertEqual(result.status, "completed") + + +class LimitTests(ClientTestCase): + def refused(self, document, meta=None, **kwargs): + replies = [reply(200, meta or meta_body()), whoami_reply(**kwargs)] + with fake_http(*replies) as fake: + with self.assertRaises(ApiError) as ctx: + self.client().push(document, name="n", exit_strategy=1) + self.assertEqual([r.method for r in fake.requests], ["GET", "GET"]) + self.assertEqual(ctx.exception.exit_code, codes.UPLOAD_REJECTED) + self.assertEqual(ctx.exception.code, "limit_exceeded") + return ctx.exception.message + + def test_a_document_over_a_limit_never_leaves_the_machine(self): + resource = {"type_id": 1, "type_code": "A", "location": "x", "count": 1} + cases = { + "resource items": self.document(inventory={"resources": [resource] * 1001}), + "count": self.document( + inventory={"resources": [dict(resource, count=10001)]} + ), + "location": self.document( + inventory={"resources": [dict(resource, location="x" * 256)]} + ), + "costs": self.document(inventory={"costs": [{}] * 13}), + "egress items": self.document(inventory={"egress": {"items": [{}] * 1001}}), + "flags": self.document( + inventory={"egress": {"items": [{"flags": ["f"] * 11}]}} + ), + "notes": self.document( + inventory={"egress": {"items": [{"notes": ["n"] * 11}]}} + ), + "tiers": self.document( + inventory={"egress": {"items": [{"tiers": [{}] * 11}]}} + ), + "type ids": self.document( + sync={ + "collector": {"catalogue": {"resource_type_ids": list(range(5001))}} + } + ), + "too old": self.document(sync={"completed_at": int(self.now) - 31 * 86400}), + "ahead of the clock": self.document( + sync={"completed_at": int(self.now) + 86401} + ), + "body": self.document(inventory={"note": "x" * 1_000_001}), + } + for label, document in cases.items(): + with self.subTest(label): + self.assertTrue(self.refused(document)) + + def test_the_announced_limits_win_over_the_defaults(self): + document = self.document( + inventory={"resources": [{"type_id": 1, "location": "x", "count": 1}] * 3} + ) + meta = meta_body(limits={"max_resource_items": 2}) + self.assertIn("limit of 2", self.refused(document, meta)) + + generous = meta_body(limits={"max_resource_items": 5000}) + document = self.document( + inventory={ + "resources": [{"type_id": 1, "location": "x", "count": 1}] * 1001 + } + ) + with fake_http(reply(200, generous), whoami_reply(), created_reply()): + self.client().push(document, name="n", exit_strategy=1) + + def test_a_limit_the_host_leaves_out_falls_back_to_the_default(self): + meta = meta_body() + del meta["limits"]["max_cost_items"] + document = self.document(inventory={"costs": [{}] * 13}) + self.assertIn("limit of 12", self.refused(document, meta)) + + def test_an_assessment_name_over_the_limit_is_refused_for_an_account_key(self): + replies = [meta_reply(), whoami_reply(kind="account")] + with fake_http(*replies): + with self.assertRaises(ApiError) as ctx: + self.client().push(self.document(), name="n" * 256, exit_strategy=1) + self.assertEqual(ctx.exception.code, "limit_exceeded") + + def test_the_messages_never_contain_the_document(self): + document = self.document( + inventory={ + "resources": [{"type_id": 1, "location": "secret-loc", "count": 10001}] + } + ) + message = self.refused(document) + self.assertNotIn("secret-loc", message) + + +class TargetTests(ClientTestCase): + def target_of(self, replies, **kwargs): + _, fake = self.push(replies, **kwargs) + return self.body_of(fake.requests[2]).get("target") + + def test_an_assessment_key_sends_no_target(self): + self.assertIsNone(self.target_of(success_replies())) + + def test_an_account_key_creates_an_assessment_with_the_strategy_name(self): + for number, name in EXIT_STRATEGIES.items(): + with self.subTest(number=number): + replies = [ + meta_reply(), + whoami_reply(kind="account"), + created_reply(), + ] + target = self.target_of( + replies, + exit_strategy=number, + client=self.client(cache_path=self.tmp / f"c{number}"), + ) + self.assertEqual( + target, {"create": {"name": "Flow Test", "exit_strategy": name}} + ) + + def test_an_assessment_id_names_the_target_for_either_key(self): + for kind in ("account", "assessment"): + with self.subTest(kind=kind): + replies = [meta_reply(), whoami_reply(kind=kind), created_reply()] + target = self.target_of( + replies, + assessment_id=ASSESSMENT_ID.upper(), + client=self.client(cache_path=self.tmp / kind), + ) + self.assertEqual(target, {"assessment_id": ASSESSMENT_ID}) + + def test_an_assessment_id_that_is_not_an_id_is_exit_14_without_a_push(self): + error, fake = self.failure( + [meta_reply(), whoami_reply(kind="account")], assessment_id="not-an-id" + ) + self.assertEqual((error.exit_code, error.code), (14, "invalid_assessment_id")) + self.assertEqual(len(fake.requests), 2) + + def test_an_unknown_exit_strategy_is_not_sent(self): + error, fake = self.failure( + [meta_reply(), whoami_reply(kind="account")], exit_strategy=9 + ) + self.assertEqual(error.exit_code, codes.UPLOAD_REJECTED) + self.assertEqual(len(fake.requests), 2) + + def test_the_document_is_not_changed_and_an_old_target_is_replaced(self): + document = self.document(target={"assessment_id": "old"}) + before = json.dumps(document, sort_keys=True) + with fake_http(*success_replies()) as fake: + self.client().push(document, name="n", exit_strategy=1) + self.assertEqual(json.dumps(document, sort_keys=True), before) + self.assertNotIn("target", self.body_of(fake.requests[2])) + + +class ResponseTests(ClientTestCase): + def test_201_carries_the_ids_the_link_and_the_status(self): + result, _ = self.push(success_replies()) + self.assertEqual( + ( + result.status, + result.sync_id, + result.assessment_id, + result.assessment_name, + result.assessment_created, + result.url, + result.replayed, + result.pending, + result.request_id, + ), + ( + "completed", + PLATFORM_SYNC_ID, + ASSESSMENT_ID, + "Flow Test", + False, + "https://platform.test/assessments/a-1", + False, + False, + REQUEST_ID, + ), + ) + + def test_200_with_idempotent_replayed_is_a_replay(self): + replayed = reply(200, sync_body(), **{"Idempotent-Replayed": "true"}) + result, _ = self.push([meta_reply(), whoami_reply(), replayed]) + self.assertTrue(result.replayed) + self.assertFalse(result.pending) + + def test_completed_with_warnings_is_a_success_and_the_warnings_come_back(self): + warnings = [ + { + "code": "unknown_resource_types", + "message": "Unknown types.", + "details": {}, + }, + {"code": "tier_downgraded", "message": "Granted\n Basic."}, + ] + body = sync_body(status="completed_with_warnings", warnings=warnings) + whoami = whoami_body(warnings=[{"code": "key_expiring", "message": "Soon."}]) + result, _ = self.push([meta_reply(), reply(200, whoami), reply(201, body)]) + self.assertEqual(result.status, "completed_with_warnings") + self.assertEqual( + result.warnings, + ( + "Soon. (key_expiring)", + "Unknown types. (unknown_resource_types)", + "Granted Basic. (tier_downgraded)", + ), + ) + + def test_202_is_polled_until_it_completes(self): + accepted = reply( + 202, + sync_body(status="accepted", results=None), + **{"Retry-After": "3", "Location": "/api/v2/syncs/x"}, + ) + processing = reply(200, sync_body(status="processing"), **{"Retry-After": "7"}) + done = reply(200, sync_body()) + result, fake = self.push( + [meta_reply(), whoami_reply(), accepted, processing, done] + ) + + self.assertEqual(result.status, "completed") + self.assertFalse(result.pending) + self.assertEqual(self.slept, [3.0, 7.0]) + polls = fake.requests[3:] + self.assertEqual( + [(r.method, r.url) for r in polls], + [("GET", f"{HOST}/api/v2/syncs/{PLATFORM_SYNC_ID}")] * 2, + ) + for poll in polls: + self.assertNotIn("Idempotency-Key", poll.kwargs["headers"]) + self.assertEqual(poll.kwargs["headers"]["Authorization"], f"Bearer {KEY}") + + def test_polling_without_a_retry_after_waits_a_default_and_caps_a_long_one(self): + accepted = reply(202, sync_body(status="accepted")) + slow = reply(200, sync_body(status="processing"), **{"Retry-After": "900"}) + result, _ = self.push( + [meta_reply(), whoami_reply(), accepted, slow, reply(200, sync_body())] + ) + self.assertEqual(self.slept, [2.0, 30.0]) + self.assertEqual(result.status, "completed") + + def test_a_sync_still_processing_when_the_budget_runs_out_is_pending(self): + def still(): + return reply(200, sync_body(status="processing"), **{"Retry-After": "2"}) + + accepted = reply(202, sync_body(status="accepted"), **{"Retry-After": "2"}) + client = self.client(timeout=(10.0, 5.0)) + with fake_http( + meta_reply(), whoami_reply(), accepted, still(), still(), still() + ) as fake: + result = client.push(self.document(), name="n", exit_strategy=1) + + self.assertTrue(result.pending) + self.assertEqual(result.status, "processing") + self.assertEqual(result.url, "https://platform.test/assessments/a-1") + self.assertEqual(self.slept, [2.0, 2.0, 1.0]) + self.assertEqual(len(fake.requests), 6) + + def test_a_pending_sync_without_an_id_cannot_be_polled(self): + body = sync_body(status="accepted") + del body["id"] + result, fake = self.push([meta_reply(), whoami_reply(), reply(202, body)]) + self.assertTrue(result.pending) + self.assertEqual(len(fake.requests), 3) + + def test_a_poll_that_fails_is_mapped_like_any_error(self): + accepted = reply(202, sync_body(status="accepted")) + gone = reply(404, error_body("sync_not_found")) + error, _ = self.failure([meta_reply(), whoami_reply(), accepted, gone]) + self.assertEqual((error.exit_code, error.code), (14, "sync_not_found")) + + def test_a_sync_that_failed_is_mapped_from_its_error(self): + failed = sync_body( + status="failed", + error=error_body("internal_error", "Scoring failed.", True)["error"], + ) + error, _ = self.failure([meta_reply(), whoami_reply(), reply(201, failed)]) + self.assertEqual((error.exit_code, error.code), (12, "internal_error")) + self.assertEqual(error.message, "Scoring failed.") + + no_detail = sync_body(status="failed") + error, _ = self.failure( + [meta_reply(), whoami_reply(), reply(201, no_detail)], + client=self.client(cache_path=self.tmp / "other"), + ) + self.assertEqual(error.code, "sync_failed") + + def test_a_2xx_without_a_json_object_is_exit_12(self): + for answer in ( + make_response(201, text="proxy"), + reply(201, ["not", "an", "object"]), + ): + with self.subTest(answer=answer.text[:12]): + error, _ = self.failure( + [meta_reply(), whoami_reply(), answer], + client=self.client(cache_path=self.tmp / uuid.uuid4().hex), + ) + self.assertEqual( + (error.exit_code, error.code), (12, "invalid_response") + ) + + def test_an_unsafe_link_is_dropped_and_a_missing_one_is_none(self): + for url in ("javascript:alert(1)", "//evil.test/x", None, 5): + with self.subTest(url=url): + result, _ = self.push( + success_replies(assessment={"url": url}), + client=self.client(cache_path=self.tmp / uuid.uuid4().hex), + ) + self.assertIsNone(result.url) + + +class ErrorMappingTests(ClientTestCase): + def test_every_error_in_the_definition_maps_to_its_exit_code(self): + self.assertEqual(set(ERROR_EXIT_CODES), set(ERRORS["errors"])) + for code, entry in ERRORS["errors"].items(): + with self.subTest(code): + self.assertEqual(ERROR_EXIT_CODES[code], entry["cli_exit"]) + self.assertEqual( + exit_code_for(code, entry["http"], entry["retryable"]), + entry["cli_exit"], + ) + + def test_the_exit_codes_in_the_definition_are_the_ones_in_codes_py(self): + known = { + 10: codes.UPLOAD_REJECTED, + 11: codes.UPLOAD_AUTH, + 12: codes.UPLOAD_UNAVAILABLE, + 13: codes.UPLOAD_QUOTA, + 14: codes.UPLOAD_TARGET, + 15: codes.UPLOAD_UNSUPPORTED, + } + self.assertEqual(sorted(ERRORS["exit_codes"]), sorted(known)) + self.assertEqual(sorted(set(ERROR_EXIT_CODES.values())), sorted(known)) + + def test_a_server_error_answer_reaches_the_caller_with_code_message_and_id(self): + for code, entry in ERRORS["errors"].items(): + with self.subTest(code): + body = error_body(code, f"message for {code}", entry["retryable"]) + error, _ = self.failure( + [meta_reply(), whoami_reply(), reply(entry["http"], body)], + client=self.client(cache_path=self.tmp / code), + ) + self.assertEqual(error.code, code) + self.assertEqual(error.message, f"message for {code}") + self.assertEqual(error.request_id, REQUEST_ID) + self.assertEqual(error.exit_code, entry["cli_exit"]) + self.assertEqual(error.status, entry["http"]) + self.assertEqual(error.retryable, entry["retryable"]) + + def test_unknown_codes_fall_back_to_the_status_class_and_retryable(self): + cases = ( + (400, False, 10), + (413, False, 10), + (422, False, 10), + (409, False, 10), + (401, False, 11), + (403, False, 11), + (402, False, 13), + (404, False, 14), + (410, False, 14), + (429, True, 12), + (409, True, 12), + (500, False, 12), + (503, False, 12), + (502, True, 12), + ) + for status, retryable, exit_code in cases: + with self.subTest(status=status, retryable=retryable): + self.assertEqual( + exit_code_for("brand_new_code", status, retryable), exit_code + ) + body = error_body("brand_new_code", "A new thing.", retryable) + error, _ = self.failure( + [meta_reply(), whoami_reply(), reply(status, body)], + client=self.client(cache_path=self.tmp / f"{status}{retryable}"), + ) + self.assertEqual( + (error.code, error.exit_code), ("brand_new_code", exit_code) + ) + self.assertEqual(exit_code_for(None, None, False), codes.UPLOAD_REJECTED) + self.assertEqual(exit_code_for(None, None, True), codes.UPLOAD_UNAVAILABLE) + + def test_an_answer_that_is_not_an_error_object_uses_the_status_class(self): + for status, text, exit_code in ( + (502, "Bad gateway", 12), + (503, "", 12), + (401, "Unauthorized", 11), + (400, "{not json", 10), + (404, '{"error": "plain string"}', 14), + ): + with self.subTest(status=status): + error, _ = self.failure( + [meta_reply(), whoami_reply(), make_response(status, text=text)], + client=self.client(cache_path=self.tmp / str(status) / text[:3]), + ) + self.assertEqual(error.exit_code, exit_code) + self.assertEqual(error.code, f"http_{status}") + self.assertEqual(error.message, f"HTTP status {status}") + self.assertNotIn("Bad gateway", error.message) + + def test_the_request_id_falls_back_to_the_one_the_client_sent(self): + answer = make_response(502, text="bad") + error, fake = self.failure([meta_reply(), whoami_reply(), answer]) + self.assertEqual( + error.request_id, fake.requests[2].kwargs["headers"]["X-Request-ID"] + ) + + def test_a_wrong_region_answer_names_the_expected_host(self): + body = error_body( + "wrong_region", + "This key belongs to the other region.", + details=[ + { + "code": "wrong_region", + "message": "m", + "expected_host": "https://eu.platform.example.com", + } + ], + ) + error, _ = self.failure([meta_reply(), whoami_reply(), reply(403, body)]) + self.assertEqual(error.exit_code, codes.UPLOAD_AUTH) + self.assertIn("https://eu.platform.example.com", error.message) + + def test_messages_are_one_line_and_never_echo_the_key(self): + body = error_body("validation_failed", f"Bad\nvalue {KEY} here") + error, _ = self.failure([meta_reply(), whoami_reply(), reply(422, body)]) + self.assertNotIn("\n", error.message) + self.assertNotIn(KEY, error.message) + self.assertIn("[api key]", error.message) + + +class LoggingTests(ClientTestCase): + def test_nothing_secret_reaches_the_logs(self): + secret_location = "secret-location-marker" + document = self.document( + inventory={ + "resources": [ + { + "type_id": 1, + "type_code": "T", + "location": secret_location, + "count": 1, + } + ] + } + ) + runs = ( + success_replies(), + [ + meta_reply(), + whoami_reply(), + reply(422, error_body("validation_failed", "no")), + ], + [meta_reply(), requests.ConnectionError(f"failed with {KEY}")], + ) + with self.assertLogs("cloudexit.platform.client", level=logging.DEBUG) as logs: + for index, replies in enumerate(runs): + try: + with fake_http(*replies): + self.client(cache_path=self.tmp / str(index)).push( + document, name="n", exit_strategy=1 + ) + except ApiError: + pass + + text = "\n".join(logs.output) + self.assertTrue(logs.records) + for secret in (KEY, KEY[8:], "Bearer", "Authorization", secret_location): + self.assertNotIn(secret, text) + self.assertIn("request_id=", text) + self.assertRegex(text, r"body_bytes=\d+") + self.assertIn(REQUEST_ID, text) + + def test_the_exception_text_does_not_carry_the_key_or_the_body(self): + with fake_http(requests.ConnectionError(f"boom {KEY}")): + with self.assertRaises(ApiError) as ctx: + self.client().whoami() + self.assertNotIn(KEY, str(ctx.exception)) + self.assertNotIn(KEY, repr(ctx.exception)) + self.assertIsNone(ctx.exception.__cause__) + + +EXAMPLES = DEFINITION / "examples" +TOKEN = re.compile(r"\{\{([^}]*)\}\}") + +# Requests the CLI never sends: the platform's refusal of them is not the +# client's to handle. +NEVER_SENT = { + "09": "no Idempotency-Key", + "10": "an Idempotency-Key that differs from sync.id", + "27": "an account key without a target", + "37": "a document that breaks the schema", + "39": "invalid JSON", + "40": "another media type", +} +# Requests the CLI refuses before sending, from what /meta announces. +REFUSED_LOCALLY = { + "38": (codes.UPLOAD_REJECTED, 2), + "41": (codes.UPLOAD_UNSUPPORTED, 1), +} +# Key checks that happen without a request. +KEY_CHECKED_LOCALLY = {"33", "34"} +# Reads the CLI makes: capabilities, identity, and the poll of a sync. +READS = {"01", "02", "03", "04", "31", "32", "42", "43"} +# Not part of what a push needs. +NOT_USED = {"35", "44", "45", "46", "47", "48", "49", "50"} + + +class Replay: + def __init__(self, now): + self.now = now + self.ids = { + "main": ASSESSMENT_ID, + "first_sync": PLATFORM_SYNC_ID, + } + self.keys = {} + + def identifier(self, name): + if name not in self.ids: + self.ids[name] = str(uuid.uuid5(uuid.NAMESPACE_URL, f"example/{name}")) + return self.ids[name] + + def fill(self, value, keys=None): + if isinstance(value, dict): + return {k: self.fill(v, keys) for k, v in value.items()} + if isinstance(value, list): + return [self.fill(v, keys) for v in value] + if not isinstance(value, str) or "{{" not in value: + return value + whole = TOKEN.fullmatch(value) + if whole: + return self.token(whole.group(1), keys) + return TOKEN.sub(lambda m: str(self.token(m.group(1), keys)), value) + + def token(self, name, keys): + if name.endswith("_id"): + return self.identifier(name[: -len("_id")]) + offset = re.fullmatch(r"now([+-]\d+)?", name) + if offset: + return int(self.now) + int(offset.group(1) or 0) + key_name = name.split("|")[0] + if key_name in (keys or {}): + key = keys[key_name] + return corrupt(key) if name.endswith("|corrupt") else key + raise AssertionError(f"unknown placeholder {{{{{name}}}}}") + + +def load_examples(): + return { + path.name[:2]: json.loads(path.read_text(encoding="utf-8")) + for path in sorted(EXAMPLES.glob("*.json")) + } + + +class ExampleSupport(ClientTestCase): + maxDiff = None + + @classmethod + def setUpClass(cls): + cls.examples = load_examples() + + def setUp(self): + super().setUp() + self.now = 1_800_000_000.0 + self.replay = Replay(self.now) + + def keys_for(self, example): + keys = {} + for index, spec in enumerate(example["fixtures"].get("keys", [])): + prefix = "esc_sk_" + keys[spec["name"]] = make_key(prefix, "ABCDEFGH"[index]) + return keys + + def whoami_for(self, example, key_name): + spec = next(k for k in example["fixtures"]["keys"] if k["name"] == key_name) + scopes = spec.get("scopes") or ["syncs:write"] + return reply( + 200, + whoami_body( + kind=spec["kind"], + scopes=scopes, + assessment_id=self.replay.identifier(spec.get("assessment", "main")), + ), + ) + + def response_for(self, example, keys): + expect = self.replay.fill(example["expect"], keys) + headers = dict(expect.get("headers") or {}) + headers.setdefault("X-Request-ID", REQUEST_ID) + for name in expect.get("headers_present", []): + headers.setdefault(name, "present") + subset = expect.get("body_subset") or {} + if subset.get("object") == "error": + body = merged(error_body(subset["error"]["code"]), subset) + body["error"].setdefault("request_id", REQUEST_ID) + else: + body = merged(sync_body(), subset) + return make_response(expect["status"], json_body=body, headers=headers) + + def run_example(self, number, attempts=1, **options): + example = self.examples[number] + keys = self.keys_for(example) + request = self.replay.fill(example["request"], keys) + key_name = TOKEN.search(example["request"]["headers"]["Authorization"]).group(1) + key = keys[key_name] + body = request["body"] + target = body.get("target") or {} + create = target.get("create") or {} + strategy = {name: n for n, name in EXIT_STRATEGIES.items()}[ + create.get("exit_strategy", "repatriation") + ] + document = {k: v for k, v in body.items() if k != "target"} + options.setdefault("cache_path", self.tmp / number) + client = self.last_client = self.client(key=key, **options) + replies = [meta_reply(), self.whoami_for(example, key_name)] + [ + self.response_for(example, keys) for _ in range(attempts) + ] + with fake_http(*replies) as fake: + try: + result = client.push( + document, + name=create.get("name", "Example"), + exit_strategy=strategy, + assessment_id=target.get("assessment_id"), + ) + error = None + except ApiError as exc: + result, error = None, exc + return example, request, fake, result, error + + +class ExampleReplayTests(ExampleSupport): + def test_every_example_is_accounted_for(self): + sent = { + number + for number, example in self.examples.items() + if example["request"]["method"] == "POST" + } + accounted = ( + set(NEVER_SENT) + | set(REFUSED_LOCALLY) + | KEY_CHECKED_LOCALLY + | READS + | NOT_USED + | {n for n in sent if n not in NEVER_SENT and n not in REFUSED_LOCALLY} + ) + self.assertEqual(set(self.examples), accounted) + + def test_what_the_cli_never_sends_really_differs_from_what_it_sends(self): + for number in NEVER_SENT: + with self.subTest(number): + example = self.examples[number] + replay = Replay(self.now) + request = replay.fill( + example["request"], {"assessment_key": KEY, "account_key": KEY} + ) + headers, body = request["headers"], request.get("body") + differs = ( + "Idempotency-Key" not in headers + or not isinstance(body, dict) + or headers["Idempotency-Key"] != (body.get("sync") or {}).get("id") + or headers.get("Content-Type") != "application/json" + or bool(schema_errors(body)) + or ( + request["headers"]["Authorization"] == f"Bearer {KEY}" + and "target" not in body + and number == "27" + ) + ) + self.assertTrue(differs, NEVER_SENT[number]) + + def test_the_request_the_cli_sends_is_the_one_in_the_example(self): + for number, example in self.examples.items(): + if ( + example["request"]["method"] != "POST" + or number in NEVER_SENT + or number in REFUSED_LOCALLY + ): + continue + with self.subTest(number=number, name=example["name"]): + _, request, fake, _, _ = self.run_example(number) + post = fake.requests[2] + self.assertEqual( + (post.method, post.url), ("POST", HOST + request["path"]) + ) + self.assertEqual(self.body_of(post), request["body"]) + self.assertEqual(schema_errors(self.body_of(post)), []) + headers = post.kwargs["headers"] + for name in ("Idempotency-Key", "Content-Type"): + self.assertEqual(headers[name], request["headers"][name]) + self.assertEqual( + headers["Idempotency-Key"], request["body"]["sync"]["id"] + ) + self.assertTrue(headers["Authorization"].startswith("Bearer esc_sk_")) + self.assertEqual(headers["Prefer"], "wait=20") + + def test_success_examples_end_in_a_result(self): + for number, example in self.examples.items(): + if example["request"]["method"] != "POST" or example["expect"][ + "status" + ] not in (200, 201, 202): + continue + with self.subTest(number=number, name=example["name"]): + example, _, _, result, error = self.run_example(number) + self.assertIsNone(error) + subset = example["expect"].get("body_subset") or {} + self.assertEqual(result.status, subset.get("status", "completed")) + headers = example["expect"].get("headers") or {} + self.assertEqual( + result.replayed, + example["expect"]["status"] == 200 + and headers.get("Idempotent-Replayed") == "true", + ) + created = (subset.get("assessment") or {}).get("created") + if created is not None: + self.assertEqual(result.assessment_created, created) + self.assertEqual(result.assessment_id, ASSESSMENT_ID) + + def test_error_examples_end_in_the_exit_code_of_the_registry(self): + seen = set() + for number, example in self.examples.items(): + subset = example["expect"].get("body_subset") or {} + if ( + example["request"]["method"] != "POST" + or subset.get("object") != "error" + or number in NEVER_SENT + or number in REFUSED_LOCALLY + ): + continue + with self.subTest(number=number, name=example["name"]): + _, _, _, result, error = self.run_example(number) + code = subset["error"]["code"] + seen.add(code) + self.assertIsNone(result) + self.assertEqual(error.code, code) + self.assertEqual(error.exit_code, ERRORS["errors"][code]["cli_exit"]) + self.assertEqual(error.status, example["expect"]["status"]) + self.assertEqual(error.request_id, REQUEST_ID) + self.assertTrue(error.message) + self.assertLessEqual( + { + "idempotency_key_reused", + "target_mismatch", + "catalogue_mismatch", + "catalogue_sha256_mismatch", + "insufficient_scope", + "assessment_not_found", + "key_binding_violation", + "payload_too_large", + "insufficient_credits", + }, + seen, + ) + + def test_examples_the_cli_refuses_locally_send_no_push(self): + for number, (exit_code, reads) in REFUSED_LOCALLY.items(): + with self.subTest(number=number): + _, _, fake, result, error = self.run_example_refused(number, reads) + self.assertIsNone(result) + self.assertEqual(error.exit_code, exit_code) + self.assertEqual([r.method for r in fake.requests], ["GET"] * reads) + + def run_example_refused(self, number, reads): + example = self.examples[number] + keys = self.keys_for(example) + request = self.replay.fill(example["request"], keys) + key_name = TOKEN.search(example["request"]["headers"]["Authorization"]).group(1) + client = self.client(key=keys[key_name], cache_path=self.tmp / number) + replies = [meta_reply(), self.whoami_for(example, key_name)][:reads] + with fake_http(*replies) as fake: + try: + result = client.push(request["body"], name="n", exit_strategy=1) + error = None + except ApiError as exc: + result, error = None, exc + return example, request, fake, result, error + + def test_key_examples_are_refused_locally_without_a_request(self): + for number in KEY_CHECKED_LOCALLY: + with self.subTest(number=number): + example = self.examples[number] + keys = self.keys_for(example) + request = self.replay.fill(example["request"], keys) + key = request["headers"]["Authorization"].removeprefix("Bearer ") + with fake_http() as fake: + with self.assertRaises(ApiError) as ctx: + check_key(key) + self.assertEqual(fake.requests, []) + self.assertEqual(ctx.exception.exit_code, codes.UPLOAD_AUTH) + self.assertEqual( + ctx.exception.code, + example["expect"]["body_subset"]["error"]["code"], + ) + + def test_the_read_examples_match_what_the_client_does_with_them(self): + for number in ("31", "32"): + with self.subTest(number=number): + example = self.examples[number] + keys = self.keys_for(example) + client = self.client(key=next(iter(keys.values()))) + answer = self.response_for(example, keys) + with fake_http(meta_reply(), answer): + with self.assertRaises(ApiError) as ctx: + client.whoami() + self.assertEqual(ctx.exception.exit_code, codes.UPLOAD_AUTH) + self.assertEqual( + ctx.exception.code, + example["expect"]["body_subset"]["error"]["code"], + ) + + example = self.examples["02"] + keys = self.keys_for(example) + with fake_http( + meta_reply(), self.whoami_for(example, "assessment_key") + ) as fake: + who = self.client(key=keys["assessment_key"]).whoami() + self.assertEqual(who["key"]["kind"], "assessment") + self.assertEqual(fake.requests[1].url, HOST + example["request"]["path"]) + + with fake_http(reply(200, meta_body())) as fake: + self.client().meta() + self.assertEqual( + fake.requests[0].url, HOST + self.examples["01"]["request"]["path"] + ) + self.assertEqual(component_errors("Meta", meta_body()), []) + + def test_the_poll_examples_decide_the_end_of_a_202(self): + accepted = reply(202, sync_body(status="accepted"), **{"Retry-After": "1"}) + for number, outcome in (("42", "ok"), ("43", "error")): + with self.subTest(number=number): + example = self.examples[number] + answer = self.response_for(example, self.keys_for(example)) + with fake_http(meta_reply(), whoami_reply(), accepted, answer) as fake: + client = self.client(cache_path=self.tmp / number) + if outcome == "ok": + result = client.push(self.document(), name="n", exit_strategy=1) + self.assertEqual(result.status, "completed") + else: + with self.assertRaises(ApiError) as ctx: + client.push(self.document(), name="n", exit_strategy=1) + self.assertEqual(ctx.exception.exit_code, codes.UPLOAD_TARGET) + self.assertEqual(fake.requests[3].method, "GET") + + +class FixtureTests(unittest.TestCase): + def test_the_fake_answers_are_valid_against_the_definition(self): + self.assertEqual(component_errors("Meta", meta_body()), []) + self.assertEqual(component_errors("Whoami", whoami_body()), []) + self.assertEqual(component_errors("Whoami", whoami_body(kind="account")), []) + self.assertEqual(component_errors("Sync", sync_body()), []) + self.assertEqual( + component_errors("ErrorResponse", error_body("validation_failed")), [] + ) + + def test_a_minimal_document_is_not_the_real_thing(self): + self.assertNotEqual(schema_errors(minimal_document(SYNC_ID)), []) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/platform/test_contract.py b/tests/platform/test_contract.py new file mode 100644 index 0000000..30cb53f --- /dev/null +++ b/tests/platform/test_contract.py @@ -0,0 +1,670 @@ +import json +import re +import unittest +from urllib.parse import urlsplit + +import yaml + +from cloudexit.platform import client as platform_client +from cloudexit.platform.client import ( + ERROR_EXIT_CODES, + EXIT_STRATEGIES, + PENDING_STATUSES, +) +from cloudexit.platform.spool import Spool, flush +from cloudexit.platform.document import PROVIDERS, SCHEMA_VERSION +from cloudexit.platform.upload import upload +from tests.platform import test_client as base +from tests.cli import test_upload_modes as modes +from tests.support.api import ( + HOST, + KEY, + PLATFORM_SYNC_ID, + created_reply, + error_body, + meta_body, + meta_reply, + reply, + sync_body, + whoami_body, + whoami_reply, +) +from tests.support.http import fake_http +from tests.support.sync import DEFINITION, schema_errors +from cloudexit import codes +from cloudexit.settings import Setting, Settings +from tests.support.paths import ROOT + +SPEC = yaml.safe_load((DEFINITION / "openapi.yaml").read_text(encoding="utf-8")) +ERRORS = yaml.safe_load((DEFINITION / "errors.yaml").read_text(encoding="utf-8")) +SERVER_PATH = urlsplit(SPEC["servers"][0]["url"].replace("{host}", "h")).path + +# The definition version these tests were last reviewed against. A new version +# fails the guard below until someone has read what changed. +REVIEWED_DEFINITION = "2.0.0" + +# Operations the CLI calls. The others read what a host stores and are not +# part of collecting and pushing. +USED_OPERATIONS = { + ("get", "/meta"), + ("get", "/whoami"), + ("post", "/syncs"), + ("get", "/syncs/{sync_id}"), +} +NOT_APPLICABLE_OPERATIONS = { + ("get", "/assessments"): "listing assessments is not part of a push", + ( + "get", + "/assessments/{assessment_id}", + ): "reading an assessment is not part of a push", + ( + "get", + "/assessments/{assessment_id}/syncs", + ): "listing syncs is not part of a push", +} +# Error codes the CLI cannot meet on /api/v2 +NOT_APPLICABLE_ERRORS = { + "assessment_key_v1": "returned by /api/v1 in the v1 envelope, never by /api/v2", +} +# Where each warning of the definition reaches the CLI +WARNING_ROUTES = { + "unknown_resource_types": "sync", + "resource_type_inactive": "sync", + "resource_type_code_mismatch": "sync", + "duplicate_items_merged": "sync", + "tier_downgraded": "sync", + "name_ignored": "sync", + "key_expiring": "whoami", + "client_version_deprecated": "sync", + "collector_catalogue_outdated": "sync", + "host_catalogue_outdated": "sync", + "collector_catalogue_unknown": "sync", +} +# Statuses of a finished sync that end the run well, and the one that does not +SUCCESS_STATUSES = ("completed", "completed_with_warnings") +FAILED_STATUS = "failed" +# Vectors the CLI has tests for, and the one it has no use for +VECTORS = { + "scope_key.json": "tests/platform/test_document.py", + "content_sha256.json": None, +} + + +def resolve(node): + while isinstance(node, dict) and "$ref" in node: + target = SPEC + for part in node["$ref"][2:].split("/"): + target = target[part] + node = target + return node + + +def find_operation(method, path): + for template, operations in SPEC["paths"].items(): + pattern = "^" + re.sub(r"\{[^}]+\}", "[^/]+", template) + "$" + if re.fullmatch(pattern, path) and method.lower() in operations: + return template, operations[method.lower()] + return None, None + + +def settings_for(**changes): + fields = dict( + host=Setting(HOST, "test"), + api_key=Setting(KEY, "test"), + upload_budget=Setting(0.0, "test"), + ) + fields.update(changes) + return Settings(**fields) + + +class ContractCase(base.ClientTestCase): + def check_request(self, recorded, client, key=KEY): + parsed = urlsplit(recorded.url) + self.assertTrue(parsed.path.startswith(SERVER_PATH), recorded.url) + template, operation = find_operation( + recorded.method, parsed.path[len(SERVER_PATH) :] + ) + self.assertIsNotNone( + operation, f"{recorded.method} {parsed.path} is not in the definition" + ) + + headers = { + name.lower(): value + for name, value in { + **client.session.headers, + **recorded.kwargs.get("headers", {}), + }.items() + } + if operation.get("security", SPEC.get("security")): + self.assertEqual(headers.get("authorization"), f"Bearer {key}") + else: + self.assertNotIn("authorization", headers) + + declared = [resolve(p) for p in operation.get("parameters", [])] + for parameter in declared: + if parameter["in"] != "header": + continue + name = parameter["name"].lower() + if parameter.get("required"): + self.assertIn(name, headers, f"{template} needs {parameter['name']}") + pattern = parameter.get("schema", {}).get("pattern") + if name in headers and pattern: + self.assertRegex(headers[name], pattern, parameter["name"]) + queries = {p["name"] for p in declared if p["in"] == "query"} + sent = {q.split("=")[0] for q in parsed.query.split("&") if q} + self.assertLessEqual(sent, queries) + self.assertTrue(headers.get("user-agent")) + + if "requestBody" in operation: + media = resolve(operation["requestBody"])["content"] + self.assertIn(headers.get("content-type"), media) + body = json.loads(recorded.kwargs["data"].decode("utf-8")) + self.assertEqual(schema_errors(body), []) + self.assertEqual(headers["idempotency-key"], body["sync"]["id"]) + return body + self.assertIsNone(recorded.kwargs.get("data")) + return None + + +class RequestContractTests(ContractCase): + def test_meta_and_whoami(self): + client = self.client() + with fake_http(meta_reply(), whoami_reply()) as fake: + client.meta() + client.whoami() + + meta, who = fake.requests + self.check_request(meta, client) + self.check_request(who, client) + self.assertEqual(find_operation("get", "/meta")[0], "/meta") + + def test_meta_is_asked_without_the_key(self): + client = self.client() + with fake_http(meta_reply()) as fake: + client.meta() + self.assertNotIn("Authorization", fake.requests[0].kwargs.get("headers", {})) + + def test_a_retried_push_repeats_the_same_request(self): + client = self.client(budget=100.0, max_attempts=2) + body = self.document() + failed = reply(503, error_body("service_unavailable", retryable=True)) + with fake_http(meta_reply(), whoami_reply(), failed, created_reply()) as fake: + client.push(body, name="n", exit_strategy=1) + + first, second = fake.requests[2:] + self.assertEqual(first.kwargs["data"], second.kwargs["data"]) + self.assertEqual( + first.kwargs["headers"]["Idempotency-Key"], + second.kwargs["headers"]["Idempotency-Key"], + ) + self.assertEqual(first.kwargs["headers"]["Idempotency-Key"], body["sync"]["id"]) + + def test_the_poll_of_a_pending_sync_follows_the_definition(self): + client = self.client() + accepted = reply(202, sync_body(status="accepted"), **{"Retry-After": "1"}) + with fake_http( + meta_reply(), whoami_reply(), accepted, reply(200, sync_body()) + ) as fake: + client.push(self.document(), name="n", exit_strategy=1) + + poll = fake.requests[3] + template, _ = find_operation( + poll.method, urlsplit(poll.url).path[len(SERVER_PATH) :] + ) + self.assertEqual(template, "/syncs/{sync_id}") + self.check_request(poll, client) + self.assertTrue(poll.url.endswith(PLATFORM_SYNC_ID)) + + +class ExampleContractTests(base.ExampleSupport): + def sent(self): + return [ + number + for number, example in self.examples.items() + if example["request"]["method"] == "POST" + and number not in base.NEVER_SENT + and number not in base.REFUSED_LOCALLY + ] + + def test_every_request_the_cli_sends_matches_the_definition(self): + check = ContractCase() + for number in self.sent(): + with self.subTest(number=number): + _, request, fake, _, _ = self.run_example(number) + post = fake.requests[2] + body = check.check_request( + post, self.last_client, self.last_client._key + ) + self.assertEqual(body, request["body"]) + + def test_the_first_requests_of_a_push_match_the_definition_too(self): + check = ContractCase() + _, _, fake, _, _ = self.run_example("25") + for recorded in fake.requests[:2]: + check.check_request(recorded, self.last_client, self.last_client._key) + + def test_errors_are_retried_exactly_when_the_registry_says_so(self): + checked = 0 + for number in self.sent(): + example = self.examples[number] + subset = example["expect"].get("body_subset") or {} + if subset.get("object") != "error": + continue + entry = ERRORS["errors"][subset["error"]["code"]] + attempts = 3 if entry["retryable"] else 1 + with self.subTest(number=number, code=subset["error"]["code"]): + self.slept.clear() + _, _, fake, result, error = self.run_example( + number, attempts=attempts, budget=1000.0, max_attempts=3 + ) + self.assertIsNone(result) + self.assertEqual(len(fake.requests) - 2, attempts) + self.assertEqual(error.attempts, attempts) + self.assertEqual(error.exit_code, entry["cli_exit"]) + self.assertEqual(len(self.slept), attempts - 1) + checked += 1 + self.assertGreater(checked, 10) + + def test_the_replay_example_is_a_success_and_is_not_retried(self): + example, _, fake, result, error = self.run_example("07") + + self.assertIsNone(error) + self.assertTrue(result.replayed) + self.assertEqual(len(fake.requests), 3) + self.assertEqual(example["expect"]["headers"]["Idempotent-Replayed"], "true") + + def test_the_warnings_of_every_example_reach_the_result(self): + found = set() + for number in self.sent(): + expect = self.examples[number]["expect"] + wanted = [ + w["code"] for w in (expect.get("body_subset") or {}).get("warnings", []) + ] + if expect["status"] not in (200, 201) or not wanted: + continue + with self.subTest(number=number): + _, _, _, result, error = self.run_example(number) + self.assertIsNone(error) + self.assertEqual([w["code"] for w in result.warning_items], wanted) + found.update(wanted) + self.assertGreaterEqual(len(found), 4) + + +class ErrorMatrixTests(base.ClientTestCase): + def codes(self): + return { + code: entry + for code, entry in ERRORS["errors"].items() + if code not in NOT_APPLICABLE_ERRORS + } + + def test_every_error_of_the_registry_is_retried_by_its_flag_and_honours_retry_after( + self, + ): + for code, entry in self.codes().items(): + attempts = 3 if entry["retryable"] else 1 + with self.subTest(code=code): + self.slept.clear() + client = self.client( + budget=1000.0, max_attempts=3, cache_path=self.tmp / code + ) + replies = [meta_reply(), whoami_reply()] + [ + reply( + entry["http"], + error_body(code, retryable=entry["retryable"]), + **{"Retry-After": "7"}, + ) + for _ in range(attempts) + ] + with fake_http(*replies) as fake: + with self.assertRaises(platform_client.ApiError) as ctx: + client.push(self.document(), name="n", exit_strategy=1) + + self.assertEqual(len(fake.requests) - 2, attempts) + self.assertEqual(ctx.exception.exit_code, entry["cli_exit"]) + self.assertEqual(ctx.exception.retryable, entry["retryable"]) + # Retry-After is waited as announced, and ignored when nothing is retried + self.assertEqual(self.slept, [7.0] * (attempts - 1)) + + def test_a_retryable_error_that_goes_away_ends_in_success(self): + for code, entry in self.codes().items(): + if not entry["retryable"]: + continue + with self.subTest(code=code): + client = self.client( + budget=1000.0, max_attempts=3, cache_path=self.tmp / code + ) + failed = reply(entry["http"], error_body(code, retryable=True)) + with fake_http(meta_reply(), whoami_reply(), failed, created_reply()): + result = client.push(self.document(), name="n", exit_strategy=1) + self.assertEqual(result.status, "completed") + + def test_the_exit_code_never_depends_on_the_wording(self): + for code, entry in self.codes().items(): + with self.subTest(code=code): + body = error_body(code, "Das hat nicht geklappt.", entry["retryable"]) + error, _ = self.failure( + [meta_reply(), whoami_reply(), reply(entry["http"], body)], + client=self.client(cache_path=self.tmp / code), + ) + self.assertEqual(error.exit_code, entry["cli_exit"]) + + +class WarningContractTests(modes.ModeTestCase): + def item(self, code): + return { + "code": code, + "message": f"The platform warns: {code}.", + "details": {"type_ids": [981]}, + } + + def replies(self, code): + item = self.item(code) + if WARNING_ROUTES[code] == "whoami": + return [meta_reply(), whoami_reply(warnings=[item]), created_reply()] + return [meta_reply(), whoami_reply(), created_reply(warnings=[item])] + + def test_every_warning_is_shown_and_does_not_fail_a_normal_run(self): + for code in WARNING_ROUTES: + with self.subTest(code=code): + self.run_assessment( + self.report(), + settings=modes.flow.CONFIGURED, + replies=self.replies(code), + ) + + shown = [ + kwargs.get("logs") + for args, kwargs in self.steps + if args[0] == "Platform warning" + ] + self.assertEqual( + shown, [platform_client.warning_line({**self.item(code)})] + ) + self.assertEqual( + [i["code"] for i in main_run()["upload"].warning_items], [code] + ) + + def test_every_warning_fails_a_strict_run_after_the_report(self): + for code in WARNING_ROUTES: + with self.subTest(code=code): + report = self.report() + with self.assertRaises(SystemExit) as ctx: + self.run_assessment( + report, + settings=modes.flow.CONFIGURED, + replies=self.replies(code), + strict=True, + ) + + self.assertEqual(ctx.exception.code, codes.UPLOAD_REJECTED) + self.assert_local_report(report) + + +def main_run(): + from cloudexit.cli import app + + return app.RUN + + +class ReplayContractTests(base.ClientTestCase): + def setUp(self): + super().setUp() + self.spool = Spool(self.tmp / "spool") + self.settings = settings_for() + + def options(self): + return { + "cache_path": self.tmp / "cache", + "sleep": self.sleep, + "clock": lambda: self.now, + "environ": {}, + } + + def replayed(self): + return reply(200, sync_body(), **{"Idempotent-Replayed": "true"}) + + def upload(self, replies, **kwargs): + with fake_http(*replies): + return upload( + self.document(), + self.settings, + name="n", + meta={"exit_strategy": 1}, + spool=self.spool, + **self.options(), + **kwargs, + ) + + def test_a_replayed_answer_is_a_success_and_spools_nothing(self): + result = self.upload([meta_reply(), whoami_reply(), self.replayed()]) + + self.assertTrue(result.success) + self.assertTrue(result.replayed) + self.assertEqual(self.spool.entries(), []) + + def test_a_spooled_sync_delivered_as_a_replay_leaves_no_second_entry(self): + failed = reply(503, error_body("service_unavailable", retryable=True)) + first = self.upload([meta_reply(), whoami_reply(), failed]) + self.assertTrue(first.spooled) + self.assertEqual(len(self.spool.entries()), 1) + + # The same sync fails again: still one entry for its id + again = reply(503, error_body("service_unavailable", retryable=True)) + self.upload([whoami_reply(), again]) + self.assertEqual(len(self.spool.entries()), 1) + + with fake_http(whoami_reply(), self.replayed()): + result = flush(self.settings, self.spool, **self.options()) + + self.assertEqual(len(result.uploaded), 1) + self.assertEqual(self.spool.entries(), []) + + +class ForwardCompatibilityTests(base.ClientTestCase): + def test_unknown_fields_in_every_answer_are_ignored(self): + meta = meta_body() + meta["future_block"] = {"a": 1} + meta["limits"]["max_future_things"] = 5 + meta["clients"]["cloudexit"]["future_policy"] = True + who = whoami_body() + who["key"]["future_flag"] = "x" + who["future_block"] = [1, 2] + sync = sync_body() + sync["future_block"] = {"nested": {"deep": True}} + sync["assessment"]["future_field"] = 1 + sync["catalogue"]["future_field"] = [] + sync["results"]["future_field"] = None + + with fake_http(reply(200, meta), reply(200, who), reply(201, sync)): + result = self.client().push(self.document(), name="n", exit_strategy=1) + + self.assertEqual(result.status, "completed") + + def test_an_unknown_warning_code_is_shown_and_kept(self): + item = { + "code": "brand_new_warning", + "message": "Something we have not met yet.", + "details": {"anything": {"at": ["all"]}}, + } + result, _ = self.push( + [meta_reply(), whoami_reply(), created_reply(warnings=[item])] + ) + + self.assertEqual( + [i["code"] for i in result.warning_items], ["brand_new_warning"] + ) + self.assertTrue( + any("Something we have not met yet." in w for w in result.warnings) + ) + + def test_an_unknown_error_code_is_judged_by_its_status_and_flag(self): + for status, retryable, exit_code in ( + (422, False, codes.UPLOAD_REJECTED), + (403, False, codes.UPLOAD_AUTH), + (503, True, codes.UPLOAD_UNAVAILABLE), + (429, True, codes.UPLOAD_UNAVAILABLE), + (402, False, codes.UPLOAD_QUOTA), + ): + with self.subTest(status=status): + body = error_body( + "brand_new_error", retryable=retryable, future={"x": [1]} + ) + body["error"]["details"] = [{"code": "new_detail", "extra": 1}] + error, _ = self.failure( + [meta_reply(), whoami_reply(), reply(status, body)], + client=self.client(cache_path=self.tmp / str(status)), + ) + self.assertEqual(error.code, "brand_new_error") + self.assertEqual(error.exit_code, exit_code) + + def test_a_new_status_value_is_not_a_crash(self): + result, _ = self.push( + [meta_reply(), whoami_reply(), reply(201, sync_body(status="quarantined"))] + ) + self.assertEqual(result.status, "quarantined") + + def test_every_status_of_the_definition_has_an_outcome(self): + statuses = resolve({"$ref": "#/components/schemas/SyncStatus"})["enum"] + for status in statuses: + with self.subTest(status=status): + if status in PENDING_STATUSES: + accepted = reply( + 202, sync_body(status=status), **{"Retry-After": "0"} + ) + replies = [ + meta_reply(), + whoami_reply(), + accepted, + reply(200, sync_body()), + ] + result, _ = self.push( + replies, client=self.client(cache_path=self.tmp / status) + ) + self.assertEqual(result.status, "completed") + elif status in SUCCESS_STATUSES: + result, _ = self.push( + [ + meta_reply(), + whoami_reply(), + reply(201, sync_body(status=status)), + ], + client=self.client(cache_path=self.tmp / status), + ) + self.assertEqual(result.status, status) + else: + self.assertEqual(status, FAILED_STATUS) + failed = sync_body(status=status) + failed["error"] = { + "code": "internal_error", + "message": "It broke.", + "retryable": True, + } + error, _ = self.failure( + [meta_reply(), whoami_reply(), reply(201, failed)], + client=self.client(cache_path=self.tmp / status), + ) + self.assertEqual(error.exit_code, codes.UPLOAD_UNAVAILABLE) + + +class DefinitionGuardTests(unittest.TestCase): + def test_a_new_definition_version_needs_a_review_of_these_tests(self): + version = (DEFINITION / "VERSION").read_text(encoding="utf-8").strip() + self.assertEqual( + version, + REVIEWED_DEFINITION, + "the vendored definition changed: read what it adds, extend the " + "contract tests or the lists above, then update REVIEWED_DEFINITION", + ) + self.assertEqual(SPEC["info"]["version"], version) + + def test_the_cli_speaks_a_schema_version_of_the_definitions_major(self): + major, minor = SCHEMA_VERSION.split(".") + version = ( + (DEFINITION / "VERSION").read_text(encoding="utf-8").strip().split(".") + ) + self.assertEqual(major, version[0]) + self.assertLessEqual(int(minor), int(version[1])) + self.assertIn(SPEC["servers"][0]["url"].split("/")[-1], ("v" + major,)) + + def test_every_error_code_is_known_to_the_cli_or_listed_as_not_applicable(self): + listed = set(ERRORS["errors"]) + enum = set(resolve({"$ref": "#/components/schemas/ErrorCode"})["enum"]) + self.assertEqual(listed, enum) + self.assertLessEqual(set(NOT_APPLICABLE_ERRORS), listed) + for code in listed - set(NOT_APPLICABLE_ERRORS): + self.assertIn(code, ERROR_EXIT_CODES, f"teach the CLI {code}") + for code in NOT_APPLICABLE_ERRORS: + self.assertEqual(ERRORS["errors"][code].get("api"), "v1", code) + + def test_every_warning_code_has_a_route_in_these_tests(self): + enum = set(resolve({"$ref": "#/components/schemas/WarningCode"})["enum"]) + self.assertEqual(set(ERRORS["warnings"]), enum) + self.assertEqual(set(WARNING_ROUTES), enum) + + def test_every_operation_is_used_by_the_cli_or_listed_as_not_applicable(self): + operations = { + (method, template) + for template, found in SPEC["paths"].items() + for method in found + if method != "parameters" + } + self.assertEqual(operations, USED_OPERATIONS | set(NOT_APPLICABLE_OPERATIONS)) + + def test_every_example_and_what_it_expects_is_in_the_definition(self): + examples = base.load_examples() + seen = set() + for number, example in examples.items(): + request = example["request"] + path = re.sub(r"\{\{[^}]*\}\}", "x", request["path"]).split("?")[0] + template, _ = find_operation(request["method"], path[len(SERVER_PATH) :]) + self.assertIsNotNone(template, f"example {number}") + seen.add((request["method"].lower(), template)) + subset = example["expect"].get("body_subset") or {} + if subset.get("object") == "error": + self.assertIn(subset["error"]["code"], ERRORS["errors"], number) + for warning in subset.get("warnings", []): + self.assertIn(warning["code"], ERRORS["warnings"], number) + self.assertLessEqual(seen, USED_OPERATIONS | set(NOT_APPLICABLE_OPERATIONS)) + self.assertGreaterEqual(len(examples), 51) + + def test_the_examples_the_cli_does_not_act_on_are_all_named(self): + examples = base.load_examples() + named = ( + set(base.NEVER_SENT) + | set(base.REFUSED_LOCALLY) + | base.KEY_CHECKED_LOCALLY + | base.READS + | base.NOT_USED + ) + for number in named: + self.assertIn(number, examples) + for number in set(examples) - named: + self.assertEqual(examples[number]["request"]["method"], "POST", number) + + def test_the_enums_the_cli_maps_are_the_definitions(self): + def enum(name): + return set(resolve({"$ref": f"#/components/schemas/{name}"})["enum"]) + + self.assertEqual(set(PROVIDERS.values()), enum("Provider")) + self.assertEqual(set(EXIT_STRATEGIES.values()), enum("ExitStrategy")) + self.assertEqual( + set(PENDING_STATUSES) | set(SUCCESS_STATUSES) | {FAILED_STATUS}, + enum("SyncStatus"), + ) + + def test_the_vectors_have_a_test_or_a_reason_not_to(self): + listed = {path.name for path in (DEFINITION / "vectors").glob("*.json")} + self.assertEqual(listed, set(VECTORS)) + for name, test in VECTORS.items(): + if test: + self.assertIn(name, (ROOT / test).read_text(encoding="utf-8")) + + def test_the_cli_does_not_compute_the_content_hash(self): + for path in sorted((ROOT / "cloudexit").rglob("*.py")): + text = path.read_text(encoding="utf-8") + self.assertNotIn("content_sha256", text, path.name) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/platform/test_definition_copy.py b/tests/platform/test_definition_copy.py new file mode 100644 index 0000000..b661778 --- /dev/null +++ b/tests/platform/test_definition_copy.py @@ -0,0 +1,76 @@ +import hashlib +import shutil +import tempfile +import unittest +from pathlib import Path +from tests.support.paths import DEFINITION + +MANIFEST = "MANIFEST.sha256" + + +def manifest_problems(directory): + directory = Path(directory) + expected = {} + for line in (directory / MANIFEST).read_text(encoding="utf-8").splitlines(): + digest, _, name = line.partition(" ") + expected[name] = digest + + actual = { + path.relative_to(directory) + .as_posix(): hashlib.sha256(path.read_bytes()) + .hexdigest() + for path in directory.rglob("*") + if path.is_file() + and path.name != MANIFEST + and "__pycache__" not in path.parts + and path.suffix != ".pyc" + } + + problems = [] + for name in sorted(expected.keys() - actual.keys()): + problems.append(f"missing: {name}") + for name in sorted(actual.keys() - expected.keys()): + problems.append(f"not in the manifest: {name}") + for name in sorted(expected.keys() & actual.keys()): + if expected[name] != actual[name]: + problems.append(f"modified: {name}") + return problems + + +class DefinitionCopyTests(unittest.TestCase): + def test_the_version_file_is_a_semantic_version(self): + version = (DEFINITION / "VERSION").read_text(encoding="utf-8").strip() + major, minor, patch = version.split(".") + self.assertEqual(major, "2") + self.assertTrue(minor.isdigit() and patch.isdigit()) + + def test_every_file_matches_the_manifest(self): + self.assertEqual(manifest_problems(DEFINITION), []) + + def test_the_manifest_lists_the_version_file(self): + manifest = (DEFINITION / MANIFEST).read_text(encoding="utf-8") + self.assertIn(" VERSION\n", manifest) + + def test_a_modified_file_is_detected(self): + with tempfile.TemporaryDirectory() as tmp: + copy = Path(tmp) / "api-v2" + shutil.copytree(DEFINITION, copy) + (copy / "VERSION").write_text("2.9.9\n", encoding="utf-8") + + self.assertEqual(manifest_problems(copy), ["modified: VERSION"]) + + def test_a_missing_or_extra_file_is_detected(self): + with tempfile.TemporaryDirectory() as tmp: + copy = Path(tmp) / "api-v2" + shutil.copytree(DEFINITION, copy) + (copy / "errors.yaml").unlink() + (copy / "extra.json").write_text("{}", encoding="utf-8") + + self.assertEqual( + manifest_problems(copy), + ["missing: errors.yaml", "not in the manifest: extra.json"], + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/platform/test_document.py b/tests/platform/test_document.py new file mode 100644 index 0000000..016b86a --- /dev/null +++ b/tests/platform/test_document.py @@ -0,0 +1,1005 @@ +import copy +import hashlib +import hmac +import json +import os +import stat +import tempfile +import unittest +from argparse import Namespace +from decimal import Decimal +from pathlib import Path +from unittest.mock import patch + +from cloudexit.cli import app +from cloudexit.cli.parser import parse_arguments +from cloudexit.platform import document as platform_document +from cloudexit.platform.document import ( + SCHEMA_VERSION, + RunContext, + ScopeError, + SyncDocumentError, + build_sync_document, + decimal_amount, + new_uuid7, + prepare_sync_document, + salt_path, + scope_key, + validate_document, +) +from tests.support.isolation import isolated_environ +from tests.support.sync import ( + ACCESS_KEY, + CLIENT_SECRET, + COMPLETED_AT, + DEFINITION, + SECRET_KEY, + SECRETS, + SESSION_TOKEN, + STARTED_AT, + SYNC_ID, + build_run, + egress_row, + schema_errors, +) +from cloudexit import codes +from cloudexit.version import __version__ + +LOGGER = "cloudexit.platform.document" +AWS_DETAILS = { + "accessKey": ACCESS_KEY, + "secretKey": SECRET_KEY, + "sessionToken": SESSION_TOKEN, + "region": "eu-central-1", + "accountId": "123456789012", +} +AZURE_DETAILS = { + "tenantId": "11111111-1111-4111-8111-111111111111", + "subscriptionId": "22222222-2222-4222-8222-222222222222", + "resourceGroupName": "Prod-RG", + "clientId": "33333333-3333-4333-8333-333333333333", + "clientSecret": CLIENT_SECRET, + "credential": object(), +} +CLAIM = { + "version": "2026-11-02", + "sequence": 41, + "sha256": "c" * 64, +} + + +class SyncDocumentTestCase(unittest.TestCase): + def setUp(self): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.tmp = Path(tmp.name) + patcher = patch.dict(os.environ, isolated_environ(self.tmp)) + patcher.start() + self.addCleanup(patcher.stop) + + def report(self, name=None, **kwargs): + self.reports = getattr(self, "reports", 0) + 1 + return build_run(self.tmp / (name or f"report-{self.reports}"), **kwargs) + + def context(self, provider=2, details=None, **overrides): + values = dict( + sync_id=SYNC_ID, + started_at=STARTED_AT, + cloud_service_provider=provider, + assessment_type=2, + provider_details=details + or (AWS_DETAILS if provider == 2 else AZURE_DETAILS), + completed_at=COMPLETED_AT, + ) + values.update(overrides) + return RunContext(**values) + + def assert_valid(self, document): + self.assertEqual(schema_errors(document), []) + validate_document(document) + + +AWS_RESOURCES = [ + (1, "eu-central-1", 8), + (4, "eu-central-1", 2), + (2, "eu-central-1", 36), +] +AWS_COSTS = [("2026-09-01", 1234.5600000000002, "USD"), ("2026-10-01", 15.5, "USD")] + + +def resource(type_id, code, location, count): + return {"type_id": type_id, "type_code": code, "location": location, "count": count} + + +class GoldenDocumentTests(SyncDocumentTestCase): + def test_aws_api_run(self): + report = self.report(resources=AWS_RESOURCES, costs=AWS_COSTS) + + document = build_sync_document(report, self.context(2)) + + self.assertEqual( + document, + { + "schema_version": "2.0", + "sync": { + "id": SYNC_ID, + "started_at": STARTED_AT, + "completed_at": COMPLETED_AT, + "collector": { + "name": "cloudexit", + "version": __version__, + "mode": "api", + "catalogue": {**CLAIM, "resource_type_ids": [1, 4]}, + }, + }, + "assessment_type": 2, + "scope": { + "provider": "aws", + "aws": {"account_id": "123456789012", "regions": ["eu-central-1"]}, + }, + "inventory": { + "resources": [ + resource(1, "AWS.EC2.DescribeInstances", "eu-central-1", 8), + resource(2, "AWS.S3.ListBuckets", "eu-central-1", 36), + resource(4, "AWS.RDS.DescribeDBInstances", "eu-central-1", 2), + ], + "costs": [ + { + "month": "2026-09", + "amount": "1234.56", + "currency": "USD", + "final": True, + }, + { + "month": "2026-10", + "amount": "15.5", + "currency": "USD", + "final": False, + }, + ], + "egress": { + "collected": False, + "truncated": False, + "names": "plain", + "items": [], + }, + }, + }, + ) + self.assert_valid(document) + + def test_azure_api_run(self): + report = self.report( + resources=[(3, "westeurope", 5)], costs=[("2026-10-01", 7, "EUR")] + ) + + document = build_sync_document(report, self.context(1, assessment_type=1)) + + self.assertEqual( + document["scope"], + { + "provider": "azure", + "azure": { + "tenant_id": AZURE_DETAILS["tenantId"], + "subscription_id": AZURE_DETAILS["subscriptionId"], + "resource_group": "Prod-RG", + }, + }, + ) + self.assertEqual(document["assessment_type"], 1) + self.assertEqual( + document["sync"]["collector"]["catalogue"]["resource_type_ids"], [3] + ) + self.assertEqual( + document["inventory"]["resources"], + [resource(3, "Azure.Compute.VirtualMachines", "westeurope", 5)], + ) + self.assertEqual( + document["inventory"]["costs"], + [{"month": "2026-10", "amount": "7", "currency": "EUR", "final": False}], + ) + self.assert_valid(document) + + def test_azure_subscription_wide_scope_has_no_resource_group(self): + details = {**AZURE_DETAILS, "resourceGroupName": ""} + document = build_sync_document( + self.report(resources=[(3, "westeurope", 1)]), self.context(1, details) + ) + self.assertIsNone(document["scope"]["azure"]["resource_group"]) + self.assert_valid(document) + + def test_tfstate_run(self): + report = self.report( + resources=[(1, "eu-west-1", 3)], + tfstate_scope={ + "file": "infra.tfstate", + "lineage": "e1c2f0c0-0000-4000-8000-000000000000", + "serial": 7, + }, + ) + details = {"tfstatePath": "infra.tfstate"} + + document = build_sync_document( + report, self.context(2, details, assessment_type=1) + ) + + self.assertEqual(document["sync"]["collector"]["mode"], "tfstate") + self.assertEqual( + document["scope"], + { + "provider": "aws", + "tfstate": { + "lineage": "e1c2f0c0-0000-4000-8000-000000000000", + "serial": 7, + }, + }, + ) + self.assertEqual(document["inventory"]["costs"], []) + self.assert_valid(document) + + def test_a_tfstate_without_a_lineage_cannot_be_described(self): + report = self.report(tfstate_scope={"lineage": None, "serial": 1}) + with self.assertRaises(SyncDocumentError): + build_sync_document( + report, self.context(2, {"tfstatePath": "infra.tfstate"}) + ) + + def test_a_dataset_without_catalogue_meta_leaves_the_catalogue_out(self): + report = self.report(resources=AWS_RESOURCES, meta=False) + + document = build_sync_document(report, self.context(2)) + + self.assertNotIn("catalogue", document["sync"]["collector"]) + self.assert_valid(document) + + def test_a_missing_aws_account_id_is_an_error(self): + details = {k: v for k, v in AWS_DETAILS.items() if k != "accountId"} + with self.assertRaises(SyncDocumentError) as ctx: + build_sync_document(self.report(), self.context(2, details)) + self.assertIn("/aws/account_id", str(ctx.exception)) + + def test_assessment_type_follows_the_run_depth(self): + report = self.report(resources=AWS_RESOURCES) + for assessment_type, collected, expected in ( + (1, False, 1), + (2, False, 2), + (1, True, 3), + (2, True, 3), + ): + with self.subTest(assessment_type=assessment_type, collected=collected): + document = build_sync_document( + report, + self.context( + 2, + assessment_type=assessment_type, + egress_collected=collected, + ), + ) + self.assertEqual(document["assessment_type"], expected) + + def test_the_target_and_ci_are_left_out_unless_given(self): + document = build_sync_document(self.report(), self.context(2)) + self.assertNotIn("target", document) + self.assertNotIn("ci", document["sync"]) + + def test_the_collector_version_and_schema_version(self): + document = build_sync_document(self.report(), self.context(2)) + version = (DEFINITION / "VERSION").read_text(encoding="utf-8").strip() + major, minor, _ = version.split(".") + + self.assertEqual(SCHEMA_VERSION, f"{major}.{minor}") + self.assertEqual(document["schema_version"], SCHEMA_VERSION) + self.assertEqual(document["sync"]["collector"]["version"], __version__) + + +class EgressTests(SyncDocumentTestCase): + ROWS = [ + egress_row(2, "bucket-b", None, notes=["size unknown"]), + egress_row( + 1, + "web-1", + 5_000_000, + flags=["encrypted"], + notes=["n1"], + tiers=[("GLACIER", 2_000_000, 1), ("STANDARD", 3_000_000, 0)], + ), + ] + + def egress(self, rows=None, **overrides): + report = self.report( + resources=AWS_RESOURCES, egress=self.ROWS if rows is None else rows + ) + context = self.context(2, egress_collected=True, **overrides) + return build_sync_document(report, context) + + def test_the_egress_block_from_the_run_tables(self): + document = self.egress() + + self.assertEqual(document["assessment_type"], 3) + self.assertEqual( + document["inventory"]["egress"], + { + "collected": True, + "truncated": False, + "names": "plain", + "items": [ + { + "type_id": 1, + "name": "web-1", + "size_bytes": 5_000_000, + "flags": ["encrypted"], + "notes": ["n1"], + "tiers": [ + { + "tier": "STANDARD", + "size_bytes": 3_000_000, + "is_archive": False, + }, + { + "tier": "GLACIER", + "size_bytes": 2_000_000, + "is_archive": True, + }, + ], + }, + { + "type_id": 2, + "name": "bucket-b", + "size_bytes": None, + "flags": [], + "notes": ["size unknown"], + "tiers": [], + }, + ], + }, + ) + self.assert_valid(document) + + def test_egress_that_was_not_collected_is_not_sent(self): + report = self.report(resources=AWS_RESOURCES, egress=self.ROWS) + document = build_sync_document(report, self.context(2, egress_collected=False)) + + self.assertEqual( + document["inventory"]["egress"], + {"collected": False, "truncated": False, "names": "plain", "items": []}, + ) + self.assertNotIn("web-1", json.dumps(document)) + self.assert_valid(document) + + def test_collected_with_no_rows_is_still_collected(self): + document = self.egress(rows=[]) + egress = document["inventory"]["egress"] + self.assertTrue(egress["collected"]) + self.assertFalse(egress["truncated"]) + self.assertEqual(egress["items"], []) + + def test_too_many_items_keep_the_largest_and_set_truncated(self): + rows = [egress_row(1, f"secret-name-{n}", n + 1) for n in range(1005)] + with self.assertLogs(LOGGER, level="WARNING") as logs: + document = self.egress(rows=rows) + + egress = document["inventory"]["egress"] + self.assertTrue(egress["truncated"]) + self.assertEqual(len(egress["items"]), 1000) + self.assertEqual(egress["items"][0]["size_bytes"], 1005) + self.assertEqual(min(item["size_bytes"] for item in egress["items"]), 6) + self.assertEqual(len(logs.output), 1) + self.assertIn("5 items dropped", logs.output[0]) + self.assertNotIn("secret-name", logs.output[0]) + self.assert_valid(document) + + def test_long_strings_and_lists_are_trimmed_without_logging_names(self): + row = egress_row( + 1, + "n" * 300, + 2**60, + flags=[f"flag-{n}" + "f" * 80 for n in range(12)], + notes=[f"note-{n}" for n in range(11)], + tiers=[(f"tier-{n}" + "t" * 60, n, 0) for n in range(12)], + ) + with self.assertLogs(LOGGER, level="WARNING") as logs: + document = self.egress(rows=[row]) + + item = document["inventory"]["egress"]["items"][0] + self.assertTrue(document["inventory"]["egress"]["truncated"]) + self.assertEqual(len(item["name"]), 255) + self.assertEqual(item["size_bytes"], 2**53 - 1) + self.assertEqual(len(item["flags"]), 10) + self.assertTrue(all(len(flag) <= 64 for flag in item["flags"])) + self.assertEqual(len(item["notes"]), 10) + self.assertEqual(len(item["tiers"]), 10) + self.assertTrue(all(len(tier["tier"]) <= 50 for tier in item["tiers"])) + text = logs.output[0] + for label in ( + "names shortened", + "sizes capped", + "flags dropped", + "tiers dropped", + ): + self.assertIn(label, text) + self.assertNotIn("nnnn", text) + self.assert_valid(document) + + def test_untrimmed_egress_does_not_log_a_warning(self): + with self.assertNoLogs(LOGGER, level="WARNING"): + self.egress() + + +class HashedNamesTests(SyncDocumentTestCase): + ROWS = [egress_row(1, "web-1", 10), egress_row(2, "bucket-b", 5)] + + def document(self, details=None, **overrides): + report = self.report( + name=f"report-{len(list(self.tmp.iterdir()))}", + resources=AWS_RESOURCES, + egress=self.ROWS, + ) + context = self.context( + 2, details, egress_collected=True, egress_names="hashed", **overrides + ) + return build_sync_document(report, context) + + def names(self, document): + return { + item["type_id"]: item["name"] + for item in document["inventory"]["egress"]["items"] + } + + def test_names_are_salted_hashes_and_marked_hashed(self): + document = self.document() + + egress = document["inventory"]["egress"] + self.assertEqual(egress["names"], "hashed") + for name in self.names(document).values(): + self.assertRegex(name, r"^[0-9a-f]{64}$") + self.assertNotIn("web-1", json.dumps(document)) + self.assert_valid(document) + + def test_the_hash_is_an_hmac_with_the_stored_scope_salt(self): + document = self.document() + + salt_file = salt_path("aws:123456789012:eu-central-1") + salt = bytes.fromhex(salt_file.read_text().strip()) + expected = hmac.new(salt, b"web-1", hashlib.sha256).hexdigest() + self.assertEqual(self.names(document)[1], expected) + + @unittest.skipUnless(os.name == "posix", "POSIX permissions") + def test_the_salt_file_is_private(self): + self.document() + salt_file = salt_path("aws:123456789012:eu-central-1") + self.assertEqual(stat.S_IMODE(salt_file.stat().st_mode), 0o600) + + def test_hashes_are_stable_within_a_scope(self): + first = self.names(self.document()) + second = self.names(self.document()) + self.assertEqual(first, second) + + def test_hashes_differ_across_scopes(self): + first = self.names(self.document()) + other = {**AWS_DETAILS, "accountId": "210987654321"} + second = self.names(self.document(other)) + self.assertNotEqual(first[1], second[1]) + + def test_plain_names_are_unchanged_and_need_no_salt(self): + report = self.report(resources=AWS_RESOURCES, egress=self.ROWS) + document = build_sync_document(report, self.context(2, egress_collected=True)) + self.assertEqual( + {item["name"] for item in document["inventory"]["egress"]["items"]}, + {"web-1", "bucket-b"}, + ) + self.assertFalse((self.tmp / "data").exists()) + + +class ScopeKeyVectorTests(unittest.TestCase): + def test_the_published_vectors(self): + vectors = json.loads( + (DEFINITION / "vectors" / "scope_key.json").read_text(encoding="utf-8") + ) + self.assertGreater(len(vectors["cases"]), 20) + + for case in vectors["cases"]: + with self.subTest(case["name"]): + expect = case["expect"] + if "scope_key" in expect: + self.assertEqual(scope_key(case["scope"]), expect["scope_key"]) + else: + with self.assertRaises(ScopeError) as ctx: + scope_key(case["scope"]) + if "pointer" in expect["error"]: + self.assertEqual( + ctx.exception.pointer, expect["error"]["pointer"] + ) + + +class Uuid7Tests(unittest.TestCase): + def test_layout(self): + value = new_uuid7(now_ms=0x0193_9B8E_7C2A) + + self.assertRegex( + value, + r"^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$", + ) + self.assertTrue(value.startswith("01939b8e-7c2a-7")) + + def test_ids_sort_by_time(self): + ids = [new_uuid7(now_ms=1_700_000_000_000 + step * 1000) for step in range(50)] + self.assertEqual(ids, sorted(ids)) + self.assertEqual(len(set(ids)), 50) + + def test_the_default_id_is_a_version_7_uuid(self): + self.assertRegex(new_uuid7(), platform_document.UUID7_RE.pattern) + + def test_the_fallback_is_used_without_a_native_implementation(self): + with patch.object(platform_document.uuid, "uuid7", None, create=True): + self.assertRegex(new_uuid7(), platform_document.UUID7_RE.pattern) + + +class DecimalAmountTests(unittest.TestCase): + def test_amounts_are_exact_decimal_strings(self): + cases = { + 0.1 + 0.2: "0.3", + 1234.5600000000002: "1234.56", + 5: "5", + 0.0: "0", + -0.0000001: "0", + 1e-06: "0.000001", + 12.50: "12.5", + "12.50": "12.5", + Decimal("1E+3"): "1000", + -3.75: "-3.75", + 123456789.12: "123456789.12", + } + for value, expected in cases.items(): + with self.subTest(value=value): + self.assertEqual(decimal_amount(value), expected) + + def test_no_amount_looks_like_a_float(self): + for value in (1e-5, 1e15, 1 / 3, 99999999.99): + self.assertRegex(decimal_amount(value), r"^-?[0-9]+(\.[0-9]+)?$") + + def test_a_bad_amount_is_an_error(self): + for value in ("abc", float("nan"), float("inf"), 1e30): + with self.subTest(value=value), self.assertRaises(SyncDocumentError): + decimal_amount(value) + + +class InventoryTests(SyncDocumentTestCase): + def test_counts_above_the_item_limit_are_split_and_sum_up(self): + report = self.report(resources=[(1, "eu-central-1", 25000)]) + items = build_sync_document(report, self.context(2))["inventory"]["resources"] + + self.assertEqual([item["count"] for item in items], [10000, 10000, 5000]) + self.assertEqual({item["location"] for item in items}, {"eu-central-1"}) + + def test_a_zero_count_is_kept(self): + report = self.report(resources=[(1, "eu-central-1", 0)]) + items = build_sync_document(report, self.context(2))["inventory"]["resources"] + self.assertEqual([item["count"] for item in items], [0]) + + def test_more_than_twelve_months_keep_the_latest(self): + costs = [ + (f"2025-{month:02d}-01", float(month), "USD") for month in range(1, 13) + ] + costs += [("2026-01-01", 13.0, "USD"), ("2026-02-01", 14.0, "USD")] + report = self.report(costs=costs) + with self.assertLogs(LOGGER, level="WARNING") as logs: + items = build_sync_document(report, self.context(2))["inventory"]["costs"] + + self.assertEqual(len(items), 12) + self.assertEqual(items[0]["month"], "2025-03") + self.assertEqual(items[-1]["month"], "2026-02") + self.assertIn("2 month(s) dropped", logs.output[0]) + + def test_months_before_the_current_one_are_final(self): + costs = [("2026-08-01", 1.0, "USD"), ("2026-10-01", 2.0, "USD")] + items = build_sync_document(self.report(costs=costs), self.context(2))[ + "inventory" + ]["costs"] + self.assertEqual([item["final"] for item in items], [True, False]) + + def test_an_unreadable_run_database_is_a_sync_document_error(self): + with self.assertRaises(SyncDocumentError): + build_sync_document(str(self.tmp / "missing"), self.context(2)) + + +class NoCredentialsTests(SyncDocumentTestCase): + def test_no_secret_value_or_credential_key_is_in_any_document(self): + rows = [egress_row(1, "web-1", 10, notes=["ok"])] + for provider in (1, 2): + for collected in (False, True): + with self.subTest(provider=provider, collected=collected): + report = self.report( + name=f"r-{provider}-{collected}", + resources=[(3 if provider == 1 else 1, "x", 1)], + egress=rows, + ) + document = build_sync_document( + report, + self.context( + provider, egress_collected=collected, egress_names="hashed" + ), + ) + text = json.dumps(document) + + for secret in SECRETS: + self.assertNotIn(secret, text) + for key in ( + "accessKey", + "secretKey", + "sessionToken", + "clientSecret", + ): + self.assertNotIn(key, text) + self.assertNotIn("credential", text.lower()) + + def test_the_written_file_has_no_secrets_either(self): + report = self.report(resources=AWS_RESOURCES) + output = prepare_sync_document(report, self.context(2)) + text = output.path.read_text(encoding="utf-8") + for secret in SECRETS: + self.assertNotIn(secret, text) + + +class ValidationTests(SyncDocumentTestCase): + def valid(self): + report = self.report( + resources=AWS_RESOURCES, costs=AWS_COSTS, egress=EgressTests.ROWS + ) + return build_sync_document(report, self.context(2, egress_collected=True)) + + def mutations(self): + def set_path(path, value): + def apply(doc): + target = doc + for part in path[:-1]: + target = target[part] + target[path[-1]] = value + + return apply + + def remove(path): + def apply(doc): + target = doc + for part in path[:-1]: + target = target[part] + del target[path[-1]] + + return apply + + return { + "count over the limit": set_path( + ["inventory", "resources", 0, "count"], 10001 + ), + "type id zero": set_path(["inventory", "resources", 0, "type_id"], 0), + "float amount": set_path(["inventory", "costs", 0, "amount"], 1.5), + "scientific amount": set_path(["inventory", "costs", 0, "amount"], "1e3"), + "bad month": set_path(["inventory", "costs", 0, "month"], "2026-13"), + "bad currency": set_path(["inventory", "costs", 0, "currency"], "US"), + "missing sync id": remove(["sync", "id"]), + "sync id not v7": set_path( + ["sync", "id"], "01939b8e-7c2a-4d41-9a51-3c8f0a6e21d4" + ), + "empty regions": set_path(["scope", "aws", "regions"], []), + "bad account id": set_path(["scope", "aws", "account_id"], "123"), + "assessment type": set_path(["assessment_type"], 4), + "bad egress names": set_path(["inventory", "egress", "names"], "other"), + "egress flag too long": set_path( + ["inventory", "egress", "items", 0, "flags"], ["f" * 65] + ), + "egress size too large": set_path( + ["inventory", "egress", "items", 0, "size_bytes"], 2**53 + ), + "missing inventory": remove(["inventory"]), + "catalogue sha256": set_path( + ["sync", "collector", "catalogue", "sha256"], "ABC" + ), + } + + def test_the_valid_document_passes_both_checks(self): + self.assert_valid(self.valid()) + + def test_the_runtime_check_and_the_schema_reject_the_same_mistakes(self): + for name, mutate in self.mutations().items(): + with self.subTest(name): + document = copy.deepcopy(self.valid()) + mutate(document) + + self.assertNotEqual(schema_errors(document), [], "schema accepts it") + with self.assertRaises(SyncDocumentError): + validate_document(document) + + def test_rules_that_only_the_runtime_check_enforces(self): + # The host applies these; the JSON schema cannot express them. + for name, mutate in { + "completed before started": lambda doc: doc["sync"].update(completed_at=1), + "tfstate mode with an api scope": lambda doc: doc["sync"][ + "collector" + ].update(mode="tfstate"), + }.items(): + with self.subTest(name): + document = copy.deepcopy(self.valid()) + mutate(document) + with self.assertRaises(SyncDocumentError): + validate_document(document) + + def test_too_many_resource_items_are_rejected(self): + document = self.valid() + document["inventory"]["resources"] = [ + {"type_id": 1, "location": f"loc-{n}", "count": 1} for n in range(1001) + ] + self.assertNotEqual(schema_errors(document), []) + with self.assertRaises(SyncDocumentError): + validate_document(document) + + def test_a_document_over_the_body_limit_is_rejected(self): + document = self.valid() + document["inventory"]["egress"]["items"][0]["notes"] = ["x" * 255] * 10 + document["inventory"]["egress"]["items"] = [ + copy.deepcopy(document["inventory"]["egress"]["items"][0]) + for _ in range(500) + ] + with self.assertRaises(SyncDocumentError) as ctx: + validate_document(document) + self.assertIn("larger than", " ".join(ctx.exception.problems)) + + def test_problems_name_the_pointer(self): + document = self.valid() + document["inventory"]["resources"][0]["count"] = -1 + with self.assertRaises(SyncDocumentError) as ctx: + validate_document(document) + self.assertIn("/inventory/resources/0/count", ctx.exception.problems[0]) + + +class OutputTests(SyncDocumentTestCase): + def prepare(self, **kwargs): + report = self.report(resources=AWS_RESOURCES, costs=AWS_COSTS) + return report, prepare_sync_document(report, self.context(2), **kwargs) + + def test_the_document_is_written_next_to_the_report(self): + report, output = self.prepare() + + self.assertEqual(output.path, Path(report) / "raw_data" / "sync.json") + document = json.loads(output.path.read_text(encoding="utf-8")) + self.assertEqual(document["sync"]["id"], SYNC_ID) + self.assertEqual(schema_errors(document), []) + self.assertEqual(output.size, output.path.stat().st_size) + self.assertEqual( + output.sha256, hashlib.sha256(output.path.read_bytes()).hexdigest() + ) + self.assertIsNone(output.emitted) + + def test_emit_payload_writes_a_copy_named_after_the_sync_id(self): + out_dir = self.tmp / "out" / "nested" + report, output = self.prepare(emit_dir=str(out_dir)) + + self.assertEqual(output.emitted, out_dir / f"{SYNC_ID}.json") + self.assertEqual(output.emitted.read_bytes(), output.path.read_bytes()) + + def test_the_summary_line_has_counts_and_a_hash_but_no_names(self): + report = self.report( + resources=AWS_RESOURCES, + costs=AWS_COSTS, + egress=[egress_row(1, "very-secret-name", 10, notes=["secret note"])], + ) + with self.assertLogs(LOGGER, level="INFO") as logs: + output = prepare_sync_document( + report, self.context(2, egress_collected=True) + ) + + (line,) = logs.output + self.assertIn(SYNC_ID, line) + self.assertIn("3 resource item(s), 2 cost month(s)", line) + self.assertIn("egress collected=true truncated=false (1 item(s))", line) + self.assertIn(f"{output.size} bytes", line) + self.assertIn(output.sha256, line) + self.assertNotIn("very-secret-name", line) + self.assertNotIn("secret note", line) + self.assertEqual(output.summary, line.split(":", 2)[2]) + + def test_the_document_is_not_dumped_at_debug_level(self): + report = self.report( + resources=AWS_RESOURCES, + egress=[egress_row(1, "very-secret-name", 10)], + ) + with self.assertLogs(level="DEBUG") as logs: + prepare_sync_document(report, self.context(2, egress_collected=True)) + self.assertNotIn("very-secret-name", "\n".join(logs.output)) + + def test_nothing_is_written_when_the_document_is_invalid(self): + report = self.report(resources=AWS_RESOURCES) + details = {**AWS_DETAILS, "region": "not a region"} + with self.assertRaises(SyncDocumentError): + prepare_sync_document(report, self.context(2, details)) + self.assertFalse((Path(report) / "raw_data" / "sync.json").exists()) + + +class RunAssessmentTests(SyncDocumentTestCase): + CONFIG = { + "name": "Test Assessment", + "cloudServiceProvider": 2, + "exitStrategy": 1, + "assessmentType": 1, + "providerDetails": dict(AWS_DETAILS), + } + + def run_assessment(self, report, *, egress_ok=None, **kwargs): + raw_data = os.path.join(report, "raw_data") + patches = [ + patch("cloudexit.cli.app.validate_config"), + patch( + "cloudexit.cli.app.create_directory", return_value=(report, raw_data) + ), + patch("cloudexit.cli.app.verify_credentials", return_value=(True, "ok")), + patch( + "cloudexit.cli.app.test_permissions", + return_value=(True, True, True, "ok"), + ), + patch( + "cloudexit.cli.app.create_resource_inventory", + return_value={"success": True, "logs": ""}, + ), + patch( + "cloudexit.cli.app.create_cost_inventory", + return_value={"success": True, "logs": ""}, + ), + patch( + "cloudexit.cli.app.perform_risk_assessment", + return_value={"success": True, "logs": ""}, + ), + patch( + "cloudexit.cli.app.generate_report", + 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"), + patch("cloudexit.cli.app.console.print"), + ] + if egress_ok is not None: + patches += [ + patch( + "cloudexit.cli.app.estimate_egress", + return_value={"success": egress_ok, "logs": "x", "json_path": "p"}, + ), + ] + for item in patches: + item.start() + self.addCleanup(item.stop) + return app.run_assessment(copy.deepcopy(self.CONFIG), "aws", **kwargs) + + def test_every_run_writes_a_sync_document(self): + report = self.report(resources=AWS_RESOURCES, costs=AWS_COSTS) + + self.run_assessment(report) + + document = json.loads( + (Path(report) / "raw_data" / "sync.json").read_text(encoding="utf-8") + ) + self.assertRegex(document["sync"]["id"], platform_document.UUID7_RE.pattern) + self.assertEqual(document["assessment_type"], 1) + self.assertFalse(document["inventory"]["egress"]["collected"]) + self.assertEqual(schema_errors(document), []) + + def test_the_sync_id_is_created_at_collection_start(self): + report = self.report(resources=AWS_RESOURCES) + with patch("cloudexit.cli.app.new_uuid7", return_value=SYNC_ID) as mock_id: + self.run_assessment(report) + + mock_id.assert_called_once_with() + document = json.loads( + (Path(report) / "raw_data" / "sync.json").read_text(encoding="utf-8") + ) + self.assertEqual(document["sync"]["id"], SYNC_ID) + + def test_emit_payload_and_hashed_names_reach_the_document(self): + report = self.report(resources=AWS_RESOURCES, egress=EgressTests.ROWS) + out_dir = self.tmp / "emitted" + + self.run_assessment( + report, + egress=True, + egress_ok=True, + emit_payload=str(out_dir), + egress_names="hashed", + ) + + (emitted,) = out_dir.glob("*.json") + document = json.loads(emitted.read_text(encoding="utf-8")) + self.assertTrue(document["inventory"]["egress"]["collected"]) + self.assertEqual(document["inventory"]["egress"]["names"], "hashed") + self.assertEqual(document["assessment_type"], 3) + self.assertEqual( + emitted.read_bytes(), (Path(report) / "raw_data" / "sync.json").read_bytes() + ) + + def test_a_failed_egress_stage_is_not_collected(self): + report = self.report(resources=AWS_RESOURCES, egress=EgressTests.ROWS) + + with self.assertRaises(SystemExit) as ctx: + self.run_assessment(report, egress=True, egress_ok=False) + + self.assertEqual(ctx.exception.code, codes.EGRESS) + document = json.loads( + (Path(report) / "raw_data" / "sync.json").read_text(encoding="utf-8") + ) + self.assertFalse(document["inventory"]["egress"]["collected"]) + self.assertEqual(document["inventory"]["egress"]["items"], []) + + def test_a_problem_does_not_fail_a_plain_run(self): + report = str(self.tmp / "no-database") + os.makedirs(os.path.join(report, "raw_data")) + + self.run_assessment(report) + + self.assertFalse((Path(report) / "raw_data" / "sync.json").exists()) + statuses = [call.kwargs.get("status") for call in app.print_step.mock_calls] + self.assertIn("warning", statuses) + + def test_a_problem_fails_a_dry_run_and_an_emit_request(self): + for kwargs in ({"dry_run": True}, {"emit_payload": str(self.tmp / "out")}): + with self.subTest(kwargs=kwargs): + report = str(self.tmp / f"no-database-{len(kwargs)}{list(kwargs)[0]}") + os.makedirs(os.path.join(report, "raw_data")) + with self.assertRaises(SystemExit) as ctx: + self.run_assessment(report, **kwargs) + self.assertEqual(ctx.exception.code, codes.REPORT) + + +class ArgumentTests(unittest.TestCase): + def parse(self, *argv): + with patch("sys.argv", ["cloudexit", *argv]): + return parse_arguments() + + def test_defaults(self): + for provider in ("aws", "azure"): + args = self.parse(provider) + self.assertIsNone(args.emit_payload) + self.assertEqual(args.egress_names, "plain") + + def test_flags_on_aws_and_azure(self): + for provider in ("aws", "azure"): + args = self.parse( + provider, "--emit-payload", "out", "--egress-names", "hashed" + ) + self.assertEqual(args.emit_payload, "out") + self.assertEqual(args.egress_names, "hashed") + + def test_other_values_for_the_names_option_are_refused(self): + with ( + patch("sys.stderr"), + self.assertRaises(SystemExit), + ): + self.parse("aws", "--egress-names", "obfuscated") + + def test_handlers_pass_the_options_on(self): + env = { + "ESC_EXIT_STRATEGY": "1", + "ESC_ASSESSMENT_TYPE": "1", + "AWS_ACCESS_KEY_ID": "x", + } + args = Namespace( + config=None, + profile=None, + tfstate="infra.tfstate", + name=None, + non_interactive=True, + dry_run=False, + egress=False, + emit_payload="out", + egress_names="hashed", + ) + with ( + patch.dict(os.environ, env), + patch("cloudexit.cli.app.run_assessment") as mock_run, + patch("cloudexit.cli.app.console.print"), + ): + app.handle_aws(args) + + self.assertEqual(mock_run.call_args.kwargs["emit_payload"], "out") + self.assertEqual(mock_run.call_args.kwargs["egress_names"], "hashed") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/platform/test_retry.py b/tests/platform/test_retry.py new file mode 100644 index 0000000..bbf188d --- /dev/null +++ b/tests/platform/test_retry.py @@ -0,0 +1,402 @@ +import random +import unittest +from email.utils import formatdate + +import requests + +from cloudexit.platform.client import ( + BACKOFF_CAP, + MAX_ATTEMPTS, + ApiClient, + ApiError, + parse_retry_after, +) +from tests.support.api import ( + HOST, + KEY, + created_reply, + error_body, + meta_reply, + reply, + whoami_reply, +) +from tests.support.http import fake_http, make_response +from tests.platform.test_client import ClientTestCase +from cloudexit import codes + + +class Ceiling: + # A random source that always answers the top of the range + def uniform(self, low, high): + return high + + +def unavailable(status=503, code="service_unavailable", **headers): + return reply(status, error_body(code, "Try later.", True), **headers) + + +class RetryTestCase(ClientTestCase): + def retrying(self, **options): + options.setdefault("budget", 300.0) + options.setdefault("rng", random.Random(7)) + return self.client(**options) + + def posts(self, fake): + return [r for r in fake.requests if r.method == "POST"] + + +class BackoffTests(RetryTestCase): + def test_a_transient_failure_is_retried_until_it_goes_through(self): + replies = [ + meta_reply(), + whoami_reply(), + unavailable(), + unavailable(), + created_reply(), + ] + result, fake = self.push(replies, client=self.retrying()) + + self.assertEqual(result.status, "completed") + self.assertEqual(len(self.posts(fake)), 3) + self.assertEqual(len(self.slept), 2) + + def test_the_delays_are_jittered_within_one_two_four_seconds(self): + client = self.retrying(rng=random.Random(11)) + replies = [meta_reply(), whoami_reply(), *[unavailable()] * 3, created_reply()] + self.push(replies, client=client) + + expected = random.Random(11) + ceilings = [1, 2, 4] + self.assertEqual( + self.slept, [expected.uniform(0, ceiling) for ceiling in ceilings] + ) + for delay, ceiling in zip(self.slept, ceilings): + self.assertGreaterEqual(delay, 0) + self.assertLessEqual(delay, ceiling) + + def test_the_ceiling_doubles_from_one_second_and_stops_at_sixty(self): + client = self.retrying(rng=Ceiling()) + error = ApiError("x", "x", codes.UPLOAD_UNAVAILABLE, retryable=True) + + ceilings = [client._delay(error, attempt) for attempt in range(1, 10)] + + self.assertEqual(ceilings, [1, 2, 4, 8, 16, 32, 60, 60, 60]) + self.assertEqual(BACKOFF_CAP, 60) + + def test_full_jitter_can_be_anywhere_from_zero_to_the_ceiling(self): + client = self.retrying(rng=random.Random(3)) + error = ApiError("x", "x", codes.UPLOAD_UNAVAILABLE, retryable=True) + + delays = [client._delay(error, 5) for _ in range(200)] + + self.assertTrue(all(0 <= delay <= 16 for delay in delays)) + self.assertLess(min(delays), 2) + self.assertGreater(max(delays), 14) + + def test_at_most_six_attempts_are_made(self): + replies = [meta_reply(), whoami_reply(), *[unavailable()] * MAX_ATTEMPTS] + error, fake = self.failure(replies, client=self.retrying()) + + self.assertEqual(len(self.posts(fake)), 6) + self.assertEqual((error.attempts, error.exhausted), (6, True)) + self.assertEqual(error.exit_code, codes.UPLOAD_UNAVAILABLE) + self.assertEqual(len(self.slept), 5) + + def test_the_attempt_limit_can_be_lowered(self): + replies = [meta_reply(), whoami_reply(), unavailable(), unavailable()] + error, fake = self.failure(replies, client=self.retrying(max_attempts=2)) + + self.assertEqual(len(self.posts(fake)), 2) + self.assertEqual(error.attempts, 2) + + +class RetryableFailureTests(RetryTestCase): + def test_these_statuses_are_retried_even_without_an_error_body(self): + for status in (408, 425, 429, 500, 502, 503, 504): + with self.subTest(status=status): + self.slept.clear() + result, fake = self.push( + [ + meta_reply(), + whoami_reply(), + make_response(status), + created_reply(), + ], + client=self.retrying(), + ) + self.assertEqual(result.status, "completed") + self.assertEqual(len(self.posts(fake)), 2) + + def test_connection_errors_and_timeouts_are_retried(self): + replies = [ + meta_reply(), + whoami_reply(), + requests.ConnectionError("refused"), + requests.ConnectTimeout("slow"), + requests.ReadTimeout("slow"), + created_reply(), + ] + result, fake = self.push(replies, client=self.retrying()) + + self.assertEqual(result.status, "completed") + self.assertEqual(len(self.posts(fake)), 4) + + def test_an_error_the_platform_marks_retryable_is_retried(self): + for code, status in ( + ("request_in_progress", 409), + ("rate_limited", 429), + ("maintenance", 503), + ("internal_error", 500), + ): + with self.subTest(code=code): + result, _ = self.push( + [ + meta_reply(), + whoami_reply(), + reply(status, error_body(code, "Wait.", True)), + created_reply(), + ], + client=self.retrying(), + ) + self.assertEqual(result.status, "completed") + + def test_a_retry_that_reaches_a_final_error_stops(self): + replies = [ + meta_reply(), + whoami_reply(), + unavailable(), + reply(422, error_body("validation_failed", "No.", False)), + ] + error, fake = self.failure(replies, client=self.retrying()) + + self.assertEqual((error.code, error.exit_code), ("validation_failed", 10)) + self.assertEqual(len(self.posts(fake)), 2) + self.assertFalse(error.exhausted) + + +class FinalFailureTests(RetryTestCase): + def test_these_failures_are_final_and_cost_one_request(self): + cases = ( + (400, "invalid_json", 10), + (401, "invalid_api_key", 11), + (402, "insufficient_credits", 13), + (409, "target_mismatch", 14), + (409, "assessment_archived", 14), + (422, "validation_failed", 10), + (422, "idempotency_key_reused", 10), + (403, "insufficient_scope", 11), + (404, "assessment_not_found", 14), + (429, "quota_exceeded", 13), + ) + for status, code, exit_code in cases: + with self.subTest(code=code): + self.slept.clear() + error, fake = self.failure( + [ + meta_reply(), + whoami_reply(), + reply(status, error_body(code, "No.", False)), + ], + client=self.retrying(), + ) + self.assertEqual((error.code, error.exit_code), (code, exit_code)) + self.assertEqual(len(self.posts(fake)), 1) + self.assertEqual(self.slept, []) + self.assertFalse(error.retryable) + + def test_a_status_without_an_error_body_that_is_not_retryable_is_final(self): + for status in (400, 401, 402, 404, 409, 422): + with self.subTest(status=status): + error, fake = self.failure( + [meta_reply(), whoami_reply(), make_response(status)], + client=self.retrying(), + ) + self.assertEqual(len(self.posts(fake)), 1) + self.assertFalse(error.retryable) + + def test_a_tls_failure_is_not_retried(self): + error, fake = self.failure( + [meta_reply(), whoami_reply(), requests.exceptions.SSLError("bad")], + client=self.retrying(), + ) + self.assertEqual(len(self.posts(fake)), 1) + self.assertEqual(error.code, "tls_error") + + +class IdempotencyTests(RetryTestCase): + def test_every_attempt_carries_the_same_key_and_the_same_body(self): + replies = [ + meta_reply(), + whoami_reply(), + unavailable(), + unavailable(), + created_reply(), + ] + _, fake = self.push(replies, client=self.retrying()) + + posts = self.posts(fake) + self.assertEqual(len(posts), 3) + keys = {p.kwargs["headers"]["Idempotency-Key"] for p in posts} + bodies = {p.kwargs["data"] for p in posts} + self.assertEqual(len(keys), 1) + self.assertEqual(len(bodies), 1) + self.assertEqual(keys.pop(), self.document()["sync"]["id"]) + + def test_every_attempt_is_its_own_request_for_the_platform_logs(self): + replies = [meta_reply(), whoami_reply(), unavailable(), created_reply()] + _, fake = self.push(replies, client=self.retrying()) + + ids = [p.kwargs["headers"]["X-Request-ID"] for p in self.posts(fake)] + self.assertEqual(len(set(ids)), 2) + + def test_only_idempotent_requests_are_retried(self): + # Every POST carries the key, and the reads are safe to repeat + replies = [meta_reply(), whoami_reply(), unavailable(), created_reply()] + _, fake = self.push(replies, client=self.retrying()) + for request in fake.requests: + if request.method == "POST": + self.assertIn("Idempotency-Key", request.kwargs["headers"]) + else: + self.assertEqual(request.method, "GET") + + def test_a_read_before_the_push_is_retried_within_the_same_budget(self): + replies = [meta_reply(), unavailable(), whoami_reply(), created_reply()] + result, fake = self.push(replies, client=self.retrying()) + + self.assertEqual(result.status, "completed") + self.assertEqual( + [r.method for r in fake.requests], ["GET", "GET", "GET", "POST"] + ) + + +class RetryAfterTests(RetryTestCase): + def test_retry_after_in_seconds_is_the_wait(self): + replies = [ + meta_reply(), + whoami_reply(), + unavailable(**{"Retry-After": "7"}), + created_reply(), + ] + self.push(replies, client=self.retrying()) + self.assertEqual(self.slept, [7.0]) + + def test_retry_after_as_an_http_date_is_the_wait(self): + date = formatdate(self.now + 12, usegmt=True) + replies = [ + meta_reply(), + whoami_reply(), + unavailable(**{"Retry-After": date}), + created_reply(), + ] + self.push(replies, client=self.retrying()) + self.assertEqual(self.slept, [12.0]) + + def test_a_date_in_the_past_means_no_wait(self): + date = formatdate(self.now - 30, usegmt=True) + replies = [ + meta_reply(), + whoami_reply(), + unavailable(**{"Retry-After": date}), + created_reply(), + ] + self.push(replies, client=self.retrying()) + self.assertEqual(self.slept, [0.0]) + + def test_retry_after_replaces_the_backoff_even_when_it_is_long(self): + replies = [ + meta_reply(), + whoami_reply(), + unavailable(**{"Retry-After": "90"}), + created_reply(), + ] + self.push(replies, client=self.retrying(rng=Ceiling())) + self.assertEqual(self.slept, [90.0]) + + def test_a_retry_after_beyond_the_budget_gives_up_at_once(self): + replies = [ + meta_reply(), + whoami_reply(), + unavailable(**{"Retry-After": "120"}), + ] + error, fake = self.failure(replies, client=self.retrying(budget=60.0)) + + self.assertEqual(self.slept, []) + self.assertEqual(len(self.posts(fake)), 1) + self.assertTrue(error.exhausted) + self.assertEqual(error.retry_after, 120.0) + self.assertEqual(error.exit_code, codes.UPLOAD_UNAVAILABLE) + + def test_the_budget_runs_out_across_several_waits(self): + client = self.retrying(budget=5.0, rng=Ceiling()) + replies = [meta_reply(), whoami_reply(), *[unavailable()] * 3] + error, fake = self.failure(replies, client=client) + + # Waits of 1 and 2 seconds fit; the 4 second wait would not + self.assertEqual(self.slept, [1.0, 2.0]) + self.assertEqual(len(self.posts(fake)), 3) + self.assertTrue(error.exhausted) + + def test_an_unusable_retry_after_falls_back_to_the_backoff(self): + replies = [ + meta_reply(), + whoami_reply(), + unavailable(**{"Retry-After": "soon"}), + created_reply(), + ] + self.push(replies, client=self.retrying(rng=Ceiling())) + self.assertEqual(self.slept, [1.0]) + + def test_parse_retry_after(self): + now = 1_800_000_000.0 + self.assertEqual(parse_retry_after("5", now), 5.0) + self.assertEqual(parse_retry_after("0", now), 0.0) + self.assertEqual(parse_retry_after("2.5", now), 2.5) + self.assertEqual( + parse_retry_after(formatdate(now + 40, usegmt=True), now), 40.0 + ) + self.assertEqual(parse_retry_after(formatdate(now - 40, usegmt=True), now), 0.0) + for value in (None, "", " ", "soon", "-", "Thu, nonsense"): + self.assertIsNone(parse_retry_after(value, now)) + self.assertEqual(parse_retry_after("-5", now), 0.0) + + +class BudgetTests(RetryTestCase): + def test_without_a_budget_there_is_one_attempt(self): + error, fake = self.failure( + [meta_reply(), whoami_reply(), unavailable()], + client=self.client(budget=0.0), + ) + self.assertEqual(len(self.posts(fake)), 1) + self.assertTrue(error.exhausted) + + def test_the_client_takes_the_budget_from_the_settings(self): + from cloudexit.settings import Setting, Settings + + settings = Settings( + host=Setting(HOST, "test"), + api_key=Setting(KEY, "test"), + upload_budget=Setting(42.0, "test"), + ) + self.assertEqual(ApiClient.from_settings(settings, environ={}).budget, 42.0) + + def test_the_default_budget_is_five_minutes(self): + from cloudexit.settings import Setting, Settings + + settings = Settings(host=Setting(HOST, "test"), api_key=Setting(KEY, "test")) + self.assertEqual(ApiClient.from_settings(settings, environ={}).budget, 300.0) + + def test_two_pushes_do_not_share_one_budget(self): + client = self.retrying(budget=3.0, rng=Ceiling()) + for _ in range(2): + replies = [unavailable(), created_reply()] + with fake_http( + *([meta_reply(), whoami_reply()] if _ == 0 else [whoami_reply()]), + *replies, + ): + client.push(self.document(), name="n", exit_strategy=1) + + self.assertEqual(self.slept, [1.0, 1.0]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/platform/test_spool.py b/tests/platform/test_spool.py new file mode 100644 index 0000000..968a0cb --- /dev/null +++ b/tests/platform/test_spool.py @@ -0,0 +1,1458 @@ +import contextlib +import io +import json +import logging +import os +import stat +import subprocess +import sys +import tempfile +import time +import unittest +from pathlib import Path +from unittest.mock import patch + +from cloudexit.cli import app +from cloudexit.platform.client import ApiError +from cloudexit.platform.spool import ( + AUTO_FLUSH_BUDGET, + FlushResult, + Spool, + flush, + format_age, + host_id, + key_fingerprint, + parse_age, + spool_root, + state_dir, +) +from cloudexit.platform.upload import UploadResult, read_sync_files, upload +from tests.support.isolation import isolated_environ +from tests.support.api import ( + EU_KEY, + HOST, + KEY, + created_reply, + error_body, + meta_body, + meta_reply, + minimal_document, + reply, + whoami_reply, +) +from tests.support.http import fake_http +from cloudexit import codes +from cloudexit.settings import Setting, Settings +from tests.support.paths import ROOT + +NOW = 1_800_000_000.0 +OLD, MIDDLE, NEW = (f"01933b9e-7c2a-7d41-9a51-3c8f0a6e000{n}" for n in (1, 2, 3)) +DAY = 86400 +POSIX = os.name != "nt" + + +class Ceiling: + def uniform(self, low, high): + return high + + +def unavailable(): + return reply(503, error_body("service_unavailable", "Later.", True)) + + +def refused(status=422, code="validation_failed"): + return reply(status, error_body(code, "No.", False)) + + +class SpoolCase(unittest.TestCase): + def setUp(self): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.tmp = Path(tmp.name) + self.now = NOW + self.mono = [0.0] + self.slept = [] + self.spool = Spool(self.tmp / "spool", clock=lambda: self.now) + + def sleep(self, seconds): + self.slept.append(seconds) + self.mono[0] += seconds + + def settings(self, **fields): + values = { + "host": Setting(HOST, "test"), + "api_key": Setting(KEY, "test"), + "spool_dir": Setting(str(self.tmp / "spool"), "test"), + "upload_budget": Setting(0.0, "test"), + } + values.update(fields) + return Settings(**values) + + def options(self, **more): + options = { + "clock": lambda: self.now, + "monotonic": lambda: self.mono[0], + "sleep": self.sleep, + "cache_path": self.tmp / "cache", + "environ": {}, + "rng": Ceiling(), + } + options.update(more) + return options + + def document(self, sync_id=OLD, age_days=0): + return minimal_document(sync_id, int(self.now - age_days * DAY - 60)) + + def add(self, sync_id=OLD, *, created=None, age_days=0, error=None, **meta): + if created is not None: + self.now = created + entry = self.spool.add( + HOST, + self.document(sync_id, age_days), + error=error, + attempts=meta.pop("attempts", 1), + name=meta.pop("name", "Spooled"), + exit_strategy=meta.pop("exit_strategy", 1), + assessment_id=meta.pop("assessment_id", None), + key_id=meta.pop("key_id", key_fingerprint(KEY)), + ) + self.now = NOW + return entry + + +class HostIdTests(unittest.TestCase): + def test_equivalent_spellings_of_a_host_are_one_id(self): + ids = { + host_id(host) + for host in ( + "https://platform.example.com", + "platform.example.com", + "HTTPS://Platform.Example.com/", + "https://platform.example.com:443", + ) + } + self.assertEqual(len(ids), 1) + + def test_different_hosts_and_ports_are_different_ids(self): + ids = { + host_id(host) + for host in ( + "https://a.example.com", + "https://b.example.com", + "http://localhost:8000", + "http://localhost:8001", + ) + } + self.assertEqual(len(ids), 4) + + def test_the_id_is_short_hex_and_does_not_contain_the_host(self): + value = host_id("https://platform.example.com") + self.assertRegex(value, r"^[0-9a-f]{16}$") + self.assertNotIn("example", value) + + +class RootTests(unittest.TestCase): + @unittest.skipIf(os.name == "nt", "absolute /xdg paths are POSIX paths") + def test_the_state_directory_follows_the_platform_conventions(self): + self.assertEqual( + state_dir({"XDG_STATE_HOME": "/xdg"}, platform="linux"), + Path("/xdg/cloudexit"), + ) + self.assertEqual( + state_dir({"XDG_STATE_HOME": "relative"}, platform="linux"), + Path.home() / ".local" / "state" / "cloudexit", + ) + self.assertEqual( + state_dir({"LOCALAPPDATA": "C:/L"}, platform="win32"), + Path("C:/L") / "cloudexit" / "state", + ) + + @unittest.skipIf(os.name == "nt", "absolute /xdg paths are POSIX paths") + def test_the_spool_lives_under_the_state_directory_unless_set(self): + with patch.dict(os.environ, {"XDG_STATE_HOME": "/xdg"}): + self.assertEqual(spool_root(None), Path("/xdg/cloudexit/spool")) + settings = Settings(spool_dir=Setting("/elsewhere", "test")) + self.assertEqual(spool_root(settings), Path("/elsewhere")) + + +class AgeTests(unittest.TestCase): + def test_ages(self): + self.assertEqual(parse_age("30d"), 30 * DAY) + self.assertEqual(parse_age("12h"), 12 * 3600) + self.assertEqual(parse_age("90m"), 5400) + self.assertEqual(parse_age("45s"), 45) + self.assertEqual(parse_age(" 5D "), 5 * DAY) + + def test_something_that_is_not_an_age_is_refused(self): + for text in ("30", "d", "-3d", "1w", "", "soon"): + with self.subTest(text=text): + with self.assertRaises(ValueError): + parse_age(text) + + def test_ages_are_shown_in_their_largest_unit(self): + self.assertEqual( + [format_age(s) for s in (5, 90, 7200, 3 * DAY, -4)], + ["5s", "1m", "2h", "3d", "0s"], + ) + + +class SpoolFilesTests(SpoolCase): + def test_a_sync_is_stored_under_the_host_id_by_its_sync_id(self): + entry = self.add() + + directory = self.spool.root / host_id(HOST) + self.assertEqual(entry.document_path, directory / f"{OLD}.json") + self.assertEqual(entry.meta_path, directory / f"{OLD}.meta.json") + self.assertTrue(entry.document_path.is_file()) + self.assertTrue(entry.meta_path.is_file()) + + @unittest.skipUnless(POSIX, "file modes") + def test_files_are_for_the_owner_only(self): + entry = self.add() + + for path in (entry.document_path, entry.meta_path): + self.assertEqual(stat.S_IMODE(path.stat().st_mode), 0o600) + for path in (self.spool.root, entry.document_path.parent): + self.assertEqual(stat.S_IMODE(path.stat().st_mode), 0o700) + + def test_the_host_name_is_not_in_any_path(self): + entry = self.add() + self.assertNotIn("platform", str(entry.document_path)) + self.assertNotIn("test", entry.document_path.parent.name) + + def test_the_document_is_stored_as_it_is_and_nothing_else(self): + document = self.document() + entry = self.add() + + stored = json.loads(entry.document_path.read_text(encoding="utf-8")) + self.assertEqual(stored, document) + + def test_the_meta_holds_the_attempts_and_what_a_later_upload_needs(self): + error = ApiError("maintenance", "m", 12, request_id="req-1", retryable=True) + entry = self.add( + error=error, attempts=3, name="Prod", exit_strategy=3, assessment_id="a" + ) + + meta = json.loads(entry.meta_path.read_text(encoding="utf-8")) + self.assertEqual( + meta, + { + "sync_id": OLD, + "host_id": host_id(HOST), + "created": int(NOW), + "first_attempt": int(NOW), + "last_attempt": int(NOW), + "attempts": 3, + "last_error": "maintenance", + "request_id": "req-1", + "name": "Prod", + "exit_strategy": 3, + "assessment_id": "a", + "key_id": key_fingerprint(KEY), + }, + ) + + def test_adding_again_adds_the_attempts_and_keeps_the_first_times(self): + self.add(attempts=2, created=NOW - 100) + self.now = NOW + later = ApiError("timeout", "t", 12, request_id="req-9", retryable=True) + entry = self.add(error=later, attempts=4) + + self.assertEqual(entry.meta["attempts"], 6) + self.assertEqual(entry.meta["created"], int(NOW - 100)) + self.assertEqual(entry.meta["first_attempt"], int(NOW - 100)) + self.assertEqual(entry.meta["last_attempt"], int(NOW)) + self.assertEqual(entry.meta["last_error"], "timeout") + + def test_the_key_never_reaches_the_files(self): + error = ApiError("invalid_api_key", f"bad {KEY}", 11, request_id="r") + self.add(error=error) + + for path in self.spool.root.rglob("*"): + if path.is_file(): + data = path.read_bytes() + self.assertNotIn(KEY.encode(), data) + self.assertNotIn(KEY[8:].encode(), data) + self.assertNotIn(b"Bearer", data) + self.assertNotIn(b"Authorization", data) + + def test_a_sync_id_that_could_leave_the_directory_is_refused(self): + for sync_id in ("../escape", "a/b", "", None, "x"): + with self.subTest(sync_id=sync_id): + document = {"sync": {"id": sync_id}} + with self.assertRaises(ValueError): + self.spool.add( + HOST, + document, + error=None, + attempts=1, + name=None, + exit_strategy=None, + assessment_id=None, + ) + self.assertFalse(list(self.tmp.rglob("escape*"))) + + def test_no_temporary_file_is_left_behind(self): + self.add() + names = [p.name for p in self.spool.root.rglob("*") if p.is_file()] + self.assertEqual(sorted(names), [f"{OLD}.json", f"{OLD}.meta.json"]) + + +class EntriesTests(SpoolCase): + def test_entries_come_oldest_first(self): + self.add(NEW, created=NOW - 10) + self.add(OLD, created=NOW - 300) + self.add(MIDDLE, created=NOW - 100) + + self.assertEqual([e.sync_id for e in self.spool.entries()], [OLD, MIDDLE, NEW]) + + def test_entries_can_be_limited_to_one_host(self): + self.add(OLD) + self.spool.add( + "https://other.example.com", + self.document(NEW), + error=None, + attempts=1, + name=None, + exit_strategy=None, + assessment_id=None, + ) + + self.assertEqual(len(self.spool.entries()), 2) + self.assertEqual([e.sync_id for e in self.spool.entries(host_id(HOST))], [OLD]) + + def test_a_document_whose_meta_was_lost_is_still_listed(self): + entry = self.add() + entry.meta_path.unlink() + + (found,) = self.spool.entries() + self.assertEqual(found.sync_id, OLD) + self.assertEqual(found.meta["sync_id"], OLD) + + def test_other_files_in_the_spool_are_ignored(self): + entry = self.add() + (entry.document_path.parent / "notes.txt").write_text("x") + (entry.document_path.parent / ".hidden.json").write_text("{}") + + self.assertEqual([e.sync_id for e in self.spool.entries()], [OLD]) + + def test_an_empty_or_missing_spool_has_no_entries(self): + self.assertEqual(self.spool.entries(), []) + self.assertEqual(Spool(self.tmp / "nowhere").entries(), []) + + def test_remove_deletes_both_files_and_the_empty_host_directory(self): + entry = self.add() + self.spool.remove(entry) + + self.assertFalse(entry.document_path.exists()) + self.assertFalse(entry.meta_path.exists()) + self.assertFalse(entry.document_path.parent.exists()) + + def test_purge_by_age_keeps_the_newer_entries(self): + self.add(OLD, created=NOW - 40 * DAY) + self.add(MIDDLE, created=NOW - 10 * DAY) + self.add(NEW, created=NOW - 1 * DAY) + + removed = self.spool.purge(older_than=30 * DAY) + + self.assertEqual([e.sync_id for e in removed], [OLD]) + self.assertEqual([e.sync_id for e in self.spool.entries()], [MIDDLE, NEW]) + + def test_purge_without_an_age_removes_everything_of_the_host(self): + self.add(OLD) + self.add(MIDDLE) + self.spool.add( + "https://other.example.com", + self.document(NEW), + error=None, + attempts=1, + name=None, + exit_strategy=None, + assessment_id=None, + ) + + self.spool.purge(host=host_id(HOST)) + + self.assertEqual([e.sync_id for e in self.spool.entries()], [NEW]) + + +class UploadSpoolTests(SpoolCase): + def run_upload(self, replies, *, document=None, client=None, **kwargs): + options = self.options(budget=5.0, **(client or {})) + with fake_http(*replies) as fake: + result = upload( + document or self.document(), + kwargs.pop("settings", self.settings()), + name="Spooled", + meta={"exit_strategy": 1}, + spool=self.spool, + **kwargs, + **options, + ) + return result, fake + + def test_a_budget_that_runs_out_on_retryable_failures_spools_the_sync(self): + replies = [meta_reply(), whoami_reply(), *[unavailable()] * 3] + result, fake = self.run_upload(replies) + + self.assertTrue(result.failed) + self.assertTrue(result.spooled) + self.assertEqual(result.exit_code, codes.UPLOAD_UNAVAILABLE) + (entry,) = self.spool.entries() + self.assertEqual(entry.sync_id, OLD) + self.assertEqual(entry.meta["attempts"], 3) + self.assertEqual(entry.meta["last_error"], "service_unavailable") + self.assertEqual(entry.meta["name"], "Spooled") + self.assertEqual(self.spool.read(entry), self.document()) + + def test_a_platform_that_cannot_be_reached_spools_too(self): + import requests + + result, _ = self.run_upload( + [ + meta_reply(), + whoami_reply(), + *[requests.ConnectionError("refused")] * 3, + ], + settings=self.settings(), + ) + self.assertTrue(result.spooled) + self.assertEqual( + self.spool.entries()[0].meta["last_error"], "connection_failed" + ) + + def test_a_final_failure_is_not_spooled_by_default(self): + for status, code in ( + (400, "invalid_json"), + (401, "invalid_api_key"), + (402, "insufficient_credits"), + (409, "target_mismatch"), + (422, "validation_failed"), + ): + with self.subTest(code=code): + result, _ = self.run_upload( + [meta_reply(), whoami_reply(), refused(status, code)], + client={"cache_path": self.tmp / f"cache-{code}"}, + ) + self.assertFalse(result.spooled) + self.assertEqual(self.spool.entries(), []) + self.assertEqual(result.error_code, code) + + def test_spool_on_error_keeps_a_final_failure_for_a_manual_retry(self): + result, _ = self.run_upload( + [meta_reply(), whoami_reply(), refused()], spool_on_error=True + ) + + self.assertTrue(result.spooled) + self.assertEqual(result.exit_code, codes.UPLOAD_REJECTED) + (entry,) = self.spool.entries() + self.assertEqual(entry.meta["last_error"], "validation_failed") + + def test_a_bad_key_is_not_spooled_unless_asked(self): + from tests.support.api import corrupt + + settings = self.settings(api_key=Setting(corrupt(KEY), "test")) + result, fake = self.run_upload([], settings=settings) + self.assertFalse(result.spooled) + self.assertEqual(fake.requests, []) + + result, _ = self.run_upload([], settings=settings, spool_on_error=True) + self.assertTrue(result.spooled) + + def test_a_sync_that_gets_through_removes_its_spooled_copy(self): + self.add(OLD) + result, _ = self.run_upload([meta_reply(), whoami_reply(), created_reply()]) + + self.assertTrue(result.success) + self.assertEqual(self.spool.entries(), []) + + def test_a_spool_that_cannot_be_written_does_not_crash_the_run(self): + blocker = self.tmp / "blocker" + blocker.write_text("a file") + self.spool = Spool(blocker / "spool") + with self.assertLogs("cloudexit.platform.upload", level="WARNING") as logs: + result, _ = self.run_upload( + [meta_reply(), whoami_reply(), *[unavailable()] * 3] + ) + + self.assertTrue(result.failed) + self.assertFalse(result.spooled) + self.assertTrue(any("could not be spooled" in line for line in logs.output)) + + def test_the_key_is_in_no_spool_file_and_no_log(self): + with self.assertLogs(level=logging.DEBUG) as logs: + self.run_upload([meta_reply(), whoami_reply(), *[unavailable()] * 3]) + + text = "\n".join(logs.output) + for secret in (KEY, KEY[8:], "Bearer"): + self.assertNotIn(secret, text) + for path in self.spool.root.rglob("*"): + if path.is_file(): + self.assertNotIn(KEY.encode(), path.read_bytes()) + + def test_a_missing_document_is_not_spooled(self): + result = upload(None, self.settings(), name="x", meta={}, spool=self.spool) + self.assertFalse(result.spooled) + self.assertEqual(self.spool.entries(), []) + + +class FlushTests(SpoolCase): + def run_flush(self, replies, **kwargs): + kwargs.setdefault("host", HOST) + settings = self.settings(upload_budget=Setting(5.0, "test")) + options = self.options( + **{"cache_path": kwargs.pop("cache_path", self.tmp / "cache")} + ) + with fake_http(*replies) as fake: + result = flush(settings, self.spool, **kwargs, **options) + return result, fake + + def keys(self, fake): + return [ + r.kwargs["headers"]["Idempotency-Key"] + for r in fake.requests + if r.method == "POST" + ] + + def test_the_oldest_sync_goes_first_and_delivered_ones_are_removed(self): + self.add(NEW, created=NOW - 10) + self.add(OLD, created=NOW - 300) + self.add(MIDDLE, created=NOW - 100) + + result, fake = self.run_flush( + [ + meta_reply(), + whoami_reply(), + created_reply(), + whoami_reply(), + created_reply(), + whoami_reply(), + created_reply(), + ] + ) + + self.assertEqual(self.keys(fake), [OLD, MIDDLE, NEW]) + self.assertEqual(result.uploaded, [OLD, MIDDLE, NEW]) + self.assertEqual(self.spool.entries(), []) + + def test_nothing_spooled_means_no_request(self): + result, fake = self.run_flush([]) + self.assertTrue(result.empty) + self.assertEqual(fake.requests, []) + + def flush_with(self, key, replies=()): + settings = self.settings( + api_key=Setting(key, "test"), upload_budget=Setting(5.0, "test") + ) + with fake_http(*replies) as fake: + result = flush(settings, self.spool, **self.options()) + return result, fake + + def test_a_sync_is_not_delivered_with_another_key_and_stays(self): + self.add(OLD) + + result, fake = self.flush_with(EU_KEY) + + self.assertEqual(fake.requests, []) + self.assertEqual(result.uploaded, []) + self.assertEqual(result.other_key, [OLD]) + self.assertFalse(result.empty) + self.assertEqual([e.sync_id for e in self.spool.entries()], [OLD]) + + def test_a_sync_is_delivered_once_with_the_key_that_spooled_it(self): + self.add(OLD) + self.flush_with(EU_KEY) + + result, fake = self.flush_with( + KEY, [meta_reply(), whoami_reply(), created_reply()] + ) + again, second = self.flush_with(KEY) + + self.assertEqual(result.uploaded, [OLD]) + self.assertEqual(result.other_key, []) + self.assertEqual(self.spool.entries(), []) + self.assertEqual(second.requests, []) + self.assertTrue(again.empty) + + def test_only_the_entries_of_the_key_in_use_are_delivered(self): + self.add(OLD, created=NOW - 300, key_id=key_fingerprint(EU_KEY)) + self.add(NEW, created=NOW - 10) + + result, fake = self.flush_with( + KEY, [meta_reply(), whoami_reply(), created_reply()] + ) + + self.assertEqual(self.keys(fake), [NEW]) + self.assertEqual(result.uploaded, [NEW]) + self.assertEqual(result.other_key, [OLD]) + self.assertEqual([e.sync_id for e in self.spool.entries()], [OLD]) + + def test_an_entry_without_a_recorded_key_is_never_delivered(self): + entry = self.add(OLD) + meta = json.loads(entry.meta_path.read_text(encoding="utf-8")) + del meta["key_id"] + entry.meta_path.write_text(json.dumps(meta), encoding="utf-8") + lost = self.add(NEW) + lost.meta_path.unlink() + + result, fake = self.flush_with(KEY) + + self.assertEqual(fake.requests, []) + self.assertEqual(sorted(result.other_key), sorted([OLD, NEW])) + self.assertEqual(len(self.spool.entries()), 2) + + def test_the_spool_never_holds_a_key(self): + self.add(OLD) + self.add(NEW, key_id=key_fingerprint(EU_KEY)) + + text = "".join( + path.read_text(encoding="utf-8") + for path in (self.tmp / "spool").rglob("*") + if path.is_file() + ) + + self.assertTrue(text) + for key in (KEY, EU_KEY): + self.assertNotIn(key, text) + self.assertNotIn(key.split("_sk_")[-1], text) + self.assertEqual(len(key_fingerprint(KEY)), 16) + self.assertNotEqual(key_fingerprint(KEY), key_fingerprint(EU_KEY)) + self.assertIsNone(key_fingerprint(None)) + self.assertIsNone(key_fingerprint("")) + + def test_a_failed_upload_spools_with_the_fingerprint_of_its_key(self): + settings = self.settings(upload_budget=Setting(0.0, "test")) + document = self.document(OLD) + + with fake_http(meta_reply(), whoami_reply(), unavailable()): + result = upload( + document, + settings, + name="Prod", + meta={"exit_strategy": 1}, + spool=self.spool, + **self.options(), + ) + + self.assertTrue(result.spooled) + entries = self.spool.entries(host_id(HOST)) + self.assertEqual([e.key_id for e in entries], [key_fingerprint(KEY)]) + + def test_entries_of_another_host_are_left_alone(self): + self.spool.add( + "https://other.example.com", + self.document(NEW), + error=None, + attempts=1, + name=None, + exit_strategy=None, + assessment_id=None, + ) + result, fake = self.run_flush([]) + + self.assertTrue(result.empty) + self.assertEqual(len(self.spool.entries()), 1) + + def test_a_platform_that_stays_down_keeps_the_rest_waiting(self): + self.add(OLD, created=NOW - 300) + self.add(MIDDLE, created=NOW - 100) + self.add(NEW, created=NOW - 10) + + replies = [ + meta_reply(), + whoami_reply(), + created_reply(), + whoami_reply(), + *[unavailable()] * 3, + ] + result, fake = self.run_flush(replies) + + self.assertEqual(result.uploaded, [OLD]) + self.assertEqual(result.pending, [MIDDLE, NEW]) + self.assertEqual(result.stopped, "service_unavailable") + self.assertEqual(self.keys(fake), [OLD, MIDDLE, MIDDLE, MIDDLE]) + kept = {e.sync_id: e for e in self.spool.entries()} + self.assertEqual(sorted(kept), [MIDDLE, NEW]) + self.assertEqual(kept[MIDDLE].meta["attempts"], 1 + 3) + self.assertEqual(kept[MIDDLE].meta["last_error"], "service_unavailable") + self.assertEqual(kept[NEW].meta["attempts"], 1) + + def test_a_refused_sync_stays_and_the_next_one_is_still_tried(self): + self.add(OLD, created=NOW - 300) + self.add(NEW, created=NOW - 10) + + replies = [ + meta_reply(), + whoami_reply(), + refused(422, "validation_failed"), + whoami_reply(), + created_reply(), + ] + result, fake = self.run_flush(replies) + + self.assertEqual(result.failed, {OLD: "validation_failed"}) + self.assertEqual(result.uploaded, [NEW]) + self.assertEqual(self.keys(fake), [OLD, NEW]) + (kept,) = self.spool.entries() + self.assertEqual(kept.sync_id, OLD) + self.assertEqual(kept.meta["last_error"], "validation_failed") + + def test_a_failure_every_entry_would_share_stops_the_flush(self): + for status, code in ((401, "api_key_revoked"), (402, "insufficient_credits")): + with self.subTest(code=code): + self.spool.purge() + self.add(OLD, created=NOW - 300) + self.add(NEW, created=NOW - 10) + + result, fake = self.run_flush( + [meta_reply(), whoami_reply(), refused(status, code)], + cache_path=self.tmp / f"cache-{code}", + ) + + self.assertEqual(result.failed, {OLD: code}) + self.assertEqual(result.pending, [NEW]) + self.assertEqual(result.stopped, code) + self.assertEqual(self.keys(fake), [OLD]) + + def test_a_sync_older_than_the_platform_accepts_is_reported_and_kept(self): + self.add(OLD, created=NOW - 300, age_days=11) + self.add(NEW, created=NOW - 10, age_days=5) + + meta = meta_reply(limits={"max_collection_age_days": 10}) + result, fake = self.run_flush([meta, whoami_reply(), created_reply()]) + + self.assertEqual(result.rejected, [OLD]) + self.assertEqual(result.uploaded, [NEW]) + self.assertEqual(self.keys(fake), [NEW]) + self.assertEqual([e.sync_id for e in self.spool.entries()], [OLD]) + + def test_the_default_age_limit_applies_without_a_platform_answer(self): + self.add(OLD, age_days=31) + meta = meta_body() + del meta["limits"]["max_collection_age_days"] + + result, fake = self.run_flush([reply(200, meta)]) + + self.assertEqual(result.rejected, [OLD]) + self.assertEqual(self.keys(fake), []) + + def test_the_whole_flush_has_one_budget(self): + self.add(OLD, created=NOW - 300) + self.add(NEW, created=NOW - 10) + + def spent(entry, pushed): + self.mono[0] += 20.0 + + result, fake = self.run_flush( + [meta_reply(), whoami_reply(), created_reply()], + budget=AUTO_FLUSH_BUDGET - 45, + on_delivered=spent, + ) + + self.assertEqual(result.uploaded, [OLD]) + self.assertEqual(result.pending, [NEW]) + self.assertEqual(result.stopped, "budget") + self.assertEqual(self.keys(fake), [OLD]) + + def test_an_entry_is_sent_with_what_was_stored_about_its_target(self): + self.add(OLD, name="Quarterly", exit_strategy=3) + + _, fake = self.run_flush( + [meta_reply(), whoami_reply(kind="account"), created_reply()] + ) + + body = json.loads(fake.requests[-1].kwargs["data"]) + self.assertEqual( + body["target"], + {"create": {"name": "Quarterly", "exit_strategy": "alternate_cloud"}}, + ) + + def test_a_document_that_cannot_be_read_is_reported(self): + entry = self.add(OLD) + entry.document_path.write_text("{not json") + + result, _ = self.run_flush([meta_reply()]) + + self.assertEqual(result.failed, {OLD: "unreadable"}) + + def test_the_stored_body_is_what_the_first_attempt_sent(self): + document = self.document() + _, first = self.run_upload(document) + self.add(OLD) + _, second = self.run_flush([meta_reply(), whoami_reply(), created_reply()]) + + self.assertEqual( + first.requests[-1].kwargs["data"], second.requests[-1].kwargs["data"] + ) + + def run_upload(self, document): + with fake_http(meta_reply(), whoami_reply(), created_reply()) as fake: + upload( + document, + self.settings(), + name="Spooled", + meta={"exit_strategy": 1}, + spool=self.spool, + **self.options(cache_path=self.tmp / "cache-first"), + ) + return None, fake + + +class AutoFlushTests(SpoolCase): + CONFIG = { + "name": "Auto", + "cloudServiceProvider": 2, + "exitStrategy": 1, + "assessmentType": 1, + "providerDetails": { + "accessKey": "a", + "secretKey": "b", + "region": "eu-central-1", + }, + } + + def run_run( + self, *, flush_spool=True, flush_result=None, flush_error=None, dry_run=False + ): + order, steps = [], [] + report = self.tmp / "r" + (report / "data").mkdir(parents=True, exist_ok=True) + raw = self.tmp / "raw" + raw.mkdir(exist_ok=True) + + def flushing(*args, **kwargs): + order.append(("flush", kwargs)) + if flush_error: + raise flush_error + return flush_result or FlushResult() + + def directory(): + order.append(("directory", None)) + return str(report), str(raw) + + patches = [ + patch("cloudexit.cli.app.validate_config"), + patch("cloudexit.cli.app.flush", side_effect=flushing), + patch("cloudexit.cli.app.create_directory", side_effect=directory), + patch("cloudexit.cli.app.add_run_log_handler"), + patch("cloudexit.cli.app.verify_credentials", return_value=(True, "ok")), + patch( + "cloudexit.cli.app.test_permissions", + return_value=(True, True, True, "ok"), + ), + patch( + "cloudexit.cli.app.create_resource_inventory", + return_value={"success": True, "logs": ""}, + ), + patch( + "cloudexit.cli.app.create_cost_inventory", + return_value={"success": True, "logs": ""}, + ), + patch( + "cloudexit.cli.app.perform_risk_assessment", + return_value={"success": True, "logs": ""}, + ), + patch( + "cloudexit.cli.app.generate_report", + return_value={"success": True, "reports": {}}, + ), + patch("cloudexit.cli.app.prepare_sync_document", return_value=None), + patch( + "cloudexit.cli.app.upload", + side_effect=lambda *a, **k: order.append(("upload", k)) + or UploadResult(True, True, message="uploaded", sync_id=OLD), + ), + patch( + "cloudexit.cli.app.print_step", + side_effect=lambda *a, **k: steps.append((a, k)), + ), + patch("cloudexit.cli.app.console.print"), + patch("cloudexit.cli.app.console.status"), + ] + for item in patches: + item.start() + try: + app.run_assessment( + dict(self.CONFIG), + "aws", + non_interactive=True, + settings=self.settings(), + flush_spool=flush_spool, + dry_run=dry_run, + ) + finally: + for item in reversed(patches): + item.stop() + return order, steps + + def test_earlier_syncs_are_delivered_before_the_new_run_starts(self): + self.add(OLD) + order, _ = self.run_run(flush_result=FlushResult(uploaded=[OLD])) + + names = [name for name, _ in order] + self.assertEqual(names[:2], ["flush", "directory"]) + self.assertIn("upload", names) + + def test_the_flush_has_a_shorter_budget(self): + self.add(OLD) + order, _ = self.run_run() + + self.assertEqual(order[0][1]["budget"], AUTO_FLUSH_BUDGET) + self.assertEqual(AUTO_FLUSH_BUDGET, 60.0) + + def test_no_flush_skips_it(self): + self.add(OLD) + order, _ = self.run_run(flush_spool=False) + + self.assertNotIn("flush", [name for name, _ in order]) + self.assertIn("upload", [name for name, _ in order]) + + def test_nothing_spooled_means_no_flush(self): + order, _ = self.run_run() + self.assertNotIn("flush", [name for name, _ in order]) + + def test_a_dry_run_does_not_flush(self): + self.add(OLD) + order, _ = self.run_run(dry_run=True) + self.assertNotIn("flush", [name for name, _ in order]) + + def test_what_was_delivered_and_what_waits_is_said(self): + self.add(OLD) + result = FlushResult( + uploaded=[OLD, MIDDLE], pending=[NEW], stopped="maintenance" + ) + _, steps = self.run_run(flush_result=result) + + text = " | ".join(str(a[0]) + " " + str(k.get("logs", "")) for a, k in steps) + self.assertIn("Delivered 2 spooled syncs from earlier runs.", text) + self.assertIn("1 spooled sync still waiting.", text) + self.assertIn("maintenance", text) + + def test_what_belongs_to_another_key_is_said_and_the_run_goes_on(self): + self.add(OLD, key_id=key_fingerprint(EU_KEY)) + order, steps = self.run_run(flush_result=FlushResult(other_key=[OLD])) + + text = " | ".join(str(a[0]) + " " + str(k.get("logs", "")) for a, k in steps) + self.assertIn("1 spooled sync for another API key left in the spool.", text) + self.assertIn("not delivered with this key", text) + self.assertIn("upload", [name for name, _ in order]) + + def test_a_failing_flush_never_blocks_the_new_run(self): + self.add(OLD) + for error in ( + ApiError("maintenance", "The platform is down.", 12, retryable=True), + RuntimeError("boom"), + OSError("disk"), + ): + with self.subTest(error=type(error).__name__): + order, steps = self.run_run(flush_error=error) + + self.assertIn("upload", [name for name, _ in order]) + self.assertIn( + "Delivering earlier syncs failed.", [a[0] for a, _ in steps] + ) + + def test_a_run_whose_upload_was_spooled_says_how_to_deliver_it(self): + spooled = UploadResult( + True, False, 12, "upload_unavailable", "later", OLD, spooled=True + ) + steps = [] + with ( + patch("cloudexit.cli.app.validate_config"), + patch( + "cloudexit.cli.app.create_directory", + return_value=(str(self.tmp / "r"), str(self.tmp / "raw")), + ), + patch("cloudexit.cli.app.add_run_log_handler"), + patch("cloudexit.cli.app.verify_credentials", return_value=(True, "ok")), + patch( + "cloudexit.cli.app.test_permissions", + return_value=(True, True, True, "ok"), + ), + patch( + "cloudexit.cli.app.create_resource_inventory", + return_value={"success": True, "logs": ""}, + ), + patch( + "cloudexit.cli.app.create_cost_inventory", + return_value={"success": True, "logs": ""}, + ), + patch( + "cloudexit.cli.app.perform_risk_assessment", + return_value={"success": True, "logs": ""}, + ), + patch( + "cloudexit.cli.app.generate_report", + return_value={"success": True, "reports": {}}, + ), + patch("cloudexit.cli.app.prepare_sync_document", return_value=None), + patch("cloudexit.cli.app.write_upload_record"), + patch("cloudexit.cli.app.upload", return_value=spooled), + patch( + "cloudexit.cli.app.print_step", + side_effect=lambda *a, **k: steps.append((a, k)), + ), + patch("cloudexit.cli.app.console.print"), + patch("cloudexit.cli.app.console.status"), + self.assertRaises(SystemExit) as ctx, + ): + (self.tmp / "r" / "data").mkdir(parents=True, exist_ok=True) + (self.tmp / "raw").mkdir(exist_ok=True) + app.run_assessment( + dict(self.CONFIG), + "aws", + non_interactive=True, + settings=self.settings(), + flush_spool=False, + ) + + self.assertEqual(ctx.exception.code, codes.UPLOAD_UNAVAILABLE) + logs = [k.get("logs") for _, k in steps] + self.assertIn("spooled; run `cloudexit spool flush`", logs) + + +class CommandTestCase(SpoolCase): + def document(self, sync_id=OLD, age_days=0): + # The commands read the real clock, so the documents are recent + return minimal_document(sync_id, int(time.time() - age_days * DAY - 60)) + + def setUp(self): + super().setUp() + env = {k: v for k, v in os.environ.items() if not k.startswith("CLOUDEXIT_")} + env.update( + { + "CLOUDEXIT_HOST": HOST, + "CLOUDEXIT_API_KEY": KEY, + "CLOUDEXIT_SPOOL_DIR": str(self.tmp / "spool"), + "CLOUDEXIT_UPLOAD_BUDGET": "0", + **isolated_environ(self.tmp), + } + ) + patcher = patch.dict(os.environ, env, clear=True) + patcher.start() + self.addCleanup(patcher.stop) + + def args(self, **values): + import argparse + + return argparse.Namespace(**values) + + def run_command(self, handler, args, replies=()): + out = io.StringIO() + code = 0 + with ( + contextlib.redirect_stdout(out), + patch( + "cloudexit.cli.app.console.print", + side_effect=lambda *a, **k: out.write( + " ".join(str(x) for x in a) + "\n" + ), + ), + fake_http(*replies) as fake, + ): + try: + handler(args) + except SystemExit as exc: + code = exc.code or 0 + return code, out.getvalue(), fake + + +class UploadCommandTests(CommandTestCase): + def files(self): + folder = self.tmp / "report" / "raw_data" + folder.mkdir(parents=True) + (folder / "sync.json").write_text(json.dumps(self.document(OLD))) + (folder / "upload.json").write_text(json.dumps({"status": "failed"})) + (folder / "assessment_result.json").write_text(json.dumps({"meta": {}})) + (folder / "broken.json").write_text("{not json") + return folder + + def upload_args(self, *paths, **more): + values = { + "paths": list(paths), + "name": None, + "exit_strategy": None, + "spool_on_error": False, + } + values.update(more) + return self.args(**values) + + def test_a_directory_uploads_only_its_sync_documents(self): + folder = self.files() + with patch("cloudexit.cli.app.validate_document"): + code, out, fake = self.run_command( + app.handle_upload, + self.upload_args(str(folder)), + [meta_reply(), whoami_reply(), created_reply()], + ) + + self.assertEqual(code, 0, out) + self.assertEqual(len([r for r in fake.requests if r.method == "POST"]), 1) + self.assertIn("sync.json: uploaded", out) + self.assertNotIn("upload.json", out.replace("sync.json", "")) + + def test_several_files_and_folders_are_uploaded_in_the_order_given(self): + first = self.tmp / "first.json" + second = self.tmp / "second.json" + first.write_text(json.dumps(self.document(MIDDLE))) + second.write_text(json.dumps(self.document(NEW))) + with patch("cloudexit.cli.app.validate_document"): + code, out, fake = self.run_command( + app.handle_upload, + self.upload_args(str(second), str(first)), + [ + meta_reply(), + whoami_reply(), + created_reply(), + whoami_reply(), + created_reply(), + ], + ) + + keys = [ + r.kwargs["headers"]["Idempotency-Key"] + for r in fake.requests + if r.method == "POST" + ] + self.assertEqual((code, keys), (0, [NEW, MIDDLE])) + + def test_a_file_that_is_not_a_sync_document_is_refused(self): + other = self.tmp / "other.json" + other.write_text(json.dumps({"meta": {}})) + missing = self.tmp / "missing.json" + + code, out, fake = self.run_command( + app.handle_upload, self.upload_args(str(other), str(missing)) + ) + + self.assertEqual(code, codes.UPLOAD_REJECTED) + self.assertIn("is not a sync document", out) + self.assertIn("no such file or directory", out) + self.assertEqual(fake.requests, []) + + def test_a_folder_without_sync_documents_is_reported(self): + empty = self.tmp / "empty" + empty.mkdir() + code, out, _ = self.run_command(app.handle_upload, self.upload_args(str(empty))) + + self.assertEqual(code, codes.UPLOAD_REJECTED) + self.assertIn("holds no sync documents", out) + + def test_a_document_that_breaks_the_schema_is_not_sent(self): + file = self.tmp / "sync.json" + file.write_text(json.dumps(self.document(OLD))) + + code, out, fake = self.run_command( + app.handle_upload, self.upload_args(str(file)) + ) + + self.assertEqual(code, codes.UPLOAD_REJECTED) + self.assertEqual(fake.requests, []) + + def test_an_upload_that_cannot_get_through_is_spooled_and_exits_12(self): + file = self.tmp / "sync.json" + file.write_text(json.dumps(self.document(OLD))) + with patch("cloudexit.cli.app.validate_document"): + code, out, _ = self.run_command( + app.handle_upload, + self.upload_args(str(file)), + [meta_reply(), whoami_reply(), unavailable()], + ) + + self.assertEqual(code, codes.UPLOAD_UNAVAILABLE) + self.assertIn("spooled; run `cloudexit spool flush`", out) + self.assertEqual([e.sync_id for e in self.spool.entries()], [OLD]) + + def test_spool_on_error_keeps_a_refused_upload(self): + file = self.tmp / "sync.json" + file.write_text(json.dumps(self.document(OLD))) + with patch("cloudexit.cli.app.validate_document"): + code, out, _ = self.run_command( + app.handle_upload, + self.upload_args(str(file), spool_on_error=True), + [meta_reply(), whoami_reply(), refused()], + ) + + self.assertEqual(code, codes.UPLOAD_REJECTED) + self.assertEqual(len(self.spool.entries()), 1) + + def test_the_charge_and_the_link_are_shown(self): + file = self.tmp / "sync.json" + file.write_text(json.dumps(self.document(OLD))) + billing = {"class": "new", "charged_credits": 20, "balance": 380} + with patch("cloudexit.cli.app.validate_document"): + code, out, _ = self.run_command( + app.handle_upload, + self.upload_args(str(file)), + [meta_reply(), whoami_reply(), created_reply(billing=billing)], + ) + + self.assertIn("Charged 20 credits; balance 380.", out) + self.assertIn("https://platform.test/assessments/a-1", out) + + def test_an_account_key_needs_to_be_told_where_the_sync_goes(self): + file = self.tmp / "sync.json" + file.write_text(json.dumps(self.document(OLD))) + with patch("cloudexit.cli.app.validate_document"): + code, out, _ = self.run_command( + app.handle_upload, + self.upload_args(str(file)), + [meta_reply(), whoami_reply(kind="account")], + ) + + self.assertEqual(code, codes.UPLOAD_REJECTED) + self.assertIn("invalid_target", out) + + def test_without_a_platform_there_is_nothing_to_upload_to(self): + with patch.dict(os.environ, {"CLOUDEXIT_API_KEY": ""}): + code, out, fake = self.run_command( + app.handle_upload, self.upload_args(str(self.tmp / "x.json")) + ) + self.assertEqual(code, codes.UPLOAD_AUTH) + self.assertIn("No platform is configured", out) + + def test_a_mistyped_key_is_refused_before_any_request(self): + from tests.support.api import corrupt + + with patch.dict(os.environ, {"CLOUDEXIT_API_KEY": corrupt(KEY)}): + code, out, fake = self.run_command( + app.handle_upload, self.upload_args(str(self.tmp / "x.json")) + ) + self.assertEqual(code, codes.UPLOAD_AUTH) + self.assertEqual(fake.requests, []) + + def test_read_sync_files_finds_documents_by_their_shape(self): + folder = self.files() + found, problems = read_sync_files([str(folder)]) + + self.assertEqual([p.name for p, _ in found], ["sync.json"]) + self.assertEqual(problems, []) + + +class SpoolCommandTests(CommandTestCase): + def spool_args(self, command, **more): + values = { + "spool_command": command, + "host": None, + "older_than": None, + "yes": False, + } + values.update(more) + return self.args(**values) + + def test_list_shows_id_host_age_attempts_and_last_error(self): + error = ApiError("maintenance", "m", 12, request_id="r", retryable=True) + self.add(OLD, created=NOW - 2 * DAY, error=error, attempts=4) + with patch("time.time", return_value=NOW): + code, out, _ = self.run_command(app.handle_spool, self.spool_args("list")) + + lines = out.strip().splitlines() + self.assertEqual(code, 0) + self.assertEqual( + lines[0].split(), ["SYNC", "ID", "HOST", "AGE", "ATTEMPTS", "LAST", "ERROR"] + ) + self.assertEqual( + lines[1].split(), [OLD, host_id(HOST), "2d", "4", "maintenance"] + ) + + def test_list_marks_what_belongs_to_another_key(self): + self.add(OLD) + self.add(NEW, key_id=key_fingerprint(EU_KEY)) + with patch("time.time", return_value=NOW): + _, out, _ = self.run_command(app.handle_spool, self.spool_args("list")) + + lines = {line.split()[0]: line for line in out.strip().splitlines()[1:]} + self.assertNotIn("not for the key in use", lines[OLD]) + self.assertIn("not for the key in use", lines[NEW]) + + def test_flush_leaves_the_entries_of_another_key_and_says_so(self): + self.add(OLD, key_id=key_fingerprint(EU_KEY)) + code, out, fake = self.run_command( + app.handle_spool, self.spool_args("flush"), [] + ) + + self.assertEqual(code, 0) + self.assertIn("1 spooled sync for another API key left in the spool.", out) + self.assertEqual([r for r in fake.requests if r.method == "POST"], []) + self.assertEqual(len(self.spool.entries()), 1) + + def test_list_marks_what_the_platform_will_reject(self): + self.add(OLD, created=NOW - 31 * DAY) + with patch("time.time", return_value=NOW): + _, out, _ = self.run_command(app.handle_spool, self.spool_args("list")) + self.assertIn("will be rejected (too old)", out) + + def test_list_of_an_empty_spool(self): + _, out, _ = self.run_command(app.handle_spool, self.spool_args("list")) + self.assertIn("The spool is empty.", out) + + def test_list_can_be_limited_to_one_host(self): + self.add(OLD) + _, out, _ = self.run_command( + app.handle_spool, self.spool_args("list", host="https://other.example.com") + ) + self.assertIn("The spool is empty.", out) + + def test_flush_delivers_and_clears_the_spool(self): + self.add(OLD) + code, out, fake = self.run_command( + app.handle_spool, + self.spool_args("flush"), + [meta_reply(), whoami_reply(), created_reply()], + ) + + self.assertEqual(code, 0) + self.assertIn("Delivered 1 spooled sync", out) + self.assertEqual(self.spool.entries(), []) + + def test_flush_exits_12_while_something_still_waits(self): + self.add(OLD) + code, out, _ = self.run_command( + app.handle_spool, + self.spool_args("flush"), + [meta_reply(), whoami_reply(), unavailable()], + ) + + self.assertEqual(code, codes.UPLOAD_UNAVAILABLE) + self.assertIn("still waiting", out) + self.assertEqual(len(self.spool.entries()), 1) + + def test_flush_of_an_empty_spool(self): + code, out, fake = self.run_command(app.handle_spool, self.spool_args("flush")) + self.assertEqual(code, 0) + self.assertIn("Nothing to deliver", out) + self.assertEqual(fake.requests, []) + + def test_flush_can_name_the_host(self): + self.add(OLD) + code, out, fake = self.run_command( + app.handle_spool, + self.spool_args("flush", host="https://other.example.com"), + ) + self.assertIn("Nothing to deliver", out) + self.assertEqual(len(self.spool.entries()), 1) + + def test_flush_needs_a_platform(self): + with patch.dict(os.environ, {"CLOUDEXIT_HOST": ""}): + code, out, _ = self.run_command(app.handle_spool, self.spool_args("flush")) + self.assertEqual(code, codes.UPLOAD_AUTH) + + def test_purge_asks_first_and_deletes_on_yes(self): + self.add(OLD) + self.add(NEW) + with patch("builtins.input", return_value="y") as asked: + code, out, _ = self.run_command(app.handle_spool, self.spool_args("purge")) + + self.assertIn("Delete 2 spooled syncs?", asked.call_args.args[0]) + self.assertIn("Deleted 2 spooled syncs.", out) + self.assertEqual(self.spool.entries(), []) + + def test_purge_keeps_everything_on_no(self): + self.add(OLD) + for answer in ("n", "", "no"): + with self.subTest(answer=answer): + with patch("builtins.input", return_value=answer): + _, out, _ = self.run_command( + app.handle_spool, self.spool_args("purge") + ) + self.assertIn("Nothing deleted.", out) + self.assertEqual(len(self.spool.entries()), 1) + + def test_purge_with_yes_does_not_ask(self): + self.add(OLD) + with patch("builtins.input", side_effect=AssertionError("asked")): + _, out, _ = self.run_command( + app.handle_spool, self.spool_args("purge", yes=True) + ) + self.assertEqual(self.spool.entries(), []) + + def test_purge_by_age_leaves_the_newer_syncs(self): + self.add(OLD, created=NOW - 40 * DAY) + self.add(NEW, created=NOW - 1 * DAY) + with patch("time.time", return_value=NOW): + _, out, _ = self.run_command( + app.handle_spool, self.spool_args("purge", older_than="30d", yes=True) + ) + + self.assertIn("Deleted 1 spooled sync.", out) + self.assertEqual([e.sync_id for e in self.spool.entries()], [NEW]) + + def test_purge_of_nothing(self): + _, out, _ = self.run_command( + app.handle_spool, self.spool_args("purge", yes=True) + ) + self.assertIn("Nothing to purge.", out) + + def test_purge_with_a_bad_age_is_a_configuration_error(self): + code, out, _ = self.run_command( + app.handle_spool, self.spool_args("purge", older_than="soon") + ) + self.assertEqual(code, codes.CONFIG) + + def test_purge_without_a_terminal_says_to_use_yes(self): + self.add(OLD) + with patch("builtins.input", side_effect=EOFError): + code, out, _ = self.run_command(app.handle_spool, self.spool_args("purge")) + + self.assertEqual(code, codes.CONFIG) + self.assertEqual(len(self.spool.entries()), 1) + + +class SpoolCliTests(unittest.TestCase): + def setUp(self): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.tmp = Path(tmp.name) + self.env = { + k: v + for k, v in os.environ.items() + if not k.startswith(("CLOUDEXIT_", "ESC_")) + } + self.env.update( + { + **isolated_environ(self.tmp), + "COLUMNS": "200", + } + ) + + def run_cli(self, *argv, stdin=None): + result = subprocess.run( + [sys.executable, str(ROOT / "main.py"), *argv], + cwd=self.tmp, + env=self.env, + stdin=stdin if stdin is not None else subprocess.DEVNULL, + capture_output=True, + text=True, + timeout=120, + ) + return result.returncode, " ".join((result.stdout + result.stderr).split()) + + def test_the_commands_run_without_a_platform_where_they_can(self): + code, out = self.run_cli("spool", "list") + self.assertEqual((code, out), (0, "The spool is empty.")) + code, out = self.run_cli("spool", "purge", "--yes") + self.assertEqual((code, out), (0, "Nothing to purge.")) + + def test_upload_and_flush_need_a_platform(self): + for argv in (("upload", "sync.json"), ("spool", "flush")): + with self.subTest(argv=argv): + code, out = self.run_cli(*argv) + self.assertEqual(code, codes.UPLOAD_AUTH, out) + self.assertIn("No platform is configured", out) + self.assertNotIn("Traceback", out) + + def test_the_help_lists_the_new_commands_and_flags(self): + code, out = self.run_cli("--help") + self.assertEqual(code, 0) + for word in ("upload", "spool"): + self.assertIn(word, out) + code, out = self.run_cli("aws", "--help") + for flag in ("--upload-budget", "--spool-on-error", "--no-flush"): + self.assertIn(flag, out) + code, out = self.run_cli("spool", "purge", "--help") + for flag in ("--older-than", "--yes"): + self.assertIn(flag, out) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/providers/__init__.py b/tests/providers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/providers/aws/__init__.py b/tests/providers/aws/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/providers/aws/test_client.py b/tests/providers/aws/test_client.py new file mode 100644 index 0000000..0e8c24d --- /dev/null +++ b/tests/providers/aws/test_client.py @@ -0,0 +1,718 @@ +import logging +import os +import tempfile +import unittest +from datetime import datetime +from pathlib import Path +from unittest.mock import MagicMock, patch + +import boto3 +import botocore.session +from botocore.awsrequest import AWSResponse +from botocore.exceptions import ( + ConnectTimeoutError, + EndpointConnectionError, + UnknownServiceError, +) + +from cloudexit.providers.aws import client as aws_client +from cloudexit.providers.aws import costs as aws_costs +from cloudexit.providers.aws.client import ( + AWS_RETRY_CONFIG, + CONNECTION_ATTEMPTS, + SERVICE_NOT_OFFERED, + SERVICE_OK, + SERVICE_UNKNOWN, + convert_datetime, + create_client, + extract_result_path, + paginate, + paginate_or_call, + parse_resource_type_params, + resolve_aws_call_spec, + service_status, +) +from cloudexit.providers.aws.inventory import build_aws_resource_inventory + +REGION = "eu-central-1" +URL = "https://ec2.eu-central-1.amazonaws.com" + + +def row(id_, service, operation="list_things", result="Things"): + return { + "code": f"AWS.{service}.{operation}.{result}", + "id": id_, + "name": f"{service} {operation}", + "csp": 2, + "status": "t", + "params": "{}", + } + + +class Raw: + def __init__(self, data): + self.data = data + + def stream(self): + yield self.data + + +def throttled(): + body = ( + b"RequestLimitExceeded" + b"slow downx" + b"" + ) + return AWSResponse(URL, 503, {"Content-Type": "text/xml"}, Raw(body)) + + +def answered(): + body = ( + b"" + b"x" + ) + return AWSResponse(URL, 200, {"Content-Type": "text/xml"}, Raw(body)) + + +class ConfigTests(unittest.TestCase): + def test_the_clients_give_up_on_a_connection_after_5_seconds_and_a_read_after_30( + self, + ): + self.assertEqual(AWS_RETRY_CONFIG.connect_timeout, 5) + self.assertEqual(AWS_RETRY_CONFIG.read_timeout, 30) + + def test_throttling_keeps_the_adaptive_retries(self): + self.assertEqual( + AWS_RETRY_CONFIG.retries, {"mode": "adaptive", "max_attempts": 8} + ) + + +class RetryBehaviourTests(unittest.TestCase): + def client(self): + session = boto3.Session( + aws_access_key_id="AK", aws_secret_access_key="SK", region_name=REGION + ) + return create_client(session, "ec2", REGION) + + def sends(self, answers): + return patch("botocore.httpsession.URLLib3Session.send", side_effect=answers) + + def test_a_connection_timeout_costs_at_most_two_attempts(self): + error = ConnectTimeoutError(endpoint_url=URL) + with self.sends(error) as send, patch("time.sleep"): + with self.assertRaises(ConnectTimeoutError): + self.client().describe_regions() + + self.assertEqual(send.call_count, CONNECTION_ATTEMPTS) + + def test_an_unreachable_endpoint_costs_at_most_two_attempts(self): + error = EndpointConnectionError(endpoint_url=URL) + with self.sends(error) as send, patch("time.sleep") as sleep: + with self.assertRaises(EndpointConnectionError): + self.client().describe_regions() + + self.assertEqual(send.call_count, CONNECTION_ATTEMPTS) + self.assertEqual(sleep.call_count, CONNECTION_ATTEMPTS - 1) + + def test_a_connection_that_works_the_second_time_succeeds(self): + answers = [ConnectTimeoutError(endpoint_url=URL), answered()] + with self.sends(answers) as send, patch("time.sleep"): + result = self.client().describe_regions() + + self.assertEqual(result["Regions"], []) + self.assertEqual(send.call_count, 2) + + def test_throttling_is_still_retried_beyond_two_attempts(self): + answers = [throttled(), throttled(), throttled(), answered()] + with self.sends(answers) as send, patch("time.sleep"): + result = self.client().describe_regions() + + self.assertEqual(result["Regions"], []) + self.assertEqual(send.call_count, 4) + + def test_the_retry_limit_ignores_every_other_error(self): + # Called as botocore does; only a connection error is cut short. + aws_client.limit_connection_retries(5, caught_exception=RuntimeError("x")) + aws_client.limit_connection_retries(5, caught_exception=None) + aws_client.limit_connection_retries( + 1, caught_exception=ConnectTimeoutError(endpoint_url=URL) + ) + with self.assertRaises(EndpointConnectionError): + aws_client.limit_connection_retries( + 2, caught_exception=EndpointConnectionError(endpoint_url=URL) + ) + + +class ServiceStatusTests(unittest.TestCase): + def test_a_service_the_installed_botocore_does_not_know_is_unknown(self): + for service in ("elastictranscoder", "no-such-service"): + with self.subTest(service=service): + self.assertEqual(service_status(service, REGION), SERVICE_UNKNOWN) + + def test_a_regional_service_is_offered_where_botocore_lists_it(self): + self.assertEqual(service_status("ec2", REGION), SERVICE_OK) + self.assertEqual(service_status("ec2", "us-east-1"), SERVICE_OK) + + def test_a_global_service_is_never_skipped(self): + for service in ("iam", "cloudfront", "route53", "organizations"): + for region in (REGION, "us-east-1", "ap-southeast-2"): + with self.subTest(service=service, region=region): + self.assertEqual(service_status(service, region), SERVICE_OK) + + def test_a_region_botocore_has_never_heard_of_is_left_to_the_call(self): + self.assertEqual(service_status("ec2", "xx-fake-1"), SERVICE_OK) + + def test_a_service_missing_from_a_region_is_not_offered(self): + session = botocore.session.get_session() + found = None + for service in session.get_available_services(): + regions = session.get_available_regions(service) + everything = session.get_available_regions(service, allow_non_regional=True) + if regions and set(everything) == set(regions) and REGION not in regions: + found = service + break + if found is None: + self.skipTest("every installed service lists this region") + + self.assertEqual(service_status(found, REGION), SERVICE_NOT_OFFERED) + + +class SweepTests(unittest.TestCase): + def run_sweep(self, rows, *, status=None, client=None, calls=None): + records = [] + + class Capture(logging.Handler): + def emit(self, record): + records.append(record) + + aws_logger = logging.getLogger("cloudexit.providers.aws") + handler = Capture() + previous = aws_logger.level + aws_logger.addHandler(handler) + aws_logger.setLevel(logging.DEBUG) + session = MagicMock() + if client is not None: + session.client.side_effect = client + try: + with tempfile.TemporaryDirectory() as tmp: + report = os.path.join(tmp, "report") + raw = os.path.join(tmp, "raw") + os.makedirs(os.path.join(report, "data")) + os.makedirs(raw) + patches = [ + patch( + "cloudexit.providers.aws.inventory.load_data", return_value=rows + ), + patch( + "cloudexit.providers.aws.inventory.boto3.Session", + return_value=session, + ), + patch("cloudexit.providers.aws.inventory.connect"), + patch( + "cloudexit.providers.aws.inventory.paginate_or_call", + side_effect=calls or (lambda *a, **k: []), + ), + ] + if status is not None: + patches.append( + patch( + "cloudexit.providers.aws.inventory.service_status", + side_effect=lambda service, region: status.get( + service, SERVICE_OK + ), + ) + ) + for item in patches: + item.start() + try: + build_aws_resource_inventory( + 2, + {"accessKey": "AK", "secretKey": "SK", "region": REGION}, + report, + raw, + ) + finally: + for item in reversed(patches): + item.stop() + finally: + aws_logger.removeHandler(handler) + aws_logger.setLevel(previous) + return session, records + + def messages(self, records, level=None): + return [r.getMessage() for r in records if level is None or r.levelno == level] + + def test_a_service_the_region_does_not_offer_makes_no_client_call(self): + rows = [row(1, "healthlake"), row(2, "healthlake", "list_other")] + session, records = self.run_sweep( + rows, status={"healthlake": SERVICE_NOT_OFFERED} + ) + + session.client.assert_not_called() + debug = [ + m + for m in self.messages(records, logging.DEBUG) + if m.startswith("Skipping healthlake") + ] + self.assertEqual(len(debug), 1) + self.assertIn(f"not offered in {REGION}", debug[0]) + + def test_a_service_unknown_to_boto3_is_skipped_once_without_a_call(self): + rows = [row(1, "elastictranscoder"), row(2, "elastictranscoder", "list_two")] + session, records = self.run_sweep( + rows, status={"elastictranscoder": SERVICE_UNKNOWN} + ) + + session.client.assert_not_called() + debug = [ + m + for m in self.messages(records, logging.DEBUG) + if m.startswith("Skipping elastictranscoder") + ] + self.assertEqual(len(debug), 1) + + def test_an_unknown_service_error_from_boto3_is_skipped_once_per_service(self): + def client(service, **kwargs): + raise UnknownServiceError(service_name=service, known_service_names=["ec2"]) + + rows = [row(1, "iotevents-data"), row(2, "iotevents-data", "list_two")] + session, records = self.run_sweep(rows, status={}, client=client) + + self.assertEqual(session.client.call_count, 1) + debug = [ + m + for m in self.messages(records, logging.DEBUG) + if m.startswith("Skipping iotevents-data") + ] + self.assertEqual(len(debug), 1) + self.assertFalse(any(r.levelno >= logging.WARNING for r in records)) + + def test_an_unreachable_service_is_given_up_on_once_for_every_resource_type(self): + def calls(client, operation, *args, **kwargs): + raise EndpointConnectionError(endpoint_url="https://inspector.example") + + rows = [ + row(1, "inspector"), + row(2, "inspector", "list_two"), + row(3, "inspector", "list_three"), + row(4, "ec2", "describe_instances", "Reservations"), + ] + attempted = [] + + def counting(client, operation, *args, **kwargs): + attempted.append(operation) + if operation != "describe_instances": + raise EndpointConnectionError(endpoint_url="https://inspector.example") + return [] + + session, records = self.run_sweep(rows, status={}, calls=counting) + + # The first inspector call fails; its other types are skipped; ec2 still runs + self.assertEqual(attempted, ["list_things", "describe_instances"]) + self.assertEqual(session.client.call_count, 2) + self.assertFalse(any(r.levelno >= logging.WARNING for r in records)) + + def test_other_errors_do_not_stop_the_other_resource_types_of_a_service(self): + attempted = [] + + def counting(client, operation, *args, **kwargs): + attempted.append(operation) + raise RuntimeError("AccessDenied") + + rows = [row(1, "inspector"), row(2, "inspector", "list_two")] + self.run_sweep(rows, status={}, calls=counting) + + self.assertEqual(attempted, ["list_things", "list_two"]) + + def test_one_summary_line_tells_what_was_skipped(self): + def counting(client, operation, *args, **kwargs): + if operation == "list_dead": + raise EndpointConnectionError(endpoint_url="https://x.example") + return [] + + rows = [ + row(1, "healthlake"), + row(2, "healthlake", "list_two"), + row(3, "frauddetector"), + row(4, "elastictranscoder"), + row(5, "inspector", "list_dead"), + row(6, "inspector", "list_again"), + row(7, "ec2", "describe_instances", "Reservations"), + ] + _, records = self.run_sweep( + rows, + status={ + "healthlake": SERVICE_NOT_OFFERED, + "frauddetector": SERVICE_NOT_OFFERED, + "elastictranscoder": SERVICE_UNKNOWN, + }, + calls=counting, + ) + + summary = [ + r + for r in records + if r.levelno == logging.INFO and "service calls skipped" in r.getMessage() + ] + self.assertEqual(len(summary), 1) + self.assertEqual( + summary[0].getMessage(), + f"6 service calls skipped (not offered in {REGION}: frauddetector, " + "healthlake; unknown to boto3: elastictranscoder; failed: inspector)", + ) + + def test_a_clean_sweep_logs_no_skip_summary(self): + _, records = self.run_sweep( + [row(1, "ec2", "describe_instances", "Reservations")], status={} + ) + self.assertFalse( + any("service calls skipped" in r.getMessage() for r in records) + ) + + def test_the_real_pre_check_lets_global_and_regional_services_through(self): + rows = [ + row(1, "iam", "list_users", "Users"), + row(2, "ec2", "describe_instances", "Reservations"), + ] + session, _ = self.run_sweep(rows) + + self.assertEqual(session.client.call_count, 2) + + +class ClientFactoryTests(unittest.TestCase): + def test_every_collector_client_gets_the_timeouts_and_the_retry_limit(self): + session = MagicMock() + client = create_client(session, "cloudwatch", REGION) + + session.client.assert_called_once_with( + "cloudwatch", region_name=REGION, config=AWS_RETRY_CONFIG + ) + client.meta.events.register_first.assert_called_once_with( + "needs-retry", aws_client.limit_connection_retries + ) + + def test_the_cost_explorer_stays_pinned_to_us_east_1(self): + source = Path(aws_costs.__file__).read_text(encoding="utf-8") + self.assertIn('create_client(session, "ce", "us-east-1")', source) + + +class ConvertDatetimeTests(unittest.TestCase): + def test_converts_datetime_in_flat_dict(self): + dt = datetime(2026, 1, 15, 12, 30, 0) + result = convert_datetime({"created": dt, "name": "test"}) + self.assertEqual(result["created"], "2026-01-15T12:30:00") + self.assertEqual(result["name"], "test") + + def test_converts_datetime_in_list(self): + dt = datetime(2026, 3, 1) + result = convert_datetime([dt, "keep"]) + self.assertEqual(result[0], "2026-03-01T00:00:00") + self.assertEqual(result[1], "keep") + + def test_converts_nested_datetime(self): + dt = datetime(2026, 6, 15) + result = convert_datetime({"items": [{"ts": dt}]}) + self.assertEqual(result["items"][0]["ts"], "2026-06-15T00:00:00") + + def test_leaves_non_datetime_values_unchanged(self): + data = {"count": 5, "name": "ec2", "tags": ["a", "b"]} + result = convert_datetime(data) + self.assertEqual(result, {"count": 5, "name": "ec2", "tags": ["a", "b"]}) + + def test_handles_empty_structures(self): + self.assertEqual(convert_datetime({}), {}) + self.assertEqual(convert_datetime([]), []) + self.assertIsNone(convert_datetime(None)) + + +class ParseResourceTypeParamsTests(unittest.TestCase): + def test_parses_json_string(self): + self.assertEqual( + parse_resource_type_params('{"service": "s3"}'), {"service": "s3"} + ) + + def test_empty_object_string(self): + self.assertEqual(parse_resource_type_params("{}"), {}) + + def test_none_and_empty_string_degrade_to_empty_dict(self): + self.assertEqual(parse_resource_type_params(None), {}) + self.assertEqual(parse_resource_type_params(""), {}) + + def test_malformed_json_degrades_to_empty_dict(self): + self.assertEqual(parse_resource_type_params("{not json"), {}) + + def test_non_object_json_degrades_to_empty_dict(self): + self.assertEqual(parse_resource_type_params("[1, 2]"), {}) + + def test_dict_passes_through(self): + self.assertEqual( + parse_resource_type_params({"service": "ec2"}), {"service": "ec2"} + ) + + +class ResolveAwsCallSpecTests(unittest.TestCase): + def test_four_part_code_with_no_params(self): + spec = resolve_aws_call_spec("AWS.s3.list_buckets.Buckets", {}) + self.assertEqual( + spec, + { + "source": "code", + "service": "s3", + "operation": "list_buckets", + "result_path": ["Buckets"], + "kwargs": {}, + }, + ) + + def test_five_part_code_with_no_params_keeps_full_result_path(self): + spec = resolve_aws_call_spec( + "AWS.cloudfront.list_distributions.DistributionList.Items", {} + ) + self.assertEqual(spec["result_path"], ["DistributionList", "Items"]) + self.assertEqual(spec["service"], "cloudfront") + + def test_params_take_precedence_over_code(self): + spec = resolve_aws_call_spec( + "AWS.wrong.wrong_op.Wrong", + { + "service": "cloudfront", + "operation": "list_distributions", + "result_path": ["DistributionList", "Items"], + }, + ) + self.assertEqual( + spec, + { + "source": "params", + "service": "cloudfront", + "operation": "list_distributions", + "result_path": ["DistributionList", "Items"], + "kwargs": {}, + }, + ) + + def test_params_with_kwargs(self): + spec = resolve_aws_call_spec( + "AWS.ec2.describe_snapshots.Snapshots", + { + "service": "ec2", + "operation": "describe_snapshots", + "result_path": ["Snapshots"], + "kwargs": {"OwnerIds": ["self"]}, + }, + ) + self.assertEqual(spec["kwargs"], {"OwnerIds": ["self"]}) + + def test_unknown_params_keys_are_ignored(self): + spec = resolve_aws_call_spec( + "AWS.s3.list_buckets.Buckets", + { + "service": "s3", + "operation": "list_buckets", + "result_path": ["Buckets"], + "future_key": "whatever", + }, + ) + self.assertNotIn("future_key", spec) + self.assertEqual(spec["service"], "s3") + + def test_params_missing_result_path_defaults_to_empty(self): + spec = resolve_aws_call_spec( + "AWS.iam", {"service": "iam", "operation": "list_users"} + ) + self.assertEqual(spec["result_path"], []) + + def test_other_cloud_params_fall_back_to_code(self): + spec = resolve_aws_call_spec( + "AWS.s3.list_buckets.Buckets", {"kind": "functionapp"} + ) + self.assertEqual(spec["service"], "s3") + + def test_partial_params_fall_back_to_code(self): + spec = resolve_aws_call_spec("AWS.s3.list_buckets.Buckets", {"service": "s3"}) + self.assertEqual(spec["operation"], "list_buckets") + + def test_source_records_which_branch_resolved_the_row(self): + from_code = resolve_aws_call_spec("AWS.s3.list_buckets.Buckets", {}) + from_params = resolve_aws_call_spec( + "AWS.s3.list_buckets.Buckets", + {"service": "s3", "operation": "list_buckets"}, + ) + self.assertEqual(from_code["source"], "code") + self.assertEqual(from_params["source"], "params") + + def test_two_part_placeholder_is_unresolvable(self): + self.assertIsNone(resolve_aws_call_spec("AWS.iam", {})) + + def test_malformed_code_is_unresolvable(self): + self.assertIsNone(resolve_aws_call_spec("AWS.ec2.describe_instances", {})) + self.assertIsNone(resolve_aws_call_spec("Azure.a.b.c", {})) + self.assertIsNone(resolve_aws_call_spec("", {})) + + +class ExtractResultPathTests(unittest.TestCase): + def test_single_key(self): + self.assertEqual(extract_result_path({"Items": [1, 2]}, ["Items"]), [1, 2]) + + def test_nested_key(self): + self.assertEqual( + extract_result_path({"A": {"B": ["x"]}}, ["A", "B"]), + ["x"], + ) + + def test_missing_intermediate_key_returns_empty(self): + self.assertEqual(extract_result_path({"A": {}}, ["A", "B"]), []) + self.assertEqual(extract_result_path({}, ["A", "B"]), []) + + def test_non_dict_intermediate_returns_empty(self): + self.assertEqual(extract_result_path({"A": "scalar"}, ["A", "B"]), []) + + def test_non_list_final_value_returns_empty(self): + self.assertEqual(extract_result_path({"A": {"B": 5}}, ["A", "B"]), []) + self.assertEqual(extract_result_path({"A": {"B": {}}}, ["A", "B"]), []) + + def test_empty_path_returns_empty(self): + self.assertEqual(extract_result_path({"Items": [1]}, []), []) + + def test_non_dict_container_returns_empty(self): + self.assertEqual(extract_result_path(None, ["Items"]), []) + + +class PaginateTests(unittest.TestCase): + def _fake_client(self, pages): + """Build a stub client whose paginator yields the given pages.""" + paginator = MagicMock() + paginator.paginate.return_value = iter(pages) + client = MagicMock() + client.get_paginator.return_value = paginator + return client + + def test_collects_items_across_pages(self): + client = self._fake_client( + [{"Items": [1, 2, 3]}, {"Items": [4, 5]}, {"Items": [6]}] + ) + self.assertEqual(paginate(client, "any_op", ["Items"]), [1, 2, 3, 4, 5, 6]) + + def test_missing_result_key_treated_as_empty(self): + client = self._fake_client([{"Items": [1]}, {}]) + self.assertEqual(paginate(client, "any_op", ["Items"]), [1]) + + def test_walks_nested_result_path_per_page(self): + client = self._fake_client( + [ + {"DistributionList": {"Items": ["d1", "d2"]}}, + {"DistributionList": {"Items": ["d3"]}}, + ] + ) + self.assertEqual( + paginate(client, "list_distributions", ["DistributionList", "Items"]), + ["d1", "d2", "d3"], + ) + + def test_page_missing_intermediate_key_yields_nothing_for_that_page(self): + client = self._fake_client( + [{"DistributionList": {"Items": ["d1"]}}, {}, {"DistributionList": {}}] + ) + self.assertEqual( + paginate(client, "list_distributions", ["DistributionList", "Items"]), + ["d1"], + ) + + def test_bare_string_result_path_is_treated_as_single_key(self): + client = self._fake_client([{"Volumes": ["v1", "v2"]}]) + self.assertEqual(paginate(client, "describe_volumes", "Volumes"), ["v1", "v2"]) + + def test_stops_and_truncates_at_max_items(self): + pages = [{"Items": list(range(4))} for _ in range(10)] + client = self._fake_client(pages) + + result = paginate(client, "any_op", ["Items"], 10) + + self.assertEqual(len(result), 10) + self.assertEqual(result, list(range(4)) * 2 + [0, 1]) + + def test_forwards_kwargs_to_paginator(self): + client = self._fake_client([{"Items": []}]) + paginate(client, "any_op", ["Items"], MaxResults=50) + client.get_paginator.return_value.paginate.assert_called_once_with( + MaxResults=50 + ) + + def test_throttling_mid_pagination_does_not_silently_truncate(self): + + def flaky_pages(): + yield {"Items": ["r1", "r2"]} + raise botocore.exceptions.ClientError( + {"Error": {"Code": "Throttling", "Message": "slow down"}}, + "ListSomething", + ) + + paginator = MagicMock() + paginator.paginate.return_value = flaky_pages() + client = MagicMock() + client.get_paginator.return_value = paginator + + with self.assertRaises(botocore.exceptions.ClientError): + paginate(client, "any_op", "Items") + + +class PaginateOrCallTests(unittest.TestCase): + def test_uses_paginator_when_available(self): + paginator = MagicMock() + paginator.paginate.return_value = iter([{"Items": [1, 2]}, {"Items": [3]}]) + client = MagicMock() + client.can_paginate.return_value = True + client.get_paginator.return_value = paginator + + self.assertEqual(paginate_or_call(client, "list_things", ["Items"]), [1, 2, 3]) + client.can_paginate.assert_called_once_with("list_things") + + def test_falls_back_to_single_call_when_not_paginable(self): + client = MagicMock() + client.can_paginate.return_value = False + client.list_things.return_value = {"Items": ["a", "b"]} + + self.assertEqual(paginate_or_call(client, "list_things", ["Items"]), ["a", "b"]) + client.list_things.assert_called_once_with() + + def test_non_dict_response_returns_empty_list(self): + client = MagicMock() + client.can_paginate.return_value = False + client.list_things.return_value = None + + self.assertEqual(paginate_or_call(client, "list_things", ["Items"]), []) + + def test_walks_nested_result_path_on_single_call(self): + client = MagicMock() + client.can_paginate.return_value = False + client.get_apps.return_value = { + "ApplicationsResponse": {"Applications": ["a1", "a2"]} + } + + self.assertEqual( + paginate_or_call( + client, "get_apps", ["ApplicationsResponse", "Applications"] + ), + ["a1", "a2"], + ) + + def test_forwards_kwargs_to_single_call(self): + client = MagicMock() + client.can_paginate.return_value = False + client.describe_images.return_value = {"Images": []} + + paginate_or_call(client, "describe_images", ["Images"], Owners=["self"]) + client.describe_images.assert_called_once_with(Owners=["self"]) + + def test_truncates_single_call_at_max_items(self): + client = MagicMock() + client.can_paginate.return_value = False + client.list_things.return_value = {"Items": list(range(20))} + + self.assertEqual( + paginate_or_call(client, "list_things", ["Items"], 5), [0, 1, 2, 3, 4] + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/providers/aws/test_costs.py b/tests/providers/aws/test_costs.py new file mode 100644 index 0000000..577a8f2 --- /dev/null +++ b/tests/providers/aws/test_costs.py @@ -0,0 +1,151 @@ +# tests/providers/aws/test_costs.py +import os +import tempfile +import unittest +from datetime import date, datetime, timezone +from unittest.mock import MagicMock, patch + + +from cloudexit.providers.aws.costs import get_missing_months_aws + + +class GetMissingMonthsAwsTests(unittest.TestCase): + @patch("cloudexit.providers.aws.costs.datetime") + def test_returns_missing_months(self, mock_dt): + mock_dt.now.return_value = datetime(2026, 6, 15, tzinfo=timezone.utc) + mock_dt.strptime = datetime.strptime + + processed = {"2026-06-01", "2026-05-01", "2026-04-01"} + missing = get_missing_months_aws(processed, 6) + + # Should be missing: 2026-03, 2026-02, 2026-01 + self.assertEqual(len(missing), 3) + self.assertIn(date(2026, 3, 1), missing) + self.assertIn(date(2026, 2, 1), missing) + self.assertIn(date(2026, 1, 1), missing) + + @patch("cloudexit.providers.aws.costs.datetime") + def test_returns_empty_when_all_present(self, mock_dt): + mock_dt.now.return_value = datetime(2026, 6, 15, tzinfo=timezone.utc) + mock_dt.strptime = datetime.strptime + + processed = { + "2026-06-01", + "2026-05-01", + "2026-04-01", + "2026-03-01", + "2026-02-01", + "2026-01-01", + } + missing = get_missing_months_aws(processed, 6) + self.assertEqual(missing, []) + + @patch("cloudexit.providers.aws.costs.datetime") + def test_returns_all_when_none_processed(self, mock_dt): + mock_dt.now.return_value = datetime(2026, 6, 15, tzinfo=timezone.utc) + mock_dt.strptime = datetime.strptime + + missing = get_missing_months_aws(set(), 6) + self.assertEqual(len(missing), 6) + + +class BuildAwsCostInventoryErrorTests(unittest.TestCase): + @patch("cloudexit.providers.aws.costs.connect") + @patch("cloudexit.providers.aws.costs.boto3.Session") + def test_passes_session_token_to_boto3_session( + self, mock_session_cls, mock_connect + ): + mock_session = MagicMock() + mock_session_cls.return_value = mock_session + mock_ce = MagicMock() + mock_session.client.return_value = mock_ce + mock_ce.get_cost_and_usage.return_value = {"ResultsByTime": []} + + mock_conn = MagicMock() + mock_connect.return_value.__enter__ = MagicMock(return_value=mock_conn) + mock_connect.return_value.__exit__ = MagicMock(return_value=False) + + from cloudexit.providers.aws.costs import build_aws_cost_inventory + + with tempfile.TemporaryDirectory() as tmp: + report_path = os.path.join(tmp, "report") + raw_data_path = os.path.join(tmp, "raw") + os.makedirs(os.path.join(report_path, "data"), exist_ok=True) + os.makedirs(raw_data_path, exist_ok=True) + + build_aws_cost_inventory( + 2, + { + "accessKey": "AK", + "secretKey": "SK", + "sessionToken": "TOKEN", + "region": "us-east-1", + }, + report_path, + raw_data_path, + ) + + mock_session_cls.assert_called_once_with( + aws_access_key_id="AK", + aws_secret_access_key="SK", + aws_session_token="TOKEN", + region_name="us-east-1", + ) + + @patch("cloudexit.providers.aws.costs.connect") + @patch("cloudexit.providers.aws.costs.boto3.Session") + def test_sqlite_error_is_logged_but_not_reraised( + self, mock_session_cls, mock_connect + ): + """sqlite3.Error is caught and logged but NOT re-raised in current code.""" + import sqlite3 + + mock_session = MagicMock() + mock_session_cls.return_value = mock_session + mock_ce = MagicMock() + mock_session.client.return_value = mock_ce + mock_ce.get_cost_and_usage.return_value = { + "ResultsByTime": [ + { + "TimePeriod": {"Start": "2026-01-01"}, + "Groups": [ + { + "Metrics": { + "UnblendedCost": {"Amount": "10.0", "Unit": "USD"} + } + } + ], + } + ] + } + + mock_conn = MagicMock() + mock_connect.return_value.__enter__ = MagicMock(return_value=mock_conn) + mock_connect.return_value.__exit__ = MagicMock(return_value=False) + mock_cursor = MagicMock() + mock_conn.cursor.return_value = mock_cursor + mock_cursor.execute.side_effect = sqlite3.Error("disk I/O error") + + from cloudexit.providers.aws.costs import build_aws_cost_inventory + + with tempfile.TemporaryDirectory() as tmp: + report_path = os.path.join(tmp, "report") + raw_data_path = os.path.join(tmp, "raw") + os.makedirs(os.path.join(report_path, "data"), exist_ok=True) + os.makedirs(raw_data_path, exist_ok=True) + + # sqlite3.Error is caught but NOT re-raised in current code + # (this documents the current behavior) + try: + build_aws_cost_inventory( + 2, + {"accessKey": "AK", "secretKey": "SK", "region": "us-east-1"}, + report_path, + raw_data_path, + ) + except sqlite3.Error: + pass # Expected: current code catches but does not re-raise sqlite3.Error + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/providers/aws/test_egress.py b/tests/providers/aws/test_egress.py new file mode 100644 index 0000000..06abe8c --- /dev/null +++ b/tests/providers/aws/test_egress.py @@ -0,0 +1,905 @@ +# tests/providers/aws/test_egress.py +import unittest +from unittest.mock import MagicMock, patch + +from botocore.exceptions import ClientError + +from cloudexit.egress.model import GIB +from cloudexit.providers.aws.egress import ( + _collect_dynamodb_tables, + _collect_list_item_size, + _collect_not_sizeable, + _collect_rds_instances, + _collect_s3_buckets, + _list_buckets_in_region, + collect_aws_egress, + fetch_latest_metric_values, +) + +REGION = "eu-central-1" + +S3_CODE = "AWS.s3.list_buckets.Buckets" +VOLUME_CODE = "AWS.ec2.describe_volumes.Volumes" +SNAPSHOT_CODE = "AWS.ec2.describe_snapshots.Snapshots" +RDS_CODE = "AWS.rds.describe_db_instances.DBInstances" +DYNAMODB_CODE = "AWS.dynamodb.list_tables.TableNames" +# The catalogue row enumerates backup plans; sizing overrides it with vaults. +BACKUP_CODE = "AWS.backup.list_backup_plans.BackupPlansList" + +# Stands in for what load_egress_registry() builds out of resourcetype_data, +# so these tests exercise the engine rather than the shipped master data. +TEST_REGISTRY = { + S3_CODE: { + "category": "object", + "label": "S3 (Simple Storage Service)", + "strategy": "s3_bucket_metrics", + "enumeration": { + "service": "s3", + "operation": "list_buckets", + "result_path": ["Buckets"], + }, + }, + VOLUME_CODE: { + "category": "block", + "label": "Elastic Block Store (EBS)", + "strategy": "ebs_volumes", + "enumeration": { + "service": "ec2", + "operation": "describe_volumes", + "result_path": ["Volumes"], + }, + "sizing": { + "id_field": "VolumeId", + "name_tag": "Name", + "size_field": "Size", + "size_unit": "GiB", + "flags": ["allocated (upper bound)"], + }, + }, + SNAPSHOT_CODE: { + "category": "block", + "label": "EBS Snapshots", + "strategy": "ebs_snapshots", + "enumeration": { + "service": "ec2", + "operation": "describe_snapshots", + "result_path": ["Snapshots"], + "kwargs": {"OwnerIds": ["self"]}, + }, + "sizing": { + "id_field": "SnapshotId", + "size_field": "VolumeSize", + "size_unit": "GiB", + "flags": ["allocated (upper bound)"], + "notes": ["incremental – shares blocks with sibling snapshots"], + }, + }, + RDS_CODE: { + "category": "database", + "label": "RDS (Relational Database Service)", + "strategy": "rds_instances", + "enumeration": { + "service": "rds", + "operation": "describe_db_instances", + "result_path": ["DBInstances"], + }, + }, + DYNAMODB_CODE: { + "category": "database", + "label": "DynamoDB", + "strategy": "dynamodb_tables", + "enumeration": { + "service": "dynamodb", + "operation": "list_tables", + "result_path": ["TableNames"], + }, + }, + BACKUP_CODE: { + "category": "backup", + "label": "Backup", + "strategy": "backup_vaults", + "enumeration": { + "service": "backup", + "operation": "list_backup_vaults", + "result_path": ["BackupVaultList"], + }, + "sizing": { + "id_field": "BackupVaultName", + "arn_field": "BackupVaultArn", + "flags": ["backup vault – not sized"], + "count_field": "NumberOfRecoveryPoints", + "count_note": "{count} recovery points (cannot be exported directly)", + }, + }, +} + +_REGISTRY_PATCHER = None + + +def setUpModule(): + global _REGISTRY_PATCHER + _REGISTRY_PATCHER = patch( + "cloudexit.providers.aws.egress.load_egress_registry", + return_value=TEST_REGISTRY, + ) + _REGISTRY_PATCHER.start() + + +def tearDownModule(): + _REGISTRY_PATCHER.stop() + + +def _client_error(code, operation): + return ClientError({"Error": {"Code": code, "Message": code}}, operation) + + +def _mock_session(clients): + session = MagicMock() + session.client.side_effect = lambda service, **kwargs: clients[service] + return session + + +def _mock_paginator(client, pages): + paginator = MagicMock() + paginator.paginate.return_value = pages + client.get_paginator.return_value = paginator + return paginator + + +class FetchLatestMetricValuesTests(unittest.TestCase): + def test_returns_latest_value_per_query(self): + cloudwatch = MagicMock() + cloudwatch.get_metric_data.return_value = { + "MetricDataResults": [ + {"Id": "q0", "Values": [123.0, 50.0]}, + {"Id": "q1", "Values": []}, + ] + } + specs = [ + {"id": "q0", "namespace": "AWS/S3", "metric_name": "m", "dimensions": []}, + {"id": "q1", "namespace": "AWS/S3", "metric_name": "m", "dimensions": []}, + ] + + result = fetch_latest_metric_values(cloudwatch, specs) + + # ScanBy=TimestampDescending: the first value is the latest datapoint. + self.assertEqual(result, {"q0": 123.0, "q1": None}) + kwargs = cloudwatch.get_metric_data.call_args.kwargs + self.assertEqual(kwargs["ScanBy"], "TimestampDescending") + + def test_merges_values_across_pages(self): + cloudwatch = MagicMock() + cloudwatch.get_metric_data.side_effect = [ + { + "MetricDataResults": [{"Id": "q0", "Values": []}], + "NextToken": "page-2", + }, + {"MetricDataResults": [{"Id": "q0", "Values": [42.0]}]}, + ] + specs = [ + {"id": "q0", "namespace": "AWS/S3", "metric_name": "m", "dimensions": []}, + ] + + result = fetch_latest_metric_values(cloudwatch, specs) + + self.assertEqual(result, {"q0": 42.0}) + + def test_empty_specs_return_empty_dict_without_api_call(self): + cloudwatch = MagicMock() + + self.assertEqual(fetch_latest_metric_values(cloudwatch, []), {}) + cloudwatch.get_metric_data.assert_not_called() + + def test_returns_none_on_client_error(self): + cloudwatch = MagicMock() + cloudwatch.get_metric_data.side_effect = _client_error( + "AccessDenied", "GetMetricData" + ) + specs = [ + {"id": "q0", "namespace": "AWS/S3", "metric_name": "m", "dimensions": []}, + ] + + self.assertIsNone(fetch_latest_metric_values(cloudwatch, specs)) + + +class ListBucketsInRegionTests(unittest.TestCase): + def test_falls_back_to_bucket_region_field_when_filter_unsupported(self): + s3_client = MagicMock() + s3_client.list_buckets.side_effect = [ + _client_error("InvalidRequest", "ListBuckets"), + { + "Buckets": [ + {"Name": "data-eu", "BucketRegion": REGION}, + {"Name": "data-us", "BucketRegion": "us-east-1"}, + ] + }, + ] + + names = _list_buckets_in_region( + s3_client, REGION, TEST_REGISTRY[S3_CODE]["enumeration"] + ) + + self.assertEqual(names, ["data-eu"]) + s3_client.get_bucket_location.assert_not_called() + + def test_falls_back_to_get_bucket_location_when_field_missing(self): + s3_client = MagicMock() + s3_client.list_buckets.side_effect = [ + _client_error("InvalidRequest", "ListBuckets"), + {"Buckets": [{"Name": "data-eu"}, {"Name": "legacy-us"}]}, + ] + s3_client.get_bucket_location.side_effect = lambda Bucket: { + # us-east-1 buckets report None via the legacy API. + "LocationConstraint": REGION if Bucket == "data-eu" else None + } + + names = _list_buckets_in_region( + s3_client, REGION, TEST_REGISTRY[S3_CODE]["enumeration"] + ) + + self.assertEqual(names, ["data-eu"]) + + def test_bucket_is_skipped_when_location_lookup_is_denied(self): + s3_client = MagicMock() + s3_client.list_buckets.side_effect = [ + _client_error("InvalidRequest", "ListBuckets"), + {"Buckets": [{"Name": "no-access"}]}, + ] + s3_client.get_bucket_location.side_effect = _client_error( + "AccessDenied", "GetBucketLocation" + ) + + self.assertEqual( + _list_buckets_in_region( + s3_client, REGION, TEST_REGISTRY[S3_CODE]["enumeration"] + ), + [], + ) + + +class S3BucketCollectorTests(unittest.TestCase): + def _clients(self, bucket_locations, metrics, metric_values): + s3_client = MagicMock() + + def list_buckets(**kwargs): + names = bucket_locations + if "BucketRegion" in kwargs: + names = [ + name + for name, location in bucket_locations.items() + if (location or "us-east-1") == kwargs["BucketRegion"] + ] + return {"Buckets": [{"Name": name} for name in names]} + + s3_client.list_buckets.side_effect = list_buckets + s3_client.get_bucket_location.side_effect = lambda Bucket: { + "LocationConstraint": bucket_locations[Bucket] + } + s3_client.get_bucket_replication.side_effect = _client_error( + "ReplicationConfigurationNotFoundError", "GetBucketReplication" + ) + cloudwatch = MagicMock() + cloudwatch.list_metrics.return_value = {"Metrics": metrics} + cloudwatch.get_metric_data.return_value = { + "MetricDataResults": [ + {"Id": query_id, "Values": [value]} + for query_id, value in metric_values.items() + ] + } + return {"s3": s3_client, "cloudwatch": cloudwatch} + + @staticmethod + def _size_metric(bucket, storage_type): + return { + "Dimensions": [ + {"Name": "BucketName", "Value": bucket}, + {"Name": "StorageType", "Value": storage_type}, + ] + } + + def test_storage_type_split_archive_flag_and_region_filter(self): + clients = self._clients( + bucket_locations={"data-eu": REGION, "data-us": None}, + metrics=[ + self._size_metric("data-eu", "StandardStorage"), + self._size_metric("data-eu", "GlacierStorage"), + self._size_metric("data-eu", "DeepArchiveStorage"), + ], + metric_values={ + "q0": float(50 * GIB), + "q1": float(8 * GIB), + "q2": float(2 * GIB), + }, + ) + entry = TEST_REGISTRY[S3_CODE] + + rows = _collect_s3_buckets(_mock_session(clients), REGION, S3_CODE, entry) + + # The us-east-1 bucket is outside the assessment region, and the + # filter must not rely on GetBucketLocation (not in ViewOnlyAccess). + self.assertEqual([row["name"] for row in rows], ["data-eu"]) + clients["s3"].list_buckets.assert_called_once_with(BucketRegion=REGION) + clients["s3"].get_bucket_location.assert_not_called() + row = rows[0] + self.assertEqual(row["size_bytes"], 60 * GIB) + self.assertEqual( + row["tier_bytes"], + {"Standard": 50 * GIB, "Glacier": 8 * GIB, "Deep Archive": 2 * GIB}, + ) + self.assertTrue(any("restore required" in flag for flag in row["flags"])) + + def test_no_datapoints_records_unknown_size(self): + clients = self._clients( + bucket_locations={"fresh-bucket": REGION}, + metrics=[self._size_metric("fresh-bucket", "StandardStorage")], + metric_values={}, + ) + entry = TEST_REGISTRY[S3_CODE] + + rows = _collect_s3_buckets(_mock_session(clients), REGION, S3_CODE, entry) + + self.assertIsNone(rows[0]["size_bytes"]) + self.assertTrue(rows[0]["size_unknown"]) + + def test_replication_configuration_adds_note(self): + clients = self._clients( + bucket_locations={"replicated": REGION}, + metrics=[self._size_metric("replicated", "StandardStorage")], + metric_values={"q0": float(GIB)}, + ) + clients["s3"].get_bucket_replication.side_effect = None + clients["s3"].get_bucket_replication.return_value = { + "ReplicationConfiguration": {"Rules": [{}]} + } + entry = TEST_REGISTRY[S3_CODE] + + rows = _collect_s3_buckets(_mock_session(clients), REGION, S3_CODE, entry) + + self.assertTrue(any("replication" in note for note in rows[0]["notes"])) + + +class EbsCollectorTests(unittest.TestCase): + def test_volume_size_is_allocated_upper_bound(self): + ec2_client = MagicMock() + _mock_paginator( + ec2_client, + [ + { + "Volumes": [ + { + "VolumeId": "vol-1", + "Size": 100, + "Tags": [{"Key": "Name", "Value": "data-disk"}], + } + ] + } + ], + ) + entry = TEST_REGISTRY[VOLUME_CODE] + + rows = _collect_list_item_size( + _mock_session({"ec2": ec2_client}), REGION, VOLUME_CODE, entry + ) + + self.assertEqual(rows[0]["name"], "data-disk") + self.assertEqual(rows[0]["size_bytes"], 100 * GIB) + self.assertIn("allocated (upper bound)", rows[0]["flags"]) + + def test_snapshots_are_owned_only_and_carry_shared_block_note(self): + ec2_client = MagicMock() + paginator = _mock_paginator( + ec2_client, + [{"Snapshots": [{"SnapshotId": "snap-1", "VolumeSize": 50}]}], + ) + entry = TEST_REGISTRY[SNAPSHOT_CODE] + + rows = _collect_list_item_size( + _mock_session({"ec2": ec2_client}), REGION, SNAPSHOT_CODE, entry + ) + + paginator.paginate.assert_called_once_with(OwnerIds=["self"]) + self.assertEqual(rows[0]["size_bytes"], 50 * GIB) + self.assertTrue(any("shares blocks" in note for note in rows[0]["notes"])) + + def test_owner_filter_comes_from_entry_kwargs_not_the_source(self): + # The master data and the old hardcoded filter agree today, so only a + # changed entry proves the call is actually data-driven. + ec2_client = MagicMock() + paginator = _mock_paginator(ec2_client, [{"Snapshots": []}]) + entry = { + **TEST_REGISTRY[SNAPSHOT_CODE], + "enumeration": { + **TEST_REGISTRY[SNAPSHOT_CODE]["enumeration"], + "kwargs": {"OwnerIds": ["123456"]}, + }, + } + + _collect_list_item_size( + _mock_session({"ec2": ec2_client}), REGION, SNAPSHOT_CODE, entry + ) + + paginator.paginate.assert_called_once_with(OwnerIds=["123456"]) + + def test_entry_without_kwargs_passes_no_extra_arguments(self): + ec2_client = MagicMock() + paginator = _mock_paginator(ec2_client, [{"Snapshots": []}]) + entry = { + **TEST_REGISTRY[SNAPSHOT_CODE], + "enumeration": { + key: value + for key, value in TEST_REGISTRY[SNAPSHOT_CODE]["enumeration"].items() + if key != "kwargs" + }, + } + + _collect_list_item_size( + _mock_session({"ec2": ec2_client}), REGION, SNAPSHOT_CODE, entry + ) + + paginator.paginate.assert_called_once_with() + + +class RdsCollectorTests(unittest.TestCase): + def _clients(self, instances, metric_results): + rds_client = MagicMock() + _mock_paginator(rds_client, [{"DBInstances": instances}]) + cloudwatch = MagicMock() + cloudwatch.get_metric_data.return_value = {"MetricDataResults": metric_results} + return {"rds": rds_client, "cloudwatch": cloudwatch} + + def test_used_space_computed_from_free_storage_space(self): + clients = self._clients( + instances=[ + { + "DBInstanceIdentifier": "appdb", + "Engine": "postgres", + "AllocatedStorage": 100, + } + ], + metric_results=[{"Id": "q0", "Values": [float(40 * GIB)]}], + ) + entry = TEST_REGISTRY[RDS_CODE] + + rows = _collect_rds_instances(_mock_session(clients), REGION, RDS_CODE, entry) + + self.assertEqual(rows[0]["size_bytes"], 60 * GIB) + self.assertTrue(any("allocated" in note for note in rows[0]["notes"])) + self.assertNotIn("allocated (upper bound)", rows[0]["flags"]) + + def test_missing_metric_falls_back_to_allocated_upper_bound(self): + clients = self._clients( + instances=[ + { + "DBInstanceIdentifier": "appdb", + "Engine": "mysql", + "AllocatedStorage": 100, + } + ], + metric_results=[{"Id": "q0", "Values": []}], + ) + entry = TEST_REGISTRY[RDS_CODE] + + rows = _collect_rds_instances(_mock_session(clients), REGION, RDS_CODE, entry) + + self.assertEqual(rows[0]["size_bytes"], 100 * GIB) + self.assertIn("allocated (upper bound)", rows[0]["flags"]) + + def test_aurora_instances_are_flagged_not_sized(self): + clients = self._clients( + instances=[ + { + "DBInstanceIdentifier": "aurora-1", + "Engine": "aurora-postgresql", + "AllocatedStorage": 1, + } + ], + metric_results=[], + ) + entry = TEST_REGISTRY[RDS_CODE] + + rows = _collect_rds_instances(_mock_session(clients), REGION, RDS_CODE, entry) + + self.assertIsNone(rows[0]["size_bytes"]) + self.assertTrue(any("Aurora" in flag for flag in rows[0]["flags"])) + clients["cloudwatch"].get_metric_data.assert_not_called() + + +class DynamoDbCollectorTests(unittest.TestCase): + def test_table_and_index_sizes_are_summed(self): + dynamodb_client = MagicMock() + _mock_paginator(dynamodb_client, [{"TableNames": ["orders"]}]) + dynamodb_client.describe_table.return_value = { + "Table": { + "TableArn": "arn:aws:dynamodb:eu-central-1:1:table/orders", + "TableSizeBytes": 1000, + "GlobalSecondaryIndexes": [ + {"IndexSizeBytes": 300}, + {"IndexSizeBytes": 200}, + ], + } + } + entry = TEST_REGISTRY[DYNAMODB_CODE] + + rows = _collect_dynamodb_tables( + _mock_session({"dynamodb": dynamodb_client}), REGION, DYNAMODB_CODE, entry + ) + + self.assertEqual(rows[0]["size_bytes"], 1500) + + def test_empty_table_is_zero_not_unknown(self): + dynamodb_client = MagicMock() + _mock_paginator(dynamodb_client, [{"TableNames": ["empty"]}]) + dynamodb_client.describe_table.return_value = {"Table": {"TableSizeBytes": 0}} + entry = TEST_REGISTRY[DYNAMODB_CODE] + + rows = _collect_dynamodb_tables( + _mock_session({"dynamodb": dynamodb_client}), REGION, DYNAMODB_CODE, entry + ) + + self.assertEqual(rows[0]["size_bytes"], 0) + self.assertFalse(rows[0]["size_unknown"]) + + +class BackupVaultCollectorTests(unittest.TestCase): + def test_vault_is_flagged_not_sized_with_recovery_point_note(self): + backup_client = MagicMock() + _mock_paginator( + backup_client, + [ + { + "BackupVaultList": [ + {"BackupVaultName": "prod-vault", "NumberOfRecoveryPoints": 12} + ] + } + ], + ) + entry = TEST_REGISTRY[BACKUP_CODE] + + rows = _collect_not_sizeable( + _mock_session({"backup": backup_client}), REGION, BACKUP_CODE, entry + ) + + self.assertIsNone(rows[0]["size_bytes"]) + self.assertFalse(rows[0]["size_unknown"]) + self.assertIn("backup vault – not sized", rows[0]["flags"]) + self.assertTrue(any("12 recovery points" in note for note in rows[0]["notes"])) + + def test_call_comes_from_entry_enumeration_not_the_catalogue_code(self): + # The catalogue row enumerates backup plans for the inventory scan; + # sizing needs the vaults, so the entry overrides the call. + backup_client = MagicMock() + _mock_paginator(backup_client, [{"BackupVaultList": []}]) + session = _mock_session({"backup": backup_client}) + + _collect_not_sizeable(session, REGION, BACKUP_CODE, TEST_REGISTRY[BACKUP_CODE]) + + self.assertEqual(session.client.call_args.args[0], "backup") + backup_client.get_paginator.assert_called_once_with("list_backup_vaults") + + def test_enumeration_drives_a_different_service_and_operation(self): + other_client = MagicMock() + _mock_paginator(other_client, [{"Vaults": [{"BackupVaultName": "v1"}]}]) + session = _mock_session({"otherservice": other_client}) + entry = { + **TEST_REGISTRY[BACKUP_CODE], + "enumeration": { + "service": "otherservice", + "operation": "list_vaults", + "result_path": ["Vaults"], + }, + } + + rows = _collect_not_sizeable(session, REGION, BACKUP_CODE, entry) + + self.assertEqual(session.client.call_args.args[0], "otherservice") + other_client.get_paginator.assert_called_once_with("list_vaults") + self.assertEqual([row["name"] for row in rows], ["v1"]) + + def test_enumeration_kwargs_are_forwarded(self): + backup_client = MagicMock() + paginator = _mock_paginator(backup_client, [{"BackupVaultList": []}]) + entry = { + **TEST_REGISTRY[BACKUP_CODE], + "enumeration": { + **TEST_REGISTRY[BACKUP_CODE]["enumeration"], + "kwargs": {"ByVaultType": "BACKUP_VAULT"}, + }, + } + + _collect_not_sizeable( + _mock_session({"backup": backup_client}), REGION, BACKUP_CODE, entry + ) + + paginator.paginate.assert_called_once_with(ByVaultType="BACKUP_VAULT") + + def test_nested_result_path_is_walked(self): + backup_client = MagicMock() + _mock_paginator( + backup_client, [{"Outer": {"Inner": [{"BackupVaultName": "nested"}]}}] + ) + entry = { + **TEST_REGISTRY[BACKUP_CODE], + "enumeration": { + "service": "backup", + "operation": "list_backup_vaults", + "result_path": ["Outer", "Inner"], + }, + } + + rows = _collect_not_sizeable( + _mock_session({"backup": backup_client}), REGION, BACKUP_CODE, entry + ) + + self.assertEqual([row["name"] for row in rows], ["nested"]) + + +class CollectAwsEgressTests(unittest.TestCase): + _PROVIDER_DETAILS = { + "accessKey": "AKIAIOSFODNN7EXAMPLE", + "secretKey": "secret", + "region": REGION, + } + + def _empty_clients(self): + s3_client = MagicMock() + s3_client.list_buckets.return_value = {"Buckets": []} + clients = {"s3": s3_client, "cloudwatch": MagicMock()} + for service, result_key in ( + ("ec2", "Volumes"), + ("rds", "DBInstances"), + ("dynamodb", "TableNames"), + ("backup", "BackupVaultList"), + ): + client = MagicMock() + _mock_paginator(client, [{result_key: []}]) + clients[service] = client + return clients + + @patch("cloudexit.providers.aws.egress.boto3") + def test_returns_rows_and_archive_tiers(self, mock_boto3): + clients = self._empty_clients() + mock_boto3.Session.return_value = _mock_session(clients) + + rows, archive_tiers = collect_aws_egress(self._PROVIDER_DETAILS) + + self.assertEqual(rows, []) + self.assertEqual(archive_tiers, {"Archive", "Glacier", "Deep Archive"}) + mock_boto3.Session.assert_called_once_with( + aws_access_key_id="AKIAIOSFODNN7EXAMPLE", + aws_secret_access_key="secret", + aws_session_token=None, + region_name=REGION, + ) + + @patch("cloudexit.providers.aws.egress.boto3") + def test_one_failing_service_does_not_fail_the_stage(self, mock_boto3): + clients = self._empty_clients() + clients["s3"].list_buckets.side_effect = _client_error( + "AccessDenied", "ListBuckets" + ) + volume_paginator = MagicMock() + volume_paginator.paginate.return_value = [ + {"Volumes": [{"VolumeId": "vol-1", "Size": 10}]} + ] + clients["ec2"].get_paginator.return_value = volume_paginator + mock_boto3.Session.return_value = _mock_session(clients) + + rows, _ = collect_aws_egress(self._PROVIDER_DETAILS) + + # Volumes and snapshots share the ec2 paginator mock here; the point + # is that the S3 failure is contained and other rows still arrive. + self.assertTrue(any(row["id"] == "vol-1" for row in rows)) + + +class UnknownStrategyTests(unittest.TestCase): + @patch("cloudexit.providers.aws.egress.boto3") + def test_unknown_strategy_warns_and_skips_without_raising(self, mock_boto3): + registry = { + "AWS.future.list_things.Things": { + "category": "object", + "label": "Something New", + "strategy": "not_implemented_yet", + }, + VOLUME_CODE: TEST_REGISTRY[VOLUME_CODE], + } + ec2_client = MagicMock() + _mock_paginator(ec2_client, [{"Volumes": [{"VolumeId": "vol-1", "Size": 10}]}]) + mock_boto3.Session.return_value = _mock_session({"ec2": ec2_client}) + + with patch( + "cloudexit.providers.aws.egress.load_egress_registry", return_value=registry + ): + with self.assertLogs( + "cloudexit.providers.aws.egress", level="WARNING" + ) as captured: + rows, _ = collect_aws_egress( + { + "accessKey": "AKIAIOSFODNN7EXAMPLE", + "secretKey": "secret", + "region": REGION, + } + ) + + # The known strategy still runs; only the unknown one is skipped. + self.assertEqual([row["id"] for row in rows], ["vol-1"]) + self.assertIn("not_implemented_yet", captured.output[0]) + + +class CollectorFailureVisibilityTests(unittest.TestCase): + @patch("cloudexit.providers.aws.egress.boto3") + def test_a_dropped_resource_type_is_warned_not_buried_at_debug(self, mock_boto3): + # A master-data row missing its sizing block removes a whole resource + # type from the estimate; that must be visible in run.log. + registry = {VOLUME_CODE: {**TEST_REGISTRY[VOLUME_CODE]}} + del registry[VOLUME_CODE]["sizing"] + ec2_client = MagicMock() + _mock_paginator(ec2_client, [{"Volumes": [{"VolumeId": "vol-1", "Size": 10}]}]) + mock_boto3.Session.return_value = _mock_session({"ec2": ec2_client}) + + with patch( + "cloudexit.providers.aws.egress.load_egress_registry", return_value=registry + ): + with self.assertLogs( + "cloudexit.providers.aws.egress", level="WARNING" + ) as captured: + rows, _ = collect_aws_egress( + { + "accessKey": "AKIAIOSFODNN7EXAMPLE", + "secretKey": "secret", + "region": REGION, + } + ) + + self.assertEqual(rows, []) + self.assertIn(VOLUME_CODE, captured.output[0]) + self.assertIn("KeyError", captured.output[0]) + self.assertIn("missing from the estimate", captured.output[0]) + + +class ParameterisedSizingTests(unittest.TestCase): + def _entry(self, sizing, result_key="FileSystems"): + return { + "category": "block", + "label": "Elastic File System", + "strategy": "ebs_volumes", + "enumeration": { + "service": "elasticfilesystem", + "operation": "describe_file_systems", + "result_path": [result_key], + }, + "sizing": sizing, + } + + def _collect(self, entry, items, result_key="FileSystems"): + client = MagicMock() + _mock_paginator(client, [{result_key: items}]) + return _collect_list_item_size( + _mock_session({"elasticfilesystem": client}), REGION, "AWS.efs", entry + ) + + def test_nested_size_field_and_byte_unit(self): + entry = self._entry( + { + "id_field": "FileSystemId", + "name_field": "Name", + "size_field": ["SizeInBytes", "Value"], + "size_unit": "B", + "flags": ["metered size"], + } + ) + + rows = self._collect( + entry, + [ + { + "FileSystemId": "fs-1", + "Name": "shared", + "SizeInBytes": {"Value": 4096}, + } + ], + ) + + self.assertEqual(rows[0]["size_bytes"], 4096) + self.assertEqual(rows[0]["name"], "shared") + self.assertEqual(rows[0]["flags"], ["metered size"]) + + def test_missing_nested_size_is_unknown_not_zero(self): + entry = self._entry( + { + "id_field": "FileSystemId", + "size_field": ["SizeInBytes", "Value"], + "size_unit": "B", + } + ) + + rows = self._collect(entry, [{"FileSystemId": "fs-1"}]) + + self.assertIsNone(rows[0]["size_bytes"]) + self.assertTrue(rows[0]["size_unknown"]) + + def test_flags_and_notes_are_withheld_when_size_is_unknown(self): + entry = self._entry( + { + "id_field": "FileSystemId", + "size_field": "Size", + "flags": ["allocated"], + "notes": ["incremental"], + } + ) + + rows = self._collect(entry, [{"FileSystemId": "fs-1"}]) + + self.assertEqual(rows[0]["flags"], []) + self.assertEqual(rows[0]["notes"], []) + + def test_name_tag_falls_back_to_the_identifier(self): + entry = self._entry( + {"id_field": "FileSystemId", "name_tag": "Name", "size_field": "Size"} + ) + + rows = self._collect( + entry, [{"FileSystemId": "fs-1", "Tags": [{"Key": "env", "Value": "prod"}]}] + ) + + self.assertEqual(rows[0]["name"], "fs-1") + + def test_arn_field_falls_back_to_the_identifier_when_absent(self): + client = MagicMock() + _mock_paginator(client, [{"Vaults": [{"VaultName": "v1"}]}]) + entry = { + "category": "backup", + "label": "Some Vault", + "strategy": "backup_vaults", + "enumeration": { + "service": "glacier", + "operation": "list_vaults", + "result_path": ["Vaults"], + }, + "sizing": { + "id_field": "VaultName", + "arn_field": "VaultARN", + "flags": ["not sized"], + "count_field": "NumberOfArchives", + "count_note": "{count} archives held", + }, + } + + rows = _collect_not_sizeable( + _mock_session({"glacier": client}), REGION, "AWS.glacier", entry + ) + + self.assertEqual(rows[0]["id"], "v1") + self.assertEqual(rows[0]["flags"], ["not sized"]) + self.assertEqual(rows[0]["notes"], []) + + def test_count_note_template_is_filled_from_the_item(self): + client = MagicMock() + _mock_paginator( + client, [{"Vaults": [{"VaultName": "v1", "NumberOfArchives": 7}]}] + ) + entry = { + "category": "backup", + "label": "Some Vault", + "strategy": "backup_vaults", + "enumeration": { + "service": "glacier", + "operation": "list_vaults", + "result_path": ["Vaults"], + }, + "sizing": { + "id_field": "VaultName", + "flags": [], + "count_field": "NumberOfArchives", + "count_note": "{count} archives held", + }, + } + + rows = _collect_not_sizeable( + _mock_session({"glacier": client}), REGION, "AWS.glacier", entry + ) + + self.assertEqual(rows[0]["notes"], ["7 archives held"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/providers/aws/test_inventory.py b/tests/providers/aws/test_inventory.py new file mode 100644 index 0000000..ad73ddc --- /dev/null +++ b/tests/providers/aws/test_inventory.py @@ -0,0 +1,316 @@ +# tests/providers/aws/test_inventory.py +import logging +import os +import tempfile +import unittest +from unittest.mock import MagicMock, patch + +import botocore.exceptions + +from cloudexit.providers.aws.client import MAX_ITEMS_PER_RESOURCE_TYPE + + +class BuildAwsResourceInventoryErrorTests(unittest.TestCase): + @patch("cloudexit.providers.aws.inventory.load_data") + @patch("cloudexit.providers.aws.inventory.boto3.Session") + def test_outer_exception_is_logged_silently(self, mock_session_cls, mock_load_data): + """build_aws_resource_inventory catches all outer exceptions silently.""" + mock_load_data.side_effect = RuntimeError("DB unavailable") + + from cloudexit.providers.aws.inventory import build_aws_resource_inventory + + with tempfile.TemporaryDirectory() as tmp: + report_path = os.path.join(tmp, "report") + raw_data_path = os.path.join(tmp, "raw") + os.makedirs(os.path.join(report_path, "data"), exist_ok=True) + os.makedirs(raw_data_path, exist_ok=True) + + # Should not raise -- outer except swallows everything + build_aws_resource_inventory( + 2, + {"accessKey": "AK", "secretKey": "SK", "region": "us-east-1"}, + report_path, + raw_data_path, + ) + + +class BuildAwsResourceInventoryPerServiceTests(unittest.TestCase): + @patch("cloudexit.providers.aws.inventory.connect") + @patch("cloudexit.providers.aws.inventory.paginate_or_call") + @patch("cloudexit.providers.aws.inventory.boto3.Session") + @patch("cloudexit.providers.aws.inventory.load_data") + def test_failed_service_is_skipped_and_logged_at_debug( + self, mock_load_data, mock_session_cls, mock_poc, mock_connect + ): + mock_load_data.return_value = [ + { + "code": "AWS.ec2.describe_instances.Reservations", + "id": 1, + "name": "EC2", + "csp": 2, + "status": "t", + }, + { + "code": "AWS.s3.list_buckets.Buckets", + "id": 2, + "name": "S3", + "csp": 2, + "status": "t", + }, + ] + # First service raises (e.g. AccessDenied); second returns resources. + mock_poc.side_effect = [ + botocore.exceptions.ClientError( + {"Error": {"Code": "AccessDenied", "Message": "no"}}, + "DescribeInstances", + ), + [{"InstanceId": "i-1"}], + ] + mock_connect.return_value.__enter__.return_value = MagicMock() + + from cloudexit.providers.aws.inventory import build_aws_resource_inventory + + with tempfile.TemporaryDirectory() as tmp: + report_path = os.path.join(tmp, "report") + raw_data_path = os.path.join(tmp, "raw") + os.makedirs(os.path.join(report_path, "data"), exist_ok=True) + os.makedirs(raw_data_path, exist_ok=True) + + with self.assertLogs("cloudexit.providers.aws", level="DEBUG") as cm: + build_aws_resource_inventory( + 2, + {"accessKey": "AK", "secretKey": "SK", "region": "us-east-1"}, + report_path, + raw_data_path, + ) + + # Loop continued past the failing service to the next one. + self.assertEqual(mock_poc.call_count, 2) + # The failure was recorded at DEBUG, naming the failed service... + self.assertTrue( + any( + r.levelno == logging.DEBUG and "ec2" in r.getMessage() + for r in cm.records + ) + ) + # ...and never escalated to WARNING/ERROR (stays off the console). + self.assertFalse(any(r.levelno >= logging.WARNING for r in cm.records)) + + +class BuildAwsResourceInventorySpecTests(unittest.TestCase): + """The scanner drives the resolved spec, not the raw code string.""" + + def _row(self, id_, code, name, params="{}"): + return { + "code": code, + "id": id_, + "name": name, + "csp": 2, + "status": "t", + "params": params, + } + + def _run(self, rows, poc_return=None): + """Run the scanner with load_data/boto3/paginate_or_call mocked out. + + Returns (paginate_or_call mock, captured log records). A plain handler + is used instead of assertLogs because a clean scan logs nothing at all. + """ + records: list[logging.LogRecord] = [] + + class _Capture(logging.Handler): + def emit(self, record): + records.append(record) + + aws_logger = logging.getLogger("cloudexit.providers.aws") + handler = _Capture() + previous_level = aws_logger.level + aws_logger.addHandler(handler) + aws_logger.setLevel(logging.DEBUG) + + try: + with tempfile.TemporaryDirectory() as tmp: + report_path = os.path.join(tmp, "report") + raw_data_path = os.path.join(tmp, "raw") + os.makedirs(os.path.join(report_path, "data"), exist_ok=True) + os.makedirs(raw_data_path, exist_ok=True) + + with ( + patch( + "cloudexit.providers.aws.inventory.load_data", return_value=rows + ), + patch("cloudexit.providers.aws.inventory.boto3.Session"), + patch("cloudexit.providers.aws.inventory.connect") as mock_connect, + patch( + "cloudexit.providers.aws.inventory.paginate_or_call" + ) as mock_poc, + ): + mock_connect.return_value.__enter__.return_value = MagicMock() + mock_poc.return_value = [] if poc_return is None else poc_return + + from cloudexit.providers.aws.inventory import ( + build_aws_resource_inventory, + ) + + build_aws_resource_inventory( + 2, + {"accessKey": "AK", "secretKey": "SK", "region": "us-east-1"}, + report_path, + raw_data_path, + ) + return mock_poc, records + finally: + aws_logger.removeHandler(handler) + aws_logger.setLevel(previous_level) + + def test_params_drive_the_call_for_nested_result_path(self): + rows = [ + self._row( + 1, + "AWS.cloudfront.list_distributions.DistributionList.Items", + "CloudFront", + '{"service": "cloudfront", "operation": "list_distributions", ' + '"result_path": ["DistributionList", "Items"]}', + ) + ] + mock_poc, records = self._run(rows, poc_return=[{"Id": "E1"}]) + + args, kwargs = mock_poc.call_args + self.assertEqual(args[1], "list_distributions") + self.assertEqual(args[2], ["DistributionList", "Items"]) + self.assertEqual(kwargs, {}) + self.assertFalse(any(r.levelno >= logging.WARNING for r in records)) + + def test_params_kwargs_are_forwarded_to_the_call(self): + rows = [ + self._row( + 1, + "AWS.ec2.describe_snapshots.Snapshots", + "EBS Snapshot", + '{"service": "ec2", "operation": "describe_snapshots", ' + '"result_path": ["Snapshots"], "kwargs": {"OwnerIds": ["self"]}}', + ) + ] + mock_poc, _ = self._run(rows) + + _, kwargs = mock_poc.call_args + self.assertEqual(kwargs, {"OwnerIds": ["self"]}) + + def test_code_is_used_when_params_are_empty(self): + rows = [self._row(1, "AWS.s3.list_buckets.Buckets", "S3")] + mock_poc, _ = self._run(rows) + + args, _ = mock_poc.call_args + self.assertEqual(args[1], "list_buckets") + self.assertEqual(args[2], ["Buckets"]) + + def test_two_part_placeholder_is_skipped_at_debug(self): + rows = [self._row(1, "AWS.iam", "IAM")] + mock_poc, records = self._run(rows) + + mock_poc.assert_not_called() + self.assertFalse(any(r.levelno >= logging.WARNING for r in records)) + self.assertTrue( + any( + "AWS.iam" in r.getMessage() + for r in records + if r.levelno == logging.DEBUG + ) + ) + + def test_malformed_code_is_skipped_with_warning(self): + rows = [self._row(1, "AWS.ec2.describe_instances", "Broken")] + mock_poc, records = self._run(rows) + + mock_poc.assert_not_called() + self.assertTrue( + any( + r.levelno == logging.WARNING + and "AWS.ec2.describe_instances" in r.getMessage() + for r in records + ) + ) + + def test_item_cap_hit_logs_warning(self): + rows = [self._row(1, "AWS.ec2.describe_images.Images", "AMI")] + with patch("cloudexit.providers.aws.inventory.MAX_ITEMS_PER_RESOURCE_TYPE", 3): + _, records = self._run(rows, poc_return=[{"ImageId": "ami"}] * 3) + + self.assertTrue( + any( + r.levelno == logging.WARNING + and "describe_images" in r.getMessage() + and "ec2" in r.getMessage() + for r in records + ) + ) + + def test_per_row_debug_line_names_the_spec_source(self): + rows = [ + self._row( + 1, + "AWS.cloudfront.list_distributions.DistributionList.Items", + "CloudFront", + '{"service": "cloudfront", "operation": "list_distributions", ' + '"result_path": ["DistributionList", "Items"]}', + ), + self._row(2, "AWS.s3.list_buckets.Buckets", "S3"), + ] + _, records = self._run(rows) + messages = [r.getMessage() for r in records if r.levelno == logging.DEBUG] + + self.assertTrue( + any("AWS.cloudfront" in m and "from params" in m for m in messages), + messages, + ) + self.assertTrue( + any("AWS.s3.list_buckets" in m and "from code" in m for m in messages), + messages, + ) + + def test_summary_line_counts_every_resolution_outcome(self): + rows = [ + self._row( + 1, + "AWS.cloudfront.list_distributions.DistributionList.Items", + "CloudFront", + '{"service": "cloudfront", "operation": "list_distributions", ' + '"result_path": ["DistributionList", "Items"]}', + ), + self._row(2, "AWS.s3.list_buckets.Buckets", "S3"), + self._row(3, "AWS.iam", "IAM"), + self._row(4, "AWS.ec2.describe_instances", "Broken"), + ] + _, records = self._run(rows) + summary = [ + r.getMessage() + for r in records + if r.levelno == logging.INFO + and "Resource type resolution" in r.getMessage() + ] + + self.assertEqual(len(summary), 1, records) + self.assertEqual( + summary[0], + "Resource type resolution: 2 of 4 rows scanned (1 from params, " + "1 from code), 1 placeholders skipped, 1 invalid.", + ) + + def test_summary_is_info_so_it_stays_off_the_default_console(self): + rows = [self._row(1, "AWS.s3.list_buckets.Buckets", "S3")] + _, records = self._run(rows) + + summary = [r for r in records if "Resource type resolution" in r.getMessage()] + self.assertEqual([r.levelno for r in summary], [logging.INFO]) + + def test_item_cap_is_passed_to_paginate_or_call(self): + + rows = [self._row(1, "AWS.s3.list_buckets.Buckets", "S3")] + mock_poc, _ = self._run(rows) + + args, _ = mock_poc.call_args + self.assertEqual(args[3], MAX_ITEMS_PER_RESOURCE_TYPE) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/providers/azure/__init__.py b/tests/providers/azure/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/providers/azure/test_costs.py b/tests/providers/azure/test_costs.py new file mode 100644 index 0000000..05e1c0b --- /dev/null +++ b/tests/providers/azure/test_costs.py @@ -0,0 +1,422 @@ +import logging +import os +import sqlite3 +import tempfile +import unittest +from datetime import date +from unittest.mock import MagicMock, Mock, patch + +from azure.core.exceptions import ClientAuthenticationError, HttpResponseError + +from cloudexit.cli import app +from cloudexit.engine import create_cost_inventory +from cloudexit.providers.azure.costs import ( + COST_RATE_LIMITED, + CostRateLimited, + build_azure_cost_inventory, + get_missing_months_azure, + query_costs, + retry_wait, +) +from cloudexit import codes + +COST_HEADER = "x-ms-ratelimit-microsoft.costmanagement-entity-retry-after" + + +def http_error(status, **headers): + response = Mock(status_code=status, headers=headers, reason="Too Many Requests") + return HttpResponseError(response=response) + + +def cost_result(rows): + result = MagicMock() + result.rows = rows + result.as_dict.return_value = {"properties": {"rows": rows}} + return result + + +class RetryWaitTests(unittest.TestCase): + def test_the_wait_follows_retry_after(self): + self.assertEqual(retry_wait(http_error(429, **{"Retry-After": "37"}), 0), 37) + + def test_the_wait_follows_the_cost_management_headers(self): + error = http_error(429, **{COST_HEADER: "12"}) + self.assertEqual(retry_wait(error, 0), 12) + + def test_the_longest_wait_the_response_asks_for_is_used(self): + error = http_error( + 429, + **{ + "Retry-After": "5", + COST_HEADER: "40", + "x-ms-ratelimit-microsoft.costmanagement-tenant-retry-after": "25", + }, + ) + self.assertEqual(retry_wait(error, 0), 40) + + def test_without_a_usable_header_the_wait_grows_20_40_60(self): + for headers in ({}, {"Retry-After": "soon"}, {"x-other-retry-after": "9"}): + with self.subTest(headers=headers): + error = http_error(429, **headers) + self.assertEqual( + [retry_wait(error, attempt) for attempt in range(5)], + [20, 40, 60, 60, 60], + ) + + def test_an_error_without_a_response_uses_the_defaults(self): + self.assertEqual(retry_wait(RuntimeError("x"), 1), 40) + + +class QueryCostsTests(unittest.TestCase): + def setUp(self): + sleep = patch("cloudexit.providers.azure.costs.time.sleep") + notice = patch("cloudexit.providers.azure.costs.print_step") + self.sleep = sleep.start() + self.notice = notice.start() + self.addCleanup(sleep.stop) + self.addCleanup(notice.stop) + self.client = MagicMock() + + def run_query(self, *answers): + self.client.query.usage.side_effect = list(answers) + return query_costs(self.client, "/scope", "query") + + def test_a_first_answer_needs_no_wait(self): + result = self.run_query("rows") + + self.assertEqual(result, "rows") + self.sleep.assert_not_called() + self.notice.assert_not_called() + + def test_429_then_200_succeeds_after_one_wait_that_follows_the_header(self): + result = self.run_query(http_error(429, **{"Retry-After": "37"}), "rows") + + self.assertEqual(result, "rows") + self.sleep.assert_called_once_with(37) + self.assertEqual(self.client.query.usage.call_count, 2) + + def test_the_wait_is_announced_in_a_step_line(self): + self.run_query(http_error(429, **{"Retry-After": "40"}), "rows") + + (call,) = self.notice.call_args_list + self.assertEqual( + call.args[0], "Azure cost API is rate limited, retrying in 40 s…" + ) + self.assertEqual(call.kwargs["status"], "warning") + + def test_without_headers_the_waits_are_20_40_and_60_seconds(self): + result = self.run_query( + http_error(429), http_error(429), http_error(429), "rows" + ) + + self.assertEqual(result, "rows") + self.assertEqual([c.args[0] for c in self.sleep.call_args_list], [20, 40, 60]) + + def test_four_429s_give_up_with_the_clear_message(self): + with self.assertRaises(CostRateLimited) as ctx: + self.run_query(*[http_error(429) for _ in range(4)]) + + self.assertEqual(str(ctx.exception), COST_RATE_LIMITED) + self.assertEqual(self.client.query.usage.call_count, 4) + self.assertEqual([c.args[0] for c in self.sleep.call_args_list], [20, 40, 60]) + + def test_a_long_wait_that_would_pass_three_minutes_in_total_gives_up(self): + with self.assertRaises(CostRateLimited): + self.run_query(*[http_error(429, **{"Retry-After": "100"})] * 4) + + self.assertEqual([c.args[0] for c in self.sleep.call_args_list], [100]) + self.assertEqual(self.client.query.usage.call_count, 2) + + def test_503_is_retried_too(self): + self.assertEqual(self.run_query(http_error(503), "rows"), "rows") + self.sleep.assert_called_once_with(20) + + def test_other_errors_are_not_retried(self): + for status in (400, 401, 403, 404, 500): + with self.subTest(status=status): + self.client.query.usage.reset_mock() + with self.assertRaises(HttpResponseError) as ctx: + self.run_query(http_error(status)) + self.assertEqual(ctx.exception.status_code, status) + self.assertEqual(self.client.query.usage.call_count, 1) + self.sleep.assert_not_called() + + def test_errors_that_are_not_http_errors_are_not_retried(self): + with self.assertRaises(RuntimeError): + self.run_query(RuntimeError("boom")) + self.sleep.assert_not_called() + + +class CostInventoryTests(unittest.TestCase): + DETAILS = { + "credential": MagicMock(), + "subscriptionId": "00000000-0000-0000-0000-000000000000", + "resourceGroupName": "rg", + } + + def setUp(self): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.report = os.path.join(tmp.name, "report") + self.raw = os.path.join(tmp.name, "raw") + os.makedirs(os.path.join(self.report, "data")) + os.makedirs(self.raw) + conn = sqlite3.connect(os.path.join(self.report, "data", "assessment.db")) + conn.execute( + "CREATE TABLE cost_inventory (id INTEGER PRIMARY KEY AUTOINCREMENT, " + "month TEXT NOT NULL, cost REAL NOT NULL, currency TEXT NOT NULL, " + "UNIQUE(month))" + ) + conn.commit() + conn.close() + for target in ( + "cloudexit.providers.azure.costs.time.sleep", + "cloudexit.providers.azure.costs.print_step", + ): + patcher = patch(target) + patcher.start() + self.addCleanup(patcher.stop) + + def costs(self): + conn = sqlite3.connect(os.path.join(self.report, "data", "assessment.db")) + try: + return conn.execute("SELECT count(*) FROM cost_inventory").fetchone()[0] + finally: + conn.close() + + def test_a_run_that_is_rate_limited_once_still_stores_the_costs(self): + rows = [[12.5, "2026-09-01T00:00:00", "USD"]] + client = MagicMock() + client.query.usage.side_effect = [http_error(429), cost_result(rows)] + + with patch( + "azure.mgmt.costmanagement.CostManagementClient", return_value=client + ): + build_azure_cost_inventory(1, self.DETAILS, self.report, self.raw) + + self.assertEqual(self.costs(), 6) + + def test_a_run_that_stays_rate_limited_ends_with_the_clear_line_and_no_error_log( + self, + ): + client = MagicMock() + client.query.usage.side_effect = [http_error(429) for _ in range(4)] + + with ( + patch( + "azure.mgmt.costmanagement.CostManagementClient", return_value=client + ), + self.assertNoLogs(level=logging.ERROR), + ): + result = create_cost_inventory(1, self.DETAILS, self.report, self.raw) + + self.assertEqual(result, {"success": False, "logs": COST_RATE_LIMITED}) + self.assertEqual(self.costs(), 0) + + def test_the_traceback_is_kept_for_run_log(self): + client = MagicMock() + client.query.usage.side_effect = [http_error(429) for _ in range(4)] + + with ( + patch( + "azure.mgmt.costmanagement.CostManagementClient", return_value=client + ), + self.assertLogs("cloudexit", level=logging.DEBUG) as logs, + ): + create_cost_inventory(1, self.DETAILS, self.report, self.raw) + + self.assertTrue(any(record.exc_info for record in logs.records)) + self.assertFalse( + any(record.levelno >= logging.ERROR for record in logs.records) + ) + + def test_other_cost_errors_still_log_at_error_level(self): + client = MagicMock() + client.query.usage.side_effect = http_error(403) + + with ( + patch( + "azure.mgmt.costmanagement.CostManagementClient", return_value=client + ), + self.assertLogs("cloudexit", level=logging.ERROR), + ): + result = create_cost_inventory(1, self.DETAILS, self.report, self.raw) + + self.assertFalse(result["success"]) + self.assertEqual(client.query.usage.call_count, 1) + + +class RunExitTests(unittest.TestCase): + CONFIG = { + "name": "Azure", + "cloudServiceProvider": 1, + "exitStrategy": 1, + "assessmentType": 1, + "providerDetails": { + "credential": MagicMock(), + "tenantId": "t", + "subscriptionId": "00000000-0000-0000-0000-000000000000", + "resourceGroupName": "rg", + }, + } + + def test_a_stage_that_stays_rate_limited_exits_6_with_the_one_line(self): + steps = [] + client = MagicMock() + client.query.usage.side_effect = [http_error(429) for _ in range(4)] + with tempfile.TemporaryDirectory() as tmp: + report, raw = os.path.join(tmp, "r"), os.path.join(tmp, "raw") + os.makedirs(os.path.join(report, "data")) + os.makedirs(raw) + with ( + patch("cloudexit.cli.app.validate_config"), + patch("cloudexit.cli.app.require_azure_sdk"), + patch("cloudexit.cli.app.create_directory", return_value=(report, raw)), + patch("cloudexit.cli.app.add_run_log_handler"), + patch( + "cloudexit.cli.app.verify_credentials", return_value=(True, "ok") + ), + patch( + "cloudexit.cli.app.test_permissions", + return_value=(True, True, True, "ok"), + ), + patch( + "cloudexit.cli.app.create_resource_inventory", + return_value={"success": True, "logs": ""}, + ), + patch( + "azure.mgmt.costmanagement.CostManagementClient", + return_value=client, + ), + patch("cloudexit.providers.azure.costs.time.sleep"), + patch("cloudexit.providers.azure.costs.print_step"), + patch( + "cloudexit.cli.app.print_step", + side_effect=lambda *a, **k: steps.append((a, k)), + ), + patch("cloudexit.cli.app.console.print"), + self.assertNoLogs(level=logging.ERROR), + self.assertRaises(SystemExit) as ctx, + ): + app.run_assessment(dict(self.CONFIG), "azure") + + self.assertEqual(ctx.exception.code, codes.COST_INVENTORY) + failed = [k for a, k in steps if k.get("status") == "error"] + self.assertEqual([k["logs"] for k in failed], [COST_RATE_LIMITED]) + self.assertNotIn("Traceback", COST_RATE_LIMITED) + + +class GetMissingMonthsAzureTests(unittest.TestCase): + def test_returns_missing_months(self): + today = date.today() + # Provide 3 months, expect 3 missing from the 6-month window + processed = { + today.replace(day=1).isoformat(), + } + missing = get_missing_months_azure(processed, 6) + self.assertEqual(len(missing), 5) + + def test_returns_empty_when_all_present(self): + from dateutil.relativedelta import relativedelta + + today = date.today() + start = today.replace(day=1) - relativedelta(months=5) + processed = set() + for i in range(6): + m = (start + relativedelta(months=i)).replace(day=1) + processed.add(m.isoformat()) + + missing = get_missing_months_azure(processed, 6) + self.assertEqual(len(missing), 0) + + def test_handles_timestamp_format(self): + from dateutil.relativedelta import relativedelta + + today = date.today() + start = today.replace(day=1) - relativedelta(months=5) + processed = set() + for i in range(6): + m = (start + relativedelta(months=i)).replace(day=1) + processed.add(f"{m.isoformat()}T00:00:00") + + missing = get_missing_months_azure(processed, 6) + self.assertEqual(len(missing), 0) + + def test_returns_all_when_none_processed(self): + missing = get_missing_months_azure(set(), 6) + self.assertEqual(len(missing), 6) + + +class BuildAzureCostInventoryErrorTests(unittest.TestCase): + @patch("azure.identity.ClientSecretCredential") + def test_auth_error_is_reraised(self, mock_cred_cls): + mock_cred_cls.side_effect = ClientAuthenticationError( + message="Invalid credentials" + ) + + from cloudexit.providers.azure.costs import build_azure_cost_inventory + + with self.assertRaises(ClientAuthenticationError): + build_azure_cost_inventory( + 1, + { + "tenantId": "t", + "clientId": "c", + "clientSecret": "s", + "subscriptionId": "sub", + "resourceGroupName": "rg", + }, + "/fake/report", + "/fake/raw", + ) + + @patch("cloudexit.providers.azure.costs.connect") + @patch("azure.mgmt.costmanagement.CostManagementClient") + @patch("azure.identity.ClientSecretCredential") + def test_sqlite_error_is_reraised(self, mock_cred_cls, mock_cost_cls, mock_connect): + import sqlite3 + + mock_cost_client = MagicMock() + mock_cost_cls.return_value = mock_cost_client + + mock_cost_data = MagicMock() + mock_cost_data.rows = [ + [42.5, "2026-01-01T00:00:00", "USD"], + ] + mock_cost_data.as_dict.return_value = {} + mock_cost_client.query.usage.return_value = mock_cost_data + + mock_conn = MagicMock() + mock_connect.return_value.__enter__ = MagicMock(return_value=mock_conn) + mock_connect.return_value.__exit__ = MagicMock(return_value=False) + mock_cursor = MagicMock() + mock_conn.cursor.return_value = mock_cursor + mock_cursor.execute.side_effect = sqlite3.Error("disk I/O error") + + from cloudexit.providers.azure.costs import build_azure_cost_inventory + import tempfile + import os + + with tempfile.TemporaryDirectory() as tmp: + report_path = os.path.join(tmp, "report") + raw_data_path = os.path.join(tmp, "raw") + os.makedirs(os.path.join(report_path, "data"), exist_ok=True) + os.makedirs(raw_data_path, exist_ok=True) + + with self.assertRaises(sqlite3.Error): + build_azure_cost_inventory( + 1, + { + "tenantId": "t", + "clientId": "c", + "clientSecret": "s", + "subscriptionId": "sub", + "resourceGroupName": "rg", + }, + report_path, + raw_data_path, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/providers/azure/test_egress.py b/tests/providers/azure/test_egress.py new file mode 100644 index 0000000..1af9632 --- /dev/null +++ b/tests/providers/azure/test_egress.py @@ -0,0 +1,436 @@ +# tests/providers/azure/test_egress.py +import unittest +from unittest.mock import MagicMock, patch + +import requests + +from cloudexit.egress.model import GIB, format_bytes +from cloudexit.providers.azure.egress import ( + build_egress_inventory, + collect_azure_egress, + fetch_monitor_metrics, + filter_data_bearing_resources, +) + +# Stands in for what load_egress_registry() builds out of resourcetype_data, +# so these tests exercise the engine rather than the shipped master data. +TEST_REGISTRY = { + "microsoft.storage/storageaccounts": { + "category": "object", + "label": "Storage Account", + "strategy": "storage_account_metrics", + }, + "microsoft.compute/disks": { + "category": "block", + "label": "Managed Disks", + "strategy": "allocated_size_property", + "api_version": "2024-03-02", + "size_property": "diskSizeGB", + }, + "microsoft.compute/snapshots": { + "category": "block", + "label": "Managed Disk Snapshots", + "strategy": "allocated_size_property", + "api_version": "2024-03-02", + "size_property": "diskSizeGB", + }, + "microsoft.sql/servers/databases": { + "category": "database", + "label": "Azure SQL Database", + "strategy": "monitor_metric", + "metrics": ["storage"], + }, + "microsoft.documentdb/databaseaccounts": { + "category": "database", + "label": "Azure Cosmos DB", + "strategy": "monitor_metric", + "metrics": ["DataUsage", "IndexUsage"], + }, + "microsoft.dbforpostgresql/flexibleservers": { + "category": "database", + "label": "Azure Database for PostgreSQL", + "strategy": "monitor_metric", + "metrics": ["storage_used"], + }, + "microsoft.dbformysql/flexibleservers": { + "category": "database", + "label": "Azure Database for MySQL", + "strategy": "monitor_metric", + "metrics": ["storage_used"], + }, + "microsoft.recoveryservices/vaults": { + "category": "backup", + "label": "Azure Site Recovery", + "strategy": "vault_warning", + }, +} + +_REGISTRY_PATCHER = None + + +def setUpModule(): + global _REGISTRY_PATCHER + _REGISTRY_PATCHER = patch( + "cloudexit.providers.azure.egress.load_egress_registry", + return_value=TEST_REGISTRY, + ) + _REGISTRY_PATCHER.start() + + +def tearDownModule(): + _REGISTRY_PATCHER.stop() + + +def _mock_resource(resource_type, name, resource_id, sku_name=None): + resource = MagicMock() + resource.type = resource_type + resource.name = name + resource.id = resource_id + if sku_name: + resource.sku = MagicMock() + resource.sku.name = sku_name + else: + resource.sku = None + return resource + + +def _mock_credential(): + credential = MagicMock() + credential.get_token.return_value = MagicMock(token="fake-token") + return credential + + +class RegistryFilteringTests(unittest.TestCase): + def test_picks_data_bearing_types_out_of_mixed_list(self): + resources = [ + _mock_resource("Microsoft.Storage/storageAccounts", "sa1", "/sa1"), + _mock_resource("Microsoft.Network/virtualNetworks", "vnet1", "/vnet1"), + _mock_resource("Microsoft.Compute/disks", "disk1", "/disk1"), + _mock_resource("Microsoft.Compute/virtualMachines", "vm1", "/vm1"), + _mock_resource("Microsoft.RecoveryServices/vaults", "vault1", "/vault1"), + ] + + matched = filter_data_bearing_resources(resources, TEST_REGISTRY) + matched_names = [resource.name for resource, _ in matched] + + self.assertEqual(matched_names, ["sa1", "disk1", "vault1"]) + + def test_matching_is_case_insensitive(self): + resources = [ + _mock_resource("MICROSOFT.STORAGE/StorageAccounts", "sa1", "/sa1"), + ] + + matched = filter_data_bearing_resources(resources, TEST_REGISTRY) + + self.assertEqual(len(matched), 1) + self.assertEqual(matched[0][1]["strategy"], "storage_account_metrics") + + def test_returns_empty_for_no_data_bearing_resources(self): + resources = [ + _mock_resource("Microsoft.Network/networkInterfaces", "nic1", "/nic1"), + ] + + self.assertEqual(filter_data_bearing_resources(resources, TEST_REGISTRY), []) + + +class FetchMonitorMetricsTests(unittest.TestCase): + @patch("cloudexit.providers.azure.egress.requests.get") + def test_parses_normal_response_using_latest_datapoint(self, mock_get): + mock_response = MagicMock() + mock_response.json.return_value = { + "value": [ + { + "name": {"value": "UsedCapacity"}, + "timeseries": [ + { + "metadatavalues": [], + "data": [ + {"average": 50.0}, + {"average": 123.0}, + {"average": None}, + ], + } + ], + } + ] + } + mock_get.return_value = mock_response + + result = fetch_monitor_metrics(_mock_credential(), "/sa1", ["UsedCapacity"]) + + self.assertEqual( + result, {"UsedCapacity": [{"dimension": None, "value": 123.0}]} + ) + + @patch("cloudexit.providers.azure.egress.requests.get") + def test_parses_dimension_split_response(self, mock_get): + mock_response = MagicMock() + mock_response.json.return_value = { + "value": [ + { + "name": {"value": "BlobCapacity"}, + "timeseries": [ + { + "metadatavalues": [ + {"name": {"value": "tier"}, "value": "Hot"} + ], + "data": [{"average": 10.0}], + }, + { + "metadatavalues": [ + {"name": {"value": "tier"}, "value": "Archive"} + ], + "data": [{"average": 20.0}], + }, + ], + } + ] + } + mock_get.return_value = mock_response + + result = fetch_monitor_metrics( + _mock_credential(), + "/sa1/blobServices/default", + ["BlobCapacity"], + dimension="Tier", + ) + + self.assertEqual( + result, + { + "BlobCapacity": [ + {"dimension": "Hot", "value": 10.0}, + {"dimension": "Archive", "value": 20.0}, + ] + }, + ) + params = mock_get.call_args.kwargs["params"] + self.assertEqual(params["$filter"], "Tier eq '*'") + + @patch("cloudexit.providers.azure.egress.requests.get") + def test_handles_empty_series_without_treating_as_zero(self, mock_get): + mock_response = MagicMock() + mock_response.json.return_value = { + "value": [{"name": {"value": "storage"}, "timeseries": []}] + } + mock_get.return_value = mock_response + + result = fetch_monitor_metrics(_mock_credential(), "/db1", ["storage"]) + + self.assertEqual(result, {"storage": []}) + + @patch("cloudexit.providers.azure.egress.requests.get") + def test_handles_missing_datapoints_without_treating_as_zero(self, mock_get): + mock_response = MagicMock() + mock_response.json.return_value = { + "value": [ + { + "name": {"value": "storage"}, + "timeseries": [{"metadatavalues": [], "data": [{"average": None}]}], + } + ] + } + mock_get.return_value = mock_response + + result = fetch_monitor_metrics(_mock_credential(), "/db1", ["storage"]) + + self.assertEqual(result, {"storage": []}) + + @patch("cloudexit.providers.azure.egress.requests.get") + def test_returns_none_on_http_error(self, mock_get): + mock_response = MagicMock() + mock_response.raise_for_status.side_effect = requests.HTTPError("403") + mock_get.return_value = mock_response + + result = fetch_monitor_metrics(_mock_credential(), "/sa1", ["UsedCapacity"]) + + self.assertIsNone(result) + + @patch("cloudexit.providers.azure.egress.requests.get") + def test_returns_none_on_connection_error(self, mock_get): + mock_get.side_effect = requests.ConnectionError("network down") + + result = fetch_monitor_metrics(_mock_credential(), "/sa1", ["UsedCapacity"]) + + self.assertIsNone(result) + + +class StorageAccountTierSplitTests(unittest.TestCase): + @patch("cloudexit.providers.azure.egress.fetch_monitor_metrics") + def test_hot_cool_archive_split_and_archive_flag(self, mock_fetch): + mock_fetch.side_effect = [ + {"UsedCapacity": [{"dimension": None, "value": float(60 * GIB)}]}, + { + "BlobCapacity": [ + {"dimension": "Hot", "value": float(30 * GIB)}, + {"dimension": "Cool", "value": float(20 * GIB)}, + {"dimension": "Archive", "value": float(10 * GIB)}, + ] + }, + ] + resource = _mock_resource( + "Microsoft.Storage/storageAccounts", "sa1", "/sa1", sku_name="Standard_LRS" + ) + + rows, findings = build_egress_inventory(MagicMock(), MagicMock(), [resource]) + + self.assertEqual(len(rows), 1) + row = rows[0] + self.assertEqual(row["size_bytes"], 60 * GIB) + self.assertEqual( + row["tier_bytes"], + {"Hot": 30 * GIB, "Cool": 20 * GIB, "Archive": 10 * GIB}, + ) + self.assertTrue(any("Archive" in flag for flag in row["flags"])) + self.assertEqual(findings, []) + + @patch("cloudexit.providers.azure.egress.fetch_monitor_metrics") + def test_geo_replicated_sku_adds_informational_finding(self, mock_fetch): + mock_fetch.side_effect = [ + {"UsedCapacity": [{"dimension": None, "value": float(GIB)}]}, + {"BlobCapacity": []}, + ] + resource = _mock_resource( + "Microsoft.Storage/storageAccounts", + "sa1", + "/sa1", + sku_name="Standard_RAGRS", + ) + + rows, findings = build_egress_inventory(MagicMock(), MagicMock(), [resource]) + + self.assertTrue(any("geo-replicated" in note for note in rows[0]["notes"])) + info_findings = [f for f in findings if f["severity"] == "info"] + self.assertEqual(len(info_findings), 1) + self.assertIn("not additional data to egress", info_findings[0]["message"]) + + @patch("cloudexit.providers.azure.egress.fetch_monitor_metrics", return_value=None) + def test_no_datapoints_records_unknown_size(self, mock_fetch): + resource = _mock_resource("Microsoft.Storage/storageAccounts", "sa1", "/sa1") + + rows, findings = build_egress_inventory(MagicMock(), MagicMock(), [resource]) + + self.assertIsNone(rows[0]["size_bytes"]) + self.assertTrue(rows[0]["size_unknown"]) + self.assertEqual(format_bytes(rows[0]["size_bytes"]), "n/a") + self.assertTrue(any("size unknown" in f["message"] for f in findings)) + + +class AllocatedSizeTests(unittest.TestCase): + def test_disk_size_read_from_property_and_flagged_as_upper_bound(self): + resource = _mock_resource("Microsoft.Compute/disks", "disk1", "/disk1") + resource_client = MagicMock() + full_resource = MagicMock() + full_resource.properties = {"diskSizeGB": 128} + resource_client.resources.get_by_id.return_value = full_resource + + rows, findings = build_egress_inventory( + MagicMock(), resource_client, [resource] + ) + + self.assertEqual(rows[0]["size_bytes"], 128 * GIB) + self.assertIn("allocated (upper bound)", rows[0]["flags"]) + self.assertEqual(findings, []) + + def test_collector_error_is_contained_as_finding(self): + resource = _mock_resource("Microsoft.Compute/disks", "disk1", "/disk1") + resource_client = MagicMock() + resource_client.resources.get_by_id.side_effect = RuntimeError("api error") + + rows, findings = build_egress_inventory( + MagicMock(), resource_client, [resource] + ) + + self.assertIsNone(rows[0]["size_bytes"]) + self.assertIn("size unavailable", rows[0]["flags"]) + self.assertTrue(any("size lookup failed" in f["message"] for f in findings)) + + +class VaultDetectionTests(unittest.TestCase): + @patch("cloudexit.providers.azure.egress.fetch_monitor_metrics") + def test_vault_produces_warning_finding_and_no_size(self, mock_fetch): + resource = _mock_resource("Microsoft.RecoveryServices/vaults", "vault1", "/v1") + resource_client = MagicMock() + + rows, findings = build_egress_inventory( + MagicMock(), resource_client, [resource] + ) + + self.assertIsNone(rows[0]["size_bytes"]) + self.assertFalse(rows[0]["size_unknown"]) + warnings = [f for f in findings if f["severity"] == "warning"] + self.assertEqual(len(warnings), 1) + self.assertIn("cannot be exported from Azure", warnings[0]["message"]) + mock_fetch.assert_not_called() + resource_client.resources.get_by_id.assert_not_called() + + +class CollectAzureEgressTests(unittest.TestCase): + @patch("cloudexit.providers.azure.egress.fetch_monitor_metrics") + @patch("azure.mgmt.resource.resources.ResourceManagementClient") + def test_returns_rows_and_archive_tiers(self, mock_rmc_cls, mock_fetch): + mock_fetch.side_effect = [ + {"UsedCapacity": [{"dimension": None, "value": float(10 * GIB)}]}, + {"BlobCapacity": []}, + ] + resource = _mock_resource("Microsoft.Storage/storageAccounts", "sa1", "/sa1") + mock_client = MagicMock() + mock_client.resources.list_by_resource_group.return_value = [resource] + mock_rmc_cls.return_value = mock_client + + rows, archive_tiers = collect_azure_egress( + { + "credential": _mock_credential(), + "subscriptionId": "sub-id", + "resourceGroupName": "rg", + } + ) + + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0]["name"], "sa1") + self.assertEqual(rows[0]["size_bytes"], 10 * GIB) + self.assertNotIn("findings", rows[0]) + self.assertEqual(archive_tiers, {"Archive"}) + + +class UnknownStrategyTests(unittest.TestCase): + @patch("cloudexit.providers.azure.egress.fetch_monitor_metrics") + def test_unknown_strategy_warns_and_skips_without_raising(self, mock_fetch): + mock_fetch.side_effect = [ + {"UsedCapacity": [{"dimension": None, "value": float(GIB)}]}, + {"BlobCapacity": []}, + ] + registry = { + "microsoft.future/widgets": { + "category": "object", + "label": "Something New", + "strategy": "not_implemented_yet", + }, + "microsoft.storage/storageaccounts": TEST_REGISTRY[ + "microsoft.storage/storageaccounts" + ], + } + resources = [ + _mock_resource("Microsoft.Future/widgets", "widget1", "/w1"), + _mock_resource("Microsoft.Storage/storageAccounts", "sa1", "/sa1"), + ] + + with patch( + "cloudexit.providers.azure.egress.load_egress_registry", + return_value=registry, + ): + with self.assertLogs( + "cloudexit.providers.azure.egress", level="WARNING" + ) as captured: + rows, findings = build_egress_inventory( + MagicMock(), MagicMock(), resources + ) + + # The known strategy still runs; only the unknown one is skipped. + self.assertEqual([row["name"] for row in rows], ["sa1"]) + self.assertEqual(rows[0]["size_bytes"], GIB) + self.assertIn("not_implemented_yet", captured.output[0]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/providers/azure/test_inventory.py b/tests/providers/azure/test_inventory.py new file mode 100644 index 0000000..d130fa8 --- /dev/null +++ b/tests/providers/azure/test_inventory.py @@ -0,0 +1,94 @@ +# tests/providers/azure/test_inventory.py +import unittest +from unittest.mock import MagicMock, patch + +from azure.core.exceptions import AzureError, ClientAuthenticationError + +from cloudexit.providers.azure.inventory import is_resource_inventory_empty + + +class IsResourceInventoryEmptyTests(unittest.TestCase): + @patch("azure.mgmt.resource.resources.ResourceManagementClient") + def test_returns_true_when_no_resources(self, mock_rmc_cls): + mock_client = MagicMock() + mock_rmc_cls.return_value = mock_client + mock_client.resources.list_by_resource_group.return_value = iter([]) + + result = is_resource_inventory_empty(MagicMock(), "sub-123", "rg-test") + self.assertTrue(result) + + @patch("azure.mgmt.resource.resources.ResourceManagementClient") + def test_returns_false_when_resources_exist(self, mock_rmc_cls): + mock_client = MagicMock() + mock_rmc_cls.return_value = mock_client + mock_resource = MagicMock() + mock_client.resources.list_by_resource_group.return_value = iter( + [mock_resource] + ) + + result = is_resource_inventory_empty(MagicMock(), "sub-123", "rg-test") + self.assertFalse(result) + + @patch("azure.mgmt.resource.resources.ResourceManagementClient") + def test_raises_on_azure_error(self, mock_rmc_cls): + mock_client = MagicMock() + mock_rmc_cls.return_value = mock_client + mock_client.resources.list_by_resource_group.side_effect = AzureError( + "Connection failed" + ) + + with self.assertRaises(AzureError): + is_resource_inventory_empty(MagicMock(), "sub-123", "rg-test") + + +class BuildAzureResourceInventoryErrorTests(unittest.TestCase): + @patch("cloudexit.providers.azure.inventory.is_resource_inventory_empty") + @patch("azure.identity.ClientSecretCredential") + def test_auth_error_is_reraised(self, mock_cred_cls, mock_empty_check): + mock_cred_cls.side_effect = ClientAuthenticationError( + message="Invalid credentials" + ) + + from cloudexit.providers.azure.inventory import build_azure_resource_inventory + + with self.assertRaises(ClientAuthenticationError): + build_azure_resource_inventory( + 1, + { + "tenantId": "t", + "clientId": "c", + "clientSecret": "s", + "subscriptionId": "sub", + "resourceGroupName": "rg", + }, + "/fake/report", + "/fake/raw", + ) + + @patch( + "cloudexit.providers.azure.inventory.is_resource_inventory_empty", + return_value=True, + ) + @patch("azure.identity.ClientSecretCredential") + def test_returns_early_when_inventory_empty(self, mock_cred_cls, mock_empty_check): + from cloudexit.providers.azure.inventory import build_azure_resource_inventory + + # Should not raise, returns early + result = build_azure_resource_inventory( + 1, + { + "tenantId": "t", + "clientId": "c", + "clientSecret": "s", + "subscriptionId": "sub", + "resourceGroupName": "rg", + }, + "/fake/report", + "/fake/raw", + ) + self.assertIsNone(result) + mock_empty_check.assert_called_once() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/providers/azure/test_sdk_shapes.py b/tests/providers/azure/test_sdk_shapes.py new file mode 100644 index 0000000..8b6d1b9 --- /dev/null +++ b/tests/providers/azure/test_sdk_shapes.py @@ -0,0 +1,477 @@ +import json +import sqlite3 +import tempfile +import time +import unittest +from datetime import date +from pathlib import Path +from unittest import mock + +from azure.core.credentials import AccessToken +from azure.mgmt.costmanagement.models import QueryDefinition, TimeframeType +from dateutil.relativedelta import relativedelta + +from cloudexit.providers.azure.clients import ( + create_cost_client, + create_resource_client, + create_subscription_client, +) +from cloudexit.providers.azure.costs import build_azure_cost_inventory, cost_result_dict +from cloudexit.providers.azure.inventory import ( + build_azure_resource_inventory, + is_resource_inventory_empty, + plain_json, +) +from cloudexit.egress.model import GIB +from cloudexit.providers.azure.egress import build_egress_inventory +from tests.support.http import fake_http, make_response +from cloudexit.providers.azure.cli_session import ( + select_resource_group, + select_subscription, +) + +SUBSCRIPTION = "00000000-0000-0000-0000-000000000000" +GROUP = "rg-demo" +GROUP_ID = f"/subscriptions/{SUBSCRIPTION}/resourceGroups/{GROUP}" +STORAGE_ID = f"{GROUP_ID}/providers/Microsoft.Storage/storageAccounts/stdemo" +NETWORK_ID = f"{GROUP_ID}/providers/Microsoft.Network/virtualNetworks/vnet" +DISK_ID = f"{GROUP_ID}/providers/Microsoft.Compute/disks/disk1" + +STORAGE_ACCOUNT = { + "id": STORAGE_ID, + "name": "stdemo", + "type": "Microsoft.Storage/storageAccounts", + "location": "westeurope", + "tags": {"env": "demo"}, + "sku": {"name": "Standard_GRS", "tier": "Standard"}, + "kind": "StorageV2", + "identity": { + "type": "SystemAssigned", + "principalId": "11111111-1111-1111-1111-111111111111", + "tenantId": "22222222-2222-2222-2222-222222222222", + }, + "managedBy": f"{GROUP_ID}/providers/Microsoft.Web/sites/app", + "createdTime": "2026-01-02T03:04:05.0000000Z", + "changedTime": "2026-02-03T04:05:06.0000000Z", + "provisioningState": "Succeeded", +} +VIRTUAL_NETWORK = { + "id": NETWORK_ID, + "name": "vnet", + "type": "Microsoft.Network/virtualNetworks", + "location": " WestEurope ", +} +VIRTUAL_NETWORK_TWO = dict(VIRTUAL_NETWORK, id=NETWORK_ID + "-2", name="vnet-2") +DISK = { + "id": DISK_ID, + "name": "disk1", + "type": "Microsoft.Compute/disks", + "location": "westeurope", +} + + +class FakeCredential: + def get_token(self, *scopes, **kwargs): + return AccessToken("not-a-token", int(time.time()) + 3600) + + +def json_reply(body, status=200): + response = make_response(status, json_body=body) + # The SDK's transport sets a flag on, and closes, the urllib3 response + response.raw = mock.MagicMock() + response._content_consumed = True + return response + + +def listing(*resources): + return json_reply({"value": list(resources)}) + + +class ShapeTestCase(unittest.TestCase): + def setUp(self): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.tmp = Path(tmp.name) + self.credential = FakeCredential() + self.details = { + "credential": self.credential, + "subscriptionId": SUBSCRIPTION, + "resourceGroupName": GROUP, + } + # The mapping of resource types is cached on the function for a run + self.addCleanup(self.forget_type_cache) + self.forget_type_cache() + + def forget_type_cache(self): + if hasattr(build_azure_resource_inventory, "_resource_type_cache"): + del build_azure_resource_inventory._resource_type_cache + + def report(self, *tables): + report = self.tmp / "report" + (report / "data").mkdir(parents=True, exist_ok=True) + raw = self.tmp / "raw" + raw.mkdir(exist_ok=True) + conn = sqlite3.connect(report / "data" / "assessment.db") + conn.executescript(";".join(tables)) + conn.commit() + conn.close() + return str(report), str(raw) + + def rows(self, report, sql): + conn = sqlite3.connect(Path(report) / "data" / "assessment.db") + try: + return conn.execute(sql).fetchall() + finally: + conn.close() + + +class ClientSurfaceTests(ShapeTestCase): + def test_the_factories_build_clients_with_the_operations_the_cli_calls(self): + resource_client = create_resource_client(self.credential, SUBSCRIPTION) + self.assertTrue(callable(resource_client.resources.list_by_resource_group)) + self.assertTrue(callable(resource_client.resources.get_by_id)) + self.assertTrue(callable(resource_client.resource_groups.list)) + + subscription_client = create_subscription_client(self.credential) + self.assertTrue(callable(subscription_client.subscriptions.list)) + + cost_client = create_cost_client(self.credential) + self.assertTrue(callable(cost_client.query.usage)) + + def test_the_clients_come_from_the_packages_the_sdk_now_splits_them_into(self): + self.assertEqual( + type(create_resource_client(self.credential, SUBSCRIPTION)).__module__, + "azure.mgmt.resource.resources._client", + ) + self.assertEqual( + type(create_subscription_client(self.credential)).__module__, + "azure.mgmt.subscription._subscription_client", + ) + + def test_the_authorization_client_still_has_role_assignments(self): + from azure.mgmt.authorization import AuthorizationManagementClient + + client = AuthorizationManagementClient(self.credential, SUBSCRIPTION) + self.assertTrue(callable(client.role_assignments.list_for_scope)) + + +class ListedResourceTests(ShapeTestCase): + def listed(self, *resources): + client = create_resource_client(self.credential, SUBSCRIPTION) + with fake_http(listing(*resources)) as fake: + return list(client.resources.list_by_resource_group(GROUP)), fake + + def test_a_listed_resource_exposes_what_the_inventory_reads(self): + resources, fake = self.listed(STORAGE_ACCOUNT, VIRTUAL_NETWORK) + + storage, network = resources + self.assertEqual(storage.type, "Microsoft.Storage/storageAccounts") + self.assertEqual(storage.location, "westeurope") + self.assertEqual(storage.id, STORAGE_ID) + self.assertEqual(storage.name, "stdemo") + self.assertEqual(network.location, " WestEurope ") + self.assertIn( + f"/resourcegroups/{GROUP}/resources", fake.requests[0].url.lower() + ) + self.assertEqual(fake.requests[0].method, "GET") + + def test_the_sku_name_is_read_the_way_the_egress_collector_reads_it(self): + (storage,), _ = self.listed(STORAGE_ACCOUNT) + self.assertEqual( + getattr(getattr(storage, "sku", None), "name", None), "Standard_GRS" + ) + + (network,), _ = self.listed(VIRTUAL_NETWORK) + self.assertIsNone(getattr(getattr(network, "sku", None), "name", None)) + + def test_a_resource_without_a_location_reads_as_none(self): + (resource,), _ = self.listed({k: v for k, v in DISK.items() if k != "location"}) + self.assertIsNone(resource.location) + + def test_the_raw_data_form_is_plain_json_with_the_rest_names(self): + resources, _ = self.listed(STORAGE_ACCOUNT, VIRTUAL_NETWORK) + + plain = json.loads(json.dumps([plain_json(r) for r in resources])) + + self.assertEqual(plain[0], STORAGE_ACCOUNT) + self.assertEqual(plain[1], VIRTUAL_NETWORK) + + def test_plain_json_turns_models_lists_and_dates_into_json_types(self): + (storage,), _ = self.listed(STORAGE_ACCOUNT) + self.assertEqual( + plain_json(storage.identity)["principalId"], + STORAGE_ACCOUNT["identity"]["principalId"], + ) + self.assertEqual(plain_json((1, [date(2026, 1, 2)])), [1, ["2026-01-02"]]) + self.assertEqual(plain_json(None), None) + + +class ResourceInventoryTests(ShapeTestCase): + TABLES = ( + "CREATE TABLE resourcetype (id INTEGER PRIMARY KEY, csp INTEGER, code TEXT, " + "name TEXT, icon TEXT, status TEXT)", + "INSERT INTO resourcetype VALUES " + "(11, 1, 'Microsoft.Storage/storageAccounts', 'Storage Account', '/i.png', 't')", + "INSERT INTO resourcetype VALUES " + "(12, 1, 'microsoft.network/virtualnetworks', 'Virtual Network', '/i.png', 't')", + "CREATE TABLE resource_inventory (id INTEGER PRIMARY KEY AUTOINCREMENT, " + "resource_type INTEGER NOT NULL, location TEXT NOT NULL, " + "count INTEGER NOT NULL, UNIQUE(resource_type, location))", + ) + + def test_types_and_locations_are_counted_as_before(self): + report, raw = self.report(*self.TABLES) + resources = [STORAGE_ACCOUNT, VIRTUAL_NETWORK, VIRTUAL_NETWORK_TWO] + # One listing for the emptiness check, one for the inventory + with fake_http(listing(*resources), listing(*resources)) as fake: + build_azure_resource_inventory(1, self.details, report, raw) + + self.assertEqual( + self.rows( + report, + "SELECT resource_type, location, count FROM resource_inventory ORDER BY 1", + ), + [(11, "westeurope", 1), (12, "westeurope", 2)], + ) + self.assertEqual(len(fake.requests), 2) + + def test_a_resource_without_a_location_is_counted_as_global(self): + report, raw = self.report(*self.TABLES) + global_resource = {k: v for k, v in VIRTUAL_NETWORK.items() if k != "location"} + blank_resource = dict(VIRTUAL_NETWORK, id=NETWORK_ID + "-2", location=" ") + resources = [STORAGE_ACCOUNT, global_resource, blank_resource] + with fake_http(listing(*resources), listing(*resources)): + build_azure_resource_inventory(1, self.details, report, raw) + + self.assertEqual( + self.rows( + report, + "SELECT resource_type, location, count FROM resource_inventory ORDER BY 1, 2", + ), + [(11, "westeurope", 1), (12, "global", 2)], + ) + + def test_the_raw_file_holds_the_rest_shaped_resources(self): + report, raw = self.report(*self.TABLES) + with fake_http(listing(STORAGE_ACCOUNT), listing(STORAGE_ACCOUNT)): + build_azure_resource_inventory(1, self.details, report, raw) + + saved = json.loads( + (Path(raw) / "resource_inventory_raw_data.json").read_text(encoding="utf-8") + ) + self.assertEqual(saved, [STORAGE_ACCOUNT]) + + def test_an_empty_group_is_detected(self): + with fake_http(listing()): + self.assertTrue( + is_resource_inventory_empty(self.credential, SUBSCRIPTION, GROUP) + ) + with fake_http(listing(DISK)): + self.assertFalse( + is_resource_inventory_empty(self.credential, SUBSCRIPTION, GROUP) + ) + + +class SelectionTests(ShapeTestCase): + def test_subscriptions_and_resource_groups_are_read_by_the_selection_prompts(self): + subscription_client = create_subscription_client(self.credential) + subscription = { + "id": f"/subscriptions/{SUBSCRIPTION}", + "subscriptionId": SUBSCRIPTION, + "displayName": "Demo subscription", + "state": "Enabled", + } + with fake_http(listing(subscription)): + subscriptions = list(subscription_client.subscriptions.list()) + + resource_client = create_resource_client(self.credential, SUBSCRIPTION) + group = {"id": GROUP_ID, "name": GROUP, "location": "westeurope"} + with fake_http(listing(group)): + groups = list(resource_client.resource_groups.list()) + + with ( + mock.patch("builtins.input", return_value="1"), + mock.patch( + "cloudexit.providers.azure.cli_session.console.print" + ) as printed, + ): + chosen = select_subscription(subscriptions) + name = select_resource_group(groups) + + self.assertEqual(chosen.subscription_id, SUBSCRIPTION) + self.assertEqual(chosen.display_name, "Demo subscription") + self.assertEqual(name, GROUP) + self.assertIn( + f"1. Demo subscription ({SUBSCRIPTION})", + [call.args[0] for call in printed.call_args_list], + ) + + +class CostQueryTests(ShapeTestCase): + TABLES = ( + "CREATE TABLE cost_inventory (id INTEGER PRIMARY KEY AUTOINCREMENT, " + "month TEXT NOT NULL, cost REAL NOT NULL, currency TEXT NOT NULL, " + "UNIQUE(month))", + ) + + def months(self, count): + first = date.today().replace(day=1) + return [first - relativedelta(months=back) for back in range(count)] + + def result(self, rows): + return json_reply( + { + "id": "providers/Microsoft.CostManagement/query/demo", + "name": "demo", + "type": "Microsoft.CostManagement/query", + "properties": { + "columns": [ + {"name": "Cost", "type": "Number"}, + {"name": "UsageDate", "type": "Datetime"}, + {"name": "Currency", "type": "String"}, + ], + "rows": rows, + }, + } + ) + + def test_the_query_is_sent_with_its_period_and_dataset(self): + report, raw = self.report(*self.TABLES) + with fake_http(self.result([])) as fake: + build_azure_cost_inventory(1, self.details, report, raw) + + (request,) = fake.requests + self.assertEqual(request.method, "POST") + self.assertIn( + f"{GROUP_ID}/providers/Microsoft.CostManagement/query", request.url + ) + body = json.loads(request.kwargs["data"]) + self.assertEqual(body["type"], "Usage") + self.assertEqual(body["timeframe"], "Custom") + self.assertEqual( + sorted(body["timePeriod"]), + ["from", "to"], + ) + self.assertEqual(body["dataset"]["granularity"], "Monthly") + self.assertEqual( + body["dataset"]["aggregation"], + {"totalCost": {"name": "Cost", "function": "Sum"}}, + ) + + def test_cost_rows_become_months_and_the_gaps_are_filled_with_zero(self): + report, raw = self.report(*self.TABLES) + current, previous = self.months(2) + rows = [ + [100.5, f"{previous.isoformat()}T00:00:00", "EUR"], + [20.25, f"{current.isoformat()}T00:00:00", "EUR"], + ] + with fake_http(self.result(rows)): + build_azure_cost_inventory(1, self.details, report, raw) + + stored = dict( + (month, (cost, currency)) + for month, cost, currency in self.rows( + report, "SELECT month, cost, currency FROM cost_inventory" + ) + ) + self.assertEqual(stored[previous.isoformat()], (100.5, "EUR")) + self.assertEqual(stored[current.isoformat()], (20.25, "EUR")) + zero_months = [m for m, (cost, _) in stored.items() if cost == 0] + self.assertEqual(len(zero_months), 4) + self.assertEqual(len(stored), 6) + + def test_the_raw_file_keeps_the_columns_and_rows_at_the_top(self): + report, raw = self.report(*self.TABLES) + current = self.months(1)[0] + rows = [[1.5, f"{current.isoformat()}T00:00:00", "USD"]] + with fake_http(self.result(rows)): + build_azure_cost_inventory(1, self.details, report, raw) + + saved = json.loads( + (Path(raw) / "cost_inventory_raw_data.json").read_text(encoding="utf-8") + ) + self.assertEqual(saved["rows"], rows) + self.assertEqual( + [c["name"] for c in saved["columns"]], ["Cost", "UsageDate", "Currency"] + ) + self.assertNotIn("properties", saved) + + def test_cost_result_dict_flattens_the_properties(self): + client = create_cost_client(self.credential) + with fake_http(self.result([[1, "2026-01-01T00:00:00", "USD"]])): + result = client.query.usage( + GROUP_ID, + QueryDefinition( + type="Usage", + timeframe=TimeframeType.CUSTOM, + time_period={ + "from": "2026-01-01T00:00:00Z", + "to": "2026-02-01T00:00:00Z", + }, + dataset={"granularity": "Monthly"}, + ), + ) + + self.assertEqual(result.rows, [[1, "2026-01-01T00:00:00", "USD"]]) + flat = cost_result_dict(result) + self.assertEqual(flat["rows"], result.rows) + self.assertIn("columns", flat) + self.assertNotIn("properties", flat) + + +REGISTRY = { + "microsoft.storage/storageaccounts": { + "category": "object", + "label": "Storage Account", + "strategy": "storage_account_metrics", + }, + "microsoft.compute/disks": { + "category": "block", + "label": "Managed Disks", + "strategy": "allocated_size_property", + "api_version": "2024-03-02", + "size_property": "diskSizeGB", + }, +} + + +@mock.patch( + "cloudexit.providers.azure.egress.load_egress_registry", return_value=REGISTRY +) +class EgressSizingTests(ShapeTestCase): + def test_an_allocated_size_is_read_with_the_api_version_as_a_keyword(self, _): + client = create_resource_client(self.credential, SUBSCRIPTION) + disk = dict(DISK, properties={"diskSizeGB": 128}) + with fake_http(listing(DISK), json_reply(disk)) as fake: + resources = list(client.resources.list_by_resource_group(GROUP)) + rows, findings = build_egress_inventory(self.credential, client, resources) + + self.assertEqual(findings, []) + self.assertEqual(rows[0]["size_bytes"], 128 * GIB) + self.assertIn("allocated (upper bound)", rows[0]["flags"]) + lookup = fake.requests[1] + self.assertEqual(lookup.method, "GET") + self.assertIn(DISK_ID, lookup.url) + self.assertIn("api-version=2024-03-02", lookup.url) + + def test_a_storage_account_sku_marks_geo_replication(self, _): + client = create_resource_client(self.credential, SUBSCRIPTION) + with fake_http(listing(STORAGE_ACCOUNT)): + resources = list(client.resources.list_by_resource_group(GROUP)) + metrics = [ + {"UsedCapacity": [{"dimension": None, "value": float(GIB)}]}, + {"BlobCapacity": []}, + ] + with mock.patch( + "cloudexit.providers.azure.egress.fetch_monitor_metrics", + side_effect=metrics, + ): + rows, findings = build_egress_inventory(self.credential, client, resources) + + self.assertEqual(rows[0]["size_bytes"], GIB) + self.assertIn("geo-replicated (Standard_GRS)", rows[0]["notes"]) + self.assertEqual([f["severity"] for f in findings], ["info"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/report/__init__.py b/tests/report/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/report/test_basic_report.py b/tests/report/test_basic_report.py new file mode 100644 index 0000000..b56f3c9 --- /dev/null +++ b/tests/report/test_basic_report.py @@ -0,0 +1,1097 @@ +import copy +import itertools +import json +import re +import tempfile +import unittest +from datetime import datetime, timezone +from pathlib import Path + +from pypdf import PdfReader + +from cloudexit.engine import generate_report, perform_risk_assessment +from cloudexit.report import fonts +from cloudexit.report.context import Provenance +from cloudexit.report.executive_summary import generate_executive_summary +from cloudexit.report.notices import notices_html +from cloudexit.report.palette import ( + DEFAULT_APPEARANCE, + Appearance, + contrast_failures, + contrast_pairs, + contrast_ratio, +) +from cloudexit.report.sections import ( + AWS_ICONS_URL, + AWS_TRADEMARK_URL, + AZURE_ICONS_URL, + LICENCE_URL, + REPOSITORY_URL, +) +from cloudexit.report.wording import BLOCKS, TITLE_CASE_KEYS +from tests.support.flow import build_flow_run +from tests.support.html import html_text +from tests.support.report import ( + build_empty_report_fixture, + build_fixture_context, + build_report_fixture, + stage_minimal_assets, +) +from tests.support.report_terms import ( + american_hits, + find_terms, + is_title_case, + retired_term_hits, +) +from tests.support.sync import SYNC_ID +from cloudexit.version import __version__ + +URL = "https://platform.test/assessments/a-1" +AWS_NOTICE_LINKS = { + AWS_ICONS_URL, + AWS_TRADEMARK_URL, + REPOSITORY_URL, + LICENCE_URL, +} +COLLECTED = datetime(2026, 5, 7, 12, 0, tzinfo=timezone.utc) +BASE_OUTLINE = [ + (0, "Brief Summary"), + (1, "Key Findings"), + (0, "Introduction"), + (1, "Purpose"), + (1, "Scope"), + (1, "Method"), + (1, "Limitations"), + (0, "Costs Overview"), + (0, "Resource Inventory"), + (0, "Risk Inventory"), + (0, "Alternative Technologies"), + (0, "Risk Severity Levels"), + (0, "Glossary"), +] +OUTLINE = BASE_OUTLINE + [(0, "Legal Notices")] +CLOSING_PAGE_TEXTS = ( + "What the Full Assessment Adds", + "The Full Assessment", + "How to Get Them", + "HOST", +) +SCORE_PATTERNS = ( + r"/ 100", + r"/ 5\b", + "Ø", + r"\bgrade [A-F]\b", + "Exit Readiness", + "Lock-in Score", + "Scored Alternatives", +) +# An edition name; the "Enterprise support" flag of an alternative is not one +PROMOTION_PATTERNS = ( + r"\bLight\b", + r"\bEnterprise\b(?!\s+support)", + "Exit Readiness Grade", + "Vendor Lock-in Score", +) +EXITCLOUD_URL = "https://app.exitcloud.io/assessments/a-1" +TFSTATE_SCOPE = { + "file": "infra.tfstate", + "sha256": "ab" * 32, + "lineage": "e1c2f0c0-0000-4000-8000-000000000000", + "serial": 7, + "locations": ["eu-west-1"], +} + + +def provenance(**changes): + values = dict( + collector_version=__version__, + collected_at=COLLECTED, + generated_at=COLLECTED, + run_id=SYNC_ID, + catalogue_version="2026-11-02", + catalogue_sequence=41, + catalogue_sha256="c" * 64, + ) + values.update(changes) + return Provenance(**values) + + +def uploaded(**changes): + return provenance(uploaded=True, platform_url=URL, assessment_id="a-1", **changes) + + +def page_texts(reader): + return [" ".join(page.extract_text().split()) for page in reader.pages] + + +def outline_entries(items, level=0): + entries = [] + for item in items: + if isinstance(item, list): + entries += outline_entries(item, level + 1) + else: + entries.append((level, item.title)) + return entries + + +def xmp_text(reader): + metadata = reader.trailer["/Root"]["/Metadata"].get_object() + return metadata.get_data().decode("utf-8") + + +def metadata_texts(reader): + info = reader.metadata + return [str(value) for value in info.values()] + [xmp_text(reader)] + + +def link_targets(reader): + targets = set() + for page in reader.pages: + for annotation in page.get("/Annots", []): + action = annotation.get_object().get("/A") + if action and "/URI" in action: + targets.add(str(action["/URI"])) + return targets + + +_RENDERS = {} +_COUNTER = itertools.count() +_SHARED = None + + +def setUpModule(): + global _SHARED + _SHARED = tempfile.TemporaryDirectory() + + +def tearDownModule(): + _RENDERS.clear() + _SHARED.cleanup() + + +class BasicReportTestCase(unittest.TestCase): + def setUp(self): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.tmp = Path(tmp.name) + self.count = 0 + + def report_dir(self): + self.count += 1 + path = self.tmp / f"report-{self.count}" + path.mkdir() + stage_minimal_assets(str(path)) + return path + + def azure_fixture(self): + fixture = copy.deepcopy(build_report_fixture()) + fixture["metadata"]["cloud_service_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 tfstate_fixture(self): + fixture = copy.deepcopy(build_report_fixture()) + fixture["provider_details"] = {"tfstatePath": "infra.tfstate"} + fixture["cost_data"] = [] + return fixture + + def scenarios(self): + aws = build_report_fixture() + for name, fixture, extra in ( + ("aws", aws, {}), + ("azure", self.azure_fixture(), {}), + ("tfstate", self.tfstate_fixture(), {"tfstate_scope": TFSTATE_SCOPE}), + ): + for state, prov in (("offline", provenance()), ("uploaded", uploaded())): + yield f"{name} {state}", fixture, {**extra, "provenance": prov} + + def render(self, fixture=None, key=None, **overrides): + # Rendering is the slow part, so tests that read the same report share it. + if key in _RENDERS: + return _RENDERS[key] + directory = Path(_SHARED.name) / f"report-{next(_COUNTER)}" + directory.mkdir() + stage_minimal_assets(str(directory)) + context = build_fixture_context( + fixture or build_report_fixture(), directory, **overrides + ) + path = generate_executive_summary(context) + result = (path, context, PdfReader(path)) + if key: + _RENDERS[key] = result + return result + + +class SmokeRenderTests(BasicReportTestCase): + def test_every_provider_renders_offline_and_uploaded(self): + for name, fixture, overrides in self.scenarios(): + with self.subTest(name): + path, _context, reader = self.render(fixture, key=name, **overrides) + + self.assertEqual(Path(path).name, "report.pdf") + self.assertGreaterEqual(len(reader.pages), 9) + self.assertTrue(all(page_texts(reader))) + + def test_an_empty_assessment_renders(self): + fixture = build_empty_report_fixture() + + _path, _context, reader = self.render(fixture) + + texts = " ".join(page_texts(reader)) + self.assertIn("There are no resources", texts) + self.assertIn("There are no risks identified", texts) + self.assertIn("No cost data available", texts) + + def test_a_tfstate_assessment_explains_the_missing_costs(self): + _path, _context, reader = self.render( + self.tfstate_fixture(), tfstate_scope=TFSTATE_SCOPE + ) + + text = " ".join(page_texts(reader)) + self.assertIn("Terraform state files hold no billing data", text) + self.assertIn("infra.tfstate", text) + self.assertIn("e1c2f0c0-0000-4000-8000-000000000000 (serial 7)", text) + + +class TfstateCoverageTests(BasicReportTestCase): + def coverage(self, other=0, unmapped=0): + return { + "instances_total": 9 + other + unmapped, + "instances_counted": 9, + "instances_excluded_other_provider": other, + "instances_excluded_unmapped": unmapped, + } + + def pages(self, key, coverage, **overrides): + _path, context, reader = self.render( + self.tfstate_fixture(), + key=key, + tfstate_scope=TFSTATE_SCOPE, + tfstate_coverage=coverage, + **overrides, + ) + return context, page_texts(reader) + + def test_both_kinds_are_named_with_the_total_in_the_inventory_caption(self): + context, pages = self.pages("covered", self.coverage(other=3, unmapped=2)) + + self.assertEqual(context.scope.not_assessed, 5) + self.assertIn( + "Not Assessed 3 resources of other providers, " + "2 resources of types not in the data catalogue", + pages[1], + ) + inventory = next(text for text in pages if "Share = resources" in text) + self.assertIn( + "5 resources in the state file were not assessed (see page 2)", inventory + ) + + def test_only_the_parts_that_are_not_zero_are_shown(self): + for key, coverage, expected, absent in ( + ( + "other", + self.coverage(other=4), + "4 resources of other providers", + "types not in", + ), + ( + "unmapped", + self.coverage(unmapped=1), + "1 resource of a type not in the data catalogue", + "other provider", + ), + ( + "one other", + self.coverage(other=1), + "1 resource of another provider", + "types not in", + ), + ): + with self.subTest(key): + _context, pages = self.pages(key, coverage) + row = pages[1].split("Not Assessed", 1)[1].split("Source", 1)[0] + + self.assertIn(expected, row) + self.assertNotIn(absent, row) + + def test_a_singular_total_says_was(self): + _context, pages = self.pages("one total", self.coverage(unmapped=1)) + + inventory = next(text for text in pages if "Share = resources" in text) + self.assertIn( + "1 resource in the state file was not assessed (see page 2)", inventory + ) + + def test_without_excluded_instances_there_is_no_row_and_no_caption(self): + for key, coverage in ( + ("zeros", self.coverage()), + ("older manifest", None), + ("empty", {}), + ( + "not numbers", + { + "instances_excluded_other_provider": "3", + "instances_excluded_unmapped": -2, + }, + ), + ): + with self.subTest(key): + context, pages = self.pages(key, coverage) + text = " ".join(pages) + + self.assertEqual(context.scope.not_assessed, 0) + self.assertNotIn("Not Assessed", text) + self.assertNotIn("were not assessed", text) + self.assertNotIn("was not assessed", text) + + def test_a_live_run_never_shows_the_row(self): + _path, context, reader = self.render( + key="live coverage", tfstate_coverage=self.coverage(other=3, unmapped=2) + ) + + self.assertEqual(context.scope.not_assessed, 0) + self.assertNotIn("Not Assessed", " ".join(page_texts(reader))) + + +class StructureTests(BasicReportTestCase): + def test_the_heading_outline_is_the_expected_list(self): + for name, fixture, overrides in self.scenarios(): + with self.subTest(name): + _path, _context, reader = self.render(fixture, key=name, **overrides) + self.assertEqual(outline_entries(reader.outline), OUTLINE) + + def test_the_outline_is_the_same_for_an_upload_without_a_link(self): + _path, _context, reader = self.render( + key="no link", provenance=provenance(uploaded=True, assessment_id="a-77") + ) + + self.assertEqual(outline_entries(reader.outline), OUTLINE) + + def test_the_outline_does_not_depend_on_the_data(self): + _path, _context, reader = self.render( + build_empty_report_fixture(), provenance=provenance() + ) + self.assertEqual(outline_entries(reader.outline), OUTLINE) + + def test_each_chapter_starts_a_page_and_page_two_is_the_document_information(self): + _path, _context, reader = self.render(key="default") + pages = page_texts(reader) + + self.assertIn("Executive Summary", pages[0]) + self.assertNotIn("Page 1 of", pages[0]) + self.assertTrue(pages[1].startswith("Executive Summary")) + self.assertIn("Document Information", pages[1]) + self.assertIn(f"Page 2 of {len(pages)}", pages[1]) + self.assertIn("Brief Summary", pages[2]) + + chapters = [entry for entry in reader.outline if not isinstance(entry, list)] + numbers = [reader.get_destination_page_number(entry) for entry in chapters] + # One chapter per page, except the severity levels and the glossary + self.assertEqual(len(set(numbers)), len(numbers) - 1) + self.assertEqual(numbers[-3], numbers[-2]) + self.assertIn("Legal Notices", pages[numbers[-1]]) + self.assertEqual(numbers[-1], len(pages) - 1) + + def test_running_header_and_footer(self): + path, context, reader = self.render(key="default") + pages = page_texts(reader) + + for index, text in enumerate(pages[1:], start=2): + self.assertIn("Smoke Test Assessment", text) + self.assertIn(context.document_id, text) + self.assertIn("COMMUNITY EDITION", text) + self.assertNotIn("CONFIDENTIAL", 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(key="default") + 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) + + def test_the_fonts_ship_with_their_licence(self): + for filename in fonts.FONT_FILES.values(): + self.assertTrue((fonts.FONT_DIR / filename).is_file(), filename) + licence = (fonts.FONT_DIR / "OFL.txt").read_text(encoding="utf-8") + self.assertIn("SIL OPEN FONT LICENSE", licence) + + +class ContentTests(BasicReportTestCase): + def test_the_findings_use_the_local_results(self): + _path, _context, reader = self.render(key="default") + text = " ".join(page_texts(reader)) + + self.assertIn("Key Findings", text) + self.assertIn("unique exit risk", text) + self.assertIn("EC2 Instance", text) + self.assertIn("OpenStack", text) + self.assertIn("OpenInfra Foundation", text) + self.assertIn("Examples are listed alphabetically", text) + + def test_the_resource_inventory_lists_each_type_once(self): + _path, context, _reader = self.render(key="default") + + self.assertEqual([entry.name for entry in context.resources], ["EC2 Instance"]) + self.assertEqual( + context.total_resources, + sum(row["count"] for row in build_report_fixture()["resource_inventory"]), + ) + + def test_documents_are_identified_by_the_end_of_the_run_id(self): + _path, context, _reader = self.render(key="default") + self.assertEqual(context.document_id, "ES-0a6e21d4-EN") + + _path, local, _reader = self.render(provenance=provenance(run_id=None)) + self.assertEqual(local.document_id, "ES-LOCAL-EN") + + def test_costs_before_the_current_month_are_final(self): + fixture = build_report_fixture() + fixture["cost_data"] = [ + {"month": "2026-04-01", "cost": 10.0, "currency": "USD"}, + {"month": "2026-05-01", "cost": 12.0, "currency": "USD"}, + ] + _path, context, reader = self.render(fixture) + + self.assertEqual([entry.final for entry in context.costs], [True, False]) + self.assertIn("provisional", " ".join(page_texts(reader))) + + def test_mixed_currencies_are_not_totalled(self): + fixture = build_report_fixture() + fixture["cost_data"][0] = {**fixture["cost_data"][0], "currency": "EUR"} + + _path, _context, reader = self.render(fixture) + + self.assertIn("more than one currency", " ".join(page_texts(reader))) + + def test_the_pinned_risk_numbers_and_names_are_shown(self): + _path, context, reader = self.render(key="default") + + self.assertEqual([risk.code for risk in context.risks], ["R1"]) + text = " ".join(page_texts(reader)) + self.assertIn("R1", text) + self.assertIn("Limited Alternatives", text) + + def test_a_catalogue_severity_and_code_win_when_the_dataset_has_them(self): + fixture = build_report_fixture() + fixture["risk_definitions"] = [ + { + **fixture["risk_definitions"][0], + "code": "ALT-1", + "severity": "low", + "catalogue_severity": "critical", + } + ] + _path, context, _reader = self.render(fixture) + + self.assertEqual(context.risks[0].code, "ALT-1") + self.assertEqual(context.risks[0].severity, "critical") + + def test_names_from_the_data_are_escaped(self): + fixture = build_report_fixture() + fixture["metadata"]["name"] = "Prod & Co" + mapping = copy.deepcopy(fixture["resource_type_mapping"]) + mapping["101"]["name"] = "Bad name &" + fixture["resource_type_mapping"] = mapping + + _path, _context, reader = self.render(fixture) + + text = " ".join(page_texts(reader)) + self.assertIn("Prod & Co", text) + self.assertIn("Bad name &", text) + + +class AlternativesTests(BasicReportTestCase): + def test_a_missing_vendor_shows_a_dash_and_alternatives_are_alphabetical(self): + fixture = build_report_fixture() + technologies = fixture["alternative_technologies"] + technologies.append( + { + **technologies[0], + "id": 2, + "product_name": "Alpha", + "organization_id": None, + } + ) + fixture["alternatives"].append( + {"resource_type": "101", "strategy_type": "1", "alternative_technology": 2} + ) + + _path, context, reader = self.render(fixture) + + entry = context.alternatives[0] + self.assertEqual( + [alt.name for alt in entry.alternatives], ["Alpha", "OpenStack"] + ) + self.assertEqual(entry.alternatives[0].vendor, "") + self.assertEqual(entry.alternatives[1].vendor, "OpenInfra Foundation") + text = " ".join(page_texts(reader)) + self.assertIn("Alpha · –", text) + self.assertIn("OpenStack · OpenInfra Foundation", text) + self.assertLess(text.index("Alpha · –"), text.index("OpenStack ·")) + self.assertNotIn("Unknown Organization", text) + + +class ProvenanceTests(BasicReportTestCase): + def test_an_offline_report_says_it_is_self_collected(self): + path, _context, reader = self.render(key="offline", provenance=provenance()) + pages = page_texts(reader) + + self.assertIn( + f"Collected by cloudexit {__version__} on 2026-05-07", + pages[0], + ) + second = pages[1] + self.assertIn(f"Collected by cloudexit {__version__}", second) + self.assertIn(SYNC_ID, second) + self.assertIn("2026-11-02 · (sha256 cccccccccccc)", second) + self.assertIn("Self-collected data: no platform evaluated this report.", second) + self.assertEqual( + link_targets(reader) - AWS_NOTICE_LINKS, + set(), + ) + + def test_an_uploaded_report_carries_the_link_on_page_two_only(self): + _path, _context, reader = self.render(key="uploaded", provenance=uploaded()) + pages = page_texts(reader) + + self.assertIn("Uploaded to the platform", pages[1]) + self.assertIn(URL, pages[1]) + self.assertNotIn("Self-collected data", " ".join(pages)) + self.assertEqual(sum(URL in text for text in pages), 1) + self.assertEqual(link_targets(reader) - AWS_NOTICE_LINKS, {URL}) + + def test_an_upload_without_a_link_names_the_assessment_on_page_two(self): + _path, _context, reader = self.render( + key="no link", provenance=provenance(uploaded=True, assessment_id="a-77") + ) + pages = page_texts(reader) + + self.assertIn("Uploaded to the platform", pages[1]) + self.assertIn("Assessment ID: a-77", pages[1]) + self.assertEqual(sum("a-77" in text for text in pages), 1) + self.assertEqual(link_targets(reader) - AWS_NOTICE_LINKS, set()) + + def test_a_failed_upload_says_what_happened(self): + for name, changes, expected in ( + ( + "kept", + {"upload_reason": "upload_unavailable", "upload_spooled": True}, + "Upload failed: upload_unavailable. The run was kept for a later upload.", + ), + ( + "lost", + {"upload_reason": "upload_rejected"}, + "Upload failed: upload_rejected.", + ), + ("no reason", {}, "Upload failed."), + ( + "no reason, kept", + {"upload_spooled": True}, + "Upload failed. The run was kept", + ), + ): + with self.subTest(name): + _path, _context, reader = self.render( + key=f"failed {name}", + provenance=provenance(upload_failed=True, **changes), + ) + pages = page_texts(reader) + + self.assertIn(expected, pages[1]) + self.assertNotIn("Self-collected data", " ".join(pages)) + self.assertEqual("kept for a later upload" in pages[1], "kept" in name) + self.assertEqual(link_targets(reader) - AWS_NOTICE_LINKS, set()) + + def test_a_reason_that_ends_in_a_full_stop_is_not_doubled(self): + _path, _context, reader = self.render( + key="failed stop", + provenance=provenance(upload_failed=True, upload_reason="No answer."), + ) + + self.assertIn("Upload failed: No answer. ", page_texts(reader)[1] + " ") + self.assertNotIn("No answer..", page_texts(reader)[1]) + + def test_an_unknown_catalogue_version_is_said_so(self): + _path, _context, reader = self.render( + provenance=provenance( + catalogue_version=None, catalogue_sequence=None, catalogue_sha256=None + ) + ) + self.assertIn("Unknown (the dataset records no version)", page_texts(reader)[1]) + + def test_the_pdf_metadata_follows_the_cover(self): + path, context, reader = self.render(key="default") + info = reader.metadata + + self.assertEqual(info.title, "Executive Summary – Smoke Test Assessment") + self.assertIn(context.document_id, info.subject) + version = context.provenance.collector_version + self.assertIn(f"collected by cloudexit {version}", info.subject) + self.assertEqual(info.creator, f"cloudexit {version}") + self.assertEqual(info.author, "cloudexit") + for keyword in (context.document_id, SYNC_ID, "catalogue 2026-11-02"): + self.assertIn(keyword, info["/Keywords"]) + # The dates are the generation time, not the render clock + self.assertIn("20260507120000", info["/CreationDate"]) + + def test_the_xmp_packet_carries_the_provenance(self): + _path, _context, reader = self.render(key="uploaded", provenance=uploaded()) + packet = xmp_text(reader) + + for expected in ( + f"{SYNC_ID}", + f"{__version__}", + "2026-11-02", + "true", + "cloudexit", + ): + self.assertIn(expected, packet) + + def test_the_json_report_has_a_provenance_object(self): + for upload, expected_url in ( + (None, None), + ( + { + "status": "uploaded", + "sync_id": SYNC_ID, + "assessment_id": "a-1", + "url": URL, + }, + URL, + ), + ): + with self.subTest(uploaded=bool(upload)): + report = build_flow_run( + self.tmp / f"flow-{bool(upload)}", + resources=[(1, "eu-central-1", 3)], + costs=[("2026-09-01", 10.0, "USD")], + ) + perform_risk_assessment(1, report) + result = generate_report( + 2, + {"region": "eu-central-1"}, + 1, + 1, + "Flow", + report, + str(Path(report) / "raw_data"), + upload=upload, + sync_id=SYNC_ID, + ) + self.assertTrue(result["success"], result.get("logs")) + + document = json.loads(Path(result["reports"]["JSON"]).read_text()) + self.assertEqual( + document["provenance"], + { + "collector_version": __version__, + "catalogue_version": "2026-11-02", + "catalogue_sequence": 41, + "catalogue_sha256": "c" * 64, + "sync_id": SYNC_ID, + "uploaded": bool(upload), + "platform_url": expected_url, + }, + ) + html = (Path(report) / "index.html").read_text(encoding="utf-8") + text = html_text(html) + self.assertIn(SYNC_ID, text) + self.assertEqual(URL in html, bool(upload)) + self.assertEqual( + "Self-collected data: no platform evaluated this report." in text, + not upload, + ) + + +class BasicOnlyTests(BasicReportTestCase): + def test_no_score_or_grade_appears_on_any_page(self): + for name, fixture, overrides in self.scenarios(): + with self.subTest(name): + _path, _context, reader = self.render(fixture, key=name, **overrides) + body = " ".join(page_texts(reader)) + + for pattern in SCORE_PATTERNS: + self.assertIsNone(re.search(pattern, body, re.IGNORECASE), pattern) + + def test_no_closing_page_and_no_command_is_left(self): + for name, fixture, overrides in self.scenarios(): + with self.subTest(name): + _path, _context, reader = self.render(fixture, key=name, **overrides) + body = " ".join(page_texts(reader)) + + for text in CLOSING_PAGE_TEXTS: + self.assertNotIn(text, body) + self.assertNotIn("--api-key-file", body) + + def test_no_edition_or_grade_is_promoted_on_any_page(self): + for name, fixture, overrides in self.scenarios(): + with self.subTest(name): + _path, _context, reader = self.render(fixture, key=name, **overrides) + for number, text in enumerate(page_texts(reader), start=1): + for pattern in PROMOTION_PATTERNS: + self.assertIsNone(re.search(pattern, text), (number, pattern)) + + def test_the_enterprise_support_flag_does_not_trip_the_edition_check(self): + _path, _context, reader = self.render(key="default") + body = " ".join(page_texts(reader)) + + self.assertIn("Enterprise support", body) + self.assertIsNone(re.search(PROMOTION_PATTERNS[1], body)) + self.assertIsNotNone(re.search(PROMOTION_PATTERNS[1], "The Enterprise edition")) + self.assertIsNotNone(re.search(PROMOTION_PATTERNS[0], "A Light assessment")) + + def test_exitcloud_is_named_only_on_page_two_of_an_uploaded_report(self): + for name, fixture, overrides in self.scenarios(): + if "uploaded" in name: + overrides = { + **overrides, + "provenance": provenance( + uploaded=True, platform_url=EXITCLOUD_URL, assessment_id="a-1" + ), + } + with self.subTest(name): + _path, _context, reader = self.render(fixture, **overrides) + pages = [text.lower() for text in page_texts(reader)] + named = [ + n for n, text in enumerate(pages, start=1) if "exitcloud" in text + ] + + self.assertEqual(named, [2] if "uploaded" in name else []) + + def test_escapecloud_is_named_only_on_the_legal_notices_page(self): + for name, fixture, overrides in self.scenarios(): + with self.subTest(name): + _path, _context, reader = self.render(fixture, key=name, **overrides) + pages = [text.lower() for text in page_texts(reader)] + named = [ + n for n, text in enumerate(pages, start=1) if "escapecloud" in text + ] + + self.assertEqual(named, [len(pages)]) + self.assertIn("legal notices", pages[-1]) + + +class TermGuardTests(BasicReportTestCase): + def test_no_regulatory_word_or_foreign_brand_in_pdf_html_or_metadata(self): + for name, fixture, overrides in self.scenarios(): + with self.subTest(name): + _path, _context, reader = self.render(fixture, key=name, **overrides) + texts = page_texts(reader) + metadata_texts(reader) + for text in texts: + self.assertEqual(find_terms(text), []) + + report = build_flow_run( + self.tmp / "guard", + resources=[(1, "eu-central-1", 3)], + costs=[("2026-09-01", 10.0, "USD")], + ) + perform_risk_assessment(1, report) + generate_report( + 2, + {"region": "eu-central-1"}, + 1, + 1, + "Flow", + report, + str(Path(report) / "raw_data"), + sync_id=SYNC_ID, + ) + html = (Path(report) / "index.html").read_text(encoding="utf-8") + self.assertEqual(find_terms(html_text(html)), []) + self.assertEqual(find_terms(html.split(" {value}" for tag, value in references(html) if not is_local(value) + ] + for base, css in (("", html),) + tuple(stylesheets): + problems += [ + f"css {value}" for value in css_references(css) if not is_local(value, base) + ] + return problems + + +def composed_of(text, pieces): + # Whether the text is built only from the pieces, joined by single spaces + if not text: + return True + return any( + text.startswith(piece) + and (len(text) == len(piece) or text[len(piece)] == " ") + and composed_of(text[len(piece) :].lstrip(" "), pieces) + for piece in pieces + if piece + ) + + +class SampleTestCase(unittest.TestCase): + def cases(self): + yield from STATE["scenarios"].items() + + def folder(self, case): + return STATE["results"][case].directory + + def page(self, case): + return (self.folder(case) / "index.html").read_text(encoding="utf-8") + + def text(self, case): + return html_text(self.page(case)) + + def section(self, case, name): + match = re.search( + rf']*id="{name}".*?', self.page(case), re.DOTALL + ) + self.assertIsNotNone(match, f"{case}: no section {name}") + return match.group(0) + + def tour(self, case): + match = re.search( + r'', + self.page(case), + re.DOTALL, + ) + return json.loads(match.group(1)) + + def sections(self, case): + return re.findall(r']*id="([a-z]+)"', self.page(case)) + + +class StructureTests(SampleTestCase): + def test_the_sections_come_in_the_planned_order_with_egress_only_when_it_ran(self): + for case, scenario in self.cases(): + with self.subTest(case): + expected = WITH_EGRESS if scenario.egress else BASE_SECTIONS + self.assertEqual(self.sections(case), expected) + nav = re.search(r"", self.page(case), re.DOTALL).group(0) + self.assertEqual(re.findall(r'', nav), expected) + + def test_the_download_menu_has_one_pdf_or_two_and_no_json(self): + for case, scenario in self.cases(): + with self.subTest(case): + menu = re.search( + r'")]) + + self.assertIn("egress prices are public list prices", figures("aws_typical")) + self.assertNotIn("egress", figures("aws_no_egress")) + self.assertIn("a state file holds no billing data", figures("tfstate_aws")) + + def test_the_trademark_sentences_are_in_the_page_and_both_pdfs(self): + for case, scenario in self.cases(): + with self.subTest(case): + text = self.text(case) + self.assertIn(BLOCKS["notices_trademark_own"], text) + self.assertIn(BLOCKS["notices_trademark_others"], text) + for pdf in ("report.pdf", "egress.pdf"): + if (self.folder(case) / pdf).is_file(): + last = page_texts(PdfReader(str(self.folder(case) / pdf)))[-1] + self.assertIn(BLOCKS["notices_trademark_own"], last) + self.assertIn(BLOCKS["notices_trademark_others"], last) + + def test_no_trademark_claim_for_cloudexit_or_the_logo(self): + texts = [value for value in BLOCKS.values() if isinstance(value, str)] + texts += [self.text(case) for case, _scenario in self.cases()] + for text in texts: + self.assertIsNone( + re.search(r"(?i)cloudexit[^.]{0,60}trademark", text), text[:80] + ) + self.assertIsNone( + re.search( + r"(?i)(escapecloud|cloudexit)\b[^.]{0,60}\blogo\b[^.]{0,30}trademark", + text, + ), + text[:80], + ) + for case, _scenario in self.cases(): + self.assertEqual(find_terms(self.text(case)), []) + + def test_the_logo_files_carry_their_own_notice(self): + note = asset_path("img", "logo", "README.md").read_text(encoding="utf-8") + + self.assertIn( + "The EscapeCloud logo files in this directory are not covered by this " + "project's LICENSE. All rights reserved.", + note, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/report/test_icon_assets.py b/tests/report/test_icon_assets.py new file mode 100644 index 0000000..df46dab --- /dev/null +++ b/tests/report/test_icon_assets.py @@ -0,0 +1,62 @@ +import sqlite3 +import tempfile +import unittest +from pathlib import Path + +from cloudexit.resources import asset_path +from cloudexit.report.egress import FALLBACK_ICONS +from tests.make_smoke_dataset import main as make_smoke_dataset + + +def icon_file(icon): + return asset_path(*icon.lstrip("/").split("/")) + + +class IconAssetTests(unittest.TestCase): + def dataset_icons(self): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "smoke.db" + make_smoke_dataset(str(path)) + conn = sqlite3.connect(path) + try: + return [row[0] for row in conn.execute("SELECT icon FROM resourcetype")] + finally: + conn.close() + + def test_every_png_icon_a_dataset_points_at_is_in_the_package(self): + icons = [icon for icon in self.dataset_icons() if icon.endswith(".png")] + + self.assertTrue(icons) + for icon in icons: + with self.subTest(icon=icon): + self.assertTrue(icon_file(icon).is_file()) + + def test_the_icons_the_egress_report_hardcodes_are_in_the_package(self): + for code, icon in FALLBACK_ICONS.items(): + with self.subTest(code=code): + self.assertTrue(icon_file(icon).is_file()) + + def test_the_dataset_points_at_png_icons_only(self): + for icon in self.dataset_icons(): + self.assertTrue(not icon or icon.endswith(".png"), icon) + + def test_no_svg_icon_is_shipped(self): + self.assertEqual(sorted(asset_path("icons").rglob("*.svg")), []) + + def test_every_provider_icon_folder_holds_png_files(self): + for provider in ("aws", "azure"): + with self.subTest(provider=provider): + icons = list(asset_path("icons", provider).rglob("*.png")) + self.assertGreater(len(icons), 300) + + def test_the_assets_nothing_uses_are_not_shipped(self): + self.assertFalse(asset_path("img", "logo", "favicon.ico").exists()) + self.assertFalse(asset_path("fonts", "IBMPlexSans-Medium.ttf").exists()) + + def test_the_icon_licence_notice_stays(self): + notice = asset_path("icons", "README.md").read_text(encoding="utf-8") + self.assertIn("NOT covered by this project's LICENSE", notice) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/report/test_icon_placeholder.py b/tests/report/test_icon_placeholder.py new file mode 100644 index 0000000..fbafdbd --- /dev/null +++ b/tests/report/test_icon_placeholder.py @@ -0,0 +1,393 @@ +import copy +import json +import os +import re +import shutil +import tempfile +import unittest +from io import BytesIO +from pathlib import Path +from unittest.mock import patch + +from pypdf import PdfReader +from reportlab.lib.pagesizes import A4 +from reportlab.pdfgen.canvas import Canvas +from reportlab.platypus import SimpleDocTemplate, Table + +from cloudexit.report.executive_summary import generate_executive_summary +from cloudexit.report.helpers import initials +from cloudexit.report.palette import contrast_failures +from cloudexit.report.placeholder import IconPlaceholder, LabelWithPlaceholder +from cloudexit.report.sections import ALT_BODY +from cloudexit.egress.model import GIB +from cloudexit.resources import asset_path +from cloudexit.report.writers import generate_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, + build_report_fixture, + stage_minimal_assets, + stage_report_assets, +) +from tests.report.test_egress import ( + _archive_tiers, + _fake_load_data, + _payload, + _row, + _seed_egress_inventory, +) + +EC2_ICON = "/icons/aws/Compute/EC2.png" +LAMBDA_ICON = "/icons/aws/Compute/Lambda.png" +PLACEHOLDER = '' + + +def with_initials(initials_text, name): + # The text extractor may give the label before or after the placeholder. + return rf"({initials_text}\s+{name}|{name}\s+{initials_text})" + + +def pdf_text(path): + return "\n".join(page.extract_text() for page in PdfReader(path).pages) + + +def image_count(path): + return sum(len(page.images) for page in PdfReader(path).pages) + + +class InitialsTests(unittest.TestCase): + def test_two_words_give_two_letters_and_one_word_gives_one(self): + cases = { + "Virtual Machine": "VM", + "S3 Bucket": "SB", + "storage account": "SA", + "Elastic Block Store Volumes": "EB", + "Lambda": "L", + " padded words ": "PW", + "école supérieure": "ÉS", + } + for name, expected in cases.items(): + with self.subTest(name=name): + self.assertEqual(initials(name), expected) + + def test_a_missing_or_symbol_only_name_is_a_question_mark(self): + for name in (None, "", " ", "–", "()", "- -"): + with self.subTest(name=name): + self.assertEqual(initials(name), "?") + + def test_a_word_that_starts_with_a_symbol_uses_its_first_letter(self): + self.assertEqual(initials("(Classic) VM"), "CV") + + +class FlowableTests(unittest.TestCase): + def render(self, flowables, size=A4): + buffer = BytesIO() + SimpleDocTemplate(buffer, pagesize=size).build(flowables) + buffer.seek(0) + return PdfReader(buffer) + + def test_the_placeholder_is_a_square_of_the_icon_size(self): + placeholder = IconPlaceholder("Virtual Machine", 20) + self.assertEqual(placeholder.wrap(100, 100), (20, 20)) + + def test_the_placeholder_draws_the_initials_as_text(self): + reader = self.render([IconPlaceholder("Virtual Machine", 20)]) + self.assertIn("VM", reader.pages[0].extract_text()) + + def test_a_label_with_a_placeholder_wraps_like_text_and_keeps_the_icon_height(self): + short = LabelWithPlaceholder("Lambda", "Lambda", ALT_BODY) + width, height = short.wrap(200, 500) + self.assertEqual(width, 200) + self.assertGreaterEqual(height, short.size) + + long_name = "A very long resource type name " * 6 + long = LabelWithPlaceholder(long_name, f"{long_name}", ALT_BODY) + _, long_height = long.wrap(120, 500) + self.assertGreater(long_height, height) + + def test_a_label_in_a_table_cell_shows_the_initials_and_the_name(self): + cell = LabelWithPlaceholder( + "Virtual Machine", "Virtual Machine", ALT_BODY + ) + text = self.render([Table([[cell, "7"]], colWidths=[200, 50])]) + text = text.pages[0].extract_text() + self.assertRegex(text, with_initials("VM", "Virtual Machine")) + + def test_the_placeholder_colours_pass_the_report_contrast_check(self): + self.assertEqual(contrast_failures(), []) + + def test_a_canvas_can_draw_it_directly(self): + buffer = BytesIO() + canvas = Canvas(buffer, pagesize=A4) + IconPlaceholder("S3 Bucket", 11).drawOn(canvas, 50, 50) + canvas.save() + buffer.seek(0) + self.assertIn("SB", PdfReader(buffer).pages[0].extract_text()) + + +class TempDirTestCase(unittest.TestCase): + def setUp(self): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.tmp = Path(tmp.name) + self.count = 0 + + def directory(self): + self.count += 1 + path = self.tmp / f"report-{self.count}" + path.mkdir() + return path + + +def fixture_with(extra_types): + fixture = copy.deepcopy(build_report_fixture()) + for type_id, mapping in extra_types.items(): + if mapping is not None: + fixture["resource_type_mapping"][str(type_id)] = mapping + fixture["resource_inventory"].append( + {"resource_type": type_id, "location": "eu-central-1", "count": 3} + ) + return fixture + + +class BasicPdfTests(TempDirTestCase): + def render(self, fixture): + directory = self.directory() + stage_minimal_assets(str(directory)) + context = build_fixture_context(fixture, directory) + return generate_executive_summary(context), context + + def test_a_type_without_an_icon_gets_a_placeholder_and_the_others_keep_their_image( + self, + ): + fixture = fixture_with( + { + 303: { + "id": 303, + "code": "AWS.Compute.Machines", + "name": "Virtual Machine", + "icon": "", + }, + 404: None, + } + ) + + path, context = self.render(fixture) + + icons = {entry.name: entry.icon for entry in context.resources} + self.assertTrue(os.path.isfile(icons["EC2 Instance"])) + self.assertIsNone(icons["Virtual Machine"]) + self.assertIsNone(icons["–"]) + text = pdf_text(path) + self.assertRegex(text, with_initials("VM", "Virtual Machine")) + self.assertIn("?", text) + + def test_the_placeholder_replaces_an_image_it_does_not_add_one(self): + with_icon = fixture_with( + { + 303: { + "id": 303, + "code": "AWS.Compute.Machines", + "name": "Virtual Machine", + "icon": LAMBDA_ICON, + } + } + ) + without_icon = fixture_with( + { + 303: { + "id": 303, + "code": "AWS.Compute.Machines", + "name": "Virtual Machine", + "icon": "", + } + } + ) + + with_path, _ = self.render(with_icon) + without_path, _ = self.render(without_icon) + + self.assertGreater(image_count(with_path), image_count(without_path)) + self.assertNotRegex(pdf_text(with_path), with_initials("VM", "Virtual Machine")) + + def test_an_icon_file_that_does_not_exist_is_a_placeholder_too(self): + fixture = fixture_with( + { + 303: { + "id": 303, + "code": "AWS.Compute.Machines", + "name": "Virtual Machine", + "icon": "/icons/aws/Compute/NoSuchIcon.png", + } + } + ) + path, context = self.render(fixture) + + self.assertIsNone( + next(e.icon for e in context.resources if e.name == "Virtual Machine") + ) + self.assertRegex(pdf_text(path), with_initials("VM", "Virtual Machine")) + + +class HtmlReportTests(TempDirTestCase): + def render(self, fixture, with_icon=True): + directory = self.directory() + if with_icon: + # The page points only at files in its own folder + icon = directory / "assets" / EC2_ICON.lstrip("/") + icon.parent.mkdir(parents=True) + shutil.copyfile(asset_path("icons", "aws", "Compute", "EC2.png"), icon) + context = build_fixture_context(fixture, directory) + return Path(generate_html_report(context)).read_text(encoding="utf-8") + + def test_a_type_without_an_icon_renders_the_placeholder_not_a_broken_image(self): + fixture = fixture_with( + { + 303: { + "id": 303, + "code": "AWS.Compute.Machines", + "name": "Virtual Machine", + "icon": "", + }, + 404: None, + } + ) + + html = self.render(fixture) + + self.assertIn(PLACEHOLDER.format("VM"), html) + # A type missing from the mapping has no name, so its initials are "?" + self.assertIn(PLACEHOLDER.format("?"), html) + self.assertNotIn("default.png", html) + self.assertNotIn('src="assets/"', html) + self.assertNotIn("None", re.findall(r'src="assets/[^"]*"', html)) + + def test_a_type_with_an_icon_keeps_its_image(self): + html = self.render(fixture_with({})) + + self.assertIn(f'src="assets{EC2_ICON}"', html) + self.assertNotIn(PLACEHOLDER.format("EI"), html) + + def test_an_icon_the_report_folder_does_not_hold_is_a_placeholder(self): + # The page never points outside the report folder + html = self.render(fixture_with({}), with_icon=False) + + self.assertNotIn(f'src="assets{EC2_ICON}"', html) + self.assertIn(PLACEHOLDER.format("EI"), html) + + def test_both_kinds_appear_side_by_side(self): + fixture = fixture_with( + { + 303: { + "id": 303, + "code": "AWS.Compute.Machines", + "name": "Virtual Machine", + "icon": "", + } + } + ) + html = self.render(fixture) + + self.assertIn(f'src="assets{EC2_ICON}"', html) + self.assertIn(PLACEHOLDER.format("VM"), html) + + def test_the_stylesheet_styles_the_placeholder(self): + css = asset_path("css", "report.css").read_text(encoding="utf-8") + self.assertIn(".rt .ph", css) + + +class EnrichedInventoryTests(unittest.TestCase): + MAPPING = { + "1": {"code": "A", "name": "With Icon", "icon": EC2_ICON}, + "2": {"code": "B", "name": "No Icon", "icon": ""}, + } + INVENTORY = [ + {"resource_type": 1, "location": "x", "count": 1}, + {"resource_type": 2, "location": "x", "count": 1}, + {"resource_type": 3, "location": "x", "count": 1}, + ] + + def test_the_inventory_carries_no_fallback_icon(self): + rows = enrich_resource_inventory(self.INVENTORY, self.MAPPING) + + self.assertEqual([row["icon"] for row in rows], [EC2_ICON, "", ""]) + self.assertTrue(all("icon_url" not in row for row in rows)) + + +class EgressReportTests(TempDirTestCase): + ROWS = [ + _row( + "stdemo", + "Storage Account", + "object", + 2048 * GIB, + resource_type="Microsoft.Storage/storageAccounts", + ), + _row("vol1", "Managed Disk", "block", 100 * GIB, resource_type="some/type"), + ] + + def prepare(self, icons): + directory = self.directory() + stage_report_assets(str(directory)) + json_path = directory / "egress_inventory_raw_data.json" + json_path.write_text(json.dumps(_payload(self.ROWS)), encoding="utf-8") + _seed_egress_inventory(str(directory), self.ROWS, _archive_tiers(self.ROWS)) + return directory, str(json_path), icons + + def run_report(self, generate, icons): + directory, json_path, icons = self.prepare(icons) + with ( + patch("cloudexit.report.egress.load_data", side_effect=_fake_load_data), + patch("cloudexit.report.egress._build_icon_lookup", return_value=icons), + ): + result = generate(str(directory), json_path) + self.assertTrue(result["success"], result.get("logs")) + return result + + 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( + cell("/icons/aws/Compute/NoSuchIcon.png"), LabelWithPlaceholder + ) + 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} + + text = pdf_text(self.run_pdf(icons)) + + self.assertRegex(text, r"MD") + self.assertIn("Managed Disk", text) + + def test_the_pdf_has_one_image_less_when_a_type_loses_its_icon(self): + both = { + "microsoft.storage/storageaccounts": EC2_ICON, + "some/type": LAMBDA_ICON, + } + one = {"microsoft.storage/storageaccounts": EC2_ICON} + count_both = image_count(self.run_pdf(both)) + + self.assertEqual(image_count(self.run_pdf(one)), count_both - 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/report/test_no_credentials.py b/tests/report/test_no_credentials.py new file mode 100644 index 0000000..59783b1 --- /dev/null +++ b/tests/report/test_no_credentials.py @@ -0,0 +1,235 @@ +import copy +from contextlib import closing +import json +import logging +import os +import sqlite3 +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from pypdf import PdfReader + +from cloudexit.cli import app +from cloudexit.egress.model import write_egress_inventory +from tests.report import test_egress as egress_helpers +from tests.report.test_report_flow import FlowTestCase +from tests.support.credentials import AWS_DETAILS, AZURE_DETAILS, leaked_fragments + +MSAL_LINE = '{"client_id": "%s", "api_id": "729", "event_name": "msal.token"}' + + +def pricing_for_both_clouds(table_name, db_path=None): + if table_name == "egresspricing": + return [ + {**row, "csp": csp} + for csp in (1, 2) + for row in egress_helpers.RENDER_PRICING + ] + return [] + + +def folder_text(folder): + # Every file of the report folder as text; the PDFs by their extracted text + found = {} + for path in sorted(Path(folder).rglob("*")): + relative = path.relative_to(folder) + if not path.is_file() or relative.parts[0] == "assets": + continue + if path.suffix == ".pdf": + reader = PdfReader(str(path)) + found[relative.as_posix()] = "\n".join( + page.extract_text() or "" for page in reader.pages + ) + else: + found[relative.as_posix()] = path.read_bytes().decode("utf-8", "ignore") + return found + + +def find_leaks(folder, details): + fragments = leaked_fragments(details) + return sorted( + f"{name}: {fragment}" + for name, text in folder_text(folder).items() + for fragment in fragments + if fragments[fragment] in text + ) + + +class OutputGuardTests(FlowTestCase): + def setUp(self): + super().setUp() + root = logging.getLogger() + saved = (root.level, list(root.handlers)) + levels = { + name: logging.getLogger(name).level + for name in (*app._THIRD_PARTY_NOISY, *app._ALWAYS_QUIET) + } + + def restore(): + root.setLevel(saved[0]) + root.handlers[:] = saved[1] + for name, level in levels.items(): + logging.getLogger(name).setLevel(level) + + self.addCleanup(restore) + + def run_with_egress(self, provider, details): + # (type id, code, label) of a resource type in the fixture catalogue + type_id, code, label = { + 2: (1, "AWS.EC2.DescribeInstances", "EC2 Instance"), + 1: (3, "Azure.Compute.VirtualMachines", "Virtual Machine"), + }[provider] + report = self.report( + resources=[(type_id, "eu-central-1" if provider == 2 else "westeurope", 8)] + ) + raw_data = os.path.join(report, "raw_data") + rows = [ + egress_helpers._row( + "proddata", + label, + "object", + 2048 * egress_helpers.GIB, + resource_type=code, + ), + egress_helpers._row("vault1", label, "object", None, resource_type=code), + ] + + def estimate(csp, provider_details, raw_path, **kwargs): + payload = egress_helpers._payload(rows) + payload["meta"]["cloud_service_provider"] = csp + json_path = os.path.join(raw_path, "egress_inventory_raw_data.json") + Path(json_path).write_text(json.dumps(payload), encoding="utf-8") + db_path = os.path.join(report, "data", "assessment.db") + with closing(sqlite3.connect(db_path)) as conn: + conn.execute( + "CREATE TABLE IF NOT EXISTS resourcetype_data (id INTEGER PRIMARY " + "KEY, resource_type INTEGER NOT NULL, data_category TEXT NOT NULL, " + "strategy TEXT NOT NULL, params TEXT, status TEXT NOT NULL)" + ) + conn.execute( + "INSERT INTO resourcetype_data VALUES (1, ?, 'object', 's', '{}', 't')", + (type_id,), + ) + conn.commit() + write_egress_inventory( + rows, {code: type_id}, egress_helpers._archive_tiers(rows), db_path + ) + return {"success": True, "logs": "", "json_path": json_path} + + def verify(csp, provider_details): + # What MSAL writes at DEBUG while the Azure credential is built + logging.getLogger("msal.telemetry").debug( + MSAL_LINE % provider_details.get("clientId", "none") + ) + return True, "ok" + + patches = [ + patch("cloudexit.cli.app.validate_config"), + patch( + "cloudexit.cli.app.create_directory", return_value=(report, raw_data) + ), + patch("cloudexit.cli.app.verify_credentials", side_effect=verify), + patch( + "cloudexit.cli.app.test_permissions", + return_value=(True, True, True, "ok"), + ), + patch( + "cloudexit.cli.app.create_resource_inventory", + return_value={"success": True, "logs": ""}, + ), + patch( + "cloudexit.cli.app.create_cost_inventory", + return_value={"success": True, "logs": ""}, + ), + patch("cloudexit.cli.app.estimate_egress", side_effect=estimate), + patch( + "cloudexit.report.egress.load_data", + side_effect=pricing_for_both_clouds, + ), + patch("cloudexit.cli.app.print_step"), + patch("cloudexit.cli.app.console.print"), + ] + for item in patches: + item.start() + self.addCleanup(item.stop) + + config = { + "name": "Guard Test", + "cloudServiceProvider": provider, + "exitStrategy": 1, + "assessmentType": 1, + "providerDetails": copy.deepcopy(details), + } + # -vv is the most permissive setting: everything not muted gets into run.log + app.configure_logging(2) + app.run_assessment( + config, + "aws" if provider == 2 else "azure", + egress=True, + flush_spool=False, + ) + return report + + def assert_complete_run(self, report): + for name in ( + "index.html", + "report.pdf", + "egress.pdf", + "run.log", + "raw_data/assessment_result.json", + "raw_data/sync.json", + ): + self.assertTrue((Path(report) / name).is_file(), name) + + def test_an_aws_run_with_egress_writes_no_credential(self): + report = self.run_with_egress(2, AWS_DETAILS) + + self.assert_complete_run(report) + self.assertEqual(find_leaks(report, AWS_DETAILS), []) + + def test_an_azure_run_with_egress_writes_no_credential(self): + report = self.run_with_egress(1, AZURE_DETAILS) + + self.assert_complete_run(report) + self.assertEqual(find_leaks(report, AZURE_DETAILS), []) + log = (Path(report) / "run.log").read_text(encoding="utf-8") + self.assertNotIn("msal", log) + + 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("Assessment Scope", text) + self.assertIn("Subscription ID", text) + self.assertIn(AZURE_DETAILS["subscriptionId"], text) + self.assertNotIn("Client", text) + + def test_the_guard_finds_a_planted_value(self): + for part in ("", "[:4]", "[-4:]"): + with self.subTest(part=part): + with tempfile.TemporaryDirectory() as tmp: + value = eval(f"AZURE_DETAILS['clientId']{part}") + (Path(tmp) / "raw_data").mkdir() + (Path(tmp) / "raw_data" / "x.json").write_text( + f'{{"a": "{value}"}}', encoding="utf-8" + ) + (Path(tmp) / "assets").mkdir() + (Path(tmp) / "assets" / "ignored.txt").write_text(value) + + leaks = find_leaks(tmp, AZURE_DETAILS) + + self.assertTrue(leaks) + self.assertTrue( + all(leak.startswith("raw_data/x.json") for leak in leaks) + ) + + def test_the_guard_finds_nothing_in_a_clean_folder(self): + with tempfile.TemporaryDirectory() as tmp: + (Path(tmp) / "run.log").write_text("nothing to see", encoding="utf-8") + self.assertEqual(find_leaks(tmp, AWS_DETAILS), []) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/report/test_report_flow.py b/tests/report/test_report_flow.py new file mode 100644 index 0000000..32ea635 --- /dev/null +++ b/tests/report/test_report_flow.py @@ -0,0 +1,874 @@ +import copy +import inspect +import io +import json +import os +import sqlite3 +import sys +import tempfile +import unittest +from dataclasses import replace +from contextlib import nullcontext +from pathlib import Path +from unittest.mock import patch + +import requests +from rich.console import Console + +from cloudexit.cli import app +from cloudexit.engine import generate_report, perform_risk_assessment +from cloudexit.platform.document import SyncDocumentError +from cloudexit.platform.upload import ( + REASON_REJECTED, + REASON_UNAVAILABLE, + UploadResult, + failure, + report_info, + upload, + write_upload_record, +) +from tests.support.html import html_text +from tests.support.isolation import forget_cache, isolated_environ +from tests.support.api import ( + ASSESSMENT_ID, + KEY, + URL, + corrupt, + created_reply, + error_body, + meta_reply, + minimal_document, + reply, + success_replies, + sync_body, + whoami_reply, +) +from tests.support.egress import empty_estimate +from tests.support.flow import LOCAL_RISKS, SERVER_SCORES, build_flow_run +from tests.support.http import fake_http, make_response +from tests.support.sync import SYNC_ID, egress_row +from cloudexit import codes +from cloudexit.settings import Setting, Settings + +AWS_RESOURCES = [(1, "eu-central-1", 8), (4, "eu-central-1", 2)] +COSTS = [("2026-09-01", 12.5, "USD")] +CONFIG = { + "name": "Flow Test", + "cloudServiceProvider": 2, + "exitStrategy": 1, + "assessmentType": 2, + "providerDetails": { + "accessKey": "AKIAFAKEACCESSKEY1234", + "secretKey": "fake-secret-key-AAAABBBBCCCC", + "region": "eu-central-1", + "accountId": "123456789012", + }, +} +CONFIGURED = Settings( + host=Setting("https://platform.test", "env CLOUDEXIT_HOST"), + api_key=Setting(KEY, "env CLOUDEXIT_API_KEY"), + upload_budget=Setting(0.0, "env CLOUDEXIT_UPLOAD_BUDGET"), +) + + +def failed_replies(status, code="service_unavailable", retryable=True): + if status is None: + return [meta_reply(), whoami_reply(), requests.ConnectionError("refused")] + body = error_body(code, "server said no", retryable) + return [meta_reply(), whoami_reply(), reply(status, body)] + + +class FlowTestCase(unittest.TestCase): + def setUp(self): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.tmp = Path(tmp.name) + patcher = patch.dict( + os.environ, + isolated_environ(self.tmp), + ) + patcher.start() + self.addCleanup(patcher.stop) + self.counter = 0 + + def forget_meta(self): + forget_cache(self.tmp) + + def report(self, **kwargs): + self.counter += 1 + kwargs.setdefault("resources", AWS_RESOURCES) + kwargs.setdefault("costs", COSTS) + return build_flow_run(self.tmp / f"report-{self.counter}", **kwargs) + + def rows(self, report, table): + conn = sqlite3.connect(Path(report) / "data" / "assessment.db") + try: + return conn.execute(f"SELECT * FROM {table}").fetchall() + finally: + conn.close() + + def raw(self, report, name): + return Path(report) / "raw_data" / name + + def json_report(self, report): + return json.loads(self.raw(report, "assessment_result.json").read_text()) + + def html(self, report): + return (Path(report) / "index.html").read_text(encoding="utf-8") + + +class LocalResultsTests(FlowTestCase): + def test_the_risk_assessment_has_no_online_mode_any_more(self): + parameters = list(inspect.signature(perform_risk_assessment).parameters) + self.assertEqual(parameters, ["exit_strategy", "report_path"]) + + def test_the_risk_assessment_computes_the_local_risks(self): + report = self.report() + + result = perform_risk_assessment(1, report) + + self.assertTrue(result["success"]) + self.assertEqual( + [(row[1], row[2]) for row in self.rows(report, "risk_inventory")], + LOCAL_RISKS, + ) + + def test_platform_scores_left_in_the_database_are_never_rendered(self): + report = self.report(platform_scores=True) + perform_risk_assessment(1, report) + + for assessment_type in (1, 2): + with self.subTest(assessment_type=assessment_type): + result = generate_report( + 2, + CONFIG["providerDetails"], + 1, + assessment_type, + "Flow", + report, + str(Path(report) / "raw_data"), + ) + self.assertTrue(result["success"], result.get("logs")) + + self.assertNotIn("scoring_data", self.json_report(report)["data"]) + html = self.html(report) + self.assertNotIn(f"exitScoreValue = {SERVER_SCORES[0]}", html) + ( + self.assertNotIn("Scoring #1 - Exit Readiness", html) + if (assessment_type == 1) + else None + ) + + def test_an_offline_report_has_no_upload_trace(self): + report = self.report() + perform_risk_assessment(1, report) + + result = generate_report( + 2, + CONFIG["providerDetails"], + 1, + 1, + "Flow", + report, + str(Path(report) / "raw_data"), + ) + + self.assertTrue(result["success"]) + document = self.json_report(report) + self.assertNotIn("upload", document["meta"]) + self.assertEqual( + sorted(document["data"]), + [ + "alternative_technologies", + "cost_inventory", + "resource_inventory", + "risk_inventory", + ], + ) + self.assertEqual(len(document["data"]["resource_inventory"]), 2) + self.assertEqual( + sorted(int(entry["id"]) for entry in document["data"]["risk_inventory"]), + [2, 4], + ) + self.assertNotIn("Upload failed", self.html(report)) + self.assertTrue(Path(result["reports"]["PDF"]).is_file()) + + def generate(self, report, upload_info): + return generate_report( + 2, + CONFIG["providerDetails"], + 1, + 1, + "Flow", + report, + str(Path(report) / "raw_data"), + upload=upload_info, + ) + + def test_a_failed_upload_puts_a_banner_in_every_report(self): + report = self.report() + perform_risk_assessment(1, report) + info = {"status": "failed", "reason": REASON_UNAVAILABLE, "sync_id": SYNC_ID} + + result = self.generate(report, info) + + self.assertTrue(result["success"], result.get("logs")) + self.assertIn("Upload failed: upload_unavailable.", self.html(report)) + self.assertEqual(self.json_report(report)["meta"]["upload"], info) + self.assertTrue(Path(result["reports"]["PDF"]).is_file()) + + def test_a_failed_upload_that_was_kept_says_so_in_the_provenance(self): + report = self.report() + perform_risk_assessment(1, report) + info = { + "status": "failed", + "reason": REASON_UNAVAILABLE, + "sync_id": SYNC_ID, + "spooled": True, + } + + result = self.generate(report, info) + + self.assertTrue(result["success"], result.get("logs")) + self.assertIn( + "Upload failed: upload_unavailable. The run was kept for a later upload.", + html_text(self.html(report)), + ) + self.assertEqual(self.json_report(report)["meta"]["upload"], info) + + def test_a_successful_upload_records_only_the_link(self): + report = self.report() + perform_risk_assessment(1, report) + info = { + "status": "uploaded", + "sync_id": SYNC_ID, + "assessment_id": ASSESSMENT_ID, + "url": URL, + } + + result = self.generate(report, info) + + self.assertTrue(result["success"], result.get("logs")) + html = self.html(report) + self.assertIn(f'href="{URL}"', html) + self.assertIn("Open on the platform", html) + self.assertEqual(self.json_report(report)["meta"]["upload"], info) + + def test_banner_text_is_escaped(self): + report = self.report() + perform_risk_assessment(1, report) + info = {"status": "failed", "reason": ""} + + self.generate(report, info) + + self.assertNotIn("", self.html(report)) + + +class UploadInterfaceTests(FlowTestCase): + def call(self, replies, document=None, settings=CONFIGURED): + self.forget_meta() + with fake_http(*replies) as fake: + result = upload( + document if document is not None else minimal_document(SYNC_ID), + settings, + name="Flow", + meta={"assessment_type": 1, "exit_strategy": 1}, + ) + return result, fake + + def test_success_carries_the_link_and_nothing_else(self): + result, fake = self.call(success_replies()) + + self.assertTrue(result.success) + self.assertFalse(result.failed) + self.assertEqual( + (result.sync_id, result.assessment_id, result.url), + (SYNC_ID, ASSESSMENT_ID, URL), + ) + self.assertEqual( + [(r.method, r.url) for r in fake.requests], + [ + ("GET", "https://platform.test/api/v2/meta"), + ("GET", "https://platform.test/api/v2/whoami"), + ("POST", "https://platform.test/api/v2/syncs"), + ], + ) + self.assertFalse(hasattr(result, "risks")) + self.assertFalse(hasattr(result, "scores")) + + def test_the_result_carries_the_platform_ids_and_warnings(self): + warning = {"code": "tier_downgraded", "message": "Granted\nBasic."} + result, _ = self.call(success_replies(warnings=[warning])) + + self.assertEqual(result.warnings, ("Granted Basic. (tier_downgraded)",)) + self.assertEqual(result.platform_sync_id, sync_body()["id"]) + self.assertEqual(result.request_id, "req-fake-0001") + self.assertEqual(result.message, "uploaded") + + def test_an_unsafe_link_is_dropped(self): + result, _ = self.call( + success_replies(assessment={"url": "javascript:alert(1)"}) + ) + self.assertIsNone(result.url) + + def test_failures_map_to_the_upload_codes(self): + cases = ( + (503, "service_unavailable", True, REASON_UNAVAILABLE, 12), + (500, "internal_error", True, REASON_UNAVAILABLE, 12), + (429, "rate_limited", True, REASON_UNAVAILABLE, 12), + (None, None, True, REASON_UNAVAILABLE, 12), + (400, "invalid_json", False, REASON_REJECTED, 10), + (422, "validation_failed", False, REASON_REJECTED, 10), + (401, "invalid_api_key", False, "upload_auth", 11), + (402, "insufficient_credits", False, "upload_quota", 13), + (404, "assessment_not_found", False, "upload_target", 14), + (400, "unsupported_schema_version", False, "upload_unsupported", 15), + ) + for status, code, retryable, reason, exit_code in cases: + with self.subTest(status=status, code=code): + result, _ = self.call(failed_replies(status, code, retryable)) + self.assertTrue(result.failed) + self.assertEqual((result.reason, result.exit_code), (reason, exit_code)) + self.assertEqual(result.sync_id, SYNC_ID) + if code: + self.assertEqual(result.message, "server said no") + self.assertEqual(result.error_code, code) + self.assertEqual(result.request_id, "req-fake-0001") + + def test_no_document_is_a_rejected_upload_without_a_request(self): + with fake_http(): + result = upload(None, CONFIGURED, name="Flow", meta={}) + self.assertEqual((result.reason, result.exit_code), (REASON_REJECTED, 10)) + + def test_an_unexpected_error_is_a_failed_upload_not_a_crash(self): + with patch( + "cloudexit.platform.upload.ApiClient.from_settings", + side_effect=ValueError("x"), + ): + result = upload(minimal_document(SYNC_ID), CONFIGURED, name="Flow", meta={}) + self.assertTrue(result.failed) + self.assertEqual(result.reason, REASON_UNAVAILABLE) + self.assertIn("ValueError", result.message) + + def test_report_info_and_the_record_file(self): + success = UploadResult( + True, True, sync_id=SYNC_ID, assessment_id=ASSESSMENT_ID, url=URL + ) + failed = failure(REASON_REJECTED, "nope", SYNC_ID) + + self.assertEqual( + report_info(success), + { + "status": "uploaded", + "sync_id": SYNC_ID, + "assessment_id": ASSESSMENT_ID, + "url": URL, + }, + ) + self.assertEqual( + report_info(failed), + {"status": "failed", "reason": REASON_REJECTED, "sync_id": SYNC_ID}, + ) + self.assertIsNone(report_info(None)) + self.assertIsNone(report_info(UploadResult(False, False))) + + kept = replace(failed, spooled=True) + self.assertEqual( + report_info(kept), + { + "status": "failed", + "reason": REASON_REJECTED, + "sync_id": SYNC_ID, + "spooled": True, + }, + ) + + detailed = UploadResult( + True, + True, + sync_id=SYNC_ID, + assessment_id=ASSESSMENT_ID, + url=URL, + platform_sync_id="p-1", + request_id="req-1", + warnings=("Granted Basic.",), + pending=True, + ) + self.assertEqual(report_info(detailed), report_info(success)) + record = json.loads( + write_upload_record(str(self.tmp), detailed).read_text(encoding="utf-8") + ) + self.assertEqual(record["warnings"], ["Granted Basic."]) + self.assertEqual( + (record["platform_sync_id"], record["request_id"], record["pending"]), + ("p-1", "req-1", True), + ) + + path = write_upload_record(str(self.tmp), success) + record = json.loads(path.read_text()) + self.assertEqual(record["url"], URL) + self.assertIn("recorded_at", record) + self.assertIsNone( + write_upload_record(str(self.tmp), UploadResult(False, False)) + ) + self.assertNotIn("nope", json.dumps(report_info(failed))) + + +class RunFlowTests(FlowTestCase): + def run_assessment( + self, + report, + *, + settings=None, + replies=None, + egress_ok=None, + assessment_type=2, + real_terminal=False, + **kwargs, + ): + self.steps = [] + self.output = io.StringIO() + raw_data = os.path.join(report, "raw_data") + console_patch = patch("cloudexit.cli.app.console.print") + patches = [ + patch("cloudexit.cli.app.validate_config"), + patch( + "cloudexit.cli.app.create_directory", return_value=(report, raw_data) + ), + patch("cloudexit.cli.app.verify_credentials", return_value=(True, "ok")), + patch( + "cloudexit.cli.app.test_permissions", + return_value=(True, True, True, "ok"), + ), + patch( + "cloudexit.cli.app.create_resource_inventory", + return_value={"success": True, "logs": ""}, + ), + patch( + "cloudexit.cli.app.create_cost_inventory", + return_value={"success": True, "logs": ""}, + ), + patch("cloudexit.cli.app.add_run_log_handler"), + ] + if real_terminal: + # The real printing, into a buffer: what the user would read + shown = Console(file=self.output, width=200, color_system=None) + patches += [ + patch("cloudexit.cli.app.console", shown), + patch("cloudexit.terminal.console", shown), + ] + else: + patches += [ + patch( + "cloudexit.cli.app.print_step", + side_effect=lambda *a, **k: self.steps.append((a, k)), + ), + console_patch, + ] + if egress_ok is not None: + patches += [ + patch( + "cloudexit.cli.app.estimate_egress", + return_value={"success": egress_ok, "logs": "x", "json_path": "p"}, + ), + # The estimate file is a stub: the report is made from this one + patch( + "cloudexit.engine.load_egress_estimate", + return_value=empty_estimate(), + ), + patch( + "cloudexit.engine.generate_egress_report", + return_value="egress.pdf", + ), + ] + for item in patches: + started = item.start() + self.addCleanup(item.stop) + if item is console_patch: + self.printed = started + + config = copy.deepcopy(CONFIG) + config["assessmentType"] = assessment_type + self.forget_meta() + # The spool has tests of its own; here an earlier run must not answer for this one + kwargs.setdefault("flush_spool", False) + with fake_http(*replies) if replies is not None else nullcontext(): + return app.run_assessment(config, "aws", settings=settings, **kwargs) + + def sync(self, report): + return json.loads(self.raw(report, "sync.json").read_text()) + + def statuses(self): + return [kwargs.get("status") for _, kwargs in self.steps] + + def assert_local_report(self, report): + for path in ( + Path(report) / "index.html", + Path(report) / "report.pdf", + self.raw(report, "assessment_result.json"), + self.raw(report, "sync.json"), + ): + self.assertTrue(path.is_file(), f"{path.name} is missing") + + def test_an_offline_run_forces_basic_and_never_uploads(self): + report = self.report() + with patch("cloudexit.cli.app.upload") as mock_upload: + self.run_assessment(report) + + mock_upload.assert_not_called() + self.assert_local_report(report) + self.assertEqual(self.sync(report)["assessment_type"], 1) + self.assertFalse(self.raw(report, "upload.json").exists()) + self.assertNotIn("upload", self.json_report(report)["meta"]) + self.assertEqual(self.json_report(report)["meta"]["assessment_type"], 1) + self.assertEqual( + [(row[1], row[2]) for row in self.rows(report, "risk_inventory")], + LOCAL_RISKS, + ) + messages = [args[0] for args, _ in self.steps] + self.assertIn("Basic assessment: Standard needs an upload.", messages) + + def test_the_local_results_are_computed_when_connected_too(self): + report = self.report() + self.run_assessment(report, settings=CONFIGURED, replies=success_replies()) + self.assertEqual( + [(row[1], row[2]) for row in self.rows(report, "risk_inventory")], + LOCAL_RISKS, + ) + + def test_a_successful_upload_stores_the_link_and_no_platform_results(self): + report = self.report() + + self.run_assessment(report, settings=CONFIGURED, replies=success_replies()) + + self.assert_local_report(report) + record = json.loads(self.raw(report, "upload.json").read_text()) + self.assertEqual( + {key: record[key] for key in ("status", "sync_id", "assessment_id", "url")}, + { + "status": "uploaded", + "sync_id": self.sync(report)["sync"]["id"], + "assessment_id": ASSESSMENT_ID, + "url": URL, + }, + ) + # No server risks or scores reach the database or the local report + self.assertEqual(self.rows(report, "scoring_data"), []) + self.assertEqual( + [(row[1], row[2]) for row in self.rows(report, "risk_inventory")], + LOCAL_RISKS, + ) + document = self.json_report(report) + self.assertNotIn("scoring_data", document["data"]) + self.assertEqual(document["meta"]["upload"]["url"], URL) + self.assertIn(URL, self.html(report)) + self.assertNotIn(str(SERVER_SCORES[0]), json.dumps(document["data"])) + + def test_connected_runs_keep_the_requested_depth_in_the_document(self): + report = self.report() + + self.run_assessment(report, settings=CONFIGURED, replies=success_replies()) + + self.assertEqual(self.sync(report)["assessment_type"], 2) + # The local report is always the Basic report + self.assertEqual(self.json_report(report)["meta"]["assessment_type"], 1) + messages = [args[0] for args, _ in self.steps] + self.assertEqual([m for m in messages if m.startswith("Basic assessment")], []) + + def test_platform_text_with_brackets_is_shown_literally_and_the_run_goes_on(self): + message = "rejected [/bold] [red]x[/red] :warning: [1, 2]" + report = self.report() + replies = [ + meta_reply(), + whoami_reply(), + reply(422, error_body("validation_failed", message)), + ] + + with self.assertRaises(SystemExit) as ctx: + self.run_assessment( + report, settings=CONFIGURED, replies=replies, real_terminal=True + ) + + self.assertEqual(ctx.exception.code, codes.UPLOAD_REJECTED) + self.assertIn(message, self.output.getvalue()) + self.assertNotIn("Unexpected error", self.output.getvalue()) + self.assert_local_report(report) + + def test_platform_capabilities_of_the_wrong_shape_do_not_stop_an_upload(self): + report = self.report() + replies = [ + meta_reply(contract="v2", clients=["x"], limits="none", host=7), + whoami_reply(), + created_reply(), + ] + + self.run_assessment(report, settings=CONFIGURED, replies=replies) + + self.assert_local_report(report) + record = json.loads(self.raw(report, "upload.json").read_text()) + self.assertEqual(record["status"], "uploaded") + + @unittest.skipIf(sys.platform == "win32", "POSIX file modes") + def test_a_finished_run_leaves_nothing_readable_by_others(self): + for replies, settings in ((None, None), (success_replies(), CONFIGURED)): + with self.subTest(uploaded=settings is not None): + report = self.report() + for path in Path(report).rglob("*"): + path.chmod(0o777 if path.is_dir() else 0o666) + + self.run_assessment(report, settings=settings, replies=replies) + + self.assert_local_report(report) + loose = [ + str(path.relative_to(report)) + for path in [Path(report), *Path(report).rglob("*")] + if not path.is_symlink() and path.stat().st_mode & 0o077 + ] + self.assertEqual(loose, []) + self.assertEqual( + self.raw(report, "sync.json").stat().st_mode & 0o777, 0o600 + ) + + def test_an_unexpected_error_with_brackets_is_shown_literally(self): + report = self.report() + with ( + patch( + "cloudexit.cli.app.prepare_sync_document", + side_effect=RuntimeError("bad [/bold] text"), + ), + self.assertRaises(SystemExit) as ctx, + ): + self.run_assessment(report, real_terminal=True) + + self.assertEqual(ctx.exception.code, codes.UNEXPECTED) + self.assertIn("Unexpected error: bad [/bold] text", self.output.getvalue()) + + def test_a_failed_upload_still_writes_the_report_and_exits_with_an_upload_code( + self, + ): + for status, code, exit_code, reason in ( + (503, "service_unavailable", codes.UPLOAD_UNAVAILABLE, REASON_UNAVAILABLE), + (None, None, codes.UPLOAD_UNAVAILABLE, REASON_UNAVAILABLE), + (422, "validation_failed", codes.UPLOAD_REJECTED, REASON_REJECTED), + (401, "api_key_revoked", codes.UPLOAD_AUTH, "upload_auth"), + ): + with self.subTest(status=status): + report = self.report() + with self.assertRaises(SystemExit) as ctx: + self.run_assessment( + report, + settings=CONFIGURED, + replies=failed_replies(status, code, status in (503, None)), + ) + + self.assertEqual(ctx.exception.code, exit_code) + self.assert_local_report(report) + self.assertIn(f"Upload failed: {reason}.", self.html(report)) + self.assertEqual( + self.json_report(report)["meta"]["upload"]["reason"], reason + ) + record = json.loads(self.raw(report, "upload.json").read_text()) + self.assertEqual( + (record["status"], record["reason"]), ("failed", reason) + ) + self.assertEqual(self.rows(report, "scoring_data"), []) + + def test_the_failure_is_printed_with_its_code_and_request_id(self): + report = self.report() + with self.assertRaises(SystemExit): + self.run_assessment( + report, + settings=CONFIGURED, + replies=failed_replies(422, "validation_failed", False), + ) + + (logs,) = [ + kwargs["logs"] + for args, kwargs in self.steps + if args[0] == "Uploading to the platform..." and kwargs.get("logs") + ] + self.assertEqual(logs, "validation_failed: server said no") + self.assertIn("req-fake-0001", str(self.printed.call_args_list)) + + def test_warnings_from_the_platform_are_printed_one_per_line(self): + report = self.report() + warnings = [ + {"code": "unknown_resource_types", "message": "Two types are unknown."}, + {"code": "tier_downgraded", "message": "Granted Basic."}, + ] + self.run_assessment( + report, + settings=CONFIGURED, + replies=success_replies(warnings=warnings), + ) + + printed = [ + kwargs["logs"] + for args, kwargs in self.steps + if args[0] == "Platform warning" + ] + self.assertEqual( + printed, + [ + "Two types are unknown. (unknown_resource_types)", + "Granted Basic. (tier_downgraded)", + ], + ) + + def test_a_run_still_processing_is_recorded_and_exits_zero(self): + report = self.report() + pending = UploadResult( + True, + True, + message="accepted, still processing", + sync_id=SYNC_ID, + assessment_id=ASSESSMENT_ID, + url=URL, + pending=True, + ) + with patch("cloudexit.cli.app.upload", return_value=pending): + self.run_assessment(report, settings=CONFIGURED) + + self.assert_local_report(report) + record = json.loads(self.raw(report, "upload.json").read_text()) + self.assertEqual((record["status"], record["pending"]), ("uploaded", True)) + self.assertIn(URL, self.html(report)) + logs = [kwargs.get("logs") for _, kwargs in self.steps] + self.assertIn("Accepted by the platform, still processing.", logs) + + def test_an_unreadable_platform_answer_does_not_cost_the_report(self): + report = self.report() + with self.assertRaises(SystemExit) as ctx: + self.run_assessment( + report, + settings=CONFIGURED, + replies=[ + meta_reply(), + whoami_reply(), + make_response(201, text="not json"), + ], + ) + + self.assertEqual(ctx.exception.code, codes.UPLOAD_UNAVAILABLE) + self.assert_local_report(report) + + def test_a_key_that_cannot_be_right_is_a_failed_upload_not_an_offline_run(self): + report = self.report() + settings = Settings( + host=CONFIGURED.host, api_key=Setting(corrupt(KEY), "env CLOUDEXIT_API_KEY") + ) + with ( + patch("cloudexit.cli.app.upload") as mock_upload, + self.assertRaises(SystemExit) as ctx, + ): + self.run_assessment(report, settings=settings, replies=[]) + + mock_upload.assert_not_called() + self.assertEqual(ctx.exception.code, codes.UPLOAD_AUTH) + self.assert_local_report(report) + self.assertIn("Upload failed: upload_auth.", self.html(report)) + # A configured platform keeps the requested depth: nothing forces Basic + self.assertEqual(self.sync(report)["assessment_type"], 2) + messages = [args[0] for args, _ in self.steps] + self.assertEqual([m for m in messages if m.startswith("Basic assessment")], []) + + def test_basic_is_forced_only_without_platform_configuration(self): + for settings in (None, Settings()): + with self.subTest(settings=settings): + report = self.report() + self.run_assessment(report, settings=settings) + self.assertEqual(self.sync(report)["assessment_type"], 1) + + def test_a_dry_run_never_uploads_and_never_forces_basic(self): + report = self.report() + with patch("cloudexit.cli.app.upload") as mock_upload: + self.run_assessment( + report, + settings=CONFIGURED, + replies=[], + dry_run=True, + ) + + mock_upload.assert_not_called() + self.assert_local_report(report) + self.assertEqual(self.sync(report)["assessment_type"], 2) + self.assertFalse(self.raw(report, "upload.json").exists()) + + def test_collection_problems_win_over_upload_codes(self): + report = self.report(egress=[egress_row(1, "web-1", 10)]) + with self.assertRaises(SystemExit) as ctx: + self.run_assessment( + report, + settings=CONFIGURED, + replies=failed_replies(503, "service_unavailable"), + egress=True, + egress_ok=False, + ) + + self.assertEqual(ctx.exception.code, codes.EGRESS) + self.assert_local_report(report) + self.assertIn("Upload failed", self.html(report)) + + def test_a_sync_document_problem_wins_over_an_upload_code(self): + report = self.report() + with ( + patch( + "cloudexit.cli.app.prepare_sync_document", + side_effect=SyncDocumentError("bad"), + ), + self.assertRaises(SystemExit) as ctx, + ): + self.run_assessment( + report, + settings=CONFIGURED, + replies=[], + emit_payload=str(self.tmp / "out"), + ) + + self.assertEqual(ctx.exception.code, codes.REPORT) + self.assertTrue((Path(report) / "index.html").is_file()) + + def test_the_upload_runs_after_the_collection_and_before_the_report(self): + report = self.report(egress=[egress_row(1, "web-1", 10)]) + order = [] + original_upload = app.upload + + def traced_upload(*args, **kwargs): + order.append("upload") + return original_upload(*args, **kwargs) + + def traced_report(*args, **kwargs): + order.append("report") + return generate_report(*args, **kwargs) + + def traced_risk(*args, **kwargs): + order.append("risk") + return perform_risk_assessment(*args, **kwargs) + + with ( + patch("cloudexit.cli.app.upload", side_effect=traced_upload), + patch("cloudexit.cli.app.generate_report", side_effect=traced_report), + patch("cloudexit.cli.app.perform_risk_assessment", side_effect=traced_risk), + patch( + "cloudexit.cli.app.estimate_egress", + side_effect=lambda *a, **k: order.append("egress") + or {"success": True, "json_path": "p"}, + ), + patch( + "cloudexit.engine.load_egress_estimate", return_value=empty_estimate() + ), + patch("cloudexit.engine.generate_egress_report", return_value="egress.pdf"), + ): + self.run_assessment( + report, + settings=CONFIGURED, + replies=success_replies(), + egress=True, + ) + + self.assertEqual(order, ["egress", "risk", "upload", "report"]) + self.assertTrue(self.sync(report)["inventory"]["egress"]["collected"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/report/test_report_pipeline.py b/tests/report/test_report_pipeline.py new file mode 100644 index 0000000..de7bcdb --- /dev/null +++ b/tests/report/test_report_pipeline.py @@ -0,0 +1,175 @@ +import json +import tempfile +import unittest +from pathlib import Path + +from cloudexit.report.executive_summary import generate_executive_summary +from cloudexit.report.writers import ( + generate_html_report, + generate_json_report, +) +from cloudexit.report.json_data import transform_cost_inventory_for_json +from tests.support.report import ( + build_empty_report_fixture, + build_fixture_context, + build_report_fixture, + stage_minimal_assets, +) + + +class ReportPipelineSmokeTests(unittest.TestCase): + def html(self, fixture, **overrides): + with tempfile.TemporaryDirectory() as report_dir: + context = build_fixture_context(fixture, report_dir, **overrides) + html_path = generate_html_report(context) + + self.assertTrue(Path(html_path).exists()) + self.assertEqual(Path(html_path).name, "index.html") + return Path(html_path).read_text(encoding="utf-8") + + def test_generate_html_report_creates_expected_output(self): + html = self.html(build_report_fixture()) + + self.assertIn("Smoke Test Assessment", html) + self.assertIn("Amazon Web Services", html) + self.assertIn("OpenStack", html) + self.assertIn("OpenInfra Foundation", html) + self.assertIn("EC2 Instance", html) + self.assertIn('data-os="1"', html) + + def test_generate_html_report_renders_empty_state_output(self): + html = self.html(build_empty_report_fixture()) + + self.assertIn("Empty State Assessment", html) + self.assertIn("No exit risks were identified.", html) + self.assertIn("No cost data available for this assessment.", html) + self.assertIn("No resources were found in the defined scope.", html) + self.assertIn("No resource types to list alternatives for.", html) + # No filters when there is nothing to filter + self.assertNotIn('class="toolbar"', html) + + def test_generate_json_report_creates_expected_structure(self): + fixture = build_report_fixture() + + with tempfile.TemporaryDirectory() as tmp_dir: + raw_data_path = Path(tmp_dir) / "raw_data" + raw_data_path.mkdir() + + json_path = generate_json_report( + str(raw_data_path), + fixture["metadata"], + fixture["resource_type_mapping"], + fixture["resource_inventory"], + fixture["cost_data"], + None, + fixture["risk_data"], + fixture["risk_definitions"], + fixture["alternatives"], + fixture["alternative_technologies"], + fixture["exit_strategy"], + ) + + payload = json.loads(Path(json_path).read_text(encoding="utf-8")) + + self.assertEqual(payload["meta"]["name"], "Smoke Test Assessment") + self.assertEqual( + payload["data"]["resource_inventory"][0]["resource_name"], "EC2 Instance" + ) + self.assertEqual(payload["data"]["cost_inventory"][0]["month"], "2025-11-01") + self.assertEqual( + payload["data"]["alternative_technologies"]["1"][0]["product_name"], + "OpenStack", + ) + + def test_generate_pdf_report_creates_non_empty_file(self): + fixture = build_report_fixture() + + with tempfile.TemporaryDirectory() as report_dir: + stage_minimal_assets(report_dir) + + pdf_path = generate_executive_summary( + build_fixture_context(fixture, report_dir) + ) + + pdf_file = Path(pdf_path) + + self.assertEqual(pdf_file.name, "report.pdf") + self.assertTrue(pdf_file.exists()) + self.assertGreater(pdf_file.stat().st_size, 0) + + +class EmptyCostInventoryReportTests(unittest.TestCase): + """tfstate mode skips Stage 4, so reports must render with resources but no costs.""" + + def test_html_report_renders_resources_without_cost_data(self): + fixture = build_report_fixture() + with tempfile.TemporaryDirectory() as report_dir: + context = build_fixture_context(fixture, report_dir, cost_data=[]) + html = Path(generate_html_report(context)).read_text(encoding="utf-8") + + self.assertIn("EC2 Instance", html) + self.assertIn("No cost data available for this assessment.", html) + self.assertNotIn('class="bars"', html) + + def test_json_report_has_empty_cost_inventory(self): + fixture = build_report_fixture() + + with tempfile.TemporaryDirectory() as tmp_dir: + raw_data_path = Path(tmp_dir) / "raw_data" + raw_data_path.mkdir() + + json_path = generate_json_report( + str(raw_data_path), + fixture["metadata"], + fixture["resource_type_mapping"], + fixture["resource_inventory"], + [], + None, + fixture["risk_data"], + fixture["risk_definitions"], + fixture["alternatives"], + fixture["alternative_technologies"], + fixture["exit_strategy"], + ) + + payload = json.loads(Path(json_path).read_text(encoding="utf-8")) + + self.assertEqual(payload["data"]["cost_inventory"], []) + self.assertEqual( + payload["data"]["resource_inventory"][0]["resource_name"], "EC2 Instance" + ) + + def test_pdf_report_generates_without_cost_data(self): + fixture = build_report_fixture() + + with tempfile.TemporaryDirectory() as report_dir: + stage_minimal_assets(report_dir) + + pdf_path = generate_executive_summary( + build_fixture_context(fixture, report_dir, cost_data=[]) + ) + + pdf_file = Path(pdf_path) + + self.assertTrue(pdf_file.exists()) + self.assertGreater(pdf_file.stat().st_size, 0) + + +class ReportTransformTests(unittest.TestCase): + def test_transform_cost_inventory_for_json_sorts_months(self): + unsorted_costs = [ + {"month": "2026-03-01", "cost": 9.0, "currency": "USD"}, + {"month": "2026-01-01", "cost": 14.75, "currency": "USD"}, + {"month": "2026-02-01", "cost": 11.25, "currency": "USD"}, + ] + + transformed = transform_cost_inventory_for_json(unsorted_costs) + + self.assertEqual( + [item["month"] for item in transformed], + ["2026-01-01", "2026-02-01", "2026-03-01"], + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/report/test_report_transforms.py b/tests/report/test_report_transforms.py new file mode 100644 index 0000000..cf58cff --- /dev/null +++ b/tests/report/test_report_transforms.py @@ -0,0 +1,157 @@ +import unittest + +from cloudexit.report.json_data import ( + transform_alt_tech_for_json, + transform_resource_inventory_for_json, + transform_risk_inventory_for_json, +) + + +def build_resource_type_mapping(): + return { + "101": { + "id": 101, + "code": "AWS.EC2.DescribeInstances.Reservations", + "name": "EC2 Instance", + "icon": "/icons/aws/Compute/EC2.png", + }, + "202": { + "id": 202, + "code": "AWS.S3.ListBuckets.Buckets", + "name": "S3 Bucket", + "icon": "/icons/aws/Storage/Simple-Storage-Service.png", + }, + } + + +def build_resource_inventory(): + return [ + {"resource_type": 101, "location": "eu-central-1", "count": 2}, + {"resource_type": 202, "location": "eu-central-1", "count": 1}, + ] + + +def build_risk_definitions(): + return [ + { + "id": "1", + "name": "Limited Alternatives", + "description": "There are only a few alternatives available.", + "severity": "high", + }, + { + "id": "7", + "name": "Large Service Footprint", + "description": "The service footprint is broad.", + "severity": "medium", + }, + ] + + +def build_risk_data(): + return [ + {"resource_type": "101", "risk": "1"}, + {"resource_type": "202", "risk": "1"}, + {"resource_type": "null", "risk": "7"}, + ] + + +def build_alternatives(): + return [ + {"resource_type": 101, "strategy_type": 1, "alternative_technology": 1}, + {"resource_type": 101, "strategy_type": 3, "alternative_technology": 2}, + {"resource_type": 202, "strategy_type": 1, "alternative_technology": 2}, + ] + + +def build_alternative_technologies(): + return [ + { + "id": 1, + "product_name": "OpenStack", + "product_description": "Open source cloud platform.", + "product_url": "https://www.openstack.org/", + "open_source": "t", + "support_plan": "t", + "status": "t", + "organization_id": 10, + }, + { + "id": 2, + "product_name": "MinIO", + "product_description": "Object storage platform.", + "product_url": "https://min.io/", + "open_source": "t", + "support_plan": "f", + "status": "t", + "organization_id": 20, + }, + { + "id": 3, + "product_name": "Inactive Tech", + "product_description": "Should be ignored.", + "product_url": "https://example.com/", + "open_source": "t", + "support_plan": "t", + "status": "f", + }, + ] + + +def build_alternative_technology_organizations(): + return [ + { + "id": 10, + "name": "OpenInfra Foundation", + "url": "https://openinfra.org/", + "country_code": "US", + }, + { + "id": 20, + "name": "European Storage Collective", + "url": "https://example.eu/", + "country_code": "DE", + }, + ] + + +class JsonTransformTests(unittest.TestCase): + def test_transform_resource_inventory_for_json_maps_names_and_codes(self): + transformed = transform_resource_inventory_for_json( + build_resource_inventory(), + build_resource_type_mapping(), + ) + + self.assertEqual(transformed[0]["resource_name"], "EC2 Instance") + self.assertEqual( + transformed[1]["code"], + "AWS.S3.ListBuckets.Buckets", + ) + + def test_transform_risk_inventory_for_json_maps_impacted_resource_ids(self): + transformed = transform_risk_inventory_for_json( + build_risk_data(), + build_risk_definitions(), + build_resource_inventory(), + ) + + transformed_by_id = {item["id"]: item for item in transformed} + self.assertCountEqual(transformed_by_id["1"]["impacted_resources"], [1, 2]) + self.assertEqual(transformed_by_id["1"]["impacted_resources_count"], 2) + self.assertIsNone(transformed_by_id["7"]["impacted_resources_count"]) + + def test_transform_alt_tech_for_json_groups_by_resource_id(self): + transformed = transform_alt_tech_for_json( + build_resource_inventory(), + build_alternatives(), + build_alternative_technologies(), + exit_strategy=1, + ) + + self.assertEqual(list(transformed.keys()), [1, 2]) + self.assertEqual(transformed[1][0]["product_name"], "OpenStack") + self.assertEqual(transformed[2][0]["product_name"], "MinIO") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/report_fixtures.py b/tests/report_fixtures.py deleted file mode 100644 index a3ff6d7..0000000 --- a/tests/report_fixtures.py +++ /dev/null @@ -1,126 +0,0 @@ -import shutil -from pathlib import Path - - -def build_report_fixture(): - metadata = { - "name": "Smoke Test Assessment", - "cloud_service_provider": 2, - "exit_strategy": 1, - "assessment_type": 1, - "timestamp": "2026-05-07 12:00:00 UTC", - } - provider_details = { - "accessKey": "AKIA_TEST", - "secretKey": "SECRET_TEST", - "region": "eu-central-1", - } - resource_type_mapping = { - "101": { - "id": 101, - "code": "AWS.EC2.DescribeInstances.Reservations", - "name": "EC2 Instance", - "icon": "/icons/misc/no_image.png", - } - } - resource_inventory = [ - {"resource_type": 101, "location": "eu-central-1", "count": 2}, - ] - cost_data = [ - {"month": "2025-11-01", "cost": 10.5, "currency": "USD"}, - {"month": "2025-12-01", "cost": 12.0, "currency": "USD"}, - {"month": "2026-01-01", "cost": 14.75, "currency": "USD"}, - {"month": "2026-02-01", "cost": 11.25, "currency": "USD"}, - {"month": "2026-03-01", "cost": 9.0, "currency": "USD"}, - {"month": "2026-04-01", "cost": 13.4, "currency": "USD"}, - ] - risk_definitions = [ - { - "id": "1", - "name": "Limited Alternatives", - "description": "There are only a few alternatives available.", - "severity": "high", - } - ] - risk_data = [ - {"resource_type": "101", "risk": "1"}, - ] - alternatives = [ - {"resource_type": "101", "strategy_type": "1", "alternative_technology": 1}, - ] - alternative_technologies = [ - { - "id": 1, - "product_name": "OpenStack", - "product_description": "Open source cloud platform.", - "product_url": "https://www.openstack.org/", - "open_source": "t", - "support_plan": "t", - "status": "t", - "organization_id": 10, - } - ] - alternative_technology_organizations = [ - { - "id": 10, - "name": "OpenInfra Foundation", - "url": "https://openinfra.org/", - "country_code": "US", - "stability_tier": 5, - "years_in_business": 14, - } - ] - return { - "metadata": metadata, - "provider_details": provider_details, - "resource_type_mapping": resource_type_mapping, - "resource_inventory": resource_inventory, - "cost_data": cost_data, - "risk_definitions": risk_definitions, - "risk_data": risk_data, - "alternatives": alternatives, - "alternative_technologies": alternative_technologies, - "alternative_technology_organizations": alternative_technology_organizations, - "exit_strategy": 1, - } - - -def build_empty_report_fixture(): - metadata = { - "name": "Empty State Assessment", - "cloud_service_provider": 2, - "exit_strategy": 1, - "assessment_type": 2, - "timestamp": "2026-05-08 10:00:00 UTC", - } - provider_details = { - "accessKey": "AKIA_EMPTY", - "secretKey": "SECRET_EMPTY", - "region": "eu-central-1", - } - return { - "metadata": metadata, - "provider_details": provider_details, - "resource_type_mapping": {}, - "resource_inventory": [], - "cost_data": [], - "risk_definitions": [], - "risk_data": [], - "alternatives": [], - "alternative_technologies": [], - "alternative_technology_organizations": [], - "exit_strategy": 1, - } - - -def stage_report_assets(report_path: str) -> None: - report_assets = Path(report_path) / "assets" - report_assets.mkdir(parents=True, exist_ok=True) - - source_assets = Path("assets") - for folder in ("css", "img", "icons"): - shutil.copytree( - source_assets / folder, - report_assets / folder, - dirs_exist_ok=True, - ) diff --git a/tests/runconfig/__init__.py b/tests/runconfig/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/runconfig/test_loader.py b/tests/runconfig/test_loader.py new file mode 100644 index 0000000..7ef5dad --- /dev/null +++ b/tests/runconfig/test_loader.py @@ -0,0 +1,43 @@ +import json +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from cloudexit.runconfig.loader import load_config + + +class LoadConfigTests(unittest.TestCase): + def test_load_config_returns_parsed_json(self): + with tempfile.TemporaryDirectory() as tmp_dir: + config_path = Path(tmp_dir) / "config.json" + expected = { + "cloudServiceProvider": 2, + "assessmentType": 1, + "providerDetails": {"region": "eu-central-1"}, + } + config_path.write_text(json.dumps(expected), encoding="utf-8") + + self.assertEqual(load_config(str(config_path)), expected) + + def test_load_config_returns_none_for_missing_file(self): + with patch("cloudexit.runconfig.loader.console.print") as mock_print: + result = load_config("/tmp/does-not-exist-config.json") + + self.assertIsNone(result) + mock_print.assert_called_once() + + def test_load_config_returns_none_for_invalid_json(self): + with tempfile.TemporaryDirectory() as tmp_dir: + config_path = Path(tmp_dir) / "config.json" + config_path.write_text("{invalid json", encoding="utf-8") + + with patch("cloudexit.runconfig.loader.console.print") as mock_print: + result = load_config(str(config_path)) + + self.assertIsNone(result) + mock_print.assert_called_once() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_validate.py b/tests/runconfig/test_validate.py similarity index 98% rename from tests/test_validate.py rename to tests/runconfig/test_validate.py index 73c8456..ee8cf3d 100644 --- a/tests/test_validate.py +++ b/tests/runconfig/test_validate.py @@ -3,7 +3,7 @@ import unittest from pathlib import Path -from utils.validate import validate_config, validate_region +from cloudexit.runconfig.validate import validate_config, validate_region def build_aws_config(): diff --git a/tests/samples/__init__.py b/tests/samples/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/samples/__main__.py b/tests/samples/__main__.py new file mode 100644 index 0000000..c4fe0fd --- /dev/null +++ b/tests/samples/__main__.py @@ -0,0 +1,177 @@ +import argparse +import sys +from html import escape +from pathlib import Path + +from cloudexit.catalogue.store import active_catalogue_path +from tests.samples.render import render_cases +from tests.samples.scenarios import ScenarioError, load_scenarios + +DOCUMENTS = ( + ("report.pdf", "Executive Summary"), + ("egress.pdf", "Egress & Data Landscape"), +) +PAGE = (595, 842) + +STYLE = """ +body { font: 14px/1.45 system-ui, sans-serif; margin: 2rem; color: #1a1f24; } +table { border-collapse: collapse; width: 100%; } +th, td { text-align: left; vertical-align: top; padding: .45rem .7rem; + border-bottom: 1px solid #e3e7ea; } +th { background: #f5f7f8; } +a { color: #005470; } +.failed { color: #b8322a; font-weight: 600; } +.muted { color: #6b7680; } +""" + + +def parse(argv): + parser = argparse.ArgumentParser( + prog="python -m tests.samples", + description="Render the sample assessments through the real report pipeline.", + ) + parser.add_argument("--case", nargs="+", metavar="NAME", help="cases to render") + parser.add_argument("--out", default="samples", help="output folder") + parser.add_argument( + "--catalogue", help="catalogue to render with (default: the installed one)" + ) + parser.add_argument( + "--sheets", action="store_true", help="contact sheets, needs PyMuPDF" + ) + return parser.parse_args(argv) + + +def load_pymupdf(): + try: + import pymupdf + except ImportError: + try: + import fitz as pymupdf + except ImportError: + return None + return pymupdf + + +def write_sheets(result, pymupdf): + # Four pages to a sheet, two by two + sheets, number = [], 0 + for name, _title in DOCUMENTS: + if name not in result.pages: + continue + source = pymupdf.open(str(result.directory / name)) + for first in range(0, source.page_count, 4): + number += 1 + last = min(first + 4, source.page_count) + sheet = pymupdf.open() + page = sheet.new_page(width=2 * PAGE[0], height=2 * PAGE[1]) + for slot, index in enumerate(range(first, last)): + x, y = (slot % 2) * PAGE[0], (slot // 2) * PAGE[1] + page.show_pdf_page( + pymupdf.Rect(x, y, x + PAGE[0], y + PAGE[1]), source, index + ) + file = f"sheet-{number}.png" + page.get_pixmap(dpi=110).save(str(result.directory / file)) + sheets.append((f"{name} {first + 1}–{last}", file)) + source.close() + return sheets + + +def link(case, file, label): + return f'{escape(label)}' + + +def overview(scenarios, results, failures, sheets): + by_case = {result.case: result for result in results} + rows = [] + for case, scenario in scenarios.items(): + result = by_case.get(case) + if result is None: + rows.append( + f"{escape(case)}{escape(scenario.name)}" + f'{escape(scenario.provider)}' + f"{escape(failures.get(case, 'not rendered'))}" + ) + continue + pages = ", ".join( + f"{title}: {result.pages[name]}" + for name, title in DOCUMENTS + if name in result.pages + ) + links = [ + link(case, name, title) + for name, title in ( + ("report.pdf", "Executive Summary PDF"), + ("index.html", "HTML report"), + ("egress.pdf", "Egress PDF"), + ) + if name in result.files + ] + links += [link(case, file, label) for label, file in sheets.get(case, [])] + rows.append( + f"{escape(case)}{escape(scenario.name)}" + f"{escape(scenario.provider)}{escape(scenario.purpose)}" + f"{escape(pages)}{'
'.join(links)}" + ) + return ( + '' + '' + f"Sample Reports" + '

Sample reports

Fictional data, rendered through ' + "the real report pipeline.

" + "" + f"{''.join(rows)}
CaseNameProviderPurposePagesOpen
\n" + ) + + +def main(argv=None): + args = parse(argv) + try: + scenarios = load_scenarios() + except ScenarioError as exc: + print(f"Scenario error: {exc}", file=sys.stderr) + return 2 + + if args.case: + unknown = [case for case in args.case if case not in scenarios] + if unknown: + print( + f"No such case: {', '.join(unknown)}. Cases: {', '.join(scenarios)}", + file=sys.stderr, + ) + return 2 + scenarios = {case: scenarios[case] for case in scenarios if case in args.case} + + catalogue = Path(args.catalogue) if args.catalogue else active_catalogue_path() + if not catalogue.is_file(): + print( + f"No catalogue at {catalogue}. Run cloudexit once so that the " + "catalogue is downloaded, or pass --catalogue PATH.", + file=sys.stderr, + ) + return 2 + + out = Path(args.out) + out.mkdir(parents=True, exist_ok=True) + results, failures = render_cases(scenarios.values(), out, catalogue_path=catalogue) + + sheets = {} + if args.sheets: + pymupdf = load_pymupdf() + if pymupdf is None: + print("PyMuPDF is not installed: no contact sheets (pip install pymupdf).") + else: + sheets = {result.case: write_sheets(result, pymupdf) for result in results} + + (out / "index.html").write_text( + overview(scenarios, results, failures, sheets), encoding="utf-8" + ) + for result in results: + print(f"rendered {result.case}") + for message in failures.values(): + print(f"FAILED {message}", file=sys.stderr) + print(f"Overview: {out / 'index.html'}") + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/samples/catalogue.py b/tests/samples/catalogue.py new file mode 100644 index 0000000..dc11903 --- /dev/null +++ b/tests/samples/catalogue.py @@ -0,0 +1,226 @@ +import sqlite3 + +from tests.make_smoke_dataset import TABLES +from tests.support.catalogue import build_catalogue +from tests.support.flow import RISKS + +EXTRA_TABLES = """ +CREATE TABLE resourcetype_data ( + id INTEGER PRIMARY KEY, resource_type INTEGER NOT NULL UNIQUE, + data_category TEXT NOT NULL, strategy TEXT NOT NULL, params TEXT, + status TEXT NOT NULL +); +CREATE TABLE egresspricing ( + id INTEGER PRIMARY KEY, csp INTEGER NOT NULL, component TEXT NOT NULL, + zone TEXT NOT NULL DEFAULT 'zone1', tier_from REAL NOT NULL, tier_to REAL, + price_per_unit REAL NOT NULL, currency TEXT NOT NULL DEFAULT 'USD', + unit TEXT NOT NULL DEFAULT 'GiB', valid_from TEXT NOT NULL, valid_to TEXT +); +""" + +# Terraform type to resource type code, for the state file fixtures +TF_CODES = { + "aws_instance": "AWS.ec2.describe_instances.Reservations", + "aws_s3_bucket": "AWS.s3.list_buckets.Buckets", + "aws_lambda_function": "AWS.lambda.list_functions.Functions", + "aws_sqs_queue": "AWS.sqs.list_queues.QueueUrls", + "azurerm_virtual_machine": "Microsoft.Compute/virtualMachines", + "azurerm_storage_account": "Microsoft.Storage/storageAccounts", + "azurerm_mssql_database": "Microsoft.Sql/servers/databases", +} + +NAMES = { + "AWS.ec2.describe_instances.Reservations": "EC2 Instance", + "AWS.s3.list_buckets.Buckets": "S3 Bucket", + "AWS.ec2.describe_volumes.Volumes": "EBS Volume", + "AWS.rds.describe_db_instances.DBInstances": "RDS Instance", + "AWS.dynamodb.list_tables.TableNames": "DynamoDB Table", + "AWS.lambda.list_functions.Functions": "Lambda Function", + "AWS.eks.list_clusters.clusters": "EKS Cluster", + "AWS.sqs.list_queues.QueueUrls": "SQS Queue", + "AWS.sns.list_topics.Topics": "SNS Topic", + "AWS.apigateway.get_rest_apis.items": "API Gateway", + "AWS.acm.list_certificates.CertificateSummaryList": "Certificate Manager", + "AWS.cloudwatch.describe_alarms.MetricAlarms": "CloudWatch Alarm", + "AWS.backup.list_backup_plans.BackupPlansList": "Backup Plan", + "Microsoft.Compute/virtualMachines": "Virtual Machine", + "Microsoft.ContainerService/managedClusters": "Kubernetes Cluster", + "Microsoft.Storage/storageAccounts": "Storage Account", + "Microsoft.Sql/servers/databases": "SQL Database", + "Microsoft.DocumentDB/databaseAccounts": "Cosmos DB Account", + "Microsoft.DBforPostgreSQL/flexibleServers": "PostgreSQL Flexible Server", + "Microsoft.RecoveryServices/vaults": "Recovery Services Vault", + "Microsoft.ContainerRegistry/registries": "Container Registry", + "Microsoft.Compute/disks": "Managed Disk", + # Deliberately long, for the stress case + "AWS.cloudformation.list_stacks.StackSummaries": ( + "Sample Infrastructure Stack Definition With An Unreasonably Long Name" + ), + "AWS.codeartifact.list_repositories.repositories": ( + "Sample Artifact Repository For Internally Built And Mirrored Packages" + ), + "AWS.cloudfront.list_distributions.DistributionList.Items": ( + "Sample Content Delivery Distribution For Static And Dynamic Content" + ), +} + +ICONS = { + "AWS.ec2.describe_instances.Reservations": "/icons/aws/Compute/EC2.png", + "AWS.s3.list_buckets.Buckets": "/icons/aws/Storage/Simple-Storage-Service.png", + "AWS.ec2.describe_volumes.Volumes": "/icons/aws/Storage/Elastic-Block-Store.png", + "AWS.rds.describe_db_instances.DBInstances": "/icons/aws/Database/RDS.png", + "AWS.dynamodb.list_tables.TableNames": "/icons/aws/Database/DynamoDB.png", + "AWS.lambda.list_functions.Functions": "/icons/aws/Compute/Lambda.png", + "Microsoft.Compute/virtualMachines": ( + "/icons/azure/compute/10021-icon-service-Virtual-Machine.png" + ), + "Microsoft.Storage/storageAccounts": ( + "/icons/azure/storage/10086-icon-service-Storage-Accounts.png" + ), + "Microsoft.Sql/servers/databases": ( + "/icons/azure/databases/10130-icon-service-SQL-Database.png" + ), + "Microsoft.Compute/disks": "/icons/azure/compute/10032-icon-service-Disks.png", +} + +# The data a type carries, for the egress estimate +CATEGORIES = { + "AWS.s3.list_buckets.Buckets": "object", + "AWS.ec2.describe_volumes.Volumes": "block", + "AWS.rds.describe_db_instances.DBInstances": "database", + "AWS.dynamodb.list_tables.TableNames": "database", + "Microsoft.Storage/storageAccounts": "object", + "Microsoft.Compute/disks": "block", + "Microsoft.Sql/servers/databases": "database", + "Microsoft.DocumentDB/databaseAccounts": "database", + "Microsoft.RecoveryServices/vaults": "backup", +} + +# Types that are not in the inventory catalogue but still carry data +INACTIVE = {"Microsoft.Compute/disks"} + +# (alternatives, of them with a support plan) per type; the rest has enough +ALTERNATIVES = { + "AWS.ec2.describe_volumes.Volumes": (0, 0), + "AWS.dynamodb.list_tables.TableNames": (0, 0), + "AWS.s3.list_buckets.Buckets": (2, 1), + "AWS.rds.describe_db_instances.DBInstances": (1, 0), + "AWS.eks.list_clusters.clusters": (4, 2), + "Microsoft.ContainerRegistry/registries": (0, 0), + "Microsoft.DocumentDB/databaseAccounts": (2, 2), + "Microsoft.Compute/virtualMachines": (5, 4), + # More than the page shows at first, so the rest is collapsed + "AWS.ec2.describe_instances.Reservations": (12, 10), +} +DEFAULT_ALTERNATIVES = (3, 3) + +LADDERS = { + 1: (0.087, 0.083, 0.07, 0.05), + 2: (0.09, 0.085, 0.07, 0.05), +} + + +def placeholder_name(code): + if code in NAMES: + return NAMES[code] + service = code.split(".")[1] if code.startswith("AWS.") else code.split("/")[0] + return f"Sample {service} Resource" + + +def provider_of(code): + return 2 if code.startswith("AWS.") else 1 + + +def _pricing_rows(): + rows = [] + for csp, (first, second, third, fourth) in LADDERS.items(): + tiers = ( + (0, 100, 0.0), + (100, 10240, first), + (10240, 51200, second), + (51200, 153600, third), + (153600, None, fourth), + ) + for lower, upper, price in tiers: + rows.append((csp, "internet_egress", lower, upper, price)) + rows.append((csp, "archive_retrieval", 0, None, 0.02)) + return rows + + +def build_sample_catalogue(path, scenarios): + # The codes the scenarios use, and the ones the state files map from + codes = set(TF_CODES.values()) + for scenario in scenarios: + codes |= scenario.type_codes() + codes = sorted(codes) + tf_codes = {code: tf for tf, code in TF_CODES.items()} + + build_catalogue(path, version="2026-11-02") + conn = sqlite3.connect(path) + conn.executescript(TABLES) + conn.executescript(EXTRA_TABLES) + conn.execute("DELETE FROM resourcetype") + conn.executemany("INSERT INTO risk VALUES (?, ?, ?, ?)", RISKS) + + ids = {} + for index, code in enumerate(codes, start=100): + ids[code] = index + conn.execute( + "INSERT INTO resourcetype (id, csp, code, name, icon, status, tf_code) " + "VALUES (?, ?, ?, ?, ?, ?, ?)", + ( + index, + provider_of(code), + code, + placeholder_name(code), + ICONS.get(code, ""), + "f" if code in INACTIVE else "t", + tf_codes.get(code), + ), + ) + if code in CATEGORIES: + conn.execute( + "INSERT INTO resourcetype_data (resource_type, data_category, " + "strategy, params, status) VALUES (?, ?, 'sample', '{}', 't')", + (index, CATEGORIES[code]), + ) + + conn.execute( + "INSERT INTO alternativetechnologyorganization (id, name, url, " + "stability_tier, country_code, years_in_business) " + "VALUES (1, 'Sample Vendor', NULL, 3, 'DE', 12)" + ) + technology = 0 + for code, resource_type in ids.items(): + total, supported = ALTERNATIVES.get(code, DEFAULT_ALTERNATIVES) + for position in range(total): + technology += 1 + conn.execute( + "INSERT INTO alternativetechnology (id, product_name, " + "product_description, product_url, open_source, support_plan, " + "status, organization_id) VALUES (?, ?, '', NULL, ?, ?, 't', ?)", + ( + technology, + f"Sample Technology {technology}", + "t" if position % 2 else "f", + "t" if position < supported else "f", + # Some have no recorded vendor + 1 if technology % 4 else None, + ), + ) + for strategy in (1, 2, 3): + conn.execute( + "INSERT INTO alternative (resource_type, " + "alternative_technology, strategy_type) VALUES (?, ?, ?)", + (resource_type, technology, strategy), + ) + + conn.executemany( + "INSERT INTO egresspricing (csp, component, zone, tier_from, tier_to, " + "price_per_unit, currency, unit, valid_from, valid_to) " + "VALUES (?, ?, 'zone1', ?, ?, ?, 'USD', 'GiB', '2026-07-01', NULL)", + _pricing_rows(), + ) + conn.commit() + conn.close() + return ids diff --git a/tests/samples/render.py b/tests/samples/render.py new file mode 100644 index 0000000..84eaaaf --- /dev/null +++ b/tests/samples/render.py @@ -0,0 +1,292 @@ +import logging +import shutil +import sqlite3 +import tempfile +from contextlib import ExitStack +from dataclasses import dataclass, field +from datetime import datetime +from pathlib import Path +from unittest.mock import patch + +from pypdf import PdfReader + +from cloudexit.catalogue import store +from cloudexit.catalogue.db import connect +from cloudexit.egress.estimate import estimate_egress +from cloudexit.egress.model import GIB, load_egress_registry, new_row +from cloudexit.engine import ( + create_resource_inventory, + generate_report, + perform_risk_assessment, +) +from cloudexit.report.assets import copy_assets +from tests.support.html import html_blocks + +# What a rendered case holds. The HTML report and each PDF have a text dump, so +# a change to any of them can be reviewed with a diff. +EXPECTED_FILES = { + "always": ("report.pdf", "index.html", "texts/report.txt", "texts/index-html.txt"), + "egress": ("egress.pdf", "texts/egress.txt"), +} +ASSESSMENT_TYPE = 1 + + +class RenderError(Exception): + pass + + +@dataclass +class RenderResult: + case: str + directory: Path + files: list = field(default_factory=list) + pages: dict = field(default_factory=dict) + + +def expected_files(scenario): + files = list(EXPECTED_FILES["always"]) + if scenario.egress is not None: + files += EXPECTED_FILES["egress"] + return files + + +def frozen_datetime(moment): + class FrozenDatetime(datetime): + @classmethod + def now(cls, tz=None): + return moment.astimezone(tz) if tz else moment.replace(tzinfo=None) + + return FrozenDatetime + + +def pdf_dump(path): + reader = PdfReader(str(path)) + parts = [] + for number, page in enumerate(reader.pages, start=1): + lines = [line.rstrip() for line in (page.extract_text() or "").splitlines()] + parts.append(f"--- page {number} ---\n" + "\n".join(lines)) + return "\n".join(parts) + "\n", len(reader.pages) + + +def _catalogue_types(catalogue_path, csp): + uri = f"{Path(catalogue_path).resolve().as_uri()}?mode=ro" + conn = sqlite3.connect(uri, uri=True) + try: + rows = conn.execute( + "SELECT code, id, status FROM resourcetype WHERE csp = ?", (csp,) + ).fetchall() + finally: + conn.close() + return {code: (type_id, status == "t") for code, type_id, status in rows} + + +def _check_types(scenario, catalogue_path): + types = _catalogue_types(catalogue_path, scenario.csp) + for resource in scenario.resources: + code = resource["type"] + if code not in types: + raise RenderError(f"resource type {code} is not in the catalogue") + if not types[code][1]: + raise RenderError(f"resource type {code} is not active in the catalogue") + return {code: type_id for code, (type_id, _active) in types.items()} + + +def _insert_inventory(scenario, report_path, type_ids): + db_path = Path(report_path) / "data" / "assessment.db" + with connect(db_path=db_path) as conn: + conn.executemany( + "INSERT INTO resource_inventory (resource_type, location, count) " + "VALUES (?, ?, ?) ON CONFLICT(resource_type, location) " + "DO UPDATE SET count = excluded.count", + [ + (type_ids[item["type"]], item["region"], item["count"]) + for item in scenario.resources + ], + ) + costs = scenario.costs + if costs: + currencies = costs.get("currencies") or {} + conn.executemany( + "INSERT INTO cost_inventory (month, cost, currency) VALUES (?, ?, ?) " + "ON CONFLICT(month) DO UPDATE SET cost = excluded.cost, " + "currency = excluded.currency", + [ + (f"{month}-01", amount, currencies.get(month, costs["currency"])) + for month, amount in sorted(costs["months"].items()) + ], + ) + conn.commit() + + +def _egress_rows(scenario): + # What the collectors would have found, in the shape they return + registry = load_egress_registry(scenario.csp) + rows = [] + for index, item in enumerate(scenario.egress["resources"], start=1): + key = item["type"].strip().lower() if scenario.csp == 1 else item["type"] + if key not in registry: + raise RenderError(f"egress type {item['type']} is not in the registry") + entry = registry[key] + row = new_row( + f"/sample/{index}", + item["name"], + item["type"], + entry["label"], + entry["category"], + ) + if item["size_gib"] is not None: + row["size_bytes"] = round(item["size_gib"] * GIB) + row["size_unknown"] = bool(item.get("unknown")) + if item.get("tiers"): + row["tier_bytes"] = { + tier: round(gib * GIB) for tier, gib in item["tiers"].items() + } + row["flags"] = list(item.get("flags", [])) + row["notes"] = list(item.get("notes", [])) + rows.append(row) + return rows, set(scenario.egress["archive_tiers"]) + + +def _estimate_egress(scenario, report_path, raw_data_path): + rows, archive_tiers = _egress_rows(scenario) + with ( + patch( + "cloudexit.providers.aws.egress.collect_aws_egress", + return_value=(rows, archive_tiers), + ), + patch( + "cloudexit.providers.azure.egress.collect_azure_egress", + return_value=(rows, archive_tiers), + ), + ): + result = estimate_egress( + scenario.csp, + scenario.provider_details(), + raw_data_path, + report_path=report_path, + name=scenario.name, + exit_strategy=scenario.exit_strategy, + assessment_type=ASSESSMENT_TYPE, + ) + if not result["success"]: + raise RenderError(f"egress estimate failed: {result['logs']}") + return result["json_path"] + + +def _write_dumps(scenario, directory, result): + texts = directory / "texts" + texts.mkdir() + for pdf, dump in (("report.pdf", "report.txt"), ("egress.pdf", "egress.txt")): + if (directory / pdf).is_file(): + text, pages = pdf_dump(directory / pdf) + (texts / dump).write_text(text, encoding="utf-8") + result.pages[pdf] = pages + source = (directory / "index.html").read_text(encoding="utf-8") + (texts / "index-html.txt").write_text(html_blocks(source), encoding="utf-8") + + +def _render(scenario, directory, catalogue_path): + type_ids = _check_types(scenario, catalogue_path) + report_path = str(directory) + raw_data_path = str(directory / "raw_data") + (directory / "raw_data").mkdir(parents=True) + details = scenario.provider_details() + + if scenario.tfstate: + # The real inventory stage, which also copies the assets and the catalogue + result = create_resource_inventory( + scenario.csp, details, report_path, raw_data_path + ) + if not result["success"]: + raise RenderError(f"resource inventory failed: {result['logs']}") + else: + copy_assets(report_path, scenario.csp) + _insert_inventory(scenario, report_path, type_ids) + + egress_json = None + if scenario.egress is not None: + egress_json = _estimate_egress(scenario, report_path, raw_data_path) + + risks = perform_risk_assessment(scenario.exit_strategy, report_path) + if not risks["success"]: + raise RenderError(f"risk assessment failed: {risks['logs']}") + + report = generate_report( + scenario.csp, + details, + scenario.exit_strategy, + ASSESSMENT_TYPE, + scenario.name, + report_path, + raw_data_path, + upload=scenario.upload, + sync_id=scenario.run_id, + egress_json_path=egress_json, + ) + if not report["success"]: + raise RenderError(f"report failed: {report['logs']}") + if report.get("egress_pdf_logs"): + raise RenderError(f"egress.pdf failed: {report['egress_pdf_logs']}") + + +def _legacy_copy(catalogue_path, directory): + # The dataset of an older release: the same tables, no catalogue_meta + copy = Path(directory) / "legacy_catalogue.db" + shutil.copy(catalogue_path, copy) + conn = sqlite3.connect(copy) + try: + conn.execute("DROP TABLE IF EXISTS catalogue_meta") + conn.commit() + finally: + conn.close() + return copy + + +def render_case(scenario, out_dir, *, catalogue_path): + directory = Path(out_dir) / scenario.case + if directory.exists(): + shutil.rmtree(directory) + directory.mkdir(parents=True) + + previous = store._active_override + clock = frozen_datetime(scenario.clock) + try: + with ExitStack() as stack: + if scenario.catalogue == "legacy": + scratch = stack.enter_context(tempfile.TemporaryDirectory()) + catalogue_path = _legacy_copy(catalogue_path, scratch) + store.set_active_catalogue(catalogue_path) + # The warnings of the pipeline are about the sample data, not news + stack.callback(logging.disable, logging.root.manager.disable) + logging.disable(logging.WARNING) + for module in ("report.context", "engine", "egress.estimate"): + stack.enter_context(patch(f"cloudexit.{module}.datetime", clock)) + _render(scenario, directory, catalogue_path) + except RenderError as exc: + raise RenderError(f"{scenario.case}: {exc}") from exc + finally: + store.set_active_catalogue(previous) + + result = RenderResult(scenario.case, directory) + _write_dumps(scenario, directory, result) + result.files = [ + name for name in expected_files(scenario) if (directory / name).is_file() + ] + return result + + +def render_cases(scenarios, out_dir, *, catalogue_path): + # A case that fails is reported, and the others still render + results, failures = [], {} + for scenario in scenarios: + try: + results.append( + render_case(scenario, out_dir, catalogue_path=catalogue_path) + ) + except Exception as exc: + failures[scenario.case] = ( + str(exc) + if isinstance(exc, RenderError) + else f"{scenario.case}: {exc!r}" + ) + return results, failures diff --git a/tests/samples/rendered.py b/tests/samples/rendered.py new file mode 100644 index 0000000..6eae4c3 --- /dev/null +++ b/tests/samples/rendered.py @@ -0,0 +1,31 @@ +import atexit +import tempfile +from pathlib import Path + +from tests.samples.catalogue import build_sample_catalogue +from tests.samples.render import render_cases +from tests.samples.scenarios import load_scenarios + +_STATE = {} + + +def rendered(): + # Every sample case rendered once per test run, for all the tests that read + # a finished report folder; rendering is the slow part + if not _STATE: + tmp = tempfile.TemporaryDirectory() + atexit.register(tmp.cleanup) + root = Path(tmp.name) + scenarios = load_scenarios() + catalogue = root / "catalogue.db" + build_sample_catalogue(catalogue, scenarios.values()) + results, failures = render_cases( + scenarios.values(), root / "out", catalogue_path=catalogue + ) + _STATE.update( + scenarios=scenarios, + catalogue=catalogue, + results={result.case: result for result in results}, + failures=failures, + ) + return _STATE diff --git a/tests/samples/scenarios.py b/tests/samples/scenarios.py new file mode 100644 index 0000000..a531f25 --- /dev/null +++ b/tests/samples/scenarios.py @@ -0,0 +1,308 @@ +import json +import re +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path + +from tests.support.paths import ROOT + +SAMPLES_DIR = ROOT / "tests" / "fixtures" / "samples" +TFSTATE_DIR = SAMPLES_DIR / "tfstate" + +PROVIDER_IDS = {"azure": 1, "aws": 2} +FIELDS = ( + "name", + "purpose", + "provider", + "exit_strategy", + "scope", + "resources", + "costs", + "egress", + "upload", + "clock", + "run_id", +) +OPTIONAL_FIELDS = ("catalogue",) +CATALOGUES = ("legacy",) +SCOPE_FIELDS = { + "aws": ("accountId", "region"), + "azure": ("tenantId", "subscriptionId", "resourceGroupName"), +} +UPLOAD_FIELDS = ("status", "url", "assessment_id", "sync_id", "reason", "spooled") +UPLOAD_STATUSES = ("uploaded", "failed") +MONTH = re.compile(r"^\d{4}-(0[1-9]|1[0-2])$") +CURRENCY = re.compile(r"^[A-Z]{3}$") +UUID7 = re.compile( + r"^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" +) + + +class ScenarioError(ValueError): + pass + + +@dataclass(frozen=True) +class Scenario: + case: str + path: Path + name: str + purpose: str + provider: str + exit_strategy: int + scope: dict + resources: tuple + costs: dict | None + egress: dict | None + upload: dict | None + clock: datetime + run_id: str + # "legacy": a catalogue without catalogue_meta + catalogue: str | None = None + + @property + def csp(self): + return PROVIDER_IDS[self.provider] + + @property + def tfstate(self): + name = self.scope.get("tfstate") + return str(TFSTATE_DIR / name) if name else None + + def provider_details(self): + # What the collectors would have been given, without any credential + if self.tfstate: + return {"tfstatePath": self.tfstate} + return dict(self.scope) + + def type_codes(self): + codes = {resource["type"] for resource in self.resources} + if self.egress: + codes |= {resource["type"] for resource in self.egress["resources"]} + return codes + + +def _fail(path, field, message): + raise ScenarioError(f"{Path(path).name}: {field}: {message}") + + +def _text(path, field, value): + if not isinstance(value, str) or not value.strip(): + _fail(path, field, "expected a non-empty string") + return value + + +def _number(path, field, value, *, allow_none=False): + if value is None and allow_none: + return None + if isinstance(value, bool) or not isinstance(value, (int, float)) or value < 0: + _fail(path, field, "expected a number that is not negative") + return value + + +def _count(path, field, value): + if isinstance(value, bool) or not isinstance(value, int) or value < 1: + _fail(path, field, "expected a whole number from 1") + return value + + +def _only(path, field, data, allowed): + for key in data: + if key not in allowed: + _fail(path, f"{field}.{key}", "unknown field") + + +def _scope(path, provider, scope): + if not isinstance(scope, dict): + _fail(path, "scope", "expected an object") + if "tfstate" in scope: + _only(path, "scope", scope, ("tfstate",)) + name = _text(path, "scope.tfstate", scope["tfstate"]) + if Path(name).name != name or not (TFSTATE_DIR / name).is_file(): + _fail(path, "scope.tfstate", f"no such file in {TFSTATE_DIR.name}/: {name}") + return scope + fields = SCOPE_FIELDS[provider] + _only(path, "scope", scope, fields) + for field in fields: + if field not in scope: + _fail(path, f"scope.{field}", "missing") + _text(path, f"scope.{field}", scope[field]) + return scope + + +def _resources(path, resources): + if not isinstance(resources, list): + _fail(path, "resources", "expected a list") + seen = set() + for index, resource in enumerate(resources): + field = f"resources[{index}]" + if not isinstance(resource, dict): + _fail(path, field, "expected an object") + _only(path, field, resource, ("type", "region", "count")) + for key in ("type", "region", "count"): + if key not in resource: + _fail(path, f"{field}.{key}", "missing") + _text(path, f"{field}.type", resource["type"]) + _text(path, f"{field}.region", resource["region"]) + _count(path, f"{field}.count", resource["count"]) + key = (resource["type"], resource["region"]) + if key in seen: + _fail(path, field, f"{key[0]} in {key[1]} is listed twice") + seen.add(key) + return tuple(resources) + + +def _costs(path, costs): + if costs is None: + return None + if not isinstance(costs, dict): + _fail(path, "costs", "expected an object or null") + _only(path, "costs", costs, ("currency", "months", "currencies")) + for key in ("currency", "months"): + if key not in costs: + _fail(path, f"costs.{key}", "missing") + if not CURRENCY.match(str(costs["currency"])): + _fail(path, "costs.currency", "expected a three-letter code such as USD") + if not isinstance(costs["months"], dict): + _fail(path, "costs.months", "expected an object of YYYY-MM: amount") + for month, amount in costs["months"].items(): + if not MONTH.match(month): + _fail(path, f"costs.months.{month}", "expected a month as YYYY-MM") + _number(path, f"costs.months.{month}", amount) + for month, currency in (costs.get("currencies") or {}).items(): + if month not in costs["months"]: + _fail(path, f"costs.currencies.{month}", "not a month of costs.months") + if not CURRENCY.match(str(currency)): + _fail(path, f"costs.currencies.{month}", "expected a three-letter code") + return costs + + +def _egress(path, egress): + if egress is None: + return None + if not isinstance(egress, dict): + _fail(path, "egress", "expected an object or null") + _only(path, "egress", egress, ("resources", "archive_tiers")) + tiers = egress.get("archive_tiers", []) + if not isinstance(tiers, list) or not all(isinstance(t, str) for t in tiers): + _fail(path, "egress.archive_tiers", "expected a list of tier names") + resources = egress.get("resources") + if not isinstance(resources, list): + _fail(path, "egress.resources", "expected a list") + for index, resource in enumerate(resources): + field = f"egress.resources[{index}]" + if not isinstance(resource, dict): + _fail(path, field, "expected an object") + _only( + path, + field, + resource, + ("type", "name", "size_gib", "unknown", "tiers", "flags", "notes"), + ) + for key in ("type", "name", "size_gib"): + if key not in resource: + _fail(path, f"{field}.{key}", "missing") + _text(path, f"{field}.type", resource["type"]) + _text(path, f"{field}.name", resource["name"]) + _number(path, f"{field}.size_gib", resource["size_gib"], allow_none=True) + for key in ("tiers",): + for tier, gib in (resource.get(key) or {}).items(): + _number(path, f"{field}.{key}.{tier}", gib) + for key in ("flags", "notes"): + values = resource.get(key, []) + if not isinstance(values, list) or not all( + isinstance(v, str) for v in values + ): + _fail(path, f"{field}.{key}", "expected a list of strings") + return egress + + +def _upload(path, upload): + if upload is None: + return None + if not isinstance(upload, dict): + _fail(path, "upload", "expected an object or null") + _only(path, "upload", upload, UPLOAD_FIELDS) + status = _text(path, "upload.status", upload.get("status")) + if status not in UPLOAD_STATUSES: + _fail(path, "upload.status", f"expected one of {list(UPLOAD_STATUSES)}") + if "reason" in upload: + _text(path, "upload.reason", upload["reason"]) + if "spooled" in upload and not isinstance(upload["spooled"], bool): + _fail(path, "upload.spooled", "expected true or false") + return upload + + +def _catalogue(path, value): + if value is not None and value not in CATALOGUES: + _fail(path, "catalogue", f"expected one of {list(CATALOGUES)}, got {value!r}") + return value + + +def _clock(path, value): + text = _text(path, "clock", value) + if not text.endswith("Z"): + _fail(path, "clock", "expected an ISO time in UTC ending in Z") + try: + return datetime.fromisoformat(text).astimezone(timezone.utc) + except ValueError: + _fail(path, "clock", f"not an ISO time: {text}") + + +def load_scenario(path): + path = Path(path) + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise ScenarioError(f"{path.name}: cannot read the file: {exc}") from exc + if not isinstance(data, dict): + _fail(path, "(file)", "expected an object") + for field in FIELDS: + if field not in data: + _fail(path, field, "missing") + _only(path, "(file)", data, FIELDS + OPTIONAL_FIELDS) + + provider = data["provider"] + if provider not in PROVIDER_IDS: + _fail( + path, + "provider", + f"expected one of {sorted(PROVIDER_IDS)}, got {provider!r}", + ) + strategy = data["exit_strategy"] + if strategy not in (1, 2, 3) or isinstance(strategy, bool): + _fail(path, "exit_strategy", "expected 1, 2 or 3") + run_id = _text(path, "run_id", data["run_id"]) + if not UUID7.match(run_id): + _fail(path, "run_id", "expected a UUIDv7-shaped string") + + scope = _scope(path, provider, data["scope"]) + resources = _resources(path, data["resources"]) + costs = _costs(path, data["costs"]) + egress = _egress(path, data["egress"]) + if "tfstate" in scope and (resources or costs or egress): + _fail(path, "scope.tfstate", "a state file has no resources, costs or egress") + + return Scenario( + case=path.stem, + path=path, + name=_text(path, "name", data["name"]), + purpose=_text(path, "purpose", data["purpose"]), + provider=provider, + exit_strategy=strategy, + scope=scope, + resources=resources, + costs=costs, + egress=egress, + upload=_upload(path, data["upload"]), + clock=_clock(path, data["clock"]), + run_id=run_id, + catalogue=_catalogue(path, data.get("catalogue")), + ) + + +def load_scenarios(directory=SAMPLES_DIR): + return { + path.stem: load_scenario(path) + for path in sorted(Path(directory).glob("*.json")) + } diff --git a/tests/samples/test_samples.py b/tests/samples/test_samples.py new file mode 100644 index 0000000..a8f459f --- /dev/null +++ b/tests/samples/test_samples.py @@ -0,0 +1,565 @@ +import io +import re +import sqlite3 +import tempfile +import unittest +from contextlib import redirect_stderr, redirect_stdout +from pathlib import Path +from unittest.mock import patch + +from pypdf import PdfReader + +from cloudexit.report.sections import AWS_ICONS_URL, AZURE_ICONS_URL +from cloudexit.report.wording import BLOCKS +from tests.report.test_basic_report import metadata_texts, page_texts +from tests.report.test_no_credentials import find_leaks, folder_text +from tests.samples import __main__ as samples_main +from tests.samples.catalogue import build_sample_catalogue +from tests.samples.rendered import rendered +from tests.samples.render import ( + expected_files, + pdf_dump, + render_case, + render_cases, +) +from tests.support.credentials import AWS_DETAILS, AZURE_DETAILS +from tests.support.html import html_blocks, html_text +from tests.support.report_terms import american_hits, find_terms, retired_term_hits +from tests.support.sync import SECRETS + +IDENTIFYING = ("accountId", "tenantId", "subscriptionId", "resourceGroupName") +ICON_MARKERS = {"azure": "Microsoft group", "aws": "Amazon.com"} +EGRESS_FILES = ( + "egress.pdf", + "texts/egress.txt", +) + +STATE = {} + + +def squashed(text): + return text.replace(" ", "") + + +def setUpModule(): + STATE.update(rendered()) + + +def tearDownModule(): + STATE.clear() + + +class RenderedTestCase(unittest.TestCase): + def cases(self): + yield from STATE["scenarios"].items() + + def folder(self, case): + return STATE["results"][case].directory + + def text(self, case, name): + return (self.folder(case) / name).read_text(encoding="utf-8") + + def pdf_pages(self, case, name): + return page_texts(PdfReader(str(self.folder(case) / name))) + + +class RenderTests(RenderedTestCase): + def test_every_case_renders(self): + self.assertEqual(STATE["failures"], {}) + self.assertEqual(set(STATE["results"]), set(STATE["scenarios"])) + + def test_the_expected_files_exist_and_no_others_of_the_list(self): + for case, scenario in self.cases(): + with self.subTest(case): + for name in expected_files(scenario): + self.assertTrue((self.folder(case) / name).is_file(), name) + if scenario.egress is None: + for name in EGRESS_FILES: + self.assertFalse((self.folder(case) / name).exists(), name) + self.assertEqual(STATE["results"][case].files, expected_files(scenario)) + + def test_a_state_file_never_produces_egress(self): + for case, scenario in self.cases(): + if scenario.tfstate: + with self.subTest(case): + self.assertEqual(scenario.egress, None) + self.assertFalse((self.folder(case) / "egress.pdf").exists()) + + def test_the_dumps_match_the_files(self): + for case, scenario in self.cases(): + with self.subTest(case): + folder = self.folder(case) + for pdf, dump in ( + ("report.pdf", "report.txt"), + ("egress.pdf", "egress.txt"), + ): + if (folder / pdf).is_file(): + text, pages = pdf_dump(folder / pdf) + self.assertEqual(self.text(case, f"texts/{dump}"), text) + self.assertEqual(text.count("--- page "), pages) + self.assertEqual( + self.text(case, "texts/index-html.txt"), + html_blocks(self.text(case, "index.html")), + ) + # One HTML report: there is no second page for the egress cases + self.assertFalse((folder / "egress.html").exists()) + + def test_the_html_dump_has_the_egress_section_for_the_egress_cases(self): + for case, scenario in self.cases(): + with self.subTest(case): + dump = self.text(case, "texts/index-html.txt") + has_section = "## Egress & Data Landscape" in dump + self.assertEqual(has_section, scenario.egress is not None) + + def test_rendering_a_case_twice_gives_the_same_texts(self): + scenario = STATE["scenarios"]["aws_typical"] + with tempfile.TemporaryDirectory() as tmp: + again = render_case(scenario, tmp, catalogue_path=STATE["catalogue"]) + for name in expected_files(scenario): + if name.startswith("texts/"): + self.assertEqual( + (again.directory / name).read_text(encoding="utf-8"), + self.text("aws_typical", name), + name, + ) + + +class GuardTests(RenderedTestCase): + def test_no_credential_and_no_value_of_another_case(self): + for case, scenario in self.cases(): + with self.subTest(case): + folder = self.folder(case) + state = "" + if scenario.tfstate: + state = Path(scenario.tfstate).read_text(encoding="utf-8") + own = {v for k, v in scenario.scope.items() if k in IDENTIFYING} + others = { + v + for other, candidate in self.cases() + if other != case + for k, v in candidate.scope.items() + if k in IDENTIFYING + } + forbidden = {v for v in others - own if v not in state} + texts = folder_text(folder) + for name, text in texts.items(): + for value in forbidden: + self.assertNotIn(value, text, f"{name}: {value}") + for secret in SECRETS: + self.assertNotIn(secret, text, name) + self.assertIsNone(re.search(r"\bAKIA[0-9A-Z]{16}\b", text), name) + for details in (AWS_DETAILS, AZURE_DETAILS): + self.assertEqual(find_leaks(folder, details), []) + + def test_the_term_wording_and_retired_term_guards(self): + for case, scenario in self.cases(): + with self.subTest(case): + folder = self.folder(case) + texts = [] + for pdf in ("report.pdf", "egress.pdf"): + if (folder / pdf).is_file(): + reader = PdfReader(str(folder / pdf)) + for text in metadata_texts(reader): + self.assertEqual(find_terms(text), []) + texts += page_texts(reader) + source = self.text(case, "index.html") + texts.append(html_text(source)) + self.assertEqual(find_terms(source.split(" None: + report_assets = Path(report_path) / "assets" + report_assets.mkdir(parents=True, exist_ok=True) + + source_assets = asset_path() + for folder in ("css", "img", "icons"): + shutil.copytree( + source_assets / folder, + report_assets / folder, + dirs_exist_ok=True, + ) + + +def build_fixture_context(fixture, report_dir, **overrides): + # A report context from a fixture, for rendering the Basic report. + from datetime import datetime, timezone + + from cloudexit.report.context import Provenance, build_context + + collected = datetime(2026, 5, 7, 12, 0, tzinfo=timezone.utc) + provenance = overrides.pop( + "provenance", + Provenance( + collector_version="2.0.0", + collected_at=collected, + generated_at=collected, + run_id="01939b8e-7c2a-7d41-9a51-3c8f0a6e21d4", + catalogue_version="2026-11-02", + catalogue_sequence=41, + catalogue_sha256="c" * 64, + ), + ) + values = dict( + metadata=fixture["metadata"], + provider_details=fixture["provider_details"], + resource_type_mapping=fixture["resource_type_mapping"], + resource_inventory=fixture["resource_inventory"], + cost_data=fixture["cost_data"], + risk_data=fixture["risk_data"], + risk_definitions=fixture["risk_definitions"], + alternatives=fixture["alternatives"], + alternative_technologies=fixture["alternative_technologies"], + alternative_technology_organizations=fixture[ + "alternative_technology_organizations" + ], + exit_strategy=fixture["exit_strategy"], + tfstate_scope=None, + provenance=provenance, + report_path=str(report_dir), + ) + values.update(overrides) + return build_context(**values) + + +def stage_minimal_assets(report_path: str) -> None: + # What the Basic report needs: the logo. Copying the whole icon set for + # every render would dominate the test time; an icon missing from the report + # folder is read from the installed package. + report_assets = Path(report_path) / "assets" + shutil.copytree(asset_path("img"), report_assets / "img", dirs_exist_ok=True) diff --git a/tests/support/report_terms.py b/tests/support/report_terms.py new file mode 100644 index 0000000..ef94ed1 --- /dev/null +++ b/tests/support/report_terms.py @@ -0,0 +1,157 @@ +import re + +ACRONYMS = ( + "DORA", + "FINMA", + "PRA", + "ECB", + "EBA", + "EBF", + "ESMA", + "EIOPA", + "ESAs", + "CTPP", + "ICT", + "GDPR", + "NIS2", + "FCA", + "BaFin", + "MaRisk", + "BAIT", + "SS2/21", +) + +PHRASES = ( + "regulation", + "regulatory", + "regulator", + "regulated", + "supervisor", + "supervisory", + "competent authority", + "financial entity", + "outsourcing", + "compliance", + "compliant", + "article 28", + "art. 28", + "circular", + "european banking federation", + "cloud banking forum", + "european central bank", + "control demand", + "rating grid", + "concentration risk", + "third-party service provider", + "regulatory framework", + "2022/2554", +) + +# Branding of other products' reports. The platforms are named on the closing +# page and the legal notices, which are checked separately. "Community +# Edition" is this product's own edition and is no longer foreign. +FOREIGN_BRANDS = ( + "Generated with exitcloud.io", + "exitcloud.io document", +) + + +def find_terms(text): + found = [ + acronym + for acronym in ACRONYMS + if re.search(rf"(?]+>", " ", label).split() + for index, word in enumerate(words): + core = word.strip("(),.:;·–—’'\"+/") + if not core or not core[0].isalpha(): + continue + if word.lower().rstrip(",") in TITLE_SMALL_WORDS: + if index and word != word.lower(): + return False + if index == 0 and word.lower() != "vs." and not word[0].isupper(): + return False + continue + if not core[0].isupper(): + return False + return True + + +def retired_term_hits(text): + hits = [] + for pattern, allowed in RETIRED_TERMS: + for match in re.finditer(pattern, text, re.IGNORECASE): + context = text[max(0, match.start() - 40) : match.end() + 40] + if not any(phrase in context for phrase in allowed): + hits.append(context) + return hits + + +def american_hits(text): + hits = [] + for match in AMERICAN.finditer(text): + context = text[max(0, match.start() - 30) : match.end() + 30] + if not any(name in context for name in AMERICAN_ALLOWED): + hits.append(context) + return hits diff --git a/tests/support/sync.py b/tests/support/sync.py new file mode 100644 index 0000000..dd097db --- /dev/null +++ b/tests/support/sync.py @@ -0,0 +1,124 @@ +import json +import sqlite3 +from datetime import datetime, timezone +from pathlib import Path + +import yaml +from jsonschema import Draft202012Validator + +from tests.support.catalogue import build_catalogue +from tests.support.paths import DEFINITION + +STARTED_AT = int(datetime(2026, 10, 23, 12, 0, tzinfo=timezone.utc).timestamp()) +COMPLETED_AT = STARTED_AT + 90 +SYNC_ID = "01939b8e-7c2a-7d41-9a51-3c8f0a6e21d4" + +ACCESS_KEY = "AKIAFAKEACCESSKEY1234" +SECRET_KEY = "fake-secret-key-AAAABBBBCCCC" +SESSION_TOKEN = "fake-session-token-XYZXYZXYZ" +CLIENT_SECRET = "fake-client-secret-0123456789" +SECRETS = (ACCESS_KEY, SECRET_KEY, SESSION_TOKEN, CLIENT_SECRET) + +RUN_TABLES = """ +CREATE TABLE resource_inventory ( + id INTEGER PRIMARY KEY AUTOINCREMENT, resource_type INTEGER NOT NULL, + location TEXT NOT NULL, count INTEGER NOT NULL, UNIQUE(resource_type, location) +); +CREATE TABLE cost_inventory ( + id INTEGER PRIMARY KEY AUTOINCREMENT, month TEXT NOT NULL, cost REAL NOT NULL, + currency TEXT NOT NULL, UNIQUE(month) +); +CREATE TABLE egress_inventory ( + id INTEGER PRIMARY KEY AUTOINCREMENT, resource_type INTEGER NOT NULL, + name TEXT NOT NULL, size_bytes INTEGER, size_unknown INTEGER NOT NULL DEFAULT 0, + flags TEXT, notes TEXT +); +CREATE TABLE egress_inventory_tier ( + id INTEGER PRIMARY KEY AUTOINCREMENT, egress_inventory_id INTEGER NOT NULL, + tier TEXT NOT NULL, size_bytes INTEGER NOT NULL, + is_archive INTEGER NOT NULL DEFAULT 0 +); +""" + + +def egress_row(type_id, name, size=None, flags=(), notes=(), tiers=()): + return { + "type_id": type_id, + "name": name, + "size": size, + "flags": list(flags), + "notes": list(notes), + "tiers": list(tiers), + } + + +def build_run( + report_path, + *, + resources=(), + costs=(), + egress=(), + tfstate_scope=None, + **catalogue, +): + report = Path(report_path) + (report / "data").mkdir(parents=True, exist_ok=True) + (report / "raw_data").mkdir(parents=True, exist_ok=True) + + db_path = report / "data" / "assessment.db" + build_catalogue(db_path, **catalogue) + conn = sqlite3.connect(db_path) + conn.executescript(RUN_TABLES) + conn.executemany( + "INSERT INTO resource_inventory (resource_type, location, count) " + "VALUES (?, ?, ?)", + resources, + ) + conn.executemany( + "INSERT INTO cost_inventory (month, cost, currency) VALUES (?, ?, ?)", costs + ) + for row in egress: + cursor = conn.execute( + "INSERT INTO egress_inventory " + "(resource_type, name, size_bytes, size_unknown, flags, notes) " + "VALUES (?, ?, ?, ?, ?, ?)", + ( + row["type_id"], + row["name"], + row["size"], + int(row["size"] is None), + json.dumps(row["flags"]), + json.dumps(row["notes"]), + ), + ) + conn.executemany( + "INSERT INTO egress_inventory_tier " + "(egress_inventory_id, tier, size_bytes, is_archive) VALUES (?, ?, ?, ?)", + [(cursor.lastrowid, *tier) for tier in row["tiers"]], + ) + conn.commit() + conn.close() + + if tfstate_scope is not None: + manifest = report / "raw_data" / "tfstate_manifest.json" + manifest.write_text(json.dumps({"scope": tfstate_scope}), encoding="utf-8") + return str(report) + + +def request_validator(): + spec = yaml.safe_load((DEFINITION / "openapi.yaml").read_text(encoding="utf-8")) + root = { + "$ref": "#/components/schemas/SyncRequest", + "components": spec["components"], + } + return Draft202012Validator( + root, format_checker=Draft202012Validator.FORMAT_CHECKER + ) + + +def schema_errors(document): + validator = request_validator() + return sorted( + f"/{'/'.join(str(part) for part in error.absolute_path)}: {error.message}" + for error in validator.iter_errors(document) + ) diff --git a/tests/support/test_http.py b/tests/support/test_http.py new file mode 100644 index 0000000..0963e27 --- /dev/null +++ b/tests/support/test_http.py @@ -0,0 +1,68 @@ +import unittest + +import requests + +from tests.support.http import fake_http, make_response + + +class FakeHTTPTests(unittest.TestCase): + def test_replies_are_returned_in_order_and_requests_recorded(self): + with fake_http( + make_response(json_body={"n": 1}), + make_response(status=503, text="down"), + ) as fake: + first = requests.get("https://api.test/one", timeout=5) + second = requests.post("https://api.test/two", json={"a": 1}) + + self.assertEqual(first.json(), {"n": 1}) + self.assertEqual(second.status_code, 503) + self.assertEqual( + [(r.method, r.url) for r in fake.requests], + [("GET", "https://api.test/one"), ("POST", "https://api.test/two")], + ) + self.assertEqual(fake.requests[1].kwargs["json"], {"a": 1}) + + def test_exception_replies_are_raised(self): + with fake_http(requests.ConnectionError("refused")): + with self.assertRaises(requests.ConnectionError): + requests.get("https://api.test/") + + def test_request_beyond_the_queued_replies_fails(self): + with fake_http(): + with self.assertRaises(AssertionError): + requests.get("https://api.test/") + + def test_unused_replies_fail_the_test(self): + with self.assertRaises(AssertionError): + with fake_http(make_response()): + pass + + def test_raise_for_status_follows_the_status_code(self): + with fake_http(make_response(status=500)): + response = requests.get("https://api.test/") + with self.assertRaises(requests.HTTPError): + response.raise_for_status() + + +class FakeHTTPExampleTests(unittest.TestCase): + def test_a_request_through_a_session_is_recorded_with_its_headers(self): + with fake_http(make_response(json_body={"object": "meta"})) as fake: + with requests.Session() as session: + response = session.get( + "https://api.test/api/v2/meta", headers={"Accept": "text/plain"} + ) + + self.assertEqual(response.json(), {"object": "meta"}) + (request,) = fake.requests + self.assertEqual(request.url, "https://api.test/api/v2/meta") + self.assertEqual(request.kwargs["headers"], {"Accept": "text/plain"}) + + def test_a_connection_error_reply_is_raised_from_a_session(self): + with fake_http(requests.ConnectionError("refused")): + with requests.Session() as session: + with self.assertRaises(requests.ConnectionError): + session.get("https://api.test/") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_asset_paths.py b/tests/test_asset_paths.py new file mode 100644 index 0000000..1d17b13 --- /dev/null +++ b/tests/test_asset_paths.py @@ -0,0 +1,147 @@ +import os +import sys +import tempfile +import unittest +from pathlib import Path, PurePosixPath +from unittest.mock import patch + +from cloudexit.report.cover import LOGO_FILE, logo_reader +from cloudexit.report.helpers import report_icon +from cloudexit.resources import find_asset, resolve_inside, safe_relative + +REFUSED = ( + "", + "/", + "..", + "../x.png", + "icons/../../x.png", + "/../x.png", + "//host/share/x.png", + "///x.png", + "\\\\host\\share\\x.png", + "\\\\host\\share\\x.png".replace("\\", "/"), + "C:\\x.png", + "C:/x.png", + "c:x.png", + "icons\\aws\\EC2.png", + "/icons\\aws.png", + "x.png:stream", + "x\x00.png", +) + + +class AssetPathTestCase(unittest.TestCase): + def setUp(self): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.tmp = Path(tmp.name) + self.report = self.tmp / "report" + self.assets = self.report / "assets" + (self.assets / "icons" / "aws").mkdir(parents=True) + (self.assets / "icons" / "aws" / "EC2.png").write_bytes(b"png") + self.outside = self.tmp / "outside.png" + self.outside.write_bytes(b"secret") + + +class SafeRelativeTests(unittest.TestCase): + def test_a_normal_relative_path_is_kept(self): + self.assertEqual( + safe_relative("icons/aws/EC2.png"), PurePosixPath("icons/aws/EC2.png") + ) + + def test_the_catalogue_form_with_one_leading_slash_means_the_assets_folder(self): + self.assertEqual( + safe_relative("/icons/aws/EC2.png"), PurePosixPath("icons/aws/EC2.png") + ) + + def test_everything_that_could_leave_the_folder_is_refused(self): + for relative in REFUSED: + with self.subTest(relative=relative): + self.assertIsNone(safe_relative(relative)) + self.assertIsNone(safe_relative(None)) + + +class FindAssetTests(AssetPathTestCase): + def test_the_report_logo_is_found_by_its_own_name_on_every_system(self): + self.assertNotIn("\\", LOGO_FILE) + self.assertEqual(safe_relative(LOGO_FILE), PurePosixPath(LOGO_FILE)) + self.assertIsNotNone(find_asset(self.report, LOGO_FILE)) + self.assertIsNotNone(logo_reader(self.report)) + + def test_a_normal_icon_still_resolves(self): + found = find_asset(self.report, "/icons/aws/EC2.png") + + self.assertEqual(found, self.assets / "icons" / "aws" / "EC2.png") + + def test_refused_paths_find_nothing_and_touch_no_file(self): + with ( + patch.object(Path, "is_file", side_effect=AssertionError("looked")), + patch.object(Path, "resolve", side_effect=AssertionError("looked")), + patch.object(os.path, "isfile", side_effect=AssertionError("looked")), + patch.object(os, "stat", side_effect=AssertionError("looked")), + ): + for relative in REFUSED: + with self.subTest(relative=relative): + self.assertIsNone(find_asset(self.report, relative)) + + def test_an_absolute_path_is_read_inside_the_assets_only(self): + looked = [] + real = Path.is_file + + def spy(path): + looked.append(path) + return real(path) + + with patch.object(Path, "is_file", spy): + found = find_asset(self.report, str(self.outside)) + + self.assertIsNone(found) + for path in looked: + self.assertNotEqual(path, self.outside) + + @unittest.skipIf(sys.platform == "win32", "needs symbolic links") + def test_a_link_out_of_the_assets_is_not_followed(self): + (self.assets / "icons" / "link.png").symlink_to(self.outside) + + self.assertIsNone(find_asset(self.report, "icons/link.png")) + self.assertIsNone(resolve_inside(self.assets, "icons/link.png")) + + +class ReportIconTests(AssetPathTestCase): + def test_a_normal_icon_points_into_the_folder(self): + self.assertEqual( + report_icon(self.report, "icons/aws/EC2.png"), "assets/icons/aws/EC2.png" + ) + self.assertEqual( + report_icon(self.report, "/icons/aws/EC2.png"), "assets/icons/aws/EC2.png" + ) + + def test_a_missing_icon_is_none(self): + self.assertIsNone(report_icon(self.report, "icons/aws/missing.png")) + self.assertIsNone(report_icon(self.report, None)) + self.assertIsNone(report_icon(self.report, "")) + + def test_refused_paths_give_the_placeholder_and_touch_no_file(self): + with ( + patch.object(Path, "is_file", side_effect=AssertionError("looked")), + patch.object(Path, "resolve", side_effect=AssertionError("looked")), + patch.object(os.path, "isfile", side_effect=AssertionError("looked")), + patch.object(os, "stat", side_effect=AssertionError("looked")), + ): + for relative in REFUSED: + with self.subTest(relative=relative): + self.assertIsNone(report_icon(self.report, relative)) + + def test_a_file_outside_the_folder_is_never_pointed_at(self): + for relative in ( + str(self.outside), + "../outside.png", + "..\\outside.png", + f"/{self.outside}", + ): + with self.subTest(relative=relative): + self.assertIsNone(report_icon(self.report, relative)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_docs.py b/tests/test_docs.py new file mode 100644 index 0000000..9254ff7 --- /dev/null +++ b/tests/test_docs.py @@ -0,0 +1,206 @@ +import argparse +import re +import unittest +from unittest.mock import patch + +from cloudexit.cli.parser import parse_arguments +from cloudexit.settings import ENV_VARS +from tests.support.paths import ROOT + +MARKDOWN = ("README.md", "CHANGELOG.md", "docs/migration-2.0.md") +INVOCATION = re.compile( + r"(?", ">>", "|", "&&", "2>&1", "") + + +class Captured(Exception): + pass + + +def built_parser(): + # parse_arguments() builds the parser and parses in one go, so take the + # parser it would have used rather than changing the CLI for a test + holder = [] + + def capture(self, *args, **kwargs): + holder.append(self) + raise Captured + + with patch.object(argparse.ArgumentParser, "parse_args", capture): + try: + parse_arguments() + except Captured: + pass + return holder[0] + + +def surface(parser, flags, commands, path=()): + for action in parser._actions: + flags.update(action.option_strings) + if isinstance(action, argparse._SubParsersAction): + for name, subparser in action.choices.items(): + commands.add((*path, name)) + surface(subparser, flags, commands, (*path, name)) + + +def text_of(name): + return (ROOT / name).read_text(encoding="utf-8") + + +def code_fragments(name): + body = text_of(name).replace("\\\n", " ") + yield from re.findall(r"```[a-z]*\n(.*?)```", body, re.S) + yield from re.findall(r"`([^`\n]+)`", body) + + +def invocations(): + for name in MARKDOWN: + for fragment in code_fragments(name): + for match in INVOCATION.finditer(fragment): + tokens = [] + for token in match.group(1).split(): + token = token.strip("[]`'\",") + if token in STOP or token.startswith(">"): + break + tokens.append(token) + if tokens: + yield name, tokens + + +class DocumentedCommandTests(unittest.TestCase): + def setUp(self): + self.flags, self.commands = set(), set() + surface(built_parser(), self.flags, self.commands) + + def test_the_parser_surface_was_read(self): + self.assertIn(("aws",), self.commands) + self.assertIn(("spool", "flush"), self.commands) + self.assertIn("--upload", self.flags) + + def test_every_documented_flag_exists(self): + unknown = { + f"{name}: {token}" + for name, tokens in invocations() + for token in tokens + if token.startswith("--") and token not in self.flags + } + self.assertEqual(sorted(unknown), []) + + def test_every_documented_subcommand_exists(self): + unknown = set() + for name, tokens in invocations(): + words = [token for token in tokens if not token.startswith("-")] + if words and (words[0],) not in self.commands: + unknown.add(f"{name}: {words[0]}") + self.assertEqual(sorted(unknown), []) + + def test_every_documented_environment_variable_exists(self): + known = set(ENV_VARS.values()) + unknown = { + f"{name}: {found}" + for name in MARKDOWN + for found in re.findall(r"CLOUDEXIT_[A-Z_]+", text_of(name)) + if found not in known + } + self.assertEqual(sorted(unknown), []) + + +def slug(heading): + text = re.sub(r"[^\w\s-]", "", heading.strip().lower()) + return re.sub(r"\s+", "-", text.strip()) + + +def anchors(name): + found, fenced = set(), False + for line in text_of(name).splitlines(): + if line.startswith("```"): + fenced = not fenced + elif not fenced and line.startswith("#"): + found.add(slug(line.lstrip("#"))) + return found + + +class MarkdownLinkTests(unittest.TestCase): + LINK = re.compile(r"\[[^\]]*\]\(([^)\s]+)\)") + + def links(self): + for name in MARKDOWN: + for target in self.LINK.findall(text_of(name)): + if not target.startswith(("http://", "https://", "mailto:", "#")): + yield name, target + elif target.startswith("#"): + yield name, target + + def test_every_relative_link_points_at_something_in_the_repository(self): + missing = [] + for name, target in self.links(): + path = target.split("#", 1)[0] + if path and not ((ROOT / name).parent / path).exists(): + missing.append(f"{name} -> {target}") + self.assertEqual(missing, []) + + def test_every_anchor_exists_in_the_file_it_points_at(self): + missing = [] + for name, target in self.links(): + path, _, anchor = target.partition("#") + if not anchor: + continue + if path: + resolved = ((ROOT / name).parent / path).resolve() + if resolved.suffix != ".md" or not resolved.is_file(): + continue + target_name = resolved.relative_to(ROOT).as_posix() + else: + target_name = name + if anchor not in anchors(target_name): + missing.append(f"{name} -> {target}") + self.assertEqual(missing, []) + + +class ReadmeStateTests(unittest.TestCase): + def test_nothing_is_marked_alpha_any_more(self): + for name in MARKDOWN: + with self.subTest(name): + self.assertNotIn("(alpha)", text_of(name)) + + def test_the_moved_reference_block_is_not_in_the_readme(self): + body = text_of("README.md") + for heading in ( + "## Platform settings", + "## Catalogue dataset", + "## Sync document", + "## Reports and uploads", + "### Retries and the spool", + ): + with self.subTest(heading): + self.assertNotIn(heading, body) + + def test_the_readme_points_at_the_documentation_and_the_migration_guide(self): + body = text_of("README.md") + self.assertIn("## Documentation", body) + self.assertIn("./docs/migration-2.0.md", body) + self.assertIn("./CHANGELOG.md", body) + + +class ExampleConfigTests(unittest.TestCase): + PLACEHOLDERS = { + "config/aws_example.json": "AKIAIOSFODNN7EXAMPLE", + "config/azure_example.json": "00000000-0000-0000-0000-000000000000", + } + + def test_the_examples_carry_obvious_placeholders(self): + for name, expected in self.PLACEHOLDERS.items(): + with self.subTest(name): + self.assertIn(expected, text_of(name)) + + def test_no_example_config_holds_a_secret(self): + for name in self.PLACEHOLDERS: + with self.subTest(name): + body = text_of(name) + self.assertNotRegex(body, r'"secretKey"\s*:\s*"[^"]') + self.assertNotRegex(body, r'"clientSecret"\s*:\s*"[^"]') + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_engine.py b/tests/test_engine.py index c055445..b14669a 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -4,7 +4,7 @@ from pathlib import Path from unittest.mock import MagicMock, patch -from core.engine import _load_tfstate_scope, sync_assessment, test_permissions +from cloudexit.engine import _load_tfstate_scope, test_permissions class TestPermissionsAwsHybridMode(unittest.TestCase): @@ -16,7 +16,7 @@ def setUp(self): "region": "eu-central-1", } - @patch("core.engine.boto3.client") + @patch("cloudexit.engine.boto3.client") def test_iam_user_keeps_policy_based_validation(self, mock_boto_client): sts_client = MagicMock() sts_client.get_caller_identity.return_value = { @@ -48,7 +48,7 @@ def client_side_effect(service_name, **kwargs): self.assertTrue(permission_cost) self.assertIn("policies validated", logs) - @patch("core.engine.boto3.client") + @patch("cloudexit.engine.boto3.client") def test_assumed_role_uses_capability_checks(self, mock_boto_client): sts_client = MagicMock() sts_client.get_caller_identity.return_value = { @@ -77,7 +77,7 @@ def client_side_effect(service_name, **kwargs): self.assertTrue(permission_cost) self.assertIn("capability checks validated", logs) - @patch("core.engine.boto3.client") + @patch("cloudexit.engine.boto3.client") def test_assumed_role_cost_capability_failure(self, mock_boto_client): sts_client = MagicMock() sts_client.get_caller_identity.return_value = { @@ -108,60 +108,6 @@ def client_side_effect(service_name, **kwargs): self.assertIn("ce:GetCostAndUsage failed", logs) -class SyncAssessmentContractTests(unittest.TestCase): - def test_offline_returns_success_without_calling_the_api(self): - with patch("core.engine.post_assessment") as mock_post: - result = sync_assessment( - report_path="/tmp", - name="n", - started_at=0, - metadata={}, - mode="offline", - token=None, - ) - self.assertTrue(result["success"]) - self.assertFalse(result["online"]) - mock_post.assert_not_called() - - def test_server_failure_returns_dict_not_raises(self): - with patch( - "core.engine.post_assessment", - return_value={"success": False, "payload": None, "logs": "401"}, - ): - result = sync_assessment( - report_path="/tmp", - name="n", - started_at=0, - metadata={}, - mode="online", - token="tok", - ) - self.assertFalse(result["success"]) - self.assertIn("401", result["logs"]) - - def test_local_db_failure_returns_dict_not_raises(self): - good = { - "success": True, - "payload": { - "data": {"risk_inventory": [{"id": 1, "impacted_resources": []}]} - }, - } - with ( - patch("core.engine.post_assessment", return_value=good), - patch("core.engine.connect", side_effect=Exception("db down")), - ): - result = sync_assessment( - report_path="/tmp", - name="n", - started_at=0, - metadata={}, - mode="online", - token="tok", - ) - self.assertFalse(result["success"]) - self.assertIn("store server risks", result["logs"]) - - class LoadTfstateScopeTests(unittest.TestCase): def _write_manifest(self, directory, payload): path = Path(directory) / "tfstate_manifest.json" @@ -172,24 +118,37 @@ def test_returns_scope_block_from_manifest(self): with tempfile.TemporaryDirectory() as tmp_dir: self._write_manifest(tmp_dir, {"scope": scope, "counted": []}) - self.assertEqual(_load_tfstate_scope(tmp_dir), scope) + self.assertEqual(_load_tfstate_scope(tmp_dir), (scope, None)) + + def test_returns_the_coverage_block_with_the_scope(self): + scope = {"file": "infra.tfstate"} + coverage = { + "instances_total": 9, + "instances_counted": 5, + "instances_excluded_other_provider": 3, + "instances_excluded_unmapped": 1, + } + with tempfile.TemporaryDirectory() as tmp_dir: + self._write_manifest(tmp_dir, {"scope": scope, "coverage": coverage}) + + self.assertEqual(_load_tfstate_scope(tmp_dir), (scope, coverage)) - def test_missing_manifest_returns_none_without_raising(self): + def test_missing_manifest_returns_nothing_without_raising(self): with tempfile.TemporaryDirectory() as tmp_dir: - self.assertIsNone(_load_tfstate_scope(tmp_dir)) + self.assertEqual(_load_tfstate_scope(tmp_dir), (None, None)) - def test_corrupt_manifest_returns_none_without_raising(self): + def test_corrupt_manifest_returns_nothing_without_raising(self): with tempfile.TemporaryDirectory() as tmp_dir: path = Path(tmp_dir) / "tfstate_manifest.json" path.write_text("{not json", encoding="utf-8") - self.assertIsNone(_load_tfstate_scope(tmp_dir)) + self.assertEqual(_load_tfstate_scope(tmp_dir), (None, None)) - def test_manifest_without_scope_returns_none(self): + def test_manifest_without_scope_returns_nothing(self): with tempfile.TemporaryDirectory() as tmp_dir: self._write_manifest(tmp_dir, {"counted": []}) - self.assertIsNone(_load_tfstate_scope(tmp_dir)) + self.assertEqual(_load_tfstate_scope(tmp_dir), (None, None)) if __name__ == "__main__": diff --git a/tests/test_layering.py b/tests/test_layering.py new file mode 100644 index 0000000..6199e14 --- /dev/null +++ b/tests/test_layering.py @@ -0,0 +1,111 @@ +import ast +import unittest + +from tests.support.paths import ROOT + +PACKAGE = ROOT / "cloudexit" + +# What each area of the package may import from another area. Imports inside an +# area are free. A new edge fails the test: either it is a mistake, or it needs +# a deliberate entry here (and a line in the pull request that adds it). +ALLOWED = { + "codes": set(), + "version": set(), + "resources": set(), + "private_files": set(), + "log_redaction": set(), + "terminal": {"codes"}, + "settings": set(), + "catalogue": {"settings"}, + "runconfig": {"codes", "terminal"}, + "tfstate": {"catalogue"}, + "providers": {"catalogue", "egress", "terminal"}, + # egress.estimate reaches the providers inside a function, on purpose + "egress": {"catalogue", "providers"}, + "platform": {"catalogue", "settings", "version", "codes", "private_files"}, + "report": {"catalogue", "egress", "providers", "resources", "version", "codes"}, + "engine": {"catalogue", "providers", "report", "tfstate", "terminal", "codes"}, +} +# The command line sits on top of everything +UNRESTRICTED = {"cli", "__main__"} + + +def area_of(module): + parts = module.split(".") + return parts[1] if len(parts) > 1 else None + + +def module_name(path): + parts = list(path.relative_to(ROOT).with_suffix("").parts) + if parts[-1] == "__init__": + parts.pop() + return ".".join(parts) + + +def imported_modules(path): + tree = ast.parse(path.read_text(encoding="utf-8")) + package = module_name(path).split(".") + if path.name != "__init__.py": + package.pop() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + yield alias.name + elif isinstance(node, ast.ImportFrom): + base = package[: len(package) - node.level + 1] if node.level else [] + yield ".".join([*base, *([node.module] if node.module else [])]) + + +def edges(): + found = set() + for path in sorted(PACKAGE.rglob("*.py")): + source = area_of(module_name(path)) + for name in imported_modules(path): + target = area_of(name) if name.startswith("cloudexit.") else None + if target and target != source: + found.add((source, target, path.relative_to(ROOT).as_posix())) + return found + + +class LayeringTests(unittest.TestCase): + def test_every_area_of_the_package_is_known(self): + areas = {area_of(module_name(p)) for p in PACKAGE.rglob("*.py")} + areas.discard(None) + self.assertEqual(areas - UNRESTRICTED - set(ALLOWED), set()) + + def test_no_area_imports_another_that_it_is_not_allowed_to(self): + violations = sorted( + f"{path}: {source} -> {target}" + for source, target, path in edges() + if source not in UNRESTRICTED and target not in ALLOWED[source] + ) + self.assertEqual(violations, []) + + def test_only_the_command_line_imports_the_command_line(self): + violations = sorted( + path + for source, target, path in edges() + if target == "cli" and source not in UNRESTRICTED + ) + self.assertEqual(violations, []) + + def test_platform_and_providers_do_not_know_each_other(self): + for source, target, path in edges(): + self.assertFalse( + {source, target} == {"platform", "providers"}, f"{path}: {source}" + ) + + def test_the_leaves_import_nothing_but_the_exit_codes(self): + for leaf in ("codes", "version", "resources", "terminal"): + for source, target, path in edges(): + if source == leaf: + self.assertEqual(target, "codes", path) + + def test_the_checker_finds_an_edge(self): + self.assertIn( + ("providers", "terminal", "cloudexit/providers/azure/costs.py"), edges() + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_logging.py b/tests/test_logging.py new file mode 100644 index 0000000..ea8f6cc --- /dev/null +++ b/tests/test_logging.py @@ -0,0 +1,217 @@ +import ast +import importlib +import logging +import tempfile +import unittest +from io import StringIO +from pathlib import Path +from unittest.mock import patch + +from rich.console import Console + +from cloudexit.cli import app +from cloudexit.log_redaction import redact +from tests.support.paths import ROOT + +PACKAGE = ROOT / "cloudexit" +# Loggers of other libraries that the package sets a level on +THIRD_PARTY = ( + "botocore", + "botocore.auth", + "botocore.credentials", + "botocore.endpoint", + "boto3", + "azure", + "urllib3", + "msal", + "PIL", +) + +ACCESS_KEY = "AKIAIOSFODNN7EXAMPLE" +SIGNATURE = "deadbeefcafe0123456789abcdef0123456789abcdef0123456789abcdef0123" +TOKEN = "FwoGZXIvYXdzEExAMPLESESSIONTOKEN" +SECRET = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" + + +class PreparedRequest: + # botocore reaches the logger with the request object as a %s argument, so + # the secret is not in the format string + def __repr__(self): + return ( + "" + ) + + +class LoggingStateMixin: + def setUp(self): + root = logging.getLogger() + saved = (root.level, list(root.handlers)) + levels = {name: logging.getLogger(name).level for name in THIRD_PARTY} + + def restore(): + root.setLevel(saved[0]) + root.handlers[:] = saved[1] + for name, level in levels.items(): + logging.getLogger(name).setLevel(level) + + self.addCleanup(restore) + + def level(self, name): + return logging.getLogger(name).getEffectiveLevel() + + +def module_loggers(path): + # `logger = logging.getLogger(...)` at module level + tree = ast.parse(path.read_text(encoding="utf-8")) + for node in tree.body: + if ( + isinstance(node, ast.Assign) + and any(isinstance(t, ast.Name) and t.id == "logger" for t in node.targets) + and isinstance(node.value, ast.Call) + and ast.unparse(node.value.func) == "logging.getLogger" + ): + yield node + + +class LoggerNameTests(unittest.TestCase): + def test_every_logger_of_the_package_is_named_after_its_module(self): + found = [] + for path in sorted(PACKAGE.rglob("*.py")): + for node in module_loggers(path): + if ast.unparse(node.value) != "logging.getLogger(__name__)": + found.append(f"{path.relative_to(ROOT).as_posix()}:{node.lineno}") + self.assertEqual(found, []) + + def test_a_module_logger_carries_the_dotted_path_of_its_module(self): + for name in ( + "cloudexit.platform.client", + "cloudexit.providers.aws.inventory", + "cloudexit.report.egress", + "cloudexit.catalogue.download", + "cloudexit.settings", + "cloudexit.engine", + ): + with self.subTest(name): + module = importlib.import_module(name) + self.assertEqual(module.logger.name, name) + + def test_the_run_log_names_the_module_that_wrote_each_line(self): + from cloudexit.platform import client + + with tempfile.TemporaryDirectory() as tmp: + app.add_run_log_handler(tmp) + try: + client.logger.warning("a line for the run log") + finally: + app.close_run_log_handler() + text = (Path(tmp) / "run.log").read_text(encoding="utf-8") + self.assertIn("cloudexit.platform.client - WARNING - a line", text) + self.assertNotIn("core.engine", text) + + +class ThirdPartyLevelTests(LoggingStateMixin, unittest.TestCase): + def test_msal_is_never_below_warning_because_it_logs_the_client_id(self): + for verbose in (0, 1, 2, 3): + with self.subTest(verbose=verbose): + app.configure_logging(verbose) + self.assertEqual(self.level("msal"), logging.WARNING) + self.assertEqual(self.level("msal.telemetry"), logging.WARNING) + + def test_urllib3_shows_connection_lines_only_at_the_deepest_verbosity(self): + for verbose, expected in ( + (0, logging.WARNING), + (1, logging.WARNING), + (2, logging.DEBUG), + ): + with self.subTest(verbose=verbose): + app.configure_logging(verbose) + self.assertEqual(self.level("urllib3"), expected) + + def test_the_other_libraries_keep_their_levels(self): + app.configure_logging(0) + for name in ("botocore", "boto3", "azure", "PIL"): + self.assertEqual(self.level(name), logging.WARNING, name) + app.configure_logging(2) + for name in ("botocore", "boto3", "azure"): + self.assertEqual(self.level(name), logging.DEBUG, name) + self.assertEqual(self.level("PIL"), logging.WARNING) + + def test_botocore_signing_and_credentials_are_never_below_warning(self): + for verbose in (0, 1, 2): + with self.subTest(verbose=verbose): + app.configure_logging(verbose) + self.assertEqual(self.level("botocore.auth"), logging.WARNING) + self.assertEqual(self.level("botocore.credentials"), logging.WARNING) + + def test_the_endpoint_logger_still_shows_requests_at_the_deepest_verbosity(self): + app.configure_logging(2) + self.assertEqual(self.level("botocore.endpoint"), logging.DEBUG) + + +class CredentialRedactionTests(LoggingStateMixin, unittest.TestCase): + def emit(self): + buffer = StringIO() + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + with patch.object(app, "console", Console(file=buffer, width=2000)): + app.configure_logging(2) + app.add_run_log_handler(tmp.name) + try: + endpoint = logging.getLogger("botocore.endpoint") + endpoint.debug("Sending http request: %s", PreparedRequest()) + endpoint.debug( + "Making request with headers Authorization: AWS4-HMAC-SHA256 " + f"Credential={ACCESS_KEY}/20261025/eu-west-1/ec2/aws4_request, " + f"SignedHeaders=host;x-amz-date, Signature={SIGNATURE}" + ) + logging.getLogger("botocore.auth").warning( + "CanonicalRequest:\nPOST\n/\n\nx-amz-security-token:%s\n", TOKEN + ) + logging.getLogger("botocore.credentials").warning( + "Found credentials: aws_secret_access_key=%s, " + "aws_session_token=%s", + SECRET, + TOKEN, + ) + finally: + app.close_run_log_handler() + run_log = (Path(tmp.name) / "run.log").read_text(encoding="utf-8") + # The console wraps long lines, so compare without whitespace + return "".join(buffer.getvalue().split()), run_log + + def test_no_credential_reaches_the_console_or_the_run_log(self): + console_text, run_log = self.emit() + for name, text in (("console", console_text), ("run.log", run_log)): + for secret in (ACCESS_KEY, SIGNATURE, TOKEN, SECRET): + with self.subTest(target=name, secret=secret[:8]): + self.assertNotIn(secret, text) + with self.subTest(target=name): + self.assertIn("REDACTED", text) + + def test_the_request_detail_around_the_secret_is_kept(self): + _, run_log = self.emit() + self.assertIn("ec2.eu-west-1.amazonaws.com", run_log) + self.assertIn("botocore.endpoint - DEBUG", run_log) + self.assertIn("'Authorization': REDACTED", run_log) + + def test_a_credential_scope_outside_a_header_keeps_its_shape(self): + # A presigned URL carries the key ID in the query string, where the + # scope that follows it is worth reading + cleaned = redact( + "https://s3.eu-west-1.amazonaws.com/bucket/key?" + f"X-Amz-Credential={ACCESS_KEY}/20261025/eu-west-1/s3/aws4_request" + f"&X-Amz-Signature={SIGNATURE}" + ) + self.assertIn("Credential=REDACTED/20261025/eu-west-1/s3/aws4_request", cleaned) + self.assertIn("X-Amz-Signature=REDACTED", cleaned) + self.assertNotIn(ACCESS_KEY, cleaned) + self.assertNotIn(SIGNATURE, cleaned) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_packaging.py b/tests/test_packaging.py new file mode 100644 index 0000000..5afd579 --- /dev/null +++ b/tests/test_packaging.py @@ -0,0 +1,300 @@ +import os +import re +import shutil +import subprocess +import sys +import tempfile +import unittest +import zipfile +from contextlib import contextmanager +from pathlib import Path +from unittest import mock + +from cloudexit import resources +from cloudexit.engine import generate_report, perform_risk_assessment +from cloudexit.report import fonts +from cloudexit.resources import asset_path, find_asset +from cloudexit.report.assets import copy_assets +from tests.support.isolation import isolated_environ +from tests.support.flow import build_flow_run +from tests.report.test_report_flow import AWS_RESOURCES, CONFIG, COSTS +from tests.support.paths import ROOT + +SOURCE_FILES = ("pyproject.toml", "README.md", "main.py") +SOURCE_DIRS = ("cloudexit",) + + +@contextmanager +def working_directory(path): + previous = os.getcwd() + os.chdir(path) + try: + yield + finally: + os.chdir(previous) + + +def asset_files(root): + return sorted( + path.relative_to(root).as_posix() + for path in Path(root).rglob("*") + if path.is_file() and not path.name.startswith(".") + ) + + +class WheelContentsTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + tmp = tempfile.TemporaryDirectory() + cls.addClassCleanup(tmp.cleanup) + # Build from a copy so no stale build directory in the checkout can + # hide a file that the package data does not pick up. + source = Path(tmp.name) / "source" + source.mkdir() + for name in SOURCE_FILES: + shutil.copy(ROOT / name, source / name) + for name in SOURCE_DIRS: + shutil.copytree( + ROOT / name, source / name, ignore=shutil.ignore_patterns("__pycache__") + ) + out = Path(tmp.name) / "wheel" + subprocess.run( + [sys.executable, "-m", "pip", "wheel", ".", "--no-deps", "-w", str(out)], + cwd=source, + check=True, + capture_output=True, + ) + (wheel,) = out.glob("*.whl") + cls.wheel = zipfile.ZipFile(wheel) + cls.addClassCleanup(cls.wheel.close) + cls.names = set(cls.wheel.namelist()) + + def test_the_wheel_contains_every_asset_file(self): + expected = {f"cloudexit/assets/{name}" for name in asset_files(asset_path())} + packaged = {n for n in self.names if n.startswith("cloudexit/assets/")} + self.assertEqual(sorted(expected - packaged), []) + self.assertEqual(sorted(packaged - expected), []) + self.assertGreater(len(expected), 1000) + + def test_the_files_a_report_needs_are_in_the_wheel(self): + for name in ( + "cloudexit/assets/fonts/IBMPlexSans-Regular.ttf", + "cloudexit/assets/fonts/IBMPlexMono-SemiBold.ttf", + "cloudexit/assets/fonts/OFL.txt", + "cloudexit/assets/template/index.html", + "cloudexit/assets/css/report.css", + "cloudexit/assets/css/driver.css", + "cloudexit/assets/js/driver.js.iife.js", + "cloudexit/assets/js/driver.js.LICENSE.txt", + "cloudexit/assets/img/csp/aws.svg", + "cloudexit/assets/img/csp/azure.svg", + "cloudexit/assets/img/csp/gcp.svg", + "cloudexit/assets/img/csp/alibaba.svg", + "cloudexit/assets/img/logo/report_logo.png", + "cloudexit/assets/img/logo/README.md", + "cloudexit/assets/icons/aws/Compute/EC2.png", + ): + with self.subTest(name): + self.assertIn(name, self.names) + + def test_the_wheel_ships_no_svg_icons_and_no_unused_assets(self): + # The four provider logos are the only SVG files; the service icons are PNG + svgs = [n for n in self.names if n.endswith(".svg")] + self.assertEqual( + [n for n in svgs if not n.startswith("cloudexit/assets/img/csp/")], [] + ) + self.assertEqual([n for n in svgs if "/icons/" in n], []) + self.assertEqual(len(svgs), 4) + self.assertNotIn("cloudexit/assets/template/egress.html", self.names) + self.assertNotIn("cloudexit/assets/css/style.css", self.names) + self.assertNotIn("cloudexit/assets/img/logo/favicon.ico", self.names) + self.assertNotIn("cloudexit/assets/fonts/IBMPlexSans-Medium.ttf", self.names) + + def test_the_wheel_has_no_loose_assets_directory(self): + self.assertFalse([n for n in self.names if n.startswith("assets/")]) + + def test_the_only_top_level_name_installed_is_cloudexit(self): + top = {n.split("/")[0] for n in self.names} + self.assertEqual( + {name for name in top if not name.endswith(".dist-info")}, {"cloudexit"} + ) + + def test_the_root_script_is_not_installed(self): + self.assertNotIn("main.py", self.names) + + def test_the_console_script_points_at_the_package(self): + (entry,) = [n for n in self.names if n.endswith("entry_points.txt")] + text = self.wheel.read(entry).decode("utf-8") + self.assertIn("cloudexit = cloudexit.cli:main", text) + + +class AssetLookupTests(unittest.TestCase): + def test_the_bundled_files_exist(self): + for parts in ( + ("template", "index.html"), + ("css", "report.css"), + ("js", "driver.js.iife.js"), + ("img", "csp", "azure.svg"), + ("fonts", "IBMPlexSans-Regular.ttf"), + ("img", "logo", "report_logo.png"), + ): + with self.subTest(parts=parts): + self.assertTrue(asset_path(*parts).is_file()) + + def test_the_assets_are_found_through_the_package_not_the_current_directory(self): + self.assertEqual(asset_path(), ROOT / "cloudexit" / "assets") + with tempfile.TemporaryDirectory() as empty, working_directory(empty): + self.assertTrue(asset_path("template", "index.html").is_file()) + self.assertTrue(asset_path("fonts", "IBMPlexSans-Regular.ttf").is_file()) + + def test_assets_that_are_not_on_the_file_system_are_a_clear_error(self): + with mock.patch.object(resources, "files", return_value=object()): + with self.assertRaises(RuntimeError) as ctx: + asset_path("fonts") + self.assertIn("not on the file system", str(ctx.exception)) + + def test_a_report_copy_wins_over_the_bundled_file(self): + with tempfile.TemporaryDirectory() as report: + copied = Path(report) / "assets" / "img" / "logo" / "report_logo.png" + copied.parent.mkdir(parents=True) + copied.write_bytes(b"copy") + + self.assertEqual(find_asset(report, "img/logo/report_logo.png"), copied) + self.assertEqual( + find_asset(report, "/icons/aws/Compute/EC2.png"), + asset_path("icons", "aws", "Compute", "EC2.png"), + ) + + def test_find_asset_refuses_paths_that_leave_the_assets(self): + with tempfile.TemporaryDirectory() as report: + for relative in ("", "/", "../cloudexit/engine.py", "icons/../../main.py"): + with self.subTest(relative=relative): + self.assertIsNone(find_asset(report, relative)) + self.assertIsNone(find_asset(report, "icons/aws/missing.png")) + + +class RunFromAnotherDirectoryTests(unittest.TestCase): + def setUp(self): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.tmp = Path(tmp.name) + patcher = mock.patch.dict( + os.environ, + isolated_environ(self.tmp), + ) + patcher.start() + self.addCleanup(patcher.stop) + self.elsewhere = self.tmp / "elsewhere" + self.elsewhere.mkdir() + + def test_the_html_and_pdf_reports_are_written_from_an_empty_directory(self): + report = build_flow_run( + self.tmp / "report", resources=AWS_RESOURCES, costs=COSTS + ) + with working_directory(self.elsewhere): + perform_risk_assessment(1, report) + result = generate_report( + 2, + CONFIG["providerDetails"], + 1, + 1, + "Flow", + report, + str(Path(report) / "raw_data"), + ) + + self.assertTrue(result["success"], result.get("logs")) + self.assertGreater((Path(report) / "index.html").stat().st_size, 1000) + self.assertGreater((Path(report) / "report.pdf").stat().st_size, 5000) + self.assertEqual(os.listdir(self.elsewhere), []) + + def test_the_report_folder_still_gets_its_own_copy_of_the_assets(self): + report = self.tmp / "report" + (report / "data").mkdir(parents=True) + (report / "data" / "assessment.db").write_bytes(b"") + with working_directory(self.elsewhere): + copy_assets(str(report), 2) + + assets = report / "assets" + self.assertTrue((assets / "css" / "report.css").is_file()) + self.assertTrue((assets / "fonts" / "IBMPlexSans-Regular.ttf").is_file()) + self.assertTrue((assets / "fonts" / "OFL.txt").is_file()) + self.assertTrue((assets / "js" / "driver.js.iife.js").is_file()) + self.assertTrue((assets / "img" / "logo" / "report_logo.png").is_file()) + self.assertTrue(list((assets / "icons" / "aws").rglob("*.png"))) + self.assertFalse((assets / "icons" / "azure").exists()) + self.assertEqual(os.listdir(self.elsewhere), []) + + +class MissingFontTests(unittest.TestCase): + def test_a_missing_font_names_the_file_and_the_incomplete_installation(self): + with tempfile.TemporaryDirectory() as empty: + with ( + mock.patch.object(fonts, "FONT_DIR", Path(empty)), + mock.patch.object( + fonts.pdfmetrics, "getRegisteredFontNames", return_value=[] + ), + ): + with self.assertRaises(fonts.MissingFontError) as ctx: + fonts.register_fonts() + + message = str(ctx.exception) + self.assertIn("IBMPlexSans-Regular.ttf", message) + self.assertIn("installation is incomplete", message) + self.assertIsInstance(ctx.exception, FileNotFoundError) + + +class NoCurrentDirectoryLookupTests(unittest.TestCase): + PATTERNS = ( + re.compile(r"(?:join|Path)\(\s*[\"']assets[\"']"), + re.compile(r"[\"']assets/"), + re.compile(r"[\"']\./assets"), + re.compile(r"parents\[\d\]\s*/\s*[\"']assets[\"']"), + ) + # Paths inside the report folder, or links inside the HTML report. + ALLOWED = ('"logo": f"assets/img/csp/',) + + def sources(self): + for name in SOURCE_DIRS: + for path in sorted((ROOT / name).rglob("*.py")): + yield path + yield ROOT / "main.py" + + def test_no_code_reads_assets_relative_to_the_current_directory(self): + found = [] + for path in self.sources(): + for number, line in enumerate( + path.read_text(encoding="utf-8").splitlines(), 1 + ): + if any(allowed in line for allowed in self.ALLOWED): + continue + if any(pattern.search(line) for pattern in self.PATTERNS): + found.append(f"{path.relative_to(ROOT)}:{number}: {line.strip()}") + self.assertEqual(found, []) + + def test_the_check_would_catch_the_old_lookups(self): + for line in ( + 'os.path.join("assets", "template", "index.html")', + 'Path("assets") / "fonts"', + 'src = "assets/css"', + 'Path(__file__).resolve().parents[2] / "assets" / "fonts"', + ): + with self.subTest(line=line): + self.assertTrue(any(p.search(line) for p in self.PATTERNS)) + + +class VersionFileTests(unittest.TestCase): + def test_the_release_workflow_edits_the_file_the_build_reads(self): + workflow = (ROOT / ".github" / "workflows" / "release.yml").read_text( + encoding="utf-8" + ) + pyproject = (ROOT / "pyproject.toml").read_text(encoding="utf-8") + self.assertIn('attr = "cloudexit.version.__version__"', pyproject) + paths = set(re.findall(r"[\w/]+/version\.py", workflow)) + self.assertEqual(paths, {"cloudexit/version.py"}) + text = (ROOT / "cloudexit" / "version.py").read_text(encoding="utf-8") + self.assertRegex(text, r'(?m)^__version__ = ".+"$') + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_pdf_sections.py b/tests/test_pdf_sections.py deleted file mode 100644 index 4c8bd2e..0000000 --- a/tests/test_pdf_sections.py +++ /dev/null @@ -1,448 +0,0 @@ -import tempfile -import unittest - -from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle -from reportlab.lib.colors import HexColor -from reportlab.platypus import Paragraph, PageBreak, Table, TableStyle - -from core.utils_report import ( - anonymize_string, - _default_table_style, - _build_summary_section, - _build_scope_section, - _build_cost_section, - _build_risk_section, - _build_scoring_section, - _build_resource_section, - _build_alt_tech_section, -) -from tests.report_fixtures import ( - build_report_fixture, - stage_report_assets, -) - - -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 AnonymizeStringTests(unittest.TestCase): - def test_anonymizes_middle_of_long_string(self): - result = anonymize_string("AKIAIOSFODNN7EXAMPLE") - self.assertTrue(result.startswith("AKIA")) - self.assertTrue(result.endswith("MPLE")) - self.assertIn("*", result) - self.assertEqual(len(result), len("AKIAIOSFODNN7EXAMPLE")) - - def test_short_string_fully_masked(self): - result = anonymize_string("abcd") - self.assertEqual(result, "****") - - def test_empty_string_returns_empty_mask(self): - result = anonymize_string("") - self.assertEqual(result, "") - - def test_non_string_returns_na(self): - self.assertEqual(anonymize_string(None), "N/A") - self.assertEqual(anonymize_string(12345), "N/A") - - def test_custom_num_visible(self): - result = anonymize_string("ABCDEFGHIJ", num_visible=2) - self.assertEqual(result, "AB******IJ") - - -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() - - def test_aws_scope_contains_access_key(self): - content = _build_scope_section( - self.fixture["metadata"], - self.fixture["provider_details"], - self.styles, - self.content_style, - ) - tables = [item for item in content if isinstance(item, Table)] - self.assertEqual(len(tables), 1) - - def test_azure_scope_contains_tenant_id(self): - metadata = {**self.fixture["metadata"], "cloud_service_provider": 1} - provider_details = { - "tenantId": "tenant-123", - "clientId": "client-456", - "clientSecret": "secret-789-very-long-value", - "subscriptionId": "sub-000", - "resourceGroupName": "rg-test", - } - 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) - - def test_unknown_provider_returns_na(self): - metadata = {**self.fixture["metadata"], "cloud_service_provider": 99} - content = _build_scope_section(metadata, {}, self.styles, self.content_style) - tables = [item for item in content if isinstance(item, Table)] - self.assertEqual(len(tables), 1) - - -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_is_unchanged(self): - rows = self._rows( - self.fixture["metadata"], self.fixture["provider_details"], None - ) - - self.assertEqual(list(rows), ["Access Key", "Secret Key", "Region"]) - self.assertEqual(rows["Region"], "eu-central-1") - - -class BuildCostSectionTests(unittest.TestCase): - def setUp(self): - self.styles, self.content_style = _make_styles() - self.fixture = build_report_fixture() - - def test_returns_list_ending_with_page_break(self): - content = _build_cost_section( - self.fixture["cost_data"], self.styles, self.content_style - ) - self.assertIsInstance(content, list) - self.assertIsInstance(content[-1], PageBreak) - - def test_contains_cost_table(self): - content = _build_cost_section( - self.fixture["cost_data"], self.styles, self.content_style - ) - tables = [item for item in content if isinstance(item, Table)] - self.assertGreaterEqual(len(tables), 1) - - -class BuildRiskSectionTests(unittest.TestCase): - def setUp(self): - self.styles, self.content_style = _make_styles() - self.fixture = build_report_fixture() - - def test_returns_list_ending_with_page_break(self): - with tempfile.TemporaryDirectory() as report_dir: - stage_report_assets(report_dir) - content = _build_risk_section( - self.fixture["risk_data"], - self.fixture["risk_definitions"], - self.fixture["resource_inventory"], - report_dir, - self.styles, - self.content_style, - ) - self.assertIsInstance(content[-1], PageBreak) - - def test_contains_risk_table(self): - with tempfile.TemporaryDirectory() as report_dir: - stage_report_assets(report_dir) - content = _build_risk_section( - self.fixture["risk_data"], - self.fixture["risk_definitions"], - self.fixture["resource_inventory"], - report_dir, - self.styles, - self.content_style, - ) - tables = [item for item in content if isinstance(item, Table)] - self.assertGreaterEqual(len(tables), 1) - - def test_empty_risk_data_produces_table_with_header_and_total_only(self): - with tempfile.TemporaryDirectory() as report_dir: - stage_report_assets(report_dir) - content = _build_risk_section( - [], - self.fixture["risk_definitions"], - self.fixture["resource_inventory"], - report_dir, - self.styles, - self.content_style, - ) - tables = [item for item in content if isinstance(item, Table)] - self.assertGreaterEqual(len(tables), 1) - - -class BuildResourceSectionTests(unittest.TestCase): - def setUp(self): - self.styles, self.content_style = _make_styles() - self.fixture = build_report_fixture() - - def test_returns_list_ending_with_page_break(self): - with tempfile.TemporaryDirectory() as report_dir: - content = _build_resource_section( - self.fixture["resource_inventory"], - self.fixture["resource_type_mapping"], - report_dir, - self.styles, - self.content_style, - ) - self.assertIsInstance(content[-1], PageBreak) - - def test_contains_resource_table(self): - with tempfile.TemporaryDirectory() as report_dir: - content = _build_resource_section( - self.fixture["resource_inventory"], - self.fixture["resource_type_mapping"], - report_dir, - self.styles, - self.content_style, - ) - tables = [item for item in content if isinstance(item, Table)] - self.assertEqual(len(tables), 1) - - def test_empty_inventory_produces_header_and_total_only(self): - with tempfile.TemporaryDirectory() as report_dir: - content = _build_resource_section( - [], - {}, - report_dir, - self.styles, - self.content_style, - ) - tables = [item for item in content if isinstance(item, Table)] - self.assertEqual(len(tables), 1) - - -class BuildAltTechSectionTests(unittest.TestCase): - def setUp(self): - self.styles, self.content_style = _make_styles() - self.fixture = build_report_fixture() - - def test_returns_list_ending_with_page_break(self): - with tempfile.TemporaryDirectory() as report_dir: - content = _build_alt_tech_section( - self.fixture["resource_inventory"], - self.fixture["resource_type_mapping"], - self.fixture["alternatives"], - self.fixture["alternative_technologies"], - self.fixture["exit_strategy"], - report_dir, - self.styles, - self.content_style, - ) - self.assertIsInstance(content[-1], PageBreak) - - def test_contains_alt_tech_table(self): - with tempfile.TemporaryDirectory() as report_dir: - content = _build_alt_tech_section( - self.fixture["resource_inventory"], - self.fixture["resource_type_mapping"], - self.fixture["alternatives"], - self.fixture["alternative_technologies"], - self.fixture["exit_strategy"], - report_dir, - self.styles, - self.content_style, - ) - tables = [item for item in content if isinstance(item, Table)] - self.assertGreaterEqual(len(tables), 1) - - -class BuildScoringSectionTests(unittest.TestCase): - def setUp(self): - self.styles, self.content_style = _make_styles() - - def test_with_scoring_data_returns_content(self): - scoring_data = { - "exit_score": 72, - "human_score": 4, - "technology_score": 3, - "operational_score": 2, - } - with tempfile.TemporaryDirectory() as report_dir: - content = _build_scoring_section( - scoring_data, report_dir, self.styles, self.content_style - ) - self.assertIsInstance(content, list) - self.assertGreater(len(content), 0) - self.assertIsInstance(content[-1], PageBreak) - - def test_with_none_scoring_data_uses_zero_defaults(self): - with tempfile.TemporaryDirectory() as report_dir: - content = _build_scoring_section( - None, report_dir, self.styles, self.content_style - ) - self.assertIsInstance(content, list) - self.assertGreater(len(content), 0) - - def test_contains_exit_score_and_vendor_lockin_tables(self): - scoring_data = { - "exit_score": 50, - "human_score": 3, - "technology_score": 4, - "operational_score": 2, - } - with tempfile.TemporaryDirectory() as report_dir: - content = _build_scoring_section( - scoring_data, report_dir, self.styles, self.content_style - ) - tables = [item for item in content if isinstance(item, Table)] - self.assertEqual(len(tables), 2) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_portability.py b/tests/test_portability.py new file mode 100644 index 0000000..966f8be --- /dev/null +++ b/tests/test_portability.py @@ -0,0 +1,201 @@ +import ast +import io +import logging +import os +import subprocess +import sys +import sqlite3 +import tempfile +import unittest +import unittest.mock +from pathlib import Path + +from cloudexit.cli import app +import tests +from cloudexit.catalogue import db as catalogue_db +from tests.support.isolation import isolated_environ +from tests.make_smoke_dataset import main as make_smoke_dataset +from tests.support.paths import ROOT + +SOURCES = [ + ROOT / "main.py", + *(ROOT / "cloudexit").rglob("*.py"), +] + + +def text_mode_calls(tree): + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + keywords = {k.arg: k.value for k in node.keywords} + if isinstance(node.func, ast.Name) and node.func.id == "open": + mode = node.args[1] if len(node.args) > 1 else keywords.get("mode") + binary = isinstance(mode, ast.Constant) and "b" in str(mode.value) + if not binary: + yield node, keywords + elif isinstance(node.func, ast.Attribute) and node.func.attr in ( + "read_text", + "write_text", + ): + yield node, keywords + + +class EncodingTests(unittest.TestCase): + def test_text_files_are_opened_with_an_explicit_encoding(self): + # The default encoding on Windows is not UTF-8 + missing = [] + for path in SOURCES: + tree = ast.parse(path.read_text(encoding="utf-8")) + for node, keywords in text_mode_calls(tree): + if "encoding" not in keywords: + missing.append(f"{path.relative_to(ROOT)}:{node.lineno}") + self.assertEqual(missing, []) + + def test_the_checker_sees_a_call_without_an_encoding(self): + tree = ast.parse('open("x", "w")\nopen("x", "rb")\np.read_text()\n') + found = [node.lineno for node, _ in text_mode_calls(tree)] + self.assertEqual(found, [1, 3]) + + +class IsolationTests(unittest.TestCase): + def test_the_tests_run_with_a_home_of_their_own(self): + for name in ("HOME", "USERPROFILE", "APPDATA", "LOCALAPPDATA"): + self.assertTrue(Path(os.environ[name]).is_relative_to(tests._home), name) + + +class OpenFileTests(unittest.TestCase): + def setUp(self): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.path = Path(tmp.name) / "run.db" + with catalogue_db.connect(self.path) as conn: + conn.execute("CREATE TABLE risk (id INTEGER, name TEXT)") + conn.execute("INSERT INTO risk VALUES (1, 'a')") + + def assert_closed(self, conn): + with self.assertRaises(sqlite3.ProgrammingError): + conn.execute("SELECT 1") + + def test_a_with_block_commits_and_closes_the_connection(self): + with catalogue_db.connect(self.path) as conn: + conn.execute("INSERT INTO risk VALUES (2, 'b')") + self.assert_closed(conn) + self.assertEqual(len(catalogue_db.load_data("risk", self.path)), 2) + + def test_a_failed_with_block_rolls_back_and_closes(self): + with self.assertRaises(RuntimeError): + with catalogue_db.connect(self.path) as conn: + conn.execute("INSERT INTO risk VALUES (3, 'c')") + raise RuntimeError("stop") + self.assert_closed(conn) + self.assertEqual(len(catalogue_db.load_data("risk", self.path)), 1) + + def test_the_helpers_close_their_connection_on_success_and_on_error(self): + opened = [] + real = catalogue_db.connect + + def spy(path=None): + conn = real(path) + opened.append(conn) + return conn + + catalogue_db.connect, original = spy, catalogue_db.connect + try: + catalogue_db.load_data("risk", self.path) + catalogue_db.fetch_one("SELECT * FROM risk", db_path=self.path) + catalogue_db.fetch_all("SELECT * FROM risk", db_path=self.path) + catalogue_db.execute_query("UPDATE risk SET name = 'z'", db_path=self.path) + with self.assertRaises(sqlite3.Error): + catalogue_db.fetch_all("SELECT * FROM nothing", db_path=self.path) + finally: + catalogue_db.connect = original + self.assertEqual(len(opened), 5) + for conn in opened: + self.assert_closed(conn) + + +class OutputEncodingTests(unittest.TestCase): + def test_a_stream_that_cannot_hold_the_arrows_is_switched_to_utf8(self): + raw = io.BytesIO() + stream = io.TextIOWrapper(raw, encoding="cp1252", write_through=True) + with unittest.mock.patch.object(sys, "stdout", stream): + app._use_utf8_output() + sys.stdout.write("\u21b3 done") + self.assertEqual(raw.getvalue().decode("utf-8"), "\u21b3 done") + + def test_a_stream_without_an_encoding_is_left_alone(self): + with unittest.mock.patch.object(sys, "stdout", io.StringIO()): + app._use_utf8_output() + + def test_a_run_into_a_cp1252_pipe_still_completes(self): + with tempfile.TemporaryDirectory() as tmp: + tmp = Path(tmp) + dataset = tmp / "smoke.db" + make_smoke_dataset(str(dataset)) + env = {**os.environ, **isolated_environ(tmp)} + env.update( + PYTHONIOENCODING="cp1252", + ESC_EXIT_STRATEGY="1", + ESC_ASSESSMENT_TYPE="1", + ) + state = ROOT / "tests" / "fixtures" / "smoke" / "aws.tfstate" + result = subprocess.run( + [ + sys.executable, + str(ROOT / "main.py"), + "aws", + "--tfstate", + str(state), + "--catalogue", + str(dataset), + "--non-interactive", + "--dry-run", + ], + cwd=tmp, + env=env, + stdin=subprocess.DEVNULL, + capture_output=True, + timeout=120, + ) + output = (result.stdout + result.stderr).decode("utf-8", "replace") + self.assertEqual(result.returncode, 0, output) + self.assertNotIn("UnicodeEncodeError", output) + + +class RunLogTests(unittest.TestCase): + def test_the_run_log_is_closed_when_the_run_ends(self): + with tempfile.TemporaryDirectory() as tmp: + app.add_run_log_handler(tmp) + handler = app._run_log_handler + self.assertIn(handler, logging.getLogger().handlers) + app.close_run_log_handler() + self.assertNotIn(handler, logging.getLogger().handlers) + self.assertIsNone(handler.stream) + self.assertIsNone(app._run_log_handler) + (Path(tmp) / "run.log").unlink() + + def test_a_second_run_log_replaces_the_first(self): + with ( + tempfile.TemporaryDirectory() as first, + tempfile.TemporaryDirectory() as second, + ): + app.add_run_log_handler(first) + opened = app._run_log_handler + app.add_run_log_handler(second) + self.assertIsNone(opened.stream) + self.assertNotIn(opened, logging.getLogger().handlers) + app.close_run_log_handler() + + +class LineEndingTests(unittest.TestCase): + def test_the_definition_copy_is_checked_out_with_unix_line_endings(self): + attributes = (ROOT / ".gitattributes").read_text(encoding="utf-8") + self.assertIn("docs/api-v2/** text eol=lf", attributes) + + def test_the_bundled_scripts_are_never_converted(self): + attributes = (ROOT / ".gitattributes").read_text(encoding="utf-8") + for pattern in ( + "cloudexit/assets/js/** -text", + "cloudexit/assets/css/driver.css -text", + ): + self.assertIn(pattern, attributes) diff --git a/tests/test_report_pipeline.py b/tests/test_report_pipeline.py deleted file mode 100644 index 415b1fa..0000000 --- a/tests/test_report_pipeline.py +++ /dev/null @@ -1,245 +0,0 @@ -import json -import tempfile -import unittest -from pathlib import Path - -from core.utils_report import ( - generate_html_report, - generate_json_report, - generate_pdf_report, -) -from core.utils_report_json import transform_cost_inventory_for_json -from tests.report_fixtures import ( - build_empty_report_fixture, - build_report_fixture, - stage_report_assets, -) - - -class ReportPipelineSmokeTests(unittest.TestCase): - def test_generate_html_report_creates_expected_output(self): - fixture = build_report_fixture() - - with tempfile.TemporaryDirectory() as report_dir: - html_path = generate_html_report( - report_dir, - fixture["metadata"], - fixture["resource_type_mapping"], - fixture["resource_inventory"], - fixture["cost_data"], - None, - fixture["risk_data"], - fixture["risk_definitions"], - fixture["alternatives"], - fixture["alternative_technologies"], - fixture["exit_strategy"], - fixture["alternative_technology_organizations"], - ) - - self.assertTrue(Path(html_path).exists()) - html = Path(html_path).read_text(encoding="utf-8") - - self.assertIn("Smoke Test Assessment", html) - self.assertIn("Amazon Web Services", html) - self.assertIn("OpenStack", html) - self.assertIn("OpenInfra Foundation", html) - self.assertIn("All Regions", html) - self.assertIn("data-org-region", html) - self.assertIn("EC2 Instance", html) - - def test_generate_html_report_renders_empty_state_output(self): - fixture = build_empty_report_fixture() - - with tempfile.TemporaryDirectory() as report_dir: - html_path = generate_html_report( - report_dir, - fixture["metadata"], - fixture["resource_type_mapping"], - fixture["resource_inventory"], - fixture["cost_data"], - None, - fixture["risk_data"], - fixture["risk_definitions"], - fixture["alternatives"], - fixture["alternative_technologies"], - fixture["exit_strategy"], - fixture["alternative_technology_organizations"], - ) - - self.assertTrue(Path(html_path).exists()) - html = Path(html_path).read_text(encoding="utf-8") - - self.assertIn("Empty State Assessment", html) - self.assertIn("No risk data available.", html) - self.assertIn("No cost data available.", html) - self.assertIn("No exit score data available.", html) - self.assertIn("No vendor lock-in score data available.", html) - self.assertIn("No resources were discovered during the assessment.", html) - self.assertIn("No alternative technologies are available", html) - self.assertNotIn('id="risksChart"', html) - self.assertNotIn('id="costsChart"', html) - self.assertNotIn('id="exitScoreChart"', html) - self.assertNotIn('id="vendorLockInScoreChart"', html) - - def test_generate_json_report_creates_expected_structure(self): - fixture = build_report_fixture() - - with tempfile.TemporaryDirectory() as tmp_dir: - raw_data_path = Path(tmp_dir) / "raw_data" - raw_data_path.mkdir() - - json_path = generate_json_report( - str(raw_data_path), - fixture["metadata"], - fixture["resource_type_mapping"], - fixture["resource_inventory"], - fixture["cost_data"], - None, - fixture["risk_data"], - fixture["risk_definitions"], - fixture["alternatives"], - fixture["alternative_technologies"], - fixture["exit_strategy"], - ) - - payload = json.loads(Path(json_path).read_text(encoding="utf-8")) - - self.assertEqual(payload["meta"]["name"], "Smoke Test Assessment") - self.assertEqual( - payload["data"]["resource_inventory"][0]["resource_name"], "EC2 Instance" - ) - self.assertEqual(payload["data"]["cost_inventory"][0]["month"], "2025-11-01") - self.assertEqual( - payload["data"]["alternative_technologies"]["1"][0]["product_name"], - "OpenStack", - ) - - def test_generate_pdf_report_creates_non_empty_file(self): - fixture = build_report_fixture() - - with tempfile.TemporaryDirectory() as report_dir: - stage_report_assets(report_dir) - - pdf_path = generate_pdf_report( - fixture["provider_details"], - report_dir, - fixture["metadata"], - fixture["resource_type_mapping"], - fixture["resource_inventory"], - fixture["cost_data"], - None, - fixture["risk_data"], - fixture["risk_definitions"], - fixture["alternatives"], - fixture["alternative_technologies"], - fixture["exit_strategy"], - ) - - pdf_file = Path(pdf_path) - - self.assertTrue(pdf_file.exists()) - self.assertGreater(pdf_file.stat().st_size, 0) - - -class EmptyCostInventoryReportTests(unittest.TestCase): - """tfstate mode skips Stage 4, so reports must render with resources but no costs.""" - - def test_html_report_renders_resources_without_cost_data(self): - fixture = build_report_fixture() - - with tempfile.TemporaryDirectory() as report_dir: - html_path = generate_html_report( - report_dir, - fixture["metadata"], - fixture["resource_type_mapping"], - fixture["resource_inventory"], - [], - None, - fixture["risk_data"], - fixture["risk_definitions"], - fixture["alternatives"], - fixture["alternative_technologies"], - fixture["exit_strategy"], - fixture["alternative_technology_organizations"], - ) - - self.assertTrue(Path(html_path).exists()) - html = Path(html_path).read_text(encoding="utf-8") - - self.assertIn("EC2 Instance", html) - self.assertIn("No cost data available.", html) - self.assertNotIn('id="costsChart"', html) - - def test_json_report_has_empty_cost_inventory(self): - fixture = build_report_fixture() - - with tempfile.TemporaryDirectory() as tmp_dir: - raw_data_path = Path(tmp_dir) / "raw_data" - raw_data_path.mkdir() - - json_path = generate_json_report( - str(raw_data_path), - fixture["metadata"], - fixture["resource_type_mapping"], - fixture["resource_inventory"], - [], - None, - fixture["risk_data"], - fixture["risk_definitions"], - fixture["alternatives"], - fixture["alternative_technologies"], - fixture["exit_strategy"], - ) - - payload = json.loads(Path(json_path).read_text(encoding="utf-8")) - - self.assertEqual(payload["data"]["cost_inventory"], []) - self.assertEqual( - payload["data"]["resource_inventory"][0]["resource_name"], "EC2 Instance" - ) - - def test_pdf_report_generates_without_cost_data(self): - fixture = build_report_fixture() - - with tempfile.TemporaryDirectory() as report_dir: - stage_report_assets(report_dir) - - pdf_path = generate_pdf_report( - fixture["provider_details"], - report_dir, - fixture["metadata"], - fixture["resource_type_mapping"], - fixture["resource_inventory"], - [], - None, - fixture["risk_data"], - fixture["risk_definitions"], - fixture["alternatives"], - fixture["alternative_technologies"], - fixture["exit_strategy"], - ) - - pdf_file = Path(pdf_path) - - self.assertTrue(pdf_file.exists()) - self.assertGreater(pdf_file.stat().st_size, 0) - - -class ReportTransformTests(unittest.TestCase): - def test_transform_cost_inventory_for_json_sorts_months(self): - unsorted_costs = [ - {"month": "2026-03-01", "cost": 9.0, "currency": "USD"}, - {"month": "2026-01-01", "cost": 14.75, "currency": "USD"}, - {"month": "2026-02-01", "cost": 11.25, "currency": "USD"}, - ] - - transformed = transform_cost_inventory_for_json(unsorted_costs) - - self.assertEqual( - [item["month"] for item in transformed], - ["2026-01-01", "2026-02-01", "2026-03-01"], - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_report_transforms.py b/tests/test_report_transforms.py deleted file mode 100644 index 0efb361..0000000 --- a/tests/test_report_transforms.py +++ /dev/null @@ -1,292 +0,0 @@ -import tempfile -import unittest - -from core.utils_report_html import ( - transform_alt_tech_for_html, - transform_cost_inventory_for_html, - transform_risk_inventory_for_html, -) -from core.utils_report_json import ( - transform_alt_tech_for_json, - transform_resource_inventory_for_json, - transform_risk_inventory_for_json, -) -from core.utils_report_pdf import ( - transform_alt_tech_for_pdf, - transform_cost_inventory_for_pdf, - transform_resource_inventory_for_pdf, - transform_risk_inventory_for_pdf, -) - - -def build_resource_type_mapping(): - return { - "101": { - "id": 101, - "code": "AWS.EC2.DescribeInstances.Reservations", - "name": "EC2 Instance", - "icon": "/icons/misc/no_image.png", - }, - "202": { - "id": 202, - "code": "AWS.S3.ListBuckets.Buckets", - "name": "S3 Bucket", - "icon": "/icons/misc/no_image.png", - }, - } - - -def build_resource_inventory(): - return [ - {"resource_type": 101, "location": "eu-central-1", "count": 2}, - {"resource_type": 202, "location": "eu-central-1", "count": 1}, - ] - - -def build_risk_definitions(): - return [ - { - "id": "1", - "name": "Limited Alternatives", - "description": "There are only a few alternatives available.", - "severity": "high", - }, - { - "id": "7", - "name": "Large Service Footprint", - "description": "The service footprint is broad.", - "severity": "medium", - }, - ] - - -def build_risk_data(): - return [ - {"resource_type": "101", "risk": "1"}, - {"resource_type": "202", "risk": "1"}, - {"resource_type": "null", "risk": "7"}, - ] - - -def build_alternatives(): - return [ - {"resource_type": 101, "strategy_type": 1, "alternative_technology": 1}, - {"resource_type": 101, "strategy_type": 3, "alternative_technology": 2}, - {"resource_type": 202, "strategy_type": 1, "alternative_technology": 2}, - ] - - -def build_alternative_technologies(): - return [ - { - "id": 1, - "product_name": "OpenStack", - "product_description": "Open source cloud platform.", - "product_url": "https://www.openstack.org/", - "open_source": "t", - "support_plan": "t", - "status": "t", - "organization_id": 10, - }, - { - "id": 2, - "product_name": "MinIO", - "product_description": "Object storage platform.", - "product_url": "https://min.io/", - "open_source": "t", - "support_plan": "f", - "status": "t", - "organization_id": 20, - }, - { - "id": 3, - "product_name": "Inactive Tech", - "product_description": "Should be ignored.", - "product_url": "https://example.com/", - "open_source": "t", - "support_plan": "t", - "status": "f", - }, - ] - - -def build_alternative_technology_organizations(): - return [ - { - "id": 10, - "name": "OpenInfra Foundation", - "url": "https://openinfra.org/", - "country_code": "US", - }, - { - "id": 20, - "name": "European Storage Collective", - "url": "https://example.eu/", - "country_code": "DE", - }, - ] - - -class HtmlTransformTests(unittest.TestCase): - def test_transform_cost_inventory_for_html_sorts_and_sums_costs(self): - months, cost_values, total_cost, currency_code, currency_symbol = ( - transform_cost_inventory_for_html( - [ - {"month": "2026-02-01", "cost": 11.25, "currency": "USD"}, - {"month": "2026-01-01", "cost": 14.75, "currency": "USD"}, - ] - ) - ) - - self.assertEqual(months, ["Jan", "Feb"]) - self.assertEqual(cost_values, [14.75, 11.25]) - self.assertEqual(total_cost, 26.0) - self.assertEqual(currency_code, "USD") - self.assertEqual(currency_symbol, "$") - - def test_transform_risk_inventory_for_html_counts_overall_and_resource_risks(self): - resource_inventory = { - "101": {"name": "EC2 Instance"}, - "202": {"name": "S3 Bucket"}, - } - - risks, severity_counts = transform_risk_inventory_for_html( - build_risk_data(), - build_risk_definitions(), - resource_inventory, - ) - - self.assertEqual([risk["severity"] for risk in risks], ["high", "medium"]) - self.assertEqual(risks[0]["impacted_resources_count"], 2) - self.assertCountEqual( - risks[0]["impacted_resources"], ["EC2 Instance", "S3 Bucket"] - ) - self.assertIsNone(risks[1]["impacted_resources_count"]) - self.assertEqual(severity_counts, {"high": 1, "medium": 1, "low": 0}) - - def test_transform_alt_tech_for_html_filters_by_strategy_and_status(self): - transformed = transform_alt_tech_for_html( - build_resource_inventory(), - build_alternatives(), - build_alternative_technologies(), - exit_strategy=1, - alternative_technology_organizations=build_alternative_technology_organizations(), - ) - - self.assertEqual(len(transformed), 2) - self.assertEqual(transformed[0]["product_name"], "OpenStack") - self.assertEqual(transformed[1]["product_name"], "MinIO") - self.assertTrue(transformed[0]["open_source"]) - self.assertFalse(transformed[1]["support_plan"]) - self.assertEqual(transformed[0]["organization_name"], "OpenInfra Foundation") - self.assertEqual(transformed[0]["organization_region"], "united-states") - self.assertEqual(transformed[1]["organization_region"], "european-union") - - -class JsonTransformTests(unittest.TestCase): - def test_transform_resource_inventory_for_json_maps_names_and_codes(self): - transformed = transform_resource_inventory_for_json( - build_resource_inventory(), - build_resource_type_mapping(), - ) - - self.assertEqual(transformed[0]["resource_name"], "EC2 Instance") - self.assertEqual( - transformed[1]["code"], - "AWS.S3.ListBuckets.Buckets", - ) - - def test_transform_risk_inventory_for_json_maps_impacted_resource_ids(self): - transformed = transform_risk_inventory_for_json( - build_risk_data(), - build_risk_definitions(), - build_resource_inventory(), - ) - - transformed_by_id = {item["id"]: item for item in transformed} - self.assertCountEqual(transformed_by_id["1"]["impacted_resources"], [1, 2]) - self.assertEqual(transformed_by_id["1"]["impacted_resources_count"], 2) - self.assertIsNone(transformed_by_id["7"]["impacted_resources_count"]) - - def test_transform_alt_tech_for_json_groups_by_resource_id(self): - transformed = transform_alt_tech_for_json( - build_resource_inventory(), - build_alternatives(), - build_alternative_technologies(), - exit_strategy=1, - ) - - self.assertEqual(list(transformed.keys()), [1, 2]) - self.assertEqual(transformed[1][0]["product_name"], "OpenStack") - self.assertEqual(transformed[2][0]["product_name"], "MinIO") - - -class PdfTransformTests(unittest.TestCase): - def test_transform_cost_inventory_for_pdf_limits_to_last_six_months(self): - months, costs, currency_symbol = transform_cost_inventory_for_pdf( - [ - {"month": "2025-10-01", "cost": 8.0, "currency": "USD"}, - {"month": "2025-11-01", "cost": 10.5, "currency": "USD"}, - {"month": "2025-12-01", "cost": 12.0, "currency": "USD"}, - {"month": "2026-01-01", "cost": 14.75, "currency": "USD"}, - {"month": "2026-02-01", "cost": 11.25, "currency": "USD"}, - {"month": "2026-03-01", "cost": 9.0, "currency": "USD"}, - {"month": "2026-04-01", "cost": 13.4, "currency": "USD"}, - ] - ) - - self.assertEqual(months, ["Nov", "Dec", "Jan", "Feb", "Mar", "Apr"]) - self.assertEqual(costs, [10.5, 12.0, 14.75, 11.25, 9.0, 13.4]) - self.assertEqual(currency_symbol, "$") - - def test_transform_risk_inventory_for_pdf_counts_resource_backed_risks(self): - risks, severity_counts = transform_risk_inventory_for_pdf( - build_risk_data(), - build_risk_definitions(), - build_resource_inventory(), - ) - - risks_by_name = {item["name"]: item for item in risks} - self.assertEqual( - risks_by_name["Limited Alternatives"]["impacted_resources_count"], 2 - ) - self.assertEqual( - risks_by_name["Large Service Footprint"]["impacted_resources_count"], 0 - ) - self.assertEqual(severity_counts, {"high": 1, "medium": 1, "low": 0}) - - def test_transform_resource_inventory_for_pdf_builds_report_relative_icon_paths( - self, - ): - with tempfile.TemporaryDirectory() as report_dir: - transformed = transform_resource_inventory_for_pdf( - build_resource_inventory(), - build_resource_type_mapping(), - report_dir, - ) - - self.assertEqual(transformed[0]["resource_name"], "EC2 Instance") - self.assertTrue( - transformed[0]["icon_url"].endswith("/assets/icons/misc/no_image.png") - ) - - def test_transform_alt_tech_for_pdf_counts_matching_alternatives(self): - with tempfile.TemporaryDirectory() as report_dir: - transformed = transform_alt_tech_for_pdf( - build_resource_inventory(), - build_resource_type_mapping(), - build_alternatives(), - build_alternative_technologies(), - exit_strategy=1, - report_path=report_dir, - ) - - self.assertEqual(transformed[0]["count"], 1) - self.assertEqual(transformed[1]["count"], 1) - self.assertTrue( - transformed[0]["icon_url"].endswith("/assets/icons/misc/no_image.png") - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_settings.py b/tests/test_settings.py new file mode 100644 index 0000000..4e4c41d --- /dev/null +++ b/tests/test_settings.py @@ -0,0 +1,674 @@ +import ast +import contextlib +import io +import os +import subprocess +import sys +import tempfile +import unittest +from argparse import Namespace +from pathlib import Path +from unittest.mock import patch + +from cloudexit.cli import app +from cloudexit.cli.parser import parse_arguments +from tests.support.isolation import isolated_environ +from cloudexit import codes +from cloudexit.settings import ( + DEFAULT_TIMEOUT, + Settings, + SettingsError, + config_file_path, + describe, + load_settings, + mask_key, + normalize_host, +) + +LOGGER = "cloudexit.settings" +KEY = "sk_live_0123456789abcdef" +LEGACY_HOST = "platform.test" +LEGACY_KEY = "fixture-key-0123456789" + + +class SettingsTestCase(unittest.TestCase): + def setUp(self): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.tmp = Path(tmp.name) + + @property + def config_path(self): + return self.tmp / "config" / "cloudexit" / "config.toml" + + def write_config(self, text, mode=0o600): + self.config_path.parent.mkdir(parents=True, exist_ok=True) + self.config_path.write_text(text, encoding="utf-8") + self.config_path.chmod(mode) + return self.config_path + + def write_key(self, name="api-key", key=KEY, mode=0o600): + path = self.tmp / name + path.write_text(f"{key}\n", encoding="utf-8") + path.chmod(mode) + return str(path) + + def load(self, flags=None, env=None): + environ = {**isolated_environ(self.tmp), **(env or {})} + return load_settings(flags, environ=environ) + + @contextlib.contextmanager + def directory_with_config_py(self): + # A real config.py next to the run, to prove it is never read + legacy = self.tmp / "legacy" + legacy.mkdir(exist_ok=True) + (legacy / "config.py").write_text( + f"HOST = 'old.test'\nKEY = '{LEGACY_KEY}'\n", encoding="utf-8" + ) + with contextlib.chdir(legacy): + yield legacy + + +class ConfigFilePathTests(unittest.TestCase): + @unittest.skipIf(os.name == "nt", "absolute /xdg paths are POSIX paths") + def test_uses_xdg_config_home_when_absolute(self): + path = config_file_path({"XDG_CONFIG_HOME": "/xdg"}, platform="linux") + self.assertEqual(path, Path("/xdg/cloudexit/config.toml")) + + def test_defaults_to_dot_config_in_the_home_directory(self): + expected = Path.home() / ".config" / "cloudexit" / "config.toml" + self.assertEqual(config_file_path({}, platform="linux"), expected) + + @unittest.skipIf(os.name == "nt", "absolute /xdg paths are POSIX paths") + def test_ignores_a_relative_xdg_config_home(self): + expected = Path.home() / ".config" / "cloudexit" / "config.toml" + path = config_file_path({"XDG_CONFIG_HOME": "relative"}, platform="linux") + self.assertEqual(path, expected) + + def test_uses_appdata_on_windows(self): + path = config_file_path({"APPDATA": "/appdata"}, platform="win32") + self.assertEqual(path, Path("/appdata/cloudexit/config.toml")) + + +class NormalizeHostTests(unittest.TestCase): + def test_adds_https_when_there_is_no_scheme(self): + self.assertEqual(normalize_host("example.com"), "https://example.com") + + def test_keeps_https_and_strips_the_trailing_slash(self): + self.assertEqual( + normalize_host(" https://example.com/ "), "https://example.com" + ) + + def test_rejects_plain_http_for_remote_hosts(self): + with self.assertRaises(SettingsError) as ctx: + normalize_host("http://example.com") + self.assertIn("https://", str(ctx.exception)) + + def test_accepts_plain_http_for_localhost(self): + self.assertEqual( + normalize_host("http://localhost:8000"), "http://localhost:8000" + ) + self.assertEqual(normalize_host("http://127.0.0.1"), "http://127.0.0.1") + + def test_rejects_other_schemes_and_empty_values(self): + for value in ("ftp://example.com", "", " ", "https://"): + with self.assertRaises(SettingsError, msg=value): + normalize_host(value) + + +class PrecedenceTests(SettingsTestCase): + def test_nothing_configured_is_all_unset(self): + settings = self.load() + + self.assertIsNone(settings.host.value) + self.assertIsNone(settings.api_key.value) + self.assertEqual(settings.timeout.value, DEFAULT_TIMEOUT) + self.assertEqual(settings.timeout.source, "default") + self.assertFalse(settings.config_found) + + def test_host_flag_over_env_over_file(self): + self.write_config('[default]\nhost = "file.test"\n') + env = {"CLOUDEXIT_HOST": "env.test"} + steps = [ + ( + self.load({"host": "flag.test"}, env), + "https://flag.test", + "flag --host", + ), + (self.load(None, env), "https://env.test", "env CLOUDEXIT_HOST"), + (self.load(), "https://file.test", "config file [default]"), + ] + + for settings, value, source in steps: + self.assertEqual(settings.host.value, value) + self.assertEqual(settings.host.source, source) + + def test_each_value_is_resolved_on_its_own(self): + ca_bundle = self.tmp / "ca.pem" + ca_bundle.write_text("pem") + self.write_config( + f"[default]\nassessment_id = 'from-file'\nca_bundle = '{ca_bundle}'\n" + ) + + settings = self.load( + {"host": "flag.test"}, + {"CLOUDEXIT_ASSESSMENT_ID": "from-env", "CLOUDEXIT_TIMEOUT": "30"}, + ) + + self.assertEqual(settings.host.source, "flag --host") + self.assertEqual(settings.assessment_id.value, "from-env") + self.assertEqual(settings.assessment_id.source, "env CLOUDEXIT_ASSESSMENT_ID") + self.assertEqual(settings.ca_bundle.value, str(ca_bundle)) + self.assertEqual(settings.ca_bundle.source, "config file [default]") + self.assertEqual(settings.timeout.value, 30.0) + self.assertEqual(settings.timeout.source, "env CLOUDEXIT_TIMEOUT") + + def test_timeout_flag_overrides_env(self): + settings = self.load({"timeout": 5.0}, {"CLOUDEXIT_TIMEOUT": "30"}) + self.assertEqual(settings.timeout.value, 5.0) + self.assertEqual(settings.timeout.source, "flag --timeout") + + def test_invalid_timeouts_are_rejected(self): + for value in ("soon", "0", "-3"): + with self.assertRaises(SettingsError, msg=value): + self.load(None, {"CLOUDEXIT_TIMEOUT": value}) + with self.assertRaises(SettingsError): + self.load({"timeout": 0.0}) + + def test_upload_is_a_mode_from_a_flag_env_or_file(self): + self.assertEqual(self.load(None).upload.value, "auto") + self.assertEqual(self.load(None).upload.source, "default") + self.assertEqual(self.load({"upload": "required"}).upload.value, "required") + self.assertEqual( + self.load(None, {"CLOUDEXIT_UPLOAD": "OFF"}).upload.value, "off" + ) + self.write_config('[default]\nupload = "required"\n') + settings = self.load() + self.assertEqual(settings.upload.value, "required") + self.assertEqual(settings.upload.source, "config file [default]") + self.assertEqual( + self.load({"upload": "off"}, {"CLOUDEXIT_UPLOAD": "required"}).upload.value, + "off", + ) + + def test_the_old_boolean_upload_values_still_map_to_a_mode(self): + self.assertEqual( + self.load(None, {"CLOUDEXIT_UPLOAD": "Yes"}).upload.value, "auto" + ) + self.assertEqual( + self.load(None, {"CLOUDEXIT_UPLOAD": "false"}).upload.value, "off" + ) + self.write_config("[default]\nupload = false\n") + self.assertEqual(self.load().upload.value, "off") + + def test_an_unknown_upload_mode_names_its_source(self): + with self.assertRaises(SettingsError) as ctx: + self.load(None, {"CLOUDEXIT_UPLOAD": "maybe"}) + self.assertIn("env CLOUDEXIT_UPLOAD", str(ctx.exception)) + self.assertIn("auto, required or off", str(ctx.exception)) + + def test_http_host_is_rejected_with_its_source(self): + with self.assertRaises(SettingsError) as ctx: + self.load(None, {"CLOUDEXIT_HOST": "http://example.com"}) + self.assertIn("env CLOUDEXIT_HOST", str(ctx.exception)) + + def test_local_http_host_is_accepted(self): + settings = self.load({"host": "http://localhost:8000"}) + self.assertEqual(settings.host.value, "http://localhost:8000") + + def test_a_host_without_scheme_gets_https(self): + self.assertEqual( + self.load({"host": "example.com"}).host.value, "https://example.com" + ) + + def test_a_lower_precedence_bad_host_is_not_validated(self): + self.write_config('[default]\nhost = "http://example.com"\n') + self.assertEqual( + self.load({"host": "flag.test"}).host.value, "https://flag.test" + ) + + def test_spool_dir_and_catalogue_url_come_from_the_environment(self): + settings = self.load( + None, + { + "CLOUDEXIT_SPOOL_DIR": str(self.tmp / "spool"), + "CLOUDEXIT_CATALOGUE_URL": "https://data.example.test/db.gz", + }, + ) + self.assertEqual(settings.spool_dir.value, str(self.tmp / "spool")) + self.assertEqual(settings.catalogue_url.source, "env CLOUDEXIT_CATALOGUE_URL") + + def test_malformed_config_file_is_a_clear_error(self): + self.write_config("[default\n") + with self.assertRaises(SettingsError) as ctx: + self.load() + self.assertIn(str(self.config_path), str(ctx.exception)) + + def test_wrong_value_types_in_the_file_are_rejected(self): + self.write_config("[default]\nhost = 5\n") + with self.assertRaises(SettingsError): + self.load() + + def test_unknown_keys_in_the_file_warn(self): + self.write_config('[default]\nhots = "typo.test"\n') + with self.assertLogs(LOGGER, level="WARNING") as logs: + self.load() + self.assertIn("hots", logs.output[0]) + + def test_missing_ca_bundle_is_rejected(self): + with self.assertRaises(SettingsError): + self.load({"ca_bundle": str(self.tmp / "missing.pem")}) + + +class ProfileTests(SettingsTestCase): + CONFIG = """ +[default] +host = "default.test" +assessment_id = "default-assessment" + +[profiles.staging] +host = "staging.test" +""" + + def test_profile_from_flag_overrides_default_values(self): + self.write_config(self.CONFIG) + settings = self.load({"profile": "staging"}) + + self.assertEqual(settings.host.value, "https://staging.test") + self.assertEqual(settings.host.source, "config file [profiles.staging]") + self.assertEqual(settings.profile.source, "flag --config-profile") + + def test_unset_profile_values_fall_back_to_default(self): + self.write_config(self.CONFIG) + settings = self.load(None, {"CLOUDEXIT_PROFILE": "staging"}) + + self.assertEqual(settings.assessment_id.value, "default-assessment") + self.assertEqual(settings.assessment_id.source, "config file [default]") + self.assertEqual(settings.profile.source, "env CLOUDEXIT_PROFILE") + + def test_without_a_profile_only_default_is_used(self): + self.write_config(self.CONFIG) + self.assertEqual(self.load().host.value, "https://default.test") + + def test_unknown_profile_is_an_error(self): + self.write_config(self.CONFIG) + with self.assertRaises(SettingsError) as ctx: + self.load({"profile": "prod"}) + self.assertIn("prod", str(ctx.exception)) + + def test_profile_without_a_config_file_is_an_error(self): + with self.assertRaises(SettingsError): + self.load({"profile": "staging"}) + + def test_flag_still_beats_the_profile(self): + self.write_config(self.CONFIG) + settings = self.load({"profile": "staging", "host": "flag.test"}) + self.assertEqual(settings.host.value, "https://flag.test") + + +class KeyFileTests(SettingsTestCase): + def test_key_is_read_from_the_flag_file(self): + settings = self.load({"api_key_file": self.write_key()}) + + self.assertEqual(settings.api_key.value, KEY) + self.assertEqual(settings.api_key.source, "flag --api-key-file") + self.assertEqual(settings.api_key_file.value, str(self.tmp / "api-key")) + + def test_env_key_file_and_env_key(self): + from_file = self.load(None, {"CLOUDEXIT_API_KEY_FILE": self.write_key()}) + self.assertEqual(from_file.api_key.value, KEY) + self.assertEqual(from_file.api_key.source, "env CLOUDEXIT_API_KEY_FILE") + + inline = self.load(None, {"CLOUDEXIT_API_KEY": "inline-key-123456"}) + self.assertEqual(inline.api_key.value, "inline-key-123456") + self.assertEqual(inline.api_key.source, "env CLOUDEXIT_API_KEY") + self.assertIsNone(inline.api_key_file.value) + + def test_key_file_wins_over_an_inline_key_in_the_same_layer(self): + env = { + "CLOUDEXIT_API_KEY_FILE": self.write_key(), + "CLOUDEXIT_API_KEY": "inline-key-123456", + } + self.assertEqual(self.load(None, env).api_key.value, KEY) + + def test_a_higher_layer_inline_key_beats_a_lower_layer_key_file(self): + self.write_config(f"[default]\napi_key_file = '{self.write_key()}'\n") + settings = self.load(None, {"CLOUDEXIT_API_KEY": "inline-key-123456"}) + self.assertEqual(settings.api_key.value, "inline-key-123456") + + def test_relative_key_file_in_the_config_is_relative_to_the_config(self): + self.write_key("keys-api", key="relative-key-123456") + self.write_config('[default]\napi_key_file = "../../keys-api"\n') + + settings = self.load() + + self.assertEqual(settings.api_key.value, "relative-key-123456") + self.assertEqual(settings.api_key.source, "config file [default]") + + def test_key_in_the_config_file(self): + self.write_config('[default]\napi_key = "file-key-0123456789"\n') + settings = self.load() + self.assertEqual(settings.api_key.value, "file-key-0123456789") + + def test_missing_and_empty_key_files_are_errors(self): + with self.assertRaises(SettingsError): + self.load({"api_key_file": str(self.tmp / "missing")}) + empty = self.tmp / "empty" + empty.write_text("\n") + with self.assertRaises(SettingsError): + self.load({"api_key_file": str(empty)}) + + def test_the_key_never_appears_in_a_repr(self): + settings = self.load({"api_key_file": self.write_key()}) + self.assertNotIn(KEY, repr(settings)) + + +@unittest.skipUnless(os.name == "posix", "POSIX permissions") +class PermissionWarningTests(SettingsTestCase): + def test_config_file_readable_by_others_warns(self): + path = self.write_config('[default]\nhost = "x.test"\n', mode=0o644) + with self.assertLogs(LOGGER, level="WARNING") as logs: + self.load() + self.assertIn(str(path), logs.output[0]) + self.assertIn("chmod 600", logs.output[0]) + + def test_private_config_file_does_not_warn(self): + self.write_config('[default]\nhost = "x.test"\n', mode=0o600) + with self.assertNoLogs(LOGGER, level="WARNING"): + self.load() + + def test_key_file_readable_by_group_warns(self): + path = self.write_key(mode=0o640) + with self.assertLogs(LOGGER, level="WARNING") as logs: + self.load({"api_key_file": path}) + self.assertIn(path, logs.output[0]) + self.assertNotIn(KEY, logs.output[0]) + + def test_private_key_file_does_not_warn(self): + with self.assertNoLogs(LOGGER, level="WARNING"): + self.load({"api_key_file": self.write_key(mode=0o600)}) + + +class LegacySourceTests(SettingsTestCase): + def test_host_and_key_env_vars_are_no_longer_read(self): + env = {"HOST": LEGACY_HOST, "KEY": LEGACY_KEY} + with self.assertLogs(LOGGER, level="WARNING") as logs: + settings = self.load(None, env) + + self.assertIsNone(settings.host.value) + self.assertIsNone(settings.api_key.value) + self.assertEqual(len(logs.output), 1) + self.assertIn("HOST and KEY are no longer read", logs.output[0]) + self.assertNotIn(LEGACY_HOST, logs.output[0]) + self.assertNotIn(LEGACY_KEY, logs.output[0]) + + def test_host_alone_or_key_alone_does_not_warn(self): + for env in ({"HOST": LEGACY_HOST}, {"KEY": LEGACY_KEY}): + with self.assertNoLogs(LOGGER, level="WARNING"): + settings = self.load(None, env) + self.assertIsNone(settings.host.value) + self.assertIsNone(settings.api_key.value) + + def test_cloudexit_env_vars_are_used_and_do_not_warn(self): + env = { + "HOST": LEGACY_HOST, + "KEY": LEGACY_KEY, + "CLOUDEXIT_HOST": "env.test", + "CLOUDEXIT_API_KEY": "inline-key-123456", + } + with self.assertNoLogs(LOGGER, level="WARNING"): + settings = self.load(None, env) + + self.assertEqual(settings.host.value, "https://env.test") + self.assertEqual(settings.host.source, "env CLOUDEXIT_HOST") + self.assertEqual(settings.api_key.source, "env CLOUDEXIT_API_KEY") + + def test_a_config_py_in_the_working_directory_is_no_longer_read(self): + with self.directory_with_config_py(): + with self.assertLogs(LOGGER, level="WARNING") as logs: + settings = self.load() + + self.assertIsNone(settings.host.value) + self.assertIsNone(settings.api_key.value) + self.assertEqual(len(logs.output), 1) + self.assertIn("config.py is no longer read", logs.output[0]) + self.assertNotIn(LEGACY_KEY, logs.output[0]) + + def test_a_host_from_another_source_silences_the_config_py_warning(self): + with self.directory_with_config_py(): + with self.assertNoLogs(LOGGER, level="WARNING"): + settings = self.load(None, {"CLOUDEXIT_HOST": "env.test"}) + self.assertEqual(settings.host.source, "env CLOUDEXIT_HOST") + + def test_both_legacy_sources_give_a_single_warning(self): + env = {"HOST": LEGACY_HOST, "KEY": LEGACY_KEY} + with self.directory_with_config_py(): + with self.assertLogs(LOGGER, level="WARNING") as logs: + self.load(None, env) + + self.assertEqual(len(logs.output), 1) + self.assertIn("HOST and KEY are no longer read", logs.output[0]) + self.assertIn("config.py is no longer read", logs.output[0]) + + def test_no_module_in_the_package_imports_config(self): + offenders = [] + for path in Path("cloudexit").rglob("*.py"): + tree = ast.parse(path.read_text(encoding="utf-8")) + for node in ast.walk(tree): + names = [] + if isinstance(node, ast.Import): + names = [alias.name for alias in node.names] + elif isinstance(node, ast.ImportFrom) and node.level == 0: + names = [node.module or ""] + if any(name.split(".")[0] == "config" for name in names): + offenders.append(str(path)) + self.assertEqual(offenders, []) + + +class LegacyConfigPySubprocessTests(unittest.TestCase): + # `python -m cloudexit` puts the current directory first on sys.path, so a + # config.py there used to be executed at start-up + def setUp(self): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.tmp = Path(tmp.name) + self.marker = self.tmp / "config-py-was-executed" + (self.tmp / "config.py").write_text( + "from pathlib import Path\n" + f"Path({str(self.marker)!r}).write_text('executed')\n" + "HOST = 'old.test'\n" + f"KEY = {LEGACY_KEY!r}\n", + encoding="utf-8", + ) + + def run_cloudexit(self, *args): + root = Path(__file__).resolve().parent.parent + env = {**os.environ, "PYTHONPATH": str(root)} + for name in ("HOST", "KEY", "CLOUDEXIT_HOST", "CLOUDEXIT_API_KEY"): + env.pop(name, None) + return subprocess.run( + [sys.executable, "-m", "cloudexit", *args], + cwd=self.tmp, + env=env, + capture_output=True, + text=True, + ) + + def test_version_does_not_execute_config_py(self): + result = self.run_cloudexit("--version") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertFalse(self.marker.exists()) + + def test_config_show_lists_no_legacy_source(self): + result = self.run_cloudexit("config", "show") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertFalse(self.marker.exists()) + out = result.stdout + result.stderr + self.assertNotIn("legacy", out) + self.assertNotIn("old.test", out) + self.assertNotIn(LEGACY_KEY, out) + + +class DescribeTests(SettingsTestCase): + def test_masks_the_key_to_prefix_and_last_four(self): + for prefix in ("esc_sk_", "exc_eu_sk_", "exc_us_sk_"): + with self.subTest(prefix=prefix): + self.assertEqual(mask_key(f"{prefix}AAAABBBBCCCC"), f"{prefix}...CCCC") + self.assertEqual(mask_key(KEY), "sk_l...cdef") + self.assertEqual(mask_key("short"), "****") + self.assertEqual(mask_key("esc_sk_abcd"), "****") + self.assertEqual(mask_key(None), "-") + + def test_output_lists_values_with_sources_and_never_the_full_key(self): + settings = self.load( + {"host": "flag.test", "api_key_file": self.write_key()}, + {"CLOUDEXIT_ASSESSMENT_ID": "a-1"}, + ) + text = "\n".join(describe(settings)) + + self.assertNotIn(KEY, text) + self.assertIn("sk_l...cdef", text) + self.assertIn("https://flag.test", text) + self.assertIn("flag --host", text) + self.assertIn("flag --api-key-file", text) + self.assertIn("env CLOUDEXIT_ASSESSMENT_ID", text) + self.assertIn("(not found)", text) + + def test_unset_values_are_shown_as_unset(self): + lines = describe(Settings()) + host_line = next(line for line in lines if line.startswith("host")) + self.assertIn("-", host_line) + self.assertIn("unset", host_line) + + +class ConfigCommandTests(SettingsTestCase): + def run_main(self, argv, env=None): + environ = {**isolated_environ(self.tmp), **(env or {})} + out = io.StringIO() + with ( + patch("sys.argv", ["cloudexit", *argv]), + patch.dict(os.environ, environ, clear=True), + patch("cloudexit.cli.app.prepare_catalogue") as mock_dataset, + patch("cloudexit.cli.app.console.print") as mock_print, + contextlib.redirect_stdout(out), + ): + try: + app.main() + code = None + except SystemExit as exc: + code = exc.code + return out.getvalue(), code, mock_dataset, mock_print + + def test_config_path_prints_the_location_without_banner_or_dataset(self): + out, code, mock_dataset, mock_print = self.run_main(["config", "path"]) + + self.assertIsNone(code) + self.assertEqual(out.strip(), str(self.config_path)) + mock_dataset.assert_not_called() + mock_print.assert_not_called() + + def test_config_show_masks_keys(self): + out, code, mock_dataset, _ = self.run_main( + ["config", "show", "--host", "flag.test"], + {"CLOUDEXIT_API_KEY": KEY}, + ) + + self.assertIsNone(code) + self.assertNotIn(KEY, out) + self.assertIn("sk_l...cdef", out) + self.assertIn("https://flag.test", out) + self.assertIn("flag --host", out) + mock_dataset.assert_not_called() + + def test_config_show_with_a_bad_host_exits_with_the_config_code(self): + _, code, _, mock_print = self.run_main( + ["config", "show"], {"CLOUDEXIT_HOST": "http://example.com"} + ) + + self.assertEqual(code, codes.CONFIG) + self.assertIn("https://", mock_print.call_args.args[0]) + + def test_config_requires_a_subcommand(self): + with ( + patch("sys.argv", ["cloudexit", "config"]), + contextlib.redirect_stderr(io.StringIO()), + self.assertRaises(SystemExit), + ): + parse_arguments() + + +class ArgumentTests(unittest.TestCase): + def parse(self, *argv): + with patch("sys.argv", ["cloudexit", *argv]): + return parse_arguments() + + def test_platform_flags_on_aws_and_azure(self): + for provider in ("aws", "azure"): + args = self.parse( + provider, + "--host", + "platform.test", + "--api-key-file", + "/k", + "--config-profile", + "staging", + "--assessment-id", + "a-1", + "--ca-bundle", + "/ca.pem", + "--timeout", + "7.5", + ) + self.assertEqual(args.host, "platform.test") + self.assertEqual(args.api_key_file, "/k") + self.assertEqual(args.config_profile, "staging") + self.assertEqual(args.assessment_id, "a-1") + self.assertEqual(args.ca_bundle, "/ca.pem") + self.assertEqual(args.timeout, 7.5) + + def test_aws_profile_still_means_the_aws_profile(self): + args = self.parse("aws", "--profile", "my-aws") + self.assertEqual(args.profile, "my-aws") + self.assertIsNone(args.config_profile) + + def test_main_resolves_settings_before_the_dataset_download(self): + args = Namespace(cloud_provider="aws") + with ( + patch("cloudexit.cli.app.parse_arguments", return_value=args), + patch( + "cloudexit.cli.app.load_settings", return_value=Settings() + ) as mock_load, + patch("cloudexit.cli.app.prepare_catalogue") as mock_dataset, + patch("cloudexit.cli.app.handle_aws") as mock_handle, + patch("cloudexit.cli.app.console.print"), + ): + app.main() + + mock_load.assert_called_once() + self.assertIsInstance(args.settings, Settings) + mock_dataset.assert_called_once() + mock_handle.assert_called_once_with(args) + + def test_a_bad_setting_fails_before_the_dataset_download(self): + with ( + patch( + "cloudexit.cli.app.parse_arguments", + return_value=Namespace(cloud_provider="aws"), + ), + patch( + "cloudexit.cli.app.load_settings", side_effect=SettingsError("bad host") + ), + patch("cloudexit.cli.app.prepare_catalogue") as mock_dataset, + patch("cloudexit.cli.app.handle_aws") as mock_handle, + patch("cloudexit.cli.app.console.print"), + self.assertRaises(SystemExit) as ctx, + ): + app.main() + + self.assertEqual(ctx.exception.code, codes.CONFIG) + mock_dataset.assert_not_called() + mock_handle.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_tfstate.py b/tests/test_tfstate.py new file mode 100644 index 0000000..4b1997e --- /dev/null +++ b/tests/test_tfstate.py @@ -0,0 +1,890 @@ +import json +import os +import sqlite3 +import tempfile +import unittest +from pathlib import Path + +from cloudexit.tfstate import ( + build_tfstate_resource_inventory, + extract_managed_resources, + extract_state_scope, + file_sha256, + parse_tfstate, +) + +# Schema subset the tfstate builder touches, mirroring datasets/data.db. +SCHEMA = """ +CREATE TABLE resourcetype ( + id INTEGER PRIMARY KEY, + csp INTEGER NOT NULL, + code TEXT NOT NULL, + name TEXT NOT NULL, + icon TEXT NOT NULL, + status TEXT CHECK(status IN ('t','f')) NOT NULL, + tf_code TEXT +); +CREATE TABLE resource_inventory ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + resource_type INTEGER NOT NULL, + location TEXT NOT NULL, + count INTEGER NOT NULL, + UNIQUE(resource_type, location) +); +""" + + +def build_state(resources, **overrides): + state = { + "version": 4, + "terraform_version": "1.14.6", + "serial": 7, + "lineage": "e1c2f0c0-0000-0000-0000-000000000000", + "resources": resources, + } + state.update(overrides) + return state + + +def managed(resource_type, name, instances, module=None): + entry = { + "mode": "managed", + "type": resource_type, + "name": name, + "provider": f'provider["registry.terraform.io/hashicorp/{resource_type.split("_")[0]}"]', + "instances": instances, + } + if module: + entry["module"] = module + return entry + + +def instance(attributes=None, index_key=None): + entry = {"schema_version": 0, "attributes": attributes or {}} + if index_key is not None: + entry["index_key"] = index_key + return entry + + +def write_state(directory, state, filename="infra.tfstate"): + path = Path(directory) / filename + path.write_text(json.dumps(state), encoding="utf-8") + return str(path) + + +def seed_db(db_path, rows): + """rows: (id, csp, code, name, status, tf_code)""" + conn = sqlite3.connect(db_path) + conn.executescript(SCHEMA) + conn.executemany( + "INSERT INTO resourcetype (id, csp, code, name, icon, status, tf_code) " + "VALUES (?, ?, ?, ?, '/icons/aws/Compute/EC2.png', ?, ?)", + rows, + ) + conn.commit() + conn.close() + + +class ParseTfstateTests(unittest.TestCase): + def test_rejects_missing_file(self): + with self.assertRaisesRegex(ValueError, "Could not read Terraform state file"): + parse_tfstate("/tmp/does-not-exist.tfstate") + + def test_rejects_invalid_json(self): + with tempfile.TemporaryDirectory() as tmp_dir: + path = Path(tmp_dir) / "broken.tfstate" + path.write_text("{not json", encoding="utf-8") + + with self.assertRaisesRegex(ValueError, "not valid JSON"): + parse_tfstate(str(path)) + + def test_rejects_legacy_state_version(self): + with tempfile.TemporaryDirectory() as tmp_dir: + path = write_state(tmp_dir, build_state([], version=3)) + + with self.assertRaisesRegex(ValueError, "version 4"): + parse_tfstate(path) + + def test_rejects_non_object_document(self): + with tempfile.TemporaryDirectory() as tmp_dir: + path = Path(tmp_dir) / "list.tfstate" + path.write_text("[]", encoding="utf-8") + + with self.assertRaisesRegex(ValueError, "not a state document"): + parse_tfstate(str(path)) + + def test_accepts_minimal_valid_state(self): + with tempfile.TemporaryDirectory() as tmp_dir: + path = write_state( + tmp_dir, + build_state( + [ + managed( + "aws_s3_bucket", "this", [instance({"region": "eu-west-1"})] + ) + ] + ), + ) + + state = parse_tfstate(path) + + self.assertEqual(state["version"], 4) + self.assertEqual(state["terraform_version"], "1.14.6") + self.assertEqual(len(state["resources"]), 1) + + +class ExtractManagedResourcesTests(unittest.TestCase): + def test_excludes_data_sources(self): + state = build_state( + [ + { + "mode": "data", + "type": "aws_caller_identity", + "name": "current", + "instances": [instance({"region": "eu-west-1"})], + }, + managed("aws_s3_bucket", "this", [instance({"region": "eu-west-1"})]), + ] + ) + + records = extract_managed_resources(state) + + self.assertEqual([r["type"] for r in records], ["aws_s3_bucket"]) + + def test_one_record_per_instance(self): + state = build_state( + [ + managed( + "aws_dynamodb_table", + "this", + [ + instance({"region": "eu-central-1"}, index_key="alpha"), + instance({"region": "eu-central-1"}, index_key="beta"), + instance({"region": "eu-central-1"}, index_key="gamma"), + ], + ) + ] + ) + + records = extract_managed_resources(state) + + self.assertEqual(len(records), 3) + self.assertEqual({r["location"] for r in records}, {"eu-central-1"}) + + def test_extracts_and_lowercases_aws_region(self): + state = build_state( + [managed("aws_s3_bucket", "this", [instance({"region": " EU-West-1 "})])] + ) + + records = extract_managed_resources(state) + + self.assertEqual(records[0]["location"], "eu-west-1") + + def test_extracts_and_lowercases_azure_location(self): + state = build_state( + [ + managed( + "azurerm_managed_disk", + "this", + [instance({"location": "NorthEurope"})], + ) + ] + ) + + records = extract_managed_resources(state) + + self.assertEqual(records[0]["location"], "northeurope") + + def test_falls_back_to_unknown_location(self): + state = build_state( + [ + managed("aws_iam_role", "this", [instance({"name": "role"})]), + managed("aws_iam_policy", "this", [instance({"region": " "})]), + managed("aws_iam_user", "this", [instance()]), + ] + ) + + records = extract_managed_resources(state) + + self.assertEqual([r["location"] for r in records], ["unknown"] * 3) + + def test_falls_back_to_region_from_own_arn(self): + state = build_state( + [ + managed( + "aws_dynamodb_table", + "this", + [ + instance( + { + "arn": "arn:aws:dynamodb:EU-Central-1:266579820564:table/orders" + } + ) + ], + ) + ] + ) + + records = extract_managed_resources(state) + + self.assertEqual(records[0]["location"], "eu-central-1") + + def test_region_attribute_wins_over_arn(self): + state = build_state( + [ + managed( + "aws_efs_file_system", + "this", + [ + instance( + { + "region": "eu-west-1", + "arn": "arn:aws:elasticfilesystem:us-east-1:266579820564:file-system/fs-1", + } + ) + ], + ) + ] + ) + + records = extract_managed_resources(state) + + self.assertEqual(records[0]["location"], "eu-west-1") + + def test_ignores_arns_of_referenced_resources(self): + # stream_arn / kms_key_arn point at other resources, which may live in a + # different region or account; only the resource's own arn may be read. + state = build_state( + [ + managed( + "aws_dynamodb_table", + "this", + [ + instance( + { + "stream_arn": "arn:aws:dynamodb:us-east-1:999999999999:table/x/stream/2024", + "kms_key_arn": "arn:aws:kms:ap-south-1:999999999999:key/abc", + "restore_source_table_arn": "arn:aws:dynamodb:sa-east-1:999999999999:table/y", + } + ) + ], + ) + ] + ) + + records = extract_managed_resources(state) + + self.assertEqual(records[0]["location"], "unknown") + + def test_global_and_malformed_arns_fall_back_to_unknown(self): + state = build_state( + [ + # Global services write an empty region field by design. + managed( + "aws_s3_bucket", + "this", + [instance({"arn": "arn:aws:s3:::my-bucket"})], + ), + managed( + "aws_iam_role", + "this", + [instance({"arn": "arn:aws:iam::266579820564:role/admin"})], + ), + managed( + "aws_vpc", "trunc", [instance({"arn": "arn:aws:ec2:eu-west-1"})] + ), + managed("aws_vpc", "nonsense", [instance({"arn": "not-an-arn"})]), + managed("aws_vpc", "wrongtype", [instance({"arn": 42})]), + ] + ) + + records = extract_managed_resources(state) + + self.assertEqual([r["location"] for r in records], ["unknown"] * 5) + + def test_arn_account_id_is_never_extracted(self): + state = build_state( + [ + managed( + "aws_dynamodb_table", + "this", + [ + instance( + { + "arn": "arn:aws:dynamodb:eu-central-1:266579820564:table/t" + } + ) + ], + ) + ] + ) + + records = extract_managed_resources(state) + + self.assertEqual(records[0]["location"], "eu-central-1") + self.assertNotIn("266579820564", json.dumps(records)) + + def test_address_includes_module_and_index_key(self): + state = build_state( + [ + managed( + "aws_s3_bucket", + "this", + [instance({"region": "eu-west-1"}, index_key=3)], + module="module.storage", + ), + managed("aws_vpc", "main", [instance({"region": "eu-west-1"})]), + ] + ) + + records = extract_managed_resources(state) + + self.assertEqual(records[0]["address"], "module.storage.aws_s3_bucket.this[3]") + self.assertEqual(records[1]["address"], "aws_vpc.main") + + def test_never_copies_attributes(self): + state = build_state( + [ + managed( + "azurerm_storage_account", + "this", + [ + instance( + { + "location": "westeurope", + "primary_connection_string": "SUPER_SECRET_VALUE", + } + ) + ], + ) + ] + ) + + records = extract_managed_resources(state) + + self.assertEqual(set(records[0].keys()), {"address", "type", "location"}) + self.assertNotIn("SUPER_SECRET_VALUE", json.dumps(records)) + + +class StateScopeTests(unittest.TestCase): + def test_sha256_matches_the_file_contents(self): + import hashlib + + with tempfile.TemporaryDirectory() as tmp_dir: + path = write_state(tmp_dir, build_state([])) + expected = hashlib.sha256(Path(path).read_bytes()).hexdigest() + + self.assertEqual(file_sha256(path), expected) + + def test_sha256_returns_none_for_unreadable_file(self): + self.assertIsNone(file_sha256("/tmp/does-not-exist.tfstate")) + + def test_extracts_azure_subscription_and_resource_groups(self): + state = build_state( + [ + managed( + "azurerm_storage_account", + "this", + [ + instance( + { + "id": "/subscriptions/sub-a/resourceGroups/rg-1/providers/Microsoft.Storage/storageAccounts/x" + } + ), + instance( + { + "id": "/subscriptions/sub-a/resourceGroups/rg-2/providers/Microsoft.Storage/storageAccounts/y" + } + ), + ], + ) + ] + ) + + scope = extract_state_scope(state, 1) + + self.assertEqual(scope["subscriptions"], ["sub-a"]) + self.assertEqual(scope["resource_groups"], ["rg-1", "rg-2"]) + + def test_handles_multiple_subscriptions(self): + state = build_state( + [ + managed( + "azurerm_managed_disk", + "this", + [ + instance( + { + "id": "/subscriptions/sub-b/resourceGroups/rg-1/providers/x" + } + ), + instance( + { + "id": "/subscriptions/sub-a/resourceGroups/rg-1/providers/x" + } + ), + ], + ) + ] + ) + + scope = extract_state_scope(state, 1) + + self.assertEqual(scope["subscriptions"], ["sub-a", "sub-b"]) + + def test_ignores_data_sources_and_unparsable_ids(self): + state = build_state( + [ + { + "mode": "data", + "type": "azurerm_resource_group", + "name": "this", + "instances": [ + instance( + { + "id": "/subscriptions/data-sub/resourceGroups/rg-x/providers/y" + } + ) + ], + }, + managed("azurerm_managed_disk", "a", [instance({"id": "not-an-id"})]), + managed("azurerm_managed_disk", "b", [instance({"id": 42})]), + managed("azurerm_managed_disk", "c", [instance()]), + ] + ) + + scope = extract_state_scope(state, 1) + + self.assertEqual(scope["subscriptions"], []) + self.assertEqual(scope["resource_groups"], []) + + def test_aws_state_yields_no_subscription_or_resource_group(self): + state = build_state( + [ + managed( + "aws_s3_bucket", + "this", + [instance({"region": "eu-west-1", "id": "my-bucket"})], + ) + ] + ) + + scope = extract_state_scope(state, 2) + + self.assertEqual(scope, {"subscriptions": [], "resource_groups": []}) + + +class BuildTfstateResourceInventoryTests(unittest.TestCase): + AWS_ROWS = [ + (444, 2, "AWS.s3.list_buckets.Buckets", "S3 Bucket", "t", "aws_s3_bucket"), + ( + 300, + 2, + "AWS.dynamodb.list_tables.TableNames", + "DynamoDB Table", + "t", + "aws_dynamodb_table", + ), + ( + 500, + 2, + "AWS.ec2.describe_instances.Reservations", + "EC2 Instance", + "f", + "aws_instance", + ), + ( + 600, + 1, + "Microsoft.Compute/virtualMachines", + "Virtual Machine", + "t", + "azurerm_linux_virtual_machine", + ), + ] + + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.addCleanup(self._tmp.cleanup) + self.report_path = self._tmp.name + self.raw_data_path = os.path.join(self.report_path, "raw_data") + os.makedirs(os.path.join(self.report_path, "data")) + os.makedirs(self.raw_data_path) + self.db_path = os.path.join(self.report_path, "data", "assessment.db") + + def _inventory(self): + conn = sqlite3.connect(self.db_path) + rows = conn.execute( + "SELECT resource_type, location, count FROM resource_inventory " + "ORDER BY resource_type, location" + ).fetchall() + conn.close() + return rows + + def _manifest(self): + path = Path(self.raw_data_path) / "tfstate_manifest.json" + return json.loads(path.read_text(encoding="utf-8")), path.read_text( + encoding="utf-8" + ) + + def _build(self, state, csp=2, rows=None, filename="infra.tfstate"): + seed_db(self.db_path, self.AWS_ROWS if rows is None else rows) + state_path = write_state(self._tmp.name, state, filename=filename) + build_tfstate_resource_inventory( + csp, + {"tfstatePath": state_path}, + self.report_path, + self.raw_data_path, + ) + return state_path + + def _build_returning(self, state, csp=2, rows=None): + seed_db(self.db_path, self.AWS_ROWS if rows is None else rows) + state_path = write_state(self._tmp.name, state) + return build_tfstate_resource_inventory( + csp, + {"tfstatePath": state_path}, + self.report_path, + self.raw_data_path, + ) + + def test_arn_derived_regions_aggregate_and_stay_out_of_the_manifest(self): + state = build_state( + [ + managed( + "aws_dynamodb_table", + "tables", + [ + instance( + { + "arn": "arn:aws:dynamodb:eu-central-1:266579820564:table/a" + }, + index_key="a", + ), + instance( + { + "arn": "arn:aws:dynamodb:eu-central-1:266579820564:table/b" + }, + index_key="b", + ), + instance( + {"arn": "arn:aws:dynamodb:us-east-1:266579820564:table/c"}, + index_key="c", + ), + ], + ) + ] + ) + + self._build(state) + + self.assertEqual( + self._inventory(), + [(300, "eu-central-1", 2), (300, "us-east-1", 1)], + ) + + _, serialized = self._manifest() + self.assertNotIn("266579820564", serialized) + self.assertNotIn("arn:aws", serialized) + + def test_aggregates_matched_types_per_location(self): + state = build_state( + [ + managed( + "aws_s3_bucket", + "this", + [ + instance({"region": "eu-west-1"}, index_key=0), + instance({"region": "eu-west-1"}, index_key=1), + instance({"region": "us-east-1"}, index_key=2), + ], + ), + managed( + "aws_dynamodb_table", + "tables", + [instance({"region": "eu-west-1"})], + ), + ] + ) + + self._build(state) + + self.assertEqual( + self._inventory(), + [ + (300, "eu-west-1", 1), + (444, "eu-west-1", 2), + (444, "us-east-1", 1), + ], + ) + + def test_unmapped_types_are_skipped_and_recorded_in_manifest(self): + state = build_state( + [ + managed("aws_s3_bucket", "this", [instance({"region": "eu-west-1"})]), + managed( + "aws_s3_bucket_versioning", + "this", + [ + instance({"region": "eu-west-1"}, index_key=0), + instance({"region": "eu-west-1"}, index_key=1), + ], + ), + ] + ) + + self._build(state) + + self.assertEqual(self._inventory(), [(444, "eu-west-1", 1)]) + + manifest, _ = self._manifest() + self.assertEqual(manifest["unmapped_types"], {"aws_s3_bucket_versioning": 2}) + self.assertEqual( + [entry["type"] for entry in manifest["counted"]], ["aws_s3_bucket"] + ) + + def test_duplicate_tf_code_resolves_to_lowest_id(self): + rows = [ + (444, 2, "AWS.s3.list_buckets.Buckets", "S3 Bucket", "t", "aws_s3_bucket"), + ( + 293, + 2, + "AWS.glacier.list_vaults.VaultList", + "Glacier Vault", + "t", + "aws_s3_bucket", + ), + ] + state = build_state( + [managed("aws_s3_bucket", "this", [instance({"region": "eu-west-1"})])] + ) + + self._build(state, rows=rows) + + self.assertEqual(self._inventory(), [(293, "eu-west-1", 1)]) + + def test_ignores_disabled_rows_and_other_csp_rows(self): + state = build_state( + [ + managed("aws_instance", "this", [instance({"region": "eu-west-1"})]), + managed( + "azurerm_linux_virtual_machine", + "this", + [instance({"location": "westeurope"})], + ), + ] + ) + + self._build(state, csp=2) + + self.assertEqual(self._inventory(), []) + manifest, _ = self._manifest() + # Same-provider glue and foreign resources are reported separately. + self.assertEqual(manifest["unmapped_types"], {"aws_instance": 1}) + self.assertEqual( + manifest["other_provider_types"], {"azurerm_linux_virtual_machine": 1} + ) + + def test_rejects_state_with_no_resources_for_the_selected_provider(self): + state = build_state( + [ + managed( + "azurerm_storage_account", + "this", + [ + instance({"location": "westeurope"}, index_key=0), + instance({"location": "westeurope"}, index_key=1), + ], + ) + ] + ) + seed_db(self.db_path, self.AWS_ROWS) + state_path = write_state(self._tmp.name, state) + + with self.assertRaises(ValueError) as ctx: + build_tfstate_resource_inventory( + 2, {"tfstatePath": state_path}, self.report_path, self.raw_data_path + ) + + message = str(ctx.exception) + self.assertIn("No AWS resources found", message) + self.assertIn("2 Azure resources", message) + self.assertIn("main.py azure --tfstate", message) + + def test_rejects_state_holding_only_unsupported_providers(self): + state = build_state( + [ + managed("google_storage_bucket", "this", [instance({})]), + managed("cloudflare_record", "this", [instance({})]), + ] + ) + seed_db(self.db_path, self.AWS_ROWS) + state_path = write_state(self._tmp.name, state) + + with self.assertRaises(ValueError) as ctx: + build_tfstate_resource_inventory( + 2, {"tfstatePath": state_path}, self.report_path, self.raw_data_path + ) + + message = str(ctx.exception) + self.assertIn("No AWS resources found", message) + self.assertIn("other providers", message) + # Nothing to redirect to — cloudexit cannot assess these. + self.assertNotIn("Did you mean", message) + + def test_empty_state_does_not_raise(self): + coverage = self._build_returning(build_state([])) + + self.assertEqual(coverage["instances_total"], 0) + self.assertEqual(self._inventory(), []) + + def test_mixed_state_counts_own_and_reports_excluded(self): + state = build_state( + [ + managed( + "aws_s3_bucket", + "this", + [ + instance({"region": "eu-west-1"}, index_key=0), + instance({"region": "eu-west-1"}, index_key=1), + instance({"region": "eu-west-1"}, index_key=2), + ], + ), + managed( + "azurerm_storage_account", + "this", + [ + instance({"location": "westeurope"}, index_key=i) + for i in range(27) + ], + ), + managed("aws_s3_bucket_versioning", "this", [instance({})]), + ] + ) + + coverage = self._build_returning(state) + + self.assertEqual(self._inventory(), [(444, "eu-west-1", 3)]) + self.assertEqual( + coverage, + { + "instances_total": 31, + "instances_counted": 3, + "instances_excluded_other_provider": 27, + "instances_excluded_unmapped": 1, + }, + ) + + manifest, _ = self._manifest() + self.assertEqual(manifest["coverage"], coverage) + self.assertEqual( + manifest["other_provider_types"], {"azurerm_storage_account": 27} + ) + self.assertEqual(manifest["unmapped_types"], {"aws_s3_bucket_versioning": 1}) + + def test_manifest_carries_no_attributes(self): + state = build_state( + [ + managed( + "aws_s3_bucket", + "this", + [ + instance( + { + "region": "eu-west-1", + "bucket": "my-bucket", + "generated_password": "SUPER_SECRET_VALUE", + } + ) + ], + ) + ] + ) + + state_path = self._build(state) + + manifest, serialized = self._manifest() + self.assertNotIn("attributes", serialized) + self.assertNotIn("SUPER_SECRET_VALUE", serialized) + self.assertNotIn("my-bucket", serialized) + self.assertEqual(manifest["source_file"], os.path.basename(state_path)) + self.assertNotIn(os.path.dirname(state_path), serialized) + self.assertEqual(manifest["terraform_version"], "1.14.6") + self.assertEqual(manifest["state_serial"], 7) + self.assertEqual( + set(manifest["counted"][0].keys()), + {"address", "type", "resource_type_id", "location"}, + ) + + def test_manifest_scope_block_records_file_state_and_locations(self): + import hashlib + + state = build_state( + [ + managed( + "aws_s3_bucket", + "this", + [ + instance({"region": "eu-west-1"}, index_key=0), + instance({"region": "us-east-1"}, index_key=1), + ], + ) + ] + ) + + state_path = self._build(state) + + manifest, _ = self._manifest() + scope = manifest["scope"] + self.assertEqual(scope["file"], "infra.tfstate") + self.assertEqual( + scope["sha256"], hashlib.sha256(Path(state_path).read_bytes()).hexdigest() + ) + self.assertEqual(scope["lineage"], "e1c2f0c0-0000-0000-0000-000000000000") + self.assertEqual(scope["serial"], 7) + self.assertEqual(scope["locations"], ["eu-west-1", "us-east-1"]) + self.assertEqual(scope["subscriptions"], []) + self.assertEqual(scope["resource_groups"], []) + + def test_rerun_is_idempotent(self): + state = build_state( + [ + managed( + "aws_s3_bucket", + "this", + [ + instance({"region": "eu-west-1"}, index_key=0), + instance({"region": "eu-west-1"}, index_key=1), + ], + ) + ] + ) + + state_path = self._build(state) + build_tfstate_resource_inventory( + 2, + {"tfstatePath": state_path}, + self.report_path, + self.raw_data_path, + ) + + self.assertEqual(self._inventory(), [(444, "eu-west-1", 2)]) + + def test_invalid_state_raises_value_error(self): + seed_db(self.db_path, self.AWS_ROWS) + + with self.assertRaisesRegex(ValueError, "version 4"): + build_tfstate_resource_inventory( + 2, + { + "tfstatePath": write_state( + self._tmp.name, build_state([], version=3) + ) + }, + self.report_path, + self.raw_data_path, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_utils_and_main.py b/tests/test_utils_and_main.py deleted file mode 100644 index 27c5d92..0000000 --- a/tests/test_utils_and_main.py +++ /dev/null @@ -1,1155 +0,0 @@ -import json -import os -import tempfile -import types -import unittest -from argparse import Namespace -from pathlib import Path -from unittest.mock import ANY, MagicMock, patch - -import main -from utils import codes -from utils.connection import resolve_mode -from utils.utils import load_config - -VALID_CONFIG = { - "name": "Test Assessment", - "cloudServiceProvider": 2, - "exitStrategy": 1, - "assessmentType": 1, - "providerDetails": { - "accessKey": "AKIAIOSFODNN7EXAMPLE", - "secretKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "region": "eu-central-1", - }, -} - - -class LoadConfigTests(unittest.TestCase): - def test_load_config_returns_parsed_json(self): - with tempfile.TemporaryDirectory() as tmp_dir: - config_path = Path(tmp_dir) / "config.json" - expected = { - "cloudServiceProvider": 2, - "assessmentType": 1, - "providerDetails": {"region": "eu-central-1"}, - } - config_path.write_text(json.dumps(expected), encoding="utf-8") - - self.assertEqual(load_config(str(config_path)), expected) - - def test_load_config_returns_none_for_missing_file(self): - with patch("utils.utils.console.print") as mock_print: - result = load_config("/tmp/does-not-exist-config.json") - - self.assertIsNone(result) - mock_print.assert_called_once() - - def test_load_config_returns_none_for_invalid_json(self): - with tempfile.TemporaryDirectory() as tmp_dir: - config_path = Path(tmp_dir) / "config.json" - config_path.write_text("{invalid json", encoding="utf-8") - - with patch("utils.utils.console.print") as mock_print: - result = load_config(str(config_path)) - - self.assertIsNone(result) - mock_print.assert_called_once() - - -def _base_patches(): - return [ - patch("main.console.print"), - patch("main.print_step"), - patch("main.resolve_mode", return_value=("offline", None)), - patch("main.create_directory", return_value=("/tmp/report", "/tmp/report/raw")), - patch("main.verify_credentials", return_value=(True, "ok")), - patch( - "main.test_permissions", - return_value=(True, True, True, "ok"), - ), - patch( - "main.create_resource_inventory", - return_value={"success": True, "logs": ""}, - ), - patch( - "main.create_cost_inventory", - return_value={"success": True, "logs": ""}, - ), - patch( - "main.perform_risk_assessment", - return_value={"success": True, "logs": ""}, - ), - patch( - "main.generate_report", - return_value={ - "success": True, - "reports": {"HTML": "/tmp/report/index.html"}, - }, - ), - ] - - -class RunAssessmentExitCodeTests(unittest.TestCase): - def _run_with_patches(self, overrides: dict): - patches = _base_patches() - for p in patches: - p.start() - for target, kwargs in overrides.items(): - patch(target, **kwargs).start() - try: - main.run_assessment(VALID_CONFIG.copy(), "aws") - finally: - patch.stopall() - - def test_config_validation_failure_exits_2(self): - with self.assertRaises(SystemExit) as ctx: - with ( - patch("main.validate_config", side_effect=ValueError("bad config")), - patch("main.resolve_mode"), - patch("main.create_directory"), - patch("main.verify_credentials"), - patch("main.print_step"), - patch("main.console.print"), - ): - main.run_assessment(VALID_CONFIG.copy(), "aws") - self.assertEqual(ctx.exception.code, codes.CONFIG) - - def test_directory_creation_failure_exits_2(self): - with self.assertRaises(SystemExit) as ctx: - with ( - patch("main.validate_config"), - patch("main.resolve_mode", return_value=("offline", None)), - patch("main.create_directory", side_effect=RuntimeError("disk full")), - patch("main.verify_credentials"), - patch("main.print_step"), - patch("main.console.print"), - ): - main.run_assessment(VALID_CONFIG.copy(), "aws") - self.assertEqual(ctx.exception.code, codes.CONFIG) - - def test_credential_failure_exits_3(self): - with self.assertRaises(SystemExit) as ctx: - with ( - patch("main.validate_config"), - patch("main.resolve_mode", return_value=("offline", None)), - patch("main.create_directory", return_value=("/tmp/r", "/tmp/r/raw")), - patch("main.verify_credentials", return_value=(False, "bad creds")), - patch("main.print_step"), - patch("main.console.print"), - ): - main.run_assessment(VALID_CONFIG.copy(), "aws") - self.assertEqual(ctx.exception.code, codes.CREDENTIALS) - - def test_permission_failure_exits_4(self): - with self.assertRaises(SystemExit) as ctx: - with ( - patch("main.validate_config"), - patch("main.resolve_mode", return_value=("offline", None)), - patch("main.create_directory", return_value=("/tmp/r", "/tmp/r/raw")), - patch("main.verify_credentials", return_value=(True, "ok")), - patch( - "main.test_permissions", - return_value=(False, False, False, "no perms"), - ), - patch("main.print_step"), - patch("main.console.print"), - ): - main.run_assessment(VALID_CONFIG.copy(), "aws") - self.assertEqual(ctx.exception.code, codes.PERMISSIONS) - - def test_resource_inventory_failure_exits_5(self): - with self.assertRaises(SystemExit) as ctx: - with ( - patch("main.validate_config"), - patch("main.resolve_mode", return_value=("offline", None)), - patch("main.create_directory", return_value=("/tmp/r", "/tmp/r/raw")), - patch("main.verify_credentials", return_value=(True, "ok")), - patch("main.test_permissions", return_value=(True, True, True, "ok")), - patch( - "main.create_resource_inventory", - return_value={"success": False, "logs": "api error"}, - ), - patch("main.print_step"), - patch("main.console.print"), - ): - main.run_assessment(VALID_CONFIG.copy(), "aws") - self.assertEqual(ctx.exception.code, codes.RESOURCE_INVENTORY) - - def test_cost_inventory_failure_exits_6(self): - with self.assertRaises(SystemExit) as ctx: - with ( - patch("main.validate_config"), - patch("main.resolve_mode", return_value=("offline", None)), - patch("main.create_directory", return_value=("/tmp/r", "/tmp/r/raw")), - patch("main.verify_credentials", return_value=(True, "ok")), - patch("main.test_permissions", return_value=(True, True, True, "ok")), - patch( - "main.create_resource_inventory", - return_value={"success": True, "logs": ""}, - ), - patch( - "main.create_cost_inventory", - return_value={"success": False, "logs": "billing error"}, - ), - patch("main.print_step"), - patch("main.console.print"), - ): - main.run_assessment(VALID_CONFIG.copy(), "aws") - self.assertEqual(ctx.exception.code, codes.COST_INVENTORY) - - def test_risk_assessment_failure_exits_7(self): - with self.assertRaises(SystemExit) as ctx: - with ( - patch("main.validate_config"), - patch("main.resolve_mode", return_value=("offline", None)), - patch("main.create_directory", return_value=("/tmp/r", "/tmp/r/raw")), - patch("main.verify_credentials", return_value=(True, "ok")), - patch("main.test_permissions", return_value=(True, True, True, "ok")), - patch( - "main.create_resource_inventory", - return_value={"success": True, "logs": ""}, - ), - patch( - "main.create_cost_inventory", - return_value={"success": True, "logs": ""}, - ), - patch( - "main.perform_risk_assessment", - return_value={"success": False, "logs": "risk error"}, - ), - patch("main.print_step"), - patch("main.console.print"), - ): - main.run_assessment(VALID_CONFIG.copy(), "aws") - self.assertEqual(ctx.exception.code, codes.RISK_ASSESSMENT) - - def test_report_generation_failure_exits_8(self): - with self.assertRaises(SystemExit) as ctx: - with ( - patch("main.validate_config"), - patch("main.resolve_mode", return_value=("offline", None)), - patch("main.create_directory", return_value=("/tmp/r", "/tmp/r/raw")), - patch("main.verify_credentials", return_value=(True, "ok")), - patch("main.test_permissions", return_value=(True, True, True, "ok")), - patch( - "main.create_resource_inventory", - return_value={"success": True, "logs": ""}, - ), - patch( - "main.create_cost_inventory", - return_value={"success": True, "logs": ""}, - ), - patch( - "main.perform_risk_assessment", - return_value={"success": True, "logs": ""}, - ), - patch( - "main.generate_report", - return_value={"success": False, "logs": "render error"}, - ), - patch("main.print_step"), - patch("main.console.print"), - ): - main.run_assessment(VALID_CONFIG.copy(), "aws") - self.assertEqual(ctx.exception.code, codes.REPORT) - - def test_unexpected_exception_exits_1(self): - with self.assertRaises(SystemExit) as ctx: - with ( - patch("main.validate_config", side_effect=RuntimeError("boom")), - patch("main.print_step"), - patch("main.console.print"), - ): - main.run_assessment(VALID_CONFIG.copy(), "aws") - self.assertEqual(ctx.exception.code, codes.UNEXPECTED) - - def test_unexpected_error_writes_traceback_file(self): - with tempfile.TemporaryDirectory() as tmp_dir: - raw_data_path = os.path.join(tmp_dir, "raw") - os.makedirs(raw_data_path, exist_ok=True) - with self.assertRaises(SystemExit) as ctx: - with ( - patch("main.validate_config"), - patch("main.resolve_mode", return_value=("offline", None)), - patch( - "main.create_directory", - return_value=(tmp_dir, raw_data_path), - ), - patch( - "main.verify_credentials", - side_effect=RuntimeError("boom-unexpected"), - ), - patch("main.print_step"), - patch("main.console.print"), - ): - main.run_assessment(VALID_CONFIG.copy(), "aws") - - self.assertEqual(ctx.exception.code, codes.UNEXPECTED) - logs = [f for f in os.listdir(tmp_dir) if f.startswith("error-")] - self.assertEqual(len(logs), 1) - body = Path(tmp_dir, logs[0]).read_text(encoding="utf-8") - self.assertIn("Traceback", body) - self.assertIn("boom-unexpected", body) - - def test_online_sync_failure_exits_risk_assessment(self): - with self.assertRaises(SystemExit) as ctx: - with ( - patch("main.validate_config"), - patch("main.resolve_mode", return_value=("online", "jwt-token")), - patch("main.create_directory", return_value=("/tmp/r", "/tmp/r/raw")), - patch("main.verify_credentials", return_value=(True, "ok")), - patch("main.test_permissions", return_value=(True, True, True, "ok")), - patch( - "main.create_resource_inventory", - return_value={"success": True, "logs": ""}, - ), - patch( - "main.create_cost_inventory", - return_value={"success": True, "logs": ""}, - ), - patch( - "main.sync_assessment", - return_value={"success": False, "logs": "server responded 401"}, - ), - patch("main.print_step"), - patch("main.console.print"), - ): - main.run_assessment(VALID_CONFIG.copy(), "aws") - self.assertEqual(ctx.exception.code, codes.RISK_ASSESSMENT) - - def test_full_success_exits_0(self): - with ( - patch("main.validate_config"), - patch("main.resolve_mode", return_value=("offline", None)), - patch("main.create_directory", return_value=("/tmp/r", "/tmp/r/raw")), - patch("main.verify_credentials", return_value=(True, "ok")), - patch("main.test_permissions", return_value=(True, True, True, "ok")), - patch( - "main.create_resource_inventory", - return_value={"success": True, "logs": ""}, - ), - patch( - "main.create_cost_inventory", return_value={"success": True, "logs": ""} - ), - patch( - "main.perform_risk_assessment", - return_value={"success": True, "logs": ""}, - ), - patch( - "main.generate_report", return_value={"success": True, "reports": {}} - ), - patch("main.print_step"), - patch("main.console.print"), - ): - result = main.run_assessment(VALID_CONFIG.copy(), "aws") - - self.assertIsNone(result) - - def test_dry_run_writes_payload_generates_report_and_skips_sync(self): - with tempfile.TemporaryDirectory() as tmp_dir: - raw_data_path = os.path.join(tmp_dir, "raw_data") - os.makedirs(raw_data_path, exist_ok=True) - payload_path = os.path.join(raw_data_path, "payload.json") - config = VALID_CONFIG.copy() - config["assessmentType"] = 2 - - with ( - patch("main.validate_config"), - patch("main.resolve_mode", return_value=("online", "jwt-token")), - patch( - "main.create_directory", - return_value=(tmp_dir, raw_data_path), - ), - patch("main.verify_credentials", return_value=(True, "ok")), - patch("main.test_permissions", return_value=(True, True, True, "ok")), - patch( - "main.create_resource_inventory", - return_value={"success": True, "logs": ""}, - ), - patch( - "main.create_cost_inventory", - return_value={"success": True, "logs": ""}, - ), - patch( - "main.write_assessment_payload", - return_value=payload_path, - ) as mock_write, - patch( - "main.perform_risk_assessment", - return_value={"success": True, "logs": ""}, - ) as mock_risk, - patch( - "main.generate_report", - return_value={ - "success": True, - "reports": { - "HTML": f"{tmp_dir}/index.html", - "PDF": f"{tmp_dir}/report.pdf", - }, - }, - ) as mock_report, - patch("main.sync_assessment") as mock_sync, - patch("main.print_step"), - patch("main.console.print"), - ): - main.run_assessment(config, "aws", dry_run=True) - - mock_write.assert_called_once_with( - raw_data_path, - report_path=tmp_dir, - name=config["name"], - started_at=ANY, - exit_strategy=config["exitStrategy"], - cloud_service_provider=config["cloudServiceProvider"], - assessment_type=2, - ) - mock_risk.assert_called_once_with( - exit_strategy=config["exitStrategy"], - report_path=tmp_dir, - mode="offline", - ) - mock_report.assert_called_once() - mock_sync.assert_not_called() - - def test_dry_run_flag_passed_from_handle_aws(self): - with ( - patch.dict(os.environ, NonInteractiveAWSTests._BASE_ENV, clear=False), - patch("main.validate_region"), - patch("main.run_assessment") as mock_run, - patch("main.console.print"), - ): - main.handle_aws(_ni_aws_args(dry_run=True)) - - mock_run.assert_called_once_with( - ANY, "aws", dry_run=True, non_interactive=True, egress=False - ) - - -def _ni_aws_args(**kwargs): - defaults = dict( - config=None, - profile=None, - name=None, - non_interactive=True, - dry_run=False, - egress=False, - ) - defaults.update(kwargs) - return Namespace(**defaults) - - -def _ni_azure_args(**kwargs): - defaults = dict( - config=None, - cli=False, - name=None, - non_interactive=True, - dry_run=False, - egress=False, - ) - defaults.update(kwargs) - return Namespace(**defaults) - - -class NonInteractiveAWSTests(unittest.TestCase): - _BASE_ENV = { - "AWS_ACCESS_KEY_ID": "AKIAIOSFODNN7EXAMPLE", - "AWS_SECRET_ACCESS_KEY": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "AWS_DEFAULT_REGION": "eu-central-1", - "ESC_EXIT_STRATEGY": "1", - "ESC_ASSESSMENT_TYPE": "1", - } - - def test_builds_config_from_env_and_calls_run_assessment(self): - with ( - patch.dict(os.environ, self._BASE_ENV, clear=False), - patch("main.validate_region"), - patch("main.run_assessment") as mock_run, - patch("main.console.print"), - ): - main.handle_aws(_ni_aws_args()) - - mock_run.assert_called_once() - config_arg = mock_run.call_args[0][0] - self.assertEqual(config_arg["exitStrategy"], 1) - self.assertEqual(config_arg["assessmentType"], 1) - self.assertEqual( - config_arg["providerDetails"]["accessKey"], "AKIAIOSFODNN7EXAMPLE" - ) - self.assertEqual(config_arg["providerDetails"]["region"], "eu-central-1") - - def test_includes_optional_session_token_from_env(self): - env = {**self._BASE_ENV, "AWS_SESSION_TOKEN": "sts-session-token"} - with ( - patch.dict(os.environ, env, clear=False), - patch("main.validate_region"), - patch("main.run_assessment") as mock_run, - patch("main.console.print"), - ): - main.handle_aws(_ni_aws_args()) - - config_arg = mock_run.call_args[0][0] - self.assertEqual( - config_arg["providerDetails"]["sessionToken"], "sts-session-token" - ) - - def test_missing_exit_strategy_exits_config(self): - env = {k: v for k, v in self._BASE_ENV.items() if k != "ESC_EXIT_STRATEGY"} - with ( - patch.dict(os.environ, env, clear=False), - patch("main.console.print"), - ): - with self.assertRaises(SystemExit) as ctx: - main.handle_aws(_ni_aws_args()) - self.assertEqual(ctx.exception.code, codes.CONFIG) - - def test_invalid_exit_strategy_exits_config(self): - env = {**self._BASE_ENV, "ESC_EXIT_STRATEGY": "9"} - with ( - patch.dict(os.environ, env, clear=False), - patch("main.console.print"), - ): - with self.assertRaises(SystemExit) as ctx: - main.handle_aws(_ni_aws_args()) - self.assertEqual(ctx.exception.code, codes.CONFIG) - - def test_missing_aws_region_exits_config(self): - env = {k: v for k, v in self._BASE_ENV.items() if k != "AWS_DEFAULT_REGION"} - with ( - patch.dict(os.environ, env, clear=False), - patch("main.console.print"), - ): - with self.assertRaises(SystemExit) as ctx: - main.handle_aws(_ni_aws_args()) - self.assertEqual(ctx.exception.code, codes.CONFIG) - - -class NonInteractiveAzureTests(unittest.TestCase): - _BASE_ENV = { - "AZURE_TENANT_ID": "tenant-id-123", - "AZURE_CLIENT_ID": "client-id-456", - "AZURE_CLIENT_SECRET": "super-secret", - "ESC_SUBSCRIPTION_ID": "sub-id-789", - "ESC_RESOURCE_GROUP": "my-rg", - "ESC_EXIT_STRATEGY": "1", - "ESC_ASSESSMENT_TYPE": "1", - } - - def test_builds_config_from_env_and_calls_run_assessment(self): - mock_credential = MagicMock() - with ( - patch.dict(os.environ, self._BASE_ENV, clear=False), - patch("main.ClientSecretCredential", return_value=mock_credential), - patch("main.run_assessment") as mock_run, - patch("main.console.print"), - ): - main.handle_azure(_ni_azure_args()) - - mock_run.assert_called_once() - config_arg = mock_run.call_args[0][0] - self.assertEqual(config_arg["exitStrategy"], 1) - self.assertEqual(config_arg["providerDetails"]["tenantId"], "tenant-id-123") - self.assertEqual(config_arg["providerDetails"]["subscriptionId"], "sub-id-789") - self.assertEqual(config_arg["providerDetails"]["resourceGroupName"], "my-rg") - - def test_uses_default_credential_when_client_secret_missing(self): - env = {k: v for k, v in self._BASE_ENV.items() if k != "AZURE_CLIENT_SECRET"} - mock_credential = MagicMock() - with ( - patch.dict(os.environ, env, clear=False), - patch("main.DefaultAzureCredential", return_value=mock_credential), - patch("main.ClientSecretCredential") as mock_client_secret_cred, - patch("main.run_assessment") as mock_run, - patch("main.console.print"), - ): - main.handle_azure(_ni_azure_args()) - - mock_client_secret_cred.assert_not_called() - mock_run.assert_called_once() - config_arg = mock_run.call_args[0][0] - self.assertEqual(config_arg["providerDetails"]["credential"], mock_credential) - self.assertEqual(config_arg["providerDetails"]["tenantId"], "tenant-id-123") - self.assertEqual(config_arg["providerDetails"]["subscriptionId"], "sub-id-789") - self.assertEqual(config_arg["providerDetails"]["resourceGroupName"], "my-rg") - self.assertEqual(config_arg["providerDetails"]["clientId"], "client-id-456") - self.assertNotIn("clientSecret", config_arg["providerDetails"]) - - def test_missing_client_id_for_oidc_raises_config_error(self): - env = { - k: v - for k, v in self._BASE_ENV.items() - if k not in ("AZURE_CLIENT_SECRET", "AZURE_CLIENT_ID") - } - with ( - patch.dict(os.environ, env, clear=False), - patch("main.console.print"), - ): - with self.assertRaises(main.ConfigError): - main.handle_azure(_ni_azure_args()) - - def test_missing_subscription_id_exits_config(self): - env = {k: v for k, v in self._BASE_ENV.items() if k != "ESC_SUBSCRIPTION_ID"} - with ( - patch.dict(os.environ, env, clear=False), - patch("main.console.print"), - ): - with self.assertRaises(SystemExit) as ctx: - main.handle_azure(_ni_azure_args()) - self.assertEqual(ctx.exception.code, codes.CONFIG) - - def test_missing_resource_group_exits_config(self): - env = {k: v for k, v in self._BASE_ENV.items() if k != "ESC_RESOURCE_GROUP"} - with ( - patch.dict(os.environ, env, clear=False), - patch("main.console.print"), - ): - with self.assertRaises(SystemExit) as ctx: - main.handle_azure(_ni_azure_args()) - self.assertEqual(ctx.exception.code, codes.CONFIG) - - -class EgressStageTests(unittest.TestCase): - _ESTIMATE_OK = { - "success": True, - "logs": "", - "json_path": "/tmp/report/raw/egress_inventory_raw_data.json", - } - _REPORT_OK = { - "success": True, - "logs": "", - "html_path": "/tmp/report/egress.html", - } - _PDF_OK = { - "success": True, - "logs": "", - "pdf_path": "/tmp/report/egress.pdf", - } - - @staticmethod - def _azure_config(): - return { - "name": "Azure Egress Test", - "cloudServiceProvider": 1, - "exitStrategy": 1, - "assessmentType": 1, - "providerDetails": { - "credential": MagicMock(), - "tenantId": "tenant-id", - "subscriptionId": "sub-id", - "resourceGroupName": "my-rg", - }, - } - - def test_egress_module_not_invoked_without_flag(self): - patches = _base_patches() - for p in patches: - p.start() - mock_estimate = patch("main.estimate_egress").start() - mock_render = patch("main.generate_egress_html_report").start() - mock_pdf = patch("main.generate_egress_pdf_report").start() - try: - main.run_assessment(self._azure_config(), "azure") - finally: - patch.stopall() - - mock_estimate.assert_not_called() - mock_render.assert_not_called() - mock_pdf.assert_not_called() - - def test_egress_invoked_after_report_generation(self): - manager = MagicMock() - patches = _base_patches() - for p in patches: - p.start() - mock_report = patch( - "main.generate_report", - return_value={"success": True, "reports": {}}, - ).start() - mock_estimate = patch( - "main.estimate_egress", return_value=self._ESTIMATE_OK - ).start() - mock_render = patch( - "main.generate_egress_html_report", return_value=self._REPORT_OK - ).start() - mock_pdf = patch( - "main.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: - main.run_assessment(config, "azure", egress=True) - finally: - patch.stopall() - - mock_estimate.assert_called_once_with( - 1, - config["providerDetails"], - "/tmp/report/raw", - report_path="/tmp/report", - name=config["name"], - exit_strategy=config["exitStrategy"], - assessment_type=config["assessmentType"], - ) - 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"], - ) - call_names = [name for name, _, _ in manager.mock_calls] - self.assertLess( - call_names.index("generate_report"), call_names.index("estimate_egress") - ) - self.assertLess( - call_names.index("estimate_egress"), - call_names.index("generate_egress_html_report"), - ) - self.assertLess( - call_names.index("generate_egress_html_report"), - call_names.index("generate_egress_pdf_report"), - ) - - def test_egress_failure_still_prints_outputs_and_exits_egress(self): - patches = _base_patches() - for p in patches: - p.start() - patch( - "main.estimate_egress", - return_value={"success": False, "logs": "metrics api error"}, - ).start() - mock_render = patch("main.generate_egress_html_report").start() - mock_pdf = patch("main.generate_egress_pdf_report").start() - mock_print = patch("main.console.print").start() - try: - with self.assertRaises(SystemExit) as ctx: - main.run_assessment(self._azure_config(), "azure", egress=True) - finally: - patch.stopall() - - 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)) - - def test_egress_report_failure_still_prints_outputs_and_exits_egress(self): - patches = _base_patches() - for p in patches: - p.start() - patch("main.estimate_egress", return_value=self._ESTIMATE_OK).start() - patch( - "main.generate_egress_html_report", - return_value={"success": False, "logs": "template error"}, - ).start() - patch("main.generate_egress_pdf_report", return_value=self._PDF_OK).start() - mock_print = patch("main.console.print").start() - try: - with self.assertRaises(SystemExit) as ctx: - main.run_assessment(self._azure_config(), "azure", egress=True) - finally: - patch.stopall() - - self.assertEqual(ctx.exception.code, codes.EGRESS) - printed = [str(call) for call in mock_print.call_args_list] - self.assertTrue(any("Outputs:" in line for line in printed)) - # The JSON path is internal input for the reports; it is never listed - # in Outputs. The failed HTML report is not listed, but the PDF ran - # 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)) - - def test_egress_pdf_failure_still_prints_outputs_and_exits_egress(self): - patches = _base_patches() - for p in patches: - p.start() - patch("main.estimate_egress", return_value=self._ESTIMATE_OK).start() - patch("main.generate_egress_html_report", return_value=self._REPORT_OK).start() - patch( - "main.generate_egress_pdf_report", - return_value={"success": False, "logs": "reportlab error"}, - ).start() - mock_print = patch("main.console.print").start() - try: - with self.assertRaises(SystemExit) as ctx: - main.run_assessment(self._azure_config(), "azure", egress=True) - finally: - patch.stopall() - - 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("Egress Report:" in line for line in printed)) - self.assertFalse(any("Egress PDF:" in line for line in printed)) - - def test_egress_invoked_for_aws_with_provider_code(self): - patches = _base_patches() - for p in patches: - p.start() - mock_estimate = patch( - "main.estimate_egress", return_value=self._ESTIMATE_OK - ).start() - patch("main.generate_egress_html_report", return_value=self._REPORT_OK).start() - patch("main.generate_egress_pdf_report", return_value=self._PDF_OK).start() - config = VALID_CONFIG.copy() - try: - main.run_assessment(config, "aws", egress=True) - finally: - patch.stopall() - - mock_estimate.assert_called_once_with( - 2, - config["providerDetails"], - "/tmp/report/raw", - report_path="/tmp/report", - name=config["name"], - exit_strategy=config["exitStrategy"], - assessment_type=config["assessmentType"], - ) - - def test_handle_azure_passes_egress_flag(self): - mock_credential = MagicMock() - with ( - patch.dict(os.environ, NonInteractiveAzureTests._BASE_ENV, clear=False), - patch("main.ClientSecretCredential", return_value=mock_credential), - patch("main.run_assessment") as mock_run, - patch("main.console.print"), - ): - main.handle_azure(_ni_azure_args(egress=True)) - - mock_run.assert_called_once_with( - ANY, "azure", dry_run=False, non_interactive=True, egress=True - ) - - def test_handle_aws_passes_egress_flag(self): - with ( - patch.dict(os.environ, NonInteractiveAWSTests._BASE_ENV, clear=False), - patch("main.validate_region"), - patch("main.run_assessment") as mock_run, - patch("main.console.print"), - ): - main.handle_aws(_ni_aws_args(egress=True)) - - mock_run.assert_called_once_with( - ANY, "aws", dry_run=False, non_interactive=True, egress=True - ) - - -class MainExitCodeTests(unittest.TestCase): - def test_config_error_from_handler_exits_config(self): - with ( - patch("main.initialize_dataset"), - patch("main.console.print"), - patch( - "main.parse_arguments", - return_value=Namespace(cloud_provider="aws"), - ), - patch("main.handle_aws", side_effect=main.ConfigError), - ): - with self.assertRaises(SystemExit) as ctx: - main.main() - self.assertEqual(ctx.exception.code, codes.CONFIG) - - def test_bad_config_file_exits_config_end_to_end(self): - args = Namespace( - cloud_provider="aws", - config="/nonexistent/aws.json", - profile=None, - name=None, - non_interactive=False, - dry_run=False, - egress=False, - ) - with ( - patch("main.initialize_dataset"), - patch("main.console.print"), - patch("main.parse_arguments", return_value=args), - patch("main.load_config", return_value=None), - patch("main.run_assessment") as mock_run, - ): - with self.assertRaises(SystemExit) as ctx: - main.main() - self.assertEqual(ctx.exception.code, codes.CONFIG) - mock_run.assert_not_called() - - -class ResolveModeEnvVarTests(unittest.TestCase): - def test_host_and_key_env_override_config(self): - fake_config = types.SimpleNamespace(HOST="config-host.io", KEY="config-key") - with ( - patch("utils.connection.config", fake_config), - patch.dict( - os.environ, {"HOST": "env-host.io", "KEY": "env-key"}, clear=False - ), - patch("utils.connection.get_jwt_token", return_value="tok123"), - ): - mode, token = resolve_mode() - - self.assertEqual(mode, "online") - self.assertEqual(token, "tok123") - - def test_falls_back_to_config_when_env_not_set(self): - fake_config = types.SimpleNamespace(HOST="", KEY="") - env = {k: v for k, v in os.environ.items() if k not in ("HOST", "KEY")} - with ( - patch("utils.connection.config", fake_config), - patch.dict(os.environ, env, clear=True), - ): - mode, token = resolve_mode() - - self.assertEqual(mode, "offline") - self.assertIsNone(token) - - -TFSTATE_CONFIG = { - "name": "Tfstate Assessment", - "cloudServiceProvider": 2, - "exitStrategy": 1, - "assessmentType": 1, - "providerDetails": {"tfstatePath": "config/aws-01.tfstate"}, -} - - -class TfstateCliTests(unittest.TestCase): - _ENV = {"ESC_EXIT_STRATEGY": "3", "ESC_ASSESSMENT_TYPE": "1"} - - def test_egress_with_tfstate_exits_config_for_aws(self): - with patch("main.console.print"): - with self.assertRaises(SystemExit) as ctx: - main.handle_aws(_ni_aws_args(tfstate="infra.tfstate", egress=True)) - self.assertEqual(ctx.exception.code, codes.CONFIG) - - def test_egress_with_tfstate_exits_config_for_azure(self): - with patch("main.console.print"): - with self.assertRaises(SystemExit) as ctx: - main.handle_azure(_ni_azure_args(tfstate="infra.tfstate", egress=True)) - self.assertEqual(ctx.exception.code, codes.CONFIG) - - def test_aws_non_interactive_needs_no_aws_env_vars(self): - env = { - k: v - for k, v in os.environ.items() - if not k.startswith("AWS_") and not k.startswith("ESC_") - } - env.update(self._ENV) - with ( - patch.dict(os.environ, env, clear=True), - patch("main.run_assessment") as mock_run, - patch("main.console.print"), - ): - main.handle_aws(_ni_aws_args(tfstate="infra.tfstate")) - - mock_run.assert_called_once() - config_arg = mock_run.call_args[0][0] - self.assertEqual(config_arg["exitStrategy"], 3) - self.assertEqual(config_arg["assessmentType"], 1) - self.assertEqual( - config_arg["providerDetails"], {"tfstatePath": "infra.tfstate"} - ) - - def test_azure_non_interactive_needs_no_azure_env_vars(self): - env = { - k: v - for k, v in os.environ.items() - if not k.startswith("AZURE_") and not k.startswith("ESC_") - } - env.update(self._ENV) - with ( - patch.dict(os.environ, env, clear=True), - patch("main.run_assessment") as mock_run, - patch("main.console.print"), - ): - main.handle_azure(_ni_azure_args(tfstate="infra.tfstate")) - - mock_run.assert_called_once() - config_arg = mock_run.call_args[0][0] - self.assertEqual(config_arg["cloudServiceProvider"], 1) - self.assertEqual(config_arg["exitStrategy"], 3) - self.assertEqual( - config_arg["providerDetails"], {"tfstatePath": "infra.tfstate"} - ) - - def test_config_file_carries_tfstate_path_through_handle_aws(self): - with tempfile.TemporaryDirectory() as tmp_dir: - state_path = Path(tmp_dir) / "infra.tfstate" - state_path.write_text( - json.dumps({"version": 4, "resources": []}), encoding="utf-8" - ) - config_path = Path(tmp_dir) / "config.json" - config_path.write_text( - json.dumps( - { - "cloudServiceProvider": 2, - "exitStrategy": 1, - "assessmentType": 1, - "providerDetails": {"tfstatePath": str(state_path)}, - } - ), - encoding="utf-8", - ) - - with ( - patch("main.run_assessment") as mock_run, - patch("main.console.print"), - ): - main.handle_aws(_ni_aws_args(config=str(config_path))) - - config_arg = mock_run.call_args[0][0] - self.assertEqual( - config_arg["providerDetails"], {"tfstatePath": str(state_path)} - ) - # The same config must survive validation without credential fields. - self.assertTrue(main.validate_config(config_arg)) - - def test_tfstate_parses_from_both_subcommands(self): - with patch("sys.argv", ["main.py", "aws", "--tfstate", "infra.tfstate"]): - args = main.parse_arguments() - self.assertEqual(args.tfstate, "infra.tfstate") - - with patch( - "sys.argv", ["main.py", "azure", "--tfstate", "infra.tfstate", "--dry-run"] - ): - args = main.parse_arguments() - self.assertEqual(args.tfstate, "infra.tfstate") - self.assertTrue(args.dry_run) - - def test_tfstate_is_mutually_exclusive_with_config(self): - with patch( - "sys.argv", ["main.py", "aws", "--config", "c.json", "--tfstate", "s"] - ): - with self.assertRaises(SystemExit): - main.parse_arguments() - - def test_run_assessment_skips_credential_permission_and_cost_stages(self): - with tempfile.TemporaryDirectory() as tmp_dir: - raw_data_path = os.path.join(tmp_dir, "raw_data") - os.makedirs(raw_data_path, exist_ok=True) - - with ( - patch("main.validate_config"), - patch("main.resolve_mode", return_value=("offline", None)), - patch("main.create_directory", return_value=(tmp_dir, raw_data_path)), - patch("main.verify_credentials") as mock_creds, - patch("main.test_permissions") as mock_perms, - patch("main.create_cost_inventory") as mock_cost, - patch( - "main.create_resource_inventory", - return_value={"success": True, "logs": ""}, - ) as mock_inventory, - patch( - "main.perform_risk_assessment", - return_value={"success": True, "logs": ""}, - ) as mock_risk, - patch( - "main.generate_report", - return_value={"success": True, "reports": {}}, - ) as mock_report, - patch("main.print_step"), - patch("main.console.print"), - ): - main.run_assessment(TFSTATE_CONFIG.copy(), "aws") - - mock_creds.assert_not_called() - mock_perms.assert_not_called() - mock_cost.assert_not_called() - mock_inventory.assert_called_once_with( - 2, {"tfstatePath": "config/aws-01.tfstate"}, tmp_dir, raw_data_path - ) - mock_risk.assert_called_once() - mock_report.assert_called_once() - - def _run_tfstate_stage3(self, inventory_result): - with tempfile.TemporaryDirectory() as tmp_dir: - raw_data_path = os.path.join(tmp_dir, "raw_data") - os.makedirs(raw_data_path, exist_ok=True) - - with ( - patch("main.validate_config"), - patch("main.resolve_mode", return_value=("offline", None)), - patch("main.create_directory", return_value=(tmp_dir, raw_data_path)), - patch("main.create_resource_inventory", return_value=inventory_result), - patch( - "main.perform_risk_assessment", - return_value={"success": True, "logs": ""}, - ), - patch( - "main.generate_report", - return_value={"success": True, "reports": {}}, - ), - patch("main.print_step") as mock_step, - patch("main.console.print"), - ): - main.run_assessment(TFSTATE_CONFIG.copy(), "aws") - - return [ - call - for call in mock_step.call_args_list - if "resource inventory" in call.args[0] - ] - - def test_excluded_foreign_resources_downgrade_stage3_to_warning(self): - calls = self._run_tfstate_stage3( - { - "success": True, - "logs": "", - "coverage": { - "instances_total": 31, - "instances_counted": 3, - "instances_excluded_other_provider": 27, - "instances_excluded_unmapped": 1, - }, - } - ) - - self.assertEqual(len(calls), 1) - self.assertEqual(calls[0].kwargs["status"], "warning") - logs = calls[0].kwargs["logs"] - self.assertIn("Assessed 3 of 31 resources", logs) - self.assertIn("27 excluded", logs) - - def test_clean_tfstate_run_keeps_stage3_ok(self): - calls = self._run_tfstate_stage3( - { - "success": True, - "logs": "", - "coverage": { - "instances_total": 8, - "instances_counted": 5, - "instances_excluded_other_provider": 0, - "instances_excluded_unmapped": 3, - }, - } - ) - - self.assertEqual(len(calls), 1) - self.assertEqual(calls[0].kwargs["status"], "ok") - - def test_live_mode_result_without_coverage_keeps_stage3_ok(self): - calls = self._run_tfstate_stage3({"success": True, "logs": ""}) - - self.assertEqual(len(calls), 1) - self.assertEqual(calls[0].kwargs["status"], "ok") - - def test_run_assessment_rejects_egress_from_tfstate_config_file(self): - with ( - patch("main.validate_config"), - patch("main.resolve_mode", return_value=("offline", None)), - patch("main.create_directory") as mock_dir, - patch("main.print_step"), - patch("main.console.print"), - ): - with self.assertRaises(SystemExit) as ctx: - main.run_assessment(TFSTATE_CONFIG.copy(), "aws", egress=True) - - self.assertEqual(ctx.exception.code, codes.CONFIG) - mock_dir.assert_not_called() - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_utils_aws.py b/tests/test_utils_aws.py deleted file mode 100644 index 7aa3112..0000000 --- a/tests/test_utils_aws.py +++ /dev/null @@ -1,776 +0,0 @@ -# tests/test_utils_aws.py -import logging -import os -import tempfile -import unittest -from datetime import date, datetime, timezone -from unittest.mock import MagicMock, patch - -import botocore.exceptions - -from core.utils_aws import ( - convert_datetime, - extract_result_path, - get_missing_months_aws, - paginate, - paginate_or_call, - parse_resource_type_params, - resolve_aws_call_spec, -) - - -class ConvertDatetimeTests(unittest.TestCase): - def test_converts_datetime_in_flat_dict(self): - dt = datetime(2026, 1, 15, 12, 30, 0) - result = convert_datetime({"created": dt, "name": "test"}) - self.assertEqual(result["created"], "2026-01-15T12:30:00") - self.assertEqual(result["name"], "test") - - def test_converts_datetime_in_list(self): - dt = datetime(2026, 3, 1) - result = convert_datetime([dt, "keep"]) - self.assertEqual(result[0], "2026-03-01T00:00:00") - self.assertEqual(result[1], "keep") - - def test_converts_nested_datetime(self): - dt = datetime(2026, 6, 15) - result = convert_datetime({"items": [{"ts": dt}]}) - self.assertEqual(result["items"][0]["ts"], "2026-06-15T00:00:00") - - def test_leaves_non_datetime_values_unchanged(self): - data = {"count": 5, "name": "ec2", "tags": ["a", "b"]} - result = convert_datetime(data) - self.assertEqual(result, {"count": 5, "name": "ec2", "tags": ["a", "b"]}) - - def test_handles_empty_structures(self): - self.assertEqual(convert_datetime({}), {}) - self.assertEqual(convert_datetime([]), []) - self.assertIsNone(convert_datetime(None)) - - -class GetMissingMonthsAwsTests(unittest.TestCase): - @patch("core.utils_aws.datetime") - def test_returns_missing_months(self, mock_dt): - mock_dt.now.return_value = datetime(2026, 6, 15, tzinfo=timezone.utc) - mock_dt.strptime = datetime.strptime - - processed = {"2026-06-01", "2026-05-01", "2026-04-01"} - missing = get_missing_months_aws(processed, 6) - - # Should be missing: 2026-03, 2026-02, 2026-01 - self.assertEqual(len(missing), 3) - self.assertIn(date(2026, 3, 1), missing) - self.assertIn(date(2026, 2, 1), missing) - self.assertIn(date(2026, 1, 1), missing) - - @patch("core.utils_aws.datetime") - def test_returns_empty_when_all_present(self, mock_dt): - mock_dt.now.return_value = datetime(2026, 6, 15, tzinfo=timezone.utc) - mock_dt.strptime = datetime.strptime - - processed = { - "2026-06-01", - "2026-05-01", - "2026-04-01", - "2026-03-01", - "2026-02-01", - "2026-01-01", - } - missing = get_missing_months_aws(processed, 6) - self.assertEqual(missing, []) - - @patch("core.utils_aws.datetime") - def test_returns_all_when_none_processed(self, mock_dt): - mock_dt.now.return_value = datetime(2026, 6, 15, tzinfo=timezone.utc) - mock_dt.strptime = datetime.strptime - - missing = get_missing_months_aws(set(), 6) - self.assertEqual(len(missing), 6) - - -class BuildAwsCostInventoryErrorTests(unittest.TestCase): - @patch("core.utils_aws.connect") - @patch("core.utils_aws.boto3.Session") - def test_passes_session_token_to_boto3_session( - self, mock_session_cls, mock_connect - ): - mock_session = MagicMock() - mock_session_cls.return_value = mock_session - mock_ce = MagicMock() - mock_session.client.return_value = mock_ce - mock_ce.get_cost_and_usage.return_value = {"ResultsByTime": []} - - mock_conn = MagicMock() - mock_connect.return_value.__enter__ = MagicMock(return_value=mock_conn) - mock_connect.return_value.__exit__ = MagicMock(return_value=False) - - from core.utils_aws import build_aws_cost_inventory - - with tempfile.TemporaryDirectory() as tmp: - report_path = os.path.join(tmp, "report") - raw_data_path = os.path.join(tmp, "raw") - os.makedirs(os.path.join(report_path, "data"), exist_ok=True) - os.makedirs(raw_data_path, exist_ok=True) - - build_aws_cost_inventory( - 2, - { - "accessKey": "AK", - "secretKey": "SK", - "sessionToken": "TOKEN", - "region": "us-east-1", - }, - report_path, - raw_data_path, - ) - - mock_session_cls.assert_called_once_with( - aws_access_key_id="AK", - aws_secret_access_key="SK", - aws_session_token="TOKEN", - region_name="us-east-1", - ) - - @patch("core.utils_aws.connect") - @patch("core.utils_aws.boto3.Session") - def test_sqlite_error_is_logged_but_not_reraised( - self, mock_session_cls, mock_connect - ): - """sqlite3.Error is caught and logged but NOT re-raised in current code.""" - import sqlite3 - - mock_session = MagicMock() - mock_session_cls.return_value = mock_session - mock_ce = MagicMock() - mock_session.client.return_value = mock_ce - mock_ce.get_cost_and_usage.return_value = { - "ResultsByTime": [ - { - "TimePeriod": {"Start": "2026-01-01"}, - "Groups": [ - { - "Metrics": { - "UnblendedCost": {"Amount": "10.0", "Unit": "USD"} - } - } - ], - } - ] - } - - mock_conn = MagicMock() - mock_connect.return_value.__enter__ = MagicMock(return_value=mock_conn) - mock_connect.return_value.__exit__ = MagicMock(return_value=False) - mock_cursor = MagicMock() - mock_conn.cursor.return_value = mock_cursor - mock_cursor.execute.side_effect = sqlite3.Error("disk I/O error") - - from core.utils_aws import build_aws_cost_inventory - - with tempfile.TemporaryDirectory() as tmp: - report_path = os.path.join(tmp, "report") - raw_data_path = os.path.join(tmp, "raw") - os.makedirs(os.path.join(report_path, "data"), exist_ok=True) - os.makedirs(raw_data_path, exist_ok=True) - - # sqlite3.Error is caught but NOT re-raised in current code - # (this documents the current behavior) - try: - build_aws_cost_inventory( - 2, - {"accessKey": "AK", "secretKey": "SK", "region": "us-east-1"}, - report_path, - raw_data_path, - ) - except sqlite3.Error: - pass # Expected: current code catches but does not re-raise sqlite3.Error - - -class BuildAwsResourceInventoryErrorTests(unittest.TestCase): - @patch("core.utils_aws.load_data") - @patch("core.utils_aws.boto3.Session") - def test_outer_exception_is_logged_silently(self, mock_session_cls, mock_load_data): - """build_aws_resource_inventory catches all outer exceptions silently.""" - mock_load_data.side_effect = RuntimeError("DB unavailable") - - from core.utils_aws import build_aws_resource_inventory - - with tempfile.TemporaryDirectory() as tmp: - report_path = os.path.join(tmp, "report") - raw_data_path = os.path.join(tmp, "raw") - os.makedirs(os.path.join(report_path, "data"), exist_ok=True) - os.makedirs(raw_data_path, exist_ok=True) - - # Should not raise -- outer except swallows everything - build_aws_resource_inventory( - 2, - {"accessKey": "AK", "secretKey": "SK", "region": "us-east-1"}, - report_path, - raw_data_path, - ) - - -class BuildAwsResourceInventoryPerServiceTests(unittest.TestCase): - @patch("core.utils_aws.connect") - @patch("core.utils_aws.paginate_or_call") - @patch("core.utils_aws.boto3.Session") - @patch("core.utils_aws.load_data") - def test_failed_service_is_skipped_and_logged_at_debug( - self, mock_load_data, mock_session_cls, mock_poc, mock_connect - ): - mock_load_data.return_value = [ - { - "code": "AWS.ec2.describe_instances.Reservations", - "id": 1, - "name": "EC2", - "csp": 2, - "status": "t", - }, - { - "code": "AWS.s3.list_buckets.Buckets", - "id": 2, - "name": "S3", - "csp": 2, - "status": "t", - }, - ] - # First service raises (e.g. AccessDenied); second returns resources. - mock_poc.side_effect = [ - botocore.exceptions.ClientError( - {"Error": {"Code": "AccessDenied", "Message": "no"}}, - "DescribeInstances", - ), - [{"InstanceId": "i-1"}], - ] - mock_connect.return_value.__enter__.return_value = MagicMock() - - from core.utils_aws import build_aws_resource_inventory - - with tempfile.TemporaryDirectory() as tmp: - report_path = os.path.join(tmp, "report") - raw_data_path = os.path.join(tmp, "raw") - os.makedirs(os.path.join(report_path, "data"), exist_ok=True) - os.makedirs(raw_data_path, exist_ok=True) - - with self.assertLogs("core.engine.aws", level="DEBUG") as cm: - build_aws_resource_inventory( - 2, - {"accessKey": "AK", "secretKey": "SK", "region": "us-east-1"}, - report_path, - raw_data_path, - ) - - # Loop continued past the failing service to the next one. - self.assertEqual(mock_poc.call_count, 2) - # The failure was recorded at DEBUG, naming the failed service... - self.assertTrue( - any( - r.levelno == logging.DEBUG and "ec2" in r.getMessage() - for r in cm.records - ) - ) - # ...and never escalated to WARNING/ERROR (stays off the console). - self.assertFalse(any(r.levelno >= logging.WARNING for r in cm.records)) - - -class ParseResourceTypeParamsTests(unittest.TestCase): - def test_parses_json_string(self): - self.assertEqual( - parse_resource_type_params('{"service": "s3"}'), {"service": "s3"} - ) - - def test_empty_object_string(self): - self.assertEqual(parse_resource_type_params("{}"), {}) - - def test_none_and_empty_string_degrade_to_empty_dict(self): - self.assertEqual(parse_resource_type_params(None), {}) - self.assertEqual(parse_resource_type_params(""), {}) - - def test_malformed_json_degrades_to_empty_dict(self): - self.assertEqual(parse_resource_type_params("{not json"), {}) - - def test_non_object_json_degrades_to_empty_dict(self): - self.assertEqual(parse_resource_type_params("[1, 2]"), {}) - - def test_dict_passes_through(self): - self.assertEqual( - parse_resource_type_params({"service": "ec2"}), {"service": "ec2"} - ) - - -class ResolveAwsCallSpecTests(unittest.TestCase): - def test_four_part_code_with_no_params(self): - spec = resolve_aws_call_spec("AWS.s3.list_buckets.Buckets", {}) - self.assertEqual( - spec, - { - "source": "code", - "service": "s3", - "operation": "list_buckets", - "result_path": ["Buckets"], - "kwargs": {}, - }, - ) - - def test_five_part_code_with_no_params_keeps_full_result_path(self): - spec = resolve_aws_call_spec( - "AWS.cloudfront.list_distributions.DistributionList.Items", {} - ) - self.assertEqual(spec["result_path"], ["DistributionList", "Items"]) - self.assertEqual(spec["service"], "cloudfront") - - def test_params_take_precedence_over_code(self): - spec = resolve_aws_call_spec( - "AWS.wrong.wrong_op.Wrong", - { - "service": "cloudfront", - "operation": "list_distributions", - "result_path": ["DistributionList", "Items"], - }, - ) - self.assertEqual( - spec, - { - "source": "params", - "service": "cloudfront", - "operation": "list_distributions", - "result_path": ["DistributionList", "Items"], - "kwargs": {}, - }, - ) - - def test_params_with_kwargs(self): - spec = resolve_aws_call_spec( - "AWS.ec2.describe_snapshots.Snapshots", - { - "service": "ec2", - "operation": "describe_snapshots", - "result_path": ["Snapshots"], - "kwargs": {"OwnerIds": ["self"]}, - }, - ) - self.assertEqual(spec["kwargs"], {"OwnerIds": ["self"]}) - - def test_unknown_params_keys_are_ignored(self): - spec = resolve_aws_call_spec( - "AWS.s3.list_buckets.Buckets", - { - "service": "s3", - "operation": "list_buckets", - "result_path": ["Buckets"], - "future_key": "whatever", - }, - ) - self.assertNotIn("future_key", spec) - self.assertEqual(spec["service"], "s3") - - def test_params_missing_result_path_defaults_to_empty(self): - spec = resolve_aws_call_spec( - "AWS.iam", {"service": "iam", "operation": "list_users"} - ) - self.assertEqual(spec["result_path"], []) - - def test_other_cloud_params_fall_back_to_code(self): - spec = resolve_aws_call_spec( - "AWS.s3.list_buckets.Buckets", {"kind": "functionapp"} - ) - self.assertEqual(spec["service"], "s3") - - def test_partial_params_fall_back_to_code(self): - spec = resolve_aws_call_spec("AWS.s3.list_buckets.Buckets", {"service": "s3"}) - self.assertEqual(spec["operation"], "list_buckets") - - def test_source_records_which_branch_resolved_the_row(self): - from_code = resolve_aws_call_spec("AWS.s3.list_buckets.Buckets", {}) - from_params = resolve_aws_call_spec( - "AWS.s3.list_buckets.Buckets", - {"service": "s3", "operation": "list_buckets"}, - ) - self.assertEqual(from_code["source"], "code") - self.assertEqual(from_params["source"], "params") - - def test_two_part_placeholder_is_unresolvable(self): - self.assertIsNone(resolve_aws_call_spec("AWS.iam", {})) - - def test_malformed_code_is_unresolvable(self): - self.assertIsNone(resolve_aws_call_spec("AWS.ec2.describe_instances", {})) - self.assertIsNone(resolve_aws_call_spec("Azure.a.b.c", {})) - self.assertIsNone(resolve_aws_call_spec("", {})) - - -class ExtractResultPathTests(unittest.TestCase): - def test_single_key(self): - self.assertEqual(extract_result_path({"Items": [1, 2]}, ["Items"]), [1, 2]) - - def test_nested_key(self): - self.assertEqual( - extract_result_path({"A": {"B": ["x"]}}, ["A", "B"]), - ["x"], - ) - - def test_missing_intermediate_key_returns_empty(self): - self.assertEqual(extract_result_path({"A": {}}, ["A", "B"]), []) - self.assertEqual(extract_result_path({}, ["A", "B"]), []) - - def test_non_dict_intermediate_returns_empty(self): - self.assertEqual(extract_result_path({"A": "scalar"}, ["A", "B"]), []) - - def test_non_list_final_value_returns_empty(self): - self.assertEqual(extract_result_path({"A": {"B": 5}}, ["A", "B"]), []) - self.assertEqual(extract_result_path({"A": {"B": {}}}, ["A", "B"]), []) - - def test_empty_path_returns_empty(self): - self.assertEqual(extract_result_path({"Items": [1]}, []), []) - - def test_non_dict_container_returns_empty(self): - self.assertEqual(extract_result_path(None, ["Items"]), []) - - -class BuildAwsResourceInventorySpecTests(unittest.TestCase): - """The scanner drives the resolved spec, not the raw code string.""" - - def _row(self, id_, code, name, params="{}"): - return { - "code": code, - "id": id_, - "name": name, - "csp": 2, - "status": "t", - "params": params, - } - - def _run(self, rows, poc_return=None): - """Run the scanner with load_data/boto3/paginate_or_call mocked out. - - Returns (paginate_or_call mock, captured log records). A plain handler - is used instead of assertLogs because a clean scan logs nothing at all. - """ - records: list[logging.LogRecord] = [] - - class _Capture(logging.Handler): - def emit(self, record): - records.append(record) - - aws_logger = logging.getLogger("core.engine.aws") - handler = _Capture() - previous_level = aws_logger.level - aws_logger.addHandler(handler) - aws_logger.setLevel(logging.DEBUG) - - try: - with tempfile.TemporaryDirectory() as tmp: - report_path = os.path.join(tmp, "report") - raw_data_path = os.path.join(tmp, "raw") - os.makedirs(os.path.join(report_path, "data"), exist_ok=True) - os.makedirs(raw_data_path, exist_ok=True) - - with ( - patch("core.utils_aws.load_data", return_value=rows), - patch("core.utils_aws.boto3.Session"), - patch("core.utils_aws.connect") as mock_connect, - patch("core.utils_aws.paginate_or_call") as mock_poc, - ): - mock_connect.return_value.__enter__.return_value = MagicMock() - mock_poc.return_value = [] if poc_return is None else poc_return - - from core.utils_aws import build_aws_resource_inventory - - build_aws_resource_inventory( - 2, - {"accessKey": "AK", "secretKey": "SK", "region": "us-east-1"}, - report_path, - raw_data_path, - ) - return mock_poc, records - finally: - aws_logger.removeHandler(handler) - aws_logger.setLevel(previous_level) - - def test_params_drive_the_call_for_nested_result_path(self): - rows = [ - self._row( - 1, - "AWS.cloudfront.list_distributions.DistributionList.Items", - "CloudFront", - '{"service": "cloudfront", "operation": "list_distributions", ' - '"result_path": ["DistributionList", "Items"]}', - ) - ] - mock_poc, records = self._run(rows, poc_return=[{"Id": "E1"}]) - - args, kwargs = mock_poc.call_args - self.assertEqual(args[1], "list_distributions") - self.assertEqual(args[2], ["DistributionList", "Items"]) - self.assertEqual(kwargs, {}) - self.assertFalse(any(r.levelno >= logging.WARNING for r in records)) - - def test_params_kwargs_are_forwarded_to_the_call(self): - rows = [ - self._row( - 1, - "AWS.ec2.describe_snapshots.Snapshots", - "EBS Snapshot", - '{"service": "ec2", "operation": "describe_snapshots", ' - '"result_path": ["Snapshots"], "kwargs": {"OwnerIds": ["self"]}}', - ) - ] - mock_poc, _ = self._run(rows) - - _, kwargs = mock_poc.call_args - self.assertEqual(kwargs, {"OwnerIds": ["self"]}) - - def test_code_is_used_when_params_are_empty(self): - rows = [self._row(1, "AWS.s3.list_buckets.Buckets", "S3")] - mock_poc, _ = self._run(rows) - - args, _ = mock_poc.call_args - self.assertEqual(args[1], "list_buckets") - self.assertEqual(args[2], ["Buckets"]) - - def test_two_part_placeholder_is_skipped_at_debug(self): - rows = [self._row(1, "AWS.iam", "IAM")] - mock_poc, records = self._run(rows) - - mock_poc.assert_not_called() - self.assertFalse(any(r.levelno >= logging.WARNING for r in records)) - self.assertTrue( - any( - "AWS.iam" in r.getMessage() - for r in records - if r.levelno == logging.DEBUG - ) - ) - - def test_malformed_code_is_skipped_with_warning(self): - rows = [self._row(1, "AWS.ec2.describe_instances", "Broken")] - mock_poc, records = self._run(rows) - - mock_poc.assert_not_called() - self.assertTrue( - any( - r.levelno == logging.WARNING - and "AWS.ec2.describe_instances" in r.getMessage() - for r in records - ) - ) - - def test_item_cap_hit_logs_warning(self): - rows = [self._row(1, "AWS.ec2.describe_images.Images", "AMI")] - with patch("core.utils_aws.MAX_ITEMS_PER_RESOURCE_TYPE", 3): - _, records = self._run(rows, poc_return=[{"ImageId": "ami"}] * 3) - - self.assertTrue( - any( - r.levelno == logging.WARNING - and "describe_images" in r.getMessage() - and "ec2" in r.getMessage() - for r in records - ) - ) - - def test_per_row_debug_line_names_the_spec_source(self): - rows = [ - self._row( - 1, - "AWS.cloudfront.list_distributions.DistributionList.Items", - "CloudFront", - '{"service": "cloudfront", "operation": "list_distributions", ' - '"result_path": ["DistributionList", "Items"]}', - ), - self._row(2, "AWS.s3.list_buckets.Buckets", "S3"), - ] - _, records = self._run(rows) - messages = [r.getMessage() for r in records if r.levelno == logging.DEBUG] - - self.assertTrue( - any("AWS.cloudfront" in m and "from params" in m for m in messages), - messages, - ) - self.assertTrue( - any("AWS.s3.list_buckets" in m and "from code" in m for m in messages), - messages, - ) - - def test_summary_line_counts_every_resolution_outcome(self): - rows = [ - self._row( - 1, - "AWS.cloudfront.list_distributions.DistributionList.Items", - "CloudFront", - '{"service": "cloudfront", "operation": "list_distributions", ' - '"result_path": ["DistributionList", "Items"]}', - ), - self._row(2, "AWS.s3.list_buckets.Buckets", "S3"), - self._row(3, "AWS.iam", "IAM"), - self._row(4, "AWS.ec2.describe_instances", "Broken"), - ] - _, records = self._run(rows) - summary = [ - r.getMessage() - for r in records - if r.levelno == logging.INFO - and "Resource type resolution" in r.getMessage() - ] - - self.assertEqual(len(summary), 1, records) - self.assertEqual( - summary[0], - "Resource type resolution: 2 of 4 rows scanned (1 from params, " - "1 from code), 1 placeholders skipped, 1 invalid.", - ) - - def test_summary_is_info_so_it_stays_off_the_default_console(self): - rows = [self._row(1, "AWS.s3.list_buckets.Buckets", "S3")] - _, records = self._run(rows) - - summary = [r for r in records if "Resource type resolution" in r.getMessage()] - self.assertEqual([r.levelno for r in summary], [logging.INFO]) - - def test_item_cap_is_passed_to_paginate_or_call(self): - from core.utils_aws import MAX_ITEMS_PER_RESOURCE_TYPE - - rows = [self._row(1, "AWS.s3.list_buckets.Buckets", "S3")] - mock_poc, _ = self._run(rows) - - args, _ = mock_poc.call_args - self.assertEqual(args[3], MAX_ITEMS_PER_RESOURCE_TYPE) - - -class PaginateTests(unittest.TestCase): - def _fake_client(self, pages): - """Build a stub client whose paginator yields the given pages.""" - paginator = MagicMock() - paginator.paginate.return_value = iter(pages) - client = MagicMock() - client.get_paginator.return_value = paginator - return client - - def test_collects_items_across_pages(self): - client = self._fake_client( - [{"Items": [1, 2, 3]}, {"Items": [4, 5]}, {"Items": [6]}] - ) - self.assertEqual(paginate(client, "any_op", ["Items"]), [1, 2, 3, 4, 5, 6]) - - def test_missing_result_key_treated_as_empty(self): - client = self._fake_client([{"Items": [1]}, {}]) - self.assertEqual(paginate(client, "any_op", ["Items"]), [1]) - - def test_walks_nested_result_path_per_page(self): - client = self._fake_client( - [ - {"DistributionList": {"Items": ["d1", "d2"]}}, - {"DistributionList": {"Items": ["d3"]}}, - ] - ) - self.assertEqual( - paginate(client, "list_distributions", ["DistributionList", "Items"]), - ["d1", "d2", "d3"], - ) - - def test_page_missing_intermediate_key_yields_nothing_for_that_page(self): - client = self._fake_client( - [{"DistributionList": {"Items": ["d1"]}}, {}, {"DistributionList": {}}] - ) - self.assertEqual( - paginate(client, "list_distributions", ["DistributionList", "Items"]), - ["d1"], - ) - - def test_bare_string_result_path_is_treated_as_single_key(self): - client = self._fake_client([{"Volumes": ["v1", "v2"]}]) - self.assertEqual(paginate(client, "describe_volumes", "Volumes"), ["v1", "v2"]) - - def test_stops_and_truncates_at_max_items(self): - pages = [{"Items": list(range(4))} for _ in range(10)] - client = self._fake_client(pages) - - result = paginate(client, "any_op", ["Items"], 10) - - self.assertEqual(len(result), 10) - self.assertEqual(result, list(range(4)) * 2 + [0, 1]) - - def test_forwards_kwargs_to_paginator(self): - client = self._fake_client([{"Items": []}]) - paginate(client, "any_op", ["Items"], MaxResults=50) - client.get_paginator.return_value.paginate.assert_called_once_with( - MaxResults=50 - ) - - def test_throttling_mid_pagination_does_not_silently_truncate(self): - - def flaky_pages(): - yield {"Items": ["r1", "r2"]} - raise botocore.exceptions.ClientError( - {"Error": {"Code": "Throttling", "Message": "slow down"}}, - "ListSomething", - ) - - paginator = MagicMock() - paginator.paginate.return_value = flaky_pages() - client = MagicMock() - client.get_paginator.return_value = paginator - - with self.assertRaises(botocore.exceptions.ClientError): - paginate(client, "any_op", "Items") - - -class PaginateOrCallTests(unittest.TestCase): - def test_uses_paginator_when_available(self): - paginator = MagicMock() - paginator.paginate.return_value = iter([{"Items": [1, 2]}, {"Items": [3]}]) - client = MagicMock() - client.can_paginate.return_value = True - client.get_paginator.return_value = paginator - - self.assertEqual(paginate_or_call(client, "list_things", ["Items"]), [1, 2, 3]) - client.can_paginate.assert_called_once_with("list_things") - - def test_falls_back_to_single_call_when_not_paginable(self): - client = MagicMock() - client.can_paginate.return_value = False - client.list_things.return_value = {"Items": ["a", "b"]} - - self.assertEqual(paginate_or_call(client, "list_things", ["Items"]), ["a", "b"]) - client.list_things.assert_called_once_with() - - def test_non_dict_response_returns_empty_list(self): - client = MagicMock() - client.can_paginate.return_value = False - client.list_things.return_value = None - - self.assertEqual(paginate_or_call(client, "list_things", ["Items"]), []) - - def test_walks_nested_result_path_on_single_call(self): - client = MagicMock() - client.can_paginate.return_value = False - client.get_apps.return_value = { - "ApplicationsResponse": {"Applications": ["a1", "a2"]} - } - - self.assertEqual( - paginate_or_call( - client, "get_apps", ["ApplicationsResponse", "Applications"] - ), - ["a1", "a2"], - ) - - def test_forwards_kwargs_to_single_call(self): - client = MagicMock() - client.can_paginate.return_value = False - client.describe_images.return_value = {"Images": []} - - paginate_or_call(client, "describe_images", ["Images"], Owners=["self"]) - client.describe_images.assert_called_once_with(Owners=["self"]) - - def test_truncates_single_call_at_max_items(self): - client = MagicMock() - client.can_paginate.return_value = False - client.list_things.return_value = {"Items": list(range(20))} - - self.assertEqual( - paginate_or_call(client, "list_things", ["Items"], 5), [0, 1, 2, 3, 4] - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_utils_azure.py b/tests/test_utils_azure.py deleted file mode 100644 index 7507677..0000000 --- a/tests/test_utils_azure.py +++ /dev/null @@ -1,207 +0,0 @@ -# tests/test_utils_azure.py -import unittest -from datetime import date -from unittest.mock import MagicMock, patch - -from azure.core.exceptions import AzureError, ClientAuthenticationError - -from core.utils_azure import ( - get_missing_months_azure, - is_resource_inventory_empty, -) - - -class GetMissingMonthsAzureTests(unittest.TestCase): - def test_returns_missing_months(self): - today = date.today() - # Provide 3 months, expect 3 missing from the 6-month window - processed = { - today.replace(day=1).isoformat(), - } - missing = get_missing_months_azure(processed, 6) - self.assertEqual(len(missing), 5) - - def test_returns_empty_when_all_present(self): - from dateutil.relativedelta import relativedelta - - today = date.today() - start = today.replace(day=1) - relativedelta(months=5) - processed = set() - for i in range(6): - m = (start + relativedelta(months=i)).replace(day=1) - processed.add(m.isoformat()) - - missing = get_missing_months_azure(processed, 6) - self.assertEqual(len(missing), 0) - - def test_handles_timestamp_format(self): - from dateutil.relativedelta import relativedelta - - today = date.today() - start = today.replace(day=1) - relativedelta(months=5) - processed = set() - for i in range(6): - m = (start + relativedelta(months=i)).replace(day=1) - processed.add(f"{m.isoformat()}T00:00:00") - - missing = get_missing_months_azure(processed, 6) - self.assertEqual(len(missing), 0) - - def test_returns_all_when_none_processed(self): - missing = get_missing_months_azure(set(), 6) - self.assertEqual(len(missing), 6) - - -class IsResourceInventoryEmptyTests(unittest.TestCase): - @patch("core.utils_azure.ResourceManagementClient") - def test_returns_true_when_no_resources(self, mock_rmc_cls): - mock_client = MagicMock() - mock_rmc_cls.return_value = mock_client - mock_client.resources.list_by_resource_group.return_value = iter([]) - - result = is_resource_inventory_empty(MagicMock(), "sub-123", "rg-test") - self.assertTrue(result) - - @patch("core.utils_azure.ResourceManagementClient") - def test_returns_false_when_resources_exist(self, mock_rmc_cls): - mock_client = MagicMock() - mock_rmc_cls.return_value = mock_client - mock_resource = MagicMock() - mock_client.resources.list_by_resource_group.return_value = iter( - [mock_resource] - ) - - result = is_resource_inventory_empty(MagicMock(), "sub-123", "rg-test") - self.assertFalse(result) - - @patch("core.utils_azure.ResourceManagementClient") - def test_raises_on_azure_error(self, mock_rmc_cls): - mock_client = MagicMock() - mock_rmc_cls.return_value = mock_client - mock_client.resources.list_by_resource_group.side_effect = AzureError( - "Connection failed" - ) - - with self.assertRaises(AzureError): - is_resource_inventory_empty(MagicMock(), "sub-123", "rg-test") - - -class BuildAzureResourceInventoryErrorTests(unittest.TestCase): - @patch("core.utils_azure.is_resource_inventory_empty") - @patch("core.utils_azure.ClientSecretCredential") - def test_auth_error_is_reraised(self, mock_cred_cls, mock_empty_check): - mock_cred_cls.side_effect = ClientAuthenticationError( - message="Invalid credentials" - ) - - from core.utils_azure import build_azure_resource_inventory - - with self.assertRaises(ClientAuthenticationError): - build_azure_resource_inventory( - 1, - { - "tenantId": "t", - "clientId": "c", - "clientSecret": "s", - "subscriptionId": "sub", - "resourceGroupName": "rg", - }, - "/fake/report", - "/fake/raw", - ) - - @patch("core.utils_azure.is_resource_inventory_empty", return_value=True) - @patch("core.utils_azure.ClientSecretCredential") - def test_returns_early_when_inventory_empty(self, mock_cred_cls, mock_empty_check): - from core.utils_azure import build_azure_resource_inventory - - # Should not raise, returns early - result = build_azure_resource_inventory( - 1, - { - "tenantId": "t", - "clientId": "c", - "clientSecret": "s", - "subscriptionId": "sub", - "resourceGroupName": "rg", - }, - "/fake/report", - "/fake/raw", - ) - self.assertIsNone(result) - mock_empty_check.assert_called_once() - - -class BuildAzureCostInventoryErrorTests(unittest.TestCase): - @patch("core.utils_azure.ClientSecretCredential") - def test_auth_error_is_reraised(self, mock_cred_cls): - mock_cred_cls.side_effect = ClientAuthenticationError( - message="Invalid credentials" - ) - - from core.utils_azure import build_azure_cost_inventory - - with self.assertRaises(ClientAuthenticationError): - build_azure_cost_inventory( - 1, - { - "tenantId": "t", - "clientId": "c", - "clientSecret": "s", - "subscriptionId": "sub", - "resourceGroupName": "rg", - }, - "/fake/report", - "/fake/raw", - ) - - @patch("core.utils_azure.connect") - @patch("core.utils_azure.CostManagementClient") - @patch("core.utils_azure.ClientSecretCredential") - def test_sqlite_error_is_reraised(self, mock_cred_cls, mock_cost_cls, mock_connect): - import sqlite3 - - mock_cost_client = MagicMock() - mock_cost_cls.return_value = mock_cost_client - - mock_cost_data = MagicMock() - mock_cost_data.rows = [ - [42.5, "2026-01-01T00:00:00", "USD"], - ] - mock_cost_data.as_dict.return_value = {} - mock_cost_client.query.usage.return_value = mock_cost_data - - mock_conn = MagicMock() - mock_connect.return_value.__enter__ = MagicMock(return_value=mock_conn) - mock_connect.return_value.__exit__ = MagicMock(return_value=False) - mock_cursor = MagicMock() - mock_conn.cursor.return_value = mock_cursor - mock_cursor.execute.side_effect = sqlite3.Error("disk I/O error") - - from core.utils_azure import build_azure_cost_inventory - import tempfile - import os - - with tempfile.TemporaryDirectory() as tmp: - report_path = os.path.join(tmp, "report") - raw_data_path = os.path.join(tmp, "raw") - os.makedirs(os.path.join(report_path, "data"), exist_ok=True) - os.makedirs(raw_data_path, exist_ok=True) - - with self.assertRaises(sqlite3.Error): - build_azure_cost_inventory( - 1, - { - "tenantId": "t", - "clientId": "c", - "clientSecret": "s", - "subscriptionId": "sub", - "resourceGroupName": "rg", - }, - report_path, - raw_data_path, - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_utils_egress.py b/tests/test_utils_egress.py deleted file mode 100644 index ca157c8..0000000 --- a/tests/test_utils_egress.py +++ /dev/null @@ -1,298 +0,0 @@ -# tests/test_utils_egress.py -import json -import os -import sqlite3 -import tempfile -import unittest -from unittest.mock import patch - -from core.utils_egress import ( - GIB, - compute_totals, - estimate_egress, - format_bytes, - new_row, - read_egress_inventory, - write_egress_inventory, -) - -# Same envelope as the assessment JSON report (see generate_json_report). -EXPECTED_META_KEYS = [ - "assessment_type", - "cloud_service_provider", - "exit_strategy", - "name", - "timestamp", -] -EXPECTED_DATA_KEYS = ["resources", "totals"] - - -def _sample_rows(): - storage = new_row("/sa1", "sa1", "some/type", "Storage", "object") - storage["size_bytes"] = 60 * GIB - storage["tier_bytes"] = {"Hot": 50 * GIB, "Archive": 10 * GIB} - disk = new_row("/disk1", "disk1", "some/type", "Disk", "block") - disk["size_bytes"] = 40 * GIB - unknown = new_row("/db1", "db1", "some/type", "Database", "database") - unknown["size_unknown"] = True - return [storage, disk, unknown] - - -class FormatBytesTests(unittest.TestCase): - def test_formats_binary_units_and_none(self): - self.assertEqual(format_bytes(None), "n/a") - self.assertEqual(format_bytes(512), "512.0 B") - self.assertEqual(format_bytes(GIB), "1.0 GiB") - self.assertEqual(format_bytes(1536 * GIB), "1.5 TiB") - - -class ComputeTotalsTests(unittest.TestCase): - def test_sums_known_sizes_archive_tiers_and_unknowns(self): - totals = compute_totals(_sample_rows(), archive_tiers={"Archive"}) - - self.assertEqual(totals["known_size_bytes"], 100 * GIB) - self.assertEqual(totals["archive_tier_bytes"], 10 * GIB) - self.assertEqual(totals["resources_discovered"], 3) - self.assertEqual(totals["resources_with_unknown_size"], 1) - - def test_archive_tier_set_controls_what_counts_as_archive(self): - rows = [new_row("/b1", "b1", "some/type", "Bucket", "object")] - rows[0]["size_bytes"] = 30 * GIB - rows[0]["tier_bytes"] = { - "Standard": 10 * GIB, - "Glacier": 15 * GIB, - "Deep Archive": 5 * GIB, - } - - totals = compute_totals(rows, archive_tiers={"Glacier", "Deep Archive"}) - - self.assertEqual(totals["archive_tier_bytes"], 20 * GIB) - - -# The sample rows all share one type; one catalogue entry covers them. -_SAMPLE_REGISTRY = { - "some/type": { - "resource_type_id": 1, - "category": "object", - "label": "Storage", - "strategy": "whatever", - } -} - - -_EGRESS_TABLES = """ -CREATE TABLE egress_inventory ( - id INTEGER PRIMARY KEY AUTOINCREMENT, resource_type INTEGER NOT NULL, - name TEXT NOT NULL, size_bytes INTEGER, - size_unknown INTEGER NOT NULL DEFAULT 0, flags TEXT, notes TEXT); -CREATE TABLE egress_inventory_tier ( - id INTEGER PRIMARY KEY AUTOINCREMENT, egress_inventory_id INTEGER NOT NULL, - tier TEXT NOT NULL, size_bytes INTEGER NOT NULL, - is_archive INTEGER NOT NULL DEFAULT 0); -""" - - -class EgressInventoryRoundTripTests(unittest.TestCase): - _CATALOGUE = """ -CREATE TABLE resourcetype ( - id INTEGER PRIMARY KEY, csp INTEGER NOT NULL, code TEXT NOT NULL, - name TEXT NOT NULL, icon TEXT NOT NULL, status TEXT NOT NULL); -CREATE TABLE resourcetype_data ( - id INTEGER PRIMARY KEY, resource_type INTEGER NOT NULL, - data_category TEXT NOT NULL, strategy TEXT NOT NULL, params TEXT, - status TEXT NOT NULL); -""" - - def setUp(self): - self._dir = tempfile.TemporaryDirectory() - self.addCleanup(self._dir.cleanup) - self.db_path = os.path.join(self._dir.name, "assessment.db") - conn = sqlite3.connect(self.db_path) - conn.executescript(self._CATALOGUE + _EGRESS_TABLES) - conn.execute( - "INSERT INTO resourcetype VALUES (7, 1, 'some/type', 'Storage', 'i', 't')" - ) - conn.execute( - "INSERT INTO resourcetype_data VALUES (1, 7, 'object', 's', '{}', 't')" - ) - conn.commit() - conn.close() - - def _round_trip(self, rows, archive_tiers): - write_egress_inventory(rows, {"some/type": 7}, archive_tiers, self.db_path) - return read_egress_inventory(self.db_path) - - def test_rows_survive_storage_unchanged(self): - original = _sample_rows() - - stored, _ = self._round_trip(original, {"Archive"}) - - self.assertEqual(len(stored), len(original)) - for before, after in zip(original, stored): - for field in ( - "name", - "size_bytes", - "size_unknown", - "tier_bytes", - "flags", - "notes", - ): - self.assertEqual(after[field], before[field], field) - - def test_label_and_category_come_back_from_the_catalogue(self): - stored, _ = self._round_trip(_sample_rows(), set()) - - self.assertEqual(stored[0]["label"], "Storage") - self.assertEqual(stored[0]["category"], "object") - self.assertEqual(stored[0]["type"], "some/type") - - def test_totals_are_identical_before_and_after_storage(self): - original = _sample_rows() - archive_tiers = {"Archive"} - - stored, stored_tiers = self._round_trip(original, archive_tiers) - - # What the report headlines show must not change with the store. - self.assertEqual( - compute_totals(stored, stored_tiers), - compute_totals(original, archive_tiers), - ) - - def test_is_archive_marks_only_the_archive_tiers(self): - _, stored_tiers = self._round_trip(_sample_rows(), {"Archive"}) - - self.assertEqual(stored_tiers, {"Archive"}) - - def test_row_without_a_catalogue_entry_warns_and_is_skipped(self): - orphan = new_row("/x", "x", "unmapped/type", "Nope", "object") - - with self.assertLogs("core.engine.egress", level="WARNING") as captured: - write_egress_inventory([orphan], {"some/type": 7}, set(), self.db_path) - - rows, _ = read_egress_inventory(self.db_path) - self.assertEqual(rows, []) - self.assertIn("unmapped/type", captured.output[0]) - - -class EstimateEgressDispatchTests(unittest.TestCase): - def _run(self, cloud_service_provider, provider_details): - with tempfile.TemporaryDirectory() as tmp_dir: - db_dir = os.path.join(tmp_dir, "data") - os.makedirs(db_dir) - conn = sqlite3.connect(os.path.join(db_dir, "assessment.db")) - conn.executescript(_EGRESS_TABLES) - conn.commit() - conn.close() - - with patch( - "core.utils_egress.load_egress_registry", - return_value=_SAMPLE_REGISTRY, - ): - result = estimate_egress( - cloud_service_provider, - provider_details, - tmp_dir, - report_path=tmp_dir, - name="Exit Assessment Test", - exit_strategy=3, - assessment_type=1, - ) - payload = None - if result["success"]: - with open(result["json_path"], encoding="utf-8") as json_file: - payload = json.load(json_file) - self.assertEqual( - result["json_path"], - os.path.join(tmp_dir, "egress_inventory_raw_data.json"), - ) - stored = ( - sqlite3.connect(os.path.join(tmp_dir, "data", "assessment.db")) - .execute("SELECT COUNT(*) FROM egress_inventory") - .fetchone()[0] - ) - self.assertEqual(stored, len(payload["data"]["resources"])) - return result, payload - - @patch("core.utils_egress_azure.collect_azure_egress") - def test_azure_dispatch_and_json_schema(self, mock_collect): - mock_collect.return_value = (_sample_rows(), {"Archive"}) - - result, payload = self._run(1, {"any": "details"}) - - self.assertTrue(result["success"]) - mock_collect.assert_called_once_with({"any": "details"}) - # Same meta/data envelope as the assessment JSON report; cost - # scenarios and findings belong to the Platform offering and must - # not leak into the JSON output. - self.assertEqual(sorted(payload.keys()), ["data", "meta"]) - self.assertEqual(sorted(payload["meta"].keys()), EXPECTED_META_KEYS) - self.assertEqual(sorted(payload["data"].keys()), EXPECTED_DATA_KEYS) - self.assertEqual(payload["meta"]["name"], "Exit Assessment Test") - self.assertEqual(payload["meta"]["cloud_service_provider"], 1) - self.assertEqual(payload["meta"]["exit_strategy"], 3) - self.assertEqual(payload["meta"]["assessment_type"], 1) - self.assertEqual(payload["data"]["totals"]["known_size_bytes"], 100 * GIB) - self.assertEqual(payload["data"]["totals"]["archive_tier_bytes"], 10 * GIB) - - @patch("core.utils_egress_azure.collect_azure_egress") - def test_json_row_shape(self, mock_collect): - rows = _sample_rows() - mock_collect.return_value = (rows, {"Archive"}) - - _, payload = self._run(1, {"any": "details"}) - - resource = payload["data"]["resources"][0] - self.assertEqual( - list(resource), - [ - "id", - "name", - "code", - "category", - "size_bytes", - "size_unknown", - "tier_bytes", - "flags", - "notes", - ], - ) - self.assertEqual(resource["id"], "/sa1") - self.assertEqual(resource["name"], "sa1") - # "type" is published as "code"; the label lives in the catalogue. - self.assertEqual(resource["code"], "some/type") - self.assertNotIn("label", resource) - self.assertNotIn("type", resource) - self.assertEqual(resource["tier_bytes"], {"Hot": 50 * GIB, "Archive": 10 * GIB}) - - @patch("core.utils_egress_aws.collect_aws_egress") - def test_aws_dispatch_and_json_schema(self, mock_collect): - mock_collect.return_value = (_sample_rows(), {"Archive"}) - - result, payload = self._run(2, {"region": "eu-central-1"}) - - self.assertTrue(result["success"]) - mock_collect.assert_called_once_with({"region": "eu-central-1"}) - self.assertEqual(sorted(payload.keys()), ["data", "meta"]) - self.assertEqual(sorted(payload["meta"].keys()), EXPECTED_META_KEYS) - self.assertEqual(sorted(payload["data"].keys()), EXPECTED_DATA_KEYS) - self.assertEqual(payload["meta"]["cloud_service_provider"], 2) - - def test_unsupported_provider_fails_without_raising(self): - result, payload = self._run(3, {}) - - self.assertFalse(result["success"]) - self.assertIn("Unsupported cloud service provider", result["logs"]) - self.assertIsNone(payload) - - @patch("core.utils_egress_azure.collect_azure_egress") - def test_collection_error_returns_failure(self, mock_collect): - mock_collect.side_effect = RuntimeError("enumeration failed") - - result, payload = self._run(1, {}) - - self.assertFalse(result["success"]) - self.assertEqual(result["logs"], "enumeration failed") - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_utils_egress_aws.py b/tests/test_utils_egress_aws.py deleted file mode 100644 index f3a44e7..0000000 --- a/tests/test_utils_egress_aws.py +++ /dev/null @@ -1,897 +0,0 @@ -# tests/test_utils_egress_aws.py -import unittest -from unittest.mock import MagicMock, patch - -from botocore.exceptions import ClientError - -from core.utils_egress import GIB -from core.utils_egress_aws import ( - _collect_dynamodb_tables, - _collect_list_item_size, - _collect_not_sizeable, - _collect_rds_instances, - _collect_s3_buckets, - _list_buckets_in_region, - collect_aws_egress, - fetch_latest_metric_values, -) - -REGION = "eu-central-1" - -S3_CODE = "AWS.s3.list_buckets.Buckets" -VOLUME_CODE = "AWS.ec2.describe_volumes.Volumes" -SNAPSHOT_CODE = "AWS.ec2.describe_snapshots.Snapshots" -RDS_CODE = "AWS.rds.describe_db_instances.DBInstances" -DYNAMODB_CODE = "AWS.dynamodb.list_tables.TableNames" -# The catalogue row enumerates backup plans; sizing overrides it with vaults. -BACKUP_CODE = "AWS.backup.list_backup_plans.BackupPlansList" - -# Stands in for what load_egress_registry() builds out of resourcetype_data, -# so these tests exercise the engine rather than the shipped master data. -TEST_REGISTRY = { - S3_CODE: { - "category": "object", - "label": "S3 (Simple Storage Service)", - "strategy": "s3_bucket_metrics", - "enumeration": { - "service": "s3", - "operation": "list_buckets", - "result_path": ["Buckets"], - }, - }, - VOLUME_CODE: { - "category": "block", - "label": "Elastic Block Store (EBS)", - "strategy": "ebs_volumes", - "enumeration": { - "service": "ec2", - "operation": "describe_volumes", - "result_path": ["Volumes"], - }, - "sizing": { - "id_field": "VolumeId", - "name_tag": "Name", - "size_field": "Size", - "size_unit": "GiB", - "flags": ["allocated (upper bound)"], - }, - }, - SNAPSHOT_CODE: { - "category": "block", - "label": "EBS Snapshots", - "strategy": "ebs_snapshots", - "enumeration": { - "service": "ec2", - "operation": "describe_snapshots", - "result_path": ["Snapshots"], - "kwargs": {"OwnerIds": ["self"]}, - }, - "sizing": { - "id_field": "SnapshotId", - "size_field": "VolumeSize", - "size_unit": "GiB", - "flags": ["allocated (upper bound)"], - "notes": ["incremental – shares blocks with sibling snapshots"], - }, - }, - RDS_CODE: { - "category": "database", - "label": "RDS (Relational Database Service)", - "strategy": "rds_instances", - "enumeration": { - "service": "rds", - "operation": "describe_db_instances", - "result_path": ["DBInstances"], - }, - }, - DYNAMODB_CODE: { - "category": "database", - "label": "DynamoDB", - "strategy": "dynamodb_tables", - "enumeration": { - "service": "dynamodb", - "operation": "list_tables", - "result_path": ["TableNames"], - }, - }, - BACKUP_CODE: { - "category": "backup", - "label": "Backup", - "strategy": "backup_vaults", - "enumeration": { - "service": "backup", - "operation": "list_backup_vaults", - "result_path": ["BackupVaultList"], - }, - "sizing": { - "id_field": "BackupVaultName", - "arn_field": "BackupVaultArn", - "flags": ["backup vault – not sized"], - "count_field": "NumberOfRecoveryPoints", - "count_note": "{count} recovery points (cannot be exported directly)", - }, - }, -} - -_REGISTRY_PATCHER = None - - -def setUpModule(): - global _REGISTRY_PATCHER - _REGISTRY_PATCHER = patch( - "core.utils_egress_aws.load_egress_registry", - return_value=TEST_REGISTRY, - ) - _REGISTRY_PATCHER.start() - - -def tearDownModule(): - _REGISTRY_PATCHER.stop() - - -def _client_error(code, operation): - return ClientError({"Error": {"Code": code, "Message": code}}, operation) - - -def _mock_session(clients): - session = MagicMock() - session.client.side_effect = lambda service, **kwargs: clients[service] - return session - - -def _mock_paginator(client, pages): - paginator = MagicMock() - paginator.paginate.return_value = pages - client.get_paginator.return_value = paginator - return paginator - - -class FetchLatestMetricValuesTests(unittest.TestCase): - def test_returns_latest_value_per_query(self): - cloudwatch = MagicMock() - cloudwatch.get_metric_data.return_value = { - "MetricDataResults": [ - {"Id": "q0", "Values": [123.0, 50.0]}, - {"Id": "q1", "Values": []}, - ] - } - specs = [ - {"id": "q0", "namespace": "AWS/S3", "metric_name": "m", "dimensions": []}, - {"id": "q1", "namespace": "AWS/S3", "metric_name": "m", "dimensions": []}, - ] - - result = fetch_latest_metric_values(cloudwatch, specs) - - # ScanBy=TimestampDescending: the first value is the latest datapoint. - self.assertEqual(result, {"q0": 123.0, "q1": None}) - kwargs = cloudwatch.get_metric_data.call_args.kwargs - self.assertEqual(kwargs["ScanBy"], "TimestampDescending") - - def test_merges_values_across_pages(self): - cloudwatch = MagicMock() - cloudwatch.get_metric_data.side_effect = [ - { - "MetricDataResults": [{"Id": "q0", "Values": []}], - "NextToken": "page-2", - }, - {"MetricDataResults": [{"Id": "q0", "Values": [42.0]}]}, - ] - specs = [ - {"id": "q0", "namespace": "AWS/S3", "metric_name": "m", "dimensions": []}, - ] - - result = fetch_latest_metric_values(cloudwatch, specs) - - self.assertEqual(result, {"q0": 42.0}) - - def test_empty_specs_return_empty_dict_without_api_call(self): - cloudwatch = MagicMock() - - self.assertEqual(fetch_latest_metric_values(cloudwatch, []), {}) - cloudwatch.get_metric_data.assert_not_called() - - def test_returns_none_on_client_error(self): - cloudwatch = MagicMock() - cloudwatch.get_metric_data.side_effect = _client_error( - "AccessDenied", "GetMetricData" - ) - specs = [ - {"id": "q0", "namespace": "AWS/S3", "metric_name": "m", "dimensions": []}, - ] - - self.assertIsNone(fetch_latest_metric_values(cloudwatch, specs)) - - -class ListBucketsInRegionTests(unittest.TestCase): - def test_falls_back_to_bucket_region_field_when_filter_unsupported(self): - s3_client = MagicMock() - s3_client.list_buckets.side_effect = [ - _client_error("InvalidRequest", "ListBuckets"), - { - "Buckets": [ - {"Name": "data-eu", "BucketRegion": REGION}, - {"Name": "data-us", "BucketRegion": "us-east-1"}, - ] - }, - ] - - names = _list_buckets_in_region( - s3_client, REGION, TEST_REGISTRY[S3_CODE]["enumeration"] - ) - - self.assertEqual(names, ["data-eu"]) - s3_client.get_bucket_location.assert_not_called() - - def test_falls_back_to_get_bucket_location_when_field_missing(self): - s3_client = MagicMock() - s3_client.list_buckets.side_effect = [ - _client_error("InvalidRequest", "ListBuckets"), - {"Buckets": [{"Name": "data-eu"}, {"Name": "legacy-us"}]}, - ] - s3_client.get_bucket_location.side_effect = lambda Bucket: { - # us-east-1 buckets report None via the legacy API. - "LocationConstraint": REGION if Bucket == "data-eu" else None - } - - names = _list_buckets_in_region( - s3_client, REGION, TEST_REGISTRY[S3_CODE]["enumeration"] - ) - - self.assertEqual(names, ["data-eu"]) - - def test_bucket_is_skipped_when_location_lookup_is_denied(self): - s3_client = MagicMock() - s3_client.list_buckets.side_effect = [ - _client_error("InvalidRequest", "ListBuckets"), - {"Buckets": [{"Name": "no-access"}]}, - ] - s3_client.get_bucket_location.side_effect = _client_error( - "AccessDenied", "GetBucketLocation" - ) - - self.assertEqual( - _list_buckets_in_region( - s3_client, REGION, TEST_REGISTRY[S3_CODE]["enumeration"] - ), - [], - ) - - -class S3BucketCollectorTests(unittest.TestCase): - def _clients(self, bucket_locations, metrics, metric_values): - s3_client = MagicMock() - - def list_buckets(**kwargs): - names = bucket_locations - if "BucketRegion" in kwargs: - names = [ - name - for name, location in bucket_locations.items() - if (location or "us-east-1") == kwargs["BucketRegion"] - ] - return {"Buckets": [{"Name": name} for name in names]} - - s3_client.list_buckets.side_effect = list_buckets - s3_client.get_bucket_location.side_effect = lambda Bucket: { - "LocationConstraint": bucket_locations[Bucket] - } - s3_client.get_bucket_replication.side_effect = _client_error( - "ReplicationConfigurationNotFoundError", "GetBucketReplication" - ) - cloudwatch = MagicMock() - cloudwatch.list_metrics.return_value = {"Metrics": metrics} - cloudwatch.get_metric_data.return_value = { - "MetricDataResults": [ - {"Id": query_id, "Values": [value]} - for query_id, value in metric_values.items() - ] - } - return {"s3": s3_client, "cloudwatch": cloudwatch} - - @staticmethod - def _size_metric(bucket, storage_type): - return { - "Dimensions": [ - {"Name": "BucketName", "Value": bucket}, - {"Name": "StorageType", "Value": storage_type}, - ] - } - - def test_storage_type_split_archive_flag_and_region_filter(self): - clients = self._clients( - bucket_locations={"data-eu": REGION, "data-us": None}, - metrics=[ - self._size_metric("data-eu", "StandardStorage"), - self._size_metric("data-eu", "GlacierStorage"), - self._size_metric("data-eu", "DeepArchiveStorage"), - ], - metric_values={ - "q0": float(50 * GIB), - "q1": float(8 * GIB), - "q2": float(2 * GIB), - }, - ) - entry = TEST_REGISTRY[S3_CODE] - - rows = _collect_s3_buckets(_mock_session(clients), REGION, S3_CODE, entry) - - # The us-east-1 bucket is outside the assessment region, and the - # filter must not rely on GetBucketLocation (not in ViewOnlyAccess). - self.assertEqual([row["name"] for row in rows], ["data-eu"]) - clients["s3"].list_buckets.assert_called_once_with(BucketRegion=REGION) - clients["s3"].get_bucket_location.assert_not_called() - row = rows[0] - self.assertEqual(row["size_bytes"], 60 * GIB) - self.assertEqual( - row["tier_bytes"], - {"Standard": 50 * GIB, "Glacier": 8 * GIB, "Deep Archive": 2 * GIB}, - ) - self.assertTrue(any("restore required" in flag for flag in row["flags"])) - - def test_no_datapoints_records_unknown_size(self): - clients = self._clients( - bucket_locations={"fresh-bucket": REGION}, - metrics=[self._size_metric("fresh-bucket", "StandardStorage")], - metric_values={}, - ) - entry = TEST_REGISTRY[S3_CODE] - - rows = _collect_s3_buckets(_mock_session(clients), REGION, S3_CODE, entry) - - self.assertIsNone(rows[0]["size_bytes"]) - self.assertTrue(rows[0]["size_unknown"]) - - def test_replication_configuration_adds_note(self): - clients = self._clients( - bucket_locations={"replicated": REGION}, - metrics=[self._size_metric("replicated", "StandardStorage")], - metric_values={"q0": float(GIB)}, - ) - clients["s3"].get_bucket_replication.side_effect = None - clients["s3"].get_bucket_replication.return_value = { - "ReplicationConfiguration": {"Rules": [{}]} - } - entry = TEST_REGISTRY[S3_CODE] - - rows = _collect_s3_buckets(_mock_session(clients), REGION, S3_CODE, entry) - - self.assertTrue(any("replication" in note for note in rows[0]["notes"])) - - -class EbsCollectorTests(unittest.TestCase): - def test_volume_size_is_allocated_upper_bound(self): - ec2_client = MagicMock() - _mock_paginator( - ec2_client, - [ - { - "Volumes": [ - { - "VolumeId": "vol-1", - "Size": 100, - "Tags": [{"Key": "Name", "Value": "data-disk"}], - } - ] - } - ], - ) - entry = TEST_REGISTRY[VOLUME_CODE] - - rows = _collect_list_item_size( - _mock_session({"ec2": ec2_client}), REGION, VOLUME_CODE, entry - ) - - self.assertEqual(rows[0]["name"], "data-disk") - self.assertEqual(rows[0]["size_bytes"], 100 * GIB) - self.assertIn("allocated (upper bound)", rows[0]["flags"]) - - def test_snapshots_are_owned_only_and_carry_shared_block_note(self): - ec2_client = MagicMock() - paginator = _mock_paginator( - ec2_client, - [{"Snapshots": [{"SnapshotId": "snap-1", "VolumeSize": 50}]}], - ) - entry = TEST_REGISTRY[SNAPSHOT_CODE] - - rows = _collect_list_item_size( - _mock_session({"ec2": ec2_client}), REGION, SNAPSHOT_CODE, entry - ) - - paginator.paginate.assert_called_once_with(OwnerIds=["self"]) - self.assertEqual(rows[0]["size_bytes"], 50 * GIB) - self.assertTrue(any("shares blocks" in note for note in rows[0]["notes"])) - - def test_owner_filter_comes_from_entry_kwargs_not_the_source(self): - # The master data and the old hardcoded filter agree today, so only a - # changed entry proves the call is actually data-driven. - ec2_client = MagicMock() - paginator = _mock_paginator(ec2_client, [{"Snapshots": []}]) - entry = { - **TEST_REGISTRY[SNAPSHOT_CODE], - "enumeration": { - **TEST_REGISTRY[SNAPSHOT_CODE]["enumeration"], - "kwargs": {"OwnerIds": ["123456"]}, - }, - } - - _collect_list_item_size( - _mock_session({"ec2": ec2_client}), REGION, SNAPSHOT_CODE, entry - ) - - paginator.paginate.assert_called_once_with(OwnerIds=["123456"]) - - def test_entry_without_kwargs_passes_no_extra_arguments(self): - ec2_client = MagicMock() - paginator = _mock_paginator(ec2_client, [{"Snapshots": []}]) - entry = { - **TEST_REGISTRY[SNAPSHOT_CODE], - "enumeration": { - key: value - for key, value in TEST_REGISTRY[SNAPSHOT_CODE]["enumeration"].items() - if key != "kwargs" - }, - } - - _collect_list_item_size( - _mock_session({"ec2": ec2_client}), REGION, SNAPSHOT_CODE, entry - ) - - paginator.paginate.assert_called_once_with() - - -class RdsCollectorTests(unittest.TestCase): - def _clients(self, instances, metric_results): - rds_client = MagicMock() - _mock_paginator(rds_client, [{"DBInstances": instances}]) - cloudwatch = MagicMock() - cloudwatch.get_metric_data.return_value = {"MetricDataResults": metric_results} - return {"rds": rds_client, "cloudwatch": cloudwatch} - - def test_used_space_computed_from_free_storage_space(self): - clients = self._clients( - instances=[ - { - "DBInstanceIdentifier": "appdb", - "Engine": "postgres", - "AllocatedStorage": 100, - } - ], - metric_results=[{"Id": "q0", "Values": [float(40 * GIB)]}], - ) - entry = TEST_REGISTRY[RDS_CODE] - - rows = _collect_rds_instances(_mock_session(clients), REGION, RDS_CODE, entry) - - self.assertEqual(rows[0]["size_bytes"], 60 * GIB) - self.assertTrue(any("allocated" in note for note in rows[0]["notes"])) - self.assertNotIn("allocated (upper bound)", rows[0]["flags"]) - - def test_missing_metric_falls_back_to_allocated_upper_bound(self): - clients = self._clients( - instances=[ - { - "DBInstanceIdentifier": "appdb", - "Engine": "mysql", - "AllocatedStorage": 100, - } - ], - metric_results=[{"Id": "q0", "Values": []}], - ) - entry = TEST_REGISTRY[RDS_CODE] - - rows = _collect_rds_instances(_mock_session(clients), REGION, RDS_CODE, entry) - - self.assertEqual(rows[0]["size_bytes"], 100 * GIB) - self.assertIn("allocated (upper bound)", rows[0]["flags"]) - - def test_aurora_instances_are_flagged_not_sized(self): - clients = self._clients( - instances=[ - { - "DBInstanceIdentifier": "aurora-1", - "Engine": "aurora-postgresql", - "AllocatedStorage": 1, - } - ], - metric_results=[], - ) - entry = TEST_REGISTRY[RDS_CODE] - - rows = _collect_rds_instances(_mock_session(clients), REGION, RDS_CODE, entry) - - self.assertIsNone(rows[0]["size_bytes"]) - self.assertTrue(any("Aurora" in flag for flag in rows[0]["flags"])) - clients["cloudwatch"].get_metric_data.assert_not_called() - - -class DynamoDbCollectorTests(unittest.TestCase): - def test_table_and_index_sizes_are_summed(self): - dynamodb_client = MagicMock() - _mock_paginator(dynamodb_client, [{"TableNames": ["orders"]}]) - dynamodb_client.describe_table.return_value = { - "Table": { - "TableArn": "arn:aws:dynamodb:eu-central-1:1:table/orders", - "TableSizeBytes": 1000, - "GlobalSecondaryIndexes": [ - {"IndexSizeBytes": 300}, - {"IndexSizeBytes": 200}, - ], - } - } - entry = TEST_REGISTRY[DYNAMODB_CODE] - - rows = _collect_dynamodb_tables( - _mock_session({"dynamodb": dynamodb_client}), REGION, DYNAMODB_CODE, entry - ) - - self.assertEqual(rows[0]["size_bytes"], 1500) - - def test_empty_table_is_zero_not_unknown(self): - dynamodb_client = MagicMock() - _mock_paginator(dynamodb_client, [{"TableNames": ["empty"]}]) - dynamodb_client.describe_table.return_value = {"Table": {"TableSizeBytes": 0}} - entry = TEST_REGISTRY[DYNAMODB_CODE] - - rows = _collect_dynamodb_tables( - _mock_session({"dynamodb": dynamodb_client}), REGION, DYNAMODB_CODE, entry - ) - - self.assertEqual(rows[0]["size_bytes"], 0) - self.assertFalse(rows[0]["size_unknown"]) - - -class BackupVaultCollectorTests(unittest.TestCase): - def test_vault_is_flagged_not_sized_with_recovery_point_note(self): - backup_client = MagicMock() - _mock_paginator( - backup_client, - [ - { - "BackupVaultList": [ - {"BackupVaultName": "prod-vault", "NumberOfRecoveryPoints": 12} - ] - } - ], - ) - entry = TEST_REGISTRY[BACKUP_CODE] - - rows = _collect_not_sizeable( - _mock_session({"backup": backup_client}), REGION, BACKUP_CODE, entry - ) - - self.assertIsNone(rows[0]["size_bytes"]) - self.assertFalse(rows[0]["size_unknown"]) - self.assertIn("backup vault – not sized", rows[0]["flags"]) - self.assertTrue(any("12 recovery points" in note for note in rows[0]["notes"])) - - def test_call_comes_from_entry_enumeration_not_the_catalogue_code(self): - # The catalogue row enumerates backup plans for the inventory scan; - # sizing needs the vaults, so the entry overrides the call. - backup_client = MagicMock() - _mock_paginator(backup_client, [{"BackupVaultList": []}]) - session = _mock_session({"backup": backup_client}) - - _collect_not_sizeable(session, REGION, BACKUP_CODE, TEST_REGISTRY[BACKUP_CODE]) - - self.assertEqual(session.client.call_args.args[0], "backup") - backup_client.get_paginator.assert_called_once_with("list_backup_vaults") - - def test_enumeration_drives_a_different_service_and_operation(self): - other_client = MagicMock() - _mock_paginator(other_client, [{"Vaults": [{"BackupVaultName": "v1"}]}]) - session = _mock_session({"otherservice": other_client}) - entry = { - **TEST_REGISTRY[BACKUP_CODE], - "enumeration": { - "service": "otherservice", - "operation": "list_vaults", - "result_path": ["Vaults"], - }, - } - - rows = _collect_not_sizeable(session, REGION, BACKUP_CODE, entry) - - self.assertEqual(session.client.call_args.args[0], "otherservice") - other_client.get_paginator.assert_called_once_with("list_vaults") - self.assertEqual([row["name"] for row in rows], ["v1"]) - - def test_enumeration_kwargs_are_forwarded(self): - backup_client = MagicMock() - paginator = _mock_paginator(backup_client, [{"BackupVaultList": []}]) - entry = { - **TEST_REGISTRY[BACKUP_CODE], - "enumeration": { - **TEST_REGISTRY[BACKUP_CODE]["enumeration"], - "kwargs": {"ByVaultType": "BACKUP_VAULT"}, - }, - } - - _collect_not_sizeable( - _mock_session({"backup": backup_client}), REGION, BACKUP_CODE, entry - ) - - paginator.paginate.assert_called_once_with(ByVaultType="BACKUP_VAULT") - - def test_nested_result_path_is_walked(self): - backup_client = MagicMock() - _mock_paginator( - backup_client, [{"Outer": {"Inner": [{"BackupVaultName": "nested"}]}}] - ) - entry = { - **TEST_REGISTRY[BACKUP_CODE], - "enumeration": { - "service": "backup", - "operation": "list_backup_vaults", - "result_path": ["Outer", "Inner"], - }, - } - - rows = _collect_not_sizeable( - _mock_session({"backup": backup_client}), REGION, BACKUP_CODE, entry - ) - - self.assertEqual([row["name"] for row in rows], ["nested"]) - - -class CollectAwsEgressTests(unittest.TestCase): - _PROVIDER_DETAILS = { - "accessKey": "AKIAIOSFODNN7EXAMPLE", - "secretKey": "secret", - "region": REGION, - } - - def _empty_clients(self): - s3_client = MagicMock() - s3_client.list_buckets.return_value = {"Buckets": []} - clients = {"s3": s3_client, "cloudwatch": MagicMock()} - for service, result_key in ( - ("ec2", "Volumes"), - ("rds", "DBInstances"), - ("dynamodb", "TableNames"), - ("backup", "BackupVaultList"), - ): - client = MagicMock() - _mock_paginator(client, [{result_key: []}]) - clients[service] = client - return clients - - @patch("core.utils_egress_aws.boto3") - def test_returns_rows_and_archive_tiers(self, mock_boto3): - clients = self._empty_clients() - mock_boto3.Session.return_value = _mock_session(clients) - - rows, archive_tiers = collect_aws_egress(self._PROVIDER_DETAILS) - - self.assertEqual(rows, []) - self.assertEqual(archive_tiers, {"Archive", "Glacier", "Deep Archive"}) - mock_boto3.Session.assert_called_once_with( - aws_access_key_id="AKIAIOSFODNN7EXAMPLE", - aws_secret_access_key="secret", - aws_session_token=None, - region_name=REGION, - ) - - @patch("core.utils_egress_aws.boto3") - def test_one_failing_service_does_not_fail_the_stage(self, mock_boto3): - clients = self._empty_clients() - clients["s3"].list_buckets.side_effect = _client_error( - "AccessDenied", "ListBuckets" - ) - volume_paginator = MagicMock() - volume_paginator.paginate.return_value = [ - {"Volumes": [{"VolumeId": "vol-1", "Size": 10}]} - ] - clients["ec2"].get_paginator.return_value = volume_paginator - mock_boto3.Session.return_value = _mock_session(clients) - - rows, _ = collect_aws_egress(self._PROVIDER_DETAILS) - - # Volumes and snapshots share the ec2 paginator mock here; the point - # is that the S3 failure is contained and other rows still arrive. - self.assertTrue(any(row["id"] == "vol-1" for row in rows)) - - -class UnknownStrategyTests(unittest.TestCase): - @patch("core.utils_egress_aws.boto3") - def test_unknown_strategy_warns_and_skips_without_raising(self, mock_boto3): - registry = { - "AWS.future.list_things.Things": { - "category": "object", - "label": "Something New", - "strategy": "not_implemented_yet", - }, - VOLUME_CODE: TEST_REGISTRY[VOLUME_CODE], - } - ec2_client = MagicMock() - _mock_paginator(ec2_client, [{"Volumes": [{"VolumeId": "vol-1", "Size": 10}]}]) - mock_boto3.Session.return_value = _mock_session({"ec2": ec2_client}) - - with patch("core.utils_egress_aws.load_egress_registry", return_value=registry): - with self.assertLogs("core.engine.egress.aws", level="WARNING") as captured: - rows, _ = collect_aws_egress( - { - "accessKey": "AKIAIOSFODNN7EXAMPLE", - "secretKey": "secret", - "region": REGION, - } - ) - - # The known strategy still runs; only the unknown one is skipped. - self.assertEqual([row["id"] for row in rows], ["vol-1"]) - self.assertIn("not_implemented_yet", captured.output[0]) - - -class CollectorFailureVisibilityTests(unittest.TestCase): - @patch("core.utils_egress_aws.boto3") - def test_a_dropped_resource_type_is_warned_not_buried_at_debug(self, mock_boto3): - # A master-data row missing its sizing block removes a whole resource - # type from the estimate; that must be visible in run.log. - registry = {VOLUME_CODE: {**TEST_REGISTRY[VOLUME_CODE]}} - del registry[VOLUME_CODE]["sizing"] - ec2_client = MagicMock() - _mock_paginator(ec2_client, [{"Volumes": [{"VolumeId": "vol-1", "Size": 10}]}]) - mock_boto3.Session.return_value = _mock_session({"ec2": ec2_client}) - - with patch("core.utils_egress_aws.load_egress_registry", return_value=registry): - with self.assertLogs("core.engine.egress.aws", level="WARNING") as captured: - rows, _ = collect_aws_egress( - { - "accessKey": "AKIAIOSFODNN7EXAMPLE", - "secretKey": "secret", - "region": REGION, - } - ) - - self.assertEqual(rows, []) - self.assertIn(VOLUME_CODE, captured.output[0]) - self.assertIn("KeyError", captured.output[0]) - self.assertIn("missing from the estimate", captured.output[0]) - - -class ParameterisedSizingTests(unittest.TestCase): - def _entry(self, sizing, result_key="FileSystems"): - return { - "category": "block", - "label": "Elastic File System", - "strategy": "ebs_volumes", - "enumeration": { - "service": "elasticfilesystem", - "operation": "describe_file_systems", - "result_path": [result_key], - }, - "sizing": sizing, - } - - def _collect(self, entry, items, result_key="FileSystems"): - client = MagicMock() - _mock_paginator(client, [{result_key: items}]) - return _collect_list_item_size( - _mock_session({"elasticfilesystem": client}), REGION, "AWS.efs", entry - ) - - def test_nested_size_field_and_byte_unit(self): - entry = self._entry( - { - "id_field": "FileSystemId", - "name_field": "Name", - "size_field": ["SizeInBytes", "Value"], - "size_unit": "B", - "flags": ["metered size"], - } - ) - - rows = self._collect( - entry, - [ - { - "FileSystemId": "fs-1", - "Name": "shared", - "SizeInBytes": {"Value": 4096}, - } - ], - ) - - self.assertEqual(rows[0]["size_bytes"], 4096) - self.assertEqual(rows[0]["name"], "shared") - self.assertEqual(rows[0]["flags"], ["metered size"]) - - def test_missing_nested_size_is_unknown_not_zero(self): - entry = self._entry( - { - "id_field": "FileSystemId", - "size_field": ["SizeInBytes", "Value"], - "size_unit": "B", - } - ) - - rows = self._collect(entry, [{"FileSystemId": "fs-1"}]) - - self.assertIsNone(rows[0]["size_bytes"]) - self.assertTrue(rows[0]["size_unknown"]) - - def test_flags_and_notes_are_withheld_when_size_is_unknown(self): - entry = self._entry( - { - "id_field": "FileSystemId", - "size_field": "Size", - "flags": ["allocated"], - "notes": ["incremental"], - } - ) - - rows = self._collect(entry, [{"FileSystemId": "fs-1"}]) - - self.assertEqual(rows[0]["flags"], []) - self.assertEqual(rows[0]["notes"], []) - - def test_name_tag_falls_back_to_the_identifier(self): - entry = self._entry( - {"id_field": "FileSystemId", "name_tag": "Name", "size_field": "Size"} - ) - - rows = self._collect( - entry, [{"FileSystemId": "fs-1", "Tags": [{"Key": "env", "Value": "prod"}]}] - ) - - self.assertEqual(rows[0]["name"], "fs-1") - - def test_arn_field_falls_back_to_the_identifier_when_absent(self): - client = MagicMock() - _mock_paginator(client, [{"Vaults": [{"VaultName": "v1"}]}]) - entry = { - "category": "backup", - "label": "Some Vault", - "strategy": "backup_vaults", - "enumeration": { - "service": "glacier", - "operation": "list_vaults", - "result_path": ["Vaults"], - }, - "sizing": { - "id_field": "VaultName", - "arn_field": "VaultARN", - "flags": ["not sized"], - "count_field": "NumberOfArchives", - "count_note": "{count} archives held", - }, - } - - rows = _collect_not_sizeable( - _mock_session({"glacier": client}), REGION, "AWS.glacier", entry - ) - - self.assertEqual(rows[0]["id"], "v1") - self.assertEqual(rows[0]["flags"], ["not sized"]) - self.assertEqual(rows[0]["notes"], []) - - def test_count_note_template_is_filled_from_the_item(self): - client = MagicMock() - _mock_paginator( - client, [{"Vaults": [{"VaultName": "v1", "NumberOfArchives": 7}]}] - ) - entry = { - "category": "backup", - "label": "Some Vault", - "strategy": "backup_vaults", - "enumeration": { - "service": "glacier", - "operation": "list_vaults", - "result_path": ["Vaults"], - }, - "sizing": { - "id_field": "VaultName", - "flags": [], - "count_field": "NumberOfArchives", - "count_note": "{count} archives held", - }, - } - - rows = _collect_not_sizeable( - _mock_session({"glacier": client}), REGION, "AWS.glacier", entry - ) - - self.assertEqual(rows[0]["notes"], ["7 archives held"]) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_utils_egress_azure.py b/tests/test_utils_egress_azure.py deleted file mode 100644 index 97c8ff6..0000000 --- a/tests/test_utils_egress_azure.py +++ /dev/null @@ -1,435 +0,0 @@ -# tests/test_utils_egress_azure.py -import unittest -from unittest.mock import MagicMock, patch - -import requests - -from core.utils_egress import GIB, format_bytes -from core.utils_egress_azure import ( - build_egress_inventory, - collect_azure_egress, - fetch_monitor_metrics, - filter_data_bearing_resources, -) - -# Stands in for what load_egress_registry() builds out of resourcetype_data, -# so these tests exercise the engine rather than the shipped master data. -TEST_REGISTRY = { - "microsoft.storage/storageaccounts": { - "category": "object", - "label": "Storage Account", - "strategy": "storage_account_metrics", - }, - "microsoft.compute/disks": { - "category": "block", - "label": "Managed Disks", - "strategy": "allocated_size_property", - "api_version": "2024-03-02", - "size_property": "diskSizeGB", - }, - "microsoft.compute/snapshots": { - "category": "block", - "label": "Managed Disk Snapshots", - "strategy": "allocated_size_property", - "api_version": "2024-03-02", - "size_property": "diskSizeGB", - }, - "microsoft.sql/servers/databases": { - "category": "database", - "label": "Azure SQL Database", - "strategy": "monitor_metric", - "metrics": ["storage"], - }, - "microsoft.documentdb/databaseaccounts": { - "category": "database", - "label": "Azure Cosmos DB", - "strategy": "monitor_metric", - "metrics": ["DataUsage", "IndexUsage"], - }, - "microsoft.dbforpostgresql/flexibleservers": { - "category": "database", - "label": "Azure Database for PostgreSQL", - "strategy": "monitor_metric", - "metrics": ["storage_used"], - }, - "microsoft.dbformysql/flexibleservers": { - "category": "database", - "label": "Azure Database for MySQL", - "strategy": "monitor_metric", - "metrics": ["storage_used"], - }, - "microsoft.recoveryservices/vaults": { - "category": "backup", - "label": "Azure Site Recovery", - "strategy": "vault_warning", - }, -} - -_REGISTRY_PATCHER = None - - -def setUpModule(): - global _REGISTRY_PATCHER - _REGISTRY_PATCHER = patch( - "core.utils_egress_azure.load_egress_registry", - return_value=TEST_REGISTRY, - ) - _REGISTRY_PATCHER.start() - - -def tearDownModule(): - _REGISTRY_PATCHER.stop() - - -def _mock_resource(resource_type, name, resource_id, sku_name=None): - resource = MagicMock() - resource.type = resource_type - resource.name = name - resource.id = resource_id - if sku_name: - resource.sku = MagicMock() - resource.sku.name = sku_name - else: - resource.sku = None - return resource - - -def _mock_credential(): - credential = MagicMock() - credential.get_token.return_value = MagicMock(token="fake-token") - return credential - - -class RegistryFilteringTests(unittest.TestCase): - def test_picks_data_bearing_types_out_of_mixed_list(self): - resources = [ - _mock_resource("Microsoft.Storage/storageAccounts", "sa1", "/sa1"), - _mock_resource("Microsoft.Network/virtualNetworks", "vnet1", "/vnet1"), - _mock_resource("Microsoft.Compute/disks", "disk1", "/disk1"), - _mock_resource("Microsoft.Compute/virtualMachines", "vm1", "/vm1"), - _mock_resource("Microsoft.RecoveryServices/vaults", "vault1", "/vault1"), - ] - - matched = filter_data_bearing_resources(resources, TEST_REGISTRY) - matched_names = [resource.name for resource, _ in matched] - - self.assertEqual(matched_names, ["sa1", "disk1", "vault1"]) - - def test_matching_is_case_insensitive(self): - resources = [ - _mock_resource("MICROSOFT.STORAGE/StorageAccounts", "sa1", "/sa1"), - ] - - matched = filter_data_bearing_resources(resources, TEST_REGISTRY) - - self.assertEqual(len(matched), 1) - self.assertEqual(matched[0][1]["strategy"], "storage_account_metrics") - - def test_returns_empty_for_no_data_bearing_resources(self): - resources = [ - _mock_resource("Microsoft.Network/networkInterfaces", "nic1", "/nic1"), - ] - - self.assertEqual(filter_data_bearing_resources(resources, TEST_REGISTRY), []) - - -class FetchMonitorMetricsTests(unittest.TestCase): - @patch("core.utils_egress_azure.requests.get") - def test_parses_normal_response_using_latest_datapoint(self, mock_get): - mock_response = MagicMock() - mock_response.json.return_value = { - "value": [ - { - "name": {"value": "UsedCapacity"}, - "timeseries": [ - { - "metadatavalues": [], - "data": [ - {"average": 50.0}, - {"average": 123.0}, - {"average": None}, - ], - } - ], - } - ] - } - mock_get.return_value = mock_response - - result = fetch_monitor_metrics(_mock_credential(), "/sa1", ["UsedCapacity"]) - - self.assertEqual( - result, {"UsedCapacity": [{"dimension": None, "value": 123.0}]} - ) - - @patch("core.utils_egress_azure.requests.get") - def test_parses_dimension_split_response(self, mock_get): - mock_response = MagicMock() - mock_response.json.return_value = { - "value": [ - { - "name": {"value": "BlobCapacity"}, - "timeseries": [ - { - "metadatavalues": [ - {"name": {"value": "tier"}, "value": "Hot"} - ], - "data": [{"average": 10.0}], - }, - { - "metadatavalues": [ - {"name": {"value": "tier"}, "value": "Archive"} - ], - "data": [{"average": 20.0}], - }, - ], - } - ] - } - mock_get.return_value = mock_response - - result = fetch_monitor_metrics( - _mock_credential(), - "/sa1/blobServices/default", - ["BlobCapacity"], - dimension="Tier", - ) - - self.assertEqual( - result, - { - "BlobCapacity": [ - {"dimension": "Hot", "value": 10.0}, - {"dimension": "Archive", "value": 20.0}, - ] - }, - ) - params = mock_get.call_args.kwargs["params"] - self.assertEqual(params["$filter"], "Tier eq '*'") - - @patch("core.utils_egress_azure.requests.get") - def test_handles_empty_series_without_treating_as_zero(self, mock_get): - mock_response = MagicMock() - mock_response.json.return_value = { - "value": [{"name": {"value": "storage"}, "timeseries": []}] - } - mock_get.return_value = mock_response - - result = fetch_monitor_metrics(_mock_credential(), "/db1", ["storage"]) - - self.assertEqual(result, {"storage": []}) - - @patch("core.utils_egress_azure.requests.get") - def test_handles_missing_datapoints_without_treating_as_zero(self, mock_get): - mock_response = MagicMock() - mock_response.json.return_value = { - "value": [ - { - "name": {"value": "storage"}, - "timeseries": [{"metadatavalues": [], "data": [{"average": None}]}], - } - ] - } - mock_get.return_value = mock_response - - result = fetch_monitor_metrics(_mock_credential(), "/db1", ["storage"]) - - self.assertEqual(result, {"storage": []}) - - @patch("core.utils_egress_azure.requests.get") - def test_returns_none_on_http_error(self, mock_get): - mock_response = MagicMock() - mock_response.raise_for_status.side_effect = requests.HTTPError("403") - mock_get.return_value = mock_response - - result = fetch_monitor_metrics(_mock_credential(), "/sa1", ["UsedCapacity"]) - - self.assertIsNone(result) - - @patch("core.utils_egress_azure.requests.get") - def test_returns_none_on_connection_error(self, mock_get): - mock_get.side_effect = requests.ConnectionError("network down") - - result = fetch_monitor_metrics(_mock_credential(), "/sa1", ["UsedCapacity"]) - - self.assertIsNone(result) - - -class StorageAccountTierSplitTests(unittest.TestCase): - @patch("core.utils_egress_azure.fetch_monitor_metrics") - def test_hot_cool_archive_split_and_archive_flag(self, mock_fetch): - mock_fetch.side_effect = [ - {"UsedCapacity": [{"dimension": None, "value": float(60 * GIB)}]}, - { - "BlobCapacity": [ - {"dimension": "Hot", "value": float(30 * GIB)}, - {"dimension": "Cool", "value": float(20 * GIB)}, - {"dimension": "Archive", "value": float(10 * GIB)}, - ] - }, - ] - resource = _mock_resource( - "Microsoft.Storage/storageAccounts", "sa1", "/sa1", sku_name="Standard_LRS" - ) - - rows, findings = build_egress_inventory(MagicMock(), MagicMock(), [resource]) - - self.assertEqual(len(rows), 1) - row = rows[0] - self.assertEqual(row["size_bytes"], 60 * GIB) - self.assertEqual( - row["tier_bytes"], - {"Hot": 30 * GIB, "Cool": 20 * GIB, "Archive": 10 * GIB}, - ) - self.assertTrue(any("Archive" in flag for flag in row["flags"])) - self.assertEqual(findings, []) - - @patch("core.utils_egress_azure.fetch_monitor_metrics") - def test_geo_replicated_sku_adds_informational_finding(self, mock_fetch): - mock_fetch.side_effect = [ - {"UsedCapacity": [{"dimension": None, "value": float(GIB)}]}, - {"BlobCapacity": []}, - ] - resource = _mock_resource( - "Microsoft.Storage/storageAccounts", - "sa1", - "/sa1", - sku_name="Standard_RAGRS", - ) - - rows, findings = build_egress_inventory(MagicMock(), MagicMock(), [resource]) - - self.assertTrue(any("geo-replicated" in note for note in rows[0]["notes"])) - info_findings = [f for f in findings if f["severity"] == "info"] - self.assertEqual(len(info_findings), 1) - self.assertIn("not additional data to egress", info_findings[0]["message"]) - - @patch("core.utils_egress_azure.fetch_monitor_metrics", return_value=None) - def test_no_datapoints_records_unknown_size(self, mock_fetch): - resource = _mock_resource("Microsoft.Storage/storageAccounts", "sa1", "/sa1") - - rows, findings = build_egress_inventory(MagicMock(), MagicMock(), [resource]) - - self.assertIsNone(rows[0]["size_bytes"]) - self.assertTrue(rows[0]["size_unknown"]) - self.assertEqual(format_bytes(rows[0]["size_bytes"]), "n/a") - self.assertTrue(any("size unknown" in f["message"] for f in findings)) - - -class AllocatedSizeTests(unittest.TestCase): - def test_disk_size_read_from_property_and_flagged_as_upper_bound(self): - resource = _mock_resource("Microsoft.Compute/disks", "disk1", "/disk1") - resource_client = MagicMock() - full_resource = MagicMock() - full_resource.properties = {"diskSizeGB": 128} - resource_client.resources.get_by_id.return_value = full_resource - - rows, findings = build_egress_inventory( - MagicMock(), resource_client, [resource] - ) - - self.assertEqual(rows[0]["size_bytes"], 128 * GIB) - self.assertIn("allocated (upper bound)", rows[0]["flags"]) - self.assertEqual(findings, []) - - def test_collector_error_is_contained_as_finding(self): - resource = _mock_resource("Microsoft.Compute/disks", "disk1", "/disk1") - resource_client = MagicMock() - resource_client.resources.get_by_id.side_effect = RuntimeError("api error") - - rows, findings = build_egress_inventory( - MagicMock(), resource_client, [resource] - ) - - self.assertIsNone(rows[0]["size_bytes"]) - self.assertIn("size unavailable", rows[0]["flags"]) - self.assertTrue(any("size lookup failed" in f["message"] for f in findings)) - - -class VaultDetectionTests(unittest.TestCase): - @patch("core.utils_egress_azure.fetch_monitor_metrics") - def test_vault_produces_warning_finding_and_no_size(self, mock_fetch): - resource = _mock_resource("Microsoft.RecoveryServices/vaults", "vault1", "/v1") - resource_client = MagicMock() - - rows, findings = build_egress_inventory( - MagicMock(), resource_client, [resource] - ) - - self.assertIsNone(rows[0]["size_bytes"]) - self.assertFalse(rows[0]["size_unknown"]) - warnings = [f for f in findings if f["severity"] == "warning"] - self.assertEqual(len(warnings), 1) - self.assertIn("cannot be exported from Azure", warnings[0]["message"]) - mock_fetch.assert_not_called() - resource_client.resources.get_by_id.assert_not_called() - - -class CollectAzureEgressTests(unittest.TestCase): - @patch("core.utils_egress_azure.fetch_monitor_metrics") - @patch("core.utils_egress_azure.ResourceManagementClient") - def test_returns_rows_and_archive_tiers(self, mock_rmc_cls, mock_fetch): - mock_fetch.side_effect = [ - {"UsedCapacity": [{"dimension": None, "value": float(10 * GIB)}]}, - {"BlobCapacity": []}, - ] - resource = _mock_resource("Microsoft.Storage/storageAccounts", "sa1", "/sa1") - mock_client = MagicMock() - mock_client.resources.list_by_resource_group.return_value = [resource] - mock_rmc_cls.return_value = mock_client - - rows, archive_tiers = collect_azure_egress( - { - "credential": _mock_credential(), - "subscriptionId": "sub-id", - "resourceGroupName": "rg", - } - ) - - self.assertEqual(len(rows), 1) - self.assertEqual(rows[0]["name"], "sa1") - self.assertEqual(rows[0]["size_bytes"], 10 * GIB) - self.assertNotIn("findings", rows[0]) - self.assertEqual(archive_tiers, {"Archive"}) - - -class UnknownStrategyTests(unittest.TestCase): - @patch("core.utils_egress_azure.fetch_monitor_metrics") - def test_unknown_strategy_warns_and_skips_without_raising(self, mock_fetch): - mock_fetch.side_effect = [ - {"UsedCapacity": [{"dimension": None, "value": float(GIB)}]}, - {"BlobCapacity": []}, - ] - registry = { - "microsoft.future/widgets": { - "category": "object", - "label": "Something New", - "strategy": "not_implemented_yet", - }, - "microsoft.storage/storageaccounts": TEST_REGISTRY[ - "microsoft.storage/storageaccounts" - ], - } - resources = [ - _mock_resource("Microsoft.Future/widgets", "widget1", "/w1"), - _mock_resource("Microsoft.Storage/storageAccounts", "sa1", "/sa1"), - ] - - with patch( - "core.utils_egress_azure.load_egress_registry", return_value=registry - ): - with self.assertLogs( - "core.engine.egress.azure", level="WARNING" - ) as captured: - rows, findings = build_egress_inventory( - MagicMock(), MagicMock(), resources - ) - - # The known strategy still runs; only the unknown one is skipped. - self.assertEqual([row["name"] for row in rows], ["sa1"]) - self.assertEqual(rows[0]["size_bytes"], GIB) - self.assertIn("not_implemented_yet", captured.output[0]) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_utils_egress_registry.py b/tests/test_utils_egress_registry.py deleted file mode 100644 index 17f7b81..0000000 --- a/tests/test_utils_egress_registry.py +++ /dev/null @@ -1,281 +0,0 @@ -# tests/test_utils_egress_registry.py -import os -import sqlite3 -import tempfile -import unittest -from unittest.mock import MagicMock, patch - -from core.utils_db import load_data -from core.utils_egress import estimate_egress, load_egress_registry -from core.utils_egress_azure import filter_data_bearing_resources - -# (id, csp, code, name, icon, status) -RESOURCE_TYPES = [ - (1, 1, "Microsoft.Storage/storageAccounts", "Storage Account", "sa.png", "t"), - # Parent status 'f': stays out of the inventory catalogue but still holds - # data that has to be egressed. - (2, 1, "Microsoft.Compute/disks", "Managed Disks", "disk.png", "f"), - (3, 1, "Microsoft.Network/virtualNetworks", "Virtual Network", "vnet.png", "t"), - (4, 2, "AWS.s3.list_buckets.Buckets", "S3 (Simple Storage Service)", "s3.png", "t"), - (5, 2, "AWS.backup.list_backup_plans.BackupPlansList", "Backup", "bk.png", "t"), -] - -# (id, resource_type, data_category, strategy, params, status) -RESOURCE_TYPE_DATA = [ - (1, 1, "object", "storage_account_metrics", "{}", "t"), - ( - 2, - 2, - "block", - "allocated_size_property", - '{"api_version": "2024-03-02", "size_property": "diskSizeGB"}', - "t", - ), - # Own status 'f': switched off, must never reach the registry. - (3, 3, "network", "should_never_load", "{}", "f"), - (4, 4, "object", "s3_bucket_metrics", "{}", "t"), - ( - 5, - 5, - "backup", - "backup_vaults", - '{"enumeration": {"service": "backup", ' - '"operation": "list_backup_vaults", "result_path": ["BackupVaultList"]}}', - "t", - ), -] - -_SCHEMA = """ -CREATE TABLE resourcetype ( - id INTEGER PRIMARY KEY, - csp INTEGER NOT NULL, - code TEXT NOT NULL, - name TEXT NOT NULL, - icon TEXT NOT NULL, - status TEXT NOT NULL -); -CREATE TABLE resourcetype_data ( - id INTEGER PRIMARY KEY, - resource_type INTEGER NOT NULL, - data_category TEXT NOT NULL, - strategy TEXT NOT NULL, - params TEXT, - status TEXT NOT NULL -); -""" - - -def _make_db(path, *, with_data_table=True, data_rows=RESOURCE_TYPE_DATA): - conn = sqlite3.connect(path) - schema = _SCHEMA - if not with_data_table: - schema = _SCHEMA[: _SCHEMA.index("CREATE TABLE resourcetype_data")] - conn.executescript(schema) - conn.executemany("INSERT INTO resourcetype VALUES (?,?,?,?,?,?)", RESOURCE_TYPES) - if with_data_table: - conn.executemany( - "INSERT INTO resourcetype_data VALUES (?,?,?,?,?,?)", data_rows - ) - conn.commit() - conn.close() - - -class _FixtureDbTests(unittest.TestCase): - WITH_DATA_TABLE = True - DATA_ROWS = RESOURCE_TYPE_DATA - - def setUp(self): - handle, self.db_path = tempfile.mkstemp(suffix=".db") - os.close(handle) - self.addCleanup(os.unlink, self.db_path) - _make_db( - self.db_path, - with_data_table=self.WITH_DATA_TABLE, - data_rows=self.DATA_ROWS, - ) - patcher = patch( - "core.utils_egress.load_data", - side_effect=lambda table: load_data(table, db_path=self.db_path), - ) - self.addCleanup(patcher.stop) - patcher.start() - - -class RegistryConstructionTests(_FixtureDbTests): - def test_azure_registry_joins_and_keys_by_lowercased_code(self): - registry = load_egress_registry(1) - - self.assertEqual( - sorted(registry), - ["microsoft.compute/disks", "microsoft.storage/storageaccounts"], - ) - - def test_aws_registry_keeps_code_verbatim(self): - registry = load_egress_registry(2) - - self.assertEqual( - sorted(registry), - [ - "AWS.backup.list_backup_plans.BackupPlansList", - "AWS.s3.list_buckets.Buckets", - ], - ) - - def test_params_are_merged_into_the_entry(self): - entry = load_egress_registry(1)["microsoft.compute/disks"] - - self.assertEqual(entry["api_version"], "2024-03-02") - self.assertEqual(entry["size_property"], "diskSizeGB") - - def test_label_comes_from_resourcetype_name(self): - registry = load_egress_registry(2) - - self.assertEqual( - registry["AWS.s3.list_buckets.Buckets"]["label"], - "S3 (Simple Storage Service)", - ) - - def test_category_and_strategy_come_from_resourcetype_data(self): - entry = load_egress_registry(1)["microsoft.storage/storageaccounts"] - - self.assertEqual(entry["category"], "object") - self.assertEqual(entry["strategy"], "storage_account_metrics") - - def test_enumeration_params_survive_the_merge(self): - registry = load_egress_registry(2) - entry = registry["AWS.backup.list_backup_plans.BackupPlansList"] - - self.assertEqual( - entry["enumeration"], - { - "service": "backup", - "operation": "list_backup_vaults", - "result_path": ["BackupVaultList"], - }, - ) - - def test_other_csp_rows_are_not_included(self): - self.assertNotIn("AWS.s3.list_buckets.Buckets", load_egress_registry(1)) - self.assertNotIn("microsoft.compute/disks", load_egress_registry(2)) - - -class ParamsCannotShadowCanonicalKeysTests(_FixtureDbTests): - DATA_ROWS = [ - ( - 1, - 1, - "object", - "storage_account_metrics", - '{"label": "hijacked", "category": "hijacked", "strategy": "hijacked"}', - "t", - ) - ] - - def test_canonical_keys_win_over_params(self): - entry = load_egress_registry(1)["microsoft.storage/storageaccounts"] - - self.assertEqual(entry["label"], "Storage Account") - self.assertEqual(entry["category"], "object") - self.assertEqual(entry["strategy"], "storage_account_metrics") - - -class StatusFilteringTests(_FixtureDbTests): - def test_row_with_disabled_parent_resourcetype_is_included(self): - # 513/514/515 ship with resourcetype.status = 'f' on purpose; filtering - # on it would drop the whole block category from the estimate. - registry = load_egress_registry(1) - - self.assertIn("microsoft.compute/disks", registry) - self.assertEqual(registry["microsoft.compute/disks"]["label"], "Managed Disks") - - def test_row_with_disabled_resourcetype_data_is_excluded(self): - registry = load_egress_registry(1) - - self.assertNotIn("microsoft.network/virtualnetworks", registry) - strategies = [entry["strategy"] for entry in registry.values()] - self.assertNotIn("should_never_load", strategies) - - -class AzureKeyMatchingTests(_FixtureDbTests): - def test_lowercased_key_matches_an_arm_resource_type(self): - registry = load_egress_registry(1) - resource = MagicMock() - # ARM returns the provider namespace in its own casing. - resource.type = "Microsoft.Storage/storageAccounts" - resource.name = "sa1" - - matched = filter_data_bearing_resources([resource], registry) - - self.assertEqual(len(matched), 1) - self.assertEqual(matched[0][1]["strategy"], "storage_account_metrics") - self.assertEqual(matched[0][1]["label"], "Storage Account") - - -class MissingTableTests(_FixtureDbTests): - WITH_DATA_TABLE = False - - def test_missing_table_fails_the_stage(self): - # There is no built-in fallback: without master data the estimate would - # be silently wrong, so the egress stage has to fail instead. - with self.assertRaises(sqlite3.Error): - load_egress_registry(1) - - def test_estimate_egress_reports_the_failure_instead_of_raising(self): - with patch( - "core.utils_egress_azure.collect_azure_egress", - side_effect=sqlite3.OperationalError("no such table: resourcetype_data"), - ): - result = estimate_egress( - 1, - {}, - tempfile.gettempdir(), - report_path=tempfile.gettempdir(), - name="acme", - exit_strategy=1, - assessment_type=1, - ) - - self.assertFalse(result["success"]) - self.assertIn("resourcetype_data", result["logs"]) - - -class EmptyRegistryTests(_FixtureDbTests): - DATA_ROWS = [] - - def test_no_matching_rows_raises(self): - with self.assertRaises(ValueError) as ctx: - load_egress_registry(2) - - self.assertIn("CSP 2", str(ctx.exception)) - - -class OtherCspOnlyTests(_FixtureDbTests): - DATA_ROWS = [(1, 1, "object", "storage_account_metrics", "{}", "t")] - - def test_csp_without_enabled_rows_raises_even_when_another_csp_has_them(self): - self.assertIn("microsoft.storage/storageaccounts", load_egress_registry(1)) - with self.assertRaises(ValueError): - load_egress_registry(2) - - -class MalformedParamsTests(_FixtureDbTests): - DATA_ROWS = [ - (1, 1, "object", "storage_account_metrics", "{not json", "t"), - (2, 2, "block", "allocated_size_property", None, "t"), - ] - - def test_bad_params_degrade_to_empty_without_dropping_the_row(self): - registry = load_egress_registry(1) - - self.assertEqual( - sorted(registry), - ["microsoft.compute/disks", "microsoft.storage/storageaccounts"], - ) - self.assertEqual( - registry["microsoft.storage/storageaccounts"]["strategy"], - "storage_account_metrics", - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_utils_report_egress.py b/tests/test_utils_report_egress.py deleted file mode 100644 index cbd4c54..0000000 --- a/tests/test_utils_report_egress.py +++ /dev/null @@ -1,970 +0,0 @@ -# tests/test_utils_report_egress.py -import json -import os -import pathlib -import sqlite3 -import tempfile -import unittest -from unittest.mock import patch - -from reportlab.graphics.shapes import Drawing -from reportlab.platypus import PageBreak, Paragraph, Table - -from tests.report_fixtures import stage_report_assets - -from core.utils_egress import GIB, write_egress_inventory -from core.utils_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, - calculate_tiered_cost, - format_fee, - free_tier_limit_display, - generate_egress_html_report, - generate_egress_pdf_report, - load_pricing, -) - - -def _price_row( - csp, component, tier_from, tier_to, price, unit="GiB", zone="zone1", valid_to=None -): - return { - "id": 0, - "csp": csp, - "component": component, - "zone": zone, - "tier_from": tier_from, - "tier_to": tier_to, - "price_per_unit": price, - "currency": "USD", - "unit": unit, - "valid_from": "2026-07-01", - "valid_to": valid_to, - } - - -AZURE_INTERNET_TIERS = [ - _price_row(1, "internet_egress", 0, 100, 0.0), - _price_row(1, "internet_egress", 100, 10240, 0.087), - _price_row(1, "internet_egress", 10240, 51200, 0.083), - _price_row(1, "internet_egress", 51200, 153600, 0.07), - _price_row(1, "internet_egress", 153600, None, 0.05), -] - -SIMPLE_PRICING = [ - _price_row(1, "internet_egress", 0, 100, 0.0), - _price_row(1, "internet_egress", 100, None, 0.1), - _price_row(1, "archive_retrieval", 0, None, 0.02), -] - -RENDER_PRICING = AZURE_INTERNET_TIERS + [ - _price_row(1, "archive_retrieval", 0, None, 0.02), -] - - -def _fake_load_data(table_name, db_path=None): - if table_name == "egresspricing": - return list(RENDER_PRICING) - return [] - - -def _row(name, label, category, size_bytes, resource_type="some/type", **overrides): - row = { - "id": f"/{name}", - "name": name, - "type": resource_type, - "label": label, - "category": category, - "size_bytes": size_bytes, - "size_unknown": False, - "tier_bytes": None, - "flags": [], - "notes": [], - } - row.update(overrides) - return row - - -def _totals(rows, unknown_count=0, archive_tier_bytes=0): - return { - "known_size_bytes": sum( - row["size_bytes"] for row in rows if row["size_bytes"] is not None - ), - "archive_tier_bytes": archive_tier_bytes, - "resources_discovered": len(rows), - "resources_with_unknown_size": unknown_count, - } - - -def _archive_tiers(rows): - # Any tier carrying bytes in a fixture that declares archive bytes. - return { - tier - for row in rows - for tier in (row.get("tier_bytes") or {}) - if tier in ("Archive", "Glacier", "Deep Archive") - } - - -def _payload(rows, unknown_count=0, archive_tier_bytes=0): - return { - "meta": { - "name": "Exit Assessment Test", - "cloud_service_provider": 1, - "exit_strategy": 3, - "assessment_type": 1, - "timestamp": "2026-07-12 18:45:08 UTC", - }, - "data": { - "resources": rows, - "totals": _totals(rows, unknown_count, archive_tier_bytes), - }, - } - - -_EGRESS_SCHEMA = """ -CREATE TABLE resourcetype ( - id INTEGER PRIMARY KEY, csp INTEGER NOT NULL, code TEXT NOT NULL, - name TEXT NOT NULL, icon TEXT NOT NULL, status TEXT NOT NULL); -CREATE TABLE resourcetype_data ( - id INTEGER PRIMARY KEY, resource_type INTEGER NOT NULL, - data_category TEXT NOT NULL, strategy TEXT NOT NULL, params TEXT, - status TEXT NOT NULL); -CREATE TABLE egress_inventory ( - id INTEGER PRIMARY KEY AUTOINCREMENT, resource_type INTEGER NOT NULL, - name TEXT NOT NULL, size_bytes INTEGER, - size_unknown INTEGER NOT NULL DEFAULT 0, flags TEXT, notes TEXT); -CREATE TABLE egress_inventory_tier ( - id INTEGER PRIMARY KEY AUTOINCREMENT, egress_inventory_id INTEGER NOT NULL, - tier TEXT NOT NULL, size_bytes INTEGER NOT NULL, - is_archive INTEGER NOT NULL DEFAULT 0); -""" - - -def _seed_egress_inventory(report_path, rows, archive_tiers=frozenset()): - """Store fixture rows the way the engine does, via the real writer.""" - db_dir = os.path.join(report_path, "data") - os.makedirs(db_dir, exist_ok=True) - db_path = os.path.join(db_dir, "assessment.db") - conn = sqlite3.connect(db_path) - conn.executescript(_EGRESS_SCHEMA) - - # One catalogue row per distinct (type, label, category); the report reads - # label and category back through the FK. - codes, type_ids = {}, {} - for row in rows: - triple = (row["type"], row["label"], row["category"]) - if triple in codes: - continue - rt_id = len(codes) + 1 - code = row["type"] if row["type"] not in type_ids else f"{row['type']}#{rt_id}" - codes[triple] = code - type_ids[code] = rt_id - conn.execute( - "INSERT INTO resourcetype (id, csp, code, name, icon, status) " - "VALUES (?, 1, ?, ?, 'icon.png', 't')", - (rt_id, code, row["label"]), - ) - conn.execute( - "INSERT INTO resourcetype_data (id, resource_type, data_category, " - "strategy, params, status) VALUES (?, ?, ?, 'strategy', '{}', 't')", - (rt_id, rt_id, row["category"]), - ) - conn.commit() - conn.close() - - stored = [ - {**row, "type": codes[(row["type"], row["label"], row["category"])]} - for row in rows - ] - write_egress_inventory(stored, type_ids, set(archive_tiers), db_path) - return db_path - - -class CategoryCoverageTests(unittest.TestCase): - # The CHECK constraint on resourcetype_data.data_category. A category the - # report does not know about renders an unstyled badge and, worse, drops - # out of the allocation chart while still counting towards the totals. - DATA_CATEGORIES = {"object", "block", "file", "database", "backup"} - - def test_every_master_data_category_is_known_to_the_report(self): - self.assertEqual(set(CATEGORIES), self.DATA_CATEGORIES) - - def test_only_backup_is_left_out_of_the_allocation_chart(self): - excluded = {k for k, v in CATEGORIES.items() if not v["in_allocation"]} - - # Backup is enumerated but never sized, so it has nothing to allocate. - self.assertEqual(excluded, {"backup"}) - - def test_allocated_categories_have_a_colour_and_a_label(self): - for key, info in CATEGORIES.items(): - if info["in_allocation"]: - self.assertIsNotNone(info["color"], key) - self.assertIsNotNone(info["label"], key) - - def test_template_styles_every_category_badge(self): - css = pathlib.Path("assets/template/egress.html").read_text(encoding="utf-8") - - for key in CATEGORIES: - self.assertIn(f".category-{key} {{", css, f"no badge rule for {key!r}") - - -class LoadPricingTests(unittest.TestCase): - @staticmethod - def _make_report_db(report_path, rows): - db_dir = os.path.join(report_path, "data") - os.makedirs(db_dir, exist_ok=True) - conn = sqlite3.connect(os.path.join(db_dir, "assessment.db")) - conn.execute( - "CREATE TABLE egresspricing (id INTEGER PRIMARY KEY, csp INTEGER, " - "component TEXT, zone TEXT, tier_from REAL, tier_to REAL, " - "price_per_unit REAL, currency TEXT, unit TEXT, valid_from TEXT, " - "valid_to TEXT)" - ) - conn.executemany( - "INSERT INTO egresspricing (csp, component, zone, tier_from, tier_to, " - "price_per_unit, currency, unit, valid_from, valid_to) " - "VALUES (:csp, :component, :zone, :tier_from, :tier_to, " - ":price_per_unit, :currency, :unit, :valid_from, :valid_to)", - [{key: row[key] for key in row if key != "id"} for row in rows], - ) - conn.commit() - conn.close() - - def test_loads_only_active_rows_from_run_database(self): - rows = SIMPLE_PRICING + [ - _price_row(1, "internet_egress", 100, None, 0.2, valid_to="2026-06-30"), - ] - with tempfile.TemporaryDirectory() as tmp_dir: - self._make_report_db(tmp_dir, rows) - loaded = load_pricing(tmp_dir) - - self.assertEqual(len(loaded), len(SIMPLE_PRICING)) - self.assertTrue(all(not row["valid_to"] for row in loaded)) - - def test_falls_back_to_master_dataset_when_table_missing(self): - with patch( - "core.utils_report_egress.load_data", - side_effect=[sqlite3.OperationalError("no such table"), SIMPLE_PRICING], - ) as mock_load: - loaded = load_pricing("/tmp/report") - - self.assertEqual(len(loaded), len(SIMPLE_PRICING)) - self.assertEqual(mock_load.call_count, 2) - # The fallback call uses the master dataset default path. - self.assertEqual(mock_load.call_args_list[1].kwargs, {}) - - @unittest.skipUnless( - os.path.exists("datasets/data.db"), "master dataset not downloaded" - ) - def test_shipped_dataset_has_contiguous_zone1_ladders(self): - from itertools import groupby - - from core.utils_db import load_data as db_load - - rows = [ - row - for row in db_load("egresspricing", db_path="datasets/data.db") - if not row["valid_to"] - ] - rows.sort(key=lambda r: (r["csp"], r["component"], r["zone"], r["tier_from"])) - seen = set() - for key, group in groupby( - rows, key=lambda r: (r["csp"], r["component"], r["zone"]) - ): - group = list(group) - seen.add(key) - self.assertEqual(group[0]["tier_from"], 0, key) - for lower, upper in zip(group, group[1:]): - self.assertEqual(lower["tier_to"], upper["tier_from"], key) - self.assertIsNone(group[-1]["tier_to"], key) - for csp in (1, 2): - self.assertIn((csp, "internet_egress", "zone1"), seen) - - -class TieredCostTests(unittest.TestCase): - def test_free_tier_under_100_gib(self): - self.assertEqual(calculate_tiered_cost(50, AZURE_INTERNET_TIERS), 0.0) - self.assertEqual(calculate_tiered_cost(100, AZURE_INTERNET_TIERS), 0.0) - - def test_multi_tib_crosses_tiers(self): - # 20 TiB: 100 GiB free, 10,140 GiB @ 0.087, 10,240 GiB @ 0.083 - expected = 10140 * 0.087 + 10240 * 0.083 - self.assertAlmostEqual( - calculate_tiered_cost(20 * 1024, AZURE_INTERNET_TIERS), expected - ) - - def test_unbounded_top_tier(self): - expected = 10140 * 0.087 + 40960 * 0.083 + 102400 * 0.07 + 100 * 0.05 - self.assertAlmostEqual( - calculate_tiered_cost(153700, AZURE_INTERNET_TIERS), expected - ) - - -class BuildFeeEstimateTests(unittest.TestCase): - def test_prorates_internet_fee_and_adds_retrieval_per_resource(self): - rows = [ - _row( - "sa1", - "Storage Account", - "object", - 600 * GIB, - tier_bytes={"Hot": 400 * GIB, "Archive": 200 * GIB}, - ), - _row("disk1", "Managed Disk", "block", 400 * GIB), - _row("db1", "SQL Database", "database", None, size_unknown=True), - ] - totals = _totals(rows, unknown_count=1, archive_tier_bytes=200 * GIB) - - total_fee, fees_by_id = build_fee_estimate(rows, totals, 1, SIMPLE_PRICING) - - # internet: (1000 - 100) * 0.1 = 90; retrieval: 200 * 0.02 = 4 - self.assertAlmostEqual(total_fee, 94.0) - self.assertAlmostEqual(fees_by_id["/sa1"], 90 * 0.6 + 4.0) - self.assertAlmostEqual(fees_by_id["/disk1"], 90 * 0.4) - self.assertIsNone(fees_by_id["/db1"]) - - def test_zero_bytes_produce_zero_fee(self): - rows = [_row("vault1", "Backup Vault", "backup", None)] - totals = _totals(rows) - - total_fee, fees_by_id = build_fee_estimate(rows, totals, 1, SIMPLE_PRICING) - - self.assertEqual(total_fee, 0.0) - self.assertIsNone(fees_by_id["/vault1"]) - - def test_format_fee(self): - self.assertEqual(format_fee(None), "n/a") - self.assertEqual(format_fee(0), "$0.00") - self.assertEqual(format_fee(1732.1), "$1,732.10") - - def test_free_tier_limit_derived_from_pricing(self): - self.assertEqual(free_tier_limit_display(SIMPLE_PRICING, 1), "100 GiB") - self.assertIsNone(free_tier_limit_display(SIMPLE_PRICING, 2)) - - -class PricingUnitAndZoneTests(unittest.TestCase): - def test_units_follow_the_pricing_rows(self): - prices = [ - _price_row(1, "internet_egress", 0, 100, 0.0, unit="GB"), - _price_row(1, "internet_egress", 100, None, 0.1, unit="GB"), - ] - rows = [_row("sa1", "Storage Account", "object", 1000 * 10**9)] - - total_fee, _ = build_fee_estimate(rows, _totals(rows), 1, prices) - - # Decimal-GB ladder: (1000 GB - 100 GB free) * 0.1 - self.assertAlmostEqual(total_fee, 90.0) - - def test_other_zones_are_ignored(self): - prices = SIMPLE_PRICING + [ - _price_row(1, "internet_egress", 100, None, 9.9, zone="zone3"), - ] - rows = [_row("sa1", "Storage Account", "object", 200 * GIB)] - - total_fee, _ = build_fee_estimate(rows, _totals(rows), 1, prices) - - self.assertAlmostEqual(total_fee, 10.0) - - def test_missing_provider_pricing_raises(self): - rows = [_row("sa1", "Storage Account", "object", 200 * GIB)] - - with self.assertRaises(ValueError): - build_fee_estimate(rows, _totals(rows), 2, SIMPLE_PRICING) - - -class ResolveIconTests(unittest.TestCase): - def test_exact_match_from_resourcetype_table(self): - lookup = {"microsoft.storage/storageaccounts": "/icons/azure/storage/sa.png"} - - icon = _resolve_icon("Microsoft.Storage/storageAccounts", lookup) - - self.assertEqual(icon, "/icons/azure/storage/sa.png") - - def test_trims_path_segments_until_match(self): - lookup = {"microsoft.sql": "/icons/azure/databases/sql.png"} - - icon = _resolve_icon("Microsoft.Sql/servers/databases", lookup) - - self.assertEqual(icon, "/icons/azure/databases/sql.png") - - def test_known_gap_uses_hardcoded_fallback(self): - icon = _resolve_icon("AWS.ec2.describe_volumes.Volumes", {}) - - self.assertEqual(icon, "/icons/aws/Storage/Elastic-Block-Store.png") - - def test_unknown_type_uses_default_icon(self): - icon = _resolve_icon("Vendor.Unknown/things", {}) - - self.assertEqual(icon, "/icons/misc/no_image.png") - - -class AllocationSeriesTests(unittest.TestCase): - def test_groups_known_bytes_by_category_in_fixed_order(self): - rows = [ - _row("disk1", "Managed Disk", "block", 40 * GIB), - _row("sa1", "Storage Account", "object", 60 * GIB), - _row("db1", "SQL Database", "database", 10 * GIB), - _row("vault1", "Recovery Services Vault", "backup", None), - _row("db2", "SQL Database", "database", None, size_unknown=True), - ] - - labels, values, colors = _build_allocation(rows) - - self.assertEqual( - labels, - ["Object Storage", "Block (allocated)", "Databases"], - ) - self.assertEqual(values, [60 * GIB, 40 * GIB, 10 * GIB]) - self.assertEqual(len(colors), 3) - - def test_zero_categories_are_omitted(self): - rows = [_row("sa1", "Storage Account", "object", 5 * GIB)] - - labels, values, _ = _build_allocation(rows) - - self.assertEqual(labels, ["Object Storage"]) - self.assertEqual(values, [5 * GIB]) - - -class TypeGroupTests(unittest.TestCase): - def test_groups_by_label_and_sorts_by_fee(self): - rows = [ - _row("disk1", "Managed Disk", "block", 100 * GIB), - _row("disk2", "Managed Disk", "block", 50 * GIB), - _row("sa1", "Storage Account", "object", 400 * GIB), - _row("vault1", "Recovery Services Vault", "backup", None), - ] - fees_by_id = {"/disk1": 10.0, "/disk2": 5.0, "/sa1": 40.0, "/vault1": None} - - groups = _build_type_groups(rows, fees_by_id, {}) - - self.assertEqual( - [group["label"] for group in groups], - ["Storage Account", "Managed Disk", "Recovery Services Vault"], - ) - disk_group = groups[1] - self.assertEqual(disk_group["size_display"], "150.0 GiB") - self.assertEqual(disk_group["fee_display"], "$15.00") - self.assertEqual(len(disk_group["resources"]), 2) - self.assertEqual(disk_group["resources"][0]["fee_display"], "$10.00") - vault_group = groups[2] - self.assertEqual(vault_group["size_display"], "n/a") - self.assertEqual(vault_group["fee_display"], "n/a") - self.assertIsNone(vault_group["resources"][0]["fee_display"]) - - def test_flags_and_notes_become_resource_detail(self): - rows = [ - _row( - "disk1", - "Managed Disk", - "block", - 100 * GIB, - flags=["allocated (upper bound)"], - notes=["shared with vm-1"], - ), - ] - - groups = _build_type_groups(rows, {"/disk1": 1.0}, {}) - - self.assertEqual( - groups[0]["resources"][0]["detail"], - "allocated (upper bound); shared with vm-1", - ) - - -class GenerateEgressHtmlReportTests(unittest.TestCase): - def _generate(self, payload): - with tempfile.TemporaryDirectory() as tmp_dir: - json_path = os.path.join(tmp_dir, "egress_inventory_raw_data.json") - with open(json_path, "w", encoding="utf-8") as json_file: - json.dump(payload, json_file) - _seed_egress_inventory( - tmp_dir, - payload["data"]["resources"], - _archive_tiers(payload["data"]["resources"]), - ) - - with patch( - "core.utils_report_egress.load_data", - side_effect=_fake_load_data, - ): - result = generate_egress_html_report(tmp_dir, json_path) - - html = None - if result["success"]: - self.assertEqual( - result["html_path"], os.path.join(tmp_dir, "egress.html") - ) - with open(result["html_path"], encoding="utf-8") as html_file: - html = html_file.read() - return result, html - - def test_renders_report_with_fees_table_and_charts(self): - rows = [ - _row( - "proddata", - "Storage Account", - "object", - 2048 * GIB, - resource_type="Microsoft.Storage/storageAccounts", - ), - _row( - "vm-osdisk", - "Managed Disk", - "block", - 512 * GIB, - resource_type="Microsoft.Compute/disks", - flags=["allocated (upper bound)"], - ), - ] - result, html = self._generate(_payload(rows)) - - self.assertTrue(result["success"], result["logs"]) - self.assertIn("Exit Assessment Test", html) - normalized_html = " ".join(html.split()) - self.assertIn( - "The Data Landscape & Egress Estimation feature is currently in", - html, - ) - self.assertIn("mailto:beta@escapecloud.io", html) - # (2560 - 100) GiB * 0.087 = $214.02 total internet egress fee - self.assertIn("$214.02", html) - self.assertIn("Estimated Egress Fee", html) - self.assertIn("Estimated one-time charge to transfer the identified", html) - self.assertIn( - 'any resources whose size could not be measured. A "+" means', - normalized_html, - ) - self.assertIn("lower-bound estimate.", html) - self.assertIn("feesChart", html) - self.assertIn("allocationChart", html) - self.assertIn("proddata", html) - self.assertIn("category-object", html) - self.assertIn("allocated (upper bound)", html) - self.assertNotIn("Estimates only", html) - self.assertNotIn("At least", html) - - def test_unknown_sizes_add_plus_suffix_to_headlines(self): - rows = [ - _row("sa1", "Storage Account", "object", 2048 * GIB), - _row("db1", "SQL Database", "database", None, size_unknown=True), - ] - result, html = self._generate(_payload(rows, unknown_count=1)) - - self.assertTrue(result["success"], result["logs"]) - # 2048 GiB known: (2048 - 100) * 0.087 = $169.48, "+" marks the lower bound - self.assertIn("2.0 TiB+", html) - self.assertIn("$169.48+", html) - self.assertIn("Excludes 1 resource", html) - self.assertIn("n/a", html) - - def test_free_tier_environment_shows_included_in_free_tier_card(self): - rows = [_row("sa1", "Storage Account", "object", 5 * GIB)] - result, html = self._generate(_payload(rows)) - - self.assertTrue(result["success"], result["logs"]) - self.assertIn("Included in Free Tier", html) - # The fee headline stays visible above the card, as a clean "$0". - self.assertIn("Estimated Egress Fee", html) - self.assertIn('
$0
', html) - self.assertIn("5.0 GiB", html) - self.assertIn("100 GiB free tier", html) - self.assertNotIn("No fee estimate available.", html) - self.assertNotIn("feesChart", html) - - def test_empty_inventory_renders_empty_states(self): - result, html = self._generate(_payload([])) - - self.assertTrue(result["success"], result["logs"]) - self.assertIn("$0.00", html) - # Zero known bytes is the empty state, not the free-tier success card. - self.assertIn("No fee estimate available.", html) - self.assertNotIn("Included in Free Tier", html) - self.assertIn("No data volume available.", html) - self.assertIn( - "No data-bearing resources were discovered during the", - html, - ) - self.assertNotIn("allocationChart", html) - - def test_missing_json_returns_failure(self): - with tempfile.TemporaryDirectory() as tmp_dir: - result = generate_egress_html_report( - tmp_dir, os.path.join(tmp_dir, "missing.json") - ) - - 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( - "core.utils_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/test_utils_sync.py b/tests/test_utils_sync.py deleted file mode 100644 index 6c0694c..0000000 --- a/tests/test_utils_sync.py +++ /dev/null @@ -1,122 +0,0 @@ -import json -import os -import tempfile -import types -import unittest -from unittest.mock import MagicMock, patch - -from core.utils_sync import post_assessment - - -class PostAssessmentHostResolutionTests(unittest.TestCase): - def test_uses_host_from_environment_when_config_host_empty(self): - fake_response = MagicMock() - fake_response.ok = True - fake_response.status_code = 200 - fake_response.json.return_value = {"data": {"ok": True}} - - with ( - patch.dict(os.environ, {"HOST": "env.exitcloud.io"}, clear=False), - patch( - "core.utils_sync.config", - types.SimpleNamespace(HOST=""), - ), - patch("core.utils_sync._build_payload", return_value={"sample": "payload"}), - patch( - "core.utils_sync.requests.post", return_value=fake_response - ) as mock_post, - ): - result = post_assessment( - name="Demo", - started_at=123, - report_path="/tmp/report", - meta={ - "exit_strategy": 1, - "cloud_service_provider": 2, - "assessment_type": 1, - }, - token="jwt-token", - ) - - self.assertTrue(result["success"]) - called_url = mock_post.call_args.args[0] - self.assertEqual(called_url, "https://env.exitcloud.io/api/v1/assessments/") - - def test_returns_clear_error_when_host_missing_everywhere(self): - with ( - patch.dict(os.environ, {}, clear=True), - patch( - "core.utils_sync.config", - types.SimpleNamespace(HOST=""), - ), - ): - result = post_assessment( - name="Demo", - started_at=123, - report_path="/tmp/report", - meta={ - "exit_strategy": 1, - "cloud_service_provider": 2, - "assessment_type": 1, - }, - token="jwt-token", - ) - - self.assertFalse(result["success"]) - self.assertEqual(result["payload"], None) - self.assertEqual(result["logs"], "HOST missing in environment and config.py") - - -class WriteAssessmentPayloadTests(unittest.TestCase): - def test_writes_payload_json_to_raw_data(self): - with tempfile.TemporaryDirectory() as tmp_dir: - report_path = os.path.join(tmp_dir, "report") - raw_data_path = os.path.join(report_path, "raw_data") - data_path = os.path.join(report_path, "data") - os.makedirs(raw_data_path, exist_ok=True) - os.makedirs(data_path, exist_ok=True) - - with patch("core.utils_sync.load_data") as mock_load: - mock_load.side_effect = lambda table, db_path=None: ( - [ - { - "resource_type": 10, - "location": "eu-central-1", - "count": 3, - } - ] - if table == "resource_inventory" - else [ - { - "month": "2025-01", - "cost": 42.5, - "currency": "USD", - } - ] - ) - - from core.utils_sync import write_assessment_payload - - payload_path = write_assessment_payload( - raw_data_path, - report_path=report_path, - name="Dry Run Demo", - started_at=1000, - exit_strategy=1, - cloud_service_provider=2, - assessment_type=2, - ) - - self.assertEqual(payload_path, os.path.join(raw_data_path, "payload.json")) - with open(payload_path, encoding="utf-8") as payload_file: - payload = json.load(payload_file) - - self.assertEqual(payload["type"], "local.assessment.succeeded") - self.assertEqual(payload["data"]["name"], "Dry Run Demo") - self.assertEqual(payload["data"]["assessmentType"], 2) - self.assertEqual(len(payload["data"]["resource_inventory"]), 1) - self.assertEqual(len(payload["data"]["cost_inventory"]), 1) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_utils_tfstate.py b/tests/test_utils_tfstate.py deleted file mode 100644 index f12983c..0000000 --- a/tests/test_utils_tfstate.py +++ /dev/null @@ -1,890 +0,0 @@ -import json -import os -import sqlite3 -import tempfile -import unittest -from pathlib import Path - -from core.utils_tfstate import ( - build_tfstate_resource_inventory, - extract_managed_resources, - extract_state_scope, - file_sha256, - parse_tfstate, -) - -# Schema subset the tfstate builder touches, mirroring datasets/data.db. -SCHEMA = """ -CREATE TABLE resourcetype ( - id INTEGER PRIMARY KEY, - csp INTEGER NOT NULL, - code TEXT NOT NULL, - name TEXT NOT NULL, - icon TEXT NOT NULL, - status TEXT CHECK(status IN ('t','f')) NOT NULL, - tf_code TEXT -); -CREATE TABLE resource_inventory ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - resource_type INTEGER NOT NULL, - location TEXT NOT NULL, - count INTEGER NOT NULL, - UNIQUE(resource_type, location) -); -""" - - -def build_state(resources, **overrides): - state = { - "version": 4, - "terraform_version": "1.14.6", - "serial": 7, - "lineage": "e1c2f0c0-0000-0000-0000-000000000000", - "resources": resources, - } - state.update(overrides) - return state - - -def managed(resource_type, name, instances, module=None): - entry = { - "mode": "managed", - "type": resource_type, - "name": name, - "provider": f'provider["registry.terraform.io/hashicorp/{resource_type.split("_")[0]}"]', - "instances": instances, - } - if module: - entry["module"] = module - return entry - - -def instance(attributes=None, index_key=None): - entry = {"schema_version": 0, "attributes": attributes or {}} - if index_key is not None: - entry["index_key"] = index_key - return entry - - -def write_state(directory, state, filename="infra.tfstate"): - path = Path(directory) / filename - path.write_text(json.dumps(state), encoding="utf-8") - return str(path) - - -def seed_db(db_path, rows): - """rows: (id, csp, code, name, status, tf_code)""" - conn = sqlite3.connect(db_path) - conn.executescript(SCHEMA) - conn.executemany( - "INSERT INTO resourcetype (id, csp, code, name, icon, status, tf_code) " - "VALUES (?, ?, ?, ?, '/icons/misc/no_image.png', ?, ?)", - rows, - ) - conn.commit() - conn.close() - - -class ParseTfstateTests(unittest.TestCase): - def test_rejects_missing_file(self): - with self.assertRaisesRegex(ValueError, "Could not read Terraform state file"): - parse_tfstate("/tmp/does-not-exist.tfstate") - - def test_rejects_invalid_json(self): - with tempfile.TemporaryDirectory() as tmp_dir: - path = Path(tmp_dir) / "broken.tfstate" - path.write_text("{not json", encoding="utf-8") - - with self.assertRaisesRegex(ValueError, "not valid JSON"): - parse_tfstate(str(path)) - - def test_rejects_legacy_state_version(self): - with tempfile.TemporaryDirectory() as tmp_dir: - path = write_state(tmp_dir, build_state([], version=3)) - - with self.assertRaisesRegex(ValueError, "version 4"): - parse_tfstate(path) - - def test_rejects_non_object_document(self): - with tempfile.TemporaryDirectory() as tmp_dir: - path = Path(tmp_dir) / "list.tfstate" - path.write_text("[]", encoding="utf-8") - - with self.assertRaisesRegex(ValueError, "not a state document"): - parse_tfstate(str(path)) - - def test_accepts_minimal_valid_state(self): - with tempfile.TemporaryDirectory() as tmp_dir: - path = write_state( - tmp_dir, - build_state( - [ - managed( - "aws_s3_bucket", "this", [instance({"region": "eu-west-1"})] - ) - ] - ), - ) - - state = parse_tfstate(path) - - self.assertEqual(state["version"], 4) - self.assertEqual(state["terraform_version"], "1.14.6") - self.assertEqual(len(state["resources"]), 1) - - -class ExtractManagedResourcesTests(unittest.TestCase): - def test_excludes_data_sources(self): - state = build_state( - [ - { - "mode": "data", - "type": "aws_caller_identity", - "name": "current", - "instances": [instance({"region": "eu-west-1"})], - }, - managed("aws_s3_bucket", "this", [instance({"region": "eu-west-1"})]), - ] - ) - - records = extract_managed_resources(state) - - self.assertEqual([r["type"] for r in records], ["aws_s3_bucket"]) - - def test_one_record_per_instance(self): - state = build_state( - [ - managed( - "aws_dynamodb_table", - "this", - [ - instance({"region": "eu-central-1"}, index_key="alpha"), - instance({"region": "eu-central-1"}, index_key="beta"), - instance({"region": "eu-central-1"}, index_key="gamma"), - ], - ) - ] - ) - - records = extract_managed_resources(state) - - self.assertEqual(len(records), 3) - self.assertEqual({r["location"] for r in records}, {"eu-central-1"}) - - def test_extracts_and_lowercases_aws_region(self): - state = build_state( - [managed("aws_s3_bucket", "this", [instance({"region": " EU-West-1 "})])] - ) - - records = extract_managed_resources(state) - - self.assertEqual(records[0]["location"], "eu-west-1") - - def test_extracts_and_lowercases_azure_location(self): - state = build_state( - [ - managed( - "azurerm_managed_disk", - "this", - [instance({"location": "NorthEurope"})], - ) - ] - ) - - records = extract_managed_resources(state) - - self.assertEqual(records[0]["location"], "northeurope") - - def test_falls_back_to_unknown_location(self): - state = build_state( - [ - managed("aws_iam_role", "this", [instance({"name": "role"})]), - managed("aws_iam_policy", "this", [instance({"region": " "})]), - managed("aws_iam_user", "this", [instance()]), - ] - ) - - records = extract_managed_resources(state) - - self.assertEqual([r["location"] for r in records], ["unknown"] * 3) - - def test_falls_back_to_region_from_own_arn(self): - state = build_state( - [ - managed( - "aws_dynamodb_table", - "this", - [ - instance( - { - "arn": "arn:aws:dynamodb:EU-Central-1:266579820564:table/orders" - } - ) - ], - ) - ] - ) - - records = extract_managed_resources(state) - - self.assertEqual(records[0]["location"], "eu-central-1") - - def test_region_attribute_wins_over_arn(self): - state = build_state( - [ - managed( - "aws_efs_file_system", - "this", - [ - instance( - { - "region": "eu-west-1", - "arn": "arn:aws:elasticfilesystem:us-east-1:266579820564:file-system/fs-1", - } - ) - ], - ) - ] - ) - - records = extract_managed_resources(state) - - self.assertEqual(records[0]["location"], "eu-west-1") - - def test_ignores_arns_of_referenced_resources(self): - # stream_arn / kms_key_arn point at other resources, which may live in a - # different region or account; only the resource's own arn may be read. - state = build_state( - [ - managed( - "aws_dynamodb_table", - "this", - [ - instance( - { - "stream_arn": "arn:aws:dynamodb:us-east-1:999999999999:table/x/stream/2024", - "kms_key_arn": "arn:aws:kms:ap-south-1:999999999999:key/abc", - "restore_source_table_arn": "arn:aws:dynamodb:sa-east-1:999999999999:table/y", - } - ) - ], - ) - ] - ) - - records = extract_managed_resources(state) - - self.assertEqual(records[0]["location"], "unknown") - - def test_global_and_malformed_arns_fall_back_to_unknown(self): - state = build_state( - [ - # Global services write an empty region field by design. - managed( - "aws_s3_bucket", - "this", - [instance({"arn": "arn:aws:s3:::my-bucket"})], - ), - managed( - "aws_iam_role", - "this", - [instance({"arn": "arn:aws:iam::266579820564:role/admin"})], - ), - managed( - "aws_vpc", "trunc", [instance({"arn": "arn:aws:ec2:eu-west-1"})] - ), - managed("aws_vpc", "nonsense", [instance({"arn": "not-an-arn"})]), - managed("aws_vpc", "wrongtype", [instance({"arn": 42})]), - ] - ) - - records = extract_managed_resources(state) - - self.assertEqual([r["location"] for r in records], ["unknown"] * 5) - - def test_arn_account_id_is_never_extracted(self): - state = build_state( - [ - managed( - "aws_dynamodb_table", - "this", - [ - instance( - { - "arn": "arn:aws:dynamodb:eu-central-1:266579820564:table/t" - } - ) - ], - ) - ] - ) - - records = extract_managed_resources(state) - - self.assertEqual(records[0]["location"], "eu-central-1") - self.assertNotIn("266579820564", json.dumps(records)) - - def test_address_includes_module_and_index_key(self): - state = build_state( - [ - managed( - "aws_s3_bucket", - "this", - [instance({"region": "eu-west-1"}, index_key=3)], - module="module.storage", - ), - managed("aws_vpc", "main", [instance({"region": "eu-west-1"})]), - ] - ) - - records = extract_managed_resources(state) - - self.assertEqual(records[0]["address"], "module.storage.aws_s3_bucket.this[3]") - self.assertEqual(records[1]["address"], "aws_vpc.main") - - def test_never_copies_attributes(self): - state = build_state( - [ - managed( - "azurerm_storage_account", - "this", - [ - instance( - { - "location": "westeurope", - "primary_connection_string": "SUPER_SECRET_VALUE", - } - ) - ], - ) - ] - ) - - records = extract_managed_resources(state) - - self.assertEqual(set(records[0].keys()), {"address", "type", "location"}) - self.assertNotIn("SUPER_SECRET_VALUE", json.dumps(records)) - - -class StateScopeTests(unittest.TestCase): - def test_sha256_matches_the_file_contents(self): - import hashlib - - with tempfile.TemporaryDirectory() as tmp_dir: - path = write_state(tmp_dir, build_state([])) - expected = hashlib.sha256(Path(path).read_bytes()).hexdigest() - - self.assertEqual(file_sha256(path), expected) - - def test_sha256_returns_none_for_unreadable_file(self): - self.assertIsNone(file_sha256("/tmp/does-not-exist.tfstate")) - - def test_extracts_azure_subscription_and_resource_groups(self): - state = build_state( - [ - managed( - "azurerm_storage_account", - "this", - [ - instance( - { - "id": "/subscriptions/sub-a/resourceGroups/rg-1/providers/Microsoft.Storage/storageAccounts/x" - } - ), - instance( - { - "id": "/subscriptions/sub-a/resourceGroups/rg-2/providers/Microsoft.Storage/storageAccounts/y" - } - ), - ], - ) - ] - ) - - scope = extract_state_scope(state, 1) - - self.assertEqual(scope["subscriptions"], ["sub-a"]) - self.assertEqual(scope["resource_groups"], ["rg-1", "rg-2"]) - - def test_handles_multiple_subscriptions(self): - state = build_state( - [ - managed( - "azurerm_managed_disk", - "this", - [ - instance( - { - "id": "/subscriptions/sub-b/resourceGroups/rg-1/providers/x" - } - ), - instance( - { - "id": "/subscriptions/sub-a/resourceGroups/rg-1/providers/x" - } - ), - ], - ) - ] - ) - - scope = extract_state_scope(state, 1) - - self.assertEqual(scope["subscriptions"], ["sub-a", "sub-b"]) - - def test_ignores_data_sources_and_unparsable_ids(self): - state = build_state( - [ - { - "mode": "data", - "type": "azurerm_resource_group", - "name": "this", - "instances": [ - instance( - { - "id": "/subscriptions/data-sub/resourceGroups/rg-x/providers/y" - } - ) - ], - }, - managed("azurerm_managed_disk", "a", [instance({"id": "not-an-id"})]), - managed("azurerm_managed_disk", "b", [instance({"id": 42})]), - managed("azurerm_managed_disk", "c", [instance()]), - ] - ) - - scope = extract_state_scope(state, 1) - - self.assertEqual(scope["subscriptions"], []) - self.assertEqual(scope["resource_groups"], []) - - def test_aws_state_yields_no_subscription_or_resource_group(self): - state = build_state( - [ - managed( - "aws_s3_bucket", - "this", - [instance({"region": "eu-west-1", "id": "my-bucket"})], - ) - ] - ) - - scope = extract_state_scope(state, 2) - - self.assertEqual(scope, {"subscriptions": [], "resource_groups": []}) - - -class BuildTfstateResourceInventoryTests(unittest.TestCase): - AWS_ROWS = [ - (444, 2, "AWS.s3.list_buckets.Buckets", "S3 Bucket", "t", "aws_s3_bucket"), - ( - 300, - 2, - "AWS.dynamodb.list_tables.TableNames", - "DynamoDB Table", - "t", - "aws_dynamodb_table", - ), - ( - 500, - 2, - "AWS.ec2.describe_instances.Reservations", - "EC2 Instance", - "f", - "aws_instance", - ), - ( - 600, - 1, - "Microsoft.Compute/virtualMachines", - "Virtual Machine", - "t", - "azurerm_linux_virtual_machine", - ), - ] - - def setUp(self): - self._tmp = tempfile.TemporaryDirectory() - self.addCleanup(self._tmp.cleanup) - self.report_path = self._tmp.name - self.raw_data_path = os.path.join(self.report_path, "raw_data") - os.makedirs(os.path.join(self.report_path, "data")) - os.makedirs(self.raw_data_path) - self.db_path = os.path.join(self.report_path, "data", "assessment.db") - - def _inventory(self): - conn = sqlite3.connect(self.db_path) - rows = conn.execute( - "SELECT resource_type, location, count FROM resource_inventory " - "ORDER BY resource_type, location" - ).fetchall() - conn.close() - return rows - - def _manifest(self): - path = Path(self.raw_data_path) / "tfstate_manifest.json" - return json.loads(path.read_text(encoding="utf-8")), path.read_text( - encoding="utf-8" - ) - - def _build(self, state, csp=2, rows=None, filename="infra.tfstate"): - seed_db(self.db_path, self.AWS_ROWS if rows is None else rows) - state_path = write_state(self._tmp.name, state, filename=filename) - build_tfstate_resource_inventory( - csp, - {"tfstatePath": state_path}, - self.report_path, - self.raw_data_path, - ) - return state_path - - def _build_returning(self, state, csp=2, rows=None): - seed_db(self.db_path, self.AWS_ROWS if rows is None else rows) - state_path = write_state(self._tmp.name, state) - return build_tfstate_resource_inventory( - csp, - {"tfstatePath": state_path}, - self.report_path, - self.raw_data_path, - ) - - def test_arn_derived_regions_aggregate_and_stay_out_of_the_manifest(self): - state = build_state( - [ - managed( - "aws_dynamodb_table", - "tables", - [ - instance( - { - "arn": "arn:aws:dynamodb:eu-central-1:266579820564:table/a" - }, - index_key="a", - ), - instance( - { - "arn": "arn:aws:dynamodb:eu-central-1:266579820564:table/b" - }, - index_key="b", - ), - instance( - {"arn": "arn:aws:dynamodb:us-east-1:266579820564:table/c"}, - index_key="c", - ), - ], - ) - ] - ) - - self._build(state) - - self.assertEqual( - self._inventory(), - [(300, "eu-central-1", 2), (300, "us-east-1", 1)], - ) - - _, serialized = self._manifest() - self.assertNotIn("266579820564", serialized) - self.assertNotIn("arn:aws", serialized) - - def test_aggregates_matched_types_per_location(self): - state = build_state( - [ - managed( - "aws_s3_bucket", - "this", - [ - instance({"region": "eu-west-1"}, index_key=0), - instance({"region": "eu-west-1"}, index_key=1), - instance({"region": "us-east-1"}, index_key=2), - ], - ), - managed( - "aws_dynamodb_table", - "tables", - [instance({"region": "eu-west-1"})], - ), - ] - ) - - self._build(state) - - self.assertEqual( - self._inventory(), - [ - (300, "eu-west-1", 1), - (444, "eu-west-1", 2), - (444, "us-east-1", 1), - ], - ) - - def test_unmapped_types_are_skipped_and_recorded_in_manifest(self): - state = build_state( - [ - managed("aws_s3_bucket", "this", [instance({"region": "eu-west-1"})]), - managed( - "aws_s3_bucket_versioning", - "this", - [ - instance({"region": "eu-west-1"}, index_key=0), - instance({"region": "eu-west-1"}, index_key=1), - ], - ), - ] - ) - - self._build(state) - - self.assertEqual(self._inventory(), [(444, "eu-west-1", 1)]) - - manifest, _ = self._manifest() - self.assertEqual(manifest["unmapped_types"], {"aws_s3_bucket_versioning": 2}) - self.assertEqual( - [entry["type"] for entry in manifest["counted"]], ["aws_s3_bucket"] - ) - - def test_duplicate_tf_code_resolves_to_lowest_id(self): - rows = [ - (444, 2, "AWS.s3.list_buckets.Buckets", "S3 Bucket", "t", "aws_s3_bucket"), - ( - 293, - 2, - "AWS.glacier.list_vaults.VaultList", - "Glacier Vault", - "t", - "aws_s3_bucket", - ), - ] - state = build_state( - [managed("aws_s3_bucket", "this", [instance({"region": "eu-west-1"})])] - ) - - self._build(state, rows=rows) - - self.assertEqual(self._inventory(), [(293, "eu-west-1", 1)]) - - def test_ignores_disabled_rows_and_other_csp_rows(self): - state = build_state( - [ - managed("aws_instance", "this", [instance({"region": "eu-west-1"})]), - managed( - "azurerm_linux_virtual_machine", - "this", - [instance({"location": "westeurope"})], - ), - ] - ) - - self._build(state, csp=2) - - self.assertEqual(self._inventory(), []) - manifest, _ = self._manifest() - # Same-provider glue and foreign resources are reported separately. - self.assertEqual(manifest["unmapped_types"], {"aws_instance": 1}) - self.assertEqual( - manifest["other_provider_types"], {"azurerm_linux_virtual_machine": 1} - ) - - def test_rejects_state_with_no_resources_for_the_selected_provider(self): - state = build_state( - [ - managed( - "azurerm_storage_account", - "this", - [ - instance({"location": "westeurope"}, index_key=0), - instance({"location": "westeurope"}, index_key=1), - ], - ) - ] - ) - seed_db(self.db_path, self.AWS_ROWS) - state_path = write_state(self._tmp.name, state) - - with self.assertRaises(ValueError) as ctx: - build_tfstate_resource_inventory( - 2, {"tfstatePath": state_path}, self.report_path, self.raw_data_path - ) - - message = str(ctx.exception) - self.assertIn("No AWS resources found", message) - self.assertIn("2 Azure resources", message) - self.assertIn("main.py azure --tfstate", message) - - def test_rejects_state_holding_only_unsupported_providers(self): - state = build_state( - [ - managed("google_storage_bucket", "this", [instance({})]), - managed("cloudflare_record", "this", [instance({})]), - ] - ) - seed_db(self.db_path, self.AWS_ROWS) - state_path = write_state(self._tmp.name, state) - - with self.assertRaises(ValueError) as ctx: - build_tfstate_resource_inventory( - 2, {"tfstatePath": state_path}, self.report_path, self.raw_data_path - ) - - message = str(ctx.exception) - self.assertIn("No AWS resources found", message) - self.assertIn("other providers", message) - # Nothing to redirect to — cloudexit cannot assess these. - self.assertNotIn("Did you mean", message) - - def test_empty_state_does_not_raise(self): - coverage = self._build_returning(build_state([])) - - self.assertEqual(coverage["instances_total"], 0) - self.assertEqual(self._inventory(), []) - - def test_mixed_state_counts_own_and_reports_excluded(self): - state = build_state( - [ - managed( - "aws_s3_bucket", - "this", - [ - instance({"region": "eu-west-1"}, index_key=0), - instance({"region": "eu-west-1"}, index_key=1), - instance({"region": "eu-west-1"}, index_key=2), - ], - ), - managed( - "azurerm_storage_account", - "this", - [ - instance({"location": "westeurope"}, index_key=i) - for i in range(27) - ], - ), - managed("aws_s3_bucket_versioning", "this", [instance({})]), - ] - ) - - coverage = self._build_returning(state) - - self.assertEqual(self._inventory(), [(444, "eu-west-1", 3)]) - self.assertEqual( - coverage, - { - "instances_total": 31, - "instances_counted": 3, - "instances_excluded_other_provider": 27, - "instances_excluded_unmapped": 1, - }, - ) - - manifest, _ = self._manifest() - self.assertEqual(manifest["coverage"], coverage) - self.assertEqual( - manifest["other_provider_types"], {"azurerm_storage_account": 27} - ) - self.assertEqual(manifest["unmapped_types"], {"aws_s3_bucket_versioning": 1}) - - def test_manifest_carries_no_attributes(self): - state = build_state( - [ - managed( - "aws_s3_bucket", - "this", - [ - instance( - { - "region": "eu-west-1", - "bucket": "my-bucket", - "generated_password": "SUPER_SECRET_VALUE", - } - ) - ], - ) - ] - ) - - state_path = self._build(state) - - manifest, serialized = self._manifest() - self.assertNotIn("attributes", serialized) - self.assertNotIn("SUPER_SECRET_VALUE", serialized) - self.assertNotIn("my-bucket", serialized) - self.assertEqual(manifest["source_file"], os.path.basename(state_path)) - self.assertNotIn(os.path.dirname(state_path), serialized) - self.assertEqual(manifest["terraform_version"], "1.14.6") - self.assertEqual(manifest["state_serial"], 7) - self.assertEqual( - set(manifest["counted"][0].keys()), - {"address", "type", "resource_type_id", "location"}, - ) - - def test_manifest_scope_block_records_file_state_and_locations(self): - import hashlib - - state = build_state( - [ - managed( - "aws_s3_bucket", - "this", - [ - instance({"region": "eu-west-1"}, index_key=0), - instance({"region": "us-east-1"}, index_key=1), - ], - ) - ] - ) - - state_path = self._build(state) - - manifest, _ = self._manifest() - scope = manifest["scope"] - self.assertEqual(scope["file"], "infra.tfstate") - self.assertEqual( - scope["sha256"], hashlib.sha256(Path(state_path).read_bytes()).hexdigest() - ) - self.assertEqual(scope["lineage"], "e1c2f0c0-0000-0000-0000-000000000000") - self.assertEqual(scope["serial"], 7) - self.assertEqual(scope["locations"], ["eu-west-1", "us-east-1"]) - self.assertEqual(scope["subscriptions"], []) - self.assertEqual(scope["resource_groups"], []) - - def test_rerun_is_idempotent(self): - state = build_state( - [ - managed( - "aws_s3_bucket", - "this", - [ - instance({"region": "eu-west-1"}, index_key=0), - instance({"region": "eu-west-1"}, index_key=1), - ], - ) - ] - ) - - state_path = self._build(state) - build_tfstate_resource_inventory( - 2, - {"tfstatePath": state_path}, - self.report_path, - self.raw_data_path, - ) - - self.assertEqual(self._inventory(), [(444, "eu-west-1", 2)]) - - def test_invalid_state_raises_value_error(self): - seed_db(self.db_path, self.AWS_ROWS) - - with self.assertRaisesRegex(ValueError, "version 4"): - build_tfstate_resource_inventory( - 2, - { - "tfstatePath": write_state( - self._tmp.name, build_state([], version=3) - ) - }, - self.report_path, - self.raw_data_path, - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/utils/__init__.py b/utils/__init__.py deleted file mode 100644 index 4a484dd..0000000 --- a/utils/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Shared utility modules used by the CLI.""" diff --git a/utils/aws.py b/utils/aws.py deleted file mode 100644 index 1e9fc10..0000000 --- a/utils/aws.py +++ /dev/null @@ -1,23 +0,0 @@ -# utils/aws.py -import logging -import shutil -import subprocess - -logger = logging.getLogger("main.utils.aws") - - -def is_aws_cli_installed() -> bool: - return shutil.which("aws") is not None - - -def is_aws_profile_valid(profile: str) -> bool: - try: - subprocess.run( - ["aws", "configure", "list", "--profile", profile], - check=True, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - return True - except subprocess.CalledProcessError: - return False diff --git a/utils/azure.py b/utils/azure.py deleted file mode 100644 index 95ead3d..0000000 --- a/utils/azure.py +++ /dev/null @@ -1,76 +0,0 @@ -# utils/azure.py -import logging -import shutil -import subprocess -from typing import Any -from rich.console import Console -from azure.identity import AzureCliCredential -from azure.core.exceptions import ClientAuthenticationError - -logger = logging.getLogger("main.utils.azure") -console = Console() - - -def is_azure_cli_installed() -> bool: - return shutil.which("az") is not None - - -def is_azure_cli_logged_in() -> bool: - try: - # Run the 'az account show' command to check if the user is logged in - subprocess.run( - ["az", "account", "show"], - check=True, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - return True - except subprocess.CalledProcessError: - return False - - -def is_azure_cli_token_expired() -> bool: - credential = AzureCliCredential() - try: - credential.get_token("https://management.azure.com/.default") - return False # Token is valid - except ClientAuthenticationError as e: - if "AADSTS700082" in str(e): - return True # Token expired - return False - - -def select_subscription(subscriptions: list[Any]) -> Any: - # logger.info("Listing available subscriptions for selection.") - console.print("Available Subscriptions:") - for idx, sub in enumerate(subscriptions, start=1): - console.print(f"{idx}. {sub.display_name} ({sub.subscription_id})") - while True: - try: - selection = int(input("Select a subscription by number: ").strip()) - if not (1 <= selection <= len(subscriptions)): - raise ValueError("Invalid subscription selection.") - selected_subscription = subscriptions[selection - 1] - # logger.info(f"Subscription selected: {selected_subscription.display_name} ({selected_subscription.subscription_id})") - return selected_subscription - except ValueError as e: - logger.debug(f"Invalid subscription selection: {e}") - console.print(f"[red]{e} Please select a valid number.[/red]") - - -def select_resource_group(resource_groups: list[Any]) -> str: - # logger.info("Listing available resource groups for selection.") - console.print("Available Resource Groups:") - for idx, rg in enumerate(resource_groups, start=1): - console.print(f"{idx}. {rg.name}") - while True: - try: - selection = int(input("Select a resource group by number: ").strip()) - if not (1 <= selection <= len(resource_groups)): - raise ValueError("Invalid resource group selection.") - selected_resource_group = resource_groups[selection - 1].name - # logger.info(f"Resource Group selected: {selected_resource_group}") - return selected_resource_group - except ValueError as e: - logger.debug(f"Invalid resource group selection: {e}") - console.print(f"[red]{e} Please select a valid number.[/red]") diff --git a/utils/codes.py b/utils/codes.py deleted file mode 100644 index c6572f0..0000000 --- a/utils/codes.py +++ /dev/null @@ -1,12 +0,0 @@ -# utils/codes.py - -SUCCESS = 0 -UNEXPECTED = 1 -CONFIG = 2 -CREDENTIALS = 3 -PERMISSIONS = 4 -RESOURCE_INVENTORY = 5 -COST_INVENTORY = 6 -RISK_ASSESSMENT = 7 -REPORT = 8 -EGRESS = 9 diff --git a/utils/connection.py b/utils/connection.py deleted file mode 100644 index 6dd1f26..0000000 --- a/utils/connection.py +++ /dev/null @@ -1,100 +0,0 @@ -# utils/connection.py -from __future__ import annotations - -import logging -import os -import requests - -logger = logging.getLogger("main.utils.connection") - -try: - import config -except ModuleNotFoundError: - config = None - -_AUTH_PATH = "/api/v1/auth/token/" - - -def _build_url(host: str) -> str: - host = host.strip().rstrip("/") - if host.startswith("http://"): - host = "https://" + host[len("http://") :] - elif not host.startswith("https://"): - host = f"https://{host}" - return f"{host}{_AUTH_PATH}" - - -def get_jwt_token( - host: str | None = None, key: str | None = None, *, timeout: int = 10 -) -> str | None: - host = ( - host - or os.environ.get("HOST", "").strip() - or (getattr(config, "HOST", "") if config else "") - ) - key = ( - key - or os.environ.get("KEY", "").strip() - or (getattr(config, "KEY", "") if config else "") - ) - - if not host: - logger.debug("HOST empty – skipping ExitCloud authentication.") - return None - if not key: - logger.debug("KEY empty – skipping ExitCloud authentication.") - return None - - url = _build_url(host) - headers = {"Authorization": f"Bearer {key}"} - - try: - resp = requests.post(url, headers=headers, timeout=timeout) - resp.raise_for_status() - data = resp.json() - - token = ( - data.get("access_token") - or data.get("token") - or data.get("access") - or data.get("jwt") - ) - if token: - return token - - logger.error( - "Authentication succeeded but token field missing in response: %s", data - ) - except requests.RequestException as exc: - logger.error( - "EscapeCloud authentication request failed: %s", exc, exc_info=True - ) - except ValueError: - logger.error( - "EscapeCloud authentication response was not valid JSON.", exc_info=True - ) - - return None - - -def resolve_mode() -> tuple[str, str | None]: - host = os.environ.get("HOST", "").strip() or ( - getattr(config, "HOST", "") if config else "" - ) - key = os.environ.get("KEY", "").strip() or ( - getattr(config, "KEY", "") if config else "" - ) - - if not host: - logger.debug("HOST empty – running in offline mode.") - return "offline", None - if not key: - logger.debug("KEY empty – running in offline mode.") - return "offline", None - - token = get_jwt_token(host=host, key=key) - if token: - return "online", token - - logger.debug("ExitCloud auth failed – falling back to offline mode.") - return "offline", None diff --git a/utils/constants.py b/utils/constants.py deleted file mode 100644 index 69a6fb5..0000000 --- a/utils/constants.py +++ /dev/null @@ -1,33 +0,0 @@ -# utils/constants.py -REGION_CHOICES = [ - ("us-east-1", "us-east-1 (N. Virginia)"), - ("us-east-2", "us-east-2 (Ohio)"), - ("us-west-1", "us-west-1 (N. California)"), - ("us-west-2", "us-west-2 (Oregon)"), - ("af-south-1", "af-south-1 (Cape Town)"), - ("ap-east-1", "ap-east-1 (Hong Kong)"), - ("ap-south-1", "ap-south-1 (Mumbai)"), - ("ap-northeast-1", "ap-northeast-1 (Tokyo)"), - ("ap-northeast-2", "ap-northeast-2 (Seoul)"), - ("ap-northeast-3", "ap-northeast-3 (Osaka)"), - ("ap-southeast-1", "ap-southeast-1 (Singapore)"), - ("ap-southeast-2", "ap-southeast-2 (Sydney)"), - ("ca-central-1", "ca-central-1 (Central)"), - ("eu-central-1", "eu-central-1 (Frankfurt)"), - ("eu-west-1", "eu-west-1 (Ireland)"), - ("eu-west-2", "eu-west-2 (London)"), - ("eu-west-3", "eu-west-3 (Paris)"), - ("eu-south-1", "eu-south-1 (Milan)"), - ("eu-north-1", "eu-north-1 (Stockholm)"), - ("me-south-1", "me-south-1 (Bahrain)"), - ("sa-east-1", "sa-east-1 (São Paulo)"), -] - -REQUIRED_FIELDS_AZURE = [ - "clientId", - "clientSecret", - "tenantId", - "subscriptionId", - "resourceGroupName", -] -REQUIRED_FIELDS_AWS = ["accessKey", "secretKey", "region"] diff --git a/utils/data.py b/utils/data.py deleted file mode 100644 index 31c0a8f..0000000 --- a/utils/data.py +++ /dev/null @@ -1,166 +0,0 @@ -# utils/data.py -import os -import sys -import gzip -import shutil -import hashlib -import time -import requests - -from datetime import datetime, timedelta, timezone -from pathlib import Path -from requests.exceptions import RequestException, ConnectionError, Timeout - -# Constants -DATASET_FOLDER = Path("datasets") -REMOTE_STORAGE_URL = "https://cloudexit-oss-data-eu.fsn1.your-objectstorage.com" - - -def get_monday_date() -> str: - now = datetime.now(timezone.utc) - monday = now - timedelta(days=now.weekday()) - - if now.weekday() == 0 and now.hour < 8: - last_monday = monday - timedelta(days=7) - return last_monday.strftime("cloudexit-%Y-%m-%d.db.gz") - else: - return monday.strftime("cloudexit-%Y-%m-%d.db.gz") - - -def compute_file_hash(filepath: str) -> str: - hash_sha256 = hashlib.sha256() - with open(filepath, "rb") as f: - for chunk in iter(lambda: f.read(4096), b""): - hash_sha256.update(chunk) - return hash_sha256.hexdigest() - - -def download_file(url: str, destination: str, retries: int = 3, delay: int = 5) -> bool: - for attempt in range(retries): - try: - response = requests.get(url, stream=True, timeout=30) - response.raise_for_status() - - with open(destination, "wb") as f: - shutil.copyfileobj(response.raw, f) - - print(f"[INFO] Download successful: {destination}") - return True - - except ConnectionError: - print( - f"[ERROR] Connection failed while downloading {url}. Retrying ({attempt + 1}/{retries})..." - ) - except Timeout: - print( - f"[ERROR] Request timed out while downloading {url}. Retrying ({attempt + 1}/{retries})..." - ) - except RequestException as e: - print(f"[ERROR] Failed to download {url}: {e}") - break - - time.sleep(delay) - - print(f"[ERROR] Unable to download file after {retries} attempts: {url}") - return False - - -def fetch_remote_checksum( - checksum_url: str, retries: int = 3, delay: int = 5 -) -> str | None: - for attempt in range(retries): - try: - response = requests.get(checksum_url, timeout=10) - response.raise_for_status() - return response.text.strip().split()[0] - - except ConnectionError: - print( - f"[ERROR] Connection failed when fetching {checksum_url}. Retrying ({attempt + 1}/{retries})..." - ) - except Timeout: - print( - f"[ERROR] Request timed out when fetching {checksum_url}. Retrying ({attempt + 1}/{retries})..." - ) - except RequestException as e: - print(f"[ERROR] Failed to fetch {checksum_url}: {e}") - break - - time.sleep(delay) - - print(f"[ERROR] Unable to fetch remote checksum after {retries} attempts.") - return None - - -def initialize_dataset() -> None: - DATASET_FOLDER.mkdir(exist_ok=True) - - latest_file = get_monday_date() - latest_file_url = f"{REMOTE_STORAGE_URL}/{latest_file}" - latest_checksum_url = f"{REMOTE_STORAGE_URL}/{latest_file}.sha256" - latest_symlink_file = f"{REMOTE_STORAGE_URL}/cloudexit-latest.db.gz" - latest_symlink_checksum_url = f"{REMOTE_STORAGE_URL}/cloudexit-latest.db.gz.sha256" - - local_db_path = DATASET_FOLDER / "data.db" - local_compressed_path = DATASET_FOLDER / latest_file - - # Fetch checksum for the date-based file - remote_checksum = fetch_remote_checksum(latest_checksum_url) - if not remote_checksum: - print(f"[INFO] Unable to fetch remote checksum from {latest_checksum_url}.") - print(f"[INFO] Trying latest symlink from {latest_symlink_checksum_url}...") - remote_checksum = fetch_remote_checksum(latest_symlink_checksum_url) - latest_file_url = latest_symlink_file - latest_file = "cloudexit-latest.db.gz" - local_compressed_path = DATASET_FOLDER / latest_file - - if not remote_checksum: - print("[ERROR] Unable to fetch any remote checksum. Skipping update.") - - else: - # Check if local compressed file exists - if local_compressed_path.exists(): - local_checksum = compute_file_hash(local_compressed_path) - if local_checksum == remote_checksum: - print("[INFO] Local dataset is up-to-date. No download needed.") - return - else: - print( - "[INFO] Local dataset is outdated. Removing old files and downloading new dataset..." - ) - - # Remove all old compressed and extracted files - for file in DATASET_FOLDER.glob("cloudexit-*.db.gz"): - os.remove(file) - if local_db_path.exists(): - os.remove(local_db_path) - - # Download and extract dataset - if download_file(latest_file_url, local_compressed_path): - downloaded_checksum = compute_file_hash(local_compressed_path) - if downloaded_checksum != remote_checksum: - print( - f"[ERROR] Checksum mismatch for {latest_file}. " - f"Expected {remote_checksum}, got {downloaded_checksum}. " - "Discarding downloaded file." - ) - os.remove(local_compressed_path) - else: - print( - f"[INFO] Checksum verified. Extracting dataset from {latest_file}..." - ) - - with ( - gzip.open(local_compressed_path, "rb") as f_in, - open(local_db_path, "wb") as f_out, - ): - shutil.copyfileobj(f_in, f_out) - - print("[INFO] Dataset updated successfully.") - - if not local_db_path.exists() or local_db_path.stat().st_size == 0: - print( - f"[ERROR] Dataset database {local_db_path} is missing or empty! " - "Cannot proceed without data." - ) - sys.exit(1) diff --git a/utils/sync.py b/utils/sync.py deleted file mode 100644 index 609c058..0000000 --- a/utils/sync.py +++ /dev/null @@ -1,53 +0,0 @@ -# utils/sync.py -from __future__ import annotations - -import logging -import requests -import config -from typing import Any -from utils.auth import get_jwt_token - -logger = logging.getLogger("main.utils.sync") - -_ASSESS_PATH = "/api/v1/assessments/" - - -def _build_url(host: str) -> str: - host = host.strip().rstrip("/") - if host.startswith("http://"): - host = "https://" + host[len("http://") :] - elif not host.startswith("https://"): - host = f"https://{host}" - return f"{host}{_ASSESS_PATH}" - - -def submit_assessment( - payload: dict[str, Any], - *, - host: str | None = None, - key: str | None = None, - timeout: int = 10, -) -> requests.Response | None: - host = host or getattr(config, "HOST", "") if config else "" - if not host: - logger.debug("HOST not configured – skipping assessment sync.") - return None - - token = get_jwt_token(host=host, key=key) if key else get_jwt_token(host=host) - if not token: - logger.debug("Could not obtain JWT – skipping assessment sync.") - return None - - url = _build_url(host) - headers = { - "Authorization": f"Bearer {token}", - "Content-Type": "application/json", - } - - try: - resp = requests.post(url, headers=headers, json=payload, timeout=timeout) - logger.info("POST %s – status %s", url, resp.status_code) - return resp - except requests.RequestException as exc: - logger.error("Assessment POST failed: %s", exc, exc_info=True) - return None diff --git a/utils/utils.py b/utils/utils.py deleted file mode 100644 index b6f264c..0000000 --- a/utils/utils.py +++ /dev/null @@ -1,187 +0,0 @@ -# utils/utils.py -import os -import sys -import logging -import json -from typing import Any -from rich.console import Console -from rich.style import Style -from time import sleep -from datetime import datetime - -from utils import codes - -logger = logging.getLogger("main.utils") -console = Console() - - -def load_config(file_path: str) -> dict[str, Any] | None: - try: - # logger.info(f"Attempting to load config file from {file_path}") - with open(file_path, "r") as f: - config = json.load(f) - # logger.info("Config file loaded successfully.") - return config - except Exception as e: - logger.error(f"Error loading config file: {e}", exc_info=True) - console.print(f"[red]Error loading config file: {e}[/red]") - return None - - -def resolve_name(args) -> str: - if getattr(args, "name", None) and args.name.strip(): - return args.name.strip() - return f"Exit Assessment {datetime.now().strftime('%Y%m%d_%H%M%S')}" - - -def build_config( - cloud_provider: int, - exit_strategy: int, - assessment_type: int, - provider_details: dict, - args, -) -> dict: - return { - "name": resolve_name(args), - "cloudServiceProvider": cloud_provider, - "exitStrategy": exit_strategy, - "assessmentType": assessment_type, - "providerDetails": provider_details, - } - - -def prompt_required_inputs() -> tuple[int, int]: - while True: - try: - exit_strategy = int( - input( - "Enter Exit Strategy (1 for 'Repatriation to On-Premises', 3 for 'Migration to Alternate Cloud'): " - ).strip() - ) - if exit_strategy not in [1, 3]: - raise ValueError("Invalid exit strategy.") - # logger.info(f"Exit Strategy selected: {exit_strategy}") - break - except ValueError as e: - logger.debug(f"Invalid exit strategy input: {e}") - console.print(f"[red]{e} Please enter 1 or 3.[/red]") - - while True: - try: - assessment_type = int( - input( - "Enter Assessment Type (1 for 'Basic', 2 for 'Standard'): " - ).strip() - ) - if assessment_type not in [1, 2]: - raise ValueError("Invalid assessment type.") - # logger.info(f"Assessment Type selected: {assessment_type}") - break - except ValueError as e: - logger.debug(f"Invalid assessment type input: {e}") - console.print(f"[red]{e} Please enter 1 or 2.[/red]") - - return exit_strategy, assessment_type - - -def print_step( - description: str, status: str = "pending", logs: str | None = None -) -> None: - # Define styles for statuses - ok_style = Style(color="green", bold=True) - error_style = Style(color="red", bold=True) - warning_style = Style(color="yellow", bold=True) - # Map statuses to their visual representation - status_map = { - "ok": "[ ok ]", - "error": "[ error ]", - "warning": "[ warn ]", - "pending": "[ ... ]", - } - - # Handle the pending status with a spinner - if status == "pending": - with console.status( - f"{description:<50} [yellow]{status_map['pending']}[/yellow]", - spinner="dots", - ): - sleep(2) - print_step(description, status="ok") - elif status == "ok": - console.print(f"{description:<50} {status_map['ok']}", style=ok_style) - elif status == "warning": - console.print(f"{description:<50} {status_map['warning']}", style=warning_style) - if logs: - console.print(f" ↳ {logs}", style="dim") - elif status == "error": - console.print(f"{description:<50} {status_map['error']}", style=error_style) - if logs: - console.print(f" ↳ {logs}", style="dim") - - -ascii_art = r""" - _ _ _ _ - | | | | (_) | - ___| | ___ _ _ __| | _____ ___| |_ - / __| |/ _ \| | | |/ _` |/ _ \ \/ / | __| -| (__| | (_) | |_| | (_| | __/> <| | |_ - \___|_|\___/ \__,_|\__,_|\___/_/\_\_|\__| - - -""" - - -def create_directory(base_path="reports"): - # Generate the main directory with a timestamp - timestamp = datetime.now().strftime("%Y%m%d%H%M%S") - directory_path = os.path.join(base_path, timestamp) - - # Create the main directory - os.makedirs(directory_path, exist_ok=True) - - # Create the raw_data subdirectory within the main directory - raw_data_path = os.path.join(directory_path, "raw_data") - os.makedirs(raw_data_path, exist_ok=True) - - return directory_path, raw_data_path - - -def require_env(var: str, description: str) -> str: - """Return the value of an env var, or exit with CONFIG if it is unset/empty.""" - value = os.environ.get(var, "").strip() - if not value: - console.print( - f"[red]--non-interactive requires {description}. " - f"Set the {var} environment variable.[/red]" - ) - sys.exit(codes.CONFIG) - return value - - -def require_env_int(var: str, description: str, valid: set) -> int: - """Return an env var parsed as int, validated against an allowed set.""" - raw = require_env(var, description) - try: - value = int(raw) - except ValueError: - console.print(f"[red]{var} must be an integer, got: {raw!r}[/red]") - sys.exit(codes.CONFIG) - if value not in valid: - console.print(f"[red]{var} must be one of {sorted(valid)}, got: {value}[/red]") - sys.exit(codes.CONFIG) - return value - - -def print_help_message(): - console.print("EscapeCloud - Community Edition", style="bold cyan") - console.print("[green]Run the script with one of the following options:[/green]\n") - console.print(" python3 main.py aws") - console.print(" python3 main.py aws --config config/aws.json") - console.print(" python3 main.py aws --profile PROFILE") - console.print(" python3 main.py aws --name 'DMS System' ") - console.print(" python3 main.py aws --tfstate infra.tfstate") - console.print(" python3 main.py azure") - console.print(" python3 main.py azure --config config/azure.json") - console.print(" python3 main.py azure --cli") - console.print(" python3 main.py azure --name 'DMS System'") - console.print(" python3 main.py azure --tfstate infra.tfstate --dry-run") diff --git a/utils/version.py b/utils/version.py deleted file mode 100644 index bf78826..0000000 --- a/utils/version.py +++ /dev/null @@ -1 +0,0 @@ -__version__ = "1.1.7"