Skip to content

Commit 3c4dfad

Browse files
authored
Merge pull request #104 from A3S-Lab/feat/e2b-runtime-readiness
fix(compat): gate runtime envd publication on readiness
2 parents c356346 + d26b4fb commit 3c4dfad

8 files changed

Lines changed: 246 additions & 27 deletions

File tree

.github/workflows/e2b-runtime-image.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,12 +45,12 @@ jobs:
4545
type=sha,format=long
4646
type=raw,value=edge,enable={{is_default_branch}}
4747
48-
- name: Build and push the multi-architecture image
48+
- name: Build and push the runtime image
4949
uses: docker/build-push-action@v6
5050
with:
5151
context: .
5252
file: deploy/e2b/Dockerfile
53-
platforms: linux/amd64,linux/arm64
53+
platforms: ${{ github.ref == 'refs/heads/main' && 'linux/amd64,linux/arm64' || 'linux/amd64' }}
5454
push: true
5555
tags: ${{ steps.metadata.outputs.tags }}
5656
labels: ${{ steps.metadata.outputs.labels }}

deploy/e2b/README.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,10 @@ The build pins:
1010
- Code Interpreter commit `5aeca43fe3fae2df260b1fb17c71fed5b5dac852`;
1111
- the JavaScript kernel commit declared in the Dockerfile.
1212

13-
The image starts envd on port `49983`, initializes its default user and working
14-
directory, and starts the Code Interpreter service on port `49999`. A production
15-
ACL template selects the in-Sandbox daemon explicitly:
13+
The image waits for Jupyter and the Code Interpreter service on port `49999`
14+
before it starts envd on port `49983` and initializes envd's default user and
15+
working directory. A production ACL template selects the in-Sandbox daemon
16+
explicitly:
1617

