chore: import upstream snapshot with attribution
SDK Tests / changes (push) Successful in 2m29s
Real E2E Tests / changes (push) Successful in 2m29s
Deploy Docs Pages / build (push) Has been cancelled
Deploy Docs Pages / deploy (push) Has been cancelled
Real E2E Tests / JavaScript E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Python E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Java E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / C# E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Go E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Real E2E CI (push) Has been cancelled
SDK Tests / SDK CI (push) Has been cancelled
SDK Tests / CLI Tests (push) Has been cancelled
SDK Tests / Python SDK Quality (code-interpreter) (push) Has been cancelled
SDK Tests / Python SDK Quality (sandbox) (push) Has been cancelled
SDK Tests / Python SDK Tests (code-interpreter) (push) Has been cancelled
SDK Tests / JavaScript SDK Quality And Tests (code-interpreter) (push) Has been cancelled
SDK Tests / JavaScript SDK Quality And Tests (sandbox) (push) Has been cancelled
SDK Tests / Python SDK Tests (sandbox) (push) Has been cancelled
SDK Tests / CLI Quality (push) Has been cancelled
SDK Tests / Kotlin SDK Quality And Tests (sandbox) (push) Has been cancelled
SDK Tests / Kotlin SDK Quality And Tests (code-interpreter) (push) Has been cancelled
SDK Tests / C# SDK Quality And Tests (code-interpreter) (push) Has been cancelled
SDK Tests / C# SDK Quality And Tests (sandbox) (push) Has been cancelled
SDK Tests / Go SDK Quality And Tests (push) Has been cancelled
SDK Tests / changes (push) Successful in 2m29s
Real E2E Tests / changes (push) Successful in 2m29s
Deploy Docs Pages / build (push) Has been cancelled
Deploy Docs Pages / deploy (push) Has been cancelled
Real E2E Tests / JavaScript E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Python E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Java E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / C# E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Go E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Real E2E CI (push) Has been cancelled
SDK Tests / SDK CI (push) Has been cancelled
SDK Tests / CLI Tests (push) Has been cancelled
SDK Tests / Python SDK Quality (code-interpreter) (push) Has been cancelled
SDK Tests / Python SDK Quality (sandbox) (push) Has been cancelled
SDK Tests / Python SDK Tests (code-interpreter) (push) Has been cancelled
SDK Tests / JavaScript SDK Quality And Tests (code-interpreter) (push) Has been cancelled
SDK Tests / JavaScript SDK Quality And Tests (sandbox) (push) Has been cancelled
SDK Tests / Python SDK Tests (sandbox) (push) Has been cancelled
SDK Tests / CLI Quality (push) Has been cancelled
SDK Tests / Kotlin SDK Quality And Tests (sandbox) (push) Has been cancelled
SDK Tests / Kotlin SDK Quality And Tests (code-interpreter) (push) Has been cancelled
SDK Tests / C# SDK Quality And Tests (code-interpreter) (push) Has been cancelled
SDK Tests / C# SDK Quality And Tests (sandbox) (push) Has been cancelled
SDK Tests / Go SDK Quality And Tests (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
/*
|
||||
* Copyright 2025 Alibaba Group Holding Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.alibaba.opensandbox.e2e;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import com.alibaba.opensandbox.sandbox.config.ConnectionConfig;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.time.Duration;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.*;
|
||||
import org.junit.jupiter.api.*;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/** Base class for all E2E tests providing common setup and teardown functionality. */
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
public abstract class BaseE2ETest {
|
||||
|
||||
protected static final Logger logger = LoggerFactory.getLogger(BaseE2ETest.class);
|
||||
|
||||
// ==========================================
|
||||
// Configuration Keys
|
||||
// ==========================================
|
||||
private static final String PROP_API_KEY = "opensandbox.test.api.key";
|
||||
private static final String PROP_DOMAIN = "opensandbox.test.domain";
|
||||
private static final String PROP_PROTOCOL = "opensandbox.test.protocol";
|
||||
private static final String PROP_IMG_DEFAULT = "opensandbox.sandbox.default.image";
|
||||
private static final String ENV_API_KEY = "OPENSANDBOX_TEST_API_KEY";
|
||||
private static final String ENV_DOMAIN = "OPENSANDBOX_TEST_DOMAIN";
|
||||
private static final String ENV_PROTOCOL = "OPENSANDBOX_TEST_PROTOCOL";
|
||||
private static final String ENV_IMG_DEFAULT = "OPENSANDBOX_SANDBOX_DEFAULT_IMAGE";
|
||||
private static final String ENV_USE_SERVER_PROXY = "OPENSANDBOX_TEST_USE_SERVER_PROXY";
|
||||
|
||||
// ==========================================
|
||||
// Shared State (Static)
|
||||
// ==========================================
|
||||
protected static final Properties testProperties = new Properties();
|
||||
protected static ConnectionConfig sharedConnectionConfig;
|
||||
|
||||
static {
|
||||
loadTestProperties();
|
||||
initializeSharedConfig();
|
||||
}
|
||||
|
||||
protected static String getSandboxImage() {
|
||||
return configValue(PROP_IMG_DEFAULT, ENV_IMG_DEFAULT, "opensandbox/code-interpreter:latest");
|
||||
}
|
||||
|
||||
protected static ConnectionConfig createConnectionConfig(boolean useServerProxy) {
|
||||
String protocol = configValue(PROP_PROTOCOL, ENV_PROTOCOL, "http");
|
||||
return ConnectionConfig.builder()
|
||||
.apiKey(configValue(PROP_API_KEY, ENV_API_KEY, "e2e-test"))
|
||||
.domain(configValue(PROP_DOMAIN, ENV_DOMAIN, "localhost:8080"))
|
||||
.requestTimeout(Duration.ofMinutes(3))
|
||||
.protocol(protocol)
|
||||
.useServerProxy(useServerProxy || shouldUseServerProxy())
|
||||
.build();
|
||||
}
|
||||
|
||||
private static void loadTestProperties() {
|
||||
try (InputStream input =
|
||||
BaseE2ETest.class.getClassLoader().getResourceAsStream("test.properties")) {
|
||||
if (input != null) {
|
||||
testProperties.load(input);
|
||||
} else {
|
||||
logger.warn("test.properties file not found, using default values.");
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException("Failed to load test properties", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void initializeSharedConfig() {
|
||||
String protocol = configValue(PROP_PROTOCOL, ENV_PROTOCOL, "http");
|
||||
sharedConnectionConfig =
|
||||
ConnectionConfig.builder()
|
||||
.apiKey(configValue(PROP_API_KEY, ENV_API_KEY, "e2e-test"))
|
||||
.domain(configValue(PROP_DOMAIN, ENV_DOMAIN, "localhost:8080"))
|
||||
.requestTimeout(Duration.ofMinutes(3))
|
||||
.protocol(protocol)
|
||||
.useServerProxy(shouldUseServerProxy())
|
||||
.build();
|
||||
}
|
||||
|
||||
private static String configValue(String propertyKey, String envKey, String defaultValue) {
|
||||
String envValue = System.getenv(envKey);
|
||||
if (envValue != null && !envValue.isBlank()) {
|
||||
return envValue;
|
||||
}
|
||||
return testProperties.getProperty(propertyKey, defaultValue);
|
||||
}
|
||||
|
||||
private static boolean shouldUseServerProxy() {
|
||||
String envValue = System.getenv(ENV_USE_SERVER_PROXY);
|
||||
return envValue != null && Boolean.parseBoolean(envValue);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void beforeEach(TestInfo testInfo) {
|
||||
logger.info("=== Starting test: {} ===", testInfo.getDisplayName());
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Shared assertion helpers (ported from python e2e style)
|
||||
// ==========================================
|
||||
protected static long nowMs() {
|
||||
return System.currentTimeMillis();
|
||||
}
|
||||
|
||||
protected static void assertRecentTimestampMs(long ts, long toleranceMs) {
|
||||
assertTrue(ts > 0, "timestamp must be > 0");
|
||||
long delta = Math.abs(nowMs() - ts);
|
||||
assertTrue(
|
||||
delta <= toleranceMs,
|
||||
"timestamp too far from now: delta=" + delta + "ms (ts=" + ts + ")");
|
||||
}
|
||||
|
||||
protected static void assertEndpointHasPort(String endpoint, int expectedPort) {
|
||||
assertNotNull(endpoint);
|
||||
assertFalse(endpoint.contains("://"), "unexpected scheme in endpoint: " + endpoint);
|
||||
if (endpoint.contains("/")) {
|
||||
assertTrue(
|
||||
endpoint.endsWith("/" + expectedPort),
|
||||
"endpoint route must end with /" + expectedPort + ": " + endpoint);
|
||||
String prefix = endpoint.split("/", 2)[0];
|
||||
assertFalse(prefix.isBlank(), "missing domain in endpoint: " + endpoint);
|
||||
return;
|
||||
}
|
||||
int idx = endpoint.lastIndexOf(':');
|
||||
assertTrue(idx > 0, "missing host:port in endpoint: " + endpoint);
|
||||
String host = endpoint.substring(0, idx);
|
||||
String port = endpoint.substring(idx + 1);
|
||||
assertFalse(host.isBlank(), "missing host in endpoint: " + endpoint);
|
||||
assertTrue(port.matches("\\d+"), "non-numeric port in endpoint: " + endpoint);
|
||||
assertEquals(expectedPort, Integer.parseInt(port), "endpoint port mismatch: " + endpoint);
|
||||
}
|
||||
|
||||
protected static void assertTimesClose(
|
||||
OffsetDateTime createdAt, OffsetDateTime modifiedAt, long toleranceSeconds) {
|
||||
long delta = Math.abs(Duration.between(createdAt, modifiedAt).getSeconds());
|
||||
assertTrue(delta <= toleranceSeconds, "created/modified skew too large: " + delta + "s");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,536 @@
|
||||
/*
|
||||
* Copyright 2026 Alibaba Group Holding Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.alibaba.opensandbox.e2e;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import com.alibaba.opensandbox.sandbox.Sandbox;
|
||||
import com.alibaba.opensandbox.sandbox.domain.models.execd.executions.Execution;
|
||||
import com.alibaba.opensandbox.sandbox.domain.models.execd.executions.OutputMessage;
|
||||
import com.alibaba.opensandbox.sandbox.domain.models.execd.executions.RunCommandRequest;
|
||||
import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.Credential;
|
||||
import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.CredentialAuth;
|
||||
import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.CredentialBinding;
|
||||
import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.CredentialBindingMetadata;
|
||||
import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.CredentialBindingMutationSet;
|
||||
import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.CredentialMatch;
|
||||
import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.CredentialMetadata;
|
||||
import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.CredentialMutationSet;
|
||||
import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.CredentialProxyConfig;
|
||||
import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.CredentialSubstitution;
|
||||
import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.CredentialVaultCreateRequest;
|
||||
import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.CredentialVaultPatchRequest;
|
||||
import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.CredentialVaultState;
|
||||
import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.CustomHeaderEntry;
|
||||
import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.NetworkPolicy;
|
||||
import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.NetworkRule;
|
||||
import java.time.Duration;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.stream.Collectors;
|
||||
import org.junit.jupiter.api.Assumptions;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Tag;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.Timeout;
|
||||
|
||||
@Tag("e2e")
|
||||
@DisplayName("Credential Vault E2E Tests (JVM/Kotlin SDK)")
|
||||
class CredentialVaultE2ETest extends BaseE2ETest {
|
||||
|
||||
private static final String DEFAULT_TARGET_HOST = "credential-vault-e2e.opensandbox.test";
|
||||
private static final Map<String, String> SECRET_VALUES =
|
||||
Map.of(
|
||||
"bearer-token", "vault-bearer-token",
|
||||
"basic-token", "dXNlcjpwYXNz",
|
||||
"api-key-token", "vault-api-key-token",
|
||||
"client-id", "vault-client-id",
|
||||
"client-secret", "vault-client-secret",
|
||||
"query-secret", "vault-query-secret",
|
||||
"path-secret", "vault-path-secret",
|
||||
"body-secret", "vault-body-secret",
|
||||
"runtime-token", "vault-runtime-token",
|
||||
"runtime-token-replaced", "vault-runtime-token-replaced");
|
||||
|
||||
@Test
|
||||
@Timeout(value = 5, unit = TimeUnit.MINUTES)
|
||||
void credentialVaultInjectsAllAuthTypes() {
|
||||
String targetIp = credentialVaultTargetIp();
|
||||
Sandbox sandbox = createCredentialVaultSandbox(targetIp);
|
||||
|
||||
try {
|
||||
CredentialVaultState state =
|
||||
sandbox.credentialVault()
|
||||
.create(
|
||||
CredentialVaultCreateRequest.builder()
|
||||
.credentials(
|
||||
credentials(
|
||||
"bearer-token",
|
||||
"basic-token",
|
||||
"api-key-token",
|
||||
"client-id",
|
||||
"client-secret",
|
||||
"runtime-token",
|
||||
"runtime-token-replaced"))
|
||||
.bindings(
|
||||
List.of(
|
||||
binding(
|
||||
"bearer",
|
||||
"/bearer",
|
||||
CredentialAuth.bearer(
|
||||
"bearer-token")),
|
||||
binding(
|
||||
"basic",
|
||||
"/basic",
|
||||
CredentialAuth.basic(
|
||||
"basic-token")),
|
||||
binding(
|
||||
"api-key",
|
||||
"/api-key",
|
||||
CredentialAuth.apiKey(
|
||||
"X-Api-Key",
|
||||
"api-key-token")),
|
||||
binding(
|
||||
"custom-headers",
|
||||
"/custom-headers",
|
||||
CredentialAuth.customHeaders(
|
||||
List.of(
|
||||
CustomHeaderEntry
|
||||
.builder()
|
||||
.name(
|
||||
"X-Client-Id")
|
||||
.credential(
|
||||
"client-id")
|
||||
.build(),
|
||||
CustomHeaderEntry
|
||||
.builder()
|
||||
.name(
|
||||
"X-Client-Secret")
|
||||
.credential(
|
||||
"client-secret")
|
||||
.build())))))
|
||||
.build());
|
||||
|
||||
Set<String> authTypes =
|
||||
state.getBindings().stream()
|
||||
.map(CredentialBindingMetadata::getAuth)
|
||||
.filter(auth -> auth != null)
|
||||
.map(auth -> auth.getType())
|
||||
.collect(Collectors.toSet());
|
||||
assertEquals(Set.of("bearer", "basic", "apiKey", "customHeaders"), authTypes);
|
||||
|
||||
for (String path : List.of("/bearer", "/basic", "/api-key", "/custom-headers")) {
|
||||
String response = curlJson(sandbox, targetIp, path, true);
|
||||
assertJsonCase(response, path.substring(1), true, "[]");
|
||||
}
|
||||
} finally {
|
||||
killSandbox(sandbox);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@Timeout(value = 5, unit = TimeUnit.MINUTES)
|
||||
void credentialVaultSubstitutesPlaceholdersInQueryPathAndBody() {
|
||||
String targetIp = credentialVaultTargetIp();
|
||||
Sandbox sandbox = createCredentialVaultSandbox(targetIp);
|
||||
|
||||
try {
|
||||
sandbox.credentialVault()
|
||||
.create(
|
||||
CredentialVaultCreateRequest.builder()
|
||||
.credentials(
|
||||
credentials(
|
||||
"query-secret", "path-secret", "body-secret"))
|
||||
.bindings(
|
||||
List.of(
|
||||
binding(
|
||||
"query-substitution",
|
||||
"/query-substitution",
|
||||
CredentialAuth.passthrough(
|
||||
List.of(
|
||||
substitution(
|
||||
"query-secret",
|
||||
"__query_secret__",
|
||||
CredentialSubstitution
|
||||
.Surface
|
||||
.QUERY)))),
|
||||
binding(
|
||||
"path-substitution",
|
||||
"/tenant/*",
|
||||
CredentialAuth.passthrough(
|
||||
List.of(
|
||||
substitution(
|
||||
"path-secret",
|
||||
"__path_secret__",
|
||||
CredentialSubstitution
|
||||
.Surface
|
||||
.PATH)))),
|
||||
binding(
|
||||
"body-substitution",
|
||||
"/body-substitution",
|
||||
CredentialAuth.passthrough(
|
||||
List.of(
|
||||
substitution(
|
||||
"body-secret",
|
||||
"__body_secret__",
|
||||
CredentialSubstitution
|
||||
.Surface
|
||||
.BODY))),
|
||||
List.of("POST"))))
|
||||
.build());
|
||||
|
||||
String response =
|
||||
curlJson(
|
||||
sandbox,
|
||||
targetIp,
|
||||
"/query-substitution?api_key=__query_secret__",
|
||||
true);
|
||||
assertJsonCase(response, "query-substitution", true, "[]");
|
||||
|
||||
response =
|
||||
curlJson(
|
||||
sandbox,
|
||||
targetIp,
|
||||
"/tenant/__path_secret__/resource?tenant=__path_secret__",
|
||||
true);
|
||||
assertJsonCase(response, "path-substitution", true, "[]");
|
||||
assertTrue(response.contains("\"queryStillPlaceholder\":true"), response);
|
||||
|
||||
response =
|
||||
curlJson(
|
||||
sandbox,
|
||||
targetIp,
|
||||
"/body-substitution",
|
||||
true,
|
||||
"POST",
|
||||
List.of("content-type: application/json"),
|
||||
"{\"client_secret\":\"__body_secret__\"}");
|
||||
assertJsonCase(response, "body-substitution", true, "[]");
|
||||
} finally {
|
||||
killSandbox(sandbox);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@Timeout(value = 5, unit = TimeUnit.MINUTES)
|
||||
void credentialVaultRuntimeMutationAddsReplacesAndDeletesBinding() {
|
||||
String targetIp = credentialVaultTargetIp();
|
||||
Sandbox sandbox = createCredentialVaultSandbox(targetIp);
|
||||
|
||||
try {
|
||||
CredentialVaultState state =
|
||||
sandbox.credentialVault()
|
||||
.create(
|
||||
CredentialVaultCreateRequest.builder()
|
||||
.credentials(List.of())
|
||||
.bindings(List.of())
|
||||
.build());
|
||||
assertEquals(1, state.getRevision());
|
||||
assertTrue(state.getCredentials().isEmpty());
|
||||
assertTrue(state.getBindings().isEmpty());
|
||||
|
||||
state =
|
||||
sandbox.credentialVault()
|
||||
.patch(
|
||||
CredentialVaultPatchRequest.builder()
|
||||
.expectedRevision(state.getRevision())
|
||||
.credentials(
|
||||
CredentialMutationSet.builder()
|
||||
.add(
|
||||
List.of(
|
||||
credential(
|
||||
"runtime-token",
|
||||
"runtime-token")))
|
||||
.build())
|
||||
.bindings(
|
||||
CredentialBindingMutationSet.builder()
|
||||
.add(
|
||||
List.of(
|
||||
binding(
|
||||
"runtime-added",
|
||||
"/runtime-added",
|
||||
CredentialAuth
|
||||
.apiKey(
|
||||
"X-Runtime-Token",
|
||||
"runtime-token"))))
|
||||
.build())
|
||||
.build());
|
||||
assertEquals(2, state.getRevision());
|
||||
assertEquals(List.of("runtime-token"), credentialNames(state.getCredentials()));
|
||||
assertEquals(List.of("runtime-added"), bindingNames(state.getBindings()));
|
||||
|
||||
String response = curlJson(sandbox, targetIp, "/runtime-added", true);
|
||||
assertJsonCase(response, "runtime-added", true, "[]");
|
||||
|
||||
state =
|
||||
sandbox.credentialVault()
|
||||
.patch(
|
||||
CredentialVaultPatchRequest.builder()
|
||||
.expectedRevision(state.getRevision())
|
||||
.bindings(
|
||||
CredentialBindingMutationSet.builder()
|
||||
.delete("runtime-added")
|
||||
.build())
|
||||
.build());
|
||||
assertEquals(3, state.getRevision());
|
||||
assertTrue(state.getBindings().isEmpty());
|
||||
|
||||
state =
|
||||
sandbox.credentialVault()
|
||||
.patch(
|
||||
CredentialVaultPatchRequest.builder()
|
||||
.expectedRevision(state.getRevision())
|
||||
.credentials(
|
||||
CredentialMutationSet.builder()
|
||||
.replace(
|
||||
List.of(
|
||||
credential(
|
||||
"runtime-token",
|
||||
"runtime-token-replaced")))
|
||||
.build())
|
||||
.bindings(
|
||||
CredentialBindingMutationSet.builder()
|
||||
.add(
|
||||
List.of(
|
||||
binding(
|
||||
"runtime-replaced",
|
||||
"/runtime-replaced",
|
||||
CredentialAuth
|
||||
.apiKey(
|
||||
"X-Runtime-Token",
|
||||
"runtime-token"))))
|
||||
.build())
|
||||
.build());
|
||||
assertEquals(4, state.getRevision());
|
||||
assertEquals(List.of("runtime-token"), credentialNames(state.getCredentials()));
|
||||
assertEquals(List.of("runtime-replaced"), bindingNames(state.getBindings()));
|
||||
|
||||
response = curlJson(sandbox, targetIp, "/runtime-replaced", true);
|
||||
assertJsonCase(response, "runtime-replaced", true, "[]");
|
||||
|
||||
response = curlJson(sandbox, targetIp, "/runtime-added", false);
|
||||
assertJsonCase(response, "runtime-added", false, "[\"x-runtime-token\"]");
|
||||
|
||||
state =
|
||||
sandbox.credentialVault()
|
||||
.patch(
|
||||
CredentialVaultPatchRequest.builder()
|
||||
.expectedRevision(state.getRevision())
|
||||
.bindings(
|
||||
CredentialBindingMutationSet.builder()
|
||||
.delete("runtime-replaced")
|
||||
.build())
|
||||
.build());
|
||||
assertEquals(5, state.getRevision());
|
||||
assertTrue(state.getBindings().isEmpty());
|
||||
|
||||
state =
|
||||
sandbox.credentialVault()
|
||||
.patch(
|
||||
CredentialVaultPatchRequest.builder()
|
||||
.expectedRevision(state.getRevision())
|
||||
.credentials(
|
||||
CredentialMutationSet.builder()
|
||||
.delete("runtime-token")
|
||||
.build())
|
||||
.build());
|
||||
assertEquals(6, state.getRevision());
|
||||
assertTrue(state.getCredentials().isEmpty());
|
||||
} finally {
|
||||
killSandbox(sandbox);
|
||||
}
|
||||
}
|
||||
|
||||
private static Sandbox createCredentialVaultSandbox(String targetIp) {
|
||||
String image =
|
||||
envOrDefault("OPENSANDBOX_CREDENTIAL_VAULT_E2E_SANDBOX_IMAGE", getSandboxImage());
|
||||
Map<String, String> resource = new HashMap<>();
|
||||
resource.put("cpu", envOrDefault("OPENSANDBOX_E2E_SANDBOX_CPU", "1"));
|
||||
resource.put("memory", envOrDefault("OPENSANDBOX_E2E_SANDBOX_MEMORY", "2Gi"));
|
||||
|
||||
return Sandbox.builder()
|
||||
.connectionConfig(createConnectionConfig(false))
|
||||
.image(image)
|
||||
.resource(resource)
|
||||
.timeout(Duration.ofMinutes(5))
|
||||
.readyTimeout(Duration.ofSeconds(90))
|
||||
.networkPolicy(
|
||||
NetworkPolicy.builder()
|
||||
.defaultAction(NetworkPolicy.DefaultAction.DENY)
|
||||
.addEgress(
|
||||
NetworkRule.builder()
|
||||
.action(NetworkRule.Action.ALLOW)
|
||||
.target(credentialVaultTargetHost())
|
||||
.build())
|
||||
.addEgress(
|
||||
NetworkRule.builder()
|
||||
.action(NetworkRule.Action.ALLOW)
|
||||
.target(targetIp)
|
||||
.build())
|
||||
.build())
|
||||
.credentialProxy(CredentialProxyConfig.enabled())
|
||||
.metadata(
|
||||
Map.of(
|
||||
envOrDefault(
|
||||
"OPENSANDBOX_CREDENTIAL_VAULT_E2E_LABEL_KEY",
|
||||
"opensandbox.e2e"),
|
||||
envOrDefault(
|
||||
"OPENSANDBOX_CREDENTIAL_VAULT_E2E_LABEL_VALUE",
|
||||
"credential-vault")))
|
||||
.build();
|
||||
}
|
||||
|
||||
private static List<Credential> credentials(String... names) {
|
||||
return java.util.Arrays.stream(names)
|
||||
.map(name -> credential(name, name))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private static Credential credential(String name, String valueName) {
|
||||
return Credential.builder().name(name).inlineSource(SECRET_VALUES.get(valueName)).build();
|
||||
}
|
||||
|
||||
private static CredentialBinding binding(String name, String path, CredentialAuth auth) {
|
||||
return binding(name, path, auth, List.of("GET"));
|
||||
}
|
||||
|
||||
private static CredentialBinding binding(
|
||||
String name, String path, CredentialAuth auth, List<String> methods) {
|
||||
return CredentialBinding.builder()
|
||||
.name(name)
|
||||
.match(
|
||||
CredentialMatch.builder()
|
||||
.schemes(CredentialMatch.Scheme.HTTP)
|
||||
.hosts(credentialVaultTargetHost())
|
||||
.methods(methods)
|
||||
.paths(path)
|
||||
.build())
|
||||
.auth(auth)
|
||||
.build();
|
||||
}
|
||||
|
||||
private static CredentialSubstitution substitution(
|
||||
String credential, String placeholder, CredentialSubstitution.Surface surface) {
|
||||
return CredentialSubstitution.builder()
|
||||
.credential(credential)
|
||||
.placeholder(placeholder)
|
||||
.surfaces(surface)
|
||||
.build();
|
||||
}
|
||||
|
||||
private static String curlJson(
|
||||
Sandbox sandbox, String targetIp, String path, boolean failOnHttpError) {
|
||||
return curlJson(sandbox, targetIp, path, failOnHttpError, null, List.of(), null);
|
||||
}
|
||||
|
||||
private static String curlJson(
|
||||
Sandbox sandbox,
|
||||
String targetIp,
|
||||
String path,
|
||||
boolean failOnHttpError,
|
||||
String method,
|
||||
List<String> headers,
|
||||
String data) {
|
||||
String failFlag = failOnHttpError ? "--fail " : "";
|
||||
String methodFlag = method == null ? "" : "--request " + method + " ";
|
||||
String headerFlags =
|
||||
headers.stream()
|
||||
.map(header -> "--header " + shellQuote(header) + " ")
|
||||
.collect(Collectors.joining());
|
||||
String dataFlag = data == null ? "" : "--data " + shellQuote(data) + " ";
|
||||
String command =
|
||||
"curl "
|
||||
+ failFlag
|
||||
+ "--silent --show-error --connect-timeout 5 --max-time 20 "
|
||||
+ methodFlag
|
||||
+ headerFlags
|
||||
+ dataFlag
|
||||
+ "--resolve "
|
||||
+ credentialVaultTargetHost()
|
||||
+ ":80:"
|
||||
+ targetIp
|
||||
+ " http://"
|
||||
+ credentialVaultTargetHost()
|
||||
+ path;
|
||||
for (String secret : SECRET_VALUES.values()) {
|
||||
assertFalse(command.contains(secret), "command must not contain secret material");
|
||||
}
|
||||
|
||||
Execution execution =
|
||||
sandbox.commands().run(RunCommandRequest.builder().command(command).build());
|
||||
assertNull(execution.getError(), "curl command failed");
|
||||
assertEquals(0, execution.getExitCode());
|
||||
String stdout =
|
||||
execution.getLogs().getStdout().stream()
|
||||
.map(OutputMessage::getText)
|
||||
.collect(Collectors.joining());
|
||||
assertFalse(stdout.isBlank(), "curl response must not be blank");
|
||||
return stdout;
|
||||
}
|
||||
|
||||
private static String shellQuote(String value) {
|
||||
return "'" + value.replace("'", "'\\''") + "'";
|
||||
}
|
||||
|
||||
private static void assertJsonCase(
|
||||
String payload, String expectedCase, boolean expectedOk, String expectedMissing) {
|
||||
assertTrue(payload.contains("\"ok\":" + expectedOk), payload);
|
||||
assertTrue(payload.contains("\"case\":\"" + expectedCase + "\""), payload);
|
||||
assertTrue(payload.contains("\"missingOrInvalid\":" + expectedMissing), payload);
|
||||
}
|
||||
|
||||
private static List<String> credentialNames(List<CredentialMetadata> credentials) {
|
||||
return credentials.stream().map(CredentialMetadata::getName).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private static List<String> bindingNames(List<CredentialBindingMetadata> bindings) {
|
||||
return bindings.stream()
|
||||
.map(CredentialBindingMetadata::getName)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private static String credentialVaultTargetHost() {
|
||||
return envOrDefault("OPENSANDBOX_CREDENTIAL_VAULT_E2E_TARGET_HOST", DEFAULT_TARGET_HOST);
|
||||
}
|
||||
|
||||
private static String credentialVaultTargetIp() {
|
||||
String targetIp = System.getenv("OPENSANDBOX_CREDENTIAL_VAULT_E2E_TARGET_IP");
|
||||
Assumptions.assumeTrue(
|
||||
targetIp != null && !targetIp.isBlank(),
|
||||
"Set OPENSANDBOX_CREDENTIAL_VAULT_E2E_TARGET_IP to run Credential Vault E2E");
|
||||
return targetIp;
|
||||
}
|
||||
|
||||
private static String envOrDefault(String name, String defaultValue) {
|
||||
String value = System.getenv(name);
|
||||
return value == null || value.isBlank() ? defaultValue : value;
|
||||
}
|
||||
|
||||
private static void killSandbox(Sandbox sandbox) {
|
||||
try {
|
||||
sandbox.kill();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
try {
|
||||
sandbox.close();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,839 @@
|
||||
/*
|
||||
* Copyright 2026 Alibaba Group Holding Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.alibaba.opensandbox.e2e;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import com.alibaba.opensandbox.sandbox.Sandbox;
|
||||
import com.alibaba.opensandbox.sandbox.domain.exceptions.SandboxException;
|
||||
import com.alibaba.opensandbox.sandbox.domain.models.execd.executions.Execution;
|
||||
import com.alibaba.opensandbox.sandbox.domain.models.execd.filesystem.ContentReplaceEntry;
|
||||
import com.alibaba.opensandbox.sandbox.domain.models.execd.filesystem.EntryInfo;
|
||||
import com.alibaba.opensandbox.sandbox.domain.models.execd.filesystem.MoveEntry;
|
||||
import com.alibaba.opensandbox.sandbox.domain.models.execd.filesystem.SearchEntry;
|
||||
import com.alibaba.opensandbox.sandbox.domain.models.execd.filesystem.SetPermissionEntry;
|
||||
import com.alibaba.opensandbox.sandbox.domain.models.execd.filesystem.WriteEntry;
|
||||
import com.alibaba.opensandbox.sandbox.domain.models.execd.isolated.BindMount;
|
||||
import com.alibaba.opensandbox.sandbox.domain.models.execd.isolated.CreateIsolatedSessionRequest;
|
||||
import com.alibaba.opensandbox.sandbox.domain.models.execd.isolated.IsolatedCapabilities;
|
||||
import com.alibaba.opensandbox.sandbox.domain.models.execd.isolated.IsolatedRunRequest;
|
||||
import com.alibaba.opensandbox.sandbox.domain.models.execd.isolated.IsolatedWorkspaceSpec;
|
||||
import com.alibaba.opensandbox.sandbox.domain.services.IsolationSession;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
import org.junit.jupiter.api.*;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
|
||||
public class IsolatedSessionE2ETest extends BaseE2ETest {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(IsolatedSessionE2ETest.class);
|
||||
private Sandbox sandbox;
|
||||
|
||||
private static String stdoutText(Execution exec) {
|
||||
return exec.getLogs().getStdout().stream()
|
||||
.map(m -> m.getText())
|
||||
.collect(Collectors.joining());
|
||||
}
|
||||
|
||||
@BeforeAll
|
||||
void setup() {
|
||||
sandbox =
|
||||
Sandbox.builder()
|
||||
.connectionConfig(sharedConnectionConfig)
|
||||
.image(getSandboxImage())
|
||||
.readyTimeout(Duration.ofMinutes(2))
|
||||
.extensions(Map.of("bootstrap.execd.isolation", "enable"))
|
||||
.build();
|
||||
|
||||
IsolatedCapabilities caps = sandbox.isolation().capabilities();
|
||||
log.info(
|
||||
"Isolation capabilities: available={} isolator={} version={} message={}",
|
||||
caps.getAvailable(),
|
||||
caps.getIsolator(),
|
||||
caps.getVersion(),
|
||||
caps.getMessage());
|
||||
if (!caps.getAvailable()) {
|
||||
fail("Isolation NOT available: " + (caps.getMessage() != null ? caps.getMessage() : "unknown reason"));
|
||||
}
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
void tearDown() {
|
||||
if (sandbox != null) {
|
||||
sandbox.kill();
|
||||
sandbox.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(1)
|
||||
void testCapabilities() {
|
||||
IsolatedCapabilities caps = sandbox.isolation().capabilities();
|
||||
assertTrue(caps.getAvailable());
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(2)
|
||||
void testSessionLifecycle() {
|
||||
IsolationSession session =
|
||||
sandbox.isolation()
|
||||
.create(new CreateIsolatedSessionRequest(
|
||||
new IsolatedWorkspaceSpec("/tmp", "rw"),
|
||||
"balanced", null, null, null, null, null, null, null, null));
|
||||
assertNotNull(session.getSessionId());
|
||||
|
||||
var state = session.get();
|
||||
assertEquals("active", state.getStatus());
|
||||
|
||||
session.delete();
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(3)
|
||||
void testRunEcho() {
|
||||
IsolationSession session =
|
||||
sandbox.isolation()
|
||||
.create(new CreateIsolatedSessionRequest(
|
||||
new IsolatedWorkspaceSpec("/tmp", "rw"),
|
||||
"balanced", null, null, null, null, null, null, null, null));
|
||||
try {
|
||||
Execution exec = session.run(new IsolatedRunRequest("echo hello-isolation", null, null));
|
||||
assertTrue(stdoutText(exec).contains("hello-isolation"));
|
||||
} finally {
|
||||
session.delete();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(4)
|
||||
void testPidIsolation() {
|
||||
IsolationSession session =
|
||||
sandbox.isolation()
|
||||
.create(new CreateIsolatedSessionRequest(
|
||||
new IsolatedWorkspaceSpec("/tmp", "rw"),
|
||||
"balanced", null, null, null, null, null, null, null, null));
|
||||
try {
|
||||
Execution exec = session.run(new IsolatedRunRequest("echo $$", null, null));
|
||||
int pid = Integer.parseInt(stdoutText(exec).trim());
|
||||
assertTrue(pid <= 2, "expected PID 1 or 2, got " + pid);
|
||||
} finally {
|
||||
session.delete();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(5)
|
||||
void testRunWithEnvs() {
|
||||
IsolationSession session =
|
||||
sandbox.isolation()
|
||||
.create(new CreateIsolatedSessionRequest(
|
||||
new IsolatedWorkspaceSpec("/tmp", "rw"),
|
||||
"balanced", null, null, null, null, null, null, null, null));
|
||||
try {
|
||||
Execution exec =
|
||||
session.run(new IsolatedRunRequest(
|
||||
"echo $MY_VAR",
|
||||
Map.of("MY_VAR", "test-value-42"),
|
||||
null));
|
||||
assertTrue(stdoutText(exec).contains("test-value-42"));
|
||||
} finally {
|
||||
session.delete();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(6)
|
||||
void testSessionStatePersists() {
|
||||
IsolationSession session =
|
||||
sandbox.isolation()
|
||||
.create(new CreateIsolatedSessionRequest(
|
||||
new IsolatedWorkspaceSpec("/tmp", "rw"),
|
||||
"balanced", null, null, null, null, null, null, null, null));
|
||||
try {
|
||||
session.run(new IsolatedRunRequest("export PERSIST_VAR=abc123", null, null));
|
||||
Execution exec = session.run(new IsolatedRunRequest("echo $PERSIST_VAR", null, null));
|
||||
assertTrue(stdoutText(exec).contains("abc123"));
|
||||
} finally {
|
||||
session.delete();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(7)
|
||||
void testTmpIsolation() {
|
||||
sandbox.commands().run("mkdir -p /workspace");
|
||||
|
||||
IsolationSession sessionA =
|
||||
sandbox.isolation()
|
||||
.create(new CreateIsolatedSessionRequest(
|
||||
new IsolatedWorkspaceSpec("/workspace", "rw"),
|
||||
"strict", null, null, null, null, null, null, null, null));
|
||||
IsolationSession sessionB =
|
||||
sandbox.isolation()
|
||||
.create(new CreateIsolatedSessionRequest(
|
||||
new IsolatedWorkspaceSpec("/workspace", "rw"),
|
||||
"strict", null, null, null, null, null, null, null, null));
|
||||
try {
|
||||
sessionA.run(new IsolatedRunRequest(
|
||||
"echo secret > /tmp/isolated_test_file.txt", null, null));
|
||||
Execution exec = sessionB.run(new IsolatedRunRequest(
|
||||
"cat /tmp/isolated_test_file.txt 2>&1 || echo NOT_FOUND", null, null));
|
||||
String text = stdoutText(exec);
|
||||
assertTrue(
|
||||
text.contains("NOT_FOUND") || text.contains("No such file"),
|
||||
"expected /tmp isolation, got: " + text);
|
||||
} finally {
|
||||
sessionA.delete();
|
||||
sessionB.delete();
|
||||
}
|
||||
}
|
||||
|
||||
// ── RW mode: filesystem API tests ───────────────────────────────
|
||||
|
||||
private IsolationSession createSession(String mode, String path) {
|
||||
return sandbox.isolation()
|
||||
.create(new CreateIsolatedSessionRequest(
|
||||
new IsolatedWorkspaceSpec(path, mode),
|
||||
"balanced", null, null, null, null, null, null, null, null));
|
||||
}
|
||||
|
||||
private IsolationSession createSession(String mode) {
|
||||
return createSession(mode, "/tmp");
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(8)
|
||||
void testRwFilesUploadDownload() {
|
||||
IsolationSession session = createSession("rw");
|
||||
try {
|
||||
String path = "/tmp/upload_rw_" + System.currentTimeMillis() + ".txt";
|
||||
session.getFiles().write(List.of(
|
||||
WriteEntry.builder().path(path).data("rw upload").mode(644).build()));
|
||||
String content = session.getFiles().readFile(path);
|
||||
assertEquals("rw upload", content);
|
||||
} finally {
|
||||
session.delete();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(9)
|
||||
void testRwFilesReadBytes() {
|
||||
IsolationSession session = createSession("rw");
|
||||
try {
|
||||
String path = "/tmp/bytes_rw_" + System.currentTimeMillis() + ".bin";
|
||||
byte[] data = new byte[]{0x00, 0x01, 0x02, (byte) 0xff};
|
||||
session.getFiles().write(List.of(
|
||||
WriteEntry.builder().path(path).data(data).mode(644).build()));
|
||||
byte[] read = session.getFiles().readByteArray(path);
|
||||
assertArrayEquals(data, read);
|
||||
} finally {
|
||||
session.delete();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(10)
|
||||
void testRwFilesInfo() {
|
||||
IsolationSession session = createSession("rw");
|
||||
try {
|
||||
String path = "/tmp/info_rw_" + System.currentTimeMillis() + ".txt";
|
||||
session.getFiles().write(List.of(
|
||||
WriteEntry.builder().path(path).data("info").mode(644).build()));
|
||||
Map<String, EntryInfo> infoMap = session.getFiles().readFileInfo(List.of(path));
|
||||
assertTrue(infoMap.containsKey(path));
|
||||
assertEquals(4, infoMap.get(path).getSize());
|
||||
assertEquals(644, infoMap.get(path).getMode());
|
||||
} finally {
|
||||
session.delete();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(11)
|
||||
void testRwFilesSearch() {
|
||||
IsolationSession session = createSession("rw");
|
||||
try {
|
||||
String prefix = "/tmp/search_rw_" + System.currentTimeMillis();
|
||||
session.run(new IsolatedRunRequest("mkdir -p " + prefix, null, null));
|
||||
session.getFiles().write(List.of(
|
||||
WriteEntry.builder().path(prefix + "/a.txt").data("a").mode(644).build(),
|
||||
WriteEntry.builder().path(prefix + "/b.txt").data("b").mode(644).build(),
|
||||
WriteEntry.builder().path(prefix + "/c.log").data("c").mode(644).build()));
|
||||
List<EntryInfo> results = session.getFiles().search(
|
||||
SearchEntry.builder().path(prefix).pattern("*.txt").build());
|
||||
assertEquals(2, results.size());
|
||||
List<String> paths = results.stream().map(EntryInfo::getPath).collect(Collectors.toList());
|
||||
assertTrue(paths.stream().anyMatch(p -> p.contains("a.txt")));
|
||||
assertTrue(paths.stream().anyMatch(p -> p.contains("b.txt")));
|
||||
} finally {
|
||||
session.delete();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(12)
|
||||
void testRwFilesMkdir() {
|
||||
IsolationSession session = createSession("rw");
|
||||
try {
|
||||
String dir = "/tmp/mkdir_rw_" + System.currentTimeMillis();
|
||||
session.getFiles().createDirectories(List.of(
|
||||
WriteEntry.builder().path(dir).mode(755).build()));
|
||||
Map<String, EntryInfo> infoMap = session.getFiles().readFileInfo(List.of(dir));
|
||||
assertTrue(infoMap.containsKey(dir));
|
||||
} finally {
|
||||
session.delete();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(13)
|
||||
void testRwFilesDelete() {
|
||||
IsolationSession session = createSession("rw");
|
||||
try {
|
||||
String path = "/tmp/delete_rw_" + System.currentTimeMillis() + ".txt";
|
||||
session.getFiles().write(List.of(
|
||||
WriteEntry.builder().path(path).data("del").mode(644).build()));
|
||||
session.getFiles().deleteFiles(List.of(path));
|
||||
assertThrows(SandboxException.class,
|
||||
() -> session.getFiles().readFileInfo(List.of(path)));
|
||||
} finally {
|
||||
session.delete();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(14)
|
||||
void testRwFilesMove() {
|
||||
IsolationSession session = createSession("rw");
|
||||
try {
|
||||
String src = "/tmp/mv_rw_src_" + System.currentTimeMillis() + ".txt";
|
||||
String dst = "/tmp/mv_rw_dst_" + System.currentTimeMillis() + ".txt";
|
||||
session.getFiles().write(List.of(
|
||||
WriteEntry.builder().path(src).data("move").mode(644).build()));
|
||||
session.getFiles().moveFiles(List.of(
|
||||
MoveEntry.builder().src(src).dest(dst).build()));
|
||||
assertEquals("move", session.getFiles().readFile(dst));
|
||||
} finally {
|
||||
session.delete();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(15)
|
||||
void testRwFilesChmod() {
|
||||
IsolationSession session = createSession("rw");
|
||||
try {
|
||||
String path = "/tmp/chmod_rw_" + System.currentTimeMillis() + ".txt";
|
||||
session.getFiles().write(List.of(
|
||||
WriteEntry.builder().path(path).data("ch").mode(644).build()));
|
||||
session.getFiles().setPermissions(List.of(
|
||||
SetPermissionEntry.builder().path(path).mode(755).build()));
|
||||
Map<String, EntryInfo> infoMap = session.getFiles().readFileInfo(List.of(path));
|
||||
assertEquals(755, infoMap.get(path).getMode());
|
||||
} finally {
|
||||
session.delete();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(16)
|
||||
void testRwFilesReplace() {
|
||||
IsolationSession session = createSession("rw");
|
||||
try {
|
||||
String path = "/tmp/replace_rw_" + System.currentTimeMillis() + ".txt";
|
||||
session.getFiles().write(List.of(
|
||||
WriteEntry.builder().path(path).data("hello old world").mode(644).build()));
|
||||
session.getFiles().replaceContents(List.of(
|
||||
ContentReplaceEntry.builder()
|
||||
.path(path).oldContent("old").newContent("new").build()));
|
||||
String content = session.getFiles().readFile(path);
|
||||
assertTrue(content.contains("new"));
|
||||
assertFalse(content.contains("old"));
|
||||
} finally {
|
||||
session.delete();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(17)
|
||||
void testRwFilesListDirectory() {
|
||||
IsolationSession session = createSession("rw");
|
||||
try {
|
||||
String prefix = "/tmp/listdir_rw_" + System.currentTimeMillis();
|
||||
session.run(new IsolatedRunRequest("mkdir -p " + prefix + "/sub", null, null));
|
||||
session.getFiles().write(List.of(
|
||||
WriteEntry.builder().path(prefix + "/f1.txt").data("f1").mode(644).build(),
|
||||
WriteEntry.builder().path(prefix + "/sub/f2.txt").data("f2").mode(644).build()));
|
||||
List<EntryInfo> entries = session.getFiles().listDirectory(prefix);
|
||||
List<String> names = entries.stream().map(EntryInfo::getPath).collect(Collectors.toList());
|
||||
assertTrue(names.stream().anyMatch(n -> n.contains("f1.txt")));
|
||||
assertTrue(names.stream().anyMatch(n -> n.contains("sub")));
|
||||
} finally {
|
||||
session.delete();
|
||||
}
|
||||
}
|
||||
|
||||
// ── RO mode tests ───────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@Order(18)
|
||||
void testRoCanReadExistingFiles() {
|
||||
String marker = "ro_read_" + System.currentTimeMillis() + ".txt";
|
||||
sandbox.commands().run("echo ro-data > /tmp/" + marker);
|
||||
IsolationSession session = createSession("ro");
|
||||
try {
|
||||
Execution exec = session.run(
|
||||
new IsolatedRunRequest("cat /tmp/" + marker, null, null));
|
||||
assertTrue(stdoutText(exec).contains("ro-data"));
|
||||
} finally {
|
||||
session.delete();
|
||||
sandbox.commands().run("rm -f /tmp/" + marker);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(19)
|
||||
void testRoCannotWrite() {
|
||||
IsolationSession session = createSession("ro");
|
||||
try {
|
||||
Execution exec = session.run(new IsolatedRunRequest(
|
||||
"echo fail > /tmp/ro_write_test.txt 2>&1; echo EXIT=$?", null, null));
|
||||
String text = stdoutText(exec);
|
||||
assertTrue(
|
||||
text.contains("EXIT=1") || text.contains("Read-only") || text.contains("Permission denied"),
|
||||
"expected write failure in RO mode, got: " + text);
|
||||
} finally {
|
||||
session.delete();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(20)
|
||||
void testRoFilesApiRead() {
|
||||
String marker = "ro_api_" + System.currentTimeMillis() + ".txt";
|
||||
sandbox.commands().run("echo ro-api-data > /tmp/" + marker);
|
||||
IsolationSession session = createSession("ro");
|
||||
try {
|
||||
String content = session.getFiles().readFile("/tmp/" + marker);
|
||||
assertTrue(content.contains("ro-api-data"));
|
||||
} finally {
|
||||
session.delete();
|
||||
sandbox.commands().run("rm -f /tmp/" + marker);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(21)
|
||||
void testRoFilesApiSearch() {
|
||||
String prefix = "/tmp/ro_search_" + System.currentTimeMillis();
|
||||
sandbox.commands().run("mkdir -p " + prefix + " && echo x > " + prefix + "/file.txt");
|
||||
IsolationSession session = createSession("ro");
|
||||
try {
|
||||
List<EntryInfo> results = session.getFiles().search(
|
||||
SearchEntry.builder().path(prefix).pattern("*.txt").build());
|
||||
assertTrue(results.size() >= 1);
|
||||
} finally {
|
||||
session.delete();
|
||||
sandbox.commands().run("rm -rf " + prefix);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(22)
|
||||
void testRoFilesApiListDirectory() {
|
||||
String prefix = "/tmp/ro_listdir_" + System.currentTimeMillis();
|
||||
sandbox.commands().run("mkdir -p " + prefix + " && echo x > " + prefix + "/f.txt");
|
||||
IsolationSession session = createSession("ro");
|
||||
try {
|
||||
List<EntryInfo> entries = session.getFiles().listDirectory(prefix);
|
||||
assertTrue(entries.size() >= 1);
|
||||
} finally {
|
||||
session.delete();
|
||||
sandbox.commands().run("rm -rf " + prefix);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Overlay mode tests ──────────────────────────────────────────
|
||||
|
||||
private void requireOverlay() {
|
||||
IsolatedCapabilities caps = sandbox.isolation().capabilities();
|
||||
Assumptions.assumeTrue(
|
||||
caps.getCommitSupported() || caps.getDiffSupported(),
|
||||
"overlay mode not available in this environment");
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(23)
|
||||
void testOverlayWritesNotVisibleOnHost() {
|
||||
requireOverlay();
|
||||
String marker = "overlay_invis_" + System.currentTimeMillis() + ".txt";
|
||||
IsolationSession session = createSession("overlay");
|
||||
try {
|
||||
session.run(new IsolatedRunRequest(
|
||||
"echo overlay-data > /tmp/" + marker, null, null));
|
||||
Execution hostCheck = sandbox.commands().run(
|
||||
"cat /tmp/" + marker + " 2>&1 || echo NOT_FOUND");
|
||||
String text = stdoutText(hostCheck);
|
||||
assertTrue(
|
||||
text.contains("NOT_FOUND") || text.contains("No such file"),
|
||||
"overlay write should not be visible on host, got: " + text);
|
||||
} finally {
|
||||
session.delete();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(24)
|
||||
void testOverlayCanReadHostFiles() {
|
||||
requireOverlay();
|
||||
String marker = "overlay_lower_" + System.currentTimeMillis() + ".txt";
|
||||
sandbox.commands().run("echo lower-data > /tmp/" + marker);
|
||||
IsolationSession session = createSession("overlay");
|
||||
try {
|
||||
Execution exec = session.run(new IsolatedRunRequest(
|
||||
"cat /tmp/" + marker, null, null));
|
||||
assertTrue(stdoutText(exec).contains("lower-data"));
|
||||
} finally {
|
||||
session.delete();
|
||||
sandbox.commands().run("rm -f /tmp/" + marker);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(25)
|
||||
void testOverlayCowDoesNotMutateHost() {
|
||||
requireOverlay();
|
||||
String marker = "overlay_cow_" + System.currentTimeMillis() + ".txt";
|
||||
sandbox.commands().run("echo original > /tmp/" + marker);
|
||||
IsolationSession session = createSession("overlay");
|
||||
try {
|
||||
session.run(new IsolatedRunRequest(
|
||||
"echo modified > /tmp/" + marker, null, null));
|
||||
Execution inSession = session.run(new IsolatedRunRequest(
|
||||
"cat /tmp/" + marker, null, null));
|
||||
assertTrue(stdoutText(inSession).contains("modified"));
|
||||
Execution hostCheck = sandbox.commands().run("cat /tmp/" + marker);
|
||||
assertTrue(stdoutText(hostCheck).contains("original"));
|
||||
} finally {
|
||||
session.delete();
|
||||
sandbox.commands().run("rm -f /tmp/" + marker);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(26)
|
||||
void testOverlayFilesApiUploadDownload() {
|
||||
requireOverlay();
|
||||
IsolationSession session = createSession("overlay");
|
||||
try {
|
||||
String path = "/tmp/ov_upload_" + System.currentTimeMillis() + ".txt";
|
||||
session.getFiles().write(List.of(
|
||||
WriteEntry.builder().path(path).data("overlay file").mode(644).build()));
|
||||
String content = session.getFiles().readFile(path);
|
||||
assertEquals("overlay file", content);
|
||||
// Host should NOT see it
|
||||
Execution hostCheck = sandbox.commands().run(
|
||||
"cat " + path + " 2>&1 || echo NOT_FOUND");
|
||||
String hostText = stdoutText(hostCheck);
|
||||
assertTrue(
|
||||
hostText.contains("NOT_FOUND") || hostText.contains("No such file"),
|
||||
"overlay file should not be visible on host, got: " + hostText);
|
||||
} finally {
|
||||
session.delete();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(27)
|
||||
void testOverlayFilesApiSearch() {
|
||||
requireOverlay();
|
||||
String prefix = "/tmp/ov_search_" + System.currentTimeMillis();
|
||||
sandbox.commands().run("mkdir -p " + prefix + " && echo lower > " + prefix + "/lower.txt");
|
||||
IsolationSession session = createSession("overlay");
|
||||
try {
|
||||
session.getFiles().write(List.of(
|
||||
WriteEntry.builder().path(prefix + "/upper.txt").data("upper").mode(644).build()));
|
||||
List<EntryInfo> results = session.getFiles().search(
|
||||
SearchEntry.builder().path(prefix).pattern("*.txt").build());
|
||||
List<String> paths = results.stream().map(EntryInfo::getPath).collect(Collectors.toList());
|
||||
assertTrue(paths.stream().anyMatch(p -> p.contains("lower.txt")));
|
||||
assertTrue(paths.stream().anyMatch(p -> p.contains("upper.txt")));
|
||||
} finally {
|
||||
session.delete();
|
||||
sandbox.commands().run("rm -rf " + prefix);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(28)
|
||||
void testOverlayFilesApiDelete() {
|
||||
requireOverlay();
|
||||
String prefix = "/tmp/ov_del_" + System.currentTimeMillis();
|
||||
sandbox.commands().run("mkdir -p " + prefix + " && echo x > " + prefix + "/target.txt");
|
||||
IsolationSession session = createSession("overlay");
|
||||
try {
|
||||
session.getFiles().deleteFiles(List.of(prefix + "/target.txt"));
|
||||
assertThrows(SandboxException.class,
|
||||
() -> session.getFiles().readFileInfo(List.of(prefix + "/target.txt")));
|
||||
// Host file should be untouched
|
||||
Execution hostCheck = sandbox.commands().run("cat " + prefix + "/target.txt");
|
||||
assertTrue(stdoutText(hostCheck).contains("x"));
|
||||
} finally {
|
||||
session.delete();
|
||||
sandbox.commands().run("rm -rf " + prefix);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(29)
|
||||
void testOverlayFilesApiMove() {
|
||||
requireOverlay();
|
||||
IsolationSession session = createSession("overlay");
|
||||
try {
|
||||
String src = "/tmp/ov_mv_src_" + System.currentTimeMillis() + ".txt";
|
||||
String dst = "/tmp/ov_mv_dst_" + System.currentTimeMillis() + ".txt";
|
||||
session.getFiles().write(List.of(
|
||||
WriteEntry.builder().path(src).data("moveme").mode(644).build()));
|
||||
session.getFiles().moveFiles(List.of(
|
||||
MoveEntry.builder().src(src).dest(dst).build()));
|
||||
assertEquals("moveme", session.getFiles().readFile(dst));
|
||||
} finally {
|
||||
session.delete();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(30)
|
||||
void testOverlayFilesApiChmod() {
|
||||
requireOverlay();
|
||||
String marker = "ov_chmod_" + System.currentTimeMillis() + ".txt";
|
||||
sandbox.commands().run("echo ch > /tmp/" + marker + " && chmod 644 /tmp/" + marker);
|
||||
IsolationSession session = createSession("overlay");
|
||||
try {
|
||||
session.getFiles().setPermissions(List.of(
|
||||
SetPermissionEntry.builder().path("/tmp/" + marker).mode(755).build()));
|
||||
Map<String, EntryInfo> infoMap =
|
||||
session.getFiles().readFileInfo(List.of("/tmp/" + marker));
|
||||
assertEquals(755, infoMap.get("/tmp/" + marker).getMode());
|
||||
// Host should still be 644
|
||||
Execution hostCheck = sandbox.commands().run("stat -c %a /tmp/" + marker);
|
||||
assertTrue(stdoutText(hostCheck).contains("644"));
|
||||
} finally {
|
||||
session.delete();
|
||||
sandbox.commands().run("rm -f /tmp/" + marker);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(31)
|
||||
void testOverlayFilesApiReplace() {
|
||||
requireOverlay();
|
||||
String marker = "ov_repl_" + System.currentTimeMillis() + ".txt";
|
||||
sandbox.commands().run("printf 'hello old world' > /tmp/" + marker);
|
||||
IsolationSession session = createSession("overlay");
|
||||
try {
|
||||
session.getFiles().replaceContents(List.of(
|
||||
ContentReplaceEntry.builder()
|
||||
.path("/tmp/" + marker)
|
||||
.oldContent("old")
|
||||
.newContent("new")
|
||||
.build()));
|
||||
String content = session.getFiles().readFile("/tmp/" + marker);
|
||||
assertTrue(content.contains("new"));
|
||||
assertFalse(content.contains("old"));
|
||||
// Host unchanged
|
||||
Execution hostCheck = sandbox.commands().run("cat /tmp/" + marker);
|
||||
assertTrue(stdoutText(hostCheck).contains("old"));
|
||||
} finally {
|
||||
session.delete();
|
||||
sandbox.commands().run("rm -f /tmp/" + marker);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(32)
|
||||
void testOverlayFilesApiListDirectory() {
|
||||
requireOverlay();
|
||||
String prefix = "/tmp/ov_ls_" + System.currentTimeMillis();
|
||||
sandbox.commands().run("mkdir -p " + prefix + " && echo l > " + prefix + "/lower.txt");
|
||||
IsolationSession session = createSession("overlay");
|
||||
try {
|
||||
session.getFiles().write(List.of(
|
||||
WriteEntry.builder().path(prefix + "/upper.txt").data("u").mode(644).build()));
|
||||
List<EntryInfo> entries = session.getFiles().listDirectory(prefix);
|
||||
List<String> names = entries.stream().map(EntryInfo::getPath).collect(Collectors.toList());
|
||||
assertTrue(names.stream().anyMatch(n -> n.contains("lower.txt")));
|
||||
assertTrue(names.stream().anyMatch(n -> n.contains("upper.txt")));
|
||||
} finally {
|
||||
session.delete();
|
||||
sandbox.commands().run("rm -rf " + prefix);
|
||||
}
|
||||
}
|
||||
|
||||
// ── run_once / withSession convenience API tests ─────────────────
|
||||
|
||||
@Test
|
||||
@Order(33)
|
||||
void testRunOnce() {
|
||||
Execution exec = sandbox.isolation()
|
||||
.runOnce("echo runonce-e2e", "/tmp", "rw", null, null, null, null, null);
|
||||
assertTrue(stdoutText(exec).contains("runonce-e2e"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(34)
|
||||
void testRunOnceWithEnvs() {
|
||||
Execution exec = sandbox.isolation()
|
||||
.runOnce(
|
||||
"echo $E2E_RUN_ONCE",
|
||||
"/tmp",
|
||||
"rw",
|
||||
Map.of("E2E_RUN_ONCE", "kt-value"),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null);
|
||||
assertTrue(stdoutText(exec).contains("kt-value"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(35)
|
||||
void testWithSession() {
|
||||
String output = sandbox.isolation().withSession(
|
||||
new CreateIsolatedSessionRequest(
|
||||
new IsolatedWorkspaceSpec("/tmp", "rw"),
|
||||
"balanced", null, null, null, null, null, null, null, null),
|
||||
session -> {
|
||||
session.run(new IsolatedRunRequest("export WS_VAR=with-session-kt", null, null));
|
||||
Execution exec = session.run(new IsolatedRunRequest("echo $WS_VAR", null, null));
|
||||
return stdoutText(exec);
|
||||
});
|
||||
assertTrue(output.contains("with-session-kt"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(36)
|
||||
void testWithSessionMultiRun() {
|
||||
String output = sandbox.isolation().withSession(
|
||||
new CreateIsolatedSessionRequest(
|
||||
new IsolatedWorkspaceSpec("/tmp", "rw"),
|
||||
"balanced", null, null, null, null, null, null, null, null),
|
||||
session -> {
|
||||
session.run(new IsolatedRunRequest("echo step1 > /tmp/ws_test_kt.txt", null, null));
|
||||
Execution exec = session.run(new IsolatedRunRequest("cat /tmp/ws_test_kt.txt", null, null));
|
||||
return stdoutText(exec);
|
||||
});
|
||||
assertTrue(output.contains("step1"));
|
||||
}
|
||||
|
||||
// ── Bind mount tests (explicit source->dest binds) ─────────────────
|
||||
|
||||
@Test
|
||||
@Order(37)
|
||||
void testBindReadWriteHostVisible() {
|
||||
long ts = System.currentTimeMillis();
|
||||
// Source must be within the execd writable allowlist (e.g. /data).
|
||||
String srcDir = "/data/bind_rw_" + ts;
|
||||
String dest = "/mnt/bind_rw";
|
||||
String fileName = "from_sandbox.txt";
|
||||
String content = "bind-rw-visible-on-host";
|
||||
|
||||
// Create the source dir and the destination mount point (bwrap binds
|
||||
// onto an existing dir; it cannot create one under the read-only root).
|
||||
sandbox.commands().run("mkdir -p " + srcDir + " " + dest);
|
||||
|
||||
IsolationSession session =
|
||||
sandbox.isolation()
|
||||
.create(new CreateIsolatedSessionRequest(
|
||||
new IsolatedWorkspaceSpec("/tmp", "rw"),
|
||||
"balanced",
|
||||
null,
|
||||
List.of(new BindMount(srcDir, dest, null)),
|
||||
null, null, null, null, null, null));
|
||||
try {
|
||||
Execution exec = session.run(new IsolatedRunRequest(
|
||||
"echo -n " + content + " > " + dest + "/" + fileName
|
||||
+ " && cat " + dest + "/" + fileName,
|
||||
null, null));
|
||||
assertTrue(stdoutText(exec).contains(content));
|
||||
|
||||
Execution hostCheck = sandbox.commands().run("cat " + srcDir + "/" + fileName);
|
||||
assertTrue(stdoutText(hostCheck).contains(content));
|
||||
} finally {
|
||||
session.delete();
|
||||
sandbox.commands().run("rm -rf " + srcDir);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(38)
|
||||
void testBindIllegalRejected() {
|
||||
assertThrows(
|
||||
SandboxException.class,
|
||||
() -> sandbox.isolation()
|
||||
.create(new CreateIsolatedSessionRequest(
|
||||
new IsolatedWorkspaceSpec("/tmp", "rw"),
|
||||
"balanced",
|
||||
null,
|
||||
// /etc is not in the writable allowlist.
|
||||
List.of(new BindMount("/etc", "/mnt/etc", null)),
|
||||
null, null, null, null, null, null)));
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(39)
|
||||
void testBindReadOnlyReadable() {
|
||||
long ts = System.currentTimeMillis();
|
||||
String srcDir = "/data/bind_ro_" + ts;
|
||||
String dest = "/mnt/bind_ro";
|
||||
String fileName = "host_created.txt";
|
||||
String content = "bind-ro-host-content";
|
||||
|
||||
sandbox.commands().run(
|
||||
"mkdir -p " + srcDir + " " + dest + " && echo -n " + content + " > " + srcDir + "/" + fileName);
|
||||
|
||||
IsolationSession session =
|
||||
sandbox.isolation()
|
||||
.create(new CreateIsolatedSessionRequest(
|
||||
new IsolatedWorkspaceSpec("/tmp", "rw"),
|
||||
"balanced",
|
||||
null,
|
||||
List.of(new BindMount(srcDir, dest, true)),
|
||||
null, null, null, null, null, null));
|
||||
try {
|
||||
Execution exec = session.run(new IsolatedRunRequest("cat " + dest + "/" + fileName, null, null));
|
||||
assertTrue(stdoutText(exec).contains(content));
|
||||
|
||||
Execution write = session.run(new IsolatedRunRequest(
|
||||
"echo x > " + dest + "/newfile.txt 2>&1; echo EXIT=$?", null, null));
|
||||
String text = stdoutText(write);
|
||||
assertTrue(
|
||||
text.contains("EXIT=1") || text.contains("Read-only")
|
||||
|| text.contains("read-only") || text.contains("Permission denied"),
|
||||
"expected write to fail through read-only bind, got: " + text);
|
||||
} finally {
|
||||
session.delete();
|
||||
sandbox.commands().run("rm -rf " + srcDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,272 @@
|
||||
/*
|
||||
* Copyright 2025 Alibaba Group Holding Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.alibaba.opensandbox.e2e;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import com.alibaba.opensandbox.sandbox.Sandbox;
|
||||
import com.alibaba.opensandbox.sandbox.SandboxManager;
|
||||
import com.alibaba.opensandbox.sandbox.domain.exceptions.SandboxException;
|
||||
import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.*;
|
||||
import java.time.Duration;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.junit.jupiter.api.*;
|
||||
|
||||
/**
|
||||
* E2E tests for SandboxManager list/filter semantics.
|
||||
*
|
||||
* <p>Focus:
|
||||
*
|
||||
* <ul>
|
||||
* <li>states filter uses OR logic
|
||||
* <li>metadata filter uses AND logic
|
||||
* </ul>
|
||||
*
|
||||
* <p>We create 3 dedicated sandboxes per run to keep assertions deterministic and avoid impacting
|
||||
* the shared sandbox used by other tests.
|
||||
*/
|
||||
@Tag("e2e")
|
||||
@DisplayName("SandboxManager E2E Tests (Java SDK) - List/Filter Semantics")
|
||||
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
|
||||
public class SandboxManagerE2ETest extends BaseE2ETest {
|
||||
|
||||
private SandboxManager sandboxManager;
|
||||
private Sandbox s1;
|
||||
private Sandbox s2;
|
||||
private Sandbox s3;
|
||||
private String tag;
|
||||
|
||||
@BeforeAll
|
||||
void setup() throws InterruptedException {
|
||||
sandboxManager = SandboxManager.builder().connectionConfig(sharedConnectionConfig).build();
|
||||
tag = "e2e-sandbox-manager-" + UUID.randomUUID().toString().substring(0, 8);
|
||||
Map<String, String> resourceMap = new HashMap<>();
|
||||
resourceMap.put("cpu", "1");
|
||||
resourceMap.put("memory", "2Gi");
|
||||
|
||||
s1 =
|
||||
Sandbox.builder()
|
||||
.connectionConfig(sharedConnectionConfig)
|
||||
.image(getSandboxImage())
|
||||
.resource(resourceMap)
|
||||
.timeout(Duration.ofMinutes(5))
|
||||
.readyTimeout(Duration.ofSeconds(60))
|
||||
.metadata(Map.of("tag", tag, "team", "t1", "env", "prod"))
|
||||
.env("E2E_TEST", "true")
|
||||
.env("EXECD_API_GRACE_SHUTDOWN", "3s")
|
||||
.env("EXECD_JUPYTER_IDLE_POLL_INTERVAL", "200ms")
|
||||
.healthCheckPollingInterval(Duration.ofMillis(500))
|
||||
.build();
|
||||
s2 =
|
||||
Sandbox.builder()
|
||||
.connectionConfig(sharedConnectionConfig)
|
||||
.image(getSandboxImage())
|
||||
.resource(resourceMap)
|
||||
.timeout(Duration.ofMinutes(5))
|
||||
.readyTimeout(Duration.ofSeconds(60))
|
||||
.metadata(Map.of("tag", tag, "team", "t1", "env", "dev"))
|
||||
.env("E2E_TEST", "true")
|
||||
.env("EXECD_API_GRACE_SHUTDOWN", "3s")
|
||||
.env("EXECD_JUPYTER_IDLE_POLL_INTERVAL", "200ms")
|
||||
.healthCheckPollingInterval(Duration.ofMillis(500))
|
||||
.build();
|
||||
s3 =
|
||||
Sandbox.builder()
|
||||
.connectionConfig(sharedConnectionConfig)
|
||||
.image(getSandboxImage())
|
||||
.resource(resourceMap)
|
||||
.timeout(Duration.ofMinutes(5))
|
||||
.readyTimeout(Duration.ofSeconds(60))
|
||||
.metadata(Map.of("tag", tag, "env", "prod"))
|
||||
.env("E2E_TEST", "true")
|
||||
.env("EXECD_API_GRACE_SHUTDOWN", "3s")
|
||||
.env("EXECD_JUPYTER_IDLE_POLL_INTERVAL", "200ms")
|
||||
.healthCheckPollingInterval(Duration.ofMillis(500))
|
||||
.build();
|
||||
|
||||
assertTrue(s1.isHealthy());
|
||||
assertTrue(s2.isHealthy());
|
||||
assertTrue(s3.isHealthy());
|
||||
|
||||
// Pause s3 to create a deterministic non-Running state.
|
||||
sandboxManager.pauseSandbox(s3.getId());
|
||||
long deadline = System.currentTimeMillis() + 180_000;
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
SandboxInfo info = sandboxManager.getSandboxInfo(s3.getId());
|
||||
if ("Paused".equals(info.getStatus().getState())) {
|
||||
break;
|
||||
}
|
||||
Thread.sleep(1000);
|
||||
}
|
||||
assertEquals("Paused", sandboxManager.getSandboxInfo(s3.getId()).getStatus().getState());
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
void teardown() {
|
||||
for (Sandbox s : List.of(s1, s2, s3)) {
|
||||
if (s == null) continue;
|
||||
try {
|
||||
s.kill();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
try {
|
||||
s.close();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
if (sandboxManager != null) {
|
||||
try {
|
||||
sandboxManager.close();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(1)
|
||||
@DisplayName("states filter uses OR semantics")
|
||||
@Timeout(value = 2, unit = TimeUnit.MINUTES)
|
||||
void testStatesFilterOrLogic() {
|
||||
SandboxFilter filter =
|
||||
SandboxFilter.builder()
|
||||
.states("Running", "Paused")
|
||||
.metadata(Map.of("tag", tag))
|
||||
.pageSize(50)
|
||||
.build();
|
||||
PagedSandboxInfos infos = sandboxManager.listSandboxInfos(filter);
|
||||
Set<String> ids = new HashSet<>();
|
||||
for (SandboxInfo info : infos.getSandboxInfos()) {
|
||||
ids.add(info.getId());
|
||||
}
|
||||
assertTrue(ids.containsAll(Set.of(s1.getId(), s2.getId(), s3.getId())));
|
||||
|
||||
PagedSandboxInfos pausedOnly =
|
||||
sandboxManager.listSandboxInfos(
|
||||
SandboxFilter.builder()
|
||||
.states("Paused")
|
||||
.metadata(Map.of("tag", tag))
|
||||
.pageSize(50)
|
||||
.build());
|
||||
Set<String> pausedIds = new HashSet<>();
|
||||
for (SandboxInfo info : pausedOnly.getSandboxInfos()) {
|
||||
pausedIds.add(info.getId());
|
||||
}
|
||||
assertTrue(pausedIds.contains(s3.getId()));
|
||||
assertFalse(pausedIds.contains(s1.getId()));
|
||||
assertFalse(pausedIds.contains(s2.getId()));
|
||||
|
||||
PagedSandboxInfos runningOnly =
|
||||
sandboxManager.listSandboxInfos(
|
||||
SandboxFilter.builder()
|
||||
.states("Running")
|
||||
.metadata(Map.of("tag", tag))
|
||||
.pageSize(50)
|
||||
.build());
|
||||
Set<String> runningIds = new HashSet<>();
|
||||
for (SandboxInfo info : runningOnly.getSandboxInfos()) {
|
||||
runningIds.add(info.getId());
|
||||
}
|
||||
assertTrue(runningIds.contains(s1.getId()));
|
||||
assertTrue(runningIds.contains(s2.getId()));
|
||||
assertFalse(runningIds.contains(s3.getId()));
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(2)
|
||||
@DisplayName("metadata filter uses AND semantics")
|
||||
@Timeout(value = 2, unit = TimeUnit.MINUTES)
|
||||
void testMetadataFilterAndLogic() {
|
||||
PagedSandboxInfos tagAndTeam =
|
||||
sandboxManager.listSandboxInfos(
|
||||
SandboxFilter.builder()
|
||||
.metadata(Map.of("tag", tag, "team", "t1"))
|
||||
.pageSize(50)
|
||||
.build());
|
||||
Set<String> ids = new HashSet<>();
|
||||
for (SandboxInfo info : tagAndTeam.getSandboxInfos()) {
|
||||
ids.add(info.getId());
|
||||
}
|
||||
assertTrue(ids.contains(s1.getId()));
|
||||
assertTrue(ids.contains(s2.getId()));
|
||||
assertFalse(ids.contains(s3.getId()));
|
||||
|
||||
PagedSandboxInfos tagTeamEnv =
|
||||
sandboxManager.listSandboxInfos(
|
||||
SandboxFilter.builder()
|
||||
.metadata(Map.of("tag", tag, "team", "t1", "env", "prod"))
|
||||
.pageSize(50)
|
||||
.build());
|
||||
Set<String> ids2 = new HashSet<>();
|
||||
for (SandboxInfo info : tagTeamEnv.getSandboxInfos()) {
|
||||
ids2.add(info.getId());
|
||||
}
|
||||
assertTrue(ids2.contains(s1.getId()));
|
||||
assertFalse(ids2.contains(s2.getId()));
|
||||
assertFalse(ids2.contains(s3.getId()));
|
||||
|
||||
PagedSandboxInfos tagEnv =
|
||||
sandboxManager.listSandboxInfos(
|
||||
SandboxFilter.builder()
|
||||
.metadata(Map.of("tag", tag, "env", "prod"))
|
||||
.pageSize(50)
|
||||
.build());
|
||||
Set<String> ids3 = new HashSet<>();
|
||||
for (SandboxInfo info : tagEnv.getSandboxInfos()) {
|
||||
ids3.add(info.getId());
|
||||
}
|
||||
assertTrue(ids3.contains(s1.getId()));
|
||||
assertTrue(ids3.contains(s3.getId()));
|
||||
assertFalse(ids3.contains(s2.getId()));
|
||||
|
||||
PagedSandboxInfos noneMatch =
|
||||
sandboxManager.listSandboxInfos(
|
||||
SandboxFilter.builder()
|
||||
.metadata(Map.of("tag", tag, "team", "t2"))
|
||||
.pageSize(50)
|
||||
.build());
|
||||
for (SandboxInfo info : noneMatch.getSandboxInfos()) {
|
||||
assertFalse(Set.of(s1.getId(), s2.getId(), s3.getId()).contains(info.getId()));
|
||||
}
|
||||
|
||||
Map<String, String> patch = new HashMap<>();
|
||||
patch.put("env", "stage");
|
||||
patch.put("team", null);
|
||||
SandboxInfo patched = sandboxManager.patchSandboxMetadata(s2.getId(), patch);
|
||||
assertEquals("stage", patched.getMetadata().get("env"));
|
||||
assertFalse(patched.getMetadata().containsKey("team"));
|
||||
|
||||
SandboxInfo refreshed = sandboxManager.getSandboxInfo(s2.getId());
|
||||
assertEquals("stage", refreshed.getMetadata().get("env"));
|
||||
assertFalse(refreshed.getMetadata().containsKey("team"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(3)
|
||||
@DisplayName("invalid operations raise SandboxException")
|
||||
@Timeout(value = 1, unit = TimeUnit.MINUTES)
|
||||
void testInvalidOperations() {
|
||||
String nonExistentId = "non-existent-" + System.nanoTime();
|
||||
assertThrows(SandboxException.class, () -> sandboxManager.getSandboxInfo(nonExistentId));
|
||||
assertThrows(SandboxException.class, () -> sandboxManager.pauseSandbox(nonExistentId));
|
||||
assertThrows(SandboxException.class, () -> sandboxManager.resumeSandbox(nonExistentId));
|
||||
assertThrows(SandboxException.class, () -> sandboxManager.killSandbox(nonExistentId));
|
||||
assertThrows(
|
||||
SandboxException.class,
|
||||
() -> sandboxManager.renewSandbox(nonExistentId, Duration.ofMinutes(5)));
|
||||
}
|
||||
}
|
||||
+818
@@ -0,0 +1,818 @@
|
||||
/*
|
||||
* Copyright 2025 Alibaba Group Holding Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.alibaba.opensandbox.e2e;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.junit.jupiter.api.Assumptions.assumeTrue;
|
||||
|
||||
import com.alibaba.opensandbox.sandbox.Sandbox;
|
||||
import com.alibaba.opensandbox.sandbox.SandboxManager;
|
||||
import com.alibaba.opensandbox.sandbox.domain.exceptions.PoolAcquireFailedException;
|
||||
import com.alibaba.opensandbox.sandbox.domain.exceptions.PoolDestroyedException;
|
||||
import com.alibaba.opensandbox.sandbox.domain.exceptions.PoolEmptyException;
|
||||
import com.alibaba.opensandbox.sandbox.domain.models.execd.executions.Execution;
|
||||
import com.alibaba.opensandbox.sandbox.domain.models.execd.executions.RunCommandRequest;
|
||||
import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.PagedSandboxInfos;
|
||||
import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxFilter;
|
||||
import com.alibaba.opensandbox.sandbox.domain.pool.AcquirePolicy;
|
||||
import com.alibaba.opensandbox.sandbox.domain.pool.PoolCreationSpec;
|
||||
import com.alibaba.opensandbox.sandbox.domain.pool.PoolDestroyOptions;
|
||||
import com.alibaba.opensandbox.sandbox.domain.pool.PoolDestroyResult;
|
||||
import com.alibaba.opensandbox.sandbox.domain.pool.PoolDestroyState;
|
||||
import com.alibaba.opensandbox.sandbox.infrastructure.pool.RedisPoolStateStore;
|
||||
import com.alibaba.opensandbox.sandbox.pool.SandboxPool;
|
||||
import com.alibaba.opensandbox.sandbox.pool.SandboxPoolManager;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.function.BooleanSupplier;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Tag;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.Timeout;
|
||||
import redis.clients.jedis.JedisPooled;
|
||||
|
||||
@Tag("e2e")
|
||||
@DisplayName("SandboxPool E2E Tests (Redis Distributed)")
|
||||
public class SandboxPoolRedisDistributedE2ETest extends BaseE2ETest {
|
||||
private static final Duration RECONCILE_INTERVAL = Duration.ofSeconds(1);
|
||||
private static final Duration PRIMARY_LOCK_TTL = Duration.ofSeconds(4);
|
||||
private static final Duration DRAIN_TIMEOUT = Duration.ofMillis(200);
|
||||
private static final Duration AWAIT_TIMEOUT = Duration.ofMinutes(2);
|
||||
|
||||
private final List<SandboxPool> pools = new ArrayList<>();
|
||||
private final List<Sandbox> borrowed = new CopyOnWriteArrayList<>();
|
||||
|
||||
private JedisPooled redis;
|
||||
private SandboxManager sandboxManager;
|
||||
private String keyPrefix;
|
||||
private String tag;
|
||||
|
||||
@BeforeEach
|
||||
void setupRedis() {
|
||||
String redisUrl = System.getenv("OPENSANDBOX_TEST_REDIS_URL");
|
||||
assumeTrue(
|
||||
redisUrl != null && !redisUrl.isBlank(),
|
||||
"Set OPENSANDBOX_TEST_REDIS_URL to run Redis-backed pool E2E tests");
|
||||
redis = new JedisPooled(redisUrl);
|
||||
keyPrefix = "opensandbox:e2e:" + UUID.randomUUID();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void teardown() {
|
||||
for (Sandbox sandbox : borrowed) {
|
||||
killAndCloseQuietly(sandbox);
|
||||
}
|
||||
borrowed.clear();
|
||||
|
||||
for (SandboxPool pool : pools) {
|
||||
try {
|
||||
pool.resize(0);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
try {
|
||||
pool.releaseAllIdle();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
try {
|
||||
pool.shutdown(false);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
pools.clear();
|
||||
|
||||
if (sandboxManager != null && tag != null) {
|
||||
cleanupTaggedSandboxes(tag);
|
||||
}
|
||||
if (sandboxManager != null) {
|
||||
try {
|
||||
sandboxManager.close();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
if (redis != null) {
|
||||
cleanupRedisKeys();
|
||||
try {
|
||||
redis.close();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Redis store supports cross-node acquire, shared resize, and idle drain")
|
||||
@Timeout(value = 6, unit = TimeUnit.MINUTES)
|
||||
void testCrossNodeAcquireResizeAndDrain() throws Exception {
|
||||
tag = "e2e-redis-pool-" + UUID.randomUUID().toString().substring(0, 8);
|
||||
String poolName = "redis-pool-" + tag;
|
||||
sandboxManager = SandboxManager.builder().connectionConfig(sharedConnectionConfig).build();
|
||||
|
||||
RedisPoolStateStore storeA = new RedisPoolStateStore(redis, keyPrefix);
|
||||
RedisPoolStateStore storeB = new RedisPoolStateStore(redis, keyPrefix);
|
||||
SandboxPool poolA = createPool(poolName, "owner-a-" + tag, storeA, 2);
|
||||
SandboxPool poolB = createPool(poolName, "owner-b-" + tag, storeB, 2);
|
||||
pools.add(poolA);
|
||||
pools.add(poolB);
|
||||
|
||||
poolA.start();
|
||||
poolB.start();
|
||||
|
||||
eventually(
|
||||
"Redis-backed distributed pool warmed idle",
|
||||
AWAIT_TIMEOUT,
|
||||
Duration.ofSeconds(1),
|
||||
() -> poolA.snapshot().getIdleCount() >= 1);
|
||||
|
||||
Sandbox sandbox = poolB.acquire(Duration.ofMinutes(5), AcquirePolicy.FAIL_FAST);
|
||||
borrowed.add(sandbox);
|
||||
assertTrue(sandbox.isHealthy(), "cross-node acquire should return a healthy sandbox");
|
||||
Execution execution =
|
||||
sandbox.commands()
|
||||
.run(RunCommandRequest.builder().command("echo redis-dist-ok").build());
|
||||
assertNotNull(execution);
|
||||
assertNull(execution.getError());
|
||||
|
||||
poolB.resize(0);
|
||||
eventually(
|
||||
"Redis-backed idle drains after resize(0)",
|
||||
Duration.ofSeconds(45),
|
||||
Duration.ofSeconds(1),
|
||||
() -> poolA.snapshot().getIdleCount() == 0);
|
||||
|
||||
Thread.sleep(RECONCILE_INTERVAL.multipliedBy(3).toMillis());
|
||||
assertEquals(
|
||||
0, poolA.snapshot().getIdleCount(), "idle should stay at zero after resize(0)");
|
||||
assertThrows(
|
||||
PoolEmptyException.class,
|
||||
() -> poolA.acquire(Duration.ofMinutes(2), AcquirePolicy.FAIL_FAST));
|
||||
|
||||
Sandbox direct = poolA.acquire(Duration.ofMinutes(5), AcquirePolicy.DIRECT_CREATE);
|
||||
borrowed.add(direct);
|
||||
assertTrue(
|
||||
direct.isHealthy(), "direct create should still work when shared maxIdle is zero");
|
||||
Execution directExecution =
|
||||
direct.commands()
|
||||
.run(RunCommandRequest.builder().command("echo redis-direct-ok").build());
|
||||
assertNotNull(directExecution);
|
||||
assertNull(directExecution.getError());
|
||||
assertEquals(
|
||||
0,
|
||||
poolA.snapshot().getIdleCount(),
|
||||
"DIRECT_CREATE must not repopulate the shared idle store");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Redis primary lock fails over after leader shutdown")
|
||||
@Timeout(value = 6, unit = TimeUnit.MINUTES)
|
||||
void testPrimaryFailoverAfterLeaderShutdown() throws Exception {
|
||||
tag = "e2e-redis-failover-" + UUID.randomUUID().toString().substring(0, 8);
|
||||
String poolName = "redis-failover-" + tag;
|
||||
sandboxManager = SandboxManager.builder().connectionConfig(sharedConnectionConfig).build();
|
||||
|
||||
RedisPoolStateStore storeA = new RedisPoolStateStore(redis, keyPrefix);
|
||||
RedisPoolStateStore storeB = new RedisPoolStateStore(redis, keyPrefix);
|
||||
SandboxPool poolA = createPool(poolName, "owner-a-" + tag, storeA, 1);
|
||||
SandboxPool poolB = createPool(poolName, "owner-b-" + tag, storeB, 1);
|
||||
pools.add(poolA);
|
||||
pools.add(poolB);
|
||||
|
||||
poolA.start();
|
||||
|
||||
eventually(
|
||||
"first Redis-backed node warms idle",
|
||||
AWAIT_TIMEOUT,
|
||||
Duration.ofSeconds(1),
|
||||
() -> poolA.snapshot().getIdleCount() >= 1);
|
||||
|
||||
int beforeShutdown = poolA.snapshot().getIdleCount();
|
||||
poolB.start();
|
||||
poolA.shutdown(false);
|
||||
poolB.resize(0);
|
||||
eventually(
|
||||
"remaining node applies shared resize after peer shutdown",
|
||||
Duration.ofSeconds(45),
|
||||
Duration.ofSeconds(1),
|
||||
() -> poolB.snapshot().getIdleCount() == 0);
|
||||
|
||||
poolB.resize(1);
|
||||
eventually(
|
||||
"remaining node replenishes after failover",
|
||||
AWAIT_TIMEOUT,
|
||||
Duration.ofSeconds(1),
|
||||
() -> poolB.snapshot().getIdleCount() >= 1);
|
||||
assertTrue(beforeShutdown >= 1, "poolA should have warmed idle before shutdown");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Redis start overwrites stale shared maxIdle after restart")
|
||||
@Timeout(value = 7, unit = TimeUnit.MINUTES)
|
||||
void testStartOverwritesStaleSharedMaxIdleAfterRestart() throws Exception {
|
||||
tag = "e2e-redis-restart-config-" + UUID.randomUUID().toString().substring(0, 8);
|
||||
String poolName = "redis-restart-config-" + tag;
|
||||
sandboxManager = SandboxManager.builder().connectionConfig(sharedConnectionConfig).build();
|
||||
|
||||
RedisPoolStateStore storeA = new RedisPoolStateStore(redis, keyPrefix);
|
||||
SandboxPool poolA = createPool(poolName, "owner-a-" + tag, storeA, 1);
|
||||
pools.add(poolA);
|
||||
|
||||
poolA.start();
|
||||
eventually(
|
||||
"initial Redis-backed pool warms",
|
||||
AWAIT_TIMEOUT,
|
||||
Duration.ofSeconds(1),
|
||||
() -> poolA.snapshot().getIdleCount() >= 1);
|
||||
poolA.resize(0);
|
||||
eventually(
|
||||
"initial Redis-backed pool drains to zero",
|
||||
Duration.ofSeconds(45),
|
||||
Duration.ofSeconds(1),
|
||||
() -> poolA.snapshot().getIdleCount() == 0);
|
||||
poolA.shutdown(false);
|
||||
|
||||
RedisPoolStateStore storeB = new RedisPoolStateStore(redis, keyPrefix);
|
||||
SandboxPool poolB = createPool(poolName, "owner-b-" + tag, storeB, 2);
|
||||
pools.add(poolB);
|
||||
poolB.start();
|
||||
|
||||
eventually(
|
||||
"restart with same Redis namespace uses new configured maxIdle",
|
||||
AWAIT_TIMEOUT,
|
||||
Duration.ofSeconds(1),
|
||||
() -> poolB.snapshot().getMaxIdle() == 2 && poolB.snapshot().getIdleCount() >= 2);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Redis secondary resize is applied by primary periodic reconcile")
|
||||
@Timeout(value = 7, unit = TimeUnit.MINUTES)
|
||||
void testSecondaryResizeAppliedByPrimaryPeriodicReconcile() throws Exception {
|
||||
tag = "e2e-redis-secondary-resize-" + UUID.randomUUID().toString().substring(0, 8);
|
||||
String poolName = "redis-secondary-resize-" + tag;
|
||||
String ownerA = "owner-a-" + tag;
|
||||
String ownerB = "owner-b-" + tag;
|
||||
sandboxManager = SandboxManager.builder().connectionConfig(sharedConnectionConfig).build();
|
||||
|
||||
RedisPoolStateStore storeA = new RedisPoolStateStore(redis, keyPrefix);
|
||||
RedisPoolStateStore storeB = new RedisPoolStateStore(redis, keyPrefix);
|
||||
SandboxPool poolA = createPool(poolName, ownerA, storeA, 2);
|
||||
SandboxPool poolB = createPool(poolName, ownerB, storeB, 2);
|
||||
pools.add(poolA);
|
||||
pools.add(poolB);
|
||||
String lockKey = poolKey(poolName, "lock");
|
||||
|
||||
poolA.start();
|
||||
eventually(
|
||||
"primary Redis-backed node owns lock and warms",
|
||||
AWAIT_TIMEOUT,
|
||||
Duration.ofSeconds(1),
|
||||
() -> ownerA.equals(redis.get(lockKey)) && poolA.snapshot().getIdleCount() >= 2);
|
||||
poolB.start();
|
||||
|
||||
poolB.resize(0);
|
||||
eventually(
|
||||
"secondary resize to zero is applied by primary",
|
||||
AWAIT_TIMEOUT,
|
||||
Duration.ofSeconds(1),
|
||||
() -> ownerA.equals(redis.get(lockKey)) && poolA.snapshot().getIdleCount() == 0);
|
||||
poolB.resize(2);
|
||||
eventually(
|
||||
"secondary resize up is applied by primary",
|
||||
AWAIT_TIMEOUT,
|
||||
Duration.ofSeconds(1),
|
||||
() -> ownerA.equals(redis.get(lockKey)) && poolA.snapshot().getIdleCount() >= 2);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Redis idle take is unique under concurrent cross-node acquire")
|
||||
@Timeout(value = 6, unit = TimeUnit.MINUTES)
|
||||
void testConcurrentCrossNodeAcquireDoesNotDuplicateIdle() throws Exception {
|
||||
tag = "e2e-redis-concurrent-" + UUID.randomUUID().toString().substring(0, 8);
|
||||
String poolName = "redis-concurrent-" + tag;
|
||||
sandboxManager = SandboxManager.builder().connectionConfig(sharedConnectionConfig).build();
|
||||
|
||||
RedisPoolStateStore storeA = new RedisPoolStateStore(redis, keyPrefix);
|
||||
RedisPoolStateStore storeB = new RedisPoolStateStore(redis, keyPrefix);
|
||||
SandboxPool poolA = createPool(poolName, "owner-a-" + tag, storeA, 2);
|
||||
SandboxPool poolB = createPool(poolName, "owner-b-" + tag, storeB, 2);
|
||||
pools.add(poolA);
|
||||
pools.add(poolB);
|
||||
|
||||
poolA.start();
|
||||
poolB.start();
|
||||
eventually(
|
||||
"Redis-backed pool warms two idle sandboxes",
|
||||
AWAIT_TIMEOUT,
|
||||
Duration.ofSeconds(1),
|
||||
() -> poolA.snapshot().getIdleCount() >= 2);
|
||||
|
||||
ExecutorService executor = Executors.newFixedThreadPool(2);
|
||||
CountDownLatch start = new CountDownLatch(1);
|
||||
Set<String> acquiredIds = ConcurrentHashMap.newKeySet();
|
||||
List<Future<Sandbox>> futures = new ArrayList<>();
|
||||
futures.add(
|
||||
executor.submit(
|
||||
() -> {
|
||||
start.await();
|
||||
return poolA.acquire(Duration.ofMinutes(5), AcquirePolicy.FAIL_FAST);
|
||||
}));
|
||||
futures.add(
|
||||
executor.submit(
|
||||
() -> {
|
||||
start.await();
|
||||
return poolB.acquire(Duration.ofMinutes(5), AcquirePolicy.FAIL_FAST);
|
||||
}));
|
||||
start.countDown();
|
||||
|
||||
try {
|
||||
for (Future<Sandbox> future : futures) {
|
||||
Sandbox sandbox = future.get(90, TimeUnit.SECONDS);
|
||||
borrowed.add(sandbox);
|
||||
assertTrue(sandbox.isHealthy(), "concurrent acquire should return healthy sandbox");
|
||||
assertTrue(
|
||||
acquiredIds.add(sandbox.getId()), "sandbox ID must not be acquired twice");
|
||||
}
|
||||
} finally {
|
||||
executor.shutdownNow();
|
||||
}
|
||||
|
||||
assertEquals(
|
||||
2, acquiredIds.size(), "two concurrent acquires should get two distinct sandboxes");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Redis DESTROYING fence blocks existing and replacement pool nodes")
|
||||
@Timeout(value = 6, unit = TimeUnit.MINUTES)
|
||||
void testDestroyingFenceBlocksRedisBackedPoolNodes() throws Exception {
|
||||
tag = "e2e-redis-destroying-" + UUID.randomUUID().toString().substring(0, 8);
|
||||
String poolName = "redis-destroying-" + tag;
|
||||
String ownerA = "owner-a-" + tag;
|
||||
sandboxManager = SandboxManager.builder().connectionConfig(sharedConnectionConfig).build();
|
||||
|
||||
RedisPoolStateStore storeA = new RedisPoolStateStore(redis, keyPrefix);
|
||||
RedisPoolStateStore storeB = new RedisPoolStateStore(redis, keyPrefix);
|
||||
SandboxPool poolA =
|
||||
createPoolBuilder(poolName, ownerA, storeA, 1)
|
||||
.reconcileInterval(Duration.ofMinutes(5))
|
||||
.build();
|
||||
SandboxPool poolB =
|
||||
createPoolBuilder(poolName, "owner-b-" + tag, storeB, 1)
|
||||
.reconcileInterval(Duration.ofMinutes(5))
|
||||
.build();
|
||||
pools.add(poolA);
|
||||
pools.add(poolB);
|
||||
poolA.start();
|
||||
poolB.start();
|
||||
|
||||
eventually(
|
||||
"Redis-backed DESTROYING fence target warms one shared idle sandbox",
|
||||
AWAIT_TIMEOUT,
|
||||
Duration.ofSeconds(1),
|
||||
() -> storeA.snapshotCounters(poolName).getIdleCount() >= 1);
|
||||
|
||||
storeA.beginDestroy(poolName, "destroyer-" + tag);
|
||||
|
||||
assertEquals(PoolDestroyState.DESTROYING, storeA.getDestroyState(poolName));
|
||||
assertFalse(
|
||||
storeA.tryAcquirePrimaryLock(poolName, "owner-c-" + tag, Duration.ofSeconds(30)));
|
||||
assertFalse(storeA.renewPrimaryLock(poolName, ownerA, Duration.ofSeconds(30)));
|
||||
assertThrows(PoolDestroyedException.class, () -> storeA.putIdle(poolName, "blocked-id"));
|
||||
assertThrows(PoolDestroyedException.class, () -> storeB.setMaxIdle(poolName, 2));
|
||||
|
||||
assertThrows(
|
||||
PoolDestroyedException.class,
|
||||
() -> poolA.acquire(Duration.ofMinutes(5), AcquirePolicy.DIRECT_CREATE));
|
||||
assertThrows(
|
||||
PoolDestroyedException.class,
|
||||
() -> poolB.acquire(Duration.ofMinutes(5), AcquirePolicy.DIRECT_CREATE));
|
||||
assertThrows(PoolDestroyedException.class, () -> poolA.resize(1));
|
||||
|
||||
SandboxPool replacement =
|
||||
createPoolBuilder(poolName, "owner-replacement-" + tag, storeB, 1)
|
||||
.reconcileInterval(Duration.ofMinutes(5))
|
||||
.build();
|
||||
assertThrows(PoolDestroyedException.class, replacement::start);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Redis destroy on one node fences all pool nodes and preserves tombstone")
|
||||
@Timeout(value = 6, unit = TimeUnit.MINUTES)
|
||||
void testDestroyFencesAllRedisBackedPoolNodes() throws Exception {
|
||||
tag = "e2e-redis-destroy-" + UUID.randomUUID().toString().substring(0, 8);
|
||||
String poolName = "redis-destroy-" + tag;
|
||||
sandboxManager = SandboxManager.builder().connectionConfig(sharedConnectionConfig).build();
|
||||
|
||||
RedisPoolStateStore storeA = new RedisPoolStateStore(redis, keyPrefix);
|
||||
RedisPoolStateStore storeB = new RedisPoolStateStore(redis, keyPrefix);
|
||||
SandboxPool poolA =
|
||||
createPoolBuilder(poolName, "owner-a-" + tag, storeA, 1)
|
||||
.reconcileInterval(Duration.ofMinutes(5))
|
||||
.build();
|
||||
SandboxPool poolB =
|
||||
createPoolBuilder(poolName, "owner-b-" + tag, storeB, 1)
|
||||
.reconcileInterval(Duration.ofMinutes(5))
|
||||
.build();
|
||||
pools.add(poolA);
|
||||
pools.add(poolB);
|
||||
poolA.start();
|
||||
poolB.start();
|
||||
|
||||
eventually(
|
||||
"Redis-backed destroy target warms one shared idle sandbox",
|
||||
AWAIT_TIMEOUT,
|
||||
Duration.ofSeconds(1),
|
||||
() -> storeA.snapshotCounters(poolName).getIdleCount() >= 1);
|
||||
|
||||
SandboxPoolManager poolManager =
|
||||
SandboxPoolManager.builder()
|
||||
.stateStore(storeA)
|
||||
.connectionConfig(sharedConnectionConfig)
|
||||
.ownerId("manager-" + tag)
|
||||
.build();
|
||||
PoolDestroyResult result = poolManager.destroy(poolName, new PoolDestroyOptions());
|
||||
|
||||
assertEquals(PoolDestroyState.DESTROYED, result.getState());
|
||||
assertTrue(result.getPersistentStateCleared(), "destroy should clear Redis pool state");
|
||||
assertTrue(result.getDrainedIdleCount() >= 1, "destroy should drain shared idle ids");
|
||||
assertEquals(result.getDrainedIdleCount(), result.getKilledIdleCount());
|
||||
assertEquals(PoolDestroyState.DESTROYED, storeA.getDestroyState(poolName));
|
||||
assertEquals(0, storeA.snapshotCounters(poolName).getIdleCount());
|
||||
assertNull(storeA.getMaxIdle(poolName));
|
||||
assertFalse(
|
||||
storeA.tryAcquirePrimaryLock(poolName, "owner-c-" + tag, Duration.ofSeconds(30)));
|
||||
assertFalse(storeA.renewPrimaryLock(poolName, "owner-a-" + tag, Duration.ofSeconds(30)));
|
||||
|
||||
assertThrows(
|
||||
PoolDestroyedException.class,
|
||||
() -> poolA.acquire(Duration.ofMinutes(5), AcquirePolicy.DIRECT_CREATE));
|
||||
assertThrows(
|
||||
PoolDestroyedException.class,
|
||||
() -> poolB.acquire(Duration.ofMinutes(5), AcquirePolicy.DIRECT_CREATE));
|
||||
assertThrows(PoolDestroyedException.class, () -> poolA.resize(1));
|
||||
|
||||
SandboxPool replacement =
|
||||
createPoolBuilder(poolName, "owner-replacement-" + tag, storeB, 1)
|
||||
.reconcileInterval(Duration.ofMinutes(5))
|
||||
.build();
|
||||
assertThrows(PoolDestroyedException.class, replacement::start);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Redis store atomic take remains unique under local contention")
|
||||
@Timeout(value = 1, unit = TimeUnit.MINUTES)
|
||||
void testRedisStoreAtomicTakeUnderContention() throws Exception {
|
||||
String poolName = "redis-store-contention-" + UUID.randomUUID();
|
||||
RedisPoolStateStore store = new RedisPoolStateStore(redis, keyPrefix);
|
||||
int idleCount = 50;
|
||||
int workerCount = 16;
|
||||
for (int i = 0; i < idleCount; i++) {
|
||||
store.putIdle(poolName, "id-" + i);
|
||||
}
|
||||
|
||||
ExecutorService executor = Executors.newFixedThreadPool(workerCount);
|
||||
CountDownLatch start = new CountDownLatch(1);
|
||||
Set<String> taken = ConcurrentHashMap.newKeySet();
|
||||
List<Future<?>> futures = new ArrayList<>();
|
||||
for (int i = 0; i < workerCount; i++) {
|
||||
futures.add(
|
||||
executor.submit(
|
||||
() -> {
|
||||
start.await();
|
||||
while (true) {
|
||||
String id = store.tryTakeIdle(poolName);
|
||||
if (id == null) {
|
||||
return null;
|
||||
}
|
||||
assertTrue(taken.add(id), "duplicate idle ID taken: " + id);
|
||||
}
|
||||
}));
|
||||
}
|
||||
start.countDown();
|
||||
|
||||
try {
|
||||
for (Future<?> future : futures) {
|
||||
future.get(30, TimeUnit.SECONDS);
|
||||
}
|
||||
} finally {
|
||||
executor.shutdownNow();
|
||||
}
|
||||
|
||||
assertEquals(idleCount, taken.size());
|
||||
assertEquals(0, store.snapshotCounters(poolName).getIdleCount());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Redis expired idle is not removed by snapshot but take reaps it")
|
||||
@Timeout(value = 1, unit = TimeUnit.MINUTES)
|
||||
void testExpiredIdleIsNotRemovedBySnapshotButTakeReapsIt() throws Exception {
|
||||
String poolName = "redis-expired-idle-" + UUID.randomUUID();
|
||||
RedisPoolStateStore store = new RedisPoolStateStore(redis, keyPrefix);
|
||||
|
||||
store.setIdleEntryTtl(poolName, Duration.ofMillis(50));
|
||||
store.putIdle(poolName, "expired-" + UUID.randomUUID());
|
||||
Thread.sleep(100);
|
||||
|
||||
assertEquals(1, store.snapshotCounters(poolName).getIdleCount());
|
||||
assertNull(store.tryTakeIdle(poolName));
|
||||
assertEquals(0, store.snapshotCounters(poolName).getIdleCount());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Redis concurrent acquire and resize jitter stay bounded")
|
||||
@Timeout(value = 7, unit = TimeUnit.MINUTES)
|
||||
void testConcurrentAcquireAndResizeJitterStayBounded() throws Exception {
|
||||
tag = "e2e-redis-acquire-resize-jitter-" + UUID.randomUUID().toString().substring(0, 8);
|
||||
String poolName = "redis-acquire-resize-jitter-" + tag;
|
||||
sandboxManager = SandboxManager.builder().connectionConfig(sharedConnectionConfig).build();
|
||||
|
||||
RedisPoolStateStore storeA = new RedisPoolStateStore(redis, keyPrefix);
|
||||
RedisPoolStateStore storeB = new RedisPoolStateStore(redis, keyPrefix);
|
||||
SandboxPool poolA = createPool(poolName, "owner-a-" + tag, storeA, 2);
|
||||
SandboxPool poolB = createPool(poolName, "owner-b-" + tag, storeB, 2);
|
||||
pools.add(poolA);
|
||||
pools.add(poolB);
|
||||
poolA.start();
|
||||
poolB.start();
|
||||
eventually(
|
||||
"Redis-backed jitter pool warms two idle sandboxes",
|
||||
AWAIT_TIMEOUT,
|
||||
Duration.ofSeconds(1),
|
||||
() -> poolA.snapshot().getIdleCount() >= 2);
|
||||
|
||||
ExecutorService executor = Executors.newFixedThreadPool(5);
|
||||
Set<String> acquiredIds = ConcurrentHashMap.newKeySet();
|
||||
List<Future<?>> futures = new ArrayList<>();
|
||||
for (int i = 0; i < 4; i++) {
|
||||
final int index = i;
|
||||
futures.add(
|
||||
executor.submit(
|
||||
() -> {
|
||||
SandboxPool pool = index % 2 == 0 ? poolA : poolB;
|
||||
Sandbox sandbox =
|
||||
pool.acquire(
|
||||
Duration.ofMinutes(5), AcquirePolicy.DIRECT_CREATE);
|
||||
borrowed.add(sandbox);
|
||||
assertTrue(
|
||||
acquiredIds.add(sandbox.getId()),
|
||||
"sandbox ID must not be acquired twice");
|
||||
Execution execution =
|
||||
sandbox.commands()
|
||||
.run(
|
||||
RunCommandRequest.builder()
|
||||
.command(
|
||||
"echo redis-jitter-"
|
||||
+ index)
|
||||
.build());
|
||||
assertNotNull(execution);
|
||||
assertNull(execution.getError());
|
||||
return null;
|
||||
}));
|
||||
}
|
||||
futures.add(
|
||||
executor.submit(
|
||||
() -> {
|
||||
for (int i = 0; i < 8; i++) {
|
||||
(i % 2 == 0 ? poolA : poolB).resize(i % 3);
|
||||
Thread.sleep(200);
|
||||
}
|
||||
poolB.resize(2);
|
||||
return null;
|
||||
}));
|
||||
|
||||
try {
|
||||
for (Future<?> future : futures) {
|
||||
future.get(180, TimeUnit.SECONDS);
|
||||
}
|
||||
} finally {
|
||||
executor.shutdownNow();
|
||||
}
|
||||
assertEquals(4, acquiredIds.size());
|
||||
eventually(
|
||||
"Redis-backed acquire plus resize jitter converges and stays bounded",
|
||||
Duration.ofSeconds(90),
|
||||
Duration.ofSeconds(1),
|
||||
() -> poolA.snapshot().getIdleCount() <= 2 && countTaggedSandboxes(tag) <= 8);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Redis stale idle is removed and direct-create fallback works")
|
||||
@Timeout(value = 6, unit = TimeUnit.MINUTES)
|
||||
void testStaleIdleIsRemovedAndDirectCreateFallbackWorks() throws Exception {
|
||||
tag = "e2e-redis-stale-" + UUID.randomUUID().toString().substring(0, 8);
|
||||
String poolName = "redis-stale-" + tag;
|
||||
sandboxManager = SandboxManager.builder().connectionConfig(sharedConnectionConfig).build();
|
||||
|
||||
RedisPoolStateStore storeA = new RedisPoolStateStore(redis, keyPrefix);
|
||||
RedisPoolStateStore storeB = new RedisPoolStateStore(redis, keyPrefix);
|
||||
SandboxPool poolA = createPool(poolName, "owner-a-" + tag, storeA, 0);
|
||||
SandboxPool poolB = createPool(poolName, "owner-b-" + tag, storeB, 0);
|
||||
pools.add(poolA);
|
||||
pools.add(poolB);
|
||||
poolA.start();
|
||||
poolB.start();
|
||||
|
||||
storeA.putIdle(poolName, "missing-" + UUID.randomUUID());
|
||||
assertThrows(
|
||||
PoolAcquireFailedException.class,
|
||||
() -> poolB.acquire(Duration.ofSeconds(2), AcquirePolicy.FAIL_FAST));
|
||||
assertEquals(0, storeA.snapshotCounters(poolName).getIdleCount());
|
||||
|
||||
Sandbox sandbox = poolB.acquire(Duration.ofMinutes(5), AcquirePolicy.DIRECT_CREATE);
|
||||
borrowed.add(sandbox);
|
||||
assertTrue(sandbox.isHealthy(), "direct create should work after stale idle removal");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Redis store drops lost-lock warmup orphan and recovers")
|
||||
@Timeout(value = 7, unit = TimeUnit.MINUTES)
|
||||
void testLostLockWindowDropsWarmupOrphanAndRecovers() throws Exception {
|
||||
tag = "e2e-redis-renew-window-" + UUID.randomUUID().toString().substring(0, 8);
|
||||
String poolName = "redis-renew-window-" + tag;
|
||||
sandboxManager = SandboxManager.builder().connectionConfig(sharedConnectionConfig).build();
|
||||
|
||||
AtomicBoolean droppedOnce = new AtomicBoolean(false);
|
||||
RedisPoolStateStore store = new RedisPoolStateStore(redis, keyPrefix);
|
||||
String lockKey = poolKey(poolName, "lock");
|
||||
SandboxPool pool =
|
||||
createPoolBuilder(poolName, "owner-a-" + tag, store, 1)
|
||||
.warmupSandboxPreparer(
|
||||
sandbox -> {
|
||||
if (droppedOnce.compareAndSet(false, true)) {
|
||||
redis.del(lockKey);
|
||||
}
|
||||
})
|
||||
.build();
|
||||
pools.add(pool);
|
||||
|
||||
pool.start();
|
||||
eventually(
|
||||
"Redis-backed pool recovers after losing primary lock during warmup",
|
||||
Duration.ofSeconds(90),
|
||||
Duration.ofMillis(500),
|
||||
() -> pool.snapshot().getIdleCount() == 1);
|
||||
eventually(
|
||||
"lost-lock orphan cleanup keeps remote tagged count bounded",
|
||||
Duration.ofSeconds(60),
|
||||
Duration.ofSeconds(1),
|
||||
() -> countTaggedSandboxes(tag) == 1);
|
||||
}
|
||||
|
||||
private SandboxPool createPool(
|
||||
String poolName, String ownerId, RedisPoolStateStore store, int maxIdle) {
|
||||
return createPoolBuilder(poolName, ownerId, store, maxIdle).build();
|
||||
}
|
||||
|
||||
private SandboxPool.Builder createPoolBuilder(
|
||||
String poolName, String ownerId, RedisPoolStateStore store, int maxIdle) {
|
||||
PoolCreationSpec creationSpec =
|
||||
PoolCreationSpec.builder()
|
||||
.image(getSandboxImage())
|
||||
.entrypoint(List.of("tail -f /dev/null"))
|
||||
.metadata(Map.of("tag", tag, "suite", "sandbox-pool-redis-e2e"))
|
||||
.env(
|
||||
Map.of(
|
||||
"E2E_TEST",
|
||||
"true",
|
||||
"EXECD_API_GRACE_SHUTDOWN",
|
||||
"3s",
|
||||
"EXECD_JUPYTER_IDLE_POLL_INTERVAL",
|
||||
"1s"))
|
||||
.build();
|
||||
return SandboxPool.builder()
|
||||
.poolName(poolName)
|
||||
.ownerId(ownerId)
|
||||
.maxIdle(maxIdle)
|
||||
.warmupConcurrency(1)
|
||||
.stateStore(store)
|
||||
.connectionConfig(sharedConnectionConfig)
|
||||
.creationSpec(creationSpec)
|
||||
.reconcileInterval(RECONCILE_INTERVAL)
|
||||
.primaryLockTtl(PRIMARY_LOCK_TTL)
|
||||
.drainTimeout(DRAIN_TIMEOUT);
|
||||
}
|
||||
|
||||
private void cleanupTaggedSandboxes(String cleanupTag) {
|
||||
for (int i = 0; i < 5; i++) {
|
||||
try {
|
||||
PagedSandboxInfos infos =
|
||||
sandboxManager.listSandboxInfos(
|
||||
SandboxFilter.builder()
|
||||
.metadata(Map.of("tag", cleanupTag))
|
||||
.pageSize(50)
|
||||
.build());
|
||||
if (infos.getSandboxInfos().isEmpty()) {
|
||||
return;
|
||||
}
|
||||
infos.getSandboxInfos()
|
||||
.forEach(
|
||||
info -> {
|
||||
try {
|
||||
sandboxManager.killSandbox(info.getId());
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
});
|
||||
} catch (Exception ignored) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private int countTaggedSandboxes(String queryTag) {
|
||||
if (sandboxManager == null || queryTag == null || queryTag.isBlank()) {
|
||||
return 0;
|
||||
}
|
||||
PagedSandboxInfos infos =
|
||||
sandboxManager.listSandboxInfos(
|
||||
SandboxFilter.builder()
|
||||
.metadata(Map.of("tag", queryTag))
|
||||
.pageSize(50)
|
||||
.build());
|
||||
return infos.getSandboxInfos().size();
|
||||
}
|
||||
|
||||
private String poolKey(String poolName, String suffix) {
|
||||
String tag =
|
||||
java.util.Base64.getUrlEncoder()
|
||||
.withoutPadding()
|
||||
.encodeToString(poolName.getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
return keyPrefix + ":{" + tag + "}:" + suffix;
|
||||
}
|
||||
|
||||
private void cleanupRedisKeys() {
|
||||
if (keyPrefix == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Set<String> keys = redis.keys(keyPrefix + "*");
|
||||
if (!keys.isEmpty()) {
|
||||
redis.del(keys.toArray(String[]::new));
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
private void eventually(
|
||||
String description, Duration timeout, Duration interval, BooleanSupplier condition)
|
||||
throws InterruptedException {
|
||||
long deadline = System.currentTimeMillis() + timeout.toMillis();
|
||||
Throwable lastError = null;
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
try {
|
||||
if (condition.getAsBoolean()) {
|
||||
return;
|
||||
}
|
||||
} catch (Throwable t) {
|
||||
lastError = t;
|
||||
}
|
||||
Thread.sleep(interval.toMillis());
|
||||
}
|
||||
if (lastError != null) {
|
||||
fail(
|
||||
"Timed out waiting for "
|
||||
+ description
|
||||
+ ", last error: "
|
||||
+ lastError.getMessage());
|
||||
} else {
|
||||
fail("Timed out waiting for " + description);
|
||||
}
|
||||
}
|
||||
|
||||
private static void killAndCloseQuietly(Sandbox sandbox) {
|
||||
if (sandbox == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
sandbox.kill();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
try {
|
||||
sandbox.close();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
+1315
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,209 @@
|
||||
/*
|
||||
* Copyright 2026 Alibaba Group Holding Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.alibaba.opensandbox.e2e;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import com.alibaba.opensandbox.sandbox.Sandbox;
|
||||
import com.alibaba.opensandbox.sandbox.domain.models.execd.executions.Execution;
|
||||
import com.alibaba.opensandbox.sandbox.domain.models.execd.executions.RunCommandRequest;
|
||||
import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxEndpoint;
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.time.Duration;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.junit.jupiter.api.Assumptions;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Tag;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.Timeout;
|
||||
|
||||
@Tag("e2e")
|
||||
@DisplayName("Sandbox secured access E2E Tests (Java SDK)")
|
||||
public class SandboxSecureAccessE2ETest extends BaseE2ETest {
|
||||
private static final String SECURE_ACCESS_HEADER = "OpenSandbox-Secure-Access";
|
||||
private static final String SECURE_ACCESS_VERIFIABLE_ENV =
|
||||
"OPENSANDBOX_TEST_SECURE_ACCESS_VERIFIABLE";
|
||||
|
||||
@Test
|
||||
@DisplayName("secureAccess protects secured endpoints in K8s gateway mode")
|
||||
@Timeout(value = 3, unit = TimeUnit.MINUTES)
|
||||
void testSecureAccessTokenEndToEnd() throws Exception {
|
||||
Assumptions.assumeTrue(
|
||||
isSecureAccessVerifiable(),
|
||||
SECURE_ACCESS_VERIFIABLE_ENV
|
||||
+ "=true is required to verify secureAccess in a Kubernetes gateway environment");
|
||||
|
||||
Sandbox sandbox =
|
||||
Sandbox.builder()
|
||||
.connectionConfig(sharedConnectionConfig)
|
||||
.image(getSandboxImage())
|
||||
.timeout(Duration.ofMinutes(2))
|
||||
.readyTimeout(Duration.ofSeconds(60))
|
||||
.secureAccess()
|
||||
.env("EXECD_API_GRACE_SHUTDOWN", "3s")
|
||||
.env("EXECD_JUPYTER_IDLE_POLL_INTERVAL", "200ms")
|
||||
.metadata(Map.of("tag", "secure-access-java-e2e-test"))
|
||||
.build();
|
||||
|
||||
try {
|
||||
SandboxEndpoint execdEndpoint = sandbox.getEndpoint(44772);
|
||||
assertEndpointHasPort(execdEndpoint.getEndpoint(), 44772);
|
||||
|
||||
Map<String, String> execdHeaders = execdEndpoint.getHeaders();
|
||||
assertNotNull(execdHeaders);
|
||||
assertTrue(
|
||||
execdHeaders.containsKey(SECURE_ACCESS_HEADER),
|
||||
"secureAccess endpoint must include the secure access header");
|
||||
String token = execdHeaders.get(SECURE_ACCESS_HEADER);
|
||||
assertNotNull(token);
|
||||
assertFalse(token.isBlank());
|
||||
|
||||
SandboxEndpoint userEndpoint = sandbox.getEndpoint(8080);
|
||||
assertEquals(
|
||||
token,
|
||||
userEndpoint.getHeaders().get(SECURE_ACCESS_HEADER),
|
||||
"all endpoints for a secured sandbox should return the same secure access token");
|
||||
|
||||
Execution sdkRun =
|
||||
sandbox.commands()
|
||||
.run(
|
||||
RunCommandRequest.builder()
|
||||
.command("echo secure-access-sdk-ok")
|
||||
.build());
|
||||
assertNotNull(sdkRun);
|
||||
assertNull(sdkRun.getError(), "SDK command should include endpoint headers");
|
||||
assertEquals(1, sdkRun.getLogs().getStdout().size());
|
||||
assertEquals("secure-access-sdk-ok", sdkRun.getLogs().getStdout().get(0).getText());
|
||||
|
||||
HttpClient client =
|
||||
HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build();
|
||||
URI pingUri = endpointUri(execdEndpoint, "/ping");
|
||||
|
||||
HttpResponse<String> missingToken = sendPing(client, pingUri, execdHeaders, null);
|
||||
assertEquals(
|
||||
401,
|
||||
missingToken.statusCode(),
|
||||
"secured endpoint must reject missing access token");
|
||||
|
||||
HttpResponse<String> wrongToken =
|
||||
sendPing(client, pingUri, execdHeaders, "definitely-wrong-token");
|
||||
assertEquals(
|
||||
401,
|
||||
wrongToken.statusCode(),
|
||||
"secured endpoint must reject wrong access token");
|
||||
|
||||
HttpResponse<String> correctToken = sendPing(client, pingUri, execdHeaders, token);
|
||||
assertEquals(
|
||||
200,
|
||||
correctToken.statusCode(),
|
||||
"secured endpoint must accept the endpoint token");
|
||||
} finally {
|
||||
killAndClose(sandbox);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("default sandbox does not require secure access token")
|
||||
@Timeout(value = 3, unit = TimeUnit.MINUTES)
|
||||
void testDefaultSandboxDoesNotReturnAccessToken() throws Exception {
|
||||
Assumptions.assumeTrue(
|
||||
isSecureAccessVerifiable(),
|
||||
SECURE_ACCESS_VERIFIABLE_ENV
|
||||
+ "=true is required to verify secureAccess in a Kubernetes gateway environment");
|
||||
|
||||
Sandbox sandbox =
|
||||
Sandbox.builder()
|
||||
.connectionConfig(sharedConnectionConfig)
|
||||
.image(getSandboxImage())
|
||||
.timeout(Duration.ofMinutes(2))
|
||||
.readyTimeout(Duration.ofSeconds(60))
|
||||
.env("EXECD_API_GRACE_SHUTDOWN", "3s")
|
||||
.env("EXECD_JUPYTER_IDLE_POLL_INTERVAL", "200ms")
|
||||
.metadata(Map.of("tag", "non-secure-access-java-e2e-test"))
|
||||
.build();
|
||||
|
||||
try {
|
||||
SandboxEndpoint execdEndpoint = sandbox.getEndpoint(44772);
|
||||
assertEndpointHasPort(execdEndpoint.getEndpoint(), 44772);
|
||||
assertFalse(
|
||||
execdEndpoint.getHeaders().containsKey(SECURE_ACCESS_HEADER),
|
||||
"default sandbox endpoint should not include secure access token");
|
||||
|
||||
HttpClient client =
|
||||
HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build();
|
||||
HttpResponse<String> ping =
|
||||
sendPing(
|
||||
client,
|
||||
endpointUri(execdEndpoint, "/ping"),
|
||||
execdEndpoint.getHeaders(),
|
||||
null);
|
||||
assertEquals(
|
||||
200, ping.statusCode(), "default endpoint should allow requests without token");
|
||||
} finally {
|
||||
killAndClose(sandbox);
|
||||
}
|
||||
}
|
||||
|
||||
private static URI endpointUri(SandboxEndpoint endpoint, String path) {
|
||||
String protocol = testProperties.getProperty("opensandbox.test.protocol", "https");
|
||||
String base = endpoint.getEndpoint();
|
||||
while (base.endsWith("/")) {
|
||||
base = base.substring(0, base.length() - 1);
|
||||
}
|
||||
String normalizedPath = path.startsWith("/") ? path : "/" + path;
|
||||
return URI.create(protocol + "://" + base + normalizedPath);
|
||||
}
|
||||
|
||||
private static HttpResponse<String> sendPing(
|
||||
HttpClient client, URI uri, Map<String, String> endpointHeaders, String token)
|
||||
throws IOException, InterruptedException {
|
||||
HttpRequest.Builder builder =
|
||||
HttpRequest.newBuilder(uri).timeout(Duration.ofSeconds(20)).GET();
|
||||
for (Map.Entry<String, String> header : endpointHeaders.entrySet()) {
|
||||
if (!SECURE_ACCESS_HEADER.equalsIgnoreCase(header.getKey())) {
|
||||
builder.header(header.getKey(), header.getValue());
|
||||
}
|
||||
}
|
||||
if (token != null) {
|
||||
builder.header(SECURE_ACCESS_HEADER, token);
|
||||
}
|
||||
return client.send(builder.build(), HttpResponse.BodyHandlers.ofString());
|
||||
}
|
||||
|
||||
private static boolean isSecureAccessVerifiable() {
|
||||
return Boolean.parseBoolean(System.getenv(SECURE_ACCESS_VERIFIABLE_ENV));
|
||||
}
|
||||
|
||||
private static void killAndClose(Sandbox sandbox) {
|
||||
if (sandbox == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
sandbox.kill();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
try {
|
||||
sandbox.close();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
/*
|
||||
* Copyright 2025 Alibaba Group Holding Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.alibaba.opensandbox.e2e;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import com.alibaba.opensandbox.sandbox.Sandbox;
|
||||
import com.alibaba.opensandbox.sandbox.SandboxManager;
|
||||
import com.alibaba.opensandbox.sandbox.domain.exceptions.SandboxApiException;
|
||||
import com.alibaba.opensandbox.sandbox.domain.exceptions.SandboxException;
|
||||
import com.alibaba.opensandbox.sandbox.domain.models.execd.executions.Execution;
|
||||
import com.alibaba.opensandbox.sandbox.domain.models.execd.executions.RunCommandRequest;
|
||||
import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.PagedSnapshotInfos;
|
||||
import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SnapshotFilter;
|
||||
import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SnapshotInfo;
|
||||
import java.time.Duration;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.junit.jupiter.api.Assumptions;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Tag;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.Timeout;
|
||||
|
||||
@Tag("e2e")
|
||||
@DisplayName("Snapshot E2E Tests (Java SDK)")
|
||||
public class SnapshotE2ETest extends BaseE2ETest {
|
||||
|
||||
private static final Duration SNAPSHOT_TIMEOUT = Duration.ofMinutes(5);
|
||||
private static final Duration DELETE_TIMEOUT = Duration.ofMinutes(3);
|
||||
private static final String SNAPSHOT_DIR = "/tmp/opensandbox-snapshot-e2e";
|
||||
private static final String SNAPSHOT_FILE = SNAPSHOT_DIR + "/marker.txt";
|
||||
|
||||
@Test
|
||||
@DisplayName("snapshot create, poll, list, restore, and delete")
|
||||
@Timeout(value = 10, unit = TimeUnit.MINUTES)
|
||||
void testSnapshotLifecycleEndToEnd() throws InterruptedException {
|
||||
SandboxManager manager =
|
||||
SandboxManager.builder().connectionConfig(sharedConnectionConfig).build();
|
||||
Sandbox source = null;
|
||||
Sandbox restored = null;
|
||||
String readySnapshotId = null;
|
||||
String tag = "snapshot-e2e-" + UUID.randomUUID().toString().substring(0, 8);
|
||||
String marker = "snapshot-marker-" + UUID.randomUUID();
|
||||
|
||||
try {
|
||||
source =
|
||||
Sandbox.builder()
|
||||
.connectionConfig(sharedConnectionConfig)
|
||||
.image(getSandboxImage())
|
||||
.timeout(Duration.ofMinutes(5))
|
||||
.readyTimeout(Duration.ofSeconds(60))
|
||||
.metadata(Map.of("tag", tag, "role", "source"))
|
||||
.env("E2E_TEST", "true")
|
||||
.healthCheckPollingInterval(Duration.ofMillis(500))
|
||||
.build();
|
||||
assertTrue(source.isHealthy(), "source sandbox should be healthy");
|
||||
|
||||
runOk(
|
||||
source,
|
||||
"mkdir -p "
|
||||
+ SNAPSHOT_DIR
|
||||
+ " && printf '%s' '"
|
||||
+ marker
|
||||
+ "' > "
|
||||
+ SNAPSHOT_FILE);
|
||||
|
||||
SnapshotInfo accepted = source.createSnapshot(tag + "-ready");
|
||||
readySnapshotId = accepted.getId();
|
||||
assertSnapshotFields(
|
||||
accepted,
|
||||
readySnapshotId,
|
||||
source.getId(),
|
||||
tag + "-ready",
|
||||
"Creating",
|
||||
"snapshot_accepted",
|
||||
"Snapshot creation accepted.",
|
||||
null);
|
||||
|
||||
SnapshotInfo ready =
|
||||
waitForSnapshotState(manager, readySnapshotId, "Ready", SNAPSHOT_TIMEOUT);
|
||||
assertSnapshotFields(
|
||||
ready,
|
||||
readySnapshotId,
|
||||
source.getId(),
|
||||
tag + "-ready",
|
||||
"Ready",
|
||||
null,
|
||||
null,
|
||||
accepted.getCreatedAt());
|
||||
assertFalse(
|
||||
ready.getStatus()
|
||||
.getLastTransitionAt()
|
||||
.isBefore(accepted.getStatus().getLastTransitionAt()),
|
||||
"Ready transition timestamp should not be earlier than Creating");
|
||||
|
||||
PagedSnapshotInfos allSnapshots =
|
||||
manager.listSnapshots(
|
||||
SnapshotFilter.builder()
|
||||
.sandboxId(source.getId())
|
||||
.page(1)
|
||||
.pageSize(50)
|
||||
.build());
|
||||
assertSnapshotList(allSnapshots, 1, 50, ready);
|
||||
|
||||
PagedSnapshotInfos readySnapshots =
|
||||
manager.listSnapshots(
|
||||
SnapshotFilter.builder()
|
||||
.sandboxId(source.getId())
|
||||
.page(1)
|
||||
.states("Ready")
|
||||
.pageSize(50)
|
||||
.build());
|
||||
assertSnapshotList(readySnapshots, 1, 50, ready);
|
||||
|
||||
restored =
|
||||
Sandbox.builder()
|
||||
.connectionConfig(sharedConnectionConfig)
|
||||
.snapshotId(readySnapshotId)
|
||||
.timeout(Duration.ofMinutes(5))
|
||||
.readyTimeout(Duration.ofSeconds(60))
|
||||
.metadata(Map.of("tag", tag, "role", "restored"))
|
||||
.env("E2E_TEST", "true")
|
||||
.healthCheckPollingInterval(Duration.ofMillis(500))
|
||||
.build();
|
||||
assertTrue(restored.isHealthy(), "restored sandbox should be healthy");
|
||||
Execution restoredRead =
|
||||
runOk(restored, "test -f " + SNAPSHOT_FILE + " && cat " + SNAPSHOT_FILE);
|
||||
assertEquals(1, restoredRead.getLogs().getStdout().size());
|
||||
assertEquals(marker, restoredRead.getLogs().getStdout().get(0).getText());
|
||||
|
||||
closeSandbox(restored);
|
||||
restored = null;
|
||||
|
||||
manager.deleteSnapshot(readySnapshotId);
|
||||
waitForSnapshotDeleted(manager, readySnapshotId, DELETE_TIMEOUT);
|
||||
readySnapshotId = null;
|
||||
|
||||
PagedSnapshotInfos readySnapshotsAfterDelete =
|
||||
manager.listSnapshots(
|
||||
SnapshotFilter.builder()
|
||||
.sandboxId(source.getId())
|
||||
.page(1)
|
||||
.states("Ready")
|
||||
.pageSize(50)
|
||||
.build());
|
||||
assertSnapshotList(readySnapshotsAfterDelete, 1, 50);
|
||||
} finally {
|
||||
closeSandbox(restored);
|
||||
closeSandbox(source);
|
||||
deleteSnapshotIfExists(manager, readySnapshotId);
|
||||
manager.close();
|
||||
}
|
||||
}
|
||||
|
||||
private static Execution runOk(Sandbox sandbox, String command) {
|
||||
Execution execution =
|
||||
sandbox.commands().run(RunCommandRequest.builder().command(command).build());
|
||||
assertNotNull(execution);
|
||||
assertNull(execution.getError(), "command failed: " + command);
|
||||
return execution;
|
||||
}
|
||||
|
||||
private static SnapshotInfo waitForSnapshotState(
|
||||
SandboxManager manager, String snapshotId, String expectedState, Duration timeout)
|
||||
throws InterruptedException {
|
||||
long deadline = System.nanoTime() + timeout.toNanos();
|
||||
SnapshotInfo last = null;
|
||||
while (System.nanoTime() < deadline) {
|
||||
last = manager.getSnapshot(snapshotId);
|
||||
String state = last.getStatus().getState();
|
||||
if (expectedState.equals(state)) {
|
||||
return last;
|
||||
}
|
||||
if ("Failed".equals(state)) {
|
||||
fail(
|
||||
"snapshot failed: reason="
|
||||
+ last.getStatus().getReason()
|
||||
+ " message="
|
||||
+ last.getStatus().getMessage());
|
||||
}
|
||||
Thread.sleep(1000);
|
||||
}
|
||||
fail(
|
||||
"snapshot did not become "
|
||||
+ expectedState
|
||||
+ " within "
|
||||
+ timeout
|
||||
+ ", lastState="
|
||||
+ (last == null ? "<none>" : last.getStatus().getState()));
|
||||
throw new IllegalStateException("unreachable");
|
||||
}
|
||||
|
||||
private static void waitForSnapshotDeleted(
|
||||
SandboxManager manager, String snapshotId, Duration timeout)
|
||||
throws InterruptedException {
|
||||
long deadline = System.nanoTime() + timeout.toNanos();
|
||||
while (System.nanoTime() < deadline) {
|
||||
try {
|
||||
manager.getSnapshot(snapshotId);
|
||||
} catch (SandboxApiException exception) {
|
||||
if (Integer.valueOf(404).equals(exception.getStatusCode())) {
|
||||
return;
|
||||
}
|
||||
throw exception;
|
||||
}
|
||||
Thread.sleep(1000);
|
||||
}
|
||||
fail("snapshot was not deleted within " + timeout + ": " + snapshotId);
|
||||
}
|
||||
|
||||
private static void deleteSnapshotIfExists(SandboxManager manager, String snapshotId) {
|
||||
if (snapshotId == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
manager.deleteSnapshot(snapshotId);
|
||||
} catch (SandboxException ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
private static void assertSnapshotFields(
|
||||
SnapshotInfo snapshot,
|
||||
String expectedId,
|
||||
String expectedSandboxId,
|
||||
String expectedName,
|
||||
String expectedState,
|
||||
String expectedReason,
|
||||
String expectedMessage,
|
||||
OffsetDateTime expectedCreatedAt) {
|
||||
assertNotNull(snapshot);
|
||||
assertEquals(expectedId, snapshot.getId());
|
||||
assertEquals(expectedSandboxId, snapshot.getSandboxId());
|
||||
assertEquals(expectedName, snapshot.getName());
|
||||
assertNotNull(snapshot.getStatus());
|
||||
assertEquals(expectedState, snapshot.getStatus().getState());
|
||||
assertNotNull(snapshot.getCreatedAt(), "snapshot.createdAt should be present");
|
||||
assertNotNull(
|
||||
snapshot.getStatus().getLastTransitionAt(),
|
||||
"snapshot.status.lastTransitionAt should be present");
|
||||
assertFalse(
|
||||
snapshot.getStatus().getLastTransitionAt().isBefore(snapshot.getCreatedAt()),
|
||||
"snapshot.status.lastTransitionAt should not be earlier than createdAt");
|
||||
|
||||
if (expectedCreatedAt != null) {
|
||||
assertEquals(expectedCreatedAt, snapshot.getCreatedAt());
|
||||
}
|
||||
|
||||
if (expectedReason != null) {
|
||||
assertEquals(expectedReason, snapshot.getStatus().getReason());
|
||||
} else {
|
||||
assertNotNull(
|
||||
snapshot.getStatus().getReason(), "snapshot.status.reason should be present");
|
||||
assertFalse(
|
||||
snapshot.getStatus().getReason().isBlank(),
|
||||
"snapshot.status.reason should not be blank");
|
||||
}
|
||||
|
||||
if (expectedMessage != null) {
|
||||
assertEquals(expectedMessage, snapshot.getStatus().getMessage());
|
||||
} else {
|
||||
assertNotNull(
|
||||
snapshot.getStatus().getMessage(), "snapshot.status.message should be present");
|
||||
assertFalse(
|
||||
snapshot.getStatus().getMessage().isBlank(),
|
||||
"snapshot.status.message should not be blank");
|
||||
}
|
||||
}
|
||||
|
||||
private static void assertSnapshotList(
|
||||
PagedSnapshotInfos snapshotPage,
|
||||
int expectedPage,
|
||||
int expectedPageSize,
|
||||
SnapshotInfo... expected) {
|
||||
assertNotNull(snapshotPage);
|
||||
assertNotNull(snapshotPage.getSnapshotInfos());
|
||||
assertNotNull(snapshotPage.getPagination());
|
||||
assertEquals(expectedPage, snapshotPage.getPagination().getPage());
|
||||
assertEquals(expectedPageSize, snapshotPage.getPagination().getPageSize());
|
||||
assertEquals(expected.length, snapshotPage.getSnapshotInfos().size());
|
||||
assertEquals(expected.length, snapshotPage.getPagination().getTotalItems());
|
||||
assertEquals(expected.length == 0 ? 0 : 1, snapshotPage.getPagination().getTotalPages());
|
||||
assertFalse(snapshotPage.getPagination().getHasNextPage());
|
||||
|
||||
List<SnapshotInfo> expectedSnapshots = Arrays.asList(expected);
|
||||
for (SnapshotInfo expectedSnapshot : expectedSnapshots) {
|
||||
SnapshotInfo listedSnapshot =
|
||||
snapshotPage.getSnapshotInfos().stream()
|
||||
.filter(snapshot -> expectedSnapshot.getId().equals(snapshot.getId()))
|
||||
.findFirst()
|
||||
.orElseThrow(
|
||||
() ->
|
||||
new AssertionError(
|
||||
"snapshot "
|
||||
+ expectedSnapshot.getId()
|
||||
+ " missing from listSnapshots response"));
|
||||
assertSnapshotEquals(expectedSnapshot, listedSnapshot);
|
||||
}
|
||||
}
|
||||
|
||||
private static void assertSnapshotEquals(SnapshotInfo expected, SnapshotInfo actual) {
|
||||
assertNotNull(expected);
|
||||
assertNotNull(actual);
|
||||
assertEquals(expected.getId(), actual.getId());
|
||||
assertEquals(expected.getSandboxId(), actual.getSandboxId());
|
||||
assertEquals(expected.getName(), actual.getName());
|
||||
assertEquals(expected.getCreatedAt(), actual.getCreatedAt());
|
||||
assertNotNull(actual.getStatus());
|
||||
assertEquals(expected.getStatus().getState(), actual.getStatus().getState());
|
||||
assertEquals(expected.getStatus().getReason(), actual.getStatus().getReason());
|
||||
assertEquals(expected.getStatus().getMessage(), actual.getStatus().getMessage());
|
||||
assertEquals(
|
||||
expected.getStatus().getLastTransitionAt(),
|
||||
actual.getStatus().getLastTransitionAt());
|
||||
}
|
||||
|
||||
private static void closeSandbox(Sandbox sandbox) {
|
||||
if (sandbox == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
sandbox.kill();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
try {
|
||||
sandbox.close();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
# OpenSandbox E2E Test Configuration
|
||||
# Default values for local/CI runs. Override via editing this file or by providing your own build.
|
||||
opensandbox.test.domain=localhost:8080
|
||||
opensandbox.test.protocol=http
|
||||
opensandbox.test.api.key=e2e-test
|
||||
opensandbox.sandbox.default.image=opensandbox/code-interpreter:latest
|
||||
Reference in New Issue
Block a user