Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Update toolchain to uv; add more linting #3

Merged
merged 3 commits into from
Nov 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@ __pycache__/
# shell utility, run sporadically and 2) committing a lockfile means keeping it
# up to date every time there is a security issue. In this case, we're going to
# assume "follow latest" for each use/installation.
Pipfile.lock
uv.lock
requirements.txt
5 changes: 2 additions & 3 deletions .mega-linter.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,9 @@ DISABLE_LINTERS:
# A self-contained maintaince page is not expected to conform to best
# practices for web site.
- HTML_DJLINT
# Pylint doesn't handle our Pipenv dependencies, and is redundant with ruff.
# Pylint errors on missing dependencies, and is redundant with ruff.
- PYTHON_PYLINT
# Pyright doesn't handle our pipenv dependencies, plus we aren't using
# static typing.
# Pyright errors on missing dependencies.
- PYTHON_PYRIGHT
# Disable dependency security scanning.
- REPOSITORY_GRYPE
Expand Down
1 change: 1 addition & 0 deletions .python-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
3.12
9 changes: 0 additions & 9 deletions .ruff.toml

This file was deleted.

24 changes: 16 additions & 8 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,30 @@

.PHONY: all clean test

all:
@echo 'no default: supported targets are "requirements.txt", ".venv", "clean" and "sync"' >&2
all: requirements.txt

clean:
rm -Rf __pycache__ .ruff_cache megalinter-reports
rm -Rf requirements.txt __pycache__ .venv .ruff_cache megalinter-reports

lint:
docker pull oxsecurity/megalinter-python:v7
docker run --rm --platform linux/amd64 -v '$(CURDIR):/tmp/lint:rw' oxsecurity/megalinter-python:v7

test:
@echo "No tests to run ... would you like to 'make lint'?"
@echo "No tests to run ... would you like to 'make lint'?" >&2

requirements.txt: Pipfile.lock .license-header
requirements.txt: requirements.in
cat .license-header > requirements.txt
# Because we are avoiding pinning dep versions, we also prune them from the
# generated requirements.txt file.
pipenv requirements --exclude-markers | sed 's/=.*$$//' >> requirements.txt
uv pip compile requirements.in >> requirements.txt

Pipfile.lock: Pipfile
pipenv lock
.venv:
uv venv .venv

sync: .venv requirements.txt
uv pip sync requirements.txt

fmt:
uv tool -q run black *.py
uv tool -q run isort *.py
15 changes: 0 additions & 15 deletions Pipfile

This file was deleted.

26 changes: 14 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# CDN Maintenance Toggle script
# CDN maintenance toggle script

This script disables/enables CDN services operating on AWS Cloudfront by
setting them into maintenance mode, implemented as a Cloudfront edge function
Expand All @@ -14,31 +14,33 @@ Features include:

For more details, run the script with the `--help` option.

## Setup & Usage for Linux Foundation / LFX sites
## Setup & usage for Linux Foundation / LFX sites

Recommend installation is via a pipenv-managed virtualenv, and pyenv to install
the supported Python release if your system doesn't have Python 3.11.
Recommend installation is via a `uv` virtualenv, and `pyenv` to install the
supported Python release.

Be sure to set `AWS_PROFILE` with MFA and/or SSO authentication helpers before
running the script.
The script relies on the environment to provide AWS authentication and
determine which account to connect to. Export the `AWS_PROFILE` environment
variable, or run the script with `aws-vault`, depending on your setup.

```bash
pyenv install 3.11
pipenv install
pipenv shell
pyenv install 3.12 # or: brew install [email protected]; brew pyenv-sync
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is slightly redundant since the uv venv step will see the .python-version and grab a uv managed Python with the correct version (and doesn't need that version on the system to be installed):

❯ make .venv
uv venv .venv
Using CPython 3.12.7
Creating virtual environment at: .venv
Activate with: source .venv/bin/activate
❯ which python
/Users/jpmcb/workspace/linuxfoundation/cdn-maintenance-toggle/.venv/bin/python
❯ python --version
Python 3.12.7

make sync # requires `uv` to be installed
source .venv/bin/activate
./cdn_maintenance_toggle.py --template lfx-maintenance.html -v --disable-sites "*.platform.linuxfoundation.org" "*.lfx.dev"
./cdn_maintenance_toggle.py -v --enable-sites "*.platform.linuxfoundation.org" "*.lfx.dev"
./cdn_maintenance_toggle.py --cleanup
deactivate
```

Alternativelly, you can install the required Python packages system-wide or to
the current user. This tool has been developed against Python 3.11 and may not
the current user. This tool has been developed against Python 3.12 and may not
work on other versions.

