docker-stack 0.2.1__tar.gz → 0.2.3__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 (21) hide show
  1. {docker-stack-0.2.1 → docker-stack-0.2.3}/PKG-INFO +2 -2
  2. {docker-stack-0.2.1 → docker-stack-0.2.3}/docker_stack/cli.py +184 -8
  3. {docker-stack-0.2.1 → docker-stack-0.2.3}/docker_stack/envsubst.py +4 -0
  4. {docker-stack-0.2.1 → docker-stack-0.2.3}/docker_stack/helpers.py +15 -8
  5. {docker-stack-0.2.1 → docker-stack-0.2.3}/docker_stack.egg-info/PKG-INFO +2 -2
  6. {docker-stack-0.2.1 → docker-stack-0.2.3}/setup.py +2 -2
  7. {docker-stack-0.2.1 → docker-stack-0.2.3}/README.md +0 -0
  8. {docker-stack-0.2.1 → docker-stack-0.2.3}/docker_stack/__init__.py +0 -0
  9. {docker-stack-0.2.1 → docker-stack-0.2.3}/docker_stack/compose.py +0 -0
  10. {docker-stack-0.2.1 → docker-stack-0.2.3}/docker_stack/docker_objects.py +0 -0
  11. {docker-stack-0.2.1 → docker-stack-0.2.3}/docker_stack/envsubst_merge.py +0 -0
  12. {docker-stack-0.2.1 → docker-stack-0.2.3}/docker_stack/merge_conf.py +0 -0
  13. {docker-stack-0.2.1 → docker-stack-0.2.3}/docker_stack/registry.py +0 -0
  14. {docker-stack-0.2.1 → docker-stack-0.2.3}/docker_stack/url_parser.py +0 -0
  15. {docker-stack-0.2.1 → docker-stack-0.2.3}/docker_stack.egg-info/SOURCES.txt +0 -0
  16. {docker-stack-0.2.1 → docker-stack-0.2.3}/docker_stack.egg-info/dependency_links.txt +0 -0
  17. {docker-stack-0.2.1 → docker-stack-0.2.3}/docker_stack.egg-info/entry_points.txt +0 -0
  18. {docker-stack-0.2.1 → docker-stack-0.2.3}/docker_stack.egg-info/requires.txt +0 -0
  19. {docker-stack-0.2.1 → docker-stack-0.2.3}/docker_stack.egg-info/top_level.txt +0 -0
  20. {docker-stack-0.2.1 → docker-stack-0.2.3}/pyproject.toml +0 -0
  21. {docker-stack-0.2.1 → docker-stack-0.2.3}/setup.cfg +0 -0
@@ -1,10 +1,10 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: docker-stack
3
- Version: 0.2.1
3
+ Version: 0.2.3
4
4
  Summary: CLI for deploying and managing Docker stacks.
5
5
  Home-page: https://github.com/mesuidp/docker-stack
6
6
  Author: Sudip Bhattarai
7
- Author-email: sudip.dev.np@gmail.com
7
+ Author-email: sudip@bhattarai.me
8
8
  Classifier: Programming Language :: Python :: 3
9
9
  Classifier: License :: OSI Approved :: MIT License
10
10
  Classifier: Operating System :: OS Independent
@@ -1,5 +1,8 @@
1
1
  #!/usr/bin/env python3
2
2
  import argparse
3
+ import base64
4
+ import re
5
+ import subprocess
3
6
  import sys
4
7
  from pathlib import Path
5
8
  from typing import List
@@ -121,10 +124,150 @@ class DockerStack:
121
124
  else:
122
125
  processed_objects[name] = details
123
126
  return processed_objects
