Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion .github/workflows/cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ name: publish to Pypi
on:
push:
tags:
- v*
- 'v[0-9]+.[0-9]+.[0-9]+'
- 'v[0-9]+.[0-9]+.[0-9]+-*'

jobs:
publish:
Expand All @@ -28,6 +29,15 @@ jobs:
virtualenvs-create: true
virtualenvs-in-project: true

- name: Set package version
run: |
TAG=${{ github.ref_name }}
VERSION=$(echo "$TAG" | sed 's/^v//')

echo "tag: $TAG, version: ${VERSION}"
sed -i -e 's|^version = "0.0.0-dev"|version = "'$VERSION'"|' pyproject.toml


- name: Set build hash
run: |
HASH=${{ github.sha }}
Expand Down
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
# Change Log

## [0.7.0]

### Features and Enhancements

- [cli] adjustable container executor per program
- [cli] support custom sandbox executor class for extendable parameters
- [config] add default custom executor for `apple_container`
- [config] set volume type as optional
- [config] fetch another and merge another property by using `fetch_prop(location)`
- [config] toggleable `fetch_prop(location)` by env `SNDK_FETCH_PROP` (value `yes`)
- [config] flatten if the container caller is array and the result of `fetch_prop(location)` helper is an array
- [packaging] actual version will be injected during build and publish pipeline

## [0.6.0]

### Features and Enhancements
Expand Down
189 changes: 114 additions & 75 deletions README.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "sandock"
version = "0.6.0"
version = "0.0.0-dev"
description = "CLI tool for sandbox execution using container approach"
authors = [{ name = "Imam Omar Mochtar", email = "[email protected]" }]
license = { text = "MIT" }
Expand Down
50 changes: 50 additions & 0 deletions pyproject.toml-e
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
[project]
name = "sandock"
version = "0.0.0-dev"
description = "CLI tool for sandbox execution using container approach"
authors = [{ name = "Imam Omar Mochtar", email = "[email protected]" }]
license = { text = "MIT" }
readme = "README.md"
requires-python = ">=3.9"

[project.scripts]
sandock = "sandock.cli:main"

[project.urls]
Repository = "https://github.com/iomarmochtar/sandock"

[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"

[project.optional-dependencies]
yml-config = [ "pyyaml (>=6.0.2,<7.0.0)" ]

[tool.poetry.group.dev]
optional = true

[tool.poetry.group.dev.dependencies]
black = "^25.1.0"
mypy = "^1.15.0"
types-pyyaml = "^6.0.12.20250402"
coverage = "^7.8.0"
flake8 = "^7.2.0"
flake8-pyproject = "^1.2.3"
pyyaml = "^6.0.2"

[tool.black]
target-version = ['py39']

[tool.mypy]
python_version = "3.9"
mypy_path = "sandock"
exclude = "tests"
strict = true
pretty = true
show_error_codes = true
show_column_numbers = true

[tool.flake8]
max-line-length = 88
extend-ignore = ["E501", "E252"]
exclude = ["build", "dist", "__pycache__"]
104 changes: 83 additions & 21 deletions sandock/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,26 @@
import sys
import logging
import subprocess
from typing import List, Tuple, Dict, Any, Optional
import importlib
from typing import List, Tuple, Dict, Any, Optional, Type
from argparse import ArgumentParser, Namespace, REMAINDER, ArgumentTypeError
from importlib.metadata import metadata
from .config import MainConfig, load_config_file, main_config_finder
from .shared import log, SANDBOX_DEBUG_ENV, CONFIG_PATH_ENV, run_shell
from .config.program import Program
from .shared import log, SANDBOX_DEBUG_ENV, CONFIG_PATH_ENV, KV, run_shell
from .sandbox import SandboxExec
from .volume import VolumeMgr
from .exceptions import SandboxBaseException, SandboxExecConfig, SandboxVolumeExec
from ._version import __version__, __build_hash__


def import_sandbox_dynamic_class(full_class_path: str) -> Type[SandboxExec]:
module_path, class_name = full_class_path.rsplit(".", 1)
module = importlib.import_module(module_path)

return getattr(module, class_name) # type: ignore[no-any-return]


def parse_arg_key_value(s: str) -> Tuple[str, str]:
if "=" not in s:
raise ArgumentTypeError(f"Invalid format: '{s}', expected KEY=VALUE")
Expand Down Expand Up @@ -304,6 +313,27 @@ def main(self) -> None:

class CmdRun(BaseCommand):
description = "run program"
program_cfg: Program

def __init__(self, *args, **kwargs) -> None: # type: ignore[no-untyped-def]
super().__init__(*args, **kwargs)
program_cfg = self.config.programs.get(self.args.program)
if not program_cfg:
raise SandboxExecConfig(f"`{self.args.program}` is not defined")

self.program_cfg = program_cfg

@staticmethod
def register_arguments(parser: ArgumentParser) -> None:
parser.add_argument(
"program",
)

parser.add_argument(
"program_args",
nargs=REMAINDER,
help="arguments that will be forwarded, excluded for the override args",
)

@property
def overrides_args(self) -> ArgumentParser:
Expand Down Expand Up @@ -352,6 +382,7 @@ def overrides_args(self) -> ArgumentParser:
self.override_arg(name="recreate-img"),
action="store_true",
default=False,
dest="hook_recreate_img",
help="recreate the used container image",
)

