Docker enhancements: containers #22
52
docs/src/recipes/docker.md
Normal file
52
docs/src/recipes/docker.md
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
# Docker
|
||||||
|
|
||||||
|
This is work-in-progress integration with Docker for Scone.
|
||||||
|
|
||||||
|
For this set of recipes to work, you will need the sous to have the `docker`
|
||||||
|
Python package installed; for this, you can use `pip install docker` with the
|
||||||
|
scone virtualenv activated. (TODO link to better documentation about installing
|
||||||
|
other Python deps in a scone venv.)
|
||||||
|
|
||||||
|
| Recipe | Needs | Provides |
|
||||||
|
| -----: | ----- | -------- |
|
||||||
|
| [`docker-container`](#docker-container) | | `docker_container` |
|
||||||
|
|
||||||
|
## `docker-container`
|
||||||
|
|
||||||
|
**Preconditions**: the image must be available or downloadable
|
||||||
|
|
||||||
|
**Provides**: `docker-container(?)` where `?` is the argument `name`.
|
||||||
|
|
||||||
|
| Argument | Accepted Values | Default | Description |
|
||||||
|
| -------: | --------------- | ------- | ----------- |
|
||||||
|
| image | any Docker image that exists or can be pulled | *required* | This name is used to specify what image to install. |
|
||||||
|
| name | any ID string | *required* | This name identifies the container and is passed to docker. |
|
||||||
|
| command | any command string | *optional* | If specified, this sets the command that will be run by the container. |
|
||||||
|
| ports | dictionary of "(container port)/tcp" or "(container port)/udp" to {host = "(host address)", port = "(host port)"} | empty | This mapping describes how ports are published from inside the container to the host. |
|
||||||
|
| volumes | dictionary of "(volume name)" or "/path/to/binding/on/host" to {bind = "/path/in/container", mode = "rw" or "ro"} | empty | This mapping describes what filesystem resources are mounted into the container. |
|
||||||
|
| environment | dictionary of "(key)" to value | empty | This mapping describes what environment variables are given to the container. |
|
||||||
|
| restart_policy | "always" or "on-failure" | "on-failure" | This specifies the container's restart policy. |
|
||||||
|
|
||||||
|
### Example
|
||||||
|
|
||||||
|
```scoml
|
||||||
|
[[docker-container]]
|
||||||
|
image = "org/image:1"
|
||||||
|
name = "mycontainer"
|
||||||
|
ports = {
|
||||||
|
"80/tcp" = {
|
||||||
|
"host" = "127.0.0.1",
|
||||||
|
"port" = 4080
|
||||||
|
}
|
||||||
|
}
|
||||||
|
volumes = {
|
||||||
|
"/var/lib/mycontainer" = {
|
||||||
|
bind = "/data",
|
||||||
|
mode = "rw"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
environment = {
|
||||||
|
MYCONTAINER_MODE = "production"
|
||||||
|
}
|
||||||
|
restart_policy = "always"
|
||||||
|
```
|
@ -1,10 +1,13 @@
|
|||||||
from scone.default.utensils.docker_utensils import (
|
from scone.default.utensils.docker_utensils import (
|
||||||
|
ContainerState,
|
||||||
DockerContainerRun,
|
DockerContainerRun,
|
||||||
|
DockerContainerState,
|
||||||
DockerImagePull,
|
DockerImagePull,
|
||||||
DockerNetworkCreate,
|
DockerNetworkCreate,
|
||||||
DockerVolumeCreate,
|
DockerVolumeCreate,
|
||||||
)
|
)
|
||||||
from scone.head.kitchen import Kitchen
|
from scone.head.head import Head
|
||||||
|
from scone.head.kitchen import Kitchen, Preparation
|
||||||
from scone.head.recipe import Recipe, RecipeContext
|
from scone.head.recipe import Recipe, RecipeContext
|
||||||
from scone.head.utils import check_type, check_type_opt
|
from scone.head.utils import check_type, check_type_opt
|
||||||
|
|
||||||
@ -16,12 +19,36 @@ class DockerContainer(Recipe):
|
|||||||
super().__init__(recipe_context, args, head)
|
super().__init__(recipe_context, args, head)
|
||||||
|
|
||||||
self.image = check_type(args.get("image"), str)
|
self.image = check_type(args.get("image"), str)
|
||||||
self.command = check_type(args.get("command"), str)
|
self.command = check_type_opt(args.get("command"), str)
|
||||||
|
self.name = check_type(args.get("name"), str)
|
||||||
|
self.volumes = check_type(args.get("volumes", dict()), dict)
|
||||||
|
self.ports = check_type(args.get("ports", dict()), dict)
|
||||||
|
self.environment = check_type(args.get("environment", dict()), dict)
|
||||||
|
self.restart_policy = check_type(args.get("restart_policy", "on-failure"), str)
|
||||||
|
|
||||||
|
def prepare(self, preparation: Preparation, head: Head) -> None:
|
||||||
|
super().prepare(preparation, head)
|
||||||
|
preparation.provides("docker-container", self.name)
|
||||||
|
|
||||||
async def cook(self, kitchen: Kitchen) -> None:
|
async def cook(self, kitchen: Kitchen) -> None:
|
||||||
kitchen.get_dependency_tracker()
|
kitchen.get_dependency_tracker()
|
||||||
|
|
||||||
|
current_state = ContainerState(
|
||||||
|
await kitchen.ut1(DockerContainerState(self.name))
|
||||||
|
)
|
||||||
|
|
||||||
|
if current_state == ContainerState.NOTFOUND:
|
||||||
await kitchen.ut1areq(
|
await kitchen.ut1areq(
|
||||||
DockerContainerRun(self.image, self.command), DockerContainerRun.Result
|
DockerContainerRun(
|
||||||
|
self.image,
|
||||||
|
self.command,
|
||||||
|
self.name,
|
||||||
|
{k: (v["host"], v["port"]) for k, v in self.ports.items()},
|
||||||
|
self.volumes,
|
||||||
|
{k: str(v) for k, v in self.environment.items()},
|
||||||
|
self.restart_policy,
|
||||||
|
),
|
||||||
|
DockerContainerRun.Result,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -15,7 +15,6 @@
|
|||||||
# You should have received a copy of the GNU General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with Scone. If not, see <https://www.gnu.org/licenses/>.
|
# along with Scone. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
from scone.default.utensils.basic_utensils import HashFile
|
|
||||||
from scone.head.kitchen import Kitchen
|
from scone.head.kitchen import Kitchen
|
||||||
|
|
||||||
|
|
||||||
|
@ -1,11 +1,14 @@
|
|||||||
from typing import Optional
|
from enum import Enum
|
||||||
|
from typing import Dict, Optional, Tuple
|
||||||
|
|
||||||
import attr
|
import attr
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import docker.errors
|
import docker.errors
|
||||||
|
from docker.models.containers import Container
|
||||||
except ImportError:
|
except ImportError:
|
||||||
docker = None
|
docker = None
|
||||||
|
Container = None
|
||||||
|
|
||||||
from scone.common.chanpro import Channel
|
from scone.common.chanpro import Channel
|
||||||
from scone.sous import Utensil
|
from scone.sous import Utensil
|
||||||
@ -15,35 +18,94 @@ _docker_client_instance = None
|
|||||||
|
|
||||||
|
|
||||||
def _docker_client():
|
def _docker_client():
|
||||||
|
if not docker:
|
||||||
|
# check docker is actually installed and give a message with the resolution
|
||||||
|
# when it isn't.
|
||||||
|
raise RuntimeError(
|
||||||
|
"You need to install docker from PyPI to use these utensils!"
|
||||||
|
)
|
||||||
|
|
||||||
global _docker_client_instance
|
global _docker_client_instance
|
||||||
if not _docker_client_instance:
|
if not _docker_client_instance:
|
||||||
_docker_client_instance = docker.from_env()
|
_docker_client_instance = docker.from_env()
|
||||||
return _docker_client_instance
|
return _docker_client_instance
|
||||||
|
|
||||||
|
|
||||||
|
class ContainerState(Enum):
|
||||||
|
NOTFOUND = 0
|
||||||
|
RUNNING = 1
|
||||||
|
EXITED = 2
|
||||||
|
RESTARTING = 3
|
||||||
|
|
||||||
|
|
||||||
|
@attr.s(auto_attribs=True)
|
||||||
|
class DockerContainerState(Utensil):
|
||||||
|
# Name of the container to check the existence of.
|
||||||
|
name: str
|
||||||
|
|
||||||
|
async def execute(self, channel: Channel, worktop: Worktop):
|
||||||
|
client = _docker_client()
|
||||||
|
# this is essentially `docker ps -a`
|
||||||
|
# TODO(perf) run this in a threaded executor since docker can be slow.
|
||||||
|
for container in client.containers.list(all=True):
|
||||||
|
container: Container
|
||||||
|
if self.name == container.name:
|
||||||
|
if container.status == "running":
|
||||||
|
await channel.send(ContainerState.RUNNING.value)
|
||||||
|
elif container.status == "exited":
|
||||||
|
await channel.send(ContainerState.EXITED.value)
|
||||||
|
elif container.status == "restarting":
|
||||||
|
await channel.send(ContainerState.RESTARTING.value)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unknown container status: {container.status}")
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
await channel.send(ContainerState.NOTFOUND.value)
|
||||||
|
|
||||||
|
|
||||||
@attr.s(auto_attribs=True)
|
@attr.s(auto_attribs=True)
|
||||||
class DockerContainerRun(Utensil):
|
class DockerContainerRun(Utensil):
|
||||||
|
# Name of the image to use to create the container.
|
||||||
image: str
|
image: str
|
||||||
command: str
|
# Command to create the container with. Optional.
|
||||||
|
command: Optional[str]
|
||||||
|
# Custom name to give the container.
|
||||||
|
name: str
|
||||||
|
# Ports to bind inside the container
|
||||||
|
# {'2222/tcp': ('127.0.0.1', 3333)} will expose port 2222 inside as 3333 outside.
|
||||||
|
ports: Dict[str, Tuple[str, int]]
|
||||||
|
# Volumes to mount inside the container.
|
||||||
|
# Key is either a host path or a container name.
|
||||||
|
# Value is a dictionary with the keys of:
|
||||||
|
# bind = path to bind inside the container
|
||||||
|
# mode = 'rw' or 'ro'
|
||||||
|
volumes: Dict[str, Dict[str, str]]
|
||||||
|
# Environment variables
|
||||||
|
environment: Dict[str, str]
|
||||||
|
# Restart policy
|
||||||
|
restart_policy: str
|
||||||
|
|
||||||
@attr.s(auto_attribs=True)
|
@attr.s(auto_attribs=True)
|
||||||
class Result:
|
class Result:
|
||||||
name: str
|
name: str
|
||||||
|
|
||||||
async def execute(self, channel: Channel, worktop: Worktop):
|
async def execute(self, channel: Channel, worktop: Worktop):
|
||||||
try:
|
restart_policy = {
|
||||||
container = _docker_client().containers.run(
|
"Name": self.restart_policy,
|
||||||
self.image, self.command, detach=True
|
}
|
||||||
)
|
if self.restart_policy == "on-failure":
|
||||||
|
restart_policy["MaximumRetryCount"] = 5
|
||||||
|
|
||||||
except docker.errors.ImageNotFound:
|
container = _docker_client().containers.run(
|
||||||
# specified image does not exist (or requires login)
|
self.image,
|
||||||
await channel.send(None)
|
self.command,
|
||||||
return
|
detach=True,
|
||||||
except docker.errors.APIError:
|
name=self.name,
|
||||||
# the docker server returned an error
|
ports=self.ports,
|
||||||
await channel.send(None)
|
volumes=self.volumes,
|
||||||
return
|
environment=self.environment,
|
||||||
|
restart_policy=restart_policy,
|
||||||
|
)
|
||||||
|
|
||||||
await channel.send(DockerContainerRun.Result(name=container.name))
|
await channel.send(DockerContainerRun.Result(name=container.name))
|
||||||
|
|
||||||
|
@ -133,7 +133,7 @@ QuotedString:
|
|||||||
;
|
;
|
||||||
|
|
||||||
UnquotedString:
|
UnquotedString:
|
||||||
value=/[^\s\n,"()0-9]([^\n,"()]*[^\s\n,"()])?/
|
value=/[^]\s\n,"(){}[0-9=]([^]\n,"(){}[=]*[^]\s\n,"(){}[=])?/
|
||||||
;
|
;
|
||||||
|
|
||||||
DottedIdString:
|
DottedIdString:
|
||||||
@ -158,12 +158,12 @@ BracketList[ws=' \t\n']:
|
|||||||
']'
|
']'
|
||||||
;
|
;
|
||||||
|
|
||||||
BraceDict[ws=' \t']:
|
BraceDict[ws=' \t\n']:
|
||||||
'{'
|
'{'
|
||||||
pairs*=DictPair[',']
|
pairs*=DictPair[',']
|
||||||
'}'
|
'}'
|
||||||
;
|
;
|
||||||
|
|
||||||
DictPair:
|
DictPair[ws=' \t\n']:
|
||||||
(key=KeyExpr) '=' (value=ValueExpr)
|
(key=KeyExpr) '=' (value=ValueExpr)
|
||||||
;
|
;
|
||||||
|
@ -120,7 +120,7 @@ class MenuRecipe:
|
|||||||
listen_edges: List[ListenEdgeDirective] = attr.ib(factory=list)
|
listen_edges: List[ListenEdgeDirective] = attr.ib(factory=list)
|
||||||
|
|
||||||
|
|
||||||
def convert_textx_value(txvalue) -> Any:
|
def convert_textx_value(txvalue) -> Union[list, str, int, bool, dict]:
|
||||||
if isinstance(txvalue, scoml_classes["NaturalList"]):
|
if isinstance(txvalue, scoml_classes["NaturalList"]):
|
||||||
return [convert_textx_value(element) for element in txvalue.elements]
|
return [convert_textx_value(element) for element in txvalue.elements]
|
||||||
elif (
|
elif (
|
||||||
@ -136,6 +136,7 @@ def convert_textx_value(txvalue) -> Any:
|
|||||||
result = dict()
|
result = dict()
|
||||||
for pair in txvalue.pairs:
|
for pair in txvalue.pairs:
|
||||||
result[convert_textx_value(pair.key)] = convert_textx_value(pair.value)
|
result[convert_textx_value(pair.key)] = convert_textx_value(pair.value)
|
||||||
|
return result
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Unknown SCOML value: {txvalue}")
|
raise ValueError(f"Unknown SCOML value: {txvalue}")
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user