lightsail-wordpress-migrator 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,221 @@
1
+ Metadata-Version: 2.4
2
+ Name: lightsail-wordpress-migrator
3
+ Version: 1.0.0
4
+ Summary: Automated migration tool for upgrading AWS Lightsail Bitnami WordPress instances to modern Amazon Linux 2023 / Debian Lightsail blueprints.
5
+ Author: Jonathan Glass
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/jonathanbglass/lightsail-wordpress-migrator
8
+ Project-URL: Repository, https://github.com/jonathanbglass/lightsail-wordpress-migrator.git
9
+ Project-URL: Issues, https://github.com/jonathanbglass/lightsail-wordpress-migrator/issues
10
+ Keywords: aws,lightsail,wordpress,bitnami,migration,cloudwatch,fail2ban,devops
11
+ Classifier: Development Status :: 5 - Production/Stable
12
+ Classifier: Intended Audience :: System Administrators
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Topic :: System :: Systems Administration
21
+ Classifier: Topic :: Internet :: WWW/HTTP :: Site Management
22
+ Requires-Python: >=3.9
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ Requires-Dist: boto3>=1.28.0
26
+ Requires-Dist: paramiko>=3.0.0
27
+ Provides-Extra: dev
28
+ Requires-Dist: pytest>=7.0.0; extra == "dev"
29
+ Requires-Dist: ruff>=0.1.0; extra == "dev"
30
+ Dynamic: license-file
31
+
32
+ # πŸš€ AWS Lightsail WordPress Migrator
33
+
34
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
35
+ [![Python 3.9+](https://img.shields.io/badge/python-3.9+-blue.svg)](https://www.python.org/downloads/)
36
+ [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)
37
+
38
+ An automated migration tool designed to upgrade legacy **Bitnami WordPress** Amazon Lightsail instances to modern **Amazon Linux 2023 / Debian Native Lightsail Blueprints** (`wordpress_ls_1_0`).
39
+
40
+ ---
41
+
42
+ ## πŸ“– The Problem
43
+
44
+ AWS Lightsail deprecated its legacy Bitnami WordPress blueprint in favor of a modern native stack running Debian/AL2023 with systemd, native MariaDB, and standard Apache.
45
+
46
+ Migrating manually is painful and error-prone because:
47
+ 1. **Incompatible Directory Layouts**: Legacy Bitnami uses `/opt/bitnami/...` and `ctlscript.sh`, whereas native instances use standard `/var/www/html` and `systemctl`.
48
+ 2. **Database Collation Incompatibilities**: MySQL 8.0 exports using `utf8mb4_0900_ai_ci` fail on target MariaDB databases without translation.
49
+ 3. **Apache Permalinks / `.htaccess` 404s**: Bitnami disables `.htaccess` runtime reading by default. When migrating to native Apache, missing `.htaccess` files break all subpages and return `404 Not Found`.
50
+ 4. **Dynamic Firewall Restrictions**: Managing temporary port 22 access without leaving SSH open permanently to `0.0.0.0/0`.
51
+ 5. **No IAM Instance Profiles on Lightsail**: Streaming server and application logs to CloudWatch requires provisioning dedicated minimal IAM credentials.
52
+
53
+ **Lightsail WordPress Migrator automates this entire lifecycle with zero manual database editing.**
54
+
55
+ ---
56
+
57
+ ## ✨ Features
58
+
59
+ - πŸ”„ **Full End-to-End Migration**: Automates database dump, `wp-content` archival, target instance provisioning, and data import.
60
+ - ⚑ **Direct S3 Transfer**: Uses presigned S3 URLs so high-volume assets transfer directly between Lightsail instances without routing through your local laptop.
61
+ - πŸ›‘οΈ **Zero-Exposure Firewall Automation**: Dynamically opens port 22 for the runner's exact `/32` public IP, and **guarantees closure** via `finally` blocks upon completion or failure.
62
+ - 🌐 **Automated Static IP Cutover (`--reassign-static-ip`)**: Automatically discovers static IPs attached to the source, reassigns them to the target upon completion, and updates WordPress URLs for seamless DNS cutover.
63
+ - πŸ”§ **Collation Translation**: Automatically converts MySQL 8 collations (`utf8mb4_0900_ai_ci` / `utf8mb4_0900_as_cs`) to MariaDB-compatible `utf8mb4_unicode_520_ci`.
64
+ - πŸ“„ **Automatic `.htaccess` Generation**: Configures standard WordPress rewrite rules and `www-data` ownership to ensure flawless subpage permalink routing.
65
+ - πŸ”’ **Optional Security Hardening (`--harden-security`)**:
66
+ - **Fail2ban**: Deploys jails for SSH brute-force (`sshd`), WordPress login/XML-RPC defense (`apache-wplogin`), and vulnerability probe/traversal blocking (`apache-scanprobe`).
67
+ - **Apache Availability Hardening**: Deploys timeout and keepalive limits to protect small instances from slowloris / connection exhaustion attacks.
68
+ - πŸ“Š **Optional CloudWatch Ingestion (`--cloudwatch`)**: Provisions a dedicated least-privilege IAM user and configures `amazon-cloudwatch-agent` to stream Apache access/error logs, PHP-FPM, WordPress debug logs, and Fail2ban logs.
69
+ - πŸ” **Interactive Discovery & Dry-Run Mode**: Discovers running Lightsail instances and validates credentials, disk space, static IPs, and SSH keys before making any changes.
70
+
71
+ ---
72
+
73
+ ## πŸ› οΈ Architecture
74
+
75
+ ```
76
+ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
77
+ β”‚ S3 Staging Bucket β”‚
78
+ β”‚ (Presigned Direct Link) β”‚
79
+ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–²β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
80
+ β”‚
81
+ 1. Export DB & Assets β”‚ 2. Direct S3 Pull
82
+ β”‚
83
+ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
84
+ β”‚ Source Lightsail Host │───────────────┴─────────────▢│ Target Lightsail Host β”‚
85
+ β”‚ (Legacy Bitnami / MySQL) β”‚ β”‚ (Native AL2023/Debian/Maria) β”‚
86
+ β”‚ β”‚ β”‚ β”‚
87
+ β”‚ β€’ Bitnami stack detection β”‚ β”‚ β€’ Collation normalization β”‚
88
+ β”‚ β€’ Database dump via socket/TCPβ”‚ β”‚ β€’ Apache .htaccess generation β”‚
89
+ β”‚ β€’ wp-content tarball β”‚ β”‚ β€’ File permissions (www-data) β”‚
90
+ β”‚ β€’ Pre-migration snapshot β”‚ β”‚ β€’ Static IP cutover β”‚
91
+ β”‚ β€’ Attached Static IP discoveryβ”‚ β”‚ β€’ Fail2ban & Security tuning β”‚
92
+ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β€’ CloudWatch Logs agent β”‚
93
+ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
94
+ ```
95
+
96
+ ---
97
+
98
+ ## πŸš€ Quick Start
99
+
100
+ ### 1. Installation
101
+
102
+ #### Option A: Clone & Run (Zero install)
103
+ ```bash
104
+ git clone https://github.com/jonathanbglass/lightsail-wordpress-migrator.git
105
+ cd lightsail-wordpress-migrator
106
+ pip install boto3 paramiko
107
+ ```
108
+
109
+ #### Option B: Install via pip
110
+ ```bash
111
+ pip install .
112
+ ```
113
+
114
+ ---
115
+
116
+ ### 2. Usage
117
+
118
+ #### Interactive Mode (Recommended)
119
+ If you don't pass arguments, the migrator interactively lists your instances, checks for attached static IPs, and prompts for confirmation:
120
+ ```bash
121
+ python migrate.py
122
+ # or if installed via pip:
123
+ lightsail-wp-migrate
124
+ ```
125
+
126
+ #### CLI One-Liner (Non-Interactive with Static IP Reassignment)
127
+ ```bash
128
+ python migrate.py \
129
+ --source "MyWordPressBitnami" \
130
+ --target "MyWordPress-Native" \
131
+ --profile default \
132
+ --region us-east-1 \
133
+ --reassign-static-ip \
134
+ --harden-security \
135
+ --cloudwatch \
136
+ --yes
137
+ ```
138
+
139
+ #### Safe Dry-Run (Pre-flight validation)
140
+ ```bash
141
+ python migrate.py \
142
+ --source "MyWordPressBitnami" \
143
+ --target "MyWordPress-Native" \
144
+ --dry-run
145
+ ```
146
+
147
+ ---
148
+
149
+ ## βš™οΈ CLI Options Reference
150
+
151
+ | Flag | Default | Description |
152
+ | :--- | :--- | :--- |
153
+ | `--source`, `-s` | `None` | Name of existing Bitnami Lightsail instance (prompted if omitted). |
154
+ | `--target`, `-t` | `<source>-2` | Name of target Lightsail instance. |
155
+ | `--profile`, `-p` | `$AWS_PROFILE` or `default` | AWS CLI credential profile. |
156
+ | `--region`, `-r` | `$AWS_DEFAULT_REGION` or `us-east-1` | AWS region. |
157
+ | `--source-key` | Auto-discovered in `~/.ssh` | Path to source SSH private key (`.pem`). |
158
+ | `--target-key` | Source key | Path to target SSH private key (`.pem`). |
159
+ | `--bundle-id` | `nano_3_0` | Lightsail bundle size (e.g. `nano_3_0`, `micro_3_0`, `small_3_0`). |
160
+ | `--s3-bucket` | Auto-derived | S3 bucket for staging transfer artifacts. |
161
+ | `--snapshot / --no-snapshot` | `True` | Create a pre-migration snapshot of source. |
162
+ | `--reassign-static-ip` | `False` | Automatically detach static IP from source and attach to target upon migration completion. |
163
+ | `--static-ip` | `Auto-detect` | Explicit name of Lightsail Static IP to reassign. |
164
+ | `--harden-security / --no-harden-security` | `True` | Install and configure Fail2ban and Apache availability protections. |
165
+ | `--cloudwatch` | `False` | Provision IAM user and stream logs to CloudWatch. |
166
+ | `--dry-run` | `False` | Test credentials, inspect instances, and verify static IPs without making changes. |
167
+ | `--yes`, `-y` | `False` | Skip interactive confirmation prompts. |
168
+ | `--verbose`, `-v` | `False` | Enable debug logging output. |
169
+
170
+ ---
171
+
172
+ ## πŸ”’ Security Hardening Details
173
+
174
+ When `--harden-security` is enabled (default), the following defenses are provisioned on the target:
175
+
176
+ 1. **Fail2ban Jails (`/etc/fail2ban/jail.local`)**:
177
+ - `sshd`: Systemd backend, max 5 retries, 1-hour ban.
178
+ - `apache-wplogin`: Rate limits `wp-login.php` and `xmlrpc.php` brute force (max 8 attempts in 5 mins -> 1-hour ban).
179
+ - `apache-scanprobe`: Blocks automated vulnerability scanners querying `.env`, `.git`, `wp-config.php`, `phpmyadmin`, or directory traversal (5 attempts -> 6-hour ban).
180
+ 2. **Apache Availability Limits (`/etc/apache2/conf-available/availability-hardening.conf`)**:
181
+ - Caps `Timeout 60`, `KeepAliveTimeout 2`, and `MaxKeepAliveRequests 50` to safeguard low-memory / 2 vCPU Lightsail instances from connection exhaustion.
182
+
183
+ ---
184
+
185
+ ## πŸ“‹ Post-Migration Checklist
186
+
187
+ After migration completes:
188
+
189
+ 1. **Verify Website & Permalinks**: Open `http://<TARGET_IP_OR_STATIC_IP>` and click several subpages to confirm `.htaccess` routing works.
190
+ 2. **Verify WordPress Admin**: Log into `http://<TARGET_IP_OR_STATIC_IP>/wp-admin` to confirm all plugins, themes, and media uploads are present.
191
+ 3. **DNS & Static IP**:
192
+ - If `--reassign-static-ip` was used, the static IP is already attached to the new target. Ensure your DNS `A` records point to it.
193
+ - Otherwise, attach a static IP in the AWS Lightsail console and update DNS.
194
+ 4. **Issue Free SSL via Let's Encrypt**:
195
+ Once your domain resolves to the new instance, run standard Certbot:
196
+ ```bash
197
+ sudo apt-get update
198
+ sudo apt-get install -y certbot python3-certbot-apache
199
+ sudo certbot --apache
200
+ ```
201
+ 5. **Retire Old Instance**: Once verified, stop or delete the old Bitnami instance.
202
+
203
+ ---
204
+
205
+ ## πŸ§ͺ Testing
206
+
207
+ Run the test suite with Python's built-in `unittest` runner:
208
+ ```bash
209
+ python -m unittest discover -s tests
210
+ ```
211
+
212
+ Or with `pytest`:
213
+ ```bash
214
+ pytest tests/
215
+ ```
216
+
217
+ ---
218
+
219
+ ## πŸ“„ License
220
+
221
+ This project is licensed under the [MIT License](LICENSE).
@@ -0,0 +1,14 @@
1
+ lightsail_wordpress_migrator-1.0.0.dist-info/licenses/LICENSE,sha256=yE-kQmyQHsUPEPu35lKlL3RxREDFJc_jRG90eeYKnS4,1071
2
+ lightsail_wp_migrate/__init__.py,sha256=Qu4LMWYHneRbEXpvLR-th0AWnajJi2wdJVfwu9N0JCA,230
3
+ lightsail_wp_migrate/aws.py,sha256=Stw8xJjKL6ovQtyjxD54DOfxUYeFecaiiNkzX4T6w5o,14815
4
+ lightsail_wp_migrate/cli.py,sha256=YDVv5bOLFyhBYM_pqPjG3z0sfFImZs3wcn-sN20rdZs,9758
5
+ lightsail_wp_migrate/orchestrator.py,sha256=TbdD6nM5HMgJ5deoojsKlnXccqr6tKcIxQsnTCfEE4o,14939
6
+ lightsail_wp_migrate/security.py,sha256=rJ4fxpCoNbmEUCL5ZkVAtFneByt-5aR3PtxIbP7xPWI,3686
7
+ lightsail_wp_migrate/ssh.py,sha256=1UaA1wnShL2kqhkJwQwsiE2sNHNZ-4qQHH3uTVlfgiU,3779
8
+ lightsail_wp_migrate/utils.py,sha256=Mo1Hm7IAn9X6jIt_7csoAmdaNPskXkhybWRPrPOrGEA,1968
9
+ lightsail_wp_migrate/wordpress.py,sha256=bYeYiBbuiM1rlDbkHX2JPDTga1pxKUtYUXaT4yT7p_w,5676
10
+ lightsail_wordpress_migrator-1.0.0.dist-info/METADATA,sha256=_SsikXdEw7PN-Q2ScbnU8Xn08rJkm_mMy2eKGPSWxR8,11607
11
+ lightsail_wordpress_migrator-1.0.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
12
+ lightsail_wordpress_migrator-1.0.0.dist-info/entry_points.txt,sha256=xm9InEEYGH2cCVUrqL8xQcwCQHRTgk2ih0-Vfodj__g,71
13
+ lightsail_wordpress_migrator-1.0.0.dist-info/top_level.txt,sha256=9SRli7g5gPsHtMUb-uV45P9ccKmvWJdUkdX0aTMb0Xs,21
14
+ lightsail_wordpress_migrator-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ lightsail-wp-migrate = lightsail_wp_migrate.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jonathan Glass
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ lightsail_wp_migrate
@@ -0,0 +1,8 @@
1
+ """
2
+ lightsail-wordpress-migrator
3
+ Automated migration tool for upgrading AWS Lightsail Bitnami WordPress instances
4
+ to modern Amazon Linux 2023 / Debian Lightsail blueprints.
5
+ """
6
+
7
+ __version__ = "1.0.0"
8
+ __author__ = "Jonathan Glass"
@@ -0,0 +1,357 @@
1
+ """
2
+ AWS resource automation: Lightsail, S3, IAM, and CloudWatch.
3
+ """
4
+
5
+ import time
6
+ import json
7
+ import logging
8
+ import ipaddress
9
+ import tempfile
10
+ import os
11
+ import boto3
12
+ from typing import List, Dict, Any, Optional, Tuple
13
+ from .ssh import run_remote, sftp_put
14
+
15
+ log = logging.getLogger("lightsail-wp-migrate")
16
+
17
+
18
+ class AWSManager:
19
+ def __init__(self, profile: Optional[str] = None, region: str = "us-east-1"):
20
+ self.profile = profile
21
+ self.region = region
22
+ self.session = boto3.Session(profile_name=profile, region_name=region)
23
+ self.ls = self.session.client("lightsail")
24
+ self.s3 = self.session.client("s3")
25
+ self.iam = self.session.client("iam")
26
+ self.sts = self.session.client("sts")
27
+
28
+ def get_account_id(self) -> str:
29
+ return self.sts.get_caller_identity()["Account"]
30
+
31
+ def list_wordpress_instances(self) -> List[Dict[str, Any]]:
32
+ """List all Lightsail instances in the region."""
33
+ resp = self.ls.get_instances()
34
+ return resp.get("instances", [])
35
+
36
+ def get_instance(self, instance_name: str) -> Dict[str, Any]:
37
+ resp = self.ls.get_instance(instanceName=instance_name)
38
+ return resp["instance"]
39
+
40
+ def get_instance_ip(self, instance_name: str) -> str:
41
+ inst = self.get_instance(instance_name)
42
+ ip = inst.get("publicIpAddress")
43
+ if not ip:
44
+ raise RuntimeError(f"Instance '{instance_name}' has no public IP assigned.")
45
+ return ip
46
+
47
+ def list_static_ips(self) -> List[Dict[str, Any]]:
48
+ resp = self.ls.get_static_ips()
49
+ return resp.get("staticIps", [])
50
+
51
+ def get_static_ip_for_instance(self, instance_name: str) -> Optional[Dict[str, Any]]:
52
+ """Find static IP attached to a given instance."""
53
+ for sip in self.list_static_ips():
54
+ if sip.get("isAttached") and sip.get("attachedTo") == instance_name:
55
+ return sip
56
+ return None
57
+
58
+ def reassign_static_ip(self, static_ip_name: str, target_instance_name: str) -> str:
59
+ """Detach static IP from current instance and attach to target instance."""
60
+ log.info("🌐 Reassigning Static IP '%s' to target instance '%s' ...", static_ip_name, target_instance_name)
61
+ try:
62
+ self.ls.detach_static_ip(staticIpName=static_ip_name)
63
+ time.sleep(3)
64
+ except Exception as e:
65
+ log.warning("Notice while detaching static IP: %s", e)
66
+
67
+ self.ls.attach_static_ip(staticIpName=static_ip_name, instanceName=target_instance_name)
68
+ time.sleep(3)
69
+
70
+ # Look up IP address
71
+ for sip in self.list_static_ips():
72
+ if sip["name"] == static_ip_name:
73
+ ip_addr = sip.get("ipAddress", "")
74
+ log.info("Static IP '%s' (%s) successfully attached to '%s'.", static_ip_name, ip_addr, target_instance_name)
75
+ return ip_addr
76
+
77
+ return ""
78
+
79
+ def is_ssh_open_for(self, instance_name: str, my_cidr: str) -> bool:
80
+ my_net = ipaddress.ip_network(my_cidr, strict=False)
81
+ ports = self.ls.get_instance_port_states(instanceName=instance_name).get("portStates", [])
82
+ for rule in ports:
83
+ if not (rule.get("fromPort") <= 22 <= rule.get("toPort")):
84
+ continue
85
+ if rule.get("protocol") not in ("tcp", "all"):
86
+ continue
87
+ if rule.get("state") != "open":
88
+ continue
89
+ for cidr in rule.get("cidrs", []):
90
+ try:
91
+ if my_net.subnet_of(ipaddress.ip_network(cidr, strict=False)):
92
+ return True
93
+ except Exception:
94
+ continue
95
+ return False
96
+
97
+ def open_ssh_firewall(self, instance_name: str, cidr: str) -> bool:
98
+ if self.is_ssh_open_for(instance_name, cidr):
99
+ log.info("Port 22 on '%s' is already open for %s.", instance_name, cidr)
100
+ return False
101
+ log.info("Opening port 22 on '%s' for %s ...", instance_name, cidr)
102
+ self.ls.open_instance_public_ports(
103
+ instanceName=instance_name,
104
+ portInfo={
105
+ "fromPort": 22,
106
+ "toPort": 22,
107
+ "protocol": "tcp",
108
+ "cidrs": [cidr],
109
+ },
110
+ )
111
+ return True
112
+
113
+ def close_ssh_firewall(self, instance_name: str, cidr: str, was_opened: bool) -> None:
114
+ if not was_opened:
115
+ return
116
+ log.info("Closing port 22 on '%s' for %s ...", instance_name, cidr)
117
+ try:
118
+ self.ls.close_instance_public_ports(
119
+ instanceName=instance_name,
120
+ portInfo={
121
+ "fromPort": 22,
122
+ "toPort": 22,
123
+ "protocol": "tcp",
124
+ "cidrs": [cidr],
125
+ },
126
+ )
127
+ except Exception as exc:
128
+ log.warning("Could not close port 22 on '%s': %s", instance_name, exc)
129
+
130
+ def create_snapshot(self, instance_name: str, snapshot_name: Optional[str] = None) -> str:
131
+ name = snapshot_name or f"{instance_name}-pre-migration-{int(time.time())}"
132
+ log.info("πŸ“Έ Creating pre-migration snapshot '%s' ...", name)
133
+ self.ls.create_instance_snapshot(
134
+ instanceName=instance_name,
135
+ instanceSnapshotName=name,
136
+ )
137
+ return name
138
+
139
+ def create_target_instance(
140
+ self,
141
+ instance_name: str,
142
+ availability_zone: str,
143
+ blueprint_id: str = "wordpress_ls_1_0",
144
+ bundle_id: str = "nano_3_0",
145
+ key_pair_name: str = "LightsailDefaultKeyPair",
146
+ ) -> None:
147
+ try:
148
+ existing = self.ls.get_instance(instanceName=instance_name)
149
+ state = existing["instance"]["state"]["name"]
150
+ log.info("Target instance '%s' already exists (state=%s). Skipping creation.", instance_name, state)
151
+ return
152
+ except self.ls.exceptions.NotFoundException:
153
+ pass
154
+
155
+ log.info("πŸš€ Creating native Lightsail instance '%s' (%s, %s)...", instance_name, blueprint_id, bundle_id)
156
+ self.ls.create_instances(
157
+ instanceNames=[instance_name],
158
+ availabilityZone=availability_zone,
159
+ blueprintId=blueprint_id,
160
+ bundleId=bundle_id,
161
+ keyPairName=key_pair_name,
162
+ )
163
+
164
+ def wait_for_instance(self, instance_name: str, target_state: str = "running", timeout: int = 600, poll: int = 10) -> None:
165
+ log.info("Waiting for '%s' to reach '%s' state ...", instance_name, target_state)
166
+ deadline = time.time() + timeout
167
+ while time.time() < deadline:
168
+ resp = self.ls.get_instance(instanceName=instance_name)
169
+ state = resp["instance"]["state"]["name"]
170
+ if state == target_state:
171
+ log.info("Instance '%s' is now %s.", instance_name, state)
172
+ return
173
+ time.sleep(poll)
174
+ raise TimeoutError(f"Timed out waiting for instance '{instance_name}' to reach '{target_state}'.")
175
+
176
+ def ensure_s3_bucket(self, bucket_name: str) -> None:
177
+ try:
178
+ self.s3.head_bucket(Bucket=bucket_name)
179
+ except Exception:
180
+ log.info("Creating S3 staging bucket '%s' in %s ...", bucket_name, self.region)
181
+ if self.region == "us-east-1":
182
+ self.s3.create_bucket(Bucket=bucket_name)
183
+ else:
184
+ self.s3.create_bucket(
185
+ Bucket=bucket_name,
186
+ CreateBucketConfiguration={"LocationConstraint": self.region},
187
+ )
188
+
189
+ def get_presigned_put_url(self, bucket: str, key: str, expires_in: int = 3600) -> str:
190
+ return self.s3.generate_presigned_url("put_object", Params={"Bucket": bucket, "Key": key}, ExpiresIn=expires_in)
191
+
192
+ def get_presigned_get_url(self, bucket: str, key: str, expires_in: int = 3600) -> str:
193
+ return self.s3.generate_presigned_url("get_object", Params={"Bucket": bucket, "Key": key}, ExpiresIn=expires_in)
194
+
195
+ def setup_cloudwatch_agent(
196
+ self,
197
+ ssh_tgt: Any,
198
+ instance_name: str,
199
+ log_group_prefix: str,
200
+ retention_days: int = 14,
201
+ include_fail2ban: bool = False,
202
+ ) -> None:
203
+ """Provision dedicated IAM user and install CloudWatch agent on target instance."""
204
+ log.info("πŸ“Š Setting up CloudWatch Logs agent on target instance ...")
205
+ prefix = log_group_prefix.rstrip("/")
206
+
207
+ # 1. Install agent
208
+ run_remote(
209
+ ssh_tgt,
210
+ "if ! dpkg -l amazon-cloudwatch-agent &>/dev/null; then "
211
+ "wget -q https://s3.amazonaws.com/amazoncloudwatch-agent/debian/amd64/latest/amazon-cloudwatch-agent.deb -O /tmp/cwa.deb && "
212
+ "sudo dpkg -i /tmp/cwa.deb && rm /tmp/cwa.deb; "
213
+ "else echo 'CloudWatch agent already installed'; fi",
214
+ )
215
+
216
+ # 2. Config JSON
217
+ collect_list = [
218
+ {
219
+ "file_path": "/var/log/apache2/access.log",
220
+ "log_group_name": f"{prefix}/apache/access",
221
+ "log_stream_name": instance_name,
222
+ "timestamp_format": "[%d/%b/%Y:%H:%M:%S %z]",
223
+ "retention_in_days": retention_days,
224
+ },
225
+ {
226
+ "file_path": "/var/log/apache2/error.log",
227
+ "log_group_name": f"{prefix}/apache/error",
228
+ "log_stream_name": instance_name,
229
+ "timestamp_format": "[%a %b %d %H:%M:%S.%f %Y]",
230
+ "retention_in_days": retention_days,
231
+ },
232
+ {
233
+ "file_path": "/var/www/html/wp-content/debug.log",
234
+ "log_group_name": f"{prefix}/wordpress/debug",
235
+ "log_stream_name": instance_name,
236
+ "timestamp_format": "%Y-%m-%d %H:%M:%S",
237
+ "retention_in_days": retention_days,
238
+ },
239
+ {
240
+ "file_path": "/var/log/syslog",
241
+ "log_group_name": f"{prefix}/os/syslog",
242
+ "log_stream_name": instance_name,
243
+ "timestamp_format": "%b %d %H:%M:%S",
244
+ "retention_in_days": retention_days,
245
+ },
246
+ {
247
+ "file_path": "/var/log/auth.log",
248
+ "log_group_name": f"{prefix}/os/auth",
249
+ "log_stream_name": instance_name,
250
+ "timestamp_format": "%b %d %H:%M:%S",
251
+ "retention_in_days": retention_days,
252
+ },
253
+ ]
254
+ if include_fail2ban:
255
+ collect_list.append({
256
+ "file_path": "/var/log/fail2ban.log",
257
+ "log_group_name": f"{prefix}/fail2ban",
258
+ "log_stream_name": instance_name,
259
+ "timestamp_format": "%Y-%m-%d %H:%M:%S,%f",
260
+ "retention_in_days": retention_days,
261
+ })
262
+
263
+ agent_config = {
264
+ "agent": {"run_as_user": "root", "region": self.region},
265
+ "logs": {
266
+ "logs_collected": {"files": {"collect_list": collect_list}},
267
+ "log_stream_name": "default",
268
+ "force_flush_interval": 15,
269
+ },
270
+ }
271
+
272
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
273
+ json.dump(agent_config, f, indent=2)
274
+ tmp_json = f.name
275
+ sftp_put(ssh_tgt, tmp_json, "/tmp/cwa_config.json")
276
+ os.unlink(tmp_json)
277
+ run_remote(
278
+ ssh_tgt,
279
+ "sudo mkdir -p /opt/aws/amazon-cloudwatch-agent/etc && "
280
+ "sudo mv /tmp/cwa_config.json /opt/aws/amazon-cloudwatch-agent/etc/amazon-cloudwatch-agent.json",
281
+ )
282
+
283
+ # 3. Dedicated IAM user
284
+ iam_user = f"wp-logs-{instance_name}"[:64]
285
+ try:
286
+ self.iam.create_user(UserName=iam_user)
287
+ log.info("Created dedicated IAM user '%s'.", iam_user)
288
+ except self.iam.exceptions.EntityAlreadyExistsException:
289
+ log.info("IAM user '%s' already exists; reusing.", iam_user)
290
+
291
+ policy_doc = json.dumps({
292
+ "Version": "2012-10-17",
293
+ "Statement": [
294
+ {
295
+ "Effect": "Allow",
296
+ "Action": [
297
+ "logs:CreateLogGroup",
298
+ "logs:CreateLogStream",
299
+ "logs:PutLogEvents",
300
+ "logs:DescribeLogStreams",
301
+ "logs:PutRetentionPolicy",
302
+ ],
303
+ "Resource": f"arn:aws:logs:{self.region}:*:log-group:{prefix}*:*",
304
+ },
305
+ {"Effect": "Allow", "Action": "logs:DescribeLogGroups", "Resource": "*"},
306
+ ],
307
+ })
308
+ self.iam.put_user_policy(UserName=iam_user, PolicyName="CloudWatchLogsWrite", PolicyDocument=policy_doc)
309
+
310
+ keys = self.iam.list_access_keys(UserName=iam_user)["AccessKeyMetadata"]
311
+ if len(keys) >= 2:
312
+ oldest = sorted(keys, key=lambda k: k["CreateDate"])[0]
313
+ self.iam.delete_access_key(UserName=iam_user, AccessKeyId=oldest["AccessKeyId"])
314
+
315
+ new_key = self.iam.create_access_key(UserName=iam_user)["AccessKey"]
316
+ time.sleep(10) # IAM eventual consistency
317
+
318
+ creds_content = (
319
+ "[AmazonCloudWatchAgent]\n"
320
+ f"aws_access_key_id = {new_key['AccessKeyId']}\n"
321
+ f"aws_secret_access_key = {new_key['SecretAccessKey']}\n"
322
+ f"region = {self.region}\n"
323
+ )
324
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".ini", delete=False) as f:
325
+ f.write(creds_content)
326
+ tmp_creds = f.name
327
+ sftp_put(ssh_tgt, tmp_creds, "/tmp/aws_creds.ini")
328
+ os.unlink(tmp_creds)
329
+ run_remote(
330
+ ssh_tgt,
331
+ "sudo mkdir -p /root/.aws && "
332
+ "sudo mv /tmp/aws_creds.ini /root/.aws/credentials && "
333
+ "sudo chmod 600 /root/.aws/credentials",
334
+ )
335
+
336
+ common_cfg = (
337
+ "[credentials]\n"
338
+ " shared_credential_profile = \"AmazonCloudWatchAgent\"\n"
339
+ " shared_credential_file = \"/root/.aws/credentials\"\n"
340
+ )
341
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f:
342
+ f.write(common_cfg)
343
+ tmp_cfg = f.name
344
+ sftp_put(ssh_tgt, tmp_cfg, "/tmp/common-config.toml")
345
+ os.unlink(tmp_cfg)
346
+ run_remote(ssh_tgt, "sudo mv /tmp/common-config.toml /opt/aws/amazon-cloudwatch-agent/etc/common-config.toml")
347
+
348
+ # 4. Start agent
349
+ run_remote(
350
+ ssh_tgt,
351
+ "sudo /opt/aws/amazon-cloudwatch-agent/bin/amazon-cloudwatch-agent-ctl "
352
+ "-a fetch-config -m onPremise "
353
+ "-c file:/opt/aws/amazon-cloudwatch-agent/etc/amazon-cloudwatch-agent.json -s 2>&1",
354
+ check=False,
355
+ )
356
+ run_remote(ssh_tgt, "sudo systemctl enable amazon-cloudwatch-agent", check=False)
357
+ log.info("CloudWatch Logs agent configured and enabled.")