127
+
128
+ def ls(self):
129
+ cmd = ["docker", "config", "ls", "--format", "{{.ID}}\t{{.Name}}\t{{.Labels}}"]
130
+ output = subprocess.check_output(cmd, text=True).strip().split("\n")
131
+ stack_versions = {}
132
+
133
+ for line in output:
134
+ parts = line.split("\t")
135
+ if len(parts) == 3 and "mesudip.stack.name" in parts[2]:
136
+ labels = {k: v for k, v in (label.split("=") for label in parts[2].split(",") if "=" in label)}
137
+ stack_name = labels.get("mesudip.stack.name")
138
+ version = labels.get("mesudip.object.version", "unknown")
139
+
140
+ if stack_name:
141
+ if stack_name not in stack_versions:
142
+ stack_versions[stack_name] = []
143
+ stack_versions[stack_name].append(version)
144
+
145
+ # Calculate max stack name width
146
+ max_stack_name_length = max(len(stack) for stack in stack_versions) if stack_versions else 10
147
+ header_stack = "Stack Name".ljust(max_stack_name_length)
148
+
149
+ print(f"{header_stack} | Versions")
150
+ print("-" * (max_stack_name_length + 12))
151
+
152
+ for stack, versions in sorted(stack_versions.items()):
153
+ versions_str = ", ".join(sorted(versions, key=int))
154
+ print(f"{stack.ljust(max_stack_name_length)} | {versions_str}")
155
+
156
+ return stack_versions
157
+
158
+ def cat(self, name:str, version:str):
159
+ if version.startswith('v') or version.startswith('V'):
160
+ version = version[1:]
161
+ if version == '1':
162
+ name = f"{name}"
163
+ else:
164
+ name = f"{name}_v{version}"
165
+
166
+ cmd = ["docker", "config", "inspect", name]
167
+ output = subprocess.check_output(cmd, text=True).strip()
168
+
169
+ # Parse the JSON output
170
+ configs = json.loads(output)
171
+ if not configs:
172
+ print(f"No config found for {name}")
173
+ return None
174
+
175
+ # Extract and decode the base64-encoded Spec.Data
176
+ encoded_data = configs[0].get("Spec", {}).get("Data", "")
177
+ if not encoded_data:
178
+ print(f"No data found in config {name}")
179
+ return None
180
+
181
+ decoded_data = base64.b64decode(encoded_data).decode("utf-8")
182
+ return decoded_data
183
+
184
+ def versions(self, stack_name):
185
+ cmd = ["docker", "config", "ls", "--format", "{{.Name}}\t{{.Labels}}"]
186
+ output = subprocess.check_output(cmd, text=True).strip().split("\n")
187
+ versions_list = []
188
+
189
+ for line in output:
190
+ parts = line.split("\t")
191
+ if len(parts) == 2 and "mesudip.stack.name" in parts[1]:
192
+ labels = {k: v for k, v in (label.split("=") for label in parts[1].split(",") if "=" in label)}
193
+ stack = labels.get("mesudip.stack.name")
194
+ version = labels.get("mesudip.object.version", "unknown")
195
+ tag = labels.get("mesudip.stack.tag", "")
196
+
197
+ if stack == stack_name:
198
+ versions_list.append((version, tag))
199
+
200
+ # Add headers to list for proper spacing calculation
201
+ versions_list.insert(0, ("Version", "Tag"))
202
+
203
+ # Determine max column widths
204
+ max_version_length = max(len(v[0]) for v in versions_list)
205
+ max_tag_length = max(len(v[1]) for v in versions_list)
206
+
207
+ # Print header
208
+ print(f"{'Version'.ljust(max_version_length)} | {'Tag'.ljust(max_tag_length)}")
209
+ print("-" * (max_version_length + max_tag_length + 3))
210
+
211
+ # Print sorted versions (excluding header)
212
+ for version, tag in sorted(versions_list[1:], key=lambda x: int(x[0]) if x[0].isdigit() else x[0]):
213
+ print(f"{version.ljust(max_version_length)} | {tag.ljust(max_tag_length)}")
214
+
215
+ return versions_list[1:]
216
+
217
+ def checkout(self, stack_name, identifier, with_registry_auth=False):
218
+ """
219
+ Deploys a stack by version or tag.
220
+
221
+ :param stack_name: Name of the stack.
222
+ :param identifier: Version (e.g., 'v1.2', 'V3') or tag (e.g., 'stable', 'latest').
223
+ :param with_registry_auth: Whether to use registry authentication.
224
+ """
225
+
226
+ # Regex to check if the identifier is a version (optional "v" or "V" at the start, followed by digits)
227
+ version_pattern = re.compile(r"^[vV]?(\d+(\.\d+)*)$")
124
228
 
