dploydb 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.
dploydb/traffic.py ADDED
@@ -0,0 +1,165 @@
1
+ """Bounded command-based maintenance and traffic hook execution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import threading
7
+ from collections.abc import Mapping
8
+ from dataclasses import dataclass
9
+ from enum import StrEnum
10
+ from pathlib import Path
11
+ from typing import Final, Protocol
12
+
13
+ from dploydb.config import TrafficConfig
14
+ from dploydb.redaction import JsonValue, SecretRegistry
15
+ from dploydb.runners.base import CommandExecutor
16
+ from dploydb.subprocesses import CommandOutcome, CommandResult, SubprocessRunner
17
+
18
+ TRAFFIC_MAX_OUTPUT_BYTES: Final = 256 * 1024
19
+
20
+
21
+ class TrafficAction(StrEnum):
22
+ """The four developer-supplied cutover hook actions."""
23
+
24
+ ENABLE_MAINTENANCE = "enable_maintenance"
25
+ DISABLE_MAINTENANCE = "disable_maintenance"
26
+ ACTIVATE_NEW = "activate_new"
27
+ ACTIVATE_OLD = "activate_old"
28
+
29
+
30
+ @dataclass(frozen=True, slots=True)
31
+ class TrafficHookResult:
32
+ """Complete bounded evidence for one attempted traffic action."""
33
+
34
+ action: TrafficAction
35
+ command: CommandResult
36
+
37
+ @property
38
+ def passed(self) -> bool:
39
+ """Require exit zero and complete stdout/stderr evidence."""
40
+ return (
41
+ self.command.outcome is CommandOutcome.SUCCEEDED
42
+ and not self.command.stdout.truncated
43
+ and not self.command.stderr.truncated
44
+ )
45
+
46
+ def as_evidence(self) -> dict[str, JsonValue]:
47
+ return {
48
+ "action": self.action.value,
49
+ "passed": self.passed,
50
+ "command": self.command.as_evidence(),
51
+ }
52
+
53
+
54
+ class TrafficController(Protocol):
55
+ """Narrow hook boundary consumed by the deployment coordinator."""
56
+
57
+ def enable_maintenance(
58
+ self,
59
+ *,
60
+ cancellation_event: threading.Event | None = None,
61
+ ) -> TrafficHookResult: ...
62
+
63
+ def disable_maintenance(
64
+ self,
65
+ *,
66
+ cancellation_event: threading.Event | None = None,
67
+ ) -> TrafficHookResult: ...
68
+
69
+ def activate_new(
70
+ self,
71
+ *,
72
+ cancellation_event: threading.Event | None = None,
73
+ ) -> TrafficHookResult: ...
74
+
75
+ def activate_old(
76
+ self,
77
+ *,
78
+ cancellation_event: threading.Event | None = None,
79
+ ) -> TrafficHookResult: ...
80
+
81
+
82
+ class CommandTrafficController:
83
+ """Execute configured traffic hooks without a shell and preserve every outcome."""
84
+
85
+ def __init__(
86
+ self,
87
+ *,
88
+ traffic: TrafficConfig,
89
+ secrets: SecretRegistry,
90
+ working_directory: Path,
91
+ command_environment: Mapping[str, str] | None = None,
92
+ command_runner: CommandExecutor | None = None,
93
+ ) -> None:
94
+ if not working_directory.is_absolute():
95
+ raise ValueError("traffic hook working_directory must be absolute")
96
+ self.traffic = traffic
97
+ self.secrets = secrets
98
+ self.working_directory = working_directory
99
+ self.command_environment = dict(
100
+ os.environ if command_environment is None else command_environment
101
+ )
102
+ self.command_runner = command_runner or SubprocessRunner(
103
+ secrets=secrets,
104
+ max_output_bytes=TRAFFIC_MAX_OUTPUT_BYTES,
105
+ )
106
+
107
+ def enable_maintenance(
108
+ self,
109
+ *,
110
+ cancellation_event: threading.Event | None = None,
111
+ ) -> TrafficHookResult:
112
+ return self._run(
113
+ TrafficAction.ENABLE_MAINTENANCE,
114
+ self.traffic.maintenance_on_command,
115
+ cancellation_event=cancellation_event,
116
+ )
117
+
118
+ def disable_maintenance(
119
+ self,
120
+ *,
121
+ cancellation_event: threading.Event | None = None,
122
+ ) -> TrafficHookResult:
123
+ return self._run(
124
+ TrafficAction.DISABLE_MAINTENANCE,
125
+ self.traffic.maintenance_off_command,
126
+ cancellation_event=cancellation_event,
127
+ )
128
+
129
+ def activate_new(
130
+ self,
131
+ *,
132
+ cancellation_event: threading.Event | None = None,
133
+ ) -> TrafficHookResult:
134
+ return self._run(
135
+ TrafficAction.ACTIVATE_NEW,
136
+ self.traffic.activate_new_command,
137
+ cancellation_event=cancellation_event,
138
+ )
139
+
140
+ def activate_old(
141
+ self,
142
+ *,
143
+ cancellation_event: threading.Event | None = None,
144
+ ) -> TrafficHookResult:
145
+ return self._run(
146
+ TrafficAction.ACTIVATE_OLD,
147
+ self.traffic.activate_old_command,
148
+ cancellation_event=cancellation_event,
149
+ )
150
+
151
+ def _run(
152
+ self,
153
+ action: TrafficAction,
154
+ command: tuple[str, ...],
155
+ *,
156
+ cancellation_event: threading.Event | None,
157
+ ) -> TrafficHookResult:
158
+ result = self.command_runner.run(
159
+ command,
160
+ timeout_seconds=self.traffic.timeout_seconds,
161
+ environment=self.command_environment,
162
+ working_directory=self.working_directory,
163
+ cancellation_event=cancellation_event,
164
+ )
165
+ return TrafficHookResult(action=action, command=result)