```bash
pip3.11 install --user boto3 trieregex
pip install --user boto3 trieregex
# Optional: to set AWS_PROFILE or other parameters via .env:
# pip3.11 install --user python-dotenv
# pip install --user python-dotenv
Comment on lines -39 to +43
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the user has the .venv setup and got the packages installed via the make sync command, they shouldn't need to manually install those pacakges. Note that these packages land in the virtual env managed by uv:

❯ ll .venv/lib/python3.12/site-packages
total 96
-rw-r--r--@  1 jpmcb  staff    18B Dec  2 16:47 _virtualenv.pth
-rw-r--r--@  1 jpmcb  staff   4.2K Dec  2 16:47 _virtualenv.py
drwxr-xr-x@ 15 jpmcb  staff   480B Dec  2 16:48 boto3
drwxr-xr-x@ 10 jpmcb  staff   320B Dec  2 16:48 boto3-1.35.72.dist-info
drwxr-xr-x@ 48 jpmcb  staff   1.5K Dec  2 16:48 botocore
drwxr-xr-x@ 10 jpmcb  staff   320B Dec  2 16:48 botocore-1.35.72.dist-info
drwxr-xr-x@ 13 jpmcb  staff   416B Dec  2 16:48 dateutil
drwxr-xr-x@ 11 jpmcb  staff   352B Dec  2 16:48 dotenv
drwxr-xr-x@ 10 jpmcb  staff   320B Dec  2 16:48 jmespath
drwxr-xr-x@  9 jpmcb  staff   288B Dec  2 16:48 jmespath-1.0.1.dist-info
drwxr-xr-x@ 10 jpmcb  staff   320B Dec  2 16:48 python_dateutil-2.9.0.post0.dist-info
drwxr-xr-x@ 10 jpmcb  staff   320B Dec  2 16:48 python_dotenv-1.0.1.dist-info
drwxr-xr-x@ 18 jpmcb  staff   576B Dec  2 16:48 s3transfer
drwxr-xr-x@ 10 jpmcb  staff   320B Dec  2 16:48 s3transfer-0.10.4.dist-info
drwxr-xr-x@  9 jpmcb  staff   288B Dec  2 16:48 six-1.16.0.dist-info
-rw-r--r--@  1 jpmcb  staff    34K Dec  2 16:48 six.py
drwxr-xr-x@ 10 jpmcb  staff   320B Dec  2 16:48 tests
drwxr-xr-x@  5 jpmcb  staff   160B Dec  2 16:48 trieregex
drwxr-xr-x@  9 jpmcb  staff   288B Dec  2 16:48 trieregex-1.0.0.dist-info
drwxr-xr-x@ 16 jpmcb  staff   512B Dec  2 16:48 urllib3
drwxr-xr-x@  9 jpmcb  staff   288B Dec  2 16:48 urllib3-1.26.20.dist-info

If you wanted to be sure the end user has those packages installed in the venv, they can do it through the uv pip interface:

uv pip install python-dotenv

./cdn_maintenance_toggle.py --template lfx-maintenance.html -v --disable-sites "*.platform.linuxfoundation.org" "*.lfx.dev"
./cdn_maintenance_toggle.py -v --enable-sites "*.platform.linuxfoundation.org" "*.lfx.dev"
./cdn_maintenance_toggle.py --cleanup
Expand Down
80 changes: 44 additions & 36 deletions cdn_maintenance_toggle.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
# Copyright The Linux Foundation and each contributor to LFX.
# SPDX-License-Identifier: MIT

"""
"""Set or clear maintenance notices on CloudFlare CDNs.

This script disables/enables CDN services operating on AWS Cloudfront by
setting them into maintenance mode, implemented as a Cloudfront edge function
returning an HTML maintenance page.
Expand All @@ -19,7 +20,7 @@

import boto3
from botocore.exceptions import ClientError
from trieregex import TrieRegEx as TRE
from trieregex import TrieRegEx

# Optional support for .env file.
try:
Expand Down Expand Up @@ -74,13 +75,15 @@
return response;
}"""

# Maximum size of Cloudfront function after interpolating the template.
MAX_FUNCTION_SIZE = 20000

def main():
"""main is the entry point for script invocations."""

def main() -> None:
"""Implement command-line interface."""
# Parse arguments from command line.
parser = argparse.ArgumentParser(
description="Set or clear maintenance notices on CloudFlare CDNs for the current AWS_PROFILE."
description="Set or clear maintenance notices on CloudFlare CDNs."
)
parser.add_argument(
"--dry-run",
Expand Down Expand Up @@ -159,24 +162,28 @@ def main():
)


def enable_sites(patterns, dry_run=False):
def enable_sites(patterns: list, dry_run: bool = False) -> None:
"""Enable sites matching patterns (clear maintenance page)."""

targets = get_matching_distributions(patterns)

if dry_run is True:
logging.warning("running in dry-run mode: any logged changes are as-if")

for distribution in targets:
remove_maintenance_function(distribution, dry_run=dry_run)


def disable_sites(patterns, html=None, allowed_ips=tuple(), dry_run=False):
def disable_sites(
patterns: list,
html: str | None = None,
allowed_ips: list | None = None,
dry_run: bool = False,
) -> None:
"""Disable sites matching patterns (set maintenance page)."""

if html is None:
# Fallback/default maintenance page HTML.
html = "<!DOCTYPE html><html><body><p>This site is down for scheduled maintenance.</p></body></html>"
html = (
"<!DOCTYPE html>"
"<html><body><p>This site is down for scheduled maintenance.</p></body></html>"
)

# Find Cloudfront distributions matching the supplied domain patterns.
targets = get_matching_distributions(patterns)
Expand All @@ -188,10 +195,13 @@ def disable_sites(patterns, html=None, allowed_ips=tuple(), dry_run=False):
# Bypass function creation if there are no matching targets.
return

if allowed_ips is None:
allowed_ips = []

html_bytes = html.encode("utf8")

# Validate passed IPs and build a trie regex pattern out of them.
tre = TRE("127.0.0.1")
tre = TrieRegEx("127.0.0.1")
for ip_text in allowed_ips:
try:
# Read IPv4 & IPv6 addresses.
Expand Down Expand Up @@ -225,9 +235,8 @@ def disable_sites(patterns, html=None, allowed_ips=tuple(), dry_run=False):
set_maintenance_function(distribution, function_name, dry_run=dry_run)


def cleanup(dry_run=False):
def cleanup(dry_run: bool = False) -> None:
"""Delete unused maintenance pages."""

if dry_run is True:
logging.warning("running in dry-run mode: any logged changes are as-if")

Expand Down Expand Up @@ -258,10 +267,16 @@ def cleanup(dry_run=False):
raise error


def create_function(function, dry_run=False):
"""Idempotently create-if-not-exist the provided CloudFront function and return the function name."""
def create_function(function: str, dry_run: bool = False) -> str:
"""Create a CloudFront function if it does not exist.

if len(function) > 20000:
The function will be named based on a hash of the function's code. Returns
the function name.
"""
if len(function) > MAX_FUNCTION_SIZE:
# Check size explicitly, rather than merely catching the corresponding
# error, to allow users to catch and work around limits even in dry-run
# mode.
raise ValueError("function is too big, try reducing allowed IPs")

# Hash the function to calculate a unique function name.
Expand Down Expand Up @@ -292,7 +307,7 @@ def create_function(function, dry_run=False):
return function_name

# The function was not found, so it needs to be created and published.
logging.info("creating function %s", function_name)
logging.info("creating & publishing function %s", function_name)
if not dry_run:
config = {
"Comment": "503 maintenance page created by cdn_maintenance_toggle",
Expand All @@ -304,16 +319,13 @@ def create_function(function, dry_run=False):
FunctionCode=function.encode("utf8"),
)

logging.info("publishing function %s", function_name)
if not dry_run:
CLIENT.publish_function(Name=function_name, IfMatch=response["ETag"])

return function_name


def remove_maintenance_function(distribution, dry_run=False):
def remove_maintenance_function(distribution: dict, dry_run: bool = False) -> None:
"""Idempotently remove any maintenance functions from a CloudFront distribution."""

resp = CLIENT.get_distribution_config(Id=distribution["Id"])
cache_config = resp["DistributionConfig"]["DefaultCacheBehavior"]

Expand Down Expand Up @@ -356,12 +368,14 @@ def remove_maintenance_function(distribution, dry_run=False):
CLIENT.update_distribution(Id=distribution["Id"], **resp)


def set_maintenance_function(distribution, function_name, dry_run=False):
def set_maintenance_function(
distribution: dict, function_name: str, dry_run: bool = False
) -> None:
"""Idempotently configure a request type function on a CloudFront distribution.

The passed function_name must be a published CloudFront function or this function will raise an exception.
The passed function_name must be a published CloudFront function or this
function will raise an exception.
"""

dist_response = CLIENT.get_distribution_config(Id=distribution["Id"])
cache_config = dist_response["DistributionConfig"]["DefaultCacheBehavior"]

Expand Down Expand Up @@ -478,12 +492,10 @@ def set_maintenance_function(distribution, function_name, dry_run=False):
CLIENT.update_distribution(Id=distribution["Id"], **dist_response)


def get_matching_distributions(patterns):
def get_matching_distributions(patterns: list) -> list:
"""Find Cloudfront distributions matching the supplied domain patterns."""

distributions = []

args = {}
args: dict[str, str] = {}
while True:
# Fetch next batch of CloudFront distributions in the current AWS
# account (global region).
Expand Down Expand Up @@ -512,13 +524,9 @@ def get_matching_distributions(patterns):
return distributions


def fnmatch_any(string, patterns):
def fnmatch_any(string: str, patterns: list) -> bool:
"""Run fnmatch on multiple patterns and return True if any match, otherwise False."""

for pattern in patterns:
if fnmatch(string, pattern):
return True
return False
return any(fnmatch(string, pattern) for pattern in patterns)


if __name__ == "__main__":
Expand Down
18 changes: 18 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Copyright The Linux Foundation and each contributor to LFX.
# SPDX-License-Identifier: MIT

[project]
name = "cdn-maintenance-toggle"
version = "0.0.1"
requires-python = ">=3.12"

[tool.isort]
profile = "black"

[tool.ruff]
target-version = "py312"
# For linting only; not intended for `ruff format`.
line-length = 100

[tool.ruff.lint]
select = ["E", "F", "UP", "B", "G", "I", "ANN"]
4 changes: 4 additions & 0 deletions requirements.in
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
boto3
botocore
python-dotenv
trieregex
11 changes: 0 additions & 11 deletions requirements.txt

This file was deleted.