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,199 @@
1
+ """
2
+ Base class for deployment configuration steps.
3
+
4
+ Each step is a modular component that handles configuration and secrets
5
+ for a specific part of the deployment (email, telemetry, storage, etc.).
6
+ """
7
+
8
+ import base64
9
+ import secrets
10
+ from abc import ABC, abstractmethod
11
+ from collections.abc import Callable
12
+ from pathlib import Path
13
+ from typing import TYPE_CHECKING, Any
14
+
15
+ if TYPE_CHECKING:
16
+ from octopize_avatar_deploy.input_gatherer import InputGatherer
17
+ from octopize_avatar_deploy.printer import Printer
18
+ from octopize_avatar_deploy.input_gatherer import ConsoleInputGatherer
19
+ from octopize_avatar_deploy.printer import ConsolePrinter
20
+
21
+
22
+ class DeploymentStep(ABC):
23
+ """
24
+ Base class for all deployment configuration steps.
25
+
26
+ Each step handles a specific aspect of the deployment configuration.
27
+ Subclasses should implement collect_config() and generate_secrets().
28
+ """
29
+
30
+ # Step metadata
31
+ name: str = "base_step"
32
+ description: str = "Base configuration step"
33
+ required: bool = True # Whether this step is required
34
+
35
+ def __init__(
36
+ self,
37
+ output_dir: Path,
38
+ defaults: dict[str, Any],
39
+ config: dict[str, Any] | None = None,
40
+ interactive: bool = True,
41
+ printer: "Printer | None" = None,
42
+ input_gatherer: "InputGatherer | None" = None,
43
+ ):
44
+ """
45
+ Initialize the step.
46
+
47
+ Args:
48
+ output_dir: Directory where files will be generated
49
+ defaults: Default configuration from defaults.yaml
50
+ config: Pre-loaded configuration (for non-interactive mode)
51
+ interactive: Whether to prompt user interactively
52
+ printer: Optional printer for output
53
+ """
54
+ self.output_dir = Path(output_dir)
55
+ self.defaults = defaults
56
+ # Use the provided config dict directly (by reference) so all steps share the same dict
57
+ # This allows steps to access config values set by previous steps
58
+ if config is None:
59
+ config = {}
60
+ self.config: dict[str, Any] = config
61
+ self.secrets: dict[str, str] = {}
62
+ self.interactive = interactive
63
+
64
+ # Import here to avoid circular dependency
65
+ if printer is None:
66
+ printer = ConsolePrinter()
67
+
68
+ self.printer: Printer = printer
69
+
70
+ if input_gatherer is None:
71
+ input_gatherer = ConsoleInputGatherer()
72
+
73
+ self.input_gatherer: InputGatherer = input_gatherer
74
+
75
+ @abstractmethod
76
+ def collect_config(self) -> dict[str, Any]:
77
+ """
78
+ Collect configuration for this step.
79
+
80
+ Returns:
81
+ dictionary of configuration values
82
+ """
83
+ pass
84
+
85
+ @abstractmethod
86
+ def generate_secrets(self) -> dict[str, str]:
87
+ """
88
+ Generate secrets for this step.
89
+
90
+ Returns:
91
+ dictionary of {filename: secret_value} for .secrets/ directory
92
+ """
93
+ pass
94
+
95
+ def can_skip(self) -> bool:
96
+ """
97
+ Determine if this step can be skipped.
98
+
99
+ Returns:
100
+ True if step can be skipped (optional and user chooses to skip)
101
+ """
102
+ return False
103
+
104
+ def validate(self) -> bool:
105
+ """
106
+ Validate configuration before proceeding.
107
+
108
+ Returns:
109
+ True if validation passes
110
+
111
+ Raises:
112
+ ValueError: If validation fails
113
+ """
114
+ return True
115
+
116
+ def get_summary(self) -> str:
117
+ """
118
+ Get a summary of the configuration for review.
119
+
120
+ Returns:
121
+ Human-readable summary string
122
+ """
123
+ return f"{self.name}: Configured"
124
+
125
+ # Helper methods for prompting user input
126
+
127
+ def prompt(
128
+ self,
129
+ message: str,
130
+ default: str | None = None,
131
+ validate: "Callable[[str], tuple[bool, str]] | None" = None,
132
+ ) -> str:
133
+ """
134
+ Prompt user for input with optional default value and validation.
135
+
136
+ Args:
137
+ message: The prompt message
138
+ default: Default value to use if user presses Enter (None = required field)
139
+ validate: Optional validation function that returns (is_valid, error_message)
140
+
141
+ Returns:
142
+ User's input or default value
143
+ """
144
+ return self.input_gatherer.prompt(message, default, validate)
145
+
146
+ def prompt_yes_no(self, message: str, default: bool = True) -> bool:
147
+ """
148
+ Prompt user for yes/no input.
149
+
150
+ Args:
151
+ message: The prompt message
152
+ default: Default value if user presses Enter
153
+
154
+ Returns:
155
+ True for yes, False for no
156
+ """
157
+ return self.input_gatherer.prompt_yes_no(message, default)
158
+
159
+ def prompt_choice(self, message: str, choices: list, default: str | None = None) -> str:
160
+ """
161
+ Prompt user to choose from list of options.
162
+
163
+ Args:
164
+ message: The prompt message
165
+ choices: List of valid choices
166
+ default: Default choice if user presses Enter
167
+
168
+ Returns:
169
+ Selected choice
170
+ """
171
+ return self.input_gatherer.prompt_choice(message, choices, default)
172
+
173
+ def get_config_value(self, key: str, default: Any = None) -> Any:
174
+ ret = self.config.get(key, default)
175
+ if ret is None:
176
+ raise ValueError(f"Configuration key '{key}' is required but not set.")
177
+ return ret
178
+
179
+ # Utility methods for generating secrets
180
+
181
+ @staticmethod
182
+ def generate_secret_token() -> str:
183
+ """Generate a secure random token (hex-encoded)."""
184
+ return secrets.token_hex()
185
+
186
+ @staticmethod
187
+ def generate_secret_urlsafe(nbytes: int = 32) -> str:
188
+ """Generate a URL-safe random token."""
189
+ return secrets.token_urlsafe(nbytes)
190
+
191
+ @staticmethod
192
+ def generate_encryption_key() -> str:
193
+ """Generate a URL-safe base64-encoded encryption key."""
194
+ return base64.urlsafe_b64encode(secrets.token_bytes(32)).decode("utf-8")
195
+
196
+ @staticmethod
197
+ def generate_base64_key(nbytes: int = 32) -> str:
198
+ """Generate a base64-encoded random key."""
199
+ return base64.b64encode(secrets.token_bytes(nbytes)).decode("utf-8")
@@ -0,0 +1,37 @@
1
+ """Database configuration step."""
2
+
3
+ import secrets
4
+ from typing import Any
5
+
6
+ from .base import DeploymentStep
7
+
8
+
9
+ class DatabaseStep(DeploymentStep):
10
+ """Handles database configuration and credentials."""
11
+
12
+ name = "database"
13
+ description = "Configure PostgreSQL database credentials"
14
+ required = True
15
+
16
+ def collect_config(self) -> dict[str, Any]:
17
+ """Collect database configuration."""
18
+ config = {}
19
+
20
+ config["DB_NAME"] = self.get_config_value("DB_NAME", "avatar")
21
+ config["DB_USER"] = self.get_config_value("DB_USER", "avatar")
22
+ config["DB_ADMIN_USER"] = self.get_config_value("DB_ADMIN_USER", "avatar_dba")
23
+
24
+ # Update self.config so generate_secrets() can access these values
25
+ self.config.update(config)
26
+
27
+ return config
28
+
29
+ def generate_secrets(self) -> dict[str, str]:
30
+ """Generate database passwords and configuration."""
31
+ return {
32
+ "db_password": secrets.token_hex(),
33
+ "db_admin_password": secrets.token_hex(),
34
+ "db_admin_user": self.config["DB_ADMIN_USER"],
35
+ "db_user": self.config["DB_USER"],
36
+ "db_name": self.config["DB_NAME"],
37
+ }
@@ -0,0 +1,88 @@
1
+ """Email configuration step."""
2
+
3
+ from typing import Any
4
+
5
+ from .base import DeploymentStep
6
+
7
+
8
+ class EmailStep(DeploymentStep):
9
+ """Handles email configuration and credentials."""
10
+
11
+ name = "email"
12
+ description = "Configure email provider (AWS SES or SMTP) and credentials"
13
+ required = True
14
+
15
+ def collect_config(self) -> dict[str, Any]:
16
+ """Collect email configuration."""
17
+ config = {}
18
+
19
+ print("\n--- Email Configuration ---")
20
+
21
+ # Email provider
22
+ default_provider = self.defaults["email"]["provider"]
23
+ provider = self.config.get(
24
+ "MAIL_PROVIDER",
25
+ self.prompt("Mail provider (aws or smtp)", default_provider)
26
+ if self.interactive
27
+ else default_provider,
28
+ ).lower()
29
+
30
+ config["MAIL_PROVIDER"] = provider
31
+
32
+ # SMTP configuration
33
+ if provider == "smtp":
34
+ smtp_defaults = self.defaults["email"]["smtp"]
35
+
36
+ config["SMTP_HOST"] = self.config.get(
37
+ "SMTP_HOST",
38
+ self.prompt("SMTP host", smtp_defaults["host"])
39
+ if self.interactive
40
+ else smtp_defaults["host"],
41
+ )
42
+ config["SMTP_PORT"] = self.config.get(
43
+ "SMTP_PORT",
44
+ self.prompt("SMTP port", str(smtp_defaults["port"]))
45
+ if self.interactive
46
+ else str(smtp_defaults["port"]),
47
+ )
48
+ config["SMTP_USE_TLS"] = self.config.get("SMTP_USE_TLS", smtp_defaults["use_tls"])
49
+ config["SMTP_START_TLS"] = self.config.get("SMTP_START_TLS", smtp_defaults["start_tls"])
50
+ config["SMTP_VERIFY"] = self.config.get("SMTP_VERIFY", smtp_defaults["verify"])
51
+ config["SMTP_SENDER_EMAIL"] = self.config.get(
52
+ "SMTP_SENDER_EMAIL",
53
+ self.prompt("SMTP sender email", smtp_defaults["sender_email"])
54
+ if self.interactive
55
+ else smtp_defaults["sender_email"],
56
+ )
57
+
58
+ # Email authentication
59
+ config["USE_EMAIL_AUTHENTICATION"] = self.config.get(
60
+ "USE_EMAIL_AUTHENTICATION",
61
+ self.defaults["application"]["email_authentication"],
62
+ )
63
+
64
+ self.config.update(config)
65
+
66
+ return config
67
+
68
+ def generate_secrets(self) -> dict[str, str]:
69
+ """Generate email-related secrets."""
70
+ secrets_dict = {}
71
+
72
+ # SMTP password (if using SMTP)
73
+ if self.config["MAIL_PROVIDER"] == "smtp":
74
+ if self.interactive:
75
+ smtp_password = self.input_gatherer.prompt(
76
+ "SMTP password (press Enter to skip)", default=""
77
+ )
78
+ if smtp_password:
79
+ secrets_dict["smtp_password"] = smtp_password
80
+
81
+ # AWS SES credentials (if using AWS)
82
+ elif self.config["MAIL_PROVIDER"] == "aws":
83
+ # Generate empty placeholder files for AWS credentials
84
+ # These will be provided by Octopize to the user
85
+ secrets_dict["aws_mail_account_access_key_id"] = ""
86
+ secrets_dict["aws_mail_account_secret_access_key"] = ""
87
+
88
+ return secrets_dict
@@ -0,0 +1,32 @@
1
+ """Logging configuration step."""
2
+
3
+ from typing import Any
4
+
5
+ from .base import DeploymentStep
6
+
7
+
8
+ class LoggingStep(DeploymentStep):
9
+ """Handles application logging configuration."""
10
+
11
+ name = "logging"
12
+ description = "Configure application logging settings"
13
+ required = False
14
+
15
+ def collect_config(self) -> dict[str, Any]:
16
+ """Collect logging configuration."""
17
+ config = {}
18
+
19
+ # Console logging
20
+ if "USE_CONSOLE_LOGGING" not in self.config:
21
+ config["USE_CONSOLE_LOGGING"] = self.defaults["application"]["use_console_logging"]
22
+
23
+ # Log level
24
+ config["LOG_LEVEL"] = self.config.get(
25
+ "LOG_LEVEL", self.defaults["application"]["log_level"]
26
+ )
27
+
28
+ return config
29
+
30
+ def generate_secrets(self) -> dict[str, str]:
31
+ """No secrets needed for logging."""
32
+ return {}
@@ -0,0 +1,81 @@
1
+ """Required configuration step - collects mandatory deployment settings."""
2
+
3
+ import secrets
4
+ from typing import Any
5
+
6
+ from .base import DeploymentStep
7
+
8
+
9
+ class RequiredConfigStep(DeploymentStep):
10
+ """Collects required configuration that must be provided."""
11
+
12
+ name = "required_config"
13
+ description = "Collect required deployment settings (PUBLIC_URL, ENV_NAME, etc.)"
14
+ required = True
15
+
16
+ def collect_config(self) -> dict[str, Any]:
17
+ """Collect required configuration."""
18
+ config = {}
19
+
20
+ # Public URL - Required
21
+ if "PUBLIC_URL" in self.config:
22
+ config["PUBLIC_URL"] = self.config["PUBLIC_URL"]
23
+ elif self.interactive:
24
+ config["PUBLIC_URL"] = self.prompt("Public URL (domain name, e.g., avatar.example.com)")
25
+ else:
26
+ config["PUBLIC_URL"] = ""
27
+
28
+ # Environment name - Required
29
+ if "ENV_NAME" in self.config:
30
+ config["ENV_NAME"] = self.config["ENV_NAME"]
31
+ elif self.interactive:
32
+ config["ENV_NAME"] = self.prompt("Environment name (e.g., mycompany-prod)")
33
+ else:
34
+ config["ENV_NAME"] = ""
35
+
36
+ # Organization name - Required
37
+ if "ORGANIZATION_NAME" in self.config:
38
+ config["ORGANIZATION_NAME"] = self.config["ORGANIZATION_NAME"]
39
+ elif self.interactive:
40
+ while True:
41
+ org_name = self.prompt("Organization name (e.g., MyCompany)")
42
+ if org_name.strip():
43
+ config["ORGANIZATION_NAME"] = org_name
44
+ break
45
+ self.printer.print_error("Organization name is required and cannot be empty")
46
+ else:
47
+ raise ValueError(
48
+ "ORGANIZATION_NAME is required but not provided in configuration file. "
49
+ "Please add ORGANIZATION_NAME to your config or run in interactive mode."
50
+ )
51
+
52
+ # Service versions
53
+ config["AVATAR_API_VERSION"] = self.config.get(
54
+ "AVATAR_API_VERSION", self.defaults["images"]["api"]
55
+ )
56
+ config["AVATAR_WEB_VERSION"] = self.config.get(
57
+ "AVATAR_WEB_VERSION", self.defaults["images"]["web"]
58
+ )
59
+ config["AVATAR_PDFGENERATOR_VERSION"] = self.config.get(
60
+ "AVATAR_PDFGENERATOR_VERSION", self.defaults["images"]["pdfgenerator"]
61
+ )
62
+ config["AVATAR_SEAWEEDFS_VERSION"] = self.config.get(
63
+ "AVATAR_SEAWEEDFS_VERSION", self.defaults["images"]["seaweedfs"]
64
+ )
65
+ config["AVATAR_AUTHENTIK_VERSION"] = self.config.get(
66
+ "AVATAR_AUTHENTIK_VERSION", self.defaults["images"]["authentik"]
67
+ )
68
+
69
+ # Update self.config so generate_secrets() can access these values
70
+ self.config.update(config)
71
+
72
+ return config
73
+
74
+ def generate_secrets(self) -> dict[str, str]:
75
+ """Generate required API secrets."""
76
+ return {
77
+ "pepper": secrets.token_hex(),
78
+ "authjwt_secret_key": secrets.token_hex(),
79
+ "organization_name": self.config["ORGANIZATION_NAME"],
80
+ "clevercloud_sso_salt": secrets.token_hex(),
81
+ }
@@ -0,0 +1,32 @@
1
+ """Storage configuration step."""
2
+
3
+ import secrets
4
+ from typing import Any
5
+
6
+ from .base import DeploymentStep
7
+
8
+
9
+ class StorageStep(DeploymentStep):
10
+ """Handles storage configuration and credentials."""
11
+
12
+ name = "storage"
13
+ description = "Configure S3-compatible storage (SeaweedFS) credentials"
14
+ required = True
15
+
16
+ def collect_config(self) -> dict[str, Any]:
17
+ """Collect storage configuration."""
18
+ config: dict[str, Any] = {}
19
+
20
+ # Storage configuration is mostly handled via secrets
21
+ # No interactive prompts needed for basic setup
22
+
23
+ return config
24
+
25
+ def generate_secrets(self) -> dict[str, str]:
26
+ """Generate storage-related secrets."""
27
+ return {
28
+ "file_jwt_secret_key": secrets.token_hex(),
29
+ "file_encryption_key": self.generate_encryption_key(),
30
+ "storage_admin_access_key_id": secrets.token_hex(),
31
+ "storage_admin_secret_access_key": secrets.token_hex(),
32
+ }
@@ -0,0 +1,58 @@
1
+ """Telemetry and monitoring configuration step."""
2
+
3
+ from typing import Any
4
+
5
+ from .base import DeploymentStep
6
+
7
+
8
+ class TelemetryStep(DeploymentStep):
9
+ """Handles telemetry and Sentry monitoring configuration."""
10
+
11
+ name = "telemetry"
12
+ description = "Configure telemetry and monitoring (Sentry, usage analytics)"
13
+ required = False
14
+
15
+ def collect_config(self) -> dict[str, Any]:
16
+ """Collect telemetry configuration."""
17
+ config = {}
18
+
19
+ sentry_enabled = (
20
+ self.prompt_yes_no(
21
+ "Enable Sentry error monitoring?",
22
+ default=self.defaults["application"]["sentry_enabled"] == "true",
23
+ )
24
+ if self.interactive
25
+ else self.defaults["application"]["sentry_enabled"] == "true"
26
+ )
27
+
28
+ config["IS_SENTRY_ENABLED"] = "true" if sentry_enabled else "false"
29
+
30
+ enable_telemetry = (
31
+ self.prompt_yes_no(
32
+ "Enable usage telemetry?",
33
+ default=self.defaults["telemetry"]["enabled"],
34
+ )
35
+ if self.interactive
36
+ else self.defaults["telemetry"]["enabled"]
37
+ )
38
+
39
+ if enable_telemetry:
40
+ config["TELEMETRY_S3_ENDPOINT_URL"] = self.defaults["telemetry"]["endpoint_url"]
41
+ config["TELEMETRY_S3_REGION"] = self.defaults["telemetry"]["region"]
42
+ else:
43
+ config["TELEMETRY_S3_ENDPOINT_URL"] = ""
44
+ config["TELEMETRY_S3_REGION"] = ""
45
+
46
+ self.config.update(config)
47
+
48
+ return config
49
+
50
+ def generate_secrets(self) -> dict[str, str]:
51
+ """Generate telemetry-related secrets."""
52
+ # Only generate secrets if telemetry is enabled
53
+ if self.config["TELEMETRY_S3_ENDPOINT_URL"]:
54
+ return {
55
+ "telemetry_s3_access_key_id": "",
56
+ "telemetry_s3_secret_access_key": "",
57
+ }
58
+ return {}
@@ -0,0 +1,89 @@
1
+ """User authentication configuration step."""
2
+
3
+ import re
4
+ from typing import Any
5
+
6
+ from .base import DeploymentStep
7
+
8
+
9
+ def validate_comma_separated_emails(value: str) -> tuple[bool, str]:
10
+ """
11
+ Validate comma-separated email addresses.
12
+
13
+ Args:
14
+ value: String containing comma-separated email addresses
15
+
16
+ Returns:
17
+ Tuple of (is_valid, error_message)
18
+ """
19
+ if not value.strip():
20
+ # Empty is allowed (optional field)
21
+ return True, ""
22
+
23
+ # Simple email regex pattern
24
+ email_pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
25
+
26
+ emails = [email.strip() for email in value.split(",")]
27
+
28
+ for email in emails:
29
+ if not email:
30
+ return False, "Empty email address found in list"
31
+ if not re.match(email_pattern, email):
32
+ return False, f"Invalid email address: {email}"
33
+
34
+ return True, ""
35
+
36
+
37
+ class UserStep(DeploymentStep):
38
+ """Handles user authentication configuration."""
39
+
40
+ name = "user"
41
+ description = "Configure user authentication settings"
42
+ required = True
43
+
44
+ def collect_config(self) -> dict[str, Any]:
45
+ """Collect user authentication configuration."""
46
+ config = {}
47
+
48
+ # Check if email authentication is enabled
49
+ # Get from config, or fallback to defaults
50
+ use_email_auth = self.config.get(
51
+ "USE_EMAIL_AUTHENTICATION",
52
+ self.defaults.get("application", {}).get("email_authentication", True),
53
+ )
54
+ # Convert to string for comparison (defaults might be bool)
55
+ use_email_auth_str = str(use_email_auth).lower()
56
+
57
+ if use_email_auth_str == "true":
58
+ if self.interactive:
59
+ admin_emails = self.prompt(
60
+ "Admin email addresses (comma-separated)",
61
+ default="",
62
+ validate=validate_comma_separated_emails,
63
+ )
64
+ config["ADMIN_EMAILS"] = admin_emails
65
+ else:
66
+ config["ADMIN_EMAILS"] = self.config.get("ADMIN_EMAILS", "")
67
+
68
+ self.config.update(config)
69
+
70
+ return config
71
+
72
+ def generate_secrets(self) -> dict[str, str]:
73
+ """Generate user-related secrets."""
74
+ secrets_dict = {}
75
+
76
+ # Admin emails for email-based authentication
77
+ # USE_EMAIL_AUTHENTICATION comes from EmailStep, so use .get() with fallback
78
+ use_email_auth = self.config.get(
79
+ "USE_EMAIL_AUTHENTICATION",
80
+ self.defaults.get("application", {}).get("email_authentication", "true"),
81
+ )
82
+ # Convert to string for comparison
83
+ use_email_auth_str = str(use_email_auth).lower()
84
+
85
+ if use_email_auth_str == "true":
86
+ admin_emails = self.config.get("ADMIN_EMAILS", "")
87
+ secrets_dict["admin_emails"] = admin_emails
88
+
89
+ return secrets_dict