Compare commits

...

11 commits

Author SHA1 Message Date
Arthur Bassi
f15dde8b30 Merge branch 'fix/sh-296-vendor-invalid-dispatch' of https://github.com/Sea-Haven-Industries/shoc-backend into fix/sh-296-vendor-invalid-dispatch 2026-09-01 09:43:14 -03:00
Arthur Bassi
03c069d10d fix(work-orders): detach shared dispatch on vendor fork
Keep same-vendor saves idempotent and drop stale DispatchWorkOrders so listing and uplift follow the new primary.
2026-09-01 09:42:41 -03:00
Arthur Bassi
af27186527
Merge branch 'dev' into fix/sh-296-vendor-invalid-dispatch 2026-09-01 09:35:00 -03:00
Adam Moussa
6ef8e9ba2a
feat(terraform): complete dev environment adoption (SH-300) (#99)
* feat(terraform): complete dev environment adoption

* fix(terraform): preserve dev release permissions
2026-08-31 20:18:44 -03:00
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
19 changed files with 907 additions and 178 deletions

View file

@ -38,6 +38,45 @@ namespace SeaHaven.DataServices.Implementation
.Include(d => d.DispatchWorkOrders)
.FirstOrDefaultAsync(d => d.Id == dispatchId, cancellationToken);
public void DetachWorkOrderFromDispatch(Dispatch dispatch, int workOrderId)
{
var links = dispatch.DispatchWorkOrders?
.Where(link => link.WorkOrderId == workOrderId)
.ToList();
if (links == null || links.Count == 0)
{
links = _context.DispatchWorkOrders
.Where(link => link.DispatchId == dispatch.Id && link.WorkOrderId == workOrderId)
.ToList();
}
foreach (var link in links)
_context.DispatchWorkOrders.Remove(link);
if (dispatch.WorkOrderId != workOrderId)
return;
var remainingIds = (dispatch.DispatchWorkOrders ?? Enumerable.Empty<DispatchWorkOrder>())
.Where(link => link.WorkOrderId != workOrderId)
.Select(link => link.WorkOrderId)
.Distinct()
.ToList();
if (remainingIds.Count == 0)
{
dispatch.WorkOrderId = null;
return;
}
var preferredOwnerId = _context.workOrders
.Where(workOrder => remainingIds.Contains(workOrder.Id)
&& workOrder.PrimaryDispatchId == dispatch.Id)
.Select(workOrder => (int?)workOrder.Id)
.FirstOrDefault();
dispatch.WorkOrderId = preferredOwnerId ?? remainingIds[0];
}
public void TrackNewWorkOrder(WorkOrder workOrder)
=> _context.workOrders.Add(workOrder);

View file

@ -15,6 +15,7 @@ namespace SeaHaven.DataServices.Interfaces
Task<WorkOrder?> GetTrackedWorkOrderAsync(int workOrderId, CancellationToken cancellationToken);
Task<Dispatch?> GetTrackedDispatchAsync(int dispatchId, int workOrderId, CancellationToken cancellationToken);
Task<Dispatch?> GetTrackedDispatchByIdAsync(int dispatchId, CancellationToken cancellationToken);
void DetachWorkOrderFromDispatch(Dispatch dispatch, int workOrderId);
void TrackNewWorkOrder(WorkOrder workOrder);
void TrackNewDispatch(Dispatch dispatch);
void TrackWorkOrderContact(int workOrderId, int contactId, string? notes);

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

@ -111,6 +111,9 @@ namespace SeaHaven.Services.Implementation
"DispatchVersionRequired",
"dispatchVersion is required for dispatch fields.");
}
if (resolved is { Created: true, Previous: { } previousDispatch })
_mutationData.DetachWorkOrderFromDispatch(previousDispatch, workOrder.Id);
}
var auditField = WorkOrderBoardFieldNames.ToAuditFieldName(canonicalField);
@ -215,10 +218,13 @@ namespace SeaHaven.Services.Implementation
primaryDispatchId,
relaxInvalidDispatch,
cancellationToken);
var vendorChanged = existing != null
&& vendorIdForCreate.HasValue
&& existing.VendorId != vendorIdForCreate.Value;
var mustReplace = existing != null
&& replaceInactivePrimary
&& (WorkOrderBoardDispatchAssignment.IsInactivePrimaryDispatch(existing.Status)
|| IsSharedDispatch(existing, workOrder.Id));
|| (IsSharedDispatch(existing, workOrder.Id) && vendorChanged));
if (existing != null && !mustReplace)
{
workOrder.PrimaryDispatchId = existing.Id;
@ -690,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

@ -392,6 +392,9 @@ public class WorkOrderBoardCancelServiceTests
public Task<Dispatch?> GetTrackedDispatchByIdAsync(int dispatchId, CancellationToken cancellationToken)
=> _inner.GetTrackedDispatchByIdAsync(dispatchId, cancellationToken);
public void DetachWorkOrderFromDispatch(Dispatch dispatch, int workOrderId)
=> _inner.DetachWorkOrderFromDispatch(dispatch, workOrderId);
public void TrackNewWorkOrder(WorkOrder workOrder) => _inner.TrackNewWorkOrder(workOrder);
public void TrackNewDispatch(Dispatch dispatch) => _inner.TrackNewDispatch(dispatch);

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

@ -33,6 +33,27 @@ public class WorkOrderBoardUpdateServiceTests
private static string ToVersion(Dispatch dispatch)
=> Convert.ToBase64String(dispatch.RowVersion ?? new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 });
private static Task<int[]> DispatchIdsForWorkOrderAsync(ApplicationDbContext context, int workOrderId)
=> context.Dispatches
.Where(dispatch =>
dispatch.WorkOrderId == workOrderId
|| context.DispatchWorkOrders.Any(link =>
link.DispatchId == dispatch.Id && link.WorkOrderId == workOrderId))
.Select(dispatch => dispatch.Id)
.OrderBy(id => id)
.ToArrayAsync();
private static Task<bool> UpliftAssociatedAsync(
ApplicationDbContext context,
int workOrderId,
int dispatchId)
=> context.DispatchUpliftRequests.AnyAsync(uplift =>
uplift.DispatchId == dispatchId
&& (context.Dispatches.Any(dispatch =>
dispatch.Id == dispatchId && dispatch.WorkOrderId == workOrderId)
|| context.DispatchWorkOrders.Any(link =>
link.DispatchId == dispatchId && link.WorkOrderId == workOrderId)));
[Fact]
public async Task PatchField_AutoSchedulesIncompleteToScheduled()
{
@ -613,6 +634,15 @@ public class WorkOrderBoardUpdateServiceTests
context.DispatchWorkOrders.AddRange(
new DispatchWorkOrder { DispatchId = 10, WorkOrderId = 1 },
new DispatchWorkOrder { DispatchId = 10, WorkOrderId = 2 });
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
{
Id = 200,
DispatchId = 10,
RequestedNTE = 900m,
Status = "Pending",
RequiredTier = 1,
NotificationStatus = "Pending"
});
await context.SaveChangesAsync();
var result = await service.PatchFieldAsync(2, new WorkOrderBoardPatchRequestDto
@ -630,11 +660,143 @@ public class WorkOrderBoardUpdateServiceTests
var original = await context.Dispatches.SingleAsync(d => d.Id == 10);
Assert.Equal(1, original.VendorId);
Assert.Equal("Sent", original.Status);
Assert.Equal(1, original.WorkOrderId);
var created = await context.Dispatches.SingleAsync(d => d.Id == result.PrimaryDispatchId);
Assert.Equal(2, created.WorkOrderId);
Assert.Equal(5, created.VendorId);
var primaryUnchanged = await context.workOrders.SingleAsync(w => w.Id == 1);
Assert.Equal(10, primaryUnchanged.PrimaryDispatchId);
Assert.False(await context.DispatchWorkOrders.AnyAsync(link =>
link.DispatchId == 10 && link.WorkOrderId == 2));
Assert.True(await context.DispatchWorkOrders.AnyAsync(link =>
link.DispatchId == 10 && link.WorkOrderId == 1));
var secondaryDispatchIds = await DispatchIdsForWorkOrderAsync(context, 2);
var primaryDispatchIds = await DispatchIdsForWorkOrderAsync(context, 1);
Assert.Equal(new int[] { result.PrimaryDispatchId!.Value }, secondaryDispatchIds);
Assert.Equal(new int[] { 10 }, primaryDispatchIds);
Assert.False(await UpliftAssociatedAsync(context, 2, 10));
Assert.True(await UpliftAssociatedAsync(context, 1, 10));
}
[Fact]
public async Task PatchField_VendorId_SharedDispatch_OwnerChange_RehomesDirectOwnership()
{
var (context, service) = CreateSut();
context.Vendors.AddRange(
new Vendor { Id = 1, CompanyName = "Old Co" },
new Vendor { Id = 5, CompanyName = "New Co" });
var shared = new Dispatch
{
Id = 10,
WorkOrderId = 1,
VendorId = 1,
Status = "Sent",
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 }
};
var primaryWo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
PrimaryDispatchId = 10,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
var secondaryWo = new WorkOrder
{
Id = 2,
LifecycleStatus = LifecycleStatus.Scheduled,
PrimaryDispatchId = 10,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 5 }
};
context.Dispatches.Add(shared);
context.workOrders.AddRange(primaryWo, secondaryWo);
context.DispatchWorkOrders.AddRange(
new DispatchWorkOrder { DispatchId = 10, WorkOrderId = 1 },
new DispatchWorkOrder { DispatchId = 10, WorkOrderId = 2 });
await context.SaveChangesAsync();
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.VendorId,
Value = "5",
WorkOrderVersion = ToVersion(primaryWo),
DispatchVersion = ToVersion(shared),
PrimaryDispatchId = 10
}, "actor-1");
Assert.Equal(5, result.VendorId);
Assert.NotEqual(10, result.PrimaryDispatchId);
var original = await context.Dispatches.SingleAsync(d => d.Id == 10);
Assert.Equal(1, original.VendorId);
Assert.Equal(2, original.WorkOrderId);
Assert.False(await context.DispatchWorkOrders.AnyAsync(link =>
link.DispatchId == 10 && link.WorkOrderId == 1));
Assert.True(await context.DispatchWorkOrders.AnyAsync(link =>
link.DispatchId == 10 && link.WorkOrderId == 2));
var secondaryUnchanged = await context.workOrders.SingleAsync(w => w.Id == 2);
Assert.Equal(10, secondaryUnchanged.PrimaryDispatchId);
var ownerDispatchIds = await DispatchIdsForWorkOrderAsync(context, 1);
var remainingDispatchIds = await DispatchIdsForWorkOrderAsync(context, 2);
Assert.Equal(new int[] { result.PrimaryDispatchId!.Value }, ownerDispatchIds);
Assert.Equal(new int[] { 10 }, remainingDispatchIds);
}
[Fact]
public async Task PatchField_VendorId_SharedDispatch_SameVendor_IsIdempotent()
{
var (context, service) = CreateSut();
context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Old Co" });
var appointment = new DateTime(2026, 6, 26);
var shared = new Dispatch
{
Id = 10,
WorkOrderId = 1,
VendorId = 1,
Status = "Sent",
ScheduledDate = appointment,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 }
};
var primaryWo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
PrimaryDispatchId = 10,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
var secondaryWo = new WorkOrder
{
Id = 2,
LifecycleStatus = LifecycleStatus.Scheduled,
PrimaryDispatchId = 10,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 5 }
};
context.Dispatches.Add(shared);
context.workOrders.AddRange(primaryWo, secondaryWo);
context.DispatchWorkOrders.AddRange(
new DispatchWorkOrder { DispatchId = 10, WorkOrderId = 1 },
new DispatchWorkOrder { DispatchId = 10, WorkOrderId = 2 });
await context.SaveChangesAsync();
var result = await service.PatchFieldAsync(2, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.VendorId,
Value = "1",
WorkOrderVersion = ToVersion(secondaryWo),
DispatchVersion = ToVersion(shared),
PrimaryDispatchId = 10
}, "actor-1");
Assert.Equal(1, result.VendorId);
Assert.Equal(10, result.PrimaryDispatchId);
Assert.Equal("Sent", result.PrimaryDispatchStatus);
Assert.Equal(1, await context.Dispatches.CountAsync());
var unchanged = await context.Dispatches.SingleAsync(d => d.Id == 10);
Assert.Equal(appointment, unchanged.ScheduledDate);
Assert.Equal("Sent", unchanged.Status);
Assert.Equal(1, unchanged.VendorId);
Assert.Equal(2, await context.DispatchWorkOrders.CountAsync());
Assert.Empty(await context.WorkOrderAuditLogs
.Where(a => a.FieldName == "VendorId")
.ToListAsync());
}
[Fact]
@ -1276,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()
{
@ -1312,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({