subcompose 1.0.0.dev0__py3-none-any.whl
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.
- subcompose/__init__.py +48 -0
- subcompose/__main__.py +24 -0
- subcompose/cli.py +430 -0
- subcompose/constants.py +33 -0
- subcompose/filtering.py +97 -0
- subcompose/logger.py +48 -0
- subcompose/parsing.py +51 -0
- subcompose/substitution.py +141 -0
- subcompose/utils.py +48 -0
- subcompose/validation.py +212 -0
- subcompose-1.0.0.dev0.dist-info/METADATA +110 -0
- subcompose-1.0.0.dev0.dist-info/RECORD +15 -0
- subcompose-1.0.0.dev0.dist-info/WHEEL +4 -0
- subcompose-1.0.0.dev0.dist-info/entry_points.txt +3 -0
- subcompose-1.0.0.dev0.dist-info/licenses/LICENSE +674 -0
subcompose/__init__.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# Copyright (C) 2026 Jose Hernandez
|
|
2
|
+
#
|
|
3
|
+
# This file is part of subcompose.
|
|
4
|
+
#
|
|
5
|
+
# subcompose is free software: you can redistribute it and/or modify
|
|
6
|
+
# it under the terms of the GNU General Public License as published by
|
|
7
|
+
# the Free Software Foundation, either version 3 of the License, or
|
|
8
|
+
# (at your option) any later version.
|
|
9
|
+
#
|
|
10
|
+
# subcompose is distributed in the hope that it will be useful,
|
|
11
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
12
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
13
|
+
# GNU General Public License for more details.
|
|
14
|
+
#
|
|
15
|
+
# You should have received a copy of the GNU General Public License
|
|
16
|
+
# along with subcompose. If not, see <https://www.gnu.org/licenses/>.
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
from subcompose.parsing import get_groups_from_data
|
|
20
|
+
from subcompose.validation import (
|
|
21
|
+
validate_groups,
|
|
22
|
+
validate_volumes,
|
|
23
|
+
check_service_extension_chain,
|
|
24
|
+
)
|
|
25
|
+
from subcompose.substitution import (
|
|
26
|
+
substitute_environment_variables,
|
|
27
|
+
substitute_image_tags,
|
|
28
|
+
)
|
|
29
|
+
from subcompose.filtering import (
|
|
30
|
+
filter_by_image_tag,
|
|
31
|
+
remove_dependencies_from_filtered_containers,
|
|
32
|
+
)
|
|
33
|
+
from subcompose.utils import represent_none, remove_subcompose_keys
|
|
34
|
+
from subcompose.cli import main
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"get_groups_from_data",
|
|
38
|
+
"validate_groups",
|
|
39
|
+
"validate_volumes",
|
|
40
|
+
"check_service_extension_chain",
|
|
41
|
+
"substitute_environment_variables",
|
|
42
|
+
"substitute_image_tags",
|
|
43
|
+
"filter_by_image_tag",
|
|
44
|
+
"remove_dependencies_from_filtered_containers",
|
|
45
|
+
"represent_none",
|
|
46
|
+
"remove_subcompose_keys",
|
|
47
|
+
"main",
|
|
48
|
+
]
|
subcompose/__main__.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Copyright (C) 2026 Jose Hernandez
|
|
2
|
+
#
|
|
3
|
+
# This file is part of subcompose.
|
|
4
|
+
#
|
|
5
|
+
# subcompose is free software: you can redistribute it and/or modify
|
|
6
|
+
# it under the terms of the GNU General Public License as published by
|
|
7
|
+
# the Free Software Foundation, either version 3 of the License, or
|
|
8
|
+
# (at your option) any later version.
|
|
9
|
+
#
|
|
10
|
+
# subcompose is distributed in the hope that it will be useful,
|
|
11
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
12
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
13
|
+
# GNU General Public License for more details.
|
|
14
|
+
#
|
|
15
|
+
# You should have received a copy of the GNU General Public License
|
|
16
|
+
# along with subcompose. If not, see <https://www.gnu.org/licenses/>.
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
"""Entry point for `python -m subcompose`."""
|
|
20
|
+
|
|
21
|
+
from subcompose.cli import main
|
|
22
|
+
|
|
23
|
+
if __name__ == "__main__":
|
|
24
|
+
main()
|
subcompose/cli.py
ADDED
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
# Copyright (C) 2026 Jose Hernandez
|
|
4
|
+
#
|
|
5
|
+
# This file is part of subcompose.
|
|
6
|
+
#
|
|
7
|
+
# subcompose is free software: you can redistribute it and/or modify
|
|
8
|
+
# it under the terms of the GNU General Public License as published by
|
|
9
|
+
# the Free Software Foundation, either version 3 of the License, or
|
|
10
|
+
# (at your option) any later version.
|
|
11
|
+
#
|
|
12
|
+
# subcompose is distributed in the hope that it will be useful,
|
|
13
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
14
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
15
|
+
# GNU General Public License for more details.
|
|
16
|
+
#
|
|
17
|
+
# You should have received a copy of the GNU General Public License
|
|
18
|
+
# along with subcompose. If not, see <https://www.gnu.org/licenses/>.
|
|
19
|
+
|
|
20
|
+
"""
|
|
21
|
+
🐳 subcompose: a command line utility to manage subsets of services in Docker compose.yaml files.
|
|
22
|
+
|
|
23
|
+
Usage:
|
|
24
|
+
subcompose ( -h | --help | -? )
|
|
25
|
+
subcompose ( -v | --version )
|
|
26
|
+
subcompose ( -l | --list ) [--compose-file=<filename>]
|
|
27
|
+
subcompose delete-containers [--debug] [--interpolate] [--all] [--unmanaged] [--service=<service_tag>]... [--group=<group_tag>]... [--compose-file=<filename>]
|
|
28
|
+
subcompose delete-images [--debug] [--interpolate] [--unmanaged] [--service=<service_tag>]... [--group=<group_tag>]... [--compose-file=<filename>]
|
|
29
|
+
subcompose preview [--debug] [--interpolate] [--unmanaged] [--var-file=<filename>] [--src-tag=<src_tag>] [--service=<service_tag>]... [--group=<group_tag>]... [--compose-file=<filename>]
|
|
30
|
+
subcompose pull [--interpolate] [--unmanaged] [--var-file=<filename>] [--src-tag=<src_tag>] [--service=<service_tag>]... [--group=<group_tag>]... [--compose-file=<filename>]
|
|
31
|
+
subcompose push [--interpolate] [--unmanaged] [--var-file=<filename>] [--src-tag=<src_tag>] [--service=<service_tag>]... [--group=<group_tag>]... [--compose-file=<filename>]
|
|
32
|
+
subcompose run [--debug] [--interpolate] [--unmanaged] [--var-file=<filename>] [--src-tag=<src_tag>] [--service=<service_tag>]... [--group=<group_tag>]... [--compose-file=<filename>]
|
|
33
|
+
subcompose stop [--debug] [--interpolate] [--unmanaged] [--service=<service_tag>]... [--group=<group_tag>]... [--compose-file=<filename>]
|
|
34
|
+
subcompose tag [--interpolate] [--unmanaged] [--var-file=<filename>] [--src-tag=<src_tag>] [--service=<service_tag>]... [--group=<group_tag>]... --registry=<registry> --dst-tag=<dst_tag> [--compose-file=<filename>]
|
|
35
|
+
subcompose validate [--debug] [--fix] [--compose-file=<filename>]
|
|
36
|
+
|
|
37
|
+
Examples:
|
|
38
|
+
subcompose run --group=core
|
|
39
|
+
subcompose run --group=minimal:1.0.0-SNAPSHOT --group=elk:1.0.0-SNAPSHOT --service=elasticsearch:latest --service=kibana:latest
|
|
40
|
+
subcompose run --group=standalone
|
|
41
|
+
subcompose stop --group=standalone
|
|
42
|
+
subcompose --list
|
|
43
|
+
subcompose preview --group=minimal --service=gateway
|
|
44
|
+
|
|
45
|
+
Please note that it is also possible to launch the docker stack by passing the output of preview to docker compose. e.g.:
|
|
46
|
+
|
|
47
|
+
subcompose preview --group=all | docker compose up
|
|
48
|
+
subcompose preview --group=core --group=elk | docker compose up
|
|
49
|
+
|
|
50
|
+
Options:
|
|
51
|
+
-E --env-var=<variable> Set ENV variable ( -E AWS_SECRET_ACCESS_KEY=<some_value> -E AWS_DEFAULT_REGION=<some_value> ... )
|
|
52
|
+
-T --dst-tag=<dst_tag> Set TAG and repo for destination (-T 127.0.0.1:5000:mytag). Uses for tag option only
|
|
53
|
+
-c --compose-file=<filename> Specify an alternate compose file [default: compose.yaml]
|
|
54
|
+
-d --debug Display debug messages in console
|
|
55
|
+
-f --var-file=<filename> Load variables from file
|
|
56
|
+
-g --group=<group_tag> Group of services to use (-g group1:tag1 -g group2:tag2 ...)
|
|
57
|
+
-h -? --help Show this screen.
|
|
58
|
+
-i --interpolate Enable variable interpolation
|
|
59
|
+
-l --list List of available groups and services
|
|
60
|
+
-r --registry=<registry> Set registry URL
|
|
61
|
+
-s --service=<service_tag> Service to use (-s service1:tag1 -s service2:tag2 ...)
|
|
62
|
+
-t --src-tag=<src_tag> Default image tag
|
|
63
|
+
-u --unmanaged Exclude managed services
|
|
64
|
+
-v --version Show version.
|
|
65
|
+
|
|
66
|
+
Commands:
|
|
67
|
+
🗑️ delete-containers Removes specified containers using the auto-generated compose.yaml configuration or all system containers with --all
|
|
68
|
+
🖼️ delete-images Removes specified images using the auto-generated compose.yaml configuration
|
|
69
|
+
👁️ preview Prints the auto-generated compose.yaml configuration to the console
|
|
70
|
+
⬇️ pull Pulls Docker images to the registry using an auto-generated compose.yaml to obtain image links
|
|
71
|
+
⬆️ push Pushes Docker images to the registry using an auto-generated compose.yaml to obtain image links
|
|
72
|
+
🐳 run Runs the compose project using an auto-generated compose.yaml
|
|
73
|
+
⏹️ stop Stops specified containers using the auto-generated compose.yaml configuration
|
|
74
|
+
🏷️ tag Adds tag to images for services and nested services that are in the script
|
|
75
|
+
🛡️ validate Validates the groups and dependencies in compose.yaml
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
import logging
|
|
79
|
+
import re
|
|
80
|
+
import subprocess
|
|
81
|
+
import sys
|
|
82
|
+
from importlib.metadata import version as _pkg_version
|
|
83
|
+
from pathlib import Path
|
|
84
|
+
from typing import Any, Dict
|
|
85
|
+
|
|
86
|
+
import yaml
|
|
87
|
+
from docopt import docopt
|
|
88
|
+
|
|
89
|
+
from subcompose.constants import (
|
|
90
|
+
ARG_DEBUG,
|
|
91
|
+
ARG_GROUPS,
|
|
92
|
+
ARG_SERVICES,
|
|
93
|
+
ARG_SRC_TAG,
|
|
94
|
+
ARG_UNMANAGED,
|
|
95
|
+
COMPOSE_COMMAND,
|
|
96
|
+
)
|
|
97
|
+
from subcompose.filtering import (
|
|
98
|
+
filter_by_image_tag,
|
|
99
|
+
remove_dependencies_from_filtered_containers,
|
|
100
|
+
)
|
|
101
|
+
from subcompose.logger import configure_logging
|
|
102
|
+
from subcompose.parsing import get_groups_from_data
|
|
103
|
+
from subcompose.substitution import (
|
|
104
|
+
substitute_environment_variables,
|
|
105
|
+
substitute_image_tags,
|
|
106
|
+
)
|
|
107
|
+
from subcompose.utils import remove_subcompose_keys, represent_none
|
|
108
|
+
from subcompose.validation import validate_groups, validate_volumes
|
|
109
|
+
|
|
110
|
+
BOLD = '\033[1m'
|
|
111
|
+
RESET = '\033[0m'
|
|
112
|
+
|
|
113
|
+
# Function to bold 'subcompose' in any string
|
|
114
|
+
def bold_subcompose(text: str) -> str:
|
|
115
|
+
return re.sub(r'(subcompose)', f'{BOLD}\1{RESET}', text, flags=re.IGNORECASE)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def main() -> None:
|
|
119
|
+
# Print banner at the top of every command output
|
|
120
|
+
banner_path = Path(__file__).parent.parent / "banner.txt"
|
|
121
|
+
if banner_path.exists():
|
|
122
|
+
banner = banner_path.read_text()
|
|
123
|
+
print(bold_subcompose(banner))
|
|
124
|
+
|
|
125
|
+
try:
|
|
126
|
+
_version = _pkg_version("subcompose")
|
|
127
|
+
arguments: Dict[str, Any] = docopt(__doc__, version=_version)
|
|
128
|
+
except Exception as e:
|
|
129
|
+
print(e)
|
|
130
|
+
sys.exit(1)
|
|
131
|
+
|
|
132
|
+
compose_file = "compose.yaml"
|
|
133
|
+
# Pre-scan arguments for compose file to load groups
|
|
134
|
+
# Note: docopt handles arguments, but for dynamic help generation dependent on file content
|
|
135
|
+
# we might need to parse manually or rely on default if available.
|
|
136
|
+
# However, logic in original script was iterating sys.argv manually before docopt.
|
|
137
|
+
# We can keep that logic.
|
|
138
|
+
for i, arg in enumerate(sys.argv):
|
|
139
|
+
if arg.startswith("--compose-file="):
|
|
140
|
+
compose_file = arg.split("=", 1)[1]
|
|
141
|
+
elif (arg == "-c" or arg == "--compose-file") and i + 1 < len(sys.argv):
|
|
142
|
+
compose_file = sys.argv[i + 1]
|
|
143
|
+
|
|
144
|
+
content = ""
|
|
145
|
+
data: Dict[str, Any] = {}
|
|
146
|
+
try:
|
|
147
|
+
content = Path(compose_file).read_text()
|
|
148
|
+
data = yaml.safe_load(content) or {}
|
|
149
|
+
except Exception as e:
|
|
150
|
+
logging.debug(f"Could not load groups: {e}")
|
|
151
|
+
pass
|
|
152
|
+
|
|
153
|
+
groups = get_groups_from_data(data)
|
|
154
|
+
|
|
155
|
+
project_name = data.get("name", "project_name")
|
|
156
|
+
help_groups = f"# {project_name} subcompose groups and services:\n"
|
|
157
|
+
for group_name in sorted(groups.keys()):
|
|
158
|
+
help_groups += f"\n[{group_name}]\n"
|
|
159
|
+
for service in sorted(groups[group_name]):
|
|
160
|
+
help_groups += f"{service}\n"
|
|
161
|
+
|
|
162
|
+
if arguments["--list"]:
|
|
163
|
+
print(help_groups)
|
|
164
|
+
return
|
|
165
|
+
|
|
166
|
+
debug = arguments[ARG_DEBUG]
|
|
167
|
+
configure_logging(debug)
|
|
168
|
+
|
|
169
|
+
logging.debug("Docker subcompose utility started...")
|
|
170
|
+
|
|
171
|
+
if arguments[ARG_SERVICES]:
|
|
172
|
+
arguments[ARG_SERVICES] = str(arguments[ARG_SERVICES][0]).split(",")
|
|
173
|
+
|
|
174
|
+
logging.debug(f"\nCommand-line arguments:\n{arguments}")
|
|
175
|
+
|
|
176
|
+
yaml.add_representer(type(None), represent_none)
|
|
177
|
+
|
|
178
|
+
try:
|
|
179
|
+
# If the generated Docker compose file is to be run in interpolation mode then the registry URL is required as
|
|
180
|
+
# part of the image names, allowing the server to know where to pull the images from. Otherwise, if running
|
|
181
|
+
# locally then no registry reference is required as part of the image names.
|
|
182
|
+
if arguments["--interpolate"]:
|
|
183
|
+
content = content.replace("${REGISTRY_URL}/", "")
|
|
184
|
+
data = yaml.safe_load(content) or {}
|
|
185
|
+
|
|
186
|
+
project_name = data.get("name", "default")
|
|
187
|
+
|
|
188
|
+
required_services = []
|
|
189
|
+
if arguments[ARG_SERVICES]:
|
|
190
|
+
for service in arguments[ARG_SERVICES]:
|
|
191
|
+
service_name = service.split(":")[0]
|
|
192
|
+
if service_name not in groups["all"]:
|
|
193
|
+
print(
|
|
194
|
+
f"\nERROR: unknown service '{service_name}' found in command line.\n"
|
|
195
|
+
)
|
|
196
|
+
sys.exit(1)
|
|
197
|
+
required_services.append(service_name)
|
|
198
|
+
|
|
199
|
+
if arguments[ARG_GROUPS]:
|
|
200
|
+
for group in arguments[ARG_GROUPS]:
|
|
201
|
+
group_name = group.split(":")[0]
|
|
202
|
+
if group_name not in groups:
|
|
203
|
+
print(f"\nERROR: unknown group '{group}' found in command line.\n")
|
|
204
|
+
sys.exit(1)
|
|
205
|
+
required_services.extend(groups[group_name])
|
|
206
|
+
|
|
207
|
+
# Add dependencies if required
|
|
208
|
+
if arguments["run"] or arguments["preview"]:
|
|
209
|
+
for service in list(required_services):
|
|
210
|
+
if "depends_on" in data["services"][service]:
|
|
211
|
+
dependencies = data["services"][service]["depends_on"]
|
|
212
|
+
for dependency in dependencies:
|
|
213
|
+
if dependency not in groups["all"]:
|
|
214
|
+
print(
|
|
215
|
+
f"\nERROR: unknown dependency '{dependency}' listed for service '{service}' in compose.yaml."
|
|
216
|
+
)
|
|
217
|
+
sys.exit(1)
|
|
218
|
+
required_services.append(dependency)
|
|
219
|
+
|
|
220
|
+
# Remove duplicates
|
|
221
|
+
required_services = list(set(required_services))
|
|
222
|
+
|
|
223
|
+
# Filter out managed services if --unmanaged is specified
|
|
224
|
+
managed_services_set = set()
|
|
225
|
+
if arguments[ARG_UNMANAGED]:
|
|
226
|
+
managed_services_set = {
|
|
227
|
+
s
|
|
228
|
+
for s in required_services
|
|
229
|
+
if data["services"][s].get("x-subcompose-managed", False)
|
|
230
|
+
}
|
|
231
|
+
required_services = [
|
|
232
|
+
s for s in required_services if s not in managed_services_set
|
|
233
|
+
]
|
|
234
|
+
|
|
235
|
+
# Stop or deletes all services if no groups or services specified
|
|
236
|
+
if not required_services and (
|
|
237
|
+
arguments["stop"]
|
|
238
|
+
or arguments["delete-containers"]
|
|
239
|
+
or arguments["delete-images"]
|
|
240
|
+
):
|
|
241
|
+
for group in groups.values():
|
|
242
|
+
required_services.extend(group)
|
|
243
|
+
required_services = list(set(required_services))
|
|
244
|
+
|
|
245
|
+
logging.debug(f"\nRequired services: {sorted(required_services)}")
|
|
246
|
+
|
|
247
|
+
# Copy data but replace services with only those required
|
|
248
|
+
required_data = {
|
|
249
|
+
**data,
|
|
250
|
+
"services": {
|
|
251
|
+
s: c
|
|
252
|
+
for s, c in data.get("services", {}).items()
|
|
253
|
+
if s in required_services
|
|
254
|
+
},
|
|
255
|
+
"volumes": data.get("volumes", {}),
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if arguments["validate"]:
|
|
259
|
+
fix = arguments["--fix"]
|
|
260
|
+
issues_groups, fixed_groups = validate_groups(groups, data, fix=fix)
|
|
261
|
+
issues_volumes, fixed_volumes = validate_volumes(data, fix=fix)
|
|
262
|
+
|
|
263
|
+
if fix and (fixed_groups or fixed_volumes):
|
|
264
|
+
with open("compose.yaml", "w") as f:
|
|
265
|
+
yaml.dump(
|
|
266
|
+
data,
|
|
267
|
+
f,
|
|
268
|
+
default_flow_style=False,
|
|
269
|
+
sort_keys=False,
|
|
270
|
+
allow_unicode=True,
|
|
271
|
+
)
|
|
272
|
+
logging.info("Fixed validation warnings and updated compose.yaml.")
|
|
273
|
+
elif not (issues_groups or issues_volumes):
|
|
274
|
+
logging.info("Validation successful. No issues found.")
|
|
275
|
+
return
|
|
276
|
+
|
|
277
|
+
# Extract variables to ignore during substitution
|
|
278
|
+
ignored_vars = []
|
|
279
|
+
if not arguments["--interpolate"]:
|
|
280
|
+
# Find all variables in the content to ignore them
|
|
281
|
+
# Matches ${VAR} or $VAR or ${VAR:-default}
|
|
282
|
+
matches = re.findall(
|
|
283
|
+
r"\$\{?([a-zA-Z_][a-zA-Z0-9_]*)(?:[:?].*?)?}?", content
|
|
284
|
+
)
|
|
285
|
+
ignored_vars = list(set(matches))
|
|
286
|
+
|
|
287
|
+
# Substitute environment variables
|
|
288
|
+
logging.debug("\nSubstituting environment variables...")
|
|
289
|
+
substituted_data = substitute_environment_variables(
|
|
290
|
+
required_data,
|
|
291
|
+
no_interpolate=not bool(arguments["--interpolate"]),
|
|
292
|
+
ignored_vars=ignored_vars,
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
# Identify default image tag
|
|
296
|
+
service_image_tags = {}
|
|
297
|
+
if arguments[ARG_SRC_TAG]:
|
|
298
|
+
service_image_tags = {s: arguments[ARG_SRC_TAG] for s in required_services}
|
|
299
|
+
|
|
300
|
+
# Identify group-specific image tags
|
|
301
|
+
if arguments[ARG_GROUPS]:
|
|
302
|
+
for group in arguments[ARG_GROUPS]:
|
|
303
|
+
if ":" in group:
|
|
304
|
+
group_name, image_tag = group.split(":")
|
|
305
|
+
for service_name in groups[group_name]:
|
|
306
|
+
service_image_tags[service_name] = image_tag
|
|
307
|
+
else:
|
|
308
|
+
for service_name in groups[group]:
|
|
309
|
+
if service_name not in service_image_tags:
|
|
310
|
+
service_image_tags[service_name] = None
|
|
311
|
+
|
|
312
|
+
# Identify service-specific image tags
|
|
313
|
+
if arguments[ARG_SERVICES]:
|
|
314
|
+
for service in arguments[ARG_SERVICES]:
|
|
315
|
+
if ":" in service:
|
|
316
|
+
service_name, image_tag = service.split(":")
|
|
317
|
+
service_image_tags[service_name] = image_tag
|
|
318
|
+
else:
|
|
319
|
+
if service not in service_image_tags:
|
|
320
|
+
service_image_tags[service] = None
|
|
321
|
+
|
|
322
|
+
# Substitute all image tags
|
|
323
|
+
if service_image_tags:
|
|
324
|
+
logging.debug("Substituting image tags...")
|
|
325
|
+
substituted_data = substitute_image_tags(
|
|
326
|
+
substituted_data, service_image_tags
|
|
327
|
+
)
|
|
328
|
+
# Only stop specified services if they are running
|
|
329
|
+
if (
|
|
330
|
+
arguments["stop"]
|
|
331
|
+
or arguments["delete-containers"]
|
|
332
|
+
or arguments["delete-images"]
|
|
333
|
+
):
|
|
334
|
+
logging.debug("Filtering running containers by image tags...")
|
|
335
|
+
substituted_data = filter_by_image_tag(
|
|
336
|
+
substituted_data, service_image_tags
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
# Remove dependencies for stopped containers so they can be stopped in isolation from their dependencies.
|
|
340
|
+
if (
|
|
341
|
+
arguments["stop"]
|
|
342
|
+
or arguments["delete-containers"]
|
|
343
|
+
or arguments["delete-images"]
|
|
344
|
+
or arguments[ARG_UNMANAGED]
|
|
345
|
+
):
|
|
346
|
+
logging.debug("Removing dependencies from filtered containers...")
|
|
347
|
+
substituted_data = remove_dependencies_from_filtered_containers(
|
|
348
|
+
substituted_data,
|
|
349
|
+
groups,
|
|
350
|
+
only_managed=arguments[ARG_UNMANAGED],
|
|
351
|
+
managed_services_set=managed_services_set,
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
substituted_data = remove_subcompose_keys(substituted_data)
|
|
355
|
+
|
|
356
|
+
effective_docker_compose = yaml.dump(
|
|
357
|
+
substituted_data,
|
|
358
|
+
default_flow_style=False,
|
|
359
|
+
sort_keys=False,
|
|
360
|
+
allow_unicode=True,
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
if arguments["preview"] or debug:
|
|
364
|
+
print(bold_subcompose(f"\n{effective_docker_compose}"))
|
|
365
|
+
if arguments["preview"]:
|
|
366
|
+
return
|
|
367
|
+
|
|
368
|
+
if arguments["run"]:
|
|
369
|
+
logging.debug(bold_subcompose("\nRunning 'docker compose up' using generated YAML..."))
|
|
370
|
+
cmd_str = f"{COMPOSE_COMMAND} --project-name {project_name} -f - up -d"
|
|
371
|
+
subprocess.run(
|
|
372
|
+
cmd_str,
|
|
373
|
+
input=effective_docker_compose,
|
|
374
|
+
shell=True,
|
|
375
|
+
check=True,
|
|
376
|
+
text=True,
|
|
377
|
+
)
|
|
378
|
+
return
|
|
379
|
+
|
|
380
|
+
if arguments["stop"]:
|
|
381
|
+
logging.debug(bold_subcompose("\nRunning 'docker compose stop' using generated YAML..."))
|
|
382
|
+
cmd_str = f"{COMPOSE_COMMAND} --project-name {project_name} -f - stop"
|
|
383
|
+
subprocess.run(
|
|
384
|
+
cmd_str,
|
|
385
|
+
input=effective_docker_compose,
|
|
386
|
+
shell=True,
|
|
387
|
+
check=True,
|
|
388
|
+
text=True,
|
|
389
|
+
)
|
|
390
|
+
return
|
|
391
|
+
|
|
392
|
+
if arguments["delete-containers"] or arguments["delete-images"]:
|
|
393
|
+
if arguments["delete-containers"] and arguments["--all"]:
|
|
394
|
+
logging.debug(bold_subcompose("\nDeleting ALL containers on the system..."))
|
|
395
|
+
cmd_str = (
|
|
396
|
+
"docker stop $(docker ps -a -q) && docker rm -f $(docker ps -a -q)"
|
|
397
|
+
)
|
|
398
|
+
subprocess.run(cmd_str, shell=True, check=False)
|
|
399
|
+
return
|
|
400
|
+
|
|
401
|
+
logging.debug(bold_subcompose("\nRunning 'docker compose rm' using generated YAML..."))
|
|
402
|
+
cmd_str = f"{COMPOSE_COMMAND} --project-name {project_name} -f - rm --force --stop"
|
|
403
|
+
subprocess.run(
|
|
404
|
+
cmd_str,
|
|
405
|
+
input=effective_docker_compose,
|
|
406
|
+
shell=True,
|
|
407
|
+
check=True,
|
|
408
|
+
text=True,
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
if arguments["delete-images"]:
|
|
412
|
+
logging.debug(bold_subcompose("\nRunning 'docker rmi' against Docker images in generated YAML..."))
|
|
413
|
+
tagged_images = [
|
|
414
|
+
params["image"]
|
|
415
|
+
for service, params in substituted_data["services"].items()
|
|
416
|
+
]
|
|
417
|
+
if tagged_images:
|
|
418
|
+
cmd_str = f"docker rmi --force {' '.join(tagged_images)}"
|
|
419
|
+
subprocess.run(cmd_str, shell=True, check=False)
|
|
420
|
+
return
|
|
421
|
+
|
|
422
|
+
except yaml.YAMLError as exc:
|
|
423
|
+
print(bold_subcompose(str(exc)))
|
|
424
|
+
except subprocess.CalledProcessError as exc:
|
|
425
|
+
print(bold_subcompose(f"Error running command: {exc}"))
|
|
426
|
+
sys.exit(1)
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
if __name__ == "__main__":
|
|
430
|
+
main()
|
subcompose/constants.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Copyright (C) 2026 Jose Hernandez
|
|
2
|
+
#
|
|
3
|
+
# This file is part of subcompose.
|
|
4
|
+
#
|
|
5
|
+
# subcompose is free software: you can redistribute it and/or modify
|
|
6
|
+
# it under the terms of the GNU General Public License as published by
|
|
7
|
+
# the Free Software Foundation, either version 3 of the License, or
|
|
8
|
+
# (at your option) any later version.
|
|
9
|
+
#
|
|
10
|
+
# subcompose is distributed in the hope that it will be useful,
|
|
11
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
12
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
13
|
+
# GNU General Public License for more details.
|
|
14
|
+
#
|
|
15
|
+
# You should have received a copy of the GNU General Public License
|
|
16
|
+
# along with subcompose. If not, see <https://www.gnu.org/licenses/>.
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
"""Constants for subcompose."""
|
|
20
|
+
|
|
21
|
+
import logging
|
|
22
|
+
|
|
23
|
+
ARG_SERVICES = "--service"
|
|
24
|
+
ARG_GROUPS = "--group"
|
|
25
|
+
ARG_SRC_TAG = "--src-tag"
|
|
26
|
+
ARG_DEBUG = "--debug"
|
|
27
|
+
ARG_COMPOSE_FILE = "--compose-file"
|
|
28
|
+
ARG_UNMANAGED = "--unmanaged"
|
|
29
|
+
|
|
30
|
+
COMPOSE_COMMAND = "docker compose"
|
|
31
|
+
MAX_LEVELNAME_LEN = max(
|
|
32
|
+
(len(str(name)) for name in logging.getLevelNamesMapping().keys()), default=0
|
|
33
|
+
)
|
subcompose/filtering.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# Copyright (C) 2026 Jose Hernandez
|
|
2
|
+
#
|
|
3
|
+
# This file is part of subcompose.
|
|
4
|
+
#
|
|
5
|
+
# subcompose is free software: you can redistribute it and/or modify
|
|
6
|
+
# it under the terms of the GNU General Public License as published by
|
|
7
|
+
# the Free Software Foundation, either version 3 of the License, or
|
|
8
|
+
# (at your option) any later version.
|
|
9
|
+
#
|
|
10
|
+
# subcompose is distributed in the hope that it will be useful,
|
|
11
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
12
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
13
|
+
# GNU General Public License for more details.
|
|
14
|
+
#
|
|
15
|
+
# You should have received a copy of the GNU General Public License
|
|
16
|
+
# along with subcompose. If not, see <https://www.gnu.org/licenses/>.
|
|
17
|
+
|
|
18
|
+
"""Filtering logic for subcompose."""
|
|
19
|
+
|
|
20
|
+
import subprocess
|
|
21
|
+
from typing import Any, Dict, List, Optional
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def filter_by_image_tag(
|
|
25
|
+
parent: Dict[str, Any], service_image_tags: Dict[str, Optional[str]]
|
|
26
|
+
) -> Dict[str, Any]:
|
|
27
|
+
"""
|
|
28
|
+
Filters services to only include those that are currently running with the specified image tags.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
parent (Dict[str, Any]): The compose data.
|
|
32
|
+
service_image_tags (Dict[str, Optional[str]]): A map of service names to image tags.
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
Dict[str, Any]: The filtered compose data.
|
|
36
|
+
"""
|
|
37
|
+
# Filter running containers by container name.
|
|
38
|
+
name_filters = '$" -f "name='.join(service_image_tags.keys())
|
|
39
|
+
cmd = f'docker ps -f "name={name_filters}$" --format "{{{{.Names}}}}"'
|
|
40
|
+
result = subprocess.run(cmd, shell=True, stdout=subprocess.PIPE, check=False)
|
|
41
|
+
running_containers = result.stdout.decode("utf-8").strip().split("\n")
|
|
42
|
+
|
|
43
|
+
parent["services"] = {
|
|
44
|
+
name: params
|
|
45
|
+
for name, params in parent["services"].items()
|
|
46
|
+
if parent["services"][name].get("container_name") in running_containers
|
|
47
|
+
or service_image_tags[name] is None
|
|
48
|
+
}
|
|
49
|
+
return parent
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def remove_dependencies_from_filtered_containers(
|
|
53
|
+
parent: Dict[str, Any],
|
|
54
|
+
groups: Dict[str, List[str]],
|
|
55
|
+
only_managed: bool = False,
|
|
56
|
+
managed_services_set: Optional[set[str]] = None,
|
|
57
|
+
) -> Dict[str, Any]:
|
|
58
|
+
"""
|
|
59
|
+
Removes dependencies from services that are not in the managed groups.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
parent (Dict[str, Any]): The compose data.
|
|
63
|
+
groups (Dict[str, List[str]]): The group definitions.
|
|
64
|
+
only_managed (bool): If True, only removes dependencies that are managed externally depending on the deployment.
|
|
65
|
+
managed_services_set (Optional[set[str]]): Set of managed services to remove from dependencies.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
Dict[str, Any]: The updated compose data.
|
|
69
|
+
"""
|
|
70
|
+
if managed_services_set is None:
|
|
71
|
+
managed_services_set = {
|
|
72
|
+
s
|
|
73
|
+
for s, c in parent["services"].items()
|
|
74
|
+
if c.get("x-subcompose-managed", False)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
for service_config in parent["services"].values():
|
|
78
|
+
if not only_managed:
|
|
79
|
+
service_config.pop("depends_on", None)
|
|
80
|
+
continue
|
|
81
|
+
|
|
82
|
+
depends_on = service_config.get("depends_on")
|
|
83
|
+
if not depends_on:
|
|
84
|
+
continue
|
|
85
|
+
|
|
86
|
+
if isinstance(depends_on, list):
|
|
87
|
+
service_config["depends_on"] = [
|
|
88
|
+
s for s in depends_on if s not in managed_services_set
|
|
89
|
+
]
|
|
90
|
+
elif isinstance(depends_on, dict):
|
|
91
|
+
for s in managed_services_set:
|
|
92
|
+
depends_on.pop(s, None)
|
|
93
|
+
|
|
94
|
+
if not service_config["depends_on"]:
|
|
95
|
+
del service_config["depends_on"]
|
|
96
|
+
|
|
97
|
+
return parent
|
subcompose/logger.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# Copyright (C) 2026 Jose Hernandez
|
|
2
|
+
#
|
|
3
|
+
# This file is part of subcompose.
|
|
4
|
+
#
|
|
5
|
+
# subcompose is free software: you can redistribute it and/or modify
|
|
6
|
+
# it under the terms of the GNU General Public License as published by
|
|
7
|
+
# the Free Software Foundation, either version 3 of the License, or
|
|
8
|
+
# (at your option) any later version.
|
|
9
|
+
#
|
|
10
|
+
# subcompose is distributed in the hope that it will be useful,
|
|
11
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
12
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
13
|
+
# GNU General Public License for more details.
|
|
14
|
+
#
|
|
15
|
+
# You should have received a copy of the GNU General Public License
|
|
16
|
+
# along with subcompose. If not, see <https://www.gnu.org/licenses/>.
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
"""Logging configuration for subcompose."""
|
|
20
|
+
|
|
21
|
+
import logging
|
|
22
|
+
import sys
|
|
23
|
+
|
|
24
|
+
from subcompose.constants import MAX_LEVELNAME_LEN
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def configure_logging(debug: bool) -> None:
|
|
28
|
+
"""
|
|
29
|
+
Configure logging for the application.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
debug (bool): Whether to enable debug logging.
|
|
33
|
+
"""
|
|
34
|
+
root_logger = logging.getLogger()
|
|
35
|
+
root_logger.setLevel(logging.DEBUG if debug else logging.INFO)
|
|
36
|
+
|
|
37
|
+
# Remove existing handlers to avoid duplication or wrong configuration
|
|
38
|
+
if root_logger.handlers:
|
|
39
|
+
for handler in root_logger.handlers:
|
|
40
|
+
root_logger.removeHandler(handler)
|
|
41
|
+
|
|
42
|
+
handler = logging.StreamHandler(sys.stdout)
|
|
43
|
+
handler.setLevel(logging.DEBUG if debug else logging.INFO)
|
|
44
|
+
formatter = logging.Formatter(
|
|
45
|
+
f"[%(levelname)-{MAX_LEVELNAME_LEN}s] %(name)s: %(message)s"
|
|
46
|
+
)
|
|
47
|
+
handler.setFormatter(formatter)
|
|
48
|
+
root_logger.addHandler(handler)
|