├── awsmp ├── __init__.py ├── yaml_utils.py ├── types.py ├── constants.py ├── errors.py ├── cli.py ├── changesets.py └── _driver.py ├── tests ├── __init__.py ├── prices.csv ├── local_config │ ├── test_config_7.yaml │ ├── test_config_5.yaml │ ├── test_config_1.yaml │ ├── test_config_6.yaml │ ├── test_config_2.yaml │ ├── test_config_3.yaml │ └── test_config_4.yaml ├── test_config.yaml ├── description.yaml ├── test_config.json └── test_changesets.py ├── .wokeignore ├── docs ├── .sphinx │ ├── _static │ │ ├── tag.png │ │ ├── favicon.png │ │ ├── header-nav.js │ │ ├── github_issue_links.css │ │ ├── github_issue_links.js │ │ ├── 404.svg │ │ ├── header.css │ │ ├── furo_colors.css │ │ └── custom.css │ ├── pa11y.json │ ├── _templates │ │ ├── base.html │ │ ├── sidebar │ │ │ └── search.html │ │ ├── 404.html │ │ ├── header.html │ │ ├── page.html │ │ └── footer.html │ ├── spellingcheck.yaml │ └── build_requirements.py ├── .custom_wordlist.txt ├── .wokeignore ├── .gitignore ├── .wordlist.txt ├── .readthedocs.yaml ├── make.bat ├── index.rst ├── inspect │ └── index.rst ├── installation.rst ├── init.sh ├── private-offer │ └── index.rst ├── Makefile ├── doc-cheat-sheet-myst.md ├── conf.py ├── doc-cheat-sheet.rst ├── custom_conf.py └── public-offer │ └── index.rst ├── .gitignore ├── .github ├── workflows │ ├── automatic-doc-checks.yml │ └── pr.yaml └── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── SECURITY.md ├── README.rst ├── renovate.json ├── tox.ini ├── snap └── snapcraft.yaml ├── pyproject.toml └── listing_configuration.yaml /awsmp/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.wokeignore: -------------------------------------------------------------------------------- 1 | docs/.wokeignore -------------------------------------------------------------------------------- /docs/.sphinx/_static/tag.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/canonical/awsmp/main/docs/.sphinx/_static/tag.png -------------------------------------------------------------------------------- /docs/.sphinx/_static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/canonical/awsmp/main/docs/.sphinx/_static/favicon.png -------------------------------------------------------------------------------- /tests/prices.csv: -------------------------------------------------------------------------------- 1 | c3.2xlarge,0.014,98.112 2 | c3.4xlarge,0.028,196.224 3 | c3.8xlarge,0.056,392.448 4 | c4.large,0.00,0.00 5 | -------------------------------------------------------------------------------- /docs/.sphinx/pa11y.json: -------------------------------------------------------------------------------- 1 | { 2 | "chromeLaunchConfig": { 3 | "args": [ 4 | "--no-sandbox" 5 | ] 6 | }, 7 | "reporter": "cli", 8 | "standard": "WCAG2AA" 9 | } -------------------------------------------------------------------------------- /docs/.custom_wordlist.txt: -------------------------------------------------------------------------------- 1 | awsmp 2 | yaml 3 | AMI 4 | config 5 | csv 6 | Snapcraft 7 | Snapstore 8 | AWS 9 | USD 10 | str 11 | awsmpcli 12 | GovCloud 13 | Instructions 14 | instance -------------------------------------------------------------------------------- /docs/.wokeignore: -------------------------------------------------------------------------------- 1 | # the cheat sheets contain a link to a repository with a block word which we 2 | # cannot avoid for now, ie 3 | # https://www.sphinx-doc.org/en/master/usage/restructuredtext/basics.html 4 | doc-cheat-sheet* 5 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | /*env*/ 2 | .sphinx/venv/ 3 | .sphinx/requirements.txt 4 | .sphinx/warnings.txt 5 | .sphinx/.wordlist.dic 6 | .sphinx/.doctrees/ 7 | .sphinx/node_modules/ 8 | package*.json 9 | _build 10 | .DS_Store 11 | __pycache__ 12 | .idea/ 13 | .vscode/ 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # editors 2 | /.idea/ 3 | /.vscode/ 4 | 5 | # python 6 | /awsmp.egg-info/ 7 | *.pyc 8 | .tox/ 9 | .cache 10 | .mypy_cache/ 11 | .pytest_cache/ 12 | __pycache__/ 13 | build 14 | dist 15 | venv/ 16 | .coverage 17 | /.eggs/ 18 | 19 | # temp files 20 | *~ 21 | *.csv 22 | *.snap 23 | -------------------------------------------------------------------------------- /docs/.sphinx/_static/header-nav.js: -------------------------------------------------------------------------------- 1 | $(document).ready(function() { 2 | $(document).on("click", function () { 3 | $(".more-links-dropdown").hide(); 4 | }); 5 | 6 | $('.nav-more-links').click(function(event) { 7 | $('.more-links-dropdown').toggle(); 8 | event.stopPropagation(); 9 | }); 10 | }) 11 | -------------------------------------------------------------------------------- /docs/.sphinx/_templates/base.html: -------------------------------------------------------------------------------- 1 | {% extends "furo/base.html" %} 2 | 3 | {% block theme_scripts %} 4 | 7 | {% endblock theme_scripts %} 8 | 9 | {# ru-fu: don't include the color variables from the conf.py file, but use a 10 |  separate CSS file to save space #} 11 | {% block theme_styles %} 12 | {% endblock theme_styles %} 13 | -------------------------------------------------------------------------------- /.github/workflows/automatic-doc-checks.yml: -------------------------------------------------------------------------------- 1 | name: Main Documentation Checks 2 | 3 | on: 4 | - push 5 | - pull_request 6 | - workflow_dispatch 7 | 8 | concurrency: 9 | group: ${{ github.workflow }}-${{ github.ref }} 10 | cancel-in-progress: true 11 | 12 | jobs: 13 | documentation-checks: 14 | uses: canonical/documentation-workflows/.github/workflows/documentation-checks.yaml@main 15 | with: 16 | working-directory: 'docs' -------------------------------------------------------------------------------- /docs/.sphinx/_templates/sidebar/search.html: -------------------------------------------------------------------------------- 1 | 7 | 8 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Reporting a vulnerability 2 | To report a security issue, file a [Private Security Report](https://github.com/Canonical/awsmp/security/advisories/new) 3 | with a description of the issue, the steps you took to create the issue, 4 | affected versions, and, if known, mitigations for the issue. 5 | The [Ubuntu Security disclosure and embargo policy](https://ubuntu.com/security/disclosure-policy) 6 | contains more information about what you can expect when you contact us and what we expect from you. 7 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ***** 2 | awsmp 3 | ***** 4 | 5 | **awsmp** is a module and CLI tool that can be used to interact with the AWS Marketplace API. 6 | 7 | Documentation 8 | ############# 9 | 10 | The documentation can be found at https://canonical-awsmp.readthedocs-hosted.com/. 11 | 12 | It'll cover installation instructions and specific use cases such as creating a private offer. 13 | 14 | Report issues 15 | ############# 16 | 17 | Please use https://github.com/canonical/awsmp/issues to report problems or ask questions. -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended" 5 | ], 6 | "packageRules": [ 7 | { 8 | "groupName": "all non-major dependencies", 9 | "groupSlug": "all-minor-patch", 10 | "matchPackageNames": [ 11 | "*" 12 | ], 13 | "matchUpdateTypes": [ 14 | "minor", 15 | "patch" 16 | ] 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. 16 | 2. 17 | 3. 18 | 19 | **Expected behavior** 20 | A clear and concise description of what you expected to happen. 21 | 22 | **Additional context** 23 | Add any other context about the problem here. 24 | -------------------------------------------------------------------------------- /docs/.wordlist.txt: -------------------------------------------------------------------------------- 1 | addons 2 | API 3 | APIs 4 | balancer 5 | Charmhub 6 | CLI 7 | Diátaxis 8 | dropdown 9 | EBS 10 | EKS 11 | enablement 12 | favicon 13 | Furo 14 | Git 15 | GitHub 16 | Grafana 17 | IAM 18 | installable 19 | JSON 20 | Juju 21 | Kubeflow 22 | Kubernetes 23 | Launchpad 24 | LTS 25 | Makefile 26 | Matrix 27 | Mattermost 28 | MyST 29 | namespace 30 | namespaces 31 | NodePort 32 | observability 33 | OEM 34 | OLM 35 | Permalink 36 | pre 37 | Quickstart 38 | ReadMe 39 | reST 40 | reStructuredText 41 | RTD 42 | subdirectories 43 | subfolders 44 | subtree 45 | Ubuntu 46 | UI 47 | UUID 48 | VM 49 | YAML -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | isolated_build = true 3 | env_list = lint, py{310,311,313}, test 4 | minversion = 4.10.0 5 | 6 | [testenv] 7 | skip_install = true 8 | deps = 9 | poetry 10 | allowlist_externals = poetry 11 | commands_pre = poetry install 12 | package = wheel 13 | wheel_build_env = .pkg 14 | 15 | [testenv:test] 16 | description = run the tests with pytest 17 | commands = 18 | poetry run poe test 19 | 20 | [testenv:lint] 21 | description = run linter 22 | commands = 23 | poetry run poe lint 24 | 25 | [testenv:fix] 26 | description = run formatters 27 | commands = 28 | poetry run poe format 29 | -------------------------------------------------------------------------------- /docs/.sphinx/_static/github_issue_links.css: -------------------------------------------------------------------------------- 1 | .github-issue-link-container { 2 | padding-right: 0.5rem; 3 | } 4 | .github-issue-link { 5 | font-size: var(--font-size--small); 6 | font-weight: bold; 7 | background-color: #D6410D; 8 | padding: 13px 23px; 9 | text-decoration: none; 10 | } 11 | .github-issue-link:link { 12 | color: #FFFFFF; 13 | } 14 | .github-issue-link:visited { 15 | color: #FFFFFF 16 | } 17 | .muted-link.github-issue-link:hover { 18 | color: #FFFFFF; 19 | text-decoration: underline; 20 | } 21 | .github-issue-link:active { 22 | color: #FFFFFF; 23 | text-decoration: underline; 24 | } 25 | -------------------------------------------------------------------------------- /docs/.sphinx/spellingcheck.yaml: -------------------------------------------------------------------------------- 1 | matrix: 2 | - name: rST files 3 | aspell: 4 | lang: en 5 | d: en_GB 6 | dictionary: 7 | wordlists: 8 | - .wordlist.txt 9 | - .custom_wordlist.txt 10 | output: .sphinx/.wordlist.dic 11 | sources: 12 | - _build/**/*.html 13 | pipeline: 14 | - pyspelling.filters.html: 15 | comments: false 16 | attributes: 17 | - title 18 | - alt 19 | ignores: 20 | - code 21 | - pre 22 | - spellexception 23 | - link 24 | - title 25 | - div.relatedlinks 26 | - strong.command 27 | - div.visually-hidden 28 | - img 29 | - a.p-navigation__link 30 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | 22 | -------------------------------------------------------------------------------- /awsmp/yaml_utils.py: -------------------------------------------------------------------------------- 1 | import yaml 2 | from yaml.dumper import SafeDumper 3 | 4 | 5 | class LiteralString(str): 6 | pass 7 | 8 | 9 | def literal_str_representer(dumper, data): 10 | text = data if data.endswith("\n") else data + "\n" 11 | return dumper.represent_scalar("tag:yaml.org,2002:str", text, style="|") 12 | 13 | 14 | class IndentListDumper(SafeDumper): 15 | def increase_indent(self, flow=False, indentless=False): 16 | return super().increase_indent(flow, False) 17 | 18 | 19 | yaml.add_representer(LiteralString, literal_str_representer, Dumper=IndentListDumper) 20 | 21 | 22 | def dump(data, config): 23 | yaml.dump(data, config, Dumper=IndentListDumper, default_flow_style=False, indent=2, sort_keys=False) 24 | -------------------------------------------------------------------------------- /docs/.sphinx/_templates/404.html: -------------------------------------------------------------------------------- 1 | {% extends "page.html" %} 2 | 3 | {% block content -%} 4 |
5 |

Page not found

