Updates to work with metadata service

This commit is contained in:
David Fairbanks 2025-08-10 17:23:45 -04:00
parent 92fd23b18c
commit e9d1a9ccc1
Signed by: david-fairbanks42
GPG Key ID: 23A5FB8E1952978F
9 changed files with 709 additions and 534 deletions

View File

@ -11,7 +11,7 @@ during the snapshot start which will break the database.
## Usage ## Usage
After the required configuration settings are set up in the `.env` file, simply executing `php backup.php` will After the required configuration settings are set up in the `.env` file, simply executing `php backup.php` will
perform the backup. The script has additional options such as `--no-prune` to prevent the script from removing old perform the backup. The script has additional options such as `--no-prune` to prevent the script from removing old
snapshots. If the configuration setting `ENABLE` is set to `false` the script will no perform any actions. To override snapshots. If the configuration setting `ENABLE` is set to `false` the script will not perform any actions. To override
the `ENABLE` setting, `--force` can be used. The script will also respond to `--help` to outline the usage. the `ENABLE` setting, `--force` can be used. The script will also respond to `--help` to outline the usage.
## Setup ## Setup

View File

@ -13,7 +13,6 @@ use Carbon\Carbon;
* Class Dates * Class Dates
* *
* @author David Fairbanks <david@makerdave.com> * @author David Fairbanks <david@makerdave.com>
* @package Fairbanks\Kizarian\Utilities
* @version 2.0 * @version 2.0
*/ */
class Dates { class Dates {

View File

@ -2,7 +2,7 @@
/** /**
* Ec2Backup.php * Ec2Backup.php
* *
* @copyright 2023 Fairbanks Publishing LLC * @copyright 2025 Fairbanks Publishing LLC
*/ */
namespace App; namespace App;
@ -10,21 +10,19 @@ namespace App;
use Aws\Ec2\Ec2Client; use Aws\Ec2\Ec2Client;
/** /**
* Class Ec2Snapshot * Class Ec2Backup
* *
* @author David Fairbanks <david@makerdave.com> * @author David Fairbanks <david@makerdave.com>
* @version 2.0 * @version 3.0
*/ */
class Ec2Backup class Ec2Backup
{ {
/**
* @var Ec2Client
*/
private Ec2Client $ec2Client; private Ec2Client $ec2Client;
private array $options = [
/** 'noPrune' => false,
* @var boolean 'force' => false,
*/ 'dryRun' => false,
];
private bool $enable = true; private bool $enable = true;
/** /**
@ -34,21 +32,26 @@ class Ec2Backup
*/ */
private int $maxBackupCount = 4; private int $maxBackupCount = 4;
public function __construct(Ec2Client $ec2Client) { public function __construct(Ec2Client $ec2Client, array $options = [])
{
$this->ec2Client = $ec2Client; $this->ec2Client = $ec2Client;
foreach ($this->options as $key => $default) {
if (array_key_exists($key, $options) && is_bool($options[$key])) {
$this->options[$key] = $options[$key];
}
}
$this->enable = boolean(config('ENABLE', true)); $this->enable = boolean(config('ENABLE', true));
$this->maxBackupCount = (int)config('MAX_SNAPSHOT_COUNT', 4); $this->maxBackupCount = (int) config('MAX_SNAPSHOT_COUNT', 4);
if($this->maxBackupCount <= 0) if ($this->maxBackupCount <= 0) {
$this->maxBackupCount = 4; $this->maxBackupCount = 4;
} }
}
public function create(array $options=[]): void public function create(): void
{ {
$options = array_merge(['noPrune' => false, 'force' => false], $options); if (! $this->enable && ! $this->options['force']) {
if (!$this->enable && !$options['force']) {
app_echo('EC2 Backup is disabled in environment'); app_echo('EC2 Backup is disabled in environment');
return; return;
} }
@ -63,30 +66,31 @@ class Ec2Backup
try { try {
$machine = MachineDetails::getDetails(); $machine = MachineDetails::getDetails();
} catch(\Exception $e) { } catch (\Exception $e) {
app_echo("Error getting machine details: {$e->getMessage()}"); app_echo("Error getting machine details: {$e->getMessage()}");
return; return;
} }
if ($machine['type'] != 'ec2') { if ($machine->type != 'ec2') {
app_echo('Instance type is wrong to do an EC2 backup', $machine); app_echo('Instance type is wrong to do an EC2 backup', (array) $machine);
return; return;
} }
$volumes = $this->getVolumes($machine['instanceId']); $volumes = $this->getVolumes($machine->instanceId);
$tags = $this->getTags($machine['instanceId']); $tags = $this->getTags($machine->instanceId);
foreach ($volumes as $volume) { foreach ($volumes as $volume) {
if ($options['noPrune']) { if ($this->options['noPrune']) {
$pruneWording = '(pruning disabled)'; $pruneWording = '(pruning disabled)';
} else { } else {
$pruneCount = $this->pruneBackups($volume['volumeId']); $pruneCount = $this->pruneBackups($volume['volumeId']);
$pruneWording = "and pruned {$pruneCount} old snapshots"; $pruneWording = "and pruned {$pruneCount} old snapshots";
} }
$name = (isset($tags['Name'])) ? $tags['Name'] : $machine['instanceId']; $name = (isset($tags['Name'])) ? $tags['Name'] : $machine->instanceId;
if (count($volumes) > 1) if (count($volumes) > 1) {
$name .= " ({$volume['device']})"; $name .= " ({$volume['device']})";
}
if ($this->backup(['volumeId' => $volume['volumeId'], 'name' => $name])) { if ($this->backup(['volumeId' => $volume['volumeId'], 'name' => $name])) {
app_echo("Successfully started snapshot for {$volume['volumeId']} {$pruneWording}"); app_echo("Successfully started snapshot for {$volume['volumeId']} {$pruneWording}");
@ -110,7 +114,7 @@ class Ec2Backup
] ]
] ]
); );
} catch(\Exception $e) { } catch (\Exception $e) {
app_echo('Error getting volumes: ' . $e->getMessage()); app_echo('Error getting volumes: ' . $e->getMessage());
return []; return [];
} }
@ -140,7 +144,7 @@ class Ec2Backup
] ]
] ]
); );
} catch(\Exception $e) { } catch (\Exception $e) {
app_echo('Error getting instance tags: ' . $e->getMessage()); app_echo('Error getting instance tags: ' . $e->getMessage());
return []; return [];
} }
@ -167,15 +171,16 @@ class Ec2Backup
] ]
] ]
); );
} catch(\Exception $e) { } catch (\Exception $e) {
app_echo('Error getting current snapshots: ' . $e->getMessage()); app_echo('Error getting current snapshots: ' . $e->getMessage());
return []; return [];
} }
$snapshots = []; $snapshots = [];
foreach ($result['Snapshots'] as $snapshot) { foreach ($result['Snapshots'] as $snapshot) {
if ($snapshot['State'] != 'completed') if ($snapshot['State'] != 'completed') {
continue; continue;
}
$snapshots[$snapshot['SnapshotId']] = [ $snapshots[$snapshot['SnapshotId']] = [
'started' => Dates::makeCarbon($snapshot['StartTime']), 'started' => Dates::makeCarbon($snapshot['StartTime']),
@ -184,7 +189,7 @@ class Ec2Backup
]; ];
} }
uasort($snapshots, function($a, $b) { uasort($snapshots, function ($a, $b) {
if ($a['started'] == $b['started']) { if ($a['started'] == $b['started']) {
return 0; return 0;
} else { } else {
@ -204,7 +209,7 @@ class Ec2Backup
} }
$prune = array_slice($backups, 0, count($backups) - $this->maxBackupCount); $prune = array_slice($backups, 0, count($backups) - $this->maxBackupCount);
if(empty($prune)) { if (empty($prune)) {
return 0; return 0;
} }
@ -213,11 +218,11 @@ class Ec2Backup
try { try {
$this->ec2Client->deleteSnapshot( $this->ec2Client->deleteSnapshot(
[ [
'DryRun' => false, 'DryRun' => $this->options['dryRun'],
'SnapshotId' => $snapshotId 'SnapshotId' => $snapshotId
] ]
); );
} catch(\Exception $e) { } catch (\Exception $e) {
app_echo('Error pruning snapshot: ' . $e->getMessage()); app_echo('Error pruning snapshot: ' . $e->getMessage());
continue; continue;
} }
@ -230,7 +235,7 @@ class Ec2Backup
public function backup(array $params): bool public function backup(array $params): bool
{ {
if (!isset($params['volumeId'])) { if (! isset($params['volumeId'])) {
app_echo('Volume ID is not set in backup parameters'); app_echo('Volume ID is not set in backup parameters');
return false; return false;
} }
@ -239,7 +244,7 @@ class Ec2Backup
$tags = [['Key' => 'Name', 'Value' => $name]]; $tags = [['Key' => 'Name', 'Value' => $name]];
$snapTags = json_decode(config('TAGS', '[]'), true); $snapTags = json_decode(config('TAGS', '[]'), true);
if (is_array($snapTags) && !empty($snapTags)) { if (is_array($snapTags) && ! empty($snapTags)) {
foreach ($snapTags as $key => $value) { foreach ($snapTags as $key => $value) {
$tags[] = ['Key' => $key, 'Value' => $value]; $tags[] = ['Key' => $key, 'Value' => $value];
} }
@ -249,7 +254,7 @@ class Ec2Backup
$this->ec2Client->createSnapshot( $this->ec2Client->createSnapshot(
[ [
'Description' => sprintf('%s Backup %s', $name, date('Y-m-d')), 'Description' => sprintf('%s Backup %s', $name, date('Y-m-d')),
'DryRun' => false, 'DryRun' => $this->options['dryRun'],
'TagSpecifications' => [ 'TagSpecifications' => [
[ [
'ResourceType' => 'snapshot', 'ResourceType' => 'snapshot',
@ -259,7 +264,7 @@ class Ec2Backup
'VolumeId' => $params['volumeId'] 'VolumeId' => $params['volumeId']
] ]
); );
} catch(\Exception $e) { } catch (\Exception $e) {
app_echo($e->getMessage()); app_echo($e->getMessage());
return false; return false;
} }

View File

@ -2,46 +2,38 @@
/** /**
* MachineDetails.php * MachineDetails.php
* *
* @copyright 2023 Fairbanks Publishing LLC * @copyright 2025 Fairbanks Publishing LLC
*/ */
namespace App; namespace App;
use Carbon\Carbon;
use Carbon\CarbonInterface;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
use Exception;
/** /**
* Class MachineDetails * Class MachineDetails
* *
* @author David Fairbanks <david@makerdave.com> * @author David Fairbanks <david@makerdave.com>
* @version 2.0 * @version 3.0
*/ */
class MachineDetails class MachineDetails
{ {
/**
* @var MachineDetails
*/
protected static MachineDetails $instance; protected static MachineDetails $instance;
/** protected string|null $type = null;
* @var array protected string|null $machineId = null;
*/ protected string|null $instanceId = null;
protected static array $details = [ protected string|null $region = null;
'type' => null, protected string|null $privateIp = null;
'id' => null, protected string|null $publicIp = null;
'machineId' => null,
'instanceId' => null,
'region' => null,
'publicIp' => null,
'privateIp' => null,
];
/** private Client|null $metadataServiceClient = null;
* PHP config setting for default_socket_timeout to reset to after doing file_get_contents() private string|null $metadataServiceToken = null;
* @var int|null private CarbonInterface|null $metadataServiceTokenDate = null;
*/
protected ?int $socketTimeout = null;
/**
* @return MachineDetails
*/
public static function getInstance(): MachineDetails public static function getInstance(): MachineDetails
{ {
if (!isset(static::$instance)) { if (!isset(static::$instance)) {
@ -51,162 +43,75 @@ class MachineDetails
return static::$instance; return static::$instance;
} }
/** public static function getDetails(): object
* @return array
*/
public static function getDetails(): array
{ {
if(self::$instance === null) if(! isset(self::$instance))
self::$instance = self::getInstance(); self::$instance = self::getInstance();
return self::$details; return self::$instance->get();
} }
/** public function get(): object
* @return string|null
*/
public static function id(): ?string
{ {
if(self::$instance === null) return (object) [
self::$instance = self::getInstance(); 'type' => $this->type,
'machineId' => $this->machineId,
return self::$details['id']; 'instanceId' => $this->instanceId,
} 'region' => $this->region,
'privateIp' => $this->privateIp,
/** 'publicIp' => $this->publicIp,
* @return string|null
*/
public static function machineId(): ?string
{
if(self::$instance === null)
self::$instance = self::getInstance();
return self::$details['machineId'];
}
/**
* @return string
*/
public static function fullMachineId(): string
{
if(self::$instance === null)
self::$instance = self::getInstance();
$id = [
config('MACHINE_TYPE'),
self::$details['region'],
self::$details['machineId']
]; ];
return implode('-', array_unique(array_filter($id)));
} }
/** public function publicIp(): string|null
* @return string|null
*/
public static function region(): ?string
{ {
if(self::$instance === null) if (! empty($this->publicIp)) {
self::$instance = self::getInstance(); return $this->publicIp;
return self::$details['region'];
} }
/** $this->ensureMetadataService();
* @return string|null if (empty($this->metadataServiceToken)) {
*/ return null;
public static function publicIp(): ?string
{
if(self::$instance === null)
self::$instance = self::getInstance();
if(self::$details['publicIp'] === null) {
self::$details['publicIp'] = match (self::$details['type']) {
'ec2' => self::getEc2PublicIp(),
default => self::getLocalPublicIp(),
};
} }
return self::$details['privateIp']; try {
$response = $this->metadataServiceClient->request(
'GET',
'meta-data/public-ipv4',
[
'headers' => [
'X-aws-ec2-metadata-token' => $this->metadataServiceToken,
],
]
);
$this->publicIp = $response->getBody()->getContents();
} catch (GuzzleException|Exception $e) {
app_echo(get_class($e) . ' getting IMDS V2 instance details: ' . $e->getMessage());
} }
/** return $this->publicIp;
* @return string|null
*/
public static function privateIp(): ?string
{
if(self::$instance === null)
self::$instance = self::getInstance();
return self::$details['privateIp'];
}
/* Private methods */
private static function getLocalPublicIp(): string
{
return '127.0.0.1';
}
private static function getEc2PublicIp(): false|string
{
if(self::$instance === null)
self::$instance = self::getInstance();
self::$instance->shortenTimeout();
$ip = @file_get_contents('http://169.254.169.254/latest/meta-data/public-ipv4');
self::$instance->resetTimeout();
return $ip;
} }
private function __construct() { private function __construct() {
$type = config('MACHINE_TYPE'); if ($this->isEc2()) {
if($type === null || empty($type)) { $this->type = 'ec2';
$type = $this->determineMachineType(); $this->instanceIdentity();
} } else {
$this->type = 'unknown';
switch($type) {
case 'ec2' :
self::$details['type'] = 'ec2';
self::$details = array_merge(self::$details, $this->getEc2Data());
break;
default :
self::$details['type'] = 'local';
self::$details = array_merge(self::$details, $this->getLocalData());
} }
} }
/** private function isEc2(): bool
* Make clone magic method private, so nobody can clone instance. {
*/ $uname = php_uname();
return preg_match('/ip-\d{2,3}-\d{1,3}-\d{1,3}-\d{1,3}\s.*-aws/', $uname);
}
private function __clone() {} private function __clone() {}
private function determineMachineType(): string private function instanceIdentity(): void
{
$this->shortenTimeout();
$hostname = @file_get_contents('http://169.254.169.254/latest/meta-data/hostname');
$this->resetTimeout();
if(strpos($hostname, 'ec2') !== false) {
return 'ec2';
} else {
return 'local';
}
}
private function getLocalData(): array
{
return [
'id' => 'dev',
'machineId' => 'dev',
'region' => 'local',
'publicIp' => '127.0.0.1',
'privateIp' => '127.0.0.1',
];
}
private function getEc2Data(): array
{ {
/* /*
* curl "http://169.254.169.254/latest/dynamic/instance-identity/document" * curl "http://169.254.169.254/latest/dynamic/instance-identity/document"
@ -228,40 +133,74 @@ class MachineDetails
* } * }
*/ */
$this->shortenTimeout(); $this->ensureMetadataService();
$json = @file_get_contents('http://169.254.169.254/latest/dynamic/instance-identity/document'); if (empty($this->metadataServiceToken)) {
$this->resetTimeout(); return;
$data = json_decode($json, true);
if(!is_array($data) || empty($data)) {
throw new \Exception('Invalid machine details from http://169.254.169.254. Assumption is this is not an EC2 instance.');
} }
if(isset($data['instanceId'])) try {
$data['machineId'] = substr($data['instanceId'], 2); $response = $this->metadataServiceClient->request(
'GET',
'dynamic/instance-identity/document',
[
'headers' => [
'X-aws-ec2-metadata-token' => $this->metadataServiceToken,
],
]
);
$out = []; $json = $response->getBody()->getContents();
foreach(self::$details as $key => $v) { } catch (GuzzleException|Exception $e) {
$out[$key] = (isset($data[$key])) ? $data[$key] : $v; app_echo(get_class($e) . ' getting IMDS V2 instance details: ' . $e->getMessage());
return;
} }
return $out; $data = json_decode($json);
$this->instanceId = $data->instanceId;
$this->region = $data->region;
$this->privateIp = $data->privateIp;
if (! empty($data->instanceId)) {
$this->machineId = substr($data->instanceId, 2);
}
} }
private function shortenTimeout(): void private function ensureMetadataService(): void
{ {
if($this->socketTimeout === null) { if ($this->metadataServiceClient === null) {
$this->socketTimeout = ini_get('default_socket_timeout'); $this->metadataServiceClient = new Client(['base_uri' => 'http://169.254.169.254/latest/']);
} }
ini_set('default_socket_timeout', 2); if ($this->metadataServiceToken !== null && $this->metadataServiceTokenDate !== null
&& $this->metadataServiceTokenDate->isAfter(Carbon::now()->addSeconds(-60))
) {
return;
} }
private function resetTimeout(): void $this->metadataServiceToken = null;
{ $this->metadataServiceTokenDate = Carbon::now();
if($this->socketTimeout !== null) {
ini_set('default_socket_timeout', $this->socketTimeout); // https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/instancedata-dynamic-data-retrieval.html
// https://stackoverflow.com/a/74334921/667613
// TOKEN=`curl -X PUT "http://169.254.169.254/latest/api/token" -H "X-aws-ec2-metadata-token-ttl-seconds: 21600"`
// curl -H "X-aws-ec2-metadata-token: $TOKEN" http://169.254.169.254/latest/dynamic/instance-identity/document
try {
$response = $this->metadataServiceClient->request(
'PUT',
'api/token',
[
'headers' => [
'X-aws-ec2-metadata-token-ttl-seconds' => 60,
],
]
);
$this->metadataServiceToken = $response->getBody()->getContents();
} catch (GuzzleException|Exception $e) {
app_echo(get_class($e) . ' getting IMDS V2 token: ' . $e->getMessage());
} }
} }
} }

View File

@ -5,64 +5,6 @@
* @copyright (c) 2018, Fairbanks Publishing * @copyright (c) 2018, Fairbanks Publishing
*/ */
if(!function_exists('array_combine_safe')) {
/**
* Same affect as array_combine but does not error if the two arrays are different lengths
*
* @param array $keys
* @param array $values
* @param null $default
*
* @return array
*/
function array_combine_safe(array $keys = [], array $values = [], $default = null): array
{
if (empty($keys)) {
return [];
}
$out = [];
foreach ($keys as $index => $key) {
$out[$key] = (isset($values[$index])) ? $values[$index] : $default;
}
return $out;
}
}
if(!function_exists('array_limit_keys')) {
/**
* Filter input array to only have the keys provided
*
* Only ensures first level of supplied array
*
* @param array $keys
* @param array $input
*
* @return array
*/
function array_limit_keys(array $keys = [], array $input = []): array
{
if(empty($keys)) {
return [];
}
//return array_filter($input, function($item) use ($keys) {
// return in_array($item, $keys);
//}, ARRAY_FILTER_USE_KEY);
$out = [];
foreach ($keys as $key) {
if (array_key_exists($key, $input)) {
$out[$key] = $input[$key];
}
}
return $out;
}
}
if(!function_exists('boolean')) { if(!function_exists('boolean')) {
/** /**
* Convert value to boolean * Convert value to boolean

View File

@ -1,6 +1,6 @@
<?php <?php
$arguments = getopt('fph', ['force', 'no-prune', 'help']); $arguments = getopt('fph', ['force', 'no-prune', 'dry-run', 'help']);
if(isset($arguments['h']) || isset($arguments['help'])) { if(isset($arguments['h']) || isset($arguments['help'])) {
echo <<<hereDoc echo <<<hereDoc
Create and rotate EBS snapshots on AWS for EC2 instances. Create and rotate EBS snapshots on AWS for EC2 instances.
@ -11,6 +11,7 @@ Usage:
Options: Options:
-f, --force Override the ENABLE environment variable -f, --force Override the ENABLE environment variable
-p, --no-prune Prevent the removal of old snapshots -p, --no-prune Prevent the removal of old snapshots
--dry-run Do not actually create the snapshot
hereDoc; hereDoc;
exit; exit;
@ -18,7 +19,8 @@ hereDoc;
$options = [ $options = [
'force' => isset($arguments['f']) || isset($arguments['force']), 'force' => isset($arguments['f']) || isset($arguments['force']),
'noPrune' => isset($arguments['p']) || isset($arguments['no-prune']) 'noPrune' => isset($arguments['p']) || isset($arguments['no-prune']),
'dryRun' => isset($arguments['dry-run']),
]; ];
require_once(__DIR__ . '/vendor/autoload.php'); require_once(__DIR__ . '/vendor/autoload.php');
@ -31,7 +33,7 @@ $dotenv->required('AWS_REGION')->notEmpty();
$dotenv->ifPresent('MAX_SNAPSHOT_COUNT')->isInteger(); $dotenv->ifPresent('MAX_SNAPSHOT_COUNT')->isInteger();
$dotenv->ifPresent('ENABLE')->isBoolean(); $dotenv->ifPresent('ENABLE')->isBoolean();
$ec2Client = new \Aws\Ec2\Ec2Client([ $ec2Client = new Aws\Ec2\Ec2Client([
'credentials' => [ 'credentials' => [
'key' => config('AWS_KEY'), 'key' => config('AWS_KEY'),
'secret' => config('AWS_SECRET') 'secret' => config('AWS_SECRET')
@ -39,6 +41,5 @@ $ec2Client = new \Aws\Ec2\Ec2Client([
'region' => config('AWS_REGION'), 'region' => config('AWS_REGION'),
'version' => config('AWS_VERSION', 'latest') 'version' => config('AWS_VERSION', 'latest')
]); ]);
$ec2Backup = new \App\Ec2Backup($ec2Client); $ec2Backup = new App\Ec2Backup($ec2Client, $options);
$ec2Backup->create(); $ec2Backup->create();

View File

@ -2,8 +2,9 @@
"require": { "require": {
"php": "^8.2", "php": "^8.2",
"ext-json": "*", "ext-json": "*",
"aws/aws-sdk-php": "^3.209", "aws/aws-sdk-php": "^3.308.0",
"nesbot/carbon": "^2.68", "guzzlehttp/guzzle": "^7.2",
"nesbot/carbon": "3.8.4.0",
"vlucas/phpdotenv": "^5.5" "vlucas/phpdotenv": "^5.5"
}, },
"config": { "config": {

756
composer.lock generated

File diff suppressed because it is too large Load Diff

12
metadata.php Normal file
View File

@ -0,0 +1,12 @@
<?php
require_once(__DIR__ . '/vendor/autoload.php');
$dotenv = Dotenv\Dotenv::createImmutable(__DIR__);
$dotenv->load();
$dotenv->required('AWS_KEY')->notEmpty();
$dotenv->required('AWS_SECRET')->notEmpty();
$dotenv->required('AWS_REGION')->notEmpty();
$machine = App\MachineDetails::getDetails();
echo json_encode($machine, JSON_PRETTY_PRINT) . PHP_EOL;