Merge branch 'fix/sh-296-vendor-invalid-dispatch' of https://github.com/Sea-Haven-Industries/shoc-backend into fix/sh-296-vendor-invalid-dispatch

This commit is contained in:
Arthur Bassi 2026-09-01 09:43:14 -03:00
commit f15dde8b30
16 changed files with 695 additions and 177 deletions

View file

@ -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;
}
}

View file

@ -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);

View file

@ -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();

View file

@ -696,8 +696,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);

View file

@ -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);
}
}

View file

@ -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);
}

View file

@ -1438,6 +1438,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()
{
@ -1474,7 +1521,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);
}

View file

@ -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

View file

@ -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;
}
}

View file

@ -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"

View file

@ -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,
},
}

View file

@ -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(

View file

@ -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

View file

@ -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"
}

View file

@ -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
}

View file

@ -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({