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.
Files changed (27) hide show
  1. {docker_stack-0.3.1 → docker_stack-1.0.0}/PKG-INFO +1 -1
  2. {docker_stack-0.3.1 → docker_stack-1.0.0}/docker_stack/cli.py +271 -16
  3. {docker_stack-0.3.1 → docker_stack-1.0.0}/docker_stack/envsubst.py +7 -4
  4. {docker_stack-0.3.1 → docker_stack-1.0.0}/docker_stack.egg-info/PKG-INFO +1 -1
  5. {docker_stack-0.3.1 → docker_stack-1.0.0}/docker_stack.egg-info/SOURCES.txt +3 -1
  6. {docker_stack-0.3.1 → docker_stack-1.0.0}/setup.py +1 -1
  7. docker_stack-1.0.0/tests/test_docker_stack.py +107 -0
  8. docker_stack-1.0.0/tests/test_load_env.py +154 -0
  9. docker_stack-1.0.0/tests/test_node_ls.py +76 -0
  10. docker_stack-0.3.1/tests/test_docker_stack.py +0 -7
  11. {docker_stack-0.3.1 → docker_stack-1.0.0}/README.md +0 -0
  12. {docker_stack-0.3.1 → docker_stack-1.0.0}/docker_stack/__init__.py +0 -0
  13. {docker_stack-0.3.1 → docker_stack-1.0.0}/docker_stack/compose.py +0 -0
  14. {docker_stack-0.3.1 → docker_stack-1.0.0}/docker_stack/docker_objects.py +0 -0
  15. {docker_stack-0.3.1 → docker_stack-1.0.0}/docker_stack/envsubst_merge.py +0 -0
  16. {docker_stack-0.3.1 → docker_stack-1.0.0}/docker_stack/helpers.py +0 -0
  17. {docker_stack-0.3.1 → docker_stack-1.0.0}/docker_stack/markers.py +0 -0
  18. {docker_stack-0.3.1 → docker_stack-1.0.0}/docker_stack/merge_conf.py +0 -0
  19. {docker_stack-0.3.1 → docker_stack-1.0.0}/docker_stack/registry.py +0 -0
  20. {docker_stack-0.3.1 → docker_stack-1.0.0}/docker_stack/url_parser.py +0 -0
  21. {docker_stack-0.3.1 → docker_stack-1.0.0}/docker_stack.egg-info/dependency_links.txt +0 -0
  22. {docker_stack-0.3.1 → docker_stack-1.0.0}/docker_stack.egg-info/entry_points.txt +0 -0
  23. {docker_stack-0.3.1 → docker_stack-1.0.0}/docker_stack.egg-info/requires.txt +0 -0
  24. {docker_stack-0.3.1 → docker_stack-1.0.0}/docker_stack.egg-info/top_level.txt +0 -0
  25. {docker_stack-0.3.1 → docker_stack-1.0.0}/pyproject.toml +0 -0
  26. {docker_stack-0.3.1 → docker_stack-1.0.0}/setup.cfg +0 -0
  27. {docker_stack-0.3.1 → docker_stack-1.0.0}/tests/test_docker_objects.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: docker-stack
3
- Version: 0.3.1
3
+ Version: 1.0.0
4
4
  Summary: CLI for deploying and managing Docker stacks.
5
5
  Home-page: https://github.com/mesudip/docker-stack
6
6
  Author: Sudip Bhattarai
@@ -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
- with open(env_file) as f:
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
- (object_name, command) = manager.create(name, data, labels=labels, stack=stack)
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(os.path.join(base_dir, build_config.get("context", ".")))
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
- default_value = match.group(2) if match.group(2) is not None else None
127
- result = env.get(var, default_value)
128
- if result is None:
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 is None:
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
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: docker-stack
3
- Version: 0.3.1
3
+ Version: 1.0.0
4
4
  Summary: CLI for deploying and managing Docker stacks.
5
5
  Home-page: https://github.com/mesudip/docker-stack
6
6
  Author: Sudip Bhattarai
@@ -19,4 +19,6 @@ docker_stack.egg-info/entry_points.txt
19
19
  docker_stack.egg-info/requires.txt
20
20
  docker_stack.egg-info/top_level.txt
21
21
  tests/test_docker_objects.py
22
- tests/test_docker_stack.py
22
+ tests/test_docker_stack.py
23
+ tests/test_load_env.py
24
+ tests/test_node_ls.py
@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
2
2
 
3
3
  setup(
4
4
  name="docker-stack",
5
- version="0.3.1",
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