6 |
7 |
8 |
9 | {{ body }} 10 |
11 |
12 | Penguin with a question mark 13 |
14 |
15 |
16 |
17 | {%- endblock content %} 18 | -------------------------------------------------------------------------------- /awsmp/types.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, List, Literal, TypedDict, Union 2 | 3 | from typing_extensions import NotRequired 4 | 5 | 6 | class UpdateDimensionChange(TypedDict): 7 | Description: str # Description shown on customer's billing 8 | Key: str # Instance type name 9 | Name: str # Instance type name 10 | Types: List[Literal["Metered"]] # Dimension type. 'Metered' for AMI product 11 | Unit: Literal["Hrs", "Units"] # Billing unit 12 | 13 | 14 | class ChangeSetType(TypedDict): 15 | ChangeType: str 16 | ChangeName: NotRequired[str] 17 | Entity: Dict[str, str] 18 | DetailsDocument: Union[List[Dict[str, Any]], Dict[str, Any]] 19 | ChangeSetId: NotRequired[str] 20 | 21 | 22 | class ChangeSetReturnType(TypedDict): 23 | ChangeSetArn: str 24 | ChangeSetId: str 25 | -------------------------------------------------------------------------------- /docs/.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yaml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | # Required 6 | version: 2 7 | 8 | # Set the version of Python and other tools you might need 9 | build: 10 | os: ubuntu-22.04 11 | tools: 12 | python: "3.11" 13 | jobs: 14 | post_checkout: 15 | - cd docs && python3 .sphinx/build_requirements.py 16 | 17 | # Build documentation in the docs/ directory with Sphinx 18 | sphinx: 19 | builder: dirhtml 20 | configuration: docs/conf.py 21 | fail_on_warning: true 22 | 23 | # If using Sphinx, optionally build your docs in additional formats such as PDF 24 | formats: 25 | - pdf 26 | 27 | # Optionally declare the Python requirements required to build your docs 28 | python: 29 | install: 30 | - requirements: docs/.sphinx/requirements.txt 31 | -------------------------------------------------------------------------------- /.github/workflows/pr.yaml: -------------------------------------------------------------------------------- 1 | name: testing 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | tox: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | python: ["3.10", "3.11", "3.12", "3.13"] 15 | steps: 16 | - name: Setup Python 17 | uses: actions/setup-python@v5 18 | with: 19 | python-version: ${{ matrix.python }} 20 | - name: Install tox and any other packages 21 | run: | 22 | # FIXME: not pining to an exact version results in getting poetry 1.0.10 which doesn't work 23 | pip3 install tox poetry==1.7.1 24 | poetry --version 25 | - uses: actions/checkout@v5 26 | - name: Run tox 27 | run: tox 28 | snap: 29 | runs-on: ubuntu-latest 30 | steps: 31 | - uses: actions/checkout@v5 32 | - uses: snapcore/action-build@v1 33 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | %SPHINXBUILD% >NUL 2>NUL 14 | if errorlevel 9009 ( 15 | echo. 16 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 17 | echo.installed, then set the SPHINXBUILD environment variable to point 18 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 19 | echo.may add the Sphinx directory to PATH. 20 | echo. 21 | echo.If you don't have Sphinx installed, grab it from 22 | echo.https://www.sphinx-doc.org/ 23 | exit /b 1 24 | ) 25 | 26 | if "%1" == "" goto help 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | awsmp 2 | ===== 3 | 4 | awsmp is a module and CLI tool that can be used to interact with the `AWS Marketplace API`_ 5 | 6 | --------- 7 | 8 | In this documentation 9 | --------------------- 10 | 11 | .. grid:: 1 1 1 1 12 | :padding: 0 13 | 14 | .. grid-item:: :doc:`Installation ` 15 | 16 | **Instructions** covering how to setup and install awsmp 17 | 18 | .. grid:: 1 1 2 2 19 | :padding: 0 20 | :reverse: 21 | 22 | .. grid-item:: :doc:`Public offer ` 23 | 24 | **Product** creation related APIs 25 | 26 | .. grid-item:: :doc:`Private offer ` 27 | 28 | **Private offer** creation related APIs 29 | 30 | .. toctree:: 31 | :hidden: 32 | :maxdepth: 4 33 | 34 | Installation 35 | Manage offer 36 | Create a public offer 37 | Create a private offer 38 | 39 | .. _`AWS Marketplace API`: https://docs.aws.amazon.com/marketplace-catalog/latest/api-reference/welcome.html 40 | -------------------------------------------------------------------------------- /docs/.sphinx/_static/github_issue_links.js: -------------------------------------------------------------------------------- 1 | // if we already have an onload function, save that one 2 | var prev_handler = window.onload; 3 | 4 | window.onload = function() { 5 | // call the previous onload function 6 | if (prev_handler) { 7 | prev_handler(); 8 | } 9 | 10 | const link = document.createElement("a"); 11 | link.classList.add("muted-link"); 12 | link.classList.add("github-issue-link"); 13 | link.text = "Give feedback"; 14 | link.href = ( 15 | github_url 16 | + "/issues/new?" 17 | + "title=docs%3A+TYPE+YOUR+QUESTION+HERE" 18 | + "&body=*Please describe the question or issue you're facing with " 19 | + `"${document.title}"` 20 | + ".*" 21 | + "%0A%0A%0A%0A%0A" 22 | + "---" 23 | + "%0A" 24 | + `*Reported+from%3A+${location.href}*` 25 | ); 26 | link.target = "_blank"; 27 | 28 | const div = document.createElement("div"); 29 | div.classList.add("github-issue-link-container"); 30 | div.append(link) 31 | 32 | const container = document.querySelector(".article-container > .content-icon-container"); 33 | container.prepend(div); 34 | }; 35 | -------------------------------------------------------------------------------- /docs/.sphinx/_templates/header.html: -------------------------------------------------------------------------------- 1 | 37 | -------------------------------------------------------------------------------- /snap/snapcraft.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: awsmp 3 | base: core22 4 | version: git 5 | summary: A tool to create, inspect, and manage products on the AWS Marketplace. 6 | description: | 7 | A tool to create, inspect, and manage products on the AWS Marketplace. 8 | 9 | Credentials in ~/.aws/ need to be configured to be able to use this tool. 10 | 11 | Note: This snap is provided and maintained by Canonical! 12 | (**not** by Amazon/AWS) 13 | confinement: strict 14 | license: GPL-3.0 15 | architectures: 16 | - build-on: [amd64] 17 | - build-on: [arm64] 18 | 19 | plugs: 20 | dot-aws-config: 21 | interface: personal-files 22 | read: 23 | - $HOME/.aws/config 24 | dot-aws-credentials: 25 | interface: personal-files 26 | read: 27 | - $HOME/.aws/credentials 28 | dot-aws-models: 29 | interface: personal-files 30 | read: 31 | - $HOME/.aws/models 32 | 33 | apps: 34 | awsmp: 35 | command: bin/awsmp 36 | environment: 37 | PYTHONPATH: $SNAP/lib/python3.10/site-packages 38 | # need to set $HOME to the real HOME here because this is a strict snap 39 | # and the creds for aws are in $HOME/.aws 40 | HOME: $SNAP_REAL_HOME 41 | plugs: 42 | - home 43 | - network 44 | - dot-aws-config 45 | - dot-aws-credentials 46 | - dot-aws-models 47 | 48 | parts: 49 | awsmp: 50 | plugin: python 51 | source: . 52 | stage-packages: 53 | - python3-poetry 54 | -------------------------------------------------------------------------------- /docs/inspect/index.rst: -------------------------------------------------------------------------------- 1 | How to manage an offer 2 | ====================== 3 | 4 | To manage existing offers in `AWS marketplace management portal`_, use the API calls described below. 5 | 6 | 7 | Identify differences in public offer 8 | ------------------------------------ 9 | 10 | Before requesting changes to a public offer, compare the listing information in the `AWS marketplace management portal`_ with 11 | the local configuration file to avoid unnecessary modifications. 12 | 13 | The CLI command below will display the differences in product details, including description details and region availability. 14 | 15 | To identify the differences, run: 16 | 17 | .. code-block:: 18 | 19 | $ awsmp inspect entity-diff prod-1234 local-config.yaml 20 | { 21 | "added": [ 22 | { 23 | "name": "Videos", 24 | "value": ["https://video-url"], 25 | } 26 | ], 27 | "removed": [], 28 | "changed": [ 29 | { 30 | "name": "Highlights", 31 | "old_value": ["test_highlight_1"], 32 | "new_value": ["test_highlight_1", "test_highlight_2"], 33 | }, 34 | ], 35 | }, 36 | ... output stripped here ... 37 | 38 | The output shows the fields in ``local-config.yaml`` that have different values compared to the public listing. 39 | 40 | 41 | .. _`AWS marketplace management portal`: https://aws.amazon.com/marketplace/management/ 42 | -------------------------------------------------------------------------------- /docs/.sphinx/_templates/page.html: -------------------------------------------------------------------------------- 1 | {% extends "furo/page.html" %} 2 | 3 | {% block footer %} 4 | {% include "footer.html" %} 5 | {% endblock footer %} 6 | 7 | {% block body -%} 8 | {% include "header.html" %} 9 | {{ super() }} 10 | {%- endblock body %} 11 | 12 | {% if meta and ((meta.discourse and discourse_prefix) or meta.relatedlinks) %} 13 | {% set furo_hide_toc_orig = furo_hide_toc %} 14 | {% set furo_hide_toc=false %} 15 | {% endif %} 16 | 17 | {% block right_sidebar %} 18 |
19 | {% if not furo_hide_toc_orig %} 20 |
21 | 22 | {{ _("Contents") }} 23 | 24 |
25 |
26 |
27 | {{ toc }} 28 |
29 |
30 | {% endif %} 31 | {% if meta and ((meta.discourse and discourse_prefix) or meta.relatedlinks) %} 32 | 37 | 47 | {% endif %} 48 |
49 | {% endblock right_sidebar %} 50 | -------------------------------------------------------------------------------- /tests/local_config/test_config_7.yaml: -------------------------------------------------------------------------------- 1 | product: 2 | description: 3 | product_title: "test" 4 | logourl: "https://test-logourl" 5 | video_urls: [] 6 | short_description: "test_short_description" 7 | long_description: | 8 | test_long_description 9 | highlights: 10 | - "test_highlight_1" 11 | search_keywords: 12 | - "test_keyword_1" 13 | categories: 14 | - "Migration" 15 | support_description: | 16 | test_support_description 17 | support_resources: 18 | - "test_support_resource" 19 | additional_resources: 20 | - test-link: "https://test-url" 21 | sku: "test" 22 | region: 23 | commercial_regions: 24 | - us-east-1 25 | - us-east-2 26 | future_region_support: true 27 | version: 28 | version_title: "test_version_title_1" 29 | release_notes: | 30 | test_release_notes 31 | ami_id: ami-test1 32 | access_role_arn: arn:aws:iam::test 33 | os_user_name: test_os_user_name 34 | os_system_version: test_os_system_version 35 | os_system_name: test_os 36 | scanning_port: 22 37 | usage_instructions: | 38 | test_usage_instructions 39 | recommended_instance_type: m5.large 40 | ip_protocol: tcp 41 | ip_ranges: 42 | - "0.0.0.0/0" 43 | from_port: 22 44 | to_port: 22 45 | offer: 46 | refund_policy: | 47 | test_refund_policy_term 48 | eula_document: 49 | - type: "CustomEula" 50 | url: "test_eula_url" 51 | instance_types: 52 | - name: "a1.large" 53 | hourly: 0.004 54 | - name: "a1.xlarge" 55 | hourly: 0.007 56 | -------------------------------------------------------------------------------- /tests/test_config.yaml: -------------------------------------------------------------------------------- 1 | offer: 2 | refund_policy: | 3 | test_refund_policy_term 4 | eula_document: 5 | - type: "CustomEula" 6 | url: "test_eula_url" 7 | instance_types: 8 | - name: "a1.large" 9 | hourly: 0.004 10 | yearly: 24.528 11 | - name: "a1.xlarge" 12 | hourly: 0.007 13 | yearly: 49.056 14 | product: 15 | description: 16 | product_title: "test" 17 | logourl: "https://test-logourl" 18 | video_urls: [] 19 | short_description: "test_short_description" 20 | long_description: | 21 | test_long_description 22 | highlights: 23 | - "test_highlight_1" 24 | search_keywords: 25 | - "test_keyword_1" 26 | categories: 27 | - "Migration" 28 | support_description: | 29 | test_support_description 30 | support_resources: 31 | - "test_support_resource" 32 | additional_resources: 33 | - test-link: "https://test-url" 34 | sku: "test" 35 | region: 36 | commercial_regions: 37 | - us-east-1 38 | - us-east-2 39 | future_region_support: true 40 | version: 41 | version_title: "test_version_title" 42 | release_notes: | 43 | test_release_notes 44 | ami_id: ami-test 45 | access_role_arn: arn:aws:iam::test 46 | os_user_name: test_os_user_name 47 | os_system_version: test_os_system_version 48 | os_system_name: test_os 49 | scanning_port: 22 50 | usage_instructions: | 51 | test_usage_instructions 52 | recommended_instance_type: m5.large 53 | ip_protocol: tcp 54 | ip_ranges: 55 | - "0.0.0.0/0" 56 | from_port: 22 57 | to_port: 22 58 | -------------------------------------------------------------------------------- /tests/local_config/test_config_5.yaml: -------------------------------------------------------------------------------- 1 | product: 2 | description: 3 | product_title: "test" 4 | logourl: "https://test-logourl" 5 | video_urls: [] 6 | short_description: "test_short_description" 7 | long_description: | 8 | test_long_description 9 | highlights: 10 | - "test_highlight_1" 11 | search_keywords: 12 | - "test_keyword_1" 13 | categories: 14 | - "Migration" 15 | support_description: | 16 | test_support_description 17 | support_resources: 18 | - "test_support_resource" 19 | additional_resources: 20 | - test-link: "https://test-url" 21 | sku: "test" 22 | region: 23 | commercial_regions: 24 | - us-east-1 25 | - us-east-2 26 | future_region_support: true 27 | version: 28 | version_title: "test_version_title_1" 29 | release_notes: | 30 | test_release_notes 31 | ami_id: ami-test1 32 | access_role_arn: arn:aws:iam::test 33 | os_user_name: test_os_user_name 34 | os_system_version: test_os_system_version 35 | os_system_name: test_os 36 | scanning_port: 22 37 | usage_instructions: | 38 | test_usage_instructions 39 | recommended_instance_type: m5.large 40 | ip_protocol: tcp 41 | ip_ranges: 42 | - "0.0.0.0/0" 43 | from_port: 22 44 | to_port: 22 45 | offer: 46 | refund_policy: | 47 | 100% refund 48 | eula_document: 49 | - type: "CustomEula" 50 | url: "test_eula_url" 51 | instance_types: 52 | - name: "a1.large" 53 | hourly: 0.004 54 | yearly: 24.528 55 | - name: "a1.xlarge" 56 | hourly: 0.007 57 | yearly: 49.056 58 | -------------------------------------------------------------------------------- /tests/local_config/test_config_1.yaml: -------------------------------------------------------------------------------- 1 | product: 2 | description: 3 | product_title: "test" 4 | logourl: "https://test-logourl" 5 | video_urls: [] 6 | short_description: "test_short_description" 7 | long_description: | 8 | test_long_description 9 | highlights: 10 | - "test_highlight_1" 11 | search_keywords: 12 | - "test_keyword_1" 13 | categories: 14 | - "Migration" 15 | support_description: | 16 | test_support_description 17 | support_resources: 18 | - "test_support_resource" 19 | additional_resources: 20 | - test-link: "https://test-url" 21 | sku: "test" 22 | region: 23 | commercial_regions: 24 | - us-east-1 25 | - us-east-2 26 | future_region_support: true 27 | version: 28 | version_title: "test_version_title_1" 29 | release_notes: | 30 | test_release_notes 31 | ami_id: ami-test1 32 | access_role_arn: arn:aws:iam::test 33 | os_user_name: test_os_user_name 34 | os_system_version: test_os_system_version 35 | os_system_name: test_os 36 | scanning_port: 22 37 | usage_instructions: | 38 | test_usage_instructions 39 | recommended_instance_type: m5.large 40 | ip_protocol: tcp 41 | ip_ranges: 42 | - "0.0.0.0/0" 43 | from_port: 22 44 | to_port: 22 45 | offer: 46 | refund_policy: | 47 | test_refund_policy_term 48 | eula_document: 49 | - type: "CustomEula" 50 | url: "test_eula_url" 51 | instance_types: 52 | - name: "a1.large" 53 | hourly: 0.004 54 | yearly: 24.528 55 | - name: "a1.xlarge" 56 | hourly: 0.007 57 | yearly: 49.056 58 | -------------------------------------------------------------------------------- /tests/local_config/test_config_6.yaml: -------------------------------------------------------------------------------- 1 | product: 2 | description: 3 | product_title: "test" 4 | logourl: "https://test-logourl" 5 | video_urls: [] 6 | short_description: "test_short_description" 7 | long_description: | 8 | test_long_description 9 | highlights: 10 | - "test_highlight_1" 11 | search_keywords: 12 | - "test_keyword_1" 13 | categories: 14 | - "Migration" 15 | support_description: | 16 | test_support_description 17 | support_resources: 18 | - "test_support_resource" 19 | additional_resources: 20 | - test-link: "https://test-url" 21 | sku: "test" 22 | region: 23 | commercial_regions: 24 | - us-east-1 25 | - us-east-2 26 | future_region_support: true 27 | version: 28 | version_title: "test_version_title_1" 29 | release_notes: | 30 | test_release_notes 31 | ami_id: ami-test1 32 | access_role_arn: arn:aws:iam::test 33 | os_user_name: test_os_user_name 34 | os_system_version: test_os_system_version 35 | os_system_name: test_os 36 | scanning_port: 22 37 | usage_instructions: | 38 | test_usage_instructions 39 | recommended_instance_type: m5.large 40 | ip_protocol: tcp 41 | ip_ranges: 42 | - "0.0.0.0/0" 43 | from_port: 22 44 | to_port: 22 45 | offer: 46 | refund_policy: | 47 | test_refund_policy_term 48 | eula_document: 49 | - type: "CustomEula" 50 | url: "test_eula_url" 51 | instance_types: 52 | - name: "a1.large" 53 | hourly: 0.004 54 | yearly: 30.0 55 | - name: "a1.xlarge" 56 | hourly: 0.007 57 | yearly: 49.056 58 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "awsmp" 3 | version = "0.1.0" 4 | description = "" 5 | authors = [ 6 | "Thomas Bechtold ", 7 | "Jess Jang ", 8 | "Ryan Hill ", 9 | ] 10 | 11 | readme = "README.rst" 12 | 13 | [tool.poetry.scripts] 14 | awsmpcli = "awsmp.cli:main" 15 | awsmp = "awsmp.cli:main" 16 | 17 | [tool.poetry.dependencies] 18 | python = "^3.10" 19 | boto3 = "^1.39.3" 20 | pydantic = "^2.2.0" 21 | click = "^8.1.6" 22 | prettytable = "^3.8.0" 23 | pyyaml = "^6.0.1" 24 | 25 | [tool.poetry.group.dev.dependencies] 26 | black = "^24.0.0" 27 | poethepoet = "^0.36.0" 28 | pytest = "^8.0.0" 29 | tox = "^4.10.0" 30 | isort = "^5.12.0" 31 | mypy = "^1.6.1" 32 | boto3-stubs = "^1.39.3" 33 | types-pyyaml = "^6.0.12.12" 34 | pytest-cov = "^4.1.0" 35 | typing-extensions = "^4.8.0" 36 | 37 | [tool.poe.tasks] 38 | tox = "tox" 39 | lint = ["isort-check", "black-check", "mypy"] 40 | format = ["isort", "black"] 41 | test = "pytest --cov=awsmp" 42 | test-cov-details = "pytest --cov=awsmp --cov-report term-missing" 43 | black = "black ." 44 | black-check = "black --check ." 45 | isort = "isort ." 46 | isort-check = "isort --check ." 47 | mypy = "mypy . --check-untyped-defs" 48 | 49 | [tool.black] 50 | line-length = 120 51 | extend-exclude = ''' 52 | /( 53 | # The following are specific to Black, you probably don't want those. 54 | docs 55 | )/ 56 | ''' 57 | 58 | [tool.isort] 59 | profile = "black" 60 | skip_glob = ["docs/*"] 61 | 62 | [tool.mypy] 63 | exclude = "docs/" 64 | 65 | 66 | [build-system] 67 | requires = ["poetry-core>=1.2.0"] 68 | build-backend = "poetry.core.masonry.api" 69 | -------------------------------------------------------------------------------- /tests/local_config/test_config_2.yaml: -------------------------------------------------------------------------------- 1 | product: 2 | description: 3 | product_title: "test" 4 | logourl: "https://test-logourl" 5 | video_urls: [] 6 | short_description: "test_short_description" 7 | long_description: | 8 | test_long_description 9 | highlights: 10 | - "test_highlight_1" 11 | - "test_highlight_2" 12 | search_keywords: 13 | - "test_keyword_1" 14 | categories: 15 | - "Migration" 16 | support_description: | 17 | test_support_description 18 | support_resources: 19 | - "test_support_resource" 20 | additional_resources: 21 | - test-link: "https://test-url" 22 | sku: "test" 23 | region: 24 | commercial_regions: 25 | - us-east-1 26 | - us-east-2 27 | - eu-west-1 28 | future_region_support: true 29 | version: 30 | version_title: "test_version_title_2" 31 | release_notes: | 32 | test_release_notes 33 | ami_id: ami-test2 34 | access_role_arn: arn:aws:iam::test 35 | os_user_name: test_os_user_name 36 | os_system_version: test_os_system_version 37 | os_system_name: test_os 38 | scanning_port: 22 39 | usage_instructions: | 40 | test_usage_instructions 41 | recommended_instance_type: m5.large 42 | ip_protocol: tcp 43 | ip_ranges: 44 | - "0.0.0.0/0" 45 | from_port: 22 46 | to_port: 22 47 | offer: 48 | refund_policy: | 49 | test_refund_policy_term 50 | eula_document: 51 | - type: "CustomEula" 52 | url: "test_eula_url" 53 | instance_types: 54 | - name: "a1.large" 55 | hourly: 0.004 56 | yearly: 24.528 57 | - name: "a1.xlarge" 58 | hourly: 0.007 59 | yearly: 49.056 60 | -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | Installation 2 | ============ 3 | 4 | Setup profile configuration 5 | --------------------------- 6 | 7 | Before using this tool, you need to setup the AWS configuration and credential files. Follow `CLI configuration documentation`_ to 8 | create these two files. 9 | 10 | Example config file: 11 | 12 | .. code-block:: 13 | 14 | $ cat ~/.aws/config 15 | [default] 16 | region = us-east-1 17 | 18 | Example credential file: 19 | 20 | .. code-block:: 21 | 22 | $ cat ~/.aws/credentials 23 | [default] 24 | aws_secret_access_key = $YOUR_SECRET_ACCESS_KEY 25 | aws_access_key_id = $YOUR_ACCESS_KEY_ID 26 | 27 | Install awsmp using snap 28 | ------------------------ 29 | 30 | ``awsmp`` is available in the `Snapstore`_, and it can be installed using: 31 | 32 | .. code-block:: 33 | 34 | snap install awsmp 35 | 36 | This will install the latest version in your machine. We would highly recommend you to install the latest version, but refer to this `Snapcraft channel doc`_ for installing a different version or from channel. 37 | 38 | CLI usage 39 | ---------- 40 | 41 | The command line interface called ``awsmp`` accepts the standard 42 | AWS environment variables such as `AWS_PROFILE`. Note that Marketplace 43 | interaction needs to happen in the `us-east-1` region (which is set 44 | as the default in the CLI). 45 | 46 | *awsmpcli is the legacy binary name, and is still available outside of snap builds.* 47 | 48 | .. _`CLI configuration documentation`: https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-files.html#cli-configure-files-using-profiles 49 | .. _`Snapstore`: https://snapcraft.io/awsmp 50 | .. _`snapcraft channel doc`: https://snapcraft.io/docs/channels -------------------------------------------------------------------------------- /tests/local_config/test_config_3.yaml: -------------------------------------------------------------------------------- 1 | product: 2 | description: 3 | product_title: "test_prod" 4 | logourl: "https://test-logourl.pdf" 5 | video_urls: 6 | - "https://test-video" 7 | short_description: "test_short_description\nshort_description" 8 | long_description: | 9 | test_long_description 10 | 11 | very_long 12 | highlights: 13 | - "test_highlight_1" 14 | - "test_highlight_2" 15 | search_keywords: 16 | - "test_keyword_1" 17 | - "test_keyword_2" 18 | categories: 19 | - "Migration" 20 | - "Testing" 21 | support_description: | 22 | test_support_description 23 | 24 | with new lines 25 | 26 | support_resources: 27 | - "test_support_resource" 28 | additional_resources: 29 | - test-link: "https://test-url" 30 | - test-link2: "https://test-url2" 31 | sku: "test" 32 | region: 33 | commercial_regions: 34 | - us-east-1 35 | future_region_support: False 36 | version: 37 | version_title: "test_version_title_3" 38 | release_notes: | 39 | test_release_notes 40 | ami_id: ami-test3 41 | access_role_arn: arn:aws:iam::test 42 | os_user_name: test_os_user_name 43 | os_system_version: test_os_system_version 44 | os_system_name: test_os 45 | scanning_port: 22 46 | usage_instructions: | 47 | test_usage_instructions 48 | recommended_instance_type: m5.large 49 | ip_protocol: tcp 50 | ip_ranges: 51 | - "0.0.0.0/0" 52 | from_port: 22 53 | to_port: 22 54 | offer: 55 | refund_policy: | 56 | test_refund_policy_term 57 | eula_document: 58 | - type: "CustomEula" 59 | url: "test_eula_url" 60 | instance_types: 61 | - name: "a1.large" 62 | hourly: 0.004 63 | yearly: 24.528 64 | - name: "a1.xlarge" 65 | hourly: 0.007 66 | yearly: 49.056 67 | -------------------------------------------------------------------------------- /awsmp/constants.py: -------------------------------------------------------------------------------- 1 | CATEGORIES = [ 2 | "Backup & Recovery", 3 | "Data Analytics", 4 | "High Performance Computing", 5 | "Migration", 6 | "Network Infrastructure", 7 | "Operating Systems", 8 | "Security", 9 | "Storage", 10 | "Agile Lifecycle Management", 11 | "Application Development", 12 | "Application Servers", 13 | "Application Stacks", 14 | "Continuous Integration and Continuous Delivery", 15 | "Infrastructure as Code", 16 | "Issue & Bug Tracking", 17 | "Monitoring", 18 | "Log Analysis", 19 | "Source Control", 20 | "Testing", 21 | "Blockchain", 22 | "Collaboration & Productivity", 23 | "Contact Center", 24 | "Content Management", 25 | "CRM", 26 | "eCommerce", 27 | "eLearning", 28 | "Human Resources", 29 | "IT Business Management", 30 | "Project Management", 31 | "Human Review Services", 32 | "ML Solutions", 33 | "Data Labeling Services", 34 | "Computer Vision", 35 | "Natural Language Processing", 36 | "Speech Recognition", 37 | "Text", 38 | "Image", 39 | "Video", 40 | "Audio", 41 | "Structured", 42 | "Intelligent Automation", 43 | "Financial Services Data", 44 | "Healthcare & Life Sciences Data", 45 | "Media & Entertainment Data", 46 | "Telecommunications Data", 47 | "Gaming Data", 48 | "Automotive Data", 49 | "Manufacturing Data", 50 | "Resources Data", 51 | "Retail, Location & Marketing Data", 52 | "Public Sector Data", 53 | "Environmental Data", 54 | "Analytics", 55 | "Applications", 56 | "Device Connectivity", 57 | "Device Management", 58 | "Device Security", 59 | "Industrial IoT", 60 | "Smart Home & City", 61 | "Assessments", 62 | "Implementation", 63 | "Managed Services", 64 | "Premium Support", 65 | "Training", 66 | "Education & Research", 67 | "Financial Services", 68 | "Healthcare & Life Sciences", 69 | "Media & Entertainment", 70 | "Industrial", 71 | "Energy", 72 | ] 73 | -------------------------------------------------------------------------------- /tests/local_config/test_config_4.yaml: -------------------------------------------------------------------------------- 1 | product: 2 | description: 3 | product_title: "test_prod_id" 4 | logourl: "https://test-logourl.svg" 5 | video_urls: [] 6 | short_description: "test_long_description and another short description and short description" 7 | long_description: | 8 | test_long_description 9 | new_line 10 | another_new_line 11 | highlights: 12 | - "test_highlight_1" 13 | - "test_highlight_2" 14 | - "test_highlight_3" 15 | search_keywords: 16 | - "test_keyword_1" 17 | - "test_keyword_2" 18 | - "test_keyword_3" 19 | - "test_keyword_4" 20 | - "test_keyword_5" 21 | - "test_keyword_6" 22 | categories: 23 | - "Migration" 24 | - "Testing" 25 | - "Blockchain" 26 | support_description: | 27 | test_support_description 28 | with multiple line 29 | lines 30 | support_resources: 31 | - "test_support_resource" 32 | additional_resources: 33 | - test-link1: "https://test-url1" 34 | - test-link2: "https://test-url2" 35 | - test-link3: "https://test-url3" 36 | sku: "test" 37 | region: 38 | commercial_regions: 39 | - us-east-2 40 | future_region_support: True 41 | version: 42 | version_title: "test_version_title_4" 43 | release_notes: | 44 | test_release_notes 45 | ami_id: ami-test4 46 | access_role_arn: arn:aws:iam::test 47 | os_user_name: test_os_user_name 48 | os_system_version: test_os_system_version 49 | os_system_name: test_os 50 | scanning_port: 22 51 | usage_instructions: | 52 | test_usage_instructions 53 | recommended_instance_type: m5.large 54 | ip_protocol: tcp 55 | ip_ranges: 56 | - "0.0.0.0/0" 57 | from_port: 22 58 | to_port: 22 59 | offer: 60 | refund_policy: | 61 | test_refund_policy_term 62 | eula_document: 63 | - type: "CustomEula" 64 | url: "test_eula_url" 65 | instance_types: 66 | - name: "a1.large" 67 | hourly: 0.004 68 | yearly: 24.528 69 | - name: "a1.xlarge" 70 | hourly: 0.007 71 | yearly: 49.056 72 | -------------------------------------------------------------------------------- /tests/description.yaml: -------------------------------------------------------------------------------- 1 | product: 2 | description: 3 | product_title: "temp-listing" 4 | logourl: "https://awsmp-logos.s3.amazonaws.com/8350ae04bad5625623cc02c64eb8b0b5" 5 | video_urls: [] 6 | short_description: | 7 | Run Amazon Elastic Kubernetes Service (EKS) clusters on Ubuntu and get the advantage of a managed Kubernetes cluster and the Ubuntu ecosystem. 8 | long_description: | 9 | Canonical and AWS work together to make Ubuntu worker nodes available for Amazon's Elastic Kubernetes Services (EKS). This fully-managed service makes it easy to use Kubernetes on AWS, without being an expert in managing Kubernetes clusters. 10 | 11 | These images are based on the official Ubuntu Minimal LTS and include the custom Ubuntu-aws optimized kernel. They have been built specifically for the EKS service, therefore are not intended as general OS images. 12 | 13 | You can also upgrade to Ubuntu Pro and activate additional security features such as FIPS mode, Expanded Security Maintenance (ESM), CIS or Disa STIG hardening and Kernel Livepatch. 14 | 15 | To deploy Ubuntu worker nodes on EKS, you can use eksctl, EC2 Launch templates when creating the Node Groups on EKS console, Cloudformation templates and other mechanisms. 16 | 17 | You can upgrade to Ubuntu Pro if you need to run your worker nodes on FIPS mode, with Kernel Livepach or with ESM content. © Visit https://aws.amazon.com/marketplace/pp/prodview-negkygylqkp44?sr=0-1&ref_=beagle&applicationId=AWSMPContessa to learn more. 18 | highlights: 19 | - AWS managed Kubernetes on Ubuntu AWS-optimized worker nodes 20 | - Based on minimal Ubuntu for best performance and security 21 | - Upgrade to Pro to get access to ESM, FIPS, kernel livepatch and hardening tools 22 | search_keywords: 23 | - EKS 24 | - Kubernetes 25 | - Ubuntu 26 | categories: 27 | - Operating Systems 28 | - Application Servers 29 | support_description: Ubuntu Advantage covers two support level SLAs for 24-hour support for business days or 24x7 support coverage. Learn more at. 30 | support_resources: ~ 31 | additional_resources: [] 32 | sku: ~ 33 | -------------------------------------------------------------------------------- /listing_configuration.yaml: -------------------------------------------------------------------------------- 1 | # This is sample config file 2 | # 3 | # Field Types 4 | # 5 | # product_title: str 6 | # logourl: str 7 | # video_urls: Optional[List[str]], can only have 1 url 8 | # short_description: str 9 | # long_description: str 10 | # highlights: List[str] 11 | # search_keywords: List[str] 12 | # categories: List[str] 13 | # support_description: str 14 | # support_resources: Optional[str]0 15 | # additional_resources: Optional[List[Dict[str, str]]] 16 | # sku: Optional[str] 17 | # 18 | # For empty value, please use '~' for str type and '[]' for List type 19 | # 20 | description: 21 | product_title: "temp-listing" 22 | logourl: "https://awsmp-logos.s3.amazonaws.com/8350ae04bad5625623cc02c64eb8b0b5" 23 | video_urls: [] 24 | short_description: | 25 | A short, single sentence description of the offering. 26 | long_description: | 27 | A longer description of the offering. 28 | 29 | This can be multiple paragraphs and should fully describe the product, as well as links to external documentation where relevant. 30 | highlights: 31 | - A bullet pointed list of highlights 32 | - Why does the user want this product 33 | search_keywords: 34 | - Key 35 | - Terms 36 | - Searchability 37 | categories: # https://docs.aws.amazon.com/marketplace/latest/buyerguide/buyer-product-categories.html 38 | - Operating Systems 39 | - Application Servers 40 | support_description: Description of the support offered or available for this product 41 | support_resources: ~ 42 | additional_resources: [] 43 | sku: ~ 44 | region: 45 | commercial_regions: 46 | - us-east-1 47 | - us-east-2 48 | future_region_support: true 49 | version: 50 | version_title: "this is new title" 51 | release_notes: | 52 | This is sample release note 53 | ami_id: ami-testing 54 | access_role_arn: arn:aws:something 55 | os_user_name: ubuntu 56 | os_system_version: "20.04" 57 | os_system_name: UBUNTU 58 | scanning_port: 22 59 | usage_instructions: | 60 | This is sample instructions 61 | recommended_instance_type: t2.micro 62 | ip_protocol: tcp 63 | ip_ranges: 64 | - "0.0.0.0/0" 65 | from_port: 22 66 | to_port: 22 67 | eula_url: https://testing-eula 68 | refund_policy: | 69 | Your refund policy. 70 | -------------------------------------------------------------------------------- /docs/init.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Generate a unique directory name based on timestamp 4 | timestamp=$(date +%Y%m%d%H%M%S) 5 | temp_directory="temp-starter-pack-$timestamp" 6 | 7 | # Ask the user for the installation directory 8 | read -p "Enter the installation directory (e.g., '.' or 'docs'): " install_directory 9 | 10 | # Clone the starter pack repository to the temporary directory 11 | echo "Cloning the starter pack repository..." 12 | git clone --depth 1 https://github.com/canonical/sphinx-docs-starter-pack "$temp_directory" 13 | rm -rf "$temp_directory/.git" 14 | 15 | # Update file contents for the install directory 16 | echo "Updating working directory in workflow files..." 17 | sed -i "s|working-directory:\s*'\.'|working-directory: '$install_directory'|g" "$temp_directory/.github/workflows"/* 18 | echo "Updating .readthedocs.yaml configuration..." 19 | sed -i "s|-\s\s*python3\s\s*.sphinx/build_requirements\.py|- cd '$install_directory' \&\& python3 .sphinx/build_requirements.py|g" "$temp_directory/.readthedocs.yaml" 20 | sed -i "s|configuration:\s*conf\.py|configuration: $install_directory/conf.py|g" "$temp_directory/.readthedocs.yaml" 21 | sed -i "s|requirements:\s*\.sphinx/requirements\.txt|requirements: $install_directory/.sphinx/requirements.txt|g" "$temp_directory/.readthedocs.yaml" 22 | 23 | # Create the specified installation directory if it doesn't exist 24 | if [ ! -d "$install_directory" ]; then 25 | echo "Creating the installation directory: $install_directory" 26 | mkdir -p "$install_directory" 27 | fi 28 | 29 | # Copy the contents of the starter pack repository to the installation directory 30 | echo "Copying contents to the installation directory..." 31 | cp -R "$temp_directory"/* "$temp_directory"/.??* "$install_directory" 32 | 33 | # Move workflow files and configuration 34 | if [ "$install_directory" != "." ]; then 35 | echo "Moving workflow files and configuration..." 36 | if [ ! -d .github/workflows ]; then 37 | mkdir -p .github/workflows 38 | fi 39 | mv "$install_directory/.github/workflows"/* .github/workflows 40 | if [ ! -f .wokeignore ]; then 41 | ln -s "$install_directory/.wokeignore" 42 | else 43 | echo "ACTION REQUIRED: Found a .wokeignore file in the root directory. Include the contents from $install_directory/.wokeignore in this file!" 44 | fi 45 | fi 46 | 47 | # Clean up 48 | echo "Cleaning up..." 49 | rm -rf "$temp_directory" 50 | 51 | echo "Setup completed!" 52 | -------------------------------------------------------------------------------- /awsmp/errors.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | 4 | class MissingInstanceTypeError(Exception): 5 | def __init__(self, instance_types: List[str]): 6 | formatted_types = "\n".join(instance_types) 7 | message = f"The following instance types are missing from your pricing csv:\n{formatted_types}" 8 | super().__init__(message) 9 | 10 | 11 | class AWSException(Exception): 12 | pass 13 | 14 | 15 | class AccessDeniedException(AWSException): 16 | def __init__(self, service_name: str): 17 | message = f""" 18 | 19 | 20 | This account does not have permission to request {service_name} services. 21 | Check your IAM permission or seller registration if you use marketplace service. 22 | """ 23 | super().__init__(message) 24 | 25 | 26 | class ResourceNotFoundException(AWSException): 27 | def __init__(self, *args): 28 | if args: 29 | super().__init__(*args) 30 | else: 31 | message = """ 32 | 33 | 34 | Product/Offer ID does not exist. Please check your those information and try again. 35 | Product/Offer ID can be found in Home > Requests > Create new AMI Product from marketplace management portal https://aws.amazon.com/marketplace/management/requests/. 36 | """ 37 | super().__init__(message) 38 | 39 | 40 | class UnrecognizedClientException(AWSException): 41 | def __init__(self): 42 | message = """ 43 | 44 | 45 | This profile is not configured correctly. 46 | Please check your credential with associated profile. 47 | """ 48 | super().__init__(message) 49 | 50 | 51 | class ValidationException(AWSException): 52 | def __init__(self, error_msg): 53 | message = f""" 54 | 55 | 56 | {error_msg} 57 | Please check schema regex and request with fixed value. 58 | """ 59 | super().__init__(message) 60 | 61 | 62 | class YamlMissingKeyException(Exception): 63 | def __init__(self, missing_keys: List[List[str]]): 64 | formatted_keys = "\n".join("->".join(keys) for keys in missing_keys) 65 | message = f""" 66 | 67 | 68 | Config file does not have the following missing keys: 69 | {formatted_keys}. 70 | 71 | Please check the file and request again. 72 | """ 73 | super().__init__(message) 74 | 75 | 76 | class NoProductIdProvidedException(Exception): 77 | def __init__(self): 78 | message = f""" 79 | 80 | 81 | No product ids are provided. Please pass at least one product id for using this function. 82 | """ 83 | super().__init__(message) 84 | 85 | 86 | class AmiPriceChangeError(Exception): 87 | pass 88 | 89 | 90 | class AmiPricingModelChangeError(Exception): 91 | pass 92 | 93 | 94 | class NoVersionException(Exception): 95 | pass 96 | -------------------------------------------------------------------------------- /docs/private-offer/index.rst: -------------------------------------------------------------------------------- 1 | How to create a private offer 2 | ============================= 3 | 4 | To create a private offer in the `AWS marketplace management portal`_, use the API calls described below. 5 | 6 | 7 | List and show available offers 8 | ------------------------------ 9 | 10 | Available offers can be listed: 11 | 12 | .. code-block:: 13 | 14 | $ awsmp entity-list Offer 15 | +---------------------------+------+------------+----------------------+ 16 | | entity-id | name | visibility | last-changed | 17 | +---------------------------+------+------------+----------------------+ 18 | | a8t4vhju1o9ibx6hfi9bnuo2x | '' | Public | 2021-09-03T08:14:37Z | 19 | +---------------------------+------+------------+----------------------+ 20 | 21 | Details about an available offer can be seen using the ``enitity-id``: 22 | 23 | .. code-block:: 24 | 25 | $ awsmp entity-show a8t4vhju1o9ibx6hfi9bnuo2x 26 | {'AgreementToken': 'sample-agreement-token', 27 | 'Description': 'Worldwide offer for JUST FOR TESTING', 28 | 'Id': 'a8t4vhju1o9ibx6hfi9bnuo2x', 29 | 'MarkupPercentage': None, 30 | 'Name': None, 31 | ... output stripped here ... 32 | 33 | 34 | Create a new private offer 35 | -------------------------- 36 | 37 | A new private offer can be created with: 38 | 39 | .. code-block:: sh 40 | 41 | $ awsmp private-offer create \ 42 | --product-id 3a628887-30de-4d23-a949-93b32e4e4c5f \ 43 | --buyer-accounts 887450378614 \ 44 | --offer-name "toabctl testing" \ 45 | --pricing prices.csv 46 | 47 | ChangeSet created (ID: 1mlxbdpmabfauymeeo12hg599) 48 | https://aws.amazon.com/marketplace/management/requests/1mlxbdpmabfauymeeo12hg599 49 | 50 | 51 | This creates a new request in the AWS Marketplace web UI. 52 | That new request needs to be in the ``Succeeded`` state before a buyer can see the offer. 53 | 54 | For this command to work, you'll need a ``prices.csv`` file that contains all the 55 | instance types (dimensions) and prices available in the product. 56 | 57 | Generate a ``prices.csv`` file 58 | ------------------------------ 59 | 60 | The ``awsmp private-offer create`` command requires a ``prices.csv`` file to be available. 61 | That file contains 3 columns where the first column is the instance type, the 62 | second column is the hourly price (in USD) and the third column is the annual price. 63 | 64 | You can use a file from an existing offer and adjust it to generate 65 | a new offer. To generate the file from an existing offer, run: 66 | 67 | .. code-block:: sh 68 | 69 | awsmp pricing-template \ 70 | --offer-id offer-rsf4l7ilje2ze \ 71 | --pricing prices.csv 72 | 73 | This creates a ``prices.csv`` file from the offer with the entity Id ``offer-rsf4l7ilje2ze``. 74 | 75 | .. _`AWS marketplace management portal`: https://aws.amazon.com/marketplace/partners/management-tour?ref_=header_modules_sell_in_aws -------------------------------------------------------------------------------- /tests/test_config.json: -------------------------------------------------------------------------------- 1 | {"Description": {"ProductTitle": "test","ProductCode": "prod-test", 2 | "ShortDescription": "test_short_description", 3 | "Manufacturer": "", 4 | "LongDescription": "test_long_description", 5 | "Sku": "test", 6 | "Highlights": ["test_highlight_1"], 7 | "AssociatedProducts": "", 8 | "SearchKeywords": ["test_keyword_1"], 9 | "Visibility": "Public", 10 | "ProductState": "Active", 11 | "Categories": ["Migration"] 12 | }, 13 | "SupportInformation": {"Description": "test_support_description", "Resources": ["test_support_resource"]}, 14 | "RegionAvailability": {"FutureRegionSupport": "All", "Restrict": [], "Regions": ["us-east-1", "us-east-2"]}, 15 | "PromotionalResources": { 16 | "LogoUrl": "https://test-logourl", 17 | "Videos": [], 18 | "AdditionalResources": [{"Type": "Link", "Text": "test-link", "Url": "https://test-url"}], 19 | "PromotionalMedia": []}, 20 | "Targeting": {"PositiveTargeting": {"BuyerAccounts": ["099720109477", "297512042063", "782858285006"]}}, 21 | "Dimensions": [{"Name": "a1.large", "Description": "a1.large", "Key": "a1.large", "Unit": "Hrs", "Types": ["Metered"]}], 22 | "Compatibility": {"AvailableInstanceTypes": ["a1.large"], "RestrictedInstanceTypes": []}, 23 | "Terms": [{"Type": "SupportTerm", "RefundPolicy": "test_refund_policy_term\n"}, { 24 | "Type": "UsageBasedPricingTerm", "CurrencyCode": "USD", 25 | "RateCards": [{"RateCard": [{"DimensionKey": "a1.large","Price": "0.004"}, 26 | {"DimensionKey": "a1.xlarge","Price": "0.007"}]}]}, 27 | {"Type": "ConfigurableUpfrontPricingTerm", "CurrencyCode": "USD", "RateCards": [{ 28 | "Selector": {"Type": "Duration","Value": "P365D"}, 29 | "Constraints": {"MultipleDimensionSelection": "Allowed","QuantityConfiguration": "Allowed"}, 30 | "RateCard": [{"DimensionKey": "a1.large","Price": "24.528"}, 31 | {"DimensionKey": "a1.xlarge","Price": "49.056"}] 32 | }]}], 33 | "Versions": {"ReleaseNotes": "test release notes", 34 | "VersionTitle": "Test Ubuntu AMI", 35 | "Sources": [{"Type": "AmazonMachineImage", "Image": "ami-12345678910", "Architecture": "x86_64", "VirtualizationType": "hvm", "OperatingSystem": {"Name": "UBUNTU", "Version": "22.04 - Jammy", "Username": "ubuntu", "ScanningPort": 22}, "Compatibility": {"AvailableInstanceTypes": ["t2.nano", "t3.medium"], "RestrictedInstanceTypes": []}}], "DeliveryMethods": [{"Type": "AmazonMachineImage", "ShortDescription": "No description provided for this delivery method", "Instructions": {"Usage": "test_usage_instruction\n"}, "Recommendations": {"SecurityGroups": [{"Protocol": "tcp", "FromPort": 22, 36 | "ToPort": 22, "CidrIps": ["0.0.0.0/0"]}], "InstanceType": "t3.medium"}, "Visibility": "Public", "Title": "(x86_64) Amazon Machine Image"}], "DeliveryOptions": [{"Instructions": {"Usage": "test usage instruction\n"}, "Recommendations": {"SecurityGroups": [{"Protocol": "tcp", "FromPort": 22, "ToPort": 22, "CidrIps": ["0.0.0.0/0"]}], "InstanceType": "t3.medium"}, "Visibility": "Public", "Title": "(x86_64) Amazon Machine Image"}]} 37 | } 38 | 39 | -------------------------------------------------------------------------------- /docs/.sphinx/_static/404.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /docs/.sphinx/_templates/footer.html: -------------------------------------------------------------------------------- 1 | {# ru-fu: copied from Furo, with modifications as stated below. Modifications are marked 'mod:'. #} 2 | 3 | 40 |
41 |
42 | {%- if show_copyright %} 43 | 54 | {%- endif %} 55 | 56 | {# mod: removed "Made with" #} 57 | 58 | {%- if last_updated -%} 59 |
60 | {% trans last_updated=last_updated|e -%} 61 | Last updated on {{ last_updated }} 62 | {%- endtrans -%} 63 |
64 | {%- endif %} 65 | 66 | {%- if show_source and has_source and sourcename %} 67 |
68 | Show source 70 |
71 | {%- endif %} 72 |
73 |
74 | 75 | {# mod: replaced RTD icons with our links #} 76 | 77 | {% if discourse %} 78 | 81 | {% endif %} 82 | 83 | {% if mattermost %} 84 | 87 | {% endif %} 88 | 89 | {% if matrix %} 90 | 93 | {% endif %} 94 | 95 | {% if github_url and github_version and github_folder %} 96 | 97 | {% if github_issues %} 98 | 101 | {% endif %} 102 | 103 | 106 | {% endif %} 107 | 108 | 109 |
110 |
111 | 112 | -------------------------------------------------------------------------------- /docs/.sphinx/build_requirements.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | sys.path.append('./') 4 | from custom_conf import * 5 | 6 | # The file contains helper functions and the mechanism to build the 7 | # .sphinx/requirements.txt file that is needed to set up the virtual 8 | # environment. 9 | 10 | # You should not do any modifications to this file. Put your custom 11 | # requirements into the custom_required_modules array in the custom_conf.py 12 | # file. If you need to change this file, contribute the changes upstream. 13 | 14 | legacyCanonicalSphinxExtensionNames = [ 15 | "youtube-links", 16 | "related-links", 17 | "custom-rst-roles", 18 | "terminal-output" 19 | ] 20 | 21 | def IsAnyCanonicalSphinxExtensionUsed(): 22 | for extension in custom_extensions: 23 | if (extension.startswith("canonical.") or 24 | extension in legacyCanonicalSphinxExtensionNames): 25 | return True 26 | 27 | return False 28 | 29 | def IsNotFoundExtensionUsed(): 30 | return "notfound.extension" in custom_extensions 31 | 32 | def IsSphinxTabsUsed(): 33 | for extension in custom_extensions: 34 | if extension.startswith("sphinx_tabs."): 35 | return True 36 | 37 | return False 38 | 39 | def AreRedirectsDefined(): 40 | return ("sphinx_reredirects" in custom_extensions) or ( 41 | ("redirects" in globals()) and \ 42 | (redirects is not None) and \ 43 | (len(redirects) > 0)) 44 | 45 | def IsOpenGraphConfigured(): 46 | if "sphinxext.opengraph" in custom_extensions: 47 | return True 48 | 49 | for global_variable_name in list(globals()): 50 | if global_variable_name.startswith("ogp_"): 51 | return True 52 | 53 | return False 54 | 55 | def IsMyStParserUsed(): 56 | return ("myst_parser" in custom_extensions) or \ 57 | ("custom_myst_extensions" in globals()) 58 | 59 | def DeduplicateExtensions(extensionNames: [str]): 60 | extensionNames = dict.fromkeys(extensionNames) 61 | resultList = [] 62 | encounteredCanonicalExtensions = [] 63 | 64 | for extensionName in extensionNames: 65 | if extensionName in legacyCanonicalSphinxExtensionNames: 66 | extensionName = "canonical." + extensionName 67 | 68 | if extensionName.startswith("canonical."): 69 | if extensionName not in encounteredCanonicalExtensions: 70 | encounteredCanonicalExtensions.append(extensionName) 71 | resultList.append(extensionName) 72 | else: 73 | resultList.append(extensionName) 74 | 75 | return resultList 76 | 77 | if __name__ == "__main__": 78 | requirements = [ 79 | "furo", 80 | "pyspelling", 81 | "sphinx", 82 | "sphinx-autobuild", 83 | "sphinx-copybutton", 84 | "sphinx-design", 85 | "sphinxcontrib-jquery", 86 | "watchfiles", 87 | ] 88 | 89 | requirements.extend(custom_required_modules) 90 | 91 | if IsAnyCanonicalSphinxExtensionUsed(): 92 | requirements.append("canonical-sphinx-extensions") 93 | 94 | if IsNotFoundExtensionUsed(): 95 | requirements.append("sphinx-notfound-page") 96 | 97 | if IsSphinxTabsUsed(): 98 | requirements.append("sphinx-tabs") 99 | 100 | if AreRedirectsDefined(): 101 | requirements.append("sphinx-reredirects") 102 | 103 | if IsOpenGraphConfigured(): 104 | requirements.append("sphinxext-opengraph") 105 | 106 | if IsMyStParserUsed(): 107 | requirements.append("myst-parser") 108 | requirements.append("linkify-it-py") 109 | 110 | # removes duplicate entries 111 | requirements = list(dict.fromkeys(requirements)) 112 | requirements.sort() 113 | 114 | with open(".sphinx/requirements.txt", 'w') as requirements_file: 115 | requirements_file.write( 116 | "# DO NOT MODIFY THIS FILE DIRECTLY!\n" 117 | "#\n" 118 | "# This file is generated automatically.\n" 119 | "# Add custom requirements to the custom_required_modules\n" 120 | "# array in the custom_conf.py file and run:\n" 121 | "# make clean && make install\n") 122 | 123 | for requirement in requirements: 124 | requirements_file.write(requirement) 125 | requirements_file.write('\n') 126 | -------------------------------------------------------------------------------- /docs/.sphinx/_static/header.css: -------------------------------------------------------------------------------- 1 | .p-navigation { 2 | border-bottom: 1px solid var(--color-sidebar-background-border); 3 | } 4 | 5 | .p-navigation__nav { 6 | background: #333333; 7 | display: flex; 8 | } 9 | 10 | .p-logo { 11 | display: flex !important; 12 | padding-top: 0 !important; 13 | text-decoration: none; 14 | } 15 | 16 | .p-logo-image { 17 | height: 44px; 18 | padding-right: 10px; 19 | } 20 | 21 | .p-logo-text { 22 | margin-top: 18px; 23 | color: white; 24 | text-decoration: none; 25 | } 26 | 27 | ul.p-navigation__links { 28 | display: flex; 29 | list-style: none; 30 | margin-left: 0; 31 | margin-top: auto; 32 | margin-bottom: auto; 33 | max-width: 800px; 34 | width: 100%; 35 | } 36 | 37 | ul.p-navigation__links li { 38 | margin: 0 auto; 39 | text-align: center; 40 | width: 100%; 41 | } 42 | 43 | ul.p-navigation__links li a { 44 | background-color: rgba(0, 0, 0, 0); 45 | border: none; 46 | border-radius: 0; 47 | color: var(--color-sidebar-link-text); 48 | display: block; 49 | font-weight: 400; 50 | line-height: 1.5rem; 51 | margin: 0; 52 | overflow: hidden; 53 | padding: 1rem 0; 54 | position: relative; 55 | text-align: left; 56 | text-overflow: ellipsis; 57 | transition-duration: .1s; 58 | transition-property: background-color, color, opacity; 59 | transition-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1); 60 | white-space: nowrap; 61 | width: 100%; 62 | } 63 | 64 | ul.p-navigation__links .p-navigation__link { 65 | color: #ffffff; 66 | font-weight: 300; 67 | text-align: center; 68 | text-decoration: none; 69 | } 70 | 71 | ul.p-navigation__links .p-navigation__link:hover { 72 | background-color: #2b2b2b; 73 | } 74 | 75 | ul.p-navigation__links .p-dropdown__link:hover { 76 | background-color: var(--color-sidebar-item-background--hover); 77 | } 78 | 79 | ul.p-navigation__links .p-navigation__sub-link { 80 | background: var(--color-background-primary); 81 | padding: .5rem 0 .5rem .5rem; 82 | font-weight: 300; 83 | } 84 | 85 | ul.p-navigation__links .more-links-dropdown li a { 86 | border-left: 1px solid var(--color-sidebar-background-border); 87 | border-right: 1px solid var(--color-sidebar-background-border); 88 | } 89 | 90 | ul.p-navigation__links .more-links-dropdown li:first-child a { 91 | border-top: 1px solid var(--color-sidebar-background-border); 92 | } 93 | 94 | ul.p-navigation__links .more-links-dropdown li:last-child a { 95 | border-bottom: 1px solid var(--color-sidebar-background-border); 96 | } 97 | 98 | ul.p-navigation__links .p-navigation__logo { 99 | padding: 0.5rem; 100 | } 101 | 102 | ul.p-navigation__links .p-navigation__logo img { 103 | width: 40px; 104 | } 105 | 106 | ul.more-links-dropdown { 107 | display: none; 108 | overflow-x: visible; 109 | height: 0; 110 | z-index: 55; 111 | padding: 0; 112 | position: relative; 113 | list-style: none; 114 | margin-bottom: 0; 115 | margin-top: 0; 116 | } 117 | 118 | .nav-more-links::after { 119 | background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16'%3E%3Cpath fill='%23111' d='M8.187 11.748l6.187-6.187-1.06-1.061-5.127 5.127L3.061 4.5 2 5.561z'/%3E%3C/svg%3E"); 120 | background-position: center; 121 | background-repeat: no-repeat; 122 | background-size: contain; 123 | content: ""; 124 | display: block; 125 | filter: invert(100%); 126 | height: 1rem; 127 | pointer-events: none; 128 | position: absolute; 129 | right: 1rem; 130 | text-indent: calc(100% + 10rem); 131 | top: calc(1rem + 0.25rem); 132 | width: 1rem; 133 | } 134 | 135 | .nav-ubuntu-com { 136 | display: none; 137 | } 138 | 139 | @media only screen and (min-width: 480px) { 140 | ul.p-navigation__links li { 141 | width: 100%; 142 | } 143 | 144 | .nav-ubuntu-com { 145 | display: inherit; 146 | } 147 | } 148 | 149 | @media only screen and (max-width: 800px) { 150 | .nav-more-links { 151 | margin-left: auto !important; 152 | padding-right: 2rem !important; 153 | width: 8rem !important; 154 | } 155 | } 156 | 157 | @media only screen and (min-width: 800px) { 158 | ul.p-navigation__links li { 159 | width: 100% !important; 160 | } 161 | } 162 | 163 | @media only screen and (min-width: 1310px) { 164 | ul.p-navigation__links { 165 | margin-left: calc(50% - 41em); 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXDIR = .sphinx 7 | SPHINXOPTS ?= -c . -d $(SPHINXDIR)/.doctrees -j auto 8 | SPHINXBUILD ?= sphinx-build 9 | SOURCEDIR = . 10 | BUILDDIR = _build 11 | VENVDIR = $(SPHINXDIR)/venv 12 | PA11Y = $(SPHINXDIR)/node_modules/pa11y/bin/pa11y.js --config $(SPHINXDIR)/pa11y.json 13 | VENV = $(VENVDIR)/bin/activate 14 | 15 | .PHONY: help full-help woke-install pa11y-install install run html epub serve \ 16 | clean clean-doc spelling linkcheck woke pa11y Makefile 17 | 18 | # Put it first so that "make" without argument is like "make help". 19 | help: 20 | @echo "\n" \ 21 | "--------------------------------------------------------------- \n" \ 22 | "* watch, build and serve the documentation: make run \n" \ 23 | "* only build: make html \n" \ 24 | "* only serve: make serve \n" \ 25 | "* clean built doc files: make clean-doc \n" \ 26 | "* clean full environment: make clean \n" \ 27 | "* check links: make linkcheck \n" \ 28 | "* check spelling: make spelling \n" \ 29 | "* check inclusive language: make woke \n" \ 30 | "* check accessibility: make pa11y \n" \ 31 | "* other possible targets: make \n" \ 32 | "--------------------------------------------------------------- \n" 33 | 34 | full-help: $(VENVDIR) 35 | @. $(VENV); $(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 36 | @echo "\n\033[1;31mNOTE: This help texts shows unsupported targets!\033[0m" 37 | @echo "Run 'make help' to see supported targets." 38 | 39 | # Shouldn't assume that venv is available on Ubuntu by default; discussion here: 40 | # https://bugs.launchpad.net/ubuntu/+source/python3.4/+bug/1290847 41 | $(SPHINXDIR)/requirements.txt: 42 | python3 $(SPHINXDIR)/build_requirements.py 43 | python3 -c "import venv" || sudo apt install python3-venv 44 | 45 | # If requirements are updated, venv should be rebuilt and timestamped. 46 | $(VENVDIR): $(SPHINXDIR)/requirements.txt 47 | @echo "... setting up virtualenv" 48 | python3 -m venv $(VENVDIR) 49 | . $(VENV); pip install --require-virtualenv \ 50 | --upgrade -r $(SPHINXDIR)/requirements.txt \ 51 | --log $(VENVDIR)/pip_install.log 52 | @test ! -f $(VENVDIR)/pip_list.txt || \ 53 | mv $(VENVDIR)/pip_list.txt $(VENVDIR)/pip_list.txt.bak 54 | @. $(VENV); pip list --local --format=freeze > $(VENVDIR)/pip_list.txt 55 | @touch $(VENVDIR) 56 | 57 | woke-install: 58 | @type woke >/dev/null 2>&1 || \ 59 | { echo "Installing \"woke\" snap... \n"; sudo snap install woke; } 60 | 61 | pa11y-install: 62 | @type $(PA11Y) >/dev/null 2>&1 || { \ 63 | echo "Installing \"pa11y\" from npm... \n"; \ 64 | mkdir -p $(SPHINXDIR)/node_modules/ ; \ 65 | npm install --prefix $(SPHINXDIR) pa11y; \ 66 | } 67 | 68 | install: $(VENVDIR) 69 | 70 | run: install 71 | . $(VENV); sphinx-autobuild -b dirhtml "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) 72 | 73 | # Doesn't depend on $(BUILDDIR) to rebuild properly at every run. 74 | html: install 75 | . $(VENV); $(SPHINXBUILD) -b dirhtml "$(SOURCEDIR)" "$(BUILDDIR)" -w $(SPHINXDIR)/warnings.txt $(SPHINXOPTS) 76 | 77 | epub: install 78 | . $(VENV); $(SPHINXBUILD) -b epub "$(SOURCEDIR)" "$(BUILDDIR)" -w $(SPHINXDIR)/warnings.txt $(SPHINXOPTS) 79 | 80 | serve: html 81 | cd "$(BUILDDIR)"; python3 -m http.server 8000 82 | 83 | clean: clean-doc 84 | @test ! -e "$(VENVDIR)" -o -d "$(VENVDIR)" -a "$(abspath $(VENVDIR))" != "$(VENVDIR)" 85 | rm -rf $(VENVDIR) 86 | rm -f $(SPHINXDIR)/requirements.txt 87 | rm -rf $(SPHINXDIR)/node_modules/ 88 | 89 | clean-doc: 90 | git clean -fx "$(BUILDDIR)" 91 | rm -rf $(SPHINXDIR)/.doctrees 92 | 93 | spelling: html 94 | . $(VENV) ; python3 -m pyspelling -c $(SPHINXDIR)/spellingcheck.yaml -j $(shell nproc) 95 | 96 | linkcheck: install 97 | . $(VENV) ; $(SPHINXBUILD) -b linkcheck "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) 98 | 99 | woke: woke-install 100 | woke *.rst **/*.rst --exit-1-on-failure \ 101 | -c https://github.com/canonical/Inclusive-naming/raw/main/config.yml 102 | 103 | pa11y: pa11y-install html 104 | find $(BUILDDIR) -name *.html -print0 | xargs -n 1 -0 $(PA11Y) 105 | 106 | # Catch-all target: route all unknown targets to Sphinx using the new 107 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 108 | %: Makefile 109 | . $(VENV); $(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 110 | -------------------------------------------------------------------------------- /docs/.sphinx/_static/furo_colors.css: -------------------------------------------------------------------------------- 1 | body { 2 | --color-code-background: #f8f8f8; 3 | --color-code-foreground: black; 4 | --code-font-size: 1rem; 5 | --font-stack: Ubuntu variable, Ubuntu, -apple-system, Segoe UI, Roboto, Oxygen, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; 6 | --font-stack--monospace: Ubuntu Mono variable, Ubuntu Mono, Consolas, Monaco, Courier, monospace; 7 | --color-foreground-primary: #111; 8 | --color-foreground-secondary: var(--color-foreground-primary); 9 | --color-foreground-muted: #333; 10 | --color-background-secondary: #FFF; 11 | --color-background-hover: #f2f2f2; 12 | --color-brand-primary: #111; 13 | --color-brand-content: #06C; 14 | --color-api-background: #cdcdcd; 15 | --color-inline-code-background: rgba(0,0,0,.03); 16 | --color-sidebar-link-text: #111; 17 | --color-sidebar-item-background--current: #ebebeb; 18 | --color-sidebar-item-background--hover: #f2f2f2; 19 | --toc-font-size: var(--font-size--small); 20 | --color-admonition-title-background--note: var(--color-background-primary); 21 | --color-admonition-title-background--tip: var(--color-background-primary); 22 | --color-admonition-title-background--important: var(--color-background-primary); 23 | --color-admonition-title-background--caution: var(--color-background-primary); 24 | --color-admonition-title--note: #24598F; 25 | --color-admonition-title--tip: #24598F; 26 | --color-admonition-title--important: #C7162B; 27 | --color-admonition-title--caution: #F99B11; 28 | --color-highlighted-background: #EBEBEB; 29 | --color-link-underline: var(--color-background-primary); 30 | --color-link-underline--hover: var(--color-background-primary); 31 | --color-version-popup: #772953; 32 | } 33 | 34 | @media not print { 35 | body[data-theme="dark"] { 36 | --color-code-background: #202020; 37 | --color-code-foreground: #d0d0d0; 38 | --color-foreground-secondary: var(--color-foreground-primary); 39 | --color-foreground-muted: #CDCDCD; 40 | --color-background-secondary: var(--color-background-primary); 41 | --color-background-hover: #666; 42 | --color-brand-primary: #fff; 43 | --color-brand-content: #06C; 44 | --color-sidebar-link-text: #f7f7f7; 45 | --color-sidebar-item-background--current: #666; 46 | --color-sidebar-item-background--hover: #333; 47 | --color-admonition-background: transparent; 48 | --color-admonition-title-background--note: var(--color-background-primary); 49 | --color-admonition-title-background--tip: var(--color-background-primary); 50 | --color-admonition-title-background--important: var(--color-background-primary); 51 | --color-admonition-title-background--caution: var(--color-background-primary); 52 | --color-admonition-title--note: #24598F; 53 | --color-admonition-title--tip: #24598F; 54 | --color-admonition-title--important: #C7162B; 55 | --color-admonition-title--caution: #F99B11; 56 | --color-highlighted-background: #666; 57 | --color-link-underline: var(--color-background-primary); 58 | --color-link-underline--hover: var(--color-background-primary); 59 | --color-version-popup: #F29879; 60 | } 61 | @media (prefers-color-scheme: dark) { 62 | body:not([data-theme="light"]) { 63 | --color-code-background: #202020; 64 | --color-code-foreground: #d0d0d0; 65 | --color-foreground-secondary: var(--color-foreground-primary); 66 | --color-foreground-muted: #CDCDCD; 67 | --color-background-secondary: var(--color-background-primary); 68 | --color-background-hover: #666; 69 | --color-brand-primary: #fff; 70 | --color-brand-content: #06C; 71 | --color-sidebar-link-text: #f7f7f7; 72 | --color-sidebar-item-background--current: #666; 73 | --color-sidebar-item-background--hover: #333; 74 | --color-admonition-background: transparent; 75 | --color-admonition-title-background--note: var(--color-background-primary); 76 | --color-admonition-title-background--tip: var(--color-background-primary); 77 | --color-admonition-title-background--important: var(--color-background-primary); 78 | --color-admonition-title-background--caution: var(--color-background-primary); 79 | --color-admonition-title--note: #24598F; 80 | --color-admonition-title--tip: #24598F; 81 | --color-admonition-title--important: #C7162B; 82 | --color-admonition-title--caution: #F99B11; 83 | --color-highlighted-background: #666; 84 | --color-link-underline: var(--color-background-primary); 85 | --color-link-underline--hover: var(--color-background-primary); 86 | --color-version-popup: #F29879; 87 | } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /docs/doc-cheat-sheet-myst.md: -------------------------------------------------------------------------------- 1 | --- 2 | orphan: true 3 | myst: 4 | substitutions: 5 | reuse_key: "This is **included** text." 6 | advanced_reuse_key: "This is a substitution that includes a code block: 7 | ``` 8 | code block 9 | ```" 10 | --- 11 | 12 | (cheat-sheet-myst)= 13 | # Markdown/MyST cheat sheet 14 | 15 | This file contains the syntax for commonly used Markdown and MyST markup. 16 | Open it in your text editor to quickly copy and paste the markup you need. 17 | 18 | Also see the [MyST documentation](https://myst-parser.readthedocs.io/en/latest/index.html) for detailed information, and the [Canonical Documentation Style Guide](https://docs.ubuntu.com/styleguide/en) for general style conventions. 19 | 20 | ## H2 heading 21 | 22 | ### H3 heading 23 | 24 | #### H4 heading 25 | 26 | ##### H5 heading 27 | 28 | ## Inline formatting 29 | 30 | - {guilabel}`UI element` 31 | - `code` 32 | - {command}`command` 33 | - {kbd}`Key` 34 | - *Italic* 35 | - **Bold** 36 | 37 | ## Code blocks 38 | 39 | Start a code block: 40 | 41 | code: 42 | - example: true 43 | 44 | ``` 45 | # Demonstrate a code block 46 | code: 47 | - example: true 48 | ``` 49 | 50 | ```yaml 51 | # Demonstrate a code block 52 | code: 53 | - example: true 54 | ``` 55 | 56 | (a_section_target_myst)= 57 | ## Links 58 | 59 | - [Canonical website](https://canonical.com/) 60 | - https://canonical.com/ 61 | - {ref}`a_section_target_myst` 62 | - {ref}`Link text ` 63 | - {doc}`index` 64 | - {doc}`Link text ` 65 | 66 | 67 | ## Navigation 68 | 69 | Use the following syntax:: 70 | 71 | ```{toctree} 72 | :hidden: 73 | 74 | sub-page1 75 | sub-page2 76 | ``` 77 | 78 | ## Lists 79 | 80 | 1. Step 1 81 | - Item 1 82 | * Sub-item 83 | - Item 2 84 | 1. Sub-step 1 85 | 1. Sub-step 2 86 | 1. Step 2 87 | 1. Sub-step 1 88 | - Item 89 | 1. Sub-step 2 90 | 91 | Term 1 92 | : Definition 93 | 94 | Term 2 95 | : Definition 96 | 97 | ## Tables 98 | 99 | ## Markdown tables 100 | 101 | | Header 1 | Header 2 | 102 | |------------------------------------|----------| 103 | | Cell 1
Second paragraph | Cell 2 | 104 | | Cell 3 | Cell 4 | 105 | 106 | Centred: 107 | 108 | | Header 1 | Header 2 | 109 | |:----------------------------------:|:--------:| 110 | | Cell 1
Second paragraph | Cell 2 | 111 | | Cell 3 | Cell 4 | 112 | 113 | ## List tables 114 | 115 | ```{list-table} 116 | :header-rows: 1 117 | 118 | * - Header 1 119 | - Header 2 120 | * - Cell 1 121 | 122 | Second paragraph 123 | - Cell 2 124 | * - Cell 3 125 | - Cell 4 126 | ``` 127 | 128 | Centred: 129 | 130 | ```{list-table} 131 | :header-rows: 1 132 | :align: center 133 | 134 | * - Header 1 135 | - Header 2 136 | * - Cell 1 137 | 138 | Second paragraph 139 | - Cell 2 140 | * - Cell 3 141 | - Cell 4 142 | ``` 143 | 144 | ## Notes 145 | 146 | ```{note} 147 | A note. 148 | ``` 149 | 150 | ```{tip} 151 | A tip. 152 | ``` 153 | 154 | ```{important} 155 | Important information 156 | ``` 157 | 158 | ```{caution} 159 | This might damage your hardware! 160 | ``` 161 | 162 | ## Images 163 | 164 | ![Alt text](https://assets.ubuntu.com/v1/b3b72cb2-canonical-logo-166.png) 165 | 166 | ```{figure} https://assets.ubuntu.com/v1/b3b72cb2-canonical-logo-166.png 167 | :width: 100px 168 | :alt: Alt text 169 | 170 | Figure caption 171 | ``` 172 | 173 | ## Reuse 174 | 175 | ### Keys 176 | 177 | Keys can be defined at the top of a file, or in a `myst_substitutions` option in `conf.py`. 178 | 179 | {{reuse_key}} 180 | 181 | {{advanced_reuse_key}} 182 | 183 | ### File inclusion 184 | 185 | ```{include} index.rst 186 | :start-after: include_start 187 | :end-before: include_end 188 | ``` 189 | 190 | ## Tabs 191 | 192 | ````{tabs} 193 | ```{group-tab} Tab 1 194 | 195 | Content Tab 1 196 | ``` 197 | 198 | ```{group-tab} Tab 2 199 | Content Tab 2 200 | ``` 201 | ```` 202 | 203 | ## Glossary 204 | 205 | ```{glossary} 206 | 207 | some term 208 | Definition of the example term. 209 | ``` 210 | 211 | {term}`some term` 212 | 213 | ## More useful markup 214 | 215 | - ```{versionadded} X.Y 216 | - {abbr}`API (Application Programming Interface)` 217 | 218 | ---- 219 | 220 | ## Custom extensions 221 | 222 | Related links at the top of the page (surrounded by `---`): 223 | 224 | relatedlinks: https://github.com/canonical/lxd-sphinx-extensions, [RTFM](https://www.google.com) 225 | discourse: 12345 226 | 227 | Terms that should not be checked by the spelling checker: {spellexception}`PurposelyWrong` 228 | 229 | A single-line terminal view that separates input from output: 230 | 231 | ```{terminal} 232 | :input: command 233 | :user: root 234 | :host: vampyr 235 | :dir: /home/user/directory/ 236 | 237 | the output 238 | ``` 239 | 240 | A multi-line version of the same: 241 | 242 | ```{terminal} 243 | :user: root 244 | :host: vampyr 245 | :dir: /home/user/directory/ 246 | 247 | :input: command 1 248 | output 1 249 | :input: command 2 250 | output 2 251 | ``` 252 | 253 | A link to a YouTube video: 254 | 255 | ```{youtube} https://www.youtube.com/watch?v=iMLiK1fX4I0 256 | :title: Demo 257 | ``` 258 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | 4 | sys.path.append('./') 5 | from custom_conf import * 6 | sys.path.append('.sphinx/') 7 | from build_requirements import * 8 | 9 | # Configuration file for the Sphinx documentation builder. 10 | # You should not do any modifications to this file. Put your custom 11 | # configuration into the custom_conf.py file. 12 | # If you need to change this file, contribute the changes upstream. 13 | # 14 | # For the full list of built-in configuration values, see the documentation: 15 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 16 | 17 | ############################################################ 18 | ### Extensions 19 | ############################################################ 20 | 21 | extensions = [ 22 | 'sphinx_design', 23 | 'sphinx_copybutton', 24 | 'sphinxcontrib.jquery', 25 | ] 26 | 27 | # Only add redirects extension if any redirects are specified. 28 | if AreRedirectsDefined(): 29 | extensions.append('sphinx_reredirects') 30 | 31 | # Only add myst extensions if any configuration is present. 32 | if IsMyStParserUsed(): 33 | extensions.append('myst_parser') 34 | 35 | # Additional MyST syntax 36 | myst_enable_extensions = [ 37 | 'substitution', 38 | 'deflist', 39 | 'linkify' 40 | ] 41 | myst_enable_extensions.extend(custom_myst_extensions) 42 | 43 | # Only add Open Graph extension if any configuration is present. 44 | if IsOpenGraphConfigured(): 45 | extensions.append('sphinxext.opengraph') 46 | 47 | extensions.extend(custom_extensions) 48 | extensions = DeduplicateExtensions(extensions) 49 | 50 | ### Configuration for extensions 51 | 52 | # Used for related links 53 | if not 'discourse_prefix' in html_context and 'discourse' in html_context: 54 | html_context['discourse_prefix'] = html_context['discourse'] + '/t/' 55 | 56 | # The URL prefix for the notfound extension depends on whether the documentation uses versions. 57 | # For documentation on documentation.ubuntu.com, we also must add the slug. 58 | url_version = '' 59 | url_lang = '' 60 | 61 | # Determine if the URL uses versions and language 62 | if 'READTHEDOCS_CANONICAL_URL' in os.environ and os.environ['READTHEDOCS_CANONICAL_URL']: 63 | url_parts = os.environ['READTHEDOCS_CANONICAL_URL'].split('/') 64 | 65 | if len(url_parts) >= 2 and 'READTHEDOCS_VERSION' in os.environ and os.environ['READTHEDOCS_VERSION'] == url_parts[-2]: 66 | url_version = url_parts[-2] + '/' 67 | 68 | if len(url_parts) >= 3 and 'READTHEDOCS_LANGUAGE' in os.environ and os.environ['READTHEDOCS_LANGUAGE'] == url_parts[-3]: 69 | url_lang = url_parts[-3] + '/' 70 | 71 | # Set notfound_urls_prefix to the slug (if defined) and the version/language affix 72 | if slug: 73 | notfound_urls_prefix = '/' + slug + '/' + url_lang + url_version 74 | elif len(url_lang + url_version) > 0: 75 | notfound_urls_prefix = '/' + url_lang + url_version 76 | else: 77 | notfound_urls_prefix = '' 78 | 79 | notfound_context = { 80 | 'title': 'Page not found', 81 | 'body': '

Sorry, but the documentation page that you are looking for was not found.

\n\n

Documentation changes over time, and pages are moved around. We try to redirect you to the updated content where possible, but unfortunately, that didn\'t work this time (maybe because the content you were looking for does not exist in this version of the documentation).

\n

You can try to use the navigation to locate the content you\'re looking for, or search for a similar page.

\n', 82 | } 83 | 84 | # Default image for OGP (to prevent font errors, see 85 | # https://github.com/canonical/sphinx-docs-starter-pack/pull/54 ) 86 | if not 'ogp_image' in locals(): 87 | ogp_image = 'https://assets.ubuntu.com/v1/253da317-image-document-ubuntudocs.svg' 88 | 89 | ############################################################ 90 | ### General configuration 91 | ############################################################ 92 | 93 | exclude_patterns = [ 94 | '_build', 95 | 'Thumbs.db', 96 | '.DS_Store', 97 | '.sphinx', 98 | 'SECURITY.md', 99 | ] 100 | exclude_patterns.extend(custom_excludes) 101 | 102 | rst_epilog = ''' 103 | .. include:: /reuse/links.txt 104 | ''' 105 | if 'custom_rst_epilog' in locals(): 106 | rst_epilog = custom_rst_epilog 107 | 108 | source_suffix = { 109 | '.rst': 'restructuredtext', 110 | '.md': 'markdown', 111 | } 112 | 113 | if not 'conf_py_path' in html_context and 'github_folder' in html_context: 114 | html_context['conf_py_path'] = html_context['github_folder'] 115 | 116 | # For ignoring specific links 117 | linkcheck_anchors_ignore_for_url = [ 118 | r'https://github\.com/.*' 119 | ] 120 | linkcheck_anchors_ignore_for_url.extend(custom_linkcheck_anchors_ignore_for_url) 121 | 122 | # Tags cannot be added directly in custom_conf.py, so add them here 123 | for tag in custom_tags: 124 | tags.add(tag) 125 | 126 | ############################################################ 127 | ### Styling 128 | ############################################################ 129 | 130 | # Find the current builder 131 | builder = 'dirhtml' 132 | if '-b' in sys.argv: 133 | builder = sys.argv[sys.argv.index('-b')+1] 134 | 135 | # Setting templates_path for epub makes the build fail 136 | if builder == 'dirhtml' or builder == 'html': 137 | templates_path = ['.sphinx/_templates'] 138 | notfound_template = '404.html' 139 | 140 | # Theme configuration 141 | html_theme = 'furo' 142 | html_last_updated_fmt = '' 143 | html_permalinks_icon = '¶' 144 | 145 | if html_title == '': 146 | html_theme_options = { 147 | 'sidebar_hide_name': True 148 | } 149 | 150 | ############################################################ 151 | ### Additional files 152 | ############################################################ 153 | 154 | html_static_path = ['.sphinx/_static'] 155 | 156 | html_css_files = [ 157 | 'custom.css', 158 | 'header.css', 159 | 'github_issue_links.css', 160 | 'furo_colors.css' 161 | ] 162 | html_css_files.extend(custom_html_css_files) 163 | 164 | html_js_files = ['header-nav.js'] 165 | if 'github_issues' in html_context and html_context['github_issues'] and not disable_feedback_button: 166 | html_js_files.append('github_issue_links.js') 167 | html_js_files.extend(custom_html_js_files) 168 | -------------------------------------------------------------------------------- /docs/doc-cheat-sheet.rst: -------------------------------------------------------------------------------- 1 | :orphan: 2 | 3 | .. _cheat-sheet: 4 | 5 | reStructuredText cheat sheet 6 | ============================ 7 | 8 | This file contains the syntax for commonly used reST markup. 9 | Open it in your text editor to quickly copy and paste the markup you need. 10 | 11 | See the `reStructuredText style guide `_ for detailed information and conventions. 12 | 13 | Also see the `Sphinx reStructuredText Primer `_ for more details on reST, and the `Canonical Documentation Style Guide `_ for general style conventions. 14 | 15 | H2 heading 16 | ---------- 17 | 18 | H3 heading 19 | ~~~~~~~~~~ 20 | 21 | H4 heading 22 | ^^^^^^^^^^ 23 | 24 | H5 heading 25 | .......... 26 | 27 | Inline formatting 28 | ----------------- 29 | 30 | - :guilabel:`UI element` 31 | - ``code`` 32 | - :file:`file path` 33 | - :command:`command` 34 | - :kbd:`Key` 35 | - *Italic* 36 | - **Bold** 37 | 38 | Code blocks 39 | ----------- 40 | 41 | Start a code block:: 42 | 43 | code: 44 | - example: true 45 | 46 | .. code:: 47 | 48 | # Demonstrate a code block 49 | code: 50 | - example: true 51 | 52 | .. code:: yaml 53 | 54 | # Demonstrate a code block 55 | code: 56 | - example: true 57 | 58 | .. _a_section_target: 59 | 60 | Links 61 | ----- 62 | 63 | - `Canonical website `_ 64 | - `Canonical website`_ (defined in ``reuse/links.txt`` or at the bottom of the page) 65 | - https:\ //canonical.com/ 66 | - :ref:`a_section_target` 67 | - :ref:`Link text ` 68 | - :doc:`index` 69 | - :doc:`Link text ` 70 | 71 | 72 | Navigation 73 | ---------- 74 | 75 | Use the following syntax:: 76 | 77 | .. toctree:: 78 | :hidden: 79 | 80 | sub-page1 81 | sub-page2 82 | 83 | 84 | Lists 85 | ----- 86 | 87 | 1. Step 1 88 | 89 | - Item 1 90 | 91 | * Sub-item 92 | - Item 2 93 | 94 | i. Sub-step 1 95 | #. Sub-step 2 96 | #. Step 2 97 | 98 | a. Sub-step 1 99 | 100 | - Item 101 | #. Sub-step 2 102 | 103 | Term 1: 104 | Definition 105 | Term 2: 106 | Definition 107 | 108 | Tables 109 | ------ 110 | 111 | +----------------------+------------+ 112 | | Header 1 | Header 2 | 113 | +======================+============+ 114 | | Cell 1 | Cell 2 | 115 | | | | 116 | | Second paragraph | | 117 | +----------------------+------------+ 118 | | Cell 3 | Cell 4 | 119 | +----------------------+------------+ 120 | 121 | +----------------------+------------------+ 122 | | :center:`Header 1` | Header 2 | 123 | +======================+==================+ 124 | | Cell 1 | Cell 2 | 125 | | | | 126 | | Second paragraph | | 127 | +----------------------+------------------+ 128 | | Cell 3 | :center:`Cell 4` | 129 | +----------------------+------------------+ 130 | 131 | .. list-table:: 132 | :header-rows: 1 133 | 134 | * - Header 1 135 | - Header 2 136 | * - Cell 1 137 | 138 | Second paragraph 139 | - Cell 2 140 | * - Cell 3 141 | - Cell 4 142 | 143 | .. rst-class:: align-center 144 | 145 | +----------------------+------------+ 146 | | Header 1 | Header 2 | 147 | +======================+============+ 148 | | Cell 1 | Cell 2 | 149 | | | | 150 | | Second paragraph | | 151 | +----------------------+------------+ 152 | | Cell 3 | Cell 4 | 153 | +----------------------+------------+ 154 | 155 | .. list-table:: 156 | :header-rows: 1 157 | :align: center 158 | 159 | * - Header 1 160 | - Header 2 161 | * - Cell 1 162 | 163 | Second paragraph 164 | - Cell 2 165 | * - Cell 3 166 | - Cell 4 167 | 168 | Notes 169 | ----- 170 | 171 | .. note:: 172 | A note. 173 | 174 | .. tip:: 175 | A tip. 176 | 177 | .. important:: 178 | Important information 179 | 180 | .. caution:: 181 | This might damage your hardware! 182 | 183 | Images 184 | ------ 185 | 186 | .. image:: https://assets.ubuntu.com/v1/b3b72cb2-canonical-logo-166.png 187 | 188 | .. figure:: https://assets.ubuntu.com/v1/b3b72cb2-canonical-logo-166.png 189 | :width: 100px 190 | :alt: Alt text 191 | 192 | Figure caption 193 | 194 | Reuse 195 | ----- 196 | 197 | .. |reuse_key| replace:: This is **included** text. 198 | 199 | |reuse_key| 200 | 201 | .. include:: index.rst 202 | :start-after: include_start 203 | :end-before: include_end 204 | 205 | Tabs 206 | ---- 207 | 208 | .. tabs:: 209 | 210 | .. group-tab:: Tab 1 211 | 212 | Content Tab 1 213 | 214 | .. group-tab:: Tab 2 215 | 216 | Content Tab 2 217 | 218 | 219 | Glossary 220 | -------- 221 | 222 | .. glossary:: 223 | 224 | example term 225 | Definition of the example term. 226 | 227 | :term:`example term` 228 | 229 | More useful markup 230 | ------------------ 231 | 232 | - .. versionadded:: X.Y 233 | - | Line 1 234 | | Line 2 235 | | Line 3 236 | - .. This is a comment 237 | - :abbr:`API (Application Programming Interface)` 238 | 239 | ---- 240 | 241 | Custom extensions 242 | ----------------- 243 | 244 | Related links at the top of the page:: 245 | 246 | :relatedlinks: https://github.com/canonical/lxd-sphinx-extensions, [RTFM](https://www.google.com) 247 | :discourse: 12345 248 | 249 | Terms that should not be checked by the spelling checker: :spellexception:`PurposelyWrong` 250 | 251 | A single-line terminal view that separates input from output: 252 | 253 | .. terminal:: 254 | :input: command 255 | :user: root 256 | :host: vampyr 257 | :dir: /home/user/directory/ 258 | 259 | the output 260 | 261 | A multi-line version of the same: 262 | 263 | .. terminal:: 264 | :user: root 265 | :host: vampyr 266 | :dir: /home/user/directory/ 267 | 268 | :input: command 1 269 | output 1 270 | :input: command 2 271 | output 2 272 | 273 | A link to a YouTube video: 274 | 275 | .. youtube:: https://www.youtube.com/watch?v=iMLiK1fX4I0 276 | :title: Demo 277 | 278 | 279 | 280 | .. LINKS 281 | .. _Canonical website: https://canonical.com/ 282 | -------------------------------------------------------------------------------- /docs/custom_conf.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | # Custom configuration for the Sphinx documentation builder. 4 | # All configuration specific to your project should be done in this file. 5 | # 6 | # The file is included in the common conf.py configuration file. 7 | # You can modify any of the settings below or add any configuration that 8 | # is not covered by the common conf.py file. 9 | # 10 | # For the full list of built-in configuration values, see the documentation: 11 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 12 | # 13 | # If you're not familiar with Sphinx and don't want to use advanced 14 | # features, it is sufficient to update the settings in the "Project 15 | # information" section. 16 | 17 | ############################################################ 18 | ### Project information 19 | ############################################################ 20 | 21 | # Product name 22 | project = 'awsmp' 23 | author = 'Canonical Group Ltd' 24 | 25 | # The title you want to display for the documentation in the sidebar. 26 | # You might want to include a version number here. 27 | # To not display any title, set this option to an empty string. 28 | html_title = project + ' documentation' 29 | 30 | # The default value uses the current year as the copyright year. 31 | # 32 | # For static works, it is common to provide the year of first publication. 33 | # Another option is to give the first year and the current year 34 | # for documentation that is often changed, e.g. 2022–2023 (note the en-dash). 35 | # 36 | # A way to check a GitHub repo's creation date is to obtain a classic GitHub 37 | # token with 'repo' permissions here: https://github.com/settings/tokens 38 | # Next, use 'curl' and 'jq' to extract the date from the GitHub API's output: 39 | # 40 | # curl -H 'Authorization: token ' \ 41 | # -H 'Accept: application/vnd.github.v3.raw' \ 42 | # https://api.github.com/repos/canonical/ | jq '.created_at' 43 | 44 | copyright = '%s, %s' % (datetime.date.today().year, author) 45 | 46 | ## Open Graph configuration - defines what is displayed as a link preview 47 | ## when linking to the documentation from another website (see https://ogp.me/) 48 | # The URL where the documentation will be hosted (leave empty if you 49 | # don't know yet) 50 | # NOTE: If no ogp_* variable is defined (e.g. if you remove this section) the 51 | # sphinxext.opengraph extension will be disabled. 52 | ogp_site_url = 'https://canonical-starter-pack.readthedocs-hosted.com/' 53 | # The documentation website name (usually the same as the product name) 54 | ogp_site_name = project 55 | # The URL of an image or logo that is used in the preview 56 | ogp_image = 'https://assets.ubuntu.com/v1/6c10be67-UbuntuCloud.jpg' 57 | 58 | # Update with the local path to the favicon for your product 59 | # (default is the circle of friends) 60 | html_favicon = '.sphinx/_static/favicon.png' 61 | 62 | # (Some settings must be part of the html_context dictionary, while others 63 | # are on root level. Don't move the settings.) 64 | html_context = { 65 | 66 | # Change to the link to the website of your product (without "https://") 67 | # For example: "ubuntu.com/lxd" or "microcloud.is" 68 | # If there is no product website, edit the header template to remove the 69 | # link (see the readme for instructions). 70 | 'product_page': 'github.com/canonical/awsmp', 71 | 72 | # Add your product tag (the orange part of your logo, will be used in the 73 | # header) to ".sphinx/_static" and change the path here (start with "_static") 74 | # (default is the circle of friends) 75 | 'product_tag': '_static/tag.png', 76 | 77 | # Change to the discourse instance you want to be able to link to 78 | # using the :discourse: metadata at the top of a file 79 | # (use an empty value if you don't want to link) 80 | 'discourse': 'https://discourse.ubuntu.com', 81 | 82 | # Change to the Mattermost channel you want to link to 83 | # (use an empty value if you don't want to link) 84 | 'mattermost': 'https://chat.canonical.com/canonical/channels/public-cloud-eng', 85 | 86 | # Change to the GitHub URL for your project 87 | 'github_url': 'https://github.com/canonical/awsmp', 88 | 89 | # Change to the branch for this version of the documentation 90 | 'github_version': 'main', 91 | 92 | # Change to the folder that contains the documentation 93 | # (usually "/" or "/docs/") 94 | 'github_folder': '/', 95 | 96 | # Change to an empty value if your GitHub repo doesn't have issues enabled. 97 | # This will disable the feedback button and the issue link in the footer. 98 | 'github_issues': 'enabled', 99 | 100 | # Controls the existence of Previous / Next buttons at the bottom of pages 101 | # Valid options: none, prev, next, both 102 | 'sequential_nav': "none" 103 | } 104 | 105 | # If your project is on documentation.ubuntu.com, specify the project 106 | # slug (for example, "lxd") here. 107 | slug = "" 108 | 109 | ############################################################ 110 | ### Redirects 111 | ############################################################ 112 | 113 | # Set up redirects (https://documatt.gitlab.io/sphinx-reredirects/usage.html) 114 | # For example: 'explanation/old-name.html': '../how-to/prettify.html', 115 | # You can also configure redirects in the Read the Docs project dashboard 116 | # (see https://docs.readthedocs.io/en/stable/guides/redirects.html). 117 | # NOTE: If this variable is not defined, set to None, or the dictionary is empty, 118 | # the sphinx_reredirects extension will be disabled. 119 | redirects = {} 120 | 121 | ############################################################ 122 | ### Link checker exceptions 123 | ############################################################ 124 | 125 | # Links to ignore when checking links 126 | linkcheck_ignore = [ 127 | 'http://127.0.0.1:8000' 128 | ] 129 | 130 | # Pages on which to ignore anchors 131 | # (This list will be appended to linkcheck_anchors_ignore_for_url) 132 | custom_linkcheck_anchors_ignore_for_url = [] 133 | 134 | ############################################################ 135 | ### Additions to default configuration 136 | ############################################################ 137 | 138 | ## The following settings are appended to the default configuration. 139 | ## Use them to extend the default functionality. 140 | # NOTE: Remove this variable to disable the MyST parser extensions. 141 | custom_myst_extensions = [] 142 | 143 | # Add custom Sphinx extensions as needed. 144 | # This array contains recommended extensions that should be used. 145 | # NOTE: The following extensions are handled automatically and do 146 | # not need to be added here: myst_parser, sphinx_copybutton, sphinx_design, 147 | # sphinx_reredirects, sphinxcontrib.jquery, sphinxext.opengraph 148 | custom_extensions = [ 149 | 'sphinx_tabs.tabs', 150 | 'canonical.youtube-links', 151 | 'canonical.related-links', 152 | 'canonical.custom-rst-roles', 153 | 'canonical.terminal-output', 154 | 'notfound.extension' 155 | ] 156 | 157 | # Add custom required Python modules that must be added to the 158 | # .sphinx/requirements.txt file. 159 | # NOTE: The following modules are handled automatically and do not need to be 160 | # added here: canonical-sphinx-extensions, furo, linkify-it-py, myst-parser, 161 | # pyspelling, sphinx, sphinx-autobuild, sphinx-copybutton, sphinx-design, 162 | # sphinx-notfound-page, sphinx-reredirects, sphinx-tabs, sphinxcontrib-jquery, 163 | # sphinxext-opengraph 164 | custom_required_modules = [] 165 | 166 | # Add files or directories that should be excluded from processing. 167 | custom_excludes = [ 168 | 'doc-cheat-sheet*', 169 | ] 170 | 171 | # Add CSS files (located in .sphinx/_static/) 172 | custom_html_css_files = [] 173 | 174 | # Add JavaScript files (located in .sphinx/_static/) 175 | custom_html_js_files = [] 176 | 177 | ## The following settings override the default configuration. 178 | 179 | # Specify a reST string that is included at the end of each file. 180 | # If commented out, use the default (which pulls the reuse/links.txt 181 | # file into each reST file). 182 | custom_rst_epilog = '' 183 | 184 | # By default, the documentation includes a feedback button at the top. 185 | # You can disable it by setting the following configuration to True. 186 | disable_feedback_button = False 187 | 188 | # Add tags that you want to use for conditional inclusion of text 189 | # (https://www.sphinx-doc.org/en/master/usage/restructuredtext/directives.html#tags) 190 | custom_tags = [] 191 | 192 | ############################################################ 193 | ### Additional configuration 194 | ############################################################ 195 | 196 | ## Add any configuration that is not covered by the common conf.py file. 197 | 198 | # Define a :center: role that can be used to center the content of table cells. 199 | rst_prolog = ''' 200 | .. role:: center 201 | :class: align-center 202 | ''' 203 | -------------------------------------------------------------------------------- /docs/public-offer/index.rst: -------------------------------------------------------------------------------- 1 | How to create a public offer 2 | ============================ 3 | 4 | To create a new public AMI product listing in the `AWS marketplace management portal`_, use the API calls described below: 5 | 6 | 7 | Create product ID 8 | ----------------- 9 | 10 | .. code-block:: sh 11 | 12 | awsmp public-offer create 13 | 14 | ChangeSet created (ID: gxy13m673kmhr4vdtpu0ltwf) 15 | https://aws.amazon.com/marketplace/management/requests/gxy13m673kmhr4vdtpu0ltwf 16 | 17 | This request will generate an offer ID associated with the product ID. An offer ID is **required** generate pricing template file for updating instance types. 18 | 19 | .. note:: 20 | 21 | For an existing published listing, an offer ID can also be obtained from the AWS console under :guilabel:`AWS Marketplace` > :guilabel:`Manage subscriptions`. Select the required listing and look for the `Offer ID` under `Agreement`. 22 | 23 | If you are in the draft stage, after running this command go to your generated request and check the :guilabel:`Entities` section under :guilabel:`Change request summary`. 24 | 25 | 26 | Add/Edit product description 27 | ---------------------------- 28 | 29 | Once a product ID is created, you can add/edit the product description. The description fields are shown 30 | below or you can also refer to the sample config file (listing_configuration.yaml) 31 | 32 | .. code-block:: yaml 33 | :caption: listing_configuration.yaml 34 | 35 | product: 36 | description: 37 | product_title: str 38 | logourl: str 39 | video_urls: Optional[List[str]], can only have 1 url 40 | short_description: str 41 | long_description: str 42 | highlights: List[str] 43 | search_keywords: List[str] 44 | categories: List[str] 45 | support_description: str # Don't include space character at the beginning/end 46 | support_resources: str 47 | additional_resources: Optional[List[Dict[str, str]]] 48 | sku: Optional[str] 49 | 50 | For empty values, use ``~`` for str type and ``[]`` for List type 51 | 52 | To add/edit the product description, run: 53 | 54 | .. code-block:: sh 55 | 56 | awsmp public-offer update-description \ 57 | --product-id prod-xwpv7txqxg55e \ 58 | --config listing_configuration.yaml 59 | 60 | If a field value does not match the required format, it will show up as an error. 61 | 62 | 63 | Update instance types and pricing 64 | --------------------------------- 65 | 66 | To update instance types and pricing, you need to add offer field containing offer information of the listing. The sample config is described below. 67 | 68 | #. For hourly pricing AMI Product: 69 | 70 | .. code-block:: yaml 71 | :caption: listing_configuration.yaml 72 | 73 | offer: 74 | instance_types: 75 | - name: c3.large 76 | hourly: 0.12 77 | - name: c4.medium 78 | hourly: 0.08 79 | 80 | #. For hourly and annual pricing AMI product: 81 | 82 | .. code-block:: yaml 83 | :caption: listing_configuration.yaml 84 | 85 | offer: 86 | instance_types: 87 | - name: c3.large 88 | yearly: 123.45 89 | hourly: 0.12 90 | - name: c4.medium 91 | yearly: 45.12 92 | hourly: 0.08 93 | 94 | #. For hourly and monthly pricing AMI product: 95 | 96 | .. code-block:: yaml 97 | :caption: listing_configuration.yaml 98 | 99 | offer: 100 | instance_types: 101 | - name: c3.large 102 | yearly: 123.45 103 | hourly: 0.12 104 | - name: c4.medium 105 | yearly: 45.12 106 | hourly: 0.08 107 | monthly_subscription_fee: 50.00 108 | 109 | 110 | Once offer field is ready, run the command: 111 | 112 | .. code-block:: sh 113 | 114 | awsmp public-offer update-instance-type \ 115 | --product-id prod-xwpv7txqxg55e \ 116 | --config listing_configuration.yaml \ 117 | --allow-price-change 118 | 119 | 120 | The CLI retrieves the added and removed instance types from the configuration by comparing it with the existing listing, then sends the appropriate add/restrict instance type requests. 121 | It also compares the pricing before sending a request to avoid unnecessary price changes (increases or decreases) in the listing. To update the price, pass the `--price_change-allowed` flag. 122 | 123 | Update/Add region 124 | ----------------- 125 | 126 | To add or update region information of an AMI product listing, use a configuration file with region details and the ``update-region`` option. 127 | 128 | .. code-block:: yaml 129 | :caption: example listing_configuration.yaml 130 | 131 | ... 132 | product: 133 | region: 134 | commercial_regions: List[str] 135 | future_region_support_region: bool 136 | ... 137 | 138 | Update the region using: 139 | 140 | .. code-block:: sh 141 | 142 | awsmp public-offer update-region \ 143 | --product-id prod-xwpv7txqxg55e \ 144 | --config listing_configuration.yaml 145 | 146 | .. note:: 147 | 148 | For the `commercial_regions` field, each region name must be specified explicitly. GovCloud regions can be included in `commercial_regions`. 149 | 150 | 151 | Add new version 152 | --------------- 153 | 154 | To add new AMI version to an existing AMI listing, create a version configuration file and use the ``update-version`` option. A sample version configuration file (listing_configuration.yaml) looks like: 155 | 156 | .. code-block:: yaml 157 | :caption: example listing_configuration.yaml 158 | 159 | ... 160 | product: 161 | version: 162 | version_title: str 163 | release_notes: str 164 | ami_id: str # Format should be starting with `ami-` 165 | access_role_arn: str # Format should be starting with 'arn:aws:iam::' 166 | os_user_name: str 167 | os_system_version: str 168 | os_system_name: str # This will be converted to Uppercase 169 | scanning_port: int # 1-65535 170 | usage_instructions: str 171 | recommended_instance_type: str # Please select among instance types you added in Step 2 172 | ip_protocol: Literal['tcp', 'udp'] 173 | ip_ranges: List[str] # Upto 5 ranges can be added 174 | from_port: int # 1-65535 175 | to_port: int # 1-65535 176 | ... 177 | 178 | Add the new version using: 179 | 180 | .. code-block:: sh 181 | 182 | awsmp public-offer update-version \ 183 | --product-id prod-xwpv7txqxg55e \ 184 | --config listing_configuration.yaml 185 | 186 | Update legal/support terms 187 | -------------------------- 188 | 189 | To update the legal/support terms of the AMI product listing, you'll need a yaml file with the required terms specified in the `offer` field. 190 | 191 | .. code-block:: yaml 192 | :caption: example listing_configuration.yaml 193 | 194 | ... 195 | offer: 196 | eula_document: 197 | - type: "CustomEula" 198 | url: "https://eula-example" 199 | refund_policy: | 200 | Absolutely no refund! 201 | ... 202 | 203 | A ``eula_document`` can contain only one item. To check the type and conditionally required field (either ``url`` or ``version``), refer to `AWS Marketplace update legal resources API reference`_. 204 | 205 | Here, ``refund_policy`` contains free form of text. 206 | 207 | To update the legal terms, use: 208 | 209 | .. code-block:: sh 210 | 211 | awsmp public-offer update-legal-terms \ 212 | --product-id prod-xwpv7txqxg55e \ 213 | --config listing_configuration.yaml 214 | 215 | To update support terms, use: 216 | 217 | .. code-block:: sh 218 | 219 | awsmp public-offer update-support-terms \ 220 | --product-id prod-xwpv7txqxg55e \ 221 | --config listing_configuration.yaml 222 | 223 | Release AMI product listing 224 | --------------------------- 225 | 226 | To publish drafted AMI listing to :guilabel:`Limited` state, product ID and public offer ID are required: 227 | 228 | .. code-block:: sh 229 | 230 | awsmp public-offer release \ 231 | --product-id prod-fwu3xsqup23cs 232 | 233 | 234 | 235 | Update AMI product listing details 236 | ---------------------------------- 237 | 238 | To update AMI product listing with multiple requests for product details (Description, Region Availability, Instance types and Pricing information), run the command below, passing the product ID and product configuration file: 239 | 240 | .. code-block:: sh 241 | 242 | awsmp public-offer update \ 243 | --product-id prod-fwu3xsqup23cs 244 | --config listing_configuration.yaml 245 | 246 | 247 | Download AMI product listing details 248 | ------------------------------------ 249 | 250 | To download the AMI product listing information as a YAML file, run the command below, providing the product ID and the output file path where the file will be saved: 251 | 252 | .. code-block:: sh 253 | 254 | awsmp public-offer download \ 255 | --product-id prod-fwu3xsqup23cs 256 | --config listing.yaml 257 | 258 | 259 | .. _`AWS marketplace management portal`: https://aws.amazon.com/marketplace/management/ 260 | .. _`AWS Marketplace update legal resources API reference`: https://docs.aws.amazon.com/marketplace/latest/APIReference/work-with-private-offers.html#update-legal-terms 261 | -------------------------------------------------------------------------------- /docs/.sphinx/_static/custom.css: -------------------------------------------------------------------------------- 1 | /** 2 | Ubuntu variable font definitions. 3 | Based on https://github.com/canonical/vanilla-framework/blob/main/scss/_base_fontfaces.scss 4 | 5 | When font files are updated in Vanilla, the links to font files will need to be updated here as well. 6 | */ 7 | 8 | /* default font set */ 9 | @font-face { 10 | font-family: 'Ubuntu variable'; 11 | font-stretch: 100%; /* min and max value for the width axis, expressed as percentage */ 12 | font-style: normal; 13 | font-weight: 100 800; /* min and max value for the weight axis */ 14 | src: url('https://assets.ubuntu.com/v1/f1ea362b-Ubuntu%5Bwdth,wght%5D-latin-v0.896a.woff2') format('woff2-variations'); 15 | } 16 | 17 | @font-face { 18 | font-family: 'Ubuntu variable'; 19 | font-stretch: 100%; /* min and max value for the width axis, expressed as percentage */ 20 | font-style: italic; 21 | font-weight: 100 800; /* min and max value for the weight axis */ 22 | src: url('https://assets.ubuntu.com/v1/90b59210-Ubuntu-Italic%5Bwdth,wght%5D-latin-v0.896a.woff2') format('woff2-variations'); 23 | } 24 | 25 | @font-face { 26 | font-family: 'Ubuntu Mono variable'; 27 | font-style: normal; 28 | font-weight: 100 800; /* min and max value for the weight axis */ 29 | src: url('https://assets.ubuntu.com/v1/d5fc1819-UbuntuMono%5Bwght%5D-latin-v0.869.woff2') format('woff2-variations'); 30 | } 31 | 32 | /* cyrillic-ext */ 33 | @font-face { 34 | font-family: 'Ubuntu variable'; 35 | font-stretch: 100%; /* min and max value for the width axis, expressed as percentage */ 36 | font-style: normal; 37 | font-weight: 100 800; /* min and max value for the weight axis */ 38 | src: url('https://assets.ubuntu.com/v1/77cd6650-Ubuntu%5Bwdth,wght%5D-cyrillic-extended-v0.896a.woff2') format('woff2-variations'); 39 | unicode-range: U+0460-052F, U+20B4, U+2DE0-2DFF, U+A640-A69F; 40 | } 41 | 42 | /* cyrillic */ 43 | @font-face { 44 | font-family: 'Ubuntu variable'; 45 | font-stretch: 100%; /* min and max value for the width axis, expressed as percentage */ 46 | font-style: normal; 47 | font-weight: 100 800; /* min and max value for the weight axis */ 48 | src: url('https://assets.ubuntu.com/v1/2702fce5-Ubuntu%5Bwdth,wght%5D-cyrillic-v0.896a.woff2') format('woff2-variations'); 49 | unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; 50 | } 51 | 52 | /* greek-ext */ 53 | @font-face { 54 | font-family: 'Ubuntu variable'; 55 | font-stretch: 100%; /* min and max value for the width axis, expressed as percentage */ 56 | font-style: normal; 57 | font-weight: 100 800; /* min and max value for the weight axis */ 58 | src: url('https://assets.ubuntu.com/v1/5c108b7d-Ubuntu%5Bwdth,wght%5D-greek-extended-v0.896a.woff2') format('woff2-variations'); 59 | unicode-range: U+1F00-1FFF; 60 | } 61 | 62 | /* greek */ 63 | @font-face { 64 | font-family: 'Ubuntu variable'; 65 | font-stretch: 100%; /* min and max value for the width axis, expressed as percentage */ 66 | font-style: normal; 67 | font-weight: 100 800; /* min and max value for the weight axis */ 68 | src: url('https://assets.ubuntu.com/v1/0a14c405-Ubuntu%5Bwdth,wght%5D-greek-v0.896a.woff2') format('woff2-variations'); 69 | unicode-range: U+0370-03FF; 70 | } 71 | 72 | /* latin-ext */ 73 | @font-face { 74 | font-family: 'Ubuntu variable'; 75 | font-stretch: 100%; /* min and max value for the width axis, expressed as percentage */ 76 | font-style: normal; 77 | font-weight: 100 800; /* min and max value for the weight axis */ 78 | src: url('https://assets.ubuntu.com/v1/19f68eeb-Ubuntu%5Bwdth,wght%5D-latin-extended-v0.896a.woff2') format('woff2-variations'); 79 | unicode-range: U+0100-024F, U+1E00-1EFF, U+20A0-20AB, U+20AD-20CF, U+2C60-2C7F, U+A720-A7FF; 80 | } 81 | 82 | 83 | /** Define font-weights as per Vanilla 84 | Based on: https://github.com/canonical/vanilla-framework/blob/main/scss/_base_typography-definitions.scss 85 | 86 | regular text: 400, 87 | bold: 550, 88 | thin: 300, 89 | 90 | h1: bold, 91 | h2: 180; 92 | h3: bold, 93 | h4: 275, 94 | h5: bold, 95 | h6: regular 96 | */ 97 | 98 | /* default regular text */ 99 | html { 100 | font-weight: 400; 101 | } 102 | 103 | /* heading specific definitions */ 104 | h1, h3, h5 { font-weight: 550; } 105 | h2 { font-weight: 180; } 106 | h4 { font-weight: 275; } 107 | 108 | /* bold */ 109 | .toc-tree li.scroll-current>.reference, 110 | dl.glossary dt, 111 | dl.simple dt, 112 | dl:not([class]) dt { 113 | font-weight: 550; 114 | } 115 | 116 | 117 | /** Table styling **/ 118 | 119 | th.head { 120 | text-transform: uppercase; 121 | font-size: var(--font-size--small); 122 | text-align: initial; 123 | } 124 | 125 | table.align-center th.head { 126 | text-align: center 127 | } 128 | 129 | table.docutils { 130 | border: 0; 131 | box-shadow: none; 132 | width:100%; 133 | } 134 | 135 | table.docutils td, table.docutils th, table.docutils td:last-child, table.docutils th:last-child, table.docutils td:first-child, table.docutils th:first-child { 136 | border-right: none; 137 | border-left: none; 138 | } 139 | 140 | /* Allow to centre text horizontally in table data cells */ 141 | table.align-center { 142 | text-align: center !important; 143 | } 144 | 145 | /** No rounded corners **/ 146 | 147 | .admonition, code.literal, .sphinx-tabs-tab, .sphinx-tabs-panel, .highlight { 148 | border-radius: 0; 149 | } 150 | 151 | /** Admonition styling **/ 152 | 153 | .admonition { 154 | border-top: 1px solid #d9d9d9; 155 | border-right: 1px solid #d9d9d9; 156 | border-bottom: 1px solid #d9d9d9; 157 | } 158 | 159 | /** Color for the "copy link" symbol next to headings **/ 160 | 161 | a.headerlink { 162 | color: var(--color-brand-primary); 163 | } 164 | 165 | /** Line to the left of the current navigation entry **/ 166 | 167 | .sidebar-tree li.current-page { 168 | border-left: 2px solid var(--color-brand-primary); 169 | } 170 | 171 | /** Some tweaks for Sphinx tabs **/ 172 | 173 | [role="tablist"] { 174 | border-bottom: 1px solid var(--color-sidebar-item-background--hover); 175 | } 176 | 177 | .sphinx-tabs-tab[aria-selected="true"], .sd-tab-set>input:checked+label{ 178 | border: 0; 179 | border-bottom: 2px solid var(--color-brand-primary); 180 | font-weight: 400; 181 | font-size: 1rem; 182 | color: var(--color-brand-primary); 183 | } 184 | 185 | body[data-theme="dark"] .sphinx-tabs-tab[aria-selected="true"] { 186 | background: var(--color-background-primary); 187 | border-bottom: 2px solid var(--color-brand-primary); 188 | } 189 | 190 | button.sphinx-tabs-tab[aria-selected="false"]:hover, .sd-tab-set>input:not(:checked)+label:hover { 191 | border-bottom: 2px solid var(--color-foreground-border); 192 | } 193 | 194 | button.sphinx-tabs-tab[aria-selected="false"]{ 195 | border-bottom: 2px solid var(--color-background-primary); 196 | } 197 | 198 | body[data-theme="dark"] .sphinx-tabs-tab { 199 | background: var(--color-background-primary); 200 | } 201 | 202 | .sphinx-tabs-tab, .sd-tab-set>label{ 203 | color: var(--color-brand-primary); 204 | font-family: var(--font-stack); 205 | font-weight: 400; 206 | font-size: 1rem; 207 | padding: 1em 1.25em .5em 208 | } 209 | 210 | .sphinx-tabs-panel { 211 | border: 0; 212 | border-bottom: 1px solid var(--color-sidebar-item-background--hover); 213 | background: var(--color-background-primary); 214 | padding: 0.75rem 0 0.75rem 0; 215 | } 216 | 217 | body[data-theme="dark"] .sphinx-tabs-panel { 218 | background: var(--color-background-primary); 219 | } 220 | 221 | /** A tweak for issue #190 **/ 222 | 223 | .highlight .hll { 224 | background-color: var(--color-highlighted-background); 225 | } 226 | 227 | 228 | /** Custom classes to fix scrolling in tables by decreasing the 229 | font size or breaking certain columns. 230 | Specify the classes in the Markdown file with, for example: 231 | ```{rst-class} break-col-4 min-width-4-8 232 | ``` 233 | **/ 234 | 235 | table.dec-font-size { 236 | font-size: smaller; 237 | } 238 | table.break-col-1 td.text-left:first-child { 239 | word-break: break-word; 240 | } 241 | table.break-col-4 td.text-left:nth-child(4) { 242 | word-break: break-word; 243 | } 244 | table.min-width-1-15 td.text-left:first-child { 245 | min-width: 15em; 246 | } 247 | table.min-width-4-8 td.text-left:nth-child(4) { 248 | min-width: 8em; 249 | } 250 | 251 | /** Underline for abbreviations **/ 252 | 253 | abbr[title] { 254 | text-decoration: underline solid #cdcdcd; 255 | } 256 | 257 | /** Use the same style for right-details as for left-details **/ 258 | .bottom-of-page .right-details { 259 | font-size: var(--font-size--small); 260 | display: block; 261 | } 262 | 263 | /** Version switcher */ 264 | button.version_select { 265 | color: var(--color-foreground-primary); 266 | background-color: var(--color-toc-background); 267 | padding: 5px 10px; 268 | border: none; 269 | } 270 | 271 | .version_select:hover, .version_select:focus { 272 | background-color: var(--color-sidebar-item-background--hover); 273 | } 274 | 275 | .version_dropdown { 276 | position: relative; 277 | display: inline-block; 278 | text-align: right; 279 | font-size: var(--sidebar-item-font-size); 280 | } 281 | 282 | .available_versions { 283 | display: none; 284 | position: absolute; 285 | right: 0px; 286 | background-color: var(--color-toc-background); 287 | box-shadow: 0px 8px 16px 0px rgba(0,0,0,0.2); 288 | z-index: 11; 289 | } 290 | 291 | .available_versions a { 292 | color: var(--color-foreground-primary); 293 | padding: 12px 16px; 294 | text-decoration: none; 295 | display: block; 296 | } 297 | 298 | .available_versions a:hover {background-color: var(--color-sidebar-item-background--current)} 299 | 300 | .show {display:block;} 301 | 302 | /** Fix for nested numbered list - the nested list is lettered **/ 303 | ol.arabic ol.arabic { 304 | list-style: lower-alpha; 305 | } 306 | 307 | /** Make expandable sections look like links **/ 308 | details summary { 309 | color: var(--color-link); 310 | } 311 | 312 | /** Fix the styling of the version box for readthedocs **/ 313 | 314 | #furo-readthedocs-versions .rst-versions, #furo-readthedocs-versions .rst-current-version, #furo-readthedocs-versions:focus-within .rst-current-version, #furo-readthedocs-versions:hover .rst-current-version { 315 | background: var(--color-sidebar-item-background--hover); 316 | } 317 | 318 | .rst-versions .rst-other-versions dd a { 319 | color: var(--color-link); 320 | } 321 | 322 | #furo-readthedocs-versions:focus-within .rst-current-version .fa-book, #furo-readthedocs-versions:hover .rst-current-version .fa-book, .rst-versions .rst-other-versions { 323 | color: var(--color-sidebar-link-text); 324 | } 325 | 326 | .rst-versions .rst-current-version { 327 | color: var(--color-version-popup); 328 | font-weight: bolder; 329 | } 330 | 331 | /* Code-block copybutton invisible by default 332 | (overriding Furo config to achieve default copybutton setting). */ 333 | .highlight button.copybtn { 334 | opacity: 0; 335 | } 336 | 337 | /* Mimicking the 'Give feedback' button for UX consistency */ 338 | .sidebar-search-container input[type=submit] { 339 | color: #FFFFFF; 340 | border: 2px solid #D6410D; 341 | padding: var(--sidebar-search-input-spacing-vertical) var(--sidebar-search-input-spacing-horizontal); 342 | background: #D6410D; 343 | font-weight: bold; 344 | font-size: var(--font-size--small); 345 | cursor: pointer; 346 | } 347 | 348 | .sidebar-search-container input[type=submit]:hover { 349 | text-decoration: underline; 350 | } 351 | 352 | /* Make inline code the same size as code blocks */ 353 | p code.literal { 354 | border: 0; 355 | font-size: var(--code-font-size); 356 | } 357 | 358 | /* Use the general admonition font size for inline code */ 359 | .admonition p code.literal { 360 | font-size: var(--admonition-font-size); 361 | } 362 | -------------------------------------------------------------------------------- /awsmp/cli.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import csv 4 | import json 5 | import logging 6 | import time 7 | from typing import Dict, List, Optional, TextIO 8 | 9 | import click 10 | import prettytable 11 | import yaml 12 | from botocore.exceptions import ClientError 13 | 14 | from . import _driver, models, yaml_utils 15 | from .errors import ( 16 | AccessDeniedException, 17 | NoProductIdProvidedException, 18 | YamlMissingKeyException, 19 | ) 20 | 21 | logger = logging.getLogger(__name__) 22 | 23 | 24 | @click.group() 25 | def cli(): 26 | pass 27 | 28 | 29 | @cli.group("private-offer") 30 | def private_offer(): 31 | """ 32 | Create and update private offers 33 | """ 34 | pass 35 | 36 | 37 | @cli.group("public-offer") 38 | def public_offer(): 39 | """ 40 | Create and update public offers (Free, 3p) 41 | """ 42 | pass 43 | 44 | 45 | @cli.group("inspect") 46 | def inspect(): 47 | """ 48 | Inspect marketplace offers 49 | """ 50 | pass 51 | 52 | 53 | @inspect.command("entity-list") 54 | @click.argument("entity-type", type=click.Choice(["Offer", "AmiProduct"])) 55 | @click.option("--filter-visibility", multiple=True, type=click.Choice([v.value for v in models.AmiVisibility])) 56 | def entity_list(entity_type, filter_visibility): 57 | """ 58 | List available entities. Currently supported are entities of type "Offer" 59 | and "AmiProduct". 60 | """ 61 | entity_list = _driver.get_entities_by_visibility(entity_type, filter_visibility) 62 | 63 | t = prettytable.PrettyTable() 64 | t.field_names = ["entity-id", "name", "visibility", "last-changed"] 65 | t.add_rows([[e["EntityId"], e["Name"], e["Visibility"], e["LastModifiedDate"]] for e in entity_list]) 66 | 67 | print(t.get_string(sortby="last-changed")) 68 | 69 | 70 | @inspect.command("entity-show", help="Show a specific entity") 71 | @click.argument("entity-id") 72 | def entity_show(entity_id): 73 | details = _driver.get_entity_details(entity_id) 74 | print(json.dumps(details, indent=2)) 75 | 76 | 77 | @inspect.command("entity-versions-count") 78 | def entity_versions_count(): 79 | """ 80 | List each marketplace entry with it's number of versions, sorted by number of versions 81 | """ 82 | versions = _driver.get_ami_product_version_summary() 83 | 84 | for version in sorted(versions, key=lambda x: x[1]): 85 | print(f"{version[0]} - {version[1]} - {version[2]}") 86 | 87 | 88 | @inspect.command("entity-versions-list") 89 | @click.argument("entity-id") 90 | def entity_versions_list(entity_id): 91 | """ 92 | List all versions for a provided entity id. 93 | """ 94 | versions = _driver.get_entity_versions(entity_id) 95 | t = prettytable.PrettyTable() 96 | t.field_names = ["CreationDate", "Id", "version title"] 97 | for v in versions: 98 | t.add_row([v["CreationDate"], v["Id"], v["VersionTitle"]]) 99 | print(t.get_string(sortby="CreationDate")) 100 | 101 | 102 | @inspect.command("entity-diff") 103 | @click.argument("entity-id") 104 | @click.argument("config", type=click.File("r"), required=True) 105 | def entity_get_diff(entity_id: str, config: TextIO): 106 | """ 107 | Get the differences between marketplace listing and local configuration file. 108 | 109 | :param str entity_id: Product Id of a marketplace listing 110 | :param TextIO config: Path of local configuration file 111 | :return None 112 | :rtype None 113 | """ 114 | 115 | with open(config.name, "r") as f: 116 | yaml_config = yaml.safe_load(f) 117 | local_config_entity = models.EntityModel.get_entity_from_yaml(yaml_config) 118 | 119 | diff = _driver.diff_entity_id_vs_local(entity_id, local_config_entity) 120 | 121 | print(repr(diff)) 122 | 123 | 124 | @private_offer.command("create") 125 | @click.option("--product-id", required=True, prompt=True) 126 | @click.option("--buyer-accounts", multiple=True) 127 | @click.option("--available-for-days", required=True, default=14, type=int, prompt=True) 128 | @click.option("--valid-for-days", required=True, default=1095, type=int, prompt=True) 129 | @click.option("--with-support", is_flag=True, default=False, prompt=True) 130 | @click.option("--customer-name", type=str, required=True, prompt=True) 131 | @click.option( 132 | "--eula-url", 133 | type=str, 134 | required=False, 135 | prompt="EULA Url (Will default to the standard AWS EULA if left blank)", 136 | default="", 137 | envvar="AWSMP_EULA_URL", 138 | ) 139 | @click.option("--pricing", type=click.File("r"), required=True, prompt=True) 140 | @click.option("--dry-run/--no-dry-run", is_flag=True) 141 | @click.option("--hourly", is_flag=True, help="Create hourly listing") 142 | def offer_create( 143 | product_id, 144 | buyer_accounts, 145 | available_for_days, 146 | valid_for_days, 147 | with_support, 148 | customer_name, 149 | eula_url, 150 | pricing, 151 | dry_run, 152 | hourly=False, 153 | ): 154 | """ 155 | Create a new private offer. 156 | 157 | The file name passed via --pricing **must** be a .csv file without headers 158 | and 3 columns. The 1st column is the instance type name, 2nd column is the hourly price 159 | and the 3rd column is the annual price. E.g.: 160 | 161 | m6i.xlarge,0.007,49.056 162 | t2.nano,0.002,12.264 163 | r5d.24xlarge,0.168,1177.344 164 | 165 | Note: **all** instance types available in the product (referenced by --product-id) 166 | must be listed in this .csv file. 167 | 168 | The --available-for-days parameter says how long the offer can be accepted. 169 | The --valid-for-days parameter says how long the offer is valid when it was accepted 170 | The --eula-url option can be left blank if the default aws EULA is acceptable. 171 | The value can also be set via the `AWSMP_EULA_URL` environment variable 172 | """ 173 | if not buyer_accounts: 174 | buyers = click.prompt("Please enter all buyer accounts separated by a comma") 175 | buyer_accounts = [b.strip() for b in buyers.split(",")] 176 | 177 | offer_name = _driver.create_offer_name(product_id, buyer_accounts, with_support, customer_name) 178 | 179 | click.echo(f"> {offer_name}") 180 | confirm = click.confirm("Is the offer name listed above correct?") 181 | if not confirm: 182 | offer_name = click.prompt("Please enter the offer name in full") 183 | 184 | response = _driver.offer_create( 185 | product_id, 186 | buyer_accounts, 187 | available_for_days, 188 | valid_for_days, 189 | offer_name, 190 | eula_url, 191 | pricing, 192 | dry_run, 193 | hourly=hourly, 194 | ) 195 | 196 | print(f'ChangeSet created (ID: {response["ChangeSetId"]})') 197 | print(f'https://aws.amazon.com/marketplace/management/requests/{response["ChangeSetId"]}') 198 | 199 | 200 | @cli.command("pricing-template", help="Generate pricing template for public/private offers") 201 | @click.option("--offer-id", required=True) 202 | @click.option("--pricing", type=click.File("w+"), required=True) 203 | @click.option("--free/--no-free", default=False) 204 | def offer_pricing_template(offer_id, pricing, free): 205 | """ 206 | Create a pricing template (.csv file) based on a given offer 207 | """ 208 | 209 | offer_pricing = _driver.build_pricing_rows_from_offer(offer_id, free=free) 210 | 211 | csvwriter = csv.writer(pricing) 212 | for instance_type, hourly_price, annual_price in offer_pricing: 213 | csvwriter.writerow([instance_type, hourly_price, annual_price]) 214 | 215 | 216 | @public_offer.command("create") 217 | @click.option("--dry-run/--no-dry-run", is_flag=True) 218 | def ami_product_create(dry_run): 219 | """ 220 | Create a new AMI product listing 221 | """ 222 | response = _driver.AmiProduct.create(dry_run=dry_run) 223 | 224 | print(f'ChangeSet created (ID: {response["ChangeSetId"]})') 225 | print(f'https://aws.amazon.com/marketplace/management/requests/{response["ChangeSetId"]}') 226 | 227 | 228 | @public_offer.command("update-description") 229 | @click.option("--product-id", required=True, prompt=True) 230 | @click.option("--config", type=click.File("r"), required=True, prompt=True) 231 | @click.option("--dry-run/--no-dry-run", is_flag=True) 232 | def ami_product_update_description(product_id, config, dry_run): 233 | """ 234 | Update AMI product description 235 | """ 236 | # Load yaml file 237 | desc = _load_configuration(config, [["product", "description"]])["product"]["description"] 238 | response = _driver.AmiProduct(product_id=product_id, dry_run=dry_run).update_description(desc) 239 | print(f'ChangeSet created (ID: {response["ChangeSetId"]})') 240 | print(f'https://aws.amazon.com/marketplace/management/requests/{response["ChangeSetId"]}') 241 | 242 | 243 | @public_offer.command("update-instance-type") 244 | @click.option("--product-id", required=True, prompt=True) 245 | @click.option("--config", type=click.File("r"), required=True, prompt=True) 246 | @click.option( 247 | "--allow-price-change/--no-allow-price-change", 248 | required=True, 249 | default=False, 250 | type=click.BOOL, 251 | is_flag=True, 252 | prompt="Is price update allowed? (y). Default is False.", 253 | ) 254 | @click.option("--dry-run/--no-dry-run", is_flag=True) 255 | def ami_product_update_instance_type(product_id: str, config: TextIO, allow_price_change: bool, dry_run: bool) -> None: 256 | """ 257 | Update AMI product instance type 258 | :param str product_id: Id of listing 259 | :param TextIO config: file path of local configuration file 260 | :param bool allow_price_change: flag of allowing pricing change to update instance type information 261 | :return: None 262 | :rtype: None 263 | """ 264 | product = _driver.AmiProduct(product_id=product_id, dry_run=dry_run) 265 | offer_config = _load_configuration(config, [["offer"]])["offer"] 266 | response = product.update_instance_types(offer_config, allow_price_change) 267 | if response: 268 | print(f'ChangeSet created (ID: {response["ChangeSetId"]})') 269 | print(f'https://aws.amazon.com/marketplace/management/requests/{response["ChangeSetId"]}') 270 | 271 | 272 | @public_offer.command("instance-type-template") 273 | @click.option("--arch", required=True, prompt=True, type=click.Choice(["x86_64", "arm64", "i386"])) 274 | @click.option("--virt", required=True, prompt=True, type=click.Choice(["hvm", "paravirtual"])) 275 | def ami_product_instance_type_template(arch, virt): 276 | """ 277 | Generate AMI product instance type template 278 | """ 279 | 280 | available_instances = _driver.get_available_instance_types(arch, virt) 281 | 282 | with open("instance_type.csv", "w") as f: 283 | csvwriter = csv.writer(f) 284 | for instance in available_instances: 285 | csvwriter.writerow([instance, 0.00, 0.00]) 286 | print(f"Available instance types are exported in instance_type.csv file.") 287 | 288 | 289 | @public_offer.command("update-region") 290 | @click.option("--product-id", required=True, prompt=True) 291 | @click.option("--config", type=click.File("r"), required=True, prompt=True) 292 | @click.option("--dry-run/--no-dry-run", is_flag=True) 293 | def ami_product_update_regions(product_id, config, dry_run): 294 | """ 295 | Update AMI product region 296 | """ 297 | # Load yaml file 298 | region_config = _load_configuration(config, [["product", "region"]])["product"]["region"] 299 | 300 | product = _driver.AmiProduct(product_id=product_id, dry_run=dry_run) 301 | response = product.update_regions(region_config) 302 | print(f'ChangeSet created (ID: {response["ChangeSetId"]})') 303 | print(f'https://aws.amazon.com/marketplace/management/requests/{response["ChangeSetId"]}') 304 | 305 | 306 | @public_offer.command("update-version") 307 | @click.option("--product-id", required=True, prompt=True) 308 | @click.option("--config", type=click.File("r"), required=True, prompt=True) 309 | @click.option("--dry-run/--no-dry-run", is_flag=True) 310 | def ami_product_update_version(product_id, config, dry_run): 311 | """ 312 | Update AMI product version 313 | """ 314 | # Load yaml file 315 | version_config = _load_configuration(config, [["product", "version"]])["product"]["version"] 316 | 317 | product = _driver.AmiProduct(product_id=product_id, dry_run=dry_run) 318 | response = product.update_version(version_config) 319 | print(f'ChangeSet created (ID: {response["ChangeSetId"]})') 320 | print(f'https://aws.amazon.com/marketplace/management/requests/{response["ChangeSetId"]}') 321 | 322 | 323 | @public_offer.command("update-legal-terms") 324 | @click.option("--product-id", required=True, prompt=True) 325 | @click.option("--config", type=click.File("r"), required=True, prompt=True) 326 | @click.option("--dry-run/--no-dry-run", is_flag=True) 327 | def ami_product_update_legal_terms(product_id, config, dry_run): 328 | """ 329 | Update AMI product legal terms 330 | """ 331 | # Load yaml file 332 | eula_url = _load_configuration(config, [["offer", "eula_document"]])["offer"]["eula_document"][0] 333 | 334 | product = _driver.AmiProduct(product_id=product_id, dry_run=dry_run) 335 | response = product.update_legal_terms(eula_url) 336 | print(f'ChangeSet created (ID: {response["ChangeSetId"]})') 337 | print(f'https://aws.amazon.com/marketplace/management/requests/{response["ChangeSetId"]}') 338 | 339 | 340 | @public_offer.command("update-support-terms") 341 | @click.option("--product-id", required=True, prompt=True) 342 | @click.option("--config", type=click.File("r"), required=True, prompt=True) 343 | @click.option("--dry-run/--no-dry-run", is_flag=True) 344 | def ami_product_update_support_terms(product_id, config, dry_run): 345 | """ 346 | Update AMI product support terms 347 | """ 348 | # Load yaml file 349 | refund_policy = _load_configuration(config, [["offer", "refund_policy"]])["offer"]["refund_policy"] 350 | 351 | product = _driver.AmiProduct(product_id=product_id, dry_run=dry_run) 352 | response = product.update_support_terms(refund_policy) 353 | print(f'ChangeSet created (ID: {response["ChangeSetId"]})') 354 | print(f'https://aws.amazon.com/marketplace/management/requests/{response["ChangeSetId"]}') 355 | 356 | 357 | @public_offer.command("release") 358 | @click.option("--product-id", required=True, prompt=True) 359 | @click.option("--dry-run/--no-dry-run", is_flag=True) 360 | def ami_product_release(product_id, dry_run): 361 | """ 362 | Publish AMI product as Limited 363 | """ 364 | 365 | product = _driver.AmiProduct(product_id=product_id, dry_run=dry_run) 366 | response = product.release() 367 | print(f'ChangeSet created (ID: {response["ChangeSetId"]})') 368 | print(f'https://aws.amazon.com/marketplace/management/requests/{response["ChangeSetId"]}') 369 | 370 | 371 | @public_offer.command("update") 372 | @click.option("--product-id", required=True, prompt=True) 373 | @click.option("--config", type=click.File("r"), required=True, prompt=True) 374 | @click.option( 375 | "--allow-price-change/--no-allow-price-change", 376 | required=True, 377 | default=False, 378 | is_flag=True, 379 | prompt="Is price update allowed? (y/N). Default is False.", 380 | ) 381 | @click.option("--dry-run/--no-dry-run", is_flag=True) 382 | def ami_product_update(product_id: str, config: TextIO, allow_price_change: bool, dry_run) -> None: 383 | """ 384 | Update AMI product details (description, region, instnance type and pricing) in a single call 385 | :param str product_id: Id of listing 386 | :param TextIO config: file path of local configuration file 387 | :param bool allow_price_change: flag of allowing pricing change to update instance type information 388 | :return: None 389 | :rtype: None 390 | """ 391 | 392 | # Load yaml file 393 | configs = _load_configuration(config, [["product", "description"], ["product", "region"], ["offer"]]) 394 | product = _driver.AmiProduct(product_id=product_id, dry_run=dry_run) 395 | response = product.update(configs, allow_price_change) 396 | 397 | if response: 398 | print(f'ChangeSet created (ID: {response["ChangeSetId"]})') 399 | print(f'https://aws.amazon.com/marketplace/management/requests/{response["ChangeSetId"]}') 400 | 401 | 402 | @public_offer.command("download") 403 | @click.option("--product-id", required=True, prompt=True, help="Product id of the listing") 404 | @click.option( 405 | "--config", type=click.File("w+"), required=True, prompt=True, help="File path of local configuration file" 406 | ) 407 | def ami_product_download(product_id: str, config: TextIO) -> None: 408 | """ 409 | Download YAML local configuration from AWS Marketplace live listing. 410 | :param str product_id: Id of listing 411 | :param TextIO config: file path of local configuration file to download 412 | :return: None 413 | :rtype: None 414 | """ 415 | 416 | yaml_config = models.EntityModel(**_driver.get_full_response(product_id)).to_dict() 417 | yaml_utils.dump(yaml_config, config) 418 | print(f"{config.name} has been successfully written") 419 | 420 | 421 | def _load_configuration(config_path: TextIO, required_fields: List[List[str]]) -> Dict: 422 | """ 423 | Check if keys exist in config file before creating changeset and return config dict 424 | 425 | :param TextIO config_path: File path for configuration yaml file 426 | :param: List of :str: required_fields: List of required keys to request (e.g. [["product", "description"]] or [["product"]]) 427 | :return: dictionary of configuration 428 | :rtype: Dict 429 | """ 430 | 431 | with open(config_path.name, "r") as f: 432 | config = yaml.safe_load(f) 433 | list_of_missing_keys: List[List[str]] = [] 434 | 435 | for keys in required_fields: 436 | missing_keys = [] 437 | temp_config = config 438 | for key in keys: 439 | if key not in temp_config: 440 | idx = keys.index(key) 441 | missing_keys = keys[idx:] 442 | 443 | break 444 | else: 445 | if isinstance(temp_config[key], dict): 446 | temp_config = temp_config[key] 447 | if missing_keys: 448 | list_of_missing_keys.append(missing_keys) 449 | if list_of_missing_keys: 450 | logger.exception(f"Configuration file is missing: {missing_keys}" for missing_keys in list_of_missing_keys) 451 | raise YamlMissingKeyException(missing_keys=list_of_missing_keys) 452 | return config 453 | 454 | 455 | def main(): 456 | # Setting log format 457 | log_formatter = logging.Formatter("%(asctime)s:%(name)s:%(levelname)s:%(message)s") 458 | root_logger = logging.getLogger() 459 | root_logger.setLevel(logging.INFO) 460 | console_handler = logging.StreamHandler() 461 | console_handler.setFormatter(log_formatter) 462 | root_logger.addHandler(console_handler) 463 | cli() 464 | 465 | 466 | if __name__ == "__main__": 467 | main() 468 | -------------------------------------------------------------------------------- /awsmp/changesets.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import json 3 | from decimal import Decimal 4 | from typing import Any, Dict, List, Optional, TypedDict, Union 5 | 6 | import boto3 7 | from pydantic import BaseModel, Field, HttpUrl, conlist, field_validator 8 | from typing_extensions import NotRequired 9 | 10 | from awsmp.constants import CATEGORIES 11 | 12 | from . import models 13 | from .types import ChangeSetType, UpdateDimensionChange 14 | 15 | 16 | def _changeset_create_offer(product_id: str, offer_name: str) -> ChangeSetType: 17 | return { 18 | "ChangeType": "CreateOffer", 19 | "ChangeName": "CreateOfferChange", 20 | "Entity": {"Type": "Offer@1.0"}, 21 | "DetailsDocument": { 22 | "ProductId": product_id, 23 | }, 24 | } 25 | 26 | 27 | def _changeset_create_ami_product() -> ChangeSetType: 28 | return { 29 | "ChangeType": "CreateProduct", 30 | "ChangeName": "CreateProductChange", 31 | "Entity": {"Type": "AmiProduct@1.0"}, 32 | "DetailsDocument": {}, 33 | } 34 | 35 | 36 | def _changeset_update_information( 37 | offer_name: str, 38 | offer_id: str = "$CreateOfferChange.Entity.Identifier", 39 | ) -> ChangeSetType: 40 | return { 41 | "ChangeType": "UpdateInformation", 42 | "Entity": {"Type": "Offer@1.0", "Identifier": offer_id}, 43 | "DetailsDocument": { 44 | "Name": offer_name, 45 | "Description": "testing automatic offer creation", 46 | }, 47 | } 48 | 49 | 50 | def _changeset_update_targeting(buyer_accounts: List[str]) -> ChangeSetType: 51 | return { 52 | "ChangeType": "UpdateTargeting", 53 | "Entity": {"Type": "Offer@1.0", "Identifier": "$CreateOfferChange.Entity.Identifier"}, 54 | "DetailsDocument": {"PositiveTargeting": {"BuyerAccounts": buyer_accounts}}, 55 | } 56 | 57 | 58 | def _changeset_update_pricing_terms( 59 | instance_type_pricing: List[models.InstanceTypePricing], 60 | monthly_subscription_fee: Optional[Decimal] = None, 61 | offer_id: Optional[str] = None, 62 | ) -> ChangeSetType: 63 | """ 64 | Construct the changeset for update pricing term API reqeust 65 | 66 | :param List[models.InstanceTypePricing] instance_type_pricing: List of InstanceTypePricing objects 67 | :param Optional[Decimal] monthly_subscription_fee: Monthly subscription price for monthly recurring charge 68 | :param Optional[str] offer_id: Offer Id to request API 69 | :return: Changeset of updating pricing term 70 | :rtype: ChangeSetReturnType 71 | """ 72 | rate_cards_hourly: List[Dict[str, str]] = [] 73 | rate_cards_annual: List[Dict[str, str]] = [] 74 | 75 | # set offer_id for combined call for private offer creation 76 | if not offer_id: 77 | offer_id = "$CreateOfferChange.Entity.Identifier" 78 | 79 | # generate the rate cards 80 | for instance_type_price in instance_type_pricing: 81 | # Free public listing is 0.00 which is false 82 | if instance_type_price.price_hourly is not None: 83 | rate_cards_hourly.append( 84 | { 85 | "DimensionKey": instance_type_price.name, 86 | "Price": str(instance_type_price.price_hourly), 87 | } 88 | ) 89 | 90 | if instance_type_price.price_annual is not None: 91 | rate_cards_annual.append( 92 | { 93 | "DimensionKey": instance_type_price.name, 94 | "Price": str(instance_type_price.price_annual), 95 | } 96 | ) 97 | 98 | # hourly rate card are required for both public/private offer 99 | terms = [ 100 | { 101 | "Type": "UsageBasedPricingTerm", 102 | "CurrencyCode": "USD", 103 | "RateCards": [{"RateCard": rate_cards_hourly}], 104 | }, 105 | ] 106 | 107 | if monthly_subscription_fee: 108 | terms.append( 109 | { 110 | "Type": "RecurringPaymentTerm", 111 | "CurrencyCode": "USD", 112 | "BillingPeriod": "Monthly", 113 | "Price": str(monthly_subscription_fee), 114 | } 115 | ) 116 | # annual pricing can only be added with hourly 117 | elif rate_cards_annual: 118 | terms.append( 119 | { 120 | "Type": "ConfigurableUpfrontPricingTerm", 121 | "CurrencyCode": "USD", 122 | "RateCards": [ 123 | { 124 | "Selector": { 125 | "Type": "Duration", 126 | "Value": "P365D", 127 | }, 128 | "Constraints": { 129 | "MultipleDimensionSelection": "Allowed", 130 | "QuantityConfiguration": "Allowed", 131 | }, 132 | "RateCard": rate_cards_annual, 133 | } 134 | ], 135 | }, 136 | ) 137 | 138 | # the changeset part 139 | return { 140 | "ChangeType": "UpdatePricingTerms", 141 | "Entity": {"Type": "Offer@1.0", "Identifier": offer_id}, 142 | "DetailsDocument": {"PricingModel": "Usage", "Terms": terms}, 143 | } 144 | 145 | 146 | def _changeset_update_availability(days_from_today: int) -> ChangeSetType: 147 | end = datetime.date.today() + datetime.timedelta(days=days_from_today) 148 | return { 149 | "ChangeType": "UpdateAvailability", 150 | "Entity": { 151 | "Type": "Offer@1.0", 152 | "Identifier": "$CreateOfferChange.Entity.Identifier", 153 | }, 154 | "DetailsDocument": {"AvailabilityEndDate": end.strftime("%Y-%m-%d")}, 155 | } 156 | 157 | 158 | def _changeset_update_legal_terms(eula_document: Dict[str, str], offer_id: Optional[str] = None) -> ChangeSetType: 159 | eula = models.EulaDocumentItem(**eula_document) # type: ignore 160 | 161 | eula_changeset: dict[str, str] = {"Type": eula.type} 162 | if eula.url is not None: 163 | eula_changeset["Url"] = eula.url 164 | elif eula.version is not None: 165 | eula_changeset["Version"] = eula.version 166 | 167 | if not offer_id: 168 | offer_id = "$CreateOfferChange.Entity.Identifier" 169 | 170 | return { 171 | "ChangeType": "UpdateLegalTerms", 172 | "Entity": {"Type": "Offer@1.0", "Identifier": offer_id}, 173 | "DetailsDocument": {"Terms": [{"Type": "LegalTerm", "Documents": [eula_changeset]}]}, 174 | } 175 | 176 | 177 | def _changeset_update_validity_terms(days: int) -> ChangeSetType: 178 | return { 179 | "ChangeType": "UpdateValidityTerms", 180 | "Entity": {"Type": "Offer@1.0", "Identifier": "$CreateOfferChange.Entity.Identifier"}, 181 | "DetailsDocument": {"Terms": [{"Type": "ValidityTerm", "AgreementDuration": f"P{days}D"}]}, 182 | } 183 | 184 | 185 | def _changeset_release_offer(offer_id: Optional[str] = None) -> ChangeSetType: 186 | if not offer_id: 187 | offer_id = "$CreateOfferChange.Entity.Identifier" 188 | return { 189 | "ChangeType": "ReleaseOffer", 190 | "Entity": { 191 | "Type": "Offer@1.0", 192 | "Identifier": offer_id, 193 | }, 194 | "DetailsDocument": {}, 195 | } 196 | 197 | 198 | def _changeset_update_support_terms(refund_policy: str, offer_id: Optional[str] = None) -> ChangeSetType: 199 | if not offer_id: 200 | offer_id = "$CreateOfferChange.Entity.Identifier" 201 | return { 202 | "ChangeType": "UpdateSupportTerms", 203 | "Entity": { 204 | "Type": "Offer@1.0", 205 | "Identifier": offer_id, 206 | }, 207 | "DetailsDocument": { 208 | "Terms": [ 209 | { 210 | "Type": "SupportTerm", 211 | "RefundPolicy": refund_policy, 212 | } 213 | ] 214 | }, 215 | } 216 | 217 | 218 | def _changeset_update_ami_product_description(product_id: str, desc: Dict) -> ChangeSetType: 219 | # description data format checking 220 | m = models.Description(**desc) 221 | 222 | # return changeset 223 | return { 224 | "ChangeType": "UpdateInformation", 225 | "Entity": { 226 | "Type": "AmiProduct@1.0", 227 | "Identifier": product_id, 228 | }, 229 | "DetailsDocument": { 230 | "ProductTitle": desc["product_title"], 231 | "LogoUrl": desc["logourl"], 232 | "ShortDescription": desc["short_description"], 233 | "LongDescription": m.long_description, 234 | "Highlights": desc["highlights"], 235 | "SearchKeywords": desc["search_keywords"], 236 | "Categories": desc["categories"], 237 | "Sku": desc["sku"], 238 | "AdditionalResources": m.additional_resources, 239 | "VideoUrls": desc["video_urls"], 240 | "SupportDescription": m.support_description, 241 | }, 242 | } 243 | 244 | 245 | def _changeset_update_ami_product_instance_type(product_id: str, new_instance_types: List[str]) -> ChangeSetType: 246 | # return changeset 247 | return { 248 | "ChangeType": "AddInstanceTypes", 249 | "Entity": { 250 | "Type": "AmiProduct@1.0", 251 | "Identifier": product_id, 252 | }, 253 | "DetailsDocument": {"InstanceTypes": new_instance_types}, 254 | } 255 | 256 | 257 | def _changeset_update_ami_product_restrict_instance_type( 258 | product_id: str, removed_instance_types: List[str] 259 | ) -> ChangeSetType: 260 | # return changeset 261 | return { 262 | "ChangeType": "RestrictInstanceTypes", 263 | "Entity": { 264 | "Type": "AmiProduct@1.0", 265 | "Identifier": product_id, 266 | }, 267 | "DetailsDocument": {"InstanceTypes": removed_instance_types}, 268 | } 269 | 270 | 271 | def _changeset_update_ami_product_region(product_id: str, region_config: Dict) -> ChangeSetType: 272 | # config file format checking available regions 273 | regions = models.Region(**region_config) 274 | 275 | # return changeset 276 | return { 277 | "ChangeType": "AddRegions", 278 | "Entity": { 279 | "Type": "AmiProduct@1.0", 280 | "Identifier": product_id, 281 | }, 282 | "DetailsDocument": {"Regions": regions.commercial_regions}, 283 | } 284 | 285 | 286 | def _changeset_update_ami_product_future_region(product_id: str, region_config: Dict) -> ChangeSetType: 287 | region = models.Region(**region_config) 288 | # return changeset 289 | return { 290 | "ChangeType": "UpdateFutureRegionSupport", 291 | "Entity": { 292 | "Type": "AmiProduct@1.0", 293 | "Identifier": product_id, 294 | }, 295 | "DetailsDocument": {"FutureRegionSupport": {"SupportedRegions": region.future_region_supported()}}, 296 | } 297 | 298 | 299 | def _build_metered_instance_unit(instance_type: str) -> UpdateDimensionChange: 300 | return { 301 | "Description": instance_type, 302 | "Key": instance_type, 303 | "Name": instance_type, 304 | "Types": [ 305 | "Metered", 306 | ], 307 | "Unit": "Hrs", 308 | } 309 | 310 | 311 | def _changeset_update_ami_product_dimension(product_id: str, new_instance_types: List[str]): 312 | # generate dimension list 313 | dimension_changeset: List[UpdateDimensionChange] = [ 314 | _build_metered_instance_unit(instance_type) for instance_type in new_instance_types 315 | ] 316 | 317 | return { 318 | "ChangeType": "AddDimensions", 319 | "Entity": { 320 | "Type": "AmiProduct@1.0", 321 | "Identifier": product_id, 322 | }, 323 | "DetailsDocument": dimension_changeset, 324 | } 325 | 326 | 327 | def _changeset_update_ami_product_restrict_dimension( 328 | product_id: str, restrict_instance_types: List[str] 329 | ) -> ChangeSetType: 330 | """ 331 | Generate restrict dimension changeset 332 | 333 | :param str product_id: product id 334 | :param List[str] restrict_instance_types: list of instance types to restrict 335 | :return: Changeset 336 | :rtype: ChangeSetType 337 | 338 | e.g.) 339 | { 340 | "ChangeType": "RestrictDimensions", 341 | "Entity": { 342 | "Type: "AmiProduct@1.0" 343 | "Identifier": prod-1234, 344 | }, 345 | "DetailsDocument": [ 346 | {"Key": "c1.medium", "Types": ["Metered"]}, 347 | {"Key": "c3.xlarge", "Types": ["Metered"]}, 348 | ], 349 | } 350 | """ 351 | dimension_changeset: List[dict[str, Any]] = [ 352 | {"Key": instance_type, "Types": ["Metered"]} for instance_type in restrict_instance_types 353 | ] 354 | 355 | return { 356 | "ChangeType": "RestrictDimensions", 357 | "Entity": { 358 | "Type": "AmiProduct@1.0", 359 | "Identifier": product_id, 360 | }, 361 | "DetailsDocument": dimension_changeset, 362 | } 363 | 364 | 365 | def _changeset_update_ami_product_version(product_id: str, version_config: Dict) -> ChangeSetType: 366 | version = models.AmiVersion(**version_config) 367 | # return changeset 368 | return { 369 | "ChangeType": "AddDeliveryOptions", 370 | "Entity": { 371 | "Type": "AmiProduct@1.0", 372 | "Identifier": product_id, 373 | }, 374 | "DetailsDocument": { 375 | "Version": { 376 | "VersionTitle": version.version_title, 377 | "ReleaseNotes": version.release_notes, 378 | }, 379 | "DeliveryOptions": [ 380 | { 381 | "Details": { 382 | "AmiDeliveryOptionDetails": { 383 | "AmiSource": { 384 | "AmiId": version.ami_id, 385 | "AccessRoleArn": version.access_role_arn, 386 | "UserName": version.os_user_name, 387 | "OperatingSystemName": version.os_system_name, 388 | "OperatingSystemVersion": version.os_system_version, 389 | "ScanningPort": version.scanning_port, 390 | }, 391 | "UsageInstructions": version.usage_instructions, 392 | "RecommendedInstanceType": version.recommended_instance_type, 393 | "SecurityGroups": [ 394 | { 395 | "IpProtocol": version.ip_protocol, 396 | "IpRanges": version.ip_ranges, 397 | "FromPort": version.from_port, 398 | "ToPort": version.to_port, 399 | } 400 | ], 401 | } 402 | } 403 | } 404 | ], 405 | }, 406 | } 407 | 408 | 409 | def _changeset_release_ami_product(product_id: str) -> ChangeSetType: 410 | return { 411 | "ChangeType": "ReleaseProduct", 412 | "Entity": { 413 | "Type": "AmiProduct@1.0", 414 | "Identifier": product_id, 415 | }, 416 | "DetailsDocument": {}, 417 | } 418 | 419 | 420 | def get_changesets( 421 | product_id: str, 422 | offer_name: str, 423 | buyer_accounts: List[str], 424 | instance_type_pricing: List[models.InstanceTypePricing], 425 | available_for_days: int, 426 | valid_for_days: int, 427 | eula_document: Dict[str, str], 428 | ) -> List[ChangeSetType]: 429 | return [ 430 | _changeset_create_offer(product_id, offer_name), 431 | _changeset_update_information(offer_name), 432 | _changeset_update_targeting(buyer_accounts), 433 | _changeset_update_pricing_terms(instance_type_pricing), 434 | _changeset_update_availability(available_for_days), 435 | _changeset_update_legal_terms(eula_document), 436 | _changeset_update_validity_terms(valid_for_days), 437 | _changeset_release_offer(), 438 | ] 439 | 440 | 441 | def get_ami_listing_creation_changesets() -> List[ChangeSetType]: 442 | return [ 443 | _changeset_create_ami_product(), 444 | _changeset_create_offer("$CreateProductChange.Entity.Identifier", "Public Offer creation"), 445 | ] 446 | 447 | 448 | def get_ami_listing_update_description_changesets(product_id: str, description: Dict) -> List[ChangeSetType]: 449 | return [ 450 | _changeset_update_ami_product_description(product_id, description), 451 | ] 452 | 453 | 454 | def get_ami_listing_update_instance_type_changesets( 455 | product_id: str, 456 | offer_id: str, 457 | offer_detail: models.Offer, 458 | new_instance_types: List[str], 459 | removed_instance_types: List[str], 460 | ) -> List[ChangeSetType]: 461 | """ 462 | Return list of changeset to restrict instance types with pricing term 463 | :param str product_id: product id 464 | :param str offer_id: offer id 465 | :param models.Offer offer_detail: offer configuration in local confi file 466 | :param List[str] new_instance_types: list of instance types to add to the listing 467 | :param List[str] removed_instance_types: list of instance types to remove from the listing 468 | :return: List of Changesets 469 | :rtype: List[ChangeSetType] 470 | """ 471 | 472 | changeset_list = [ 473 | _changeset_update_pricing_terms( 474 | offer_detail.instance_types, 475 | monthly_subscription_fee=offer_detail.monthly_subscription_fee, 476 | offer_id=offer_id, 477 | ) 478 | ] 479 | if new_instance_types: 480 | changeset_list.extend( 481 | [ 482 | _changeset_update_ami_product_dimension(product_id, new_instance_types), 483 | _changeset_update_ami_product_instance_type(product_id, new_instance_types), 484 | ] 485 | ) 486 | if removed_instance_types: 487 | changeset_list.extend( 488 | [ 489 | _changeset_update_ami_product_restrict_instance_type(product_id, removed_instance_types), 490 | _changeset_update_ami_product_restrict_dimension(product_id, removed_instance_types), 491 | ] 492 | ) 493 | return changeset_list 494 | 495 | 496 | def get_ami_listing_update_region_changesets(product_id: str, region_config: Dict) -> List[ChangeSetType]: 497 | return [ 498 | _changeset_update_ami_product_region(product_id, region_config), 499 | _changeset_update_ami_product_future_region(product_id, region_config), 500 | ] 501 | 502 | 503 | def get_ami_listing_update_version_changesets(product_id: str, version_config: Dict) -> List[ChangeSetType]: 504 | return [ 505 | _changeset_update_ami_product_version(product_id, version_config), 506 | ] 507 | 508 | 509 | def get_ami_listing_update_legal_terms_changesets(eula_document: Dict[str, str], offer_id: str) -> List[ChangeSetType]: 510 | return [ 511 | _changeset_update_legal_terms(eula_document, offer_id=offer_id), 512 | ] 513 | 514 | 515 | def get_ami_listing_update_support_terms_changesets(offer_id: str, refund_policy: str) -> List[ChangeSetType]: 516 | return [ 517 | _changeset_update_support_terms(refund_policy, offer_id=offer_id), 518 | ] 519 | 520 | 521 | def get_ami_release_changesets(product_id: str, offer_id: str) -> List[ChangeSetType]: 522 | return [ 523 | _changeset_release_ami_product(product_id), 524 | _changeset_update_information(f"Product id {product_id} public offer", offer_id), 525 | _changeset_release_offer(offer_id=offer_id), 526 | ] 527 | 528 | 529 | def get_ami_listing_update_changesets(product_id: str, description: dict, region_config: dict) -> List[ChangeSetType]: 530 | return [ 531 | _changeset_update_ami_product_description(product_id, description), 532 | _changeset_update_ami_product_region(product_id, region_config), 533 | _changeset_update_ami_product_future_region(product_id, region_config), 534 | ] 535 | -------------------------------------------------------------------------------- /awsmp/_driver.py: -------------------------------------------------------------------------------- 1 | import csv 2 | import logging 3 | from typing import IO, Any, Dict, List, Optional, Tuple, cast 4 | 5 | import boto3 6 | from botocore.exceptions import ClientError 7 | 8 | from . import changesets, models 9 | from .errors import ( 10 | AccessDeniedException, 11 | AmiPriceChangeError, 12 | AmiPricingModelChangeError, 13 | MissingInstanceTypeError, 14 | NoVersionException, 15 | ResourceNotFoundException, 16 | UnrecognizedClientException, 17 | ValidationException, 18 | ) 19 | from .types import ChangeSetReturnType, ChangeSetType 20 | 21 | logger = logging.getLogger(__name__) 22 | 23 | 24 | class AmiProduct: 25 | def __init__(self, product_id: str, dry_run: bool = False): 26 | self.product_id: str = product_id 27 | self.offer_id = get_public_offer_id(product_id) 28 | self._dry_run = dry_run 29 | 30 | @staticmethod 31 | def create(dry_run: bool): 32 | changeset = changesets.get_ami_listing_creation_changesets() 33 | changeset_name = "Create new AMI Product" 34 | 35 | return get_response(changeset, changeset_name, dry_run) 36 | 37 | def update_legal_terms(self, eula_document: Dict[str, str]) -> ChangeSetReturnType: 38 | changeset = changesets.get_ami_listing_update_legal_terms_changesets(eula_document, self.offer_id) 39 | changeset_name = f"Product {self.product_id} legal terms update" 40 | 41 | return get_response(changeset, changeset_name, self._dry_run) 42 | 43 | def update_support_terms(self, refund_policy: str) -> ChangeSetReturnType: 44 | changeset = changesets.get_ami_listing_update_support_terms_changesets(self.offer_id, refund_policy) 45 | changeset_name = f"Product {self.product_id} support terms update" 46 | 47 | return get_response(changeset, changeset_name, self._dry_run) 48 | 49 | def update_description(self, desc: Dict) -> ChangeSetReturnType: 50 | changeset = changesets.get_ami_listing_update_description_changesets(self.product_id, desc) 51 | changeset_name = f"Product {self.product_id} description update" 52 | 53 | return get_response(changeset, changeset_name, self._dry_run) 54 | 55 | def update_instance_types( 56 | self, offer_config: Dict[str, Any], price_change_allowed: bool 57 | ) -> Optional[ChangeSetReturnType]: 58 | """ 59 | Update instance types and pricing term based on the offer config 60 | :param Dict[str, Any] offer_config: offer configuration loaded from yaml file 61 | :param bool price_change_allowed: flag to indicate price change is allowed 62 | :return: Changeset for updating instance API request or None 63 | :rtype: ChangeSetReturnType or None 64 | """ 65 | 66 | changeset, hourly_diff, annual_diff = self._get_instance_type_changeset_and_pricing_diff( 67 | offer_config, price_change_allowed 68 | ) 69 | changeset_name = f"Product {self.product_id} instance type update" 70 | 71 | if changeset is None: 72 | return None 73 | 74 | return get_response(changeset, changeset_name, self._dry_run) 75 | 76 | def update_regions(self, region_config: Dict) -> ChangeSetReturnType: 77 | changeset = changesets.get_ami_listing_update_region_changesets(self.product_id, region_config) 78 | changeset_name = f"Product {self.product_id} region update" 79 | 80 | return get_response(changeset, changeset_name, self._dry_run) 81 | 82 | def update_version(self, version_config: Dict) -> ChangeSetReturnType: 83 | changeset = changesets.get_ami_listing_update_version_changesets(self.product_id, version_config) 84 | changeset_name = f"Product {self.product_id} version update" 85 | 86 | return get_response(changeset, changeset_name, self._dry_run) 87 | 88 | def release(self) -> ChangeSetReturnType: 89 | changeset = changesets.get_ami_release_changesets(self.product_id, self.offer_id) 90 | changeset_name = f"Product {self.product_id} publish as limited" 91 | 92 | return get_response(changeset, changeset_name, self._dry_run) 93 | 94 | def update(self, configs: Dict[str, Any], price_change_allowed: bool) -> Optional[ChangeSetReturnType]: 95 | """ 96 | Update AMI product details (Description, Region, Instance type) and public offer pricing term 97 | :prarm configs dict[str, Any]: Local configuration file 98 | :param bool price_change_allowed: Flag to indicate price change is allowed 99 | :return: Response from the request 100 | :rtype: ChangeSetReturnType 101 | """ 102 | changeset = changesets.get_ami_listing_update_changesets( 103 | self.product_id, configs["product"]["description"], configs["product"]["region"] 104 | ) 105 | 106 | changeset_pricing, hourly_diff, annual_diff = self._get_instance_type_changeset_and_pricing_diff( 107 | configs["offer"], price_change_allowed 108 | ) 109 | 110 | if hourly_diff or annual_diff: 111 | if not price_change_allowed: 112 | logger.error( 113 | "There are pricing changes but changing price flag is not set. Please check the pricing files or set the price flag.\nPrice change details:\nHourly: %s\nAnnual: %s\n" 114 | % (hourly_diff, annual_diff) 115 | ) 116 | return None 117 | 118 | if changeset_pricing is not None: 119 | changeset.extend(changeset_pricing) 120 | 121 | changeset_name = f"Product {self.product_id} update product details" 122 | 123 | return get_response(changeset, changeset_name, self._dry_run) 124 | 125 | def _get_product_title(self): 126 | return get_entity_details(self.product_id)["Description"]["ProductTitle"] 127 | 128 | def _get_instance_type_changeset_and_pricing_diff( 129 | self, offer_config: Dict[str, Any], price_change_allowed: bool 130 | ) -> Tuple[Optional[List[ChangeSetType]], List, List]: 131 | """ 132 | Get the instance type and pricing term changeset and pricing diffs 133 | :param offer_config Dict[str, Any]: offer configuration loaded from yaml file 134 | :return: Set of changesets, hourly pricing differences and annual pricing differences 135 | :rtype: Tuple[Optional[List[ChangeSetType]], List, List] 136 | """ 137 | offer_detail = models.Offer(**offer_config) 138 | 139 | local_instance_types = {instance_type.name for instance_type in offer_detail.instance_types} 140 | existing_instance_types = _get_existing_instance_types(self.product_id) 141 | new_instance_types = list(local_instance_types - existing_instance_types) 142 | removed_instance_types = list(existing_instance_types - local_instance_types) 143 | 144 | changeset = changesets.get_ami_listing_update_instance_type_changesets( 145 | self.product_id, self.offer_id, offer_detail, new_instance_types, removed_instance_types 146 | ) 147 | 148 | hourly_diff, annual_diff = _get_pricing_diff(self.product_id, changeset, price_change_allowed) 149 | 150 | if not hourly_diff and not annual_diff: 151 | if not new_instance_types and not removed_instance_types: 152 | # There are nothing to update 153 | logger.info("There is no instance information details to update.") 154 | return None, [], [] 155 | 156 | return changeset, hourly_diff, annual_diff 157 | 158 | 159 | def get_client(service_name="marketplace-catalog", region_name="us-east-1"): 160 | return boto3.client(service_name, region_name=region_name) 161 | 162 | 163 | def get_response(changeset: List[ChangeSetType], changeset_name: str, dry_run: bool = False) -> ChangeSetReturnType: 164 | """ 165 | Request to AWS and get response of either success of failure 166 | 167 | :param List[ChangeSetType] changeset: list of changesets 168 | :param str changeset_name: name of changeset 169 | :param bool dry_run: should actions be dry_run 170 | :return: changeset with type, entity, details etc. 171 | :rtype: ChangeSetReturnType 172 | """ 173 | changeset_name = changeset_name.replace(",", "_").replace("(", "").replace(")", "") 174 | logger.info( 175 | "Requesting changes to marketplace listing", extra={"ChangeSetName": changeset_name, "ChangeSet": changeset} 176 | ) 177 | if dry_run: 178 | logger.warning("DRY_RUN: %s", changeset) 179 | return { 180 | "ChangeSetArn": "DRY_RUN", 181 | "ChangeSetId": "DRY_RUN", 182 | } 183 | try: 184 | response = get_client().start_change_set( 185 | Catalog="AWSMarketplace", 186 | ChangeSet=changeset, 187 | ChangeSetName=changeset_name, 188 | ) 189 | except ClientError as e: 190 | _raise_client_error(e) 191 | 192 | return response 193 | 194 | 195 | def _raise_client_error(exception: ClientError): 196 | exception_code, error_msg = exception.response["Error"]["Code"], exception.response["Error"]["Message"] 197 | if exception_code == "AccessDeniedException": 198 | logger.exception(f"Profile does not have marketplace access. Please check your profile role or services.") 199 | raise AccessDeniedException(service_name="marketplace") 200 | elif exception_code == "UnrecognizedClientException": 201 | logger.exception(f"Profile is not configured correctly. Please check your credential with associated profile.") 202 | raise UnrecognizedClientException from None 203 | elif exception_code == "ResourceNotFoundException": 204 | logger.exception(f"Product/Offer ID does not exist. Please check IDs and try again.") 205 | raise ResourceNotFoundException from None 206 | elif exception_code == "ValidationException": 207 | logger.exception(f"Please check schema regex and request with fixed value.") 208 | raise ValidationException(error_msg) from None 209 | else: 210 | logger.exception(error_msg) 211 | raise Exception 212 | 213 | 214 | def list_entities(entity_type: str) -> dict[str, dict[str, str]]: 215 | client = get_client() 216 | entities = dict() 217 | paginator = client.get_paginator("list_entities") 218 | page_iterator = paginator.paginate( 219 | Catalog="AWSMarketplace", 220 | EntityType=entity_type, 221 | ) 222 | for page in page_iterator: 223 | for e in page["EntitySummaryList"]: 224 | entities[e["EntityId"]] = e 225 | return entities 226 | 227 | 228 | def get_entities_by_visibility(entity_type: str, visibilities: tuple[models.AmiVisibility, ...]) -> list[dict]: 229 | """ 230 | Return entity summaries for the given entity_type filtered by Marketplace Visibility. 231 | 232 | :param str entity_type: Marketplace entity type, e.g. "Offer" or "AmiProduct" 233 | :param tuple[Visibility, ...] visibilities: One or more Visibility enum values to include 234 | :return: A list of entity summary dicts as returned by ListEntities 235 | :rtype: list[dict] 236 | """ 237 | entities = list_entities(entity_type) 238 | return [e for e in entities.values() if not visibilities or e["Visibility"] in visibilities] 239 | 240 | 241 | def get_entity_details(entity_id: str) -> Dict: 242 | client = get_client() 243 | try: 244 | e = client.describe_entity(Catalog="AWSMarketplace", EntityId=entity_id) 245 | except ClientError as error: 246 | _raise_client_error(error) 247 | 248 | return e["DetailsDocument"] 249 | 250 | 251 | def get_ami_product_version_summary() -> list[models.AmiProductVersionSummary]: 252 | """ 253 | Query a list each marketplace entry with its number of versions. 254 | """ 255 | entity_dict = list_entities("AmiProduct") 256 | versions = [ 257 | models.AmiProductVersionSummary(entity_id, len(get_entity_versions(entity_id)), entity_dict[entity_id]["Name"]) 258 | for entity_id in entity_dict.keys() 259 | ] 260 | return versions 261 | 262 | 263 | def get_public_offer_id(entity_id: str): 264 | client = get_client() 265 | e = client.list_entities( 266 | Catalog="AWSMarketplace", 267 | EntityType="Offer", 268 | EntityTypeFilters={ 269 | "OfferFilters": { 270 | "ProductId": { 271 | "ValueList": [ 272 | entity_id, 273 | ] 274 | }, 275 | "Targeting": {"ValueList": ["None"]}, 276 | } 277 | }, 278 | ) 279 | if not e["EntitySummaryList"]: 280 | raise ResourceNotFoundException(f"\n\nOffer with entity-id {entity_id} not found.\n") 281 | 282 | return e["EntitySummaryList"][0]["EntityId"] 283 | 284 | 285 | def get_entity_versions(entity_id: str) -> List[dict[str, str]]: 286 | details = get_entity_details(entity_id) 287 | if "Versions" not in details.keys(): 288 | return [] 289 | return sorted(details["Versions"], key=lambda x: x["CreationDate"]) 290 | 291 | 292 | def _get_ratecard_info(changeset: Dict, idx: int, instance_types: List[str]) -> List[Dict]: 293 | ratecard = changeset[3]["DetailsDocument"]["Terms"][idx]["RateCards"][0]["RateCard"] 294 | return [r for r in ratecard if r["DimensionKey"] in instance_types] 295 | 296 | 297 | def _get_full_ratecard_info(terms: List) -> Tuple[List, List]: 298 | """ 299 | Get the full ratecard information from Terms 300 | :param List terms: Terms details from the entity or changeset details 301 | :return two lists of hourly or/and annual rate cards 302 | :rtype: Tuple[List, List] 303 | """ 304 | hourly, annual = [], [] 305 | for term in terms: 306 | if term["Type"] == "UsageBasedPricingTerm": 307 | hourly = term["RateCards"][0]["RateCard"] 308 | elif term["Type"] == "ConfigurableUpfrontPricingTerm": 309 | annual = term["RateCards"][0]["RateCard"] 310 | 311 | return hourly, annual 312 | 313 | 314 | def _build_pricing_diff(existing_prices: List, local_prices: List) -> List: 315 | """ 316 | Compare prices of each instance types and return difference details 317 | :param List existing_prices: price information from existing/live listing 318 | :param List local_prices: price information from local configuration file 319 | :return: List of different pricing information for an instance type 320 | :rtype: List 321 | """ 322 | original_pricing, local_pricing = {}, {} 323 | if existing_prices: 324 | original_pricing = {price["DimensionKey"]: price["Price"] for price in existing_prices} 325 | if local_prices: 326 | local_pricing = {price["DimensionKey"]: price["Price"] for price in local_prices} 327 | 328 | diffs = [] 329 | for key in original_pricing: 330 | if key in local_pricing and float(original_pricing[key]) != float(local_pricing[key]): 331 | diffs.append( 332 | {"DimensionKey": key, "Original Price": original_pricing[key], "New Price": local_pricing[key]} 333 | ) 334 | 335 | return diffs 336 | 337 | 338 | def _get_pricing_diff(product_id: str, changeset: List[ChangeSetType], allow_price_update: bool) -> Tuple[List, List]: 339 | """ 340 | Check if there are differences between the given changeset from the local configuration and the existing listing pricing terms 341 | :param str product_id: product id of existing/live listing 342 | :param List[ChangeSetType] chageset: changeset from local configuration file 343 | :return: Hourly and Anuual pricing diff details 344 | :rtype: Tuple[List, List] 345 | """ 346 | change = cast(dict[str, Any], changeset[0]) 347 | local_details_document = change["DetailsDocument"] 348 | local_pricing_changesets = local_details_document["Terms"] 349 | local_hourly, local_annual = _get_full_ratecard_info(local_pricing_changesets) 350 | 351 | # existing pricing information from the listing 352 | existing_listing_status = get_entity_details(product_id)["Description"]["Visibility"] 353 | existing_terms = get_entity_details(get_public_offer_id(product_id))["Terms"] 354 | existing_hourly, existing_annual = _get_full_ratecard_info(existing_terms) 355 | 356 | diffs_hourly = _build_pricing_diff(existing_hourly, local_hourly) 357 | diffs_annual = _build_pricing_diff(existing_annual, local_annual) 358 | 359 | def _has_different_pricing_model(): 360 | return models.Offer.get_offer_type_from_offer_terms( 361 | local_pricing_changesets 362 | ) != models.Offer.get_offer_type_from_offer_terms(existing_terms) 363 | 364 | if existing_listing_status == "Restricted": 365 | # restricted instances do not support updating instance types 366 | error_message = "Restricted listings may not have instance types updated." 367 | raise AmiPriceChangeError(error_message) 368 | elif existing_listing_status != "Draft" and _has_different_pricing_model(): 369 | raise AmiPricingModelChangeError("Listing is published. Contact AWS Marketplace to change the pricing type.") 370 | 371 | existing_hourly, existing_annual = _get_full_ratecard_info(existing_terms) 372 | 373 | def any_zero_to_paid(diffs): 374 | # check if pricing request from free (0.0) to non-zero prices 375 | return bool(diffs) and any( 376 | float(item["Original Price"]) == 0.0 and float(item["New Price"]) != 0.0 for item in diffs 377 | ) 378 | 379 | instance_configuration_changed = any( 380 | [local_annual and not existing_annual, existing_annual and not local_annual, diffs_annual, diffs_hourly] 381 | ) 382 | 383 | if (any_zero_to_paid(diffs_hourly) or any_zero_to_paid(diffs_annual)) and not allow_price_update: 384 | error_msg = f"""Free product was attempted to be converted to paid product. 385 | Please check the pricing files or set the price flag.\n 386 | Price change details:\n 387 | Local pricing updates: {local_annual}\nExisting pricing in local: {existing_annual}\n" 388 | """ 389 | logger.error(error_msg) 390 | raise AmiPriceChangeError(error_msg) 391 | 392 | elif instance_configuration_changed and not allow_price_update: 393 | error_message = f"""There are pricing changes in either hourly or annual prices. 394 | Please check the pricing files or allow price change. 395 | Price change details:\n 396 | Local pricing updates: {local_annual}\nExisting pricing in local: {existing_annual}\n" 397 | """ 398 | logger.error(error_message) 399 | raise AmiPriceChangeError(error_message) 400 | 401 | return diffs_hourly, diffs_annual 402 | 403 | 404 | def build_pricing_rows_from_offer(offer_id: str, *, free: bool = False) -> list[tuple[str, str, str]]: 405 | """ 406 | Return [(instance_type, hourly, annual)] based on an existing offer. 407 | """ 408 | e = get_client().describe_entity(Catalog="AWSMarketplace", EntityId=offer_id) 409 | details = e["DetailsDocument"] 410 | 411 | prices_hourly = {} 412 | prices_annual = {} 413 | for term in details["Terms"]: 414 | if term["Type"] not in ["UsageBasedPricingTerm", "ConfigurableUpfrontPricingTerm"]: 415 | continue 416 | for rate_card in term["RateCards"]: 417 | for d in rate_card["RateCard"]: 418 | if term["Type"] == "UsageBasedPricingTerm": 419 | # hourly 420 | prices_hourly[d["DimensionKey"]] = d["Price"] 421 | elif term["Type"] == "ConfigurableUpfrontPricingTerm": 422 | # annual 423 | prices_annual[d["DimensionKey"]] = d["Price"] 424 | else: 425 | raise Exception(f'Unknown terms type {term["type"]}') 426 | 427 | # both should have the same keys so calculate the symmetric difference 428 | # this should never happen given that we get the data from an available offer 429 | # free listing can be skipped since it doesn't have annual pricing 430 | if not free: 431 | if prices_hourly.keys() ^ prices_annual.keys(): 432 | raise Exception("instance type dimensions are not identical in hourly and annual prices") 433 | else: 434 | prices_annual = prices_hourly 435 | 436 | return [(it, prices_hourly[it], prices_annual[it]) for it in sorted(prices_hourly.keys())] 437 | 438 | 439 | def _get_existing_instance_types(product_id: str): 440 | entity = get_entity_details(product_id) 441 | # New created product does not have existing instance types 442 | existing_instance_types = set() 443 | if "Dimensions" in entity: 444 | existing_instance_types = {t["Name"] for t in entity["Dimensions"]} 445 | return existing_instance_types 446 | 447 | 448 | def get_available_instance_types(arch: str, virt: str) -> list[str]: 449 | """ 450 | Return available EC2 instance types for the given arch/virt. 451 | """ 452 | client = get_client(service_name="ec2") 453 | try: 454 | e = client.get_instance_types_from_instance_requirements( 455 | ArchitectureTypes=[arch], 456 | VirtualizationTypes=[virt], 457 | InstanceRequirements={ 458 | "VCpuCount": { 459 | "Min": 0, 460 | }, 461 | "MemoryMiB": { 462 | "Min": 0, 463 | }, 464 | }, 465 | ) 466 | except ClientError: 467 | logger.exception("Profile does not have EC2 service access. Check your profile role or services.") 468 | raise AccessDeniedException(service_name="ec2") 469 | 470 | available_instances = [i["InstanceType"] for i in e["InstanceTypes"]] 471 | 472 | return available_instances 473 | 474 | 475 | def _filter_instance_types(product_id: str, changeset, hourly=False): 476 | existing_instance_types = _get_existing_instance_types(product_id) 477 | pricing_instance_types = { 478 | t["DimensionKey"] for t in changeset[3]["DetailsDocument"]["Terms"][0]["RateCards"][0]["RateCard"] 479 | } 480 | 481 | if missing_instance_types := existing_instance_types.difference(pricing_instance_types): 482 | logger.exception(f"Instance types does not match with original listing.") 483 | raise MissingInstanceTypeError(missing_instance_types) 484 | intersect = list(pricing_instance_types.intersection(existing_instance_types)) 485 | 486 | # idx 0 is hourly pricing, and 1 is annual 487 | indexes = {0} if hourly else {0, 1} 488 | for idx in indexes: 489 | changeset[3]["DetailsDocument"]["Terms"][idx]["RateCards"][0]["RateCard"] = _get_ratecard_info( 490 | changeset, idx, intersect 491 | ) 492 | return changeset 493 | 494 | 495 | def offer_create( 496 | product_id: str, 497 | buyer_accounts: list[str], 498 | available_for_days: int, 499 | valid_for_days: int, 500 | offer_name: str, 501 | eula_url: Optional[str], 502 | pricing: IO, 503 | dry_run: bool, 504 | hourly: bool = False, 505 | ) -> ChangeSetReturnType: 506 | csvreader = csv.DictReader(pricing, fieldnames=["name", "price_hourly", "price_annual"]) 507 | instance_type_pricing = [models.InstanceTypePricing(**line) for line in csvreader] # type:ignore 508 | 509 | if hourly: 510 | for i in instance_type_pricing: 511 | i.price_annual = None 512 | 513 | if eula_url: 514 | eula_document = {"type": "CustomEula", "url": eula_url} 515 | else: 516 | eula_document = {"type": "StandardEula", "version": "2022-07-14"} 517 | 518 | changeset_list = changesets.get_changesets( 519 | product_id, 520 | offer_name, 521 | buyer_accounts, 522 | instance_type_pricing, 523 | available_for_days, 524 | valid_for_days + available_for_days + 1, 525 | eula_document, 526 | ) 527 | 528 | changeset_list = _filter_instance_types(product_id, changeset_list, hourly=hourly) 529 | 530 | changeset_name = f'{f"create private offer for {product_id}: {offer_name}"[:95]}...' 531 | 532 | return get_response(changeset_list, changeset_name, dry_run) 533 | 534 | 535 | def create_offer_name(product_id: str, buyer_accounts: List[str], with_support: bool, customer_name: str) -> str: 536 | details = get_entity_details(product_id) 537 | 538 | account_part = ",".join(buyer_accounts) 539 | if len(account_part) > 50: 540 | account_part = f"{account_part[:47]}..." 541 | title_part = details["Description"]["ProductTitle"] 542 | support_part = " wSupport" if with_support else "" 543 | 544 | return f"Offer - {account_part} - {title_part}{support_part} - {customer_name}"[:150] 545 | 546 | 547 | def get_full_response(product_id: str) -> dict[str, Any]: 548 | """ 549 | Return the full response details from `entity_describe` output 550 | 551 | :param dict[str, Any] product_id: Product id of the listing 552 | :return: Dictionary of response details 553 | :rtype: dict 554 | """ 555 | 556 | listing_resp = get_entity_details(product_id) 557 | # keep the only latest version 558 | if "Versions" in listing_resp: 559 | if listing_resp["Versions"]: 560 | listing_resp["Versions"] = listing_resp["Versions"][-1] 561 | else: 562 | raise NoVersionException("Version information is empty. No version details are available.") 563 | else: 564 | raise NoVersionException("Version is not found. Listing does not have version information") 565 | 566 | offer_id = get_public_offer_id(product_id) 567 | listing_offer_resp = get_entity_details(offer_id) 568 | 569 | # filtering required term details only 570 | listing_resp["Terms"] = [] 571 | term_order = {"SupportTerm": 0, "UsageBasedPricingTerm": 1, "ConfigurableUpfrontPricingTerm": 2} 572 | if "Terms" in listing_offer_resp: 573 | listing_resp["Terms"] = sorted( 574 | [term for term in listing_offer_resp.get("Terms", []) if term["Type"] in term_order], 575 | key=lambda x: term_order.get(x["Type"], 3), 576 | ) 577 | return listing_resp 578 | 579 | 580 | def diff_entity_id_vs_local(entity_id: str, local_entity: models.EntityModel): 581 | """ 582 | Fetch live by id, compare against provided local model. 583 | """ 584 | 585 | entity_from_listing = models.EntityModel(**get_full_response(entity_id)) 586 | diff = entity_from_listing.get_diff(local_entity) 587 | 588 | return diff 589 | -------------------------------------------------------------------------------- /tests/test_changesets.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, List, Union, cast 2 | from unittest.mock import patch 3 | 4 | import pytest 5 | import yaml 6 | from pydantic import ValidationError 7 | 8 | from awsmp import changesets, models, types 9 | 10 | 11 | @pytest.mark.parametrize( 12 | "eula_document,expected", 13 | [ 14 | ({"type": "StandardEula", "version": "2022-07-14"}, {"Type": "StandardEula", "Version": "2022-07-14"}), 15 | ({"type": "CustomEula", "url": "foobar"}, {"Type": "CustomEula", "Url": "foobar"}), 16 | ], 17 | ) 18 | def test_changeset_update_legal_terms_eula_options(eula_document, expected): 19 | result = changesets._changeset_update_legal_terms(eula_document) 20 | details_document = cast(Dict[str, Any], result["DetailsDocument"]) 21 | assert details_document["Terms"][0]["Documents"][0] == expected 22 | 23 | 24 | @pytest.mark.parametrize( 25 | "eula_document, expected_msg", 26 | [ 27 | ({"type": "StandardEula"}, "Specify version of StandardEula"), 28 | ({"type": "CustomEula", "version": "foobar"}, "can't pass version of standard document"), 29 | ], 30 | ) 31 | def test_changeset_update_legal_terms_invalid_eula_options(eula_document, expected_msg): 32 | with pytest.raises(ValidationError) as e: 33 | changesets._changeset_update_legal_terms(eula_document) 34 | 35 | assert expected_msg in str(e.value) 36 | 37 | 38 | @pytest.mark.parametrize( 39 | "file_path, expected_desc", 40 | [ 41 | ("./tests/test_config.yaml", "test"), 42 | ("./tests/local_config/test_config_3.yaml", "test_prod"), 43 | ("./tests/local_config/test_config_4.yaml", "test_prod_id"), 44 | ], 45 | ) 46 | @patch("awsmp.models.boto3") 47 | def test_get_ami_product_update_changeset_description_title(mock_boto3, file_path, expected_desc): 48 | mock_boto3.client.return_value.describe_regions.return_value = { 49 | "Regions": [ 50 | {"Endpoint": "ec2.us-east-1.amazonaws.com", "RegionName": "us-east-1", "OptInStatus": "opted-in"}, 51 | {"Endpoint": "ec2.us-east-2.amazonaws.com", "RegionName": "us-east-2", "OptInStatus": "opted-in"}, 52 | ] 53 | } 54 | with open(file_path, "r") as f: 55 | config = yaml.safe_load(f) 56 | res: List[types.ChangeSetType] = changesets.get_ami_listing_update_changesets( 57 | "test-id", config["product"]["description"], config["product"]["region"] 58 | ) 59 | details_document = cast(Dict[str, Any], res[0]["DetailsDocument"]) 60 | assert details_document["ProductTitle"] == expected_desc 61 | 62 | 63 | @pytest.mark.parametrize( 64 | "file_path, expected_desc", 65 | [ 66 | ("./tests/test_config.yaml", "test_long_description"), 67 | ("./tests/local_config/test_config_3.yaml", "test_long_description\n\nvery_long"), 68 | ("./tests/local_config/test_config_4.yaml", "test_long_description\nnew_line\nanother_new_line"), 69 | ], 70 | ) 71 | @patch("awsmp.models.boto3") 72 | def test_get_ami_product_update_changeset_description_long_desc(mock_boto3, file_path, expected_desc): 73 | mock_boto3.client.return_value.describe_regions.return_value = { 74 | "Regions": [ 75 | {"Endpoint": "ec2.us-east-1.amazonaws.com", "RegionName": "us-east-1", "OptInStatus": "opted-in"}, 76 | {"Endpoint": "ec2.us-east-2.amazonaws.com", "RegionName": "us-east-2", "OptInStatus": "opted-in"}, 77 | ] 78 | } 79 | with open(file_path, "r") as f: 80 | config = yaml.safe_load(f) 81 | res: List[types.ChangeSetType] = changesets.get_ami_listing_update_changesets( 82 | "test-id", config["product"]["description"], config["product"]["region"] 83 | ) 84 | details_document = cast(Dict[str, Any], res[0]["DetailsDocument"]) 85 | assert details_document["LongDescription"] == expected_desc 86 | 87 | 88 | @pytest.mark.parametrize( 89 | "file_path, expected_desc", 90 | [ 91 | ("./tests/test_config.yaml", "test_short_description"), 92 | ("./tests/local_config/test_config_3.yaml", "test_short_description\nshort_description"), 93 | ( 94 | "./tests/local_config/test_config_4.yaml", 95 | "test_long_description and another short description and short description", 96 | ), 97 | ], 98 | ) 99 | @patch("awsmp.models.boto3") 100 | def test_get_ami_product_update_changeset_description_short_desc(mock_boto3, file_path, expected_desc): 101 | mock_boto3.client.return_value.describe_regions.return_value = { 102 | "Regions": [ 103 | {"Endpoint": "ec2.us-east-1.amazonaws.com", "RegionName": "us-east-1", "OptInStatus": "opted-in"}, 104 | {"Endpoint": "ec2.us-east-2.amazonaws.com", "RegionName": "us-east-2", "OptInStatus": "opted-in"}, 105 | ] 106 | } 107 | with open(file_path, "r") as f: 108 | config = yaml.safe_load(f) 109 | res: List[types.ChangeSetType] = changesets.get_ami_listing_update_changesets( 110 | "test-id", config["product"]["description"], config["product"]["region"] 111 | ) 112 | details_document = cast(Dict[str, Any], res[0]["DetailsDocument"]) 113 | assert details_document["ShortDescription"] == expected_desc 114 | 115 | 116 | @pytest.mark.parametrize( 117 | "file_path, expected_desc", 118 | [ 119 | ("./tests/test_config.yaml", "https://test-logourl"), 120 | ("./tests/local_config/test_config_3.yaml", "https://test-logourl.pdf"), 121 | ("./tests/local_config/test_config_4.yaml", "https://test-logourl.svg"), 122 | ], 123 | ) 124 | @patch("awsmp.models.boto3") 125 | def test_get_ami_product_update_changeset_description_logourl(mock_boto3, file_path, expected_desc): 126 | mock_boto3.client.return_value.describe_regions.return_value = { 127 | "Regions": [ 128 | {"Endpoint": "ec2.us-east-1.amazonaws.com", "RegionName": "us-east-1", "OptInStatus": "opted-in"}, 129 | {"Endpoint": "ec2.us-east-2.amazonaws.com", "RegionName": "us-east-2", "OptInStatus": "opted-in"}, 130 | ] 131 | } 132 | with open(file_path, "r") as f: 133 | config = yaml.safe_load(f) 134 | res: List[types.ChangeSetType] = changesets.get_ami_listing_update_changesets( 135 | "test-id", config["product"]["description"], config["product"]["region"] 136 | ) 137 | details_document = cast(Dict[str, Any], res[0]["DetailsDocument"]) 138 | assert details_document["LogoUrl"] == expected_desc 139 | 140 | 141 | @pytest.mark.parametrize( 142 | "file_path, expected_desc", 143 | [ 144 | ("./tests/test_config.yaml", ["test_highlight_1"]), 145 | ("./tests/local_config/test_config_3.yaml", ["test_highlight_1", "test_highlight_2"]), 146 | ("./tests/local_config/test_config_4.yaml", ["test_highlight_1", "test_highlight_2", "test_highlight_3"]), 147 | ], 148 | ) 149 | @patch("awsmp.models.boto3") 150 | def test_get_ami_product_update_changeset_description_highlights(mock_boto3, file_path, expected_desc): 151 | mock_boto3.client.return_value.describe_regions.return_value = { 152 | "Regions": [ 153 | {"Endpoint": "ec2.us-east-1.amazonaws.com", "RegionName": "us-east-1", "OptInStatus": "opted-in"}, 154 | {"Endpoint": "ec2.us-east-2.amazonaws.com", "RegionName": "us-east-2", "OptInStatus": "opted-in"}, 155 | ] 156 | } 157 | with open(file_path, "r") as f: 158 | config = yaml.safe_load(f) 159 | res: List[types.ChangeSetType] = changesets.get_ami_listing_update_changesets( 160 | "test-id", config["product"]["description"], config["product"]["region"] 161 | ) 162 | details_document = cast(Dict[str, Any], res[0]["DetailsDocument"]) 163 | assert details_document["Highlights"] == expected_desc 164 | 165 | 166 | @pytest.mark.parametrize( 167 | "file_path, expected_desc", 168 | [ 169 | ("./tests/test_config.yaml", ["test_keyword_1"]), 170 | ("./tests/local_config/test_config_3.yaml", ["test_keyword_1", "test_keyword_2"]), 171 | ( 172 | "./tests/local_config/test_config_4.yaml", 173 | [ 174 | "test_keyword_1", 175 | "test_keyword_2", 176 | "test_keyword_3", 177 | "test_keyword_4", 178 | "test_keyword_5", 179 | "test_keyword_6", 180 | ], 181 | ), 182 | ], 183 | ) 184 | @patch("awsmp.models.boto3") 185 | def test_get_ami_product_update_changeset_description_search_keywords(mock_boto3, file_path, expected_desc): 186 | mock_boto3.client.return_value.describe_regions.return_value = { 187 | "Regions": [ 188 | {"Endpoint": "ec2.us-east-1.amazonaws.com", "RegionName": "us-east-1", "OptInStatus": "opted-in"}, 189 | {"Endpoint": "ec2.us-east-2.amazonaws.com", "RegionName": "us-east-2", "OptInStatus": "opted-in"}, 190 | ] 191 | } 192 | with open(file_path, "r") as f: 193 | config = yaml.safe_load(f) 194 | res: List[types.ChangeSetType] = changesets.get_ami_listing_update_changesets( 195 | "test-id", config["product"]["description"], config["product"]["region"] 196 | ) 197 | details_document = cast(Dict[str, Any], res[0]["DetailsDocument"]) 198 | assert details_document["SearchKeywords"] == expected_desc 199 | 200 | 201 | @pytest.mark.parametrize( 202 | "file_path, expected_desc", 203 | [ 204 | ("./tests/test_config.yaml", ["Migration"]), 205 | ("./tests/local_config/test_config_3.yaml", ["Migration", "Testing"]), 206 | ("./tests/local_config/test_config_4.yaml", ["Migration", "Testing", "Blockchain"]), 207 | ], 208 | ) 209 | @patch("awsmp.models.boto3") 210 | def test_get_ami_product_update_changeset_description_categories(mock_boto3, file_path, expected_desc): 211 | mock_boto3.client.return_value.describe_regions.return_value = { 212 | "Regions": [ 213 | {"Endpoint": "ec2.us-east-1.amazonaws.com", "RegionName": "us-east-1", "OptInStatus": "opted-in"}, 214 | {"Endpoint": "ec2.us-east-2.amazonaws.com", "RegionName": "us-east-2", "OptInStatus": "opted-in"}, 215 | ] 216 | } 217 | with open(file_path, "r") as f: 218 | config = yaml.safe_load(f) 219 | res: List[types.ChangeSetType] = changesets.get_ami_listing_update_changesets( 220 | "test-id", config["product"]["description"], config["product"]["region"] 221 | ) 222 | details_document = cast(Dict[str, Any], res[0]["DetailsDocument"]) 223 | assert details_document["Categories"] == expected_desc 224 | 225 | 226 | @pytest.mark.parametrize( 227 | "file_path, expected_desc", 228 | [ 229 | ("./tests/test_config.yaml", [{"Text": "test-link", "Url": "https://test-url/"}]), 230 | ( 231 | "./tests/local_config/test_config_3.yaml", 232 | [{"Text": "test-link", "Url": "https://test-url/"}, {"Text": "test-link2", "Url": "https://test-url2/"}], 233 | ), 234 | ( 235 | "./tests/local_config/test_config_4.yaml", 236 | [ 237 | {"Text": "test-link1", "Url": "https://test-url1/"}, 238 | {"Text": "test-link2", "Url": "https://test-url2/"}, 239 | {"Text": "test-link3", "Url": "https://test-url3/"}, 240 | ], 241 | ), 242 | ], 243 | ) 244 | @patch("awsmp.models.boto3") 245 | def test_get_ami_product_update_changeset_additional_resources(mock_boto3, file_path, expected_desc): 246 | mock_boto3.client.return_value.describe_regions.return_value = { 247 | "Regions": [ 248 | {"Endpoint": "ec2.us-east-1.amazonaws.com", "RegionName": "us-east-1", "OptInStatus": "opted-in"}, 249 | {"Endpoint": "ec2.us-east-2.amazonaws.com", "RegionName": "us-east-2", "OptInStatus": "opted-in"}, 250 | ] 251 | } 252 | with open(file_path, "r") as f: 253 | config = yaml.safe_load(f) 254 | res: List[types.ChangeSetType] = changesets.get_ami_listing_update_changesets( 255 | "test-id", config["product"]["description"], config["product"]["region"] 256 | ) 257 | details_document = cast(Dict[str, Any], res[0]["DetailsDocument"]) 258 | assert details_document["AdditionalResources"] == expected_desc 259 | 260 | 261 | @pytest.mark.parametrize( 262 | "file_path, expected_desc", 263 | [ 264 | ("./tests/test_config.yaml", "test_support_description"), 265 | ("./tests/local_config/test_config_3.yaml", "test_support_description\n\nwith new lines"), 266 | ("./tests/local_config/test_config_4.yaml", "test_support_description\nwith multiple line\nlines"), 267 | ], 268 | ) 269 | @patch("awsmp.models.boto3") 270 | def test_get_ami_product_update_changeset_support_desc(mock_boto3, file_path, expected_desc): 271 | mock_boto3.client.return_value.describe_regions.return_value = { 272 | "Regions": [ 273 | {"Endpoint": "ec2.us-east-1.amazonaws.com", "RegionName": "us-east-1", "OptInStatus": "opted-in"}, 274 | {"Endpoint": "ec2.us-east-2.amazonaws.com", "RegionName": "us-east-2", "OptInStatus": "opted-in"}, 275 | ] 276 | } 277 | with open(file_path, "r") as f: 278 | config = yaml.safe_load(f) 279 | res: List[types.ChangeSetType] = changesets.get_ami_listing_update_changesets( 280 | "test-id", config["product"]["description"], config["product"]["region"] 281 | ) 282 | details_document = cast(Dict[str, Any], res[0]["DetailsDocument"]) 283 | assert details_document["SupportDescription"] == expected_desc 284 | 285 | 286 | @pytest.mark.parametrize( 287 | "file_path, expected_desc", 288 | [ 289 | ("./tests/test_config.yaml", []), 290 | ("./tests/local_config/test_config_3.yaml", ["https://test-video"]), 291 | ("./tests/local_config/test_config_4.yaml", []), 292 | ], 293 | ) 294 | @patch("awsmp.models.boto3") 295 | def test_get_ami_product_update_changeset_optional_video_urls(mock_boto3, file_path, expected_desc): 296 | mock_boto3.client.return_value.describe_regions.return_value = { 297 | "Regions": [ 298 | {"Endpoint": "ec2.us-east-1.amazonaws.com", "RegionName": "us-east-1", "OptInStatus": "opted-in"}, 299 | {"Endpoint": "ec2.us-east-2.amazonaws.com", "RegionName": "us-east-2", "OptInStatus": "opted-in"}, 300 | ] 301 | } 302 | with open(file_path, "r") as f: 303 | config = yaml.safe_load(f) 304 | res: List[types.ChangeSetType] = changesets.get_ami_listing_update_changesets( 305 | "test-id", config["product"]["description"], config["product"]["region"] 306 | ) 307 | details_document = cast(Dict[str, Any], res[0]["DetailsDocument"]) 308 | assert details_document["VideoUrls"] == expected_desc 309 | 310 | 311 | @pytest.mark.parametrize( 312 | "file_path, expected_region", 313 | [ 314 | ("./tests/test_config.yaml", ["us-east-1", "us-east-2"]), 315 | ("./tests/local_config/test_config_3.yaml", ["us-east-1"]), 316 | ("./tests/local_config/test_config_4.yaml", ["us-east-2"]), 317 | ], 318 | ) 319 | @patch("awsmp.models.boto3") 320 | def test_get_ami_product_update_changeset_region(mock_boto3, file_path, expected_region): 321 | mock_boto3.client.return_value.describe_regions.return_value = { 322 | "Regions": [ 323 | {"Endpoint": "ec2.us-east-1.amazonaws.com", "RegionName": "us-east-1", "OptInStatus": "opted-in"}, 324 | {"Endpoint": "ec2.us-east-2.amazonaws.com", "RegionName": "us-east-2", "OptInStatus": "opted-in"}, 325 | ] 326 | } 327 | with open(file_path, "r") as f: 328 | config = yaml.safe_load(f) 329 | res: List[types.ChangeSetType] = changesets.get_ami_listing_update_changesets( 330 | "test-id", config["product"]["description"], config["product"]["region"] 331 | ) 332 | details_document = cast(Dict[str, Any], res[1]["DetailsDocument"]) 333 | assert details_document["Regions"] == expected_region 334 | 335 | 336 | @pytest.mark.parametrize( 337 | "file_path, expected_future_region", 338 | [ 339 | ("./tests/test_config.yaml", ["All"]), 340 | ("./tests/local_config/test_config_3.yaml", ["None"]), 341 | ("./tests/local_config/test_config_4.yaml", ["All"]), 342 | ], 343 | ) 344 | @patch("awsmp.models.boto3") 345 | def test_get_ami_product_update_changeset_future_region(mock_boto3, file_path, expected_future_region): 346 | mock_boto3.client.return_value.describe_regions.return_value = { 347 | "Regions": [ 348 | {"Endpoint": "ec2.us-east-1.amazonaws.com", "RegionName": "us-east-1", "OptInStatus": "opted-in"}, 349 | {"Endpoint": "ec2.us-east-2.amazonaws.com", "RegionName": "us-east-2", "OptInStatus": "opted-in"}, 350 | ] 351 | } 352 | with open(file_path, "r") as f: 353 | config = yaml.safe_load(f) 354 | res: List[types.ChangeSetType] = changesets.get_ami_listing_update_changesets( 355 | "test-id", config["product"]["description"], config["product"]["region"] 356 | ) 357 | details_document = cast(Dict[str, Any], res[2]["DetailsDocument"]) 358 | assert details_document["FutureRegionSupport"]["SupportedRegions"] == expected_future_region 359 | 360 | 361 | @patch("awsmp.models.boto3") 362 | def test_get_ami_product_update_non_valid_changeset(mock_boto3): 363 | mock_boto3.client.return_value.describe_regions.return_value = { 364 | "Regions": [ 365 | {"Endpoint": "ec2.us-east-1.amazonaws.com", "RegionName": "us-east-1", "OptInStatus": "opted-in"}, 366 | {"Endpoint": "ec2.us-east-2.amazonaws.com", "RegionName": "us-east-2", "OptInStatus": "opted-in"}, 367 | ] 368 | } 369 | 370 | with pytest.raises(ValidationError): 371 | changesets.get_ami_listing_update_changesets("test-id", {}, {}) 372 | 373 | 374 | def test_get_ami_listing_update_version_changesets(): 375 | with open("./tests/test_config.yaml", "r") as f: 376 | config = yaml.safe_load(f) 377 | res: List[types.ChangeSetType] = changesets.get_ami_listing_update_version_changesets( 378 | "test-id", config["product"]["version"] 379 | ) 380 | details_document = cast(Dict[str, Any], res[0]["DetailsDocument"]) 381 | assert ( 382 | details_document["Version"]["VersionTitle"] == "test_version_title" 383 | and details_document["DeliveryOptions"][0]["Details"]["AmiDeliveryOptionDetails"]["AmiSource"]["AmiId"] 384 | == "ami-test" 385 | ) 386 | 387 | 388 | def test_get_ami_listing_update_version_non_valid_changesets(): 389 | with open("./tests/test_config.yaml", "r") as f: 390 | config = yaml.safe_load(f) 391 | config["product"]["version"]["access_role_arn"] = "iam::test" 392 | 393 | with pytest.raises(ValidationError): 394 | changesets.get_ami_listing_update_version_changesets("test-id", config["product"]["version"]) 395 | 396 | 397 | def test_get_ami_listing_update_instance_type_changesets_add_new_instance_type(): 398 | offer_config: Dict[str, Any] = { 399 | "instance_types": [ 400 | {"name": "c3.xlarge", "yearly": 123.44, "hourly": 0.12}, 401 | {"name": "c4.large", "yearly": 78.56, "hourly": 0.55}, 402 | ], 403 | "eula_document": [{"type": "StandardEula", "version": "2025-05-05"}], 404 | "refund_policy": "refund_policy", 405 | } 406 | offer_detail = models.Offer(**offer_config) 407 | res: List[types.ChangeSetType] = changesets.get_ami_listing_update_instance_type_changesets( 408 | "test-id", "test-offer_id", offer_detail, ["c4.large"], [] 409 | ) 410 | details_document = [cast(Dict[str, Any], item["DetailsDocument"]) for item in res[:]] 411 | assert ( 412 | details_document[-1]["InstanceTypes"] == ["c4.large"] 413 | and details_document[0]["Terms"][0]["RateCards"][0]["RateCard"][1] 414 | == {"DimensionKey": "c4.large", "Price": "0.55"} 415 | and details_document[0]["Terms"][1]["RateCards"][0]["RateCard"][1] 416 | == {"DimensionKey": "c4.large", "Price": "78.56"} 417 | ) 418 | 419 | 420 | def test_get_ami_listing_update_instance_type_changesets_add_new_multiple_instance_types(): 421 | offer_config: Dict[str, Any] = { 422 | "instance_types": [ 423 | {"name": "c3.xlarge", "yearly": 123.44, "hourly": 0.12}, 424 | {"name": "c4.large", "yearly": 78.56, "hourly": 0.55}, 425 | {"name": "c5.large", "yearly": 100.78, "hourly": 1.28}, 426 | ], 427 | "eula_document": [{"type": "StandardEula", "version": "2025-05-05"}], 428 | "refund_policy": "refund_policy", 429 | } 430 | offer_detail = models.Offer(**offer_config) 431 | res: List[types.ChangeSetType] = changesets.get_ami_listing_update_instance_type_changesets( 432 | "test-id", "test-offer_id", offer_detail, ["c4.large", "c5.large"], [] 433 | ) 434 | details_document = [cast(Dict[str, Any], item["DetailsDocument"]) for item in res[:]] 435 | assert ( 436 | details_document[-1]["InstanceTypes"] == ["c4.large", "c5.large"] 437 | and details_document[0]["Terms"][0]["RateCards"][0]["RateCard"][1] 438 | == {"DimensionKey": "c4.large", "Price": "0.55"} 439 | and details_document[0]["Terms"][0]["RateCards"][0]["RateCard"][2] 440 | == {"DimensionKey": "c5.large", "Price": "1.28"} 441 | ) 442 | 443 | 444 | def test_get_ami_listing_update_instance_type_changesets_add_new_instance_type_with_monthly_subscription(): 445 | offer_config: Dict[str, Any] = { 446 | "instance_types": [ 447 | {"name": "c3.xlarge", "yearly": None, "hourly": 0.12}, 448 | {"name": "c4.large", "yearly": None, "hourly": 0.55}, 449 | ], 450 | "eula_document": [{"type": "StandardEula", "version": "2025-05-05"}], 451 | "refund_policy": "refund_policy", 452 | "monthly_subscription_fee": 265.00, 453 | } 454 | offer_detail = models.Offer(**offer_config) 455 | res: List[types.ChangeSetType] = changesets.get_ami_listing_update_instance_type_changesets( 456 | "test-id", "test-offer_id", offer_detail, ["c4.large"], [] 457 | ) 458 | details_document = [cast(Dict[str, Any], item["DetailsDocument"]) for item in res[:]] 459 | assert details_document[0]["Terms"][0]["RateCards"][0]["RateCard"][1] == { 460 | "DimensionKey": "c4.large", 461 | "Price": "0.55", 462 | } and details_document[0]["Terms"][1] == { 463 | "Type": "RecurringPaymentTerm", 464 | "CurrencyCode": "USD", 465 | "BillingPeriod": "Monthly", 466 | "Price": "265.0", 467 | } 468 | 469 | 470 | def test_get_ami_listing_update_instance_type_changesets_restrict_instance_type(): 471 | offer_config: Dict[str, Any] = { 472 | "instance_types": [ 473 | {"name": "c3.xlarge", "yearly": 123.44, "hourly": 0.12}, 474 | ], 475 | "eula_document": [{"type": "StandardEula", "version": "2025-05-05"}], 476 | "refund_policy": "refund_policy", 477 | } 478 | offer_detail = models.Offer(**offer_config) 479 | res: List[types.ChangeSetType] = changesets.get_ami_listing_update_instance_type_changesets( 480 | "test-id", "test-offer_id", offer_detail, [], ["c4.large"] 481 | ) 482 | details_document = [cast(Any, item["DetailsDocument"]) for item in res[:]] 483 | 484 | assert ( 485 | details_document[1]["InstanceTypes"] == ["c4.large"] 486 | and details_document[2][0]["Key"] == "c4.large" 487 | and details_document[0]["Terms"][0]["RateCards"][0]["RateCard"][0] 488 | == {"DimensionKey": "c3.xlarge", "Price": "0.12"} 489 | ) 490 | 491 | 492 | def test_get_ami_listing_update_instance_type_changesets_restrict_multiple_instance_types(): 493 | offer_config: Dict[str, Any] = { 494 | "instance_types": [ 495 | {"name": "c3.xlarge", "yearly": 123.44, "hourly": 0.12}, 496 | ], 497 | "eula_document": [{"type": "StandardEula", "version": "2025-05-05"}], 498 | "refund_policy": "refund_policy", 499 | } 500 | offer_detail = models.Offer(**offer_config) 501 | res: List[types.ChangeSetType] = changesets.get_ami_listing_update_instance_type_changesets( 502 | "test-id", "test-offer_id", offer_detail, [], ["c4.large", "c5.large"] 503 | ) 504 | details_document = [cast(Any, item["DetailsDocument"]) for item in res[:]] 505 | 506 | assert ( 507 | details_document[1]["InstanceTypes"] == ["c4.large", "c5.large"] 508 | and details_document[2][0]["Key"] == "c4.large" 509 | and details_document[2][1]["Key"] == "c5.large" 510 | and details_document[0]["Terms"][0]["RateCards"][0]["RateCard"][0] 511 | == {"DimensionKey": "c3.xlarge", "Price": "0.12"} 512 | ) 513 | 514 | 515 | def test_get_ami_listing_update_instance_type_changesets_restrict_and_add_instance_type(): 516 | offer_config: Dict[str, Any] = { 517 | "instance_types": [ 518 | {"name": "c3.xlarge", "yearly": 123.44, "hourly": 0.12}, 519 | {"name": "c4.large", "yearly": 78.56, "hourly": 0.55}, 520 | ], 521 | "eula_document": [{"type": "StandardEula", "version": "2025-05-05"}], 522 | "refund_policy": "refund_policy", 523 | } 524 | offer_detail = models.Offer(**offer_config) 525 | res: List[types.ChangeSetType] = changesets.get_ami_listing_update_instance_type_changesets( 526 | "test-id", "test-offer_id", offer_detail, ["c4.large"], ["c1.medium"] 527 | ) 528 | details_document = [cast(Any, item["DetailsDocument"]) for item in res[:]] 529 | assert ( 530 | details_document[0]["Terms"][0]["RateCards"][0]["RateCard"][1] == {"DimensionKey": "c4.large", "Price": "0.55"} 531 | and details_document[1][0]["Key"] == "c4.large" 532 | and details_document[2]["InstanceTypes"] == ["c4.large"] 533 | and details_document[3]["InstanceTypes"] == ["c1.medium"] 534 | and details_document[4][0]["Key"] == "c1.medium" 535 | ) 536 | 537 | 538 | def test_get_ami_listing_update_instance_type_changesets_no_restrict_and_add_instance_type(): 539 | offer_config: Dict[str, Any] = { 540 | "instance_types": [ 541 | {"name": "c3.xlarge", "yearly": 123.44, "hourly": 0.12}, 542 | {"name": "c4.large", "yearly": 78.56, "hourly": 0.55}, 543 | ], 544 | "eula_document": [{"type": "StandardEula", "version": "2025-05-05"}], 545 | "refund_policy": "refund_policy", 546 | } 547 | offer_detail = models.Offer(**offer_config) 548 | res: List[types.ChangeSetType] = changesets.get_ami_listing_update_instance_type_changesets( 549 | "test-id", "test-offer_id", offer_detail, [], [] 550 | ) 551 | assert ( 552 | res[0]["ChangeType"] == "UpdatePricingTerms" 553 | and res[0]["DetailsDocument"]["Terms"][0]["RateCards"][0]["RateCard"][0]["Price"] == "0.12" # type: ignore 554 | ) 555 | --------------------------------------------------------------------------------