125
- def deploy(self, stack_name, compose_file, with_registry_auth=False):
229
+ match = version_pattern.match(identifier)
230
+ if match:
231
+ version = match.group(1) # Extract the numeric version part
232
+ tag = None
233
+ else:
234
+ tag = identifier
235
+ versions_list = self.versions(stack_name)
236
+ matching_versions = [v for v, t in versions_list if t == tag]
237
+ if not matching_versions:
238
+ raise ValueError(f"No version found for tag '{tag}' in stack '{stack_name}'")
239
+ version = matching_versions[0] # Use the first matching version
240
+
241
+ compose_content = self.cat(stack_name, version)
242
+
243
+ temp_file = f"/tmp/{stack_name}_v{version}.yml"
244
+ with open(temp_file, "w") as f:
245
+ f.write(compose_content)
246
+
247
+ print(f"Deploying stack {stack_name} with version {version} (tag: {tag})...")
248
+ self._deploy(stack_name, temp_file, compose_content, with_registry_auth=with_registry_auth, tag=tag)
249
+
250
+
251
+ def _deploy(self, stack_name, rendered_filename,rendered_content, with_registry_auth=False,tag=None):
252
+ labels = [f"mesudip.stack.name={stack_name}"]
253
+ if tag:
254
+ labels.append(f"mesudip.stack.tag={tag}")
255
+
256
+ _, cmd = self.docker.config.increment(stack_name, rendered_content,labels=labels ,stack=stack_name)
257
+ if not cmd.isNop():
258
+ self.commands.append(cmd)
259
+ cmd = ["docker", "stack", "deploy", "-c", str(rendered_filename), stack_name]
260
+ if with_registry_auth:
261
+ cmd.insert(3, "--with-registry-auth")
262
+ self.commands.append(Command(cmd,give_console=True))
263
+
264
+ def deploy(self, stack_name, compose_file, with_registry_auth=False,tag=None):
126
265
  rendered_filename, rendered_content = self.render_compose_file(compose_file,stack=stack_name)
127
- _, cmd = self.docker.config.increment(stack_name, rendered_content, [f"mesudip.stack.name={stack_name}"],stack=stack_name)
266
+ labels = [f"mesudip.stack.name={stack_name}"]
267
+ if tag:
268
+ labels.append(f"mesudip.stack.tag={tag}")
269
+
270
+ _, cmd = self.docker.config.increment(stack_name, rendered_content,labels=labels ,stack=stack_name)
128
271
  if not cmd.isNop():
129
272
  self.commands.append(cmd)
130
273
  cmd = ["docker", "stack", "deploy", "-c", str(rendered_filename), stack_name]
@@ -163,8 +306,14 @@ class DockerStack:
163
306
  build_command = ["docker", "build", "-t", image]
164
307
 
165
308
 
166
- for value in build_config.get('args', []):
167
- build_command.extend(["--build-arg", envsubst(value)])
309
+ args = build_config.get('args', [])
310
+
311
+ if isinstance(args, dict):
312
+ for key, val in args.items():
313
+ build_command.extend(["--build-arg", f"{envsubst(key)}={envsubst(val)}"])
314
+ elif isinstance(args, list):
315
+ for value in args:
316
+ build_command.extend(["--build-arg", envsubst(value)])
168
317
 
169
318
  build_command.append(os.path.join(base_dir, build_config.get('context', '.')))
170
319
  self.commands.append(Command(build_command))
@@ -203,11 +352,31 @@ def main(args:List[str]=None):
203
352
  deploy_parser.add_argument("stack_name", help="Name of the stack")
204
353
  deploy_parser.add_argument("compose_file", help="Path to the compose file")
205
354
  deploy_parser.add_argument("--with-registry-auth", action="store_true", help="Use registry authentication")
206
-
355
+ deploy_parser.add_argument("-t", "--tag", help="Tag the current deployment for later checkout", required=False)
356
+
207
357
  # Remove subcommand
208
358
  rm_parser = subparsers.add_parser("rm", help="Remove a deployed stack")
209
359
  rm_parser.add_argument("stack_name", help="Name of the stack")
210
360
 
361
+ # Ls command
362
+ subparsers.add_parser("ls",help="List docker-stacks")
363
+
364
+ cat_parser = subparsers.add_parser("cat",help="Print the docker compose of specific version")
365
+ cat_parser.add_argument("stack_name", help="Name of the stack")
366
+ cat_parser.add_argument("version", help="Stack version to cat")
367
+
368
+ checkout_parser = subparsers.add_parser("checkout",help="Deploy specific version of the stack")
369
+ checkout_parser.add_argument("stack_name", help="Name of the stack")
370
+ checkout_parser.add_argument("version", help="Stack version to cat")
371
+
372
+ # version_parser = subparsers.add_parser("version",help="Deploy specific version of the stack")
373
+ # version_parser.add_argument("stack_name", help="Name of the stack")
374
+ # version_parser.add_argument("version","versions", help="Stack version to cat")
375
+
376
+ version_parser = subparsers.add_parser("version",aliases=["versions"], help="Deploy specific version of the stack")
377
+ version_parser.add_argument("stack_name", help="Name of the stack")
378
+
379
+
211
380
  parser.add_argument("-u", "--user", help="Registry credentials in format hostname:username:password", action="append", required=False, default=[])
212
381
  parser.add_argument("-t", "--tag", help="Tag the current deployment for later checkout", required=False)
213
382
  parser.add_argument("-ro",'-r',"--ro","--r", "--dry-run", action="store_true", help="Print commands, don't execute them", required=False)
