Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support App diagnostics endpoint features #76

Merged
merged 9 commits into from
Sep 13, 2024
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

Expand All @@ -27,22 +28,15 @@ private HttpHeaders getHeaders() {
@Value("${inngest.serveOrigin:}")
private String serveOrigin;

@GetMapping()
@GetMapping
public ResponseEntity<String> index(
@RequestHeader(HttpHeaders.HOST) String hostHeader,
HttpServletRequest request
@RequestHeader(name = "X-Inngest-Signature", required = false) String signature,
@RequestHeader(name = "X-Inngest-Server-Kind", required = false) String serverKind
) {
if (commHandler.getClient().getEnv() != InngestEnv.Dev) {
// TODO: Return an UnauthenticatedIntrospection instead when app diagnostics are implemented
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body("Introspect endpoint is only available in development mode");
}
String origin = String.format("%s://%s", request.getScheme(), hostHeader);
if (this.serveOrigin != null && !this.serveOrigin.isEmpty()) {
origin = this.serveOrigin;
}
String response = commHandler.introspect(origin);
return ResponseEntity.ok().headers(getHeaders()).body(response);
String requestBody = "";
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

don't we need to get the actual request body somehow?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmm i believe it gave me an error trying to get the body of a GET request and later on trying it in cloud mode, it didn't seem to be sending a body. I'll try again to get it.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh I forgot this is a GET, so yeah you're right there probably won't be a request body. Should we make the argument optional then?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

String response = commHandler.introspect(signature, requestBody, serverKind);
return ResponseEntity.ok().headers(getHeaders()).contentType(MediaType.APPLICATION_JSON).body(response);
}

@PutMapping()
Expand Down
11 changes: 11 additions & 0 deletions inngest-spring-boot-demo/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ dependencies {
implementation("com.squareup.okhttp3:okhttp:4.12.0")

testImplementation("org.springframework.boot:spring-boot-starter-test")

if (JavaVersion.current().isJava11Compatible) {
testImplementation("uk.org.webcompere:system-stubs-jupiter:2.1.6")
} else {
testImplementation("uk.org.webcompere:system-stubs-jupiter:1.2.1")
}
Comment on lines +30 to +34
Copy link
Collaborator Author

@KiKoS0 KiKoS0 Sep 7, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't like this at all but it works and it's only a test dependency. https://github.com/webcompere/system-stubs?tab=readme-ov-file#system-stubs

⚠ WARNING: JDK Support.
This project has now moved to a JDK11 minimum version

The v2.x branch is the LTS version. However, there is best effort support to keep the Java 8 compatible
v1.x branch.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see, that sucks we can't just use one version for everything but this seems like an OK solution given they've moved their LTS to a newer JDK than inngest-kt's minimum

}

dependencyManagement {
Expand All @@ -39,6 +45,11 @@ dependencyManagement {
tasks.withType<Test> {
useJUnitPlatform()
systemProperty("junit.jupiter.execution.parallel.enabled", true)
systemProperty("test-group", "unit-test")

// Required by `system-stubs-jupiter` for JDK 21+ compatibility
// https://github.com/raphw/byte-buddy/issues/1396
jvmArgs = listOf("-Dnet.bytebuddy.experimental=true")
KiKoS0 marked this conversation as resolved.
Show resolved Hide resolved
testLogging {
events =
setOf(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
package com.inngest.springbootdemo;

import com.inngest.*;
import com.inngest.signingkey.BearerTokenKt;
import com.inngest.signingkey.SignatureVerificationKt;
import com.inngest.springboot.InngestConfiguration;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
import org.springframework.test.web.servlet.MockMvc;
import uk.org.webcompere.systemstubs.environment.EnvironmentVariables;
import uk.org.webcompere.systemstubs.jupiter.SystemStub;
import uk.org.webcompere.systemstubs.jupiter.SystemStubsExtension;

import java.util.HashMap;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

class ProductionConfiguration extends InngestConfiguration {

public static final String INNGEST_APP_ID = "spring_test_prod_demo";

@Override
protected HashMap<String, InngestFunction> functions() {
return new HashMap<>();
}

@Override
protected Inngest inngestClient() {
return new Inngest(INNGEST_APP_ID);
}

@Override
protected ServeConfig serve(Inngest client) {
return new ServeConfig(client);
}

@Bean
protected CommHandler commHandler(@Autowired Inngest inngestClient) {
ServeConfig serveConfig = new ServeConfig(inngestClient);
return new CommHandler(functions(), inngestClient, serveConfig, SupportedFrameworkName.SpringBoot);
}
}

@ExtendWith(SystemStubsExtension.class)
public class CloudModeIntrospectionTest {

private static final String productionSigningKey = "signkey-prod-b2ed992186a5cb19f6668aade821f502c1d00970dfd0e35128d51bac4649916c";
private static final String productionEventKey = "test";
@SystemStub
private static EnvironmentVariables environmentVariables;

@BeforeAll
static void beforeAll() {
environmentVariables.set("INNGEST_DEV", "0");
environmentVariables.set("INNGEST_SIGNING_KEY", productionSigningKey);
environmentVariables.set("INNGEST_EVENT_KEY", productionEventKey);
}

// The nested class is useful for setting the environment variables before the configuration class (Beans) runs.
// https://www.baeldung.com/java-system-stubs#environment-and-property-overrides-for-junit-5-spring-tests
KiKoS0 marked this conversation as resolved.
Show resolved Hide resolved
@Import(ProductionConfiguration.class)
@WebMvcTest(DemoController.class)
@Nested
@EnabledIfSystemProperty(named = "test-group", matches = "unit-test")
class InnerSpringTest {
@Autowired
private MockMvc mockMvc;

@Test
public void shouldReturnInsecureIntrospectionWhenSignatureIsMissing() throws Exception {
mockMvc.perform(get("/api/inngest").header("Host", "localhost:8080"))
.andExpect(status().isOk())
.andExpect(content().contentType("application/json"))
.andExpect(header().string(InngestHeaderKey.Framework.getValue(), "springboot"))
.andExpect(jsonPath("$.authentication_succeeded").value(false))
.andExpect(jsonPath("$.function_count").isNumber())
.andExpect(jsonPath("$.has_event_key").value(true))
.andExpect(jsonPath("$.has_signing_key").value(true))
.andExpect(jsonPath("$.mode").value("cloud"))
.andExpect(jsonPath("$.schema_version").value("2024-05-24"));
}

@Test
public void shouldReturnInsecureIntrospectionWhenSignatureIsInvalid() throws Exception {
mockMvc.perform(get("/api/inngest")
.header("Host", "localhost:8080")
.header(InngestHeaderKey.Signature.getValue(), "invalid-signature"))
.andExpect(status().isOk())
.andExpect(content().contentType("application/json"))
.andExpect(header().string(InngestHeaderKey.Framework.getValue(), "springboot"))
.andExpect(jsonPath("$.authentication_succeeded").value(false))
.andExpect(jsonPath("$.function_count").isNumber())
.andExpect(jsonPath("$.has_event_key").value(true))
.andExpect(jsonPath("$.has_signing_key").value(true))
.andExpect(jsonPath("$.mode").value("cloud"))
.andExpect(jsonPath("$.schema_version").value("2024-05-24"));
}

@Test
public void shouldReturnSecureIntrospectionWhenSignatureIsValid() throws Exception {
long currentTimestamp = System.currentTimeMillis() / 1000;

String signature = SignatureVerificationKt.signRequest("", currentTimestamp, productionSigningKey);
String formattedSignature = String.format("s=%s&t=%d", signature, currentTimestamp);

String expectedSigningKeyHash = BearerTokenKt.hashedSigningKey(productionSigningKey);

mockMvc.perform(get("/api/inngest")
.header("Host", "localhost:8080")
.header(InngestHeaderKey.Signature.getValue(), formattedSignature))
.andExpect(status().isOk())
.andExpect(content().contentType("application/json"))
.andExpect(header().string(InngestHeaderKey.Framework.getValue(), "springboot"))
.andExpect(jsonPath("$.authentication_succeeded").value(true))
.andExpect(jsonPath("$.function_count").isNumber())
.andExpect(jsonPath("$.has_event_key").value(true))
.andExpect(jsonPath("$.has_signing_key").value(true))
.andExpect(jsonPath("$.mode").value("cloud"))
.andExpect(jsonPath("$.schema_version").value("2024-05-24"))
.andExpect(jsonPath("$.api_origin").value("https://api.inngest.com/"))
.andExpect(jsonPath("$.app_id").value(ProductionConfiguration.INNGEST_APP_ID))
.andExpect(jsonPath("$.env").value("prod"))
.andExpect(jsonPath("$.event_api_origin").value("https://inn.gs/"))
.andExpect(jsonPath("$.framework").value("springboot"))
.andExpect(jsonPath("$.sdk_language").value("java"))
.andExpect(jsonPath("$.event_key_hash").value("9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"))
.andExpect(jsonPath("$.sdk_version").value(Version.Companion.getVersion()))
.andExpect(jsonPath("$.signing_key_hash").value(expectedSigningKeyHash));
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import com.inngest.InngestHeaderKey;
import com.inngest.Version;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.context.annotation.Import;
Expand All @@ -13,21 +14,23 @@

@Import(DemoTestConfiguration.class)
@WebMvcTest(DemoController.class)
public class DemoControllerTest {
public class DevModeIntrospectionTest {

@Autowired
private MockMvc mockMvc;

@Test
public void shouldReturnSyncPayload() throws Exception {
@EnabledIfSystemProperty(named = "test-group", matches = "unit-test")
public void shouldReturnInsecureIntrospectPayload() throws Exception {
mockMvc.perform(get("/api/inngest").header("Host", "localhost:8080"))
.andExpect(status().isOk())
.andExpect(content().contentType("application/json"))
.andExpect(header().string(InngestHeaderKey.Framework.getValue(), "springboot"))
.andExpect(jsonPath("$.appName").value("spring_test_demo"))
.andExpect(jsonPath("$.framework").value("springboot"))
.andExpect(jsonPath("$.v").value("0.1"))
.andExpect(jsonPath("$.url").value("http://localhost:8080/api/inngest"))
.andExpect(jsonPath("$.sdk").value(String.format("java:v%s", Version.Companion.getVersion())));
.andExpect(jsonPath("$.authentication_succeeded").isEmpty())
.andExpect(jsonPath("$.function_count").isNumber())
.andExpect(jsonPath("$.has_event_key").value(false))
.andExpect(jsonPath("$.has_signing_key").value(false))
.andExpect(jsonPath("$.mode").value("dev"))
.andExpect(jsonPath("$.schema_version").value("2024-05-24"));
}
}
1 change: 1 addition & 0 deletions inngest/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ dependencies {
implementation("io.ktor:ktor-server-core:2.3.5")

testImplementation(kotlin("test"))
testImplementation("org.junit-pioneer:junit-pioneer:1.9.1")
}

publishing {
Expand Down
59 changes: 57 additions & 2 deletions inngest/src/main/kotlin/com/inngest/Comm.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@ import com.beust.klaxon.Json
import com.beust.klaxon.Klaxon
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.SerializationFeature
import com.inngest.signingkey.checkHeadersAndValidateSignature
import com.inngest.signingkey.getAuthorizationHeader
import com.inngest.signingkey.hashedSigningKey
import java.io.IOException
import java.security.MessageDigest

data class ExecutionRequestPayload(
val ctx: ExecutionContext,
Expand Down Expand Up @@ -177,8 +180,50 @@ class CommHandler(
// TODO
// fun sync(): Result<InngestSyncResult> = Result.success(InngestSyncResult.None)

fun introspect(origin: String): String {
val requestPayload = getRegistrationRequestPayload(origin)
fun introspect(
signature: String?,
requestBody: String,
serverKind: String?,
): String {
val insecureIntrospection =
InsecureIntrospection(
functionCount = functions.size,
hasEventKey = Environment.isInngestEventKeySet(client.eventKey),
hasSigningKey = config.hasSigningKey(),
mode = if (client.env == InngestEnv.Dev) "dev" else "cloud",
)

val requestPayload =
when (client.env) {
InngestEnv.Dev -> insecureIntrospection

else ->
runCatching {
KiKoS0 marked this conversation as resolved.
Show resolved Hide resolved
checkHeadersAndValidateSignature(signature, requestBody, serverKind, config)

SecureIntrospection(
functionCount = functions.size,
hasEventKey = Environment.isInngestEventKeySet(client.eventKey),
hasSigningKey = config.hasSigningKey(),
authenticationSucceeded = true,
mode = "cloud",
env = client.env.value,
appId = config.appId(),
apiOrigin = "${config.baseUrl()}/",
framework = framework.value,
sdkVersion = Version.getVersion(),
sdkLanguage = "java",
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I went with java as the sdk language following this: https://linear.app/inngest/project/javakotlin-sdk-0e841a80efe2#projectUpdate-dd0137ce&comment-1288aae4
Let me know if we want a different value.

servePath = config.servePath(),
serveOrigin = config.serveOrigin(),
signingKeyHash = hashedSigningKey(config.signingKey()),
eventApiOrigin = "${Environment.inngestEventApiBaseUrl(client.env)}/",
eventKeyHash = if (config.hasSigningKey()) hashedEventKey(client.eventKey) else null,
)
}.getOrElse {
insecureIntrospection.apply { authenticationSucceeded = false }
}
}

return serializePayload(requestPayload)
}

Expand All @@ -198,4 +243,14 @@ class CommHandler(
val servePath = config.servePath() ?: "/api/inngest"
return "$serveOrigin$servePath"
}

private fun hashedEventKey(eventKey: String): String? =
eventKey
.takeIf { Environment.isInngestEventKeySet(it) }
?.let {
MessageDigest
.getInstance("SHA-256")
.digest(it.toByteArray())
.joinToString("") { byte -> "%02x".format(byte) }
}
}
11 changes: 10 additions & 1 deletion inngest/src/main/kotlin/com/inngest/Environment.kt
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,20 @@ object Environment {
).filterValues { (it is String) }.entries.associate { (k, v) -> k to v!! }
}

private const val DUMMY_KEY_EVENT = "NO_EVENT_KEY_SET"

fun inngestEventKey(key: String? = null): String {
if (key != null) return key
return System.getenv(InngestSystem.EventKey.value) ?: "NO_EVENT_KEY_SET"
return System.getenv(InngestSystem.EventKey.value) ?: DUMMY_KEY_EVENT
}

fun isInngestEventKeySet(value: String?) =
when {
value.isNullOrEmpty() -> false
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this because the empty case isn't being handled properly by the elvis operator on line 18? nulls would get replaced by the DUMMY_KEY_EVENT right?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

exactly yeah i should probably check if other SDKs are using a null value or not.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

value == DUMMY_KEY_EVENT -> false
else -> true
}

fun inngestEventApiBaseUrl(
env: InngestEnv,
url: String? = null,
Expand Down
43 changes: 43 additions & 0 deletions inngest/src/main/kotlin/com/inngest/Introspection.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package com.inngest

import com.beust.klaxon.Json

abstract class Introspection(
@Json("authentication_succeeded") open val authenticationSucceeded: Boolean?,
open val functionCount: Int,
open val hasEventKey: Boolean,
open val hasSigningKey: Boolean,
open val mode: String,
@Json("schema_version") val schemaVersion: String = "2024-05-24",
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is it possible to use @JsonNaming(PropertyNamingStrategy.SnakeCaseStrategy.class) so you don't have to manually snake case every property? I found this on https://stackoverflow.com/questions/10519265/jackson-overcoming-underscores-in-favor-of-camel-case

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

great suggestion i was really hoping for it to work but it doesn't seem that it works with Klaxon and i don't think it has an equivalent for it either. I wonder if a custom converter would work though.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh right I missed the SO thread was about Jackson specifically. This could be nice as a follow up but non blocking for this PR

)

internal data class InsecureIntrospection(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

any reason for using insecure/secure as the terminology when the spec and JS SDK are using unauthenticated/authenticated?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i started implementing closely with the Go sdk, i can rename them since the spec and both JS and Python has it as unauthenticated/authenticated.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah ok, fine to leave as is if there's precedent in the go sdk

@Json("authentication_succeeded") override var authenticationSucceeded: Boolean? = null,
@Json("function_count") override val functionCount: Int,
@Json("has_event_key") override val hasEventKey: Boolean,
@Json("has_signing_key") override val hasSigningKey: Boolean,
override val mode: String,
) : Introspection(authenticationSucceeded, functionCount, hasEventKey, hasSigningKey, mode)

internal data class SecureIntrospection(
@Json("api_origin") val apiOrigin: String,
@Json("app_id") val appId: String,
@Json("authentication_succeeded") override val authenticationSucceeded: Boolean?,
// TODO: Add capabilities when adding the trust probe
// @Json("capabilities") val capabilities: Capabilities,
@Json("event_api_origin") val eventApiOrigin: String,
@Json("event_key_hash") val eventKeyHash: String?,
val env: String?,
val framework: String,
@Json("function_count") override val functionCount: Int,
@Json("has_event_key") override val hasEventKey: Boolean,
@Json("has_signing_key") override val hasSigningKey: Boolean,
@Json("has_signing_key_fallback") val hasSigningKeyFallback: Boolean = false,
override val mode: String,
@Json("sdk_language") val sdkLanguage: String,
@Json("sdk_version") val sdkVersion: String,
@Json("serve_origin") val serveOrigin: String?,
@Json("serve_path") val servePath: String?,
@Json("signing_key_fallback_hash") val signingKeyFallbackHash: String? = null,
@Json("signing_key_hash") val signingKeyHash: String?,
) : Introspection(authenticationSucceeded, functionCount, hasEventKey, hasSigningKey, mode)
Loading