Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ public class CloudFormationResourceProvisioner {
private static final String LAMBDA_CODE_IDENTITY_ATTR = "FlociLambdaCodeIdentity";
private static final String LAMBDA_NAME_MODE_ATTR = "FlociLambdaFunctionNameMode";
private static final String LAMBDA_PACKAGE_TYPE_ATTR = "FlociLambdaPackageType";
static final String UPDATE_ROLLBACK_RESTORED_ATTR = "__FlociUpdateRollbackRestored";
static final String UPDATE_ROLLBACK_RESTORED_ATTR = CfnRollback.UPDATE_ROLLBACK_RESTORED_ATTR;
static final String UPDATE_ROLLBACK_FAILURE_ATTR = "__FlociUpdateRollbackFailure";
private static final String INLINE_CLEANUP_POLICY_NAME_ATTR = "__FlociInlineCleanupPolicyName";
private static final String INLINE_CLEANUP_ROLE_TARGETS_ATTR = "__FlociInlineCleanupRoleTargets";
Expand All @@ -158,7 +158,6 @@ public class CloudFormationResourceProvisioner {
private static final String NAME_MODE_GENERATED = "generated";
private static final int GENERATED_NAME_SUFFIX_LENGTH = 12;
private static final int STEP_FUNCTIONS_NAME_MAX_LENGTH = 80;
private static final String LOG_GROUP_NAME_MODE_ATTR = "FlociLogGroupNameMode";
private static final String SECRET_TARGET_MANAGED_KEYS_ATTR = "__FlociSecretTargetManagedKeys";
private static final String SECRET_TARGET_OWNER_ATTR = "__FlociSecretTargetOwner";
private static final String DDB_REPLICA_TABLE_NAME_ATTR = "TableName";
Expand Down Expand Up @@ -261,7 +260,6 @@ public class CloudFormationResourceProvisioner {
"AWS::Lambda::EventSourceMapping",
"AWS::Lambda::Function",
"AWS::Lambda::LayerVersion",
"AWS::Logs::LogGroup",
"AWS::RDS::DBCluster",
"AWS::RDS::DBClusterParameterGroup",
"AWS::RDS::DBInstance",
Expand Down Expand Up @@ -309,7 +307,6 @@ public class CloudFormationResourceProvisioner {
private final Ec2Service ec2Service;
private final RdsService rdsService;
private final EksService eksService;
private final CloudWatchLogsService logsService;
private final KinesisService kinesisService;
private final CloudWatchMetricsService cloudWatchMetricsService;
private final AutoScalingService autoScalingService;
Expand Down Expand Up @@ -377,7 +374,6 @@ public CloudFormationResourceProvisioner(S3Service s3Service,
this.ec2Service = ec2Service;
this.rdsService = rdsService;
this.eksService = eksService;
this.logsService = logsService;
this.kinesisService = kinesisService;
this.cloudWatchMetricsService = cloudWatchMetricsService;
this.autoScalingService = autoScalingService;
Expand Down Expand Up @@ -517,7 +513,6 @@ public StackResource provision(String logicalId, String resourceType, JsonNode p
provisionDbProxyTargetGroup(resource, properties, engine, region);
case "AWS::EKS::Cluster" -> provisionEksCluster(resource, properties, engine, stackName);
case "AWS::EKS::Nodegroup" -> provisionEksNodegroup(resource, properties, engine, stackName);
case "AWS::Logs::LogGroup" -> provisionLogGroup(resource, properties, engine, region, accountId, stackName);
case "AWS::Kinesis::Stream" ->
provisionKinesisStream(resource, properties, engine, region, stackName);
case "AWS::CloudWatch::Alarm" ->
Expand Down Expand Up @@ -755,7 +750,6 @@ public void delete(String resourceType, String physicalId, String region) {
case "AWS::RDS::DBClusterParameterGroup" ->
rdsService.deleteDbClusterParameterGroup(physicalId, region);
case "AWS::EKS::Cluster" -> eksService.deleteCluster(physicalId);
case "AWS::Logs::LogGroup" -> logsService.deleteLogGroup(physicalId, region);
case "AWS::Kinesis::Stream" -> kinesisService.deleteStream(physicalId, region);
case "AWS::CloudWatch::Alarm" ->
cloudWatchMetricsService.deleteAlarms(List.of(physicalId), region);
Expand Down Expand Up @@ -890,89 +884,6 @@ private void provisionEip(StackResource r, String region) {

// ── CloudWatch Logs ─────────────────────────────────────────────────────────

private void provisionLogGroup(StackResource r, JsonNode props, CloudFormationTemplateEngine engine,
String region, String accountId, String stackName) {
String explicitName = resolveOptional(props, "LogGroupName", engine);
boolean hasExplicitName = explicitName != null && !explicitName.isBlank();
String previousNameMode = r.getAttributes().get(LOG_GROUP_NAME_MODE_ATTR);
if (previousNameMode == null && r.getPhysicalId() != null) {
// Stacks persisted before FlociLogGroupNameMode existed have no recorded mode, but an
// auto-generated name always has the deterministic shape generatePhysicalName produces,
// so anything else must have been explicit.
previousNameMode = isGeneratedName(r.getPhysicalId(), stackName, r.getLogicalId(), 512)
? NAME_MODE_GENERATED
: NAME_MODE_EXPLICIT;
}
// Going from an explicit name to none is itself a replacement-worthy change on real AWS, not
// something to silently keep reconciling under the old explicit name (mirrors the same check
// for Lambda's FunctionName above).
boolean explicitNameRemoved = r.getPhysicalId() != null && !hasExplicitName
&& NAME_MODE_EXPLICIT.equals(previousNameMode);

String name;
if (hasExplicitName) {
name = explicitName;
} else if (r.getPhysicalId() != null && !explicitNameRemoved) {
// No explicit name and the prior name was itself auto-generated: keep it across updates
// instead of generating a fresh random one each time, so the log group is reconciled in
// place rather than replaced on every no-op update.
name = r.getPhysicalId();
} else {
name = generatePhysicalName(stackName, r.getLogicalId(), 512, false);
}
Integer retentionInDays = null;
String retention = resolveOptional(props, "RetentionInDays", engine);
if (retention != null && !retention.isBlank()) {
try {
retentionInDays = Integer.valueOf(retention.trim());
} catch (NumberFormatException ignored) {
// leave unset
}
}
Map<String, String> tags = new HashMap<>();
if (props != null && props.has("Tags") && props.get("Tags").isArray()) {
for (JsonNode tag : props.get("Tags")) {
String key = engine.resolve(tag.path("Key"));
if (!key.isEmpty()) {
tags.put(key, engine.resolve(tag.path("Value")));
}
}
}

// LogGroupName isn't updatable in place on real AWS (a change replaces the resource), so only
// reconcile in place when the name is unchanged and the group is still there; otherwise this is
// either a first create or a rename, both of which need a fresh createLogGroup call. On a rename,
// create the new group before deleting the old one: if the new name collides with something else
// and createLogGroup throws, the update rolls back without touching the old group, since rollback
// does not restore a resource this method already deleted.
String priorPhysicalId = r.getPhysicalId();
if (priorPhysicalId != null && priorPhysicalId.equals(name) && logsService.logGroupExists(name, region)) {
reconcileLogGroup(name, retentionInDays, tags, region);
} else {
boolean preservedPriorGroup = priorPhysicalId != null
&& !priorPhysicalId.equals(name)
&& logsService.logGroupExists(priorPhysicalId, region);
try {
logsService.createLogGroup(name, retentionInDays, tags, region);
} catch (RuntimeException failure) {
if (preservedPriorGroup) {
r.getAttributes().put(UPDATE_ROLLBACK_RESTORED_ATTR, "true");
}
throw failure;
}
if (preservedPriorGroup) {
logsService.deleteLogGroup(priorPhysicalId, region);
}
}

// Ref returns the log group name; GetAtt Arn is arn:aws:logs:<region>:<account>:log-group:<name>:*
r.setPhysicalId(name);
r.getAttributes().put("Arn",
AwsArnUtils.Arn.of("logs", region, accountId, "log-group:" + name + ":*").toString());
r.getAttributes().put(LOG_GROUP_NAME_MODE_ATTR,
hasExplicitName ? NAME_MODE_EXPLICIT : NAME_MODE_GENERATED);
}

/**
* Whether {@code physicalId} matches the exact shape {@link #generatePhysicalName} produces for
* this stack/logical id/maxLength: its base-and-truncation logic (minus the random suffix itself)
Expand Down Expand Up @@ -1017,24 +928,6 @@ private String expectedGeneratedNamePrefix(String stackName, String logicalId, i
return prefix;
}

private void reconcileLogGroup(String name, Integer retentionInDays, Map<String, String> tags, String region) {
if (retentionInDays != null) {
logsService.putRetentionPolicy(name, retentionInDays, region);
} else {
logsService.deleteRetentionPolicy(name, region);
}
Map<String, String> existingTags = logsService.listTagsLogGroup(name, region);
List<String> tagsToRemove = existingTags.keySet().stream()
.filter(key -> !tags.containsKey(key))
.toList();
if (!tagsToRemove.isEmpty()) {
logsService.untagLogGroup(name, tagsToRemove, region);
}
if (!tags.isEmpty()) {
logsService.tagLogGroup(name, tags, region);
}
}

// ── Kinesis ─────────────────────────────────────────────────────────────────

private void provisionKinesisStream(StackResource r, JsonNode props, CloudFormationTemplateEngine engine,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,15 @@ public final class CfnRollback {
*/
public static final String ROLLBACK_OWNED_ATTR = "__FlociRollbackOwned";

/**
* Marks a resource whose prior physical entity is still intact after a failed update, so the
* rollback must not try to restore it. Set by a provisioner that creates the replacement before
* deleting the original; read by {@code CloudFormationService} when deciding what a rollback
* owes. Lives here rather than on {@code CloudFormationResourceProvisioner} so extracted
* provisioners in this package can set it.
*/
public static final String UPDATE_ROLLBACK_RESTORED_ATTR = "__FlociUpdateRollbackRestored";

private CfnRollback() {
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
package io.github.hectorvent.floci.services.cloudformation.provisioners;

import com.fasterxml.jackson.databind.JsonNode;
import io.github.hectorvent.floci.core.common.AwsArnUtils;
import io.github.hectorvent.floci.services.cloudformation.model.StackResource;
import io.github.hectorvent.floci.services.cloudwatch.logs.CloudWatchLogsService;
import jakarta.enterprise.context.ApplicationScoped;

import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;

/** Provisions {@code AWS::Logs::LogGroup}. */
@ApplicationScoped
public class LogsCfnProvisioner implements CfnResourceProvisioner {

/**
* Records whether the log group's name came from the template or was generated, so a later
* update can tell a rename from a no-op. Read back off the resource's stored attributes.
*/
private static final String LOG_GROUP_NAME_MODE_ATTR = "FlociLogGroupNameMode";
private static final String NAME_MODE_EXPLICIT = "explicit";
private static final String NAME_MODE_GENERATED = "generated";
private static final int LOG_GROUP_NAME_MAX_LENGTH = 512;
private static final int GENERATED_NAME_SUFFIX_LENGTH = 12;

private final CloudWatchLogsService logsService;

public LogsCfnProvisioner(CloudWatchLogsService logsService) {
this.logsService = logsService;
}

@Override
public Set<String> resourceTypes() {
return Set.of("AWS::Logs::LogGroup");
}

@Override
public void provision(StackResource r, JsonNode props, ProvisionContext ctx) {
String explicitName = ctx.resolveOptional(props, "LogGroupName");
boolean hasExplicitName = explicitName != null && !explicitName.isBlank();
String priorPhysicalId = ctx.priorPhysicalId();

String previousNameMode = r.getAttributes().get(LOG_GROUP_NAME_MODE_ATTR);
if (previousNameMode == null && priorPhysicalId != null) {
// Stacks persisted before FlociLogGroupNameMode existed have no recorded mode, but an
// auto-generated name always has the deterministic shape generatePhysicalName produces,
// so anything else must have been explicit.
previousNameMode = isGeneratedName(priorPhysicalId, ctx.stackName(), r.getLogicalId(),
LOG_GROUP_NAME_MAX_LENGTH) ? NAME_MODE_GENERATED : NAME_MODE_EXPLICIT;
}
// Going from an explicit name to none is itself a replacement-worthy change on real AWS, not
// something to silently keep reconciling under the old explicit name.
boolean explicitNameRemoved = priorPhysicalId != null && !hasExplicitName
&& NAME_MODE_EXPLICIT.equals(previousNameMode);

String name;
if (hasExplicitName) {
name = explicitName;
} else if (priorPhysicalId != null && !explicitNameRemoved) {
// No explicit name and the prior name was itself auto-generated: keep it across updates
// instead of generating a fresh random one each time, so the log group is reconciled in
// place rather than replaced on every no-op update.
name = priorPhysicalId;
} else {
name = ctx.generatePhysicalName(r.getLogicalId(), LOG_GROUP_NAME_MAX_LENGTH, false);
}

Integer retentionInDays = null;
String retention = ctx.resolveOptional(props, "RetentionInDays");
if (retention != null && !retention.isBlank()) {
try {
retentionInDays = Integer.valueOf(retention.trim());
} catch (NumberFormatException ignored) {
// leave unset
}
}
Map<String, String> tags = new HashMap<>();
if (props != null && props.has("Tags") && props.get("Tags").isArray()) {
for (JsonNode tag : props.get("Tags")) {
String key = ctx.engine().resolve(tag.path("Key"));
if (!key.isEmpty()) {
tags.put(key, ctx.engine().resolve(tag.path("Value")));
}
}
}

// LogGroupName isn't updatable in place on real AWS (a change replaces the resource), so only
// reconcile in place when the name is unchanged and the group is still there; otherwise this is
// either a first create or a rename, both of which need a fresh createLogGroup call. On a rename,
// create the new group before deleting the old one: if the new name collides with something else
// and createLogGroup throws, the update rolls back without touching the old group, since rollback
// does not restore a resource this method already deleted.
if (priorPhysicalId != null && priorPhysicalId.equals(name)
&& logsService.logGroupExists(name, ctx.region())) {
reconcileLogGroup(name, retentionInDays, tags, ctx.region());
} else {
boolean preservedPriorGroup = priorPhysicalId != null
&& !priorPhysicalId.equals(name)
&& logsService.logGroupExists(priorPhysicalId, ctx.region());
try {
logsService.createLogGroup(name, retentionInDays, tags, ctx.region());
} catch (RuntimeException failure) {
if (preservedPriorGroup) {
r.getAttributes().put(CfnRollback.UPDATE_ROLLBACK_RESTORED_ATTR, "true");
}
throw failure;
}
if (preservedPriorGroup) {
logsService.deleteLogGroup(priorPhysicalId, ctx.region());
}
}

// Ref returns the log group name; GetAtt Arn is arn:aws:logs:<region>:<account>:log-group:<name>:*
r.setPhysicalId(name);
r.getAttributes().put("Arn", AwsArnUtils.Arn
.of("logs", ctx.region(), ctx.accountId(), "log-group:" + name + ":*").toString());
r.getAttributes().put(LOG_GROUP_NAME_MODE_ATTR,
hasExplicitName ? NAME_MODE_EXPLICIT : NAME_MODE_GENERATED);
}

private void reconcileLogGroup(String name, Integer retentionInDays, Map<String, String> tags,
String region) {
if (retentionInDays != null) {
logsService.putRetentionPolicy(name, retentionInDays, region);
} else {
logsService.deleteRetentionPolicy(name, region);
}
Map<String, String> existingTags = logsService.listTagsLogGroup(name, region);
List<String> tagsToRemove = existingTags.keySet().stream()
.filter(key -> !tags.containsKey(key))
.toList();
if (!tagsToRemove.isEmpty()) {
logsService.untagLogGroup(name, tagsToRemove, region);
}
if (!tags.isEmpty()) {
logsService.tagLogGroup(name, tags, region);
}
}

/**
* Whether a physical id has the exact shape {@code generatePhysicalName} produces. Copied from
* the monolith, which still needs it for a type that has not migrated yet.
*/
private boolean isGeneratedName(String physicalId, String stackName, String logicalId, int maxLength) {
if (physicalId == null || physicalId.length() < GENERATED_NAME_SUFFIX_LENGTH + 1) {
return false;
}
String suffix = physicalId.substring(physicalId.length() - GENERATED_NAME_SUFFIX_LENGTH);
for (int i = 0; i < suffix.length(); i++) {
char c = suffix.charAt(i);
boolean hex = (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f');
if (!hex) {
return false;
}
}
if (physicalId.charAt(physicalId.length() - GENERATED_NAME_SUFFIX_LENGTH - 1) != '-') {
return false;
}
String actualPrefix = physicalId.substring(0, physicalId.length() - GENERATED_NAME_SUFFIX_LENGTH - 1);
return actualPrefix.equals(expectedGeneratedNamePrefix(stackName, logicalId, maxLength));
}

private String expectedGeneratedNamePrefix(String stackName, String logicalId, int maxLength) {
String base = stackName + "-" + logicalId;
if (maxLength <= 0 || base.length() + 1 + GENERATED_NAME_SUFFIX_LENGTH <= maxLength) {
return base;
}
int keep = Math.max(0, maxLength - GENERATED_NAME_SUFFIX_LENGTH - 1);
String prefix = base.length() > keep ? base.substring(0, keep) : base;
while (prefix.endsWith("-")) {
prefix = prefix.substring(0, prefix.length() - 1);
}
return prefix;
}

@Override
public void delete(String resourceType, String physicalId, String region) {
logsService.deleteLogGroup(physicalId, region);
}
}
Loading
Loading