@@ -222,11 +391,18 @@ def main(args:List[str]=None):
222
391
  elif args.command == "push":
223
392
  docker.stack.push(args.compose_file)
224
393
  elif args.command == "deploy":
225
- docker.stack.deploy(args.stack_name, args.compose_file, args.with_registry_auth)
394
+ docker.stack.deploy(args.stack_name, args.compose_file, args.with_registry_auth,tag=args.tag)
395
+ elif args.command == "ls":
396
+ docker.stack.ls()
397
+
226
398
  elif args.command == "rm":
227
399
  docker.stack.rm(args.stack_name)
228
-
229
-
400
+ elif args.command == 'cat':
401
+ print(docker.stack.cat(args.stack_name,args.version))
402
+ elif args.command == 'checkout':
403
+ docker.stack.checkout(args.stack_name,args.version)
404
+ elif args.command == 'versions' or args.command == "version":
405
+ docker.stack.versions(args.stack_name)
230
406
  if args.ro:
231
407
  print("Following commands were not executed:")
232
408
  [print(" >> "+str(x)) for x in docker.stack.commands if x]
@@ -25,6 +25,7 @@ def envsubst(template_str, env=os.environ):
25
25
  # Regex for $VARIABLE without default
26
26
  pattern_without_default = re.compile(r"\$([a-zA-Z_][a-zA-Z0-9_]*)")
27
27
 
28
+ template_str = template_str.replace("$$", "__ESCAPED_DOLLAR__")
28
29
  def print_error_line(template_str, match_span):
29
30
  """Helper function to print the error context."""
30
31
  lines = template_str.splitlines()
@@ -76,6 +77,8 @@ def envsubst(template_str, env=os.environ):
76
77
 
77
78
  # Substitute variables without default values
78
79
  template_str = pattern_without_default.sub(replace_without_default, template_str)
80
+
81
+ template_str = template_str.replace("__ESCAPED_DOLLAR__", "$")
79
82
 
80
83
  return template_str
81
84
 
@@ -103,3 +106,4 @@ def main():
103
106
 
104
107
  if __name__ == "__main__":
105
108
  main()
109
+
@@ -1,4 +1,5 @@
1
1
  import subprocess
2
+ import sys
2
3
  from typing import List, Optional
3
4
 
4
5
 
@@ -91,15 +92,21 @@ class Command:
91
92
  return
92
93
  # Use the provided log value if available, otherwise use the one from the constructor
93
94
  use_log = log if log is not None else self.log
94
-
95
+ if use_log:
96
+ print("> " + " ".join(self.command),flush=True)
97
+
95
98
  if not self.stdin:
96
- if self.give_console:
97
- print("Giving console")
98
- process = subprocess.Popen(self.command, shell=True,cwd=self.cwd)
99
- process.wait()
100
- else:
101
- subprocess.run(self.command)
102
- return run_cli_command(self.command, stdin=self.stdin, log=use_log, shell=False,cwd=self.cwd)
99
+ # if self.give_console:
100
+ # print("Giving console")
101
+ # process = subprocess.Popen(self.command, shell=True,cwd=self.cwd)
102
+ # process.wait()
103
+ # return process
104
+ # else:
105
+ result= subprocess.run(self.command)
106
+ if result.returncode!= 0:
107
+ sys.exit(result.returncode)
108
+ else:
109
+ return run_cli_command(self.command, stdin=self.stdin, log=False, shell=False,cwd=self.cwd)
103
110
 
104
111
  def __str__(self) -> str:
105
112
  """
@@ -1,10 +1,10 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: docker-stack
3
- Version: 0.2.1
3
+ Version: 0.2.3
4
4
  Summary: CLI for deploying and managing Docker stacks.
5
5
  Home-page: https://github.com/mesuidp/docker-stack
6
6
  Author: Sudip Bhattarai
7
- Author-email: sudip.dev.np@gmail.com
7
+ Author-email: sudip@bhattarai.me
8
8
  Classifier: Programming Language :: Python :: 3
9
9
  Classifier: License :: OSI Approved :: MIT License
10
10
  Classifier: Operating System :: OS Independent
@@ -2,12 +2,12 @@ from setuptools import setup, find_packages
2
2
 
3
3
  setup(
4
4
  name="docker-stack",
5
- version="0.2.1",
5
+ version="0.2.3",
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",
9
9
  author="Sudip Bhattarai",
10
- author_email="sudip.dev.np@gmail.com",
10
+ author_email="sudip@bhattarai.me",
11
11
  url="https://github.com/mesuidp/docker-stack", # Replace with your project URL
12
12
  packages=find_packages(),
13
13
  install_requires=[
File without changes
File without changes