diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 4a7091e..d327271 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -26,6 +26,9 @@ jobs: dotnet-version: "8.0.x" - name: Repository quality gate + env: + BASE_REF: ${{ github.event.pull_request.base.sha || 'origin/dev' }} + HEAD_REF: ${{ github.event.pull_request.head.sha || github.sha }} run: bash scripts/governance-check.sh - name: Build Elastic Beanstalk source bundle @@ -141,6 +144,92 @@ jobs: echo "Application version did not become PROCESSED." >&2 exit 1 + - name: Discard blocking VCS run before GitHub CD + run: | + set -euo pipefail + python3 << 'PY' + import json, os, urllib.error, urllib.request + + token = os.environ["TF_API_TOKEN"] + workspace = "shoc-backend-dev" + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/vnd.api+json", + } + + def get(url): + req = urllib.request.Request(url, headers=headers) + with urllib.request.urlopen(req) as resp: + return json.load(resp) + + def post(url, payload): + data = json.dumps(payload).encode() + req = urllib.request.Request( + url, data=data, method="POST", headers=headers + ) + try: + with urllib.request.urlopen(req) as resp: + return resp.status + except urllib.error.HTTPError as exc: + if exc.code in (409, 404): + body = exc.read().decode("utf-8", "replace") + print(f"discard returned HTTP {exc.code}: {body}") + return exc.code + raise + + ws = get( + f"https://app.terraform.io/api/v2/organizations/seahaven/workspaces/{workspace}" + )["data"] + attrs = ws["attributes"] + if attrs.get("auto-apply") is True: + raise SystemExit("shoc-backend-dev auto-apply is on; refuse to continue") + if not attrs.get("speculative-enabled"): + raise SystemExit("speculative plans are off; refuse to continue") + if (attrs.get("vcs-repo") or {}).get("tags-regex"): + raise SystemExit("tag-based VCS triggering is set; refuse to continue") + expected_patterns = [ + "terraform/live/dev/**", + "terraform/live/modules/**", + ] + if attrs.get("trigger-patterns") != expected_patterns: + raise SystemExit( + "trigger-patterns must be " + f"{expected_patterns}; got {attrs.get('trigger-patterns')}" + ) + if not attrs.get("locked"): + print("workspace is unlocked") + raise SystemExit(0) + + current = ( + ws.get("relationships", {}) + .get("current-run", {}) + .get("data") + ) + if not current: + raise SystemExit("workspace is locked without a current run") + run_id = current["id"] + run = get(f"https://app.terraform.io/api/v2/runs/{run_id}")["data"] + run_attrs = run["attributes"] + status = run_attrs.get("status") + plan_only = run_attrs.get("plan-only") + print(f"current run {run_id} status={status} plan-only={plan_only}") + if plan_only: + print("speculative run does not block GitHub CD") + raise SystemExit(0) + if status in {"applying", "apply_queued"}: + raise SystemExit(f"{run_id} is {status}; wait, do not discard an apply") + discardable = { + "pending", "planned", "cost_estimated", "policy_checked", "policy_override" + } + if status not in discardable: + raise SystemExit(f"{run_id} status {status} is not discardable") + code = post( + f"https://app.terraform.io/api/v2/runs/{run_id}/actions/discard", + {"comment": "Discarded so GitHub CD can create the version-only applyable run"}, + ) + print(f"discarded {run_id} http={code}") + PY + - name: Create Terraform release run id: release-run uses: hashicorp/tfc-workflows-github/actions/create-run@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2 @@ -184,11 +273,44 @@ jobs: - name: Apply Terraform release run id: release-apply + continue-on-error: true uses: hashicorp/tfc-workflows-github/actions/apply-run@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2 with: run: ${{ steps.release-run.outputs.run_id }} comment: Apply version-only release from GitHub Actions ${{ github.sha }} + - name: Treat already-applied release run as success + env: + APPLY_OUTCOME: ${{ steps.release-apply.outcome }} + RUN_ID: ${{ steps.release-run.outputs.run_id }} + run: | + set -euo pipefail + if [ "$APPLY_OUTCOME" = "success" ]; then + echo "Apply succeeded." + exit 0 + fi + python3 << 'PY' + import json, os, urllib.request + run_id = os.environ["RUN_ID"] + token = os.environ["TF_API_TOKEN"] + req = urllib.request.Request( + f"https://app.terraform.io/api/v2/runs/{run_id}", + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/vnd.api+json", + }, + ) + with urllib.request.urlopen(req) as resp: + status = json.load(resp)["data"]["attributes"]["status"] + print(f"HCP run {run_id} status={status}") + if status == "applied": + raise SystemExit(0) + raise SystemExit( + f"Apply failed: GitHub outcome={os.environ['APPLY_OUTCOME']} " + f"HCP status={status}" + ) + PY + - name: Verify exact application version is active run: | set -euo pipefail @@ -294,16 +416,103 @@ jobs: echo "rollback_label=$prev" >> "${GITHUB_OUTPUT}" id: rollback-prepare + - name: Discard blocking VCS run before GitHub rollback + id: rollback-discard-vcs + if: failure() && steps.rollback-prepare.outputs.rollback_label != '' + run: | + set -euo pipefail + python3 << 'PY' + import json, os, urllib.error, urllib.request + + token = os.environ["TF_API_TOKEN"] + workspace = "shoc-backend-dev" + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/vnd.api+json", + } + + def get(url): + req = urllib.request.Request(url, headers=headers) + with urllib.request.urlopen(req) as resp: + return json.load(resp) + + def post(url, payload): + data = json.dumps(payload).encode() + req = urllib.request.Request( + url, data=data, method="POST", headers=headers + ) + try: + with urllib.request.urlopen(req) as resp: + return resp.status + except urllib.error.HTTPError as exc: + if exc.code in (409, 404): + body = exc.read().decode("utf-8", "replace") + print(f"discard returned HTTP {exc.code}: {body}") + return exc.code + raise + + ws = get( + f"https://app.terraform.io/api/v2/organizations/seahaven/workspaces/{workspace}" + )["data"] + attrs = ws["attributes"] + if attrs.get("auto-apply") is True: + raise SystemExit("shoc-backend-dev auto-apply is on; refuse to continue") + if not attrs.get("speculative-enabled"): + raise SystemExit("speculative plans are off; refuse to continue") + if (attrs.get("vcs-repo") or {}).get("tags-regex"): + raise SystemExit("tag-based VCS triggering is set; refuse to continue") + expected_patterns = [ + "terraform/live/dev/**", + "terraform/live/modules/**", + ] + if attrs.get("trigger-patterns") != expected_patterns: + raise SystemExit( + "trigger-patterns must be " + f"{expected_patterns}; got {attrs.get('trigger-patterns')}" + ) + if not attrs.get("locked"): + print("workspace is unlocked") + raise SystemExit(0) + + current = ( + ws.get("relationships", {}) + .get("current-run", {}) + .get("data") + ) + if not current: + raise SystemExit("workspace is locked without a current run") + run_id = current["id"] + run = get(f"https://app.terraform.io/api/v2/runs/{run_id}")["data"] + run_attrs = run["attributes"] + status = run_attrs.get("status") + plan_only = run_attrs.get("plan-only") + print(f"current run {run_id} status={status} plan-only={plan_only}") + if plan_only: + print("speculative run does not block GitHub CD") + raise SystemExit(0) + if status in {"applying", "apply_queued"}: + raise SystemExit(f"{run_id} is {status}; wait, do not discard an apply") + discardable = { + "pending", "planned", "cost_estimated", "policy_checked", "policy_override" + } + if status not in discardable: + raise SystemExit(f"{run_id} status {status} is not discardable") + code = post( + f"https://app.terraform.io/api/v2/runs/{run_id}/actions/discard", + {"comment": "Discarded so GitHub CD can create the version-only applyable run"}, + ) + print(f"discarded {run_id} http={code}") + PY + - name: Create Terraform rollback run id: rollback-run - if: failure() && steps.rollback-prepare.outputs.rollback_label != '' + if: failure() && steps.rollback-prepare.outputs.rollback_label != '' && steps.rollback-discard-vcs.outcome == 'success' uses: hashicorp/tfc-workflows-github/actions/create-run@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2 env: TF_VAR_release_version_label: '"${{ steps.rollback-prepare.outputs.rollback_label }}"' with: workspace: shoc-backend-dev message: "Rollback to ${{ steps.rollback-prepare.outputs.rollback_label }} from GitHub Actions" - - name: Read Terraform rollback plan counts id: rollback-plan if: failure() && steps.rollback-run.outcome == 'success' @@ -344,13 +553,48 @@ jobs: - name: Apply Terraform rollback run id: rollback-apply if: failure() && steps.rollback-json-guard.outcome == 'success' + continue-on-error: true uses: hashicorp/tfc-workflows-github/actions/apply-run@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2 with: run: ${{ steps.rollback-run.outputs.run_id }} comment: Apply version-only rollback from GitHub Actions ${{ github.sha }} + - name: Treat already-applied rollback run as success + id: rollback-apply-result + if: failure() && steps.rollback-apply.outcome != 'skipped' + env: + APPLY_OUTCOME: ${{ steps.rollback-apply.outcome }} + RUN_ID: ${{ steps.rollback-run.outputs.run_id }} + run: | + set -euo pipefail + if [ "$APPLY_OUTCOME" = "success" ]; then + echo "Apply succeeded." + exit 0 + fi + python3 << 'PY' + import json, os, urllib.request + run_id = os.environ["RUN_ID"] + token = os.environ["TF_API_TOKEN"] + req = urllib.request.Request( + f"https://app.terraform.io/api/v2/runs/{run_id}", + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/vnd.api+json", + }, + ) + with urllib.request.urlopen(req) as resp: + status = json.load(resp)["data"]["attributes"]["status"] + print(f"HCP run {run_id} status={status}") + if status == "applied": + raise SystemExit(0) + raise SystemExit( + f"Apply failed: GitHub outcome={os.environ['APPLY_OUTCOME']} " + f"HCP status={status}" + ) + PY + - name: Verify previous application version is active - if: failure() && steps.rollback-apply.outcome == 'success' + if: failure() && steps.rollback-apply-result.outcome == 'success' run: | set -euo pipefail prev="${{ steps.rollback-prepare.outputs.rollback_label }}" diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs index 84129ed..fcc94b5 100644 --- a/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs +++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs @@ -25,6 +25,8 @@ namespace SeaHaven.DataServices.Helpers HasAvetaDocument = w.workOrderAttachments!.Any(a => (a.IsDeleted == null || a.IsDeleted == false) && a.Category == WorkOrderMediaCategory.Aveta), + MediaCount = w.workOrderAttachments!.Count(a => + a.IsDeleted == null || a.IsDeleted == false), w.OriginalDate, w.OriginalWeek, w.WorkOrderType, @@ -157,7 +159,8 @@ namespace SeaHaven.DataServices.Helpers w.OriginalDate, w.OriginalWeek, isUnscheduled, - w.Severity); + w.Severity, + w.MediaCount); }).ToList(); } diff --git a/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs b/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs index 46f7f65..6e4348b 100644 --- a/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs +++ b/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs @@ -64,7 +64,8 @@ namespace SeaHaven.DataServices.Interfaces DateOnly? OriginalDate, DateOnly? OriginalWeek, bool IsUnscheduled, - string? Severity = null); + string? Severity = null, + int MediaCount = 0); public record WorkOrderBoardQueryResult( IReadOnlyList ScheduledRows, diff --git a/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs index 6f4af86..4ef1c62 100644 --- a/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs +++ b/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs @@ -79,6 +79,8 @@ namespace SeaHaven.Services.DTOs public string? ServiceNotes { get; set; } public List? ExtraServices { get; set; } public DocStatus? DocStatus { get; set; } + /// Live photo/video/document count for the board CompDoc cell (excludes soft-deleted). + public int MediaCount { get; set; } public DateTime? CompletedDate { get; set; } /// Board flag color (#RRGGBB). Null = no flag. Distinct from Color (dispatcher avatar). public string? FlagColor { get; set; } diff --git a/SeaHaven.Services/Helpers/WorkOrderMediaFileRules.cs b/SeaHaven.Services/Helpers/WorkOrderMediaFileRules.cs index 4786552..a1ab2f7 100644 --- a/SeaHaven.Services/Helpers/WorkOrderMediaFileRules.cs +++ b/SeaHaven.Services/Helpers/WorkOrderMediaFileRules.cs @@ -10,6 +10,7 @@ namespace SeaHaven.Services.Helpers private static readonly HashSet AllowedContentTypes = new(StringComparer.OrdinalIgnoreCase) { "image/jpeg", + "image/jpg", "image/png", "video/mp4", "video/quicktime", @@ -31,6 +32,13 @@ namespace SeaHaven.Services.Helpers new HashSet(StringComparer.OrdinalIgnoreCase) { ".docx" } }; + private static string CanonicalContentType(string contentType) + { + if (contentType.Equals("image/jpg", StringComparison.OrdinalIgnoreCase)) + return "image/jpeg"; + return contentType; + } + public static bool IsAllowed( IFormFile file, WorkOrderMediaCategory? category = WorkOrderMediaCategory.Extra) @@ -38,10 +46,11 @@ namespace SeaHaven.Services.Helpers if (file == null || file.Length <= 0) return false; - var contentType = (file.ContentType ?? string.Empty).Trim(); - if (string.IsNullOrWhiteSpace(contentType) || !AllowedContentTypes.Contains(contentType)) + var declaredType = (file.ContentType ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(declaredType) || !AllowedContentTypes.Contains(declaredType)) return false; + var contentType = CanonicalContentType(declaredType); var resolvedCategory = category ?? WorkOrderMediaCategory.Extra; if (IsDocument(contentType) && resolvedCategory is not WorkOrderMediaCategory.Extra and not WorkOrderMediaCategory.Aveta) diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardService.cs index 96f178d..3d73bde 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardService.cs @@ -137,6 +137,7 @@ namespace SeaHaven.Services.Implementation ExtraServices = ParseExtraServices(row.ExtraServices), AdditionalContacts = WorkOrderAdditionalContactsMapper.ParseJson(row.AdditionalContacts), DocStatus = row.DocStatus, + MediaCount = row.MediaCount, CompletedDate = row.CompletedDate, FlagColor = row.FlagColor, Severity = WorkOrderSeverityRules.ParseLevel(row.Severity), diff --git a/SeaHaven.Services/Implementation/WorkOrderMediaService.cs b/SeaHaven.Services/Implementation/WorkOrderMediaService.cs index 4a94fa1..0b0e38c 100644 --- a/SeaHaven.Services/Implementation/WorkOrderMediaService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderMediaService.cs @@ -216,6 +216,13 @@ namespace SeaHaven.Services.Implementation if (category == WorkOrderMediaCategory.Before || category == WorkOrderMediaCategory.After) { + if (IsDocumentAttachment(attachment.Attachments)) + { + throw new WorkOrderBoardValidationException( + "UnsupportedMediaType", + "Documents can only be categorized as Extra or Aveta."); + } + var url = attachment.Attachments ?? ""; if (category == WorkOrderMediaCategory.Before) workOrder.BeforPhotoAttachment = url; @@ -432,5 +439,11 @@ namespace SeaHaven.Services.Implementation ".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document", _ => "application/octet-stream" }; + + private static bool IsDocumentAttachment(string? attachment) + => GetContentType(GetSafeFileName(attachment ?? string.Empty)) is + "application/pdf" + or "application/msword" + or "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; } } diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs index ed3ed09..d1d9343 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs @@ -895,6 +895,81 @@ public class WorkOrderBoardServiceTests Assert.False(response.Scheduled[0].HasAvetaDocument); } + [Fact] + public async Task GetBoardAsync_MediaCount_CountsLiveAttachmentsOnly() + { + await using var context = CreateContext(); + var weekStart = new DateOnly(2026, 6, 22); + + context.workOrders.Add(new WorkOrder + { + Id = 1, + InternalWONumber = "10000000001", + WorkOrderType = WorkOrderType.Emergency, + ScheduledDate = new DateTime(2026, 6, 24, 12, 0, 0, DateTimeKind.Utc), + LifecycleStatus = LifecycleStatus.Scheduled + }); + context.workOrderAttachments.Add(new WorkOrderAttachments + { + WorkorderId = 1, + Attachments = "https://example.com/before.jpg", + Category = WorkOrderMediaCategory.Before + }); + context.workOrderAttachments.Add(new WorkOrderAttachments + { + WorkorderId = 1, + Attachments = "https://example.com/extra.jpg", + Category = WorkOrderMediaCategory.Extra + }); + context.workOrderAttachments.Add(new WorkOrderAttachments + { + WorkorderId = 1, + Attachments = "https://example.com/removed.jpg", + Category = WorkOrderMediaCategory.Extra, + IsDeleted = true + }); + await context.SaveChangesAsync(); + + var boardService = new WorkOrderBoardService( + new WorkOrderBoardDataService(context), + WorkOrderAccountTestHelpers.Resolver(context)); + var response = await boardService.GetBoardAsync(new WorkOrderBoardQueryDto + { + WeekStart = weekStart + }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null); + + Assert.Single(response.Scheduled); + Assert.Equal(2, response.Scheduled[0].MediaCount); + } + + [Fact] + public async Task GetBoardAsync_MediaCount_ZeroWithoutAttachments() + { + await using var context = CreateContext(); + var weekStart = new DateOnly(2026, 6, 22); + + context.workOrders.Add(new WorkOrder + { + Id = 1, + InternalWONumber = "10000000001", + WorkOrderType = WorkOrderType.Emergency, + ScheduledDate = new DateTime(2026, 6, 24, 12, 0, 0, DateTimeKind.Utc), + LifecycleStatus = LifecycleStatus.Scheduled + }); + await context.SaveChangesAsync(); + + var boardService = new WorkOrderBoardService( + new WorkOrderBoardDataService(context), + WorkOrderAccountTestHelpers.Resolver(context)); + var response = await boardService.GetBoardAsync(new WorkOrderBoardQueryDto + { + WeekStart = weekStart + }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null); + + Assert.Single(response.Scheduled); + Assert.Equal(0, response.Scheduled[0].MediaCount); + } + [Fact] public async Task GetBoardAsync_InvalidWeekWindow_Throws() { diff --git a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs index 8bf1480..cecb181 100644 --- a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs @@ -1428,6 +1428,44 @@ public class WorkOrderMediaServiceTests a.FieldName == "MediaCategory" && a.NewValue == "10:Before"); } + [Theory] + [InlineData("https://example.com/document.pdf")] + [InlineData("https://example.com/document.doc")] + [InlineData("https://example.com/document.docx")] + public async Task UpdateMediaCategory_DocumentToPhotoCategory_ThrowsUnsupportedMediaType( + string attachmentUrl) + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + context.workOrderAttachments.Add(new WorkOrderAttachments + { + Id = 10, + WorkorderId = 1, + Attachments = attachmentUrl, + Category = WorkOrderMediaCategory.Extra + }); + await context.SaveChangesAsync(); + + var ex = await Assert.ThrowsAsync(() => + service.UpdateMediaCategoryAsync( + 1, + 10, + WorkOrderMediaCategory.Before, + ToVersion(wo), + AuthenticatedUser(), + "actor-1")); + + Assert.Equal("UnsupportedMediaType", ex.Code); + Assert.Null(context.workOrders.Single().BeforPhotoAttachment); + Assert.True(context.workOrderAttachments.Single().IsDeleted != true); + } + [Fact] public async Task UpdateMediaCategory_StaleVersion_ThrowsConcurrencyConflict() { @@ -1856,6 +1894,13 @@ public class WorkOrderMediaFileRulesTests Assert.True(WorkOrderMediaFileRules.IsAllowed(FormFile(jpeg, "photo.jpg", "image/jpeg"))); } + [Fact] + public void IsAllowed_ImageJpgAlias_ReturnsTrue() + { + var jpeg = new byte[] { 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10 }; + Assert.True(WorkOrderMediaFileRules.IsAllowed(FormFile(jpeg, "photo.jpg", "image/jpg"))); + } + [Fact] public void IsAllowed_SpoofedExtension_ReturnsFalse() { diff --git a/terraform/live/README.md b/terraform/live/README.md index fe486cd..f9869cd 100644 --- a/terraform/live/README.md +++ b/terraform/live/README.md @@ -144,7 +144,13 @@ leave the live version unchanged. Application-CD runs pass the immutable workspace, in a variable set, or in `terraform.tfvars`. Do not upload a new configuration version on application releases; `create-run` reuses the workspace's last applied VCS config. Global auto-apply stays off. GitHub -applies only after `plan-output` counts are `0/1/0` and +`apply-run` treats an already-applied run as success so a mis-set auto-apply +cannot start a false-failure rollback. The workspace stays branch-based on +`dev` with Automatic Speculative Plans enabled and trigger patterns +`terraform/live/dev/**` and `terraform/live/modules/**`. GitHub discards a +leftover non-speculative VCS run before `create-run`, so a merge to `dev` +cannot lock the workspace out from under GitHub CD. GitHub applies only after +`plan-output` counts are `0/1/0` and `scripts/check-terraform-release-plan.py` accepts a version-only plan JSON. Staging keeps today's direct Elastic Beanstalk deploy path until staging @@ -181,5 +187,8 @@ identifiers make accidental cross-environment reuse fail review and planning. ## Safety invariants - Auto-apply remains off. +- VCS stays branch-based on `dev` with speculative PR plans enabled and + trigger patterns `terraform/live/dev/**` and `terraform/live/modules/**`. + Do not switch Automatic Run Triggering to tag-based. - Org baseline owns final HCP plan/apply permissions and manager tags. - Every imported Terraform resource has `prevent_destroy`.