Skip to content
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
50 changes: 29 additions & 21 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,14 @@ on:

env:
PYTHON_VERSION: '3.11'
PYTHON_DIR: 'lambda'
RUFF_VERSION: '0.14.9'
CERTBOT_LAMBDA_DIR: 'lambdas/certbot'
UV_VERSION: '0.5'
RUFF_VERSION: '0.14.13'
TF_VERSION: '1.12.1'
TF_LINT_VERSION: 'latest'
TF_DOCS_VERSION: 'latest'
TF_WORKING_DIR: 'terraform/'
TRIVY_VERSION: 'v0.68.1'
TRIVY_VERSION: 'v0.68.2'

jobs:
python-lint:
Expand All @@ -32,17 +33,18 @@ jobs:
- name: Check-out code
uses: actions/checkout@v6

- name: Set up uv
uses: astral-sh/setup-uv@v4
with:
version: ${{ env.UV_VERSION }}

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

- name: Install dependencies (requirements.txt)
working-directory: ${{ env.PYTHON_DIR }}
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Install dependencies (workspace)
run: uv sync --all-packages

- name: Ruff check (linter)
uses: astral-sh/ruff-action@v3
Expand Down Expand Up @@ -73,28 +75,30 @@ jobs:
echo "::notice::Skipping tests - tests directory does not exist or is empty"
fi

- name: Set up uv
if: steps.check-tests.outputs.exists == 'true'
uses: astral-sh/setup-uv@v4
with:
version: ${{ env.UV_VERSION }}

- name: Set up Python
if: steps.check-tests.outputs.exists == 'true'
uses: actions/setup-python@v6
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: 'pip'

# Test dependencies are declared in pyproject.toml
- name: Install dependencies (pyproject.toml)
- name: Install dependencies (workspace)
if: steps.check-tests.outputs.exists == 'true'
run: |
python -m pip install --upgrade pip
pip install -e ".[test]"
run: uv sync --all-packages --extra test

- name: Run tests with pytest
if: steps.check-tests.outputs.exists == 'true'
env:
AWS_DEFAULT_REGION: 'us-east-1'
run: |
pytest tests/ \
--cov=lambda \
--cov=new_stake_script \
uv run pytest tests/ \
--cov=${{ env.CERTBOT_LAMBDA_DIR }} \
--cov-report=xml \
--cov-report=html \
--cov-report=term-missing \
Expand Down Expand Up @@ -168,7 +172,7 @@ jobs:
fetch-depth: 0

- name: Setup Trivy (manual)
uses: aquasecurity/setup-trivy@v0.2.4
uses: aquasecurity/setup-trivy@v0.2.5
with:
cache: true
version: ${{ env.TRIVY_VERSION }}
Expand All @@ -187,10 +191,14 @@ jobs:
with:
sarif_file: 'trivy-results.sarif'

- name: Set up uv
uses: astral-sh/setup-uv@v4
with:
version: ${{ env.UV_VERSION }}

- name: Python Security Check (Bandit)
run: |
pip install bandit
bandit -r . -f json -o bandit-report.json || true
uvx bandit -r . -f json -o bandit-report.json || true

- name: Check for secrets (Gitleaks)
uses: gitleaks/gitleaks-action@v2
Expand All @@ -215,7 +223,7 @@ jobs:
tflint_version: ${{ env.TF_LINT_VERSION }}

- name: Setup Trivy
uses: aquasecurity/setup-trivy@v0.2.4
uses: aquasecurity/setup-trivy@v0.2.5
with:
cache: true
version: ${{ env.TRIVY_VERSION }}
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# IDE
.idea/
.vscode/

# Python
__pycache__/
Expand All @@ -9,6 +10,7 @@ __pycache__/
venv/
*.egg-info/
.ruff_cache/
.python-version
python/

