Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
4 changes: 4 additions & 0 deletions docs/services/ecs.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ own `RegisterTaskDefinition`) sees no drift. Neither changes how a local task ru
every task on the host's own architecture, and a task's output stays with its Docker container
rather than being routed to the configured log driver.

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.

### 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 @@ -937,6 +937,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());
}

// Extra hosts (e.g., host.docker.internal on Linux)
if (spec.extraHosts() != null && !spec.extraHosts().isEmpty()) {
hostConfig.withExtraHosts(spec.extraHosts().toArray(new String[0]));
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 @@ -34,6 +34,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 @@ -1100,6 +1101,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 @@ -1481,6 +1493,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")));
if (item.has("healthCheck")) {
def.setHealthCheck(parseHealthCheck(item.path("healthCheck")));
Expand Down Expand Up @@ -1538,6 +1551,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 @@ -32,6 +32,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.secretsmanager.SecretsManagerService;
import io.github.hectorvent.floci.services.ssm.SsmService;
import com.github.dockerjava.api.DockerClient;
Expand Down Expand Up @@ -134,6 +135,7 @@ public EcsTaskHandle startTask(EcsTask task, TaskDefinition taskDef,
Map<String, String> containerIds = new LinkedHashMap<>();
Map<String, Closeable> logStreamsByContainerId = new LinkedHashMap<>();
List<Container> runtimeContainers = new ArrayList<>();
List<ContainerDefinition> launchOrder = orderForVolumesFrom(taskDef.getContainerDefinitions());

// Task-level volumes consumed by per-container mountPoints: host volumes map their
// name -> absolute host source path; efsVolumeConfiguration volumes map their
Expand All @@ -157,15 +159,15 @@ 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()));
}

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

try {
for (ContainerDefinition def : taskDef.getContainerDefinitions()) {
for (ContainerDefinition def : launchOrder) {
String containerName = ContainerStorageHelper.dockerName(config, "floci-ecs-" + taskId + "-" + def.getName());

// RunTask containerOverrides matched by container name: command replaces
Expand Down Expand Up @@ -263,6 +265,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();

// Create and start container
Expand Down Expand Up @@ -302,7 +315,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 @@ -311,6 +335,48 @@ public EcsTaskHandle startTask(EcsTask task, TaskDefinition taskDef,
protectedNetwork == null ? null : protectedNetwork.eni().getNetworkInterfaceId(), region);
}

private List<ContainerDefinition> orderForVolumesFrom(List<ContainerDefinition> definitions) {
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) {
addAfterVolumeSources(definition, definitionsByName, visiting, visited, ordered);
}
return ordered;
}

private void addAfterVolumeSources(ContainerDefinition definition,
Map<String, ContainerDefinition> definitionsByName,
Set<ContainerDefinition> visiting,
Set<ContainerDefinition> visited,
List<ContainerDefinition> ordered) {
if (visited.contains(definition)) {
return;
}
if (!visiting.add(definition)) {
throw new IllegalArgumentException("ECS volumesFrom references contain a cycle at container "
+ definition.getName());
}
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());
}
addAfterVolumeSources(source, definitionsByName, 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 HealthCheck healthCheck;

Expand Down Expand Up @@ -58,6 +59,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