pipecatcloud 0.2.19__tar.gz → 0.3.0__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.2.19 → pipecatcloud-0.3.0}/CHANGELOG.md +37 -3
- {pipecatcloud-0.2.19 → pipecatcloud-0.3.0}/PKG-INFO +1 -1
- pipecatcloud-0.3.0/src/pipecatcloud/_utils/build_utils.py +403 -0
- {pipecatcloud-0.2.19 → pipecatcloud-0.3.0}/src/pipecatcloud/_utils/deploy_utils.py +36 -1
- {pipecatcloud-0.2.19 → pipecatcloud-0.3.0}/src/pipecatcloud/api.py +124 -1
- {pipecatcloud-0.2.19 → pipecatcloud-0.3.0}/src/pipecatcloud/cli/commands/auth.py +69 -32
- pipecatcloud-0.3.0/src/pipecatcloud/cli/commands/build.py +305 -0
- {pipecatcloud-0.2.19 → pipecatcloud-0.3.0}/src/pipecatcloud/cli/commands/deploy.py +354 -35
- {pipecatcloud-0.2.19 → pipecatcloud-0.3.0}/src/pipecatcloud/cli/config.py +20 -0
- {pipecatcloud-0.2.19 → pipecatcloud-0.3.0}/src/pipecatcloud/cli/entry_point.py +2 -0
- {pipecatcloud-0.2.19 → pipecatcloud-0.3.0}/src/pipecatcloud/config.py +1 -0
- {pipecatcloud-0.2.19 → pipecatcloud-0.3.0}/src/pipecatcloud.egg-info/PKG-INFO +1 -1
- {pipecatcloud-0.2.19 → pipecatcloud-0.3.0}/src/pipecatcloud.egg-info/SOURCES.txt +2 -0
- {pipecatcloud-0.2.19 → pipecatcloud-0.3.0}/uv.lock +348 -114
- {pipecatcloud-0.2.19 → pipecatcloud-0.3.0}/.github/workflows/format.yml +0 -0
- {pipecatcloud-0.2.19 → pipecatcloud-0.3.0}/.github/workflows/publish-pypi.yml +0 -0
- {pipecatcloud-0.2.19 → pipecatcloud-0.3.0}/.github/workflows/publish-test.yml +0 -0
- {pipecatcloud-0.2.19 → pipecatcloud-0.3.0}/.github/workflows/tests.yml +0 -0
- {pipecatcloud-0.2.19 → pipecatcloud-0.3.0}/.gitignore +0 -0
- {pipecatcloud-0.2.19 → pipecatcloud-0.3.0}/.pre-commit-config.yaml +0 -0
- {pipecatcloud-0.2.19 → pipecatcloud-0.3.0}/LICENSE +0 -0
- {pipecatcloud-0.2.19 → pipecatcloud-0.3.0}/README.md +0 -0
- {pipecatcloud-0.2.19 → pipecatcloud-0.3.0}/pipecat-cloud.png +0 -0
- {pipecatcloud-0.2.19 → pipecatcloud-0.3.0}/pyproject.toml +0 -0
- {pipecatcloud-0.2.19 → pipecatcloud-0.3.0}/pytest.ini +0 -0
- {pipecatcloud-0.2.19 → pipecatcloud-0.3.0}/scripts/fix-ruff.sh +0 -0
- {pipecatcloud-0.2.19 → pipecatcloud-0.3.0}/scripts/pre-commit.sh +0 -0
- {pipecatcloud-0.2.19 → pipecatcloud-0.3.0}/setup.cfg +0 -0
- {pipecatcloud-0.2.19 → pipecatcloud-0.3.0}/src/pipecatcloud/__init__.py +0 -0
- {pipecatcloud-0.2.19 → pipecatcloud-0.3.0}/src/pipecatcloud/__main__.py +0 -0
- {pipecatcloud-0.2.19 → pipecatcloud-0.3.0}/src/pipecatcloud/__version__.py +0 -0
- {pipecatcloud-0.2.19 → pipecatcloud-0.3.0}/src/pipecatcloud/_utils/__init__.py +0 -0
- {pipecatcloud-0.2.19 → pipecatcloud-0.3.0}/src/pipecatcloud/_utils/async_utils.py +0 -0
- {pipecatcloud-0.2.19 → pipecatcloud-0.3.0}/src/pipecatcloud/_utils/auth_utils.py +0 -0
- {pipecatcloud-0.2.19 → pipecatcloud-0.3.0}/src/pipecatcloud/_utils/console_utils.py +0 -0
- {pipecatcloud-0.2.19 → pipecatcloud-0.3.0}/src/pipecatcloud/_utils/regions.py +0 -0
- {pipecatcloud-0.2.19 → pipecatcloud-0.3.0}/src/pipecatcloud/agent.py +0 -0
- {pipecatcloud-0.2.19 → pipecatcloud-0.3.0}/src/pipecatcloud/cli/__init__.py +0 -0
- {pipecatcloud-0.2.19 → pipecatcloud-0.3.0}/src/pipecatcloud/cli/api.py +0 -0
- {pipecatcloud-0.2.19 → pipecatcloud-0.3.0}/src/pipecatcloud/cli/commands/__init__.py +0 -0
- {pipecatcloud-0.2.19 → pipecatcloud-0.3.0}/src/pipecatcloud/cli/commands/agent.py +0 -0
- {pipecatcloud-0.2.19 → pipecatcloud-0.3.0}/src/pipecatcloud/cli/commands/docker.py +0 -0
- {pipecatcloud-0.2.19 → pipecatcloud-0.3.0}/src/pipecatcloud/cli/commands/organizations.py +0 -0
- {pipecatcloud-0.2.19 → pipecatcloud-0.3.0}/src/pipecatcloud/cli/commands/regions.py +0 -0
- {pipecatcloud-0.2.19 → pipecatcloud-0.3.0}/src/pipecatcloud/cli/commands/secrets.py +0 -0
- {pipecatcloud-0.2.19 → pipecatcloud-0.3.0}/src/pipecatcloud/constants.py +0 -0
- {pipecatcloud-0.2.19 → pipecatcloud-0.3.0}/src/pipecatcloud/exception.py +0 -0
- {pipecatcloud-0.2.19 → pipecatcloud-0.3.0}/src/pipecatcloud/py.typed +0 -0
- {pipecatcloud-0.2.19 → pipecatcloud-0.3.0}/src/pipecatcloud/session.py +0 -0
- {pipecatcloud-0.2.19 → pipecatcloud-0.3.0}/src/pipecatcloud/smallwebrtc/session_manager.py +0 -0
- {pipecatcloud-0.2.19 → pipecatcloud-0.3.0}/src/pipecatcloud.egg-info/dependency_links.txt +0 -0
- {pipecatcloud-0.2.19 → pipecatcloud-0.3.0}/src/pipecatcloud.egg-info/entry_points.txt +0 -0
- {pipecatcloud-0.2.19 → pipecatcloud-0.3.0}/src/pipecatcloud.egg-info/requires.txt +0 -0
- {pipecatcloud-0.2.19 → pipecatcloud-0.3.0}/src/pipecatcloud.egg-info/top_level.txt +0 -0
- {pipecatcloud-0.2.19 → pipecatcloud-0.3.0}/tests/__init__.py +0 -0
- {pipecatcloud-0.2.19 → pipecatcloud-0.3.0}/tests/test_agent_sessions.py +0 -0
- {pipecatcloud-0.2.19 → pipecatcloud-0.3.0}/tests/test_agent_stop.py +0 -0
- {pipecatcloud-0.2.19 → pipecatcloud-0.3.0}/tests/test_api_client.py +0 -0
- {pipecatcloud-0.2.19 → pipecatcloud-0.3.0}/tests/test_docker_commands.py +0 -0
- {pipecatcloud-0.2.19 → pipecatcloud-0.3.0}/tests/test_krisp_viva.py +0 -0
- {pipecatcloud-0.2.19 → pipecatcloud-0.3.0}/tests/test_managed_keys.py +0 -0
- {pipecatcloud-0.2.19 → pipecatcloud-0.3.0}/tests/test_regions.py +0 -0
|
@@ -5,6 +5,43 @@ 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.0] - 2026-03-12
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **Cloud Builds**: Deploy agents without managing a container registry. Run
|
|
13
|
+
`pcc deploy` without an `image` and the CLI will build your image in the cloud.
|
|
14
|
+
- Deterministic content hashing for build caching (same files = skip rebuild)
|
|
15
|
+
- `pcc deploy --build-id <id>` to reuse an existing build
|
|
16
|
+
- `pcc deploy --yes` for non-interactive CI/CD usage
|
|
17
|
+
- `[build]` section in `pcc-deploy.toml` for build configuration
|
|
18
|
+
- New `pcc build` command group for managing cloud builds:
|
|
19
|
+
- `pcc build list` — list recent builds with status and region filtering
|
|
20
|
+
- `pcc build status <id>` — get detailed status of a build
|
|
21
|
+
- `pcc build logs <id>` — view build logs
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
|
|
25
|
+
- The `image` field in `pcc deploy` is now optional when using cloud builds
|
|
26
|
+
- The CLI now uses `desiredDeploymentId` (with fallback to `activeDeploymentId`)
|
|
27
|
+
and compares it against `reconciledDeploymentId` to determine deployment status.
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
|
|
31
|
+
- Fixed `deploy` command reporting success prematurely when updating an existing
|
|
32
|
+
agent. The CLI now uses the new `reconciledDeploymentId` field from the API to
|
|
33
|
+
verify the operator has actually processed the deployment before reporting
|
|
34
|
+
readiness, preventing false "ready" status when the operator is slow to process
|
|
35
|
+
the update.
|
|
36
|
+
|
|
37
|
+
## [0.2.20] - 2026-02-11
|
|
38
|
+
|
|
39
|
+
### Fixed
|
|
40
|
+
|
|
41
|
+
- Fixed `auth login --headless` to be fully non-interactive by using plain text
|
|
42
|
+
output instead of Rich terminal widgets, enabling use in environments like
|
|
43
|
+
Claude Code and CI/CD pipelines.
|
|
44
|
+
|
|
8
45
|
## [0.2.19] - 2026-01-27
|
|
9
46
|
|
|
10
47
|
### Added
|
|
@@ -60,7 +97,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
60
97
|
|
|
61
98
|
- Added `pcc organizations properties` commands for managing organization-level
|
|
62
99
|
configuration:
|
|
63
|
-
|
|
64
100
|
- `properties list` - Display current property values
|
|
65
101
|
- `properties set <name> <value>` - Update a property value
|
|
66
102
|
- `properties schema` - Show available properties with metadata and allowed
|
|
@@ -245,13 +281,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
245
281
|
|
|
246
282
|
- Relaxed the package requirements for `python-dotenv` and `uvicorn` to make it
|
|
247
283
|
easier to work with Pipecat Cloud. The new supported versions are:
|
|
248
|
-
|
|
249
284
|
- `"python-dotenv>=1.0.1,<2.0.0"`
|
|
250
285
|
- `"uvicorn>=0.32.0,<1.0.0"`
|
|
251
286
|
|
|
252
287
|
- Updated all types to inherit from `RunnerArguments` to align the types with
|
|
253
288
|
the Pipecat development runner types. This adds types:
|
|
254
|
-
|
|
255
289
|
- `handle_sigint`
|
|
256
290
|
- `handle_sigterm`
|
|
257
291
|
- `pipeline_idle_timeout_secs`
|
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
#
|
|
2
|
+
# Copyright (c) 2025, Daily
|
|
3
|
+
#
|
|
4
|
+
# SPDX-License-Identifier: BSD 2-Clause License
|
|
5
|
+
#
|
|
6
|
+
|
|
7
|
+
"""
|
|
8
|
+
Build utilities for Pipecat Cloud CLI.
|
|
9
|
+
|
|
10
|
+
Provides functions for creating deterministic build contexts, uploading to S3,
|
|
11
|
+
and polling build status.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import fnmatch
|
|
16
|
+
import gzip
|
|
17
|
+
import hashlib
|
|
18
|
+
import io
|
|
19
|
+
import os
|
|
20
|
+
import tarfile
|
|
21
|
+
import time
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Callable, List, Optional, Set, Tuple
|
|
25
|
+
|
|
26
|
+
import aiohttp
|
|
27
|
+
from loguru import logger
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# Default patterns to exclude from build context
|
|
31
|
+
DEFAULT_EXCLUSIONS: Set[str] = {
|
|
32
|
+
# Version control
|
|
33
|
+
".git",
|
|
34
|
+
".gitignore",
|
|
35
|
+
".gitattributes",
|
|
36
|
+
# Environment and secrets
|
|
37
|
+
".env",
|
|
38
|
+
".env.*",
|
|
39
|
+
"*.pem",
|
|
40
|
+
"*.key",
|
|
41
|
+
# Python artifacts
|
|
42
|
+
"__pycache__",
|
|
43
|
+
"*.pyc",
|
|
44
|
+
"*.pyo",
|
|
45
|
+
"*.pyd",
|
|
46
|
+
"*.so",
|
|
47
|
+
".Python",
|
|
48
|
+
# Virtual environments
|
|
49
|
+
".venv",
|
|
50
|
+
"venv",
|
|
51
|
+
"ENV",
|
|
52
|
+
"env",
|
|
53
|
+
# Testing
|
|
54
|
+
".pytest_cache",
|
|
55
|
+
".coverage",
|
|
56
|
+
"htmlcov",
|
|
57
|
+
".tox",
|
|
58
|
+
".nox",
|
|
59
|
+
# Type checking / Linting
|
|
60
|
+
".mypy_cache",
|
|
61
|
+
".ruff_cache",
|
|
62
|
+
# IDE
|
|
63
|
+
".vscode",
|
|
64
|
+
".idea",
|
|
65
|
+
"*.swp",
|
|
66
|
+
"*.swo",
|
|
67
|
+
# Build artifacts
|
|
68
|
+
"dist",
|
|
69
|
+
"build",
|
|
70
|
+
"*.egg-info",
|
|
71
|
+
# Node (if present)
|
|
72
|
+
"node_modules",
|
|
73
|
+
# Misc
|
|
74
|
+
".DS_Store",
|
|
75
|
+
"Thumbs.db",
|
|
76
|
+
"*.log",
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass
|
|
81
|
+
class BuildContext:
|
|
82
|
+
"""Represents a packaged build context."""
|
|
83
|
+
|
|
84
|
+
tarball: bytes
|
|
85
|
+
context_hash: str
|
|
86
|
+
file_count: int
|
|
87
|
+
total_size: int
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class BuildStatus:
|
|
91
|
+
"""Build status constants."""
|
|
92
|
+
|
|
93
|
+
PENDING = "pending"
|
|
94
|
+
BUILDING = "building"
|
|
95
|
+
SUCCESS = "success"
|
|
96
|
+
FAILED = "failed"
|
|
97
|
+
TIMEOUT = "timeout"
|
|
98
|
+
|
|
99
|
+
TERMINAL_STATUSES = {SUCCESS, FAILED, TIMEOUT}
|
|
100
|
+
|
|
101
|
+
@classmethod
|
|
102
|
+
def is_terminal(cls, status: str) -> bool:
|
|
103
|
+
return status in cls.TERMINAL_STATUSES
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _should_exclude(path: Path, exclusions: Set[str], base_path: Path) -> bool:
|
|
107
|
+
"""
|
|
108
|
+
Check if a path should be excluded from the build context.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
path: Path to check
|
|
112
|
+
exclusions: Set of exclusion patterns
|
|
113
|
+
base_path: Base directory for relative path calculation
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
True if path should be excluded
|
|
117
|
+
"""
|
|
118
|
+
rel_path = path.relative_to(base_path)
|
|
119
|
+
path_parts = rel_path.parts
|
|
120
|
+
|
|
121
|
+
for pattern in exclusions:
|
|
122
|
+
# Check each part of the path against the pattern
|
|
123
|
+
for part in path_parts:
|
|
124
|
+
if fnmatch.fnmatch(part, pattern):
|
|
125
|
+
return True
|
|
126
|
+
# Check full relative path
|
|
127
|
+
if fnmatch.fnmatch(str(rel_path), pattern):
|
|
128
|
+
return True
|
|
129
|
+
|
|
130
|
+
return False
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def load_dockerignore(context_dir: Path) -> Optional[Set[str]]:
|
|
134
|
+
"""
|
|
135
|
+
Load patterns from .dockerignore file if it exists.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
context_dir: Directory containing .dockerignore
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
Set of patterns or None if file doesn't exist
|
|
142
|
+
"""
|
|
143
|
+
dockerignore_path = context_dir / ".dockerignore"
|
|
144
|
+
if not dockerignore_path.exists():
|
|
145
|
+
return None
|
|
146
|
+
|
|
147
|
+
patterns = set()
|
|
148
|
+
try:
|
|
149
|
+
with open(dockerignore_path, "r") as f:
|
|
150
|
+
for line in f:
|
|
151
|
+
line = line.strip()
|
|
152
|
+
# Skip comments and empty lines
|
|
153
|
+
if line and not line.startswith("#"):
|
|
154
|
+
patterns.add(line)
|
|
155
|
+
except Exception as e:
|
|
156
|
+
logger.warning(f"Failed to read .dockerignore: {e}")
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
return patterns
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def get_exclusions(context_dir: Path, extra_patterns: Optional[List[str]] = None) -> Set[str]:
|
|
163
|
+
"""
|
|
164
|
+
Get the set of exclusion patterns to use.
|
|
165
|
+
|
|
166
|
+
Priority:
|
|
167
|
+
1. .dockerignore (if exists, takes precedence)
|
|
168
|
+
2. Default exclusions + extra patterns
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
context_dir: Build context directory
|
|
172
|
+
extra_patterns: Additional patterns from config
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
Set of exclusion patterns
|
|
176
|
+
"""
|
|
177
|
+
dockerignore = load_dockerignore(context_dir)
|
|
178
|
+
if dockerignore is not None:
|
|
179
|
+
# .dockerignore takes precedence
|
|
180
|
+
return dockerignore
|
|
181
|
+
|
|
182
|
+
# Use defaults + extras
|
|
183
|
+
exclusions = DEFAULT_EXCLUSIONS.copy()
|
|
184
|
+
if extra_patterns:
|
|
185
|
+
exclusions.update(extra_patterns)
|
|
186
|
+
return exclusions
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def create_deterministic_tarball(
|
|
190
|
+
context_dir: str,
|
|
191
|
+
exclusions: Set[str],
|
|
192
|
+
dockerfile_path: str = "Dockerfile",
|
|
193
|
+
) -> BuildContext:
|
|
194
|
+
"""
|
|
195
|
+
Create a deterministic tarball from the build context directory.
|
|
196
|
+
|
|
197
|
+
Determinism is achieved by:
|
|
198
|
+
- Sorting files alphabetically
|
|
199
|
+
- Setting mtime to Unix epoch (0)
|
|
200
|
+
- Normalizing permissions (uid=0, gid=0)
|
|
201
|
+
- Using gzip with mtime=0
|
|
202
|
+
|
|
203
|
+
Args:
|
|
204
|
+
context_dir: Directory containing build context
|
|
205
|
+
exclusions: Set of patterns to exclude
|
|
206
|
+
dockerfile_path: Path to Dockerfile (verified to exist)
|
|
207
|
+
|
|
208
|
+
Returns:
|
|
209
|
+
BuildContext with tarball bytes and metadata
|
|
210
|
+
|
|
211
|
+
Raises:
|
|
212
|
+
FileNotFoundError: If context_dir or Dockerfile doesn't exist
|
|
213
|
+
ValueError: If context is too large (>500MB)
|
|
214
|
+
"""
|
|
215
|
+
base_path = Path(context_dir).resolve()
|
|
216
|
+
|
|
217
|
+
if not base_path.exists():
|
|
218
|
+
raise FileNotFoundError(f"Context directory not found: {context_dir}")
|
|
219
|
+
|
|
220
|
+
dockerfile_full = base_path / dockerfile_path
|
|
221
|
+
if not dockerfile_full.exists():
|
|
222
|
+
raise FileNotFoundError(f"Dockerfile not found: {dockerfile_full}")
|
|
223
|
+
|
|
224
|
+
# Collect all files, sorted alphabetically
|
|
225
|
+
files_to_add: List[Tuple[Path, str]] = []
|
|
226
|
+
|
|
227
|
+
for root, dirs, files in os.walk(base_path):
|
|
228
|
+
root_path = Path(root)
|
|
229
|
+
|
|
230
|
+
# Filter directories in-place to prevent descending into excluded dirs
|
|
231
|
+
dirs[:] = [
|
|
232
|
+
d for d in sorted(dirs) if not _should_exclude(root_path / d, exclusions, base_path)
|
|
233
|
+
]
|
|
234
|
+
|
|
235
|
+
for filename in sorted(files):
|
|
236
|
+
file_path = root_path / filename
|
|
237
|
+
if not _should_exclude(file_path, exclusions, base_path):
|
|
238
|
+
rel_path = file_path.relative_to(base_path)
|
|
239
|
+
files_to_add.append((file_path, str(rel_path)))
|
|
240
|
+
|
|
241
|
+
# Sort by relative path for determinism
|
|
242
|
+
files_to_add.sort(key=lambda x: x[1])
|
|
243
|
+
|
|
244
|
+
# Create tarball in memory
|
|
245
|
+
tar_buffer = io.BytesIO()
|
|
246
|
+
total_size = 0
|
|
247
|
+
|
|
248
|
+
with tarfile.open(fileobj=tar_buffer, mode="w") as tar:
|
|
249
|
+
for file_path, arc_name in files_to_add:
|
|
250
|
+
# Create TarInfo with normalized attributes
|
|
251
|
+
info = tarfile.TarInfo(name=arc_name)
|
|
252
|
+
info.size = file_path.stat().st_size
|
|
253
|
+
info.mtime = 0 # Unix epoch for determinism
|
|
254
|
+
info.uid = 0
|
|
255
|
+
info.gid = 0
|
|
256
|
+
info.uname = ""
|
|
257
|
+
info.gname = ""
|
|
258
|
+
# Normalize permissions: 0o644 for files, 0o755 for executables
|
|
259
|
+
if os.access(file_path, os.X_OK):
|
|
260
|
+
info.mode = 0o755
|
|
261
|
+
else:
|
|
262
|
+
info.mode = 0o644
|
|
263
|
+
|
|
264
|
+
with open(file_path, "rb") as f:
|
|
265
|
+
tar.addfile(info, f)
|
|
266
|
+
|
|
267
|
+
total_size += info.size
|
|
268
|
+
|
|
269
|
+
# Check size limit (500MB)
|
|
270
|
+
MAX_CONTEXT_SIZE = 500 * 1024 * 1024
|
|
271
|
+
if total_size > MAX_CONTEXT_SIZE:
|
|
272
|
+
raise ValueError(
|
|
273
|
+
f"Build context too large: {total_size / (1024 * 1024):.1f}MB "
|
|
274
|
+
f"(max {MAX_CONTEXT_SIZE / (1024 * 1024):.0f}MB)"
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
# Compress with gzip (mtime=0 for determinism)
|
|
278
|
+
tar_bytes = tar_buffer.getvalue()
|
|
279
|
+
gzip_buffer = io.BytesIO()
|
|
280
|
+
with gzip.GzipFile(fileobj=gzip_buffer, mode="wb", mtime=0) as gz:
|
|
281
|
+
gz.write(tar_bytes)
|
|
282
|
+
|
|
283
|
+
tarball = gzip_buffer.getvalue()
|
|
284
|
+
|
|
285
|
+
# Compute MD5 hash (first 16 hex chars to match server-side)
|
|
286
|
+
context_hash = hashlib.md5(tarball).hexdigest()[:16]
|
|
287
|
+
|
|
288
|
+
logger.debug(
|
|
289
|
+
f"Created tarball: {len(files_to_add)} files, "
|
|
290
|
+
f"{len(tarball)} bytes compressed, hash={context_hash}"
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
return BuildContext(
|
|
294
|
+
tarball=tarball,
|
|
295
|
+
context_hash=context_hash,
|
|
296
|
+
file_count=len(files_to_add),
|
|
297
|
+
total_size=total_size,
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
async def upload_to_s3(
|
|
302
|
+
tarball: bytes,
|
|
303
|
+
upload_url: str,
|
|
304
|
+
upload_fields: dict,
|
|
305
|
+
) -> bool:
|
|
306
|
+
"""
|
|
307
|
+
Upload tarball to S3 using presigned POST.
|
|
308
|
+
|
|
309
|
+
Args:
|
|
310
|
+
tarball: Compressed tarball bytes
|
|
311
|
+
upload_url: Presigned S3 URL
|
|
312
|
+
upload_fields: Fields for multipart form upload
|
|
313
|
+
|
|
314
|
+
Returns:
|
|
315
|
+
True on success, False on failure
|
|
316
|
+
"""
|
|
317
|
+
# Create multipart form data
|
|
318
|
+
data = aiohttp.FormData()
|
|
319
|
+
|
|
320
|
+
# Add all presigned fields first (order matters for S3)
|
|
321
|
+
for key, value in upload_fields.items():
|
|
322
|
+
data.add_field(key, value)
|
|
323
|
+
|
|
324
|
+
# S3 presigned POST requires Content-Type as a form field (not just multipart header)
|
|
325
|
+
# Add it if not already in upload_fields
|
|
326
|
+
if "Content-Type" not in upload_fields:
|
|
327
|
+
data.add_field("Content-Type", "application/gzip")
|
|
328
|
+
|
|
329
|
+
# Add file last (must be named "file" for S3 presigned POST)
|
|
330
|
+
data.add_field("file", tarball, filename="context.tar.gz", content_type="application/gzip")
|
|
331
|
+
|
|
332
|
+
try:
|
|
333
|
+
async with aiohttp.ClientSession() as session:
|
|
334
|
+
async with session.post(upload_url, data=data) as response:
|
|
335
|
+
if response.status in (200, 201, 204):
|
|
336
|
+
logger.debug(f"Upload successful: {response.status}")
|
|
337
|
+
return True
|
|
338
|
+
else:
|
|
339
|
+
body = await response.text()
|
|
340
|
+
logger.error(f"Upload failed: {response.status} - {body}")
|
|
341
|
+
return False
|
|
342
|
+
except Exception as e:
|
|
343
|
+
logger.error(f"Upload error: {e}")
|
|
344
|
+
return False
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
async def poll_build_status(
|
|
348
|
+
build_id: str,
|
|
349
|
+
org: str,
|
|
350
|
+
api_client,
|
|
351
|
+
poll_interval: float = 3.0,
|
|
352
|
+
max_duration: float = 600.0, # 10 minutes
|
|
353
|
+
status_callback: Optional[Callable[[dict], None]] = None,
|
|
354
|
+
) -> Tuple[bool, dict]:
|
|
355
|
+
"""
|
|
356
|
+
Poll build status until completion or timeout.
|
|
357
|
+
|
|
358
|
+
Args:
|
|
359
|
+
build_id: Build ID to poll
|
|
360
|
+
org: Organization ID
|
|
361
|
+
api_client: API client instance
|
|
362
|
+
poll_interval: Seconds between polls
|
|
363
|
+
max_duration: Maximum wait time in seconds
|
|
364
|
+
status_callback: Optional callback(build_data) on each poll
|
|
365
|
+
|
|
366
|
+
Returns:
|
|
367
|
+
Tuple of (success: bool, final_build_data: dict)
|
|
368
|
+
"""
|
|
369
|
+
start_time = time.time()
|
|
370
|
+
|
|
371
|
+
while True:
|
|
372
|
+
elapsed = time.time() - start_time
|
|
373
|
+
if elapsed > max_duration:
|
|
374
|
+
return False, {"status": "timeout", "error": "Polling timeout exceeded"}
|
|
375
|
+
|
|
376
|
+
data, error = await api_client.build_get(org=org, build_id=build_id)
|
|
377
|
+
|
|
378
|
+
if error:
|
|
379
|
+
return False, {"status": "error", "error": str(error)}
|
|
380
|
+
|
|
381
|
+
if not data:
|
|
382
|
+
return False, {"status": "error", "error": "Build not found"}
|
|
383
|
+
|
|
384
|
+
build = data.get("build", data)
|
|
385
|
+
status = build.get("status", "unknown")
|
|
386
|
+
|
|
387
|
+
if status_callback:
|
|
388
|
+
status_callback(build)
|
|
389
|
+
|
|
390
|
+
if BuildStatus.is_terminal(status):
|
|
391
|
+
return status == BuildStatus.SUCCESS, build
|
|
392
|
+
|
|
393
|
+
await asyncio.sleep(poll_interval)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def format_size(size_bytes: int) -> str:
|
|
397
|
+
"""Format byte size as human-readable string."""
|
|
398
|
+
if size_bytes < 1024:
|
|
399
|
+
return f"{size_bytes} B"
|
|
400
|
+
elif size_bytes < 1024 * 1024:
|
|
401
|
+
return f"{size_bytes / 1024:.1f} KB"
|
|
402
|
+
else:
|
|
403
|
+
return f"{size_bytes / (1024 * 1024):.1f} MB"
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import functools
|
|
8
8
|
import os
|
|
9
|
-
from typing import Callable, Optional
|
|
9
|
+
from typing import Callable, List, Optional
|
|
10
10
|
|
|
11
11
|
import toml
|
|
12
12
|
from attr import dataclass, field
|
|
@@ -74,10 +74,27 @@ class KrispVivaConfig:
|
|
|
74
74
|
return {"audio_filter": self.audio_filter}
|
|
75
75
|
|
|
76
76
|
|
|
77
|
+
@dataclass
|
|
78
|
+
class BuildConfig:
|
|
79
|
+
"""Configuration for cloud builds."""
|
|
80
|
+
|
|
81
|
+
context_dir: str = "."
|
|
82
|
+
dockerfile: str = "Dockerfile"
|
|
83
|
+
exclude_patterns: List[str] = field(factory=list)
|
|
84
|
+
|
|
85
|
+
def to_dict(self):
|
|
86
|
+
return {
|
|
87
|
+
"context_dir": self.context_dir,
|
|
88
|
+
"dockerfile": self.dockerfile,
|
|
89
|
+
"exclude_patterns": self.exclude_patterns,
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
|
|
77
93
|
@dataclass
|
|
78
94
|
class DeployConfigParams:
|
|
79
95
|
agent_name: Optional[str] = None
|
|
80
96
|
image: Optional[str] = None
|
|
97
|
+
build_id: Optional[str] = None # For cloud builds
|
|
81
98
|
image_credentials: Optional[str] = None
|
|
82
99
|
secret_set: Optional[str] = None
|
|
83
100
|
region: Optional[str] = None
|
|
@@ -85,17 +102,22 @@ class DeployConfigParams:
|
|
|
85
102
|
enable_krisp: bool = False
|
|
86
103
|
enable_managed_keys: bool = False
|
|
87
104
|
docker_config: dict = field(factory=dict)
|
|
105
|
+
build_config: BuildConfig = field(factory=BuildConfig) # Cloud build configuration
|
|
88
106
|
agent_profile: Optional[str] = None
|
|
89
107
|
krisp_viva: KrispVivaConfig = field(factory=KrispVivaConfig)
|
|
90
108
|
|
|
91
109
|
def __attrs_post_init__(self):
|
|
92
110
|
if self.image is not None and ":" not in self.image:
|
|
93
111
|
raise ValueError("Provided image must include tag e.g. my-image:latest")
|
|
112
|
+
# Cannot specify both image and build_id
|
|
113
|
+
if self.image is not None and self.build_id is not None:
|
|
114
|
+
raise ValueError("Cannot specify both 'image' and 'build_id'")
|
|
94
115
|
|
|
95
116
|
def to_dict(self):
|
|
96
117
|
return {
|
|
97
118
|
"agent_name": self.agent_name,
|
|
98
119
|
"image": self.image,
|
|
120
|
+
"build_id": self.build_id,
|
|
99
121
|
"image_credentials": self.image_credentials,
|
|
100
122
|
"secret_set": self.secret_set,
|
|
101
123
|
"region": self.region,
|
|
@@ -103,6 +125,7 @@ class DeployConfigParams:
|
|
|
103
125
|
"enable_krisp": self.enable_krisp,
|
|
104
126
|
"enable_managed_keys": self.enable_managed_keys,
|
|
105
127
|
"docker_config": self.docker_config,
|
|
128
|
+
"build_config": self.build_config.to_dict() if self.build_config else None,
|
|
106
129
|
"agent_profile": self.agent_profile,
|
|
107
130
|
"krisp_viva": self.krisp_viva.to_dict() if self.krisp_viva else None,
|
|
108
131
|
}
|
|
@@ -132,11 +155,21 @@ def load_deploy_config_file() -> Optional[DeployConfigParams]:
|
|
|
132
155
|
krisp_viva_data = config_data.pop("krisp_viva", {})
|
|
133
156
|
krisp_viva_config = KrispVivaConfig(**krisp_viva_data)
|
|
134
157
|
|
|
158
|
+
# Extract build configuration if present
|
|
159
|
+
build_data = config_data.pop("build", {})
|
|
160
|
+
exclude_data = build_data.pop("exclude", {})
|
|
161
|
+
build_config = BuildConfig(
|
|
162
|
+
context_dir=build_data.get("context_dir", "."),
|
|
163
|
+
dockerfile=build_data.get("dockerfile", "Dockerfile"),
|
|
164
|
+
exclude_patterns=exclude_data.get("patterns", []),
|
|
165
|
+
)
|
|
166
|
+
|
|
135
167
|
# Create DeployConfigParams with validated data
|
|
136
168
|
validated_config = DeployConfigParams(
|
|
137
169
|
**config_data,
|
|
138
170
|
scaling=scaling_params,
|
|
139
171
|
docker_config=docker_data,
|
|
172
|
+
build_config=build_config,
|
|
140
173
|
krisp_viva=krisp_viva_config,
|
|
141
174
|
)
|
|
142
175
|
|
|
@@ -144,6 +177,7 @@ def load_deploy_config_file() -> Optional[DeployConfigParams]:
|
|
|
144
177
|
expected_keys = {
|
|
145
178
|
"agent_name",
|
|
146
179
|
"image",
|
|
180
|
+
"build_id",
|
|
147
181
|
"image_credentials",
|
|
148
182
|
"secret_set",
|
|
149
183
|
"region",
|
|
@@ -151,6 +185,7 @@ def load_deploy_config_file() -> Optional[DeployConfigParams]:
|
|
|
151
185
|
"enable_krisp",
|
|
152
186
|
"enable_managed_keys",
|
|
153
187
|
"docker",
|
|
188
|
+
"build",
|
|
154
189
|
"agent_profile",
|
|
155
190
|
"krisp_viva",
|
|
156
191
|
}
|