pipecatcloud 0.3.0__tar.gz → 0.3.1__tar.gz
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.
- {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/CHANGELOG.md +30 -0
- {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/PKG-INFO +1 -1
- {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/src/pipecatcloud/_utils/build_utils.py +14 -1
- {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/src/pipecatcloud/_utils/deploy_utils.py +180 -0
- {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/src/pipecatcloud/cli/commands/agent.py +40 -3
- {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/src/pipecatcloud/cli/commands/build.py +10 -5
- {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/src/pipecatcloud/cli/commands/deploy.py +47 -31
- {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/src/pipecatcloud.egg-info/PKG-INFO +1 -1
- {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/src/pipecatcloud.egg-info/SOURCES.txt +1 -0
- pipecatcloud-0.3.1/tests/test_deployment_status.py +478 -0
- {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/.github/workflows/format.yml +0 -0
- {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/.github/workflows/publish-pypi.yml +0 -0
- {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/.github/workflows/publish-test.yml +0 -0
- {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/.github/workflows/tests.yml +0 -0
- {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/.gitignore +0 -0
- {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/.pre-commit-config.yaml +0 -0
- {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/LICENSE +0 -0
- {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/README.md +0 -0
- {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/pipecat-cloud.png +0 -0
- {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/pyproject.toml +0 -0
- {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/pytest.ini +0 -0
- {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/scripts/fix-ruff.sh +0 -0
- {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/scripts/pre-commit.sh +0 -0
- {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/setup.cfg +0 -0
- {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/src/pipecatcloud/__init__.py +0 -0
- {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/src/pipecatcloud/__main__.py +0 -0
- {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/src/pipecatcloud/__version__.py +0 -0
- {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/src/pipecatcloud/_utils/__init__.py +0 -0
- {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/src/pipecatcloud/_utils/async_utils.py +0 -0
- {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/src/pipecatcloud/_utils/auth_utils.py +0 -0
- {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/src/pipecatcloud/_utils/console_utils.py +0 -0
- {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/src/pipecatcloud/_utils/regions.py +0 -0
- {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/src/pipecatcloud/agent.py +0 -0
- {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/src/pipecatcloud/api.py +0 -0
- {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/src/pipecatcloud/cli/__init__.py +0 -0
- {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/src/pipecatcloud/cli/api.py +0 -0
- {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/src/pipecatcloud/cli/commands/__init__.py +0 -0
- {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/src/pipecatcloud/cli/commands/auth.py +0 -0
- {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/src/pipecatcloud/cli/commands/docker.py +0 -0
- {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/src/pipecatcloud/cli/commands/organizations.py +0 -0
- {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/src/pipecatcloud/cli/commands/regions.py +0 -0
- {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/src/pipecatcloud/cli/commands/secrets.py +0 -0
- {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/src/pipecatcloud/cli/config.py +0 -0
- {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/src/pipecatcloud/cli/entry_point.py +0 -0
- {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/src/pipecatcloud/config.py +0 -0
- {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/src/pipecatcloud/constants.py +0 -0
- {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/src/pipecatcloud/exception.py +0 -0
- {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/src/pipecatcloud/py.typed +0 -0
- {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/src/pipecatcloud/session.py +0 -0
- {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/src/pipecatcloud/smallwebrtc/session_manager.py +0 -0
- {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/src/pipecatcloud.egg-info/dependency_links.txt +0 -0
- {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/src/pipecatcloud.egg-info/entry_points.txt +0 -0
- {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/src/pipecatcloud.egg-info/requires.txt +0 -0
- {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/src/pipecatcloud.egg-info/top_level.txt +0 -0
- {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/tests/__init__.py +0 -0
- {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/tests/test_agent_sessions.py +0 -0
- {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/tests/test_agent_stop.py +0 -0
- {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/tests/test_api_client.py +0 -0
- {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/tests/test_docker_commands.py +0 -0
- {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/tests/test_krisp_viva.py +0 -0
- {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/tests/test_managed_keys.py +0 -0
- {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/tests/test_regions.py +0 -0
- {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/uv.lock +0 -0
|
@@ -5,6 +5,34 @@ All notable changes to **Pipecat Cloud** will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.3.1] - 2026-03-13
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- Added `.github`, `.claude`, `pcc-deploy.toml`, `.ipynb_checkpoints`, and
|
|
13
|
+
`.cache` to default cloud build context exclusions to reduce upload size and
|
|
14
|
+
avoid including non-runtime files in container images.
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- Increased deploy polling timeout from 90 seconds to 10 minutes to support
|
|
19
|
+
large deployments with big images, high replica counts, and cross-region pulls.
|
|
20
|
+
|
|
21
|
+
- Deploy polling now shows rich, multi-line status using API conditions and
|
|
22
|
+
revision info instead of a static "Waiting for deployment to become ready..."
|
|
23
|
+
message. The headline shows condition state (e.g. "Progressing · Available"),
|
|
24
|
+
with per-revision detail lines showing phase, replica counts, and elapsed time
|
|
25
|
+
for both current and previous deployments during rolling updates.
|
|
26
|
+
|
|
27
|
+
- Deploy timeout now differentiates exit behavior: services that are available
|
|
28
|
+
and serving traffic show a soft warning instead of a hard error, with guidance
|
|
29
|
+
to check status. Hard errors are reserved for services that are truly
|
|
30
|
+
unavailable.
|
|
31
|
+
|
|
32
|
+
- `agent status` now shows per-revision details (phase, deployment ID, replica
|
|
33
|
+
counts) in the health panel when the API provides revision info, replacing the
|
|
34
|
+
binary "Health: Ready/Stopped" display.
|
|
35
|
+
|
|
8
36
|
## [0.3.0] - 2026-03-12
|
|
9
37
|
|
|
10
38
|
### Added
|
|
@@ -15,6 +43,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
15
43
|
- `pcc deploy --build-id <id>` to reuse an existing build
|
|
16
44
|
- `pcc deploy --yes` for non-interactive CI/CD usage
|
|
17
45
|
- `[build]` section in `pcc-deploy.toml` for build configuration
|
|
46
|
+
|
|
18
47
|
- New `pcc build` command group for managing cloud builds:
|
|
19
48
|
- `pcc build list` — list recent builds with status and region filtering
|
|
20
49
|
- `pcc build status <id>` — get detailed status of a build
|
|
@@ -23,6 +52,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
23
52
|
### Changed
|
|
24
53
|
|
|
25
54
|
- The `image` field in `pcc deploy` is now optional when using cloud builds
|
|
55
|
+
|
|
26
56
|
- The CLI now uses `desiredDeploymentId` (with fallback to `activeDeploymentId`)
|
|
27
57
|
and compares it against `reconciledDeploymentId` to determine deployment status.
|
|
28
58
|
|
|
@@ -26,7 +26,6 @@ from typing import Callable, List, Optional, Set, Tuple
|
|
|
26
26
|
import aiohttp
|
|
27
27
|
from loguru import logger
|
|
28
28
|
|
|
29
|
-
|
|
30
29
|
# Default patterns to exclude from build context
|
|
31
30
|
DEFAULT_EXCLUSIONS: Set[str] = {
|
|
32
31
|
# Version control
|
|
@@ -68,8 +67,22 @@ DEFAULT_EXCLUSIONS: Set[str] = {
|
|
|
68
67
|
"dist",
|
|
69
68
|
"build",
|
|
70
69
|
"*.egg-info",
|
|
70
|
+
"*.egg",
|
|
71
|
+
".eggs",
|
|
71
72
|
# Node (if present)
|
|
72
73
|
"node_modules",
|
|
74
|
+
# CI/CD
|
|
75
|
+
".github",
|
|
76
|
+
# AI tools
|
|
77
|
+
".claude",
|
|
78
|
+
".codex",
|
|
79
|
+
".cursor",
|
|
80
|
+
# Pipecat config
|
|
81
|
+
"pcc-deploy.toml",
|
|
82
|
+
# Jupyter
|
|
83
|
+
".ipynb_checkpoints",
|
|
84
|
+
# Caches
|
|
85
|
+
".cache",
|
|
73
86
|
# Misc
|
|
74
87
|
".DS_Store",
|
|
75
88
|
"Thumbs.db",
|
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
|
|
7
7
|
import functools
|
|
8
8
|
import os
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from enum import Enum
|
|
9
11
|
from typing import Callable, List, Optional
|
|
10
12
|
|
|
11
13
|
import toml
|
|
@@ -22,6 +24,184 @@ DEPLOY_STATUS_MAP = {
|
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
|
|
27
|
+
class DeploymentPhase(Enum):
|
|
28
|
+
WAITING_FOR_OPERATOR = "waiting_for_operator"
|
|
29
|
+
PROGRESSING_AVAILABLE = "progressing_available"
|
|
30
|
+
PROGRESSING_NEW = "progressing_new"
|
|
31
|
+
DEGRADED_AVAILABLE = "degraded_available"
|
|
32
|
+
UNAVAILABLE = "unavailable"
|
|
33
|
+
READY = "ready"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class DeploymentStatus:
|
|
38
|
+
phase: DeploymentPhase
|
|
39
|
+
status_message: str
|
|
40
|
+
is_available: bool = False
|
|
41
|
+
is_ready: bool = False
|
|
42
|
+
degraded_reason: Optional[str] = None
|
|
43
|
+
current_revision: Optional[dict] = None
|
|
44
|
+
previous_revision: Optional[dict] = None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _find_condition(conditions: list, condition_type: str) -> Optional[dict]:
|
|
48
|
+
"""Find a condition by type from the conditions array."""
|
|
49
|
+
for c in conditions:
|
|
50
|
+
if c.get("type") == condition_type:
|
|
51
|
+
return c
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _format_elapsed(phase_started_at: Optional[str]) -> str:
|
|
56
|
+
"""Format elapsed time since phaseStartedAt as a human-readable string."""
|
|
57
|
+
if not phase_started_at:
|
|
58
|
+
return ""
|
|
59
|
+
try:
|
|
60
|
+
started = datetime.fromisoformat(phase_started_at.replace("Z", "+00:00"))
|
|
61
|
+
elapsed = datetime.now(timezone.utc) - started
|
|
62
|
+
total_seconds = int(elapsed.total_seconds())
|
|
63
|
+
if total_seconds < 0:
|
|
64
|
+
return ""
|
|
65
|
+
if total_seconds < 60:
|
|
66
|
+
return f"{total_seconds}s"
|
|
67
|
+
minutes = total_seconds // 60
|
|
68
|
+
seconds = total_seconds % 60
|
|
69
|
+
return f"{minutes}m {seconds}s"
|
|
70
|
+
except (ValueError, TypeError):
|
|
71
|
+
return ""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _format_revision_line(label: str, rev: dict) -> str:
|
|
75
|
+
"""Format a single revision as an indented detail line."""
|
|
76
|
+
deploy_id = rev.get("deploymentID", "unknown")[:8]
|
|
77
|
+
phase = rev.get("phase", "Unknown")
|
|
78
|
+
ready_replicas = rev.get("readyReplicas")
|
|
79
|
+
|
|
80
|
+
parts = [f" [dim]{label}[/dim] [bold]({deploy_id})[/bold] {phase}"]
|
|
81
|
+
if ready_replicas is not None:
|
|
82
|
+
parts.append(f"[dim]·[/dim] {ready_replicas} agents")
|
|
83
|
+
|
|
84
|
+
elapsed = _format_elapsed(rev.get("phaseStartedAt"))
|
|
85
|
+
if elapsed:
|
|
86
|
+
parts.append(f"[dim]·[/dim] [dim]{elapsed}[/dim]")
|
|
87
|
+
|
|
88
|
+
return " ".join(parts)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _build_status_message(headline: str, current_rev=None, previous_rev=None) -> str:
|
|
92
|
+
"""Build a multi-line status message with optional revision detail lines."""
|
|
93
|
+
lines = [headline]
|
|
94
|
+
if current_rev:
|
|
95
|
+
lines.append(_format_revision_line("Current ", current_rev))
|
|
96
|
+
if previous_rev:
|
|
97
|
+
lines.append(_format_revision_line("Previous", previous_rev))
|
|
98
|
+
return "\n".join(lines)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def interpret_deployment_status(
|
|
102
|
+
agent_status: dict,
|
|
103
|
+
desired_deployment_id: Optional[str] = None,
|
|
104
|
+
) -> DeploymentStatus:
|
|
105
|
+
"""Interpret the raw API response into a structured deployment status.
|
|
106
|
+
|
|
107
|
+
Examines reconciledDeploymentId, conditions array, available/ready booleans,
|
|
108
|
+
and revision info to determine the current deployment phase and build a
|
|
109
|
+
human-readable status message.
|
|
110
|
+
"""
|
|
111
|
+
reconciled_id = agent_status.get("reconciledDeploymentId")
|
|
112
|
+
desired_id = desired_deployment_id or agent_status.get(
|
|
113
|
+
"desiredDeploymentId", agent_status.get("activeDeploymentId")
|
|
114
|
+
)
|
|
115
|
+
conditions = agent_status.get("conditions") or []
|
|
116
|
+
available = agent_status.get("available", agent_status.get("ready", False))
|
|
117
|
+
ready = agent_status.get("ready", False)
|
|
118
|
+
active_deployment_ready = agent_status.get("activeDeploymentReady", False)
|
|
119
|
+
|
|
120
|
+
current_rev = agent_status.get("currentRevision")
|
|
121
|
+
previous_rev = agent_status.get("previousRevision")
|
|
122
|
+
|
|
123
|
+
# 1. Check if operator has reconciled
|
|
124
|
+
if desired_id and reconciled_id != desired_id:
|
|
125
|
+
return DeploymentStatus(
|
|
126
|
+
phase=DeploymentPhase.WAITING_FOR_OPERATOR,
|
|
127
|
+
status_message="[dim]Waiting for operator to process deployment...[/dim]",
|
|
128
|
+
current_revision=current_rev,
|
|
129
|
+
previous_revision=previous_rev,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
# 2. Fully ready
|
|
133
|
+
if available and (ready or active_deployment_ready):
|
|
134
|
+
return DeploymentStatus(
|
|
135
|
+
phase=DeploymentPhase.READY,
|
|
136
|
+
status_message="[green]Deployment is ready[/green]",
|
|
137
|
+
is_available=True,
|
|
138
|
+
is_ready=True,
|
|
139
|
+
current_revision=current_rev,
|
|
140
|
+
previous_revision=previous_rev,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
# 3. Look at conditions for richer status
|
|
144
|
+
degraded = _find_condition(conditions, "Degraded")
|
|
145
|
+
progressing = _find_condition(conditions, "Progressing")
|
|
146
|
+
|
|
147
|
+
degraded_active = degraded and degraded.get("status") == "True"
|
|
148
|
+
degraded_reason = (
|
|
149
|
+
degraded.get("message", degraded.get("reason", "")) if degraded_active else None
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
# 4. Degraded but available — warning state
|
|
153
|
+
if degraded_active and available:
|
|
154
|
+
headline = f"[yellow]Degraded · Available[/yellow] [dim]— {degraded_reason}[/dim]"
|
|
155
|
+
return DeploymentStatus(
|
|
156
|
+
phase=DeploymentPhase.DEGRADED_AVAILABLE,
|
|
157
|
+
status_message=_build_status_message(headline, current_rev, previous_rev),
|
|
158
|
+
is_available=True,
|
|
159
|
+
degraded_reason=degraded_reason,
|
|
160
|
+
current_revision=current_rev,
|
|
161
|
+
previous_revision=previous_rev,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
# 5. Available but not ready — rolling update in progress
|
|
165
|
+
if available and not ready:
|
|
166
|
+
headline = "[cyan]Progressing · Available[/cyan]"
|
|
167
|
+
return DeploymentStatus(
|
|
168
|
+
phase=DeploymentPhase.PROGRESSING_AVAILABLE,
|
|
169
|
+
status_message=_build_status_message(headline, current_rev, previous_rev),
|
|
170
|
+
is_available=True,
|
|
171
|
+
current_revision=current_rev,
|
|
172
|
+
previous_revision=previous_rev,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
# 6. Not available, progressing — new service coming up
|
|
176
|
+
if not available and (progressing and progressing.get("status") == "True"):
|
|
177
|
+
headline = "[dim]Progressing[/dim]"
|
|
178
|
+
return DeploymentStatus(
|
|
179
|
+
phase=DeploymentPhase.PROGRESSING_NEW,
|
|
180
|
+
status_message=_build_status_message(headline, current_rev, previous_rev),
|
|
181
|
+
current_revision=current_rev,
|
|
182
|
+
previous_revision=previous_rev,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
# 7. Not available, not progressing — broken
|
|
186
|
+
if not available and not ready:
|
|
187
|
+
reason_suffix = f" [dim]— {degraded_reason}[/dim]" if degraded_reason else ""
|
|
188
|
+
headline = f"[red]Unavailable[/red]{reason_suffix}"
|
|
189
|
+
return DeploymentStatus(
|
|
190
|
+
phase=DeploymentPhase.UNAVAILABLE,
|
|
191
|
+
status_message=_build_status_message(headline, current_rev, previous_rev),
|
|
192
|
+
current_revision=current_rev,
|
|
193
|
+
previous_revision=previous_rev,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
# Fallback — shouldn't normally reach here
|
|
197
|
+
return DeploymentStatus(
|
|
198
|
+
phase=DeploymentPhase.PROGRESSING_NEW,
|
|
199
|
+
status_message="[dim]Waiting for deployment to become ready...[/dim]",
|
|
200
|
+
current_revision=current_rev,
|
|
201
|
+
previous_revision=previous_rev,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
25
205
|
@dataclass
|
|
26
206
|
class ScalingParams:
|
|
27
207
|
min_agents: Optional[int] = 0
|
|
@@ -277,7 +277,44 @@ async def status(
|
|
|
277
277
|
border_style="red",
|
|
278
278
|
)
|
|
279
279
|
|
|
280
|
-
|
|
280
|
+
# Build health/status panel with revision info when available
|
|
281
|
+
current_rev = data.get("currentRevision")
|
|
282
|
+
previous_rev = data.get("previousRevision")
|
|
283
|
+
|
|
284
|
+
if current_rev:
|
|
285
|
+
# Rich status with revision details
|
|
286
|
+
health_lines = []
|
|
287
|
+
|
|
288
|
+
rev_phase = current_rev.get("phase", "Unknown")
|
|
289
|
+
rev_id = current_rev.get("deploymentID", "")[:8]
|
|
290
|
+
rev_replicas = current_rev.get("readyReplicas")
|
|
291
|
+
|
|
292
|
+
if data["ready"]:
|
|
293
|
+
health_lines.append("[bold green]Ready[/bold green]")
|
|
294
|
+
else:
|
|
295
|
+
health_lines.append(f"[bold yellow]{rev_phase}[/bold yellow]")
|
|
296
|
+
|
|
297
|
+
current_parts = [f" Current [bold]({rev_id})[/bold] {rev_phase}"]
|
|
298
|
+
if rev_replicas is not None:
|
|
299
|
+
current_parts.append(f"[dim]·[/dim] {rev_replicas} agents")
|
|
300
|
+
health_lines.append(" ".join(current_parts))
|
|
301
|
+
|
|
302
|
+
if previous_rev:
|
|
303
|
+
prev_phase = previous_rev.get("phase", "Unknown")
|
|
304
|
+
prev_id = previous_rev.get("deploymentID", "")[:8]
|
|
305
|
+
prev_replicas = previous_rev.get("readyReplicas")
|
|
306
|
+
prev_parts = [f" Previous [bold]({prev_id})[/bold] {prev_phase}"]
|
|
307
|
+
if prev_replicas is not None:
|
|
308
|
+
prev_parts.append(f"[dim]·[/dim] {prev_replicas} agents")
|
|
309
|
+
health_lines.append(" ".join(prev_parts))
|
|
310
|
+
|
|
311
|
+
health_content = "\n".join(health_lines)
|
|
312
|
+
health_border = "green" if data["ready"] else "yellow"
|
|
313
|
+
else:
|
|
314
|
+
# Fallback: no revision data from API
|
|
315
|
+
health_content = f"[{'bold green' if data['ready'] else 'bold yellow'}]Health: {'Ready' if data['ready'] else 'Stopped'}[/]"
|
|
316
|
+
health_border = "green" if data["ready"] else "yellow"
|
|
317
|
+
|
|
281
318
|
subtitle = (
|
|
282
319
|
f"[dim]Start a new active session with[/dim] [bold cyan]{PIPECAT_CLI_NAME} agent start {agent_name}[/bold cyan]"
|
|
283
320
|
if data["ready"]
|
|
@@ -289,8 +326,8 @@ async def status(
|
|
|
289
326
|
deployment_table,
|
|
290
327
|
scaling_panel if scaling_panel else "",
|
|
291
328
|
Panel(
|
|
292
|
-
|
|
293
|
-
border_style=
|
|
329
|
+
health_content,
|
|
330
|
+
border_style=health_border,
|
|
294
331
|
expand=False,
|
|
295
332
|
),
|
|
296
333
|
error_panel if error_panel else "",
|
|
@@ -20,6 +20,7 @@ from pipecatcloud._utils.async_utils import synchronizer
|
|
|
20
20
|
from pipecatcloud._utils.auth_utils import requires_login
|
|
21
21
|
from pipecatcloud._utils.build_utils import BuildStatus, format_size
|
|
22
22
|
from pipecatcloud._utils.console_utils import console, format_timestamp
|
|
23
|
+
from pipecatcloud.cli import PIPECAT_CLI_NAME
|
|
23
24
|
from pipecatcloud.cli.api import API
|
|
24
25
|
from pipecatcloud.cli.config import config
|
|
25
26
|
|
|
@@ -90,7 +91,7 @@ async def logs(
|
|
|
90
91
|
console.print("[dim]No logs available for this build yet[/dim]")
|
|
91
92
|
console.print(
|
|
92
93
|
f"\n[dim]The build may still be starting. "
|
|
93
|
-
f"Check status with:[/dim] [bold]
|
|
94
|
+
f"Check status with:[/dim] [bold]{PIPECAT_CLI_NAME} build status {build_id}[/bold]"
|
|
94
95
|
)
|
|
95
96
|
return
|
|
96
97
|
|
|
@@ -211,11 +212,13 @@ async def status(
|
|
|
211
212
|
|
|
212
213
|
# Show helpful commands based on status
|
|
213
214
|
if status_val in (BuildStatus.FAILED, BuildStatus.TIMEOUT):
|
|
214
|
-
console.print(
|
|
215
|
+
console.print(
|
|
216
|
+
f"\n[dim]View full logs:[/dim] [bold]{PIPECAT_CLI_NAME} build logs {build_id}[/bold]"
|
|
217
|
+
)
|
|
215
218
|
elif status_val in (BuildStatus.PENDING, BuildStatus.BUILDING):
|
|
216
219
|
console.print("\n[dim]Build in progress. Check again or view logs:[/dim]")
|
|
217
|
-
console.print(f" [bold]
|
|
218
|
-
console.print(f" [bold]
|
|
220
|
+
console.print(f" [bold]{PIPECAT_CLI_NAME} build status {build_id}[/bold]")
|
|
221
|
+
console.print(f" [bold]{PIPECAT_CLI_NAME} build logs {build_id}[/bold]")
|
|
219
222
|
|
|
220
223
|
|
|
221
224
|
@build_cli.command(name="list", help="List recent cloud builds")
|
|
@@ -302,4 +305,6 @@ async def list_builds(
|
|
|
302
305
|
title_extra=showing_text,
|
|
303
306
|
)
|
|
304
307
|
|
|
305
|
-
console.print(
|
|
308
|
+
console.print(
|
|
309
|
+
f"\n[dim]View build details:[/dim] [bold]{PIPECAT_CLI_NAME} build status <build-id>[/bold]"
|
|
310
|
+
)
|
|
@@ -32,6 +32,7 @@ from pipecatcloud._utils.deploy_utils import (
|
|
|
32
32
|
DeployConfigParams,
|
|
33
33
|
KrispVivaConfig,
|
|
34
34
|
ScalingParams,
|
|
35
|
+
interpret_deployment_status,
|
|
35
36
|
with_deploy_config,
|
|
36
37
|
)
|
|
37
38
|
from pipecatcloud._utils.regions import get_region_codes, validate_region
|
|
@@ -40,7 +41,7 @@ from pipecatcloud.cli.api import API
|
|
|
40
41
|
from pipecatcloud.cli.config import config
|
|
41
42
|
from pipecatcloud.constants import KRISP_VIVA_MODELS, Region
|
|
42
43
|
|
|
43
|
-
MAX_ALIVE_CHECKS =
|
|
44
|
+
MAX_ALIVE_CHECKS = 120
|
|
44
45
|
ALIVE_CHECK_SLEEP = 5
|
|
45
46
|
|
|
46
47
|
# ----- Cloud Build Flow
|
|
@@ -238,7 +239,7 @@ async def _cloud_build_flow(
|
|
|
238
239
|
error_msg = final_build.get("errorMessage", final_build.get("error", "Unknown error"))
|
|
239
240
|
console.error(
|
|
240
241
|
f"Build failed: {error_msg}\n\n"
|
|
241
|
-
f"[dim]View logs with:[/dim] [bold]
|
|
242
|
+
f"[dim]View logs with:[/dim] [bold]{PIPECAT_CLI_NAME} build logs {build_id}[/bold]"
|
|
242
243
|
)
|
|
243
244
|
return None
|
|
244
245
|
|
|
@@ -349,6 +350,7 @@ async def _deploy(params: DeployConfigParams, org, force: bool = False):
|
|
|
349
350
|
"""
|
|
350
351
|
active_deployment_id = None
|
|
351
352
|
is_ready = False
|
|
353
|
+
last_status = None
|
|
352
354
|
checks_performed = 0
|
|
353
355
|
|
|
354
356
|
console.print(
|
|
@@ -387,42 +389,29 @@ async def _deploy(params: DeployConfigParams, org, force: bool = False):
|
|
|
387
389
|
console.error("Error checking deployment status")
|
|
388
390
|
return typer.Exit()
|
|
389
391
|
|
|
390
|
-
#
|
|
391
|
-
# - desiredDeploymentId (or activeDeploymentId): what we requested
|
|
392
|
-
# - reconciledDeploymentId: what the operator has actually processed
|
|
392
|
+
# Capture the deployment ID for display
|
|
393
393
|
desired_deployment_id = agent_status.get(
|
|
394
394
|
"desiredDeploymentId", agent_status.get("activeDeploymentId")
|
|
395
395
|
)
|
|
396
|
-
reconciled_deployment_id = agent_status.get("reconciledDeploymentId")
|
|
397
|
-
|
|
398
|
-
# Update status message with deployment ID
|
|
399
396
|
if desired_deployment_id and not active_deployment_id:
|
|
400
397
|
active_deployment_id = desired_deployment_id
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
continue
|
|
413
|
-
|
|
414
|
-
# Check if deployment is ready
|
|
415
|
-
# For KEDA deployments, we need:
|
|
416
|
-
# - available: true (can handle traffic)
|
|
417
|
-
# - activeDeploymentReady: true (ReplicaSet is ready)
|
|
418
|
-
# The 'available' field falls back to 'ready' if not present (for backwards compatibility)
|
|
419
|
-
available = agent_status.get("available", agent_status.get("ready", False))
|
|
420
|
-
deployment_ready = agent_status.get("activeDeploymentReady", False)
|
|
421
|
-
|
|
422
|
-
if available and deployment_ready:
|
|
398
|
+
|
|
399
|
+
# Interpret status using conditions, availability, and revision info
|
|
400
|
+
last_status = interpret_deployment_status(
|
|
401
|
+
agent_status, desired_deployment_id=desired_deployment_id
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
logger.debug(
|
|
405
|
+
f"Interpreted phase: {last_status.phase}, available: {last_status.is_available}"
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
if last_status.is_ready:
|
|
423
409
|
is_ready = True
|
|
424
410
|
break
|
|
425
411
|
|
|
412
|
+
# Update spinner with current status message
|
|
413
|
+
status.update(last_status.status_message)
|
|
414
|
+
|
|
426
415
|
# Wait before checking again
|
|
427
416
|
await asyncio.sleep(ALIVE_CHECK_SLEEP)
|
|
428
417
|
checks_performed += 1
|
|
@@ -450,9 +439,36 @@ async def _deploy(params: DeployConfigParams, org, force: bool = False):
|
|
|
450
439
|
f"{extra_message}",
|
|
451
440
|
title_extra=f"{'Update' if existing_agent else 'Deployment'} complete",
|
|
452
441
|
)
|
|
442
|
+
elif last_status and last_status.is_available:
|
|
443
|
+
# Timed out but service is available — soft warning, not a hard error
|
|
444
|
+
timeout_seconds = MAX_ALIVE_CHECKS * ALIVE_CHECK_SLEEP
|
|
445
|
+
console.print(
|
|
446
|
+
Panel(
|
|
447
|
+
f"Service is available and serving traffic, but the new deployment hasn't fully rolled out after {timeout_seconds}s.\n"
|
|
448
|
+
f"This is normal for deployments with large images or high replica counts.\n\n"
|
|
449
|
+
f"[bold]Check status:[/bold] `{PIPECAT_CLI_NAME} agent status {params.agent_name}`\n"
|
|
450
|
+
f"[bold]View logs:[/bold] `{PIPECAT_CLI_NAME} agent logs {params.agent_name}`",
|
|
451
|
+
title="Deployment still in progress",
|
|
452
|
+
title_align="left",
|
|
453
|
+
border_style="yellow",
|
|
454
|
+
)
|
|
455
|
+
)
|
|
456
|
+
elif last_status and last_status.degraded_reason:
|
|
457
|
+
# Timed out with degraded status
|
|
458
|
+
console.print(
|
|
459
|
+
Panel(
|
|
460
|
+
f"Deployment is degraded: {last_status.degraded_reason}\n\n"
|
|
461
|
+
f"[bold]Check status:[/bold] `{PIPECAT_CLI_NAME} agent status {params.agent_name}`\n"
|
|
462
|
+
f"[bold]View logs:[/bold] `{PIPECAT_CLI_NAME} agent logs {params.agent_name}`",
|
|
463
|
+
title="Deployment degraded",
|
|
464
|
+
title_align="left",
|
|
465
|
+
border_style="red",
|
|
466
|
+
)
|
|
467
|
+
)
|
|
453
468
|
else:
|
|
469
|
+
timeout_seconds = MAX_ALIVE_CHECKS * ALIVE_CHECK_SLEEP
|
|
454
470
|
console.error(
|
|
455
|
-
f"Deployment did not
|
|
471
|
+
f"Deployment did not become available within {timeout_seconds} seconds.\n"
|
|
456
472
|
f"Please check logs with `{PIPECAT_CLI_NAME} agent logs {params.agent_name}`"
|
|
457
473
|
)
|
|
458
474
|
|
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Unit tests for deployment status interpretation.
|
|
3
|
+
|
|
4
|
+
Tests the interpret_deployment_status function which maps raw API responses
|
|
5
|
+
to structured DeploymentStatus objects used for CLI display during deploy polling.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from pipecatcloud._utils.deploy_utils import (
|
|
9
|
+
DeploymentPhase,
|
|
10
|
+
_format_elapsed,
|
|
11
|
+
_format_revision_line,
|
|
12
|
+
interpret_deployment_status,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TestWaitingForOperator:
|
|
17
|
+
"""When reconciledDeploymentId != desiredDeploymentId, operator hasn't processed yet."""
|
|
18
|
+
|
|
19
|
+
def test_unreconciled_deployment(self):
|
|
20
|
+
status = interpret_deployment_status(
|
|
21
|
+
{
|
|
22
|
+
"desiredDeploymentId": "deploy-new",
|
|
23
|
+
"reconciledDeploymentId": "deploy-old",
|
|
24
|
+
"available": False,
|
|
25
|
+
"ready": False,
|
|
26
|
+
},
|
|
27
|
+
desired_deployment_id="deploy-new",
|
|
28
|
+
)
|
|
29
|
+
assert status.phase == DeploymentPhase.WAITING_FOR_OPERATOR
|
|
30
|
+
assert not status.is_available
|
|
31
|
+
assert not status.is_ready
|
|
32
|
+
|
|
33
|
+
def test_no_reconciled_id_yet(self):
|
|
34
|
+
status = interpret_deployment_status(
|
|
35
|
+
{
|
|
36
|
+
"desiredDeploymentId": "deploy-new",
|
|
37
|
+
"reconciledDeploymentId": None,
|
|
38
|
+
"available": False,
|
|
39
|
+
"ready": False,
|
|
40
|
+
},
|
|
41
|
+
desired_deployment_id="deploy-new",
|
|
42
|
+
)
|
|
43
|
+
assert status.phase == DeploymentPhase.WAITING_FOR_OPERATOR
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class TestReady:
|
|
47
|
+
"""When available=true and ready=true (or activeDeploymentReady=true)."""
|
|
48
|
+
|
|
49
|
+
def test_available_and_ready(self):
|
|
50
|
+
status = interpret_deployment_status(
|
|
51
|
+
{
|
|
52
|
+
"desiredDeploymentId": "deploy-1",
|
|
53
|
+
"reconciledDeploymentId": "deploy-1",
|
|
54
|
+
"available": True,
|
|
55
|
+
"ready": True,
|
|
56
|
+
},
|
|
57
|
+
desired_deployment_id="deploy-1",
|
|
58
|
+
)
|
|
59
|
+
assert status.phase == DeploymentPhase.READY
|
|
60
|
+
assert status.is_available
|
|
61
|
+
assert status.is_ready
|
|
62
|
+
|
|
63
|
+
def test_available_and_active_deployment_ready(self):
|
|
64
|
+
"""activeDeploymentReady is the legacy field, should still trigger READY."""
|
|
65
|
+
status = interpret_deployment_status(
|
|
66
|
+
{
|
|
67
|
+
"desiredDeploymentId": "deploy-1",
|
|
68
|
+
"reconciledDeploymentId": "deploy-1",
|
|
69
|
+
"available": True,
|
|
70
|
+
"ready": False,
|
|
71
|
+
"activeDeploymentReady": True,
|
|
72
|
+
},
|
|
73
|
+
desired_deployment_id="deploy-1",
|
|
74
|
+
)
|
|
75
|
+
assert status.phase == DeploymentPhase.READY
|
|
76
|
+
assert status.is_available
|
|
77
|
+
assert status.is_ready
|
|
78
|
+
|
|
79
|
+
def test_ready_fallback_to_ready_field(self):
|
|
80
|
+
"""When 'available' is missing, falls back to 'ready'."""
|
|
81
|
+
status = interpret_deployment_status(
|
|
82
|
+
{
|
|
83
|
+
"desiredDeploymentId": "deploy-1",
|
|
84
|
+
"reconciledDeploymentId": "deploy-1",
|
|
85
|
+
"ready": True,
|
|
86
|
+
"activeDeploymentReady": True,
|
|
87
|
+
},
|
|
88
|
+
desired_deployment_id="deploy-1",
|
|
89
|
+
)
|
|
90
|
+
assert status.phase == DeploymentPhase.READY
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class TestProgressingAvailable:
|
|
94
|
+
"""Available but not ready — rolling update with existing service serving traffic."""
|
|
95
|
+
|
|
96
|
+
def test_available_not_ready(self):
|
|
97
|
+
status = interpret_deployment_status(
|
|
98
|
+
{
|
|
99
|
+
"desiredDeploymentId": "deploy-2",
|
|
100
|
+
"reconciledDeploymentId": "deploy-2",
|
|
101
|
+
"available": True,
|
|
102
|
+
"ready": False,
|
|
103
|
+
"conditions": [
|
|
104
|
+
{"type": "Available", "status": "True"},
|
|
105
|
+
{"type": "Progressing", "status": "True"},
|
|
106
|
+
],
|
|
107
|
+
},
|
|
108
|
+
desired_deployment_id="deploy-2",
|
|
109
|
+
)
|
|
110
|
+
assert status.phase == DeploymentPhase.PROGRESSING_AVAILABLE
|
|
111
|
+
assert status.is_available
|
|
112
|
+
assert not status.is_ready
|
|
113
|
+
assert "Progressing" in status.status_message
|
|
114
|
+
assert "Available" in status.status_message
|
|
115
|
+
|
|
116
|
+
def test_available_not_ready_with_revision_info(self):
|
|
117
|
+
status = interpret_deployment_status(
|
|
118
|
+
{
|
|
119
|
+
"desiredDeploymentId": "deploy-2",
|
|
120
|
+
"reconciledDeploymentId": "deploy-2",
|
|
121
|
+
"available": True,
|
|
122
|
+
"ready": False,
|
|
123
|
+
"currentRevision": {
|
|
124
|
+
"deploymentID": "deploy-2",
|
|
125
|
+
"phase": "Validating",
|
|
126
|
+
"readyReplicas": 42,
|
|
127
|
+
},
|
|
128
|
+
"previousRevision": {
|
|
129
|
+
"deploymentID": "deploy-1",
|
|
130
|
+
"phase": "Draining",
|
|
131
|
+
"readyReplicas": 84,
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
desired_deployment_id="deploy-2",
|
|
135
|
+
)
|
|
136
|
+
assert status.phase == DeploymentPhase.PROGRESSING_AVAILABLE
|
|
137
|
+
assert status.is_available
|
|
138
|
+
# Multi-line: headline + current + previous
|
|
139
|
+
lines = status.status_message.split("\n")
|
|
140
|
+
assert len(lines) == 3
|
|
141
|
+
assert "42 agents" in lines[1]
|
|
142
|
+
assert "Validating" in lines[1]
|
|
143
|
+
assert "84 agents" in lines[2]
|
|
144
|
+
assert "Draining" in lines[2]
|
|
145
|
+
assert status.current_revision["readyReplicas"] == 42
|
|
146
|
+
assert status.previous_revision["phase"] == "Draining"
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class TestProgressingNew:
|
|
150
|
+
"""Not available, but progressing — new service coming up for the first time."""
|
|
151
|
+
|
|
152
|
+
def test_not_available_progressing(self):
|
|
153
|
+
status = interpret_deployment_status(
|
|
154
|
+
{
|
|
155
|
+
"desiredDeploymentId": "deploy-1",
|
|
156
|
+
"reconciledDeploymentId": "deploy-1",
|
|
157
|
+
"available": False,
|
|
158
|
+
"ready": False,
|
|
159
|
+
"conditions": [
|
|
160
|
+
{"type": "Progressing", "status": "True"},
|
|
161
|
+
],
|
|
162
|
+
},
|
|
163
|
+
desired_deployment_id="deploy-1",
|
|
164
|
+
)
|
|
165
|
+
assert status.phase == DeploymentPhase.PROGRESSING_NEW
|
|
166
|
+
assert not status.is_available
|
|
167
|
+
assert not status.is_ready
|
|
168
|
+
assert "Progressing" in status.status_message
|
|
169
|
+
|
|
170
|
+
def test_not_available_progressing_with_agents(self):
|
|
171
|
+
status = interpret_deployment_status(
|
|
172
|
+
{
|
|
173
|
+
"desiredDeploymentId": "deploy-1",
|
|
174
|
+
"reconciledDeploymentId": "deploy-1",
|
|
175
|
+
"available": False,
|
|
176
|
+
"ready": False,
|
|
177
|
+
"conditions": [
|
|
178
|
+
{"type": "Progressing", "status": "True"},
|
|
179
|
+
],
|
|
180
|
+
"currentRevision": {
|
|
181
|
+
"deploymentID": "deploy-1",
|
|
182
|
+
"phase": "Creating",
|
|
183
|
+
"readyReplicas": 3,
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
desired_deployment_id="deploy-1",
|
|
187
|
+
)
|
|
188
|
+
assert status.phase == DeploymentPhase.PROGRESSING_NEW
|
|
189
|
+
lines = status.status_message.split("\n")
|
|
190
|
+
assert len(lines) == 2
|
|
191
|
+
assert "3 agents" in lines[1]
|
|
192
|
+
assert "Creating" in lines[1]
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class TestDegradedAvailable:
|
|
196
|
+
"""Available but degraded — serving traffic but something is wrong."""
|
|
197
|
+
|
|
198
|
+
def test_degraded_and_available(self):
|
|
199
|
+
status = interpret_deployment_status(
|
|
200
|
+
{
|
|
201
|
+
"desiredDeploymentId": "deploy-1",
|
|
202
|
+
"reconciledDeploymentId": "deploy-1",
|
|
203
|
+
"available": True,
|
|
204
|
+
"ready": False,
|
|
205
|
+
"conditions": [
|
|
206
|
+
{"type": "Available", "status": "True"},
|
|
207
|
+
{
|
|
208
|
+
"type": "Degraded",
|
|
209
|
+
"status": "True",
|
|
210
|
+
"reason": "ImageResolutionFailed",
|
|
211
|
+
"message": "Failed to resolve image tag",
|
|
212
|
+
},
|
|
213
|
+
],
|
|
214
|
+
},
|
|
215
|
+
desired_deployment_id="deploy-1",
|
|
216
|
+
)
|
|
217
|
+
assert status.phase == DeploymentPhase.DEGRADED_AVAILABLE
|
|
218
|
+
assert status.is_available
|
|
219
|
+
assert not status.is_ready
|
|
220
|
+
assert status.degraded_reason == "Failed to resolve image tag"
|
|
221
|
+
assert "Degraded" in status.status_message
|
|
222
|
+
assert "Available" in status.status_message
|
|
223
|
+
|
|
224
|
+
def test_degraded_reason_falls_back_to_reason_field(self):
|
|
225
|
+
status = interpret_deployment_status(
|
|
226
|
+
{
|
|
227
|
+
"desiredDeploymentId": "deploy-1",
|
|
228
|
+
"reconciledDeploymentId": "deploy-1",
|
|
229
|
+
"available": True,
|
|
230
|
+
"ready": False,
|
|
231
|
+
"conditions": [
|
|
232
|
+
{
|
|
233
|
+
"type": "Degraded",
|
|
234
|
+
"status": "True",
|
|
235
|
+
"reason": "SomeReason",
|
|
236
|
+
},
|
|
237
|
+
],
|
|
238
|
+
},
|
|
239
|
+
desired_deployment_id="deploy-1",
|
|
240
|
+
)
|
|
241
|
+
assert status.phase == DeploymentPhase.DEGRADED_AVAILABLE
|
|
242
|
+
assert status.degraded_reason == "SomeReason"
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
class TestUnavailable:
|
|
246
|
+
"""Not available and not progressing — broken."""
|
|
247
|
+
|
|
248
|
+
def test_not_available_not_progressing(self):
|
|
249
|
+
status = interpret_deployment_status(
|
|
250
|
+
{
|
|
251
|
+
"desiredDeploymentId": "deploy-1",
|
|
252
|
+
"reconciledDeploymentId": "deploy-1",
|
|
253
|
+
"available": False,
|
|
254
|
+
"ready": False,
|
|
255
|
+
"conditions": [],
|
|
256
|
+
},
|
|
257
|
+
desired_deployment_id="deploy-1",
|
|
258
|
+
)
|
|
259
|
+
assert status.phase == DeploymentPhase.UNAVAILABLE
|
|
260
|
+
assert not status.is_available
|
|
261
|
+
assert not status.is_ready
|
|
262
|
+
assert "Unavailable" in status.status_message
|
|
263
|
+
|
|
264
|
+
def test_unavailable_with_degraded_reason(self):
|
|
265
|
+
status = interpret_deployment_status(
|
|
266
|
+
{
|
|
267
|
+
"desiredDeploymentId": "deploy-1",
|
|
268
|
+
"reconciledDeploymentId": "deploy-1",
|
|
269
|
+
"available": False,
|
|
270
|
+
"ready": False,
|
|
271
|
+
"conditions": [
|
|
272
|
+
{
|
|
273
|
+
"type": "Degraded",
|
|
274
|
+
"status": "True",
|
|
275
|
+
"message": "CrashLoopBackOff",
|
|
276
|
+
},
|
|
277
|
+
],
|
|
278
|
+
},
|
|
279
|
+
desired_deployment_id="deploy-1",
|
|
280
|
+
)
|
|
281
|
+
assert status.phase == DeploymentPhase.UNAVAILABLE
|
|
282
|
+
assert "CrashLoopBackOff" in status.status_message
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
class TestBackwardCompatibility:
|
|
286
|
+
"""Tests for API responses that don't include new fields (conditions, revisions)."""
|
|
287
|
+
|
|
288
|
+
def test_no_conditions_field(self):
|
|
289
|
+
"""Old API without conditions array should still work."""
|
|
290
|
+
status = interpret_deployment_status(
|
|
291
|
+
{
|
|
292
|
+
"desiredDeploymentId": "deploy-1",
|
|
293
|
+
"reconciledDeploymentId": "deploy-1",
|
|
294
|
+
"available": True,
|
|
295
|
+
"ready": True,
|
|
296
|
+
},
|
|
297
|
+
desired_deployment_id="deploy-1",
|
|
298
|
+
)
|
|
299
|
+
assert status.phase == DeploymentPhase.READY
|
|
300
|
+
|
|
301
|
+
def test_no_revision_info(self):
|
|
302
|
+
"""API without revision fields should work — single-line message, no replica info."""
|
|
303
|
+
status = interpret_deployment_status(
|
|
304
|
+
{
|
|
305
|
+
"desiredDeploymentId": "deploy-1",
|
|
306
|
+
"reconciledDeploymentId": "deploy-1",
|
|
307
|
+
"available": True,
|
|
308
|
+
"ready": False,
|
|
309
|
+
},
|
|
310
|
+
desired_deployment_id="deploy-1",
|
|
311
|
+
)
|
|
312
|
+
assert status.phase == DeploymentPhase.PROGRESSING_AVAILABLE
|
|
313
|
+
assert status.current_revision is None
|
|
314
|
+
# No revision info → single line, no "agents"
|
|
315
|
+
assert "\n" not in status.status_message
|
|
316
|
+
assert "agents" not in status.status_message
|
|
317
|
+
|
|
318
|
+
def test_legacy_ready_field_only(self):
|
|
319
|
+
"""Very old API that only has 'ready' field."""
|
|
320
|
+
status = interpret_deployment_status(
|
|
321
|
+
{
|
|
322
|
+
"activeDeploymentId": "deploy-1",
|
|
323
|
+
"reconciledDeploymentId": "deploy-1",
|
|
324
|
+
"ready": True,
|
|
325
|
+
"activeDeploymentReady": True,
|
|
326
|
+
},
|
|
327
|
+
desired_deployment_id="deploy-1",
|
|
328
|
+
)
|
|
329
|
+
assert status.phase == DeploymentPhase.READY
|
|
330
|
+
|
|
331
|
+
def test_no_desired_deployment_id(self):
|
|
332
|
+
"""When desired_deployment_id is None, skip reconciliation check."""
|
|
333
|
+
status = interpret_deployment_status(
|
|
334
|
+
{
|
|
335
|
+
"available": True,
|
|
336
|
+
"ready": True,
|
|
337
|
+
},
|
|
338
|
+
)
|
|
339
|
+
assert status.phase == DeploymentPhase.READY
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
class TestRevisionLines:
|
|
343
|
+
"""Tests for multi-line revision detail rendering."""
|
|
344
|
+
|
|
345
|
+
def test_current_revision_line_format(self):
|
|
346
|
+
line = _format_revision_line(
|
|
347
|
+
"Current ",
|
|
348
|
+
{
|
|
349
|
+
"deploymentID": "73071caf-abcd-1234",
|
|
350
|
+
"phase": "Validating",
|
|
351
|
+
"readyReplicas": 42,
|
|
352
|
+
},
|
|
353
|
+
)
|
|
354
|
+
assert "73071caf" in line
|
|
355
|
+
assert "Validating" in line
|
|
356
|
+
assert "42 agents" in line
|
|
357
|
+
|
|
358
|
+
def test_previous_revision_line_format(self):
|
|
359
|
+
line = _format_revision_line(
|
|
360
|
+
"Previous",
|
|
361
|
+
{
|
|
362
|
+
"deploymentID": "15122a6b-efgh-5678",
|
|
363
|
+
"phase": "Draining",
|
|
364
|
+
"readyReplicas": 68,
|
|
365
|
+
},
|
|
366
|
+
)
|
|
367
|
+
assert "15122a6b" in line
|
|
368
|
+
assert "Draining" in line
|
|
369
|
+
assert "68 agents" in line
|
|
370
|
+
|
|
371
|
+
def test_revision_line_with_elapsed_time(self):
|
|
372
|
+
from datetime import datetime, timezone
|
|
373
|
+
|
|
374
|
+
recent = (datetime.now(timezone.utc)).isoformat()
|
|
375
|
+
line = _format_revision_line(
|
|
376
|
+
"Current ",
|
|
377
|
+
{
|
|
378
|
+
"deploymentID": "abcdef12",
|
|
379
|
+
"phase": "Validating",
|
|
380
|
+
"readyReplicas": 10,
|
|
381
|
+
"phaseStartedAt": recent,
|
|
382
|
+
},
|
|
383
|
+
)
|
|
384
|
+
assert "Validating" in line
|
|
385
|
+
assert "10 agents" in line
|
|
386
|
+
# Should have a time component (0s or 1s)
|
|
387
|
+
assert "s" in line
|
|
388
|
+
|
|
389
|
+
def test_revision_line_without_agents(self):
|
|
390
|
+
"""Revision with no readyReplicas field should omit replica count."""
|
|
391
|
+
line = _format_revision_line(
|
|
392
|
+
"Current ",
|
|
393
|
+
{
|
|
394
|
+
"deploymentID": "abcdef12",
|
|
395
|
+
"phase": "Creating",
|
|
396
|
+
},
|
|
397
|
+
)
|
|
398
|
+
assert "Creating" in line
|
|
399
|
+
assert "agents" not in line
|
|
400
|
+
|
|
401
|
+
def test_multi_line_message_with_both_revisions(self):
|
|
402
|
+
status = interpret_deployment_status(
|
|
403
|
+
{
|
|
404
|
+
"desiredDeploymentId": "deploy-2",
|
|
405
|
+
"reconciledDeploymentId": "deploy-2",
|
|
406
|
+
"available": True,
|
|
407
|
+
"ready": False,
|
|
408
|
+
"currentRevision": {
|
|
409
|
+
"deploymentID": "deploy-2-full-uuid",
|
|
410
|
+
"phase": "Validating",
|
|
411
|
+
"readyReplicas": 42,
|
|
412
|
+
},
|
|
413
|
+
"previousRevision": {
|
|
414
|
+
"deploymentID": "deploy-1-full-uuid",
|
|
415
|
+
"phase": "Draining",
|
|
416
|
+
"readyReplicas": 68,
|
|
417
|
+
},
|
|
418
|
+
},
|
|
419
|
+
desired_deployment_id="deploy-2",
|
|
420
|
+
)
|
|
421
|
+
lines = status.status_message.split("\n")
|
|
422
|
+
assert len(lines) == 3
|
|
423
|
+
# Line 0: condition headline
|
|
424
|
+
assert "Progressing" in lines[0]
|
|
425
|
+
assert "Available" in lines[0]
|
|
426
|
+
# Line 1: current revision (deploymentID truncated to 8 chars)
|
|
427
|
+
assert "Current" in lines[1]
|
|
428
|
+
assert "deploy-2" in lines[1]
|
|
429
|
+
assert "42 agents" in lines[1]
|
|
430
|
+
# Line 2: previous revision
|
|
431
|
+
assert "Previous" in lines[2]
|
|
432
|
+
assert "deploy-1" in lines[2]
|
|
433
|
+
assert "68 agents" in lines[2]
|
|
434
|
+
|
|
435
|
+
def test_single_line_when_no_revisions(self):
|
|
436
|
+
"""Without revision data, message stays single-line."""
|
|
437
|
+
status = interpret_deployment_status(
|
|
438
|
+
{
|
|
439
|
+
"desiredDeploymentId": "deploy-1",
|
|
440
|
+
"reconciledDeploymentId": "deploy-1",
|
|
441
|
+
"available": False,
|
|
442
|
+
"ready": False,
|
|
443
|
+
"conditions": [{"type": "Progressing", "status": "True"}],
|
|
444
|
+
},
|
|
445
|
+
desired_deployment_id="deploy-1",
|
|
446
|
+
)
|
|
447
|
+
assert "\n" not in status.status_message
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
class TestFormatElapsed:
|
|
451
|
+
"""Tests for the elapsed time formatter."""
|
|
452
|
+
|
|
453
|
+
def test_none_input(self):
|
|
454
|
+
assert _format_elapsed(None) == ""
|
|
455
|
+
|
|
456
|
+
def test_invalid_input(self):
|
|
457
|
+
assert _format_elapsed("not-a-date") == ""
|
|
458
|
+
|
|
459
|
+
def test_recent_timestamp(self):
|
|
460
|
+
from datetime import datetime, timezone
|
|
461
|
+
|
|
462
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
463
|
+
result = _format_elapsed(now)
|
|
464
|
+
assert result in ("0s", "1s")
|
|
465
|
+
|
|
466
|
+
def test_minutes_ago(self):
|
|
467
|
+
from datetime import datetime, timedelta, timezone
|
|
468
|
+
|
|
469
|
+
two_min_ago = (datetime.now(timezone.utc) - timedelta(minutes=2, seconds=30)).isoformat()
|
|
470
|
+
result = _format_elapsed(two_min_ago)
|
|
471
|
+
assert result.startswith("2m")
|
|
472
|
+
|
|
473
|
+
def test_z_suffix(self):
|
|
474
|
+
from datetime import datetime, timedelta, timezone
|
|
475
|
+
|
|
476
|
+
ts = (datetime.now(timezone.utc) - timedelta(seconds=45)).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
477
|
+
result = _format_elapsed(ts)
|
|
478
|
+
assert "s" in result
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|