feat: Lightsail + svg animation + ec2 bug fixes

This commit is contained in:
Zakaria
2026-10-11 14:19:49 -04:00
parent 019570d8a4
commit 33c480e7e1
36 changed files with 2857 additions and 153 deletions
@@ -34,6 +34,20 @@ class PortAllocatorTest {
allocator.release(second);
}
@Test
void reportsOccupiedPortsSkippedDuringAllocation() {
PortAllocator allocator = new PortAllocator();
int first = allocator.allocate(19900, 19900);
List<Integer> occupied = new ArrayList<>();
int second = allocator.allocate(19900, 19901, occupied::add);
assertEquals(19901, second);
assertEquals(List.of(19900), occupied);
allocator.release(first);
allocator.release(second);
}
@Test
void releasedPortBecomesAvailableAgain() {
PortAllocator allocator = new PortAllocator();
@@ -2,6 +2,7 @@ package io.github.hectorvent.floci.lifecycle;
import io.github.hectorvent.floci.config.EmulatorConfig;
import io.github.hectorvent.floci.services.ec2.Ec2Service;
import io.github.hectorvent.floci.services.lightsail.LightsailService;
import io.github.hectorvent.floci.config.FlociCertificateAuthority;
import io.github.hectorvent.floci.core.common.ContainerTeardown;
import io.github.hectorvent.floci.core.common.Resettable;
@@ -38,6 +39,7 @@ class EmulatorInfoControllerTest {
@Mock private FlociCertificateAuthority certificateAuthority;
@Mock private EmulatorConfig config;
@Mock private Ec2Service ec2Service;
@Mock private LightsailService lightsailService;
@Mock private ContainerTeardown sageMakerTeardown;
@Mock private ContainerTeardown batchTeardown;
@Mock private Resettable resettable;
@@ -47,7 +49,7 @@ class EmulatorInfoControllerTest {
@BeforeEach
void setUp() {
controller = new EmulatorInfoController(serviceRegistry, initLifecycleState, storageFactory,
resettables, containerTeardowns, certificateAuthority, config, ec2Service);
resettables, containerTeardowns, certificateAuthority, config, ec2Service, lightsailService);
}
@Test
@@ -115,6 +115,6 @@ class CloudWatchLogsMetricFilterResetTest {
when(resettables.iterator()).thenAnswer(call -> List.of(resettable).iterator());
Instance<ContainerTeardown> teardowns = mock(Instance.class);
when(teardowns.iterator()).thenAnswer(call -> List.<ContainerTeardown>of().iterator());
return new EmulatorInfoController(null, null, f.factory, resettables, teardowns, null, null, null);
return new EmulatorInfoController(null, null, f.factory, resettables, teardowns, null, null, null, null);
}
}
@@ -10,9 +10,12 @@ import com.github.dockerjava.api.command.InspectContainerCmd;
import com.github.dockerjava.api.command.InspectContainerResponse;
import com.github.dockerjava.api.command.InspectExecCmd;
import com.github.dockerjava.api.command.InspectExecResponse;
import com.github.dockerjava.api.command.InspectImageCmd;
import com.github.dockerjava.api.command.InspectImageResponse;
import com.github.dockerjava.api.command.ListContainersCmd;
import com.github.dockerjava.api.command.StartContainerCmd;
import com.github.dockerjava.api.exception.DockerException;
import com.github.dockerjava.api.exception.NotFoundException;
import com.github.dockerjava.api.model.Container;
import com.github.dockerjava.api.model.ContainerConfig;
import com.github.dockerjava.api.model.ContainerNetwork;
@@ -56,8 +59,10 @@ import java.util.concurrent.Future;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.BooleanSupplier;
import java.util.function.IntConsumer;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -69,6 +74,7 @@ import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.anySet;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.nullable;
import static org.mockito.Answers.RETURNS_SELF;
import static org.mockito.Answers.RETURNS_DEEP_STUBS;
import static org.mockito.Mockito.after;
@@ -97,6 +103,8 @@ class Ec2ContainerManagerTest {
void resetBridgeIpPolling() {
Ec2ContainerManager.containerBridgeIpAttempts = 30;
Ec2ContainerManager.containerBridgeIpPollMillis = 500;
Ec2ContainerManager.sshBannerAttempts = 30;
Ec2ContainerManager.sshBannerPollMillis = 1_000;
}
@Test
@@ -731,6 +739,8 @@ class Ec2ContainerManagerTest {
void launchReturnsTheLeaseWhenTheLaunchThrows() throws Exception {
LaunchHarness harness = launchHarness();
when(harness.builder.build()).thenThrow(new IllegalStateException("daemon went away"));
AtomicInteger persistenceCalls = new AtomicInteger();
harness.manager.setInstancePersister(ignored -> persistenceCalls.incrementAndGet());
Instance instance = leasedInstance("i-throws");
@@ -742,6 +752,13 @@ class Ec2ContainerManagerTest {
verify(harness.vpcNetworkManager, timeout(2000)).detach("us-west-2", "vpc-lease", null);
verify(harness.vpcNetworkManager, timeout(2000))
.releasePrivateIp("us-west-2", "subnet-lease", "10.0.1.10");
assertEquals("failed", instance.getLaunchStatus());
assertEquals("impaired", instance.getSshStatus());
assertEquals("daemon went away", instance.getStateReasonMessage());
assertTrue(instance.getLaunchEvents().stream()
.anyMatch(event -> "failed".equals(event.getStatus())
&& "daemon went away".equals(event.getMessage())));
assertTrue(persistenceCalls.get() >= instance.getLaunchEvents().size());
}
private static Instance leasedInstance(String instanceId) {
@@ -789,13 +806,14 @@ class Ec2ContainerManagerTest {
when(inspect.exec()).thenReturn(response);
Instance instance = instance("i-labeltest");
instance.setAccountId("123456789012");
harness.manager.launch(instance, "ubuntu:24.04", null, "us-west-2");
verify(harness.builder, timeout(2000)).withLabels(Map.of(
"io.floci", "aws",
"io.floci.service", "ec2",
"io.floci.resource-id", "i-labeltest",
"io.floci.account", "000000000000",
"io.floci.account", "123456789012",
"io.floci.region", "us-west-2"));
}
@@ -968,7 +986,8 @@ class Ec2ContainerManagerTest {
Ec2ContainerManager.containerBridgeIpAttempts = 1;
Ec2ContainerManager.containerBridgeIpPollMillis = 1;
LaunchHarness harness = launchHarness();
when(harness.lifecycleManager.create(any(ContainerSpec.class), eq("linux/arm64")))
when(harness.lifecycleManager.create(any(ContainerSpec.class), eq("linux/arm64"),
any(ContainerLifecycleManager.ContainerCreationObserver.class)))
.thenReturn(TEST_CONTAINER_ID);
InspectContainerCmd inspect = mock(InspectContainerCmd.class);
InspectContainerResponse withIp = inspectResponse("172.18.0.11");
@@ -979,7 +998,7 @@ class Ec2ContainerManagerTest {
harness.manager.launch(instance,
new ResolvedAmiImage("floci/ami-ubuntu:24.04-arm64", ResolvedAmiImage.SYSTEMD_RUNTIME, true,
"linux/arm64"),
"linux/arm64", null, "enhanced", "root", true, "ssh"),
null,
"us-west-2");
@@ -987,7 +1006,49 @@ class Ec2ContainerManagerTest {
verify(harness.builder).withCmd(List.of("/sbin/init"));
verify(harness.builder).withCgroupnsMode("host");
verify(harness.builder).withBind("/sys/fs/cgroup", "/sys/fs/cgroup");
verify(harness.lifecycleManager).create(any(ContainerSpec.class), eq("linux/arm64"));
verify(harness.lifecycleManager).create(any(ContainerSpec.class), eq("linux/arm64"),
any(ContainerLifecycleManager.ContainerCreationObserver.class));
}
@Test
void legacyTailGuestIsRecognizedForSshBootstrapAfterReboot() {
assertTrue(Ec2ContainerManager.isLegacyTailGuest(new String[]{"tail", "-f", "/dev/null"}));
assertFalse(Ec2ContainerManager.isLegacyTailGuest(new String[]{"/sbin/init"}));
}
@Test
void missingLocalAmiUsesPullableMinimalFallback() {
LaunchHarness harness = launchHarness();
InspectImageCmd inspect = mock(InspectImageCmd.class);
when(harness.containerBuilder.resolveImage("floci/ami-ubuntu:20.04"))
.thenReturn("floci/ami-ubuntu:20.04");
when(harness.dockerClient.inspectImageCmd("floci/ami-ubuntu:20.04")).thenReturn(inspect);
when(inspect.exec()).thenThrow(new NotFoundException("missing"));
ResolvedAmiImage selected = harness.manager.selectLaunchImage(new ResolvedAmiImage(
"floci/ami-ubuntu:20.04", ResolvedAmiImage.SYSTEMD_RUNTIME, false,
"linux/amd64", "public.ecr.aws/docker/library/ubuntu:20.04",
"enhanced", "root", true, "ssh"));
assertEquals("public.ecr.aws/docker/library/ubuntu:20.04", selected.dockerImage());
assertFalse(selected.systemd());
assertEquals("linux/amd64", selected.dockerPlatform());
}
@Test
void availableLocalAmiKeepsItsSystemdRuntime() {
LaunchHarness harness = launchHarness();
InspectImageCmd inspect = mock(InspectImageCmd.class);
when(harness.containerBuilder.resolveImage("floci/ami-ubuntu:20.04"))
.thenReturn("floci/ami-ubuntu:20.04");
when(harness.dockerClient.inspectImageCmd("floci/ami-ubuntu:20.04")).thenReturn(inspect);
when(inspect.exec()).thenReturn(mock(InspectImageResponse.class));
ResolvedAmiImage image = new ResolvedAmiImage(
"floci/ami-ubuntu:20.04", ResolvedAmiImage.SYSTEMD_RUNTIME, false,
"linux/amd64", "public.ecr.aws/docker/library/ubuntu:20.04",
"enhanced", "root", true, "ssh");
assertEquals(image, harness.manager.selectLaunchImage(image));
}
@Test
@@ -1091,8 +1152,9 @@ class Ec2ContainerManagerTest {
Ec2ContainerManager.containerBridgeIpAttempts = 1;
Ec2ContainerManager.containerBridgeIpPollMillis = 1;
LaunchHarness harness = launchHarness();
when(harness.portAllocator.allocate(anyInt(), anyInt())).thenReturn(2201, 2202);
when(harness.lifecycleManager.create(any(ContainerSpec.class)))
when(harness.portAllocator.allocate(anyInt(), anyInt(), any(IntConsumer.class))).thenReturn(2201, 2202);
when(harness.lifecycleManager.create(any(ContainerSpec.class), nullable(String.class),
any(ContainerLifecycleManager.ContainerCreationObserver.class)))
.thenReturn("container-conflict", TEST_CONTAINER_ID);
when(harness.lifecycleManager.startCreated(anyString(), any(ContainerSpec.class)))
.thenThrow(new RuntimeException("Bind for 0.0.0.0:2201 failed: port is already allocated"))
@@ -1115,6 +1177,28 @@ class Ec2ContainerManagerTest {
verify(harness.builder).withPortBinding(22, 2202);
}
@Test
void launchRecordsOccupiedCandidatesWhenTheSshPortRangeIsExhausted() throws Exception {
LaunchHarness harness = launchHarness();
when(harness.portAllocator.allocate(anyInt(), anyInt(), any(IntConsumer.class)))
.thenAnswer(invocation -> {
IntConsumer observer = invocation.getArgument(2);
observer.accept(2200);
observer.accept(2201);
throw new RuntimeException("No free port available in range 2200-2201");
});
Instance instance = instance("i-ports-exhausted");
harness.manager.launch(instance, "ubuntu:24.04", null, "us-west-2");
awaitUntil(() -> "terminated".equals(instance.getState().getName()), Duration.ofSeconds(2));
assertTrue(instance.getLaunchEvents().stream().anyMatch(event -> "retrying".equals(event.getStatus())
&& event.getMessage().contains("2200")));
assertTrue(instance.getLaunchEvents().stream().anyMatch(event -> "retrying".equals(event.getStatus())
&& event.getMessage().contains("2201")));
assertEquals("No free port available in range 2200-2201", instance.getStateReasonMessage());
}
@Test
void launchReleasesPortAndCleansUpContainerAfterNonRetryableStartFailure() throws Exception {
LaunchHarness harness = launchHarness();
@@ -1172,7 +1256,8 @@ class Ec2ContainerManagerTest {
LaunchHarness harness = launchHarness();
CountDownLatch createEntered = new CountDownLatch(1);
CountDownLatch allowCreate = new CountDownLatch(1);
when(harness.lifecycleManager.create(any(ContainerSpec.class))).thenAnswer(invocation -> {
when(harness.lifecycleManager.create(any(ContainerSpec.class), nullable(String.class),
any(ContainerLifecycleManager.ContainerCreationObserver.class))).thenAnswer(invocation -> {
createEntered.countDown();
assertTrue(allowCreate.await(2, TimeUnit.SECONDS));
return TEST_CONTAINER_ID;
@@ -1247,7 +1332,8 @@ class Ec2ContainerManagerTest {
ExecutorService callerExecutor = Executors.newSingleThreadExecutor();
CountDownLatch createEntered = new CountDownLatch(1);
CountDownLatch releaseCreate = new CountDownLatch(1);
when(harness.lifecycleManager().create(any(ContainerSpec.class))).thenAnswer(invocation -> {
when(harness.lifecycleManager().create(any(ContainerSpec.class), nullable(String.class),
any(ContainerLifecycleManager.ContainerCreationObserver.class))).thenAnswer(invocation -> {
createEntered.countDown();
releaseCreate.await(2, TimeUnit.SECONDS);
throw new RuntimeException("test launch failure");
@@ -1368,17 +1454,7 @@ class Ec2ContainerManagerTest {
verify(harness.dockerClient, never()).copyArchiveToContainerCmd(TEST_CONTAINER_ID);
}
private static final String[] SSHD_INSTALL_PROBE_CMD = {"sh", "-c",
"if ! command -v sshd >/dev/null 2>&1 || ! command -v scp >/dev/null 2>&1; then"
+ " if command -v dnf >/dev/null 2>&1; then dnf install -y openssh-server openssh-clients >/dev/null 2>&1;"
+ " 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;"
+ "command -v sshd >/dev/null 2>&1 || exit 1;"
+ "command -v scp >/dev/null 2>&1 || exit 2"};
private static final String[] SSHD_INSTALL_PROBE_CMD = Ec2ContainerManager.sshdInstallProbeCommand();
@Test
void launchWhenSshdInstallFails_doesNotAttemptKeygenOrStart() throws Exception {
@@ -1477,6 +1553,8 @@ class Ec2ContainerManagerTest {
}
private static LaunchHarness launchHarness(ExecutorService executor, Duration userDataTimeout) {
Ec2ContainerManager.sshBannerAttempts = 1;
Ec2ContainerManager.sshBannerPollMillis = 1;
ContainerBuilder containerBuilder = mock(ContainerBuilder.class);
ContainerBuilder.Builder builder = mock(ContainerBuilder.Builder.class, withSettings().defaultAnswer(RETURNS_SELF));
when(containerBuilder.newContainer(anyString())).thenReturn(builder);
@@ -1484,6 +1562,13 @@ class Ec2ContainerManagerTest {
ContainerLifecycleManager lifecycleManager = mock(ContainerLifecycleManager.class);
when(lifecycleManager.create(any(ContainerSpec.class))).thenReturn(TEST_CONTAINER_ID);
when(lifecycleManager.create(any(ContainerSpec.class), nullable(String.class),
any(ContainerLifecycleManager.ContainerCreationObserver.class))).thenAnswer(invocation -> {
ContainerLifecycleManager.ContainerCreationObserver observer = invocation.getArgument(2);
observer.containerCreationStarted();
observer.containerCreated(TEST_CONTAINER_ID);
return TEST_CONTAINER_ID;
});
when(lifecycleManager.isContainerRunning(TEST_CONTAINER_ID)).thenReturn(true);
DockerHostResolver dockerHostResolver = mock(DockerHostResolver.class);
@@ -1491,7 +1576,7 @@ class Ec2ContainerManagerTest {
ContainerReachableEndpoint reachableEndpoint = mock(ContainerReachableEndpoint.class);
when(reachableEndpoint.baseUrl()).thenReturn("http://localhost.floci.io:4680");
PortAllocator portAllocator = mock(PortAllocator.class);
when(portAllocator.allocate(anyInt(), anyInt())).thenReturn(2201);
when(portAllocator.allocate(anyInt(), anyInt(), any(IntConsumer.class))).thenReturn(2201);
EmulatorConfig config = mock(EmulatorConfig.class);
EmulatorConfig.ServicesConfig services = mock(EmulatorConfig.ServicesConfig.class);
@@ -1543,7 +1628,8 @@ class Ec2ContainerManagerTest {
reachableEndpoint,
executor,
userDataTimeout);
return new LaunchHarness(manager, lifecycleManager, dockerClient, metadataServer, logStreamer, builder,
return new LaunchHarness(manager, lifecycleManager, dockerClient, metadataServer, logStreamer,
containerBuilder, builder,
portAllocator, portForwardManager, config, vpcNetworkManager, new CopyOnWriteArrayList<>());
}
@@ -1771,12 +1857,19 @@ class Ec2ContainerManagerTest {
void launchRunsInstanceAsMetadataOnlyWhenNoDockerDaemonIsReachable() throws Exception {
LaunchHarness harness = launchHarness();
stubDockerPing(harness.dockerClient(), false);
AtomicInteger persistenceCalls = new AtomicInteger();
harness.manager.setInstancePersister(ignored -> persistenceCalls.incrementAndGet());
Instance instance = instance("i-nodocker");
harness.manager().launch(instance, "ubuntu:24.04", null, "us-west-2");
assertEquals("running", instance.getState().getName());
assertEquals("failed", instance.getLaunchStatus());
assertEquals("impaired", instance.getSshStatus());
assertTrue(instance.getSshStatusMessage().contains("Docker daemon is unavailable"));
assertEquals("creating-container", instance.getLaunchEvents().getLast().getStage());
assertTrue(persistenceCalls.get() >= 2);
assertNull(instance.getDockerContainerId());
verify(harness.lifecycleManager(), never()).create(any(ContainerSpec.class));
verify(harness.metadataServer(), never()).registerContainer(anyString(), anyString(), any());
@@ -1788,7 +1881,8 @@ class Ec2ContainerManagerTest {
PingCmd ping = mock(PingCmd.class);
when(harness.dockerClient().pingCmd()).thenReturn(ping);
doNothing().doThrow(new RuntimeException("No such file or directory")).when(ping).exec();
when(harness.lifecycleManager().create(any(ContainerSpec.class)))
when(harness.lifecycleManager().create(any(ContainerSpec.class), nullable(String.class),
any(ContainerLifecycleManager.ContainerCreationObserver.class)))
.thenThrow(new RuntimeException("No such file or directory"));
Instance instance = instance("i-dockergone");
@@ -1846,9 +1940,10 @@ class Ec2ContainerManagerTest {
private record LaunchHarness(Ec2ContainerManager manager,
ContainerLifecycleManager lifecycleManager,
DockerClient dockerClient,
Ec2MetadataServer metadataServer,
ContainerLogStreamer logStreamer,
ContainerBuilder.Builder builder,
Ec2MetadataServer metadataServer,
ContainerLogStreamer logStreamer,
ContainerBuilder containerBuilder,
ContainerBuilder.Builder builder,
PortAllocator portAllocator,
Ec2PortForwardManager portForwardManager,
EmulatorConfig config,
@@ -37,7 +37,6 @@ class Ec2ImageCatalogTest {
"ami-ubuntu2404-amd64",
"ami-ubuntu2404-cloud-arm64",
"ami-debian12",
"ami-debian13",
"ami-alpine",
"ami-fedora42",
"ami-opensuse-leap156"), imageIds);
@@ -67,9 +66,21 @@ class Ec2ImageCatalogTest {
ResolvedAmiImage image = amiImageResolver.resolveImage("ami-amazonlinux2023");
assertEquals("floci/ami-amazonlinux:2023", image.dockerImage());
assertEquals(ResolvedAmiImage.SYSTEMD_RUNTIME, image.guestRuntime());
assertEquals(ResolvedAmiImage.SYSTEMD_RUNTIME, image.initType());
assertEquals("root", image.sshUser());
assertTrue(image.sshPreinstalled());
assertEquals("sshd", image.sshServiceName());
assertFalse(image.cloudInit());
assertTrue(image.systemd());
assertEquals("public.ecr.aws/amazonlinux/amazonlinux:2023", image.fallbackDockerImage());
}
@Test
void everyLocalCatalogImageHasAPullableFallback() {
imageCatalog.images().stream()
.filter(image -> image.dockerImage.startsWith("floci/"))
.forEach(image -> assertTrue(image.fallbackDockerImage != null
&& !image.fallbackDockerImage.isBlank(), image.imageId));
}
@Test
@@ -81,11 +92,9 @@ class Ec2ImageCatalogTest {
@Test
void resolverUsesFedoraAndLocalSshReadyImages() {
assertEquals("floci/ami-ubuntu:20.04", amiImageResolver.resolve("ami-0abcdef1234567892"));
assertEquals("floci/ami-debian:13", amiImageResolver.resolve("ami-debian13"));
assertEquals("floci/ami-fedora:42", amiImageResolver.resolve("ami-fedora42"));
assertEquals("floci/ami-opensuse:15.6", amiImageResolver.resolve("ami-opensuse-leap156"));
assertTrue(amiImageResolver.resolveImage("ami-0abcdef1234567892").systemd());
assertTrue(amiImageResolver.resolveImage("ami-debian13").systemd());
assertTrue(amiImageResolver.resolveImage("ami-fedora42").systemd());
}
@@ -2215,7 +2215,7 @@ class Ec2IntegrationTest {
.formParam("LaunchTemplateName", "sample-template")
.formParam("LaunchTemplateData.ImageId", "ami-0abcdef1234567890")
.formParam("LaunchTemplateData.InstanceType", "t3.micro")
.formParam("LaunchTemplateData.KeyName", "test-key")
.formParam("LaunchTemplateData.KeyName", keyPairName)
.formParam("LaunchTemplateData.IamInstanceProfile.Name", "sample-profile")
.formParam("LaunchTemplateData.SecurityGroupId.1", securityGroupId)
.formParam("LaunchTemplateData.UserData", launchTemplateUserData)
@@ -2328,7 +2328,7 @@ class Ec2IntegrationTest {
.formParam("VersionDescription", "updated by test")
.formParam("LaunchTemplateData.ImageId", "ami-0abcdef1234567890")
.formParam("LaunchTemplateData.InstanceType", "t3.small")
.formParam("LaunchTemplateData.KeyName", "test-key")
.formParam("LaunchTemplateData.KeyName", keyPairName)
.formParam("LaunchTemplateData.IamInstanceProfile.Name", "sample-profile-v2")
.formParam("LaunchTemplateData.SecurityGroupId.1", securityGroupId)
.formParam("LaunchTemplateData.UserData", launchTemplateVersionUserData)
@@ -2468,7 +2468,7 @@ class Ec2IntegrationTest {
.statusCode(200)
.body("RunInstancesResponse.instancesSet.item.imageId", equalTo("ami-sample-override"))
.body("RunInstancesResponse.instancesSet.item.instanceType", equalTo("c7g.large"))
.body("RunInstancesResponse.instancesSet.item.keyName", equalTo("test-key"))
.body("RunInstancesResponse.instancesSet.item.keyName", equalTo(keyPairName))
.body("RunInstancesResponse.instancesSet.item.iamInstanceProfile.arn",
equalTo("arn:aws:iam::000000000000:instance-profile/sample-profile-v2"))
.body("RunInstancesResponse.instancesSet.item.groupSet.item.groupId", equalTo(securityGroupId))
@@ -2861,7 +2861,7 @@ class Ec2IntegrationTest {
.formParam("InstanceType", "t2.micro")
.formParam("MinCount", "1")
.formParam("MaxCount", "1")
.formParam("KeyName", "test-key")
.formParam("KeyName", keyPairName)
.formParam("SubnetId", subnetId)
.formParam("SecurityGroupId.1", securityGroupId)
.header("Authorization", AUTH_HEADER)
@@ -2872,7 +2872,7 @@ class Ec2IntegrationTest {
.body("RunInstancesResponse.instancesSet.item.instanceId", startsWith("i-"))
.body("RunInstancesResponse.instancesSet.item.instanceState.name", equalTo("pending"))
.body("RunInstancesResponse.instancesSet.item.instanceType", equalTo("t2.micro"))
.body("RunInstancesResponse.instancesSet.item.keyName", equalTo("test-key"))
.body("RunInstancesResponse.instancesSet.item.keyName", equalTo(keyPairName))
.extract().path("RunInstancesResponse.instancesSet.item.instanceId");
}
@@ -3712,7 +3712,7 @@ class Ec2IntegrationTest {
.formParam("InstanceType", "t2.micro")
.formParam("MinCount", "5")
.formParam("MaxCount", "5")
.formParam("KeyName", "test-key")
.formParam("KeyName", keyPairName)
.formParam("SubnetId", subnetId)
.formParam("SecurityGroupId.1", securityGroupId)
.header("Authorization", AUTH_HEADER)
@@ -1510,6 +1510,31 @@ class Ec2ServiceTest {
assertNotEquals(first.getKeyMaterial(), second.getKeyMaterial());
assertNotEquals(first.getPublicKey(), second.getPublicKey());
assertNotEquals(first.getKeyFingerprint(), second.getKeyFingerprint());
KeyPair persisted = service.describeKeyPairs("us-east-1", List.of("usable-key"), List.of()).getFirst();
assertNull(persisted.getKeyMaterial());
assertEquals(first.getPublicKey(), persisted.getPublicKey());
}
@Test
void startupClearsLegacyPersistedPrivateKeyMaterialAcrossAccounts() {
AccountAwareStorageBackend<KeyPair> keyPairStore = AccountAwareStorageBackend.inMemory("000000000000");
KeyPair legacy = new KeyPair();
legacy.setKeyPairId("key-legacy");
legacy.setKeyName("legacy-key");
legacy.setKeyMaterial("-----BEGIN RSA PRIVATE KEY-----\nsecret\n-----END RSA PRIVATE KEY-----");
legacy.setPublicKey("ssh-rsa public");
legacy.setRegion("us-east-1");
keyPairStore.putForAccount("111111111111", "us-east-1::key-legacy", legacy);
Ec2Service service = new Ec2Service(mockConfig(true), mock(Ec2ContainerManager.class),
mock(Ec2PortForwardManager.class),
mock(AmiImageResolver.class), mock(Ec2ImageCatalog.class), new Ec2InstanceTypeCatalog(),
new InMemoryStorageFactory(Map.of("ec2-key-pairs.json", keyPairStore)));
service.restoreMetadataRegistrations();
KeyPair restored = keyPairStore.getForAccount("111111111111", "us-east-1::key-legacy").orElseThrow();
assertNull(restored.getKeyMaterial());
assertEquals("ssh-rsa public", restored.getPublicKey());
}
@Test
@@ -1856,7 +1881,7 @@ class Ec2ServiceTest {
AmiImageResolver resolver = mock(AmiImageResolver.class);
when(resolver.resolveImage("ami-arm-source"))
.thenReturn(new ResolvedAmiImage("arm-image", ResolvedAmiImage.DEFAULT_RUNTIME, false,
"linux/arm64"));
"linux/arm64", null, "public", "root", false, "sshd"));
Ec2Service service = liveService(capturingContainerManager(), resolver, catalog);
Reservation sourceReservation = service.runInstances("us-east-1", "ami-arm-source", "t4g.medium",
1, 1, null, List.of(), null, null, List.of(), null, null);
@@ -2065,6 +2090,135 @@ class Ec2ServiceTest {
verify(vpcNetworks, times(2)).reservePrivateIp("us-east-1", "subnet-a", "10.0.1.10");
}
@Test
void restorePromotesAPendingInstanceWhoseContainerIsAlreadyRunning() {
AccountAwareStorageBackend<Instance> instanceStore = AccountAwareStorageBackend.inMemory("000000000000");
Instance instance = persistedInstance("i-pending", "10.0.1.10", InstanceState.pending());
instance.setDockerContainerId("container-id");
instanceStore.put("us-east-1::i-pending", instance);
Ec2ContainerManager containerManager = mock(Ec2ContainerManager.class);
when(containerManager.restoreMetadataRegistration(instance)).thenReturn(true);
VpcNetworkManager vpcNetworks = mock(VpcNetworkManager.class);
when(vpcNetworks.enabled()).thenReturn(true);
Ec2Service service = new Ec2Service(mockConfig(false), containerManager,
mock(Ec2PortForwardManager.class), mock(AmiImageResolver.class), mock(Ec2ImageCatalog.class),
new Ec2InstanceTypeCatalog(),
new InMemoryStorageFactory(Map.of("ec2-instances.json", instanceStore)), vpcNetworks);
service.restoreMetadataRegistrations();
assertEquals("running", instanceStore.get("us-east-1::i-pending").orElseThrow().getState().getName());
}
@Test
void restoreFailsAPendingInstanceWithoutARecordedContainer() {
AccountAwareStorageBackend<Instance> instanceStore = AccountAwareStorageBackend.inMemory("000000000000");
Instance instance = persistedInstance("i-pending", "10.0.1.10", InstanceState.pending());
instanceStore.put("us-east-1::i-pending", instance);
Ec2Service service = new Ec2Service(mockConfig(false), mock(Ec2ContainerManager.class),
mock(Ec2PortForwardManager.class), mock(AmiImageResolver.class), mock(Ec2ImageCatalog.class),
new Ec2InstanceTypeCatalog(),
new InMemoryStorageFactory(Map.of("ec2-instances.json", instanceStore)),
mock(VpcNetworkManager.class));
service.restoreMetadataRegistrations();
Instance restored = instanceStore.get("us-east-1::i-pending").orElseThrow();
assertEquals("terminated", restored.getState().getName());
assertEquals("failed", restored.getLaunchStatus());
assertEquals("impaired", restored.getSshStatus());
assertEquals("startup-reconciliation", restored.getLaunchEvents().getLast().getStage());
assertEquals("failed", restored.getLaunchEvents().getLast().getStatus());
}
@Test
void restoreFailsPendingInstancesAcrossAccountPartitions() {
AccountAwareStorageBackend<Instance> instanceStore = AccountAwareStorageBackend.inMemory("000000000000");
Instance instance = persistedInstance("i-other-account", "10.0.1.10", InstanceState.pending());
instance.setAccountId("111111111111");
instanceStore.putForAccount("111111111111", "us-east-1::i-other-account", instance);
Ec2Service service = new Ec2Service(mockConfig(false), mock(Ec2ContainerManager.class),
mock(Ec2PortForwardManager.class), mock(AmiImageResolver.class), mock(Ec2ImageCatalog.class),
new Ec2InstanceTypeCatalog(),
new InMemoryStorageFactory(Map.of("ec2-instances.json", instanceStore)),
mock(VpcNetworkManager.class));
service.restoreMetadataRegistrations();
Instance restored = instanceStore.getForAccount(
"111111111111", "us-east-1::i-other-account").orElseThrow();
assertEquals("terminated", restored.getState().getName());
assertEquals("failed", restored.getLaunchStatus());
assertTrue(instanceStore.getForAccount(
"000000000000", "us-east-1::i-other-account").isEmpty());
}
@Test
void restoreFailsARunningInstanceWhoseLaunchWasInterrupted() {
AccountAwareStorageBackend<Instance> instanceStore = AccountAwareStorageBackend.inMemory("000000000000");
Instance instance = persistedInstance("i-interrupted", "10.0.1.10", InstanceState.running());
instance.setLaunchStatus("in-progress");
instanceStore.put("us-east-1::i-interrupted", instance);
Ec2Service service = new Ec2Service(mockConfig(false), mock(Ec2ContainerManager.class),
mock(Ec2PortForwardManager.class), mock(AmiImageResolver.class), mock(Ec2ImageCatalog.class),
new Ec2InstanceTypeCatalog(),
new InMemoryStorageFactory(Map.of("ec2-instances.json", instanceStore)),
mock(VpcNetworkManager.class));
service.restoreMetadataRegistrations();
Instance restored = instanceStore.get("us-east-1::i-interrupted").orElseThrow();
assertEquals("terminated", restored.getState().getName());
assertEquals("failed", restored.getLaunchStatus());
assertEquals("startup-reconciliation", restored.getLaunchEvents().getLast().getStage());
}
@Test
void describeInstanceStatusIncludesFailedLaunchOnlyWhenRequested() {
AccountAwareStorageBackend<Instance> instanceStore = AccountAwareStorageBackend.inMemory("000000000000");
Instance instance = persistedInstance("i-failed", "10.0.1.10", InstanceState.terminated());
instance.setLaunchStatus("failed");
instance.setSshStatus("impaired");
instance.setSshStatusMessage("No free port available in range 2200-2209");
instanceStore.put("us-east-1::i-failed", instance);
Ec2Service service = new Ec2Service(mockConfig(false), mock(Ec2ContainerManager.class),
mock(Ec2PortForwardManager.class), mock(AmiImageResolver.class), mock(Ec2ImageCatalog.class),
new Ec2InstanceTypeCatalog(),
new InMemoryStorageFactory(Map.of("ec2-instances.json", instanceStore)));
assertTrue(service.describeInstanceStatus("us-east-1", List.of("i-failed"), false).isEmpty());
List<Instance> statuses = service.describeInstanceStatus("us-east-1", List.of("i-failed"), true);
assertEquals(1, statuses.size());
assertEquals("impaired", statuses.getFirst().getSshStatus());
assertEquals("No free port available in range 2200-2209", statuses.getFirst().getSshStatusMessage());
}
@Test
void describeInstanceStatusClearsRecoveredSshLaunchFailure() {
AccountAwareStorageBackend<Instance> instanceStore = AccountAwareStorageBackend.inMemory("000000000000");
Instance instance = persistedInstance("i-recovered", "10.0.1.10", InstanceState.running());
instance.setLaunchStatus("failed");
instance.setSshStatus("impaired");
instance.setStateReasonCode("Client.SSHUnavailable");
instance.setStateReasonMessage("connection refused");
instanceStore.put("us-east-1::i-recovered", instance);
Ec2ContainerManager containerManager = mock(Ec2ContainerManager.class);
when(containerManager.checkSshReadiness(instance))
.thenReturn(new Ec2ContainerManager.OperationResult(true, "SSH banner received: SSH-2.0-test"));
Ec2Service service = new Ec2Service(mockConfig(false), containerManager,
mock(Ec2PortForwardManager.class), mock(AmiImageResolver.class), mock(Ec2ImageCatalog.class),
new Ec2InstanceTypeCatalog(),
new InMemoryStorageFactory(Map.of("ec2-instances.json", instanceStore)));
Instance status = service.describeInstanceStatus("us-east-1", List.of("i-recovered"), false).getFirst();
assertEquals("ready", status.getLaunchStatus());
assertEquals("ok", status.getSshStatus());
assertNull(status.getStateReasonCode());
assertNull(status.getStateReasonMessage());
}
private static Instance persistedInstance(String instanceId, String privateIp, InstanceState state) {
Instance instance = new Instance();
instance.setInstanceId(instanceId);
@@ -0,0 +1,47 @@
package io.github.hectorvent.floci.services.lightsail;
import io.github.hectorvent.floci.core.common.docker.ContainerLifecycleManager;
import org.junit.jupiter.api.Test;
import org.mockito.InOrder;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.mock;
class LightsailContainerManagerTest {
@Test
void deploymentPathsAreRelativeAndTraversalFree() {
assertDoesNotThrow(() -> LightsailContainerManager.validateDeploymentPath("assets/site.css"));
assertThrows(IllegalArgumentException.class, () -> LightsailContainerManager.validateDeploymentPath("../index.html"));
assertThrows(IllegalArgumentException.class, () -> LightsailContainerManager.validateDeploymentPath("assets/../../index.html"));
assertThrows(IllegalArgumentException.class, () -> LightsailContainerManager.validateDeploymentPath("/etc/passwd"));
assertThrows(IllegalArgumentException.class, () -> LightsailContainerManager.validateDeploymentPath("assets\\site.css"));
assertThrows(IllegalArgumentException.class, () -> LightsailContainerManager.validateDeploymentPath("assets//site.css"));
}
@Test
void nodeDeploymentPathsRejectReservedFilesAndSupervisorUsesReleaseCommand() {
assertDoesNotThrow(() -> LightsailContainerManager.validateNodePath("src/server.js"));
assertThrows(IllegalArgumentException.class, () -> LightsailContainerManager.validateNodePath(".floci-start"));
assertThrows(IllegalArgumentException.class, () -> LightsailContainerManager.validateNodePath("../package.json"));
String command = String.join(" ", LightsailContainerManager.nodeSupervisorCommand());
org.junit.jupiter.api.Assertions.assertTrue(command.contains(".floci-start"));
org.junit.jupiter.api.Assertions.assertTrue(command.contains("HOST=0.0.0.0"));
}
@Test
void wordpressTerminationStrictlyRemovesContainersAndBothVolumes() {
ContainerLifecycleManager lifecycleManager = mock(ContainerLifecycleManager.class);
LightsailContainerManager manager = new LightsailContainerManager(null, lifecycleManager, null, null, null);
manager.terminate("wordpress", "mariadb", "mariadb-data", "wordpress-content");
InOrder order = inOrder(lifecycleManager);
order.verify(lifecycleManager).stopAndRemoveStrict("wordpress", null);
order.verify(lifecycleManager).stopAndRemoveStrict("mariadb", null);
order.verify(lifecycleManager).removeVolumeStrict("mariadb-data");
order.verify(lifecycleManager).removeVolumeStrict("wordpress-content");
}
}
@@ -2,9 +2,13 @@ package io.github.hectorvent.floci.services.lightsail;
import io.github.hectorvent.floci.testing.RestAssuredJsonUtils;
import io.quarkus.test.junit.QuarkusTest;
import io.restassured.response.Response;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import java.util.List;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.hasItem;
@@ -24,6 +28,29 @@ class LightsailIntegrationTest {
RestAssuredJsonUtils.configureAwsContentTypes();
}
@AfterEach
void deleteTestInstanceContainers() {
Response instances = given()
.header("X-Amz-Target", TARGET_PREFIX + "GetInstances")
.contentType(CONTENT_TYPE)
.body("{}")
.when()
.post("/");
List<String> instanceNames = instances.jsonPath().getList("instances.name", String.class);
for (String instanceName : instanceNames) {
given()
.header("X-Amz-Target", TARGET_PREFIX + "DeleteInstance")
.contentType(CONTENT_TYPE)
.body("""
{"instanceName":"%s"}
""".formatted(instanceName))
.when()
.post("/")
.then()
.statusCode(200);
}
}
@Test
void instanceDiskAndStaticIpLifecycleRoundTripsThroughJsonHandler() {
given()
@@ -34,7 +61,7 @@ class LightsailIntegrationTest {
.post("/")
.then()
.statusCode(200)
.body("blueprints", hasSize(4))
.body("blueprints", hasSize(6))
.body("blueprints[0].blueprintId", equalTo("ubuntu_22_04"));
given()
@@ -198,6 +225,44 @@ class LightsailIntegrationTest {
.body("operations[0].id", notNullValue());
}
@Test
void publicPortRulesRemainAwsVisibleWithoutInternalForwardMappings() {
given()
.header("X-Amz-Target", TARGET_PREFIX + "CreateInstances")
.contentType(CONTENT_TYPE)
.body("""
{"instanceNames":["port-web"],"availabilityZone":"us-east-1a","blueprintId":"ubuntu_22_04","bundleId":"nano_3_0"}
""")
.when()
.post("/")
.then()
.statusCode(200);
given()
.header("X-Amz-Target", TARGET_PREFIX + "OpenInstancePublicPorts")
.contentType(CONTENT_TYPE)
.body("""
{"instanceName":"port-web","portInfo":{"fromPort":8080,"toPort":8080,"protocol":"tcp","cidrs":["0.0.0.0/0"]}}
""")
.when()
.post("/")
.then()
.statusCode(200);
given()
.header("X-Amz-Target", TARGET_PREFIX + "GetInstance")
.contentType(CONTENT_TYPE)
.body("""
{"instanceName":"port-web"}
""")
.when()
.post("/")
.then()
.statusCode(200)
.body("instance.networking.ports.fromPort", hasItem(8080))
.body("instance._portForwards", equalTo(null));
}
@Test
void keyPairsTagsRegionsAndUnsupportedOperationsUseAwsJsonShape() {
given()
@@ -303,6 +368,69 @@ class LightsailIntegrationTest {
.body("message", equalTo("keyPairName is required"));
}
@Test
void instanceSnapshotsCanBeCreatedRestoredAndDeleted() {
given()
.header("X-Amz-Target", TARGET_PREFIX + "CreateInstances")
.contentType(CONTENT_TYPE)
.body("""
{"instanceNames":["snapshot-source"],"availabilityZone":"us-east-1a","blueprintId":"ubuntu_22_04","bundleId":"nano_3_0"}
""")
.when()
.post("/")
.then()
.statusCode(200);
given()
.header("X-Amz-Target", TARGET_PREFIX + "CreateInstanceSnapshot")
.contentType(CONTENT_TYPE)
.body("""
{"instanceName":"snapshot-source","instanceSnapshotName":"snapshot-one"}
""")
.when()
.post("/")
.then()
.statusCode(200)
.body("operations[0].operationType", equalTo("CreateInstanceSnapshot"));
given()
.header("X-Amz-Target", TARGET_PREFIX + "GetInstanceSnapshot")
.contentType(CONTENT_TYPE)
.body("""
{"instanceSnapshotName":"snapshot-one"}
""")
.when()
.post("/")
.then()
.statusCode(200)
.body("instanceSnapshot.fromInstanceName", equalTo("snapshot-source"))
.body("instanceSnapshot.state", equalTo("available"));
given()
.header("X-Amz-Target", TARGET_PREFIX + "CreateInstancesFromSnapshot")
.contentType(CONTENT_TYPE)
.body("""
{"instanceSnapshotName":"snapshot-one","instanceNames":["snapshot-restored"]}
""")
.when()
.post("/")
.then()
.statusCode(200)
.body("operations[0].operationType", equalTo("CreateInstance"));
given()
.header("X-Amz-Target", TARGET_PREFIX + "DeleteInstanceSnapshot")
.contentType(CONTENT_TYPE)
.body("""
{"instanceSnapshotName":"snapshot-one"}
""")
.when()
.post("/")
.then()
.statusCode(200)
.body("operations[0].operationType", equalTo("DeleteInstanceSnapshot"));
}
@Test
void diskStaticIpAndHardwareEdgeCasesMatchLightsailBehavior() {
given()
@@ -0,0 +1,92 @@
package io.github.hectorvent.floci.services.lightsail;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import io.github.hectorvent.floci.core.common.RegionResolver;
import io.github.hectorvent.floci.core.storage.AccountAwareStorageBackend;
import io.github.hectorvent.floci.core.storage.StorageFactory;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class LightsailNodeJsTest {
private static final String REGION = "us-east-1";
private final ObjectMapper mapper = new ObjectMapper();
private LightsailService service;
private LightsailContainerManager containerManager;
@BeforeEach
void setUp() {
StorageFactory storageFactory = new StorageFactory(null, null) {
@Override
public synchronized <V> AccountAwareStorageBackend<V> create(String serviceName, String fileName,
TypeReference<Map<String, V>> typeReference) {
return AccountAwareStorageBackend.inMemory("000000000000");
}
};
containerManager = mock(LightsailContainerManager.class);
service = new LightsailService(storageFactory, mapper, new RegionResolver(REGION, "000000000000"),
containerManager, null);
}
@Test
void nodeBlueprintUsesCurrentLtsRuntimeAndDoesNotExposeRuntimeMetadata() {
LightsailContainerManager.Runtime runtime = LightsailContainerManager.runtimeFor("nodejs");
assertEquals("node:24-bookworm", runtime.image());
assertEquals("node", runtime.sshUser());
assertEquals(3000, runtime.applicationPort());
assertEquals("nodejs", service.getBlueprints().path("blueprints").get(5).path("blueprintId").asText());
when(containerManager.launch(eq("node-app"), eq(REGION), eq("nodejs"), eq("nano_3_0"), isNull(), anyString()))
.thenReturn(new LightsailContainerManager.Launch("node-container", null, null, null, 22025,
"node-app-volume", "1760000000000"));
ObjectNode request = mapper.createObjectNode()
.put("availabilityZone", REGION + "a")
.put("blueprintId", "nodejs")
.put("bundleId", "nano_3_0");
request.set("instanceNames", mapper.createArrayNode().add("node-app"));
service.createInstances(REGION, request);
ObjectNode instance = (ObjectNode) service.getInstance(REGION, "node-app").path("instance");
assertEquals("nodejs", instance.path("blueprintId").asText());
assertEquals("node", instance.path("username").asText());
assertEquals(3000, instance.path("networking").path("ports").get(1).path("fromPort").asInt());
assertFalse(instance.has("applicationVolume"));
assertFalse(instance.has("applicationRelease"));
assertFalse(instance.has("runtimeMetadata"));
service.deleteInstance(REGION, "node-app");
verify(containerManager).terminate("node-container", null, null, null, "node-app-volume");
}
@Test
void nodeSupervisorInitializesPersistentReleaseLayoutAndRestartsApplications() {
List<String> command = LightsailContainerManager.nodeSupervisorCommand();
assertEquals(List.of("sh", "-c"), command.subList(0, 2));
String script = command.get(2);
assertTrue(script.contains("$app/releases/$FLOCI_LIGHTSAIL_RELEASE_ID"));
assertTrue(script.contains("ln -s \"releases/$FLOCI_LIGHTSAIL_RELEASE_ID\" \"$app/current\""));
assertTrue(script.contains("tail -n +4"));
assertTrue(script.contains("npm start"));
assertTrue(script.contains("app.stdout.log"));
assertTrue(script.contains("app.stderr.log"));
assertTrue(script.contains("app/logs/supervisor.log"));
assertTrue(script.contains("delay=$((delay * 2))"));
}
}
@@ -0,0 +1,56 @@
package io.github.hectorvent.floci.services.lightsail;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.junit.jupiter.api.Test;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class LightsailPortForwardManagerTest {
private final ObjectMapper mapper = new ObjectMapper();
@Test
void extractsOnlyTcpRulesWithCidrSources() {
ArrayNode rules = mapper.createArrayNode()
.add(rule("tcp", 8080, 8081, true))
.add(rule("udp", 53, 53, true))
.add(rule("tcp", 9090, 9090, false));
assertEquals(Set.of(8080, 8081), LightsailPortForwardManager.extractPublishablePorts(rules, 20));
}
@Test
void includesSshForOsContainerDirectBinding() {
ArrayNode rules = mapper.createArrayNode().add(rule("tcp", 22, 22, true));
assertEquals(Set.of(22), LightsailPortForwardManager.extractPublishablePorts(rules, 20));
}
@Test
void skipsRulesWiderThanThePerInstanceLimit() {
ArrayNode rules = mapper.createArrayNode().add(rule("tcp", 1, 100, true));
assertTrue(LightsailPortForwardManager.extractPublishablePorts(rules, 20).isEmpty());
}
@Test
void forwardContainerNameIsDeterministic() {
assertEquals("floci-lightsail-fwd-web-8080", LightsailPortForwardManager.forwardContainerName("web", 8080));
}
private ObjectNode rule(String protocol, int fromPort, int toPort, boolean withCidr) {
ObjectNode rule = mapper.createObjectNode()
.put("protocol", protocol)
.put("fromPort", fromPort)
.put("toPort", toPort);
if (withCidr) {
rule.putArray("cidrs").add("0.0.0.0/0");
}
return rule;
}
}
@@ -100,10 +100,10 @@ class LightsailResourceProviderTest {
class GetSupportedResourceTypes {
@Test
void advertisesFourLightsailTypesAllUnderLightsailService() {
void advertisesLightsailResourceTypesAllUnderLightsailService() {
Set<SupportedResourceType> types = service.getSupportedResourceTypes();
assertEquals(Set.of("lightsail:Instance", "lightsail:Disk",
"lightsail:StaticIp", "lightsail:KeyPair"),
"lightsail:StaticIp", "lightsail:KeyPair", "lightsail:InstanceSnapshot"),
types.stream().map(SupportedResourceType::resourceType).collect(Collectors.toSet()));
assertTrue(types.stream().allMatch(t -> t.service().equals("lightsail")));
}
@@ -0,0 +1,119 @@
package io.github.hectorvent.floci.services.lightsail;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import io.github.hectorvent.floci.core.common.RegionResolver;
import io.github.hectorvent.floci.core.storage.AccountAwareStorageBackend;
import io.github.hectorvent.floci.core.storage.StorageFactory;
import io.github.hectorvent.floci.services.ec2.Ec2KeyMaterial;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class LightsailSshKeyTest {
private static final String REGION = "us-east-1";
private final ObjectMapper mapper = new ObjectMapper();
private LightsailService service;
private LightsailContainerManager containerManager;
@BeforeEach
void setUp() {
StorageFactory storageFactory = new StorageFactory(null, null) {
@Override
public synchronized <V> AccountAwareStorageBackend<V> create(String serviceName, String fileName,
TypeReference<Map<String, V>> typeReference) {
return AccountAwareStorageBackend.inMemory("000000000000");
}
};
containerManager = mock(LightsailContainerManager.class);
service = new LightsailService(storageFactory, mapper, new RegionResolver(REGION, "000000000000"),
containerManager, null);
}
@Test
void generatedKeyPairReturnsUsablePrivatePemAndLaunchesWithItsPublicKey() {
ObjectNode createKey = mapper.createObjectNode().put("keyPairName", "deployer");
ObjectNode keyResponse = service.createKeyPair(REGION, createKey);
String privateKey = new String(Base64.getDecoder().decode(keyResponse.path("privateKeyBase64").asText()),
StandardCharsets.UTF_8);
String publicKey = new String(Base64.getDecoder().decode(keyResponse.path("publicKeyBase64").asText()),
StandardCharsets.UTF_8);
assertTrue(privateKey.startsWith("-----BEGIN RSA PRIVATE KEY-----"));
assertTrue(Ec2KeyMaterial.fingerprintOf(publicKey) != null);
when(containerManager.launch(anyString(), anyString(), anyString(), anyString(), isNull(), anyString()))
.thenReturn(new LightsailContainerManager.Launch("container", null, null, 22022));
ObjectNode createInstance = mapper.createObjectNode()
.put("availabilityZone", REGION + "a")
.put("blueprintId", "ubuntu_22_04")
.put("bundleId", "nano_3_0")
.put("keyPairName", "deployer");
createInstance.set("instanceNames", mapper.createArrayNode().add("ssh-instance"));
service.createInstances(REGION, createInstance);
ArgumentCaptor<String> keyCaptor = ArgumentCaptor.forClass(String.class);
verify(containerManager).launch(eq("ssh-instance"), eq(REGION), eq("ubuntu_22_04"),
eq("nano_3_0"), isNull(), keyCaptor.capture());
assertEquals(publicKey, keyCaptor.getValue());
assertEquals(22022, service.hostPortMappings(REGION, "ssh-instance").getFirst().get("hostPort"));
}
@Test
void defaultKeyPairIsGeneratedAndSelectedWhenNoKeyNameIsProvided() {
when(containerManager.launch(anyString(), anyString(), anyString(), anyString(), isNull(), anyString()))
.thenReturn(new LightsailContainerManager.Launch("container", null, null, 22023));
ObjectNode request = mapper.createObjectNode()
.put("availabilityZone", REGION + "a")
.put("blueprintId", "amazon_linux_2023")
.put("bundleId", "nano_3_0");
request.set("instanceNames", mapper.createArrayNode().add("default-ssh"));
service.createInstances(REGION, request);
ObjectNode key = service.downloadDefaultKeyPair(REGION);
String privateKey = new String(Base64.getDecoder().decode(key.path("privateKeyBase64").asText()), StandardCharsets.UTF_8);
assertTrue(privateKey.startsWith("-----BEGIN RSA PRIVATE KEY-----"));
assertEquals("LightsailDefaultKeyPair", service.getInstance(REGION, "default-ssh")
.path("instance").path("sshKeyName").asText());
}
@Test
void importedKeyPairIsDecodedAndSelectedForTheInstance() {
Ec2KeyMaterial.Generated generated = Ec2KeyMaterial.generateRsa();
ObjectNode imported = mapper.createObjectNode().put("keyPairName", "imported").put("publicKeyBase64",
Base64.getEncoder().encodeToString(generated.openSshPublicKey().getBytes(StandardCharsets.UTF_8)));
service.importKeyPair(REGION, imported);
when(containerManager.launch(anyString(), anyString(), anyString(), anyString(), isNull(), anyString()))
.thenReturn(new LightsailContainerManager.Launch("container", null, null, 22024));
ObjectNode request = mapper.createObjectNode()
.put("availabilityZone", REGION + "a")
.put("blueprintId", "ubuntu_24_04")
.put("bundleId", "nano_3_0")
.put("keyPairName", "imported");
request.set("instanceNames", mapper.createArrayNode().add("imported-ssh"));
service.createInstances(REGION, request);
ArgumentCaptor<String> keyCaptor = ArgumentCaptor.forClass(String.class);
verify(containerManager).launch(eq("imported-ssh"), eq(REGION), eq("ubuntu_24_04"),
eq("nano_3_0"), isNull(), keyCaptor.capture());
assertEquals(generated.openSshPublicKey(), keyCaptor.getValue());
}
}
@@ -0,0 +1,75 @@
package io.github.hectorvent.floci.services.lightsail;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import io.github.hectorvent.floci.core.common.RegionResolver;
import io.github.hectorvent.floci.core.storage.AccountAwareStorageBackend;
import io.github.hectorvent.floci.core.storage.StorageFactory;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class LightsailWordPressTest {
private static final String REGION = "us-east-1";
private final ObjectMapper mapper = new ObjectMapper();
private LightsailService service;
private LightsailContainerManager containerManager;
@BeforeEach
void setUp() {
StorageFactory storageFactory = new StorageFactory(null, null) {
@Override
public synchronized <V> AccountAwareStorageBackend<V> create(String serviceName, String fileName,
TypeReference<Map<String, V>> typeReference) {
return AccountAwareStorageBackend.inMemory("000000000000");
}
};
containerManager = mock(LightsailContainerManager.class);
service = new LightsailService(storageFactory, mapper, new RegionResolver(REGION, "000000000000"),
containerManager, null);
}
@Test
void wordpressLaunchesAndManagesMariaDbWithoutLeakingRuntimeFields() {
when(containerManager.launch(eq("blog"), eq(REGION), eq("wordpress"), eq("nano_3_0"), isNull(), anyString()))
.thenReturn(new LightsailContainerManager.Launch("wordpress-id", "mariadb-id", "mariadb-data",
"wordpress-content", null));
ObjectNode request = mapper.createObjectNode()
.put("availabilityZone", REGION + "a")
.put("blueprintId", "wordpress")
.put("bundleId", "nano_3_0");
request.set("instanceNames", mapper.createArrayNode().add("blog"));
service.createInstances(REGION, request);
ObjectNode instance = (ObjectNode) service.getInstance(REGION, "blog").path("instance");
assertEquals("wordpress", instance.path("blueprintId").asText());
assertEquals(80, instance.path("networking").path("ports").get(1).path("fromPort").asInt());
assertFalse(instance.has("containerId"));
assertFalse(instance.has("databaseContainerId"));
assertFalse(instance.has("databaseVolume"));
assertFalse(instance.has("wordpressContentVolume"));
service.stopInstance(REGION, "blog");
service.startInstance(REGION, "blog");
service.rebootInstance(REGION, "blog");
service.deleteInstance(REGION, "blog");
verify(containerManager).stop("wordpress-id", "mariadb-id");
verify(containerManager).start("wordpress-id", "mariadb-id", false);
verify(containerManager).reboot("wordpress-id", "mariadb-id", false);
verify(containerManager).terminate("wordpress-id", "mariadb-id", "mariadb-data", "wordpress-content", null);
}
}
@@ -83,7 +83,7 @@ class AmiImageToolTest {
String catalogText = Files.readString(catalog);
assertTrue(catalogText.contains("ami-existing"));
assertTrue(catalogText.contains("ami-ubuntu2404-cloud-arm64"));
assertTrue(catalogText.contains("guestRuntime: \"systemd\""));
assertTrue(catalogText.contains("initType: \"systemd\""));
assertTrue(catalogText.contains("cloudInit: true"));
assertTrue(catalogText.contains("custom-output/ubuntu-24.04-arm64/provenance.yaml"));
}