diff --git a/documentation/openapi.json b/documentation/openapi.json index f64d016a..38512186 100644 --- a/documentation/openapi.json +++ b/documentation/openapi.json @@ -30,11 +30,54 @@ } } }, + "401": { + "description": "Not Authorized" + }, "403": { "description": "Not Allowed" + } + }, + "security": [ + { + "SecurityScheme": [] + } + ] + } + }, + "/service/appdefinition/{appId}": { + "get": { + "tags": ["App Definition Resource"], + "summary": "List app definitions", + "description": "List available app definitions.", + "parameters": [ + { + "name": "appId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AppDefinitionSpec" + } + } + } + } }, "401": { "description": "Not Authorized" + }, + "403": { + "description": "Not Allowed" } }, "security": [ @@ -69,11 +112,11 @@ } } }, - "403": { - "description": "Not Allowed" - }, "401": { "description": "Not Authorized" + }, + "403": { + "description": "Not Allowed" } }, "security": [ @@ -106,11 +149,11 @@ } } }, - "403": { - "description": "Not Allowed" - }, "401": { "description": "Not Authorized" + }, + "403": { + "description": "Not Allowed" } }, "security": [ @@ -143,11 +186,11 @@ } } }, - "403": { - "description": "Not Allowed" - }, "401": { "description": "Not Authorized" + }, + "403": { + "description": "Not Allowed" } }, "security": [ @@ -191,11 +234,11 @@ } } }, - "403": { - "description": "Not Allowed" - }, "401": { "description": "Not Authorized" + }, + "403": { + "description": "Not Allowed" } }, "security": [ @@ -242,11 +285,11 @@ } } }, - "403": { - "description": "Not Allowed" - }, "401": { "description": "Not Authorized" + }, + "403": { + "description": "Not Allowed" } }, "security": [ @@ -281,11 +324,11 @@ } } }, - "403": { - "description": "Not Allowed" - }, "401": { "description": "Not Authorized" + }, + "403": { + "description": "Not Allowed" } }, "security": [ @@ -318,11 +361,11 @@ } } }, - "403": { - "description": "Not Allowed" - }, "401": { "description": "Not Authorized" + }, + "403": { + "description": "Not Allowed" } }, "security": [ @@ -369,11 +412,11 @@ } } }, - "403": { - "description": "Not Allowed" - }, "401": { "description": "Not Authorized" + }, + "403": { + "description": "Not Allowed" } }, "security": [ @@ -409,11 +452,11 @@ } } }, - "403": { - "description": "Not Allowed" - }, "401": { "description": "Not Authorized" + }, + "403": { + "description": "Not Allowed" } }, "security": [ @@ -426,6 +469,108 @@ }, "components": { "schemas": { + "ActivityTracker": { + "type": "object", + "properties": { + "timeoutAfter": { + "format": "int32", + "type": "integer" + }, + "notifyAfter": { + "format": "int32", + "type": "integer" + } + } + }, + "AppDefinitionListRequest": { + "description": "A request to list available app definitions.", + "required": ["appId"], + "type": "object", + "properties": { + "appId": { + "description": "The App Id of this Theia Cloud instance. Request without a matching Id will be denied.", + "type": "string" + } + } + }, + "AppDefinitionSpec": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "image": { + "type": "string" + }, + "imagePullPolicy": { + "type": "string" + }, + "pullSecret": { + "type": "string" + }, + "uid": { + "format": "int32", + "type": "integer" + }, + "port": { + "format": "int32", + "type": "integer" + }, + "ingressname": { + "type": "string" + }, + "minInstances": { + "format": "int32", + "type": "integer" + }, + "maxInstances": { + "format": "int32", + "type": "integer" + }, + "timeout": { + "format": "int32", + "type": "integer" + }, + "requestsMemory": { + "type": "string" + }, + "requestsCpu": { + "type": "string" + }, + "limitsMemory": { + "type": "string" + }, + "limitsCpu": { + "type": "string" + }, + "downlinkLimit": { + "format": "int32", + "type": "integer" + }, + "uplinkLimit": { + "format": "int32", + "type": "integer" + }, + "mountPath": { + "type": "string" + }, + "monitor": { + "$ref": "#/components/schemas/Monitor" + }, + "options": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "ingressHostnamePrefixes": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "EnvironmentVars": { "description": "An object to hold all the ways environment variables can be passed. Not to be used by itself.", "type": "object", @@ -498,6 +643,18 @@ } } }, + "Monitor": { + "type": "object", + "properties": { + "port": { + "format": "int32", + "type": "integer" + }, + "activityTracker": { + "$ref": "#/components/schemas/ActivityTracker" + } + } + }, "PingRequest": { "description": "Request to ping the availability of the service.", "required": ["appId"], diff --git a/java/common/maven-conf/pom.xml b/java/common/maven-conf/pom.xml index b4683b61..9a0c3cbf 100644 --- a/java/common/maven-conf/pom.xml +++ b/java/common/maven-conf/pom.xml @@ -23,6 +23,7 @@ 3.4.0 2.2.2 5.11.0 + 5.14.2 20240303 4.7.6 1.1.4 diff --git a/java/common/org.eclipse.theia.cloud.common/pom.xml b/java/common/org.eclipse.theia.cloud.common/pom.xml index 22adab06..aff38b82 100644 --- a/java/common/org.eclipse.theia.cloud.common/pom.xml +++ b/java/common/org.eclipse.theia.cloud.common/pom.xml @@ -37,6 +37,13 @@ ${junit-jupiter.version} test + + + org.mockito + mockito-core + ${mockito.version} + test + diff --git a/java/common/org.eclipse.theia.cloud.common/src/main/java/org/eclipse/theia/cloud/common/k8s/resource/appdefinition/AppDefinitionSpec.java b/java/common/org.eclipse.theia.cloud.common/src/main/java/org/eclipse/theia/cloud/common/k8s/resource/appdefinition/AppDefinitionSpec.java index 7478d9cb..8ac0e418 100644 --- a/java/common/org.eclipse.theia.cloud.common/src/main/java/org/eclipse/theia/cloud/common/k8s/resource/appdefinition/AppDefinitionSpec.java +++ b/java/common/org.eclipse.theia.cloud.common/src/main/java/org/eclipse/theia/cloud/common/k8s/resource/appdefinition/AppDefinitionSpec.java @@ -20,6 +20,7 @@ import java.util.Map; import org.eclipse.theia.cloud.common.k8s.resource.appdefinition.hub.AppDefinitionHub; +import org.eclipse.theia.cloud.common.serialization.SensitiveData; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.annotation.JsonDeserialize; @@ -37,6 +38,7 @@ public class AppDefinitionSpec { private String imagePullPolicy; @JsonProperty("pullSecret") + @SensitiveData private String pullSecret; @JsonProperty("uid") @@ -212,10 +214,11 @@ public List getIngressHostnamePrefixes() { @Override public String toString() { + final String redactedPullSecret = "***"; return "AppDefinitionSpec [name=" + name + ", image=" + image + ", imagePullPolicy=" + imagePullPolicy - + ", pullSecret=" + pullSecret + ", uid=" + uid + ", port=" + port + ", ingressname=" + ingressname - + ", minInstances=" + minInstances + ", maxInstances=" + maxInstances + ", timeout=" + timeout - + ", requestsMemory=" + requestsMemory + ", requestsCpu=" + requestsCpu + ", limitsMemory=" + + ", pullSecret=" + redactedPullSecret + ", uid=" + uid + ", port=" + port + ", ingressname=" + + ingressname + ", minInstances=" + minInstances + ", maxInstances=" + maxInstances + ", timeout=" + + timeout + ", requestsMemory=" + requestsMemory + ", requestsCpu=" + requestsCpu + ", limitsMemory=" + limitsMemory + ", limitsCpu=" + limitsCpu + ", downlinkLimit=" + downlinkLimit + ", uplinkLimit=" + uplinkLimit + ", mountPath=" + mountPath + "]"; } diff --git a/java/common/org.eclipse.theia.cloud.common/src/main/java/org/eclipse/theia/cloud/common/serialization/SensitiveData.java b/java/common/org.eclipse.theia.cloud.common/src/main/java/org/eclipse/theia/cloud/common/serialization/SensitiveData.java new file mode 100644 index 00000000..febd3bb0 --- /dev/null +++ b/java/common/org.eclipse.theia.cloud.common/src/main/java/org/eclipse/theia/cloud/common/serialization/SensitiveData.java @@ -0,0 +1,39 @@ +/******************************************************************************** + * Copyright (C) 2024 EclipseSource and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ +package org.eclipse.theia.cloud.common.serialization; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + *

+ * Annotates object properties that contain sensitive content and must not be serialized when returned publicly or to + * arbitrary users (e.g. via a REST endpoint of the service). + *

+ *

+ * Note that the serializer {@link SensitiveDataSerializer} must be registered in the Jackson ObjectMapper for this + * annotation to be respected. + *

+ * + * @see SensitiveDataSerializer + * @see SensitiveDataBeanSerializerModifier + */ +@Target({ ElementType.FIELD }) +@Retention(RetentionPolicy.RUNTIME) +public @interface SensitiveData { +} diff --git a/java/common/org.eclipse.theia.cloud.common/src/main/java/org/eclipse/theia/cloud/common/serialization/SensitiveDataBeanSerializerModifier.java b/java/common/org.eclipse.theia.cloud.common/src/main/java/org/eclipse/theia/cloud/common/serialization/SensitiveDataBeanSerializerModifier.java new file mode 100644 index 00000000..8f766e7b --- /dev/null +++ b/java/common/org.eclipse.theia.cloud.common/src/main/java/org/eclipse/theia/cloud/common/serialization/SensitiveDataBeanSerializerModifier.java @@ -0,0 +1,34 @@ +package org.eclipse.theia.cloud.common.serialization; + +import com.fasterxml.jackson.databind.BeanDescription; +import com.fasterxml.jackson.databind.SerializationConfig; +import com.fasterxml.jackson.databind.ser.BeanSerializerModifier; +import com.fasterxml.jackson.databind.ser.BeanPropertyWriter; + +import java.util.List; + +/** + *

+ * A Jackson {@link BeanSerializerModifier} that modifies the serialization of sensitive data. It assigns the + * {@link SensitiveDataSerializer} to fields annotated with {@link SensitiveData}. The serializer is assigned for + * regular and null value serialization to prevent leaking information. + *

+ *

+ * To use this serializer modifier, it must be registered with Jackson's + * {@link com.fasterxml.jackson.databind.ObjectMapper ObjectMapper}. + *

+ */ +public class SensitiveDataBeanSerializerModifier extends BeanSerializerModifier { + @Override + public List changeProperties(SerializationConfig config, BeanDescription beanDesc, + List beanProperties) { + for (BeanPropertyWriter writer : beanProperties) { + // Check if the field has the @SensitiveData annotation + if (writer.getMember().getAnnotation(SensitiveData.class) != null) { + writer.assignNullSerializer(new SensitiveDataSerializer(writer.getType())); + writer.assignSerializer(new SensitiveDataSerializer(writer.getType())); + } + } + return beanProperties; + } +} diff --git a/java/common/org.eclipse.theia.cloud.common/src/main/java/org/eclipse/theia/cloud/common/serialization/SensitiveDataSerializer.java b/java/common/org.eclipse.theia.cloud.common/src/main/java/org/eclipse/theia/cloud/common/serialization/SensitiveDataSerializer.java new file mode 100644 index 00000000..2f41ecb4 --- /dev/null +++ b/java/common/org.eclipse.theia.cloud.common/src/main/java/org/eclipse/theia/cloud/common/serialization/SensitiveDataSerializer.java @@ -0,0 +1,54 @@ +package org.eclipse.theia.cloud.common.serialization; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.JavaType; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.SerializerProvider; + +import java.io.IOException; + +/** + * A Jackson {@link JsonSerializer} that redacts sensitive data. It suppresses the serialization of sensitive data by + * writing a predefined value instead. It must be used as the serializer for normal and null values of sensitive data. + */ +public class SensitiveDataSerializer extends JsonSerializer { + + public static String REDACTED_STRING = "***"; + public static int REDACTED_NUMBER = 0; + public static boolean REDACTED_BOOLEAN = false; + + protected JavaType propertyType; + + public SensitiveDataSerializer(JavaType propertyType) { + this.propertyType = propertyType; + } + + @Override + public void serialize(Object value, JsonGenerator gen, SerializerProvider serializers) throws IOException { + if (propertyType.isTypeOrSubTypeOf(String.class)) { + gen.writeString(REDACTED_STRING); + } else if (propertyType.isContainerType()) { + if (propertyType.isMapLikeType()) { + gen.writeStartObject(); + gen.writeEndObject(); + } else if (propertyType.isArrayType() || propertyType.isCollectionLikeType()) { + gen.writeStartArray(); + gen.writeEndArray(); + } else { + gen.writeNull(); + } + // Check value.getClass in case of primitive number types (e.g. int, long, double, etc.) + // This is necessary because the propertyType for these is not a subtype of Number but the class of the + // boxed value is. + } else if (propertyType.isTypeOrSubTypeOf(Number.class) + || (value != null && Number.class.isAssignableFrom(value.getClass()))) { + gen.writeNumber(REDACTED_NUMBER); + // Check value.getClass in case of primitive boolean type + } else if (propertyType.isTypeOrSubTypeOf(Boolean.class) + || (value != null && Boolean.class.isAssignableFrom(value.getClass()))) { + gen.writeBoolean(REDACTED_BOOLEAN); + } else { + gen.writeNull(); + } + } +} diff --git a/java/common/org.eclipse.theia.cloud.common/src/test/java/org/eclipse/theia/cloud/common/serialization/SensitiveDataSerializerTests.java b/java/common/org.eclipse.theia.cloud.common/src/test/java/org/eclipse/theia/cloud/common/serialization/SensitiveDataSerializerTests.java new file mode 100644 index 00000000..a93a2528 --- /dev/null +++ b/java/common/org.eclipse.theia.cloud.common/src/test/java/org/eclipse/theia/cloud/common/serialization/SensitiveDataSerializerTests.java @@ -0,0 +1,189 @@ +package org.eclipse.theia.cloud.common.serialization; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.JavaType; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.type.TypeFactory; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.util.HashMap; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +/** + * Unit tests for {@link SensitiveDataSerializer}. + */ +class SensitiveDataSerializerTest { + + private JsonGenerator jsonGenerator; + private SerializerProvider serializerProvider; + private TypeFactory typeFactory; + + @BeforeEach + void setUp() { + jsonGenerator = mock(JsonGenerator.class); + serializerProvider = mock(SerializerProvider.class); + typeFactory = TypeFactory.defaultInstance(); + } + + @Test + void serialize_StringType() throws IOException { + JavaType stringType = typeFactory.constructType(String.class); + SensitiveDataSerializer serializer = new SensitiveDataSerializer(stringType); + + serializer.serialize("test", jsonGenerator, serializerProvider); + verify(jsonGenerator).writeString(SensitiveDataSerializer.REDACTED_STRING); + } + + @Test + void serialize_StringType_Null() throws IOException { + JavaType stringType = typeFactory.constructType(String.class); + SensitiveDataSerializer serializer = new SensitiveDataSerializer(stringType); + + serializer.serialize(null, jsonGenerator, serializerProvider); + verify(jsonGenerator).writeString(SensitiveDataSerializer.REDACTED_STRING); + } + + @Test + void serialize_MapType() throws IOException { + JavaType mapType = typeFactory.constructMapType(HashMap.class, String.class, Object.class); + SensitiveDataSerializer serializer = new SensitiveDataSerializer(mapType); + + serializer.serialize(new HashMap<>(), jsonGenerator, serializerProvider); + verify(jsonGenerator).writeStartObject(); + verify(jsonGenerator).writeEndObject(); + } + + @Test + void serialize_MapType_Null() throws IOException { + JavaType mapType = typeFactory.constructMapType(HashMap.class, String.class, Object.class); + SensitiveDataSerializer serializer = new SensitiveDataSerializer(mapType); + + serializer.serialize(null, jsonGenerator, serializerProvider); + verify(jsonGenerator).writeStartObject(); + verify(jsonGenerator).writeEndObject(); + } + + @Test + void serialize_ArrayType() throws IOException { + JavaType arrayType = typeFactory.constructArrayType(String.class); + SensitiveDataSerializer serializer = new SensitiveDataSerializer(arrayType); + + serializer.serialize(new String[] { "test" }, jsonGenerator, serializerProvider); + verify(jsonGenerator).writeStartArray(); + verify(jsonGenerator).writeEndArray(); + } + + @Test + void serialize_ArrayType_Null() throws IOException { + JavaType arrayType = typeFactory.constructArrayType(String.class); + SensitiveDataSerializer serializer = new SensitiveDataSerializer(arrayType); + + serializer.serialize(null, jsonGenerator, serializerProvider); + verify(jsonGenerator).writeStartArray(); + verify(jsonGenerator).writeEndArray(); + } + + @Test + void serialize_NumberType() throws IOException { + JavaType numberType = typeFactory.constructType(Integer.class); + SensitiveDataSerializer serializer = new SensitiveDataSerializer(numberType); + + serializer.serialize(123, jsonGenerator, serializerProvider); + verify(jsonGenerator).writeNumber(SensitiveDataSerializer.REDACTED_NUMBER); + } + + @Test + void serialize_NumberType_Null() throws IOException { + JavaType numberType = typeFactory.constructType(Integer.class); + SensitiveDataSerializer serializer = new SensitiveDataSerializer(numberType); + + serializer.serialize(null, jsonGenerator, serializerProvider); + verify(jsonGenerator).writeNumber(SensitiveDataSerializer.REDACTED_NUMBER); + } + + @Test + void serialize_PrimitiveIntType() throws IOException { + JavaType intType = typeFactory.constructType(int.class); + SensitiveDataSerializer serializer = new SensitiveDataSerializer(intType); + + serializer.serialize(123, jsonGenerator, serializerProvider); + verify(jsonGenerator).writeNumber(SensitiveDataSerializer.REDACTED_NUMBER); + } + + @Test + void serialize_PrimitiveLongType() throws IOException { + JavaType longType = typeFactory.constructType(long.class); + SensitiveDataSerializer serializer = new SensitiveDataSerializer(longType); + + serializer.serialize(123L, jsonGenerator, serializerProvider); + verify(jsonGenerator).writeNumber(SensitiveDataSerializer.REDACTED_NUMBER); + } + + @Test + void serialize_PrimitiveFloatType() throws IOException { + JavaType floatType = typeFactory.constructType(float.class); + SensitiveDataSerializer serializer = new SensitiveDataSerializer(floatType); + + serializer.serialize(123.45f, jsonGenerator, serializerProvider); + verify(jsonGenerator).writeNumber(SensitiveDataSerializer.REDACTED_NUMBER); + } + + @Test + void serialize_PrimitiveDoubleType() throws IOException { + JavaType doubleType = typeFactory.constructType(double.class); + SensitiveDataSerializer serializer = new SensitiveDataSerializer(doubleType); + + serializer.serialize(123.45, jsonGenerator, serializerProvider); + verify(jsonGenerator).writeNumber(SensitiveDataSerializer.REDACTED_NUMBER); + } + + @Test + void serialize_BooleanType() throws IOException { + JavaType booleanType = typeFactory.constructType(Boolean.class); + SensitiveDataSerializer serializer = new SensitiveDataSerializer(booleanType); + + serializer.serialize(true, jsonGenerator, serializerProvider); + verify(jsonGenerator).writeBoolean(SensitiveDataSerializer.REDACTED_BOOLEAN); + } + + @Test + void serialize_BooleanType_Null() throws IOException { + JavaType booleanType = typeFactory.constructType(Boolean.class); + SensitiveDataSerializer serializer = new SensitiveDataSerializer(booleanType); + + serializer.serialize(null, jsonGenerator, serializerProvider); + verify(jsonGenerator).writeBoolean(SensitiveDataSerializer.REDACTED_BOOLEAN); + } + + @Test + void serialize_PrimitiveBooleanType() throws IOException { + JavaType booleanType = typeFactory.constructType(boolean.class); + SensitiveDataSerializer serializer = new SensitiveDataSerializer(booleanType); + + serializer.serialize(true, jsonGenerator, serializerProvider); + verify(jsonGenerator).writeBoolean(SensitiveDataSerializer.REDACTED_BOOLEAN); + } + + @Test + void serialize_UnsupportedType() throws IOException { + JavaType unsupportedType = typeFactory.constructType(Object.class); + SensitiveDataSerializer serializer = new SensitiveDataSerializer(unsupportedType); + + serializer.serialize(new Object(), jsonGenerator, serializerProvider); + verify(jsonGenerator).writeNull(); + } + + @Test + void serialize_UnsupportedType_Null() throws IOException { + JavaType unsupportedType = typeFactory.constructType(Object.class); + SensitiveDataSerializer serializer = new SensitiveDataSerializer(unsupportedType); + + serializer.serialize(null, jsonGenerator, serializerProvider); + verify(jsonGenerator).writeNull(); + } +} \ No newline at end of file diff --git a/java/service/org.eclipse.theia.cloud.service/src/main/java/org/eclipse/theia/cloud/service/K8sUtil.java b/java/service/org.eclipse.theia.cloud.service/src/main/java/org/eclipse/theia/cloud/service/K8sUtil.java index d138cf85..99736966 100644 --- a/java/service/org.eclipse.theia.cloud.service/src/main/java/org/eclipse/theia/cloud/service/K8sUtil.java +++ b/java/service/org.eclipse.theia.cloud.service/src/main/java/org/eclipse/theia/cloud/service/K8sUtil.java @@ -26,6 +26,7 @@ import org.eclipse.theia.cloud.common.k8s.client.DefaultTheiaCloudClient; import org.eclipse.theia.cloud.common.k8s.client.TheiaCloudClient; import org.eclipse.theia.cloud.common.k8s.resource.appdefinition.AppDefinition; +import org.eclipse.theia.cloud.common.k8s.resource.appdefinition.AppDefinitionSpec; import org.eclipse.theia.cloud.common.k8s.resource.session.Session; import org.eclipse.theia.cloud.common.k8s.resource.session.SessionSpec; import org.eclipse.theia.cloud.common.k8s.resource.session.SessionStatus; @@ -68,6 +69,10 @@ public boolean deleteWorkspace(String correlationId, String workspaceName) { return true; } + public List listAppDefinitions() { + return CLIENT.appDefinitions().specs(); + } + public List listSessions(String user) { return CLIENT.sessions().specs(user); } diff --git a/java/service/org.eclipse.theia.cloud.service/src/main/java/org/eclipse/theia/cloud/service/appdefinition/AppDefinitionListRequest.java b/java/service/org.eclipse.theia.cloud.service/src/main/java/org/eclipse/theia/cloud/service/appdefinition/AppDefinitionListRequest.java new file mode 100644 index 00000000..32268380 --- /dev/null +++ b/java/service/org.eclipse.theia.cloud.service/src/main/java/org/eclipse/theia/cloud/service/appdefinition/AppDefinitionListRequest.java @@ -0,0 +1,38 @@ +/******************************************************************************** + * Copyright (C) 2024 EclipseSource and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ +package org.eclipse.theia.cloud.service.appdefinition; + +import org.eclipse.microprofile.openapi.annotations.media.Schema; +import org.eclipse.theia.cloud.service.ServiceRequest; + +@Schema(name = "AppDefinitionListRequest", description = "A request to list available app definitions.") +public class AppDefinitionListRequest extends ServiceRequest { + public static final String KIND = "appDefinitionListRequest"; + + public AppDefinitionListRequest() { + super(KIND); + } + + public AppDefinitionListRequest(String appId) { + super(KIND, appId); + } + + @Override + public String toString() { + return "AppDefinitionListRequest [appId=" + appId + ", kind=" + kind + "]"; + } + +} diff --git a/java/service/org.eclipse.theia.cloud.service/src/main/java/org/eclipse/theia/cloud/service/appdefinition/AppDefinitionResource.java b/java/service/org.eclipse.theia.cloud.service/src/main/java/org/eclipse/theia/cloud/service/appdefinition/AppDefinitionResource.java new file mode 100644 index 00000000..83959b94 --- /dev/null +++ b/java/service/org.eclipse.theia.cloud.service/src/main/java/org/eclipse/theia/cloud/service/appdefinition/AppDefinitionResource.java @@ -0,0 +1,50 @@ +/******************************************************************************** + * Copyright (C) 2024 EclipseSource and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ +package org.eclipse.theia.cloud.service.appdefinition; + +import java.util.List; + +import org.eclipse.microprofile.openapi.annotations.Operation; +import org.eclipse.theia.cloud.common.k8s.resource.appdefinition.AppDefinitionSpec; +import org.eclipse.theia.cloud.service.ApplicationProperties; +import org.eclipse.theia.cloud.service.BaseResource; +import org.eclipse.theia.cloud.service.K8sUtil; +import io.quarkus.security.Authenticated; +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; + +@Path("/service/appdefinition") +@Authenticated +public class AppDefinitionResource extends BaseResource { + + @Inject + private K8sUtil k8sUtil; + + public AppDefinitionResource(ApplicationProperties applicationProperties) { + super(applicationProperties); + } + + @Operation(summary = "List app definitions", description = "List available app definitions.") + @GET + @Path("/{appId}") + public List list(@PathParam("appId") String appId) { + evaluateRequest(new AppDefinitionListRequest(appId)); + List appDefinitions = k8sUtil.listAppDefinitions(); + return appDefinitions; + } +} diff --git a/java/service/org.eclipse.theia.cloud.service/src/main/java/org/eclipse/theia/cloud/service/serialization/SensitiveDataCustomSerializerRegistration.java b/java/service/org.eclipse.theia.cloud.service/src/main/java/org/eclipse/theia/cloud/service/serialization/SensitiveDataCustomSerializerRegistration.java new file mode 100644 index 00000000..b774e242 --- /dev/null +++ b/java/service/org.eclipse.theia.cloud.service/src/main/java/org/eclipse/theia/cloud/service/serialization/SensitiveDataCustomSerializerRegistration.java @@ -0,0 +1,24 @@ +package org.eclipse.theia.cloud.service.serialization; + +import jakarta.inject.Singleton; + +import org.eclipse.theia.cloud.common.serialization.SensitiveDataBeanSerializerModifier; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.module.SimpleModule; +import io.quarkus.jackson.ObjectMapperCustomizer; + +/** + * Registers the {@link SensitiveDataBeanSerializerModifier} in the Jackson ObjectMapper used by Quarkus to respect the + * {@link org.eclipse.theia.cloud.common.serialization.SensitiveData SensitiveData} annotation. + */ +@Singleton +public class SensitiveDataCustomSerializerRegistration implements ObjectMapperCustomizer { + + @Override + public void customize(ObjectMapper objectMapper) { + SimpleModule module = new SimpleModule(); + module.setSerializerModifier(new SensitiveDataBeanSerializerModifier()); + objectMapper.registerModule(module); + } +} diff --git a/node/common/src/client.ts b/node/common/src/client.ts index 9717d4f4..1431ab24 100644 --- a/node/common/src/client.ts +++ b/node/common/src/client.ts @@ -3,6 +3,9 @@ import { AxiosError, AxiosRequestConfig, AxiosResponse } from 'axios'; import { v4 as uuidv4 } from 'uuid'; import { + AppDefinitionListRequest as ClientAppDefinitionListRequest, + AppDefinitionResourceApi, + AppDefinitionSpec, LaunchRequest as ClientLaunchRequest, PingRequest as ClientPingRequest, RootResourceApi, @@ -90,6 +93,11 @@ export namespace LaunchRequest { } } +export type AppDefinitionListRequest = ClientAppDefinitionListRequest & ServiceRequest; +export namespace AppDefinitionListRequest { + export const KIND = 'appDefinitionListRequest'; +} + export type SessionListRequest = ClientSessionListRequest & ServiceRequest; export namespace SessionListRequest { export const KIND = 'sessionListRequest'; @@ -135,6 +143,10 @@ export namespace TheiaCloud { return new RootResourceApi(new Configuration({ basePath: serviceUrl, accessToken })); } + function appDefinitionApi(serviceUrl: string, accessToken: string | undefined): AppDefinitionResourceApi { + return new AppDefinitionResourceApi(new Configuration({ basePath: serviceUrl, accessToken })); + } + function sessionApi(serviceUrl: string, accessToken: string | undefined): SessionResourceApi { return new SessionResourceApi(new Configuration({ basePath: serviceUrl, accessToken })); } @@ -167,6 +179,23 @@ export namespace TheiaCloud { return url; } + export namespace AppDefinition { + export async function listAppDefinitions( + request: AppDefinitionListRequest, + options: RequestOptions = {} + ): Promise { + const { accessToken, retries, timeout } = options; + return call( + () => + appDefinitionApi(request.serviceUrl, accessToken).serviceAppdefinitionAppIdGet( + request.appId, + createConfig(timeout) + ), + retries + ); + } + } + export namespace Session { export async function listSessions( request: SessionListRequest, diff --git a/node/common/src/client/api.ts b/node/common/src/client/api.ts index fbcd72b3..53c936f4 100644 --- a/node/common/src/client/api.ts +++ b/node/common/src/client/api.ts @@ -23,6 +23,165 @@ import type { RequestArgs } from './base'; // @ts-ignore import { BASE_PATH, COLLECTION_FORMATS, BaseAPI, RequiredError, operationServerMap } from './base'; +/** + * + * @export + * @interface ActivityTracker + */ +export interface ActivityTracker { + /** + * + * @type {number} + * @memberof ActivityTracker + */ + 'timeoutAfter'?: number; + /** + * + * @type {number} + * @memberof ActivityTracker + */ + 'notifyAfter'?: number; +} +/** + * A request to list available app definitions. + * @export + * @interface AppDefinitionListRequest + */ +export interface AppDefinitionListRequest { + /** + * The App Id of this Theia Cloud instance. Request without a matching Id will be denied. + * @type {string} + * @memberof AppDefinitionListRequest + */ + 'appId': string; +} +/** + * + * @export + * @interface AppDefinitionSpec + */ +export interface AppDefinitionSpec { + /** + * + * @type {string} + * @memberof AppDefinitionSpec + */ + 'name'?: string; + /** + * + * @type {string} + * @memberof AppDefinitionSpec + */ + 'image'?: string; + /** + * + * @type {string} + * @memberof AppDefinitionSpec + */ + 'imagePullPolicy'?: string; + /** + * + * @type {string} + * @memberof AppDefinitionSpec + */ + 'pullSecret'?: string; + /** + * + * @type {number} + * @memberof AppDefinitionSpec + */ + 'uid'?: number; + /** + * + * @type {number} + * @memberof AppDefinitionSpec + */ + 'port'?: number; + /** + * + * @type {string} + * @memberof AppDefinitionSpec + */ + 'ingressname'?: string; + /** + * + * @type {number} + * @memberof AppDefinitionSpec + */ + 'minInstances'?: number; + /** + * + * @type {number} + * @memberof AppDefinitionSpec + */ + 'maxInstances'?: number; + /** + * + * @type {number} + * @memberof AppDefinitionSpec + */ + 'timeout'?: number; + /** + * + * @type {string} + * @memberof AppDefinitionSpec + */ + 'requestsMemory'?: string; + /** + * + * @type {string} + * @memberof AppDefinitionSpec + */ + 'requestsCpu'?: string; + /** + * + * @type {string} + * @memberof AppDefinitionSpec + */ + 'limitsMemory'?: string; + /** + * + * @type {string} + * @memberof AppDefinitionSpec + */ + 'limitsCpu'?: string; + /** + * + * @type {number} + * @memberof AppDefinitionSpec + */ + 'downlinkLimit'?: number; + /** + * + * @type {number} + * @memberof AppDefinitionSpec + */ + 'uplinkLimit'?: number; + /** + * + * @type {string} + * @memberof AppDefinitionSpec + */ + 'mountPath'?: string; + /** + * + * @type {Monitor} + * @memberof AppDefinitionSpec + */ + 'monitor'?: Monitor; + /** + * + * @type {{ [key: string]: string; }} + * @memberof AppDefinitionSpec + */ + 'options'?: { [key: string]: string; }; + /** + * + * @type {Array} + * @memberof AppDefinitionSpec + */ + 'ingressHostnamePrefixes'?: Array; +} /** * An object to hold all the ways environment variables can be passed. Not to be used by itself. * @export @@ -103,6 +262,25 @@ export interface LaunchRequest { */ 'env'?: EnvironmentVars; } +/** + * + * @export + * @interface Monitor + */ +export interface Monitor { + /** + * + * @type {number} + * @memberof Monitor + */ + 'port'?: number; + /** + * + * @type {ActivityTracker} + * @memberof Monitor + */ + 'activityTracker'?: ActivityTracker; +} /** * Request to ping the availability of the service. * @export @@ -446,6 +624,118 @@ export interface WorkspaceListRequest { 'user': string; } +/** + * AppDefinitionResourceApi - axios parameter creator + * @export + */ +export const AppDefinitionResourceApiAxiosParamCreator = function (configuration?: Configuration) { + return { + /** + * List available app definitions. + * @summary List app definitions + * @param {string} appId + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + serviceAppdefinitionAppIdGet: async (appId: string, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'appId' is not null or undefined + assertParamExists('serviceAppdefinitionAppIdGet', 'appId', appId) + const localVarPath = `/service/appdefinition/{appId}` + .replace(`{${"appId"}}`, encodeURIComponent(String(appId))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication SecurityScheme required + // oauth required + await setOAuthToObject(localVarHeaderParameter, "SecurityScheme", [], configuration) + + + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + } +}; + +/** + * AppDefinitionResourceApi - functional programming interface + * @export + */ +export const AppDefinitionResourceApiFp = function(configuration?: Configuration) { + const localVarAxiosParamCreator = AppDefinitionResourceApiAxiosParamCreator(configuration) + return { + /** + * List available app definitions. + * @summary List app definitions + * @param {string} appId + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async serviceAppdefinitionAppIdGet(appId: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { + const localVarAxiosArgs = await localVarAxiosParamCreator.serviceAppdefinitionAppIdGet(appId, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['AppDefinitionResourceApi.serviceAppdefinitionAppIdGet']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, + } +}; + +/** + * AppDefinitionResourceApi - factory interface + * @export + */ +export const AppDefinitionResourceApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) { + const localVarFp = AppDefinitionResourceApiFp(configuration) + return { + /** + * List available app definitions. + * @summary List app definitions + * @param {string} appId + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + serviceAppdefinitionAppIdGet(appId: string, options?: RawAxiosRequestConfig): AxiosPromise> { + return localVarFp.serviceAppdefinitionAppIdGet(appId, options).then((request) => request(axios, basePath)); + }, + }; +}; + +/** + * AppDefinitionResourceApi - object-oriented interface + * @export + * @class AppDefinitionResourceApi + * @extends {BaseAPI} + */ +export class AppDefinitionResourceApi extends BaseAPI { + /** + * List available app definitions. + * @summary List app definitions + * @param {string} appId + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof AppDefinitionResourceApi + */ + public serviceAppdefinitionAppIdGet(appId: string, options?: RawAxiosRequestConfig) { + return AppDefinitionResourceApiFp(this.configuration).serviceAppdefinitionAppIdGet(appId, options).then((request) => request(this.axios, this.basePath)); + } +} + + + /** * RootResourceApi - axios parameter creator * @export diff --git a/node/testing-page/src/App.tsx b/node/testing-page/src/App.tsx index 3d574b21..bc4a26cd 100644 --- a/node/testing-page/src/App.tsx +++ b/node/testing-page/src/App.tsx @@ -2,22 +2,33 @@ import React, { useEffect, useState } from 'react'; import './App.css'; import { KeycloakConfig } from 'keycloak-js'; import Keycloak from 'keycloak-js'; -import { TheiaCloud, RequestOptions, SessionListRequest, SessionStartRequest, SessionStopRequest, WorkspaceCreationRequest, WorkspaceDeletionRequest, WorkspaceListRequest, PingRequest, LaunchRequest } from '@eclipse-theiacloud/common'; +import { + TheiaCloud, + RequestOptions, + SessionListRequest, + SessionStartRequest, + SessionStopRequest, + WorkspaceCreationRequest, + WorkspaceDeletionRequest, + WorkspaceListRequest, + PingRequest, + LaunchRequest +} from '@eclipse-theiacloud/common'; const KEYCLOAK_CONFIG: KeycloakConfig = { - url: 'https://keycloak.localdemo.io/auth/', + url: 'https://keycloak.localdemo.io/', realm: 'TheiaCloud', clientId: 'theia-cloud' }; // The base URL of the service const SERVICE_URL = 'https://service.localdemo.io'; -const APP_DEFINITION = 'theia-cloud-demo' +const APP_DEFINITION = 'theia-cloud-demo'; const APP_ID = 'asdfghjkl'; function App() { const [token, setToken] = useState(); - const [logoutUrl, setLogoutUrl] = useState() + const [logoutUrl, setLogoutUrl] = useState(); const [email, setEmail] = useState(); const [user, setUser] = useState(''); const [resourceName, setResourceName] = useState(''); @@ -28,7 +39,7 @@ function App() { .init({ onLoad: 'check-sso', redirectUri: window.location.href, - checkLoginIframe: false, + checkLoginIframe: false }) .then(auth => { if (auth) { @@ -47,7 +58,6 @@ function App() { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const login = () => { const keycloak = Keycloak(KEYCLOAK_CONFIG); keycloak @@ -59,27 +69,29 @@ function App() { .catch(() => { console.error('Authentication Failed'); }); - } + }; const executeRequest = async (requestFn: RequestFunction): Promise => { if (!token) { console.warn('No token. Request is anonymous.'); } - return requestFn(user ? user : email!, token!).then(val => { - console.log('Request successful with result:', val); - }).catch(err => { - console.error('Request failed:', err); - }) - } + return requestFn(user ? user : email!, token!) + .then(val => { + console.log('Request successful with result:', val); + }) + .catch(err => { + console.error('Request failed:', err); + }); + }; // Root requests const ping = (_user: string, accessToken?: string) => { const request: PingRequest = { appId: APP_ID, - serviceUrl: SERVICE_URL, + serviceUrl: SERVICE_URL }; - return TheiaCloud.ping(request, generateRequestOptions(accessToken)) - } + return TheiaCloud.ping(request, generateRequestOptions(accessToken)); + }; const launch = (user: string, accessToken?: string) => { const request: LaunchRequest = { appId: APP_ID, @@ -87,9 +99,9 @@ function App() { user, serviceUrl: SERVICE_URL, timeout: 3 - } + }; return TheiaCloud.launch(request, generateRequestOptions(accessToken)); - } + }; // Workspace requests const listWorkspaces = (user: string, accessToken?: string) => { @@ -97,37 +109,37 @@ function App() { appId: APP_ID, user, serviceUrl: SERVICE_URL - } + }; return TheiaCloud.Workspace.listWorkspaces(request, generateRequestOptions(accessToken)); - } + }; const createWorkspace = (user: string, accessToken?: string) => { const request: WorkspaceCreationRequest = { appId: APP_ID, appDefinition: APP_DEFINITION, user, serviceUrl: SERVICE_URL - } + }; return TheiaCloud.Workspace.createWorkspace(request, generateRequestOptions(accessToken)); - } + }; const deleteWorkspace = (user: string, accessToken?: string) => { const request: WorkspaceDeletionRequest = { appId: APP_ID, user, workspaceName: resourceName, serviceUrl: SERVICE_URL - } + }; return TheiaCloud.Workspace.deleteWorkspace(request, generateRequestOptions(accessToken)); - } - + }; + // Session requests const listSessions = (user: string, accessToken?: string) => { const request: SessionListRequest = { appId: APP_ID, user, serviceUrl: SERVICE_URL - } + }; return TheiaCloud.Session.listSessions(request, generateRequestOptions(accessToken)); - } + }; const startSession = (user: string, accessToken?: string) => { const request: SessionStartRequest = { appId: APP_ID, @@ -135,23 +147,35 @@ function App() { user, workspaceName: resourceName ? resourceName : undefined, serviceUrl: SERVICE_URL - } + }; return TheiaCloud.Session.startSession(request, generateRequestOptions(accessToken)); - } + }; const stopSession = (user: string, accessToken?: string) => { const request: SessionStopRequest = { appId: APP_ID, user, sessionName: resourceName, serviceUrl: SERVICE_URL - } + }; return TheiaCloud.Session.stopSession(request, generateRequestOptions(accessToken)); - } + }; + + // App definition requests + const listAppDefinitions = (user: string, accessToken?: string) => { + const request = { + appId: APP_ID, + user, + serviceUrl: SERVICE_URL + }; + return TheiaCloud.AppDefinition.listAppDefinitions(request, generateRequestOptions(accessToken)); + }; return ( -
+

TheiaCloud Service Test Page

-

This page is meant for internal testing only!

+

+ This page is meant for internal testing only! +

Open your browser's dev tools (F12) to see outgoing requests. Results are logged to the console as well.

{email ?

Logged in as: {email}

: } {logoutUrl && Logout} @@ -179,15 +203,18 @@ function App() {

+

+ +

); -}; +} type RequestFunction = (user: string, token: string | undefined) => Promise; function generateRequestOptions(token: string | undefined): RequestOptions { return { - accessToken: token, + accessToken: token }; } export default App;