Compare commits

..

No commits in common. "f15dde8b30d9fc46de664aae90c646380f312c3d" and "ca0404272ed22c986f48b1201666ff8f8d2ba9c4" have entirely different histories.

19 changed files with 178 additions and 907 deletions

View file

@ -38,45 +38,6 @@ 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,7 +15,6 @@ 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,8 +53,5 @@ 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.ToScheduledInstant(apptDate, start);
workOrder.ScheduledEnd = WorkOrderBoardApptTimeParser.ToScheduledInstant(apptDate, end);
workOrder.ScheduledStart = WorkOrderBoardApptTimeParser.CombineDateAndTime(apptDate, start);
workOrder.ScheduledEnd = WorkOrderBoardApptTimeParser.CombineDateAndTime(apptDate, end);
var newStart = FormatDateTime(workOrder.ScheduledStart);
var newEnd = FormatDateTime(workOrder.ScheduledEnd);

View file

@ -85,10 +85,8 @@ namespace SeaHaven.Services.Implementation
public static WorkOrderBoardRowDto MapRawRow(WorkOrderBoardRawRow row, DateTime utcNow)
{
// Appt column: date prefers dispatch appointment (vendor slot), then WO ScheduledDate.
// 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);
// Time prefers WO ScheduledStart/End (dispatcher window), then dispatch datetime.
var apptStart = row.ScheduledStart ?? row.DispatchApptDate;
var apptEnd = row.ScheduledEnd;
var (vendorId, vendorName) = WorkOrderBoardDispatchAssignment.LiveVendor(
row.VendorId,
@ -147,9 +145,6 @@ 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,9 +111,6 @@ 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);
@ -218,13 +215,10 @@ 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) && vendorChanged));
|| IsSharedDispatch(existing, workOrder.Id));
if (existing != null && !mustReplace)
{
workOrder.PrimaryDispatchId = existing.Id;
@ -696,8 +690,8 @@ namespace SeaHaven.Services.Implementation
var oldEnd = FormatDateTime(workOrder.ScheduledEnd);
var dispatchId = ResolveDispatchIdForAudit(dispatch);
workOrder.ScheduledStart = WorkOrderBoardApptTimeParser.ToScheduledInstant(apptDate, start);
workOrder.ScheduledEnd = WorkOrderBoardApptTimeParser.ToScheduledInstant(apptDate, end);
workOrder.ScheduledStart = WorkOrderBoardApptTimeParser.CombineDateAndTime(apptDate, start);
workOrder.ScheduledEnd = WorkOrderBoardApptTimeParser.CombineDateAndTime(apptDate, end);
var newStart = FormatDateTime(workOrder.ScheduledStart);
var newEnd = FormatDateTime(workOrder.ScheduledEnd);

View file

@ -392,9 +392,6 @@ 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

@ -1,53 +0,0 @@
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,93 +869,4 @@ 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,27 +33,6 @@ 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()
{
@ -634,15 +613,6 @@ 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
@ -660,143 +630,11 @@ 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]
@ -1438,53 +1276,6 @@ 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()
{
@ -1521,7 +1312,7 @@ public class WorkOrderBoardUpdateServiceTests
var reloaded = await context.workOrders.FindAsync(1);
Assert.NotNull(reloaded!.ScheduledStart);
Assert.Null(reloaded.ScheduledEnd);
Assert.Equal(new DateTime(2026, 6, 25), reloaded.ScheduledEnd);
Assert.Equal(new DateTime(2026, 6, 25).Add(TimeSpan.FromDays(30)), reloaded.ScheduledStart);
}

View file

@ -187,32 +187,6 @@ 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
@ -220,9 +194,6 @@ automatically.
`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,27 +14,6 @@ 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`;
@ -59,7 +38,6 @@ 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({
@ -156,25 +134,10 @@ export class DeployDevStack extends cdk.Stack {
}),
);
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;
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',
});
}
}

View file

@ -8,11 +8,7 @@ import json
import sys
from pathlib import Path
from terraform_import_plan_resources import (
DEV_IMPORT_BASELINE,
DEV_IMPORT_IDS,
REQUIRED_RESOURCES,
)
from terraform_import_plan_resources import REQUIRED_RESOURCES
ALLOWED_MANAGED_TYPES = {
@ -42,56 +38,9 @@ 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"))
@ -102,8 +51,6 @@ 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":
@ -114,7 +61,6 @@ 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(
@ -143,31 +89,12 @@ 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:
@ -175,28 +102,6 @@ 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,44 +30,3 @@ 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,11 +9,7 @@ import sys
import tempfile
from pathlib import Path
from terraform_import_plan_resources import (
DEV_IMPORT_BASELINE,
DEV_IMPORT_IDS,
REQUIRED_RESOURCES,
)
from terraform_import_plan_resources import REQUIRED_RESOURCES
SCRIPT = Path(__file__).with_name("check-terraform-import-plan.py")
@ -25,8 +21,6 @@ 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 = []
@ -35,39 +29,12 @@ 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": change,
"change": {"actions": actions},
}
)
if extra_resource:
@ -116,67 +83,6 @@ 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,10 +94,8 @@ 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` and
`manage_eb_settings=true` in a reviewed code change, then review the
controlled in-place role metadata, secret metadata, and Elastic Beanstalk
update:
5. Change the environment root to `adoption_complete=true` in a reviewed code
change, then review the controlled in-place role and policy update:
```bash
# Dev example. Omit any address that is not updating.
@ -105,32 +103,22 @@ 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_secretsmanager_secret.app_config \
--allow-update-address module.environment.aws_elastic_beanstalk_environment.this
--allow-update-address module.environment.aws_iam_role_policy.github_deploy \
--allow-update-address module.environment.aws_secretsmanager_secret.app_config
```
6. Apply only when every update address is named on the command line and the
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.
plan contains no create, delete, or replacement action.
The reviewed `adoption_complete=true` change updates ownership tags on IAM
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.
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.
## POC retained identifiers

View file

@ -26,8 +26,7 @@ module "environment" {
aws_account_id = local.aws_account_id
aws_region = local.aws_region
environment = "dev"
adoption_complete = true
manage_eb_settings = true
adoption_complete = false
eb_application_name = local.eb_application_name
eb_environment_name = local.eb_environment_name
eb_environment_id = local.eb_environment_id
@ -69,10 +68,6 @@ 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 = {
@ -97,6 +92,7 @@ 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.legacy_dev_s3_policy
use_legacy_s3_policy = !var.adoption_complete && 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,8 +141,6 @@ 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
@ -329,134 +327,6 @@ 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
@ -464,13 +334,150 @@ resource "aws_elastic_beanstalk_environment" "this" {
tier = "WebServer"
cname_prefix = var.eb_environment_name
dynamic "setting" {
for_each = var.manage_eb_settings ? local.managed_eb_settings : []
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]
content {
namespace = setting.value.namespace
name = setting.value.name
value = setting.value.value
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
}
}
@ -492,8 +499,8 @@ resource "aws_route53_record" "api_alias" {
type = "A"
alias {
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
name = aws_elastic_beanstalk_environment.this.cname
zone_id = data.aws_elastic_beanstalk_hosted_zone.current.id
evaluate_target_health = true
}

View file

@ -21,12 +21,6 @@ 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
}
@ -190,9 +184,8 @@ variable "github_deploy_policy_name" {
}
variable "legacy_dev_s3_policy" {
type = bool
description = "Retain the proven GitHub Elastic Beanstalk release policy until application CD is migrated separately."
default = false
type = bool
default = false
}
variable "hosted_zone_id" {
@ -212,15 +205,6 @@ 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({