diff --git a/SeaHaven.Services/Helpers/WorkOrderBoardApptTimeParser.cs b/SeaHaven.Services/Helpers/WorkOrderBoardApptTimeParser.cs index f50949a..a3a2694 100644 --- a/SeaHaven.Services/Helpers/WorkOrderBoardApptTimeParser.cs +++ b/SeaHaven.Services/Helpers/WorkOrderBoardApptTimeParser.cs @@ -53,5 +53,8 @@ namespace SeaHaven.Services.Helpers return date.Value.Date.Add(time.Value); } + + public static DateTime? ToScheduledInstant(DateTime? date, TimeSpan? time) + => time.HasValue ? CombineDateAndTime(date, time) : null; } } diff --git a/SeaHaven.Services/Helpers/WorkOrderBoardFieldMutations.cs b/SeaHaven.Services/Helpers/WorkOrderBoardFieldMutations.cs index 2477cb3..c3a59a0 100644 --- a/SeaHaven.Services/Helpers/WorkOrderBoardFieldMutations.cs +++ b/SeaHaven.Services/Helpers/WorkOrderBoardFieldMutations.cs @@ -87,8 +87,8 @@ namespace SeaHaven.Services.Helpers var oldStart = FormatDateTime(workOrder.ScheduledStart); var oldEnd = FormatDateTime(workOrder.ScheduledEnd); - workOrder.ScheduledStart = WorkOrderBoardApptTimeParser.CombineDateAndTime(apptDate, start); - workOrder.ScheduledEnd = WorkOrderBoardApptTimeParser.CombineDateAndTime(apptDate, end); + workOrder.ScheduledStart = WorkOrderBoardApptTimeParser.ToScheduledInstant(apptDate, start); + workOrder.ScheduledEnd = WorkOrderBoardApptTimeParser.ToScheduledInstant(apptDate, end); var newStart = FormatDateTime(workOrder.ScheduledStart); var newEnd = FormatDateTime(workOrder.ScheduledEnd); diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardService.cs index 972e2f1..8e8aa7d 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardService.cs @@ -85,8 +85,10 @@ namespace SeaHaven.Services.Implementation public static WorkOrderBoardRowDto MapRawRow(WorkOrderBoardRawRow row, DateTime utcNow) { // Appt column: date prefers dispatch appointment (vendor slot), then WO ScheduledDate. - // Time prefers WO ScheduledStart/End (dispatcher window), then dispatch datetime. - var apptStart = row.ScheduledStart ?? row.DispatchApptDate; + // Time uses WO ScheduledStart/End only. Do not fall back to a date-only dispatch + // instant (midnight), or clearing apptTime still renders 00:00. + var apptStart = row.ScheduledStart + ?? (HasClockTime(row.DispatchApptDate) ? row.DispatchApptDate : null); var apptEnd = row.ScheduledEnd; var (vendorId, vendorName) = WorkOrderBoardDispatchAssignment.LiveVendor( row.VendorId, @@ -145,6 +147,9 @@ namespace SeaHaven.Services.Implementation }; } + private static bool HasClockTime(DateTime? value) + => value.HasValue && value.Value.TimeOfDay != TimeSpan.Zero; + private static string FormatName(string? firstName, string? lastName) => $"{firstName ?? ""} {lastName ?? ""}".Trim(); diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs index eb7824c..53d3fe2 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs @@ -690,8 +690,8 @@ namespace SeaHaven.Services.Implementation var oldEnd = FormatDateTime(workOrder.ScheduledEnd); var dispatchId = ResolveDispatchIdForAudit(dispatch); - workOrder.ScheduledStart = WorkOrderBoardApptTimeParser.CombineDateAndTime(apptDate, start); - workOrder.ScheduledEnd = WorkOrderBoardApptTimeParser.CombineDateAndTime(apptDate, end); + workOrder.ScheduledStart = WorkOrderBoardApptTimeParser.ToScheduledInstant(apptDate, start); + workOrder.ScheduledEnd = WorkOrderBoardApptTimeParser.ToScheduledInstant(apptDate, end); var newStart = FormatDateTime(workOrder.ScheduledStart); var newEnd = FormatDateTime(workOrder.ScheduledEnd); diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardFieldMutationsTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardFieldMutationsTests.cs new file mode 100644 index 0000000..aa83201 --- /dev/null +++ b/SeaHavenIndustries.Tests/WorkOrderBoardFieldMutationsTests.cs @@ -0,0 +1,53 @@ +using Data.SeaHavenIndustries; +using SeaHaven.Services.Helpers; + +namespace SeaHavenIndustries.Tests; + +public class WorkOrderBoardFieldMutationsTests +{ + [Fact] + public void ApplyApptTime_EmptyValue_ClearsStartAndEnd_KeepsDates() + { + var appointmentDate = new DateTime(2026, 6, 25); + var workOrder = new WorkOrder + { + ScheduledDate = appointmentDate, + ScheduledStart = new DateTime(2026, 6, 25, 9, 0, 0), + ScheduledEnd = new DateTime(2026, 6, 25, 11, 0, 0) + }; + var dispatch = new Dispatch { Id = 10, ScheduledDate = appointmentDate }; + + WorkOrderBoardFieldMutations.ApplyApptTime(workOrder, dispatch, ""); + + Assert.Null(workOrder.ScheduledStart); + Assert.Null(workOrder.ScheduledEnd); + Assert.Equal(appointmentDate, workOrder.ScheduledDate); + Assert.Equal(appointmentDate, dispatch.ScheduledDate); + } + + [Fact] + public void ApplyApptTime_StartOnly_LeavesEndNull() + { + var appointmentDate = new DateTime(2026, 6, 25); + var workOrder = new WorkOrder { ScheduledDate = appointmentDate }; + var dispatch = new Dispatch { Id = 10, ScheduledDate = appointmentDate }; + + WorkOrderBoardFieldMutations.ApplyApptTime(workOrder, dispatch, "09:00"); + + Assert.Equal(new DateTime(2026, 6, 25, 9, 0, 0), workOrder.ScheduledStart); + Assert.Null(workOrder.ScheduledEnd); + } + + [Fact] + public void ApplyApptTime_Range_SetsStartAndEnd() + { + var appointmentDate = new DateTime(2026, 6, 25); + var workOrder = new WorkOrder { ScheduledDate = appointmentDate }; + var dispatch = new Dispatch { Id = 10, ScheduledDate = appointmentDate }; + + WorkOrderBoardFieldMutations.ApplyApptTime(workOrder, dispatch, "09:00 – 11:00"); + + Assert.Equal(new DateTime(2026, 6, 25, 9, 0, 0), workOrder.ScheduledStart); + Assert.Equal(new DateTime(2026, 6, 25, 11, 0, 0), workOrder.ScheduledEnd); + } +} diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs index 123cc03..a3871de 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs @@ -869,4 +869,93 @@ public class WorkOrderBoardServiceTests null, cts.Token)); } + + [Fact] + public void MapRawRow_DateOnlyDispatch_KeepsApptDate_LeavesApptTimeNull() + { + var dispatchApptDate = new DateTime(2026, 6, 25); + var row = RawRow( + scheduledDate: new DateTime(2026, 6, 23), + scheduledStart: null, + scheduledEnd: null, + dispatchApptDate: dispatchApptDate); + + var dto = WorkOrderBoardService.MapRawRow(row, DateTime.UtcNow); + + Assert.Equal(dispatchApptDate, dto.ApptDate); + Assert.Null(dto.ApptTime); + } + + [Fact] + public void MapRawRow_DispatchWithClock_FallsBackToDispatchTime() + { + var dispatchApptDate = new DateTime(2026, 6, 25, 9, 0, 0); + var row = RawRow( + scheduledDate: new DateTime(2026, 6, 23), + scheduledStart: null, + scheduledEnd: null, + dispatchApptDate: dispatchApptDate); + + var dto = WorkOrderBoardService.MapRawRow(row, DateTime.UtcNow); + + Assert.Equal(dispatchApptDate, dto.ApptDate); + Assert.Equal("09:00", dto.ApptTime); + } + + private static WorkOrderBoardRawRow RawRow( + DateTime? scheduledDate, + DateTime? scheduledStart, + DateTime? scheduledEnd, + DateTime? dispatchApptDate) + => new( + Id: 1, + InternalWONumber: "10000000001", + RescheduleCount: 0, + CarriedOver: 0, + IsAddOn: false, + WorkOrderType: null, + SiteCode: null, + LocationName: null, + PocName: null, + PocPhone: null, + PocNotes: null, + LifecycleStatus: LifecycleStatus.Scheduled, + LegacyStatus: null, + AssignTo: null, + DispatcherFirstName: null, + DispatcherLastName: null, + Initials: null, + Color: null, + DueDate: null, + ScheduledDate: scheduledDate, + ScheduledStart: scheduledStart, + ScheduledEnd: scheduledEnd, + TargetWeek: null, + ScheduleWeekOnly: null, + VendorId: null, + VendorName: null, + TechName: null, + TechPhone: null, + DispatchApptDate: dispatchApptDate, + Trade: null, + Problem: null, + ServiceNotes: null, + ExtraServices: null, + AdditionalContacts: null, + DocStatus: null, + CompletedDate: null, + FlagColor: null, + PrimaryDispatchId: 10, + RowVersion: null, + DispatchRowVersion: null, + PendingUpliftCount: 0, + HasUplift: false, + PrimaryUpliftStatus: null, + PrimaryUpliftAmount: null, + PrimaryDispatchStatus: null, + AvetaRequired: false, + HasAvetaDocument: false, + OriginalDate: null, + OriginalWeek: null, + IsUnscheduled: false); } diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs index b6e0923..8a43fa0 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs @@ -1276,6 +1276,53 @@ public class WorkOrderBoardUpdateServiceTests Assert.Equal(new DateTime(2026, 6, 25, 10, 0, 0), reloaded.ScheduledEnd); } + [Fact] + public async Task PatchField_ApptTime_EmptyClearsStartAndEnd_KeepsAppointmentDate() + { + var (context, service) = CreateSut(); + var appointmentDate = new DateTime(2026, 6, 25); + var dispatch = new Dispatch + { + Id = 10, + WorkOrderId = 1, + VendorId = 1, + ScheduledDate = appointmentDate, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 } + }; + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + PrimaryDispatchId = 10, + ScheduledDate = appointmentDate, + ScheduledStart = new DateTime(2026, 6, 25, 9, 0, 0), + ScheduledEnd = new DateTime(2026, 6, 25, 11, 0, 0), + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Vendor A" }); + context.Dispatches.Add(dispatch); + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.ApptTime, + Value = "", + WorkOrderVersion = ToVersion(wo), + DispatchVersion = ToVersion(dispatch), + PrimaryDispatchId = 10 + }, "actor-1"); + + Assert.True(string.IsNullOrEmpty(result.ApptTime)); + Assert.Equal(appointmentDate, result.ScheduledDate); + var reloaded = await context.workOrders.FindAsync(1); + Assert.Null(reloaded!.ScheduledStart); + Assert.Null(reloaded.ScheduledEnd); + Assert.Equal(appointmentDate, reloaded.ScheduledDate); + var reloadedDispatch = await context.Dispatches.FindAsync(10); + Assert.Equal(appointmentDate, reloadedDispatch!.ScheduledDate); + } + [Fact] public async Task PatchField_ApptTime_DashPrefixedToken_TreatedAsSingleStart() { @@ -1312,7 +1359,7 @@ public class WorkOrderBoardUpdateServiceTests var reloaded = await context.workOrders.FindAsync(1); Assert.NotNull(reloaded!.ScheduledStart); - Assert.Equal(new DateTime(2026, 6, 25), reloaded.ScheduledEnd); + Assert.Null(reloaded.ScheduledEnd); Assert.Equal(new DateTime(2026, 6, 25).Add(TimeSpan.FromDays(30)), reloaded.ScheduledStart); } diff --git a/infra/cdk/README.md b/infra/cdk/README.md index 8c77048..5ab4d24 100644 --- a/infra/cdk/README.md +++ b/infra/cdk/README.md @@ -187,6 +187,32 @@ npm run deploy # deploy the stack (requires AWS) All commands run from `infra/cdk/`. +## Terraform ownership transfer + +`ManageGithubDeployRole` deliberately has no default. Every CDK deployment must +state the intended ownership phase: + +```bash +# Before the controlled Terraform apply: install Retain on the role and policy. +npx cdk deploy shoc-backend-deploy-dev \ + --parameters shoc-backend-deploy-dev:ManageGithubDeployRole=true + +# After Terraform succeeds and live verification passes: relinquish ownership. +npx cdk deploy shoc-backend-deploy-dev \ + --parameters shoc-backend-deploy-dev:ManageGithubDeployRole=false +``` + +Both deployments must use the same reviewed SHA. The first keeps the role and +generated inline policy under CloudFormation while adding retention metadata. +The second removes both resources from CloudFormation ownership while retaining +them live for Terraform. After the second deployment succeeds, +`ManageGithubDeployRole=true` must never be used again. + +Omitting the parameter fails closed before deployment. If the `true` deployment +rolls back, inspect the stack resources and live role/policy before retrying; +retained resources can outlive a failed update and must not be cleaned up +automatically. + ## CI integration `npm run synth` is the deterministic local/CI validation. After synth, inspect @@ -194,6 +220,9 @@ All commands run from `infra/cdk/`. `AWS::IAM::Role`: - Trust policy `StringEquals` matches the exact audience and subject above. +- The role, generated `AWS::IAM::Policy`, and role ARN output share the + `ManageGithubDeployRoleCondition`; both resources use `DeletionPolicy` and + `UpdateReplacePolicy` set to `Retain`. - The inline policy contains no `Resource: "*"` mutation action and no service outside `elasticbeanstalk` / `s3` / `cloudformation` / `ec2` / `elasticloadbalancing` / `autoscaling`. CloudFormation discovery and diff --git a/infra/cdk/deploy-dev-stack.ts b/infra/cdk/deploy-dev-stack.ts index 6ec101d..87bad48 100644 --- a/infra/cdk/deploy-dev-stack.ts +++ b/infra/cdk/deploy-dev-stack.ts @@ -14,6 +14,27 @@ export class DeployDevStack extends cdk.Stack { constructor(scope: Construct, id: string, props: cdk.StackProps = {}) { super(scope, id, props); + const manageGithubDeployRole = new cdk.CfnParameter( + this, + 'ManageGithubDeployRole', + { + type: 'String', + allowedValues: ['true', 'false'], + description: + 'Set true only before Terraform adoption. After ownership transfer, always reuse false.', + }, + ); + const manageGithubDeployRoleCondition = new cdk.CfnCondition( + this, + 'ManageGithubDeployRoleCondition', + { + expression: cdk.Fn.conditionEquals( + manageGithubDeployRole.valueAsString, + 'true', + ), + }, + ); + const applicationArn = `arn:aws:elasticbeanstalk:${REGION}:${ACCOUNT_ID}:application/${APPLICATION_NAME}`; const environmentArn = `arn:aws:elasticbeanstalk:${REGION}:${ACCOUNT_ID}:environment/${APPLICATION_NAME}/${ENVIRONMENT_NAME}`; const oidcProviderArn = `arn:aws:iam::${ACCOUNT_ID}:oidc-provider/token.actions.githubusercontent.com`; @@ -38,6 +59,7 @@ export class DeployDevStack extends cdk.Stack { const cfnRole = deployRole.node.defaultChild as iam.CfnRole; cfnRole.cfnOptions.deletionPolicy = cdk.CfnDeletionPolicy.RETAIN; cfnRole.cfnOptions.updateReplacePolicy = cdk.CfnDeletionPolicy.RETAIN; + cfnRole.cfnOptions.condition = manageGithubDeployRoleCondition; deployRole.addToPolicy( new iam.PolicyStatement({ @@ -134,10 +156,25 @@ export class DeployDevStack extends cdk.Stack { }), ); - new cdk.CfnOutput(this, 'GithubDeployRoleArn', { - value: deployRole.roleArn, - description: 'ARN of the GitHub OIDC deploy role for shoc-backend dev.', - exportName: 'shoc-backend-deploy-dev-role-arn', - }); + const defaultPolicy = deployRole.node.findChild( + 'DefaultPolicy', + ) as iam.Policy; + defaultPolicy.applyRemovalPolicy(cdk.RemovalPolicy.RETAIN); + const cfnDefaultPolicy = defaultPolicy.node.defaultChild as iam.CfnPolicy; + cfnDefaultPolicy.cfnOptions.deletionPolicy = cdk.CfnDeletionPolicy.RETAIN; + cfnDefaultPolicy.cfnOptions.updateReplacePolicy = + cdk.CfnDeletionPolicy.RETAIN; + cfnDefaultPolicy.cfnOptions.condition = manageGithubDeployRoleCondition; + + const githubDeployRoleArn = new cdk.CfnOutput( + this, + 'GithubDeployRoleArn', + { + value: deployRole.roleArn, + description: 'ARN of the GitHub OIDC deploy role for shoc-backend dev.', + exportName: 'shoc-backend-deploy-dev-role-arn', + }, + ); + githubDeployRoleArn.condition = manageGithubDeployRoleCondition; } } diff --git a/scripts/check-terraform-import-plan.py b/scripts/check-terraform-import-plan.py index c8d9be3..494d99c 100644 --- a/scripts/check-terraform-import-plan.py +++ b/scripts/check-terraform-import-plan.py @@ -8,7 +8,11 @@ import json import sys from pathlib import Path -from terraform_import_plan_resources import REQUIRED_RESOURCES +from terraform_import_plan_resources import ( + DEV_IMPORT_BASELINE, + DEV_IMPORT_IDS, + REQUIRED_RESOURCES, +) ALLOWED_MANAGED_TYPES = { @@ -38,9 +42,56 @@ def parse_args() -> argparse.Namespace: "no-op import is proven. Repeat for each reviewed update." ), ) + parser.add_argument( + "--evidence-out", + type=Path, + help="Write machine-readable proof after every import assertion passes.", + ) return parser.parse_args() +def validate_dev_import_baseline( + resources_by_address: dict[str, dict], violations: list[str] +) -> None: + environment_address = ( + "module.environment.aws_elastic_beanstalk_environment.this" + ) + route_address = "module.environment.aws_route53_record.api_alias[0]" + expected_tags = DEV_IMPORT_BASELINE["environment_tags"] + expected_alias = DEV_IMPORT_BASELINE["api_alias"] + + for side in ("before", "after"): + environment = ( + resources_by_address.get(environment_address, {}) + .get("change", {}) + .get(side) + or {} + ) + if environment.get("tags") != expected_tags: + violations.append( + f"{environment_address}: {side} environment tags do not match " + f"the exact dev import baseline" + ) + if environment.get("setting") != []: + violations.append( + f"{environment_address}: {side} contains managed EB settings " + "during the import-only phase" + ) + + route = ( + resources_by_address.get(route_address, {}) + .get("change", {}) + .get(side) + or {} + ) + aliases = route.get("alias") or [] + if len(aliases) != 1 or aliases[0] != expected_alias: + violations.append( + f"{route_address}: {side} alias does not match the exact " + "live ALB target and zone" + ) + + def main() -> int: args = parse_args() plan = json.loads(args.plan_json.read_text(encoding="utf-8")) @@ -51,6 +102,8 @@ def main() -> int: seen_update_addresses: set[str] = set() seen_addresses: set[str] = set() required_resources = REQUIRED_RESOURCES[args.environment] + resources_by_address: dict[str, dict] = {} + initial_import = not allowed_update_addresses for resource in plan.get("resource_changes", []): if resource.get("mode", "managed") != "managed": @@ -61,6 +114,7 @@ def main() -> int: actions = set(resource.get("change", {}).get("actions", [])) managed += 1 seen_addresses.add(address) + resources_by_address[address] = resource if resource_type not in ALLOWED_MANAGED_TYPES: violations.append( @@ -89,12 +143,31 @@ def main() -> int: f"{address}: update is not explicitly allowlisted" ) + if initial_import and args.environment == "dev": + if actions != {"no-op"}: + violations.append( + f"{address}: initial dev import actions must be ['no-op'], " + f"got {sorted(actions)}" + ) + expected_import_id = DEV_IMPORT_IDS.get(address) + actual_import_id = ( + resource.get("change", {}).get("importing") or {} + ).get("id") + if actual_import_id != expected_import_id: + violations.append( + f"{address}: expected import id {expected_import_id!r}, " + f"got {actual_import_id!r}" + ) + for unused in sorted(allowed_update_addresses - seen_update_addresses): violations.append(f"{unused}: allowlisted update address is not updating") for missing in sorted(set(required_resources) - seen_addresses): violations.append(f"{missing}: required managed resource is absent") + if initial_import and args.environment == "dev": + validate_dev_import_baseline(resources_by_address, violations) + if violations: print("FAIL: live Terraform plan is not import-safe", file=sys.stderr) for violation in violations: @@ -102,6 +175,28 @@ def main() -> int: return 1 mode = "controlled update" if allowed_update_addresses else "no-op import" + if args.evidence_out: + evidence = { + "environment": args.environment, + "mode": mode, + "managed_resources": managed, + "updates": updates, + "creates": 0, + "deletes": 0, + "replacements": 0, + "imports": { + address: ( + resource.get("change", {}).get("importing") or {} + ).get("id") + for address, resource in sorted(resources_by_address.items()) + }, + } + if args.environment == "dev" and initial_import: + evidence["asserted_live_baseline"] = DEV_IMPORT_BASELINE + args.evidence_out.write_text( + json.dumps(evidence, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) print( f"PASS: {mode} plan has {managed} managed resources, " f"{updates} updates, and no create/delete/replace actions" diff --git a/scripts/terraform_import_plan_resources.py b/scripts/terraform_import_plan_resources.py index 75894af..f5ec5b8 100644 --- a/scripts/terraform_import_plan_resources.py +++ b/scripts/terraform_import_plan_resources.py @@ -30,3 +30,44 @@ REQUIRED_RESOURCES = { "module.environment.aws_route53_record.api_cname[0]": "aws_route53_record", }, } + +DEV_IMPORT_IDS = { + "module.environment.aws_elastic_beanstalk_environment.this": "e-hehnrqjjrt", + "module.environment.aws_iam_instance_profile.runtime": "shoc-backend-dev", + "module.environment.aws_iam_role.github_deploy": "githubdeploy-shoc-backend-dev", + "module.environment.aws_iam_role.runtime": "shoc-backend-dev", + "module.environment.aws_iam_role_policy.github_deploy": ( + "githubdeploy-shoc-backend-dev:GithubDeployRoleDefaultPolicyE8F540D1" + ), + "module.environment.aws_iam_role_policy.runtime_app_config": ( + "shoc-backend-dev:shoc-dev-secrets-read" + ), + "module.environment.aws_iam_role_policy.runtime_dynamo[0]": ( + "shoc-backend-dev:shoc-assume-dynamo-reader" + ), + "module.environment.aws_iam_role_policy.runtime_webhook[0]": ( + "shoc-backend-dev:shoc-procurement-webhook-hmac-read" + ), + "module.environment.aws_iam_role_policy_attachment.web_tier": ( + "shoc-backend-dev/arn:aws:iam::aws:policy/AWSElasticBeanstalkWebTier" + ), + "module.environment.aws_secretsmanager_secret.app_config": ( + "arn:aws:secretsmanager:us-east-1:396287094661:secret:" + "shoc/dev/app-config-jLRBiw" + ), + "module.environment.aws_route53_record.api_alias[0]": ( + "Z07671212N75U4YLPWZR8_api.dev.seahaven.com_A" + ), +} + +DEV_IMPORT_BASELINE = { + "environment_tags": { + "env": "dev", + "project": "shoc", + }, + "api_alias": { + "name": "awseb--awseb-cmpb3ypfib53-1654918745.us-east-1.elb.amazonaws.com", + "zone_id": "Z35SXDOTRQ7X7K", + "evaluate_target_health": True, + }, +} diff --git a/scripts/test-terraform-import-plan-check.py b/scripts/test-terraform-import-plan-check.py index f773ca0..78e0d60 100644 --- a/scripts/test-terraform-import-plan-check.py +++ b/scripts/test-terraform-import-plan-check.py @@ -9,7 +9,11 @@ import sys import tempfile from pathlib import Path -from terraform_import_plan_resources import REQUIRED_RESOURCES +from terraform_import_plan_resources import ( + DEV_IMPORT_BASELINE, + DEV_IMPORT_IDS, + REQUIRED_RESOURCES, +) SCRIPT = Path(__file__).with_name("check-terraform-import-plan.py") @@ -21,6 +25,8 @@ def run_case( omit_address: str | None = None, extra_resource: tuple[str, str, list[str]] | None = None, allowed_updates: tuple[str, ...] = (), + import_id_overrides: dict[str, str] | None = None, + state_overrides: dict[str, dict[str, object]] | None = None, empty: bool = False, ) -> subprocess.CompletedProcess[str]: changes = [] @@ -29,12 +35,39 @@ def run_case( if address == omit_address: continue actions = (actions_by_address or {}).get(address, ["no-op"]) + change: dict[str, object] = {"actions": actions} + if environment == "dev": + change["importing"] = { + "id": (import_id_overrides or {}).get( + address, DEV_IMPORT_IDS[address] + ) + } + if ( + address + == "module.environment.aws_elastic_beanstalk_environment.this" + ): + state: dict[str, object] = { + "tags": DEV_IMPORT_BASELINE["environment_tags"], + "setting": [], + } + change["before"] = state + change["after"] = state + elif ( + address + == "module.environment.aws_route53_record.api_alias[0]" + ): + state = {"alias": [DEV_IMPORT_BASELINE["api_alias"]]} + change["before"] = state + change["after"] = state + if address in (state_overrides or {}): + change["before"] = (state_overrides or {})[address] + change["after"] = (state_overrides or {})[address] changes.append( { "address": address, "mode": "managed", "type": resource_type, - "change": {"actions": actions}, + "change": change, } ) if extra_resource: @@ -83,6 +116,67 @@ def main() -> int: run_case("dev", actions_by_address={controlled_address: ["update"]}), 1, ), + ( + "unexpected initial action", + run_case("dev", actions_by_address={controlled_address: ["read"]}), + 1, + ), + ( + "wrong import id", + run_case( + "dev", + import_id_overrides={controlled_address: "wrong-role"}, + ), + 1, + ), + ( + "wrong environment tags", + run_case( + "dev", + state_overrides={ + "module.environment.aws_elastic_beanstalk_environment.this": { + "tags": { + "Name": "shoc-backend-dev", + "env": "dev", + "project": "shoc", + }, + "setting": [], + } + }, + ), + 1, + ), + ( + "managed settings during import", + run_case( + "dev", + state_overrides={ + "module.environment.aws_elastic_beanstalk_environment.this": { + "tags": DEV_IMPORT_BASELINE["environment_tags"], + "setting": [{"name": "ASPNETCORE_ENVIRONMENT"}], + } + }, + ), + 1, + ), + ( + "wrong api alias", + run_case( + "dev", + state_overrides={ + "module.environment.aws_route53_record.api_alias[0]": { + "alias": [ + { + "name": "shoc-backend-dev.us-east-1.elasticbeanstalk.com", + "zone_id": "Z117KPS5GTRQ2G", + "evaluate_target_health": True, + } + ] + } + }, + ), + 1, + ), ( "controlled update", run_case( diff --git a/terraform/live/README.md b/terraform/live/README.md index e7379c8..c6e4880 100644 --- a/terraform/live/README.md +++ b/terraform/live/README.md @@ -94,8 +94,10 @@ tf-poc rehearsal has completed both phases and therefore pins creates, deletes, replacements, and managed resource types outside the approved environment-owned boundary. 4. Apply the no-op import only after review. -5. Change the environment root to `adoption_complete=true` in a reviewed code - change, then review the controlled in-place role and policy update: +5. Change the environment root to `adoption_complete=true` and + `manage_eb_settings=true` in a reviewed code change, then review the + controlled in-place role metadata, secret metadata, and Elastic Beanstalk + update: ```bash # Dev example. Omit any address that is not updating. @@ -103,22 +105,32 @@ tf-poc rehearsal has completed both phases and therefore pins --allow-update-address module.environment.aws_iam_instance_profile.runtime \ --allow-update-address module.environment.aws_iam_role.runtime \ --allow-update-address module.environment.aws_iam_role.github_deploy \ - --allow-update-address module.environment.aws_iam_role_policy.github_deploy \ - --allow-update-address module.environment.aws_secretsmanager_secret.app_config + --allow-update-address module.environment.aws_secretsmanager_secret.app_config \ + --allow-update-address module.environment.aws_elastic_beanstalk_environment.this ``` 6. Apply only when every update address is named on the command line and the - plan contains no create, delete, or replacement action. + plan contains no create, delete, or replacement action. The dev direct ALB + alias remains pinned during this phase and must not update. + +The same reviewed change prepares the legacy dev CDK stack for ownership +transfer. Before the Terraform apply, deploy `shoc-backend-deploy-dev` with +`ManageGithubDeployRole=true` so both the role and generated inline-policy +resource carry `Retain`. After Terraform succeeds and live verification passes, +deploy the same reviewed SHA with `ManageGithubDeployRole=false`. This removes +both resources from CloudFormation ownership without deleting them. Never use +`ManageGithubDeployRole=true` again after that transfer. The reviewed `adoption_complete=true` change updates ownership tags on IAM -roles, instance profiles, and app-config secrets, and narrows the dev role to -the staging-style S3 bucket and application prefix. Elastic Beanstalk -environment tags remain at their imported values. EB accepts an added -`ManagedBy` tag request but can fail the asynchronous service-managed -CloudFormation propagation after Terraform reports success. Terraform still -manages the declared EB settings. Deploy-role descriptions and immutable -`HcpTerraformWorkspace` tags remain unchanged. Read-only AWS APIs retain -`Resource = "*"` only where AWS does not support resource-level permissions. +roles, instance profiles, and app-config secrets. Dev retains the proven GitHub +Elastic Beanstalk release policy until application CD is migrated in a separate +reviewed change; infrastructure adoption must not silently break the current +manual release path. Elastic Beanstalk environment tags remain at their imported +values. Terraform manages the declared EB settings. Secret values remain +out-of-band even after the secret shell receives `ManagedBy=terraform`. +Deploy-role descriptions and immutable `HcpTerraformWorkspace` tags remain +unchanged. Read-only AWS APIs retain `Resource = "*"` only where AWS does not +support resource-level permissions. ## POC retained identifiers diff --git a/terraform/live/dev/main.tf b/terraform/live/dev/main.tf index 3214b48..64f2aeb 100644 --- a/terraform/live/dev/main.tf +++ b/terraform/live/dev/main.tf @@ -26,7 +26,8 @@ module "environment" { aws_account_id = local.aws_account_id aws_region = local.aws_region environment = "dev" - adoption_complete = false + adoption_complete = true + manage_eb_settings = true eb_application_name = local.eb_application_name eb_environment_name = local.eb_environment_name eb_environment_id = local.eb_environment_id @@ -68,6 +69,10 @@ module "environment" { hosted_zone_id = "Z07671212N75U4YLPWZR8" api_domain = local.api_domain api_record_type = "A" + api_alias_target = { + name = "awseb--awseb-cmpb3ypfib53-1654918745.us-east-1.elb.amazonaws.com" + zone_id = "Z35SXDOTRQ7X7K" + } metadata_before_adoption = { runtime_role_description = "SHOC backend dev compute role (EB instance profile)" runtime_role_tags = { @@ -92,7 +97,6 @@ module "environment" { Project = "shoc-backend" } environment_tags = { - Name = "shoc-backend-dev" env = "dev" project = "shoc" } diff --git a/terraform/live/modules/environment-owned/main.tf b/terraform/live/modules/environment-owned/main.tf index 17c3b57..3a2522e 100644 --- a/terraform/live/modules/environment-owned/main.tf +++ b/terraform/live/modules/environment-owned/main.tf @@ -9,7 +9,7 @@ locals { environment_arn = "arn:aws:elasticbeanstalk:${var.aws_region}:${var.aws_account_id}:environment/${var.eb_application_name}/${var.eb_environment_name}" environment_stack_name = "awseb-${var.eb_environment_id}-stack" eb_bucket_name = "elasticbeanstalk-${var.aws_region}-${var.aws_account_id}" - use_legacy_s3_policy = !var.adoption_complete && var.legacy_dev_s3_policy + use_legacy_s3_policy = var.legacy_dev_s3_policy app_config_secret_pattern = "arn:aws:secretsmanager:${var.aws_region}:${var.aws_account_id}:secret:${var.app_config_secret_name}-*" } @@ -141,6 +141,8 @@ resource "aws_iam_instance_profile" "runtime" { } resource "aws_secretsmanager_secret" "app_config" { + # Terraform owns the secret shell and metadata only. Values remain out of + # band and must never be declared in this resource or its callers. name = var.app_config_secret_name description = var.metadata_before_adoption.app_config_description tags = var.adoption_complete ? merge(var.metadata_before_adoption.app_config_tags, { ManagedBy = "terraform" }) : var.metadata_before_adoption.app_config_tags @@ -327,6 +329,134 @@ resource "aws_iam_role_policy" "github_deploy" { } } +locals { + managed_eb_settings = concat( + [ + { + namespace = "aws:elasticbeanstalk:environment" + name = "EnvironmentType" + value = "LoadBalanced" + }, + { + namespace = "aws:elasticbeanstalk:environment" + name = "LoadBalancerType" + value = "application" + }, + { + namespace = "aws:elasticbeanstalk:environment" + name = "ServiceRole" + value = var.eb_service_role_name + }, + { + namespace = "aws:ec2:vpc" + name = "VPCId" + value = var.vpc_id + }, + { + namespace = "aws:ec2:vpc" + name = "Subnets" + value = join(",", sort(var.instance_subnet_ids)) + }, + { + namespace = "aws:ec2:vpc" + name = "ELBSubnets" + value = join(",", sort(var.load_balancer_subnet_ids)) + }, + { + namespace = "aws:ec2:vpc" + name = "ELBScheme" + value = "public" + }, + { + namespace = "aws:ec2:vpc" + name = "AssociatePublicIpAddress" + value = "true" + }, + { + namespace = "aws:autoscaling:launchconfiguration" + name = "IamInstanceProfile" + value = aws_iam_instance_profile.runtime.name + }, + { + namespace = "aws:autoscaling:launchconfiguration" + name = "InstanceType" + value = "t3.small" + }, + { + namespace = "aws:autoscaling:asg" + name = "MinSize" + value = "1" + }, + { + namespace = "aws:autoscaling:asg" + name = "MaxSize" + value = "1" + }, + { + namespace = "aws:elbv2:listener:443" + name = "Protocol" + value = "HTTPS" + }, + { + namespace = "aws:elbv2:listener:443" + name = "SSLCertificateArns" + value = var.shared_certificate_arn + }, + { + namespace = "aws:elasticbeanstalk:environment:process:default" + name = "HealthCheckPath" + value = "/" + }, + { + namespace = "aws:elasticbeanstalk:environment:process:default" + name = "MatcherHTTPCode" + value = "200-499" + }, + { + namespace = "aws:elasticbeanstalk:application:environment" + name = "ASPNETCORE_ENVIRONMENT" + value = "Production" + }, + { + namespace = "aws:elasticbeanstalk:application:environment" + name = "ASPNETCORE_URLS" + value = "http://0.0.0.0:5000" + }, + { + namespace = "aws:elasticbeanstalk:application:environment" + name = "WorkOrderWebhook__Enabled" + value = var.work_order_webhook_enabled ? "true" : "false" + }, + { + namespace = "aws:elasticbeanstalk:application:environment" + name = "WorkOrderWebhook__Region" + value = var.aws_region + }, + ], + var.instance_security_group_id == null ? [] : [ + { + namespace = "aws:autoscaling:launchconfiguration" + name = "SecurityGroups" + value = var.instance_security_group_id + }, + ], + [ + for key in sort(tolist(var.app_config_json_keys)) : { + namespace = "aws:elasticbeanstalk:application:environmentsecrets" + name = key + value = "${aws_secretsmanager_secret.app_config.arn}:${key}" + } + ], + var.webhook_secret_arn == null ? [] : [ + { + namespace = "aws:elasticbeanstalk:application:environment" + name = "WorkOrderWebhook__SecretId" + value = var.webhook_secret_arn + }, + ], + ) +} + resource "aws_elastic_beanstalk_environment" "this" { name = var.eb_environment_name application = var.eb_application_name @@ -334,150 +464,13 @@ resource "aws_elastic_beanstalk_environment" "this" { tier = "WebServer" cname_prefix = var.eb_environment_name - setting { - namespace = "aws:elasticbeanstalk:environment" - name = "EnvironmentType" - value = "LoadBalanced" - } - - setting { - namespace = "aws:elasticbeanstalk:environment" - name = "LoadBalancerType" - value = "application" - } - - setting { - namespace = "aws:elasticbeanstalk:environment" - name = "ServiceRole" - value = var.eb_service_role_name - } - - setting { - namespace = "aws:ec2:vpc" - name = "VPCId" - value = var.vpc_id - } - - setting { - namespace = "aws:ec2:vpc" - name = "Subnets" - value = join(",", sort(var.instance_subnet_ids)) - } - - setting { - namespace = "aws:ec2:vpc" - name = "ELBSubnets" - value = join(",", sort(var.load_balancer_subnet_ids)) - } - - setting { - namespace = "aws:ec2:vpc" - name = "ELBScheme" - value = "public" - } - - setting { - namespace = "aws:ec2:vpc" - name = "AssociatePublicIpAddress" - value = "true" - } - - setting { - namespace = "aws:autoscaling:launchconfiguration" - name = "IamInstanceProfile" - value = aws_iam_instance_profile.runtime.name - } - - setting { - namespace = "aws:autoscaling:launchconfiguration" - name = "InstanceType" - value = "t3.small" - } - dynamic "setting" { - for_each = var.instance_security_group_id == null ? [] : [var.instance_security_group_id] + for_each = var.manage_eb_settings ? local.managed_eb_settings : [] + content { - namespace = "aws:autoscaling:launchconfiguration" - name = "SecurityGroups" - value = setting.value - } - } - - setting { - namespace = "aws:autoscaling:asg" - name = "MinSize" - value = "1" - } - - setting { - namespace = "aws:autoscaling:asg" - name = "MaxSize" - value = "1" - } - - setting { - namespace = "aws:elbv2:listener:443" - name = "Protocol" - value = "HTTPS" - } - - setting { - namespace = "aws:elbv2:listener:443" - name = "SSLCertificateArns" - value = var.shared_certificate_arn - } - - setting { - namespace = "aws:elasticbeanstalk:environment:process:default" - name = "HealthCheckPath" - value = "/" - } - - setting { - namespace = "aws:elasticbeanstalk:environment:process:default" - name = "MatcherHTTPCode" - value = "200-499" - } - - dynamic "setting" { - for_each = var.app_config_json_keys - content { - namespace = "aws:elasticbeanstalk:application:environmentsecrets" - name = setting.value - value = "${aws_secretsmanager_secret.app_config.arn}:${setting.value}" - } - } - - setting { - namespace = "aws:elasticbeanstalk:application:environment" - name = "ASPNETCORE_ENVIRONMENT" - value = "Production" - } - - setting { - namespace = "aws:elasticbeanstalk:application:environment" - name = "ASPNETCORE_URLS" - value = "http://0.0.0.0:5000" - } - - setting { - namespace = "aws:elasticbeanstalk:application:environment" - name = "WorkOrderWebhook__Enabled" - value = var.work_order_webhook_enabled ? "true" : "false" - } - - setting { - namespace = "aws:elasticbeanstalk:application:environment" - name = "WorkOrderWebhook__Region" - value = var.aws_region - } - - dynamic "setting" { - for_each = var.webhook_secret_arn == null ? [] : [var.webhook_secret_arn] - content { - namespace = "aws:elasticbeanstalk:application:environment" - name = "WorkOrderWebhook__SecretId" - value = setting.value + namespace = setting.value.namespace + name = setting.value.name + value = setting.value.value } } @@ -499,8 +492,8 @@ resource "aws_route53_record" "api_alias" { type = "A" alias { - name = aws_elastic_beanstalk_environment.this.cname - zone_id = data.aws_elastic_beanstalk_hosted_zone.current.id + name = var.api_alias_target == null ? aws_elastic_beanstalk_environment.this.cname : var.api_alias_target.name + zone_id = var.api_alias_target == null ? data.aws_elastic_beanstalk_hosted_zone.current.id : var.api_alias_target.zone_id evaluate_target_health = true } diff --git a/terraform/live/modules/environment-owned/variables.tf b/terraform/live/modules/environment-owned/variables.tf index 9980d9d..ecad6eb 100644 --- a/terraform/live/modules/environment-owned/variables.tf +++ b/terraform/live/modules/environment-owned/variables.tf @@ -21,6 +21,12 @@ variable "adoption_complete" { default = false } +variable "manage_eb_settings" { + type = bool + description = "False omits managed Elastic Beanstalk settings during the import-only phase." + default = true +} + variable "eb_application_name" { type = string } @@ -184,8 +190,9 @@ variable "github_deploy_policy_name" { } variable "legacy_dev_s3_policy" { - type = bool - default = false + type = bool + description = "Retain the proven GitHub Elastic Beanstalk release policy until application CD is migrated separately." + default = false } variable "hosted_zone_id" { @@ -205,6 +212,15 @@ variable "api_record_type" { } } +variable "api_alias_target" { + type = object({ + name = string + zone_id = string + }) + description = "Exact existing Route 53 alias target preserved during import. Null resolves the target from Elastic Beanstalk." + default = null +} + variable "metadata_before_adoption" { description = "Exact current metadata preserved while adoption_complete is false." type = object({