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