Skip to content
Merged
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
5 changes: 5 additions & 0 deletions docs/services/ecs.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,11 @@ the agent's reason, `Unable to download firelens s3 config file: unable to downl
<key> from bucket <bucket>: <detail>`, instead of leaking a started router. Shared network
namespaces (AppConfig agent on `127.0.0.1:2772`) are not implemented.

Container `volumesFrom` entries are also stored and returned. In Docker mode, source containers
are launched before their consumers and their declared volumes are inherited with the requested
read-only or read-write access mode. Startup ordering also respects FireLens router dependencies;
cycles involving both volume inheritance and log routing are rejected before containers start.

### Tasks

| Operation | Description |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import com.github.dockerjava.api.model.Mount;
import com.github.dockerjava.api.model.MountType;
import com.github.dockerjava.api.model.Volume;
import com.github.dockerjava.api.model.VolumesFrom;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;

Expand Down Expand Up @@ -132,6 +133,7 @@ public static class Builder {
private String networkMode;
private final List<Mount> mounts = new ArrayList<>();
private final List<Bind> binds = new ArrayList<>();
private final List<VolumesFrom> volumesFrom = new ArrayList<>();
private final List<String> extraHosts = new ArrayList<>();
private final Map<String, String> labels = new HashMap<>();
private LogConfig logConfig;
Expand Down Expand Up @@ -319,6 +321,14 @@ public Builder withNamedVolume(String volumeName, String containerPath, boolean
return this;
}

/**
* Inherits every volume declared by another container.
*/
public Builder withVolumesFrom(String sourceContainerId, boolean readOnly) {
volumesFrom.add(new VolumesFrom(sourceContainerId, readOnly ? AccessMode.ro : AccessMode.rw));
return this;
}

/**
* Adds a mount (any type: volume, bind, tmpfs).
*/
Expand Down Expand Up @@ -592,6 +602,7 @@ public ContainerSpec build() {
networkMode,
List.copyOf(mounts),
List.copyOf(binds),
List.copyOf(volumesFrom),
List.copyOf(extraHosts),
Map.copyOf(labels),
logConfig,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -954,6 +954,10 @@ private HostConfig buildHostConfig(ContainerSpec spec) {
hostConfig.withBinds(spec.binds().toArray(new Bind[0]));
}

if (spec.volumesFrom() != null && !spec.volumesFrom().isEmpty()) {
hostConfig.withVolumesFrom(spec.volumesFrom());
}

// Docker rejects extra_hosts together with container:<id> network mode. Containers
// sharing another container's network namespace already inherit its network path.
if (spec.extraHosts() != null && !spec.extraHosts().isEmpty()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import com.github.dockerjava.api.model.DeviceRequest;
import com.github.dockerjava.api.model.LogConfig;
import com.github.dockerjava.api.model.Mount;
import com.github.dockerjava.api.model.VolumesFrom;

import java.util.List;
import java.util.Map;
Expand All @@ -24,6 +25,7 @@
* @param networkMode Docker network name or mode (null = default bridge)
* @param mounts Volume mounts (named volumes, bind mounts, tmpfs)
* @param binds Legacy bind mounts (prefer mounts for new code)
* @param volumesFrom Volumes inherited from other containers
* @param extraHosts Extra /etc/hosts entries as "hostname:ip" strings
* @param labels Container labels merged over the default floci-aws labels
* @param logConfig Docker log driver configuration (null = daemon default)
Expand All @@ -48,6 +50,7 @@ public record ContainerSpec(
String networkMode,
List<Mount> mounts,
List<Bind> binds,
List<VolumesFrom> volumesFrom,
List<String> extraHosts,
Map<String, String> labels,
LogConfig logConfig,
Expand All @@ -64,11 +67,14 @@ public record ContainerSpec(
* All other fields will be null or empty lists.
*/
public ContainerSpec(String image) {
this(image, null, List.of(), null, null, null, Map.of(), List.of(), List.of(), null, List.of(), List.of(), List.of(), Map.of(), null, false, null, List.of(), null, null, List.of(), List.of());
this(image, null, List.of(), null, null, null, Map.of(), List.of(), List.of(), null,
List.of(), List.of(), List.of(), List.of(), Map.of(), null, false, null, List.of(),
null, null, List.of(), List.of());
}

/**
* Backward-compatible constructor that defaults {@code loopbackPortBindings} to empty.
* Backward-compatible constructor that defaults {@code loopbackPortBindings},
* {@code volumesFrom}, and {@code deviceRequests} to empty.
*/
public ContainerSpec(
String image,
Expand All @@ -92,12 +98,15 @@ public ContainerSpec(
String user,
List<String> groupAdd
) {
this(image, name, env, cmd, entrypoint, memoryBytes, portBindings, List.of(), exposedPorts, networkMode, mounts, binds, extraHosts, labels, logConfig, privileged, cgroupnsMode, dnsServers, workingDir, user, groupAdd, List.of());
this(image, name, env, cmd, entrypoint, memoryBytes, portBindings, List.of(), exposedPorts,
networkMode, mounts, binds, List.of(), extraHosts, labels, logConfig, privileged,
cgroupnsMode, dnsServers, workingDir, user, groupAdd, List.of());
}

/**
* Backward-compatible constructor that defaults {@code deviceRequests} to empty, so a
* caller that predates accelerator support keeps building CPU-only containers.
* Backward-compatible constructor that defaults {@code volumesFrom} and
* {@code deviceRequests} to empty, so callers that predate volume inheritance and
* accelerator support keep their existing behaviour.
*/
public ContainerSpec(
String image,
Expand All @@ -122,7 +131,42 @@ public ContainerSpec(
String user,
List<String> groupAdd
) {
this(image, name, env, cmd, entrypoint, memoryBytes, portBindings, loopbackPortBindings, exposedPorts, networkMode, mounts, binds, extraHosts, labels, logConfig, privileged, cgroupnsMode, dnsServers, workingDir, user, groupAdd, List.of());
this(image, name, env, cmd, entrypoint, memoryBytes, portBindings, loopbackPortBindings,
exposedPorts, networkMode, mounts, binds, List.of(), extraHosts, labels, logConfig,
privileged, cgroupnsMode, dnsServers, workingDir, user, groupAdd, List.of());
}

/**
* Backward-compatible constructor that defaults {@code volumesFrom} to empty while
* preserving explicitly requested devices.
*/
public ContainerSpec(
String image,
String name,
List<String> env,
List<String> cmd,
List<String> entrypoint,
Long memoryBytes,
Map<Integer, Integer> portBindings,
List<Integer> loopbackPortBindings,
List<Integer> exposedPorts,
String networkMode,
List<Mount> mounts,
List<Bind> binds,
List<String> extraHosts,
Map<String, String> labels,
LogConfig logConfig,
boolean privileged,
String cgroupnsMode,
List<String> dnsServers,
String workingDir,
String user,
List<String> groupAdd,
List<DeviceRequest> deviceRequests
) {
this(image, name, env, cmd, entrypoint, memoryBytes, portBindings, loopbackPortBindings,
exposedPorts, networkMode, mounts, binds, List.of(), extraHosts, labels, logConfig,
privileged, cgroupnsMode, dnsServers, workingDir, user, groupAdd, deviceRequests);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
import io.github.hectorvent.floci.services.ecs.model.TaskSet;
import io.github.hectorvent.floci.services.ecs.model.EfsVolumeConfiguration;
import io.github.hectorvent.floci.services.ecs.model.Volume;
import io.github.hectorvent.floci.services.ecs.model.VolumeFrom;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
Expand Down Expand Up @@ -1102,6 +1103,17 @@ private ObjectNode containerDefinitionNode(ContainerDefinition def) {
n.set("mountPoints", mps);
}

if (def.getVolumesFrom() != null && !def.getVolumesFrom().isEmpty()) {
ArrayNode volumesFrom = objectMapper.createArrayNode();
for (VolumeFrom volumeFrom : def.getVolumesFrom()) {
ObjectNode volumeFromNode = objectMapper.createObjectNode();
volumeFromNode.put("sourceContainer", volumeFrom.sourceContainer());
volumeFromNode.put("readOnly", volumeFrom.readOnly());
volumesFrom.add(volumeFromNode);
}
n.set("volumesFrom", volumesFrom);
}

if (def.getLogConfiguration() != null) {
LogConfiguration logConfig = def.getLogConfiguration();
ObjectNode logNode = objectMapper.createObjectNode();
Expand Down Expand Up @@ -1496,6 +1508,7 @@ private List<ContainerDefinition> parseContainerDefinitions(JsonNode node) {
def.setSecrets(parseSecrets(item.path("secrets")));
}
def.setMountPoints(parseMountPoints(item.path("mountPoints")));
def.setVolumesFrom(parseVolumesFrom(item.path("volumesFrom")));
def.setLogConfiguration(parseLogConfiguration(item.path("logConfiguration")));
def.setFirelensConfiguration(parseFirelensConfiguration(
item.path("firelensConfiguration"), result.size() + 1));
Expand Down Expand Up @@ -1555,6 +1568,19 @@ private List<Secret> parseSecrets(JsonNode node) {
return result;
}

private List<VolumeFrom> parseVolumesFrom(JsonNode node) {
List<VolumeFrom> result = new ArrayList<>();
if (!node.isArray()) {
return result;
}
for (JsonNode item : node) {
result.add(new VolumeFrom(
item.path("sourceContainer").asText(),
item.path("readOnly").asBoolean(false)));
}
return result;
}

private RuntimePlatform parseRuntimePlatform(JsonNode node) {
if (node == null || !node.isObject()) {
return null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
import io.github.hectorvent.floci.services.ecs.model.Secret;
import io.github.hectorvent.floci.services.ecs.model.TaskDefinition;
import io.github.hectorvent.floci.services.ecs.model.Volume;
import io.github.hectorvent.floci.services.ecs.model.VolumeFrom;
import io.github.hectorvent.floci.services.s3.S3Service;
import io.github.hectorvent.floci.services.s3.model.S3Object;
import io.github.hectorvent.floci.services.secretsmanager.SecretsManagerService;
Expand Down Expand Up @@ -150,6 +151,16 @@ public EcsTaskHandle startTask(EcsTask task, TaskDefinition taskDef,
Map<String, String> containerIds = new LinkedHashMap<>();
Map<String, Closeable> logStreamsByContainerId = new LinkedHashMap<>();
List<Container> runtimeContainers = new ArrayList<>();
ContainerDefinition firelensRouter = findFirelensRouter(taskDef.getContainerDefinitions());
Map<String, Map<String, String>> firelensLogOptions =
awsFirelensLogOptions(taskDef.getContainerDefinitions());
if (!firelensLogOptions.isEmpty() && firelensRouter == null) {
throw new AwsException("ClientException",
"awsfirelens log driver requires a firelensConfiguration container", 400);
}

List<ContainerDefinition> launchOrder = orderForDependencies(
launchOrder(taskDef.getContainerDefinitions(), firelensRouter), firelensRouter);

// Task-level volumes consumed by per-container mountPoints: host volumes map their
// name -> absolute host source path; efsVolumeConfiguration volumes map their
Expand All @@ -173,19 +184,11 @@ public EcsTaskHandle startTask(EcsTask task, TaskDefinition taskDef,
Map<ContainerDefinition, List<String>> envVarsByContainer = new LinkedHashMap<>();
// Resolved before any container is created, so a registry-startup failure can't leak one already started.
Map<ContainerDefinition, String> imagesByContainer = new LinkedHashMap<>();
for (ContainerDefinition def : taskDef.getContainerDefinitions()) {
for (ContainerDefinition def : launchOrder) {
envVarsByContainer.put(def, buildEnvVars(def, overridesByName.get(def.getName()), region));
imagesByContainer.put(def, ecrRegistryManager.rewriteImageUri(def.getImage()));
}

ContainerDefinition firelensRouter = findFirelensRouter(taskDef.getContainerDefinitions());
Map<String, Map<String, String>> firelensLogOptions =
awsFirelensLogOptions(taskDef.getContainerDefinitions());
if (!firelensLogOptions.isEmpty() && firelensRouter == null) {
throw new AwsException("ClientException",
"awsfirelens log driver requires a firelensConfiguration container", 400);
}

PreparedNetwork protectedNetwork = prepareNetwork(task, taskDef, region, taskId);

String firelensVolumeName = null;
Expand All @@ -200,8 +203,6 @@ public EcsTaskHandle startTask(EcsTask task, TaskDefinition taskDef,
firelensSocketAddress = unixSocketAddress(firelensVolumeName);
}

List<ContainerDefinition> launchOrder =
launchOrder(taskDef.getContainerDefinitions(), firelensRouter);
String fluentHost = null;
String networkModeName = taskDef.getNetworkMode() != null
? taskDef.getNetworkMode().name()
Expand Down Expand Up @@ -323,6 +324,17 @@ public EcsTaskHandle startTask(EcsTask task, TaskDefinition taskDef,
}
}

if (def.getVolumesFrom() != null) {
for (VolumeFrom volumeFrom : def.getVolumesFrom()) {
String sourceContainerId = containerIds.get(volumeFrom.sourceContainer());
if (sourceContainerId == null) {
throw new IllegalStateException("ECS volumesFrom source container "
+ volumeFrom.sourceContainer() + " has not started");
}
specBuilder.withVolumesFrom(sourceContainerId, volumeFrom.readOnly());
}
}

ContainerSpec spec = specBuilder.build();

String dockerId;
Expand Down Expand Up @@ -379,7 +391,18 @@ public EcsTaskHandle startTask(EcsTask task, TaskDefinition taskDef,
throw e;
}

task.setContainers(runtimeContainers);
Map<String, Container> runtimeContainersByName = new LinkedHashMap<>();
for (Container container : runtimeContainers) {
runtimeContainersByName.put(container.getName(), container);
}
List<Container> containersInDefinitionOrder = new ArrayList<>();
for (ContainerDefinition definition : taskDef.getContainerDefinitions()) {
Container container = runtimeContainersByName.get(definition.getName());
if (container != null) {
containersInDefinitionOrder.add(container);
}
}
task.setContainers(containersInDefinitionOrder);
task.setLastStatus(TaskStatus.RUNNING.name());
task.setDesiredStatus(TaskStatus.RUNNING.name());
task.setStartedAt(Instant.now());
Expand All @@ -389,6 +412,53 @@ public EcsTaskHandle startTask(EcsTask task, TaskDefinition taskDef,
protectedNetwork == null ? null : protectedNetwork.eni().getNetworkInterfaceId(), region);
}

private List<ContainerDefinition> orderForDependencies(List<ContainerDefinition> definitions,
ContainerDefinition firelensRouter) {
Map<String, ContainerDefinition> definitionsByName = new LinkedHashMap<>();
for (ContainerDefinition definition : definitions) {
definitionsByName.put(definition.getName(), definition);
}

List<ContainerDefinition> ordered = new ArrayList<>();
Set<ContainerDefinition> visiting = new HashSet<>();
Set<ContainerDefinition> visited = new HashSet<>();
for (ContainerDefinition definition : definitions) {
addAfterDependencies(definition, definitionsByName, firelensRouter, visiting, visited, ordered);
}
return ordered;
}

private void addAfterDependencies(ContainerDefinition definition,
Map<String, ContainerDefinition> definitionsByName,
ContainerDefinition firelensRouter,
Set<ContainerDefinition> visiting,
Set<ContainerDefinition> visited,
List<ContainerDefinition> ordered) {
if (visited.contains(definition)) {
return;
}
if (!visiting.add(definition)) {
throw new IllegalArgumentException("ECS container dependencies contain a cycle at container "
+ definition.getName());
}
if (isAwsFirelens(definition)) {
addAfterDependencies(firelensRouter, definitionsByName, firelensRouter, visiting, visited, ordered);
}
if (definition.getVolumesFrom() != null) {
for (VolumeFrom volumeFrom : definition.getVolumesFrom()) {
ContainerDefinition source = definitionsByName.get(volumeFrom.sourceContainer());
if (source == null) {
throw new IllegalArgumentException("ECS volumesFrom references unknown source container "
+ volumeFrom.sourceContainer());
}
addAfterDependencies(source, definitionsByName, firelensRouter, visiting, visited, ordered);
}
}
visiting.remove(definition);
visited.add(definition);
ordered.add(definition);
}

private PreparedNetwork prepareNetwork(EcsTask task, TaskDefinition definition, String region, String taskId) {
if (definition.getNetworkMode() != NetworkMode.awsvpc
|| firewallManager == null || !firewallManager.enabled()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ public class ContainerDefinition {
private List<String> command;
private List<String> entryPoint;
private List<MountPoint> mountPoints;
private List<VolumeFrom> volumesFrom;
private LogConfiguration logConfiguration;
private FirelensConfiguration firelensConfiguration;
private HealthCheck healthCheck;
Expand Down Expand Up @@ -59,6 +60,9 @@ public class ContainerDefinition {
public List<MountPoint> getMountPoints() { return mountPoints; }
public void setMountPoints(List<MountPoint> mountPoints) { this.mountPoints = mountPoints; }

public List<VolumeFrom> getVolumesFrom() { return volumesFrom; }
public void setVolumesFrom(List<VolumeFrom> volumesFrom) { this.volumesFrom = volumesFrom; }

public LogConfiguration getLogConfiguration() { return logConfiguration; }
public void setLogConfiguration(LogConfiguration logConfiguration) { this.logConfiguration = logConfiguration; }

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package io.github.hectorvent.floci.services.ecs.model;

import io.quarkus.runtime.annotations.RegisterForReflection;

/**
* An ECS container volume inheritance reference:
* {@code {"sourceContainer": ..., "readOnly": ...}}.
*/
@RegisterForReflection
public record VolumeFrom(String sourceContainer, boolean readOnly) {
}
Loading
Loading