1718
```acl
1819
template_policy "code-interpreter-v1" {

deploy/e2b/entrypoint.sh

Lines changed: 36 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,30 @@ terminate() {
1111
}
1212
trap terminate EXIT INT TERM
1313

14-
/usr/local/bin/envd -isnotfc -no-cgroups &
15-
children+=("$!")
14+
wait_for_service() {
15+
local name="$1"
16+
local url="$2"
17+
local pid="$3"
1618

17-
/usr/bin/python3 /usr/local/lib/a3s-box-e2b/init-envd.py
19+
for attempt in {1..150}; do
20+
if curl --fail --silent --output /dev/null "${url}"; then
21+
return 0
22+
fi
23+
if ! kill -0 "${pid}" 2>/dev/null; then
24+
local status=0
25+
wait "${pid}" || status=$?
26+
echo "${name} exited before becoming healthy with status ${status}" >&2
27+
if ((status == 0)); then
28+
status=1
29+
fi
30+
return "${status}"
31+
fi
32+
sleep 0.2
33+
done
34+
35+
echo "${name} did not become healthy within 30 seconds" >&2
36+
return 1
37+
}
1838

1939
runuser -u user -- env \
2040
HOME=/home/user \
@@ -23,17 +43,9 @@ runuser -u user -- env \
2343
--IdentityProvider.token= \
2444
--ServerApp.root_dir=/home/user &
2545
children+=("$!")
46+
jupyter_pid="$!"
2647

27-
for attempt in {1..150}; do
28-
if curl --fail --silent --output /dev/null http://127.0.0.1:8888/api/status; then
29-
break
30-
fi
31-
if ((attempt == 150)); then
32-
echo "Jupyter did not become healthy within 30 seconds" >&2
33-
exit 1
34-
fi
35-
sleep 0.2
36-
done
48+
wait_for_service "Jupyter" "http://127.0.0.1:8888/api/status" "${jupyter_pid}"
3749

3850
runuser -u user -- env \
3951
HOME=/home/user \
@@ -48,6 +60,17 @@ runuser -u user -- env \
4860
--no-use-colors \
4961
--timeout-keep-alive 640 &
5062
children+=("$!")
63+
code_interpreter_pid="$!"
64+
65+
wait_for_service \
66+
"Code Interpreter" \
67+
"http://127.0.0.1:49999/health" \
68+
"${code_interpreter_pid}"
69+
70+
/usr/local/bin/envd -isnotfc -no-cgroups &
71+
children+=("$!")
72+
73+
/usr/bin/python3 /usr/local/lib/a3s-box-e2b/init-envd.py
5174

5275
set +e
5376
wait -n "${children[@]}"

src/compat/src/bin/a3s-box-e2b-fixture-server.rs

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
use std::collections::BTreeMap;
2+
use std::num::NonZeroU16;
23
use std::path::PathBuf;
34
use std::sync::atomic::{AtomicU64, Ordering};
45
use std::sync::{Arc, Mutex, MutexGuard};
6+
use std::time::Duration;
57

68
use a3s_box_compat::control::{
79
Clock, ControlService, ControlServiceDependencies, IdentityProviderResult, IssuedToken,
@@ -17,8 +19,8 @@ use a3s_box_compat::http::{
1719
use a3s_box_core::{
1820
resolve_execution, BoxConfig, CreateExecutionRequest, ExecutionGeneration, ExecutionId,
1921
ExecutionIsolation, ExecutionLease, ExecutionManager, ExecutionManagerError,
20-
ExecutionManagerResult, ExecutionReservation, ExecutionState, ExecutionStatus, KillOutcome,
21-
OperationId, ReconcileOutcome, ResourceConfig,
22+
ExecutionManagerResult, ExecutionPortConnector, ExecutionPortStream, ExecutionReservation,
23+
ExecutionState, ExecutionStatus, KillOutcome, OperationId, ReconcileOutcome, ResourceConfig,
2224
};
2325
use anyhow::{bail, Context, Result};
2426
use async_trait::async_trait;
@@ -39,9 +41,11 @@ async fn run() -> Result<()> {
3941
let port_file = parse_port_file()?;
4042
let clock = Arc::new(FixedClock(fixture_time()?));
4143
let tokens = Arc::new(FixtureTokens);
44+
let executions = Arc::new(FixtureExecutionManager::new(clock.clone()));
4245
let service = Arc::new(ControlService::new(ControlServiceDependencies {
4346
repository: Arc::new(MemorySandboxRepository::default()),
44-
executions: Arc::new(FixtureExecutionManager::new(clock.clone())),
47+
executions: executions.clone(),
48+
ports: executions,
4549
clock,
4650
identities: Arc::new(FixtureIdentities::default()),
4751
templates: Arc::new(FixtureTemplates),
@@ -462,3 +466,16 @@ impl ExecutionManager for FixtureExecutionManager {
462466
})
463467
}
464468
}
469+
470+
#[async_trait]
471+
impl ExecutionPortConnector for FixtureExecutionManager {
472+
async fn connect_port(
473+
&self,
474+
execution_id: &ExecutionId,
475+
_generation: ExecutionGeneration,
476+
_port: NonZeroU16,
477+
_timeout: Duration,
478+
) -> ExecutionManagerResult<ExecutionPortStream> {
479+
Err(ExecutionManagerError::NotFound(execution_id.clone()))
480+
}
481+
}

src/compat/src/control/service.rs

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,26 @@
11
use std::collections::BTreeMap;
2+
use std::num::NonZeroU16;
23
use std::sync::Arc;
34

4-
use a3s_box_core::{resolve_execution, CreateExecutionRequest, ExecutionManager, NetworkMode};
5+
use a3s_box_core::{
6+
resolve_execution, CreateExecutionRequest, ExecutionLease, ExecutionManager,
7+
ExecutionManagerError, ExecutionPortConnector, NetworkMode,
8+
};
59
use chrono::{DateTime, Duration, Utc};
610
use thiserror::Error;
711

812
use super::{
9-
Clock, CompareAndSwapResult, IdentityProviderError, LifecycleError, LifecycleFailure,
13+
Clock, CompareAndSwapResult, EnvdMode, IdentityProviderError, LifecycleError, LifecycleFailure,
1014
LifecyclePolicy, LifecycleState, NewSandboxRecord, RepositoryError, SandboxCredentials,
1115
SandboxIdentityProvider, SandboxListFilter, SandboxPage, SandboxRecord, SandboxRepository,
1216
SecretToken, TemplateProvider, TemplateProviderError, TokenIssuer, TokenIssuerError,
1317
TokenResolver, TokenScope,
1418
};
19+
use crate::routing::ENVD_PORT;
20+
21+
const RUNTIME_ENVD_READY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
22+
const RUNTIME_ENVD_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(250);
23+
const RUNTIME_ENVD_RETRY_INTERVAL: std::time::Duration = std::time::Duration::from_millis(25);
1524

1625
#[derive(Debug, Clone)]
1726
pub struct CreateSandboxRequest {
@@ -79,6 +88,7 @@ pub type ControlServiceResult<T> = std::result::Result<T, ControlServiceError>;
7988
pub struct ControlService {
8089
repository: Arc<dyn SandboxRepository>,
8190
executions: Arc<dyn ExecutionManager>,
91+
ports: Arc<dyn ExecutionPortConnector>,
8292
clock: Arc<dyn Clock>,
8393
identities: Arc<dyn SandboxIdentityProvider>,
8494
templates: Arc<dyn TemplateProvider>,
@@ -89,6 +99,7 @@ pub struct ControlService {
8999
pub struct ControlServiceDependencies {
90100
pub repository: Arc<dyn SandboxRepository>,
91101
pub executions: Arc<dyn ExecutionManager>,
102+
pub ports: Arc<dyn ExecutionPortConnector>,
92103
pub clock: Arc<dyn Clock>,
93104
pub identities: Arc<dyn SandboxIdentityProvider>,
94105
pub templates: Arc<dyn TemplateProvider>,
@@ -101,6 +112,7 @@ impl ControlService {
101112
Self {
102113
repository: dependencies.repository,
103114
executions: dependencies.executions,
115+
ports: dependencies.ports,
104116
clock: dependencies.clock,
105117
identities: dependencies.identities,
106118
templates: dependencies.templates,
@@ -180,6 +192,24 @@ impl ControlService {
180192
return Err(error.into());
181193
}
182194
};
195+
if template.envd_mode == EnvdMode::Runtime {
196+
if let Err(readiness_error) = self.wait_for_runtime_envd(&lease).await {
197+
let cleanup = self
198+
.executions
199+
.kill(&lease.execution_id, lease.generation)
200+
.await;
201+
let expected = record.generation();
202+
record.mark_failed(LifecycleFailure::RuntimeFailed)?;
203+
self.replace(expected, record).await?;
204+
return Err(match cleanup {
205+
Ok(_) => readiness_error,
206+
Err(cleanup_error) => ExecutionManagerError::Internal(format!(
207+
"{readiness_error}; runtime cleanup failed: {cleanup_error}"
208+
)),
209+
}
210+
.into());
211+
}
212+
}
183213

184214
let expected = record.generation();
185215
if let Err(error) = record.mark_running(lease) {
@@ -197,6 +227,44 @@ impl ControlService {
197227
})
198228
}
199229

230+
async fn wait_for_runtime_envd(
231+
&self,
232+
lease: &ExecutionLease,
233+
) -> Result<(), ExecutionManagerError> {
234+
let port = NonZeroU16::new(ENVD_PORT).ok_or_else(|| {
235+
ExecutionManagerError::Internal("envd port must be non-zero".to_string())
236+
})?;
237+
let deadline = tokio::time::Instant::now() + RUNTIME_ENVD_READY_TIMEOUT;
238+
loop {
239+
let last_error = match self
240+
.ports
241+
.connect_port(
242+
&lease.execution_id,
243+
lease.generation,
244+
port,
245+
RUNTIME_ENVD_CONNECT_TIMEOUT,
246+
)
247+
.await
248+
{
249+
Ok(stream) => {
250+
drop(stream);
251+
return Ok(());
252+
}
253+
Err(error @ ExecutionManagerError::InvalidRequest(_))
254+
| Err(error @ ExecutionManagerError::Internal(_)) => return Err(error),
255+
Err(error) => error,
256+
};
257+
if tokio::time::Instant::now() >= deadline {
258+
return Err(ExecutionManagerError::Unavailable(format!(
259+
"runtime envd did not become ready within {} seconds: {}",
260+
RUNTIME_ENVD_READY_TIMEOUT.as_secs(),
261+
last_error
262+
)));
263+
}
264+
tokio::time::sleep(RUNTIME_ENVD_RETRY_INTERVAL).await;
265+
}
266+
}
267+
200268
pub async fn connect(
201269
&self,
202270
owner_id: &str,

src/compat/src/control/service_tests.rs

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
use std::collections::{BTreeMap, BTreeSet};
22
use std::num::NonZeroU32;
33

4+
use a3s_box_core::{ExecutionManagerError, ExecutionState};
45
use chrono::Duration;
56

67
use super::test_support::{assert_sandbox_request, create_request, test_time, TestHarness};
@@ -126,3 +127,55 @@ async fn failed_runtime_create_is_not_published() {
126127
Err(ControlServiceError::NotFound(_))
127128
));
128129
}
130+
131+
#[tokio::test]
132+
async fn runtime_envd_is_ready_before_the_sandbox_is_published() {
133+
let harness = TestHarness::new();
134+
let mut request = create_request("owner-1");
135+
request.template_id = "runtime-envd-template".to_string();
136+
137+
let created = harness.service.create(request).await.unwrap();
138+
139+
assert_eq!(created.record.state(), LifecycleState::Running);
140+
assert_eq!(created.record.envd_mode(), EnvdMode::Runtime);
141+
assert_eq!(
142+
harness.executions.port_requests(),
143+
vec![(
144+
"execution-operation-1".to_string(),
145+
1,
146+
crate::routing::ENVD_PORT,
147+
)]
148+
);
149+
}
150+
151+
#[tokio::test]
152+
async fn permanent_runtime_envd_failure_stops_and_hides_the_execution() {
153+
let harness = TestHarness::new();
154+
harness.executions.fail_ports();
155+
let mut request = create_request("owner-1");
156+
request.template_id = "runtime-envd-template".to_string();
157+
158+
assert!(matches!(
159+
harness.service.create(request).await,
160+
Err(ControlServiceError::Execution(
161+
ExecutionManagerError::InvalidRequest(_)
162+
))
163+
));
164+
assert_eq!(
165+
harness.executions.port_requests(),
166+
vec![(
167+
"execution-operation-1".to_string(),
168+
1,
169+
crate::routing::ENVD_PORT,
170+
)]
171+
);
172+
assert_eq!(
173+
harness.executions.execution_state("execution-operation-1"),
174+
Some(ExecutionState::Stopped)
175+
);
176+
let sandbox_id = SandboxId::new("sandbox-1").unwrap();
177+
assert!(matches!(
178+
harness.service.get("owner-1", &sandbox_id).await,
179+
Err(ControlServiceError::NotFound(_))
180+
));
181+
}

0 commit comments

Comments
 (0)