Expand All @@ -364,18 +395,6 @@ def overrides_args(self) -> ArgumentParser:

return oparser

@staticmethod
def register_arguments(parser: ArgumentParser) -> None:
parser.add_argument(
"program",
)

parser.add_argument(
"program_args",
nargs=REMAINDER,
help="arguments that will be forwarded, excluded for the override args",
)

def override_properties(self, args: List[str]) -> Dict[str, Any]:
"""
convert the override argument to Program's property
Expand All @@ -401,26 +420,69 @@ def override_properties(self, args: List[str]) -> Dict[str, Any]:
result[arg_name] = v
return result

@property
def remainder_args(self) -> Tuple[List[str], Dict[str, str]]:
def apply_overrides(self) -> Tuple[List[str], KV]:
"""
capture argument that will be forwarded to program and read for sandbox-exec
"""
program_args = []
hooks = {}
snbx_args = []
overrides = {}
for remainder in self.args.program_args:
if remainder.startswith(self.override_arg()):
snbx_args.append(remainder)
continue

program_args.append(remainder)

return (program_args, self.override_properties(args=snbx_args))
for k, v in self.override_properties(args=snbx_args).items():
if k.startswith("hook_"):
hooks[k] = v
continue

if hasattr(self.program_cfg, k):
log.debug(f"overriding value {v} in property {k}")
setattr(self.program_cfg, k, v)
overrides[k] = v
continue

# persist container cannot be renamed in preventing unexpected behaviour
# (eg: need gc the stopped one with different name)
if self.program_cfg.persist.enable and "name" in overrides:
raise SandboxExecConfig("name of persist program cannot be overrided")

return program_args, hooks

@property
def executor_cls(self) -> Type[SandboxExec]:
"""
return sandbox class that will be use
"""
program_exec = self.program_cfg.executor
if not program_exec:
return SandboxExec

executor = self.config.executors.get(program_exec)
if not executor:
raise SandboxExecConfig(f"unknown executor `{program_exec}` in {self.args.program}'s config")

if not executor.load_cls:
return SandboxExec

log.debug(f"using custom sandbox exec class ~> {executor.load_cls}")
return import_sandbox_dynamic_class(full_class_path=executor.load_cls)

def main(self) -> None:
program_args, overrides = self.remainder_args
log.debug(f"overrides args ~> {overrides}")
snbx = SandboxExec(name=self.args.program, cfg=self.config, overrides=overrides)
# apply the program configuration overrides, the rest of it will be the arguments to
# container executeable
program_args, hooks = self.apply_overrides()

snbx = self.executor_cls(name=self.args.program, program=self.program_cfg, cfg=self.config)
# run hooks if any
for hook, v in hooks.items():
getattr(snbx, hook)(v)

# forward any arguments to the container executeable
snbx.do(args=program_args)


Expand All @@ -434,7 +496,7 @@ def reraise_if_debug(e: Exception) -> None:

def main(args: Optional[List[str]] = None) -> None:
meta = metadata("sandock")
cmds = dict(list=CmdList, alias=CmdAlias, run=CmdRun, volume=CmdVolume)
cmds: Dict[str, Type[BaseCommand]] = dict(list=CmdList, alias=CmdAlias, run=CmdRun, volume=CmdVolume)
parser = ArgumentParser(
description="A wrapper in running command inside container sandboxed environment",
epilog=f"Author: {meta['author']} <{meta['author-email']}>",
Expand Down
68 changes: 64 additions & 4 deletions sandock/config/__init__.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,29 @@
import os
import re
from dataclasses import dataclass, field
from typing import Dict, Optional
from typing import Dict, Optional, Any
from pathlib import Path
from ..shared import dict_merge, log, KV, CONFIG_PATH_ENV
from ..shared import dict_merge, log, fetch_prop, flatten_list, KV, CONFIG_PATH_ENV, FETCH_PROP_ENABLE_ENV
from ..exceptions import SandboxExecConfig
from .image import ImageBuild
from .program import Program
from .config import Configuration
from .backup import Backup
from ._helpers import read_config, build_if_set, dot_config_finder

DEFAULT_CUSTOM_EXECUTORS = dict(
apple_container=dict(
bin_path="container",
load_cls="sandock.executors.AppleContainerExec"
)
)

fetch_prop_re = re.compile(r"^fetch_prop\(([^)]+)\)$")


@dataclass
class Volume(object):
driver: str = "local"
driver: Optional[str] = None
driver_opts: Dict[str, str] = field(default_factory=dict)
labels: Dict[str, str] = field(default_factory=dict)

Expand All @@ -33,6 +43,16 @@ class Execution(object):
alias_program_prefix: str = ""


@dataclass
class Executor(object):
bin_path: Optional[str] = None
load_cls: Optional[str] = None

def __post_init__(self) -> None:
if self.bin_path is None and self.load_cls is None:
raise ValueError("one of `bin_path` or `load_cls` must be set on executor")


@dataclass
class MainConfig(object):
execution: Execution = field(default_factory=Execution)
Expand All @@ -42,6 +62,7 @@ class MainConfig(object):
volumes: Dict[str, Volume] = field(default_factory=dict)
images: Dict[str, ImageBuild] = field(default_factory=dict)
networks: Dict[str, Network] = field(default_factory=dict)
executors: Dict[str, Executor] = field(default_factory=dict)

def __post_init__(self) -> None:
build_if_set(self, attr="config", cls=Configuration)
Expand All @@ -50,9 +71,11 @@ def __post_init__(self) -> None:
build_if_set(self, attr="execution", cls=Execution)
build_if_set(self, attr="backup", cls=Backup)

self.executors = dict_merge(self.executors, DEFAULT_CUSTOM_EXECUTORS)
# configuration that use kv format if the value set as dict
cls_mapper = dict(
programs=Program, volumes=Volume, networks=Network, images=ImageBuild
programs=Program, volumes=Volume, networks=Network, images=ImageBuild,
executors=Executor
)

for name, prop_cls in cls_mapper.items():
Expand Down Expand Up @@ -94,6 +117,43 @@ def __post_init__(self) -> None:
if not self.programs:
raise ValueError("no program configured")

if self.fetch_prop_enable:
self.resolve_fetch_prop(target=self)

@property
def fetch_prop_enable(self) -> bool:
return os.getenv(FETCH_PROP_ENABLE_ENV) == "yes"

def resolve_fetch_prop(self, target: Any=None) -> Any:
if isinstance(target, dict):
for k, v in target.items():
target[k] = self.resolve_fetch_prop(v)
return target

elif isinstance(target, list):
return [self.resolve_fetch_prop(i) for i in target]

elif isinstance(target, str):
match = fetch_prop_re.match(target.strip())
if match:
path = match.group(1).strip()
return fetch_prop(path=path, obj=self)
return target

elif hasattr(target, "__dict__"):
for attr, val in vars(target).items():
if not val:
continue

val = self.resolve_fetch_prop(val)
if isinstance(val, list):
val = flatten_list(items=val)

setattr(target, attr, val)
return target

return target


def load_config_file(path: str) -> MainConfig:
"""
Expand Down
1 change: 1 addition & 0 deletions sandock/config/program.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ class Program(object):
interactive: bool = True
allow_home_dir: bool = False
name: Optional[str] = None
executor: Optional[str] = None
network: Optional[str] = None
hostname: Optional[str] = None
build: Optional[ImageBuild] = None
Expand Down
Loading