Skip to content

Commit

Permalink
wip locking
Browse files Browse the repository at this point in the history
  • Loading branch information
radiac committed Nov 15, 2023
1 parent 9beaa50 commit 11b997e
Show file tree
Hide file tree
Showing 32 changed files with 1,501 additions and 670 deletions.
60 changes: 39 additions & 21 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -62,13 +62,39 @@ Put together a manifest in YAML as ``d0s-manifest.yml``:
type: RepoApp
extends: "git+ssh://git@github.com:radiac/example.com.git@main"
env:
DOMAIN: example.radiac.net
DOMAIN: docker0s.example.com
host:
name: example.radiac.net
name: docker0s.example.com
See `writing manifests`_ for a full reference

.. _writing manifests: https://docker0s.readthedocs.io/en/latest/writing/index.html


Then deploy your code and bring up the containers::

d0s deploy
d0s up

or in Python as ``d0s-manifest.py``, using subclassing to perform actions before and
after operations and add custom functionality:
You can then use docker0s to manage your deployment::

# Restart a container
d0s restart website.django

# Run a command inside a container
d0s exec website.django /bin/bash

See `commands`_ for a full command reference

.. _commands: https://docker0s.readthedocs.io/en/latest/usage.html


Python power
============

You can also write your manifests in Python as ``d0s-manifest.py``, using subclassing to
perform actions before and after operations, and to extend docker0s with custom
commands:

.. code-block:: python
Expand All @@ -78,7 +104,7 @@ after operations and add custom functionality:
# Clone a repo to the host and look for docker-compose.yml in there
extends = "git+ssh://git@github.com:radiac/example.com.git@main"
env = {
"DOMAIN": "example.radiac.net"
"DOMAIN": "docker0s.example.com"
}
# Subclass operation methods to add your own logic
Expand All @@ -87,22 +113,14 @@ after operations and add custom functionality:
super().deploy()
# Perform action after deployment, eg push additional resources
class Vagrant(Host):
name = "vagrant"
See `writing manifests`_ for a full reference

.. _writing manifests: https://docker0s.readthedocs.io/en/latest/writing/index.html
@App.command
def say_hello(self, name):
print(f"Hello {name}, this runs locally")
self.host.exec("echo And {name}, this is on the host", args={'name': name})
class MyServer(Host):
name = "myserver.example.com"
Then run a command, eg::
The command is then available as::

d0s deploy
d0s up
d0s restart website.django
d0s exec website.django /bin/bash
d0s cmd website app_command arguments

See `commands`_ for a full command reference

.. _commands: https://docker0s.readthedocs.io/en/latest/usage.html
d0s website:hello
2 changes: 1 addition & 1 deletion docker0s/app/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ def deploy(self):
super().deploy()
self.push_assets_to_host()

def push_assets_to_host(self):
def push_assets_to_host(self) -> None:
cls_assets = self.collect_attr("assets")
files: str | list[str]
for mro_cls, files in cls_assets:
Expand Down
36 changes: 24 additions & 12 deletions docker0s/app/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,22 @@

import inspect
from pathlib import Path, PosixPath
from typing import Any, Callable
from typing import TYPE_CHECKING, Any, Callable

from jinja2 import Environment, FileSystemLoader, select_autoescape

from ..config import REMOTE_ASSETS, REMOTE_COMPOSE, REMOTE_ENV
from ..env import dump_env, read_env
from ..exceptions import DefinitionError
from ..host import Host
from ..lock import AppLock
from ..manifest_object import ManifestObject
from ..path import ExtendsPath
from ..reporter import reporter
from ..settings import DIR_ASSETS, FILENAME_COMPOSE, FILENAME_ENV
from .names import normalise_name, pascal_to_snake

if TYPE_CHECKING:
from ..manifest import Manifest

# Abstract app registry for type lookups
abstract_app_registry: dict[str, type[BaseApp]] = {}
Expand Down Expand Up @@ -73,6 +76,8 @@ def __contains__(self, name: str) -> bool:


class BaseApp(ManifestObject, abstract=True):
#: Manifest this is defined in
manifest: Any # get_type_hints complains if this is Manifest
_file: Path # Path to this manifest file
_dir: Path # Path to this manifest file

Expand All @@ -86,8 +91,6 @@ class BaseApp(ManifestObject, abstract=True):
#:
#: This referenced manifest will will act as the base manifest. That in turn can
#: reference an additional base manifest.
#:
#: Default: ``d0s-manifest.py``, then ``d0s-manifest.yml``
extends: str | None = None
_extends_path: ExtendsPath | None = None # Resolved path

Expand All @@ -97,14 +100,16 @@ class BaseApp(ManifestObject, abstract=True):
#:
#: For access see ``.get_compose_path``
#:
#: Default: ``docker-compose.jinja2``, then ``docker-compose.yml``
#: Default: ``docker-compose.jinja2``, then ``docker-compose.yml``, ``compose.yml``
compose: str | None = None

COMPOSE_DEFAULTS = [
"docker-compose.j2",
"docker-compose.jinja2",
"docker-compose.yml",
"docker-compose.yaml",
"compose.yml",
"compose.yaml",
]

#: Context for docker-compose Jinja2 template rendering
Expand Down Expand Up @@ -189,12 +194,15 @@ def get_docker_name(cls) -> str:
return pascal_to_snake(cls.get_name())

@classmethod
def apply_base_manifest(cls, history: list[Path] | None = None):
def apply_base_manifest(cls, *, lock: AppLock, history: list[Path] | None = None):
"""
If a base manifest can be found by _get_base_manifest, load it and look for a
BaseApp subclass with the same name as this. If found, add it to the base
classes for this class.
"""

