Skip to content

Commit d7dea84

Browse files
authored
feat(landscape): add landscape resource (#41)
* feat(landscape): add landscape resource
1 parent cbce32e commit d7dea84

File tree

11 files changed

+1887
-10
lines changed

11 files changed

+1887
-10
lines changed
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import asyncio
2+
import time
3+
4+
from codesphere import CodesphereSDK
5+
from codesphere.resources.workspace import WorkspaceCreate
6+
from codesphere.resources.workspace.landscape import ProfileBuilder, ProfileConfig
7+
8+
TEAM_ID = 123 # Replace with your actual team ID
9+
10+
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
17+
18+
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+
)
35+
36+
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
43+
44+
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!")
49+
50+
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)
59+
60+
61+
if __name__ == "__main__":
62+
asyncio.run(main())

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

Whitespace-only changes.

src/codesphere/resources/workspace/git/schema.py

Whitespace-only changes.
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
from .models import WorkspaceLandscapeManager
2+
from .schemas import (
3+
ManagedServiceBuilder,
4+
ManagedServiceConfig,
5+
NetworkConfig,
6+
PathConfig,
7+
PortConfig,
8+
Profile,
9+
ProfileBuilder,
10+
ProfileConfig,
11+
ReactiveServiceBuilder,
12+
ReactiveServiceConfig,
13+
StageConfig,
14+
Step,
15+
)
16+
17+
__all__ = [
18+
"WorkspaceLandscapeManager",
19+
"Profile",
20+
"ProfileBuilder",
21+
"ProfileConfig",
22+
"Step",
23+
"StageConfig",
24+
"ReactiveServiceConfig",
25+
"ReactiveServiceBuilder",
26+
"ManagedServiceConfig",
27+
"ManagedServiceBuilder",
28+
"NetworkConfig",
29+
"PortConfig",
30+
"PathConfig",
31+
]
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
from __future__ import annotations
2+
3+
import logging
4+
import re
5+
from typing import TYPE_CHECKING, Dict, List, Optional, Union
6+
7+
from ....core.base import ResourceList
8+
from ....core.handler import _APIOperationExecutor
9+
from ....http_client import APIHttpClient
10+
from .operations import (
11+
_DEPLOY_OP,
12+
_DEPLOY_WITH_PROFILE_OP,
13+
_SCALE_OP,
14+
_TEARDOWN_OP,
15+
)
16+
from .schemas import Profile, ProfileConfig
17+
18+
if TYPE_CHECKING:
19+
from ..schemas import CommandOutput
20+
21+
log = logging.getLogger(__name__)
22+
23+
# Regex pattern to match ci.<profile>.yml files
24+
_PROFILE_FILE_PATTERN = re.compile(r"^ci\.([A-Za-z0-9_-]+)\.yml$")
25+
# Pattern for valid profile names
26+
_VALID_PROFILE_NAME = re.compile(r"^[A-Za-z0-9_-]+$")
27+
28+
29+
def _validate_profile_name(name: str) -> None:
30+
if not _VALID_PROFILE_NAME.match(name):
31+
raise ValueError(
32+
f"Invalid profile name '{name}'. Must match pattern ^[A-Za-z0-9_-]+$"
33+
)
34+
35+
36+
def _profile_filename(name: str) -> str:
37+
_validate_profile_name(name)
38+
return f"ci.{name}.yml"
39+
40+
41+
class WorkspaceLandscapeManager(_APIOperationExecutor):
42+
def __init__(self, http_client: APIHttpClient, workspace_id: int):
43+
self._http_client = http_client
44+
self._workspace_id = workspace_id
45+
self.id = workspace_id
46+
47+
async def _run_command(self, command: str) -> "CommandOutput":
48+
from ..operations import _EXECUTE_COMMAND_OP
49+
from ..schemas import CommandInput
50+
51+
return await self._execute_operation(
52+
_EXECUTE_COMMAND_OP, data=CommandInput(command=command)
53+
)
54+
55+
async def list_profiles(self) -> ResourceList[Profile]:
56+
result = await self._run_command("ls -1 *.yml 2>/dev/null || true")
57+
58+
profiles: List[Profile] = []
59+
if result.output:
60+
for line in result.output.strip().split("\n"):
61+
if match := _PROFILE_FILE_PATTERN.match(line.strip()):
62+
profiles.append(Profile(name=match.group(1)))
63+
64+
return ResourceList[Profile](root=profiles)
65+
66+
async def save_profile(self, name: str, config: Union[ProfileConfig, str]) -> None:
67+
filename = _profile_filename(name)
68+
69+
if isinstance(config, ProfileConfig):
70+
yaml_content = config.to_yaml()
71+
else:
72+
yaml_content = config
73+
74+
body = yaml_content if yaml_content.endswith("\n") else yaml_content + "\n"
75+
await self._run_command(
76+
f"cat > {filename} << 'PROFILE_EOF'\n{body}PROFILE_EOF\n"
77+
)
78+
79+
async def get_profile(self, name: str) -> str:
80+
result = await self._run_command(f"cat {_profile_filename(name)}")
81+
return result.output
82+
83+
async def delete_profile(self, name: str) -> None:
84+
await self._run_command(f"rm -f {_profile_filename(name)}")
85+
86+
async def deploy(self, profile: Optional[str] = None) -> None:
87+
if profile is not None:
88+
_validate_profile_name(profile)
89+
await self._execute_operation(_DEPLOY_WITH_PROFILE_OP, profile=profile)
90+
else:
91+
await self._execute_operation(_DEPLOY_OP)
92+
93+
async def teardown(self) -> None:
94+
await self._execute_operation(_TEARDOWN_OP)
95+
96+
async def scale(self, services: Dict[str, int]) -> None:
97+
await self._execute_operation(_SCALE_OP, data=services)
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
from ....core.operations import APIOperation
2+
3+
_DEPLOY_OP = APIOperation(
4+
method="POST",
5+
endpoint_template="/workspaces/{id}/landscape/deploy",
6+
response_model=type(None),
7+
)
8+
9+
_DEPLOY_WITH_PROFILE_OP = APIOperation(
10+
method="POST",
11+
endpoint_template="/workspaces/{id}/landscape/deploy/{profile}",
12+
response_model=type(None),
13+
)
14+
15+
_TEARDOWN_OP = APIOperation(
16+
method="DELETE",
17+
endpoint_template="/workspaces/{id}/landscape/teardown",
18+
response_model=type(None),
19+
)
20+
21+
_SCALE_OP = APIOperation(
22+
method="PATCH",
23+
endpoint_template="/workspaces/{id}/landscape/scale",
24+
response_model=type(None),
25+
)

0 commit comments

Comments
 (0)