Full Code of escapecloud/cloudexit for AI

main 290c8ae9026d cached
39 files
299.7 KB
73.3k tokens
129 symbols
1 requests
Download .txt
Showing preview only (313K chars total). Download the full file or copy to clipboard to get everything.
Repository: escapecloud/cloudexit
Branch: main
Commit: 290c8ae9026d
Files: 39
Total size: 299.7 KB

Directory structure:
gitextract_4srv3mcm/

├── .github/
│   └── workflows/
│       └── pr-checks.yml
├── .gitignore
├── LICENSE
├── README.md
├── assets/
│   ├── css/
│   │   └── style.css
│   └── template/
│       └── index.html
├── config/
│   ├── aws_example.json
│   └── azure_example.json
├── config.py
├── core/
│   ├── __init__.py
│   ├── engine.py
│   ├── utils.py
│   ├── utils_aws.py
│   ├── utils_azure.py
│   ├── utils_db.py
│   ├── utils_report.py
│   ├── utils_report_common.py
│   ├── utils_report_html.py
│   ├── utils_report_json.py
│   ├── utils_report_pdf.py
│   └── utils_sync.py
├── main.py
├── publiccode.yml
├── requirements-dev.txt
├── requirements.txt
├── tests/
│   ├── __init__.py
│   ├── report_fixtures.py
│   ├── test_report_pipeline.py
│   ├── test_report_transforms.py
│   ├── test_utils_and_main.py
│   └── test_validate.py
└── utils/
    ├── aws.py
    ├── azure.py
    ├── connection.py
    ├── constants.py
    ├── data.py
    ├── sync.py
    ├── utils.py
    └── validate.py

================================================
FILE CONTENTS
================================================

================================================
FILE: .github/workflows/pr-checks.yml
================================================
name: PR Checks

on:
  pull_request:
    types: [opened, synchronize, reopened, ready_for_review]

permissions:
  contents: read

env:
  PYTHON_VERSION: "3.14"

jobs:
  check-linting:
    if: github.event.pull_request.draft == false
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5

      - name: Set up Python
        uses: actions/setup-python@v6
        with:
          python-version: ${{ env.PYTHON_VERSION }}
          cache: pip

      - name: Install dependencies
        run: |
          python -m pip install --upgrade pip
          pip install -r requirements-dev.txt

      - name: Ruff
        run: ruff check main.py core utils tests

  check-types:
    if: github.event.pull_request.draft == false
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5

      - name: Set up Python
        uses: actions/setup-python@v6
        with:
          python-version: ${{ env.PYTHON_VERSION }}
          cache: pip

      - name: Install dependencies
        run: |
          python -m pip install --upgrade pip
          pip install -r requirements-dev.txt

      - name: Black
        run: black --check main.py core utils tests

  check-tests:
    if: github.event.pull_request.draft == false
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5

      - name: Set up Python
        uses: actions/setup-python@v6
        with:
          python-version: ${{ env.PYTHON_VERSION }}
          cache: pip

      - name: Install dependencies
        run: |
          python -m pip install --upgrade pip
          pip install -r requirements-dev.txt

      - name: Unit tests
        run: python -m unittest discover -s tests


================================================
FILE: .gitignore
================================================
# Created by https://www.toptal.com/developers/gitignore/api/macos,windows,linux,visualstudiocode,python,node
# Edit at https://www.toptal.com/developers/gitignore?templates=macos,windows,linux,visualstudiocode,python,node

### Linux ###
*~

# temporary files which can be created if a process still has a handle open of a deleted file
.fuse_hidden*

# KDE directory preferences
.directory

# Linux trash folder which might appear on any partition or disk
.Trash-*

# .nfs files are created when an open file is removed but is still being accessed
.nfs*

### macOS ###
# General
.DS_Store
.AppleDouble
.LSOverride

# Icon must end with two \r
Icon


# Thumbnails
._*

# Files that might appear in the root of a volume
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent

# Directories potentially created on remote AFP share
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk

### macOS Patch ###
# iCloud generated files
*.icloud

### Node ###
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*

# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json

# Runtime data
pids
*.pid
*.seed
*.pid.lock

# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov

# Coverage directory used by tools like istanbul
coverage
*.lcov

# nyc test coverage
.nyc_output

# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt

# Bower dependency directory (https://bower.io/)
bower_components

# node-waf configuration
.lock-wscript

# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release

# Dependency directories
node_modules/
jspm_packages/

# Snowpack dependency directory (https://snowpack.dev/)
web_modules/

# Generated reports
reports/

# Generated reports
config/aws.json
config/azure.json

# TypeScript cache
*.tsbuildinfo

# Optional npm cache directory
.npm

# Optional eslint cache
.eslintcache

# Optional stylelint cache
.stylelintcache

# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/

# Optional REPL history
.node_repl_history

# Output of 'npm pack'
*.tgz

# Yarn Integrity file
.yarn-integrity

# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local

# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache

# Next.js build output
.next
out

# Nuxt.js build / generate output
.nuxt
dist

# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public

# vuepress build output
.vuepress/dist

# vuepress v2.x temp and cache directory
.temp

# Docusaurus cache and generated files
.docusaurus

# Serverless directories
.serverless/

# FuseBox cache
.fusebox/

# DynamoDB Local files
.dynamodb/

# TernJS port file
.tern-port

# Stores VSCode versions used for testing VSCode extensions
.vscode-test

# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*

### Node Patch ###
# Serverless Webpack directories
.webpack/

# Optional stylelint cache

# SvelteKit build / generate output
.svelte-kit

### Python ###
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class

# C extensions
*.so

# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST

# PyInstaller
#  Usually these files are written by a python script from a template
#  before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec

# Installer logs
pip-log.txt
pip-delete-this-directory.txt

# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/

# Translations
*.mo
*.pot

# Django stuff:
local_settings.py
db.sqlite3
db.sqlite3-journal

# Flask stuff:
instance/
.webassets-cache

# Scrapy stuff:
.scrapy

# Sphinx documentation
docs/_build/

# PyBuilder
.pybuilder/
target/

# Jupyter Notebook
.ipynb_checkpoints

# IPython
profile_default/
ipython_config.py

# pyenv
#   For a library or package, you might want to ignore these files since the code is
#   intended to run in multiple environments; otherwise, check them in:
# .python-version

# pipenv
#   According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
#   However, in case of collaboration, if having platform-specific dependencies or dependencies
#   having no cross-platform support, pipenv may install dependencies that don't work, or not
#   install all needed dependencies.
#Pipfile.lock

# poetry
#   Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
#   This is especially recommended for binary packages to ensure reproducibility, and is more
#   commonly ignored for libraries.
#   https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock

# pdm
#   Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
#   pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
#   in version control.
#   https://pdm.fming.dev/#use-with-ide
.pdm.toml

# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/

# Celery stuff
celerybeat-schedule
celerybeat.pid

# SageMath parsed files
*.sage.py

# Environments
.venv
env/
venv/
ENV/
env.bak/
venv.bak/

# Spyder project settings
.spyderproject
.spyproject

# Rope project settings
.ropeproject

# mkdocs documentation
/site

# mypy
.mypy_cache/
.dmypy.json
dmypy.json

# Pyre type checker
.pyre/

# pytype static type analyzer
.pytype/

# Cython debug symbols
cython_debug/

# PyCharm
#  JetBrains specific template is maintained in a separate JetBrains.gitignore that can
#  be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
#  and can be added to the global gitignore or merged into this file.  For a more nuclear
#  option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/