# Pytest
Expand Down
4 changes: 2 additions & 2 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,15 @@ repos:
- id: detect-private-key
exclude: ^tests/
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.14.9
rev: v0.14.13
hooks:
# Run the linter.
- id: ruff-check
args: [ --fix ]
# Run the formatter.
- id: ruff-format
- repo: https://github.com/antonbabenko/pre-commit-terraform
rev: v1.104.0
rev: v1.105.0
hooks:
- id: terraform_fmt
- id: terraform_docs
Expand Down
118 changes: 95 additions & 23 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ Serverless TLS certificate renewal using Let's Encrypt ACME protocol. Runs as an
- AWS account with appropriate permissions
- Route53 hosted zone for your domain
- Terraform ~> 1.12.1
- Python 3.11 and pip (for local Lambda layer building)
- Python 3.11 and [uv](https://docs.astral.sh/uv/) (for local Lambda layer building)

## Architecture

Expand Down Expand Up @@ -195,17 +195,32 @@ You can control whether the ACME account key is persisted using the `acme_persis

## Lambda Layer Building

The Lambda function requires Python dependencies (`acme`, `cryptography`, `josepy`, `boto3`) packaged as a Lambda layer. Terraform builds this layer locally during `terraform apply` using `pip install` with the `--platform manylinux2014_x86_64` flag to ensure compatibility with the Lambda runtime.
The Lambda function requires Python dependencies (`acme`, `cryptography`, `josepy`, `boto3`) packaged as a Lambda layer. Terraform builds this layer locally during `terraform apply` using `uv pip install` with the `--python-platform x86_64-manylinux2014` flag to ensure compatibility with the Lambda runtime.

**Why local building?**
- Simple setup - no Docker or CI/CD pipeline required
- Automatic rebuild when `requirements.txt` changes
- Automatic rebuild when `pyproject.toml` changes
- Suitable for single-function deployments

**Requirements:**
- Python 3.11 and pip installed locally
- Python 3.11 and [uv](https://docs.astral.sh/uv/) installed locally
- Internet access to download packages from PyPI

**Known limitation:** Terraform uses `local-exec` provisioner to build the layer, which runs during `apply` phase. However, Terraform reads `layer.zip` during `plan` phase to compute hashes. If the file doesn't exist (fresh clone, path changes), `terraform plan` will fail.

**Manual build** (when needed):
```bash
# From project root:
uv lock # if uv.lock doesn't exist
uv export --package certbot-lambda --no-hashes --no-dev --frozen --no-emit-project -o lambdas/certbot/requirements.txt
cd lambdas/certbot
rm -rf python layer.zip
mkdir -p python
uv pip install -r requirements.txt --target python/ --python-platform x86_64-manylinux2014 --only-binary :all: --python-version 3.11
rm requirements.txt
zip -r layer.zip python
```

For production environments with stricter reproducibility needs, consider building the layer in CI/CD and storing it in S3.

## Deployment
Expand All @@ -226,15 +241,15 @@ terraform apply
### Invoke Lambda without force certificate renewal

```bash
aws lambda invoke --function-name aws-certbot-lambda-prod \
aws lambda invoke --function-name certbot-lambda-prod \
--cli-binary-format raw-in-base64-out \
--payload '{"force_renewal": false}' response.json
```

### Force certificate renewal

```bash
aws lambda invoke --function-name aws-certbot-lambda-prod \
aws lambda invoke --function-name certbot-lambda-prod \
--cli-binary-format raw-in-base64-out \
--payload '{"force_renewal": true}' response.json
```
Expand All @@ -244,17 +259,17 @@ aws lambda invoke --function-name aws-certbot-lambda-prod \
```bash
# Full JSON
aws secretsmanager get-secret-value \
--secret-id aws-certbot-lambda-prod-certificate \
--secret-id certbot-lambda-prod-certificate \
--query SecretString --output text | jq .

# Certificate only
aws secretsmanager get-secret-value \
--secret-id aws-certbot-lambda-prod-certificate \
--secret-id certbot-lambda-prod-certificate \
--query SecretString --output text | jq -r .certificate > cert.pem

# Private key only
aws secretsmanager get-secret-value \
--secret-id aws-certbot-lambda-prod-certificate \
--secret-id certbot-lambda-prod-certificate \
--query SecretString --output text | jq -r .private_key > key.pem
```

Expand All @@ -263,15 +278,44 @@ aws secretsmanager get-secret-value \
```bash
# Get expiration date from secret tags (no decryption needed)
aws secretsmanager describe-secret \
--secret-id aws-certbot-lambda-prod-certificate \
--secret-id certbot-lambda-prod-certificate \
--query 'Tags[?Key==`ExpirationDate`].Value' --output text

# Get certificate issue date
aws secretsmanager describe-secret \
--secret-id aws-certbot-lambda-prod-certificate \
--secret-id certbot-lambda-prod-certificate \
--query 'Tags[?Key==`IssuedAt`].Value' --output text
```

### View Lambda logs

```bash
# Tail logs in real-time (AWS CLI v2)
aws logs tail /aws/lambda/certbot-lambda-prod --follow

# Get recent log streams
aws logs describe-log-streams \
--log-group-name /aws/lambda/certbot-lambda-prod \
--order-by LastEventTime \
--descending \
--limit 5

# Get logs from a specific stream
aws logs get-log-events \
--log-group-name /aws/lambda/certbot-lambda-prod \
--log-stream-name '<stream-name-from-above>'

# Filter logs from last hour
aws logs filter-log-events \
--log-group-name /aws/lambda/certbot-lambda-prod \
--start-time $(date -d '1 hour ago' +%s000)

# Search for errors
aws logs filter-log-events \
--log-group-name /aws/lambda/certbot-lambda-prod \
--filter-pattern "ERROR"
```

## Configuration Options

### ACME Account Key Persistence
Expand Down Expand Up @@ -328,7 +372,7 @@ Success event (`Certificate Renewed`):
"domains": ["example.com", "*.example.com"],
"expiry": "2025-03-10T00:00:00+00:00",
"issued_at": "2024-12-10T00:00:00+00:00",
"secret_name": "aws-certbot-lambda-prod-certificate"
"secret_name": "certbot-lambda-prod-certificate"
}
```

Expand All @@ -338,11 +382,11 @@ Failure event (`Certificate Renewal Failed`):
"status": "failed",
"domains": ["example.com", "*.example.com"],
"error": "Error message",
"secret_name": "aws-certbot-lambda-prod-certificate"
"secret_name": "certbot-lambda-prod-certificate"
}
```

Event source is the Lambda function name (e.g., `aws-certbot-lambda-prod`).
Event source is the Lambda function name (e.g., `certbot-lambda-prod`).

## Environment Variables

Expand All @@ -365,21 +409,17 @@ The Lambda function uses the following environment variables (automatically conf

## Testing

Unit tests are written using pytest. Run tests in a virtual environment:
Unit tests are written using pytest. Run tests using [uv](https://docs.astral.sh/uv/):

```bash
# Create and activate virtual environment
python3 -m venv venv
source venv/bin/activate

