Skip to content

Commit acc0957

Browse files
Merge pull request #292 from webtech-network/sandbox-file-injection
feat: implement static asset placement for sandbox executions
2 parents 656bf5a + c288985 commit acc0957

22 files changed

Lines changed: 668 additions & 59 deletions

.env.example

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,11 @@ JSON_LOGS=false # Set to 'true' for JSON-formatted logs (recommended for produc
2727
# Optional: Upstash Redis for result export
2828
# UPSTASH_REDIS_URL=your-redis-url
2929
# UPSTASH_REDIS_TOKEN=your-redis-token
30+
31+
# S3 Assets Configuration
32+
CRITERIA_ASSETS_BUCKET_NAME=autograder-assets
33+
CRITERIA_ASSETS_IN_MEMORY_CACHE_LIMIT=100
34+
AWS_ACCESS_KEY_ID=test
35+
AWS_SECRET_ACCESS_KEY=test
36+
AWS_REGION=us-east-1
37+
S3_ENDPOINT_URL=http://localhost:4566

autograder/models/config/setup.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
from typing import List, Optional, Any
2+
from pydantic import BaseModel, Field, field_validator
3+
4+
5+
class AssetConfig(BaseModel):
6+
"""Configuration for a static asset to be injected into the sandbox."""
7+
source: str = Field(..., description="Relative path for the asset on the S3 provider")
8+
target: str = Field(..., description="Absolute path for the asset inside the container")
9+
read_only: bool = Field(True, description="Whether the asset should be read-only (0444)")
10+
11+
@field_validator('source')
12+
@classmethod
13+
def validate_source(cls, v: str) -> str:
14+
if not v:
15+
raise ValueError("source must not be empty")
16+
if v.startswith('/'):
17+
raise ValueError("source must be a relative path")
18+
if '..' in v:
19+
raise ValueError("source must not contain path traversal (..)")
20+
return v
21+
22+
@field_validator('target')
23+
@classmethod
24+
def validate_target(cls, v: str) -> str:
25+
if not v:
26+
raise ValueError("target must not be empty")
27+
if not v.startswith('/'):
28+
raise ValueError("target must be an absolute path (starting with /)")
29+
if '..' in v:
30+
raise ValueError("target must not contain path traversal (..)")
31+
return v
32+
33+
34+
class LanguageSetupConfig(BaseModel):
35+
"""Language-specific setup configuration."""
36+
required_files: List[str] = Field(default_factory=list)
37+
setup_commands: List[Any] = Field(default_factory=list)
38+
39+
40+
class SetupConfig(BaseModel):
41+
"""Root configuration for sandbox setup, including global assets and language-specific configs."""
42+
assets: List[AssetConfig] = Field(default_factory=list)
43+
44+
# Language-specific configurations are handled dynamically
45+
# but we can provide hints for common languages
46+
python: Optional[LanguageSetupConfig] = None
47+
java: Optional[LanguageSetupConfig] = None
48+
node: Optional[LanguageSetupConfig] = None
49+
cpp: Optional[LanguageSetupConfig] = None
50+
51+
# Allow extra fields for other languages
52+
model_config = {"extra": "allow"}
53+
54+
@classmethod
55+
def from_dict(cls, data: dict) -> "SetupConfig":
56+
"""Create and validate setup config from dictionary."""
57+
return cls.model_validate(data or {})
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
from dataclasses import dataclass
2+
3+
@dataclass
4+
class ResolvedAsset:
5+
"""Resolved asset ready for injection into a sandbox."""
6+
target: str
7+
content: bytes
8+
read_only: bool = True

autograder/services/assets/__init__.py

