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.
- octopize_avatar_deploy/__init__.py +26 -0
- octopize_avatar_deploy/cli_test_harness.py +217 -0
- octopize_avatar_deploy/configure.py +705 -0
- octopize_avatar_deploy/defaults.yaml +63 -0
- octopize_avatar_deploy/download_templates.py +251 -0
- octopize_avatar_deploy/input_gatherer.py +324 -0
- octopize_avatar_deploy/printer.py +216 -0
- octopize_avatar_deploy/state_manager.py +136 -0
- octopize_avatar_deploy/steps/__init__.py +25 -0
- octopize_avatar_deploy/steps/authentik.py +46 -0
- octopize_avatar_deploy/steps/authentik_blueprint.py +79 -0
- octopize_avatar_deploy/steps/base.py +199 -0
- octopize_avatar_deploy/steps/database.py +37 -0
- octopize_avatar_deploy/steps/email.py +88 -0
- octopize_avatar_deploy/steps/logging.py +32 -0
- octopize_avatar_deploy/steps/required.py +81 -0
- octopize_avatar_deploy/steps/storage.py +32 -0
- octopize_avatar_deploy/steps/telemetry.py +58 -0
- octopize_avatar_deploy/steps/user.py +89 -0
- octopize_avatar_deploy/version_compat.py +344 -0
- octopize_deploy_tool-0.1.0.dist-info/METADATA +346 -0
- octopize_deploy_tool-0.1.0.dist-info/RECORD +24 -0
- octopize_deploy_tool-0.1.0.dist-info/WHEEL +4 -0
- octopize_deploy_tool-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,705 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Avatar Deployment Configuration Tool
|
|
4
|
+
|
|
5
|
+
This script coordinates the deployment configuration process by:
|
|
6
|
+
1. Loading configuration from files or user input
|
|
7
|
+
2. Executing deployment steps in sequence
|
|
8
|
+
3. Generating configuration files from templates
|
|
9
|
+
4. Managing deployment state for resumption
|
|
10
|
+
|
|
11
|
+
Usage:
|
|
12
|
+
# Interactive mode
|
|
13
|
+
python configure.py
|
|
14
|
+
|
|
15
|
+
# Non-interactive mode with config file
|
|
16
|
+
python configure.py --config config.yaml
|
|
17
|
+
|
|
18
|
+
# Specify output directory
|
|
19
|
+
python configure.py --output-dir /app/avatar
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import argparse
|
|
23
|
+
import os
|
|
24
|
+
import shutil
|
|
25
|
+
import sys
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
import yaml
|
|
30
|
+
from jinja2 import Environment, FileSystemLoader
|
|
31
|
+
|
|
32
|
+
from octopize_avatar_deploy.download_templates import (
|
|
33
|
+
LocalTemplateProvider,
|
|
34
|
+
download_templates,
|
|
35
|
+
)
|
|
36
|
+
from octopize_avatar_deploy.input_gatherer import (
|
|
37
|
+
ConsoleInputGatherer,
|
|
38
|
+
InputGatherer,
|
|
39
|
+
RichInputGatherer,
|
|
40
|
+
)
|
|
41
|
+
from octopize_avatar_deploy.printer import ConsolePrinter, Printer, RichPrinter
|
|
42
|
+
from octopize_avatar_deploy.state_manager import DeploymentState
|
|
43
|
+
from octopize_avatar_deploy.steps import (
|
|
44
|
+
AuthentikBlueprintStep,
|
|
45
|
+
AuthentikStep,
|
|
46
|
+
DatabaseStep,
|
|
47
|
+
DeploymentStep,
|
|
48
|
+
EmailStep,
|
|
49
|
+
LoggingStep,
|
|
50
|
+
RequiredConfigStep,
|
|
51
|
+
StorageStep,
|
|
52
|
+
TelemetryStep,
|
|
53
|
+
UserStep,
|
|
54
|
+
)
|
|
55
|
+
from octopize_avatar_deploy.version_compat import (
|
|
56
|
+
SCRIPT_VERSION,
|
|
57
|
+
VersionError,
|
|
58
|
+
validate_template_version,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class DeploymentConfigurator:
|
|
63
|
+
"""
|
|
64
|
+
Coordinates Avatar deployment configuration using modular steps.
|
|
65
|
+
|
|
66
|
+
This class acts as an executor that:
|
|
67
|
+
- Loads defaults and configuration
|
|
68
|
+
- Executes deployment steps in order
|
|
69
|
+
- Generates configuration files from templates
|
|
70
|
+
- Manages deployment state
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
# Default step classes (can be overridden in tests)
|
|
74
|
+
DEFAULT_STEP_CLASSES: list[type[DeploymentStep]] = [
|
|
75
|
+
RequiredConfigStep,
|
|
76
|
+
DatabaseStep,
|
|
77
|
+
AuthentikStep,
|
|
78
|
+
AuthentikBlueprintStep,
|
|
79
|
+
StorageStep,
|
|
80
|
+
EmailStep,
|
|
81
|
+
UserStep,
|
|
82
|
+
TelemetryStep,
|
|
83
|
+
LoggingStep,
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
def __init__(
|
|
87
|
+
self,
|
|
88
|
+
templates_dir: Path,
|
|
89
|
+
output_dir: Path,
|
|
90
|
+
defaults_file: Path | None = None,
|
|
91
|
+
config: dict[str, Any] | None = None,
|
|
92
|
+
use_state: bool = True,
|
|
93
|
+
printer: Printer | None = None,
|
|
94
|
+
input_gatherer: InputGatherer | None = None,
|
|
95
|
+
step_classes: list[type[DeploymentStep]] | None = None,
|
|
96
|
+
):
|
|
97
|
+
"""
|
|
98
|
+
Initialize the configurator.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
templates_dir: Path to templates directory
|
|
102
|
+
output_dir: Path where configuration files will be generated
|
|
103
|
+
defaults_file: Path to defaults.yaml file
|
|
104
|
+
config: Optional pre-loaded configuration dict
|
|
105
|
+
use_state: Whether to use state management for resuming
|
|
106
|
+
printer: Optional printer for output (defaults to ConsolePrinter)
|
|
107
|
+
input_gatherer: Optional input gatherer (defaults to ConsoleInputGatherer)
|
|
108
|
+
step_classes: Optional list of step classes to use (defaults to DEFAULT_STEP_CLASSES)
|
|
109
|
+
"""
|
|
110
|
+
self.templates_dir = Path(templates_dir)
|
|
111
|
+
self.output_dir = Path(output_dir)
|
|
112
|
+
self.config = config or {}
|
|
113
|
+
self.use_state = use_state
|
|
114
|
+
self.step_classes = step_classes or self.DEFAULT_STEP_CLASSES
|
|
115
|
+
|
|
116
|
+
# Use Rich implementations if in interactive terminal, otherwise Console
|
|
117
|
+
if printer is None:
|
|
118
|
+
# Only use Rich if stdout is a TTY (interactive terminal)
|
|
119
|
+
if sys.stdout.isatty():
|
|
120
|
+
self.printer: Printer = RichPrinter()
|
|
121
|
+
else:
|
|
122
|
+
self.printer = ConsolePrinter()
|
|
123
|
+
else:
|
|
124
|
+
self.printer = printer
|
|
125
|
+
|
|
126
|
+
if input_gatherer is None:
|
|
127
|
+
# Only use Rich if stdin is a TTY (interactive terminal)
|
|
128
|
+
if sys.stdin.isatty():
|
|
129
|
+
self.input_gatherer: InputGatherer = RichInputGatherer()
|
|
130
|
+
else:
|
|
131
|
+
self.input_gatherer = ConsoleInputGatherer()
|
|
132
|
+
else:
|
|
133
|
+
self.input_gatherer = input_gatherer
|
|
134
|
+
|
|
135
|
+
# Initialize state manager
|
|
136
|
+
self.state: DeploymentState | None
|
|
137
|
+
if use_state:
|
|
138
|
+
state_file = self.output_dir / ".deployment-state.yaml"
|
|
139
|
+
# Derive step names from step classes
|
|
140
|
+
step_names = [
|
|
141
|
+
f"step_{i}_{step_class.name}" for i, step_class in enumerate(self.step_classes)
|
|
142
|
+
]
|
|
143
|
+
self.state = DeploymentState(state_file, steps=step_names)
|
|
144
|
+
else:
|
|
145
|
+
self.state = None
|
|
146
|
+
|
|
147
|
+
# Load defaults
|
|
148
|
+
self.defaults = self._load_defaults(defaults_file)
|
|
149
|
+
|
|
150
|
+
# Initialize Jinja2 environment
|
|
151
|
+
self.env = Environment(
|
|
152
|
+
loader=FileSystemLoader(self.templates_dir),
|
|
153
|
+
variable_start_string="{{",
|
|
154
|
+
variable_end_string="}}",
|
|
155
|
+
trim_blocks=True,
|
|
156
|
+
lstrip_blocks=True,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
def _load_defaults(self, defaults_file: Path | None = None) -> dict[str, Any]:
|
|
160
|
+
"""Load default configuration from defaults.yaml."""
|
|
161
|
+
if defaults_file and defaults_file.exists():
|
|
162
|
+
with open(defaults_file) as f:
|
|
163
|
+
return yaml.safe_load(f)
|
|
164
|
+
else:
|
|
165
|
+
# Try to find defaults.yaml in the same directory as this script
|
|
166
|
+
script_dir = Path(__file__).parent
|
|
167
|
+
default_defaults = script_dir / "defaults.yaml"
|
|
168
|
+
if default_defaults.exists():
|
|
169
|
+
with open(default_defaults) as f:
|
|
170
|
+
return yaml.safe_load(f)
|
|
171
|
+
else:
|
|
172
|
+
raise FileNotFoundError(
|
|
173
|
+
f"defaults.yaml not found at {defaults_file} or {default_defaults}"
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
def render_template(self, template_name: str, output_name: str) -> None:
|
|
177
|
+
"""
|
|
178
|
+
Render a template file with configuration values.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
template_name: Name of the template file
|
|
182
|
+
output_name: Name of the output file
|
|
183
|
+
"""
|
|
184
|
+
try:
|
|
185
|
+
template = self.env.get_template(template_name)
|
|
186
|
+
rendered = template.render(**self.config)
|
|
187
|
+
|
|
188
|
+
output_path = self.output_dir / output_name
|
|
189
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
190
|
+
output_path.write_text(rendered)
|
|
191
|
+
|
|
192
|
+
self.printer.print_success(f"Generated: {output_path}")
|
|
193
|
+
except Exception as e:
|
|
194
|
+
self.printer.print_error(f"Error rendering {template_name}: {e}")
|
|
195
|
+
raise
|
|
196
|
+
|
|
197
|
+
def generate_configs(self) -> None:
|
|
198
|
+
"""Generate all configuration files from templates."""
|
|
199
|
+
|
|
200
|
+
self.printer.print_header("Generating Configuration Files")
|
|
201
|
+
|
|
202
|
+
# Generate .env file
|
|
203
|
+
self.render_template(".env.template", ".env")
|
|
204
|
+
|
|
205
|
+
# Generate nginx.conf
|
|
206
|
+
nginx_dir = self.output_dir / "nginx"
|
|
207
|
+
nginx_dir.mkdir(parents=True, exist_ok=True)
|
|
208
|
+
self.render_template("nginx.conf.template", "nginx/nginx.conf")
|
|
209
|
+
|
|
210
|
+
# Generate Authentik blueprint
|
|
211
|
+
authentik_dir = self.output_dir / "authentik"
|
|
212
|
+
authentik_dir.mkdir(parents=True, exist_ok=True)
|
|
213
|
+
self.render_template(
|
|
214
|
+
"authentik/octopize-avatar-blueprint.yaml.j2",
|
|
215
|
+
"authentik/octopize-avatar-blueprint.yaml",
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
# Copy docker-compose.yml from templates (it may be templated in the future)
|
|
219
|
+
docker_compose_src = self.templates_dir / "docker-compose.yml"
|
|
220
|
+
docker_compose_dst = self.output_dir / "docker-compose.yml"
|
|
221
|
+
if docker_compose_src.exists():
|
|
222
|
+
shutil.copy2(docker_compose_src, docker_compose_dst)
|
|
223
|
+
self.printer.print_success(f"Generated: {docker_compose_dst}")
|
|
224
|
+
|
|
225
|
+
self.printer.print()
|
|
226
|
+
self.printer.print_success("Configuration files generated successfully!")
|
|
227
|
+
|
|
228
|
+
def save_config_to_file(self, config_file: Path) -> None:
|
|
229
|
+
"""Save current configuration to a YAML file."""
|
|
230
|
+
with open(config_file, "w") as f:
|
|
231
|
+
yaml.dump(self.config, f, default_flow_style=False, sort_keys=False)
|
|
232
|
+
self.printer.print()
|
|
233
|
+
self.printer.print_success(f"Configuration saved to {config_file}")
|
|
234
|
+
|
|
235
|
+
def write_secrets(self, secrets: dict[str, str]) -> None:
|
|
236
|
+
"""
|
|
237
|
+
Write secrets to the .secrets/ directory.
|
|
238
|
+
|
|
239
|
+
Args:
|
|
240
|
+
secrets: Dictionary of {filename: secret_value}
|
|
241
|
+
"""
|
|
242
|
+
secrets_dir = self.output_dir / ".secrets"
|
|
243
|
+
secrets_dir.mkdir(parents=True, exist_ok=True)
|
|
244
|
+
|
|
245
|
+
for secret_name, secret_value in secrets.items():
|
|
246
|
+
secret_file = secrets_dir / secret_name
|
|
247
|
+
secret_file.write_text(secret_value)
|
|
248
|
+
|
|
249
|
+
def run(
|
|
250
|
+
self,
|
|
251
|
+
interactive: bool = True,
|
|
252
|
+
config_file: Path | None = None,
|
|
253
|
+
save_config: bool = False,
|
|
254
|
+
) -> None:
|
|
255
|
+
"""
|
|
256
|
+
Run the configuration process using step-based architecture.
|
|
257
|
+
|
|
258
|
+
Args:
|
|
259
|
+
interactive: Whether to prompt for values interactively
|
|
260
|
+
config_file: Path to YAML config file to load
|
|
261
|
+
save_config: Whether to save configuration to file
|
|
262
|
+
"""
|
|
263
|
+
# Check for existing state and prompt to resume or restart
|
|
264
|
+
if self.state and self.state.has_started() and not self.state.is_complete():
|
|
265
|
+
if interactive:
|
|
266
|
+
self.state.print_status()
|
|
267
|
+
self.printer.print_header("")
|
|
268
|
+
response = self.input_gatherer.prompt_yes_no(
|
|
269
|
+
"Resume from where you left off?", default=True
|
|
270
|
+
)
|
|
271
|
+
if not response:
|
|
272
|
+
self.printer.print("Starting fresh configuration...")
|
|
273
|
+
self.state.reset()
|
|
274
|
+
else:
|
|
275
|
+
self.printer.print("Resuming from last completed step...")
|
|
276
|
+
# Load saved config from state
|
|
277
|
+
self.config.update(self.state.get_config())
|
|
278
|
+
else:
|
|
279
|
+
# Non-interactive mode: always resume if state exists
|
|
280
|
+
self.config.update(self.state.get_config())
|
|
281
|
+
|
|
282
|
+
# Load configuration from file if provided
|
|
283
|
+
if config_file and config_file.exists():
|
|
284
|
+
self.printer.print(f"Loading configuration from {config_file}...")
|
|
285
|
+
try:
|
|
286
|
+
with open(config_file) as f:
|
|
287
|
+
loaded_config = yaml.safe_load(f) or {}
|
|
288
|
+
self.config.update(loaded_config)
|
|
289
|
+
except yaml.YAMLError as e:
|
|
290
|
+
raise RuntimeError(f"Failed to parse YAML config file: {e}") from e
|
|
291
|
+
except Exception as e:
|
|
292
|
+
raise RuntimeError(f"Failed to load config file: {e}") from e
|
|
293
|
+
|
|
294
|
+
# Instantiate deployment steps
|
|
295
|
+
steps = [
|
|
296
|
+
step_class(
|
|
297
|
+
self.output_dir,
|
|
298
|
+
self.defaults,
|
|
299
|
+
self.config,
|
|
300
|
+
interactive,
|
|
301
|
+
self.printer,
|
|
302
|
+
self.input_gatherer,
|
|
303
|
+
)
|
|
304
|
+
for step_class in self.step_classes
|
|
305
|
+
]
|
|
306
|
+
|
|
307
|
+
self.printer.print_header("Avatar Deployment Configuration")
|
|
308
|
+
self.printer.print()
|
|
309
|
+
self.printer.print("Executing configuration steps...")
|
|
310
|
+
self.printer.print()
|
|
311
|
+
|
|
312
|
+
# Execute each step
|
|
313
|
+
all_secrets = {}
|
|
314
|
+
for i, step in enumerate(steps):
|
|
315
|
+
step_name = f"step_{i}_{step.name}"
|
|
316
|
+
|
|
317
|
+
# Skip if step already completed (when resuming)
|
|
318
|
+
if self.state and self.state.is_step_completed(step_name):
|
|
319
|
+
self.printer.print_step(step.description, skipped=True)
|
|
320
|
+
continue
|
|
321
|
+
|
|
322
|
+
self.printer.print_step(step.description)
|
|
323
|
+
|
|
324
|
+
# Mark step as started in state
|
|
325
|
+
if self.state:
|
|
326
|
+
self.state.mark_step_started(step_name)
|
|
327
|
+
|
|
328
|
+
# Collect configuration from step
|
|
329
|
+
step_config = step.collect_config()
|
|
330
|
+
self.config.update(step_config)
|
|
331
|
+
|
|
332
|
+
# Generate secrets from step
|
|
333
|
+
step_secrets = step.generate_secrets()
|
|
334
|
+
all_secrets.update(step_secrets)
|
|
335
|
+
|
|
336
|
+
# Validate step
|
|
337
|
+
if not step.validate():
|
|
338
|
+
raise ValueError(f"Validation failed for step: {step.name}")
|
|
339
|
+
|
|
340
|
+
# Mark step as completed and save config to state
|
|
341
|
+
if self.state:
|
|
342
|
+
self.state.mark_step_completed(step_name)
|
|
343
|
+
self.state.update_config(self.config)
|
|
344
|
+
|
|
345
|
+
# Generate configuration files
|
|
346
|
+
self.generate_configs()
|
|
347
|
+
|
|
348
|
+
# Write all secrets
|
|
349
|
+
if all_secrets:
|
|
350
|
+
self.printer.print()
|
|
351
|
+
self.printer.print(f"Writing {len(all_secrets)} secrets to .secrets/ directory...")
|
|
352
|
+
self.write_secrets(all_secrets)
|
|
353
|
+
self.printer.print_success("Secrets written successfully")
|
|
354
|
+
|
|
355
|
+
# Save config if requested
|
|
356
|
+
if save_config:
|
|
357
|
+
config_output = self.output_dir / "deployment-config.yaml"
|
|
358
|
+
self.save_config_to_file(config_output)
|
|
359
|
+
|
|
360
|
+
# Success message
|
|
361
|
+
self.printer.print_header("Configuration Complete!")
|
|
362
|
+
self.printer.print(f"\nConfiguration files generated in: {self.output_dir}")
|
|
363
|
+
self.printer.print("\nNext steps:")
|
|
364
|
+
self.printer.print("1. Review and edit the generated .env file")
|
|
365
|
+
self.printer.print("2. Fill in any remaining secrets in .secrets/ directory")
|
|
366
|
+
self.printer.print("3. Configure TLS certificates in the tls/ directory")
|
|
367
|
+
self.printer.print("4. Run: docker compose up -d")
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
class DeploymentRunner:
|
|
371
|
+
"""
|
|
372
|
+
High-level orchestrator for the Avatar deployment process.
|
|
373
|
+
|
|
374
|
+
This class provides a CLI-independent entry point that coordinates:
|
|
375
|
+
- Template downloading from GitHub
|
|
376
|
+
- Template verification
|
|
377
|
+
- Configuration generation via DeploymentConfigurator
|
|
378
|
+
|
|
379
|
+
This allows the deployment process to be used programmatically
|
|
380
|
+
without depending on command-line arguments.
|
|
381
|
+
"""
|
|
382
|
+
|
|
383
|
+
def __init__(
|
|
384
|
+
self,
|
|
385
|
+
output_dir: Path | str,
|
|
386
|
+
template_from: str | Path = "github",
|
|
387
|
+
verbose: bool = False,
|
|
388
|
+
printer: Printer | None = None,
|
|
389
|
+
input_gatherer: InputGatherer | None = None,
|
|
390
|
+
):
|
|
391
|
+
"""
|
|
392
|
+
Initialize the deployment runner.
|
|
393
|
+
|
|
394
|
+
Args:
|
|
395
|
+
output_dir: Directory where configuration files will be generated
|
|
396
|
+
template_from: Either 'github' to download from the repo, or a path
|
|
397
|
+
to local templates directory
|
|
398
|
+
verbose: Enable verbose output
|
|
399
|
+
printer: Optional printer for output (defaults to ConsolePrinter)
|
|
400
|
+
input_gatherer: Optional input gatherer for prompts
|
|
401
|
+
"""
|
|
402
|
+
self.output_dir = Path(output_dir)
|
|
403
|
+
self.template_from = template_from
|
|
404
|
+
self.verbose = verbose
|
|
405
|
+
|
|
406
|
+
# Use Rich implementations if in interactive terminal, otherwise Console
|
|
407
|
+
if printer is None:
|
|
408
|
+
# Only use Rich if stdout is a TTY (interactive terminal)
|
|
409
|
+
if sys.stdout.isatty():
|
|
410
|
+
try:
|
|
411
|
+
self.printer: Printer = RichPrinter()
|
|
412
|
+
except ImportError:
|
|
413
|
+
self.printer = ConsolePrinter()
|
|
414
|
+
else:
|
|
415
|
+
self.printer = ConsolePrinter()
|
|
416
|
+
else:
|
|
417
|
+
self.printer = printer
|
|
418
|
+
|
|
419
|
+
if input_gatherer is None:
|
|
420
|
+
# Only use Rich if stdin is a TTY (interactive terminal)
|
|
421
|
+
if sys.stdin.isatty():
|
|
422
|
+
try:
|
|
423
|
+
self.input_gatherer: InputGatherer = RichInputGatherer()
|
|
424
|
+
except ImportError:
|
|
425
|
+
self.input_gatherer = ConsoleInputGatherer()
|
|
426
|
+
else:
|
|
427
|
+
self.input_gatherer = ConsoleInputGatherer()
|
|
428
|
+
else:
|
|
429
|
+
self.input_gatherer = input_gatherer
|
|
430
|
+
|
|
431
|
+
# Templates are always stored in output_dir/.avatar-templates
|
|
432
|
+
self.templates_dir = self.output_dir / ".avatar-templates"
|
|
433
|
+
|
|
434
|
+
def ensure_templates(self) -> bool:
|
|
435
|
+
"""
|
|
436
|
+
Ensure templates are available by downloading from GitHub or copying from local path.
|
|
437
|
+
|
|
438
|
+
Returns:
|
|
439
|
+
True if templates are available, False otherwise
|
|
440
|
+
"""
|
|
441
|
+
# Handle 'github' - download from repository
|
|
442
|
+
if self.template_from == "github":
|
|
443
|
+
if self.verbose:
|
|
444
|
+
self.printer.print("Downloading deployment templates from GitHub...")
|
|
445
|
+
|
|
446
|
+
success = download_templates(
|
|
447
|
+
output_dir=self.templates_dir,
|
|
448
|
+
force=False, # Use cached if available
|
|
449
|
+
branch="main",
|
|
450
|
+
verbose=self.verbose,
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
if not success:
|
|
454
|
+
self.printer.print_error("Failed to download templates from GitHub")
|
|
455
|
+
return False
|
|
456
|
+
|
|
457
|
+
return self._verify_templates()
|
|
458
|
+
|
|
459
|
+
# Handle local path - copy templates from specified directory
|
|
460
|
+
template_source = Path(self.template_from)
|
|
461
|
+
if not template_source.exists():
|
|
462
|
+
self.printer.print_error(f"Template source directory not found: {template_source}")
|
|
463
|
+
return False
|
|
464
|
+
|
|
465
|
+
if self.verbose:
|
|
466
|
+
self.printer.print(f"Copying templates from {template_source}")
|
|
467
|
+
|
|
468
|
+
provider = LocalTemplateProvider(source_dir=str(template_source), verbose=self.verbose)
|
|
469
|
+
|
|
470
|
+
success = provider.provide_all(self.templates_dir)
|
|
471
|
+
if not success:
|
|
472
|
+
self.printer.print_warning("Failed to copy some templates")
|
|
473
|
+
return False
|
|
474
|
+
|
|
475
|
+
return self._verify_templates()
|
|
476
|
+
|
|
477
|
+
def _verify_templates(self) -> bool:
|
|
478
|
+
"""
|
|
479
|
+
Verify that required templates exist.
|
|
480
|
+
|
|
481
|
+
Returns:
|
|
482
|
+
True if templates are available, False otherwise
|
|
483
|
+
"""
|
|
484
|
+
from octopize_avatar_deploy.download_templates import REQUIRED_FILES
|
|
485
|
+
|
|
486
|
+
if not self.templates_dir.exists():
|
|
487
|
+
if self.verbose:
|
|
488
|
+
self.printer.print_error(f"Templates directory not found: {self.templates_dir}")
|
|
489
|
+
return False
|
|
490
|
+
|
|
491
|
+
# Check for required files
|
|
492
|
+
missing_files = []
|
|
493
|
+
for filename in REQUIRED_FILES:
|
|
494
|
+
if not (self.templates_dir / filename).exists():
|
|
495
|
+
missing_files.append(filename)
|
|
496
|
+
|
|
497
|
+
if missing_files:
|
|
498
|
+
if self.verbose:
|
|
499
|
+
self.printer.print_error(
|
|
500
|
+
f"Missing required template files: {', '.join(missing_files)}"
|
|
501
|
+
)
|
|
502
|
+
return False
|
|
503
|
+
|
|
504
|
+
if self.verbose:
|
|
505
|
+
self.printer.print_success(f"Found all {len(REQUIRED_FILES)} required template files")
|
|
506
|
+
|
|
507
|
+
# Validate template version compatibility
|
|
508
|
+
return self._validate_template_version()
|
|
509
|
+
|
|
510
|
+
def _validate_template_version(self) -> bool:
|
|
511
|
+
"""
|
|
512
|
+
Validate that the template version is compatible with the script version.
|
|
513
|
+
|
|
514
|
+
Returns:
|
|
515
|
+
True if compatible, False otherwise
|
|
516
|
+
"""
|
|
517
|
+
version_file = self.templates_dir / ".template-version"
|
|
518
|
+
|
|
519
|
+
if not version_file.exists():
|
|
520
|
+
if self.verbose:
|
|
521
|
+
self.printer.print_warning(
|
|
522
|
+
"No .template-version file found, skipping version check"
|
|
523
|
+
)
|
|
524
|
+
return True
|
|
525
|
+
|
|
526
|
+
try:
|
|
527
|
+
validate_template_version(
|
|
528
|
+
version_file=version_file,
|
|
529
|
+
script_version=SCRIPT_VERSION,
|
|
530
|
+
verbose=self.verbose,
|
|
531
|
+
)
|
|
532
|
+
if self.verbose:
|
|
533
|
+
self.printer.print_success(
|
|
534
|
+
f"Template version is compatible with script version {SCRIPT_VERSION}"
|
|
535
|
+
)
|
|
536
|
+
return True
|
|
537
|
+
except VersionError as e:
|
|
538
|
+
self.printer.print_error(str(e))
|
|
539
|
+
return False
|
|
540
|
+
|
|
541
|
+
def run(
|
|
542
|
+
self,
|
|
543
|
+
interactive: bool = True,
|
|
544
|
+
config_file: Path | None = None,
|
|
545
|
+
save_config: bool = False,
|
|
546
|
+
) -> None:
|
|
547
|
+
"""
|
|
548
|
+
Run the complete deployment configuration process.
|
|
549
|
+
|
|
550
|
+
Args:
|
|
551
|
+
interactive: Whether to prompt for values interactively
|
|
552
|
+
config_file: Optional YAML config file to load
|
|
553
|
+
save_config: Whether to save configuration to file
|
|
554
|
+
|
|
555
|
+
Raises:
|
|
556
|
+
RuntimeError: If templates are not available
|
|
557
|
+
FileNotFoundError: If config file is specified but doesn't exist
|
|
558
|
+
yaml.YAMLError: If config file has invalid YAML syntax
|
|
559
|
+
ValueError: If config file has invalid values
|
|
560
|
+
KeyboardInterrupt: If user cancels the process
|
|
561
|
+
Exception: For other errors during configuration
|
|
562
|
+
"""
|
|
563
|
+
# Validate config file if provided
|
|
564
|
+
if config_file is not None:
|
|
565
|
+
if not config_file.exists():
|
|
566
|
+
self.printer.print_error(f"Config file not found: {config_file}")
|
|
567
|
+
raise FileNotFoundError(f"Config file not found: {config_file}")
|
|
568
|
+
|
|
569
|
+
# Try to load and validate the YAML
|
|
570
|
+
try:
|
|
571
|
+
with open(config_file) as f:
|
|
572
|
+
config_data = yaml.safe_load(f)
|
|
573
|
+
|
|
574
|
+
if config_data is None:
|
|
575
|
+
self.printer.print_error(f"Config file is empty: {config_file}")
|
|
576
|
+
raise ValueError(f"Config file is empty: {config_file}")
|
|
577
|
+
|
|
578
|
+
if not isinstance(config_data, dict):
|
|
579
|
+
self.printer.print_error(
|
|
580
|
+
f"Config file must contain a YAML dictionary, "
|
|
581
|
+
f"got {type(config_data).__name__}"
|
|
582
|
+
)
|
|
583
|
+
raise ValueError(
|
|
584
|
+
f"Config file must contain a YAML dictionary, "
|
|
585
|
+
f"got {type(config_data).__name__}"
|
|
586
|
+
)
|
|
587
|
+
|
|
588
|
+
except yaml.YAMLError as e:
|
|
589
|
+
self.printer.print_error(f"Invalid YAML syntax in config file: {config_file}")
|
|
590
|
+
self.printer.print_error(str(e))
|
|
591
|
+
raise
|
|
592
|
+
|
|
593
|
+
# Ensure templates are available
|
|
594
|
+
if not self.ensure_templates():
|
|
595
|
+
raise RuntimeError(
|
|
596
|
+
"Templates not available. Use --template-from github to download from the "
|
|
597
|
+
"repository, or provide a valid local path."
|
|
598
|
+
)
|
|
599
|
+
|
|
600
|
+
# Create and run configurator
|
|
601
|
+
configurator = DeploymentConfigurator(
|
|
602
|
+
templates_dir=self.templates_dir,
|
|
603
|
+
output_dir=self.output_dir,
|
|
604
|
+
printer=self.printer,
|
|
605
|
+
input_gatherer=self.input_gatherer,
|
|
606
|
+
)
|
|
607
|
+
|
|
608
|
+
configurator.run(
|
|
609
|
+
interactive=interactive,
|
|
610
|
+
config_file=config_file,
|
|
611
|
+
save_config=save_config,
|
|
612
|
+
)
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def main():
|
|
616
|
+
"""CLI entry point for Avatar deployment configuration."""
|
|
617
|
+
parser = argparse.ArgumentParser(
|
|
618
|
+
description="Avatar Deployment Configuration Tool",
|
|
619
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
620
|
+
)
|
|
621
|
+
|
|
622
|
+
parser.add_argument(
|
|
623
|
+
"--output-dir",
|
|
624
|
+
type=Path,
|
|
625
|
+
default=Path.cwd(),
|
|
626
|
+
help="Output directory for generated files (default: current directory)",
|
|
627
|
+
)
|
|
628
|
+
|
|
629
|
+
parser.add_argument(
|
|
630
|
+
"--template-from",
|
|
631
|
+
type=str,
|
|
632
|
+
default="github",
|
|
633
|
+
help="Template source: 'github' to download from repo, or path to local templates "
|
|
634
|
+
"directory (default: github)",
|
|
635
|
+
)
|
|
636
|
+
|
|
637
|
+
parser.add_argument(
|
|
638
|
+
"--config",
|
|
639
|
+
type=Path,
|
|
640
|
+
help="YAML configuration file to load",
|
|
641
|
+
)
|
|
642
|
+
|
|
643
|
+
parser.add_argument(
|
|
644
|
+
"--non-interactive",
|
|
645
|
+
action="store_true",
|
|
646
|
+
help="Run in non-interactive mode (use defaults or config file)",
|
|
647
|
+
)
|
|
648
|
+
|
|
649
|
+
parser.add_argument(
|
|
650
|
+
"--save-config",
|
|
651
|
+
action="store_true",
|
|
652
|
+
help="Save configuration to deployment-config.yaml",
|
|
653
|
+
)
|
|
654
|
+
|
|
655
|
+
parser.add_argument(
|
|
656
|
+
"--verbose",
|
|
657
|
+
action="store_true",
|
|
658
|
+
help="Verbose output",
|
|
659
|
+
)
|
|
660
|
+
|
|
661
|
+
args = parser.parse_args()
|
|
662
|
+
|
|
663
|
+
# Check if we're in test mode
|
|
664
|
+
test_printer = None
|
|
665
|
+
test_input_gatherer = None
|
|
666
|
+
if os.environ.get("AVATAR_DEPLOY_TEST_MODE") == "1":
|
|
667
|
+
from octopize_avatar_deploy.cli_test_harness import (
|
|
668
|
+
get_test_input_gatherer,
|
|
669
|
+
get_test_printer,
|
|
670
|
+
)
|
|
671
|
+
|
|
672
|
+
test_printer = get_test_printer()
|
|
673
|
+
test_input_gatherer = get_test_input_gatherer()
|
|
674
|
+
|
|
675
|
+
# Create deployment runner
|
|
676
|
+
runner = DeploymentRunner(
|
|
677
|
+
output_dir=args.output_dir,
|
|
678
|
+
template_from=args.template_from,
|
|
679
|
+
verbose=args.verbose,
|
|
680
|
+
printer=test_printer,
|
|
681
|
+
input_gatherer=test_input_gatherer,
|
|
682
|
+
)
|
|
683
|
+
|
|
684
|
+
try:
|
|
685
|
+
runner.run(
|
|
686
|
+
interactive=not args.non_interactive,
|
|
687
|
+
config_file=args.config,
|
|
688
|
+
save_config=args.save_config,
|
|
689
|
+
)
|
|
690
|
+
except KeyboardInterrupt:
|
|
691
|
+
print("\n\nConfiguration cancelled by user.")
|
|
692
|
+
sys.exit(1)
|
|
693
|
+
except RuntimeError as e:
|
|
694
|
+
print(f"\n✗ Error: {e}")
|
|
695
|
+
sys.exit(1)
|
|
696
|
+
except Exception as e:
|
|
697
|
+
print(f"\n✗ Error: {e}")
|
|
698
|
+
import traceback
|
|
699
|
+
|
|
700
|
+
traceback.print_exc()
|
|
701
|
+
sys.exit(1)
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
if __name__ == "__main__":
|
|
705
|
+
main()
|