# Install test dependencies
pip install -e ".[test]"
# Install workspace with test dependencies
uv sync --all-packages --extra test

# Run tests with coverage
PYTHONPATH=./lambda pytest tests/ -v
uv run pytest tests/ -v

# Run tests with coverage report
PYTHONPATH=./lambda pytest tests/ -v --cov=lambda --cov-report=term-missing
uv run pytest tests/ -v --cov=lambdas/certbot --cov-report=term-missing
```

Test coverage includes:
Expand All @@ -388,6 +428,38 @@ Test coverage includes:
- `_validate_config` function
- `send_notification` and `publish_event` functions

## Adding a New Lambda

This project uses [uv workspaces](https://docs.astral.sh/uv/concepts/projects/workspaces/) to manage multiple Lambda functions. To add a new Lambda:

```bash
# 1. Create directory
mkdir -p lambdas/my-new-function

# 2. Create pyproject.toml with dependencies
cat > lambdas/my-new-function/pyproject.toml << 'EOF'
[project]
name = "my-new-function-lambda"
version = "0.1.0"
description = "Description of my new Lambda function"
requires-python = ">=3.11"
dependencies = [
"boto3~=1.42",
]
EOF

# 3. Create Lambda handler
cat > lambdas/my-new-function/lambda_function.py << 'EOF'
def lambda_handler(event, context):
return {"statusCode": 200, "body": "Hello from my new Lambda!"}
EOF

# 4. Sync workspace to install dependencies
uv sync --all-packages
```

Then create corresponding Terraform resources in `terraform/` for the new Lambda function.

## TODO
- Add a feature that enables the storage of certificate-generating data in AWS ACM
- Add support for multiple Hosted Zones
Empty file removed lambda/__init__.py
Empty file.
3 changes: 0 additions & 3 deletions lambda/requirements-dev.txt

This file was deleted.

4 changes: 0 additions & 4 deletions lambda/requirements.txt

This file was deleted.

Loading