mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-05 06:02:13 +00:00
Merge branch 'dev' into fix/sh-296-vendor-invalid-dispatch
This commit is contained in:
commit
af27186527
16 changed files with 695 additions and 177 deletions
|
|
@ -53,5 +53,8 @@ namespace SeaHaven.Services.Helpers
|
||||||
|
|
||||||
return date.Value.Date.Add(time.Value);
|
return date.Value.Date.Add(time.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static DateTime? ToScheduledInstant(DateTime? date, TimeSpan? time)
|
||||||
|
=> time.HasValue ? CombineDateAndTime(date, time) : null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -87,8 +87,8 @@ namespace SeaHaven.Services.Helpers
|
||||||
var oldStart = FormatDateTime(workOrder.ScheduledStart);
|
var oldStart = FormatDateTime(workOrder.ScheduledStart);
|
||||||
var oldEnd = FormatDateTime(workOrder.ScheduledEnd);
|
var oldEnd = FormatDateTime(workOrder.ScheduledEnd);
|
||||||
|
|
||||||
workOrder.ScheduledStart = WorkOrderBoardApptTimeParser.CombineDateAndTime(apptDate, start);
|
workOrder.ScheduledStart = WorkOrderBoardApptTimeParser.ToScheduledInstant(apptDate, start);
|
||||||
workOrder.ScheduledEnd = WorkOrderBoardApptTimeParser.CombineDateAndTime(apptDate, end);
|
workOrder.ScheduledEnd = WorkOrderBoardApptTimeParser.ToScheduledInstant(apptDate, end);
|
||||||
|
|
||||||
var newStart = FormatDateTime(workOrder.ScheduledStart);
|
var newStart = FormatDateTime(workOrder.ScheduledStart);
|
||||||
var newEnd = FormatDateTime(workOrder.ScheduledEnd);
|
var newEnd = FormatDateTime(workOrder.ScheduledEnd);
|
||||||
|
|
|
||||||
|
|
@ -85,8 +85,10 @@ namespace SeaHaven.Services.Implementation
|
||||||
public static WorkOrderBoardRowDto MapRawRow(WorkOrderBoardRawRow row, DateTime utcNow)
|
public static WorkOrderBoardRowDto MapRawRow(WorkOrderBoardRawRow row, DateTime utcNow)
|
||||||
{
|
{
|
||||||
// Appt column: date prefers dispatch appointment (vendor slot), then WO ScheduledDate.
|
// Appt column: date prefers dispatch appointment (vendor slot), then WO ScheduledDate.
|
||||||
// Time prefers WO ScheduledStart/End (dispatcher window), then dispatch datetime.
|
// Time uses WO ScheduledStart/End only. Do not fall back to a date-only dispatch
|
||||||
var apptStart = row.ScheduledStart ?? row.DispatchApptDate;
|
// instant (midnight), or clearing apptTime still renders 00:00.
|
||||||
|
var apptStart = row.ScheduledStart
|
||||||
|
?? (HasClockTime(row.DispatchApptDate) ? row.DispatchApptDate : null);
|
||||||
var apptEnd = row.ScheduledEnd;
|
var apptEnd = row.ScheduledEnd;
|
||||||
var (vendorId, vendorName) = WorkOrderBoardDispatchAssignment.LiveVendor(
|
var (vendorId, vendorName) = WorkOrderBoardDispatchAssignment.LiveVendor(
|
||||||
row.VendorId,
|
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)
|
private static string FormatName(string? firstName, string? lastName)
|
||||||
=> $"{firstName ?? ""} {lastName ?? ""}".Trim();
|
=> $"{firstName ?? ""} {lastName ?? ""}".Trim();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -690,8 +690,8 @@ namespace SeaHaven.Services.Implementation
|
||||||
var oldEnd = FormatDateTime(workOrder.ScheduledEnd);
|
var oldEnd = FormatDateTime(workOrder.ScheduledEnd);
|
||||||
var dispatchId = ResolveDispatchIdForAudit(dispatch);
|
var dispatchId = ResolveDispatchIdForAudit(dispatch);
|
||||||
|
|
||||||
workOrder.ScheduledStart = WorkOrderBoardApptTimeParser.CombineDateAndTime(apptDate, start);
|
workOrder.ScheduledStart = WorkOrderBoardApptTimeParser.ToScheduledInstant(apptDate, start);
|
||||||
workOrder.ScheduledEnd = WorkOrderBoardApptTimeParser.CombineDateAndTime(apptDate, end);
|
workOrder.ScheduledEnd = WorkOrderBoardApptTimeParser.ToScheduledInstant(apptDate, end);
|
||||||
|
|
||||||
var newStart = FormatDateTime(workOrder.ScheduledStart);
|
var newStart = FormatDateTime(workOrder.ScheduledStart);
|
||||||
var newEnd = FormatDateTime(workOrder.ScheduledEnd);
|
var newEnd = FormatDateTime(workOrder.ScheduledEnd);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -869,4 +869,93 @@ public class WorkOrderBoardServiceTests
|
||||||
null,
|
null,
|
||||||
cts.Token));
|
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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1276,6 +1276,53 @@ public class WorkOrderBoardUpdateServiceTests
|
||||||
Assert.Equal(new DateTime(2026, 6, 25, 10, 0, 0), reloaded.ScheduledEnd);
|
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]
|
[Fact]
|
||||||
public async Task PatchField_ApptTime_DashPrefixedToken_TreatedAsSingleStart()
|
public async Task PatchField_ApptTime_DashPrefixedToken_TreatedAsSingleStart()
|
||||||
{
|
{
|
||||||
|
|
@ -1312,7 +1359,7 @@ public class WorkOrderBoardUpdateServiceTests
|
||||||
|
|
||||||
var reloaded = await context.workOrders.FindAsync(1);
|
var reloaded = await context.workOrders.FindAsync(1);
|
||||||
Assert.NotNull(reloaded!.ScheduledStart);
|
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);
|
Assert.Equal(new DateTime(2026, 6, 25).Add(TimeSpan.FromDays(30)), reloaded.ScheduledStart);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -187,6 +187,32 @@ npm run deploy # deploy the stack (requires AWS)
|
||||||
|
|
||||||
All commands run from `infra/cdk/`.
|
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
|
## CI integration
|
||||||
|
|
||||||
`npm run synth` is the deterministic local/CI validation. After synth, inspect
|
`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`:
|
`AWS::IAM::Role`:
|
||||||
|
|
||||||
- Trust policy `StringEquals` matches the exact audience and subject above.
|
- 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
|
- The inline policy contains no `Resource: "*"` mutation action and no service
|
||||||
outside `elasticbeanstalk` / `s3` / `cloudformation` / `ec2` /
|
outside `elasticbeanstalk` / `s3` / `cloudformation` / `ec2` /
|
||||||
`elasticloadbalancing` / `autoscaling`. CloudFormation discovery and
|
`elasticloadbalancing` / `autoscaling`. CloudFormation discovery and
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,27 @@ export class DeployDevStack extends cdk.Stack {
|
||||||
constructor(scope: Construct, id: string, props: cdk.StackProps = {}) {
|
constructor(scope: Construct, id: string, props: cdk.StackProps = {}) {
|
||||||
super(scope, id, props);
|
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 applicationArn = `arn:aws:elasticbeanstalk:${REGION}:${ACCOUNT_ID}:application/${APPLICATION_NAME}`;
|
||||||
const environmentArn = `arn:aws:elasticbeanstalk:${REGION}:${ACCOUNT_ID}:environment/${APPLICATION_NAME}/${ENVIRONMENT_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`;
|
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;
|
const cfnRole = deployRole.node.defaultChild as iam.CfnRole;
|
||||||
cfnRole.cfnOptions.deletionPolicy = cdk.CfnDeletionPolicy.RETAIN;
|
cfnRole.cfnOptions.deletionPolicy = cdk.CfnDeletionPolicy.RETAIN;
|
||||||
cfnRole.cfnOptions.updateReplacePolicy = cdk.CfnDeletionPolicy.RETAIN;
|
cfnRole.cfnOptions.updateReplacePolicy = cdk.CfnDeletionPolicy.RETAIN;
|
||||||
|
cfnRole.cfnOptions.condition = manageGithubDeployRoleCondition;
|
||||||
|
|
||||||
deployRole.addToPolicy(
|
deployRole.addToPolicy(
|
||||||
new iam.PolicyStatement({
|
new iam.PolicyStatement({
|
||||||
|
|
@ -134,10 +156,25 @@ export class DeployDevStack extends cdk.Stack {
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
new cdk.CfnOutput(this, 'GithubDeployRoleArn', {
|
const defaultPolicy = deployRole.node.findChild(
|
||||||
value: deployRole.roleArn,
|
'DefaultPolicy',
|
||||||
description: 'ARN of the GitHub OIDC deploy role for shoc-backend dev.',
|
) as iam.Policy;
|
||||||
exportName: 'shoc-backend-deploy-dev-role-arn',
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,11 @@ import json
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
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 = {
|
ALLOWED_MANAGED_TYPES = {
|
||||||
|
|
@ -38,9 +42,56 @@ def parse_args() -> argparse.Namespace:
|
||||||
"no-op import is proven. Repeat for each reviewed update."
|
"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()
|
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:
|
def main() -> int:
|
||||||
args = parse_args()
|
args = parse_args()
|
||||||
plan = json.loads(args.plan_json.read_text(encoding="utf-8"))
|
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_update_addresses: set[str] = set()
|
||||||
seen_addresses: set[str] = set()
|
seen_addresses: set[str] = set()
|
||||||
required_resources = REQUIRED_RESOURCES[args.environment]
|
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", []):
|
for resource in plan.get("resource_changes", []):
|
||||||
if resource.get("mode", "managed") != "managed":
|
if resource.get("mode", "managed") != "managed":
|
||||||
|
|
@ -61,6 +114,7 @@ def main() -> int:
|
||||||
actions = set(resource.get("change", {}).get("actions", []))
|
actions = set(resource.get("change", {}).get("actions", []))
|
||||||
managed += 1
|
managed += 1
|
||||||
seen_addresses.add(address)
|
seen_addresses.add(address)
|
||||||
|
resources_by_address[address] = resource
|
||||||
|
|
||||||
if resource_type not in ALLOWED_MANAGED_TYPES:
|
if resource_type not in ALLOWED_MANAGED_TYPES:
|
||||||
violations.append(
|
violations.append(
|
||||||
|
|
@ -89,12 +143,31 @@ def main() -> int:
|
||||||
f"{address}: update is not explicitly allowlisted"
|
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):
|
for unused in sorted(allowed_update_addresses - seen_update_addresses):
|
||||||
violations.append(f"{unused}: allowlisted update address is not updating")
|
violations.append(f"{unused}: allowlisted update address is not updating")
|
||||||
|
|
||||||
for missing in sorted(set(required_resources) - seen_addresses):
|
for missing in sorted(set(required_resources) - seen_addresses):
|
||||||
violations.append(f"{missing}: required managed resource is absent")
|
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:
|
if violations:
|
||||||
print("FAIL: live Terraform plan is not import-safe", file=sys.stderr)
|
print("FAIL: live Terraform plan is not import-safe", file=sys.stderr)
|
||||||
for violation in violations:
|
for violation in violations:
|
||||||
|
|
@ -102,6 +175,28 @@ def main() -> int:
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
mode = "controlled update" if allowed_update_addresses else "no-op import"
|
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(
|
print(
|
||||||
f"PASS: {mode} plan has {managed} managed resources, "
|
f"PASS: {mode} plan has {managed} managed resources, "
|
||||||
f"{updates} updates, and no create/delete/replace actions"
|
f"{updates} updates, and no create/delete/replace actions"
|
||||||
|
|
|
||||||
|
|
@ -30,3 +30,44 @@ REQUIRED_RESOURCES = {
|
||||||
"module.environment.aws_route53_record.api_cname[0]": "aws_route53_record",
|
"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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,11 @@ import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
from pathlib import Path
|
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")
|
SCRIPT = Path(__file__).with_name("check-terraform-import-plan.py")
|
||||||
|
|
||||||
|
|
@ -21,6 +25,8 @@ def run_case(
|
||||||
omit_address: str | None = None,
|
omit_address: str | None = None,
|
||||||
extra_resource: tuple[str, str, list[str]] | None = None,
|
extra_resource: tuple[str, str, list[str]] | None = None,
|
||||||
allowed_updates: tuple[str, ...] = (),
|
allowed_updates: tuple[str, ...] = (),
|
||||||
|
import_id_overrides: dict[str, str] | None = None,
|
||||||
|
state_overrides: dict[str, dict[str, object]] | None = None,
|
||||||
empty: bool = False,
|
empty: bool = False,
|
||||||
) -> subprocess.CompletedProcess[str]:
|
) -> subprocess.CompletedProcess[str]:
|
||||||
changes = []
|
changes = []
|
||||||
|
|
@ -29,12 +35,39 @@ def run_case(
|
||||||
if address == omit_address:
|
if address == omit_address:
|
||||||
continue
|
continue
|
||||||
actions = (actions_by_address or {}).get(address, ["no-op"])
|
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(
|
changes.append(
|
||||||
{
|
{
|
||||||
"address": address,
|
"address": address,
|
||||||
"mode": "managed",
|
"mode": "managed",
|
||||||
"type": resource_type,
|
"type": resource_type,
|
||||||
"change": {"actions": actions},
|
"change": change,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
if extra_resource:
|
if extra_resource:
|
||||||
|
|
@ -83,6 +116,67 @@ def main() -> int:
|
||||||
run_case("dev", actions_by_address={controlled_address: ["update"]}),
|
run_case("dev", actions_by_address={controlled_address: ["update"]}),
|
||||||
1,
|
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",
|
"controlled update",
|
||||||
run_case(
|
run_case(
|
||||||
|
|
|
||||||
|
|
@ -94,8 +94,10 @@ tf-poc rehearsal has completed both phases and therefore pins
|
||||||
creates, deletes, replacements, and managed resource types outside the
|
creates, deletes, replacements, and managed resource types outside the
|
||||||
approved environment-owned boundary.
|
approved environment-owned boundary.
|
||||||
4. Apply the no-op import only after review.
|
4. Apply the no-op import only after review.
|
||||||
5. Change the environment root to `adoption_complete=true` in a reviewed code
|
5. Change the environment root to `adoption_complete=true` and
|
||||||
change, then review the controlled in-place role and policy update:
|
`manage_eb_settings=true` in a reviewed code change, then review the
|
||||||
|
controlled in-place role metadata, secret metadata, and Elastic Beanstalk
|
||||||
|
update:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Dev example. Omit any address that is not updating.
|
# 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_instance_profile.runtime \
|
||||||
--allow-update-address module.environment.aws_iam_role.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.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
|
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
|
The reviewed `adoption_complete=true` change updates ownership tags on IAM
|
||||||
roles, instance profiles, and app-config secrets, and narrows the dev role to
|
roles, instance profiles, and app-config secrets. Dev retains the proven GitHub
|
||||||
the staging-style S3 bucket and application prefix. Elastic Beanstalk
|
Elastic Beanstalk release policy until application CD is migrated in a separate
|
||||||
environment tags remain at their imported values. EB accepts an added
|
reviewed change; infrastructure adoption must not silently break the current
|
||||||
`ManagedBy` tag request but can fail the asynchronous service-managed
|
manual release path. Elastic Beanstalk environment tags remain at their imported
|
||||||
CloudFormation propagation after Terraform reports success. Terraform still
|
values. Terraform manages the declared EB settings. Secret values remain
|
||||||
manages the declared EB settings. Deploy-role descriptions and immutable
|
out-of-band even after the secret shell receives `ManagedBy=terraform`.
|
||||||
`HcpTerraformWorkspace` tags remain unchanged. Read-only AWS APIs retain
|
Deploy-role descriptions and immutable `HcpTerraformWorkspace` tags remain
|
||||||
`Resource = "*"` only where AWS does not support resource-level permissions.
|
unchanged. Read-only AWS APIs retain `Resource = "*"` only where AWS does not
|
||||||
|
support resource-level permissions.
|
||||||
|
|
||||||
## POC retained identifiers
|
## POC retained identifiers
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,8 @@ module "environment" {
|
||||||
aws_account_id = local.aws_account_id
|
aws_account_id = local.aws_account_id
|
||||||
aws_region = local.aws_region
|
aws_region = local.aws_region
|
||||||
environment = "dev"
|
environment = "dev"
|
||||||
adoption_complete = false
|
adoption_complete = true
|
||||||
|
manage_eb_settings = true
|
||||||
eb_application_name = local.eb_application_name
|
eb_application_name = local.eb_application_name
|
||||||
eb_environment_name = local.eb_environment_name
|
eb_environment_name = local.eb_environment_name
|
||||||
eb_environment_id = local.eb_environment_id
|
eb_environment_id = local.eb_environment_id
|
||||||
|
|
@ -68,6 +69,10 @@ module "environment" {
|
||||||
hosted_zone_id = "Z07671212N75U4YLPWZR8"
|
hosted_zone_id = "Z07671212N75U4YLPWZR8"
|
||||||
api_domain = local.api_domain
|
api_domain = local.api_domain
|
||||||
api_record_type = "A"
|
api_record_type = "A"
|
||||||
|
api_alias_target = {
|
||||||
|
name = "awseb--awseb-cmpb3ypfib53-1654918745.us-east-1.elb.amazonaws.com"
|
||||||
|
zone_id = "Z35SXDOTRQ7X7K"
|
||||||
|
}
|
||||||
metadata_before_adoption = {
|
metadata_before_adoption = {
|
||||||
runtime_role_description = "SHOC backend dev compute role (EB instance profile)"
|
runtime_role_description = "SHOC backend dev compute role (EB instance profile)"
|
||||||
runtime_role_tags = {
|
runtime_role_tags = {
|
||||||
|
|
@ -92,7 +97,6 @@ module "environment" {
|
||||||
Project = "shoc-backend"
|
Project = "shoc-backend"
|
||||||
}
|
}
|
||||||
environment_tags = {
|
environment_tags = {
|
||||||
Name = "shoc-backend-dev"
|
|
||||||
env = "dev"
|
env = "dev"
|
||||||
project = "shoc"
|
project = "shoc"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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_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"
|
environment_stack_name = "awseb-${var.eb_environment_id}-stack"
|
||||||
eb_bucket_name = "elasticbeanstalk-${var.aws_region}-${var.aws_account_id}"
|
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}-*"
|
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" {
|
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
|
name = var.app_config_secret_name
|
||||||
description = var.metadata_before_adoption.app_config_description
|
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
|
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" {
|
resource "aws_elastic_beanstalk_environment" "this" {
|
||||||
name = var.eb_environment_name
|
name = var.eb_environment_name
|
||||||
application = var.eb_application_name
|
application = var.eb_application_name
|
||||||
|
|
@ -334,150 +464,13 @@ resource "aws_elastic_beanstalk_environment" "this" {
|
||||||
tier = "WebServer"
|
tier = "WebServer"
|
||||||
cname_prefix = var.eb_environment_name
|
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" {
|
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 {
|
content {
|
||||||
namespace = "aws:autoscaling:launchconfiguration"
|
namespace = setting.value.namespace
|
||||||
name = "SecurityGroups"
|
name = setting.value.name
|
||||||
value = setting.value
|
value = setting.value.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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -499,8 +492,8 @@ resource "aws_route53_record" "api_alias" {
|
||||||
type = "A"
|
type = "A"
|
||||||
|
|
||||||
alias {
|
alias {
|
||||||
name = aws_elastic_beanstalk_environment.this.cname
|
name = var.api_alias_target == null ? aws_elastic_beanstalk_environment.this.cname : var.api_alias_target.name
|
||||||
zone_id = data.aws_elastic_beanstalk_hosted_zone.current.id
|
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
|
evaluate_target_health = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,12 @@ variable "adoption_complete" {
|
||||||
default = false
|
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" {
|
variable "eb_application_name" {
|
||||||
type = string
|
type = string
|
||||||
}
|
}
|
||||||
|
|
@ -184,8 +190,9 @@ variable "github_deploy_policy_name" {
|
||||||
}
|
}
|
||||||
|
|
||||||
variable "legacy_dev_s3_policy" {
|
variable "legacy_dev_s3_policy" {
|
||||||
type = bool
|
type = bool
|
||||||
default = false
|
description = "Retain the proven GitHub Elastic Beanstalk release policy until application CD is migrated separately."
|
||||||
|
default = false
|
||||||
}
|
}
|
||||||
|
|
||||||
variable "hosted_zone_id" {
|
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" {
|
variable "metadata_before_adoption" {
|
||||||
description = "Exact current metadata preserved while adoption_complete is false."
|
description = "Exact current metadata preserved while adoption_complete is false."
|
||||||
type = object({
|
type = object({
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue