Skip to content

Commit 5beb026

Browse files
author
Datata1
committed
feat(pipeline-stream): add log stream
1 parent 1ac7c66 commit 5beb026

File tree

13 files changed

+1283
-44
lines changed

13 files changed

+1283
-44
lines changed

examples/create_workspace_with_landscape.py

Lines changed: 79 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,61 +1,98 @@
1+
"""
2+
Demo: Create a workspace, deploy a landscape profile, and stream logs.
3+
"""
4+
15
import asyncio
26
import time
37

48
from codesphere import CodesphereSDK
59
from codesphere.resources.workspace import WorkspaceCreate
6-
from codesphere.resources.workspace.landscape import ProfileBuilder, ProfileConfig
10+
from codesphere.resources.workspace.landscape import (
11+
PipelineStage,
12+
PipelineState,
13+
ProfileBuilder,
14+
)
15+
from codesphere.resources.workspace.logs import LogStage
716

8-
TEAM_ID = 123 # Replace with your actual team ID
17+
TEAM_ID = 35698
918

1019

11-
async def get_plan_id(sdk: CodesphereSDK, plan_name: str = "Micro") -> int:
12-
plans = await sdk.metadata.list_plans()
13-
plan = next((p for p in plans if p.title == plan_name and not p.deprecated), None)
14-
if not plan:
15-
raise ValueError(f"Plan '{plan_name}' not found")
16-
return plan.id
20+
async def main():
21+
async with CodesphereSDK() as sdk:
22+
plans = await sdk.metadata.list_plans()
23+
plan = next((p for p in plans if p.title == "Micro" and not p.deprecated), None)
24+
if not plan:
25+
raise ValueError("Micro plan not found")
1726

27+
workspace_name = f"pipeline-demo-{int(time.time())}"
28+
print(f"Creating workspace '{workspace_name}'...")
1829

19-
def build_web_profile(plan_id: int) -> ProfileConfig:
20-
"""Build a simple web service landscape profile."""
21-
return (
22-
ProfileBuilder()
23-
.prepare()
24-
.add_step("npm install", name="Install dependencies")
25-
.done()
26-
.add_reactive_service("web")
27-
.plan(plan_id)
28-
.add_step("npm start")
29-
.add_port(3000, public=True)
30-
.add_path("/", port=3000)
31-
.replicas(1)
32-
.env("NODE_ENV", "production")
33-
.build()
34-
)
30+
workspace = await sdk.workspaces.create(
31+
WorkspaceCreate(plan_id=plan.id, team_id=TEAM_ID, name=workspace_name)
32+
)
33+
print(f"✓ Workspace created (ID: {workspace.id})")
3534

35+
print("Waiting for workspace to start...")
36+
await workspace.wait_until_running(timeout=300.0, poll_interval=5.0)
37+
print("✓ Workspace is running\n")
3638

37-
async def create_workspace(sdk: CodesphereSDK, plan_id: int, name: str):
38-
workspace = await sdk.workspaces.create(
39-
WorkspaceCreate(plan_id=plan_id, team_id=TEAM_ID, name=name)
40-
)
41-
await workspace.wait_until_running(timeout=300.0, poll_interval=5.0)
42-
return workspace
39+
profile = (
40+
ProfileBuilder()
41+
.prepare()
42+
.add_step("echo 'Installing dependencies...' && sleep 2")
43+
.add_step("echo 'Setup complete!' && sleep 1")
44+
.done()
45+
.add_reactive_service("web")
46+
.plan(plan.id)
47+
.add_step(
48+
'for i in $(seq 1 50); do echo "[$i] Processing request..."; sleep 1; done'
49+
)
50+
.add_port(3000, public=True)
51+
.add_path("/", port=3000)
52+
.replicas(1)
53+
.done()
54+
.build()
55+
)
4356

57+
print("Deploying landscape profile...")
58+
await workspace.landscape.save_profile("production", profile)
59+
await workspace.landscape.deploy(profile="production")
60+
print("✓ Profile deployed\n")
4461

45-
async def deploy_landscape(workspace, profile: dict, profile_name: str = "production"):
46-
await workspace.landscape.save_profile(profile_name, profile)
47-
await workspace.landscape.deploy(profile=profile_name)
48-
print("Deployment started!")
62+
print("--- Prepare Stage ---")
63+
await workspace.landscape.start_stage(
64+
PipelineStage.PREPARE, profile="production"
65+
)
66+
prepare_status = await workspace.landscape.wait_for_stage(
67+
PipelineStage.PREPARE, timeout=60.0
68+
)
4969

70+
for status in prepare_status:
71+
icon = "✓" if status.state == PipelineState.SUCCESS else "✗"
72+
print(f"{icon} {status.server}: {status.state.value}")
5073

51-
async def main():
52-
async with CodesphereSDK() as sdk:
53-
plan_id = await get_plan_id(sdk)
54-
workspace = await create_workspace(
55-
sdk, plan_id, f"landscape-demo-{int(time.time())}"
56-
)
57-
profile = build_web_profile(plan_id)
58-
await deploy_landscape(workspace, profile)
74+
print("\nPrepare logs:")
75+
for step in range(len(prepare_status[0].steps)):
76+
logs = await workspace.logs.collect(
77+
stage=LogStage.PREPARE, step=step, timeout=5.0
78+
)
79+
for entry in logs:
80+
if entry.get_text():
81+
print(f" {entry.get_text().strip()}")
82+
83+
print("\n--- Run Stage ---")
84+
await workspace.landscape.start_stage(PipelineStage.RUN, profile="production")
85+
print("Started run stage, waiting for logs...\n")
86+
87+
print("Streaming logs from 'web' service:")
88+
count = 0
89+
async for entry in workspace.logs.stream_server(step=0, server="web"):
90+
if entry.get_text():
91+
print(f" {entry.get_text().strip()}")
92+
count += 1
93+
94+
print(f"\n✓ Stream ended ({count} log entries)")
95+
print(f"✓ Workspace {workspace.id} is still running.")
5996

6097

6198
if __name__ == "__main__":

src/codesphere/resources/workspace/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from .git import GitHead, WorkspaceGitManager
2+
from .logs import LogEntry, LogProblem, LogStage, WorkspaceLogManager
23
from .resources import WorkspacesResource
34
from .schemas import (
45
CommandInput,
@@ -19,4 +20,8 @@
1920
"CommandOutput",
2021
"WorkspaceGitManager",
2122
"GitHead",
23+
"WorkspaceLogManager",
24+
"LogEntry",
25+
"LogProblem",
26+
"LogStage",
2227
]

src/codesphere/resources/workspace/landscape/__init__.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@
44
ManagedServiceConfig,
55
NetworkConfig,
66
PathConfig,
7+
PipelineStage,
8+
PipelineState,
9+
PipelineStatus,
10+
PipelineStatusList,
711
PortConfig,
812
Profile,
913
ProfileBuilder,
@@ -12,6 +16,7 @@
1216
ReactiveServiceConfig,
1317
StageConfig,
1418
Step,
19+
StepStatus,
1520
)
1621

1722
__all__ = [
@@ -28,4 +33,9 @@
2833
"NetworkConfig",
2934
"PortConfig",
3035
"PathConfig",
36+
"PipelineStage",
37+
"PipelineState",
38+
"PipelineStatus",
39+
"PipelineStatusList",
40+
"StepStatus",
3141
]

src/codesphere/resources/workspace/landscape/models.py

Lines changed: 164 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import asyncio
34
import logging
45
import re
56
from typing import TYPE_CHECKING, Dict, List, Optional, Union
@@ -10,10 +11,20 @@
1011
from .operations import (
1112
_DEPLOY_OP,
1213
_DEPLOY_WITH_PROFILE_OP,
14+
_GET_PIPELINE_STATUS_OP,
1315
_SCALE_OP,
16+
_START_PIPELINE_STAGE_OP,
17+
_START_PIPELINE_STAGE_WITH_PROFILE_OP,
18+
_STOP_PIPELINE_STAGE_OP,
1419
_TEARDOWN_OP,
1520
)
16-
from .schemas import Profile, ProfileConfig
21+
from .schemas import (
22+
PipelineStage,
23+
PipelineState,
24+
PipelineStatusList,
25+
Profile,
26+
ProfileConfig,
27+
)
1728

1829
if TYPE_CHECKING:
1930
from ..schemas import CommandOutput
@@ -95,3 +106,155 @@ async def teardown(self) -> None:
95106

96107
async def scale(self, services: Dict[str, int]) -> None:
97108
await self._execute_operation(_SCALE_OP, data=services)
109+
110+
# Pipeline operations
111+
112+
async def start_stage(
113+
self,
114+
stage: Union[PipelineStage, str],
115+
profile: Optional[str] = None,
116+
) -> None:
117+
"""Start a pipeline stage.
118+
119+
Args:
120+
stage: The pipeline stage to start ('prepare', 'test', or 'run').
121+
profile: Optional profile name. If provided, starts the stage with
122+
that profile. Required for first run after deploy.
123+
124+
Raises:
125+
ValidationError: If the workspace is not running or parameters are invalid.
126+
NotFoundError: If the workspace is not found.
127+
"""
128+
if isinstance(stage, PipelineStage):
129+
stage = stage.value
130+
131+
if profile is not None:
132+
_validate_profile_name(profile)
133+
await self._execute_operation(
134+
_START_PIPELINE_STAGE_WITH_PROFILE_OP, stage=stage, profile=profile
135+
)
136+
else:
137+
await self._execute_operation(_START_PIPELINE_STAGE_OP, stage=stage)
138+
139+
async def stop_stage(self, stage: Union[PipelineStage, str]) -> None:
140+
"""Stop a pipeline stage.
141+
142+
Args:
143+
stage: The pipeline stage to stop ('prepare', 'test', or 'run').
144+
145+
Raises:
146+
ValidationError: If the workspace is not running or parameters are invalid.
147+
NotFoundError: If the workspace is not found.
148+
"""
149+
if isinstance(stage, PipelineStage):
150+
stage = stage.value
151+
152+
await self._execute_operation(_STOP_PIPELINE_STAGE_OP, stage=stage)
153+
154+
async def get_stage_status(
155+
self, stage: Union[PipelineStage, str]
156+
) -> PipelineStatusList:
157+
"""Get the status of a pipeline stage.
158+
159+
Args:
160+
stage: The pipeline stage to get status for ('prepare', 'test', or 'run').
161+
162+
Returns:
163+
List of PipelineStatus objects, one per replica/server.
164+
165+
Raises:
166+
ValidationError: If the workspace is not running or parameters are invalid.
167+
NotFoundError: If the workspace is not found.
168+
"""
169+
if isinstance(stage, PipelineStage):
170+
stage = stage.value
171+
172+
return await self._execute_operation(_GET_PIPELINE_STATUS_OP, stage=stage)
173+
174+
async def wait_for_stage(
175+
self,
176+
stage: Union[PipelineStage, str],
177+
*,
178+
timeout: float = 300.0,
179+
poll_interval: float = 5.0,
180+
server: Optional[str] = None,
181+
) -> PipelineStatusList:
182+
"""Wait for a pipeline stage to complete (success or failure).
183+
184+
Args:
185+
stage: The pipeline stage to wait for.
186+
timeout: Maximum time to wait in seconds (default: 300).
187+
poll_interval: Time between status checks in seconds (default: 5).
188+
server: Optional server name to filter by. If None, waits for all
189+
servers that have steps defined for this stage.
190+
191+
Returns:
192+
Final PipelineStatusList when stage completes.
193+
194+
Raises:
195+
TimeoutError: If the stage doesn't complete within the timeout.
196+
ValidationError: If the workspace is not running.
197+
"""
198+
if poll_interval <= 0:
199+
raise ValueError("poll_interval must be greater than 0")
200+
201+
stage_name = stage.value if isinstance(stage, PipelineStage) else stage
202+
elapsed = 0.0
203+
204+
while elapsed < timeout:
205+
status_list = await self.get_stage_status(stage)
206+
207+
# Filter to relevant servers for THIS stage
208+
# A server is relevant for this stage if:
209+
# - It has steps defined (meaning it participates in this stage)
210+
# - OR it's not in 'waiting' state (meaning it has started)
211+
relevant_statuses = []
212+
for s in status_list:
213+
if server is not None:
214+
# Filter by specific server
215+
if s.server == server:
216+
relevant_statuses.append(s)
217+
else:
218+
# Include servers that have steps for this stage
219+
# Servers with no steps and waiting state don't participate in this stage
220+
if s.steps:
221+
relevant_statuses.append(s)
222+
elif s.state != PipelineState.WAITING:
223+
# Started but no steps visible yet
224+
relevant_statuses.append(s)
225+
226+
# If no relevant statuses yet, keep waiting
227+
if not relevant_statuses:
228+
log.debug(
229+
"Pipeline stage '%s': no servers with steps yet, waiting...",
230+
stage_name,
231+
)
232+
await asyncio.sleep(poll_interval)
233+
elapsed += poll_interval
234+
continue
235+
236+
# Check if all relevant servers have completed
237+
all_completed = all(
238+
s.state
239+
in (PipelineState.SUCCESS, PipelineState.FAILURE, PipelineState.ABORTED)
240+
for s in relevant_statuses
241+
)
242+
243+
if all_completed:
244+
log.debug("Pipeline stage '%s' completed.", stage_name)
245+
return PipelineStatusList(root=relevant_statuses)
246+
247+
# Log current state
248+
states = [f"{s.server}={s.state.value}" for s in relevant_statuses]
249+
log.debug(
250+
"Pipeline stage '%s' status: %s (elapsed: %.1fs)",
251+
stage_name,
252+
", ".join(states),
253+
elapsed,
254+
)
255+
await asyncio.sleep(poll_interval)
256+
elapsed += poll_interval
257+
258+
raise TimeoutError(
259+
f"Pipeline stage '{stage_name}' did not complete within {timeout} seconds."
260+
)

0 commit comments

Comments
 (0)