Whitespace-only changes.
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import os
2+
import shutil
3+
import logging
4+
from typing import Optional, Dict
5+
import collections
6+
7+
logger = logging.getLogger("AssetCacheManager")
8+
9+
class AssetCacheManager:
10+
def __init__(self, in_memory_limit: int = 0, disk_cache_dir: str = "/tmp/autograder_assets_cache"):
11+
"""
12+
Initialize the asset cache manager.
13+
14+
Args:
15+
in_memory_limit: Maximum number of assets to keep in memory. If 0, in-memory cache is disabled.
16+
disk_cache_dir: Directory to use for disk caching.
17+
"""
18+
self.in_memory_limit = in_memory_limit
19+
self.disk_cache_dir = disk_cache_dir
20+
21+
# LRU cache for in-memory assets (filename -> bytes)
22+
self.in_memory_cache: Dict[str, bytes] = collections.OrderedDict()
23+
24+
# Ensure disk cache directory exists
25+
if not os.path.exists(self.disk_cache_dir):
26+
os.makedirs(self.disk_cache_dir, exist_ok=True)
27+
28+
def get(self, asset_key: str) -> Optional[bytes]:
29+
"""Get an asset from cache (memory first, then disk)."""
30+
# Try in-memory cache first
31+
if self.in_memory_limit > 0 and asset_key in self.in_memory_cache:
32+
logger.debug("In-memory cache hit for %s", asset_key)
33+
# Move to end to mark as recently used
34+
content = self.in_memory_cache.pop(asset_key)
35+
self.in_memory_cache[asset_key] = content
36+
return content
37+
38+
# Try disk cache
39+
disk_path = self._get_disk_path(asset_key)
40+
if os.path.exists(disk_path):
41+
logger.debug("Disk cache hit for %s", asset_key)
42+
try:
43+
with open(disk_path, "rb") as f:
44+
content = f.read()
45+
46+
# Store in in-memory cache if enabled
47+
if self.in_memory_limit > 0:
48+
self._add_to_memory_cache(asset_key, content)
49+
50+
return content
51+
except Exception as e:
52+
logger.error("Failed to read asset from disk cache: %s", str(e))
53+
54+
return None
55+
56+
def put(self, asset_key: str, content: bytes) -> None:
57+
"""Store an asset in both memory and disk caches."""
58+
# Save to disk
59+
disk_path = self._get_disk_path(asset_key)
60+
try:
61+
# Create subdirectories if needed (e.g., if asset_key contains '/')
62+
os.makedirs(os.path.dirname(disk_path), exist_ok=True)
63+
with open(disk_path, "wb") as f:
64+
f.write(content)
65+
logger.debug("Stored asset %s in disk cache", asset_key)
66+
except Exception as e:
67+
logger.error("Failed to write asset to disk cache: %s", str(e))
68+
69+
# Save to memory if enabled
70+
if self.in_memory_limit > 0:
71+
self._add_to_memory_cache(asset_key, content)
72+
73+
def _add_to_memory_cache(self, asset_key: str, content: bytes) -> None:
74+
"""Helper to add an asset to the LRU memory cache."""
75+
if asset_key in self.in_memory_cache:
76+
self.in_memory_cache.pop(asset_key)
77+
elif len(self.in_memory_cache) >= self.in_memory_limit:
78+
# Remove oldest item (first item in OrderedDict)
79+
self.in_memory_cache.popitem(last=False)
80+
81+
self.in_memory_cache[asset_key] = content
82+
83+
def _get_disk_path(self, asset_key: str) -> str:
84+
"""Resolve the absolute disk path for an asset key."""
85+
# Sanitize asset_key to prevent directory traversal
86+
# asset_key is expected to be a hash or a safe relative path
87+
safe_key = os.path.normpath(asset_key).lstrip('/')
88+
return os.path.join(self.disk_cache_dir, safe_key)
89+
90+
def clear(self) -> None:
91+
"""Clear all caches."""
92+
self.in_memory_cache.clear()
93+
if os.path.exists(self.disk_cache_dir):
94+
shutil.rmtree(self.disk_cache_dir)
95+
os.makedirs(self.disk_cache_dir, exist_ok=True)
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
from abc import ABC, abstractmethod
2+
from typing import Optional
3+
4+
class AssetProvider(ABC):
5+
@abstractmethod
6+
def get_asset(self, source: str, target: str, read_only: bool = True) -> Optional[bytes]:
7+
"""
8+
Fetch asset content from the provider and return it as raw bytes.
9+
10+
Args:
11+
source: Relative path to the asset in the provider.
12+
target: Absolute path where the asset should be placed in the container.
13+
read_only: Whether the file should be read-only in the container.
14+
15+
Returns:
16+
Raw content as bytes, or None if not found or on error.
17+
"""
18+
...
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import os
2+
import logging
3+
from typing import List
4+
from autograder.services.assets.s3_provider import S3AssetProvider
5+
from autograder.services.assets.cache_manager import AssetCacheManager
6+
from autograder.models.config.setup import AssetConfig
7+
from autograder.models.dataclass.asset import ResolvedAsset
8+
9+
logger = logging.getLogger("AssetSourceResolver")
10+
11+
class AssetSourceResolver:
12+
def __init__(self):
13+
in_memory_limit = int(os.getenv("CRITERIA_ASSETS_IN_MEMORY_CACHE_LIMIT", "100"))
14+
self.cache_manager = AssetCacheManager(in_memory_limit=in_memory_limit)
15+
self.provider = S3AssetProvider(self.cache_manager)
16+
17+
def resolve_assets(self, assets_config: List[AssetConfig]) -> List[ResolvedAsset]:
18+
"""
19+
Resolve all assets in the configuration.
20+
21+
Args:
22+
assets_config: List of AssetConfig objects.
23+
24+
Returns:
25+
List of ResolvedAsset objects.
26+
If any asset fails to resolve, an exception is raised.
27+
"""
28+
resolved_assets = []
29+
30+
for asset in assets_config:
31+
source = asset.source
32+
target = asset.target
33+
read_only = asset.read_only
34+
35+
# Resolve asset
36+
content = self.provider.get_asset(source, target, read_only)
37+
38+
if not content:
39+
raise RuntimeError(f"Failed to resolve asset: source={source}, target={target}")
40+
41+
resolved_assets.append(ResolvedAsset(
42+
target=target,
43+
content=content,
44+
read_only=read_only
45+
))
46+
47+
return resolved_assets
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import os
2+
import logging
3+
import boto3
4+
from botocore.config import Config
5+
from typing import Optional
6+
from autograder.services.assets.provider import AssetProvider
7+
from autograder.services.assets.cache_manager import AssetCacheManager
8+
9+
logger = logging.getLogger("S3AssetProvider")
10+
11+
class S3AssetProvider(AssetProvider):
12+
def __init__(self, cache_manager: AssetCacheManager):
13+
self.cache_manager = cache_manager
14+
15+
# Environment variables
16+
self.bucket_name = os.getenv("CRITERIA_ASSETS_BUCKET_NAME")
17+
self.access_key = os.getenv("AWS_ACCESS_KEY_ID") or os.getenv("AWS_ACCESS_ID")
18+
self.secret_key = os.getenv("AWS_SECRET_ACCESS_KEY")
19+
self.region = os.getenv("AWS_REGION", "us-east-1")
20+
self.endpoint_url = os.getenv("S3_ENDPOINT_URL")
21+
22+
# Initialize boto3 client
23+
self.s3 = boto3.client(
24+
's3',
25+
endpoint_url=self.endpoint_url,
26+
aws_access_key_id=self.access_key,
27+
aws_secret_access_key=self.secret_key,
28+
region_name=self.region,
29+
config=Config(signature_version='s3v4')
30+
)
31+
32+
def get_asset(self, source: str, target: str, read_only: bool = True) -> Optional[bytes]:
33+
"""
34+
Fetch asset from S3 and return raw content (cached).
35+
"""
36+
# Create a unique cache key based on source
37+
# (target and read_only no longer affect the content itself)
38+
cache_key = f"{source}.raw".replace('/', '_')
39+
40+
# Check cache
41+
cached_content = self.cache_manager.get(cache_key)
42+
if cached_content:
43+
logger.debug("Cache hit for %s", cache_key)
44+
return cached_content
45+
46+
# Fetch from S3
47+
logger.info("Fetching asset from S3: %s", source)
48+
try:
49+
response = self.s3.get_object(Bucket=self.bucket_name, Key=source)
50+
raw_content = response['Body'].read()
51+
52+
# Store in cache
53+
self.cache_manager.put(cache_key, raw_content)
54+
55+
return raw_content
56+
except Exception as e:
57+
logger.error("Failed to fetch asset from S3 (%s): %s", source, str(e))
58+
return None

autograder/services/command_resolver.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,11 @@ def resolve_command(
5858
return None
5959

6060
# Handle special "CMD" placeholder for auto-resolution
61-
if isinstance(program_command, str) and program_command == "CMD":
62-
return self._auto_resolve_command(language, fallback_filename)
61+
if isinstance(program_command, str):
62+
if program_command == "CMD":
63+
return self._auto_resolve_command(language, fallback_filename)
64+
# If it's any other string, use it directly as the command
65+
return program_command
6366

6467
# Handle dict format (multi-language)
6568
if isinstance(program_command, dict):

0 commit comments

Comments
 (0)