e0e362d700
SDK Tests / SDK CI (push) Blocked by required conditions
SDK Tests / changes (push) Successful in 2m29s
Real E2E Tests / changes (push) Successful in 2m29s
SDK Tests / Python SDK Tests (sandbox) (push) Waiting to run
SDK Tests / CLI Quality (push) Waiting to run
SDK Tests / CLI Tests (push) Waiting to run
SDK Tests / Python SDK Quality (code-interpreter) (push) Waiting to run
SDK Tests / Python SDK Quality (sandbox) (push) Waiting to run
SDK Tests / Python SDK Tests (code-interpreter) (push) Waiting to run
SDK Tests / JavaScript SDK Quality And Tests (code-interpreter) (push) Waiting to run
SDK Tests / JavaScript SDK Quality And Tests (sandbox) (push) Waiting to run
SDK Tests / Kotlin SDK Quality And Tests (sandbox) (push) Waiting to run
SDK Tests / Kotlin SDK Quality And Tests (code-interpreter) (push) Waiting to run
SDK Tests / C# SDK Quality And Tests (code-interpreter) (push) Waiting to run
SDK Tests / C# SDK Quality And Tests (sandbox) (push) Waiting to run
SDK Tests / Go SDK Quality And Tests (push) Waiting to run
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
1421 lines
44 KiB
Python
1421 lines
44 KiB
Python
# 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.
|
|
|
|
import textwrap
|
|
|
|
import pytest
|
|
from pydantic import ValidationError
|
|
|
|
from opensandbox_server import config as config_module
|
|
from opensandbox_server.config import (
|
|
AppConfig,
|
|
LogConfig,
|
|
RenewIntentRedisConfig,
|
|
EGRESS_MODE_DNS,
|
|
EGRESS_MODE_DNS_NFT,
|
|
EgressConfig,
|
|
GatewayConfig,
|
|
GatewayRouteModeConfig,
|
|
IngressConfig,
|
|
RuntimeConfig,
|
|
SecureAccessConfig,
|
|
SecureAccessKey,
|
|
ServerConfig,
|
|
StoreConfig,
|
|
StorageConfig,
|
|
)
|
|
|
|
|
|
def _reset_config(monkeypatch):
|
|
monkeypatch.setattr(config_module, "_config", None, raising=False)
|
|
monkeypatch.setattr(config_module, "_config_path", None, raising=False)
|
|
|
|
|
|
def test_load_config_from_file(tmp_path, monkeypatch):
|
|
_reset_config(monkeypatch)
|
|
toml = textwrap.dedent(
|
|
"""
|
|
[server]
|
|
host = "127.0.0.1"
|
|
port = 9000
|
|
api_key = "secret"
|
|
max_sandbox_timeout_seconds = 172800
|
|
|
|
[log]
|
|
level = "DEBUG"
|
|
|
|
[runtime]
|
|
type = "kubernetes"
|
|
execd_image = "opensandbox/execd:test"
|
|
|
|
[ingress]
|
|
mode = "gateway"
|
|
gateway.address = "*.opensandbox.io"
|
|
gateway.route.mode = "wildcard"
|
|
"""
|
|
)
|
|
config_path = tmp_path / "config.toml"
|
|
config_path.write_text(toml)
|
|
|
|
loaded = config_module.load_config(config_path)
|
|
assert loaded.server.host == "127.0.0.1"
|
|
assert loaded.server.port == 9000
|
|
assert loaded.log.level == "DEBUG"
|
|
assert loaded.server.api_key == "secret"
|
|
assert loaded.server.max_sandbox_timeout_seconds == 172800
|
|
assert loaded.runtime.type == "kubernetes"
|
|
assert loaded.runtime.execd_image == "opensandbox/execd:test"
|
|
assert loaded.ingress is not None
|
|
assert loaded.ingress.mode == "gateway"
|
|
assert loaded.ingress.gateway is not None
|
|
assert loaded.ingress.gateway.address == "*.opensandbox.io"
|
|
assert loaded.ingress.gateway.route.mode == "wildcard"
|
|
assert loaded.kubernetes is not None
|
|
|
|
|
|
def test_load_config_env_override_api_key(tmp_path, monkeypatch):
|
|
"""OPENSANDBOX_SERVER_API_KEY should override server.api_key from TOML."""
|
|
_reset_config(monkeypatch)
|
|
monkeypatch.setenv("OPENSANDBOX_SERVER_API_KEY", "env-secret-key")
|
|
toml = textwrap.dedent(
|
|
"""
|
|
[server]
|
|
host = "127.0.0.1"
|
|
port = 9000
|
|
api_key = "toml-secret-key"
|
|
|
|
[runtime]
|
|
type = "docker"
|
|
execd_image = "opensandbox/execd:test"
|
|
"""
|
|
)
|
|
config_path = tmp_path / "config.toml"
|
|
config_path.write_text(toml)
|
|
|
|
loaded = config_module.load_config(config_path)
|
|
assert loaded.server.api_key == "env-secret-key"
|
|
|
|
|
|
def test_load_config_env_api_key_without_toml_key(tmp_path, monkeypatch):
|
|
"""OPENSANDBOX_SERVER_API_KEY should work even when TOML omits api_key."""
|
|
_reset_config(monkeypatch)
|
|
monkeypatch.setenv("OPENSANDBOX_SERVER_API_KEY", "env-only-key")
|
|
toml = textwrap.dedent(
|
|
"""
|
|
[server]
|
|
host = "127.0.0.1"
|
|
port = 9000
|
|
|
|
[runtime]
|
|
type = "docker"
|
|
execd_image = "opensandbox/execd:test"
|
|
"""
|
|
)
|
|
config_path = tmp_path / "config.toml"
|
|
config_path.write_text(toml)
|
|
|
|
loaded = config_module.load_config(config_path)
|
|
assert loaded.server.api_key == "env-only-key"
|
|
|
|
|
|
def test_load_config_without_env_uses_toml_api_key(tmp_path, monkeypatch):
|
|
"""When OPENSANDBOX_SERVER_API_KEY is unset, TOML api_key should be used."""
|
|
_reset_config(monkeypatch)
|
|
toml = textwrap.dedent(
|
|
"""
|
|
[server]
|
|
host = "127.0.0.1"
|
|
port = 9000
|
|
api_key = "toml-secret-key"
|
|
|
|
[runtime]
|
|
type = "docker"
|
|
execd_image = "opensandbox/execd:test"
|
|
"""
|
|
)
|
|
config_path = tmp_path / "config.toml"
|
|
config_path.write_text(toml)
|
|
|
|
loaded = config_module.load_config(config_path)
|
|
assert loaded.server.api_key == "toml-secret-key"
|
|
|
|
|
|
def test_docker_runtime_disallows_kubernetes_block():
|
|
server_cfg = ServerConfig()
|
|
runtime_cfg = RuntimeConfig(type="docker", execd_image="busybox:latest")
|
|
kubernetes_cfg = config_module.KubernetesRuntimeConfig(namespace="sandbox")
|
|
with pytest.raises(ValueError):
|
|
AppConfig(server=server_cfg, runtime=runtime_cfg, kubernetes=kubernetes_cfg)
|
|
|
|
|
|
def test_server_config_defaults_include_max_sandbox_timeout():
|
|
server_cfg = ServerConfig()
|
|
assert server_cfg.max_sandbox_timeout_seconds is None
|
|
|
|
|
|
def test_server_config_uvicorn_tuning_defaults():
|
|
"""ServerConfig exposes uvicorn concurrency knobs with sensible defaults."""
|
|
server_cfg = ServerConfig()
|
|
assert server_cfg.limit_concurrency == 1024
|
|
assert server_cfg.backlog == 2048
|
|
assert server_cfg.thread_pool_size == 200
|
|
assert server_cfg.loop == "auto"
|
|
assert server_cfg.http == "auto"
|
|
|
|
|
|
def test_server_config_uvicorn_tuning_overrides():
|
|
server_cfg = ServerConfig(
|
|
limit_concurrency=256,
|
|
backlog=4096,
|
|
loop="uvloop",
|
|
http="httptools",
|
|
)
|
|
assert server_cfg.limit_concurrency == 256
|
|
assert server_cfg.backlog == 4096
|
|
assert server_cfg.loop == "uvloop"
|
|
assert server_cfg.http == "httptools"
|
|
|
|
|
|
def test_server_config_limit_concurrency_zero_disables_cap():
|
|
"""0 is the TOML-friendly disable sentinel and must collapse to None so
|
|
uvicorn applies no concurrency limit."""
|
|
cfg = ServerConfig(limit_concurrency=0)
|
|
assert cfg.limit_concurrency is None
|
|
|
|
|
|
def test_server_config_limit_concurrency_accepts_none_and_positive():
|
|
cfg = ServerConfig(limit_concurrency=None)
|
|
assert cfg.limit_concurrency is None
|
|
cfg = ServerConfig(limit_concurrency=512)
|
|
assert cfg.limit_concurrency == 512
|
|
|
|
|
|
def test_server_config_limit_concurrency_rejects_negative():
|
|
with pytest.raises(ValidationError):
|
|
ServerConfig(limit_concurrency=-1)
|
|
|
|
|
|
def test_server_config_backlog_must_be_positive():
|
|
with pytest.raises(ValidationError):
|
|
ServerConfig(backlog=0)
|
|
|
|
|
|
def test_server_config_thread_pool_size_must_be_positive():
|
|
with pytest.raises(ValidationError):
|
|
ServerConfig(thread_pool_size=0)
|
|
cfg = ServerConfig(thread_pool_size=512)
|
|
assert cfg.thread_pool_size == 512
|
|
|
|
|
|
def test_server_config_loop_and_http_reject_unknown_values():
|
|
with pytest.raises(ValidationError):
|
|
ServerConfig(loop="trio") # type: ignore[arg-type]
|
|
with pytest.raises(ValidationError):
|
|
ServerConfig(http="hyper") # type: ignore[arg-type]
|
|
|
|
|
|
def test_store_defaults_to_sqlite():
|
|
cfg = StoreConfig()
|
|
assert cfg.type == "sqlite"
|
|
assert cfg.path.endswith("opensandbox.db")
|
|
|
|
|
|
def test_renew_intent_defaults():
|
|
cfg = AppConfig(runtime=RuntimeConfig(type="docker", execd_image="opensandbox/execd:latest"))
|
|
ar = cfg.renew_intent
|
|
assert ar.enabled is False
|
|
assert ar.min_interval_seconds == 60
|
|
assert ar.redis.enabled is False
|
|
assert ar.redis.dsn is None
|
|
assert ar.redis.queue_key == "opensandbox:renew:intent"
|
|
assert ar.redis.consumer_concurrency == 8
|
|
|
|
|
|
def test_renew_intent_redis_requires_dsn_when_enabled():
|
|
with pytest.raises(ValidationError):
|
|
RenewIntentRedisConfig(enabled=True, dsn=None)
|
|
with pytest.raises(ValidationError):
|
|
RenewIntentRedisConfig(enabled=True, dsn=" ")
|
|
cfg = RenewIntentRedisConfig(enabled=True, dsn="redis://127.0.0.1:6379/0")
|
|
assert cfg.dsn == "redis://127.0.0.1:6379/0"
|
|
|
|
|
|
def test_load_config_renew_intent_dotted_redis_keys(tmp_path, monkeypatch):
|
|
_reset_config(monkeypatch)
|
|
toml = textwrap.dedent(
|
|
"""
|
|
[server]
|
|
host = "127.0.0.1"
|
|
port = 9000
|
|
|
|
[renew_intent]
|
|
enabled = true
|
|
min_interval_seconds = 30
|
|
redis.enabled = true
|
|
redis.dsn = "redis://example:6379/1"
|
|
redis.queue_key = "custom:renew"
|
|
redis.consumer_concurrency = 4
|
|
|
|
[runtime]
|
|
type = "docker"
|
|
execd_image = "opensandbox/execd:test"
|
|
"""
|
|
)
|
|
config_path = tmp_path / "config.toml"
|
|
config_path.write_text(toml)
|
|
|
|
loaded = config_module.load_config(config_path)
|
|
ar = loaded.renew_intent
|
|
assert ar.enabled is True
|
|
assert ar.min_interval_seconds == 30
|
|
assert ar.redis.enabled is True
|
|
assert ar.redis.dsn == "redis://example:6379/1"
|
|
assert ar.redis.queue_key == "custom:renew"
|
|
assert ar.redis.consumer_concurrency == 4
|
|
|
|
|
|
def test_load_config_store_block(tmp_path, monkeypatch):
|
|
_reset_config(monkeypatch)
|
|
db_path = tmp_path / "snapshots.sqlite3"
|
|
escaped_db_path = str(db_path).replace("\\", "\\\\").replace('"', '\\"')
|
|
toml = textwrap.dedent(
|
|
f"""
|
|
[store]
|
|
type = "sqlite"
|
|
path = "{escaped_db_path}"
|
|
|
|
[runtime]
|
|
type = "docker"
|
|
execd_image = "opensandbox/execd:test"
|
|
"""
|
|
)
|
|
config_path = tmp_path / "config.toml"
|
|
config_path.write_text(toml)
|
|
|
|
loaded = config_module.load_config(config_path)
|
|
assert loaded.store.type == "sqlite"
|
|
assert loaded.store.path == str(db_path)
|
|
|
|
|
|
def test_load_config_renew_intent_legacy_redis_subtable(tmp_path, monkeypatch):
|
|
"""[renew_intent.redis] remains accepted (same parsed shape as dotted keys)."""
|
|
_reset_config(monkeypatch)
|
|
toml = textwrap.dedent(
|
|
"""
|
|
[server]
|
|
host = "127.0.0.1"
|
|
port = 9000
|
|
|
|
[renew_intent]
|
|
enabled = true
|
|
|
|
[renew_intent.redis]
|
|
enabled = true
|
|
dsn = "redis://legacy:6379/0"
|
|
|
|
[runtime]
|
|
type = "docker"
|
|
execd_image = "opensandbox/execd:test"
|
|
"""
|
|
)
|
|
config_path = tmp_path / "config.toml"
|
|
config_path.write_text(toml)
|
|
|
|
loaded = config_module.load_config(config_path)
|
|
assert loaded.renew_intent.redis.enabled is True
|
|
assert loaded.renew_intent.redis.dsn == "redis://legacy:6379/0"
|
|
|
|
|
|
def test_load_config_ignores_legacy_pause_block(tmp_path, monkeypatch):
|
|
_reset_config(monkeypatch)
|
|
toml = textwrap.dedent(
|
|
"""
|
|
[server]
|
|
host = "127.0.0.1"
|
|
port = 9000
|
|
|
|
[runtime]
|
|
type = "kubernetes"
|
|
execd_image = "opensandbox/execd:test"
|
|
|
|
[pause]
|
|
snapshot_registry = "registry.example.com/sandboxes"
|
|
snapshot_push_secret = "registry-snapshot-push-secret"
|
|
resume_pull_secret = "registry-pull-secret"
|
|
snapshot_type = "Rootfs"
|
|
"""
|
|
)
|
|
config_path = tmp_path / "config.toml"
|
|
config_path.write_text(toml)
|
|
|
|
loaded = config_module.load_config(config_path)
|
|
assert loaded.runtime.type == "kubernetes"
|
|
assert not hasattr(loaded, "pause")
|
|
|
|
|
|
def test_kubernetes_runtime_fills_missing_block():
|
|
server_cfg = ServerConfig()
|
|
runtime_cfg = RuntimeConfig(type="kubernetes", execd_image="opensandbox/execd:latest")
|
|
app_cfg = AppConfig(server=server_cfg, runtime=runtime_cfg)
|
|
assert app_cfg.kubernetes is not None
|
|
|
|
|
|
def test_ingress_gateway_requires_gateway_block():
|
|
with pytest.raises(ValueError):
|
|
IngressConfig(mode="gateway")
|
|
cfg = IngressConfig(
|
|
mode="gateway",
|
|
gateway=GatewayConfig(
|
|
address="gateway.opensandbox.io",
|
|
route=GatewayRouteModeConfig(mode="uri"),
|
|
),
|
|
)
|
|
assert cfg.gateway.route.mode == "uri"
|
|
|
|
|
|
def test_gateway_address_validation_for_wildcard_mode():
|
|
with pytest.raises(ValueError):
|
|
IngressConfig(
|
|
mode="gateway",
|
|
gateway=GatewayConfig(
|
|
address="gateway.opensandbox.io",
|
|
route=GatewayRouteModeConfig(mode="wildcard"),
|
|
),
|
|
)
|
|
cfg = IngressConfig(
|
|
mode="gateway",
|
|
gateway=GatewayConfig(
|
|
address="*.opensandbox.io",
|
|
route=GatewayRouteModeConfig(mode="wildcard"),
|
|
),
|
|
)
|
|
assert cfg.gateway.address == "*.opensandbox.io"
|
|
with pytest.raises(ValueError):
|
|
IngressConfig(
|
|
mode="gateway",
|
|
gateway=GatewayConfig(
|
|
address="10.0.0.1",
|
|
route=GatewayRouteModeConfig(mode="wildcard"),
|
|
),
|
|
)
|
|
with pytest.raises(ValueError):
|
|
IngressConfig(
|
|
mode="gateway",
|
|
gateway=GatewayConfig(
|
|
address="http://10.0.0.1:8080",
|
|
route=GatewayRouteModeConfig(mode="wildcard"),
|
|
),
|
|
)
|
|
with pytest.raises(ValueError):
|
|
IngressConfig(
|
|
mode="gateway",
|
|
gateway=GatewayConfig(
|
|
address="10.0.0.1:8080",
|
|
route=GatewayRouteModeConfig(mode="wildcard"),
|
|
),
|
|
)
|
|
with pytest.raises(ValueError):
|
|
IngressConfig(
|
|
mode="gateway",
|
|
gateway=GatewayConfig(
|
|
address="https://*.opensandbox.io",
|
|
route=GatewayRouteModeConfig(mode="wildcard"),
|
|
),
|
|
)
|
|
|
|
|
|
def test_gateway_route_mode_allows_wildcard_alias():
|
|
cfg = IngressConfig(
|
|
mode="gateway",
|
|
gateway=GatewayConfig(
|
|
address="*.opensandbox.io",
|
|
route=GatewayRouteModeConfig(mode="wildcard"),
|
|
),
|
|
)
|
|
assert cfg.gateway.route.mode == "wildcard"
|
|
|
|
|
|
def test_gateway_address_validation_for_non_wildcard_mode():
|
|
with pytest.raises(ValueError):
|
|
IngressConfig(
|
|
mode="gateway",
|
|
gateway=GatewayConfig(
|
|
address="*.opensandbox.io",
|
|
route=GatewayRouteModeConfig(mode="header"),
|
|
),
|
|
)
|
|
with pytest.raises(ValueError):
|
|
IngressConfig(
|
|
mode="gateway",
|
|
gateway=GatewayConfig(
|
|
address="*.opensandbox.io",
|
|
route=GatewayRouteModeConfig(mode="uri"),
|
|
),
|
|
)
|
|
with pytest.raises(ValueError):
|
|
IngressConfig(
|
|
mode="gateway",
|
|
gateway=GatewayConfig(
|
|
address="10.0.0.1:70000",
|
|
route=GatewayRouteModeConfig(mode="header"),
|
|
),
|
|
)
|
|
with pytest.raises(ValueError):
|
|
IngressConfig(
|
|
mode="gateway",
|
|
gateway=GatewayConfig(
|
|
address="ftp://gateway.opensandbox.io",
|
|
route=GatewayRouteModeConfig(mode="header"),
|
|
),
|
|
)
|
|
with pytest.raises(ValueError):
|
|
IngressConfig(
|
|
mode="gateway",
|
|
gateway=GatewayConfig(
|
|
address="http://",
|
|
route=GatewayRouteModeConfig(mode="header"),
|
|
),
|
|
)
|
|
with pytest.raises(ValueError):
|
|
IngressConfig(
|
|
mode="gateway",
|
|
gateway=GatewayConfig(
|
|
address="http://user:pass@gateway.opensandbox.io",
|
|
route=GatewayRouteModeConfig(mode="header"),
|
|
),
|
|
)
|
|
with pytest.raises(ValueError):
|
|
IngressConfig(
|
|
mode="gateway",
|
|
gateway=GatewayConfig(
|
|
address="http://gateway.opensandbox.io:8080",
|
|
route=GatewayRouteModeConfig(mode="header"),
|
|
),
|
|
)
|
|
with pytest.raises(ValueError):
|
|
IngressConfig(
|
|
mode="gateway",
|
|
gateway=GatewayConfig(
|
|
address="http://gateway.opensandbox.io:8080",
|
|
route=GatewayRouteModeConfig(mode="uri"),
|
|
),
|
|
)
|
|
with pytest.raises(ValueError):
|
|
IngressConfig(
|
|
mode="gateway",
|
|
gateway=GatewayConfig(
|
|
address="http://[::1]",
|
|
route=GatewayRouteModeConfig(mode="header"),
|
|
),
|
|
)
|
|
cfg = IngressConfig(
|
|
mode="gateway",
|
|
gateway=GatewayConfig(
|
|
address="gateway.opensandbox.io",
|
|
route=GatewayRouteModeConfig(mode="uri"),
|
|
),
|
|
)
|
|
assert cfg.gateway.address == "gateway.opensandbox.io"
|
|
cfg_hostname = IngressConfig(
|
|
mode="gateway",
|
|
gateway=GatewayConfig(
|
|
address="gateway",
|
|
route=GatewayRouteModeConfig(mode="header"),
|
|
),
|
|
)
|
|
assert cfg_hostname.gateway.address == "gateway"
|
|
cfg_hostname_port = IngressConfig(
|
|
mode="gateway",
|
|
gateway=GatewayConfig(
|
|
address="gateway.opensandbox.io:8080",
|
|
route=GatewayRouteModeConfig(mode="header"),
|
|
),
|
|
)
|
|
assert cfg_hostname_port.gateway.address == "gateway.opensandbox.io:8080"
|
|
cfg_ip = IngressConfig(
|
|
mode="gateway",
|
|
gateway=GatewayConfig(
|
|
address="10.0.0.1",
|
|
route=GatewayRouteModeConfig(mode="header"),
|
|
),
|
|
)
|
|
assert cfg_ip.gateway.address == "10.0.0.1"
|
|
cfg_ip_port = IngressConfig(
|
|
mode="gateway",
|
|
gateway=GatewayConfig(
|
|
address="10.0.0.1:8080",
|
|
route=GatewayRouteModeConfig(mode="header"),
|
|
),
|
|
)
|
|
assert cfg_ip_port.gateway.address == "10.0.0.1:8080"
|
|
cfg_uri_freeform = IngressConfig(
|
|
mode="gateway",
|
|
gateway=GatewayConfig(
|
|
address="not a host",
|
|
route=GatewayRouteModeConfig(mode="uri"),
|
|
),
|
|
)
|
|
assert cfg_uri_freeform.gateway.address == "not a host"
|
|
cfg_uri_port_like = IngressConfig(
|
|
mode="gateway",
|
|
gateway=GatewayConfig(
|
|
address="10.0.0.1:abc",
|
|
route=GatewayRouteModeConfig(mode="uri"),
|
|
),
|
|
)
|
|
assert cfg_uri_port_like.gateway.address == "10.0.0.1:abc"
|
|
|
|
|
|
def test_gateway_address_allows_scheme_less_defaults():
|
|
cfg = IngressConfig(
|
|
mode="gateway",
|
|
gateway=GatewayConfig(
|
|
address="*.example.com",
|
|
route=GatewayRouteModeConfig(mode="wildcard"),
|
|
),
|
|
)
|
|
assert cfg.gateway.address == "*.example.com"
|
|
with pytest.raises(ValueError):
|
|
IngressConfig(
|
|
mode="gateway",
|
|
gateway=GatewayConfig(
|
|
address="https://*.example.com",
|
|
route=GatewayRouteModeConfig(mode="wildcard"),
|
|
),
|
|
)
|
|
|
|
|
|
def test_direct_mode_rejects_gateway_block():
|
|
with pytest.raises(ValueError):
|
|
IngressConfig(
|
|
mode="direct",
|
|
gateway=GatewayConfig(
|
|
address="gateway.opensandbox.io",
|
|
route=GatewayRouteModeConfig(mode="header"),
|
|
),
|
|
)
|
|
|
|
|
|
def test_docker_runtime_rejects_gateway_ingress():
|
|
server_cfg = ServerConfig()
|
|
runtime_cfg = RuntimeConfig(type="docker", execd_image="busybox:latest")
|
|
with pytest.raises(ValueError):
|
|
AppConfig(
|
|
server=server_cfg,
|
|
runtime=runtime_cfg,
|
|
ingress=IngressConfig(
|
|
mode="gateway",
|
|
gateway=GatewayConfig(
|
|
address="gateway.opensandbox.io",
|
|
route=GatewayRouteModeConfig(mode="header"),
|
|
),
|
|
),
|
|
)
|
|
# direct remains valid
|
|
app_cfg = AppConfig(
|
|
server=server_cfg,
|
|
runtime=runtime_cfg,
|
|
ingress=IngressConfig(mode="direct"),
|
|
)
|
|
assert app_cfg.ingress.mode == "direct"
|
|
|
|
|
|
def test_storage_config_defaults():
|
|
"""StorageConfig should default to empty allowed_host_paths list."""
|
|
cfg = StorageConfig()
|
|
assert cfg.allowed_host_paths == []
|
|
|
|
|
|
def test_storage_config_with_paths():
|
|
"""StorageConfig should accept explicit allowed_host_paths."""
|
|
cfg = StorageConfig(allowed_host_paths=["/data/opensandbox", "/tmp/sandbox"])
|
|
assert cfg.allowed_host_paths == ["/data/opensandbox", "/tmp/sandbox"]
|
|
|
|
|
|
def test_app_config_default_storage():
|
|
"""AppConfig should include default StorageConfig when not specified."""
|
|
server_cfg = ServerConfig()
|
|
runtime_cfg = RuntimeConfig(type="docker", execd_image="busybox:latest")
|
|
app_cfg = AppConfig(server=server_cfg, runtime=runtime_cfg)
|
|
assert app_cfg.storage is not None
|
|
assert app_cfg.storage.allowed_host_paths == []
|
|
|
|
|
|
def test_load_config_with_storage_block(tmp_path, monkeypatch):
|
|
"""StorageConfig should be loaded from [storage] TOML block."""
|
|
_reset_config(monkeypatch)
|
|
toml = textwrap.dedent(
|
|
"""
|
|
[server]
|
|
host = "127.0.0.1"
|
|
port = 9000
|
|
|
|
[runtime]
|
|
type = "docker"
|
|
execd_image = "ghcr.io/opensandbox/platform:test"
|
|
|
|
[router]
|
|
domain = "opensandbox.io"
|
|
|
|
[storage]
|
|
allowed_host_paths = ["/data/opensandbox", "/tmp/sandbox"]
|
|
"""
|
|
)
|
|
config_path = tmp_path / "config.toml"
|
|
config_path.write_text(toml)
|
|
|
|
loaded = config_module.load_config(config_path)
|
|
assert loaded.storage is not None
|
|
assert loaded.storage.allowed_host_paths == ["/data/opensandbox", "/tmp/sandbox"]
|
|
|
|
|
|
def test_load_config_without_storage_block_uses_defaults(tmp_path, monkeypatch):
|
|
"""AppConfig should use default StorageConfig when [storage] is not in TOML."""
|
|
_reset_config(monkeypatch)
|
|
toml = textwrap.dedent(
|
|
"""
|
|
[server]
|
|
host = "127.0.0.1"
|
|
port = 9000
|
|
|
|
[runtime]
|
|
type = "docker"
|
|
execd_image = "ghcr.io/opensandbox/platform:test"
|
|
|
|
[router]
|
|
domain = "opensandbox.io"
|
|
"""
|
|
)
|
|
config_path = tmp_path / "config.toml"
|
|
config_path.write_text(toml)
|
|
|
|
loaded = config_module.load_config(config_path)
|
|
assert loaded.storage is not None
|
|
assert loaded.storage.allowed_host_paths == []
|
|
|
|
|
|
def test_secure_runtime_empty_type_is_valid():
|
|
"""Empty type (default runc) should be valid."""
|
|
cfg = config_module.SecureRuntimeConfig(type="")
|
|
assert cfg.type == ""
|
|
assert cfg.docker_runtime is None
|
|
assert cfg.k8s_runtime_class is None
|
|
|
|
|
|
def test_secure_runtime_gvisor_with_docker_runtime_is_valid():
|
|
"""gVisor with docker_runtime should be valid."""
|
|
cfg = config_module.SecureRuntimeConfig(
|
|
type="gvisor",
|
|
docker_runtime="runsc",
|
|
k8s_runtime_class="gvisor",
|
|
)
|
|
assert cfg.type == "gvisor"
|
|
assert cfg.docker_runtime == "runsc"
|
|
assert cfg.k8s_runtime_class == "gvisor"
|
|
|
|
|
|
def test_secure_runtime_gvisor_with_k8s_runtime_class_is_valid():
|
|
"""gVisor with only k8s_runtime_class should be valid."""
|
|
cfg = config_module.SecureRuntimeConfig(
|
|
type="gvisor",
|
|
docker_runtime=None,
|
|
k8s_runtime_class="gvisor",
|
|
)
|
|
assert cfg.type == "gvisor"
|
|
assert cfg.docker_runtime is None
|
|
assert cfg.k8s_runtime_class == "gvisor"
|
|
|
|
|
|
def test_secure_runtime_kata_with_runtimes_is_valid():
|
|
"""Kata with both runtimes should be valid."""
|
|
cfg = config_module.SecureRuntimeConfig(
|
|
type="kata",
|
|
docker_runtime="kata-runtime",
|
|
k8s_runtime_class="kata-qemu",
|
|
)
|
|
assert cfg.type == "kata"
|
|
assert cfg.docker_runtime == "kata-runtime"
|
|
assert cfg.k8s_runtime_class == "kata-qemu"
|
|
|
|
|
|
def test_secure_runtime_firecracker_with_k8s_runtime_is_valid():
|
|
"""Firecracker with k8s_runtime_class should be valid."""
|
|
cfg = config_module.SecureRuntimeConfig(
|
|
type="firecracker",
|
|
docker_runtime="",
|
|
k8s_runtime_class="kata-fc",
|
|
)
|
|
assert cfg.type == "firecracker"
|
|
assert cfg.docker_runtime == ""
|
|
assert cfg.k8s_runtime_class == "kata-fc"
|
|
|
|
|
|
def test_secure_runtime_firecracker_without_k8s_runtime_raises_error():
|
|
"""Firecracker without k8s_runtime_class should raise error."""
|
|
with pytest.raises(ValueError) as exc:
|
|
config_module.SecureRuntimeConfig(
|
|
type="firecracker",
|
|
docker_runtime="",
|
|
k8s_runtime_class=None,
|
|
)
|
|
assert "k8s_runtime_class" in str(exc.value).lower()
|
|
|
|
|
|
def test_secure_runtime_gvisor_without_any_runtime_raises_error():
|
|
"""gVisor without any runtime configured should raise error."""
|
|
with pytest.raises(ValueError) as exc:
|
|
config_module.SecureRuntimeConfig(
|
|
type="gvisor",
|
|
docker_runtime=None,
|
|
k8s_runtime_class=None,
|
|
)
|
|
assert "docker_runtime" in str(exc.value).lower() or "k8s_runtime_class" in str(exc.value).lower()
|
|
|
|
|
|
def test_secure_runtime_kata_without_any_runtime_raises_error():
|
|
"""Kata without any runtime configured should raise error."""
|
|
with pytest.raises(ValueError) as exc:
|
|
config_module.SecureRuntimeConfig(
|
|
type="kata",
|
|
docker_runtime=None,
|
|
k8s_runtime_class=None,
|
|
)
|
|
assert "docker_runtime" in str(exc.value).lower() or "k8s_runtime_class" in str(exc.value).lower()
|
|
|
|
|
|
def test_secure_runtime_invalid_type_raises_error():
|
|
"""Invalid type should raise ValidationError."""
|
|
with pytest.raises(Exception):
|
|
config_module.SecureRuntimeConfig(type="invalid_runtime")
|
|
|
|
|
|
def test_app_config_with_secure_runtime():
|
|
"""AppConfig should parse secure_runtime section."""
|
|
cfg = AppConfig(
|
|
runtime={"type": "docker", "execd_image": "execd:v1"},
|
|
secure_runtime={
|
|
"type": "gvisor",
|
|
"docker_runtime": "runsc",
|
|
"k8s_runtime_class": "gvisor",
|
|
},
|
|
)
|
|
assert cfg.secure_runtime is not None
|
|
assert cfg.secure_runtime.type == "gvisor"
|
|
assert cfg.secure_runtime.docker_runtime == "runsc"
|
|
|
|
|
|
def test_app_config_without_secure_runtime():
|
|
"""AppConfig without secure_runtime should have None."""
|
|
cfg = AppConfig(
|
|
runtime={"type": "docker", "execd_image": "execd:v1"},
|
|
)
|
|
assert cfg.secure_runtime is None
|
|
|
|
|
|
def test_load_config_with_secure_runtime(tmp_path, monkeypatch):
|
|
"""SecureRuntimeConfig should be loaded from [secure_runtime] TOML block."""
|
|
_reset_config(monkeypatch)
|
|
toml = textwrap.dedent(
|
|
"""
|
|
[server]
|
|
host = "127.0.0.1"
|
|
port = 9000
|
|
|
|
[runtime]
|
|
type = "docker"
|
|
execd_image = "ghcr.io/opensandbox/platform:test"
|
|
|
|
[secure_runtime]
|
|
type = "gvisor"
|
|
docker_runtime = "runsc"
|
|
k8s_runtime_class = "gvisor"
|
|
"""
|
|
)
|
|
config_path = tmp_path / "config.toml"
|
|
config_path.write_text(toml)
|
|
|
|
loaded = config_module.load_config(config_path)
|
|
assert loaded.secure_runtime is not None
|
|
assert loaded.secure_runtime.type == "gvisor"
|
|
assert loaded.secure_runtime.docker_runtime == "runsc"
|
|
assert loaded.secure_runtime.k8s_runtime_class == "gvisor"
|
|
|
|
|
|
def test_docker_runtime_with_firecracker_raises_error():
|
|
"""Docker runtime with Firecracker secure runtime should raise error.
|
|
|
|
Firecracker (kata-fc) is only available as a Kubernetes RuntimeClass,
|
|
not as a Docker OCI runtime. This test prevents the silent fallback
|
|
to runc which would bypass the intended microVM isolation.
|
|
"""
|
|
with pytest.raises(ValueError) as exc:
|
|
AppConfig(
|
|
runtime={"type": "docker", "execd_image": "execd:v1"},
|
|
secure_runtime={
|
|
"type": "firecracker",
|
|
"k8s_runtime_class": "kata-fc",
|
|
},
|
|
)
|
|
assert "firecracker" in str(exc.value).lower()
|
|
assert "kubernetes" in str(exc.value).lower()
|
|
|
|
|
|
def test_kubernetes_runtime_with_firecracker_is_valid():
|
|
"""Kubernetes runtime with Firecracker should be valid."""
|
|
cfg = AppConfig(
|
|
runtime={"type": "kubernetes", "execd_image": "execd:v1"},
|
|
kubernetes={"namespace": "default"},
|
|
secure_runtime={
|
|
"type": "firecracker",
|
|
"k8s_runtime_class": "kata-fc",
|
|
},
|
|
)
|
|
assert cfg.runtime.type == "kubernetes"
|
|
assert cfg.secure_runtime is not None
|
|
assert cfg.secure_runtime.type == "firecracker"
|
|
assert cfg.secure_runtime.k8s_runtime_class == "kata-fc"
|
|
|
|
|
|
def test_egress_config_mode_literal():
|
|
base = EgressConfig(image="opensandbox/egress:v1")
|
|
assert base.mode == EGRESS_MODE_DNS
|
|
assert base.disable_ipv6 is True
|
|
cfg = EgressConfig(image="opensandbox/egress:v1", mode=EGRESS_MODE_DNS_NFT)
|
|
assert cfg.mode == EGRESS_MODE_DNS_NFT
|
|
|
|
|
|
def test_log_config_defaults():
|
|
"""LogConfig should have sensible defaults."""
|
|
cfg = LogConfig()
|
|
assert cfg.level == "INFO"
|
|
assert cfg.file_enabled is False
|
|
assert cfg.file_path is None
|
|
assert cfg.access_file_path is None
|
|
assert cfg.file_max_bytes == 100 * 1024 * 1024 # 100MB
|
|
assert cfg.file_backup_count == 5
|
|
|
|
|
|
def test_log_config_resolved_file_path():
|
|
"""resolved_file_path() should return None when file_enabled=False."""
|
|
cfg = LogConfig(file_enabled=False)
|
|
assert cfg.resolved_file_path() is None
|
|
|
|
# file_enabled=True without file_path uses default
|
|
cfg = LogConfig(file_enabled=True)
|
|
assert cfg.resolved_file_path() == LogConfig.DEFAULT_FILE_PATH
|
|
|
|
# file_enabled=True with file_path uses custom path
|
|
cfg = LogConfig(file_enabled=True, file_path="/custom/path.log")
|
|
assert cfg.resolved_file_path() == "/custom/path.log"
|
|
|
|
|
|
def test_log_config_resolved_access_file_path():
|
|
"""resolved_access_file_path() should return default path when file_enabled."""
|
|
# file_enabled=False always returns None
|
|
cfg = LogConfig(file_enabled=False, access_file_path="/path/access.log")
|
|
assert cfg.resolved_access_file_path() is None
|
|
|
|
# file_enabled=True without access_file_path returns default path
|
|
cfg = LogConfig(file_enabled=True)
|
|
assert cfg.resolved_access_file_path() == LogConfig.DEFAULT_ACCESS_FILE_PATH
|
|
|
|
# file_enabled=True with access_file_path returns the custom path
|
|
cfg = LogConfig(file_enabled=True, access_file_path="/custom/access.log")
|
|
assert cfg.resolved_access_file_path() == "/custom/access.log"
|
|
|
|
|
|
def test_log_config_level_min_length():
|
|
"""LogConfig level must be at least 3 characters."""
|
|
with pytest.raises(ValidationError):
|
|
LogConfig(level="AB")
|
|
cfg = LogConfig(level="DEBUG")
|
|
assert cfg.level == "DEBUG"
|
|
|
|
|
|
def test_log_config_file_max_bytes_validation():
|
|
"""LogConfig file_max_bytes must be at least 1."""
|
|
with pytest.raises(ValidationError):
|
|
LogConfig(file_max_bytes=0)
|
|
cfg = LogConfig(file_max_bytes=50 * 1024 * 1024) # 50MB
|
|
assert cfg.file_max_bytes == 50 * 1024 * 1024
|
|
|
|
|
|
def test_log_config_file_backup_count_validation():
|
|
"""LogConfig file_backup_count must be >= 0."""
|
|
with pytest.raises(ValidationError):
|
|
LogConfig(file_backup_count=-1)
|
|
cfg = LogConfig(file_backup_count=10)
|
|
assert cfg.file_backup_count == 10
|
|
cfg_zero = LogConfig(file_backup_count=0)
|
|
assert cfg_zero.file_backup_count == 0
|
|
|
|
|
|
def test_app_config_log_defaults():
|
|
"""AppConfig should include default LogConfig."""
|
|
cfg = AppConfig(
|
|
runtime=RuntimeConfig(type="docker", execd_image="test:latest")
|
|
)
|
|
assert cfg.log is not None
|
|
assert cfg.log.level == "INFO"
|
|
assert cfg.log.file_path is None
|
|
|
|
|
|
def test_load_config_with_log_subsection(tmp_path, monkeypatch):
|
|
"""LogConfig should be loaded from [log] TOML section."""
|
|
_reset_config(monkeypatch)
|
|
toml = textwrap.dedent(
|
|
"""
|
|
[server]
|
|
host = "127.0.0.1"
|
|
port = 9000
|
|
|
|
[log]
|
|
level = "DEBUG"
|
|
file_path = "/var/log/opensandbox/server.log"
|
|
file_max_bytes = 52428800
|
|
file_backup_count = 3
|
|
|
|
[runtime]
|
|
type = "docker"
|
|
execd_image = "opensandbox/execd:test"
|
|
"""
|
|
)
|
|
config_path = tmp_path / "config.toml"
|
|
config_path.write_text(toml)
|
|
|
|
loaded = config_module.load_config(config_path)
|
|
assert loaded.log.level == "DEBUG"
|
|
assert loaded.log.file_path == "/var/log/opensandbox/server.log"
|
|
assert loaded.log.file_max_bytes == 52428800
|
|
assert loaded.log.file_backup_count == 3
|
|
|
|
|
|
def test_load_config_without_log_subsection_uses_defaults(tmp_path, monkeypatch):
|
|
"""AppConfig should use default LogConfig when [log] is not in TOML."""
|
|
_reset_config(monkeypatch)
|
|
toml = textwrap.dedent(
|
|
"""
|
|
[server]
|
|
host = "127.0.0.1"
|
|
port = 9000
|
|
|
|
[runtime]
|
|
type = "docker"
|
|
execd_image = "opensandbox/execd:test"
|
|
"""
|
|
)
|
|
config_path = tmp_path / "config.toml"
|
|
config_path.write_text(toml)
|
|
|
|
loaded = config_module.load_config(config_path)
|
|
assert loaded.log.level == "INFO"
|
|
assert loaded.log.file_path is None
|
|
assert loaded.log.file_max_bytes == 100 * 1024 * 1024
|
|
assert loaded.log.file_backup_count == 5
|
|
|
|
|
|
def test_load_config_log_file_path_only(tmp_path, monkeypatch):
|
|
"""LogConfig should accept only file_path with other defaults."""
|
|
_reset_config(monkeypatch)
|
|
toml = textwrap.dedent(
|
|
"""
|
|
[server]
|
|
host = "127.0.0.1"
|
|
port = 9000
|
|
|
|
[log]
|
|
file_path = "/var/log/test.log"
|
|
|
|
[runtime]
|
|
type = "docker"
|
|
execd_image = "opensandbox/execd:test"
|
|
"""
|
|
)
|
|
config_path = tmp_path / "config.toml"
|
|
config_path.write_text(toml)
|
|
|
|
loaded = config_module.load_config(config_path)
|
|
assert loaded.log.level == "INFO" # default
|
|
assert loaded.log.file_path == "/var/log/test.log"
|
|
assert loaded.log.access_file_path is None # default
|
|
assert loaded.log.file_max_bytes == 100 * 1024 * 1024 # default
|
|
assert loaded.log.file_backup_count == 5 # default
|
|
|
|
|
|
def test_load_config_log_access_file_path(tmp_path, monkeypatch):
|
|
"""LogConfig should accept access_file_path for separate access log file."""
|
|
_reset_config(monkeypatch)
|
|
toml = textwrap.dedent(
|
|
"""
|
|
[server]
|
|
host = "127.0.0.1"
|
|
port = 9000
|
|
|
|
[log]
|
|
file_path = "/var/log/opensandbox/server.log"
|
|
access_file_path = "/var/log/opensandbox/access.log"
|
|
|
|
[runtime]
|
|
type = "docker"
|
|
execd_image = "opensandbox/execd:test"
|
|
"""
|
|
)
|
|
config_path = tmp_path / "config.toml"
|
|
config_path.write_text(toml)
|
|
|
|
loaded = config_module.load_config(config_path)
|
|
assert loaded.log.file_path == "/var/log/opensandbox/server.log"
|
|
assert loaded.log.access_file_path == "/var/log/opensandbox/access.log"
|
|
|
|
|
|
def test_load_config_log_file_enabled(tmp_path, monkeypatch):
|
|
"""LogConfig file_enabled should enable file logging with default paths."""
|
|
_reset_config(monkeypatch)
|
|
toml = textwrap.dedent(
|
|
"""
|
|
[server]
|
|
host = "127.0.0.1"
|
|
port = 9000
|
|
|
|
[log]
|
|
file_enabled = true
|
|
|
|
[runtime]
|
|
type = "docker"
|
|
execd_image = "opensandbox/execd:test"
|
|
"""
|
|
)
|
|
config_path = tmp_path / "config.toml"
|
|
config_path.write_text(toml)
|
|
|
|
loaded = config_module.load_config(config_path)
|
|
assert loaded.log.file_enabled is True
|
|
assert loaded.log.file_path is None # not set, uses default
|
|
assert loaded.log.access_file_path is None
|
|
# resolved_* methods should return default paths
|
|
assert loaded.log.resolved_file_path() == LogConfig.DEFAULT_FILE_PATH
|
|
assert loaded.log.resolved_access_file_path() == LogConfig.DEFAULT_ACCESS_FILE_PATH
|
|
|
|
|
|
def test_load_config_log_file_enabled_with_custom_paths(tmp_path, monkeypatch):
|
|
"""LogConfig file_enabled with custom paths should use those paths."""
|
|
_reset_config(monkeypatch)
|
|
toml = textwrap.dedent(
|
|
"""
|
|
[server]
|
|
host = "127.0.0.1"
|
|
port = 9000
|
|
|
|
[log]
|
|
file_enabled = true
|
|
file_path = "/custom/server.log"
|
|
access_file_path = "/custom/access.log"
|
|
|
|
[runtime]
|
|
type = "docker"
|
|
execd_image = "opensandbox/execd:test"
|
|
"""
|
|
)
|
|
config_path = tmp_path / "config.toml"
|
|
config_path.write_text(toml)
|
|
|
|
loaded = config_module.load_config(config_path)
|
|
assert loaded.log.file_enabled is True
|
|
assert loaded.log.resolved_file_path() == "/custom/server.log"
|
|
assert loaded.log.resolved_access_file_path() == "/custom/access.log"
|
|
|
|
|
|
# ============================================================
|
|
# SecureAccessKey / SecureAccessConfig
|
|
# ============================================================
|
|
|
|
|
|
class TestSecureAccessKey:
|
|
def test_valid_key(self) -> None:
|
|
import base64
|
|
|
|
raw = b"my-secret-key-32-bytes!"
|
|
key = SecureAccessKey(key_id="a", key=base64.b64encode(raw).decode())
|
|
assert key.get_secret_bytes() == raw
|
|
|
|
def test_key_id_must_be_single_char(self) -> None:
|
|
with pytest.raises(ValidationError):
|
|
SecureAccessKey(key_id="ab", key="AAAA")
|
|
|
|
def test_key_id_must_be_alphanumeric_lowercase(self) -> None:
|
|
with pytest.raises(ValidationError):
|
|
SecureAccessKey(key_id="A", key="AAAA")
|
|
with pytest.raises(ValidationError):
|
|
SecureAccessKey(key_id="-", key="AAAA")
|
|
with pytest.raises(ValidationError):
|
|
SecureAccessKey(key_id="", key="AAAA")
|
|
|
|
def test_key_id_edge_cases(self) -> None:
|
|
import base64
|
|
|
|
b64 = base64.b64encode(b"x").decode()
|
|
for valid in "0abcxyz":
|
|
key = SecureAccessKey(key_id=valid, key=b64)
|
|
assert key.key_id == valid
|
|
|
|
def test_key_must_be_valid_base64(self) -> None:
|
|
with pytest.raises(ValidationError, match="not valid base64"):
|
|
SecureAccessKey(key_id="a", key="!!!invalid-base64!!!")
|
|
|
|
def test_key_must_decode_to_at_least_one_byte(self) -> None:
|
|
with pytest.raises(ValidationError):
|
|
SecureAccessKey(key_id="a", key="")
|
|
|
|
def test_key_accepts_padded_and_unpadded_base64(self) -> None:
|
|
import base64
|
|
|
|
raw = b"hello"
|
|
padded = base64.b64encode(raw).decode() # "aGVsbG8="
|
|
unpadded = padded.rstrip("=")
|
|
key_a = SecureAccessKey(key_id="a", key=padded)
|
|
key_b = SecureAccessKey(key_id="b", key=unpadded)
|
|
assert key_a.get_secret_bytes() == raw
|
|
assert key_b.get_secret_bytes() == raw
|
|
|
|
|
|
class TestSecureAccessConfig:
|
|
def test_valid_config(self) -> None:
|
|
import base64
|
|
|
|
keys = [
|
|
SecureAccessKey(key_id="a", key=base64.b64encode(b"key-a").decode()),
|
|
SecureAccessKey(key_id="b", key=base64.b64encode(b"key-b").decode()),
|
|
]
|
|
cfg = SecureAccessConfig(active_key="a", keys=keys)
|
|
assert cfg.active_key == "a"
|
|
assert len(cfg.keys) == 2
|
|
|
|
def test_get_active_secret_bytes(self) -> None:
|
|
import base64
|
|
|
|
raw_a, raw_b = b"key-a-secret", b"key-b-secret"
|
|
keys = [
|
|
SecureAccessKey(key_id="a", key=base64.b64encode(raw_a).decode()),
|
|
SecureAccessKey(key_id="b", key=base64.b64encode(raw_b).decode()),
|
|
]
|
|
cfg = SecureAccessConfig(active_key="b", keys=keys)
|
|
assert cfg.get_active_secret_bytes() == raw_b
|
|
|
|
def test_active_key_must_exist_in_keys(self) -> None:
|
|
import base64
|
|
|
|
keys = [SecureAccessKey(key_id="a", key=base64.b64encode(b"key").decode())]
|
|
with pytest.raises(ValidationError, match="not found in secure_access.keys"):
|
|
SecureAccessConfig(active_key="z", keys=keys)
|
|
|
|
def test_active_key_must_be_single_char(self) -> None:
|
|
import base64
|
|
|
|
keys = [SecureAccessKey(key_id="a", key=base64.b64encode(b"key").decode())]
|
|
with pytest.raises(ValidationError):
|
|
SecureAccessConfig(active_key="ab", keys=keys)
|
|
|
|
def test_must_have_at_least_one_key(self) -> None:
|
|
with pytest.raises(ValidationError):
|
|
SecureAccessConfig(active_key="a", keys=[])
|
|
|
|
def test_rejects_duplicate_key_ids(self) -> None:
|
|
import base64
|
|
|
|
keys = [
|
|
SecureAccessKey(key_id="a", key=base64.b64encode(b"key-a").decode()),
|
|
SecureAccessKey(key_id="a", key=base64.b64encode(b"key-a-dup").decode()),
|
|
]
|
|
with pytest.raises(ValidationError, match="duplicate secure_access key_id"):
|
|
SecureAccessConfig(active_key="a", keys=keys)
|
|
|
|
|
|
class TestSecureAccessInIngressConfig:
|
|
def test_secure_access_requires_gateway_mode(self) -> None:
|
|
import base64
|
|
|
|
keys = [SecureAccessKey(key_id="a", key=base64.b64encode(b"secret").decode())]
|
|
secure = SecureAccessConfig(active_key="a", keys=keys)
|
|
with pytest.raises(ValueError, match="secure_access block requires ingress.mode = 'gateway'"):
|
|
IngressConfig(mode="direct", secure_access=secure)
|
|
|
|
def test_gateway_with_secure_access_is_valid(self) -> None:
|
|
import base64
|
|
|
|
keys = [SecureAccessKey(key_id="a", key=base64.b64encode(b"secret").decode())]
|
|
ingress = IngressConfig(
|
|
mode="gateway",
|
|
gateway=GatewayConfig(
|
|
address="*.sandbox.example.com",
|
|
route=GatewayRouteModeConfig(mode="wildcard"),
|
|
),
|
|
secure_access=SecureAccessConfig(active_key="a", keys=keys),
|
|
)
|
|
assert ingress.secure_access is not None
|
|
assert ingress.secure_access.active_key == "a"
|
|
assert ingress.secure_access.get_active_secret_bytes() == b"secret"
|
|
|
|
|
|
class TestSecureAccessTomlLoading:
|
|
def test_load_secure_access_from_toml(self, tmp_path, monkeypatch) -> None:
|
|
import base64
|
|
|
|
raw_key = b"my-base64-secret"
|
|
b64 = base64.b64encode(raw_key).decode()
|
|
toml = textwrap.dedent(
|
|
f"""
|
|
[server]
|
|
host = "127.0.0.1"
|
|
port = 9000
|
|
|
|
[runtime]
|
|
type = "kubernetes"
|
|
execd_image = "opensandbox/execd:test"
|
|
|
|
[ingress]
|
|
mode = "gateway"
|
|
|
|
[ingress.gateway]
|
|
address = "*.sandbox.example.com"
|
|
|
|
[ingress.gateway.route]
|
|
mode = "wildcard"
|
|
|
|
[ingress.secure_access]
|
|
active_key = "a"
|
|
|
|
[[ingress.secure_access.keys]]
|
|
key_id = "a"
|
|
key = "{b64}"
|
|
"""
|
|
)
|
|
config_path = tmp_path / "config.toml"
|
|
config_path.write_text(toml)
|
|
|
|
loaded = config_module.load_config(config_path)
|
|
assert loaded.ingress is not None
|
|
assert loaded.ingress.secure_access is not None
|
|
assert loaded.ingress.secure_access.active_key == "a"
|
|
assert loaded.ingress.secure_access.keys[0].key_id == "a"
|
|
assert loaded.ingress.secure_access.get_active_secret_bytes() == raw_key
|
|
|
|
def test_load_secure_access_with_multiple_keys(self, tmp_path, monkeypatch) -> None:
|
|
import base64
|
|
|
|
b64_a = base64.b64encode(b"key-a").decode()
|
|
b64_b = base64.b64encode(b"key-b").decode()
|
|
toml = textwrap.dedent(
|
|
f"""
|
|
[server]
|
|
host = "127.0.0.1"
|
|
port = 9000
|
|
|
|
[runtime]
|
|
type = "kubernetes"
|
|
execd_image = "opensandbox/execd:test"
|
|
|
|
[ingress]
|
|
mode = "gateway"
|
|
|
|
[ingress.gateway]
|
|
address = "*.example.com"
|
|
|
|
[ingress.gateway.route]
|
|
mode = "wildcard"
|
|
|
|
[ingress.secure_access]
|
|
active_key = "b"
|
|
|
|
[[ingress.secure_access.keys]]
|
|
key_id = "a"
|
|
key = "{b64_a}"
|
|
|
|
[[ingress.secure_access.keys]]
|
|
key_id = "b"
|
|
key = "{b64_b}"
|
|
"""
|
|
)
|
|
config_path = tmp_path / "config.toml"
|
|
config_path.write_text(toml)
|
|
|
|
loaded = config_module.load_config(config_path)
|
|
assert loaded.ingress.secure_access.active_key == "b"
|
|
assert loaded.ingress.secure_access.get_active_secret_bytes() == b"key-b"
|
|
|
|
def test_secure_access_direct_mode_rejected(self, tmp_path, monkeypatch) -> None:
|
|
import base64
|
|
|
|
b64 = base64.b64encode(b"key").decode()
|
|
toml = textwrap.dedent(
|
|
f"""
|
|
[server]
|
|
host = "127.0.0.1"
|
|
port = 9000
|
|
|
|
[runtime]
|
|
type = "kubernetes"
|
|
execd_image = "opensandbox/execd:test"
|
|
|
|
[ingress.secure_access]
|
|
active_key = "a"
|
|
|
|
[[ingress.secure_access.keys]]
|
|
key_id = "a"
|
|
key = "{b64}"
|
|
"""
|
|
)
|
|
config_path = tmp_path / "config.toml"
|
|
config_path.write_text(toml)
|
|
|
|
with pytest.raises(ValidationError, match="secure_access block requires ingress.mode"):
|
|
config_module.load_config(config_path)
|
|
|
|
def test_secure_access_active_key_mismatch(self, tmp_path, monkeypatch) -> None:
|
|
import base64
|
|
|
|
b64 = base64.b64encode(b"key").decode()
|
|
toml = textwrap.dedent(
|
|
f"""
|
|
[server]
|
|
host = "127.0.0.1"
|
|
port = 9000
|
|
|
|
[runtime]
|
|
type = "kubernetes"
|
|
execd_image = "opensandbox/execd:test"
|
|
|
|
[ingress]
|
|
mode = "gateway"
|
|
|
|
[ingress.gateway]
|
|
address = "*.example.com"
|
|
|
|
[ingress.gateway.route]
|
|
mode = "wildcard"
|
|
|
|
[ingress.secure_access]
|
|
active_key = "z"
|
|
|
|
[[ingress.secure_access.keys]]
|
|
key_id = "a"
|
|
key = "{b64}"
|
|
"""
|
|
)
|
|
config_path = tmp_path / "config.toml"
|
|
config_path.write_text(toml)
|
|
|
|
with pytest.raises(ValidationError, match="not found in secure_access.keys"):
|
|
config_module.load_config(config_path)
|
|
|