diff --git a/CLAUDE.md b/CLAUDE.md index cf05f7f..69e5b76 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -86,16 +86,20 @@ env-scope, created-if-missing, and never mutated by `sync:app`. ### Audit is scope-first too (`audit` / `audit:environment` / `audit:app`) Read-only counterpart to `sync` with the same scope split. `audit ` queries every resource tagged -`yolo:environment=` via the Resource Groups Tagging API, classifies each as `ok` / `drift` / `rogue`, and -renders them grouped by scope. Sync stamps a positive ownership marker on everything it creates — -`yolo:app=` for App-scope, `yolo:scope=env`/`=account` for shared infra — so the three statuses mean: +`yolo:environment=` via the Resource Groups Tagging API, classifies each as `ok` or `unexpected`, and +renders them grouped by scope. Audit is an ownership/inventory check, **not** a config check — it reads tags, the +ARN service and whether the owning app's cluster is live, and never compares a resource's attributes against the +manifest (that's `sync`'s job, where "drift" means attribute mismatch). Sync stamps a positive ownership marker +on everything it creates — `yolo:app=` for App-scope, `yolo:scope=env`/`=account` for shared infra — so: - `ok` — `yolo:app` points at a live app, or `yolo:scope=env`/`=account` is present (declared shared infra) -- `drift` — `yolo:app` points at an app whose Fargate cluster is gone -- `rogue` — has `yolo:environment` but no YOLO ownership marker (alpha-era debris, or hand-rolled infra in the env namespace) +- `unexpected` — found in the env's tag namespace but not accounted for; a `reason` column says why: + - `no ownership tag` — no `yolo:app`/`yolo:scope` marker (hand-rolled, or alpha-era debris) + - `service no longer provisioned` — YOLO-owned but of an AWS service with no `Resources/` class, so sync would never recreate it (e.g. the DynamoDB sessions table left behind after DynamoDB support was removed). Surfaced via `Audit::SERVICE_BY_RESOURCE_GROUP`, whose keys mirror the `src/Resources/*` dirs (enforced by a test), so dropping a service dir auto-surfaces its leftovers and adding one fails until catalogued — no managed service is ever false-flagged + - `app cluster gone` — YOLO-owned, managed service, but `yolo:app` points at an app whose Fargate cluster is gone -`audit:environment ` narrows to env-tier rows; `audit:app ` narrows to one app. `--drift` is a -universal flag — a no-op on `audit:environment` since env-scope resources never carry `yolo:app`. +`audit:environment ` narrows to env-tier rows; `audit:app ` narrows to one app. `--unexpected` +is a universal flag that filters to just the rows needing attention. ### Steps (`src/Steps/`) diff --git a/docs/guide/provisioning.md b/docs/guide/provisioning.md index c179eb0..5015af5 100644 --- a/docs/guide/provisioning.md +++ b/docs/guide/provisioning.md @@ -124,26 +124,35 @@ Writes that land in the fallback store during an outage are **not** synced back ## Auditing what's deployed -`yolo audit` is the read-only counterpart to `sync`. It queries every resource tagged `yolo:environment=` and classifies each one: +`yolo audit` is the read-only counterpart to `sync`. It's an **ownership/inventory** check — it queries every resource tagged `yolo:environment=` and asks "is this accounted for?". It does **not** inspect a resource's configuration; comparing live attributes against the manifest is `sync`'s job (and `sync`'s "drift" — config superseded — is a different thing from anything here). ```bash yolo audit production ``` +There are two statuses, and a **Reason** column explains every `unexpected` row: + | Status | Meaning | |---|---| | `ok` | Accounted for — `yolo:app` points at a live app, or it carries a `yolo:scope=env`/`=account` marker (declared shared infra). | -| `drift` | `yolo:app` points at an app whose ECS cluster is gone — leftover resources from a removed app. | -| `rogue` | Tagged for the environment but with **no** YOLO ownership marker — hand-rolled infrastructure or alpha-era debris in the environment's namespace. | +| `unexpected` | In the environment's tag namespace but not accounted for. See the Reason. | + +| Reason (on `unexpected`) | Meaning | +|---|---| +| `no ownership tag` | **No** YOLO ownership marker (`yolo:app`/`yolo:scope`) — hand-rolled infrastructure, or alpha-era debris in the namespace. | +| `service no longer provisioned` | YOLO-owned, but of an AWS service YOLO no longer provisions — there's no `Resources/` class for it, so a sync would never create it. Left behind when support for a service is removed (the DynamoDB sessions table after DynamoDB sessions were dropped is the canonical case). Safe to delete once confirmed. | +| `app cluster gone` | YOLO-owned, managed service, but `yolo:app` points at an app whose ECS cluster no longer exists — leftover resources from a removed app. | + +The `service no longer provisioned` check is driven by the catalogue of services YOLO has resource classes for, which mirrors the `src/Resources/*` directories. That makes it correct by construction: a managed service is never false-flagged, and the day a service is dropped its leftover resources surface automatically — no allow-list to keep in sync by hand. ::: tip The per-app dashboard isn't audited `sync:app` also generates a CloudWatch dashboard (`yolo---dashboard`) panelling the app's ECS service, ALB, SQS queues, CloudFront, S3 and logs, plus an RDS panel derived from `DB_HOST`. CloudWatch dashboards can't carry tags, so it's a read-only convenience that **won't** show up in `yolo audit`. ::: -Like sync, audit is scope-grouped — narrow it with `audit:environment ` or `audit:app `, and add `--drift` to show only the drifted rows: +Like sync, audit is scope-grouped — narrow it with `audit:environment ` or `audit:app `, and add `--unexpected` to show only the rows needing attention: ```bash -yolo audit production --drift +yolo audit production --unexpected yolo audit:app production myapp ``` diff --git a/docs/reference/commands.md b/docs/reference/commands.md index b20ed37..4ba722b 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -23,7 +23,7 @@ Every YOLO command, with its arguments and options. Run `vendor/bin/yolo` with n | [`sync:account `](#yolo-sync-account) | Provision account-global resources | | [`sync:environment `](#yolo-sync-environment) | Provision environment-shared resources | | [`sync:app `](#yolo-sync-app) | Provision one app's resources | -| [`audit `](#yolo-audit) | Audit tagged resources and flag drift | +| [`audit `](#yolo-audit) | Audit tagged resources and flag anything unexpected | | [`audit:environment `](#yolo-audit-environment) | Audit environment-tier resources | | [`audit:app `](#yolo-audit-app) | Audit one app's resources | @@ -274,10 +274,10 @@ When a [`tasks.web.autoscaling`](/reference/manifest#tasks-web-autoscaling) bloc ## `yolo audit` -Audit YOLO-tagged resources for an environment (account → environment → app) and flag unexplained drift. Read-only. +Audit YOLO-tagged resources for an environment (account → environment → app) and flag anything not accounted for. Read-only. ```bash -yolo audit [--drift] +yolo audit [--unexpected] ``` | Argument | Required | Description | @@ -286,9 +286,9 @@ yolo audit [--drift] | Option | Value | Description | |---|---|---| -| `--drift` | flag | Only show drift — resources tagged for an app that is no longer live. | +| `--unexpected` | flag | Only show unexpected resources — anything not accounted for by YOLO. | -Queries the Resource Groups Tagging API for everything tagged `yolo:environment=` and classifies each resource as **`ok`**, **`drift`**, or **`rogue`** (see [Provisioning › Auditing](/guide/provisioning#auditing-what-s-deployed)). Results are grouped by scope, drift-first within a scope, with clickable AWS Console links where the terminal supports them. +Queries the Resource Groups Tagging API for everything tagged `yolo:environment=` and classifies each resource as **`ok`** or **`unexpected`**, with a **Reason** explaining each unexpected row — `no ownership tag`, `service no longer provisioned`, or `app cluster gone` (see [Provisioning › Auditing](/guide/provisioning#auditing-what-s-deployed)). Audit is an ownership/inventory check; it does not inspect a resource's configuration (that's `sync`'s job). Results are grouped by scope, unexpected-first within a scope, with clickable AWS Console links where the terminal supports them. --- @@ -297,10 +297,10 @@ Queries the Resource Groups Tagging API for everything tagged `yolo:environment= Audit only the environment-tier resources for the given environment. ```bash -yolo audit:environment [--drift] +yolo audit:environment [--unexpected] ``` -Arguments and options as [`audit`](#yolo-audit). Filters to environment-scope rows. Environment-scope resources never carry `yolo:app`, so `--drift` is a no-op here — drift is an app-scope concept. +Arguments and options as [`audit`](#yolo-audit). Filters to environment-scope rows. Environment-scope resources never carry `yolo:app`, but they can still be `unexpected` (an untagged resource in the namespace, or a leftover of a service YOLO no longer provisions), so `--unexpected` is meaningful here. --- @@ -309,7 +309,7 @@ Arguments and options as [`audit`](#yolo-audit). Filters to environment-scope ro Audit a single app's resources for the given environment. ```bash -yolo audit:app [--drift] +yolo audit:app [--unexpected] ``` | Argument | Required | Description | @@ -319,6 +319,6 @@ yolo audit:app [--drift] | Option | Value | Description | |---|---|---| -| `--drift` | flag | Only show drift for this app. | +| `--unexpected` | flag | Only show unexpected resources for this app. | -Filters the environment-wide report to rows whose `yolo:app` tag matches ``, so only `ok` and `drift` rows for that app appear (a `rogue` resource has no `yolo:app`, so it never shows here). +Filters the environment-wide report to rows whose `yolo:app` tag matches ``, so only `ok` and `unexpected` rows for that app appear (a resource with no `yolo:app` marker never shows here). diff --git a/docs/reference/manifest.md b/docs/reference/manifest.md index 6c44f51..5ce1ea4 100644 --- a/docs/reference/manifest.md +++ b/docs/reference/manifest.md @@ -273,8 +273,6 @@ session: On a web app, omitting `session` gives you the `redis` default; set a driver to override it. On a non-web app, `SESSION_DRIVER` is left to your `.env`. -> DynamoDB is no longer a supported session backend. A manifest still setting `session.driver: dynamodb` hard-fails validation with a pointer to `redis`. - --- ## `tasks.web.*` diff --git a/src/Audit/Audit.php b/src/Audit/Audit.php index d7a55a3..35c385d 100644 --- a/src/Audit/Audit.php +++ b/src/Audit/Audit.php @@ -23,9 +23,20 @@ class Audit public const STATUS_OK = 'ok'; - public const STATUS_DRIFT = 'drift'; + public const STATUS_UNEXPECTED = 'unexpected'; - public const STATUS_ROGUE = 'rogue'; + /** + * Why an `unexpected` resource isn't accounted for — surfaced in the audit's + * Reason column. Audit only inspects tags, the ARN service and whether the + * owning app's cluster is live; it never compares a resource's configuration + * against the manifest (that's `sync`'s job), so none of these is a config + * concern — each is an ownership/inventory fact. + */ + public const REASON_DEAD_APP = 'app cluster gone'; + + public const REASON_UNMANAGED_SERVICE = 'service no longer provisioned'; + + public const REASON_NO_OWNER = 'no ownership tag'; public const SCOPE_ACCOUNT = 'account'; @@ -33,6 +44,54 @@ class Audit public const SCOPE_APP = 'app'; + /** + * The AWS services YOLO provisions, keyed by their `src/Resources/{group}` + * directory. A YOLO-owned resource (one carrying a `yolo:app` or + * `yolo:scope` marker) whose ARN service is *not* one of these is an + * `orphan` — YOLO created it once but no longer has a Resource class for + * that service, so it would never appear in a sync plan. The DynamoDB + * sessions table left behind when DynamoDB support was removed is the + * canonical case. + * + * Keys mirror the `src/Resources/*` directories one-for-one (enforced by + * ManagedServicesTest), so this stays correct by construction: dropping a + * service directory automatically surfaces its leftover resources as + * orphans, and adding one fails the test until it is catalogued here — + * which stops a newly-supported service from being false-flagged. + * + * @var array + */ + public const SERVICE_BY_RESOURCE_GROUP = [ + 'Acm' => 'acm', + 'ApplicationAutoScaling' => 'application-autoscaling', + 'CloudFront' => 'cloudfront', + 'CloudWatch' => 'cloudwatch', + 'CloudWatchLogs' => 'logs', + 'Ec2' => 'ec2', + 'Ecr' => 'ecr', + 'Ecs' => 'ecs', + 'ElastiCache' => 'elasticache', + 'ElbV2' => 'elasticloadbalancing', + 'EventBridge' => 'events', + 'Iam' => 'iam', + 'Rds' => 'rds', + 'Route53' => 'route53', + 'S3' => 's3', + 'Sns' => 'sns', + 'Sqs' => 'sqs', + ]; + + /** + * The ARN service strings YOLO provisions — the values of the + * resource-group catalogue above. + * + * @return array + */ + public static function managedServices(): array + { + return array_values(self::SERVICE_BY_RESOURCE_GROUP); + } + /** * Deploy ephemera the audit ignores: ECS task definitions (immutable * revisions pile up on every deploy and old ones can never be re-tagged) and @@ -67,41 +126,58 @@ public static function appsFromClusters(array $clusterArns, string $environment) } /** - * Classify every tagged resource against the live AWS inventory: - * - `ok` — declared and accounted for. Either app-scope with a - * `yolo:app` pointing at a live app, or env/account-scope - * with a `yolo:scope` tag (shared infra YOLO owns by design). - * - `drift` — `yolo:app` points at an app whose cluster is gone. - * - `rogue` — has `yolo:environment` (so the audit found it) but no - * YOLO ownership marker (`yolo:app`, `yolo:scope=env`, - * `yolo:scope=account`). Either alpha-era debris from - * before the scope-tag rollout, or a hand-rolled resource - * that sneaked into the env namespace. + * Classify every tagged resource against the live AWS inventory. Audit is an + * ownership/inventory check, not a configuration check — it reads tags, the + * ARN service and whether the owning app's cluster is live, and never + * compares a resource's attributes against the manifest (that is `sync`'s + * job). So there are just two statuses: * - * Drift is only ever raised from an explicit yolo:app pointing at a dead app, - * so shared infrastructure is never false-flagged. + * - `ok` — accounted for. App-scope with a `yolo:app` pointing at a + * live app, or env/account-scope shared infra YOLO owns. + * - `unexpected` — found in the environment's tag namespace but not + * accounted for. The `reason` says why: + * • REASON_NO_OWNER — no YOLO ownership marker at all + * (`yolo:app`/`yolo:scope`); hand-rolled, or alpha-era + * debris from before ownership tags. + * • REASON_UNMANAGED_SERVICE — YOLO-owned, but of a + * service YOLO no longer provisions (no `Resources/` + * class), so a sync would never recreate it. The + * DynamoDB sessions table left behind after DynamoDB + * support was removed is the canonical case: still + * tagged `yolo:app=`, so the ownership test + * alone would read it `ok`, but it's dead weight. + * • REASON_DEAD_APP — YOLO-owned, managed service, but + * `yolo:app` points at an app whose cluster is gone. + * + * The reasons are evaluated most-specific first: no-owner before unmanaged + * service before dead-app. A managed-service resource owned by a live app + * (or env/account shared infra) is never flagged. * * @param array}> $taggedResources * @param array $liveApps - * @return array{resources: array>, liveApps: array, okCount: int, driftCount: int, rogueCount: int} + * @return array{resources: array>, liveApps: array, okCount: int, unexpectedCount: int} */ public static function classify(array $taggedResources, array $liveApps): array { + $managedServices = self::managedServices(); + $resources = collect($taggedResources) ->reject(fn (array $resource) => static::isIgnored(Arn::parse($resource['ResourceARN']))) - ->map(function (array $resource) use ($liveApps) { + ->map(function (array $resource) use ($liveApps, $managedServices) { $tags = Aws::flattenTags($resource['Tags'] ?? []); $app = $tags[self::APP_TAG] ?? null; $scopeTag = $tags[self::SCOPE_TAG] ?? null; $parsed = Arn::parse($resource['ResourceARN']); $sharedScope = in_array($scopeTag, [self::SCOPE_ENV, self::SCOPE_ACCOUNT], true); + $owned = $app !== null || $sharedScope; + $managedService = $parsed !== null && in_array($parsed->service, $managedServices, true); - $status = match (true) { - $app !== null && in_array($app, $liveApps, true) => self::STATUS_OK, - $app !== null => self::STATUS_DRIFT, - $sharedScope => self::STATUS_OK, - default => self::STATUS_ROGUE, + [$status, $reason] = match (true) { + ! $owned => [self::STATUS_UNEXPECTED, self::REASON_NO_OWNER], + ! $managedService => [self::STATUS_UNEXPECTED, self::REASON_UNMANAGED_SERVICE], + $app !== null && ! in_array($app, $liveApps, true) => [self::STATUS_UNEXPECTED, self::REASON_DEAD_APP], + default => [self::STATUS_OK, null], }; return [ @@ -111,6 +187,7 @@ public static function classify(array $taggedResources, array $liveApps): array 'name' => $tags[self::NAME_TAG] ?? $parsed?->resourceId ?? $resource['ResourceARN'], 'app' => $app, 'status' => $status, + 'reason' => $reason, ]; }); @@ -118,8 +195,7 @@ public static function classify(array $taggedResources, array $liveApps): array 'resources' => $resources->values()->all(), 'liveApps' => $liveApps, 'okCount' => $resources->where('status', self::STATUS_OK)->count(), - 'driftCount' => $resources->where('status', self::STATUS_DRIFT)->count(), - 'rogueCount' => $resources->where('status', self::STATUS_ROGUE)->count(), + 'unexpectedCount' => $resources->where('status', self::STATUS_UNEXPECTED)->count(), ]; } @@ -156,7 +232,8 @@ protected static function isAccountGlobal(?Arn $arn): bool /** * A single composite sort key for the audit table: scope (account → env → app, - * top to bottom), then status (drift first within a scope), then app and name. + * top to bottom), then status (unexpected before ok within a scope), then the + * reason (so unexpected rows cluster by cause), then app and name. * Returned as one string so a single-closure `sortBy` orders the whole table — * the multi-closure `sortBy([...])` form silently ignores closure keys on * current illuminate/collections. @@ -166,12 +243,13 @@ protected static function isAccountGlobal(?Arn $arn): bool public static function orderKey(array $resource): string { $scopeOrder = [self::SCOPE_ACCOUNT => 0, self::SCOPE_ENV => 1, self::SCOPE_APP => 2]; - $statusOrder = [self::STATUS_DRIFT => 0, self::STATUS_ROGUE => 1, self::STATUS_OK => 2]; + $statusOrder = [self::STATUS_UNEXPECTED => 0, self::STATUS_OK => 1]; return sprintf( - '%d-%d-%s-%s', + '%d-%d-%s-%s-%s', $scopeOrder[$resource['scope']] ?? 9, $statusOrder[$resource['status']] ?? 9, + $resource['reason'] ?? '', $resource['app'] ?? '', $resource['name'], ); diff --git a/src/Commands/AbstractAuditCommand.php b/src/Commands/AbstractAuditCommand.php index 072b5ec..3ddece3 100644 --- a/src/Commands/AbstractAuditCommand.php +++ b/src/Commands/AbstractAuditCommand.php @@ -30,7 +30,7 @@ protected function configure(): void { $this ->addArgument('environment', InputArgument::REQUIRED, 'The environment name') - ->addOption('drift', null, InputOption::VALUE_NONE, 'Only show drift (resources tagged for an app that is no longer live)'); + ->addOption('unexpected', null, InputOption::VALUE_NONE, 'Only show unexpected resources (anything not accounted for by YOLO)'); } public function handle(): int @@ -48,17 +48,17 @@ public function handle(): int /** * Per-subcommand row filter. Return true to include the row in the table. - * Applied before the universal `--drift` filter, so subclasses don't need - * to know about `--drift` at all. + * Applied before the universal `--unexpected` filter, so subclasses don't + * need to know about `--unexpected` at all. * * @param array $resource */ abstract protected function includes(array $resource): bool; /** - * Shown when the post-filter list is empty. Subclasses tailor the wording - * to their scope so `--drift` on a scope that can't carry drift reads - * clearly rather than as a non sequitur. + * Shown when the post-filter list is empty. Subclasses tailor the wording to + * their scope so `--unexpected` reads clearly when a scope happens to have + * nothing unexpected. */ abstract protected function emptyFilterMessage(string $environment): string; @@ -82,7 +82,7 @@ protected function liveApps(string $environment): array } /** - * @param array{resources: array>, liveApps: array, okCount: int, driftCount: int, rogueCount: int} $report + * @param array{resources: array>, liveApps: array, okCount: int, unexpectedCount: int} $report */ protected function render(array $report, string $environment): int { @@ -102,42 +102,38 @@ protected function render(array $report, string $environment): int return self::SUCCESS; } - if (! $this->option('drift') && $report['driftCount'] > 0) { - warning(sprintf('%d resource(s) are drift — tagged for an app that is no longer live.', $report['driftCount'])); - } - - if (! $this->option('drift') && $report['rogueCount'] > 0) { - warning(sprintf('%d resource(s) are rogue — no YOLO ownership marker (`yolo:app` or `yolo:scope`).', $report['rogueCount'])); + if (! $this->option('unexpected') && $report['unexpectedCount'] > 0) { + warning(sprintf('%d resource(s) are unexpected — not accounted for by YOLO. Check the Reason column before removing anything.', $report['unexpectedCount'])); } table( - ['Scope', 'Status', 'Type', 'Name', 'App'], + ['Scope', 'Status', 'Type', 'Name', 'App', 'Reason'], $rows->map(fn (array $resource) => [ static::scopeLabel($resource['scope']), static::statusLabel($resource['status']), $resource['type'], static::nameCell($resource), $resource['app'] ?? '—', + $resource['reason'] ?? '—', ])->all(), ); note(sprintf( - "%d tagged for '%s' · %d drift · %d rogue · %d ok", + "%d tagged for '%s' · %d ok · %d unexpected", count($report['resources']), $environment, - $report['driftCount'], - $report['rogueCount'], $report['okCount'], + $report['unexpectedCount'], )); return self::SUCCESS; } /** - * Apply the subcommand's scope filter and the universal `--drift` flag, - * then order by scope (account → env → app, top to bottom), drift first - * within a scope, then app and name. Drift is still surfaced regardless - * of position — via the warning line, the red label and `--drift`. + * Apply the subcommand's scope filter and the universal `--unexpected` flag, + * then order by scope (account → env → app, top to bottom), unexpected first + * within a scope, then by reason, app and name. Unexpected rows are still + * surfaced regardless of position — via the warning line and the label. * * @param array> $resources * @return Collection> @@ -146,7 +142,7 @@ protected function filtered(array $resources) { return collect($resources) ->filter(fn (array $resource) => $this->includes($resource)) - ->when($this->option('drift'), fn ($rows) => $rows->where('status', Audit::STATUS_DRIFT)) + ->when($this->option('unexpected'), fn ($rows) => $rows->where('status', Audit::STATUS_UNEXPECTED)) ->sortBy(fn (array $resource) => Audit::orderKey($resource)) ->values(); } @@ -154,9 +150,8 @@ protected function filtered(array $resources) protected static function statusLabel(string $status): string { return match ($status) { - Audit::STATUS_DRIFT => 'DRIFT', Audit::STATUS_OK => 'ok', - default => 'rogue', + default => 'unexpected', }; } diff --git a/src/Commands/AuditAppCommand.php b/src/Commands/AuditAppCommand.php index 210cbb2..00897d4 100644 --- a/src/Commands/AuditAppCommand.php +++ b/src/Commands/AuditAppCommand.php @@ -6,9 +6,10 @@ /** * Audit one app's resources in an environment. Filters the env-wide audit - * report to rows whose `yolo:app` tag matches the given app — so an - * unaccounted-for (`rogue`) row never shows up here, only `ok` and - * `drift` against that app. + * report to rows whose `yolo:app` tag matches the given app — so a resource + * with no ownership marker never shows up here, only `ok` and `unexpected` + * rows (a dead app's leftovers, or a service YOLO no longer provisions) for + * that app. */ class AuditAppCommand extends AbstractAuditCommand { @@ -31,8 +32,8 @@ protected function emptyFilterMessage(string $environment): string { $app = $this->argument('app'); - if ($this->option('drift')) { - return sprintf("No drift for app '%s' in '%s'.", $app, $environment); + if ($this->option('unexpected')) { + return sprintf("Nothing unexpected for app '%s' in '%s'.", $app, $environment); } return sprintf("No resources tagged for app '%s' in '%s'.", $app, $environment); diff --git a/src/Commands/AuditCommand.php b/src/Commands/AuditCommand.php index 2d243ff..afc2322 100644 --- a/src/Commands/AuditCommand.php +++ b/src/Commands/AuditCommand.php @@ -16,7 +16,7 @@ protected function configure(): void $this ->setName('audit') - ->setDescription('Audit YOLO-tagged resources for an environment (account → environment → app) and flag unexplained drift'); + ->setDescription('Audit YOLO-tagged resources for an environment (account → environment → app) and flag anything not accounted for'); } protected function includes(array $resource): bool @@ -26,8 +26,8 @@ protected function includes(array $resource): bool protected function emptyFilterMessage(string $environment): string { - if ($this->option('drift')) { - return sprintf("No drift in '%s' — every tagged resource maps to a live app.", $environment); + if ($this->option('unexpected')) { + return sprintf("No unexpected resources in '%s' — everything tagged is accounted for.", $environment); } return sprintf("Nothing tagged for '%s'.", $environment); diff --git a/src/Commands/AuditEnvironmentCommand.php b/src/Commands/AuditEnvironmentCommand.php index 02b67e9..de225e5 100644 --- a/src/Commands/AuditEnvironmentCommand.php +++ b/src/Commands/AuditEnvironmentCommand.php @@ -7,8 +7,9 @@ /** * Audit the env-shared (environment-tier) resources for one environment — * VPC, ALB, subnets, RDS SG, SNS topic and the like. Env-scope resources - * never carry `yolo:app` by design, so `--drift` is a no-op here: drift is - * an app-scope concept. + * never carry `yolo:app`, but they can still be `unexpected` — an untagged + * resource sitting in the env namespace, or a leftover of a service YOLO no + * longer provisions — so `--unexpected` is meaningful here. */ class AuditEnvironmentCommand extends AbstractAuditCommand { @@ -28,8 +29,8 @@ protected function includes(array $resource): bool protected function emptyFilterMessage(string $environment): string { - if ($this->option('drift')) { - return sprintf("No drift at the environment tier in '%s' — drift only applies to app-scope resources.", $environment); + if ($this->option('unexpected')) { + return sprintf("Nothing unexpected at the environment tier in '%s'.", $environment); } return sprintf("No environment-tier resources tagged for '%s'.", $environment); diff --git a/src/Commands/Command.php b/src/Commands/Command.php index 98808c9..83c92ac 100644 --- a/src/Commands/Command.php +++ b/src/Commands/Command.php @@ -142,22 +142,15 @@ protected function ensureCacheStoreValid(): bool /** * `session.driver` (when set) must be a Laravel session driver YOLO supports. - * `dynamodb` was removed (DynamoDB support is gone — sessions live on Valkey), - * so it hard-fails with a pointer to redis. `redis` requires `cache.store: - * redis` — sessions can't land on a Valkey cluster that isn't provisioned — - * and that holds for the web-app default too, not just an explicit `redis`. - * Hard-fail loudly rather than silently shipping a broken session backend. + * `redis` requires `cache.store: redis` — sessions can't land on a Valkey + * cluster that isn't provisioned — and that holds for the web-app default too, + * not just an explicit `redis`. Hard-fail loudly rather than silently shipping + * a broken session backend. */ protected function ensureSessionDriverValid(): bool { $driver = Manifest::get('session.driver'); - if ($driver === 'dynamodb') { - error('yolo.yml `session.driver: dynamodb` is no longer supported — use redis. Sessions now live on the shared Valkey cluster.'); - - return false; - } - $allowed = ['redis', 'database', 'cookie', 'file']; if ($driver !== null && ! in_array($driver, $allowed, true)) { diff --git a/stubs/yolo.yml.stub b/stubs/yolo.yml.stub index 2fb40f2..3af903a 100644 --- a/stubs/yolo.yml.stub +++ b/stubs/yolo.yml.stub @@ -20,12 +20,12 @@ environments: # bucket: my-app-bucket # optional app S3 bucket, injected as AWS_BUCKET # alb: my-shared-alb # optional ALB name to adopt/create; defaults to a per-env shared yolo-{env} ALB - # Web apps default to a shared Valkey cache + dynamodb sessions (provisioned and + # Web apps default to a shared Valkey cache + Valkey-backed sessions (provisioned and # wired automatically). Uncomment only to override or opt out: # cache: # store: redis # default; file/database/array to opt out of the shared Valkey # session: - # driver: dynamodb # default; redis | database | cookie | file to change the backend + # driver: redis # default; database | cookie | file to change the backend tasks: web: diff --git a/tests/Unit/Audit/AuditTest.php b/tests/Unit/Audit/AuditTest.php index f47eb84..eb00b67 100644 --- a/tests/Unit/Audit/AuditTest.php +++ b/tests/Unit/Audit/AuditTest.php @@ -25,43 +25,80 @@ function auditResource(string $arn, array $tags = []): array expect($apps)->toBe(['codinglabs', 'ghost']); }); -it('classifies resources as ok, drift or rogue', function () { +it('classifies resources as ok or unexpected with a reason', function () { $report = Audit::classify([ auditResource('arn:aws:ecs:ap-southeast-2:111:service/yolo-production-codinglabs/web', ['yolo:app' => 'codinglabs', 'yolo:scope' => 'app', 'Name' => 'yolo-production-codinglabs-web']), auditResource('arn:aws:s3:::yolo-production-codinglabs-assets', ['yolo:app' => 'codinglabs', 'yolo:scope' => 'app', 'Name' => 'yolo-production-codinglabs-assets']), auditResource('arn:aws:ecr:ap-southeast-2:111:repository/yolo-production-ghost', ['yolo:app' => 'ghost', 'yolo:scope' => 'app', 'Name' => 'yolo-production-ghost']), - // env-scope shared infra, stamped by sync — ok, not rogue + // env-scope shared infra, stamped by sync — ok auditResource('arn:aws:elasticloadbalancing:ap-southeast-2:111:loadbalancer/app/yolo-production/abc', ['yolo:scope' => 'env', 'Name' => 'yolo-production']), - // alpha-era debris: no yolo:app, no yolo:scope — genuinely rogue + // alpha-era debris: no yolo:app, no yolo:scope auditResource('arn:aws:ssm:ap-southeast-2:111:parameter/yolo/production/background-work-strategy', ['Name' => 'yolo/production/background-work-strategy']), ], liveApps: ['codinglabs']); expect($report['okCount'])->toBe(3) - ->and($report['driftCount'])->toBe(1) - ->and($report['rogueCount'])->toBe(1); + ->and($report['unexpectedCount'])->toBe(2); $byArn = collect($report['resources'])->keyBy('arn'); - // tagged for a live app - expect($byArn['arn:aws:ecs:ap-southeast-2:111:service/yolo-production-codinglabs/web']['status'])->toBe('ok'); - // tagged for an app with no live cluster - expect($byArn['arn:aws:ecr:ap-southeast-2:111:repository/yolo-production-ghost']['status'])->toBe('drift'); - expect($byArn['arn:aws:ecr:ap-southeast-2:111:repository/yolo-production-ghost']['app'])->toBe('ghost'); - // declared env-shared infra — ok, never flagged as rogue - expect($byArn['arn:aws:elasticloadbalancing:ap-southeast-2:111:loadbalancer/app/yolo-production/abc']['status'])->toBe('ok'); - expect($byArn['arn:aws:elasticloadbalancing:ap-southeast-2:111:loadbalancer/app/yolo-production/abc']['app'])->toBeNull(); - // no YOLO ownership marker — rogue - expect($byArn['arn:aws:ssm:ap-southeast-2:111:parameter/yolo/production/background-work-strategy']['status'])->toBe('rogue'); + // tagged for a live app — ok, no reason + expect($byArn['arn:aws:ecs:ap-southeast-2:111:service/yolo-production-codinglabs/web']['status'])->toBe('ok') + ->and($byArn['arn:aws:ecs:ap-southeast-2:111:service/yolo-production-codinglabs/web']['reason'])->toBeNull(); + // tagged for an app with no live cluster — unexpected, app cluster gone + expect($byArn['arn:aws:ecr:ap-southeast-2:111:repository/yolo-production-ghost']['status'])->toBe('unexpected') + ->and($byArn['arn:aws:ecr:ap-southeast-2:111:repository/yolo-production-ghost']['reason'])->toBe(Audit::REASON_DEAD_APP) + ->and($byArn['arn:aws:ecr:ap-southeast-2:111:repository/yolo-production-ghost']['app'])->toBe('ghost'); + // declared env-shared infra — ok + expect($byArn['arn:aws:elasticloadbalancing:ap-southeast-2:111:loadbalancer/app/yolo-production/abc']['status'])->toBe('ok') + ->and($byArn['arn:aws:elasticloadbalancing:ap-southeast-2:111:loadbalancer/app/yolo-production/abc']['app'])->toBeNull(); + // no YOLO ownership marker — unexpected, no ownership tag + expect($byArn['arn:aws:ssm:ap-southeast-2:111:parameter/yolo/production/background-work-strategy']['status'])->toBe('unexpected') + ->and($byArn['arn:aws:ssm:ap-southeast-2:111:parameter/yolo/production/background-work-strategy']['reason'])->toBe(Audit::REASON_NO_OWNER); }); -it('treats a yolo:app pointing at a dead app as drift even when yolo:scope=app is stamped', function () { +it('flags a YOLO-owned resource of an unmanaged service as unexpected', function () { + $report = Audit::classify([ + // The DynamoDB sessions table left behind after DynamoDB support was + // removed: still tagged for a LIVE app, so the ownership test alone would + // read it ok — but YOLO has no DynamoDB resource any more. + auditResource('arn:aws:dynamodb:ap-southeast-2:111:table/yolo-production-codinglabs-sessions', ['yolo:app' => 'codinglabs', 'yolo:scope' => 'app', 'Name' => 'yolo-production-codinglabs-sessions']), + // A managed-service resource for the same live app stays ok — the service + // check must not over-fire on services YOLO still provisions. + auditResource('arn:aws:ecs:ap-southeast-2:111:service/yolo-production-codinglabs/web', ['yolo:app' => 'codinglabs', 'yolo:scope' => 'app', 'Name' => 'yolo-production-codinglabs-web']), + // An unmanaged service with NO ownership marker is unexpected for the + // no-owner reason — it never reaches the service check. + auditResource('arn:aws:dynamodb:ap-southeast-2:111:table/hand-rolled', ['Name' => 'hand-rolled']), + ], liveApps: ['codinglabs']); + + expect($report['okCount'])->toBe(1) + ->and($report['unexpectedCount'])->toBe(2); + + $byArn = collect($report['resources'])->keyBy('arn'); + + expect($byArn['arn:aws:dynamodb:ap-southeast-2:111:table/yolo-production-codinglabs-sessions']['status'])->toBe('unexpected') + ->and($byArn['arn:aws:dynamodb:ap-southeast-2:111:table/yolo-production-codinglabs-sessions']['reason'])->toBe(Audit::REASON_UNMANAGED_SERVICE) + ->and($byArn['arn:aws:ecs:ap-southeast-2:111:service/yolo-production-codinglabs/web']['status'])->toBe('ok') + ->and($byArn['arn:aws:dynamodb:ap-southeast-2:111:table/hand-rolled']['reason'])->toBe(Audit::REASON_NO_OWNER); +}); + +it('reports the unmanaged-service reason ahead of dead-app when the owning app is gone', function () { + $report = Audit::classify([ + auditResource('arn:aws:dynamodb:ap-southeast-2:111:table/yolo-production-ghost-sessions', ['yolo:app' => 'ghost', 'yolo:scope' => 'app']), + ], liveApps: ['codinglabs']); + + expect($report['unexpectedCount'])->toBe(1) + ->and($report['resources'][0]['status'])->toBe('unexpected') + ->and($report['resources'][0]['reason'])->toBe(Audit::REASON_UNMANAGED_SERVICE); +}); + +it('flags a yolo:app pointing at a dead app as unexpected (app cluster gone)', function () { $report = Audit::classify([ auditResource('arn:aws:ecr:ap-southeast-2:111:repository/yolo-production-ghost', ['yolo:app' => 'ghost', 'yolo:scope' => 'app']), ], liveApps: ['codinglabs']); - expect($report['driftCount'])->toBe(1) + expect($report['unexpectedCount'])->toBe(1) ->and($report['okCount'])->toBe(0) - ->and($report['rogueCount'])->toBe(0); + ->and($report['resources'][0]['reason'])->toBe(Audit::REASON_DEAD_APP); }); it('falls back to inference for resources synced before the yolo:scope rollout', function () { @@ -71,17 +108,18 @@ function auditResource(string $arn, array $tags = []): array ], liveApps: ['codinglabs']); expect($appReport['okCount'])->toBe(1) - ->and($appReport['rogueCount'])->toBe(0); + ->and($appReport['unexpectedCount'])->toBe(0); // A pre-rollout env-shared resource (no yolo:app, no yolo:scope) reads as - // rogue until sync backfills the scope tag. That's the cost of using a - // positive signal — and it's preferable to false-greening genuine debris. + // unexpected/no-owner until sync backfills the scope tag. That's the cost of + // a positive ownership signal — preferable to false-greening genuine debris. $envReport = Audit::classify([ auditResource('arn:aws:elasticloadbalancing:ap-southeast-2:111:loadbalancer/app/yolo-production/abc', ['Name' => 'yolo-production']), ], liveApps: []); expect($envReport['okCount'])->toBe(0) - ->and($envReport['rogueCount'])->toBe(1); + ->and($envReport['unexpectedCount'])->toBe(1) + ->and($envReport['resources'][0]['reason'])->toBe(Audit::REASON_NO_OWNER); }); it('assigns an ownership scope to each resource', function () { @@ -104,19 +142,20 @@ function auditResource(string $arn, array $tags = []): array ->and($byArn['arn:aws:s3:::some-bucket']['scope'])->toBe('env'); }); -it('orders rows by scope (account → env → app), then drift-first within a scope', function () { +it('orders rows by scope (account → env → app), then unexpected-first within a scope', function () { $rows = [ - ['scope' => 'app', 'status' => 'ok', 'app' => 'codinglabs', 'name' => 'web'], - ['scope' => 'env', 'status' => 'rogue', 'app' => null, 'name' => 'vpc'], - ['scope' => 'account', 'status' => 'rogue', 'app' => null, 'name' => 'oidc'], - ['scope' => 'app', 'status' => 'drift', 'app' => 'ghost', 'name' => 'repo'], - ['scope' => 'env', 'status' => 'rogue', 'app' => null, 'name' => 'alb'], + ['scope' => 'app', 'status' => 'ok', 'reason' => null, 'app' => 'codinglabs', 'name' => 'web'], + ['scope' => 'env', 'status' => 'unexpected', 'reason' => Audit::REASON_NO_OWNER, 'app' => null, 'name' => 'vpc'], + ['scope' => 'account', 'status' => 'unexpected', 'reason' => Audit::REASON_NO_OWNER, 'app' => null, 'name' => 'oidc'], + ['scope' => 'app', 'status' => 'unexpected', 'reason' => Audit::REASON_DEAD_APP, 'app' => 'ghost', 'name' => 'repo'], + ['scope' => 'env', 'status' => 'unexpected', 'reason' => Audit::REASON_NO_OWNER, 'app' => null, 'name' => 'alb'], ]; $ordered = collect($rows)->sortBy(fn (array $resource) => Audit::orderKey($resource))->values(); expect($ordered->pluck('name')->all())->toBe(['oidc', 'alb', 'vpc', 'repo', 'web']); - // account first, then env (alb before vpc by name), then app (drift before ok) + // account first, then env (alb before vpc by name), then app (the unexpected + // repo before the ok web) expect($ordered->pluck('scope')->all())->toBe(['account', 'env', 'env', 'app', 'app']); }); @@ -141,7 +180,6 @@ function auditResource(string $arn, array $tags = []): array expect($report['resources'])->toBe([]) ->and($report['okCount'])->toBe(0) - ->and($report['driftCount'])->toBe(0) - ->and($report['rogueCount'])->toBe(0) + ->and($report['unexpectedCount'])->toBe(0) ->and($report['liveApps'])->toBe(['codinglabs']); }); diff --git a/tests/Unit/Audit/ManagedServicesTest.php b/tests/Unit/Audit/ManagedServicesTest.php new file mode 100644 index 0000000..da37225 --- /dev/null +++ b/tests/Unit/Audit/ManagedServicesTest.php @@ -0,0 +1,33 @@ +map(fn (string $path) => basename($path)) + ->sort() + ->values() + ->all(); + + $catalogued = collect(array_keys(Audit::SERVICE_BY_RESOURCE_GROUP)) + ->sort() + ->values() + ->all(); + + expect($catalogued)->toBe($directories); +}); + +it('exposes the managed ARN services as the catalogue values', function () { + expect(Audit::managedServices()) + ->toBe(array_values(Audit::SERVICE_BY_RESOURCE_GROUP)) + ->toContain('ecs', 'elasticloadbalancing', 's3', 'logs'); +}); diff --git a/tests/Unit/Commands/AuditCommandsTest.php b/tests/Unit/Commands/AuditCommandsTest.php index 2ca1b42..6e98fc9 100644 --- a/tests/Unit/Commands/AuditCommandsTest.php +++ b/tests/Unit/Commands/AuditCommandsTest.php @@ -14,7 +14,7 @@ $definition = (new AuditCommand())->getDefinition(); expect($definition->hasOption('app'))->toBeFalse() - ->and($definition->hasOption('drift'))->toBeTrue() + ->and($definition->hasOption('unexpected'))->toBeTrue() ->and($definition->hasArgument('environment'))->toBeTrue(); }); @@ -25,11 +25,11 @@ ->and($definition->hasArgument('app'))->toBeTrue() ->and($definition->getArgument('app')->isRequired())->toBeTrue() ->and($definition->hasOption('app'))->toBeFalse() - ->and($definition->hasOption('drift'))->toBeTrue(); + ->and($definition->hasOption('unexpected'))->toBeTrue(); }); -it('exposes --drift consistently across all three audit verbs', function () { - expect((new AuditCommand())->getDefinition()->hasOption('drift'))->toBeTrue() - ->and((new AuditEnvironmentCommand())->getDefinition()->hasOption('drift'))->toBeTrue() - ->and((new AuditAppCommand())->getDefinition()->hasOption('drift'))->toBeTrue(); +it('exposes --unexpected consistently across all three audit verbs', function () { + expect((new AuditCommand())->getDefinition()->hasOption('unexpected'))->toBeTrue() + ->and((new AuditEnvironmentCommand())->getDefinition()->hasOption('unexpected'))->toBeTrue() + ->and((new AuditAppCommand())->getDefinition()->hasOption('unexpected'))->toBeTrue(); }); diff --git a/tests/Unit/Commands/CommandManifestIntegrityTest.php b/tests/Unit/Commands/CommandManifestIntegrityTest.php index ee166d3..73afd29 100644 --- a/tests/Unit/Commands/CommandManifestIntegrityTest.php +++ b/tests/Unit/Commands/CommandManifestIntegrityTest.php @@ -109,20 +109,6 @@ function writeRawManifest(array $manifest): void expect(invokeManifestIntegrity())->toBeTrue(); }); -it('bails on session.driver: dynamodb with a pointer to redis (support removed)', function () { - writeManifest([ - 'account-id' => '848509375702', 'region' => 'ap-southeast-2', - 'session' => ['driver' => 'dynamodb'], - ]); - - expect(invokeManifestIntegrity())->toBeFalse(); - - $output = test()->promptOutput->fetch(); - expect($output)->toContain('dynamodb'); - expect($output)->toContain('no longer supported'); - expect($output)->toContain('redis'); -}); - it('bails on an unknown session.driver', function () { writeManifest([ 'account-id' => '848509375702', 'region' => 'ap-southeast-2', diff --git a/tests/Unit/Resources/Iam/EcsTaskPolicyAndRoleTest.php b/tests/Unit/Resources/Iam/EcsTaskPolicyAndRoleTest.php index 4153380..22e56ee 100644 --- a/tests/Unit/Resources/Iam/EcsTaskPolicyAndRoleTest.php +++ b/tests/Unit/Resources/Iam/EcsTaskPolicyAndRoleTest.php @@ -46,14 +46,6 @@ ]); }); -it('grants no DynamoDB access (DynamoDB support has been removed)', function () { - $actions = collect((new EcsTaskPolicy())->document()['Statement']) - ->flatMap(fn (array $statement) => (array) $statement['Action']) - ->all(); - - expect($actions)->each->not->toStartWith('dynamodb:'); -}); - it('trusts the ecs-tasks service in the ECS task assume role policy', function () { expect((new EcsTaskRole())->assumeRolePolicyDocument())->toBe([ 'Version' => '2012-10-17', diff --git a/tests/Unit/Steps/Build/ConfigureEnvAndVersionStepTest.php b/tests/Unit/Steps/Build/ConfigureEnvAndVersionStepTest.php index 88b197a..280b7d1 100644 --- a/tests/Unit/Steps/Build/ConfigureEnvAndVersionStepTest.php +++ b/tests/Unit/Steps/Build/ConfigureEnvAndVersionStepTest.php @@ -195,10 +195,9 @@ function rebuildEnvFixture(array $config): void expect($env)->toContain('CACHE_STORE=redis'); expect($env)->toContain('REDIS_HOST=master.yolo-testing-cache.cache.amazonaws.com'); expect($env)->toContain('SESSION_DRIVER=redis'); - // No DynamoDB anything, and no SESSION_CONNECTION — a null connection routes - // the redis session handler to the stock default connection (DB 0), keeping - // sessions off the cache connection (DB 1). - expect($env)->not->toContain('DYNAMODB_CACHE_TABLE'); + // No SESSION_CONNECTION — a null connection routes the redis session handler + // to the stock default connection (DB 0), keeping sessions off the cache + // connection (DB 1). expect($env)->not->toContain('SESSION_CONNECTION'); }); @@ -213,7 +212,6 @@ function rebuildEnvFixture(array $config): void $env = file_get_contents(Paths::build('.env.testing')); expect($env)->toContain('SESSION_DRIVER=database'); - expect($env)->not->toContain('DYNAMODB_CACHE_TABLE='); }); it('does not pin SESSION_DRIVER when the manifest does not select one', function () {