diff --git a/src/main/java/io/github/hectorvent/floci/core/common/ServiceRegistry.java b/src/main/java/io/github/hectorvent/floci/core/common/ServiceRegistry.java index 5da4e28f..a53334ad 100644 --- a/src/main/java/io/github/hectorvent/floci/core/common/ServiceRegistry.java +++ b/src/main/java/io/github/hectorvent/floci/core/common/ServiceRegistry.java @@ -6,7 +6,9 @@ import org.jboss.logging.Logger; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; /** * Registry of enabled AWS services based on configuration. @@ -80,6 +82,41 @@ public List getEnabledServices() { return enabled; } + /** + * Returns all known services with their status: "running" if enabled, "available" if not. + */ + public Map getServices() { + Map services = new LinkedHashMap<>(); + services.put("ssm", status(config.services().ssm().enabled())); + services.put("sqs", status(config.services().sqs().enabled())); + services.put("s3", status(config.services().s3().enabled())); + services.put("dynamodb", status(config.services().dynamodb().enabled())); + services.put("sns", status(config.services().sns().enabled())); + services.put("lambda", status(config.services().lambda().enabled())); + services.put("apigateway", status(config.services().apigateway().enabled())); + services.put("iam", status(config.services().iam().enabled())); + services.put("elasticache", status(config.services().elasticache().enabled())); + services.put("rds", status(config.services().rds().enabled())); + services.put("events", status(config.services().eventbridge().enabled())); + services.put("logs", status(config.services().cloudwatchlogs().enabled())); + services.put("monitoring", status(config.services().cloudwatchmetrics().enabled())); + services.put("secretsmanager", status(config.services().secretsmanager().enabled())); + services.put("apigatewayv2", status(config.services().apigatewayv2().enabled())); + services.put("kinesis", status(config.services().kinesis().enabled())); + services.put("kms", status(config.services().kms().enabled())); + services.put("cognito-idp", status(config.services().cognito().enabled())); + services.put("states", status(config.services().stepfunctions().enabled())); + services.put("cloudformation", status(config.services().cloudformation().enabled())); + services.put("acm", status(config.services().acm().enabled())); + services.put("email", status(config.services().ses().enabled())); + services.put("es", status(config.services().opensearch().enabled())); + return services; + } + + private static String status(boolean enabled) { + return enabled ? "running" : "available"; + } + public void logEnabledServices() { LOG.infov("Enabled services: {0}", getEnabledServices()); } diff --git a/src/main/java/io/github/hectorvent/floci/lifecycle/HealthController.java b/src/main/java/io/github/hectorvent/floci/lifecycle/HealthController.java new file mode 100644 index 00000000..2eea07fc --- /dev/null +++ b/src/main/java/io/github/hectorvent/floci/lifecycle/HealthController.java @@ -0,0 +1,49 @@ +package io.github.hectorvent.floci.lifecycle; + +import io.github.hectorvent.floci.core.common.ServiceRegistry; +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Internal health endpoint at /_floci/health. + * Returns the Floci version and the status of each enabled service. + * Compatible with the LocalStack /_localstack/health pattern. + */ +@Path("{path:(_floci|_localstack)/health}") +@Produces(MediaType.APPLICATION_JSON) +public class HealthController { + + private final ServiceRegistry serviceRegistry; + private final String version; + + @Inject + public HealthController(ServiceRegistry serviceRegistry) { + this.serviceRegistry = serviceRegistry; + this.version = resolveVersion(); + } + + @GET + public Response health() { + Map result = new LinkedHashMap<>(); + result.put("services", serviceRegistry.getServices()); + result.put("edition", "floci-always-free"); + result.put("version", version); + + return Response.ok(result).build(); + } + + private static String resolveVersion() { + String env = System.getenv("FLOCI_VERSION"); + if (env != null && !env.isBlank()) { + return env; + } + return "dev"; + } +} diff --git a/src/test/java/io/github/hectorvent/floci/lifecycle/HealthControllerIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/lifecycle/HealthControllerIntegrationTest.java new file mode 100644 index 00000000..a8db15e4 --- /dev/null +++ b/src/test/java/io/github/hectorvent/floci/lifecycle/HealthControllerIntegrationTest.java @@ -0,0 +1,73 @@ +package io.github.hectorvent.floci.lifecycle; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.quarkus.test.junit.QuarkusTest; +import org.junit.jupiter.api.Test; + +import static io.restassured.RestAssured.given; +import static org.junit.jupiter.api.Assertions.assertEquals; + +@QuarkusTest +class HealthControllerIntegrationTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private static final String EXPECTED_HEALTH_JSON = """ + { + "services": { + "ssm": "running", + "sqs": "running", + "s3": "running", + "dynamodb": "running", + "sns": "running", + "lambda": "running", + "apigateway": "running", + "iam": "running", + "elasticache": "running", + "rds": "running", + "events": "running", + "logs": "running", + "monitoring": "running", + "secretsmanager": "running", + "apigatewayv2": "running", + "kinesis": "running", + "kms": "running", + "cognito-idp": "running", + "states": "running", + "cloudformation": "running", + "acm": "running", + "email": "running", + "es": "running" + }, + "edition": "floci-always-free", + "version": "dev" + } + """; + + @Test + void healthEndpoint_returnsExpectedJson() throws Exception { + String body = given() + .when() + .get("/_floci/health") + .then() + .statusCode(200) + .contentType("application/json") + .extract().body().asString(); + + assertEquals(MAPPER.readTree(EXPECTED_HEALTH_JSON), MAPPER.readTree(body)); + } + + @Test + void healthEndpoint_localstackCompatPath() throws Exception { + String body = given() + .when() + .get("/_localstack/health") + .then() + .statusCode(200) + .contentType("application/json") + .extract().body().asString(); + + assertEquals(MAPPER.readTree(EXPECTED_HEALTH_JSON), MAPPER.readTree(body)); + } +}