octopize.deploy_tool 0.1.0__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.
@@ -0,0 +1,63 @@
1
+ # Default Configuration Values for Avatar Deployment
2
+ # Version: 1.0.0 (MAJOR.MINOR.PATCH)
3
+ # Compatible with script version: >=1.0.0,<2.0.0
4
+
5
+ version: "1.0.0"
6
+
7
+ # Image versions - updated regularly with new releases
8
+ images:
9
+ api: "2.20.1"
10
+ web: "0.40.0"
11
+ pdfgenerator: "latest"
12
+ seaweedfs: "0.2.0"
13
+ authentik: "2025.10.2"
14
+
15
+ # Email configuration defaults
16
+ email:
17
+ provider: "aws" # 'aws' or 'smtp'
18
+ smtp:
19
+ host: "email-smtp.eu-west-3.amazonaws.com"
20
+ port: "587"
21
+ use_tls: "true"
22
+ start_tls: "false"
23
+ verify: "true"
24
+ sender_email: "noreply@octopize.io"
25
+
26
+ # Telemetry configuration (for usage analytics)
27
+ telemetry:
28
+ enabled: true # Ask user if they want to enable telemetry
29
+ endpoint_url: "https://s3.fr-par.scw.cloud"
30
+ region: "fr-par"
31
+
32
+ # Application settings (defaults when no preset is selected)
33
+ application:
34
+ use_console_logging: "false"
35
+ log_level: "INFO"
36
+ sentry_enabled: "true"
37
+ email_authentication: "true"
38
+
39
+ # Deployment presets - pre-configured settings for common scenarios
40
+ presets:
41
+ default:
42
+ description: "Production-ready configuration with telemetry and monitoring"
43
+ application:
44
+ use_console_logging: "false"
45
+ sentry_enabled: "true"
46
+ telemetry:
47
+ enabled: true
48
+
49
+ dev-mode:
50
+ description: "Development mode with console logging, no external services"
51
+ application:
52
+ use_console_logging: "true"
53
+ sentry_enabled: "false"
54
+ telemetry:
55
+ enabled: false
56
+
57
+ airgapped:
58
+ description: "Air-gapped deployment without external monitoring or telemetry"
59
+ application:
60
+ use_console_logging: "false"
61
+ sentry_enabled: "false"
62
+ telemetry:
63
+ enabled: false
@@ -0,0 +1,251 @@
1
+ """
2
+ Provide deployment templates from various sources.
3
+
4
+ This module handles obtaining necessary template files either from GitHub
5
+ or from a local directory (for testing).
6
+ """
7
+
8
+ import shutil
9
+ import urllib.request
10
+ from abc import ABC, abstractmethod
11
+ from pathlib import Path
12
+
13
+ # GitHub raw content URL base
14
+ GITHUB_RAW_BASE = "https://raw.githubusercontent.com/octopize/avatar-deployment"
15
+ DEFAULT_BRANCH = "main"
16
+
17
+ # Files to download from the docker/templates/ directory
18
+ REQUIRED_FILES = [
19
+ ".env.template",
20
+ "nginx.conf.template",
21
+ "docker-compose.yml",
22
+ ".template-version",
23
+ "authentik/octopize-avatar-blueprint.yaml.j2",
24
+ ]
25
+
26
+
27
+ class TemplateProvider(ABC):
28
+ """Abstract base class for template providers."""
29
+
30
+ def __init__(self, verbose: bool = False):
31
+ """
32
+ Initialize template provider.
33
+
34
+ Args:
35
+ verbose: Print progress information
36
+ """
37
+ self.verbose = verbose
38
+
39
+ @abstractmethod
40
+ def provide_file(self, filename: str, destination: Path) -> bool:
41
+ """
42
+ Provide a single file to the destination.
43
+
44
+ Args:
45
+ filename: Name of file to provide
46
+ destination: Local path where file should be saved
47
+
48
+ Returns:
49
+ True if successful, False otherwise
50
+ """
51
+ pass
52
+
53
+ def provide_all(self, output_dir: Path) -> bool:
54
+ """
55
+ Provide all required template files.
56
+
57
+ Args:
58
+ output_dir: Directory where files should be saved
59
+
60
+ Returns:
61
+ True if all files provided successfully
62
+ """
63
+ output_dir = Path(output_dir)
64
+ success = True
65
+
66
+ if self.verbose:
67
+ print(f"\nProviding templates to {output_dir}/")
68
+ print("=" * 60)
69
+
70
+ for filename in REQUIRED_FILES:
71
+ destination = output_dir / filename
72
+ if not self.provide_file(filename, destination):
73
+ success = False
74
+ print(f"⚠ Warning: Failed to provide {filename}")
75
+
76
+ if self.verbose:
77
+ if success:
78
+ print("\n✓ All templates provided successfully")
79
+ else:
80
+ print("\n⚠ Some templates failed")
81
+
82
+ return success
83
+
84
+ def check_cached_templates(self, output_dir: Path) -> bool:
85
+ """
86
+ Check if templates are already cached locally.
87
+
88
+ Args:
89
+ output_dir: Directory to check for cached templates
90
+
91
+ Returns:
92
+ True if all required files exist
93
+ """
94
+ output_dir = Path(output_dir)
95
+ for filename in REQUIRED_FILES:
96
+ if not (output_dir / filename).exists():
97
+ return False
98
+ return True
99
+
100
+
101
+ class GitHubTemplateProvider(TemplateProvider):
102
+ """Downloads deployment templates from GitHub."""
103
+
104
+ def __init__(self, branch: str = DEFAULT_BRANCH, verbose: bool = False):
105
+ """
106
+ Initialize GitHub template provider.
107
+
108
+ Args:
109
+ branch: Git branch to download from (default: main)
110
+ verbose: Print download progress
111
+ """
112
+ super().__init__(verbose=verbose)
113
+ self.branch = branch
114
+ self.base_url = f"{GITHUB_RAW_BASE}/{branch}/docker/templates"
115
+
116
+ def provide_file(self, filename: str, destination: Path) -> bool:
117
+ """
118
+ Download a single file from GitHub.
119
+
120
+ Args:
121
+ filename: Name of file to download (in docker/templates/ directory)
122
+ destination: Local path where file should be saved
123
+
124
+ Returns:
125
+ True if successful, False otherwise
126
+ """
127
+ url = f"{self.base_url}/{filename}"
128
+
129
+ if self.verbose:
130
+ print(f"Downloading {filename}...")
131
+ print(f" URL: {url}")
132
+ print(f" Destination: {destination}")
133
+
134
+ try:
135
+ with urllib.request.urlopen(url, timeout=10) as response:
136
+ content = response.read()
137
+
138
+ # Ensure parent directory exists
139
+ destination.parent.mkdir(parents=True, exist_ok=True)
140
+
141
+ # Write file
142
+ destination.write_bytes(content)
143
+
144
+ if self.verbose:
145
+ print(f" ✓ Downloaded {len(content)} bytes")
146
+
147
+ return True
148
+
149
+ except Exception as e:
150
+ if self.verbose:
151
+ print(f" ✗ Failed: {e}")
152
+ return False
153
+
154
+ def check_cached_templates(self, output_dir: Path) -> bool:
155
+ """
156
+ Check if templates are already cached locally.
157
+
158
+ Args:
159
+ output_dir: Directory to check for cached templates
160
+
161
+ Returns:
162
+ True if all required files exist
163
+ """
164
+ output_dir = Path(output_dir)
165
+ for filename in REQUIRED_FILES:
166
+ if not (output_dir / filename).exists():
167
+ return False
168
+ return True
169
+
170
+
171
+ class LocalTemplateProvider(TemplateProvider):
172
+ """Provides templates from a local directory (for testing)."""
173
+
174
+ def __init__(self, source_dir: Path | str, verbose: bool = False):
175
+ """
176
+ Initialize local template provider.
177
+
178
+ Args:
179
+ source_dir: Local directory containing template files
180
+ verbose: Print progress information
181
+ """
182
+ super().__init__(verbose=verbose)
183
+ self.source_dir = Path(source_dir)
184
+
185
+ def provide_file(self, filename: str, destination: Path) -> bool:
186
+ """
187
+ Copy a single file from source to destination.
188
+
189
+ Args:
190
+ filename: Name of file to copy
191
+ destination: Local path where file should be saved
192
+
193
+ Returns:
194
+ True if successful, False otherwise
195
+ """
196
+ source = self.source_dir / filename
197
+
198
+ if self.verbose:
199
+ print(f"Copying {filename}...")
200
+ print(f" Source: {source}")
201
+ print(f" Destination: {destination}")
202
+
203
+ try:
204
+ if not source.exists():
205
+ raise FileNotFoundError(f"Source file not found: {source}")
206
+
207
+ # Ensure parent directory exists
208
+ destination.parent.mkdir(parents=True, exist_ok=True)
209
+
210
+ # Copy file
211
+ shutil.copy2(source, destination)
212
+
213
+ if self.verbose:
214
+ print(f" ✓ Copied {source.stat().st_size} bytes")
215
+
216
+ return True
217
+
218
+ except Exception as e:
219
+ if self.verbose:
220
+ print(f" ✗ Failed: {e}")
221
+ return False
222
+
223
+
224
+ def download_templates(
225
+ output_dir: Path,
226
+ force: bool = False,
227
+ branch: str = DEFAULT_BRANCH,
228
+ verbose: bool = False,
229
+ ) -> bool:
230
+ """
231
+ Download deployment templates from GitHub.
232
+
233
+ Args:
234
+ output_dir: Directory where templates should be saved
235
+ force: Force download even if files already exist
236
+ branch: Git branch to download from
237
+ verbose: Print progress information
238
+
239
+ Returns:
240
+ True if successful
241
+ """
242
+ provider = GitHubTemplateProvider(branch=branch, verbose=verbose)
243
+
244
+ # Check if already cached
245
+ if not force and provider.check_cached_templates(output_dir):
246
+ if verbose:
247
+ print(f"Templates already cached in {output_dir}/")
248
+ return True
249
+
250
+ # Download
251
+ return provider.provide_all(output_dir)
@@ -0,0 +1,324 @@
1
+ """
2
+ Input gathering abstraction for deployment tool.
3
+
4
+ Provides pluggable input gathering through Protocol pattern, enabling:
5
+ - Standard console input for production
6
+ - Mock input for testing
7
+ - Rich library input with enhanced features
8
+ """
9
+
10
+ import os
11
+ import sys
12
+ from collections.abc import Callable
13
+ from typing import Protocol, runtime_checkable
14
+
15
+ from rich.console import Console
16
+ from rich.prompt import Confirm, IntPrompt, Prompt
17
+
18
+
19
+ @runtime_checkable
20
+ class InputGatherer(Protocol):
21
+ """
22
+ Protocol defining input gathering interface.
23
+
24
+ This allows different implementations (console, mock, rich) while
25
+ maintaining type safety through Protocol pattern.
26
+ """
27
+
28
+ def prompt(
29
+ self,
30
+ message: str,
31
+ default: str | None = None,
32
+ validate: Callable[[str], tuple[bool, str]] | None = None,
33
+ ) -> str:
34
+ """
35
+ Prompt user for input with optional default value and validation.
36
+
37
+ Args:
38
+ message: The prompt message
39
+ default: Default value to use if user presses Enter (None = required field)
40
+ validate: Optional validation function that returns (is_valid, error_message)
41
+
42
+ Returns:
43
+ User's input or default value
44
+ """
45
+ ...
46
+
47
+ def prompt_yes_no(self, message: str, default: bool = True) -> bool:
48
+ """
49
+ Prompt user for yes/no input.
50
+
51
+ Args:
52
+ message: The prompt message
53
+ default: Default value if user presses Enter
54
+
55
+ Returns:
56
+ True for yes, False for no
57
+ """
58
+ ...
59
+
60
+ def prompt_choice(self, message: str, choices: list[str], default: str | None = None) -> str:
61
+ """
62
+ Prompt user to choose from list of options.
63
+
64
+ Args:
65
+ message: The prompt message
66
+ choices: List of valid choices
67
+ default: Default choice if user presses Enter
68
+
69
+ Returns:
70
+ Selected choice
71
+ """
72
+ ...
73
+
74
+
75
+ class ConsoleInputGatherer:
76
+ """
77
+ Standard console-based input gatherer using built-in input().
78
+
79
+ This is the default implementation for production use.
80
+ """
81
+
82
+ def prompt(
83
+ self,
84
+ message: str,
85
+ default: str | None = None,
86
+ validate: Callable[[str], tuple[bool, str]] | None = None,
87
+ ) -> str:
88
+ """Prompt user for input with optional default value and validation."""
89
+ # Debug logging
90
+ if os.environ.get("AVATAR_DEPLOY_DEBUG_PROMPTS") == "1":
91
+ debug_msg = f"[PROMPT] {message}"
92
+ if default is not None:
93
+ debug_msg += f" (default: {default!r})"
94
+ print(debug_msg, file=sys.stderr)
95
+
96
+ while True:
97
+ if default is not None: # Has a default (including empty string)
98
+ prompt_text = (
99
+ f"{message} [{default}]: " if default else f"{message} [press Enter to skip]: "
100
+ )
101
+ response = input(prompt_text).strip()
102
+ value = response if response else default
103
+ else: # No default - required field
104
+ response = input(f"{message}: ").strip()
105
+ if not response:
106
+ print(" ⚠ This value is required")
107
+ continue
108
+ value = response
109
+
110
+ # Validate if validator provided
111
+ if validate:
112
+ is_valid, error_msg = validate(value)
113
+ if not is_valid:
114
+ print(f" ⚠ {error_msg}")
115
+ continue
116
+
117
+ return value
118
+
119
+ def prompt_yes_no(self, message: str, default: bool = True) -> bool:
120
+ """Prompt user for yes/no input."""
121
+ default_str = "Y/n" if default else "y/N"
122
+ response = input(f"{message} [{default_str}]: ").strip().lower()
123
+
124
+ if not response:
125
+ return default
126
+
127
+ return response in ["y", "yes", "true", "1"]
128
+
129
+ def prompt_choice(self, message: str, choices: list[str], default: str | None = None) -> str:
130
+ """Prompt user to choose from list of options."""
131
+ print(f"\n{message}")
132
+ for i, choice in enumerate(choices, 1):
133
+ marker = " (default)" if choice == default else ""
134
+ print(f" {i}. {choice}{marker}")
135
+
136
+ while True:
137
+ response = input(f"\nSelect [1-{len(choices)}]: ").strip()
138
+
139
+ if not response and default:
140
+ return default
141
+
142
+ try:
143
+ choice_num = int(response)
144
+ if 1 <= choice_num <= len(choices):
145
+ return choices[choice_num - 1]
146
+ else:
147
+ print(f" ⚠ Please enter a number between 1 and {len(choices)}")
148
+ except ValueError:
149
+ print(" ⚠ Please enter a valid number")
150
+
151
+
152
+ class MockInputGatherer:
153
+ """
154
+ Mock input gatherer for testing.
155
+
156
+ Returns pre-configured responses in sequence. Useful for testing
157
+ interactive flows without manual input.
158
+ """
159
+
160
+ def __init__(self, responses: list[str | bool]):
161
+ """
162
+ Initialize mock input gatherer.
163
+
164
+ Args:
165
+ responses: List of pre-configured responses to return in sequence
166
+ """
167
+ self.responses = responses
168
+ self.current_index = 0
169
+
170
+ def _get_next_response(self) -> str | bool:
171
+ """Get next response from the queue."""
172
+ if self.current_index >= len(self.responses):
173
+ raise ValueError(
174
+ f"MockInputGatherer ran out of responses (asked {self.current_index + 1} times)"
175
+ )
176
+ response = self.responses[self.current_index]
177
+ self.current_index += 1
178
+ return response
179
+
180
+ def prompt(
181
+ self,
182
+ message: str,
183
+ default: str | None = None,
184
+ validate: Callable[[str], tuple[bool, str]] | None = None,
185
+ ) -> str:
186
+ """Return next mocked response."""
187
+ # Debug logging with response preview
188
+ if os.environ.get("AVATAR_DEPLOY_DEBUG_PROMPTS") == "1":
189
+ response_preview = (
190
+ self.responses[self.current_index]
191
+ if self.current_index < len(self.responses)
192
+ else f"<default: {default!r}>"
193
+ )
194
+ print(
195
+ f"[PROMPT #{self.current_index + 1}] {message} => {response_preview!r}",
196
+ file=sys.stderr,
197
+ )
198
+
199
+ response = self._get_next_response()
200
+
201
+ if isinstance(response, bool):
202
+ raise TypeError(
203
+ "Expected string response, got bool: True"
204
+ if response
205
+ else "Expected string response, got bool: False"
206
+ )
207
+
208
+ # Use default if response is empty string and default is provided
209
+ value = response if response else (default if default is not None else response)
210
+
211
+ # Validate if validator provided (for testing validation logic)
212
+ if validate:
213
+ is_valid, error_msg = validate(value)
214
+ if not is_valid:
215
+ raise ValueError(f"MockInputGatherer: Validation failed for '{value}': {error_msg}")
216
+
217
+ return value
218
+
219
+ def prompt_yes_no(self, message: str, default: bool = True) -> bool:
220
+ """Return next mocked boolean response."""
221
+ response = self._get_next_response()
222
+ if isinstance(response, str):
223
+ # Convert string to boolean if needed
224
+ return response.lower() in ["y", "yes", "true", "1"]
225
+ return response
226
+
227
+ def prompt_choice(self, message: str, choices: list[str], default: str | None = None) -> str:
228
+ """Return next mocked choice response."""
229
+ response = self._get_next_response()
230
+ if isinstance(response, bool):
231
+ raise TypeError(f"Expected string response, got bool: {response}")
232
+
233
+ # If response is empty and default is provided, return default
234
+ if not response and default:
235
+ return default
236
+
237
+ # If response is a number, treat it as choice index (1-based)
238
+ try:
239
+ choice_num = int(response)
240
+ if 1 <= choice_num <= len(choices):
241
+ return choices[choice_num - 1]
242
+ except (ValueError, TypeError):
243
+ pass
244
+
245
+ # Otherwise, return the response as-is (assuming it's a valid choice)
246
+ return response
247
+
248
+
249
+ class RichInputGatherer:
250
+ """
251
+ Rich-based input gatherer with enhanced prompts.
252
+
253
+ Uses the 'rich' library to provide beautiful interactive prompts with:
254
+ - Styled prompts with colors
255
+ - Better formatting
256
+ - Input validation
257
+ """
258
+
259
+ def __init__(self):
260
+ """Initialize RichInputGatherer with a Console instance."""
261
+ self.console = Console()
262
+
263
+ def prompt(
264
+ self,
265
+ message: str,
266
+ default: str | None = None,
267
+ validate: Callable[[str], tuple[bool, str]] | None = None,
268
+ ) -> str:
269
+ """Prompt user for input with optional default value and validation."""
270
+ while True:
271
+ if default is not None: # Has a default (including empty string)
272
+ if default:
273
+ result = Prompt.ask(f"[cyan]{message}[/cyan]", default=default)
274
+ else:
275
+ # Empty string default - show skip hint
276
+ result = Prompt.ask(f"[cyan]{message} (press Enter to skip)[/cyan]", default="")
277
+ else: # No default - required field
278
+ # Rich Prompt requires at least empty string, so we loop
279
+ result = ""
280
+ while not result:
281
+ result = Prompt.ask(f"[cyan]{message}[/cyan]").strip()
282
+ if not result:
283
+ self.console.print("[yellow]⚠ This value is required[/yellow]")
284
+
285
+ # Validate if validator provided
286
+ if validate:
287
+ is_valid, error_msg = validate(result)
288
+ if not is_valid:
289
+ self.console.print(f"[yellow]⚠ {error_msg}[/yellow]")
290
+ continue
291
+
292
+ return result
293
+
294
+ def prompt_yes_no(self, message: str, default: bool = True) -> bool:
295
+ """Prompt user for yes/no input."""
296
+ return Confirm.ask(f"[cyan]{message}[/cyan]", default=default)
297
+
298
+ def prompt_choice(self, message: str, choices: list[str], default: str | None = None) -> str:
299
+ """Prompt user to choose from list of options."""
300
+ self.console.print(f"\n[cyan]{message}[/cyan]")
301
+ for i, choice in enumerate(choices, 1):
302
+ marker = " [dim](default)[/dim]" if choice == default else ""
303
+ self.console.print(f" [bold]{i}[/bold]. {choice}{marker}")
304
+
305
+ # Determine default index
306
+ default_index = None
307
+ if default and default in choices:
308
+ default_index = choices.index(default) + 1
309
+
310
+ while True:
311
+ if default_index:
312
+ choice_num = IntPrompt.ask(
313
+ f"\n[cyan]Select[/cyan] [dim]\\[1-{len(choices)}][/dim]",
314
+ default=default_index,
315
+ )
316
+ else:
317
+ choice_num = IntPrompt.ask(f"\n[cyan]Select[/cyan] [dim]\\[1-{len(choices)}][/dim]")
318
+
319
+ if 1 <= choice_num <= len(choices):
320
+ return choices[choice_num - 1]
321
+ else:
322
+ self.console.print(
323
+ f"[yellow]⚠ Please enter a number between 1 and {len(choices)}[/yellow]"
324
+ )