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,216 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Output printer abstraction for deployment tool.
4
+
5
+ Provides a pluggable interface for customizing output behavior
6
+ (console, file, GUI, etc.) without modifying core logic.
7
+ """
8
+
9
+ from pathlib import Path
10
+ from typing import Protocol
11
+
12
+ from rich.console import Console
13
+ from rich.panel import Panel
14
+ from rich.text import Text
15
+
16
+
17
+ class Printer(Protocol):
18
+ """Protocol for output printing."""
19
+
20
+ def print(self, message: str = "") -> None:
21
+ """Print a regular message."""
22
+ ...
23
+
24
+ def print_header(self, title: str, width: int = 60) -> None:
25
+ """Print a section header."""
26
+ ...
27
+
28
+ def print_success(self, message: str) -> None:
29
+ """Print a success message."""
30
+ ...
31
+
32
+ def print_error(self, message: str) -> None:
33
+ """Print an error message."""
34
+ ...
35
+
36
+ def print_warning(self, message: str) -> None:
37
+ """Print a warning message."""
38
+ ...
39
+
40
+ def print_step(self, description: str, skipped: bool = False) -> None:
41
+ """Print a step header."""
42
+ ...
43
+
44
+
45
+ class ConsolePrinter:
46
+ """Default console-based printer implementation."""
47
+
48
+ def print(self, message: str = "") -> None:
49
+ """Print a regular message to console."""
50
+ print(message)
51
+
52
+ def print_header(self, title: str, width: int = 60) -> None:
53
+ """Print a formatted section header."""
54
+ self.print("\n" + "=" * width)
55
+ self.print(title)
56
+ self.print("=" * width)
57
+
58
+ def print_success(self, message: str) -> None:
59
+ """Print a success message with checkmark."""
60
+ self.print(f"✓ {message}")
61
+
62
+ def print_error(self, message: str) -> None:
63
+ """Print an error message with X symbol."""
64
+ self.print(f"✗ {message}")
65
+
66
+ def print_warning(self, message: str) -> None:
67
+ """Print a warning message with warning symbol."""
68
+ self.print(f"⚠ {message}")
69
+
70
+ def print_step(self, description: str, skipped: bool = False) -> None:
71
+ """Print a step header with optional skipped indicator."""
72
+ if skipped:
73
+ self.print(f"\n--- {description} [SKIPPED - already completed] ---")
74
+ else:
75
+ self.print(f"\n--- {description} ---")
76
+
77
+
78
+ class SilentPrinter:
79
+ """Silent printer that suppresses all output."""
80
+
81
+ def print(self, message: str = "") -> None:
82
+ """Do nothing."""
83
+ pass
84
+
85
+ def print_header(self, title: str, width: int = 60) -> None:
86
+ """Do nothing."""
87
+ pass
88
+
89
+ def print_success(self, message: str) -> None:
90
+ """Do nothing."""
91
+ pass
92
+
93
+ def print_error(self, message: str) -> None:
94
+ """Do nothing."""
95
+ pass
96
+
97
+ def print_warning(self, message: str) -> None:
98
+ """Do nothing."""
99
+ pass
100
+
101
+ def print_step(self, description: str, skipped: bool = False) -> None:
102
+ """Do nothing."""
103
+ pass
104
+
105
+
106
+ class RichPrinter:
107
+ """
108
+ Rich-based printer with enhanced formatting and colors.
109
+
110
+ Uses the 'rich' library to provide beautiful console output with:
111
+ - Colors and styles
112
+ - Panels and boxes
113
+ - Better formatting
114
+ """
115
+
116
+ def __init__(self):
117
+ """Initialize RichPrinter with a Console instance."""
118
+ self.console = Console()
119
+
120
+ def print(self, message: str = "") -> None:
121
+ """Print a regular message to console."""
122
+ self.console.print(message)
123
+
124
+ def print_header(self, title: str, width: int = 60) -> None:
125
+ """Print a formatted section header with panel."""
126
+ if title:
127
+ panel = Panel(
128
+ Text(title, style="bold cyan", justify="center"),
129
+ border_style="cyan",
130
+ padding=(0, 2),
131
+ )
132
+ self.console.print()
133
+ self.console.print(panel)
134
+ else:
135
+ self.console.print()
136
+
137
+ def print_success(self, message: str) -> None:
138
+ """Print a success message with green checkmark."""
139
+ self.console.print(f"[green]✓[/green] {message}")
140
+
141
+ def print_error(self, message: str) -> None:
142
+ """Print an error message with red X symbol."""
143
+ self.console.print(f"[red]✗[/red] {message}")
144
+
145
+ def print_warning(self, message: str) -> None:
146
+ """Print a warning message with yellow warning symbol."""
147
+ self.console.print(f"[yellow]⚠[/yellow] {message}")
148
+
149
+ def print_step(self, description: str, skipped: bool = False) -> None:
150
+ """Print a step header with optional skipped indicator."""
151
+ self.console.print()
152
+ if skipped:
153
+ self.console.print(f"[dim]--- {description} [SKIPPED - already completed] ---[/dim]")
154
+ else:
155
+ self.console.print(f"[bold blue]--- {description} ---[/bold blue]")
156
+
157
+
158
+ class FilePrinter:
159
+ """
160
+ File-based printer that writes all output to a log file.
161
+
162
+ Useful for test debugging - when tests fail, you can inspect
163
+ the log file to see what happened.
164
+ """
165
+
166
+ def __init__(self, log_file: Path | str, append: bool = False):
167
+ """
168
+ Initialize FilePrinter.
169
+
170
+ Args:
171
+ log_file: Path to log file
172
+ append: Whether to append to existing file (default: False, overwrite)
173
+ """
174
+ self.log_file = Path(log_file)
175
+ self.mode = "a" if append else "w"
176
+
177
+ # Create parent directory if needed
178
+ self.log_file.parent.mkdir(parents=True, exist_ok=True)
179
+
180
+ # Clear file if not appending
181
+ if not append:
182
+ self.log_file.write_text("")
183
+
184
+ def _write(self, message: str) -> None:
185
+ """Write message to log file."""
186
+ with open(self.log_file, "a") as f:
187
+ f.write(message + "\n")
188
+
189
+ def print(self, message: str = "") -> None:
190
+ """Print a regular message to file."""
191
+ self._write(message)
192
+
193
+ def print_header(self, title: str, width: int = 60) -> None:
194
+ """Print a formatted section header to file."""
195
+ self._write("\n" + "=" * width)
196
+ self._write(title)
197
+ self._write("=" * width)
198
+
199
+ def print_success(self, message: str) -> None:
200
+ """Print a success message to file."""
201
+ self._write(f"✓ {message}")
202
+
203
+ def print_error(self, message: str) -> None:
204
+ """Print an error message to file."""
205
+ self._write(f"✗ {message}")
206
+
207
+ def print_warning(self, message: str) -> None:
208
+ """Print a warning message to file."""
209
+ self._write(f"⚠ {message}")
210
+
211
+ def print_step(self, description: str, skipped: bool = False) -> None:
212
+ """Print a step header to file."""
213
+ if skipped:
214
+ self._write(f"\n--- {description} [SKIPPED - already completed] ---")
215
+ else:
216
+ self._write(f"\n--- {description} ---")
@@ -0,0 +1,136 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ State Manager for Avatar Deployment Configuration
4
+
5
+ Manages deployment configuration state to support resuming interrupted configurations.
6
+ """
7
+
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import yaml
12
+
13
+
14
+ class DeploymentState:
15
+ """Manages the state of a deployment configuration."""
16
+
17
+ def __init__(self, state_file: Path, steps: list[str]):
18
+ """
19
+ Initialize the state manager.
20
+
21
+ Args:
22
+ state_file: Path to the state file
23
+ steps: List of step names to track. If None, uses DEFAULT_STEPS
24
+ """
25
+ self.state_file = state_file
26
+ self.steps = steps
27
+ self.state = self._load_state()
28
+
29
+ def _load_state(self) -> dict[str, Any]:
30
+ """Load state from file or create new state."""
31
+ if self.state_file.exists():
32
+ with open(self.state_file) as f:
33
+ return yaml.safe_load(f) or self._create_initial_state()
34
+ return self._create_initial_state()
35
+
36
+ def _create_initial_state(self) -> dict[str, Any]:
37
+ """Create initial state structure."""
38
+ return {
39
+ "version": "1.0",
40
+ "steps": dict.fromkeys(self.steps, "not-started"),
41
+ "config": {},
42
+ "step_data": {}, # Store step-specific data
43
+ "user_secrets_provided": {}, # Track which user secrets were provided
44
+ }
45
+
46
+ def save(self) -> None:
47
+ """Save current state to file."""
48
+ self.state_file.parent.mkdir(parents=True, exist_ok=True)
49
+ with open(self.state_file, "w") as f:
50
+ yaml.dump(self.state, f, default_flow_style=False, sort_keys=False)
51
+
52
+ def get_step_status(self, step: str) -> str:
53
+ """Get the status of a step."""
54
+ return self.state["steps"].get(step, "not-started")
55
+
56
+ def mark_step_started(self, step: str) -> None:
57
+ """Mark a step as in-progress."""
58
+ self.state["steps"][step] = "in-progress"
59
+ self.save()
60
+
61
+ def mark_step_completed(self, step: str) -> None:
62
+ """Mark a step as completed."""
63
+ self.state["steps"][step] = "completed"
64
+ self.save()
65
+
66
+ def is_step_completed(self, step: str) -> bool:
67
+ """Check if a step is completed."""
68
+ return self.get_step_status(step) == "completed"
69
+
70
+ def get_next_step(self) -> str | None:
71
+ """Get the next step that needs to be executed."""
72
+ for step in self.steps:
73
+ if not self.is_step_completed(step):
74
+ return step
75
+ return None
76
+
77
+ def update_config(self, config: dict[str, Any]) -> None:
78
+ """Update configuration in state."""
79
+ self.state["config"].update(config)
80
+ self.save()
81
+
82
+ def get_config(self) -> dict[str, Any]:
83
+ """Get current configuration from state."""
84
+ return self.state["config"].copy()
85
+
86
+ def mark_user_secret_provided(self, secret_name: str, provided: bool = True) -> None:
87
+ """Mark whether a user secret was provided."""
88
+ self.state["user_secrets_provided"][secret_name] = provided
89
+ self.save()
90
+
91
+ def is_user_secret_provided(self, secret_name: str) -> bool:
92
+ """Check if a user secret was provided."""
93
+ return self.state["user_secrets_provided"].get(secret_name, False)
94
+
95
+ def reset(self) -> None:
96
+ """Reset state to initial values."""
97
+ self.state = self._create_initial_state()
98
+ self.save()
99
+
100
+ def delete(self) -> None:
101
+ """Delete the state file."""
102
+ if self.state_file.exists():
103
+ self.state_file.unlink()
104
+
105
+ def has_started(self) -> bool:
106
+ """Check if any configuration has been started."""
107
+ return any(status != "not-started" for status in self.state["steps"].values())
108
+
109
+ def is_complete(self) -> bool:
110
+ """Check if all steps are completed."""
111
+ return all(status == "completed" for status in self.state["steps"].values())
112
+
113
+ def get_progress_summary(self) -> str:
114
+ """Get a human-readable progress summary."""
115
+ completed = sum(1 for s in self.state["steps"].values() if s == "completed")
116
+ total = len(self.steps)
117
+ return f"{completed}/{total} steps completed"
118
+
119
+ def print_status(self) -> None:
120
+ """Print current status."""
121
+ print("\n" + "=" * 60)
122
+ print("Deployment Configuration Status")
123
+ print("=" * 60)
124
+ print(f"\n{self.get_progress_summary()}\n")
125
+
126
+ for step in self.steps:
127
+ status = self.get_step_status(step)
128
+ icon = "✓" if status == "completed" else "○" if status == "not-started" else "◐"
129
+ print(f" {icon} {step.replace('_', ' ').title()}: {status}")
130
+
131
+ if self.is_complete():
132
+ print("\n✓ Configuration is complete!")
133
+ elif self.has_started():
134
+ next_step = self.get_next_step()
135
+ if next_step:
136
+ print(f"\n→ Next step: {next_step.replace('_', ' ').title()}")
@@ -0,0 +1,25 @@
1
+ """Deployment configuration steps."""
2
+
3
+ from .authentik import AuthentikStep
4
+ from .authentik_blueprint import AuthentikBlueprintStep
5
+ from .base import DeploymentStep
6
+ from .database import DatabaseStep
7
+ from .email import EmailStep
8
+ from .logging import LoggingStep
9
+ from .required import RequiredConfigStep
10
+ from .storage import StorageStep
11
+ from .telemetry import TelemetryStep
12
+ from .user import UserStep
13
+
14
+ __all__ = [
15
+ "DeploymentStep",
16
+ "RequiredConfigStep",
17
+ "EmailStep",
18
+ "TelemetryStep",
19
+ "LoggingStep",
20
+ "DatabaseStep",
21
+ "StorageStep",
22
+ "AuthentikStep",
23
+ "AuthentikBlueprintStep",
24
+ "UserStep",
25
+ ]
@@ -0,0 +1,46 @@
1
+ """Authentik configuration step."""
2
+
3
+ import secrets
4
+ from typing import Any
5
+
6
+ from .base import DeploymentStep
7
+
8
+
9
+ class AuthentikStep(DeploymentStep):
10
+ """Handles Authentik SSO configuration and credentials."""
11
+
12
+ name = "authentik"
13
+ description = "Configure Authentik SSO authentication credentials"
14
+ required = True
15
+
16
+ def collect_config(self) -> dict[str, Any]:
17
+ """Collect Authentik configuration."""
18
+ config = {}
19
+
20
+ # Authentik database name and user
21
+ # These are typically set to default values unless customized
22
+ authentik_db_name = self.config.get(
23
+ "AUTHENTIK_DATABASE_NAME",
24
+ "authentik",
25
+ )
26
+ authentik_db_user = self.config.get(
27
+ "AUTHENTIK_DATABASE_USER",
28
+ "authentik",
29
+ )
30
+
31
+ config["AUTHENTIK_DATABASE_NAME"] = authentik_db_name
32
+ config["AUTHENTIK_DATABASE_USER"] = authentik_db_user
33
+
34
+ # Update self.config so generate_secrets() can access these values
35
+ self.config.update(config)
36
+
37
+ return config
38
+
39
+ def generate_secrets(self) -> dict[str, str]:
40
+ """Generate Authentik-related secrets."""
41
+ return {
42
+ "authentik_database_name": self.config["AUTHENTIK_DATABASE_NAME"],
43
+ "authentik_database_user": self.config["AUTHENTIK_DATABASE_USER"],
44
+ "authentik_database_password": secrets.token_hex(),
45
+ "authentik_secret_key": secrets.token_hex(),
46
+ }
@@ -0,0 +1,79 @@
1
+ """Authentik blueprint configuration step."""
2
+
3
+ import secrets
4
+ from typing import Any
5
+
6
+ from .base import DeploymentStep
7
+
8
+
9
+ class AuthentikBlueprintStep(DeploymentStep):
10
+ """Handles Authentik blueprint configuration for SSO setup.
11
+
12
+ The blueprint template (octopize-avatar-blueprint.yaml) is stored in
13
+ docker/templates/authentik/ and will be populated with these values.
14
+
15
+ This step derives all values from existing configuration without prompting:
16
+ - Domain from PUBLIC_URL
17
+ - Random OAuth2 client ID
18
+ - Redirect URI from domain
19
+ - Default license type
20
+ """
21
+
22
+ name = "authentik-blueprint"
23
+ description = "Configure Authentik SSO blueprint settings"
24
+ required = True
25
+
26
+ def collect_config(self) -> dict[str, Any]:
27
+ """Collect Authentik blueprint configuration.
28
+
29
+ All values are derived from existing config without user prompts.
30
+ """
31
+ config = {}
32
+
33
+ # Extract domain from PUBLIC_URL (required config value)
34
+ public_url = self.config.get("PUBLIC_URL", "")
35
+ # Remove protocol and trailing slashes
36
+ domain = public_url.replace("https://", "").replace("http://", "").rstrip("/")
37
+
38
+ # Ensure domain is not empty
39
+ if not domain:
40
+ raise ValueError(
41
+ f"PUBLIC_URL '{public_url}' is not set or invalid; cannot derive BLUEPRINT_DOMAIN."
42
+ )
43
+
44
+ config["BLUEPRINT_DOMAIN"] = domain
45
+
46
+ # Generate random OAuth2 client ID (or use existing if provided)
47
+ client_id = self.config.get("BLUEPRINT_CLIENT_ID", secrets.token_hex(32))
48
+ config["BLUEPRINT_CLIENT_ID"] = client_id
49
+
50
+ # Generate OAuth2 client secret (or use existing if provided)
51
+ # This is a config value because it goes directly into the blueprint template
52
+ client_secret = self.config.get("BLUEPRINT_CLIENT_SECRET", secrets.token_hex(32))
53
+ config["BLUEPRINT_CLIENT_SECRET"] = client_secret
54
+
55
+ # Build API redirect URI from domain
56
+ redirect_uri = self.config.get(
57
+ "BLUEPRINT_API_REDIRECT_URI", f"https://{domain}/api/login/sso/auth"
58
+ )
59
+ config["BLUEPRINT_API_REDIRECT_URI"] = redirect_uri
60
+
61
+ # Use default license type (or from config)
62
+ license_type = self.config.get(
63
+ "BLUEPRINT_SELF_SERVICE_LICENSE",
64
+ "demo", # Default to demo license
65
+ )
66
+ config["BLUEPRINT_SELF_SERVICE_LICENSE"] = license_type
67
+
68
+ # Update self.config for generate_secrets()
69
+ self.config.update(config)
70
+
71
+ return config
72
+
73
+ def generate_secrets(self) -> dict[str, str]:
74
+ """Generate Authentik blueprint secrets.
75
+
76
+ The blueprint step doesn't generate any docker secrets.
77
+ All values are stored as config values instead.
78
+ """
79
+ return {}