Compare commits

...

7 commits

Author SHA1 Message Date
Adam Moussa
e1e547f7d0
fix(terraform): preserve live state during dev import (#98) 2026-08-31 15:05:46 -03:00
Arthur Bassi
9665be0ae5
Merge pull request #97 from Sea-Haven-Industries/fix/sh-131-clear-appt-time
fix(work-orders): persist empty apptTime as null scheduled instants
2026-08-31 14:24:50 -03:00
Arthur Bassi
ddd47f6d37
Merge branch 'dev' into fix/sh-131-clear-appt-time 2026-08-31 13:15:44 -03:00
Arthur Bassi
aca284ee46
Merge branch 'dev' into fix/sh-131-clear-appt-time 2026-08-31 13:02:11 -03:00
Arthur Bassi
a6b3d5d073 test(work-orders): cover date-only dispatch apptTime mapping 2026-08-31 13:01:02 -03:00
Arthur Bassi
e45e71d5ac test(work-orders): expect null end for start-only apptTime tokens
EOF
2026-08-31 11:13:40 -03:00
Arthur Bassi
dcb757b404 fix(work-orders): persist empty apptTime as null scheduled instants
EOF
2026-08-31 10:49:54 -03:00
13 changed files with 592 additions and 155 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

@ -651,8 +651,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

@ -1070,6 +1070,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()
{
@ -1106,7 +1153,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

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

@ -27,6 +27,7 @@ module "environment" {
aws_region = local.aws_region
environment = "dev"
adoption_complete = false
manage_eb_settings = false
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

@ -327,6 +327,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 +462,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 +490,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
}
@ -205,6 +211,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({