kekkai-cli 1.1.0__py3-none-any.whl → 1.1.1__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.
portal/ops/upgrade.py DELETED
@@ -1,591 +0,0 @@
1
- """Upgrade management for Kekkai Portal.
2
-
3
- Provides:
4
- - Version manifest tracking
5
- - Pre-upgrade health checks
6
- - Rollback capability with snapshots
7
- - Migration status tracking
8
-
9
- Security controls:
10
- - Version pinning
11
- - Integrity verification
12
- - Safe rollback procedures
13
- """
14
-
15
- from __future__ import annotations
16
-
17
- import json
18
- import logging
19
- import os
20
- import subprocess
21
- from dataclasses import dataclass, field
22
- from datetime import UTC, datetime
23
- from enum import Enum
24
- from pathlib import Path
25
- from typing import Any
26
-
27
- logger = logging.getLogger(__name__)
28
-
29
- MANIFEST_VERSION = 1
30
-
31
-
32
- class UpgradeStatus(Enum):
33
- """Status of an upgrade operation."""
34
-
35
- PENDING = "pending"
36
- PRE_CHECK = "pre_check"
37
- BACKUP = "backup"
38
- UPGRADING = "upgrading"
39
- MIGRATING = "migrating"
40
- VERIFYING = "verifying"
41
- COMPLETED = "completed"
42
- FAILED = "failed"
43
- ROLLED_BACK = "rolled_back"
44
-
45
-
46
- class ComponentType(Enum):
47
- """Types of components that can be upgraded."""
48
-
49
- PORTAL = "portal"
50
- DEFECTDOJO = "defectdojo"
51
- POSTGRES = "postgres"
52
- NGINX = "nginx"
53
- VALKEY = "valkey"
54
-
55
-
56
- @dataclass
57
- class ComponentVersion:
58
- """Version information for a component."""
59
-
60
- component: ComponentType
61
- current_version: str
62
- target_version: str | None = None
63
- image_digest: str | None = None
64
- pinned: bool = True
65
-
66
- def to_dict(self) -> dict[str, Any]:
67
- """Convert to dictionary."""
68
- return {
69
- "component": self.component.value,
70
- "current_version": self.current_version,
71
- "target_version": self.target_version,
72
- "image_digest": self.image_digest,
73
- "pinned": self.pinned,
74
- }
75
-
76
-
77
- @dataclass
78
- class VersionManifest:
79
- """Manifest tracking all component versions."""
80
-
81
- manifest_version: int = MANIFEST_VERSION
82
- created_at: datetime = field(default_factory=lambda: datetime.now(UTC))
83
- updated_at: datetime = field(default_factory=lambda: datetime.now(UTC))
84
- components: list[ComponentVersion] = field(default_factory=list)
85
- environment: str = "production"
86
- notes: str = ""
87
-
88
- def to_dict(self) -> dict[str, Any]:
89
- """Convert to dictionary."""
90
- return {
91
- "manifest_version": self.manifest_version,
92
- "created_at": self.created_at.isoformat(),
93
- "updated_at": self.updated_at.isoformat(),
94
- "components": [c.to_dict() for c in self.components],
95
- "environment": self.environment,
96
- "notes": self.notes,
97
- }
98
-
99
- def to_json(self) -> str:
100
- """Convert to JSON string."""
101
- return json.dumps(self.to_dict(), indent=2)
102
-
103
- @classmethod
104
- def from_dict(cls, data: dict[str, Any]) -> VersionManifest:
105
- """Create from dictionary."""
106
- components = [
107
- ComponentVersion(
108
- component=ComponentType(c["component"]),
109
- current_version=c["current_version"],
110
- target_version=c.get("target_version"),
111
- image_digest=c.get("image_digest"),
112
- pinned=c.get("pinned", True),
113
- )
114
- for c in data.get("components", [])
115
- ]
116
- return cls(
117
- manifest_version=data.get("manifest_version", MANIFEST_VERSION),
118
- created_at=datetime.fromisoformat(data["created_at"])
119
- if data.get("created_at")
120
- else datetime.now(UTC),
121
- updated_at=datetime.fromisoformat(data["updated_at"])
122
- if data.get("updated_at")
123
- else datetime.now(UTC),
124
- components=components,
125
- environment=data.get("environment", "production"),
126
- notes=data.get("notes", ""),
127
- )
128
-
129
- def get_component(self, component_type: ComponentType) -> ComponentVersion | None:
130
- """Get version info for a component."""
131
- for comp in self.components:
132
- if comp.component == component_type:
133
- return comp
134
- return None
135
-
136
- def set_component(self, component: ComponentVersion) -> None:
137
- """Set or update a component version."""
138
- for i, comp in enumerate(self.components):
139
- if comp.component == component.component:
140
- self.components[i] = component
141
- self.updated_at = datetime.now(UTC)
142
- return
143
- self.components.append(component)
144
- self.updated_at = datetime.now(UTC)
145
-
146
-
147
- @dataclass
148
- class HealthCheck:
149
- """Result of a health check."""
150
-
151
- name: str
152
- passed: bool
153
- message: str = ""
154
- details: dict[str, Any] = field(default_factory=dict)
155
-
156
- def to_dict(self) -> dict[str, Any]:
157
- """Convert to dictionary."""
158
- return {
159
- "name": self.name,
160
- "passed": self.passed,
161
- "message": self.message,
162
- "details": self.details,
163
- }
164
-
165
-
166
- @dataclass
167
- class UpgradeResult:
168
- """Result of an upgrade operation."""
169
-
170
- success: bool
171
- status: UpgradeStatus
172
- component: ComponentType | None = None
173
- from_version: str = ""
174
- to_version: str = ""
175
- timestamp: datetime = field(default_factory=lambda: datetime.now(UTC))
176
- duration_seconds: float = 0.0
177
- error: str | None = None
178
- health_checks: list[HealthCheck] = field(default_factory=list)
179
- backup_id: str | None = None
180
- rollback_available: bool = False
181
-
182
- def to_dict(self) -> dict[str, Any]:
183
- """Convert to dictionary."""
184
- return {
185
- "success": self.success,
186
- "status": self.status.value,
187
- "component": self.component.value if self.component else None,
188
- "from_version": self.from_version,
189
- "to_version": self.to_version,
190
- "timestamp": self.timestamp.isoformat(),
191
- "duration_seconds": self.duration_seconds,
192
- "error": self.error,
193
- "health_checks": [h.to_dict() for h in self.health_checks],
194
- "backup_id": self.backup_id,
195
- "rollback_available": self.rollback_available,
196
- }
197
-
198
-
199
- class UpgradeManager:
200
- """Manages upgrade operations for Kekkai Portal."""
201
-
202
- def __init__(
203
- self,
204
- manifest_path: Path | None = None,
205
- compose_file: Path | None = None,
206
- ) -> None:
207
- self._manifest_path = manifest_path or Path("/var/lib/kekkai-portal/version-manifest.json")
208
- self._compose_file = compose_file
209
- self._manifest: VersionManifest | None = None
210
- self._load_manifest()
211
-
212
- def _load_manifest(self) -> None:
213
- """Load version manifest from file."""
214
- if self._manifest_path.exists():
215
- try:
216
- data = json.loads(self._manifest_path.read_text())
217
- self._manifest = VersionManifest.from_dict(data)
218
- except (json.JSONDecodeError, KeyError) as e:
219
- logger.warning("Failed to load manifest: %s", e)
220
- self._manifest = self._create_default_manifest()
221
- else:
222
- self._manifest = self._create_default_manifest()
223
-
224
- def _create_default_manifest(self) -> VersionManifest:
225
- """Create default version manifest."""
226
- return VersionManifest(
227
- components=[
228
- ComponentVersion(
229
- component=ComponentType.PORTAL,
230
- current_version="0.0.0",
231
- pinned=True,
232
- ),
233
- ComponentVersion(
234
- component=ComponentType.DEFECTDOJO,
235
- current_version="2.37.0",
236
- image_digest=None,
237
- pinned=True,
238
- ),
239
- ComponentVersion(
240
- component=ComponentType.POSTGRES,
241
- current_version="16-alpine",
242
- pinned=True,
243
- ),
244
- ComponentVersion(
245
- component=ComponentType.NGINX,
246
- current_version="1.25-alpine",
247
- pinned=True,
248
- ),
249
- ComponentVersion(
250
- component=ComponentType.VALKEY,
251
- current_version="7.2-alpine",
252
- pinned=True,
253
- ),
254
- ]
255
- )
256
-
257
- def save_manifest(self) -> None:
258
- """Save version manifest to file."""
259
- if self._manifest:
260
- self._manifest_path.parent.mkdir(parents=True, exist_ok=True)
261
- self._manifest_path.write_text(self._manifest.to_json())
262
-
263
- def get_manifest(self) -> VersionManifest:
264
- """Get current version manifest."""
265
- if not self._manifest:
266
- self._manifest = self._create_default_manifest()
267
- return self._manifest
268
-
269
- def run_pre_upgrade_checks(self) -> list[HealthCheck]:
270
- """Run pre-upgrade health checks."""
271
- checks: list[HealthCheck] = []
272
-
273
- checks.append(self._check_disk_space())
274
- checks.append(self._check_database_connection())
275
- checks.append(self._check_services_running())
276
- checks.append(self._check_backup_recent())
277
-
278
- return checks
279
-
280
- def upgrade_component(
281
- self,
282
- component: ComponentType,
283
- target_version: str,
284
- create_backup: bool = True,
285
- dry_run: bool = False,
286
- ) -> UpgradeResult:
287
- """Upgrade a specific component."""
288
- start_time = datetime.now(UTC)
289
- manifest = self.get_manifest()
290
- comp_version = manifest.get_component(component)
291
-
292
- current_version = comp_version.current_version if comp_version else "unknown"
293
-
294
- health_checks = self.run_pre_upgrade_checks()
295
- failed_checks = [c for c in health_checks if not c.passed]
296
-
297
- if failed_checks:
298
- return UpgradeResult(
299
- success=False,
300
- status=UpgradeStatus.FAILED,
301
- component=component,
302
- from_version=current_version,
303
- to_version=target_version,
304
- error=f"Pre-upgrade checks failed: {', '.join(c.name for c in failed_checks)}",
305
- health_checks=health_checks,
306
- )
307
-
308
- if dry_run:
309
- logger.info(
310
- "upgrade.dry_run component=%s from=%s to=%s",
311
- component.value,
312
- current_version,
313
- target_version,
314
- )
315
- return UpgradeResult(
316
- success=True,
317
- status=UpgradeStatus.COMPLETED,
318
- component=component,
319
- from_version=current_version,
320
- to_version=target_version,
321
- health_checks=health_checks,
322
- )
323
-
324
- backup_id = None
325
- if create_backup:
326
- backup_id = self._create_pre_upgrade_backup()
327
- if not backup_id:
328
- return UpgradeResult(
329
- success=False,
330
- status=UpgradeStatus.FAILED,
331
- component=component,
332
- from_version=current_version,
333
- to_version=target_version,
334
- error="Failed to create pre-upgrade backup",
335
- health_checks=health_checks,
336
- )
337
-
338
- try:
339
- if component == ComponentType.DEFECTDOJO:
340
- self._upgrade_defectdojo(target_version)
341
- elif component == ComponentType.PORTAL:
342
- self._upgrade_portal(target_version)
343
- else:
344
- self._upgrade_docker_service(component, target_version)
345
-
346
- if comp_version:
347
- comp_version.current_version = target_version
348
- comp_version.target_version = None
349
- else:
350
- manifest.set_component(
351
- ComponentVersion(
352
- component=component,
353
- current_version=target_version,
354
- )
355
- )
356
- self.save_manifest()
357
-
358
- duration = (datetime.now(UTC) - start_time).total_seconds()
359
-
360
- logger.info(
361
- "upgrade.completed component=%s from=%s to=%s duration=%.2f",
362
- component.value,
363
- current_version,
364
- target_version,
365
- duration,
366
- )
367
-
368
- return UpgradeResult(
369
- success=True,
370
- status=UpgradeStatus.COMPLETED,
371
- component=component,
372
- from_version=current_version,
373
- to_version=target_version,
374
- duration_seconds=duration,
375
- health_checks=health_checks,
376
- backup_id=backup_id,
377
- rollback_available=bool(backup_id),
378
- )
379
-
380
- except Exception as e:
381
- logger.error(
382
- "upgrade.failed component=%s error=%s",
383
- component.value,
384
- str(e),
385
- )
386
- return UpgradeResult(
387
- success=False,
388
- status=UpgradeStatus.FAILED,
389
- component=component,
390
- from_version=current_version,
391
- to_version=target_version,
392
- error=f"Upgrade failed: {type(e).__name__}",
393
- health_checks=health_checks,
394
- backup_id=backup_id,
395
- rollback_available=bool(backup_id),
396
- )
397
-
398
- def rollback(self, backup_id: str) -> UpgradeResult:
399
- """Rollback to a previous state using backup."""
400
- start_time = datetime.now(UTC)
401
-
402
- try:
403
- logger.info("upgrade.rollback.started backup_id=%s", backup_id)
404
-
405
- duration = (datetime.now(UTC) - start_time).total_seconds()
406
-
407
- return UpgradeResult(
408
- success=True,
409
- status=UpgradeStatus.ROLLED_BACK,
410
- duration_seconds=duration,
411
- backup_id=backup_id,
412
- )
413
-
414
- except Exception as e:
415
- logger.error("upgrade.rollback.failed backup_id=%s error=%s", backup_id, str(e))
416
- return UpgradeResult(
417
- success=False,
418
- status=UpgradeStatus.FAILED,
419
- error=f"Rollback failed: {type(e).__name__}",
420
- backup_id=backup_id,
421
- )
422
-
423
- def _check_disk_space(self) -> HealthCheck:
424
- """Check available disk space."""
425
- try:
426
- result = subprocess.run( # noqa: S603
427
- ["/bin/df", "-h", "/var/lib"],
428
- capture_output=True,
429
- text=True,
430
- timeout=10,
431
- check=False,
432
- )
433
- lines = result.stdout.strip().split("\n")
434
- if len(lines) >= 2:
435
- parts = lines[1].split()
436
- if len(parts) >= 5:
437
- use_percent = int(parts[4].rstrip("%"))
438
- if use_percent > 90:
439
- return HealthCheck(
440
- name="disk_space",
441
- passed=False,
442
- message=f"Disk usage at {use_percent}%",
443
- details={"usage_percent": use_percent},
444
- )
445
- return HealthCheck(
446
- name="disk_space",
447
- passed=True,
448
- message=f"Disk usage at {use_percent}%",
449
- details={"usage_percent": use_percent},
450
- )
451
- except Exception as e:
452
- return HealthCheck(
453
- name="disk_space",
454
- passed=False,
455
- message=f"Failed to check disk space: {e}",
456
- )
457
-
458
- return HealthCheck(
459
- name="disk_space",
460
- passed=True,
461
- message="Disk space check skipped",
462
- )
463
-
464
- def _check_database_connection(self) -> HealthCheck:
465
- """Check database connectivity."""
466
- db_host = os.environ.get("DD_DATABASE_HOST", "localhost")
467
- db_port = os.environ.get("DD_DATABASE_PORT", "5432")
468
-
469
- try:
470
- result = subprocess.run( # noqa: S603
471
- ["/usr/bin/pg_isready", "-h", db_host, "-p", db_port],
472
- capture_output=True,
473
- text=True,
474
- timeout=10,
475
- check=False,
476
- )
477
- if result.returncode == 0:
478
- return HealthCheck(
479
- name="database_connection",
480
- passed=True,
481
- message="Database is accepting connections",
482
- )
483
- return HealthCheck(
484
- name="database_connection",
485
- passed=False,
486
- message="Database is not accepting connections",
487
- )
488
- except FileNotFoundError:
489
- return HealthCheck(
490
- name="database_connection",
491
- passed=True,
492
- message="pg_isready not available, skipping check",
493
- )
494
- except Exception as e:
495
- return HealthCheck(
496
- name="database_connection",
497
- passed=False,
498
- message=f"Database check failed: {e}",
499
- )
500
-
501
- def _check_services_running(self) -> HealthCheck:
502
- """Check if required services are running."""
503
- return HealthCheck(
504
- name="services_running",
505
- passed=True,
506
- message="Service check passed",
507
- )
508
-
509
- def _check_backup_recent(self) -> HealthCheck:
510
- """Check if a recent backup exists."""
511
- backup_dir = Path(os.environ.get("BACKUP_LOCAL_PATH", "/var/lib/kekkai-portal/backups"))
512
-
513
- if not backup_dir.exists():
514
- return HealthCheck(
515
- name="backup_recent",
516
- passed=False,
517
- message="No backup directory found",
518
- )
519
-
520
- backups = list(backup_dir.glob("*.tar.gz")) + list(backup_dir.glob("*.tar"))
521
- if not backups:
522
- return HealthCheck(
523
- name="backup_recent",
524
- passed=False,
525
- message="No backups found",
526
- )
527
-
528
- latest = max(backups, key=lambda p: p.stat().st_mtime)
529
- age_hours = (datetime.now(UTC).timestamp() - latest.stat().st_mtime) / 3600
530
-
531
- if age_hours > 24:
532
- return HealthCheck(
533
- name="backup_recent",
534
- passed=False,
535
- message=f"Latest backup is {age_hours:.1f} hours old",
536
- details={"backup_age_hours": age_hours, "backup_path": str(latest)},
537
- )
538
-
539
- return HealthCheck(
540
- name="backup_recent",
541
- passed=True,
542
- message=f"Latest backup is {age_hours:.1f} hours old",
543
- details={"backup_age_hours": age_hours, "backup_path": str(latest)},
544
- )
545
-
546
- def _create_pre_upgrade_backup(self) -> str | None:
547
- """Create a backup before upgrade."""
548
- from .backup import create_backup_job
549
-
550
- try:
551
- backup_job = create_backup_job()
552
- result = backup_job.backup_full()
553
- if result.success:
554
- return result.backup_id
555
- logger.error("Pre-upgrade backup failed: %s", result.error)
556
- return None
557
- except Exception as e:
558
- logger.error("Pre-upgrade backup failed: %s", e)
559
- return None
560
-
561
- def _upgrade_defectdojo(self, target_version: str) -> None:
562
- """Upgrade DefectDojo."""
563
- logger.info("upgrade.defectdojo version=%s", target_version)
564
-
565
- def _upgrade_portal(self, target_version: str) -> None:
566
- """Upgrade portal."""
567
- logger.info("upgrade.portal version=%s", target_version)
568
-
569
- def _upgrade_docker_service(self, component: ComponentType, target_version: str) -> None:
570
- """Upgrade a Docker service."""
571
- logger.info("upgrade.docker component=%s version=%s", component.value, target_version)
572
-
573
-
574
- def create_upgrade_manager(
575
- manifest_path: Path | str | None = None,
576
- compose_file: Path | str | None = None,
577
- ) -> UpgradeManager:
578
- """Create a configured UpgradeManager instance."""
579
- m_path = None
580
- if manifest_path:
581
- m_path = Path(manifest_path)
582
- elif env_path := os.environ.get("VERSION_MANIFEST_PATH"):
583
- m_path = Path(env_path)
584
-
585
- c_path = None
586
- if compose_file:
587
- c_path = Path(compose_file)
588
- elif env_compose := os.environ.get("COMPOSE_FILE"):
589
- c_path = Path(env_compose)
590
-
591
- return UpgradeManager(manifest_path=m_path, compose_file=c_path)