diff --git a/README.md b/README.md
index b61c410..b93e969 100644
--- a/README.md
+++ b/README.md
@@ -203,7 +203,7 @@ Run AWS-compatible services locally without an AWS account, auth token, or paid
Real Docker where fidelity matters
-Lambda, RDS, Neptune, ElastiCache, MSK, ECS, EC2, EKS, OpenSearch, CodeBuild, and Managed Service for Apache Flink use real Docker-backed execution instead of shallow mocks.
+Lambda, RDS, Neptune, ElastiCache, MSK, MSK Connect, ECS, EC2, EKS, OpenSearch, CodeBuild, and Managed Service for Apache Flink use real Docker-backed execution instead of shallow mocks.
@@ -244,7 +244,7 @@ LocalStack's community edition [sunset in March 2026](https://blog.localstack.cl
| License | MIT | Restricted |
| API Gateway v2 / HTTP API | Yes | No |
| Cognito | Yes | No |
-| RDS, ElastiCache, MSK | Real Docker | No |
+| RDS, ElastiCache, MSK, MSK Connect | Real Docker | No |
| Neptune (graph DB + Gremlin WebSocket) | Real Docker | No |
| DocumentDB (MongoDB-compatible) | Real Docker | No |
| ECS, EC2, EKS | Real Docker | No |
@@ -298,7 +298,7 @@ Floci supports local emulation for application services, data services, eventing
| Containers and compute | ECS, EC2, Lightsail, EKS, MWAA, ECR, EFS, CodeBuild, CodeDeploy, CodePipeline, CodeGuru Reviewer, CodeArtifact, AWS Batch, Auto Scaling, Application Auto Scaling, Elastic Beanstalk, ELB v2, ELB Classic |
| Data, analytics, and AI | Athena, Glue, Lake Formation, EMR, EMR Serverless, Redshift, Redshift Data API, Firehose, Managed Service for Apache Flink, OpenSearch, S3 Tables, S3 Vectors, Textract, Transcribe, Comprehend, Rekognition, Translate, Bedrock, Bedrock Runtime, Bedrock AgentCore, Bedrock AgentCore Control, SageMaker |
| Databases and caching | RDS, RDS Data API, Neptune, DocumentDB, DMS, MemoryDB, ElastiCache, Timestream for InfluxDB |
-| Messaging and transfer | SES, Kinesis, MSK, Amazon MQ, Transfer Family, DataSync, IoT Core, Amazon Connect, Amazon AppIntegrations |
+| Messaging and transfer | SES, Kinesis, MSK, MSK Connect, Amazon MQ, Transfer Family, DataSync, IoT Core, Amazon Connect, Amazon AppIntegrations |
| Security and governance | AWS Network Firewall, AWS RAM, Service Quotas, WAF v2, GuardDuty, Amazon Inspector, CloudTrail, CloudFront, Resource Groups Tagging API, Resource Explorer 2, CloudHSM v2, Organizations, AWS Account Management, IAM Access Analyzer, IAM Identity Center (SSO Admin, OIDC, Access Portal, SCIM), Identity Store, Amazon Macie, Amazon Detective, Security Hub, Amazon Verified Permissions, Control Catalog, Control Tower, Service Catalog, AWS Marketplace |
| Cost and billing | AWS Budgets, Pricing, Cost Explorer, Cost and Usage Reports, BCM Pricing Calculator, BCM Data Exports |
| Resilience, backup, and config | AWS FIS, AWS Backup, AWS Config, AppConfig, AppConfigData, CloudFormation, Cloud Control API |
@@ -342,6 +342,7 @@ For operation-level compatibility, see the [Services Overview](https://floci.io/
| Neptune | Real Docker | Graph DB via TinkerPop Gremlin Server (default) or Neo4j for openCypher/Bolt (`NEPTUNE_DB_TYPE`); RDS-shaped control plane; SigV4 proxy on port 8182 |
| DocumentDB | Real Docker, mock mode available | MongoDB-compatible cluster via real MongoDB containers; RDS-shaped control plane; MongoDB wire protocol on port 27017 |
| MSK | Real Docker | Kafka-compatible broker via Redpanda |
+| MSK Connect | Real Docker | Stock Kafka Connect workers with S3 custom plugin ZIPs |
| Amazon MQ | Real Docker | RabbitMQ broker via rabbitmq:3-management; AMQP + management console |
| Athena | In-process with DuckDB sidecar | Real SQL execution over S3 and Glue-backed views |
| Glue | In-process | Data Catalog, Schema Registry, tables consumed by Athena |
diff --git a/docker/ec2/ami-images/debian-13/Dockerfile b/docker/ec2/ami-images/debian-13/Dockerfile
new file mode 100644
index 0000000..56e1d68
--- /dev/null
+++ b/docker/ec2/ami-images/debian-13/Dockerfile
@@ -0,0 +1,19 @@
+FROM docker.io/library/debian:trixie-slim
+
+RUN apt-get update \
+ && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
+ systemd \
+ systemd-sysv \
+ openssh-client \
+ openssh-server \
+ iproute2 \
+ iptables \
+ socat \
+ curl \
+ ca-certificates \
+ && systemctl enable ssh \
+ && rm -rf /var/lib/apt/lists/*
+
+ENV container=docker
+STOPSIGNAL SIGRTMIN+3
+CMD ["/sbin/init"]
diff --git a/docker/ec2/ami-images/debian-13/README.md b/docker/ec2/ami-images/debian-13/README.md
new file mode 100644
index 0000000..c43758d
--- /dev/null
+++ b/docker/ec2/ami-images/debian-13/README.md
@@ -0,0 +1,10 @@
+# Debian 13 EC2 guest
+
+Build the local guest image before launching `ami-debian13`:
+
+```text
+docker build --platform linux/amd64 -t floci/ami-debian:13 docker/ec2/ami-images/debian-13
+```
+
+The image runs systemd and starts SSH on every EC2 boot. Enable application services with
+`systemctl enable --now ` so they restart after `StopInstances` and `StartInstances`.
diff --git a/docker/ec2/ami-images/fedora-42/Dockerfile b/docker/ec2/ami-images/fedora-42/Dockerfile
new file mode 100644
index 0000000..8939b2f
--- /dev/null
+++ b/docker/ec2/ami-images/fedora-42/Dockerfile
@@ -0,0 +1,17 @@
+FROM registry.fedoraproject.org/fedora:42
+
+RUN dnf install -y \
+ systemd \
+ openssh-clients \
+ openssh-server \
+ iproute \
+ iptables \
+ socat \
+ curl \
+ ca-certificates \
+ && dnf clean all \
+ && systemctl enable sshd
+
+ENV container=docker
+STOPSIGNAL SIGRTMIN+3
+CMD ["/sbin/init"]
diff --git a/docker/ec2/ami-images/fedora-42/README.md b/docker/ec2/ami-images/fedora-42/README.md
new file mode 100644
index 0000000..a8dd252
--- /dev/null
+++ b/docker/ec2/ami-images/fedora-42/README.md
@@ -0,0 +1,10 @@
+# Fedora 42 systemd EC2 guest
+
+Build the local guest image before launching `ami-fedora42`:
+
+```text
+docker build --platform linux/amd64 -t floci/ami-fedora:42 docker/ec2/ami-images/fedora-42
+```
+
+The image runs systemd and starts SSH on every EC2 boot. Enable application services with
+`systemctl enable --now ` so they restart after `StopInstances` and `StartInstances`.
diff --git a/docker/ec2/ami-images/opensuse-leap-15.6/Dockerfile b/docker/ec2/ami-images/opensuse-leap-15.6/Dockerfile
new file mode 100644
index 0000000..4fe663e
--- /dev/null
+++ b/docker/ec2/ami-images/opensuse-leap-15.6/Dockerfile
@@ -0,0 +1,4 @@
+FROM registry.opensuse.org/opensuse/leap:15.6
+
+RUN zypper --non-interactive install -y openssh iproute2 iptables socat curl ca-certificates \
+ && zypper clean --all
diff --git a/docker/ec2/ami-images/opensuse-leap-15.6/README.md b/docker/ec2/ami-images/opensuse-leap-15.6/README.md
new file mode 100644
index 0000000..d8627eb
--- /dev/null
+++ b/docker/ec2/ami-images/opensuse-leap-15.6/README.md
@@ -0,0 +1,9 @@
+# openSUSE Leap 15.6 EC2 guest
+
+Build the local guest image before launching `ami-opensuse-leap156`:
+
+```text
+docker build --platform linux/amd64 -t floci/ami-opensuse:15.6 docker/ec2/ami-images/opensuse-leap-15.6
+```
+
+The image includes OpenSSH and the EC2 metadata proxy dependencies.
diff --git a/docker/ec2/ami-images/ubuntu-20.04/Dockerfile b/docker/ec2/ami-images/ubuntu-20.04/Dockerfile
new file mode 100644
index 0000000..7efff9f
--- /dev/null
+++ b/docker/ec2/ami-images/ubuntu-20.04/Dockerfile
@@ -0,0 +1,19 @@
+FROM public.ecr.aws/docker/library/ubuntu:20.04
+
+RUN apt-get update \
+ && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
+ systemd \
+ systemd-sysv \
+ openssh-client \
+ openssh-server \
+ iproute2 \
+ iptables \
+ socat \
+ curl \
+ ca-certificates \
+ && systemctl enable ssh \
+ && rm -rf /var/lib/apt/lists/*
+
+ENV container=docker
+STOPSIGNAL SIGRTMIN+3
+CMD ["/sbin/init"]
diff --git a/docker/ec2/ami-images/ubuntu-20.04/README.md b/docker/ec2/ami-images/ubuntu-20.04/README.md
new file mode 100644
index 0000000..4e96977
--- /dev/null
+++ b/docker/ec2/ami-images/ubuntu-20.04/README.md
@@ -0,0 +1,10 @@
+# Ubuntu 20.04 systemd EC2 guest
+
+Build the local guest image before launching `ami-0abcdef1234567892`:
+
+```text
+docker build --platform linux/amd64 -t floci/ami-ubuntu:20.04 docker/ec2/ami-images/ubuntu-20.04
+```
+
+The image runs systemd and starts SSH on every EC2 boot. Enable application services with
+`systemctl enable --now ` so they restart after `StopInstances` and `StartInstances`.
diff --git a/docs/services/ec2.md b/docs/services/ec2.md
index a681504..6c68f8d 100644
--- a/docs/services/ec2.md
+++ b/docs/services/ec2.md
@@ -26,14 +26,16 @@ metadata.
| AMI ID | Aliases | Docker image |
|---|---|---|
| `ami-0abcdef1234567890` | `ami-amazonlinux2` | `public.ecr.aws/amazonlinux/amazonlinux:2` |
-| `ami-0abcdef1234567891` | `ami-amazonlinux2023` | `public.ecr.aws/amazonlinux/amazonlinux:2023` |
-| `ami-0abcdef1234567892` | `ami-ubuntu2004` | `public.ecr.aws/docker/library/ubuntu:20.04` |
+| `ami-0abcdef1234567891` | `ami-amazonlinux2023` | `floci/ami-amazonlinux:2023` |
+| `ami-0abcdef1234567892` | `ami-ubuntu2004` | `floci/ami-ubuntu:20.04` |
| `ami-ubuntu2204` | | `public.ecr.aws/docker/library/ubuntu:22.04` |
| `ami-ubuntu2404-arm64` | `ami-ubuntu2404` | `public.ecr.aws/docker/library/ubuntu:24.04` |
| `ami-ubuntu2404-amd64` | | `public.ecr.aws/docker/library/ubuntu:24.04` |
| `ami-ubuntu2404-cloud-arm64` | `ami-ubuntu2404-cloud` | `floci/ami-ubuntu:24.04-arm64` |
| `ami-debian12` | | `public.ecr.aws/docker/library/debian:12` |
+| `ami-debian13` | | `floci/ami-debian:13` |
| `ami-alpine` | | `public.ecr.aws/docker/library/alpine:latest` |
+| `ami-fedora42` | | `floci/ami-fedora:42` |
| `ami-0abcdef1234567893` | | `public.ecr.aws/amazonlinux/amazonlinux:2023` |
Any unrecognized AMI ID (including real AWS AMI IDs like `ami-0abc12345678`) falls back to the catalog `defaultDockerImage` (`public.ecr.aws/amazonlinux/amazonlinux:2023` by default).
diff --git a/docs/services/index.md b/docs/services/index.md
index 2f5a48c..9695123 100644
--- a/docs/services/index.md
+++ b/docs/services/index.md
@@ -65,6 +65,7 @@ Operation counts are exact. For dispatch-table services (Query and JSON 1.1) eac
| [RDS Data API](rds-data.md) | `/Execute`, `/BeginTransaction`, `/CommitTransaction`, `/RollbackTransaction` | REST JSON | 4 |
| [Timestream for InfluxDB](timestream-influxdb.md) | `POST /` + `X-Amz-Target: AmazonTimestreamInfluxDB.*` + InfluxDB container | JSON 1.0 + InfluxDB HTTP | 24 |
| [MSK](msk.md) | `/v1/clusters/...`, `/api/v2/clusters/...` + Redpanda broker | REST JSON + Kafka | 8 |
+| [MSK Connect](kafka-connect.md) | `/v1/custom-plugins`, `/v1/connectors`, `/v1/worker-configurations` + Kafka Connect worker | REST JSON + Kafka Connect | 13 |
| [Amazon MQ](amazonmq.md) | `/v1/brokers/...` + RabbitMQ broker | REST JSON + AMQP | 5 |
| [MWAA](mwaa.md) | `/` REST paths for environments + CLI/web proxy | REST JSON | 10 |
| [Athena](athena.md) | `POST /` + `X-Amz-Target: AmazonAthena.*` | JSON 1.1 | 4 |
diff --git a/docs/services/kafka-connect.md b/docs/services/kafka-connect.md
new file mode 100644
index 0000000..7da8129
--- /dev/null
+++ b/docs/services/kafka-connect.md
@@ -0,0 +1,45 @@
+# MSK Connect
+
+**Protocol:** REST JSON
+**Signing name:** `kafkaconnect`
+
+Floci implements the MSK Connect control plane separately from MSK. Custom plugins are ZIP
+archives stored in Floci S3. A connector starts a pinned stock Apache Kafka Connect worker, extracts
+the referenced ZIP, and mounts only that extracted plugin directory into the worker.
+
+## Supported Actions
+
+| Action | Description |
+|---|---|
+| `CreateCustomPlugin` | Register a ZIP custom plugin from an S3 bucket ARN and key |
+| `ListCustomPlugins` | List custom plugins |
+| `DescribeCustomPlugin` | Describe a custom plugin |
+| `DeleteCustomPlugin` | Delete a custom plugin |
+| `CreateWorkerConfiguration` | Create a worker properties configuration |
+| `ListWorkerConfigurations` | List worker configurations |
+| `DescribeWorkerConfiguration` | Describe a worker configuration |
+| `DeleteWorkerConfiguration` | Delete a worker configuration |
+| `CreateConnector` | Start a connector using a custom plugin |
+| `ListConnectors` | List connectors |
+| `DescribeConnector` | Describe a connector |
+| `UpdateConnector` | Update connector configuration |
+| `DeleteConnector` | Stop and delete a connector |
+
+## Worker behavior
+
+Workers use the internal Redpanda address of a Floci-managed MSK cluster. The public bootstrap
+address supplied to `CreateConnector` is resolved to that cluster but is never passed into the
+container. This keeps the worker on Floci's Docker network while host clients continue using the
+public MSK bootstrap endpoint.
+
+No connector plugin is bundled. To run an S3 sink, upload a compatible S3 sink connector ZIP to
+Floci S3, create a custom plugin for it, and use that plugin in `CreateConnector`. The worker's
+container logs are streamed to CloudWatch Logs under `/aws/msk-connect/{connectorName}`.
+
+## Configuration
+
+| Variable | Default | Description |
+|---|---|---|
+| `FLOCI_SERVICES_KAFKACONNECT_ENABLED` | `true` | Enable or disable MSK Connect |
+| `FLOCI_SERVICES_KAFKACONNECT_MOCK` | `false` | Persist control-plane metadata without starting workers |
+| `FLOCI_SERVICES_KAFKACONNECT_WORKER_IMAGE` | `apache/kafka:3.9.0` | Pinned stock Kafka Connect worker image |
diff --git a/docs/services/kinesis.md b/docs/services/kinesis.md
index e506602..c3ad8f8 100644
--- a/docs/services/kinesis.md
+++ b/docs/services/kinesis.md
@@ -91,6 +91,15 @@ TPS over 10 will trigger the LimitExceededException"), independent of the ten-ca
limit above. Floci does not simulate real-time request throttling for this or any other
action, so that limit is intentionally unenforced here.
+## Current Limitations
+
+- `ListStreams`, `ListShards`, `ListTagsForStream`, and `ListStreamConsumers` do not yet
+ support AWS pagination tokens. `ListShards` honors `MaxResults`, but does not return a
+ continuation token.
+- Resource policy actions are not implemented.
+- KMS encryption settings are recorded in stream metadata, but record payloads are not
+ cryptographically encrypted.
+
## Configuration
| Variable | Default | Description |
diff --git a/mkdocs.yml b/mkdocs.yml
index 74062f3..9d67a16 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -142,6 +142,7 @@ nav:
- RDS Data API: services/rds-data.md
- Timestream for InfluxDB: services/timestream-influxdb.md
- MSK (Kafka): services/msk.md
+ - MSK Connect: services/kafka-connect.md
- Amazon MQ: services/amazonmq.md
- Glue: services/glue.md
- Lake Formation: services/lakeformation.md
diff --git a/src/main/java/io/github/hectorvent/floci/config/EmulatorConfig.java b/src/main/java/io/github/hectorvent/floci/config/EmulatorConfig.java
index 4d96d8f..b5e75f1 100644
--- a/src/main/java/io/github/hectorvent/floci/config/EmulatorConfig.java
+++ b/src/main/java/io/github/hectorvent/floci/config/EmulatorConfig.java
@@ -302,6 +302,7 @@ public interface EmulatorConfig {
interface ServiceStorageOverrides {
SsmStorageConfig ssm();
+ KafkaConnectStorageConfig kafkaconnect();
SqsStorageConfig sqs();
S3StorageConfig s3();
DynamoDbStorageConfig dynamodb();
@@ -365,6 +366,13 @@ public interface EmulatorConfig {
long flushIntervalMs();
}
+ interface KafkaConnectStorageConfig {
+ Optional mode();
+
+ @WithDefault("5000")
+ long flushIntervalMs();
+ }
+
interface SqsStorageConfig {
Optional mode();
}
@@ -673,6 +681,7 @@ public interface EmulatorConfig {
ApiGatewayServiceConfig apigateway();
IamServiceConfig iam();
MskServiceConfig msk();
+ KafkaConnectServiceConfig kafkaconnect();
AmazonMqServiceConfig amazonmq();
KinesisAnalyticsServiceConfig kinesisAnalytics();
ElastiCacheServiceConfig elasticache();
@@ -1345,6 +1354,19 @@ public interface EmulatorConfig {
int kafkaHostPortMax();
}
+ interface KafkaConnectServiceConfig {
+ @WithDefault("true")
+ boolean enabled();
+
+ /** Metadata-only mode is useful where Docker is intentionally unavailable. */
+ @WithDefault("false")
+ boolean mock();
+
+ /** Stock Apache Kafka distribution, never a connector-plugin image. */
+ @WithDefault("apache/kafka:3.9.0")
+ String workerImage();
+ }
+
interface AmazonMqServiceConfig {
@WithDefault("true")
boolean enabled();
diff --git a/src/main/java/io/github/hectorvent/floci/core/common/ResolvedServiceCatalog.java b/src/main/java/io/github/hectorvent/floci/core/common/ResolvedServiceCatalog.java
index 5098370..8e37171 100644
--- a/src/main/java/io/github/hectorvent/floci/core/common/ResolvedServiceCatalog.java
+++ b/src/main/java/io/github/hectorvent/floci/core/common/ResolvedServiceCatalog.java
@@ -146,6 +146,12 @@ public class ResolvedServiceCatalog {
"msk", config.storage().mode(), 5000L, null, ServiceProtocol.REST_JSON,
protocols(ServiceProtocol.REST_JSON),
Set.of(), Set.of("kafka"), Set.of(), Set.of(io.github.hectorvent.floci.services.msk.MskController.class)),
+ descriptor("kafkaconnect", "kafkaconnect", config.services().kafkaconnect().enabled(), true,
+ "kafkaconnect", storageMode(config.storage().services().kafkaconnect().mode(), config.storage().mode()),
+ config.storage().services().kafkaconnect().flushIntervalMs(), null, ServiceProtocol.REST_JSON,
+ protocols(ServiceProtocol.REST_JSON),
+ Set.of(), Set.of("kafkaconnect"), Set.of(),
+ Set.of(io.github.hectorvent.floci.services.kafkaconnect.KafkaConnectController.class)),
descriptor("mq", "amazonmq", config.services().amazonmq().enabled(), true,
"amazonmq", config.storage().mode(), 5000L, null, ServiceProtocol.REST_JSON,
protocols(ServiceProtocol.REST_JSON),
diff --git a/src/main/java/io/github/hectorvent/floci/core/common/docker/ContainerLifecycleManager.java b/src/main/java/io/github/hectorvent/floci/core/common/docker/ContainerLifecycleManager.java
index 2285080..4699bc7 100644
--- a/src/main/java/io/github/hectorvent/floci/core/common/docker/ContainerLifecycleManager.java
+++ b/src/main/java/io/github/hectorvent/floci/core/common/docker/ContainerLifecycleManager.java
@@ -110,6 +110,10 @@ public class ContainerLifecycleManager {
}
}
+ public String refreshImage(String image) {
+ return imageCacheService.refreshImage(image);
+ }
+
/**
* Creates a container without starting it. Use {@link #startCreated} to
* start it after any pre-start setup (e.g. copying files into the
diff --git a/src/main/java/io/github/hectorvent/floci/lifecycle/EmulatorInfoController.java b/src/main/java/io/github/hectorvent/floci/lifecycle/EmulatorInfoController.java
index b8b4d63..3d25c79 100644
--- a/src/main/java/io/github/hectorvent/floci/lifecycle/EmulatorInfoController.java
+++ b/src/main/java/io/github/hectorvent/floci/lifecycle/EmulatorInfoController.java
@@ -6,6 +6,7 @@ import io.github.hectorvent.floci.config.FlociCertificateAuthority;
import io.github.hectorvent.floci.core.common.ContainerTeardown;
import io.github.hectorvent.floci.core.common.ContainerTeardowns;
import io.github.hectorvent.floci.core.common.ServiceRegistry;
+import io.github.hectorvent.floci.services.ec2.Ec2Service;
import io.github.hectorvent.floci.lifecycle.inithook.InitializationHook;
import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
@@ -44,15 +45,17 @@ public class EmulatorInfoController {
private final Instance containerTeardowns;
private final FlociCertificateAuthority certificateAuthority;
private final EmulatorConfig config;
+ private final Ec2Service ec2Service;
@Inject
public EmulatorInfoController(ServiceRegistry serviceRegistry,
InitLifecycleState initLifecycleState,
StorageFactory storageFactory,
Instance resettables,
- Instance containerTeardowns,
- FlociCertificateAuthority certificateAuthority,
- EmulatorConfig config) {
+ Instance containerTeardowns,
+ FlociCertificateAuthority certificateAuthority,
+ EmulatorConfig config,
+ Ec2Service ec2Service) {
this.serviceRegistry = serviceRegistry;
this.initLifecycleState = initLifecycleState;
this.storageFactory = storageFactory;
@@ -60,6 +63,7 @@ public class EmulatorInfoController {
this.containerTeardowns = containerTeardowns;
this.certificateAuthority = certificateAuthority;
this.config = config;
+ this.ec2Service = ec2Service;
this.version = resolveVersion();
}
@@ -104,7 +108,7 @@ public class EmulatorInfoController {
@GET
@Path("/diagnose")
public Response diagnose() {
- return Response.ok(Map.of()).build();
+ return Response.ok(Map.of("ec2", Map.of("instances", ec2Service.diagnosticInstances()))).build();
}
@GET
diff --git a/src/main/java/io/github/hectorvent/floci/services/codedeploy/CodeDeployService.java b/src/main/java/io/github/hectorvent/floci/services/codedeploy/CodeDeployService.java
index 267aaad..552f0bb 100644
--- a/src/main/java/io/github/hectorvent/floci/services/codedeploy/CodeDeployService.java
+++ b/src/main/java/io/github/hectorvent/floci/services/codedeploy/CodeDeployService.java
@@ -19,8 +19,11 @@ import io.github.hectorvent.floci.services.ecs.EcsService;
import io.github.hectorvent.floci.services.ecs.model.CreateTaskSetRequest;
import io.github.hectorvent.floci.services.ecs.model.EcsLoadBalancer;
import io.github.hectorvent.floci.services.ecs.model.EcsServiceModel;
+import io.github.hectorvent.floci.services.ecs.model.LaunchType;
import io.github.hectorvent.floci.services.ecs.model.TaskSet;
import io.github.hectorvent.floci.services.elbv2.ElbV2Service;
+import io.github.hectorvent.floci.services.elbv2.model.Action;
+import io.github.hectorvent.floci.services.elbv2.model.Rule;
import io.github.hectorvent.floci.services.elbv2.model.TargetGroup;
import io.github.hectorvent.floci.services.elbv2.model.TargetHealth;
import io.github.hectorvent.floci.services.lambda.LambdaService;
@@ -63,6 +66,8 @@ public class CodeDeployService {
// Lambda/ECS validation hooks must call PutLifecycleEventHookExecutionStatus within one hour.
private static final Duration DEFAULT_HOOK_CALLBACK_TIMEOUT = Duration.ofHours(1);
private static final Duration STOP_POLL_INTERVAL = Duration.ofMillis(200);
+ private static final int ECS_READINESS_MAX_POLLS = 150;
+ private static final int ECS_TARGET_HEALTH_MAX_POLLS = 900;
@FunctionalInterface
interface Sleeper {
@@ -1543,6 +1548,12 @@ public class CodeDeployService {
createTaskSet.setLoadBalancers(List.of(loadBalancer));
}
greenTaskSet = ecsService.createTaskSet(createTaskSet, region);
+ verifyGreenTaskSet(region, clusterName, serviceName, deploymentId, targetService, greenTaskSet,
+ greenTgArn);
+ if (greenTaskSet.getLaunchType() == LaunchType.FARGATE) {
+ greenTaskSet = waitForGreenTaskSetRunning(region, clusterName, serviceName, greenTaskSet,
+ stopFlag);
+ }
appendTaskSetInfo(ecsTargetMap, greenTaskSet, greenTgArn, 0.0);
finishLifecycleEvent(installEvent, "Succeeded");
} catch (Exception e) {
@@ -1585,9 +1596,9 @@ public class CodeDeployService {
if (stopFlag.get()) { finishEcsStopped(deployment, ecsTargetMap); return; }
executeEcsAllowTraffic(region, deployment, configName, ecsTargetMap,
listenerArns, blueTgArn, greenTgArn, stopFlag);
+ verifyListenersForwardToGreen(region, listenerArns, greenTgArn);
} else {
- Map allowEvent = addLifecycleEvent(ecsTargetMap, "AllowTraffic");
- finishLifecycleEvent(allowEvent, "Succeeded");
+ throw new IllegalStateException("ECS deployment group must have production listeners and blue/green target groups");
}
if (stopFlag.get()) { finishEcsStopped(deployment, ecsTargetMap); return; }
@@ -1615,10 +1626,11 @@ public class CodeDeployService {
try {
ecsService.deleteTaskSet(clusterName, serviceName,
blueTaskSet.getTaskSetArn(), true, region);
+ verifyBlueTaskSetDrained(region, clusterName, serviceName, blueTaskSet.getTaskSetArn());
finishLifecycleEvent(terminateEvent, "Succeeded");
} catch (Exception e) {
- LOG.debugv("Could not delete blue task set: {0}", e.getMessage());
- finishLifecycleEvent(terminateEvent, "Succeeded");
+ finishLifecycleEvent(terminateEvent, "Failed");
+ throw e;
}
}
@@ -1637,18 +1649,120 @@ public class CodeDeployService {
}
}
- /** A real target group is switched only once every registered green task is routable. */
- private void waitForGreenTargets(String region, String greenTgArn, AtomicBoolean stopFlag)
+ void verifyGreenTaskSet(String region, String clusterName, String serviceName, String deploymentId,
+ EcsServiceModel targetService, TaskSet createdTaskSet, String greenTgArn) {
+ if (createdTaskSet == null || createdTaskSet.getTaskSetArn() == null) {
+ throw new IllegalStateException("ECS did not create a green task set");
+ }
+ List taskSets = ecsService.describeTaskSets(clusterName, serviceName,
+ List.of(createdTaskSet.getTaskSetArn()), region);
+ if (taskSets.size() != 1) {
+ throw new IllegalStateException("ECS did not retain the green task set");
+ }
+ TaskSet greenTaskSet = taskSets.getFirst();
+ if (!deploymentId.equals(greenTaskSet.getExternalId())
+ || !"CODE_DEPLOY".equals(greenTaskSet.getStartedBy())
+ || !"ACTIVE".equals(greenTaskSet.getStatus())) {
+ throw new IllegalStateException("ECS green task set is not a CodeDeploy active task set");
+ }
+ if (greenTaskSet.getLaunchType() != LaunchType.FARGATE && greenTaskSet.getLaunchType() != LaunchType.EC2) {
+ throw new IllegalStateException("ECS green task set does not use FARGATE or EC2");
+ }
+ if (targetService.getLaunchType() != null && greenTaskSet.getLaunchType() != targetService.getLaunchType()) {
+ throw new IllegalStateException("ECS green task set launch type does not match the service");
+ }
+ if (greenTaskSet.getLaunchType() == LaunchType.EC2
+ && !hasRunningTasks(greenTaskSet)) {
+ throw new IllegalStateException("ECS green task set has no running tasks");
+ }
+ if (greenTgArn == null || greenTaskSet.getLoadBalancers() == null || greenTaskSet.getLoadBalancers().stream()
+ .noneMatch(loadBalancer -> greenTgArn.equals(loadBalancer.getTargetGroupArn()))) {
+ throw new IllegalStateException("ECS green task set is not registered with the green target group");
+ }
+ }
+
+ /** Fargate task launch is asynchronous, so the initial CreateTaskSet result is not yet stable. */
+ TaskSet waitForGreenTaskSetRunning(String region, String clusterName, String serviceName,
+ TaskSet createdTaskSet, AtomicBoolean stopFlag) throws InterruptedException {
+ for (int poll = 0; poll < ECS_READINESS_MAX_POLLS && !stopFlag.get(); poll++) {
+ List taskSets = ecsService.describeTaskSets(clusterName, serviceName,
+ List.of(createdTaskSet.getTaskSetArn()), region);
+ if (taskSets.size() == 1 && hasRunningTasks(taskSets.getFirst())) {
+ return taskSets.getFirst();
+ }
+ sleeper.sleep(STOP_POLL_INTERVAL.toMillis());
+ }
+ if (!stopFlag.get()) {
+ throw new IllegalStateException("ECS green task set has no running tasks");
+ }
+ return createdTaskSet;
+ }
+
+ private boolean hasRunningTasks(TaskSet taskSet) {
+ return taskSet.getComputedDesiredCount() > 0
+ && taskSet.getRunningCount() >= taskSet.getComputedDesiredCount();
+ }
+
+ /** A production listener is switched only once green tasks are registered and routable. */
+ void waitForGreenTargets(String region, String greenTgArn, AtomicBoolean stopFlag)
throws InterruptedException {
- while (!stopFlag.get()) {
+ boolean registeredTargets = false;
+ for (int poll = 0; poll < ECS_TARGET_HEALTH_MAX_POLLS && !stopFlag.get(); poll++) {
List targets = elbV2Service.describeTargetHealth(region, greenTgArn, null);
- // Mock ECS does not start containers or register target addresses. Preserve that control-plane
- // behavior while Docker-backed deployments wait for the ELB health checker.
- if (targets.isEmpty() || targets.stream().allMatch(target -> "healthy".equals(target.getState()))) {
+ registeredTargets = !targets.isEmpty();
+ if (registeredTargets && targets.stream().allMatch(target -> "healthy".equals(target.getState()))) {
return;
}
sleeper.sleep(STOP_POLL_INTERVAL.toMillis());
}
+ if (!stopFlag.get()) {
+ throw new IllegalStateException(registeredTargets
+ ? "ECS green target group has unhealthy targets"
+ : "ECS green target group has no registered targets");
+ }
+ }
+
+ void verifyListenersForwardToGreen(String region, List listenerArns, String greenTgArn) {
+ for (String listenerArn : listenerArns) {
+ List defaultRules = elbV2Service.describeRules(region, listenerArn, null).stream()
+ .filter(Rule::isDefault)
+ .toList();
+ if (defaultRules.size() != 1 || !forwardsOnlyTo(defaultRules.getFirst().getActions(), greenTgArn)) {
+ throw new IllegalStateException("Production listener did not switch to the green target group");
+ }
+ }
+ }
+
+ private boolean forwardsOnlyTo(List actions, String greenTgArn) {
+ if (actions == null) {
+ return false;
+ }
+ List forwardActions = actions.stream()
+ .filter(action -> "forward".equals(action.getType()))
+ .toList();
+ return !forwardActions.isEmpty()
+ && forwardActions.stream().allMatch(action -> forwardsOnlyTo(action, greenTgArn));
+ }
+
+ private boolean forwardsOnlyTo(Action action, String greenTgArn) {
+ if (action.getTargetGroups() == null || action.getTargetGroups().isEmpty()) {
+ return greenTgArn.equals(action.getTargetGroupArn());
+ }
+ boolean greenReceivesTraffic = action.getTargetGroups().stream()
+ .anyMatch(target -> greenTgArn.equals(target.getTargetGroupArn()) && effectiveWeight(target) > 0);
+ boolean otherTargetReceivesTraffic = action.getTargetGroups().stream()
+ .anyMatch(target -> !greenTgArn.equals(target.getTargetGroupArn()) && effectiveWeight(target) > 0);
+ return greenReceivesTraffic && !otherTargetReceivesTraffic;
+ }
+
+ private int effectiveWeight(Action.TargetGroupTuple target) {
+ return target.getWeight() != null ? target.getWeight() : 1;
+ }
+
+ void verifyBlueTaskSetDrained(String region, String clusterName, String serviceName, String blueTaskSetArn) {
+ if (!ecsService.describeTaskSets(clusterName, serviceName, List.of(blueTaskSetArn), region).isEmpty()) {
+ throw new IllegalStateException("ECS blue task set was not drained");
+ }
}
void executeEcsAllowTraffic(String region, Deployment deployment, String configName,
diff --git a/src/main/java/io/github/hectorvent/floci/services/codepipeline/CodePipelineService.java b/src/main/java/io/github/hectorvent/floci/services/codepipeline/CodePipelineService.java
index d77f13c..b632080 100644
--- a/src/main/java/io/github/hectorvent/floci/services/codepipeline/CodePipelineService.java
+++ b/src/main/java/io/github/hectorvent/floci/services/codepipeline/CodePipelineService.java
@@ -23,6 +23,7 @@ import io.github.hectorvent.floci.services.codepipeline.model.CodePipelinePipeli
import io.github.hectorvent.floci.services.codepipeline.model.CodePipelinePipeline.TransitionState;
import io.github.hectorvent.floci.services.codepipeline.model.CodePipelineStoredItem;
import io.github.hectorvent.floci.services.ecs.EcsService;
+import io.github.hectorvent.floci.services.ecs.model.ContainerDefinition;
import io.github.hectorvent.floci.services.ecs.model.RegisterTaskDefinitionRequest;
import io.github.hectorvent.floci.services.ecs.model.TaskDefinition;
import io.github.hectorvent.floci.services.lambda.LambdaService;
@@ -1325,10 +1326,10 @@ public class CodePipelineService {
JsonNode config = action.path("configuration");
String applicationName = requiredCodeDeployToEcsConfiguration(config, "ApplicationName");
String deploymentGroupName = requiredCodeDeployToEcsConfiguration(config, "DeploymentGroupName");
- String taskDefinitionPath = requiredCodeDeployToEcsConfiguration(config, "TaskDefinitionTemplatePath");
- String appSpecPath = requiredCodeDeployToEcsConfiguration(config, "AppSpecTemplatePath");
- String taskDefinitionTemplate = readCodeDeployToEcsTemplate(execution, action, taskDefinitionPath);
- String appSpecTemplate = readCodeDeployToEcsTemplate(execution, action, appSpecPath);
+ String taskDefinitionTemplate = readCodeDeployToEcsTemplate(execution, action, config,
+ "TaskDefinitionTemplateArtifact", "TaskDefinitionTemplatePath");
+ String appSpecTemplate = readCodeDeployToEcsTemplate(execution, action, config,
+ "AppSpecTemplateArtifact", "AppSpecTemplatePath");
RegisterTaskDefinitionRequest taskDefinitionRequest;
try {
@@ -1341,6 +1342,7 @@ public class CodePipelineService {
throw new AwsException("InvalidActionDeclarationException",
"Task definition template must specify family", 400);
}
+ substituteCodeDeployToEcsImage(execution, action, config, taskDefinitionRequest);
TaskDefinition taskDefinition = ecsService.registerTaskDefinition(taskDefinitionRequest, execution.getRegion());
String appSpec = appSpecTemplate.replace("", taskDefinition.getTaskDefinitionArn());
Map revision = Map.of("revisionType", "AppSpecContent",
@@ -1351,13 +1353,25 @@ public class CodePipelineService {
waitForCodeDeployDeployment(execution, state, deploymentId);
}
- private String readCodeDeployToEcsTemplate(CodePipelineExecution execution, JsonNode action, String reference) {
- int separator = reference.indexOf("::");
- if (separator <= 0 || separator == reference.length() - 2 || reference.indexOf("::", separator + 2) >= 0) {
- throw new AwsException("InvalidActionDeclarationException",
- "CodeDeployToECS template paths must use ArtifactName::TemplateFilePath", 400);
+ private String readCodeDeployToEcsTemplate(CodePipelineExecution execution, JsonNode action, JsonNode config,
+ String artifactField, String pathField) {
+ String templatePath = requiredCodeDeployToEcsConfiguration(config, pathField);
+ int separator = templatePath.indexOf("::");
+ if (separator >= 0) {
+ if (separator == 0 || separator == templatePath.length() - 2
+ || templatePath.indexOf("::", separator + 2) >= 0) {
+ throw new AwsException("InvalidActionDeclarationException",
+ "CodeDeployToECS template paths must use ArtifactName::TemplateFilePath", 400);
+ }
+ return readCodeDeployToEcsTemplate(execution, action, templatePath.substring(0, separator),
+ templatePath.substring(separator + 2));
}
- String artifactName = reference.substring(0, separator);
+ String artifactName = requiredCodeDeployToEcsConfiguration(config, artifactField);
+ return readCodeDeployToEcsTemplate(execution, action, artifactName, templatePath);
+ }
+
+ private String readCodeDeployToEcsTemplate(CodePipelineExecution execution, JsonNode action, String artifactName,
+ String templatePath) {
boolean declaredInput = false;
for (JsonNode input : action.path("inputArtifacts")) {
if (artifactName.equals(input.path("name").asText())) {
@@ -1373,7 +1387,7 @@ public class CodePipelineService {
if (artifact == null) {
throw new AwsException("InvalidJobStateException", "Input artifact is not available: " + artifactName, 400);
}
- return readTemplateFromArtifact(artifact, reference.substring(separator + 2));
+ return readTemplateFromArtifact(artifact, templatePath);
}
private static String requiredCodeDeployToEcsConfiguration(JsonNode config, String field) {
@@ -1385,6 +1399,40 @@ public class CodePipelineService {
return value;
}
+ private void substituteCodeDeployToEcsImage(CodePipelineExecution execution, JsonNode action, JsonNode config,
+ RegisterTaskDefinitionRequest taskDefinitionRequest) {
+ String imageArtifactName = requiredCodeDeployToEcsConfiguration(config, "Image1ArtifactName");
+ String containerName = requiredCodeDeployToEcsConfiguration(config, "Image1ContainerName");
+ String imageDetail = readCodeDeployToEcsTemplate(execution, action, imageArtifactName, "imageDetail.json");
+ JsonNode imageDetailJson;
+ try {
+ imageDetailJson = mapper.readTree(imageDetail);
+ } catch (java.io.IOException exception) {
+ throw new AwsException("InvalidActionDeclarationException",
+ "imageDetail.json must contain a valid image detail JSON object", 400);
+ }
+ String imageUri = imageDetailJson.path("ImageURI").asText(null);
+ if (imageUri == null || imageUri.isBlank()) {
+ throw new AwsException("InvalidActionDeclarationException", "imageDetail.json must specify ImageURI", 400);
+ }
+ if (taskDefinitionRequest.getContainerDefinitions() == null) {
+ throw new AwsException("InvalidActionDeclarationException",
+ "Task definition template must specify containerDefinitions", 400);
+ }
+ for (ContainerDefinition container : taskDefinitionRequest.getContainerDefinitions()) {
+ if (containerName.equals(container.getName())) {
+ if (container.getImage() == null) {
+ throw new AwsException("InvalidActionDeclarationException",
+ "Image1ContainerName must identify a container with an image", 400);
+ }
+ container.setImage(container.getImage().replace("", imageUri));
+ return;
+ }
+ }
+ throw new AwsException("InvalidActionDeclarationException",
+ "Image1ContainerName does not match a task definition container", 400);
+ }
+
private void waitForCodeDeployDeployment(CodePipelineExecution execution, ActionExecution state, String deploymentId)
throws InterruptedException {
Deployment deployment = codeDeployService.getDeployment(execution.getRegion(), deploymentId);
diff --git a/src/main/java/io/github/hectorvent/floci/services/ec2/Ec2ContainerManager.java b/src/main/java/io/github/hectorvent/floci/services/ec2/Ec2ContainerManager.java
index 151ca7a..c86a122 100644
--- a/src/main/java/io/github/hectorvent/floci/services/ec2/Ec2ContainerManager.java
+++ b/src/main/java/io/github/hectorvent/floci/services/ec2/Ec2ContainerManager.java
@@ -510,12 +510,11 @@ public class Ec2ContainerManager {
if (publicKey != null && !publicKey.isBlank()) {
injectSshKey(containerId, publicKey);
}
- // sshd runs on every instance regardless of whether a key pair was supplied,
- // matching real AWS AMIs (which start it as part of normal boot, independent of
- // key-pair association) - starting it only when a key was present meant
- // run-instances without --key-name produced a "connection refused" instead of AWS's
- // actual behavior: a running daemon with simply nothing to authenticate against.
- startSshd(containerId, instanceId);
+ // Systemd-backed AMIs start their enabled SSH unit during boot. Starting a second
+ // daemon here races it for port 22; lightweight guests still need this bootstrap.
+ if (!image.systemd()) {
+ startSshd(containerId, instanceId);
+ }
// Execute UserData
String userData = instance.getUserData();
@@ -1793,6 +1792,7 @@ public class Ec2ContainerManager {
// and sshd is never installed.
" elif command -v yum >/dev/null 2>&1; then yum install -y openssh-server openssh-clients >/dev/null 2>&1;" +
" elif command -v apt-get >/dev/null 2>&1; then DEBIAN_FRONTEND=noninteractive apt-get install -y openssh-server openssh-client >/dev/null 2>&1;" +
+ " elif command -v zypper >/dev/null 2>&1; then zypper --non-interactive install -y openssh >/dev/null 2>&1;" +
" elif command -v apk >/dev/null 2>&1; then apk add --no-cache openssh >/dev/null 2>&1;" +
" fi;" +
"fi;" +
diff --git a/src/main/java/io/github/hectorvent/floci/services/ec2/Ec2MetadataProxy.java b/src/main/java/io/github/hectorvent/floci/services/ec2/Ec2MetadataProxy.java
index e141265..629ddb4 100644
--- a/src/main/java/io/github/hectorvent/floci/services/ec2/Ec2MetadataProxy.java
+++ b/src/main/java/io/github/hectorvent/floci/services/ec2/Ec2MetadataProxy.java
@@ -35,6 +35,8 @@ public final class Ec2MetadataProxy {
// leaving the instance without a link-local IMDS endpoint.
"elif command -v yum >/dev/null 2>&1; then",
" yum install -y iproute iptables socat curl ca-certificates >/dev/null",
+ "elif command -v zypper >/dev/null 2>&1; then",
+ " zypper --non-interactive install -y iproute2 iptables socat curl ca-certificates >/dev/null",
"elif command -v apk >/dev/null 2>&1; then",
" apk add --no-cache iproute2 iptables socat curl ca-certificates >/dev/null",
"else",
diff --git a/src/main/java/io/github/hectorvent/floci/services/ec2/Ec2QueryHandler.java b/src/main/java/io/github/hectorvent/floci/services/ec2/Ec2QueryHandler.java
index 7c8e068..738f81b 100644
--- a/src/main/java/io/github/hectorvent/floci/services/ec2/Ec2QueryHandler.java
+++ b/src/main/java/io/github/hectorvent/floci/services/ec2/Ec2QueryHandler.java
@@ -702,6 +702,15 @@ public class Ec2QueryHandler {
int networkInterfaceDeviceIndex = parseIntParam(p, "NetworkInterface.1.DeviceIndex", 0);
String clientToken = p.getFirst("ClientToken");
List sgIds = getList(p, "SecurityGroupId");
+ if (sgIds.isEmpty()) {
+ sgIds = getList(p, "NetworkInterface.1.SecurityGroupId");
+ }
+ if (sgIds.isEmpty()) {
+ sgIds = getList(p, "NetworkInterface.1.Groups");
+ }
+ if (sgIds.isEmpty()) {
+ sgIds = getList(p, "NetworkInterface.1.GroupId");
+ }
// UserData is base64-encoded in the wire format
String userDataEncoded = p.getFirst("UserData");
diff --git a/src/main/java/io/github/hectorvent/floci/services/ec2/Ec2Service.java b/src/main/java/io/github/hectorvent/floci/services/ec2/Ec2Service.java
index 7036585..8d64283 100644
--- a/src/main/java/io/github/hectorvent/floci/services/ec2/Ec2Service.java
+++ b/src/main/java/io/github/hectorvent/floci/services/ec2/Ec2Service.java
@@ -5005,6 +5005,21 @@ public class Ec2Service implements ContainerTeardown, ResourceProvider {
return containerManager.isContainerRunning(instance.getDockerContainerId());
}
+ public List