### VisualStudioCode ###
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/*.code-snippets

# Local History for Visual Studio Code
.history/

# Built Visual Studio Code Extensions
*.vsix

### VisualStudioCode Patch ###
# Ignore all local history of files
.history
.ionide

### Windows ###
# Windows thumbnail cache files
Thumbs.db
Thumbs.db:encryptable
ehthumbs.db
ehthumbs_vista.db

# Dump file
*.stackdump

# Folder config file
[Dd]esktop.ini

# Recycle Bin used on file shares
$RECYCLE.BIN/

# Windows Installer files
*.cab
*.msi
*.msix
*.msm
*.msp

# Windows shortcuts
*.lnk

# End of https://www.toptal.com/developers/gitignore/api/macos,windows,linux,visualstudiocode,python,node
output/
logs/
*.xlsx


================================================
FILE: LICENSE
================================================
                    GNU AFFERO GENERAL PUBLIC LICENSE
                       Version 3, 19 November 2007

 Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
 Everyone is permitted to copy and distribute verbatim copies
 of this license document, but changing it is not allowed.

                            Preamble

  The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.

  The licenses for most software and other practical works are designed
to take away your freedom to share and change the works.  By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.

  When we speak of free software, we are referring to freedom, not
price.  Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.

  Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.

  A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate.  Many developers of free software are heartened and
encouraged by the resulting cooperation.  However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.

  The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community.  It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server.  Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.

  An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals.  This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.

  The precise terms and conditions for copying, distribution and
modification follow.

                       TERMS AND CONDITIONS

  0. Definitions.

  "This License" refers to version 3 of the GNU Affero General Public License.

  "Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.

  "The Program" refers to any copyrightable work licensed under this
License.  Each licensee is addressed as "you".  "Licensees" and
"recipients" may be individuals or organizations.

  To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy.  The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.

  A "covered work" means either the unmodified Program or a work based
on the Program.

  To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy.  Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.

  To "convey" a work means any kind of propagation that enables other
parties to make or receive copies.  Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.

  An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License.  If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.

  1. Source Code.

  The "source code" for a work means the preferred form of the work
for making modifications to it.  "Object code" means any non-source
form of a work.

  A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.

  The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form.  A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.

  The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities.  However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work.  For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.

  The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.

  The Corresponding Source for a work in source code form is that
same work.

  2. Basic Permissions.

  All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met.  This License explicitly affirms your unlimited
permission to run the unmodified Program.  The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work.  This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.

  You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force.  You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright.  Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.

  Conveying under any other circumstances is permitted solely under
the conditions stated below.  Sublicensing is not allowed; section 10
makes it unnecessary.

  3. Protecting Users' Legal Rights From Anti-Circumvention Law.

  No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.

  When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.

  4. Conveying Verbatim Copies.

  You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.

  You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.

  5. Conveying Modified Source Versions.

  You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:

    a) The work must carry prominent notices stating that you modified
    it, and giving a relevant date.

    b) The work must carry prominent notices stating that it is
    released under this License and any conditions added under section
    7.  This requirement modifies the requirement in section 4 to
    "keep intact all notices".

    c) You must license the entire work, as a whole, under this
    License to anyone who comes into possession of a copy.  This
    License will therefore apply, along with any applicable section 7
    additional terms, to the whole of the work, and all its parts,
    regardless of how they are packaged.  This License gives no
    permission to license the work in any other way, but it does not
    invalidate such permission if you have separately received it.

    d) If the work has interactive user interfaces, each must display
    Appropriate Legal Notices; however, if the Program has interactive
    interfaces that do not display Appropriate Legal Notices, your
    work need not make them do so.

  A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit.  Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.

  6. Conveying Non-Source Forms.

  You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:

    a) Convey the object code in, or embodied in, a physical product
    (including a physical distribution medium), accompanied by the
    Corresponding Source fixed on a durable physical medium
    customarily used for software interchange.

    b) Convey the object code in, or embodied in, a physical product
    (including a physical distribution medium), accompanied by a
    written offer, valid for at least three years and valid for as
    long as you offer spare parts or customer support for that product
    model, to give anyone who possesses the object code either (1) a
    copy of the Corresponding Source for all the software in the
    product that is covered by this License, on a durable physical
    medium customarily used for software interchange, for a price no
    more than your reasonable cost of physically performing this
    conveying of source, or (2) access to copy the
    Corresponding Source from a network server at no charge.

    c) Convey individual copies of the object code with a copy of the
    written offer to provide the Corresponding Source.  This
    alternative is allowed only occasionally and noncommercially, and
    only if you received the object code with such an offer, in accord
    with subsection 6b.

    d) Convey the object code by offering access from a designated
    place (gratis or for a charge), and offer equivalent access to the
    Corresponding Source in the same way through the same place at no
    further charge.  You need not require recipients to copy the
    Corresponding Source along with the object code.  If the place to
    copy the object code is a network server, the Corresponding Source
    may be on a different server (operated by you or a third party)
    that supports equivalent copying facilities, provided you maintain
    clear directions next to the object code saying where to find the
    Corresponding Source.  Regardless of what server hosts the
    Corresponding Source, you remain obligated to ensure that it is
    available for as long as needed to satisfy these requirements.

    e) Convey the object code using peer-to-peer transmission, provided
    you inform other peers where the object code and Corresponding
    Source of the work are being offered to the general public at no
    charge under subsection 6d.

  A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.

  A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling.  In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage.  For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product.  A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.

  "Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source.  The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.

  If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information.  But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).

  The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed.  Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.

  Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.

  7. Additional Terms.

  "Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law.  If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.

  When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it.  (Additional permissions may be written to require their own
removal in certain cases when you modify the work.)  You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.

  Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:

    a) Disclaiming warranty or limiting liability differently from the
    terms of sections 15 and 16 of this License; or

    b) Requiring preservation of specified reasonable legal notices or
    author attributions in that material or in the Appropriate Legal
    Notices displayed by works containing it; or

    c) Prohibiting misrepresentation of the origin of that material, or
    requiring that modified versions of such material be marked in
    reasonable ways as different from the original version; or

    d) Limiting the use for publicity purposes of names of licensors or
    authors of the material; or

    e) Declining to grant rights under trademark law for use of some
    trade names, trademarks, or service marks; or

    f) Requiring indemnification of licensors and authors of that
    material by anyone who conveys the material (or modified versions of
    it) with contractual assumptions of liability to the recipient, for
    any liability that these contractual assumptions directly impose on
    those licensors and authors.

  All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10.  If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term.  If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.

  If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.

  Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.

  8. Termination.

  You may not propagate or modify a covered work except as expressly
provided under this License.  Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).

  However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.

  Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.

  Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License.  If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.

  9. Acceptance Not Required for Having Copies.

  You are not required to accept this License in order to receive or
run a copy of the Program.  Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance.  However,
nothing other than this License grants you permission to propagate or
modify any covered work.  These actions infringe copyright if you do
not accept this License.  Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.

  10. Automatic Licensing of Downstream Recipients.

  Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License.  You are not responsible
for enforcing compliance by third parties with this License.

  An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations.  If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.

  You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License.  For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.

  11. Patents.

  A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based.  The
work thus licensed is called the contributor's "contributor version".

  A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version.  For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.

  Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.

  In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement).  To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.

  If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients.  "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.

  If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.

  A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License.  You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.

  Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.

  12. No Surrender of Others' Freedom.

  If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License.  If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all.  For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.

  13. Remote Network Interaction; Use with the GNU General Public License.

  Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software.  This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.

  Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work.  The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.

  14. Revised Versions of this License.

  The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time.  Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.

  Each version is given a distinguishing version number.  If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation.  If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.

  If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.

  Later license versions may give you additional or different
permissions.  However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.

  15. Disclaimer of Warranty.

  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.

  16. Limitation of Liability.

  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.

  17. Interpretation of Sections 15 and 16.

  If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.

                     END OF TERMS AND CONDITIONS

            How to Apply These Terms to Your New Programs

  If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.

  To do so, attach the following notices to the program.  It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.

    <one line to give the program's name and a brief idea of what it does.>
    Copyright (C) <year>  <name of author>

    This program is free software: you can redistribute it and/or modify
    it under the terms of the GNU Affero General Public License as published
    by the Free Software Foundation, either version 3 of the License, or
    (at your option) any later version.

    This program is distributed in the hope that it will be useful,
    but WITHOUT ANY WARRANTY; without even the implied warranty of
    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    GNU Affero General Public License for more details.

    You should have received a copy of the GNU Affero General Public License
    along with this program.  If not, see <https://www.gnu.org/licenses/>.

Also add information on how to contact you by electronic and paper mail.

  If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source.  For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code.  There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.

  You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.


================================================
FILE: README.md
================================================
![CloudExit](./docs/images/Main.png)

# cloudexit – Cloud Exit Assessment (Open Source)

cloudexit is an open-source tool that helps cloud engineers and technical teams assess **cloud exit readiness**.

It provides a structured, repeatable way to understand:
- what cloud services are in use
- where vendor lock-in risks exist
- how difficult an exit scenario would be
- what alternative technologies are available

cloudexit runs **locally by default**, with no account required.

---

## How cloudexit fits into the EscapeCloud ecosystem

cloudexit is the **Community / Open Source edition** of the EscapeCloud ecosystem.

- **cloudexit (this repository)**  
  Open-source, offline-first assessment engine

- **exitcloud.io**  
  Lightweight Cloud Exit Readiness Platform for individuals, SMEs, and MSPs

- **escapecloud.io**  
  Enterprise Cloud Exit Readiness Platform with advanced reporting and governance

cloudexit can be used:
- fully offline (Basic assessment)
- or connected to a platform (exitcloud.io / escapecloud.io) for richer reports and scoring

---

## Documentation

📘 **Full documentation:**  
👉 https://cloudexit.escapecloud.io

The documentation covers:
- getting started and prerequisites
- running assessments
- cloud providers and permissions
- reports and scores
- connected mode (exitcloud.io / escapecloud.io)
- troubleshooting and contribution guidelines

---

## License

cloudexit is licensed under the  
**GNU Affero General Public License v3 (AGPL-3.0)**

See the [LICENSE](https://www.gnu.org/licenses/agpl-3.0.html) file for details.

---

## Contributing

Contributions are welcome.

You can contribute by:
- reporting issues
- improving documentation
- submitting pull requests

Please see the documentation for contribution guidelines.


================================================
FILE: assets/css/style.css
================================================
/* ========================================================
   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='10' fill='%23E5EBEB'/%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='10' fill='%23E5EBEB'/%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='10' fill='%230D948B'/%3E%3Cpath d='M5.5 10.5L8.5 13.5L14.5 7.5' stroke='white' 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: 16px;
  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-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) {

}


================================================
FILE: assets/template/index.html
================================================
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <link
      rel="icon"
      type="image/png"
      sizes="16x16"
      href="assets/img/logo/favicon.png"
    />
    <title>EscapeCloud Community Edition - Cloud Exit Assessment Report</title>
    <link
      rel="stylesheet"
      href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css"
    />
    <link
      href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css"
      rel="stylesheet"
      integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN"
      crossorigin="anonymous"
    />
    <link rel="stylesheet" href="assets/css/style.css" />
    <script
      src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"
      integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL"
      crossorigin="anonymous"
    ></script>
    <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/@visactor/vchart/build/index.min.js"></script>
  </head>
  <body>
    {% 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 %}

    <div class="bg-white px-4">
      <div
        class="d-flex flex-column flex-md-row align-items-start justify-content-between align-self-md-center gap-3 py-4 px-4 project-title"
      >
        <h2 class="d-flex align-items-center gap-3 px-4">
          <img src="assets/img/logo/logo.png" width="30" alt="EscapeCloud" />
          <span>{{ name }}</span>
        </h2>
        <div class="d-flex align-items-center gap-3 px-4 flex-wrap flex-sm-nowrap">
          <a href="report.pdf" target="_blank" class="btn btn-primary"
            >Executive Summary (PDF)</a
          >
        </div>
      </div>
      <div class="divider-border"></div>
    </div>

    <main id="main-content">
      <div class="px-3 px-md-4">
        <div class="mt-1 row g-3 stats">
          <div class="col-xl-4 col-lg-6 col-md-6 chart-box">
            <div
              class="d-flex align-items-center bg-white shadow-s p-3 p-lg-4 rounded-4 h-100"
            >
              <div class="d-flex flex-wrap flex-column gap-25 sync-status">
                <div class="d-flex align-items-center gap-3 sync-status-card">
                  <div class="sync-status-icon">
                    <span>
                      {% if cloud_service_provider == 1 %}
                      <svg
                        width="22"
                        height="20"
                        viewBox="0 0 22 20"
                        fill="none"
                        xmlns="http://www.w3.org/2000/svg"
                        aria-label="Microsoft Azure"
                        role="img"
                      >
                        <path d="M7.86133 1.37256H13.4145L7.65764 18.4703C7.53972 18.8352 7.19666 19.0714 6.82145 19.0714H2.4904C1.99726 19.0714 1.61133 18.6742 1.61133 18.1912C1.61133 18.0946 1.62205 17.998 1.65421 17.9122L7.02514 1.97361C7.14306 1.61942 7.48611 1.37256 7.86133 1.37256Z" fill="url(#azure-paint0)"/>
                        <path d="M15.9334 12.8354H7.13198C6.90686 12.8354 6.72461 13.0179 6.72461 13.2433C6.72461 13.3614 6.76749 13.4687 6.85325 13.5438L12.5136 18.8245C12.6744 18.9748 12.8996 19.0606 13.1247 19.0606H18.1097L15.9334 12.8354Z" fill="#0078D4"/>
                        <path d="M7.86108 1.37256C7.47515 1.37256 7.13209 1.61942 7.02489 1.98434L1.66468 17.88C1.50388 18.3415 1.73973 18.846 2.2007 19.007C2.29719 19.0392 2.39367 19.0606 2.50088 19.0606H6.92841C7.26074 18.9962 7.53947 18.7601 7.65739 18.4381L8.72944 15.2826L12.5459 18.8674C12.7067 18.9962 12.9104 19.0714 13.1141 19.0714H18.0776L15.9014 12.8355H9.55491L13.4357 1.37256H7.86108Z" fill="url(#azure-paint1)"/>
                        <path d="M14.9584 1.97361C14.8405 1.60869 14.4974 1.37256 14.1222 1.37256H7.93652C8.31174 1.37256 8.65479 1.61942 8.77272 1.97361L14.1329 17.9015C14.2937 18.363 14.0364 18.8674 13.5755 19.0177C13.4897 19.0499 13.3932 19.0606 13.2967 19.0606H19.4824C19.9755 19.0606 20.3615 18.6635 20.3615 18.1805C20.3615 18.0839 20.3508 17.9873 20.3186 17.9015L14.9584 1.97361Z" fill="url(#azure-paint2)"/>
                        <defs>
                          <linearGradient id="azure-paint0" x1="9.88834" y1="2.68414" x2="4.11123" y2="19.7311" gradientUnits="userSpaceOnUse">
                            <stop stop-color="#114A8B"/>
                            <stop offset="1" stop-color="#0765B6"/>
                          </linearGradient>
                          <linearGradient id="azure-paint1" x1="11.6889" y1="10.6302" x2="10.355" y2="11.0807" gradientUnits="userSpaceOnUse">
                            <stop stop-opacity="0.3"/>
                            <stop offset="0.071" stop-opacity="0.2"/>
                            <stop offset="0.321" stop-opacity="0.1"/>
                            <stop offset="0.623" stop-opacity="0.05"/>
                            <stop offset="1" stop-opacity="0"/>
                          </linearGradient>
                          <linearGradient id="azure-paint2" x1="10.9974" y1="2.17127" x2="17.3387" y2="19.0457" gradientUnits="userSpaceOnUse">
                            <stop stop-color="#3BC9F3"/>
                            <stop offset="1" stop-color="#2892DF"/>
                          </linearGradient>
                        </defs>
                      </svg>
                      {% elif cloud_service_provider == 2 %}
                      <svg
                        width="35"
                        height="15"
                        viewBox="0 0 35 20"
                        fill="none"
                        xmlns="http://www.w3.org/2000/svg"
                        aria-label="Amazon Web Services"
                        role="img"
                      >
                        <g clip-path="url(#aws-clip0)">
                          <path d="M10.0898 7.25005C10.0898 7.66672 10.1453 8.00005 10.2009 8.25005C10.2843 8.50005 10.3955 8.75005 10.5623 9.05561C10.6179 9.13894 10.6457 9.22228 10.6457 9.30561C10.6457 9.41672 10.5901 9.52783 10.4233 9.63894L9.7284 10.1112C9.61722 10.1667 9.53383 10.2223 9.45044 10.2223C9.33925 10.2223 9.22807 10.1667 9.11688 10.0556C8.95011 9.88894 8.83892 9.72228 8.72774 9.52783C8.61655 9.33339 8.50537 9.13894 8.39418 8.86117C7.5325 9.88894 6.44845 10.3889 5.11423 10.3889C4.16915 10.3889 3.44645 10.1112 2.89053 9.58339C2.3346 9.05561 2.05664 8.33339 2.05664 7.4445C2.05664 6.50005 2.3902 5.72228 3.05731 5.16672C3.72441 4.61117 4.64169 4.30561 5.78134 4.30561C6.17048 4.30561 6.55963 4.33339 6.94878 4.38894C7.36572 4.4445 7.78266 4.52783 8.2274 4.63894V3.83339C8.2274 3.00005 8.06063 2.38894 7.69928 2.05561C7.33792 1.72228 6.7542 1.55561 5.89252 1.55561C5.50337 1.55561 5.11423 1.61117 4.69728 1.6945C4.28034 1.80561 3.89119 1.91672 3.50204 2.08339C3.33527 2.13894 3.19629 2.1945 3.1129 2.22228C3.02951 2.22228 2.97392 2.25005 2.94612 2.25005C2.77934 2.25005 2.72375 2.13894 2.72375 1.91672V1.36117C2.72375 1.1945 2.75155 1.05561 2.80714 0.972277C2.86273 0.888943 2.97392 0.80561 3.1129 0.750054C3.50204 0.55561 3.97458 0.388943 4.50271 0.250054C5.05863 0.111165 5.61456 0.027832 6.22608 0.027832C7.56029 0.027832 8.50537 0.333388 9.14468 0.916721C9.7562 1.52783 10.062 2.41672 10.062 3.66672L10.0898 7.25005ZM5.58676 8.9445C5.94811 8.9445 6.33726 8.88894 6.72641 8.75005C7.11555 8.61117 7.47691 8.36117 7.78266 8.02783C7.94944 7.80561 8.08842 7.58339 8.17181 7.30561C8.2274 7.02783 8.283 6.72228 8.283 6.33339V5.86117C7.94944 5.77783 7.61589 5.72228 7.25454 5.66672C6.89319 5.61117 6.55963 5.61117 6.19828 5.61117C5.44778 5.61117 4.91965 5.75005 4.53051 6.05561C4.16915 6.36117 3.97458 6.77783 3.97458 7.33339C3.97458 7.86117 4.11356 8.25005 4.39152 8.50005C4.66949 8.83339 5.05863 8.9445 5.58676 8.9445ZM14.5094 10.1667C14.3148 10.1667 14.1758 10.1389 14.0924 10.0556C14.009 10.0001 13.9256 9.83339 13.87 9.61117L11.2572 1.00005C11.2016 0.777832 11.146 0.638943 11.146 0.55561C11.146 0.388943 11.2294 0.277832 11.424 0.277832H12.508C12.7304 0.277832 12.8694 0.30561 12.9528 0.388943C13.0362 0.444499 13.1195 0.611165 13.1751 0.833388L15.0375 8.1945L16.7608 0.833388C16.8164 0.611165 16.872 0.472276 16.9832 0.388943C17.0666 0.333388 17.2334 0.277832 17.428 0.277832H18.3174C18.5398 0.277832 18.6788 0.30561 18.7622 0.388943C18.8456 0.444499 18.929 0.611165 18.9845 0.833388L20.7357 8.27783L22.6536 0.833388C22.7092 0.611165 22.7926 0.472276 22.876 0.388943C22.9594 0.333388 23.0984 0.277832 23.3208 0.277832H24.3492C24.516 0.277832 24.6272 0.361165 24.6272 0.55561C24.6272 0.611165 24.6272 0.666721 24.5994 0.722277C24.5994 0.777832 24.5716 0.888943 24.516 1.00005L21.8198 9.61117C21.7642 9.83339 21.6808 9.97228 21.5974 10.0556C21.514 10.1112 21.375 10.1667 21.1804 10.1667H20.2354C20.013 10.1667 19.874 10.1389 19.7906 10.0556C19.7072 9.97228 19.6239 9.83339 19.5683 9.61117L17.8449 2.4445L16.1215 9.61117C16.0659 9.83339 16.0103 9.97228 15.8992 10.0556C15.8158 10.1389 15.649 10.1667 15.4544 10.1667H14.5094ZM28.8244 10.4445C28.2407 10.4445 27.657 10.3889 27.101 10.2501C26.5451 10.1112 26.1004 9.97228 25.8224 9.80561C25.6556 9.6945 25.5167 9.58339 25.4889 9.50006C25.4611 9.41672 25.4055 9.27783 25.4055 9.1945V8.63894C25.4055 8.41672 25.4889 8.30561 25.6556 8.30561C25.7112 8.30561 25.7946 8.30561 25.8502 8.33339C25.9058 8.36117 26.017 8.38894 26.1282 8.4445C26.5173 8.61117 26.9065 8.75005 27.3512 8.83339C27.796 8.91672 28.2407 8.97228 28.6854 8.97228C29.3803 8.97228 29.9363 8.86117 30.2976 8.61117C30.6868 8.36117 30.8813 8.00005 30.8813 7.55561C30.8813 7.25005 30.7701 7.00005 30.5756 6.77783C30.381 6.55561 29.9919 6.38894 29.4637 6.1945L27.8515 5.6945C27.0455 5.4445 26.4339 5.05561 26.0726 4.55561C25.7112 4.05561 25.5167 3.52783 25.5167 2.9445C25.5167 2.47228 25.6278 2.05561 25.8224 1.72228C26.017 1.38894 26.295 1.05561 26.6285 0.80561C26.9621 0.55561 27.3512 0.361165 27.796 0.222276C28.2407 0.0833876 28.7132 0.027832 29.1858 0.027832C29.4359 0.027832 29.6861 0.027832 29.9363 0.0833876C30.1864 0.111165 30.4366 0.166721 30.659 0.194499C30.8813 0.250054 31.1037 0.30561 31.2983 0.361165C31.4929 0.416721 31.6596 0.500054 31.7708 0.55561C31.9376 0.638943 32.0488 0.722277 32.1044 0.833388C32.16 0.916721 32.2156 1.05561 32.2156 1.1945V1.72228C32.2156 1.9445 32.1322 2.08339 31.9654 2.08339C31.882 2.08339 31.743 2.02783 31.5484 1.9445C30.9091 1.66672 30.2142 1.50005 29.4081 1.50005C28.7688 1.50005 28.2685 1.61117 27.9349 1.80561C27.6014 2.00005 27.4068 2.33339 27.4068 2.80561C27.4068 3.11117 27.518 3.38894 27.7404 3.58339C27.9627 3.80561 28.3797 4.00005 28.9634 4.1945L30.5478 4.6945C31.3539 4.9445 31.9376 5.30561 32.2711 5.75005C32.6047 6.1945 32.7715 6.72228 32.7715 7.30561C32.7715 7.77783 32.6603 8.22228 32.4935 8.58339C32.2989 8.97228 32.021 9.30561 31.6874 9.55561C31.3539 9.83339 30.9369 10.0278 30.4644 10.1667C29.9085 10.3889 29.3803 10.4445 28.8244 10.4445Z" fill="#252F3E"/>
                          <path fill-rule="evenodd" clip-rule="evenodd" d="M30.937 15.8612C27.2679 18.5556 21.9311 20.0001 17.3725 20.0001C10.9515 20.0001 5.16993 17.639 0.805916 13.6945C0.472362 13.389 0.77812 12.9723 1.19506 13.1945C5.92042 15.9445 11.7298 17.5834 17.7616 17.5834C21.8199 17.5834 26.2951 16.7501 30.4089 15.0001C30.9926 14.7501 31.5208 15.4167 30.937 15.8612Z" fill="#FF9900"/>
                          <path fill-rule="evenodd" clip-rule="evenodd" d="M32.438 14.1391C31.9655 13.528 29.3527 13.8613 28.1574 14.0002C27.7961 14.0558 27.7405 13.7224 28.074 13.5002C30.1587 12.028 33.6055 12.4447 33.9946 12.9447C34.3838 13.4447 33.8834 16.8891 31.9377 18.528C31.6319 18.778 31.354 18.6391 31.493 18.3058C31.9377 17.1947 32.9106 14.7224 32.438 14.1391Z" fill="#FF9900"/>
                          <defs>
                            <clipPath id="aws-clip0">
                              <rect width="33.4667" height="20" fill="white" transform="translate(0.666992)"/>
                            </clipPath>
                          </defs>
                        </g>
                      </svg>
                      {% else %}
                      <img src="assets/img/logo/logo.png" alt="{{ provider_name }}" />
                      {% endif %}
                    </span>
                  </div>
                  <div class="sync-status-text">
                    <h6>Cloud Service Provider</h6>
                    <h5>{{ provider_name }}</h5>
                  </div>
                </div>
                <div class="d-flex align-items-center gap-3 sync-status-card">
                  <div class="sync-status-icon">
                    <span><i class="bi bi-sign-turn-slight-right"></i></span>
                  </div>
                  <div class="sync-status-text">
                    <h6>Exit Strategy</h6>
                    <h5>{{ strategy_name }}</h5>
                  </div>
                </div>
                <div class="d-flex align-items-center gap-3 sync-status-card">
                  <div class="sync-status-icon">
                    <span><i class="bi bi-box"></i></span>
                  </div>
                  <div class="sync-status-text">
                    <h6>Assessment Type</h6>
                    <h5>{{ assessment_name }}</h5>
                  </div>
                </div>
                <div class="d-flex align-items-center gap-3 sync-status-card">
                  <div class="sync-status-icon">
                    <span><i class="bi bi-clock"></i></span>
                  </div>
                  <div class="sync-status-text">
                    <h6>Timestamp</h6>
                    <h5>{{ timestamp }}</h5>
                  </div>
                </div>
              </div>
            </div>
          </div>

          <div class="col-xl-4 col-lg-6 col-md-6 chart-box">
            <div class="bg-white shadow-s rounded-4 h-100">
              <div class="px-3 px-lg-4 py-3 risk-dashboard">
                <div class="risk-header">
                  <div class="risk-count">
                    <h2>Risks</h2>
                    <div class="count counter">{{ total_risks }}</div>
                  </div>
                </div>
                {% if total_risks > 0 %}
                <div class="my-3 chart-container">
                  <canvas id="risksChart" width="300" height="250"></canvas>
                </div>
                {% else %}
                <div class="chart-empty-state">
                  <div class="chart-empty-state-inner">
                    <i class="bi bi-pie-chart-fill"></i>
                    <p>No risk data available.</p>
                  </div>
                </div>
                {% endif %}
              </div>
            </div>
          </div>

          <div class="col-xl-4 col-lg-6 col-md-6 chart-box">
            <div class="bg-white shadow-s rounded-4 h-100">
              <div class="px-3 px-lg-4 py-3 risk-dashboard">
                <div class="risk-header">
                  <div class="risk-count">
                    <h2>Costs (last 6 months)</h2>
                    <div class="count">
                      {% if total_cost > 0 %}{{ currency_symbol }}{{ total_cost }}{% else
                      %}-{% endif %}
                    </div>
                  </div>
                </div>
                {% if total_cost > 0 %}
                <div class="mt-4 chart-container">
                  <canvas id="costsChart" width="300" height="250"></canvas>
                </div>
                {% else %}
                <div class="chart-empty-state">
                  <div class="chart-empty-state-inner">
                    <i class="bi bi-bar-chart-fill"></i>
                    <p>No cost data available.</p>
                  </div>
                </div>
                {% endif %}
              </div>
            </div>
          </div>
        </div>

        {% if assessment_type == 2 %}
        <div class="mt-1 row g-3">
          <div class="col-md-6">
            <div class="bg-white shadow-s rounded-4 h-100 scoring-card">
              <div class="scoring-title">
                <div
                  class="d-flex align-items-center justify-content-between chart-card-head"
                >
                  <div>
                    <h3 class="m-0">Exit Readiness Score</h3>
                  </div>
                  <div class="info-hint-box">
                    <i class="bi bi-info-circle"></i>
                    <div class="hint-hoverbox">
                      <h5>Exit Score</h5>
                      <div class="hint-dt">
                        <p>
                          This gauge chart represents the EscapeCloud Platform's
                          exit score methodology, based on risk assessment
                          results and the alternative technology landscape.
                          <br /><br />It uses a benchmark developed by our
                          experts to quantify the challenges and limitations of
                          exiting the cloud:
                          <br />- Complex (0 - 20)
                          <br />- Challenging (20 - 40)
                          <br />- Manageable (40 - 60)
                          <br />- Smooth Transition (60 - 80)
                          <br />- Seamless (80 - 100)
                        </p>
                      </div>
                    </div>
                  </div>
                </div>
              </div>
              <div class="divider-border"></div>
              {% if scoring_data %}
              <div class="scoring-inner py-4">
                <div class="chart-wrapper">
                  <div
                    class="scoring"
                    id="exitScoreChart"
                    style="width: 100%; height: 100%;"
                  ></div>
                </div>
              </div>
              {% else %}
              <div class="chart-empty-state scoring-empty-state">
                <div class="chart-empty-state-inner">
                  <i class="bi bi-speedometer2"></i>
                  <p>No exit score data available.</p>
                </div>
              </div>
              {% endif %}
            </div>
          </div>

          <div class="col-md-6">
            <div class="bg-white shadow-s rounded-4 h-100 scoring-card">
              <div class="scoring-title">
                <div
                  class="d-flex align-items-center justify-content-between chart-card-head"
                >
                  <div>
                    <h3 class="m-0">Vendor Lock-In Score</h3>
                  </div>
                  <div class="info-hintbx">
                    <i class="bi bi-info-circle"></i>
                    <div class="hint-hoverbox">
                      <h5>Vendor Lock-In Score</h5>
                      <div class="hint-dt">
                        <p>
                          The radar chart visualizes alternative technologies
                          across three dimensions:
                          <br />- Human (skills availability)
                          <br />- Technology (maturity and vendor stability)
                          <br />- Operational (ecosystem and support services)
                          <br /><br /><b>Only where viable alternatives exist.</b>
                        </p>
                      </div>
                    </div>
                  </div>
                </div>
              </div>
              <div class="divider-border"></div>
              {% if scoring_data %}
              <div class="py-4 scoring-inner">
                <div
                  class="scoring"
                  id="vendorLockInScoreChart"
                  style="width: 100%; height: 100%;"
                ></div>
              </div>
              {% else %}
              <div class="chart-empty-state scoring-empty-state">
                <div class="chart-empty-state-inner">
                  <i class="bi bi-stack"></i>
                  <p>No vendor lock-in score data available.</p>
                </div>
              </div>
              {% endif %}
            </div>
          </div>
        </div>
        {% endif %}

        <div class="mt-4 row g-0">
          <div class="col-12">
            <div class="bg-white shadow-s rounded-4">
              <div
                class="d-flex align-items-center justify-content-between px-3 px-lg-4 py-3"
              >
                <h3 class="form-title">Risks</h3>
                <div class="btn-group d-inline-block">
                  <button
                    class="btn-outline-primary btn dropdown-toggle"
                    type="button"
                    id="dropdownMenuButtonSeverity"
                    data-bs-toggle="dropdown"
                    aria-expanded="false"
                  >
                    All Severities <i class="ms-1 bi bi-chevron-down"></i>
                  </button>
                  <ul
                    class="dropdown-menu"
                    aria-labelledby="dropdownMenuButtonSeverity"
                  >
                    <li>
                      <a class="dropdown-item" href="#" data-severity="all"
                        >All Severities</a
                      >
                    </li>
                    <li>
                      <a class="dropdown-item" href="#" data-severity="high">High</a>
                    </li>
                    <li>
                      <a class="dropdown-item" href="#" data-severity="medium"
                        >Medium</a
                      >
                    </li>
                    <li>
                      <a class="dropdown-item" href="#" data-severity="low">Low</a>
                    </li>
                  </ul>
                </div>
              </div>
              <div class="divider-border"></div>

              <div class="risk-table-container p-3">
                <table class="table">
                  <thead>
                    <tr>
                      <th>#</th>
                      <th>Risk</th>
                      <th>Impacted Resources</th>
                      <th>Severity</th>
                    </tr>
                  </thead>
                  <tbody id="risk-table-body">
                    {% if risks %} {% for risk in risks %}
                    <tr
                      class="risk-row {% if loop.first %}expanded{% endif %}"
                      data-target="expandable-{{ loop.index }}"
                      data-severity="{{ risk.severity }}"
                    >
                      <td class="risk-number">{{ loop.index }}</td>
                      <td class="risk-title-cell">
                        <svg
                          class="chevron-icon"
                          width="6"
                          height="8"
                          viewBox="0 0 6 8"
                          fill="none"
                          xmlns="http://www.w3.org/2000/svg"
                          aria-hidden="true"
                        >
                          <path
                            fill-rule="evenodd"
                            clip-rule="evenodd"
                            d="M0.76711 7.81586C0.537434 7.577 0.544882 7.19718 0.783745 6.9675L3.93395 4L0.783745 1.0325C0.544881 0.802823 0.537434 0.422997 0.76711 0.184134C0.996786 -0.0547285 1.37661 -0.0621767 1.61547 0.167499L5.21548 3.5675C5.33312 3.68062 5.39961 3.83679 5.39961 4C5.39961 4.16321 5.33312 4.31938 5.21548 4.4325L1.61548 7.8325C1.37661 8.06218 0.996786 8.05473 0.76711 7.81586Z"
                            fill="#112726"
                          />
                        </svg>
                        <span class="risk-title">{{ risk.name }}</span>
                      </td>
                      <td class="impacted-count">
                        {% if risk.impacted_resources_count is none %}-{% else %}{{
                        risk.impacted_resources_count }}{% endif %}
                      </td>
                      <td class="text-center">
                        <span
                          class="severity-badge severity-{{ risk.severity }}"
                          >{{ risk.severity | capitalize }}</span
                        >
                      </td>
                    </tr>
                    <tr
                      class="expandable-content {% if loop.first %}show{% endif %}"
                      id="expandable-{{ loop.index }}"
                      data-severity="{{ risk.severity }}"
                    >
                      <td></td>
                      <td colspan="3">
                        <div class="px-3 rounded-3 description">
                          <div class="description-section">
                            <div class="section-label">Description</div>
                            <div class="section-content">{{ risk.description }}</div>
                          </div>
                          {% if risk.impacted_resources_count %}
                          <div>
                            <div class="section-label">Impacted Resources</div>
                            <div class="impacted-resources-content">
                              {{ risk.impacted_resources | join(", ") }}
                            </div>
                          </div>
                          {% endif %}
                        </div>
                      </td>
                    </tr>
                    {% endfor %} {% else %}
                    <tr>
                      <td colspan="4" class="text-center py-4">
                        No risks were identified for this assessment.
                      </td>
                    </tr>
                    {% endif %}
                  </tbody>
                </table>
              </div>
            </div>
          </div>
        </div>

        <div class="mt-4 row g-0">
          <div class="col-12">
            <div class="bg-white shadow-s rounded-4">
              <div
                class="d-flex flex-wrap align-items-center justify-content-between gap-3 px-3 px-lg-4 py-3"
              >
                <h3>Resource Inventory ({{ total_resources }})</h3>
              </div>
              <div class="divider-border"></div>
              <div class="resource-inner">
                <div class="p-3 p-lg-4">
                  <div class="row gapy-3" id="resource-grid">
                    {% if resource_inventory %} {% for resource in resource_inventory %}
                    <div class="col-xl-3 col-lg-6 col-md-6 resource-item">
                      <div class="p-3 p-lg-4 rounded-4 resource-card">
                        <img
                          src="assets/{{ resource.icon | trim }}"
                          alt="{{ resource.name | trim }}"
                        />
                        <h3>{{ resource.name | trim }}</h3>
                        <h6>
                          {{ resource.count }} Resource{% if resource.count != 1 %}s{%
                          endif %} Available
                        </h6>
                      </div>
                    </div>
                    {% endfor %} {% else %}
                    <div class="col-12 text-center py-4">
                      <p>
                        No resources were discovered during the assessment.
                      </p>
                    </div>
                    {% endif %}
                  </div>
                </div>
                {% if resource_inventory|length > 8 %}
                <div class="divider-border"></div>
                <div class="text-end view-more">
                  <button
                    class="float-right mx-4 my-3 btn btn-light view-more-btn"
                    type="button"
                    id="resourceViewMore"
                  >
                    View More
                  </button>
                </div>
                {% endif %}
              </div>
            </div>
          </div>
        </div>

        <div class="mt-4 row g-0">
          <div class="col-12">
            <div class="bg-white shadow-s rounded-4">
              <div
                class="d-flex flex-column flex-lg-row align-items-start align-items-lg-center justify-content-between gap-3 bg-white px-3 px-lg-4 py-3 alt-tech-card filter-wrapper"
              >
                <h3>
                  Alternative Technologies ({{ alternative_technologies | length }})
                </h3>
                {% if alternative_technologies %}
                <div class="d-flex align-items-center gap-3 flex-wrap">
                  <div>
                    <input
                      type="search"
                      class="custom-search form-control form-control-sm"
                      placeholder="Search for technologies"
                      aria-controls="alt-tech-grid"
                      id="altTechSearch"
                    />
                  </div>
                  <div class="btn-group">
                    <button
                      class="btn btn-outline-primary btn-sm d-flex align-items-center gap-2 flex-shrink-0 dropdown-toggle"
                      type="button"
                      id="dropdownMenuButtonAlternativeTechnology"
                      data-bs-toggle="dropdown"
                      data-bs-auto-close="outside"
                      aria-expanded="false"
                    >
                      <span>Filters</span>
                      <svg
                        class="filter-icon"
                        width="16"
                        height="16"
                        viewBox="0 0 16 16"
                        fill="none"
                        xmlns="http://www.w3.org/2000/svg"
                      >
                        <path
                          d="M8.00004 2C9.8365 2 11.6368 2.1547 13.3887 2.45178C13.7439 2.51202 14 2.82237 14 3.18268V3.87868C14 4.2765 13.842 4.65803 13.5607 4.93934L9.93934 8.56066C9.65804 8.84196 9.5 9.2235 9.5 9.62132V11.5729C9.5 12.1411 9.179 12.6605 8.67082 12.9146L6.5 14V9.62132C6.5 9.2235 6.34196 8.84196 6.06066 8.56066L2.43934 4.93934C2.15804 4.65804 2 4.2765 2 3.87868V3.1827C2 2.82238 2.25605 2.51203 2.61129 2.45179C4.3632 2.1547 6.16355 2 8.00004 2Z"
                          stroke="#115E59"
                          stroke-width="1.5"
                          stroke-linecap="round"
                          stroke-linejoin="round"
                        />
                      </svg>
                    </button>

                    <ul
                      class="dropdown-menu p-3 shadow-lg border-0"
                      aria-labelledby="dropdownMenuButtonAlternativeTechnology"
                      style="width: 280px;"
                    >
                      <div class="drop-header mb-2">
                        <h6 class="fw-semibold mb-1">Filters</h6>
                      </div>
                      <div class="drop-body">
                        <div class="form-group mb-3">
                          <select class="form-select" id="resourceTypeSelect">
                            <option value="all" selected>Select Resource Type</option>
                            {% for resource in resource_inventory %}
                            <option value="{{ resource.resource_type | trim }}">
                              {{ resource.name | trim }}
                            </option>
                            {% endfor %}
                          </select>
                        </div>
                        <div class="form-toggle-switch mb-3 d-flex justify-content-between align-items-center">
                          <h6 class="mb-0">Open Source</h6>
                          <div class="toggle-switch">
                            <input type="checkbox" id="openSourceSwitch" />
                            <label for="openSourceSwitch" class="toggle-slider"></label>
                          </div>
                        </div>
                        <div class="form-toggle-switch mb-3 d-flex justify-content-between align-items-center">
                          <h6 class="mb-0">Enterprise Support</h6>
                          <div class="toggle-switch">
                            <input type="checkbox" id="enterpriseSupportSwitch" />
                            <label for="enterpriseSupportSwitch" class="toggle-slider"></label>
                          </div>
                        </div>
                      </div>
                      <div class="drop-footer d-flex justify-content-between mt-3">
                        <button type="button" class="btn btn-outline-primary btn-sm" id="clearFilters">
                          Clear
                        </button>
                        <button type="button" class="btn btn-primary btn-sm" id="applyFilters">
                          Apply
                        </button>
                      </div>
                    </ul>
                  </div>
                </div>
                {% endif %}
              </div>
              <div class="divider-border"></div>

              <div class="alttech-inner">
                <div class="p-3 p-lg-4">
                  <div class="row gapy-3" id="alt-tech-grid">
                    {% if alternative_technologies %} {% for alt_tech in
                    alternative_technologies %} {% set alt_resource = namespace(name="Resource Type " ~ alt_tech.resource_type_id) %}
                    {% for resource in resource_inventory %}
                      {% if resource.resource_type|string == alt_tech.resource_type_id|string %}
                        {% set alt_resource.name = resource.name %}
                      {% endif %}
                    {% endfor %}
                    <div
                      class="col-lg-6 alt-tech-item alt-tech-card-item"
                      data-resource-type="{{ alt_tech.resource_type_id }}"
                      data-open-source="{{ 'true' if alt_tech.open_source else 'false' }}"
                      data-enterprise-support="{{ 'true' if alt_tech.support_plan else 'false' }}"
                    >
                      <div class="p-3 p-lg-4 rounded-4 alttech-card">
                        <div
                          class="d-flex align-items-center justify-content-between alttech-title"
                        >
                          <div>
                            <h6 class="mb-1">Category: {{ alt_resource.name }}</h6>
                            <h3 class="mb-0">{{ alt_tech.product_name }}</h3>
                          </div>
                        </div>
                        <div class="my-3 alttech-text">
                          <p>{{ alt_tech.product_description }}</p>
                        </div>
                        <div class="d-flex gap-3 flex-wrap mb-3">
                          <div
                            class="verified d-inline-flex align-items-center px-2 rounded-1 gap-2 py-1 {% if alt_tech.open_source %}green-100{% else %}red-50{% endif %}"
                          >
                            {% if alt_tech.open_source %}
                            <svg
                              width="14"
                              height="12"
                              viewBox="0 0 14 12"
                              fill="none"
                              xmlns="http://www.w3.org/2000/svg"
                              aria-hidden="true"
                            >
                              <path
                                fill-rule="evenodd"
                                clip-rule="evenodd"
                                d="M13.7045 0.153466C14.034 0.404497 14.0976 0.875094 13.8466 1.20457L5.84657 11.7046C5.71541 11.8767 5.51627 11.9838 5.30033 11.9983C5.08439 12.0129 4.87271 11.9334 4.71967 11.7804L0.21967 7.28037C-0.0732233 6.98748 -0.0732233 6.5126 0.21967 6.21971C0.512563 5.92682 0.987437 5.92682 1.28033 6.21971L5.17351 10.1129L12.6534 0.295507C12.9045 -0.0339712 13.3751 -0.0975653 13.7045 0.153466Z"
                                fill="#198754"
                              />
                            </svg>
                            {% else %}
                            <svg
                              width="10"
                              height="10"
                              viewBox="0 0 10 10"
                              fill="none"
                              xmlns="http://www.w3.org/2000/svg"
                              aria-hidden="true"
                            >
                              <path
                                d="M1.28033 0.21967C0.987437 -0.0732233 0.512563 -0.0732233 0.21967 0.21967C-0.0732233 0.512563 -0.0732233 0.987437 0.21967 1.28033L3.93934 5L0.21967 8.71967C-0.0732233 9.01256 -0.0732233 9.48744 0.21967 9.78033C0.512563 10.0732 0.987437 10.0732 1.28033 9.78033L5 6.06066L8.71967 9.78033C9.01256 10.0732 9.48744 10.0732 9.78033 9.78033C10.0732 9.48744 10.0732 9.01256 9.78033 8.71967L6.06066 5L9.78033 1.28033C10.0732 0.987437 10.0732 0.512563 9.78033 0.21967C9.48744 -0.0732233 9.01256 -0.0732233 8.71967 0.21967L5 3.93934L1.28033 0.21967Z"
                                fill="#D72323"
                              />
                            </svg>
                            {% endif %}
                            <span
                              class="{% if alt_tech.open_source %}green-700{% else %}red-700{% endif %}"
                              >Open Source</span
                            >
                          </div>
                          <div
                            class="verified d-inline-flex align-items-center px-2 rounded-1 gap-2 py-1 {% if alt_tech.support_plan %}green-100{% else %}red-50{% endif %}"
                          >
                            {% if alt_tech.support_plan %}
                            <svg
                              width="14"
                              height="12"
                              viewBox="0 0 14 12"
                              fill="none"
                              xmlns="http://www.w3.org/2000/svg"
                              aria-hidden="true"
                            >
                              <path
                                fill-rule="evenodd"
                                clip-rule="evenodd"
                                d="M13.7045 0.153466C14.034 0.404497 14.0976 0.875094 13.8466 1.20457L5.84657 11.7046C5.71541 11.8767 5.51627 11.9838 5.30033 11.9983C5.08439 12.0129 4.87271 11.9334 4.71967 11.7804L0.21967 7.28037C-0.0732233 6.98748 -0.0732233 6.5126 0.21967 6.21971C0.512563 5.92682 0.987437 5.92682 1.28033 6.21971L5.17351 10.1129L12.6534 0.295507C12.9045 -0.0339712 13.3751 -0.0975653 13.7045 0.153466Z"
                                fill="#198754"
                              />
                            </svg>
                            {% else %}
                            <svg
                              width="10"
                              height="10"
                              viewBox="0 0 10 10"
                              fill="none"
                              xmlns="http://www.w3.org/2000/svg"
                              aria-hidden="true"
                            >
                              <path
                                d="M1.28033 0.21967C0.987437 -0.0732233 0.512563 -0.0732233 0.21967 0.21967C-0.0732233 0.512563 -0.0732233 0.987437 0.21967 1.28033L3.93934 5L0.21967 8.71967C-0.0732233 9.01256 -0.0732233 9.48744 0.21967 9.78033C0.512563 10.0732 0.987437 10.0732 1.28033 9.78033L5 6.06066L8.71967 9.78033C9.01256 10.0732 9.48744 10.0732 9.78033 9.78033C10.0732 9.48744 10.0732 9.01256 9.78033 8.71967L6.06066 5L9.78033 1.28033C10.0732 0.987437 10.0732 0.512563 9.78033 0.21967C9.48744 -0.0732233 9.01256 -0.0732233 8.71967 0.21967L5 3.93934L1.28033 0.21967Z"
                                fill="#D72323"
                              />
                            </svg>
                            {% endif %}
                            <span
                              class="{% if alt_tech.support_plan %}green-700{% else %}red-700{% endif %}"
                              >Enterprise Support</span
                            >
                          </div>
                        </div>
                        <div class="divider-border my-3"></div>
                        <div class="visit text-end pt-2">
                          {% if alt_tech.product_url %}
                          <a
                            href="{{ alt_tech.product_url }}{% if '?' in alt_tech.product_url %}&{% else %}?{% endif %}utm_source=escapecloud&utm_medium=referral"
                            target="_blank"
                            rel="noopener noreferrer"
                            class="d-inline-flex align-items-center text-underline gap-2"
                          >
                            <i class="bi bi-box-arrow-up-right"></i>
                            <span>Visit {{ alt_tech.product_name }}</span>
                          </a>
                          {% endif %}
                        </div>
                      </div>
                    </div>
                    {% endfor %} {% else %}
                    <div class="col-12">
                      <div class="chart-empty-state alt-tech-empty-state">
                        <div class="chart-empty-state-inner">
                          <i class="bi bi-search"></i>
                          <p>
                            No alternative technologies are available for this
                            assessment.
                          </p>
                        </div>
                      </div>
                    </div>
                    {% endif %}
                  </div>
                </div>
              </div>
            </div>
          </div>
        </div>
      </div>

      <script>
        document.addEventListener("DOMContentLoaded", function () {
          document.querySelectorAll(".risk-row").forEach((row) => {
            row.addEventListener("click", function () {
              const details = document.getElementById(this.dataset.target);
              if (!details) {
                return;
              }

              const isOpen = details.classList.contains("show");
              details.classList.toggle("show", !isOpen);
              this.classList.toggle("expanded", !isOpen);
            });
          });

          const severityButton = document.getElementById("dropdownMenuButtonSeverity");
          const severityItems = document.querySelectorAll(
            "#dropdownMenuButtonSeverity + .dropdown-menu .dropdown-item"
          );

          severityItems.forEach((item) => {
            item.addEventListener("click", function (event) {
              event.preventDefault();
              const severity = this.dataset.severity;

              if (severityButton) {
                severityButton.innerHTML =
                  this.textContent.trim() +
                  ' <i class="ms-1 bi bi-chevron-down"></i>';
              }

              document.querySelectorAll("#risk-table-body .risk-row").forEach((row) => {
                const matches = severity === "all" || row.dataset.severity === severity;
                row.style.display = matches ? "" : "none";

                const details = document.getElementById(row.dataset.target);
                if (details) {
                  details.style.display =
                    matches && details.classList.contains("show")
                      ? "table-row"
                      : matches
                        ? ""
                        : "none";
                }
              });
            });
          });

          const resourceItems = Array.from(
            document.querySelectorAll("#resource-grid .resource-item")
          );
          const resourceViewMoreButton = document.getElementById("resourceViewMore");
          let resourceVisibleLimit = 8;

          function syncResourceVisibility() {
            resourceItems.forEach((item, index) => {
              item.style.display = index < resourceVisibleLimit ? "" : "none";
            });

            if (resourceViewMoreButton) {
              resourceViewMoreButton.style.display =
                resourceItems.length > resourceVisibleLimit ? "" : "none";
            }
          }

          if (resourceItems.length) {
            syncResourceVisibility();
          }

          if (resourceViewMoreButton) {
            resourceViewMoreButton.addEventListener("click", function () {
              resourceVisibleLimit += 8;
              syncResourceVisibility();
            });
          }

          const applyFiltersBtn = document.getElementById("applyFilters");
          const clearFiltersBtn = document.getElementById("clearFilters");
          const resourceTypeSelect = document.getElementById("resourceTypeSelect");
          const openSourceSwitch = document.getElementById("openSourceSwitch");
          const enterpriseSupportSwitch = document.getElementById(
            "enterpriseSupportSwitch"
          );
          const technologyBoxes = document.querySelectorAll(".alt-tech-card-item");
          const searchInput = document.getElementById("altTechSearch");
          const gridContainer = document.getElementById("alt-tech-grid");

          if (
            applyFiltersBtn &&
            clearFiltersBtn &&
            resourceTypeSelect &&
            openSourceSwitch &&
            enterpriseSupportSwitch &&
            searchInput &&
            gridContainer
          ) {
            let noResultsMessage = document.getElementById("noResultsMessage");
            if (!noResultsMessage) {
              noResultsMessage = document.createElement("div");
              noResultsMessage.id = "noResultsMessage";
              noResultsMessage.className = "col-12 text-center text-muted py-4";
              noResultsMessage.style.display = "none";
              noResultsMessage.innerHTML =
                '<i class="bi bi-search fs-1"></i><p class="mb-0">No alternative technologies found for the selected criteria.</p>';
              gridContainer.appendChild(noResultsMessage);
            }

            technologyBoxes.forEach((box) => {
              box.dataset.filtered = "true";
            });

            function syncAltTechVisibility() {
              let filteredCount = 0;

              technologyBoxes.forEach((box) => {
                const matchesFilter = box.dataset.filtered !== "false";
                if (matchesFilter) {
                  filteredCount += 1;
                  box.style.display = "";
                } else {
                  box.style.display = "none";
                }
              });

              noResultsMessage.style.display = filteredCount === 0 ? "" : "none";
            }

            function applyAlternativeFilters() {
              const selectedResourceType = resourceTypeSelect.value;
              const isOpenSource = openSourceSwitch.checked;
              const hasEnterpriseSupport = enterpriseSupportSwitch.checked;
              const searchQuery = searchInput.value.trim().toLowerCase();

              technologyBoxes.forEach((box) => {
                const matchesResourceType =
                  selectedResourceType === "all" ||
                  box.dataset.resourceType === selectedResourceType;
                const matchesOpenSource =
                  !isOpenSource || box.dataset.openSource === "true";
                const matchesEnterpriseSupport =
                  !hasEnterpriseSupport || box.dataset.enterpriseSupport === "true";
                const matchesSearch =
                  !searchQuery || box.textContent.toLowerCase().includes(searchQuery);

                box.dataset.filtered =
                  matchesResourceType &&
                  matchesOpenSource &&
                  matchesEnterpriseSupport &&
                  matchesSearch
                    ? "true"
                    : "false";
              });

              syncAltTechVisibility();
            }

            applyFiltersBtn.addEventListener("click", function () {
              applyAlternativeFilters();
            });

            clearFiltersBtn.addEventListener("click", function () {
              resourceTypeSelect.value = "all";
              openSourceSwitch.checked = false;
              enterpriseSupportSwitch.checked = false;
              searchInput.value = "";
              technologyBoxes.forEach((box) => {
                box.dataset.filtered = "true";
                box.style.display = "";
              });
              syncAltTechVisibility();
            });

            searchInput.addEventListener("keyup", function () {
              applyAlternativeFilters();
            });

            syncAltTechVisibility();
          }
        });
      </script>

      {% if total_risks > 0 %}
      <script>
        const risksCanvas = document.getElementById("risksChart");
        if (risksCanvas) {
          const risksChart = new Chart(risksCanvas.getContext("2d"), {
            type: "doughnut",
            data: {
              labels: ["High", "Medium", "Low"],
              datasets: [
                {
                  label: "Risk(s)",
                  data: [{{ high_risk_count }}, {{ medium_risk_count }}, {{ low_risk_count }}],
                  backgroundColor: [
                    "rgba(153, 27, 27, 1)",
                    "rgba(255, 174, 31, 1)",
                    "rgba(83, 155, 255, 1)",
                  ],
                  borderColor: [
                    "rgba(153, 27, 27, 1)",
                    "rgba(255, 174, 31, 1)",
                    "rgba(83, 155, 255, 1)",
                  ],
                  borderWidth: 1,
                },
              ],
            },
            options: {
              responsive: true,
              maintainAspectRatio: false,
              cutout: "60%",
              plugins: {
                legend: {
                  position: "bottom",
                  labels: {
                    boxWidth: 20,
                    padding: 15,
                  },
                },
              },
            },
          });
        }
      </script>
      {% endif %}

      {% if total_cost > 0 %}
      <script>
        const costsCanvas = document.getElementById("costsChart");
        if (costsCanvas) {
          const months = JSON.parse('{{ months_json|safe }}');
          const costs = JSON.parse('{{ costs_json|safe }}');
          const currencySymbol = "{{ currency_symbol|safe }}";

          const costsChart = new Chart(costsCanvas.getContext("2d"), {
            type: "bar",
            data: {
              labels: months,
              datasets: [
                {
                  label: `Costs (${currencySymbol})`,
                  data: costs,
                  backgroundColor: "rgba(17, 94, 89, 0.25)",
                  borderColor: "rgba(17, 94, 89, 1)",
                  borderWidth: 1,
                },
              ],
            },
            options: {
              scales: {
                y: {
                  beginAtZero: true,
                  title: {
                    display: true,
                    text: `Amount (${currencySymbol})`,
                  },
                },
                x: {
                  title: {
                    display: false,
                    text: "Month",
                  },
                },
              },
              responsive: true,
              plugins: {
                legend: {
                  display: false,
                },
              },
              maintainAspectRatio: false,
            },
          });
        }
      </script>
      {% endif %}

      {% if scoring_data %}
      <script>
        const radarSpec = {
          type: "radar",
          height: 350,
          data: [
            {
              id: "radarData",
              values: [
                { key: "Human", value: {{ human | default(0) }} },
                { key: "Technology", value: {{ technology | default(0) }} },
                { key: "Operational", value: {{ operational | default(0) }} },
              ],
            },
          ],
          categoryField: "key",
          valueField: "value",
          point: {
            visible: false,
          },
          area: {
            visible: true,
            style: {
              fill: "rgba(17, 94, 89, 0.25)",
            },
            state: {
              hover: {
                fillOpacity: 0.5,
              },
            },
          },
          line: {
            style: {
              lineWidth: 4,
              stroke: "rgba(17, 94, 89, 1)",
            },
          },
          axes: [
            {
              orient: "radius",
              zIndex: 100,
              min: 0,
              max: 5,
              domainLine: {
                visible: false,
              },
              label: {
                visible: true,
                space: 0,
                style: {
                  textAlign: "center",
                  stroke: "#fff",
                  lineWidth: 4,
                },
              },
              grid: {
                smooth: false,
                style: {
                  lineDash: [0],
                },
              },
            },
            {
              orient: "angle",
              zIndex: 50,
              tick: {
                visible: false,
              },
              domainLine: {
                visible: false,
              },
              label: {
                space: 20,
              },
              grid: {
                style: {
                  lineDash: [0],
                },
              },
            },
          ],
        };

        const gaugeSpec = {
          width: 300,
          height: 350,
          padding: { top: 0, bottom: 0, left: 0, right: 0 },
          type: "common",
          data: [
            {
              id: "pointer",
              values: [{ type: "Score", value: {{ exit_score | default(0) | round(0) }} }],
            },
            {
              id: "segment",
              values: [
                { type: "Complex", value: 20 },
                { type: "Challenging", value: 40 },
                { type: "Manageable", value: 60 },
                { type: "Smooth Transition", value: 80 },
                { type: "Seamless", value: 100 },
              ],
            },
          ],
          series: [
            {
              type: "gauge",
              dataIndex: 1,
              radiusField: "type",
              angleField: "value",
              seriesField: "type",
              outerRadius: 0.9,
              innerRadius: 0.65,
              roundCap: true,
              segment: {
                style: {
                  cornerRadius: 500,
                  innerPadding: 5,
                  outerPadding: 5,
                  fill: {
                    type: "threshold",
                    field: "value",
                    domain: [20, 40, 60, 80, 100],
                    range: [
                      "#ba1c1d",
                      "#ba1c1d",
                      "#ff9533",
                      "#f1ca00",
                      "#76c31d",
                      "#065f43",
                    ],
                  },
                },
              },
              track: {
                visible: true,
                style: {
                  cornerRadius: 500,
                  roundCap: true,
                  fill: "rgba(0, 0, 0, 0.1)",
                },
              },
            },
            {
              type: "gaugePointer",
              dataIndex: 0,
              categoryField: "type",
              valueField: "value",
              innerRadius: 0.45,
              pin: {
                visible: true,
                width: 0.04,
                height: 0.04,
                isOnCenter: false,
                style: {
                  fill: {
                    type: "threshold",
                    field: "value",
                    domain: [20, 40, 60, 80, 100],
                    range: ["#012e53"],
                  },
                },
              },
              pinBackground: { visible: false },
              pointer: {
                width: 0.2,
                height: 0.1,
                isOnCenter: false,
                style: {
                  fill: {
                    type: "threshold",
                    field: "value",
                    domain: [20, 40, 60, 80, 100],
                    range: ["#012e53"],
                  },
                },
              },
              animation: false,
            },
          ],
          startAngle: -200,
          endAngle: 20,
          axes: [
            {
              type: "linear",
              orient: "angle",
              inside: true,
              outerRadius: 0.9,
              innerRadius: 0.6,
              min: 0,
              max: 100,
              grid: { visible: false },
              tick: {
                visible: false,
                tickSize: 0,
                style: { lineWidth: 4, lineCap: "round" },
              },
              subTick: {
                visible: false,
                tickSize: 0,
                style: { lineWidth: 4, lineCap: "round" },
              },
              label: { visible: false },
            },
            {
              type: "linear",
              orient: "radius",
              outerRadius: 0.6,
              grid: { visible: false },
              label: { visible: false },
            },
          ],
          indicator: [
            {
              visible: true,
              offsetY: "-10%",
              title: {
                style: {
                  text: "{{ exit_score | default(0) | round(0) }}",
                  fontSize: 40,
                  fontWeight: 500,
                  fontColor: "#012e53",
                },
              },
              content: [
                {
                  style: {
                    dy: 10,
                    text: "Exit Score",
                    fontSize: 20,
                  },
                },
              ],
            },
            {
              visible: true,
              offsetX: "-70%",
              offsetY: "45%",
              title: {
                style: {
                  text: "0",
                  fontSize: 14,
                },
              },
            },
            {
              visible: true,
              offsetX: "70%",
              offsetY: "45%",
              title: {
                style: {
                  text: "100",
                  fontSize: 14,
                },
              },
            },
          ],
        };

        const radarContainer = document.getElementById("vendorLockInScoreChart");
        const gaugeContainer = document.getElementById("exitScoreChart");

        if (radarContainer) {
          const vendorLockInScoreChart = new VChart.default(radarSpec, {
            dom: radarContainer,
          });
          vendorLockInScoreChart.renderSync();
        }

        if (gaugeContainer) {
          const exitScoreChart = new VChart.default(gaugeSpec, {
            dom: gaugeContainer,
          });
          exitScoreChart.renderSync();
        }
      </script>
      {% endif %}
    </main>
  </body>
</html>


================================================
FILE: config/aws_example.json
================================================
{
    "cloudServiceProvider": 2,
    "exitStrategy": 3,
    "assessmentType": 1,
    "providerDetails":{
      "accessKey":"AKIAXASFHMTLKD6YQLHA",
      "secretKey":"",
      "region":"eu-central-1"
   }
}


================================================
FILE: config/azure_example.json
================================================
{
    "cloudServiceProvider": 1,
    "exitStrategy": 3,
    "assessmentType": 1,
    "providerDetails":{
      "clientId":"57344955-1579-4058-8604-5bb8724002de",
      "clientSecret":"",
      "tenantId":"38997009-9dad-42b2-b187-53f1cb71560e",
      "subscriptionId":"",
      "resourceGroupName:":""
   }
}


================================================
FILE: config.py
================================================
# 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.

Please do not modify CLI_VERSION; it is used for debugging purposes.
"""

CLI_VERSION = "v1.0.0"

HOST = ""
KEY = ""


================================================
FILE: core/__init__.py
================================================


================================================
FILE: core/engine.py
================================================
# core/engine.py
import logging
import os
import boto3
from datetime import datetime
from typing import Any, Dict, Optional, Tuple
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_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"],
                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:
            logs = f"Azure credentials validation failed: {str(e)}"
            logger.error(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"],
                region_name=provider_details["region"],
            )
            identity = sts_client.get_caller_identity()
            user_arn = identity["Arn"]
            user_name = user_arn.split("/")[-1]

            iam_client = boto3.client(
                "iam",
                aws_access_key_id=provider_details["accessKey"],
                aws_secret_access_key=provider_details["secretKey"],
                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."

        except NoCredentialsError as e:
            logs = f"AWS credentials validation failed: {str(e)}"
            logger.error(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)

    try:

        if 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: Optional[str],
) -> 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"):
        raise RuntimeError(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)
        raise RuntimeError(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)
        raise RuntimeError(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)}


# 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)
        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.utcnow().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,
        )

        # Generate PDF report
        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,
        )

        # 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)}"}


================================================
FILE: core/utils.py
================================================
# core/utils.py
import os
import shutil
import logging

logger = logging.getLogger("core.engine.utils")


def copy_assets(report_path: str) -> None:
    assets_folders = ["css", "icons", "img"]
    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 assets_folders:
        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)

    # 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)


================================================
FILE: core/utils_aws.py
================================================
# core/utils_aws.py
import boto3
import botocore
import json
import os
import time
import logging
import sqlite3
from typing import Any, Dict, Set, List, Callable
from datetime import date, datetime
from collections import defaultdict
from dateutil.relativedelta import relativedelta
from botocore.exceptions import NoCredentialsError, ClientError

from .utils_db import connect, load_data

logger = logging.getLogger("core.engine.aws")


def aws_api_call_with_retry(
    client: Any,
    function_name: str,
    parameters: Dict[str, Any],
    max_retries: int,
    retry_delay: int,
) -> Callable[..., Any]:
    def api_call(*args, **kwargs):
        for attempt in range(max_retries):
            try:
                function_to_call = getattr(client, function_name)
                if parameters:
                    return function_to_call(**parameters, **kwargs)
                else:
                    return function_to_call(**kwargs)
            except botocore.exceptions.ClientError as error:
                error_code = error.response["Error"]["Code"]
                # logger.warning(f"ClientError: {error_code}. Attempt {attempt + 1} of {max_retries}. Retrying in {retry_delay} seconds.")
                if error_code in ["Throttling", "RequestLimitExceeded"]:
                    time.sleep(retry_delay * (2**attempt))
                    continue
                else:
                    raise
            except botocore.exceptions.BotoCoreError:
                # logger.warning(f"BotoCoreError. Attempt {attempt + 1} of {max_retries}. Retrying in {retry_delay} seconds.")
                time.sleep(retry_delay * (2**attempt))
                continue
        raise Exception(f"Failed to call {function_name} after {max_retries} attempts")

    return api_call  # Return the callable function


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,
            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"]}
            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)

        # Iterate through each resource type in the JSON
        for idx, (resource_type_code, resource_info) in enumerate(
            resource_type_mapping.items(), start=1
        ):
            parts = resource_type_code.split(".")
            if len(parts) != 4 or parts[0] != "AWS":
                # logger.warning(f"Invalid resource type format: {resource_type_code}. Skipping.")
                continue

            # Extract service name, operation name, and result key
            service_name, operation_name, result_key = parts[1], parts[2], parts[3]

            # logger.info(f"Processing service {service_name} with operation {operation_name}")

            try:
                client = session.client(service_name, region_name=region)
                if not hasattr(client, operation_name):
                    # logger.error(f"Operation {operation_name} does not exist for service {service_name}")
                    continue

                # Make the API call
                api_call = aws_api_call_with_retry(
                    client, operation_name, {}, max_retries=3, retry_delay=2
                )
                response = api_call()

                if isinstance(response, dict):
                    response.pop("ResponseMetadata", None)
                    resources = response.get(result_key.strip(), [])
                    # Handle paginated results
                    while "NextToken" in response:
                        next_token = response["NextToken"]
                        response = api_call(NextToken=next_token)
                        response.pop("ResponseMetadata", None)
                        resources.extend(response.get(result_key.strip(), []))
                else:
                    # logger.warning(f"No valid response found for {service_name} operation {operation_name}. Skipping.")
                    continue

                # 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 (NoCredentialsError, ClientError, Exception):
                # logger.error(f"Error while processing {service_name}", exc_info=True)
                continue

        # 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.utcnow().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"],
            region_name=provider_details["region"],
        )
        cost_explorer = session.client("ce", region_name="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
        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

    except Exception as e:
        logger.error(f"Error creating AWS cost inventory: {str(e)}", exc_info=True)
        raise


================================================
FILE: core/utils_azure.py
================================================
# core/utils_azure.py
import json
import os
import logging
import sqlite3
from typing import Any, Dict, Set
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:
        logger.error(
            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)
    except sqlite3.Error as e:
        logger.error(f"SQLite error: {str(e)}", exc_info=True)
    except Exception as e:
        logger.error(f"Error fetching Azure resources: {str(e)}", exc_info=True)


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
        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)
    except Exception as e:
        logger.error(f"Error creating Azure cost inventory: {str(e)}", exc_info=True)
        raise


================================================
FILE: core/utils_db.py
================================================
# core/utils_db.py
import sqlite3
import logging

# Configure logger for database operations
logger = logging.getLogger("core.engine.db")
logger.setLevel(logging.INFO)

# Default master database
MASTER_DATABASE = "datasets/data.db"


def connect(db_path=MASTER_DATABASE):
    try:
        conn = sqlite3.connect(db_path)
        return conn
    except sqlite3.Error as e:
        logger.error(f"Error connecting to database: {e}")
        raise


def load_data(table_name, db_path=MASTER_DATABASE):
    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.error(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.error(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.error(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.error(f"Error fetching data: {e}")
        raise


================================================
FILE: core/utils_report.py
================================================
# core/utils_report.py
import os
import json
import logging
from typing import List, Dict, Any, Optional
from jinja2 import Template

# 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_chart,
)

# Configure logger
logger = logging.getLogger("core.engine.report")
logger.setLevel(logging.INFO)


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: Optional[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]],
    exit_strategy: int,
) -> 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
    )

    # 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()

    template = Template(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: Optional[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]],
    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 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: Optional[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]],
    exit_strategy: int,
) -> str:
    # Define the PDF path
    pdf_path = os.path.join(report_path, "report.pdf")

    # Define a template for the header and footer
    def header_footer(canvas, doc):
        # Make sure draw_header_footer is defined and accessible
        draw_header_footer(report_path, canvas, doc)

    # Create a document template with the header and footer
    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")
    tablecontent_style = styles["BodyText"]

    # Define a custom padding value
    header_padding = 12

    content = []

    # --- # Page 1: Summary ---
    content.append(Spacer(1, header_padding))
    content.append(Paragraph("Summary", styles["Heading1"]))
    summary_block1 = "Quick overview of the assessment:"
    content.append(Paragraph(summary_block1, content_style))

    # Prepare mappings
    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"}

    # Prepare the summary data
    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"]],
    ]

    # Column widths
    summary_colWidths = [4 * cm, 11.5 * cm]

    # Create the summary table
    summary_table = Table(summary_data, colWidths=summary_colWidths)

    # Define the summary table style
    summary_table_style = TableStyle(
        [
            (
                "BACKGROUND",
                (0, 0),
                (-1, 0),
                HexColor("#115e59"),
            ),  # Header row background color
            ("TEXTCOLOR", (0, 0), (-1, 0), colors.white),  # Header row text color
            ("GRID", (0, 0), (-1, -1), 1, HexColor("#000000")),  # Grid lines
            ("ALIGN", (0, 0), (-1, -1), "LEFT"),  # Left align all cells
            (
                "VALIGN",
                (0, 0),
                (-1, -1),
                "MIDDLE",
            ),  # Middle vertical alignment for all cells
            ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),  # Bold font for header row
            ("FONTSIZE", (0, 0), (-1, 0), 11),  # Font size for header row
            ("BOTTOMPADDING", (0, 0), (-1, 0), 12),  # Padding for header row
            ("TOPPADDING", (0, 0), (-1, 0), 12),  # Padding for header row
        ]
    )

    summary_table.setStyle(summary_table_style)

    # Add summary to content
    content.append(summary_table)
    content.append(Spacer(1, 12))

    # --- Page 1: Scope of Assessment ---
    content.append(Paragraph("Scope of Assessment", styles["Heading2"]))
    scope_block1 = "Defined scope of assessment:"
    content.append(Paragraph(scope_block1, content_style))

    # Prepare the scope data
    scope_data = [["Name", "Value"]]

    if 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"])

    # Column widths
    scope_colWidths = [4 * cm, 11.5 * cm]

    # Create the scope table
    scope_table = Table(scope_data, colWidths=scope_colWidths)

    # Define the scope table style
    scope_table_style = TableStyle(
        [
            (
                "BACKGROUND",
                (0, 0),
                (-1, 0),
                HexColor("#115e59"),
            ),  # Header row background color
            ("TEXTCOLOR", (0, 0), (-1, 0), colors.white),  # Header row text color
            ("GRID", (0, 0), (-1, -1), 1, HexColor("#000000")),  # Grid lines
            ("ALIGN", (0, 0), (-1, -1), "LEFT"),  # Left align all cells
            (
                "VALIGN",
                (0, 0),
                (-1, -1),
                "MIDDLE",
            ),  # Middle vertical alignment for all cells
            ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),  # Bold font for header row
            ("FONTSIZE", (0, 0), (-1, 0), 11),  # Font size for header row
            ("BOTTOMPADDING", (0, 0), (-1, 0), 12),  # Padding for header row
            ("TOPPADDING", (0, 0), (-1, 0), 12),  # Padding for header row
        ]
    )

    scope_table.setStyle(scope_table_style)

    # Add scope to content
    content.append(scope_table)
    content.append(Spacer(1, 12))

    # --- # Page 1: Costs ---
    content.append(Paragraph("Costs", styles["Heading2"]))
    # costs_block1 = "Overview of the costs for the last 6 months:"
    # content.append(Paragraph(costs_block1, content_style))
    costs_block2 = "Examining the costs reveals the financial impact of the transition, allowing for more informed decision-making and strategic planning."
    costs_paragraph = Paragraph(costs_block2, tablecontent_style)

    # Transform the cost data for the PDF
    months, costs, currency_symbol = transform_cost_inventory_for_pdf(cost_data)

    # Draw the cost chart
    cost_chart = draw_cost_chart(months, costs)

    # Create the data structure for the table
    costcharts_table_data = [
        [costs_paragraph, "", "", cost_chart, "", ""],  # Row 1: Paragraph and Chart
        months,  # Row 2: Months
        [f"{currency_symbol} {cost:.2f}" for cost in costs],  # Row 3: Costs
    ]

    # Create the table with 6 columns
    costcharts_table = Table(
        costcharts_table_data, colWidths=[2.58333333333 * cm] * 6  # Equal width columns
    )

    # Define the table style
    costcharts_table_style = TableStyle(
        [
            # Merge cells for Row 1
            ("SPAN", (0, 0), (2, 0)),  # Merge columns 1, 2, and 3 for the paragraph
            ("SPAN", (3, 0), (5, 0)),  # Merge columns 4, 5, and 6 for the chart
            # Align the merged cell (Row 1, Column 1-2-3) to top-left
            ("VALIGN", (0, 0), (2, 0), "TOP"),  # Align vertically to top
            ("ALIGN", (0, 0), (2, 0), "LEFT"),  # Align horizontally to left
            # Remove padding for the merged cell in Row 1, Columns 1-2-3
            ("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 and text color for Row 2 (months)
            (
                "BACKGROUND",
                (0, 1),
                (-1, 1),
                HexColor("#115e59"),
            ),  # Row 2 background color
            ("TEXTCOLOR", (0, 1), (-1, 1), colors.white),  # Row 2 text color
            ("FONTNAME", (0, 1), (-1, 1), "Helvetica-Bold"),  # Bold font for Row 2
            # Center alignment for Row 2 (months)
            ("ALIGN", (0, 1), (-1, 1), "CENTER"),  # Center align -> Row 2 text
            # Font and alignment for Row 3 (costs)
            ("FONTNAME", (0, 2), (-1, 2), "Helvetica"),  # Regular font for Row 3
            ("ALIGN", (0, 2), (-1, 2), "CENTER"),  # Center align -> Row 3 text
            # Grid lines for Row 2 and Row 3
            (
                "GRID",
                (0, 1),
                (-1, 2),
                1,
                colors.black,
            ),  # Grid for months and costs rows
            # Center alignment and vertical alignment for all cells
            ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),  # Vertical alignment for all cells
            (
                "VALIGN",
                (0, 0),
                (2, 0),
                "TOP",
            ),  # Align vertically to top for the merged cell
        ]
    )

    # Apply the table style
    costcharts_table.setStyle(costcharts_table_style)

    # Add the table to your content
    content.append(costcharts_table)
    content.append(PageBreak())

    # Page 2: Risks
    content.append(Spacer(1, header_padding))
    content.append(Paragraph("Risk Assessment", styles["Heading1"]))
    risk_block1 = "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.append(Paragraph(risk_block1, content_style))
    content.append(Spacer(1, 12))

    # Transform the risk data for the PDF and get severity counts
    risks, severity_counts = transform_risk_inventory_for_pdf(
        risk_data, risk_definitions, resource_inventory
    )

    # severity_counts is a dict like: {'high': X, 'medium': Y, 'low': Z}
    risk_chart_data = {
        "high": severity_counts["high"],
        "medium": severity_counts["medium"],
        "low": severity_counts["low"],
    }
    risk_chart = draw_risk_chart(risk_chart_data)
    content.append(risk_chart)
    content.append(Spacer(1, 12))

    # Sort risks by severity
    severity_order = {"high": 1, "medium": 2, "low": 3}
    risks.sort(key=lambda r: severity_order[r["severity"]])

    # Define the path to severity icons
    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),
    }

    # Build the risk table data
    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 "-"
        )

        # Get the severity level and corresponding icon details
        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])

    # Add the total risks row
    total_risks = len(risks)
    risk_table_data.append(["Total Risks", "", "", str(total_risks)])

    # Define column widths for the risk table
    risk_table_colWidths = [0.5 * cm, 10 * cm, 3 * cm, 2 * cm]
    risk_table = Table(risk_table_data, colWidths=risk_table_colWidths)

    risk_table_style_commands = [
        ("BACKGROUND", (0, 0), (-1, 0), HexColor("#115e59")),  # Header row background
        ("TEXTCOLOR", (0, 0), (-1, 0), colors.white),  # Header text color
        ("BACKGROUND", (0, -1), (-1, -1), HexColor("#115e59")),  # Last row background
        ("TEXTCOLOR", (0, -1), (-1, -1), colors.white),  # Last row text color
        ("BOX", (0, 0), (-1, -1), 1, HexColor("#112726")),
        ("BOTTOMPADDING", (0, 0), (-1, 0), 12),  # Padding for header row
        ("TOPPADDING", (0, 0), (-1, 0), 12),
        # Remove SPAN if not needed
        # ('SPAN', (-4, -1), (-2, -1)),
        ("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())

    # Page 3: EscapeCloud Scoring
    if metadata.get("assessment_type") == 2:
        content.append(Spacer(1, header_padding))
        content.append(Paragraph("EscapeCloud Scoring", styles["Heading1"]))
        content.append(Paragraph("Scoring #1 - Exit Score", styles["Heading2"]))

        scoring_block1 = "The following gauge chart visualizes a combined score that reflects both risk assessment results and the evaluation of alternative technologies:"

        content.append(Paragraph(scoring_block1, content_style))
        content.append(Spacer(1, 12))
        exit_score = scoring_data.get("exit_score", 0) if scoring_data else 0

        # Define output path for charts
        chart_output_path = os.path.join(report_path, "assets/charts")
        os.makedirs(chart_output_path, exist_ok=True)

        exit_score_image_path = draw_exitscore_chart(
            exit_score, chart_output_path, width=750, height=500
        )

        # Define the table data
        exitscore_table_data = [
            ["", ""],
            ["Complex (0 - 20)", ""],
            ["Challenging (20 - 40)", ""],
            ["Manageable (40 - 60)", ""],
            ["Smooth Transition (60 - 80)", ""],
            ["Seamless (80 - 100)", ""],
        ]

        exitscore_table_data[1][1] = Image(
            exit_score_image_path, width=7.5 * cm, height=5 * cm
        )

        # Column widhts
        exitscore_colWidths = [5 * cm, 10.5 * cm]

        # Create the table
        exitscore_table = Table(exitscore_table_data, colWidths=exitscore_colWidths)

        # Style the table
        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, 5)),
                ("GRID", (0, 0), (-1, -1), 1, colors.black),
                ("ALIGN", (0, 1), (0, 5), "LEFT"),
                ("VALIGN", (0, 1), (0, 5), "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))

        content.append(
            Paragraph("Scoring #2 - Vendor Lock-In Score", styles["Heading2"])
        )
        scoring_block2 = "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.append(Paragraph(scoring_block2, 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
        )

        vendor_lockin_chart = draw_vendor_lockin_radar_chart(
            human_score, technology_score, operational_score
        )
        content.append(vendor_lockin_chart)

        # Define the table data
        vendor_lockin_table_data = [
            ["Human", "Technology", "Operational"],
            [human_score, technology
Download .txt
gitextract_4srv3mcm/

├── .github/
│   └── workflows/
│       └── pr-checks.yml
├── .gitignore
├── LICENSE
├── README.md
├── assets/
│   ├── css/
│   │   └── style.css
│   └── template/
│       └── index.html
├── config/
│   ├── aws_example.json
│   └── azure_example.json
├── config.py
├── core/
│   ├── __init__.py
│   ├── engine.py
│   ├── utils.py
│   ├── utils_aws.py
│   ├── utils_azure.py
│   ├── utils_db.py
│   ├── utils_report.py
│   ├── utils_report_common.py
│   ├── utils_report_html.py
│   ├── utils_report_json.py
│   ├── utils_report_pdf.py
│   └── utils_sync.py
├── main.py
├── publiccode.yml
├── requirements-dev.txt
├── requirements.txt
├── tests/
│   ├── __init__.py
│   ├── report_fixtures.py
│   ├── test_report_pipeline.py
│   ├── test_report_transforms.py
│   ├── test_utils_and_main.py
│   └── test_validate.py
└── utils/
    ├── aws.py
    ├── azure.py
    ├── connection.py
    ├── constants.py
    ├── data.py
    ├── sync.py
    ├── utils.py
    └── validate.py
Download .txt
SYMBOL INDEX (129 symbols across 24 files)

FILE: core/engine.py
  function verify_credentials (line 29) | def verify_credentials(
  function test_permissions (line 80) | def test_permissions(
  function create_resource_inventory (line 183) | def create_resource_inventory(
  function create_cost_inventory (line 212) | def create_cost_inventory(
  function sync_assessment (line 236) | def sync_assessment(
  function perform_risk_assessment (line 324) | def perform_risk_assessment(
  function generate_report (line 421) | def generate_report(

FILE: core/utils.py
  function copy_assets (line 9) | def copy_assets(report_path: str) -> None:

FILE: core/utils_aws.py
  function aws_api_call_with_retry (line 20) | def aws_api_call_with_retry(
  function convert_datetime (line 52) | def convert_datetime(obj: Any) -> Any:
  function build_aws_resource_inventory (line 64) | def build_aws_resource_inventory(
  function get_missing_months_aws (line 201) | def get_missing_months_aws(processed_costs: Set[str], max_months: int) -...
  function build_aws_cost_inventory (line 217) | def build_aws_cost_inventory(

FILE: core/utils_azure.py
  function is_resource_inventory_empty (line 22) | def is_resource_inventory_empty(
  function build_azure_resource_inventory (line 44) | def build_azure_resource_inventory(
  function get_missing_months_azure (line 136) | def get_missing_months_azure(processed_costs: Set[str], months_back: int...
  function build_azure_cost_inventory (line 159) | def build_azure_cost_inventory(

FILE: core/utils_db.py
  function connect (line 13) | def connect(db_path=MASTER_DATABASE):
  function load_data (line 22) | def load_data(table_name, db_path=MASTER_DATABASE):
  function execute_query (line 36) | def execute_query(query, params=None, db_path=MASTER_DATABASE):
  function fetch_one (line 50) | def fetch_one(query, params=None, db_path=MASTER_DATABASE):
  function fetch_all (line 64) | def fetch_all(query, params=None, db_path=MASTER_DATABASE):

FILE: core/utils_report.py
  function anonymize_string (line 53) | def anonymize_string(s: str, num_visible: int = 4) -> str:
  function generate_html_report (line 64) | def generate_html_report(
  function generate_json_report (line 162) | def generate_json_report(
  function generate_pdf_report (line 217) | def generate_pdf_report(

FILE: core/utils_report_common.py
  function sort_cost_data (line 12) | def sort_cost_data(cost_data: List[Dict[str, Any]]) -> List[Dict[str, An...
  function summarize_costs (line 16) | def summarize_costs(
  function summarize_risks (line 39) | def summarize_risks(
  function summarize_alternative_technologies (line 117) | def summarize_alternative_technologies(
  function enrich_resource_inventory (line 156) | def enrich_resource_inventory(

FILE: core/utils_report_html.py
  function transform_cost_inventory_for_html (line 16) | def transform_cost_inventory_for_html(
  function transform_risk_inventory_for_html (line 22) | def transform_risk_inventory_for_html(
  function transform_alt_tech_for_html (line 40) | def transform_alt_tech_for_html(

FILE: core/utils_report_json.py
  function transform_resource_inventory_for_json (line 17) | def transform_resource_inventory_for_json(
  function transform_cost_inventory_for_json (line 36) | def transform_cost_inventory_for_json(
  function transform_risk_inventory_for_json (line 52) | def transform_risk_inventory_for_json(
  function transform_alt_tech_for_json (line 79) | def transform_alt_tech_for_json(

FILE: core/utils_report_pdf.py
  function transform_resource_inventory_for_pdf (line 36) | def transform_resource_inventory_for_pdf(
  function transform_cost_inventory_for_pdf (line 56) | def transform_cost_inventory_for_pdf(
  function transform_risk_inventory_for_pdf (line 63) | def transform_risk_inventory_for_pdf(
  function transform_alt_tech_for_pdf (line 77) | def transform_alt_tech_for_pdf(
  function draw_header_footer (line 114) | def draw_header_footer(report_path: str, canvas, doc) -> None:
  function draw_risk_chart (line 206) | def draw_risk_chart(risk_chart_data: Dict[str, int]) -> Drawing:
  function draw_cost_chart (line 283) | def draw_cost_chart(months: List[str], costs: List[float]) -> Drawing:
  function draw_exitscore_chart (line 316) | def draw_exitscore_chart(
  function draw_vendor_lockin_radar_chart (line 345) | def draw_vendor_lockin_radar_chart(

FILE: core/utils_sync.py
  function _assess_url (line 21) | def _assess_url(host: str) -> str:
  function _build_payload (line 28) | def _build_payload(
  function post_assessment (line 87) | def post_assessment(

FILE: main.py
  function handle_aws (line 60) | def handle_aws(args):
  function handle_azure (line 172) | def handle_azure(args):
  function run_assessment (line 332) | def run_assessment(config, provider_name):
  function parse_arguments (line 573) | def parse_arguments():
  function main (line 627) | def main():

FILE: tests/report_fixtures.py
  function build_report_fixture (line 5) | def build_report_fixture():
  function build_empty_report_fixture (line 76) | def build_empty_report_fixture():
  function stage_report_assets (line 103) | def stage_report_assets(report_path: str) -> None:

FILE: tests/test_report_pipeline.py
  class ReportPipelineSmokeTests (line 19) | class ReportPipelineSmokeTests(unittest.TestCase):
    method test_generate_html_report_creates_expected_output (line 20) | def test_generate_html_report_creates_expected_output(self):
    method test_generate_html_report_renders_empty_state_output (line 46) | def test_generate_html_report_renders_empty_state_output(self):
    method test_generate_json_report_creates_expected_structure (line 79) | def test_generate_json_report_creates_expected_structure(self):
    method test_generate_pdf_report_creates_non_empty_file (line 112) | def test_generate_pdf_report_creates_non_empty_file(self):
  class ReportTransformTests (line 139) | class ReportTransformTests(unittest.TestCase):
    method test_transform_cost_inventory_for_json_sorts_months (line 140) | def test_transform_cost_inventory_for_json_sorts_months(self):

FILE: tests/test_report_transforms.py
  function build_resource_type_mapping (line 22) | def build_resource_type_mapping():
  function build_resource_inventory (line 39) | def build_resource_inventory():
  function build_risk_definitions (line 46) | def build_risk_definitions():
  function build_risk_data (line 63) | def build_risk_data():
  function build_alternatives (line 71) | def build_alternatives():
  function build_alternative_technologies (line 79) | def build_alternative_technologies():
  class HtmlTransformTests (line 111) | class HtmlTransformTests(unittest.TestCase):
    method test_transform_cost_inventory_for_html_sorts_and_sums_costs (line 112) | def test_transform_cost_inventory_for_html_sorts_and_sums_costs(self):
    method test_transform_risk_inventory_for_html_counts_overall_and_resource_risks (line 128) | def test_transform_risk_inventory_for_html_counts_overall_and_resource...
    method test_transform_alt_tech_for_html_filters_by_strategy_and_status (line 148) | def test_transform_alt_tech_for_html_filters_by_strategy_and_status(se...
  class JsonTransformTests (line 163) | class JsonTransformTests(unittest.TestCase):
    method test_transform_resource_inventory_for_json_maps_names_and_codes (line 164) | def test_transform_resource_inventory_for_json_maps_names_and_codes(se...
    method test_transform_risk_inventory_for_json_maps_impacted_resource_ids (line 176) | def test_transform_risk_inventory_for_json_maps_impacted_resource_ids(...
    method test_transform_alt_tech_for_json_groups_by_resource_id (line 188) | def test_transform_alt_tech_for_json_groups_by_resource_id(self):
  class PdfTransformTests (line 201) | class PdfTransformTests(unittest.TestCase):
    method test_transform_cost_inventory_for_pdf_limits_to_last_six_months (line 202) | def test_transform_cost_inventory_for_pdf_limits_to_last_six_months(se...
    method test_transform_risk_inventory_for_pdf_counts_resource_backed_risks (line 219) | def test_transform_risk_inventory_for_pdf_counts_resource_backed_risks...
    method test_transform_resource_inventory_for_pdf_builds_report_relative_icon_paths (line 235) | def test_transform_resource_inventory_for_pdf_builds_report_relative_i...
    method test_transform_alt_tech_for_pdf_counts_matching_alternatives (line 250) | def test_transform_alt_tech_for_pdf_counts_matching_alternatives(self):

FILE: tests/test_utils_and_main.py
  class LoadConfigTests (line 11) | class LoadConfigTests(unittest.TestCase):
    method test_load_config_returns_parsed_json (line 12) | def test_load_config_returns_parsed_json(self):
    method test_load_config_returns_none_for_missing_file (line 24) | def test_load_config_returns_none_for_missing_file(self):
    method test_load_config_returns_none_for_invalid_json (line 31) | def test_load_config_returns_none_for_invalid_json(self):
  class RunAssessmentPreValidationTests (line 43) | class RunAssessmentPreValidationTests(unittest.TestCase):
    method test_invalid_config_stops_before_pipeline_side_effects (line 44) | def test_invalid_config_stops_before_pipeline_side_effects(self):

FILE: tests/test_validate.py
  function build_aws_config (line 6) | def build_aws_config():
  function build_azure_config (line 20) | def build_azure_config():
  class ValidateRegionTests (line 36) | class ValidateRegionTests(unittest.TestCase):
    method test_accepts_known_region (line 37) | def test_accepts_known_region(self):
    method test_rejects_unknown_region (line 40) | def test_rejects_unknown_region(self):
  class ValidateConfigTests (line 45) | class ValidateConfigTests(unittest.TestCase):
    method test_accepts_valid_aws_config (line 46) | def test_accepts_valid_aws_config(self):
    method test_accepts_valid_azure_service_principal_config (line 49) | def test_accepts_valid_azure_service_principal_config(self):
    method test_accepts_valid_azure_cli_config (line 52) | def test_accepts_valid_azure_cli_config(self):
    method test_rejects_azure_config_without_client_credentials (line 63) | def test_rejects_azure_config_without_client_credentials(self):
    method test_rejects_invalid_assessment_type (line 73) | def test_rejects_invalid_assessment_type(self):
    method test_rejects_non_integer_top_level_fields (line 80) | def test_rejects_non_integer_top_level_fields(self):
    method test_rejects_invalid_name_characters (line 87) | def test_rejects_invalid_name_characters(self):
    method test_rejects_too_long_name (line 96) | def test_rejects_too_long_name(self):
    method test_rejects_aws_config_with_invalid_region (line 103) | def test_rejects_aws_config_with_invalid_region(self):

FILE: utils/aws.py
  function is_aws_cli_installed (line 9) | def is_aws_cli_installed() -> bool:
  function is_aws_profile_valid (line 13) | def is_aws_profile_valid(profile: str) -> bool:

FILE: utils/azure.py
  function is_azure_cli_installed (line 14) | def is_azure_cli_installed() -> bool:
  function is_azure_cli_logged_in (line 18) | def is_azure_cli_logged_in() -> bool:
  function is_azure_cli_token_expired (line 32) | def is_azure_cli_token_expired() -> bool:
  function select_subscription (line 43) | def select_subscription(subscriptions: List[Any]) -> Any:
  function select_resource_group (line 61) | def select_resource_group(resource_groups: List[Any]) -> str:

FILE: utils/connection.py
  function _build_url (line 18) | def _build_url(host: str) -> str:
  function get_jwt_token (line 25) | def get_jwt_token(
  function resolve_mode (line 66) | def resolve_mode() -> Tuple[str, Optional[str]]:

FILE: utils/data.py
  function get_monday_date (line 18) | def get_monday_date() -> str:
  function compute_file_hash (line 29) | def compute_file_hash(filepath: str) -> str:
  function download_file (line 37) | def download_file(url: str, destination: str, retries: int = 3, delay: i...
  function fetch_remote_checksum (line 67) | def fetch_remote_checksum(
  function initialize_dataset (line 94) | def initialize_dataset() -> None:

FILE: utils/sync.py
  function _build_url (line 15) | def _build_url(host: str) -> str:
  function submit_assessment (line 22) | def submit_assessment(

FILE: utils/utils.py
  function load_config (line 15) | def load_config(file_path: str) -> Optional[Dict[str, Any]]:
  function prompt_required_inputs (line 28) | def prompt_required_inputs() -> Tuple[int, int]:
  function print_step (line 62) | def print_step(
  function create_directory (line 109) | def create_directory(base_path="reports"):
  function print_help_message (line 124) | def print_help_message():

FILE: utils/validate.py
  function validate_region (line 6) | def validate_region(region: str) -> None:
  function validate_config (line 12) | def validate_config(config: Dict[str, Any]) -> bool:
Condensed preview — 39 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (324K chars).
[
  {
    "path": ".github/workflows/pr-checks.yml",
    "chars": 1688,
    "preview": "name: PR Checks\n\non:\n  pull_request:\n    types: [opened, synchronize, reopened, ready_for_review]\n\npermissions:\n  conten"
  },
  {
    "path": ".gitignore",
    "chars": 7146,
    "preview": "# Created by https://www.toptal.com/developers/gitignore/api/macos,windows,linux,visualstudiocode,python,node\n# Edit at "
  },
  {
    "path": "LICENSE",
    "chars": 34523,
    "preview": "                    GNU AFFERO GENERAL PUBLIC LICENSE\n                       Version 3, 19 November 2007\n\n Copyright (C)"
  },
  {
    "path": "README.md",
    "chars": 1775,
    "preview": "![CloudExit](./docs/images/Main.png)\n\n# cloudexit – Cloud Exit Assessment (Open Source)\n\ncloudexit is an open-source too"
  },
  {
    "path": "assets/css/style.css",
    "chars": 22666,
    "preview": "/* ========================================================\n   Base: Variables\n   ======================================"
  },
  {
    "path": "assets/template/index.html",
    "chars": 60085,
    "preview": "<!DOCTYPE html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"utf-8\" />\n    <meta name=\"viewport\" content=\"width=device-w"
  },
  {
    "path": "config/aws_example.json",
    "chars": 206,
    "preview": "{\n    \"cloudServiceProvider\": 2,\n    \"exitStrategy\": 3,\n    \"assessmentType\": 1,\n    \"providerDetails\":{\n      \"accessKe"
  },
  {
    "path": "config/azure_example.json",
    "chars": 308,
    "preview": "{\n    \"cloudServiceProvider\": 1,\n    \"exitStrategy\": 3,\n    \"assessmentType\": 1,\n    \"providerDetails\":{\n      \"clientId"
  },
  {
    "path": "config.py",
    "chars": 628,
    "preview": "# config.py\n\"\"\"\nConfiguration for integrating the local 'cloudexit' tool with the ExitCloud Platform (exitcloud.io).\nThi"
  },
  {
    "path": "core/__init__.py",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "core/engine.py",
    "chars": 19027,
    "preview": "# core/engine.py\nimport logging\nimport os\nimport boto3\nfrom datetime import datetime\nfrom typing import Any, Dict, Optio"
  },
  {
    "path": "core/utils.py",
    "chars": 1112,
    "preview": "# core/utils.py\nimport os\nimport shutil\nimport logging\n\nlogger = logging.getLogger(\"core.engine.utils\")\n\n\ndef copy_asset"
  },
  {
    "path": "core/utils_aws.py",
    "chars": 12058,
    "preview": "# core/utils_aws.py\nimport boto3\nimport botocore\nimport json\nimport os\nimport time\nimport logging\nimport sqlite3\nfrom ty"
  },
  {
    "path": "core/utils_azure.py",
    "chars": 9842,
    "preview": "# core/utils_azure.py\nimport json\nimport os\nimport logging\nimport sqlite3\nfrom typing import Any, Dict, Set\nfrom datetim"
  },
  {
    "path": "core/utils_db.py",
    "chars": 2180,
    "preview": "# core/utils_db.py\nimport sqlite3\nimport logging\n\n# Configure logger for database operations\nlogger = logging.getLogger("
  },
  {
    "path": "core/utils_report.py",
    "chars": 31772,
    "preview": "# core/utils_report.py\nimport os\nimport json\nimport logging\nfrom typing import List, Dict, Any, Optional\nfrom jinja2 imp"
  },
  {
    "path": "core/utils_report_common.py",
    "chars": 6046,
    "preview": "from collections import defaultdict\nfrom datetime import datetime\nfrom typing import Any, Dict, List, Optional, Tuple\n\nC"
  },
  {
    "path": "core/utils_report_html.py",
    "chars": 1900,
    "preview": "# core/utils_report_html.py\nimport logging\nfrom typing import List, Dict, Any, Tuple\n\nfrom core.utils_report_common impo"
  },
  {
    "path": "core/utils_report_json.py",
    "chars": 3170,
    "preview": "# core/utils_report_json.py\nimport logging\nfrom typing import List, Dict, Any\n\nfrom core.utils_report_common import (\n  "
  },
  {
    "path": "core/utils_report_pdf.py",
    "chars": 15029,
    "preview": "# core/utils_report_pdf.py\nimport os\nimport math\nimport logging\nfrom datetime import datetime\nfrom typing import List, D"
  },
  {
    "path": "core/utils_sync.py",
    "chars": 3298,
    "preview": "# core/utils_sync.py\nfrom __future__ import annotations\n\nimport json\nimport logging\nimport os\nimport time\nimport config\n"
  },
  {
    "path": "main.py",
    "chars": 25211,
    "preview": "# main.py\nimport logging\nimport argparse\nimport boto3\nimport time\nimport sys\nfrom rich.console import Console\nfrom datet"
  },
  {
    "path": "publiccode.yml",
    "chars": 885,
    "preview": "publiccodeYmlVersion: \"0.4.0\"\nname: cloudexit\nurl: https://github.com/escapecloud/cloudexit\nlandingURL: https://escapecl"
  },
  {
    "path": "requirements-dev.txt",
    "chars": 31,
    "preview": "-r requirements.txt\nblack\nruff\n"
  },
  {
    "path": "requirements.txt",
    "chars": 252,
    "preview": "azure-identity==1.25.3\nazure-mgmt-resource==24.0.0\nazure-mgmt-authorization==4.0.0\nazure-mgmt-costmanagement==4.0.1\nboto"
  },
  {
    "path": "tests/__init__.py",
    "chars": 1,
    "preview": "\n"
  },
  {
    "path": "tests/report_fixtures.py",
    "chars": 3450,
    "preview": "import shutil\nfrom pathlib import Path\n\n\ndef build_report_fixture():\n    metadata = {\n        \"name\": \"Smoke Test Assess"
  },
  {
    "path": "tests/test_report_pipeline.py",
    "chars": 5662,
    "preview": "import json\nimport tempfile\nimport unittest\nfrom pathlib import Path\n\nfrom core.utils_report import (\n    generate_html_"
  },
  {
    "path": "tests/test_report_transforms.py",
    "chars": 9491,
    "preview": "import tempfile\nimport unittest\n\nfrom core.utils_report_html import (\n    transform_alt_tech_for_html,\n    transform_cos"
  },
  {
    "path": "tests/test_utils_and_main.py",
    "chars": 2602,
    "preview": "import json\nimport tempfile\nimport unittest\nfrom pathlib import Path\nfrom unittest.mock import patch\n\nimport main\nfrom u"
  },
  {
    "path": "tests/test_validate.py",
    "chars": 3467,
    "preview": "import unittest\n\nfrom utils.validate import validate_config, validate_region\n\n\ndef build_aws_config():\n    return {\n    "
  },
  {
    "path": "utils/aws.py",
    "chars": 531,
    "preview": "# utils/aws.py\nimport logging\nimport shutil\nimport subprocess\n\nlogger = logging.getLogger(\"main.utils.aws\")\n\n\ndef is_aws"
  },
  {
    "path": "utils/azure.py",
    "chars": 2959,
    "preview": "# utils/azure.py\nimport logging\nimport shutil\nimport subprocess\nfrom typing import List, Any\nfrom rich.console import Co"
  },
  {
    "path": "utils/connection.py",
    "chars": 2271,
    "preview": "# utils/connection.py\nfrom __future__ import annotations\n\nimport logging\nimport requests\nfrom typing import Tuple, Optio"
  },
  {
    "path": "utils/constants.py",
    "chars": 1188,
    "preview": "# utils/constants.py\nREGION_CHOICES = [\n    (\"us-east-1\", \"us-east-1 (N. Virginia)\"),\n    (\"us-east-2\", \"us-east-2 (Ohio"
  },
  {
    "path": "utils/data.py",
    "chars": 5399,
    "preview": "# utils/data.py\nimport os\nimport gzip\nimport shutil\nimport hashlib\nimport time\nimport requests\nfrom typing import Option"
  },
  {
    "path": "utils/sync.py",
    "chars": 1420,
    "preview": "# utils/sync.py\nfrom __future__ import annotations\n\nimport logging\nimport requests\nimport config\nfrom typing import Opti"
  },
  {
    "path": "utils/utils.py",
    "chars": 4705,
    "preview": "# utils/utils.py\nimport os\nimport logging\nimport json\nfrom typing import Optional, Tuple, Dict, Any\nfrom rich.console im"
  },
  {
    "path": "utils/validate.py",
    "chars": 2944,
    "preview": "# utils/validate.py\nfrom typing import Dict, Any\nfrom .constants import REGION_CHOICES, REQUIRED_FIELDS_AZURE, REQUIRED_"
  }
]

About this extraction

This page contains the full source code of the escapecloud/cloudexit GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 39 files (299.7 KB), approximately 73.3k tokens, and a symbol index with 129 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!