# TODO: Add lockfile check here

# Avoid import loop
from ..manifest import Manifest

Expand All @@ -206,11 +214,13 @@ def apply_base_manifest(cls, history: list[Path] | None = None):
) or cls.get_name()
with reporter.task(f"Getting base manifest for {base_name} from {cls.extends}"):
if not cls._extends_path:
cls._extends_path = ExtendsPath(cls.extends, cls._dir)
cls._extends_path = ExtendsPath(cls.extends, cls._dir, lock=lock)

path = cls._extends_path.get_manifest()

base_manifest = Manifest.load(path, history, label=base_name)
base_manifest = Manifest.load(
path, history, label=base_name, origin=cls.extends
)
if base_manifest.host is not None:
raise DefinitionError("A base manifest cannot define a host")

Expand Down Expand Up @@ -305,6 +315,8 @@ def collect_attr(cls, attr: str) -> list[tuple[type[BaseApp], Any]]:
# it would be a big deal early on, but it seems to be an edge case in the
# real world - very few projects have needed custom deployment steps, and I
# suspect those could all be handled by actual importing and subclassing.
#
# Look at this at the same time as the inheritance changes in Workers
results: list[tuple[type[BaseApp], Any]] = []
for mro_cls in cls.mro():
if not issubclass(mro_cls, BaseApp) or mro_cls.abstract:
Expand Down Expand Up @@ -381,14 +393,14 @@ def remote_compose(self) -> PosixPath:
"""
A PosixPath to the remote compose file
"""
return self.remote_path / FILENAME_COMPOSE
return self.remote_path / REMOTE_COMPOSE

@property
def remote_env(self) -> PosixPath:
"""
A PosixPath for the remote env file
"""
return self.remote_path / FILENAME_ENV
return self.remote_path / REMOTE_ENV

@property
def remote_assets(self) -> PosixPath:
Expand All @@ -398,7 +410,7 @@ def remote_assets(self) -> PosixPath:
Assets are resources pushed to the server as part of the docker0s deployment -
config files, scripts, media etc
"""
return self.remote_path / DIR_ASSETS
return self.remote_path / REMOTE_ASSETS

@property
def remote_store(self) -> PosixPath:
Expand Down Expand Up @@ -484,7 +496,7 @@ def deploy(self):
self.write_env_to_host()
self.host.ensure_parent_path(self.remote_store)

def push_compose_to_host(self):
def push_compose_to_host(self) -> None:
compose_content: str = self.get_compose_content()
compose_remote: PosixPath = self.remote_compose
self.host.write(compose_remote, compose_content)
Expand Down
131 changes: 131 additions & 0 deletions docker0s/cache.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import hashlib
import json
import time
from dataclasses import asdict, dataclass
from pathlib import Path
from queue import Queue
from typing import TYPE_CHECKING, Any, Self

from .config import settings
from .reporter import ReportingThread, reporter


def now() -> int:
return int(time.time())


@dataclass
class CacheRepo:
url: str
timestamp: int

@property
def dir_name(self):
return hashlib.md5(self.url.encode()).hexdigest()

@property
def path(self):
return settings.CACHE_PATH / self.dir_name

@property
def age(self):
return now() - self.timestamp

@property
def is_cached(self):
if not self.timestamp:
is_cached = False
elif (
settings.CACHE_ENABLED
and settings.CACHE_AGE
and self.age < settings.CACHE_AGE
):
is_cached = True
else:
is_cached = False

reporter.debug(
f"Cache check\n"
f" Cache: {'enabled' if settings.CACHE_ENABLED else 'disabled'}\n"
f" URL: {self.url}\n"
f" Cache path: {self.path} {'exists' if self.path.is_dir() else 'not found'}\n"
f" Cache age: {self.age}\n"
f" Cache hit? {is_cached}"
)
return is_cached


class CacheState:
#: Path to the state file
path: Path

#: CacheRepo data
_repos: dict[str, CacheRepo]

_thread: ReportingThread
_update_queue: Queue

def __init__(self, path: Path, repos):
self.path = path
self._repos = repos
self._update_queue = Queue()
self._thread = ReportingThread(
target=self._handle_update, daemon=True, terminating=True
)
self._thread.start()

@property
def repos(self) -> dict[str, CacheRepo]:
return self._repos

@classmethod
def from_file(cls, path: Path):
reporter.debug(f"Checking for cache at {path}")
repos = {}
if path.is_file():
with path.open("r") as file:
data = json.load(file)
repos = {url: CacheRepo(**state) for url, state in data.items()}
else:
reporter.debug(f"No cache found at {path}")
state = cls(path, repos)
return state

def save(self):
data = {url: asdict(state) for url, state in self.repos.items()}
with self.path.open("w") as file:
json.dump(data, file)
reporter.debug(f"Cache saved")

def get_or_create(
self,
url: str,
timestamp: int | None = None,
) -> CacheRepo:
"""
Retries a CacheRepo from the cache or creates a new one with an expired cache
Does not save
"""
cache = self.repos.get(url)
if cache is None:
cache = CacheRepo(
url=url,
timestamp=timestamp or 0,
)
return cache

def update(self, url: str, cache: CacheRepo | None = None):
self._update_queue.put((url, cache))

def _handle_update(self):
url: str
cache: CacheRepo | None
while True:
(url, cache) = self._update_queue.get()
if cache is None:
cache = self.get_or_create(url)
cache.timestamp = now()
self._repos[url] = cache
self.save()
self._update_queue.task_done()

0 comments on commit 11b997e

Please sign in to comment.