docker-captain 0.0.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- docker_captain-0.0.1/PKG-INFO +185 -0
- docker_captain-0.0.1/README.md +172 -0
- docker_captain-0.0.1/pyproject.toml +40 -0
- docker_captain-0.0.1/src/docker_captain/__init__.py +0 -0
- docker_captain-0.0.1/src/docker_captain/config.py +100 -0
- docker_captain-0.0.1/src/docker_captain/docker.py +118 -0
- docker_captain-0.0.1/src/docker_captain/main.py +212 -0
- docker_captain-0.0.1/src/docker_captain/projects.py +95 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: docker-captain
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Add your description here
|
|
5
|
+
Requires-Dist: platformdirs>=4.5.0
|
|
6
|
+
Requires-Dist: pyyaml>=6.0.3
|
|
7
|
+
Requires-Dist: questionary>=2.1.1
|
|
8
|
+
Requires-Dist: rich>=14.2.0
|
|
9
|
+
Requires-Dist: sh>=2.2.2
|
|
10
|
+
Requires-Dist: typer>=0.19.2
|
|
11
|
+
Requires-Python: >=3.10
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# ⚓ docker-captain
|
|
15
|
+
|
|
16
|
+
**docker-captain** is a friendly command-line tool that helps you manage multiple Docker Compose projects under a single folder.
|
|
17
|
+
|
|
18
|
+
It's powered by [Typer](https://typer.tiangolo.com/), [Questionary](https://github.com/tmbo/questionary), and [sh](https://amoffat.github.io/sh/).
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## 🚀 Features
|
|
23
|
+
|
|
24
|
+
For a quick overview of the available commands, run `docker-captain --help`.
|
|
25
|
+
|
|
26
|
+
### 🔍 Project Auto-Detection
|
|
27
|
+
`docker-captain` automatically detects any subfolder containing a Docker Compose file — such as `compose.yaml`, `compose.yml`, `docker-compose.yaml`, or `docker-compose.yml`.
|
|
28
|
+
It scans the folder specified in the configuration file, or passed in the `DOCKER_CAPTAIN_PROJECTS` environment variable, which you can export here:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
export DOCKER_CAPTAIN_PROJECTS=/path/to/your/deployments # takes precedence over the config file
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Detection is purely based on the file names — if a folder contains one of those Compose files, it’s recognized as a valid “project”, taking its name from the folder.
|
|
35
|
+
|
|
36
|
+
### ⚙️ Project Management via `manage`
|
|
37
|
+
|
|
38
|
+
Use the `docker-captain manage` command to interactively select which projects should be considered *active*.
|
|
39
|
+
You’ll see a multi-select list of all detected projects — you can check or uncheck them using the keyboard, then confirm with Enter.
|
|
40
|
+
|
|
41
|
+
The selected projects become your *active fleet*, used by commands like `rally` and `abandon`.
|
|
42
|
+
|
|
43
|
+
### 🚢 Easy Interaction with Single Projects
|
|
44
|
+
|
|
45
|
+
Need to start or stop one project?
|
|
46
|
+
Use these straightforward commands:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
docker-captain start calibre --detach --remove-orphans
|
|
50
|
+
docker-captain stop calibre --remove-orphans
|
|
51
|
+
docker-captain restart calibre
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
They’re thin wrappers around standard `docker compose` commands (`up`, `down`, and `restart`), but automatically use the correct compose file and folder context.
|
|
55
|
+
|
|
56
|
+
Flags:
|
|
57
|
+
|
|
58
|
+
* `-d` / `--detach`: Run containers in background.
|
|
59
|
+
* `--remove-orphans`: Remove orphaned containers not defined in the compose file.
|
|
60
|
+
|
|
61
|
+
### 📋 Listing Projects with `list`
|
|
62
|
+
|
|
63
|
+
See all detected projects neatly formatted in a Rich table:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
docker-captain list
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
This shows:
|
|
70
|
+
|
|
71
|
+
* **Project** name
|
|
72
|
+
* Whether it’s **Active** (selected via `manage`)
|
|
73
|
+
* Whether it’s currently **Running** (`docker compose ls` is checked behind the scenes)
|
|
74
|
+
|
|
75
|
+
You can also view the compose file paths with `--verbose`:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
docker-captain list --verbose
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### ⚓ Rally and Abandon Your Fleet
|
|
82
|
+
|
|
83
|
+
Once you’ve marked projects as *active* using `manage`, you can control them all together:
|
|
84
|
+
|
|
85
|
+
* **Start all active projects:**
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
docker-captain rally --detach
|
|
89
|
+
```
|
|
90
|
+
* **Stop all active projects:**
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
docker-captain abandon
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
These commands behave like `start` and `stop`, but apply to every active project in one go — perfect for booting up or shutting down your entire environment.
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
## 📦 Installation
|
|
101
|
+
|
|
102
|
+
You can install dependencies using `uv`, `pipx`, or plain `pip`.
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
# Install with
|
|
106
|
+
uv tool install docker-captain
|
|
107
|
+
pipx install docker-captain
|
|
108
|
+
|
|
109
|
+
# or try it out with
|
|
110
|
+
uvx docker-captain
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
---
|
|
114
|
+
|
|
115
|
+
## 🗒️ Configuration
|
|
116
|
+
|
|
117
|
+
`captain-docker` support a simple YAML config file with the following structure:
|
|
118
|
+
|
|
119
|
+
```yaml
|
|
120
|
+
# ~/.config/docker-captain/config.yaml (on Linux)
|
|
121
|
+
projects_folder: /path/to/your/deployments # environment variable: DOCKER_CAPTAIN_PROJECTS_FOLDER
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
---
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
## 🧭 Folder Structure Example
|
|
128
|
+
|
|
129
|
+
Your deployments might look like this:
|
|
130
|
+
|
|
131
|
+
```
|
|
132
|
+
~/Deployments/
|
|
133
|
+
├── calibre/
|
|
134
|
+
│ └── compose.yaml
|
|
135
|
+
├── immich/
|
|
136
|
+
│ ├── compose.yaml
|
|
137
|
+
│ └── immich.env
|
|
138
|
+
├── paperless-ngx/
|
|
139
|
+
│ ├── compose.yaml
|
|
140
|
+
│ └── paperless-ngx.env
|
|
141
|
+
└── syncthing/
|
|
142
|
+
└── compose.yaml
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Each subfolder is automatically detected as a project if it has a Compose file.
|
|
146
|
+
|
|
147
|
+
---
|
|
148
|
+
|
|
149
|
+
## 🧠 Tech Stack
|
|
150
|
+
|
|
151
|
+
| Library | Purpose |
|
|
152
|
+
| -------------------------------------------------- | ---------------------------- |
|
|
153
|
+
| [Typer](https://typer.tiangolo.com/) | CLI framework |
|
|
154
|
+
| [Rich](https://rich.readthedocs.io/) | Beautiful terminal output |
|
|
155
|
+
| [Questionary](https://github.com/tmbo/questionary) | Interactive prompts |
|
|
156
|
+
| [sh](https://amoffat.github.io/sh/) | Simple subprocess management |
|
|
157
|
+
| [PyYAML](https://pyyaml.org/) | YAML parsing |
|
|
158
|
+
|
|
159
|
+
---
|
|
160
|
+
|
|
161
|
+
## 🐙 Example Workflow
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
# Detect and list all projects
|
|
165
|
+
docker-captain list
|
|
166
|
+
|
|
167
|
+
# Choose which projects are active
|
|
168
|
+
docker-captain manage
|
|
169
|
+
|
|
170
|
+
# Start all active projects
|
|
171
|
+
docker-captain rally -d
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
---
|
|
175
|
+
|
|
176
|
+
## 💡 Inspiration
|
|
177
|
+
|
|
178
|
+
I've been using `docker-compose.yaml` files to manage my home server for a while.
|
|
179
|
+
I found the internet is full of tools to observe docker deployments, but I couldn't find one to manage my Docker Compose files.
|
|
180
|
+
I wanted something simple, lightweight, and portable.
|
|
181
|
+
|
|
182
|
+
I stumbled across [jenssegers/captain](https://github.com/jenssegers/captain/), a Go project with a similar idea - a simple wrapper around `docker compose`, but only acting on one project at a time.
|
|
183
|
+
Given Python is my main language and the project hasn't seen any activity in 3 years, I decided to extend its scope and write `docker-captain`.
|
|
184
|
+
|
|
185
|
+
Hope this is useful to someone, happy sailing! ⛵
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# ⚓ docker-captain
|
|
2
|
+
|
|
3
|
+
**docker-captain** is a friendly command-line tool that helps you manage multiple Docker Compose projects under a single folder.
|
|
4
|
+
|
|
5
|
+
It's powered by [Typer](https://typer.tiangolo.com/), [Questionary](https://github.com/tmbo/questionary), and [sh](https://amoffat.github.io/sh/).
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## 🚀 Features
|
|
10
|
+
|
|
11
|
+
For a quick overview of the available commands, run `docker-captain --help`.
|
|
12
|
+
|
|
13
|
+
### 🔍 Project Auto-Detection
|
|
14
|
+
`docker-captain` automatically detects any subfolder containing a Docker Compose file — such as `compose.yaml`, `compose.yml`, `docker-compose.yaml`, or `docker-compose.yml`.
|
|
15
|
+
It scans the folder specified in the configuration file, or passed in the `DOCKER_CAPTAIN_PROJECTS` environment variable, which you can export here:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
export DOCKER_CAPTAIN_PROJECTS=/path/to/your/deployments # takes precedence over the config file
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Detection is purely based on the file names — if a folder contains one of those Compose files, it’s recognized as a valid “project”, taking its name from the folder.
|
|
22
|
+
|
|
23
|
+
### ⚙️ Project Management via `manage`
|
|
24
|
+
|
|
25
|
+
Use the `docker-captain manage` command to interactively select which projects should be considered *active*.
|
|
26
|
+
You’ll see a multi-select list of all detected projects — you can check or uncheck them using the keyboard, then confirm with Enter.
|
|
27
|
+
|
|
28
|
+
The selected projects become your *active fleet*, used by commands like `rally` and `abandon`.
|
|
29
|
+
|
|
30
|
+
### 🚢 Easy Interaction with Single Projects
|
|
31
|
+
|
|
32
|
+
Need to start or stop one project?
|
|
33
|
+
Use these straightforward commands:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
docker-captain start calibre --detach --remove-orphans
|
|
37
|
+
docker-captain stop calibre --remove-orphans
|
|
38
|
+
docker-captain restart calibre
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
They’re thin wrappers around standard `docker compose` commands (`up`, `down`, and `restart`), but automatically use the correct compose file and folder context.
|
|
42
|
+
|
|
43
|
+
Flags:
|
|
44
|
+
|
|
45
|
+
* `-d` / `--detach`: Run containers in background.
|
|
46
|
+
* `--remove-orphans`: Remove orphaned containers not defined in the compose file.
|
|
47
|
+
|
|
48
|
+
### 📋 Listing Projects with `list`
|
|
49
|
+
|
|
50
|
+
See all detected projects neatly formatted in a Rich table:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
docker-captain list
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
This shows:
|
|
57
|
+
|
|
58
|
+
* **Project** name
|
|
59
|
+
* Whether it’s **Active** (selected via `manage`)
|
|
60
|
+
* Whether it’s currently **Running** (`docker compose ls` is checked behind the scenes)
|
|
61
|
+
|
|
62
|
+
You can also view the compose file paths with `--verbose`:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
docker-captain list --verbose
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### ⚓ Rally and Abandon Your Fleet
|
|
69
|
+
|
|
70
|
+
Once you’ve marked projects as *active* using `manage`, you can control them all together:
|
|
71
|
+
|
|
72
|
+
* **Start all active projects:**
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
docker-captain rally --detach
|
|
76
|
+
```
|
|
77
|
+
* **Stop all active projects:**
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
docker-captain abandon
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
These commands behave like `start` and `stop`, but apply to every active project in one go — perfect for booting up or shutting down your entire environment.
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
## 📦 Installation
|
|
88
|
+
|
|
89
|
+
You can install dependencies using `uv`, `pipx`, or plain `pip`.
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
# Install with
|
|
93
|
+
uv tool install docker-captain
|
|
94
|
+
pipx install docker-captain
|
|
95
|
+
|
|
96
|
+
# or try it out with
|
|
97
|
+
uvx docker-captain
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
---
|
|
101
|
+
|
|
102
|
+
## 🗒️ Configuration
|
|
103
|
+
|
|
104
|
+
`captain-docker` support a simple YAML config file with the following structure:
|
|
105
|
+
|
|
106
|
+
```yaml
|
|
107
|
+
# ~/.config/docker-captain/config.yaml (on Linux)
|
|
108
|
+
projects_folder: /path/to/your/deployments # environment variable: DOCKER_CAPTAIN_PROJECTS_FOLDER
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
## 🧭 Folder Structure Example
|
|
115
|
+
|
|
116
|
+
Your deployments might look like this:
|
|
117
|
+
|
|
118
|
+
```
|
|
119
|
+
~/Deployments/
|
|
120
|
+
├── calibre/
|
|
121
|
+
│ └── compose.yaml
|
|
122
|
+
├── immich/
|
|
123
|
+
│ ├── compose.yaml
|
|
124
|
+
│ └── immich.env
|
|
125
|
+
├── paperless-ngx/
|
|
126
|
+
│ ├── compose.yaml
|
|
127
|
+
│ └── paperless-ngx.env
|
|
128
|
+
└── syncthing/
|
|
129
|
+
└── compose.yaml
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Each subfolder is automatically detected as a project if it has a Compose file.
|
|
133
|
+
|
|
134
|
+
---
|
|
135
|
+
|
|
136
|
+
## 🧠 Tech Stack
|
|
137
|
+
|
|
138
|
+
| Library | Purpose |
|
|
139
|
+
| -------------------------------------------------- | ---------------------------- |
|
|
140
|
+
| [Typer](https://typer.tiangolo.com/) | CLI framework |
|
|
141
|
+
| [Rich](https://rich.readthedocs.io/) | Beautiful terminal output |
|
|
142
|
+
| [Questionary](https://github.com/tmbo/questionary) | Interactive prompts |
|
|
143
|
+
| [sh](https://amoffat.github.io/sh/) | Simple subprocess management |
|
|
144
|
+
| [PyYAML](https://pyyaml.org/) | YAML parsing |
|
|
145
|
+
|
|
146
|
+
---
|
|
147
|
+
|
|
148
|
+
## 🐙 Example Workflow
|
|
149
|
+
|
|
150
|
+
```bash
|
|
151
|
+
# Detect and list all projects
|
|
152
|
+
docker-captain list
|
|
153
|
+
|
|
154
|
+
# Choose which projects are active
|
|
155
|
+
docker-captain manage
|
|
156
|
+
|
|
157
|
+
# Start all active projects
|
|
158
|
+
docker-captain rally -d
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
---
|
|
162
|
+
|
|
163
|
+
## 💡 Inspiration
|
|
164
|
+
|
|
165
|
+
I've been using `docker-compose.yaml` files to manage my home server for a while.
|
|
166
|
+
I found the internet is full of tools to observe docker deployments, but I couldn't find one to manage my Docker Compose files.
|
|
167
|
+
I wanted something simple, lightweight, and portable.
|
|
168
|
+
|
|
169
|
+
I stumbled across [jenssegers/captain](https://github.com/jenssegers/captain/), a Go project with a similar idea - a simple wrapper around `docker compose`, but only acting on one project at a time.
|
|
170
|
+
Given Python is my main language and the project hasn't seen any activity in 3 years, I decided to extend its scope and write `docker-captain`.
|
|
171
|
+
|
|
172
|
+
Hope this is useful to someone, happy sailing! ⛵
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "docker-captain"
|
|
3
|
+
version = "0.0.1"
|
|
4
|
+
description = "Add your description here"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"platformdirs>=4.5.0",
|
|
9
|
+
"pyyaml>=6.0.3",
|
|
10
|
+
"questionary>=2.1.1",
|
|
11
|
+
"rich>=14.2.0",
|
|
12
|
+
"sh>=2.2.2",
|
|
13
|
+
"typer>=0.19.2",
|
|
14
|
+
]
|
|
15
|
+
[dependency-groups]
|
|
16
|
+
dev = [
|
|
17
|
+
"pyright>=1.1.406",
|
|
18
|
+
"ruff>=0.14.0",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[project.scripts]
|
|
22
|
+
docker-captain = "docker_captain.main:main"
|
|
23
|
+
|
|
24
|
+
[build-system]
|
|
25
|
+
requires = ["uv_build>=0.9.2,<0.10.0"]
|
|
26
|
+
build-backend = "uv_build"
|
|
27
|
+
|
|
28
|
+
[[tool.uv.index]]
|
|
29
|
+
name = "testpypi"
|
|
30
|
+
url = "https://test.pypi.org/simple/"
|
|
31
|
+
publish-url = "https://test.pypi.org/legacy/"
|
|
32
|
+
explicit = true
|
|
33
|
+
|
|
34
|
+
[tool.uv.workspace]
|
|
35
|
+
members = [
|
|
36
|
+
"docker-captain",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
[tool.ruff]
|
|
40
|
+
line-length = 99
|
|
File without changes
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Module to help manage configuration and data files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import asdict, dataclass, field, fields, is_dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import ClassVar, Dict, List, Optional, Type, TypeVar
|
|
8
|
+
|
|
9
|
+
import yaml
|
|
10
|
+
from platformdirs import user_config_dir, user_data_dir
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
|
|
13
|
+
console = Console()
|
|
14
|
+
T = TypeVar("T", bound="CaptainFile")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class CaptainFile:
|
|
19
|
+
"""
|
|
20
|
+
Base class that serialises dataclasses to YAML.
|
|
21
|
+
Sub‑classes must be dataclasses and provide a ``DEFAULT_PATH``.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
DEFAULT_PATH: ClassVar[Path] # overridden by subclasses
|
|
25
|
+
|
|
26
|
+
@classmethod
|
|
27
|
+
def _ensure_dataclass(cls) -> None:
|
|
28
|
+
if not is_dataclass(cls):
|
|
29
|
+
raise TypeError(f"{cls.__name__} must be a dataclass")
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
def load(cls: Type[T], path: Path | None = None) -> T:
|
|
33
|
+
"""
|
|
34
|
+
Load an instance from *path* (or ``DEFAULT_PATH``). If the file
|
|
35
|
+
cannot be read or parsed, a warning is printed and a fresh instance
|
|
36
|
+
with default values is returned.
|
|
37
|
+
"""
|
|
38
|
+
cls._ensure_dataclass()
|
|
39
|
+
path = Path(path) if path is not None else cls.DEFAULT_PATH
|
|
40
|
+
|
|
41
|
+
if not path.exists():
|
|
42
|
+
return cls()
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
with path.open("r", encoding="utf-8") as f:
|
|
46
|
+
data = yaml.safe_load(f) or {}
|
|
47
|
+
except Exception as e:
|
|
48
|
+
console.print(f"[yellow]Warning: Failed to parse {path}: {e}[/yellow]")
|
|
49
|
+
return cls()
|
|
50
|
+
|
|
51
|
+
# Keep only fields defined on the dataclass
|
|
52
|
+
field_names = {f.name for f in fields(cls)}
|
|
53
|
+
filtered = {k: v for k, v in data.items() if k in field_names}
|
|
54
|
+
return cls(**filtered)
|
|
55
|
+
|
|
56
|
+
def save(self, path: Path | None = None) -> None:
|
|
57
|
+
"""
|
|
58
|
+
Write the instance to *path* (or ``DEFAULT_PATH``) as YAML.
|
|
59
|
+
The parent directory is created automatically. Errors are
|
|
60
|
+
reported with a console warning but not re‑raised.
|
|
61
|
+
"""
|
|
62
|
+
self.__class__._ensure_dataclass()
|
|
63
|
+
path = Path(path) if path is not None else self.__class__.DEFAULT_PATH
|
|
64
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
with path.open("w", encoding="utf-8") as f:
|
|
68
|
+
yaml.safe_dump(
|
|
69
|
+
asdict(self),
|
|
70
|
+
f,
|
|
71
|
+
default_flow_style=False,
|
|
72
|
+
allow_unicode=True,
|
|
73
|
+
)
|
|
74
|
+
except Exception as e:
|
|
75
|
+
console.print(f"[yellow]Warning: Failed to write {path}: {e}[/yellow]")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass
|
|
79
|
+
class CaptainConfig(CaptainFile):
|
|
80
|
+
"""Data model of the docker-captain configuration file."""
|
|
81
|
+
|
|
82
|
+
DEFAULT_PATH: ClassVar[Path] = (
|
|
83
|
+
Path(user_config_dir(appname="docker-captain", appauthor=False)) / "config.yaml"
|
|
84
|
+
)
|
|
85
|
+
ENVIROMENT: ClassVar[Dict] = {"projects_folder": "DOCKER_CAPTAIN_PROJECTS_FOLDER"}
|
|
86
|
+
|
|
87
|
+
projects_folder: Optional[Path] = field(
|
|
88
|
+
default=None, metadata={"env": "DOCKER_CAPTAIN_PROJECTS_FOLDER"}
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass
|
|
93
|
+
class CaptainData(CaptainFile):
|
|
94
|
+
"""Data model of the docker-captain data."""
|
|
95
|
+
|
|
96
|
+
DEFAULT_PATH: ClassVar[Path] = (
|
|
97
|
+
Path(user_data_dir(appname="docker-captain", appauthor=False)) / "data.yaml"
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
active_projects: List[str] = field(default_factory=list)
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Module to help wrapping 'docker compose' commands."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import List, Literal
|
|
6
|
+
|
|
7
|
+
import sh
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
|
|
10
|
+
console = Console()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class DockerCompose:
|
|
14
|
+
"""Wrapper around 'docker compose' commands."""
|
|
15
|
+
|
|
16
|
+
@staticmethod
|
|
17
|
+
def get_running_projects() -> List[str]:
|
|
18
|
+
"""Return a list of running docker compose projects using `docker compose ls`.
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
List[str]: Subset of the passed projects that are currently running.
|
|
22
|
+
"""
|
|
23
|
+
try:
|
|
24
|
+
result = sh.docker.compose.ls(format="json", _ok_code=[0, 1]) # pyright: ignore[reportAttributeAccessIssue]
|
|
25
|
+
data = json.loads(str(result))
|
|
26
|
+
running = []
|
|
27
|
+
for item in data:
|
|
28
|
+
name = item.get("Name")
|
|
29
|
+
if name and item.get("Status", "").lower().startswith("running"):
|
|
30
|
+
running.append(name)
|
|
31
|
+
return running
|
|
32
|
+
except sh.CommandNotFound as e:
|
|
33
|
+
console.print(f"[red][bold]Error:[/bold] '{e}' command not found.[/red]")
|
|
34
|
+
exit(code=1)
|
|
35
|
+
except Exception as e:
|
|
36
|
+
console.print(f"[yellow]Warning: could not determine running projects ({e})[/yellow]")
|
|
37
|
+
return []
|
|
38
|
+
|
|
39
|
+
@staticmethod
|
|
40
|
+
def up(compose_file: Path, **kwargs) -> int:
|
|
41
|
+
"""Run 'docker compose up' with the specified **kwargs.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
compose_file (Path): Path to the compose YAML file.
|
|
45
|
+
**kwargs (Dict): Optional arguments to pass directly to docker compose.
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
int: Exit code of the docker command.
|
|
49
|
+
|
|
50
|
+
"""
|
|
51
|
+
return DockerCompose._docker_compose_run(compose_file=compose_file, action="up", **kwargs)
|
|
52
|
+
|
|
53
|
+
@staticmethod
|
|
54
|
+
def down(compose_file: Path, **kwargs) -> int:
|
|
55
|
+
"""Run 'docker compose down' with the specified **kwargs.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
compose_file (Path): Path to the compose YAML file.
|
|
59
|
+
**kwargs (Dict): Optional arguments to pass directly to docker compose.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
int: Exit code of the docker command.
|
|
63
|
+
|
|
64
|
+
"""
|
|
65
|
+
return DockerCompose._docker_compose_run(
|
|
66
|
+
compose_file=compose_file, action="down", **kwargs
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
@staticmethod
|
|
70
|
+
def restart(compose_file: Path, **kwargs) -> int:
|
|
71
|
+
"""Run 'docker compose restart' with the specified **kwargs.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
compose_file (Path): Path to the compose YAML file.
|
|
75
|
+
**kwargs (Dict): Optional arguments to pass directly to docker compose.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
int: Exit code of the docker command.
|
|
79
|
+
|
|
80
|
+
"""
|
|
81
|
+
return DockerCompose._docker_compose_run(
|
|
82
|
+
compose_file=compose_file, action="restart", **kwargs
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
@staticmethod
|
|
86
|
+
def _docker_compose_run(
|
|
87
|
+
compose_file: Path,
|
|
88
|
+
action: Literal["up", "down", "restart"],
|
|
89
|
+
**kwargs,
|
|
90
|
+
) -> int:
|
|
91
|
+
"""Internal wrapper to run docker compose command via sh.docker.compose.
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
compose_file (Path): Path to the compose YAML file.
|
|
96
|
+
action (str): 'up' or 'down', to indicate which docker compose command to run.
|
|
97
|
+
**kwargs (Dict): Optional arguments to pass directly to docker compose.
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
int: Exit code of the docker command.
|
|
101
|
+
|
|
102
|
+
"""
|
|
103
|
+
console.rule(f"[bold blue]{action.upper()} {compose_file.parent.name}[/bold blue]")
|
|
104
|
+
try:
|
|
105
|
+
sh.docker.compose(action, file=compose_file, _fg=True, **kwargs) # pyright: ignore[reportAttributeAccessIssue]
|
|
106
|
+
console.print(
|
|
107
|
+
f":white_check_mark: [green]{action} succeeded for {compose_file.parent.name}[/green]"
|
|
108
|
+
)
|
|
109
|
+
return 0
|
|
110
|
+
except sh.CommandNotFound as e:
|
|
111
|
+
console.print(f"[red]'{e}' command not found.[/red]")
|
|
112
|
+
return 1
|
|
113
|
+
except sh.ErrorReturnCode as e:
|
|
114
|
+
console.print(f"[red]Command failed with exit code {e.exit_code}[/red]")
|
|
115
|
+
return int(e.exit_code)
|
|
116
|
+
except Exception as e:
|
|
117
|
+
console.print(f"[red]Error executing docker compose: {e}[/red]")
|
|
118
|
+
return 2
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""
|
|
2
|
+
A friendly CLI tool for managing multiple Docker Compose projects.
|
|
3
|
+
|
|
4
|
+
docker-captain detects projects automatically, lets you mark them as active,
|
|
5
|
+
and provides simple commands to start, stop, restart, or list your deployments
|
|
6
|
+
— individually or all at once.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import List
|
|
12
|
+
|
|
13
|
+
import questionary
|
|
14
|
+
import typer
|
|
15
|
+
from questionary import Choice
|
|
16
|
+
from rich import box
|
|
17
|
+
from rich.console import Console
|
|
18
|
+
from rich.table import Table
|
|
19
|
+
|
|
20
|
+
from docker_captain.config import CaptainData
|
|
21
|
+
from docker_captain.docker import DockerCompose
|
|
22
|
+
from docker_captain.projects import CaptainProject
|
|
23
|
+
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
# Configuration
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
app = typer.Typer(no_args_is_help=True)
|
|
29
|
+
console = Console()
|
|
30
|
+
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
# Commands
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@app.command()
|
|
37
|
+
def list(
|
|
38
|
+
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show compose file paths."),
|
|
39
|
+
) -> None:
|
|
40
|
+
"""List all discovered projects and show which ones are active and running.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
verbose (bool): If True, also show the compose file path.
|
|
44
|
+
"""
|
|
45
|
+
projects_folder = CaptainProject.projects_folder()
|
|
46
|
+
projects = CaptainProject.discover_projects()
|
|
47
|
+
captain_data: CaptainData = CaptainData.load()
|
|
48
|
+
running_projects: List[str] = DockerCompose.get_running_projects()
|
|
49
|
+
|
|
50
|
+
table = Table(title=f"Projects in {projects_folder}", box=box.SIMPLE_HEAVY)
|
|
51
|
+
table.add_column("Project", no_wrap=True)
|
|
52
|
+
table.add_column("Active", justify="center")
|
|
53
|
+
table.add_column("Running", justify="center")
|
|
54
|
+
|
|
55
|
+
if verbose:
|
|
56
|
+
table.add_column("Compose File")
|
|
57
|
+
|
|
58
|
+
for name, compose_path in projects.items():
|
|
59
|
+
active = "✓" if name in captain_data.active_projects else ""
|
|
60
|
+
running = "✓" if name in running_projects else ""
|
|
61
|
+
row = [name, active, running]
|
|
62
|
+
if verbose:
|
|
63
|
+
row.append(str(compose_path))
|
|
64
|
+
table.add_row(*row)
|
|
65
|
+
|
|
66
|
+
console.print(table)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@app.command()
|
|
70
|
+
def manage() -> None:
|
|
71
|
+
"""Interactively select which projects are active."""
|
|
72
|
+
projects_folder = CaptainProject.projects_folder()
|
|
73
|
+
projects = CaptainProject.discover_projects()
|
|
74
|
+
names = sorted(projects.keys())
|
|
75
|
+
captain_data: CaptainData = CaptainData.load()
|
|
76
|
+
|
|
77
|
+
if not names:
|
|
78
|
+
console.print(f"[yellow]No projects found in {projects_folder}.[/yellow]")
|
|
79
|
+
raise typer.Exit(code=1)
|
|
80
|
+
|
|
81
|
+
# Build choices with checkmarks for active projects
|
|
82
|
+
choices = [
|
|
83
|
+
Choice(title=n, value=n, checked=(n in captain_data.active_projects)) for n in names
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
answer = questionary.checkbox(
|
|
87
|
+
"Select active projects (space to toggle, enter to confirm):",
|
|
88
|
+
choices=choices,
|
|
89
|
+
).ask()
|
|
90
|
+
|
|
91
|
+
if answer is None:
|
|
92
|
+
console.print("[yellow]Aborted (no changes made).[/yellow]")
|
|
93
|
+
raise typer.Exit(code=0)
|
|
94
|
+
|
|
95
|
+
captain_data.active_projects = sorted(answer)
|
|
96
|
+
captain_data.save()
|
|
97
|
+
console.print(
|
|
98
|
+
f"[green]Saved {len(captain_data.active_projects)} active project(s) to {captain_data.DEFAULT_PATH}[/green]"
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@app.command()
|
|
103
|
+
def start(
|
|
104
|
+
project: str = typer.Argument(..., help="Project folder name (e.g. calibre)"),
|
|
105
|
+
detach: bool = typer.Option(False, "-d", "--detach", help="Run `docker compose up --detach`"),
|
|
106
|
+
remove_orphans: bool = typer.Option(
|
|
107
|
+
False, "--remove-orphans", help="Include --remove-orphans"
|
|
108
|
+
),
|
|
109
|
+
) -> None:
|
|
110
|
+
"""Start a single project using `docker compose up`."""
|
|
111
|
+
projects = CaptainProject.discover_projects()
|
|
112
|
+
compose_file = CaptainProject.require_project_exists(project, projects)
|
|
113
|
+
rc = DockerCompose.up(
|
|
114
|
+
compose_file=compose_file,
|
|
115
|
+
detach=detach,
|
|
116
|
+
remove_orphans=remove_orphans,
|
|
117
|
+
)
|
|
118
|
+
raise typer.Exit(code=rc)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@app.command()
|
|
122
|
+
def stop(
|
|
123
|
+
project: str = typer.Argument(..., help="Project folder name (e.g. calibre)"),
|
|
124
|
+
remove_orphans: bool = typer.Option(
|
|
125
|
+
False, "--remove-orphans", help="Include --remove-orphans"
|
|
126
|
+
),
|
|
127
|
+
) -> None:
|
|
128
|
+
"""Stop a single project using `docker compose down`."""
|
|
129
|
+
projects = CaptainProject.discover_projects()
|
|
130
|
+
compose_file = CaptainProject.require_project_exists(project, projects)
|
|
131
|
+
rc = DockerCompose.down(
|
|
132
|
+
compose_file=compose_file,
|
|
133
|
+
remove_orphans=remove_orphans,
|
|
134
|
+
)
|
|
135
|
+
raise typer.Exit(code=rc)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@app.command()
|
|
139
|
+
def restart(
|
|
140
|
+
project: str = typer.Argument(..., help="Project folder name (e.g. calibre)"),
|
|
141
|
+
) -> None:
|
|
142
|
+
"""Restart a single project using `docker compose restart`."""
|
|
143
|
+
projects = CaptainProject.discover_projects()
|
|
144
|
+
compose_file = CaptainProject.require_project_exists(project, projects)
|
|
145
|
+
rc = DockerCompose.restart(compose_file=compose_file)
|
|
146
|
+
raise typer.Exit(code=rc)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@app.command()
|
|
150
|
+
def rally(
|
|
151
|
+
detach: bool = typer.Option(False, "-d", "--detach", help="Run with --detach"),
|
|
152
|
+
remove_orphans: bool = typer.Option(
|
|
153
|
+
False, "--remove-orphans", help="Include --remove-orphans"
|
|
154
|
+
),
|
|
155
|
+
) -> None:
|
|
156
|
+
"""Start all active projects."""
|
|
157
|
+
projects = CaptainProject.discover_projects()
|
|
158
|
+
captain_data = CaptainData.load()
|
|
159
|
+
|
|
160
|
+
if not captain_data.active_projects:
|
|
161
|
+
console.print(
|
|
162
|
+
f"[yellow]No active projects found in {captain_data.DEFAULT_PATH}. Run `docker-captain manage` first.[/yellow]"
|
|
163
|
+
)
|
|
164
|
+
raise typer.Exit(code=0)
|
|
165
|
+
|
|
166
|
+
exit_code = 0
|
|
167
|
+
for name in captain_data.active_projects:
|
|
168
|
+
if name not in projects:
|
|
169
|
+
console.print(f"[red]Skipping {name}: project not found.[/red]")
|
|
170
|
+
exit_code = exit_code or 1
|
|
171
|
+
continue
|
|
172
|
+
rc = DockerCompose.up(
|
|
173
|
+
compose_file=projects[name], detach=detach, remove_orphans=remove_orphans
|
|
174
|
+
)
|
|
175
|
+
exit_code = exit_code or rc
|
|
176
|
+
raise typer.Exit(code=exit_code)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@app.command()
|
|
180
|
+
def abandon(
|
|
181
|
+
remove_orphans: bool = typer.Option(
|
|
182
|
+
False, "--remove-orphans", help="Include --remove-orphans"
|
|
183
|
+
),
|
|
184
|
+
) -> None:
|
|
185
|
+
"""Stop all active projects."""
|
|
186
|
+
projects = CaptainProject.discover_projects()
|
|
187
|
+
captain_data = CaptainData.load()
|
|
188
|
+
|
|
189
|
+
if not captain_data.active_projects:
|
|
190
|
+
console.print(
|
|
191
|
+
f"[yellow]No active projects found in {captain_data.DEFAULT_PATH}. Run `docker-captain manage` first.[/yellow]"
|
|
192
|
+
)
|
|
193
|
+
raise typer.Exit(code=0)
|
|
194
|
+
|
|
195
|
+
exit_code = 0
|
|
196
|
+
for name in captain_data.active_projects:
|
|
197
|
+
if name not in projects:
|
|
198
|
+
console.print(f"[red]Skipping {name}: project not found.[/red]")
|
|
199
|
+
exit_code = exit_code or 1
|
|
200
|
+
continue
|
|
201
|
+
rc = DockerCompose.down(compose_file=projects[name], remove_orphans=remove_orphans)
|
|
202
|
+
exit_code = exit_code or rc
|
|
203
|
+
raise typer.Exit(code=exit_code)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# ---------------------------------------------------------------------------
|
|
207
|
+
# Entry point
|
|
208
|
+
# ---------------------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def main():
|
|
212
|
+
app()
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Dict, List, Optional
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
|
|
8
|
+
from docker_captain.config import CaptainConfig
|
|
9
|
+
|
|
10
|
+
console = Console()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CaptainProject:
|
|
14
|
+
@staticmethod
|
|
15
|
+
def projects_folder() -> Path:
|
|
16
|
+
"""Get the root folder of the projects.
|
|
17
|
+
|
|
18
|
+
Either parse it from the environment variable (if provided), or from
|
|
19
|
+
the configuration file.
|
|
20
|
+
"""
|
|
21
|
+
captain_config = CaptainConfig.load()
|
|
22
|
+
folder = (
|
|
23
|
+
os.getenv(CaptainConfig.ENVIROMENT["projects_folder"])
|
|
24
|
+
or captain_config.projects_folder
|
|
25
|
+
)
|
|
26
|
+
if not folder:
|
|
27
|
+
console.print(
|
|
28
|
+
f"[bold red]Error:[/bold red] Please set the path containing your Docker Compose projects.\n"
|
|
29
|
+
f"Either add it to the {CaptainConfig.DEFAULT_PATH} file, or set with:\n\n"
|
|
30
|
+
f" export {CaptainConfig.ENVIROMENT['projects_folder']}=/path/to/your/deployments\n"
|
|
31
|
+
)
|
|
32
|
+
exit(code=1)
|
|
33
|
+
folder = Path(folder)
|
|
34
|
+
if not folder.is_absolute():
|
|
35
|
+
console.print(
|
|
36
|
+
f"[bold red]Error:[/bold red] The configured projects folder {folder} "
|
|
37
|
+
"is not an absolute path."
|
|
38
|
+
)
|
|
39
|
+
exit(code=2)
|
|
40
|
+
if not folder.exists():
|
|
41
|
+
console.print(
|
|
42
|
+
f"[bold red]Error:[/bold red] The configured projects folder {folder} "
|
|
43
|
+
"does not exist."
|
|
44
|
+
)
|
|
45
|
+
exit(code=3)
|
|
46
|
+
return folder
|
|
47
|
+
|
|
48
|
+
@staticmethod
|
|
49
|
+
def discover_projects(root: Optional[Path] = None) -> Dict[str, Path]:
|
|
50
|
+
"""Discover projects that contain a valid docker compose file.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
root (Path): The root directory containing deployment folders.
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
Dict[str, Path]: Mapping from project name to compose file path.
|
|
57
|
+
"""
|
|
58
|
+
COMPOSE_FILENAMES: List[str] = [
|
|
59
|
+
"compose.yaml",
|
|
60
|
+
"compose.yml",
|
|
61
|
+
"docker-compose.yaml",
|
|
62
|
+
"docker-compose.yml",
|
|
63
|
+
]
|
|
64
|
+
projects: Dict[str, Path] = {}
|
|
65
|
+
root = root or CaptainProject.projects_folder()
|
|
66
|
+
if not root.exists():
|
|
67
|
+
return projects
|
|
68
|
+
for child in sorted(root.iterdir()):
|
|
69
|
+
if not child.is_dir():
|
|
70
|
+
continue
|
|
71
|
+
for fname in COMPOSE_FILENAMES:
|
|
72
|
+
candidate = child / fname
|
|
73
|
+
if candidate.exists() and candidate.is_file():
|
|
74
|
+
projects[child.name] = candidate
|
|
75
|
+
break
|
|
76
|
+
return projects
|
|
77
|
+
|
|
78
|
+
@staticmethod
|
|
79
|
+
def require_project_exists(project: str, projects: Dict[str, Path]) -> Path:
|
|
80
|
+
"""Ensure the given project exists among discovered ones.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
project (str): Project name.
|
|
84
|
+
projects (Dict[str, Path]): Mapping of available projects.
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
Path: Path to the compose file.
|
|
88
|
+
|
|
89
|
+
Raises:
|
|
90
|
+
typer.Exit: If the project does not exist.
|
|
91
|
+
"""
|
|
92
|
+
if project not in projects:
|
|
93
|
+
console.print(f"[red]No such project: {project}[/red]")
|
|
94
|
+
raise typer.Exit(code=2)
|
|
95
|
+
return projects[project]
|