redock-vps 0.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.
- redock/__init__.py +0 -0
- redock/application/__init__.py +0 -0
- redock/application/add_catalog_item.py +26 -0
- redock/application/catalog_commands.py +67 -0
- redock/application/check_catalog_item.py +20 -0
- redock/application/clean_dns.py +10 -0
- redock/application/commands.py +60 -0
- redock/application/delete_catalog_item.py +13 -0
- redock/application/deploy_app.py +91 -0
- redock/application/enrich_catalog_item.py +28 -0
- redock/application/ensure_dns.py +17 -0
- redock/application/ensure_vps.py +26 -0
- redock/application/exceptions.py +78 -0
- redock/application/get_catalog_item.py +15 -0
- redock/application/list_apps.py +10 -0
- redock/application/list_catalog_items.py +14 -0
- redock/application/prepare_host.py +21 -0
- redock/application/scan_catalog_item.py +74 -0
- redock/application/search_docker_hub.py +11 -0
- redock/application/stop_app.py +30 -0
- redock/application/up.py +94 -0
- redock/application/update_catalog_item.py +37 -0
- redock/domain/__init__.py +0 -0
- redock/domain/catalog_item.py +56 -0
- redock/domain/catalog_suggestion.py +16 -0
- redock/domain/deployment.py +37 -0
- redock/domain/dns_record.py +11 -0
- redock/domain/docker_hub.py +11 -0
- redock/domain/docker_hub_image.py +12 -0
- redock/domain/ports.py +137 -0
- redock/domain/scan_result.py +25 -0
- redock/domain/value_objects.py +81 -0
- redock/domain/vps.py +22 -0
- redock/domain/vuln_scorer.py +52 -0
- redock/infrastructure/__init__.py +0 -0
- redock/infrastructure/caddy_router.py +10 -0
- redock/infrastructure/config.py +26 -0
- redock/infrastructure/docker_hub_search.py +25 -0
- redock/infrastructure/docker_scout_scanner.py +78 -0
- redock/infrastructure/hetzner_dns_provider.py +120 -0
- redock/infrastructure/hetzner_provisioner.py +71 -0
- redock/infrastructure/infomaniak_metadata_enricher.py +97 -0
- redock/infrastructure/json_repository.py +73 -0
- redock/infrastructure/printing_dns_provider.py +75 -0
- redock/infrastructure/public_dns_resolver.py +16 -0
- redock/infrastructure/remote_docker_runtime.py +59 -0
- redock/infrastructure/requests_image_details_fetcher.py +146 -0
- redock/infrastructure/sqlite_catalog_repository.py +129 -0
- redock/infrastructure/ssh_host_preparer.py +65 -0
- redock/infrastructure/trivy_scanner.py +45 -0
- redock/interfaces/__init__.py +0 -0
- redock/interfaces/cli.py +814 -0
- redock/interfaces/composition.py +133 -0
- redock/interfaces/rich_renderer.py +72 -0
- redock/server-assets/Caddyfile +3 -0
- redock/server-assets/docker-compose.caddy.yml +23 -0
- redock_vps-0.1.1.dist-info/METADATA +250 -0
- redock_vps-0.1.1.dist-info/RECORD +61 -0
- redock_vps-0.1.1.dist-info/WHEEL +4 -0
- redock_vps-0.1.1.dist-info/entry_points.txt +2 -0
- redock_vps-0.1.1.dist-info/licenses/LICENSE +21 -0
redock/__init__.py
ADDED
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from redock.application.catalog_commands import AddCatalogItemCommand
|
|
2
|
+
from redock.application.exceptions import CatalogItemAlreadyExists
|
|
3
|
+
from redock.domain.catalog_item import CatalogItem
|
|
4
|
+
from redock.domain.ports import CatalogRepository
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class AddCatalogItemUseCase:
|
|
8
|
+
def __init__(self, repo: CatalogRepository) -> None:
|
|
9
|
+
self._repo = repo
|
|
10
|
+
|
|
11
|
+
def execute(self, cmd: AddCatalogItemCommand) -> CatalogItem:
|
|
12
|
+
item = CatalogItem.from_image(
|
|
13
|
+
cmd.image,
|
|
14
|
+
port=cmd.port,
|
|
15
|
+
volume_mount=cmd.volume_mount,
|
|
16
|
+
entrypoint=cmd.entrypoint,
|
|
17
|
+
user=cmd.user,
|
|
18
|
+
user_id=cmd.user_id,
|
|
19
|
+
hub_link=cmd.hub_link,
|
|
20
|
+
project_link=cmd.project_link,
|
|
21
|
+
env=cmd.env,
|
|
22
|
+
)
|
|
23
|
+
if self._repo.get(item.id) is not None:
|
|
24
|
+
raise CatalogItemAlreadyExists(item.id)
|
|
25
|
+
self._repo.add(item)
|
|
26
|
+
return item
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
@dataclass(frozen=True)
|
|
5
|
+
class AddCatalogItemCommand:
|
|
6
|
+
image: str
|
|
7
|
+
port: int | None = None
|
|
8
|
+
volume_mount: str | None = None
|
|
9
|
+
entrypoint: str | None = None
|
|
10
|
+
user: str | None = None
|
|
11
|
+
user_id: int | None = None
|
|
12
|
+
hub_link: str | None = None
|
|
13
|
+
project_link: str | None = None
|
|
14
|
+
env: dict[str, str] = field(default_factory=dict)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class GetCatalogItemCommand:
|
|
19
|
+
id: str
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class ListCatalogItemsCommand:
|
|
24
|
+
checked_only: bool = False
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class UpdateCatalogItemCommand:
|
|
29
|
+
id: str
|
|
30
|
+
image: str | None = None
|
|
31
|
+
port: int | None = None
|
|
32
|
+
volume_mount: str | None = None
|
|
33
|
+
entrypoint: str | None = None
|
|
34
|
+
user: str | None = None
|
|
35
|
+
user_id: int | None = None
|
|
36
|
+
hub_link: str | None = None
|
|
37
|
+
project_link: str | None = None
|
|
38
|
+
env: dict[str, str] | None = None
|
|
39
|
+
unset_env: tuple[str, ...] = ()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class DeleteCatalogItemCommand:
|
|
44
|
+
id: str
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class CheckCatalogItemCommand:
|
|
49
|
+
id: str
|
|
50
|
+
checked: bool = True
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class SearchDockerHubCommand:
|
|
55
|
+
query: str
|
|
56
|
+
page_size: int = 25
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class EnrichCatalogItemCommand:
|
|
61
|
+
image: str
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True)
|
|
65
|
+
class ScanCatalogItemCommand:
|
|
66
|
+
id: str
|
|
67
|
+
dry_run: bool = False
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from datetime import datetime, timezone
|
|
2
|
+
|
|
3
|
+
from redock.application.catalog_commands import CheckCatalogItemCommand
|
|
4
|
+
from redock.application.exceptions import CatalogItemNotFound
|
|
5
|
+
from redock.domain.catalog_item import CatalogItem
|
|
6
|
+
from redock.domain.ports import CatalogRepository
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class CheckCatalogItemUseCase:
|
|
10
|
+
def __init__(self, repo: CatalogRepository) -> None:
|
|
11
|
+
self._repo = repo
|
|
12
|
+
|
|
13
|
+
def execute(self, cmd: CheckCatalogItemCommand) -> CatalogItem:
|
|
14
|
+
item = self._repo.get(cmd.id)
|
|
15
|
+
if item is None:
|
|
16
|
+
raise CatalogItemNotFound(cmd.id)
|
|
17
|
+
item.checked = cmd.checked
|
|
18
|
+
item.updated_at = datetime.now(tz=timezone.utc)
|
|
19
|
+
self._repo.update(item)
|
|
20
|
+
return item
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from redock.application.commands import CleanDnsCommand
|
|
2
|
+
from redock.domain.ports import DnsProvider
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class CleanDnsUseCase:
|
|
6
|
+
def __init__(self, provider: DnsProvider) -> None:
|
|
7
|
+
self._provider = provider
|
|
8
|
+
|
|
9
|
+
def execute(self, cmd: CleanDnsCommand) -> None:
|
|
10
|
+
self._provider.delete_records(cmd.subdomain)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
|
|
3
|
+
from redock.domain.value_objects import IPv4, Slug, Subdomain
|
|
4
|
+
from redock.domain.vps import Vps
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class EnsureVpsCommand:
|
|
9
|
+
region: str
|
|
10
|
+
server_type: str
|
|
11
|
+
ssh_key_name: str
|
|
12
|
+
label: str = "redock=true"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class PrepareHostCommand:
|
|
17
|
+
vps: Vps
|
|
18
|
+
base_domain: str
|
|
19
|
+
acme_email: str
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class EnsureDnsCommand:
|
|
24
|
+
subdomain: Subdomain
|
|
25
|
+
target_ip: IPv4
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class DeployAppCommand:
|
|
30
|
+
template_id: str
|
|
31
|
+
slug: Slug
|
|
32
|
+
subdomain: Subdomain
|
|
33
|
+
vps: Vps
|
|
34
|
+
env: dict[str, str] = field(default_factory=dict)
|
|
35
|
+
upgrade: bool = False
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class StopAppCommand:
|
|
40
|
+
slug: Slug
|
|
41
|
+
purge: bool = False
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class CleanDnsCommand:
|
|
46
|
+
subdomain: Subdomain
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class UpCommand:
|
|
51
|
+
template_id: str
|
|
52
|
+
slug: Slug
|
|
53
|
+
base_domain: str
|
|
54
|
+
acme_email: str
|
|
55
|
+
region: str
|
|
56
|
+
server_type: str
|
|
57
|
+
ssh_key_name: str
|
|
58
|
+
label: str = "redock=true"
|
|
59
|
+
env: dict[str, str] = field(default_factory=dict)
|
|
60
|
+
upgrade: bool = False
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from redock.application.catalog_commands import DeleteCatalogItemCommand
|
|
2
|
+
from redock.application.exceptions import CatalogItemNotFound
|
|
3
|
+
from redock.domain.ports import CatalogRepository
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class DeleteCatalogItemUseCase:
|
|
7
|
+
def __init__(self, repo: CatalogRepository) -> None:
|
|
8
|
+
self._repo = repo
|
|
9
|
+
|
|
10
|
+
def execute(self, cmd: DeleteCatalogItemCommand) -> None:
|
|
11
|
+
if self._repo.get(cmd.id) is None:
|
|
12
|
+
raise CatalogItemNotFound(cmd.id)
|
|
13
|
+
self._repo.delete(cmd.id)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from datetime import datetime, timezone
|
|
3
|
+
|
|
4
|
+
from redock.application.commands import DeployAppCommand
|
|
5
|
+
from redock.application.exceptions import DeploymentFailed, MissingPort, UnknownTemplate, UnapprovedTemplate
|
|
6
|
+
from redock.domain.deployment import Deployment, DeploymentStatus
|
|
7
|
+
from redock.domain.ports import CatalogRepository, ContainerRuntime, ContainerSpec, DeploymentRepository, ProxyRouter
|
|
8
|
+
from redock.domain.value_objects import ContainerName, VolumeName
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _resolve_env(env: dict[str, str]) -> dict[str, str]:
|
|
12
|
+
resolved: dict[str, str] = {}
|
|
13
|
+
for k, v in env.items():
|
|
14
|
+
if v.startswith("$"):
|
|
15
|
+
var = v[1:]
|
|
16
|
+
if var not in os.environ:
|
|
17
|
+
raise DeploymentFailed(
|
|
18
|
+
f"env var ${var} referenced in catalog but not set on this host"
|
|
19
|
+
)
|
|
20
|
+
resolved[k] = os.environ[var]
|
|
21
|
+
else:
|
|
22
|
+
resolved[k] = v
|
|
23
|
+
return resolved
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class DeployAppUseCase:
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
runtime: ContainerRuntime,
|
|
30
|
+
router: ProxyRouter,
|
|
31
|
+
repo: DeploymentRepository,
|
|
32
|
+
catalog: CatalogRepository,
|
|
33
|
+
) -> None:
|
|
34
|
+
self._runtime = runtime
|
|
35
|
+
self._router = router
|
|
36
|
+
self._repo = repo
|
|
37
|
+
self._catalog = catalog
|
|
38
|
+
|
|
39
|
+
def execute(self, cmd: DeployAppCommand) -> Deployment:
|
|
40
|
+
item = self._catalog.get(cmd.template_id)
|
|
41
|
+
if item is None:
|
|
42
|
+
raise UnknownTemplate(cmd.template_id)
|
|
43
|
+
if not item.checked:
|
|
44
|
+
raise UnapprovedTemplate(cmd.template_id)
|
|
45
|
+
if item.port is None:
|
|
46
|
+
raise MissingPort(cmd.template_id)
|
|
47
|
+
|
|
48
|
+
existing = self._repo.get(cmd.slug)
|
|
49
|
+
if existing is not None and existing.status == DeploymentStatus.RUNNING:
|
|
50
|
+
if self._runtime.is_running(cmd.vps, existing.container_name):
|
|
51
|
+
if not cmd.upgrade:
|
|
52
|
+
return existing
|
|
53
|
+
self._runtime.stop(cmd.vps, existing.container_name)
|
|
54
|
+
self._runtime.remove(cmd.vps, existing.container_name, remove_volume=False)
|
|
55
|
+
|
|
56
|
+
container_name = ContainerName.for_slug(cmd.slug)
|
|
57
|
+
volume_name = VolumeName.for_slug(cmd.slug) if item.volume_mount else None
|
|
58
|
+
labels = self._router.labels_for(cmd.subdomain, item.port)
|
|
59
|
+
|
|
60
|
+
spec = ContainerSpec(
|
|
61
|
+
image=item.image,
|
|
62
|
+
container_name=str(container_name),
|
|
63
|
+
volume_name=str(volume_name) if volume_name else None,
|
|
64
|
+
volume_mount=item.volume_mount,
|
|
65
|
+
labels=labels,
|
|
66
|
+
internal_port=item.port,
|
|
67
|
+
env=_resolve_env({**item.env, **cmd.env}),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
deployment = Deployment(
|
|
71
|
+
slug=cmd.slug,
|
|
72
|
+
template_id=cmd.template_id,
|
|
73
|
+
subdomain=cmd.subdomain,
|
|
74
|
+
container_name=container_name,
|
|
75
|
+
volume_name=volume_name,
|
|
76
|
+
vps_id=cmd.vps.id,
|
|
77
|
+
status=DeploymentStatus.PENDING,
|
|
78
|
+
created_at=datetime.now(tz=timezone.utc),
|
|
79
|
+
)
|
|
80
|
+
self._repo.save(deployment)
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
self._runtime.run(cmd.vps, spec)
|
|
84
|
+
except Exception as exc:
|
|
85
|
+
deployment.mark_failed(str(exc))
|
|
86
|
+
self._repo.save(deployment)
|
|
87
|
+
raise DeploymentFailed(str(exc)) from exc
|
|
88
|
+
|
|
89
|
+
deployment.mark_running()
|
|
90
|
+
self._repo.save(deployment)
|
|
91
|
+
return deployment
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from redock.application.catalog_commands import EnrichCatalogItemCommand
|
|
2
|
+
from redock.domain.catalog_suggestion import CatalogSuggestion
|
|
3
|
+
from redock.domain.ports import ImageDetailsFetcher, MetadataEnricher
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class EnrichCatalogItemUseCase:
|
|
7
|
+
def __init__(self, fetcher: ImageDetailsFetcher, enricher: MetadataEnricher) -> None:
|
|
8
|
+
self._fetcher = fetcher
|
|
9
|
+
self._enricher = enricher
|
|
10
|
+
|
|
11
|
+
def execute(self, cmd: EnrichCatalogItemCommand) -> CatalogSuggestion:
|
|
12
|
+
details = self._fetcher.fetch(cmd.image)
|
|
13
|
+
readme = details.full_description if details else ""
|
|
14
|
+
ai = self._enricher.enrich(cmd.image, readme)
|
|
15
|
+
registry_user = details.user if details else None
|
|
16
|
+
registry_user_id = details.user_id if details else None
|
|
17
|
+
# If the registry gave a named user but no numeric uid, the AI cannot
|
|
18
|
+
# know the uid either — suppress its guess rather than store a wrong value.
|
|
19
|
+
ai_user_id = ai.user_id if registry_user is None else None
|
|
20
|
+
return CatalogSuggestion(
|
|
21
|
+
port=ai.port,
|
|
22
|
+
volume_mount=ai.volume_mount,
|
|
23
|
+
entrypoint=ai.entrypoint,
|
|
24
|
+
user=registry_user if registry_user is not None else ai.user,
|
|
25
|
+
user_id=registry_user_id if registry_user_id is not None else ai_user_id,
|
|
26
|
+
hub_link=details.hub_link if details else None,
|
|
27
|
+
project_link=ai.project_link,
|
|
28
|
+
)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from redock.application.commands import EnsureDnsCommand
|
|
2
|
+
from redock.application.exceptions import ApplicationError
|
|
3
|
+
from redock.domain.ports import DnsProvider
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class EnsureDnsUseCase:
|
|
7
|
+
def __init__(self, dns_provider: DnsProvider) -> None:
|
|
8
|
+
self._dns_provider = dns_provider
|
|
9
|
+
|
|
10
|
+
def execute(self, cmd: EnsureDnsCommand) -> None:
|
|
11
|
+
records = self._dns_provider.required_records(cmd.subdomain, cmd.target_ip)
|
|
12
|
+
try:
|
|
13
|
+
self._dns_provider.ensure(records)
|
|
14
|
+
except ApplicationError:
|
|
15
|
+
raise
|
|
16
|
+
except Exception as exc:
|
|
17
|
+
raise ApplicationError(f"DNS setup failed: {exc}") from exc
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from redock.application.commands import EnsureVpsCommand
|
|
2
|
+
from redock.application.exceptions import VpsCreationFailed
|
|
3
|
+
from redock.domain.ports import VpsProvisioner
|
|
4
|
+
from redock.domain.vps import Vps
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class EnsureVpsUseCase:
|
|
8
|
+
def __init__(self, provisioner: VpsProvisioner) -> None:
|
|
9
|
+
self._provisioner = provisioner
|
|
10
|
+
|
|
11
|
+
def execute(self, cmd: EnsureVpsCommand) -> Vps:
|
|
12
|
+
existing = self._provisioner.find_existing(cmd.label)
|
|
13
|
+
if existing is not None:
|
|
14
|
+
return existing
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
vps = self._provisioner.create(
|
|
18
|
+
region=cmd.region,
|
|
19
|
+
server_type=cmd.server_type,
|
|
20
|
+
ssh_key_name=cmd.ssh_key_name,
|
|
21
|
+
label=cmd.label,
|
|
22
|
+
)
|
|
23
|
+
self._provisioner.wait_until_ssh_ready(vps)
|
|
24
|
+
return vps
|
|
25
|
+
except Exception as exc:
|
|
26
|
+
raise VpsCreationFailed(str(exc)) from exc
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
class ApplicationError(Exception):
|
|
2
|
+
"""Base for all application-layer errors."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class VpsCreationFailed(ApplicationError):
|
|
6
|
+
pass
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class HostPreparationFailed(ApplicationError):
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class DnsTimeout(ApplicationError):
|
|
14
|
+
def __init__(self, hostname: str) -> None:
|
|
15
|
+
super().__init__(f"DNS did not resolve for {hostname!r} within the timeout")
|
|
16
|
+
self.hostname = hostname
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class DeploymentFailed(ApplicationError):
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class UnknownTemplate(ApplicationError):
|
|
24
|
+
def __init__(self, template_id: str) -> None:
|
|
25
|
+
super().__init__(f"Unknown template: {template_id!r}. Check `redock catalog`.")
|
|
26
|
+
self.template_id = template_id
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class AlreadyDeployed(ApplicationError):
|
|
30
|
+
def __init__(self, slug: str) -> None:
|
|
31
|
+
super().__init__(f"Deployment {slug!r} is already running.")
|
|
32
|
+
self.slug = slug
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class HttpsTimeout(ApplicationError):
|
|
36
|
+
def __init__(self, url: str) -> None:
|
|
37
|
+
super().__init__(f"HTTPS endpoint {url!r} did not become available within the timeout")
|
|
38
|
+
self.url = url
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class CatalogItemNotFound(ApplicationError):
|
|
42
|
+
def __init__(self, item_id: str) -> None:
|
|
43
|
+
super().__init__(f"Catalog item {item_id!r} not found.")
|
|
44
|
+
self.item_id = item_id
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class CatalogItemAlreadyExists(ApplicationError):
|
|
48
|
+
def __init__(self, item_id: str) -> None:
|
|
49
|
+
super().__init__(
|
|
50
|
+
f"Catalog item {item_id!r} already exists. Use `redock catalog update` to modify it."
|
|
51
|
+
)
|
|
52
|
+
self.item_id = item_id
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class UnapprovedTemplate(ApplicationError):
|
|
56
|
+
def __init__(self, item_id: str) -> None:
|
|
57
|
+
super().__init__(f"{item_id!r} is not checked. Run: redock catalog check {item_id}")
|
|
58
|
+
self.item_id = item_id
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class MissingPort(ApplicationError):
|
|
62
|
+
def __init__(self, item_id: str) -> None:
|
|
63
|
+
super().__init__(
|
|
64
|
+
f"{item_id!r} has no port set. Run: redock catalog update {item_id} --port 80"
|
|
65
|
+
)
|
|
66
|
+
self.item_id = item_id
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class EnrichmentError(ApplicationError):
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class ScannerNotAvailableError(ApplicationError):
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class ScanError(ApplicationError):
|
|
78
|
+
pass
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from redock.application.catalog_commands import GetCatalogItemCommand
|
|
2
|
+
from redock.application.exceptions import CatalogItemNotFound
|
|
3
|
+
from redock.domain.catalog_item import CatalogItem
|
|
4
|
+
from redock.domain.ports import CatalogRepository
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class GetCatalogItemUseCase:
|
|
8
|
+
def __init__(self, repo: CatalogRepository) -> None:
|
|
9
|
+
self._repo = repo
|
|
10
|
+
|
|
11
|
+
def execute(self, cmd: GetCatalogItemCommand) -> CatalogItem:
|
|
12
|
+
item = self._repo.get(cmd.id)
|
|
13
|
+
if item is None:
|
|
14
|
+
raise CatalogItemNotFound(cmd.id)
|
|
15
|
+
return item
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from redock.domain.deployment import Deployment
|
|
2
|
+
from redock.domain.ports import DeploymentRepository
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ListAppsUseCase:
|
|
6
|
+
def __init__(self, repo: DeploymentRepository) -> None:
|
|
7
|
+
self._repo = repo
|
|
8
|
+
|
|
9
|
+
def execute(self) -> list[Deployment]:
|
|
10
|
+
return self._repo.list_all()
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from redock.application.catalog_commands import ListCatalogItemsCommand
|
|
2
|
+
from redock.domain.catalog_item import CatalogItem
|
|
3
|
+
from redock.domain.ports import CatalogRepository
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ListCatalogItemsUseCase:
|
|
7
|
+
def __init__(self, repo: CatalogRepository) -> None:
|
|
8
|
+
self._repo = repo
|
|
9
|
+
|
|
10
|
+
def execute(self, cmd: ListCatalogItemsCommand) -> list[CatalogItem]:
|
|
11
|
+
items = self._repo.list_all()
|
|
12
|
+
if cmd.checked_only:
|
|
13
|
+
items = [i for i in items if i.checked]
|
|
14
|
+
return items
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from redock.application.commands import PrepareHostCommand
|
|
2
|
+
from redock.application.exceptions import HostPreparationFailed
|
|
3
|
+
from redock.domain.ports import HostPreparer
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class PrepareHostUseCase:
|
|
7
|
+
def __init__(self, preparer: HostPreparer) -> None:
|
|
8
|
+
self._preparer = preparer
|
|
9
|
+
|
|
10
|
+
def execute(self, cmd: PrepareHostCommand) -> None:
|
|
11
|
+
try:
|
|
12
|
+
if self._preparer.is_prepared(cmd.vps):
|
|
13
|
+
return
|
|
14
|
+
self._preparer.prepare(cmd.vps, cmd.base_domain, cmd.acme_email)
|
|
15
|
+
except FileNotFoundError as exc:
|
|
16
|
+
raise HostPreparationFailed(
|
|
17
|
+
f"SSH key file not found: {exc.filename!r} — "
|
|
18
|
+
"set REDOCK_SSH_KEY_PATH to your private key path"
|
|
19
|
+
) from exc
|
|
20
|
+
except Exception as exc:
|
|
21
|
+
raise HostPreparationFailed(str(exc)) from exc
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from datetime import datetime, timezone
|
|
3
|
+
|
|
4
|
+
from redock.application.catalog_commands import ScanCatalogItemCommand
|
|
5
|
+
from redock.application.exceptions import CatalogItemNotFound, ScannerNotAvailableError
|
|
6
|
+
from redock.domain.ports import CatalogRepository, ImageScanner
|
|
7
|
+
from redock.domain.scan_result import ScanSummary, Vulnerability
|
|
8
|
+
from redock.domain.vuln_scorer import VulnScorer
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ScanCatalogItemUseCase:
|
|
12
|
+
def __init__(
|
|
13
|
+
self,
|
|
14
|
+
repo: CatalogRepository,
|
|
15
|
+
scanners: list[ImageScanner],
|
|
16
|
+
scorer: VulnScorer,
|
|
17
|
+
) -> None:
|
|
18
|
+
self._repo = repo
|
|
19
|
+
self._scanners = scanners
|
|
20
|
+
self._scorer = scorer
|
|
21
|
+
|
|
22
|
+
def execute(self, cmd: ScanCatalogItemCommand) -> ScanSummary:
|
|
23
|
+
item = self._repo.get(cmd.id)
|
|
24
|
+
if item is None:
|
|
25
|
+
raise CatalogItemNotFound(cmd.id)
|
|
26
|
+
|
|
27
|
+
reports = []
|
|
28
|
+
errors: list[Exception] = []
|
|
29
|
+
for scanner in self._scanners:
|
|
30
|
+
try:
|
|
31
|
+
reports.append(scanner.scan(item.image))
|
|
32
|
+
except ScannerNotAvailableError as e:
|
|
33
|
+
errors.append(e)
|
|
34
|
+
|
|
35
|
+
if not reports:
|
|
36
|
+
raise ScannerNotAvailableError(
|
|
37
|
+
f"No scanners available. Errors: {'; '.join(str(e) for e in errors)}"
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
summary = self._scorer.score(reports)
|
|
41
|
+
|
|
42
|
+
if not cmd.dry_run:
|
|
43
|
+
item.score = summary.score
|
|
44
|
+
item.scan_result = _to_dict(summary)
|
|
45
|
+
item.updated_at = datetime.now(tz=timezone.utc)
|
|
46
|
+
self._repo.update(item)
|
|
47
|
+
|
|
48
|
+
return summary
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _to_dict(summary: ScanSummary) -> dict:
|
|
52
|
+
def _counts(vulns: list[Vulnerability]) -> dict:
|
|
53
|
+
c: dict[str, int] = {"critical": 0, "high": 0, "medium": 0, "low": 0}
|
|
54
|
+
for v in vulns:
|
|
55
|
+
key = v.severity.lower() if v.severity.lower() in c else "low"
|
|
56
|
+
c[key] += 1
|
|
57
|
+
return c
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
"scanned_at": datetime.now(tz=timezone.utc).isoformat(),
|
|
61
|
+
"scanners": {
|
|
62
|
+
r.scanner: {
|
|
63
|
+
"vuln_count": len(r.vulnerabilities),
|
|
64
|
+
**_counts(r.vulnerabilities),
|
|
65
|
+
"score": summary.per_scanner_scores[r.scanner],
|
|
66
|
+
}
|
|
67
|
+
for r in summary.reports
|
|
68
|
+
},
|
|
69
|
+
"merged": {
|
|
70
|
+
"vuln_count": len(summary.merged),
|
|
71
|
+
**_counts(summary.merged),
|
|
72
|
+
},
|
|
73
|
+
"score": summary.score,
|
|
74
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from redock.application.catalog_commands import SearchDockerHubCommand
|
|
2
|
+
from redock.domain.docker_hub import DockerHubResult
|
|
3
|
+
from redock.domain.ports import DockerHubSearch
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class SearchDockerHubUseCase:
|
|
7
|
+
def __init__(self, hub: DockerHubSearch) -> None:
|
|
8
|
+
self._hub = hub
|
|
9
|
+
|
|
10
|
+
def execute(self, cmd: SearchDockerHubCommand) -> list[DockerHubResult]:
|
|
11
|
+
return self._hub.search(cmd.query, cmd.page_size)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from redock.application.commands import StopAppCommand
|
|
2
|
+
from redock.application.exceptions import ApplicationError
|
|
3
|
+
from redock.domain.ports import ContainerRuntime, DeploymentRepository
|
|
4
|
+
from redock.domain.vps import Vps
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class StopAppUseCase:
|
|
8
|
+
def __init__(self, runtime: ContainerRuntime, repo: DeploymentRepository) -> None:
|
|
9
|
+
self._runtime = runtime
|
|
10
|
+
self._repo = repo
|
|
11
|
+
|
|
12
|
+
def execute(self, cmd: StopAppCommand, vps: Vps) -> None:
|
|
13
|
+
deployment = self._repo.get(cmd.slug)
|
|
14
|
+
if deployment is None:
|
|
15
|
+
raise ApplicationError(f"No deployment found for slug {cmd.slug!r}")
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
if self._runtime.is_running(vps, deployment.container_name):
|
|
19
|
+
self._runtime.stop(vps, deployment.container_name)
|
|
20
|
+
|
|
21
|
+
if cmd.purge:
|
|
22
|
+
self._runtime.remove(vps, deployment.container_name, remove_volume=True)
|
|
23
|
+
self._repo.delete(cmd.slug)
|
|
24
|
+
else:
|
|
25
|
+
deployment.mark_stopped()
|
|
26
|
+
self._repo.save(deployment)
|
|
27
|
+
except ApplicationError:
|
|
28
|
+
raise
|
|
29
|
+
except Exception as exc:
|
|
30
|
+
raise ApplicationError(f"Failed to stop deployment {cmd.slug!r}: {exc}") from exc
|