docker-stack 0.3.1__tar.gz → 1.0.0__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_stack-0.3.1 → docker_stack-1.0.0}/PKG-INFO +1 -1
- {docker_stack-0.3.1 → docker_stack-1.0.0}/docker_stack/cli.py +271 -16
- {docker_stack-0.3.1 → docker_stack-1.0.0}/docker_stack/envsubst.py +7 -4
- {docker_stack-0.3.1 → docker_stack-1.0.0}/docker_stack.egg-info/PKG-INFO +1 -1
- {docker_stack-0.3.1 → docker_stack-1.0.0}/docker_stack.egg-info/SOURCES.txt +3 -1
- {docker_stack-0.3.1 → docker_stack-1.0.0}/setup.py +1 -1
- docker_stack-1.0.0/tests/test_docker_stack.py +107 -0
- docker_stack-1.0.0/tests/test_load_env.py +154 -0
- docker_stack-1.0.0/tests/test_node_ls.py +76 -0
- docker_stack-0.3.1/tests/test_docker_stack.py +0 -7
- {docker_stack-0.3.1 → docker_stack-1.0.0}/README.md +0 -0
- {docker_stack-0.3.1 → docker_stack-1.0.0}/docker_stack/__init__.py +0 -0
- {docker_stack-0.3.1 → docker_stack-1.0.0}/docker_stack/compose.py +0 -0
- {docker_stack-0.3.1 → docker_stack-1.0.0}/docker_stack/docker_objects.py +0 -0
- {docker_stack-0.3.1 → docker_stack-1.0.0}/docker_stack/envsubst_merge.py +0 -0
- {docker_stack-0.3.1 → docker_stack-1.0.0}/docker_stack/helpers.py +0 -0
- {docker_stack-0.3.1 → docker_stack-1.0.0}/docker_stack/markers.py +0 -0
- {docker_stack-0.3.1 → docker_stack-1.0.0}/docker_stack/merge_conf.py +0 -0
- {docker_stack-0.3.1 → docker_stack-1.0.0}/docker_stack/registry.py +0 -0
- {docker_stack-0.3.1 → docker_stack-1.0.0}/docker_stack/url_parser.py +0 -0
- {docker_stack-0.3.1 → docker_stack-1.0.0}/docker_stack.egg-info/dependency_links.txt +0 -0
- {docker_stack-0.3.1 → docker_stack-1.0.0}/docker_stack.egg-info/entry_points.txt +0 -0
- {docker_stack-0.3.1 → docker_stack-1.0.0}/docker_stack.egg-info/requires.txt +0 -0
- {docker_stack-0.3.1 → docker_stack-1.0.0}/docker_stack.egg-info/top_level.txt +0 -0
- {docker_stack-0.3.1 → docker_stack-1.0.0}/pyproject.toml +0 -0
- {docker_stack-0.3.1 → docker_stack-1.0.0}/setup.cfg +0 -0
- {docker_stack-0.3.1 → docker_stack-1.0.0}/tests/test_docker_objects.py +0 -0
|
@@ -1,23 +1,196 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
import argparse
|
|
3
3
|
import base64
|
|
4
|
+
from dataclasses import dataclass
|
|
4
5
|
import re
|
|
6
|
+
import shutil
|
|
5
7
|
import subprocess
|
|
6
8
|
import sys
|
|
9
|
+
import textwrap
|
|
7
10
|
from pathlib import Path
|
|
8
|
-
from typing import Dict, List
|
|
11
|
+
from typing import Dict, List, Optional, Tuple
|
|
9
12
|
import os
|
|
10
13
|
import yaml
|
|
11
14
|
import json
|
|
12
15
|
from docker_stack.docker_objects import DockerConfig, DockerObjectManager, DockerSecret
|
|
13
16
|
from docker_stack.helpers import Command, generate_secret
|
|
14
17
|
from docker_stack.registry import DockerRegistry
|
|
15
|
-
from .envsubst import envsubst, envsubst_load_file
|
|
18
|
+
from .envsubst import LineCheckResult, SubstitutionError, envsubst, envsubst_load_file
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class EnvFileEntry:
|
|
23
|
+
key: str
|
|
24
|
+
value: str
|
|
25
|
+
line_no: int
|
|
26
|
+
line_content: str
|
|
27
|
+
value_start_index: int
|
|
28
|
+
value_inner_offset: int
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class EnvFileResolutionError(Exception):
|
|
32
|
+
def __init__(self, env_file: str, reason: str, results: List[LineCheckResult], template_lines: List[str]):
|
|
33
|
+
self.env_file = env_file
|
|
34
|
+
self.reason = reason
|
|
35
|
+
self.results = results
|
|
36
|
+
self.template_lines = template_lines
|
|
37
|
+
super().__init__(self._format_message())
|
|
38
|
+
|
|
39
|
+
def _format_message(self) -> str:
|
|
40
|
+
formatted_lines = [f"{self.reason} in {self.env_file}:"]
|
|
41
|
+
errors_by_line = {}
|
|
42
|
+
|
|
43
|
+
for result in self.results:
|
|
44
|
+
if result.line_no not in errors_by_line:
|
|
45
|
+
errors_by_line[result.line_no] = {"line_content": result.line_content, "variables": []}
|
|
46
|
+
errors_by_line[result.line_no]["variables"].append({"name": result.variable_name, "start_index": result.start_index})
|
|
47
|
+
|
|
48
|
+
for line_no in sorted(errors_by_line):
|
|
49
|
+
line_chars = list(errors_by_line[line_no]["line_content"])
|
|
50
|
+
for item in sorted(errors_by_line[line_no]["variables"], key=lambda x: x["start_index"], reverse=True):
|
|
51
|
+
for idx in range(len(item["name"]) - 1, -1, -1):
|
|
52
|
+
line_chars.insert(item["start_index"] + idx + 1, "\u0333")
|
|
53
|
+
formatted_lines.append(f"{line_no:3d} {''.join(line_chars)}")
|
|
54
|
+
|
|
55
|
+
return "\n".join(formatted_lines)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
ENV_VAR_PATTERN = re.compile(r"\$\{([^}:\s]+)(?::-(.*?))?\}|\$([a-zA-Z_][a-zA-Z0-9_]*)")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _strip_matching_quotes(value: str) -> Tuple[str, int]:
|
|
62
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
|
|
63
|
+
return value[1:-1], 1
|
|
64
|
+
return value, 0
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _parse_env_file(env_file: str) -> Tuple[List[EnvFileEntry], List[str]]:
|
|
68
|
+
entries: List[EnvFileEntry] = []
|
|
69
|
+
with open(env_file) as f:
|
|
70
|
+
template_lines = f.read().splitlines(keepends=False)
|
|
71
|
+
|
|
72
|
+
for line_no, raw_line in enumerate(template_lines, start=1):
|
|
73
|
+
stripped = raw_line.strip()
|
|
74
|
+
if not stripped or stripped.startswith("#"):
|
|
75
|
+
continue
|
|
76
|
+
|
|
77
|
+
key_part, separator, value_part = raw_line.partition("=")
|
|
78
|
+
if not separator:
|
|
79
|
+
continue
|
|
80
|
+
|
|
81
|
+
key = key_part.strip()
|
|
82
|
+
leading_ws = len(value_part) - len(value_part.lstrip())
|
|
83
|
+
value_start_index = len(key_part) + 1 + leading_ws
|
|
84
|
+
value, value_inner_offset = _strip_matching_quotes(value_part.strip())
|
|
85
|
+
entries.append(
|
|
86
|
+
EnvFileEntry(
|
|
87
|
+
key=key,
|
|
88
|
+
value=value,
|
|
89
|
+
line_no=line_no,
|
|
90
|
+
line_content=raw_line,
|
|
91
|
+
value_start_index=value_start_index,
|
|
92
|
+
value_inner_offset=value_inner_offset,
|
|
93
|
+
)
|
|
94
|
+
)
|
|
95
|
+
return entries, template_lines
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _extract_refs(value: str) -> List[Tuple[str, int]]:
|
|
99
|
+
refs = []
|
|
100
|
+
for match in ENV_VAR_PATTERN.finditer(value):
|
|
101
|
+
var = match.group(1) if match.group(1) is not None else match.group(3)
|
|
102
|
+
start = match.start(1) if match.group(1) is not None else match.start(3)
|
|
103
|
+
refs.append((var, start))
|
|
104
|
+
return refs
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _map_substitution_error(entry: EnvFileEntry, err: SubstitutionError) -> List[LineCheckResult]:
|
|
108
|
+
mapped_results = []
|
|
109
|
+
for result in err.results:
|
|
110
|
+
mapped_results.append(
|
|
111
|
+
LineCheckResult(
|
|
112
|
+
line_no=entry.line_no,
|
|
113
|
+
line_content=entry.line_content,
|
|
114
|
+
variable_name=result.variable_name,
|
|
115
|
+
start_index=entry.value_start_index + entry.value_inner_offset + (result.start_index or 0),
|
|
116
|
+
)
|
|
117
|
+
)
|
|
118
|
+
return mapped_results
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _resolve_env_entries(entries: List[EnvFileEntry], env_file: str, base_env: Optional[Dict[str, str]] = None, max_cycles: int = 5) -> Dict[str, str]:
|
|
122
|
+
base_env = dict(base_env or os.environ)
|
|
123
|
+
current_values = {entry.key: entry.value for entry in entries}
|
|
124
|
+
local_keys = set(current_values.keys())
|
|
125
|
+
resolution_passes = max(max_cycles, len(entries))
|
|
126
|
+
|
|
127
|
+
for _ in range(resolution_passes):
|
|
128
|
+
next_values: Dict[str, str] = {}
|
|
129
|
+
missing_results: List[LineCheckResult] = []
|
|
130
|
+
changed = False
|
|
131
|
+
|
|
132
|
+
resolution_env = {**base_env, **current_values}
|
|
133
|
+
for entry in entries:
|
|
134
|
+
try:
|
|
135
|
+
resolved = envsubst(entry.value, env=resolution_env, on_error="throw")
|
|
136
|
+
except SubstitutionError as err:
|
|
137
|
+
missing_results.extend(_map_substitution_error(entry, err))
|
|
138
|
+
continue
|
|
139
|
+
|
|
140
|
+
next_values[entry.key] = resolved
|
|
141
|
+
if resolved != current_values[entry.key]:
|
|
142
|
+
changed = True
|
|
143
|
+
|
|
144
|
+
if missing_results:
|
|
145
|
+
missing_results.sort(key=lambda x: (x.line_no, x.start_index or 0))
|
|
146
|
+
raise EnvFileResolutionError(env_file, "Missing environment variables", missing_results, [])
|
|
147
|
+
|
|
148
|
+
current_values = next_values
|
|
149
|
+
if not changed:
|
|
150
|
+
break
|
|
151
|
+
|
|
152
|
+
cyclic_results: List[LineCheckResult] = []
|
|
153
|
+
for entry in entries:
|
|
154
|
+
final_value = current_values[entry.key]
|
|
155
|
+
for var_name, start_index in _extract_refs(final_value):
|
|
156
|
+
if var_name in local_keys:
|
|
157
|
+
original_refs = [(name, idx) for name, idx in _extract_refs(entry.value) if name in local_keys]
|
|
158
|
+
ref_positions = original_refs or [(var_name, 0)]
|
|
159
|
+
for original_name, original_index in ref_positions:
|
|
160
|
+
cyclic_results.append(
|
|
161
|
+
LineCheckResult(
|
|
162
|
+
line_no=entry.line_no,
|
|
163
|
+
line_content=entry.line_content,
|
|
164
|
+
variable_name=original_name,
|
|
165
|
+
start_index=entry.value_start_index + entry.value_inner_offset + original_index,
|
|
166
|
+
)
|
|
167
|
+
)
|
|
168
|
+
break
|
|
169
|
+
|
|
170
|
+
if cyclic_results:
|
|
171
|
+
deduped = {
|
|
172
|
+
(result.line_no, result.variable_name, result.start_index): result for result in cyclic_results
|
|
173
|
+
}
|
|
174
|
+
ordered_results = sorted(deduped.values(), key=lambda x: (x.line_no, x.start_index or 0))
|
|
175
|
+
raise EnvFileResolutionError(
|
|
176
|
+
env_file,
|
|
177
|
+
f"Cyclic environment variable references detected after {resolution_passes} resolution passes",
|
|
178
|
+
ordered_results,
|
|
179
|
+
[],
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
return current_values
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def load_env_file(env_file: str, base_env: Optional[Dict[str, str]] = None, max_cycles: int = 5) -> Dict[str, str]:
|
|
186
|
+
entries, _ = _parse_env_file(env_file)
|
|
187
|
+
return _resolve_env_entries(entries, env_file, base_env=base_env, max_cycles=max_cycles)
|
|
16
188
|
|
|
17
189
|
|
|
18
190
|
class Docker:
|
|
19
191
|
def __init__(self, registries: List[str] = []):
|
|
20
192
|
self.stack = DockerStack(self)
|
|
193
|
+
self.node = DockerNode()
|
|
21
194
|
self.config = DockerConfig()
|
|
22
195
|
self.secret = DockerSecret()
|
|
23
196
|
self.registry = DockerRegistry(registries)
|
|
@@ -25,12 +198,7 @@ class Docker:
|
|
|
25
198
|
@staticmethod
|
|
26
199
|
def load_env(env_file=".env"):
|
|
27
200
|
if Path(env_file).is_file():
|
|
28
|
-
|
|
29
|
-
for line in f:
|
|
30
|
-
line = line.strip()
|
|
31
|
-
if line and not line.startswith("#"):
|
|
32
|
-
key, _, value = line.partition("=")
|
|
33
|
-
os.environ[key.strip()] = value.strip()
|
|
201
|
+
os.environ.update(load_env_file(env_file))
|
|
34
202
|
|
|
35
203
|
@staticmethod
|
|
36
204
|
def check_env(example_file=".env.example"):
|
|
@@ -113,11 +281,19 @@ class DockerStack:
|
|
|
113
281
|
"""
|
|
114
282
|
processed_objects = {}
|
|
115
283
|
|
|
116
|
-
def add_obj(name, data, is_generated_secret=False):
|
|
284
|
+
def add_obj(name, data, explicit_name=None, is_generated_secret=False):
|
|
117
285
|
labels = []
|
|
118
286
|
if is_generated_secret:
|
|
119
287
|
labels.append("mesudip.secret.generated=true")
|
|
120
|
-
|
|
288
|
+
|
|
289
|
+
if explicit_name:
|
|
290
|
+
docker_object_name = explicit_name
|
|
291
|
+
elif stack:
|
|
292
|
+
docker_object_name = f"{stack}_{name}"
|
|
293
|
+
else:
|
|
294
|
+
docker_object_name = name
|
|
295
|
+
|
|
296
|
+
(object_name, command) = manager.create(docker_object_name, data, labels=labels, stack=stack)
|
|
121
297
|
if not command.isNop():
|
|
122
298
|
self.commands.append(command)
|
|
123
299
|
# If a new secret was actually created (not just reused), store it
|
|
@@ -126,17 +302,18 @@ class DockerStack:
|
|
|
126
302
|
processed_objects[name] = {"name": object_name, "external": True}
|
|
127
303
|
|
|
128
304
|
for name, details in objects.items():
|
|
305
|
+
explicit_name = details.get("name") if isinstance(details, dict) else None
|
|
129
306
|
if isinstance(details, dict) and "x-content" in details:
|
|
130
|
-
add_obj(name, details["x-content"])
|
|
307
|
+
add_obj(name, details["x-content"], explicit_name=explicit_name)
|
|
131
308
|
elif isinstance(details, dict) and "x-template" in details:
|
|
132
|
-
add_obj(name, envsubst(details["x-content"], os.environ))
|
|
309
|
+
add_obj(name, envsubst(details["x-content"], os.environ), explicit_name=explicit_name)
|
|
133
310
|
elif isinstance(details, dict) and "x-template-file" in details:
|
|
134
311
|
filename = os.path.join(base_dir, details["x-template-file"])
|
|
135
|
-
add_obj(name, envsubst_load_file(filename, os.environ))
|
|
312
|
+
add_obj(name, envsubst_load_file(filename, os.environ), explicit_name=explicit_name)
|
|
136
313
|
elif isinstance(details, dict) and "file" in details:
|
|
137
314
|
filename = os.path.join(base_dir, details["file"])
|
|
138
315
|
with open(filename) as file:
|
|
139
|
-
add_obj(name, file.read())
|
|
316
|
+
add_obj(name, file.read(), explicit_name=explicit_name)
|
|
140
317
|
elif isinstance(details, dict) and "x-generate" in details and manager.object_type == "secret":
|
|
141
318
|
is_generated_secret = True
|
|
142
319
|
generate_options = details["x-generate"]
|
|
@@ -159,7 +336,7 @@ class DockerStack:
|
|
|
159
336
|
raise ValueError(f"Invalid x-generate value for secret {name}: {generate_options}")
|
|
160
337
|
|
|
161
338
|
# Call add_obj with the potentially new secret content and the generated flag
|
|
162
|
-
add_obj(name, secret_content, is_generated_secret=True)
|
|
339
|
+
add_obj(name, secret_content, explicit_name=explicit_name, is_generated_secret=True)
|
|
163
340
|
else:
|
|
164
341
|
processed_objects[name] = details
|
|
165
342
|
return processed_objects
|
|
@@ -359,6 +536,11 @@ class DockerStack:
|
|
|
359
536
|
|
|
360
537
|
build_command = ["docker", "build", "-t", image]
|
|
361
538
|
|
|
539
|
+
dockerfile = build_config.get("dockerfile")
|
|
540
|
+
context_path = os.path.normpath(os.path.join(base_dir, build_config.get("context", ".")))
|
|
541
|
+
if dockerfile:
|
|
542
|
+
build_command.extend(["-f", os.path.normpath(os.path.join(context_path, dockerfile))])
|
|
543
|
+
|
|
362
544
|
args = build_config.get("args", [])
|
|
363
545
|
|
|
364
546
|
if isinstance(args, dict):
|
|
@@ -368,7 +550,7 @@ class DockerStack:
|
|
|
368
550
|
for value in args:
|
|
369
551
|
build_command.extend(["--build-arg", envsubst(value)])
|
|
370
552
|
|
|
371
|
-
build_command.append(
|
|
553
|
+
build_command.append(context_path)
|
|
372
554
|
self.commands.append(Command(build_command))
|
|
373
555
|
|
|
374
556
|
if push:
|
|
@@ -388,6 +570,72 @@ class DockerStack:
|
|
|
388
570
|
self.commands.append(cmd)
|
|
389
571
|
|
|
390
572
|
|
|
573
|
+
class DockerNode:
|
|
574
|
+
@staticmethod
|
|
575
|
+
def _format_labels(labels: Dict[str, str]) -> str:
|
|
576
|
+
if not labels:
|
|
577
|
+
return "-"
|
|
578
|
+
parts = []
|
|
579
|
+
for key, value in sorted(labels.items()):
|
|
580
|
+
parts.append(key if value == "true" else f"{key}={value}")
|
|
581
|
+
return ", ".join(parts)
|
|
582
|
+
|
|
583
|
+
@staticmethod
|
|
584
|
+
def ls():
|
|
585
|
+
nodes_output = subprocess.check_output(["docker", "node", "ls", "--format", "{{json .}}"], text=True).strip()
|
|
586
|
+
rows = []
|
|
587
|
+
|
|
588
|
+
for line in nodes_output.splitlines():
|
|
589
|
+
if not line:
|
|
590
|
+
continue
|
|
591
|
+
node = json.loads(line)
|
|
592
|
+
inspect = json.loads(
|
|
593
|
+
subprocess.check_output(["docker", "node", "inspect", node["ID"], "--format", "{{json .}}"], text=True).strip()
|
|
594
|
+
)
|
|
595
|
+
labels = inspect.get("Spec", {}).get("Labels", {})
|
|
596
|
+
manager_status = node.get("ManagerStatus", "").strip()
|
|
597
|
+
role = inspect.get("Spec", {}).get("Role", "-")
|
|
598
|
+
role_display = f"{role} ({manager_status})" if manager_status else role
|
|
599
|
+
rows.append(
|
|
600
|
+
{
|
|
601
|
+
"hostname": node.get("Hostname", "-"),
|
|
602
|
+
"role": role_display,
|
|
603
|
+
"state": f"{node.get('Status', '-')} / {node.get('Availability', '-')}",
|
|
604
|
+
"address": inspect.get("Status", {}).get("Addr", "-"),
|
|
605
|
+
"labels": DockerNode._format_labels(labels),
|
|
606
|
+
}
|
|
607
|
+
)
|
|
608
|
+
|
|
609
|
+
columns = [
|
|
610
|
+
("Hostname", "hostname"),
|
|
611
|
+
("Role", "role"),
|
|
612
|
+
("State", "state"),
|
|
613
|
+
("Address", "address"),
|
|
614
|
+
]
|
|
615
|
+
|
|
616
|
+
widths = {
|
|
617
|
+
key: max(len(title), max((len(str(row[key])) for row in rows), default=0))
|
|
618
|
+
for title, key in columns
|
|
619
|
+
}
|
|
620
|
+
terminal_width = shutil.get_terminal_size((120, 20)).columns
|
|
621
|
+
static_width = sum(widths.values()) + (3 * (len(columns) - 1))
|
|
622
|
+
label_width = max(24, min(60, terminal_width - static_width - 3 - len("Labels")))
|
|
623
|
+
|
|
624
|
+
header = " | ".join(title.ljust(widths[key]) for title, key in columns) + " | Labels"
|
|
625
|
+
separator = "-+-".join("-" * widths[key] for _, key in columns) + "-+-" + ("-" * label_width)
|
|
626
|
+
print(header)
|
|
627
|
+
print(separator)
|
|
628
|
+
for row in rows:
|
|
629
|
+
wrapped_labels = textwrap.wrap(row["labels"], width=label_width, break_long_words=False, break_on_hyphens=False) or ["-"]
|
|
630
|
+
first_line = " | ".join(str(row[key]).ljust(widths[key]) for _, key in columns)
|
|
631
|
+
print(f"{first_line} | {wrapped_labels[0]}")
|
|
632
|
+
continuation_prefix = " | ".join("".ljust(widths[key]) for _, key in columns)
|
|
633
|
+
for label_line in wrapped_labels[1:]:
|
|
634
|
+
print(f"{continuation_prefix} | {label_line}")
|
|
635
|
+
|
|
636
|
+
return rows
|
|
637
|
+
|
|
638
|
+
|
|
391
639
|
def main(args: List[str] = None):
|
|
392
640
|
parser = argparse.ArgumentParser(description="Deploy and manage Docker stacks.")
|
|
393
641
|
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
@@ -419,6 +667,10 @@ def main(args: List[str] = None):
|
|
|
419
667
|
# Ls command
|
|
420
668
|
subparsers.add_parser("ls", help="List docker-stacks")
|
|
421
669
|
|
|
670
|
+
node_parser = subparsers.add_parser("node", help="Inspect Docker Swarm nodes")
|
|
671
|
+
node_subparsers = node_parser.add_subparsers(dest="node_command", required=True)
|
|
672
|
+
node_subparsers.add_parser("ls", help="List Docker Swarm nodes and their labels")
|
|
673
|
+
|
|
422
674
|
cat_parser = subparsers.add_parser(
|
|
423
675
|
"cat", help="Print the docker compose of specific version. Defaults to latest version if not specified."
|
|
424
676
|
)
|
|
@@ -458,6 +710,9 @@ def main(args: List[str] = None):
|
|
|
458
710
|
docker.stack.deploy(args.stack_name, args.compose_file, args.with_registry_auth, tag=args.tag, show_generated=args.show_generated)
|
|
459
711
|
elif args.command == "ls":
|
|
460
712
|
docker.stack.ls()
|
|
713
|
+
elif args.command == "node":
|
|
714
|
+
if args.node_command == "ls":
|
|
715
|
+
docker.node.ls()
|
|
461
716
|
|
|
462
717
|
elif args.command == "rm":
|
|
463
718
|
docker.stack.rm(args.stack_name)
|
|
@@ -123,16 +123,19 @@ def envsubst(template_str, env=os.environ, replacements: Dict[str, str] = None,
|
|
|
123
123
|
# Group 1, 2 for ${VAR:-default}
|
|
124
124
|
if match.group(1) is not None:
|
|
125
125
|
var = match.group(1)
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
126
|
+
has_default = match.group(2) is not None
|
|
127
|
+
default_value = match.group(2) if has_default else None
|
|
128
|
+
result = env.get(var, None)
|
|
129
|
+
if result in (None, "") and has_default:
|
|
130
|
+
result = default_value
|
|
131
|
+
if result in (None, "") and not has_default:
|
|
129
132
|
line_errors_raw.append((var, match.start(1))) # Use match.start(1) for ${VAR}
|
|
130
133
|
return match.group(0) # Keep original if variable not found
|
|
131
134
|
# Group 3 for $VAR
|
|
132
135
|
else:
|
|
133
136
|
var = match.group(3)
|
|
134
137
|
result = env.get(var, None)
|
|
135
|
-
if result
|
|
138
|
+
if result in (None, ""):
|
|
136
139
|
line_errors_raw.append((var, match.start(3))) # Use match.start(3) for $VAR
|
|
137
140
|
return match.group(0) # Keep original if variable not found
|
|
138
141
|
|
|
@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
|
|
|
2
2
|
|
|
3
3
|
setup(
|
|
4
4
|
name="docker-stack",
|
|
5
|
-
version="0.
|
|
5
|
+
version="1.0.0",
|
|
6
6
|
description="CLI for deploying and managing Docker stacks.",
|
|
7
7
|
long_description=open("README.md").read(), # You can include a README file to describe your package
|
|
8
8
|
long_description_content_type="text/markdown",
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
from docker_stack.cli import Docker
|
|
2
|
+
from docker_stack.docker_objects import DockerConfig, DockerSecret
|
|
3
|
+
from docker_stack.helpers import Command, run_cli_command
|
|
4
|
+
from docker_stack import main
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class RecordingManager:
|
|
8
|
+
def __init__(self):
|
|
9
|
+
self.calls = []
|
|
10
|
+
|
|
11
|
+
def create(self, object_name, object_content, labels=None, stack=None):
|
|
12
|
+
self.calls.append(
|
|
13
|
+
{
|
|
14
|
+
"object_name": object_name,
|
|
15
|
+
"object_content": object_content,
|
|
16
|
+
"labels": labels or [],
|
|
17
|
+
"stack": stack,
|
|
18
|
+
}
|
|
19
|
+
)
|
|
20
|
+
return object_name, Command.nop
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_when_create_stack_support_x_content():
|
|
24
|
+
main(["deploy", "pytest_test_x_content", "./tests/docker-compose-example.yml"])
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_process_x_content_prefixes_stack_name_for_unnamed_objects():
|
|
28
|
+
docker = Docker()
|
|
29
|
+
manager = RecordingManager()
|
|
30
|
+
|
|
31
|
+
processed = docker.stack._process_x_content(
|
|
32
|
+
{
|
|
33
|
+
"config.json": {
|
|
34
|
+
"x-content": "hello",
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
manager,
|
|
38
|
+
stack="govtool",
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
assert manager.calls == [
|
|
42
|
+
{
|
|
43
|
+
"object_name": "govtool_config.json",
|
|
44
|
+
"object_content": "hello",
|
|
45
|
+
"labels": [],
|
|
46
|
+
"stack": "govtool",
|
|
47
|
+
}
|
|
48
|
+
]
|
|
49
|
+
assert processed == {"config.json": {"name": "govtool_config.json", "external": True}}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_process_x_content_preserves_explicit_name_override():
|
|
53
|
+
docker = Docker()
|
|
54
|
+
manager = RecordingManager()
|
|
55
|
+
|
|
56
|
+
processed = docker.stack._process_x_content(
|
|
57
|
+
{
|
|
58
|
+
"config.json": {
|
|
59
|
+
"name": "shared-config",
|
|
60
|
+
"x-content": "hello",
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
manager,
|
|
64
|
+
stack="govtool",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
assert manager.calls == [
|
|
68
|
+
{
|
|
69
|
+
"object_name": "shared-config",
|
|
70
|
+
"object_content": "hello",
|
|
71
|
+
"labels": [],
|
|
72
|
+
"stack": "govtool",
|
|
73
|
+
}
|
|
74
|
+
]
|
|
75
|
+
assert processed == {"config.json": {"name": "shared-config", "external": True}}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_build_uses_service_dockerfile(tmp_path):
|
|
79
|
+
compose_file = tmp_path / "docker-compose.yml"
|
|
80
|
+
compose_file.write_text(
|
|
81
|
+
"""
|
|
82
|
+
services:
|
|
83
|
+
storybook:
|
|
84
|
+
image: example/storybook:test
|
|
85
|
+
build:
|
|
86
|
+
context: ./frontend
|
|
87
|
+
dockerfile: storybook.Dockerfile
|
|
88
|
+
args:
|
|
89
|
+
NPMRC_TOKEN: dummy
|
|
90
|
+
""".strip()
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
docker = Docker()
|
|
94
|
+
docker.stack.build_and_push(str(compose_file))
|
|
95
|
+
|
|
96
|
+
assert len(docker.stack.commands) == 1
|
|
97
|
+
assert docker.stack.commands[0].command == [
|
|
98
|
+
"docker",
|
|
99
|
+
"build",
|
|
100
|
+
"-t",
|
|
101
|
+
"example/storybook:test",
|
|
102
|
+
"-f",
|
|
103
|
+
str(tmp_path / "frontend" / "storybook.Dockerfile"),
|
|
104
|
+
"--build-arg",
|
|
105
|
+
"NPMRC_TOKEN=dummy",
|
|
106
|
+
str(tmp_path / "frontend"),
|
|
107
|
+
]
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import textwrap
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from docker_stack.cli import EnvFileResolutionError, load_env_file
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def write_env(tmp_path, content: str):
|
|
9
|
+
env_file = tmp_path / ".env"
|
|
10
|
+
env_file.write_text(textwrap.dedent(content).lstrip())
|
|
11
|
+
return env_file
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_load_env_file_resolves_forward_reference_order_independent(tmp_path):
|
|
15
|
+
env_file = write_env(
|
|
16
|
+
tmp_path,
|
|
17
|
+
"""
|
|
18
|
+
C=${A}_ing
|
|
19
|
+
A=someth
|
|
20
|
+
""",
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
values = load_env_file(str(env_file), base_env={})
|
|
24
|
+
|
|
25
|
+
assert values["A"] == "someth"
|
|
26
|
+
assert values["C"] == "someth_ing"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_load_env_file_strips_matching_quotes_from_values(tmp_path):
|
|
30
|
+
env_file = write_env(
|
|
31
|
+
tmp_path,
|
|
32
|
+
"""
|
|
33
|
+
PINATA_API_JWT="abc.def"
|
|
34
|
+
IPFS_PROJECT_ID=''
|
|
35
|
+
""",
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
values = load_env_file(str(env_file), base_env={})
|
|
39
|
+
|
|
40
|
+
assert values["PINATA_API_JWT"] == "abc.def"
|
|
41
|
+
assert values["IPFS_PROJECT_ID"] == ""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_load_env_file_resolves_references_inside_quoted_values(tmp_path):
|
|
45
|
+
env_file = write_env(
|
|
46
|
+
tmp_path,
|
|
47
|
+
"""
|
|
48
|
+
C="${A}_ing"
|
|
49
|
+
A="someth"
|
|
50
|
+
""",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
values = load_env_file(str(env_file), base_env={})
|
|
54
|
+
|
|
55
|
+
assert values["A"] == "someth"
|
|
56
|
+
assert values["C"] == "someth_ing"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_load_env_file_resolves_chained_references_within_five_cycles(tmp_path):
|
|
60
|
+
env_file = write_env(
|
|
61
|
+
tmp_path,
|
|
62
|
+
"""
|
|
63
|
+
E=${D}_e
|
|
64
|
+
D=${C}_d
|
|
65
|
+
C=${B}_c
|
|
66
|
+
B=${A}_b
|
|
67
|
+
A=base
|
|
68
|
+
""",
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
values = load_env_file(str(env_file), base_env={})
|
|
72
|
+
|
|
73
|
+
assert values["E"] == "base_b_c_d_e"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_load_env_file_resolves_long_acyclic_dependency_chain(tmp_path):
|
|
77
|
+
chain = "\n".join([f"V{i}=${{V{i+1}}}" for i in range(10)] + ["V10=ok"])
|
|
78
|
+
env_file = write_env(tmp_path, chain)
|
|
79
|
+
|
|
80
|
+
values = load_env_file(str(env_file), base_env={})
|
|
81
|
+
|
|
82
|
+
assert values["V0"] == "ok"
|
|
83
|
+
assert values["V10"] == "ok"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_load_env_file_reports_missing_variable_with_line_location(tmp_path):
|
|
87
|
+
env_file = write_env(
|
|
88
|
+
tmp_path,
|
|
89
|
+
"""
|
|
90
|
+
A=${MISSING}_suffix
|
|
91
|
+
""",
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
with pytest.raises(EnvFileResolutionError) as excinfo:
|
|
95
|
+
load_env_file(str(env_file), base_env={})
|
|
96
|
+
|
|
97
|
+
message = str(excinfo.value)
|
|
98
|
+
assert "Missing environment variables" in message
|
|
99
|
+
assert str(env_file) in message
|
|
100
|
+
assert "1 A=${M̳I̳S̳S̳I̳N̳G̳}_suffix" in message
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_load_env_file_reports_missing_variable_inside_quoted_value_with_line_location(tmp_path):
|
|
104
|
+
env_file = write_env(
|
|
105
|
+
tmp_path,
|
|
106
|
+
"""
|
|
107
|
+
A="${MISSING}_suffix"
|
|
108
|
+
""",
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
with pytest.raises(EnvFileResolutionError) as excinfo:
|
|
112
|
+
load_env_file(str(env_file), base_env={})
|
|
113
|
+
|
|
114
|
+
message = str(excinfo.value)
|
|
115
|
+
assert "Missing environment variables" in message
|
|
116
|
+
assert str(env_file) in message
|
|
117
|
+
assert '1 A="${M̳I̳S̳S̳I̳N̳G̳}_suffix"' in message
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_load_env_file_reports_empty_variable_reference_with_line_location(tmp_path):
|
|
121
|
+
env_file = write_env(
|
|
122
|
+
tmp_path,
|
|
123
|
+
"""
|
|
124
|
+
VAR1=
|
|
125
|
+
IMAGE=myapp:${VAR1}
|
|
126
|
+
""",
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
with pytest.raises(EnvFileResolutionError) as excinfo:
|
|
130
|
+
load_env_file(str(env_file), base_env={})
|
|
131
|
+
|
|
132
|
+
message = str(excinfo.value)
|
|
133
|
+
assert "Missing environment variables" in message
|
|
134
|
+
assert str(env_file) in message
|
|
135
|
+
assert "2 IMAGE=myapp:${V̳A̳R̳1̳}" in message
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def test_load_env_file_reports_cycle_with_line_locations(tmp_path):
|
|
139
|
+
env_file = write_env(
|
|
140
|
+
tmp_path,
|
|
141
|
+
"""
|
|
142
|
+
A=${B}
|
|
143
|
+
B=${A}
|
|
144
|
+
""",
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
with pytest.raises(EnvFileResolutionError) as excinfo:
|
|
148
|
+
load_env_file(str(env_file), base_env={})
|
|
149
|
+
|
|
150
|
+
message = str(excinfo.value)
|
|
151
|
+
assert "Cyclic environment variable references detected after 5 resolution passes" in message
|
|
152
|
+
assert str(env_file) in message
|
|
153
|
+
assert "1 A=${B̳}" in message
|
|
154
|
+
assert "2 B=${A̳}" in message
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
from docker_stack.cli import main
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_node_ls_prints_nodes_and_labels(monkeypatch, capsys):
|
|
8
|
+
inspect_payloads = {
|
|
9
|
+
"node-1": {
|
|
10
|
+
"Spec": {
|
|
11
|
+
"Role": "manager",
|
|
12
|
+
"Labels": {
|
|
13
|
+
"blockchain": "true",
|
|
14
|
+
"gateway": "true",
|
|
15
|
+
"newt.host": "prod1",
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
"Status": {"Addr": "10.0.0.1"},
|
|
19
|
+
},
|
|
20
|
+
"node-2": {
|
|
21
|
+
"Spec": {
|
|
22
|
+
"Role": "worker",
|
|
23
|
+
"Labels": {
|
|
24
|
+
"govtool": "true",
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
"Status": {"Addr": "10.0.0.2"},
|
|
28
|
+
},
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
def fake_check_output(command, text=True):
|
|
32
|
+
if command[:3] == ["docker", "node", "ls"]:
|
|
33
|
+
return "\n".join(
|
|
34
|
+
[
|
|
35
|
+
json.dumps(
|
|
36
|
+
{
|
|
37
|
+
"ID": "node-1",
|
|
38
|
+
"Hostname": "swarm-a",
|
|
39
|
+
"Status": "Ready",
|
|
40
|
+
"Availability": "Active",
|
|
41
|
+
"ManagerStatus": "Leader",
|
|
42
|
+
}
|
|
43
|
+
),
|
|
44
|
+
json.dumps(
|
|
45
|
+
{
|
|
46
|
+
"ID": "node-2",
|
|
47
|
+
"Hostname": "swarm-b",
|
|
48
|
+
"Status": "Ready",
|
|
49
|
+
"Availability": "Drain",
|
|
50
|
+
"ManagerStatus": "",
|
|
51
|
+
}
|
|
52
|
+
),
|
|
53
|
+
]
|
|
54
|
+
)
|
|
55
|
+
if command[:3] == ["docker", "node", "inspect"]:
|
|
56
|
+
return json.dumps(inspect_payloads[command[3]])
|
|
57
|
+
raise AssertionError(f"Unexpected command: {command}")
|
|
58
|
+
|
|
59
|
+
monkeypatch.setattr("docker_stack.cli.subprocess.check_output", fake_check_output)
|
|
60
|
+
monkeypatch.setattr("docker_stack.cli.shutil.get_terminal_size", lambda fallback: os.terminal_size((72, 20)))
|
|
61
|
+
|
|
62
|
+
main(["node", "ls"])
|
|
63
|
+
|
|
64
|
+
output = capsys.readouterr().out
|
|
65
|
+
assert "Hostname" in output
|
|
66
|
+
assert "Role" in output
|
|
67
|
+
assert "State" in output
|
|
68
|
+
assert "Labels" in output
|
|
69
|
+
assert "swarm-a" in output
|
|
70
|
+
assert "manager (Leader)" in output
|
|
71
|
+
assert "10.0.0.1" in output
|
|
72
|
+
assert "blockchain, gateway," in output
|
|
73
|
+
assert "newt.host=prod1" in output
|
|
74
|
+
assert "swarm-b" in output
|
|
75
|
+
assert "worker" in output
|
|
76
|
+
assert "govtool" in output
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
from docker_stack.docker_objects import DockerConfig, DockerSecret
|
|
2
|
-
from docker_stack.helpers import Command, run_cli_command
|
|
3
|
-
from docker_stack import main
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
def test_when_create_stack_support_x_content():
|
|
7
|
-
main(["deploy", "pytest_test_x_content", "./tests/docker-compose-example.yml"])
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|