diff --git a/.drone.yml b/.drone.yml index c7dddb5..d7071cf 100644 --- a/.drone.yml +++ b/.drone.yml @@ -26,7 +26,7 @@ steps: --- kind: pipeline type: docker -name: build-deploy +name: build-pypi depends_on: - lint - test @@ -38,6 +38,8 @@ steps: - python -m build --wheel - name: publish image: plugins/pypi + depends_on: + - build when: branch: - main @@ -46,4 +48,67 @@ steps: password: from_secret: password repository: https://git.jacknet.io/api/packages/jackhadrill/pypi - skip_build: true \ No newline at end of file + skip_build: true +--- +kind: pipeline +type: docker +name: build-docker-amd64 +depends_on: + - build-pypi +platform: + arch: amd64 +steps: + - name: build + image: plugins/docker + when: + branch: + - main + settings: + dockerfile: Dockerfile + repo: git.jacknet.io/jackhadrill/container-director + tags: amd64 + username: jackhadrill + password: + from_secret: password + registry: git.jacknet.io +--- +kind: pipeline +type: docker +name: build-docker-arm64 +depends_on: + - build-pypi +platform: + arch: arm64 +steps: + - name: build + image: plugins/docker + when: + branch: + - main + settings: + dockerfile: Dockerfile + repo: git.jacknet.io/jackhadrill/container-spawner + tags: arm64 + username: jackhadrill + password: + from_secret: password + registry: git.jacknet.io +--- +kind: pipeline +type: docker +name: manifest +depends_on: + - build-docker-amd64 + - build-docker-arm64 +steps: + - name: manifest + image: plugins/manifest + settings: + username: JackNet + password: + from_secret: password + target: git.jacknet.io/jackhadrill/container-spawner:latest + template: git.jacknet.io/jackhadrill/container-spawner:ARCH + platforms: + - linux/amd64 + - linux/arm64 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1c938c5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,11 @@ +FROM python:3-alpine + +ENV CONTAINER_IMAGE="git.jacknet.io/jackhadrill/code-server:latest" +ENV CONTAINER_PREFIX="vscode" +ENV CONTAINER_NETWORK="vscode_backend" +ENV CONTAINER_PERSIST="/home/coder" + +WORKDIR /src +RUN pip install --no-cache-dir --extra-index-url https://git.jacknet.io/api/packages/jackhadrill/pypi/simple containerspawner + +CMD ["containerspawner"] diff --git a/pyproject.toml b/pyproject.toml index c8676ed..34d046e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,8 +8,13 @@ description = "A tool for spawning containers." readme = "README.md" requires-python = ">=3.9" license = {text = "MIT License"} -dependencies = [] dynamic = ["version"] +dependencies = [ + "typer[all]>=0.6.1", + "Flask==2.2.2", + "waitress>=2.1.2", + "docker==6.0.0" +] [project.optional-dependencies] lint = [ @@ -19,3 +24,6 @@ lint = [ test = [ "pytest>=7.1.2" ] + +[project.scripts] +containerspawner = "containerspawner.__main__:main" diff --git a/setup.cfg b/setup.cfg index 2e97f84..1b617c0 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,3 +1,3 @@ [metadata] name = containerspawner -version = 0.0.0 +version = 0.1.0 diff --git a/src/container_spawner/__init__.py b/src/container_spawner/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/container_spawner/__main__.py b/src/container_spawner/__main__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/containerspawner/__init__.py b/src/containerspawner/__init__.py new file mode 100644 index 0000000..e7dd5de --- /dev/null +++ b/src/containerspawner/__init__.py @@ -0,0 +1,22 @@ +"""Catch all web server to spawn Docker containers.""" +from flask import Flask, make_response, request +from containerspawner.state import StateManager + +app = Flask(__name__) +state = StateManager() + + +@app.route("/", defaults={"path": ""}) +@app.route("/") +def default(path): + """Catch all endpoint to spawn Docker containers.""" + username = request.headers.get("X-Forwarded-Preferred-User") + if not username: + return make_response("No username provided by upstream.", 400) + + if not state.is_running(username): + state.spawn(username) + + response = make_response(f"Container spawned. Reloading `{path}`...", 201) + response.headers["Refresh"] = "5" + return response diff --git a/src/containerspawner/__main__.py b/src/containerspawner/__main__.py new file mode 100644 index 0000000..67d551f --- /dev/null +++ b/src/containerspawner/__main__.py @@ -0,0 +1,33 @@ +"""Launch Container Spawner.""" +import typer +from waitress import serve +from containerspawner import app + + +def cli( + host: str = typer.Option( + "0.0.0.0", + envvar=["CONTAINER_SPAWNER_HOST"], + help="Host for Container Spawner to listen on." + ), + port: str = typer.Option( + "8080", + envvar=["CONTAINER_SPAWNER_PORT"], + help="Port for Container Spawner to listen on." + ) +): + """Run Container Spawner application using Waitress.""" + try: + serve(app, host=host, port=port) + except OSError as exception: + print(str(exception)) + raise typer.Exit(code=1) + + +def main() -> None: + """Run CLI parser.""" + typer.run(cli) + + +if __name__ == "__main__": + main() diff --git a/src/containerspawner/state.py b/src/containerspawner/state.py new file mode 100644 index 0000000..ce67521 --- /dev/null +++ b/src/containerspawner/state.py @@ -0,0 +1,58 @@ +"""State management for Container Spawner.""" +import os +import time +from typing import List +import docker +from docker.models.containers import Container + +CONTAINER_IMAGE = os.environ.get("CONTAINER_IMAGE") or "codercom/code-server:latest" +CONTAINER_PREFIX = os.environ.get("CONTAINER_PREFIX") or "vscode" +CONTAINER_NETWORK = os.environ.get("CONTAINER_NETWORK") or "vscode_backend" +CONTAINER_PERSIST = os.environ.get("CONTAINER_PERSIST") or "/home/coder" + + +class StateManager: + """Store container states.""" + def __init__(self) -> None: + self._docker: docker.DockerClient = docker.from_env() + self._spawned_containers: List[str] = [] + + def is_running(self, username: str) -> bool: + """Determines if the user's container is running. + + :param username: The username to check. + :returns: True if contaienr exists. + """ + container_name = CONTAINER_PREFIX + "-" + username + return container_name in [ + container.name for container in self._docker.containers.list(all=True) + ] + + def spawn(self, username: str) -> None: + """Spawn a new Docker container for the specified user. + + :param username: The username of the user. + """ + if self.is_running(username): + return + + container_name = CONTAINER_PREFIX + "-" + username + self._spawned_containers.append(container_name) + print(f"Spawning {container_name}!") + container: Container = self._docker.containers.run( + image=CONTAINER_IMAGE, + name=container_name, + network=CONTAINER_NETWORK, + volumes={container_name: {"bind": CONTAINER_PERSIST}}, + detach=True, + auto_remove=True + ) + + # Wait for container to start. + time.sleep(3) + + # Disable auth. + container.exec_run("sed -i 's/auth: password/auth: none/' ~/.config/code-server/config.yaml") + + # Install extensions. + container.exec_run("code-server --install-extension ms-python.python")