Skip to content

Commit d400b3f

Browse files
authoredFeb 18, 2025··
feat(auth): Add SCM authentication support for repository access (#44)
1 parent d55ec7f commit d400b3f

12 files changed

+269
-126
lines changed
 

Diff for: ‎examples/anthropic_tool_use.py

+12-15
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
#!/usr/bin/env python
1+
# export ANTHROPIC_API_KEY=...
2+
# python -m examples.anthropic_tool_use
23

34
from __future__ import annotations
45

@@ -11,6 +12,8 @@
1112
from gitpod import AsyncGitpod
1213
from gitpod.types.environment_initializer_param import Spec
1314

15+
from .scm_auth import verify_context_url # type: ignore
16+
1417
gpclient = AsyncGitpod()
1518
llmclient = Anthropic()
1619

@@ -41,8 +44,7 @@ async def create_environment(args: dict[str, str], cleanup: util.Disposables) ->
4144
env_class = await util.find_most_used_environment_class(gpclient)
4245
if not env_class:
4346
raise Exception("No environment class found. Please create one first.")
44-
env_class_id = env_class.id
45-
assert env_class_id is not None
47+
await verify_context_url(gpclient, args["context_url"], env_class.runner_id)
4648

4749
environment = (await gpclient.environments.create(
4850
spec={
@@ -54,18 +56,15 @@ async def create_environment(args: dict[str, str], cleanup: util.Disposables) ->
5456
}
5557
)]},
5658
},
57-
"machine": {"class": env_class_id},
59+
"machine": {"class": env_class.id},
5860
}
5961
)).environment
60-
assert environment is not None
61-
environment_id = environment.id
62-
assert environment_id is not None
63-
cleanup.add(lambda: asyncio.run(gpclient.environments.delete(environment_id=environment_id)))
62+
cleanup.adda(lambda: gpclient.environments.delete(environment_id=environment.id))
6463

65-
print(f"\nCreated environment: {environment_id} - waiting for it to be ready...")
66-
await util.wait_for_environment_ready(gpclient, environment_id)
67-
print(f"\nEnvironment is ready: {environment_id}")
68-
return environment_id
64+
print(f"\nCreated environment: {environment.id} - waiting for it to be ready...")
65+
await util.wait_for_environment_running(gpclient, environment.id)
66+
print(f"\nEnvironment is ready: {environment.id}")
67+
return environment.id
6968

7069
async def execute_command(args: dict[str, str]) -> str:
7170
lines_iter = await util.run_command(gpclient, args["environment_id"], args["command"])
@@ -135,6 +134,4 @@ async def main(cleanup: util.Disposables) -> None:
135134

136135
if __name__ == "__main__":
137136
import asyncio
138-
disposables = util.Disposables()
139-
with disposables:
140-
asyncio.run(main(disposables))
137+
asyncio.run(util.with_disposables(main))

Diff for: ‎examples/fs_access.py

+10-16
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
#!/usr/bin/env python
2-
31
import sys
42
import asyncio
53
from io import StringIO
@@ -11,10 +9,12 @@
119
from gitpod.types.environment_spec_param import EnvironmentSpecParam
1210
from gitpod.types.environment_initializer_param import Spec
1311

12+
from .scm_auth import verify_context_url # type: ignore
13+
1414

1515
# Examples:
16-
# - ./examples/fs_access.py
17-
# - ./examples/fs_access.py https://github.com/gitpod-io/empty
16+
# - python -m examples.fs_access
17+
# - python -m examples.fs_access https://github.com/gitpod-io/empty
1818
async def main(cleanup: util.Disposables) -> None:
1919
client = AsyncGitpod()
2020

@@ -25,8 +25,6 @@ async def main(cleanup: util.Disposables) -> None:
2525
print("Error: No environment class found. Please create one first.")
2626
sys.exit(1)
2727
print(f"Found environment class: {env_class.display_name} ({env_class.description})")
28-
env_class_id = env_class.id
29-
assert env_class_id is not None
3028

3129
print("Generating SSH key pair")
3230
key = paramiko.RSAKey.generate(2048)
@@ -39,13 +37,14 @@ async def main(cleanup: util.Disposables) -> None:
3937
key_id = "fs-access-example"
4038
spec: EnvironmentSpecParam = {
4139
"desired_phase": "ENVIRONMENT_PHASE_RUNNING",
42-
"machine": {"class": env_class_id},
40+
"machine": {"class": env_class.id},
4341
"ssh_public_keys": [{
4442
"id": key_id,
4543
"value": public_key
4644
}]
4745
}
4846
if context_url:
47+
await verify_context_url(client, context_url, env_class.runner_id)
4948
spec["content"] = {
5049
"initializer": {"specs": [Spec(
5150
context_url={
@@ -56,13 +55,10 @@ async def main(cleanup: util.Disposables) -> None:
5655

5756
print("Creating environment")
5857
environment = (await client.environments.create(spec=spec)).environment
59-
assert environment is not None
60-
environment_id = environment.id
61-
assert environment_id is not None
62-
cleanup.add(lambda: asyncio.run(client.environments.delete(environment_id=environment_id)))
58+
cleanup.adda(lambda: client.environments.delete(environment_id=environment.id))
6359

64-
env = util.EnvironmentState(client, environment_id)
65-
cleanup.add(lambda: asyncio.run(env.close()))
60+
env = util.EnvironmentState(client, environment.id)
61+
cleanup.adda(lambda: env.close())
6662

6763
print("Waiting for environment to be running")
6864
await env.wait_until_running()
@@ -104,6 +100,4 @@ async def main(cleanup: util.Disposables) -> None:
104100
print(f"File content: {content.decode()}")
105101

106102
if __name__ == "__main__":
107-
disposables = util.Disposables()
108-
with disposables:
109-
asyncio.run(main(disposables))
103+
asyncio.run(util.with_disposables(main))

Diff for: ‎examples/run_command.py

+11-17
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
#!/usr/bin/env python
2-
31
import sys
42
import asyncio
53

@@ -8,10 +6,12 @@
86
from gitpod.types.environment_spec_param import EnvironmentSpecParam
97
from gitpod.types.environment_initializer_param import Spec
108

9+
from .scm_auth import verify_context_url # type: ignore
10+
1111

1212
# Examples:
13-
# - ./examples/run_command.py 'echo "Hello World!"'
14-
# - ./examples/run_command.py 'echo "Hello World!"' https://github.com/gitpod-io/empty
13+
# - python -m examples.run_command 'echo "Hello World!"'
14+
# - python -m examples.run_command 'echo "Hello World!"' https://github.com/gitpod-io/empty
1515
async def main(cleanup: util.Disposables) -> None:
1616
client = AsyncGitpod()
1717

@@ -27,14 +27,13 @@ async def main(cleanup: util.Disposables) -> None:
2727
print("Error: No environment class found. Please create one first.")
2828
sys.exit(1)
2929
print(f"Found environment class: {env_class.display_name} ({env_class.description})")
30-
env_class_id = env_class.id
31-
assert env_class_id is not None
32-
30+
3331
spec: EnvironmentSpecParam = {
3432
"desired_phase": "ENVIRONMENT_PHASE_RUNNING",
35-
"machine": {"class": env_class_id},
33+
"machine": {"class": env_class.id},
3634
}
3735
if context_url:
36+
await verify_context_url(client, context_url, env_class.runner_id)
3837
spec["content"] = {
3938
"initializer": {"specs": [Spec(
4039
context_url={
@@ -45,20 +44,15 @@ async def main(cleanup: util.Disposables) -> None:
4544

4645
print("Creating environment")
4746
environment = (await client.environments.create(spec=spec)).environment
48-
assert environment is not None
49-
environment_id = environment.id
50-
assert environment_id is not None
51-
cleanup.add(lambda: asyncio.run(client.environments.delete(environment_id=environment_id)))
47+
cleanup.adda(lambda: client.environments.delete(environment_id=environment.id))
5248

5349
print("Waiting for environment to be ready")
54-
await util.wait_for_environment_ready(client, environment_id)
50+
await util.wait_for_environment_running(client, environment.id)
5551

5652
print("Running command")
57-
lines = await util.run_command(client, environment_id, command)
53+
lines = await util.run_command(client, environment.id, command)
5854
async for line in lines:
5955
print(line)
6056

6157
if __name__ == "__main__":
62-
disposables = util.Disposables()
63-
with disposables:
64-
asyncio.run(main(disposables))
58+
asyncio.run(util.with_disposables(main))

Diff for: ‎examples/run_service.py

+17-24
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
#!/usr/bin/env python
2-
31
import sys
42
import asyncio
53

@@ -8,56 +6,53 @@
86
from gitpod.types.environment_spec_param import EnvironmentSpecParam
97
from gitpod.types.environment_initializer_param import Spec
108

9+
from .scm_auth import verify_context_url # type: ignore
10+
1111

1212
# Examples:
13-
# - ./examples/run_service.py
14-
# - ./examples/run_service.py https://github.com/gitpod-io/empty
13+
# - python -m examples.run_service
14+
# - python -m examples.run_service https://github.com/gitpod-io/empty
1515
async def main(cleanup: util.Disposables) -> None:
1616
client = AsyncGitpod()
1717

1818
context_url = sys.argv[1] if len(sys.argv) > 1 else None
19-
19+
2020
env_class = await util.find_most_used_environment_class(client)
2121
if not env_class:
2222
print("Error: No environment class found. Please create one first.")
2323
sys.exit(1)
2424
print(f"Found environment class: {env_class.display_name} ({env_class.description})")
25-
env_class_id = env_class.id
26-
assert env_class_id is not None
2725

2826
port = 8888
2927
spec: EnvironmentSpecParam = {
3028
"desired_phase": "ENVIRONMENT_PHASE_RUNNING",
31-
"machine": {"class": env_class_id},
29+
"machine": {"class": env_class.id},
3230
"ports": [{
3331
"name": "Lama Service",
3432
"port": port,
3533
"admission": "ADMISSION_LEVEL_EVERYONE"
3634
}]
3735
}
3836
if context_url:
37+
await verify_context_url(client, context_url, env_class.runner_id)
3938
spec["content"] = {
4039
"initializer": {"specs": [Spec(
41-
context_url={
42-
"url": context_url
43-
}
44-
)]}
45-
}
40+
context_url={
41+
"url": context_url
42+
}
43+
)]}
44+
}
4645

47-
print("Creating environment")
4846
environment = (await client.environments.create(spec=spec)).environment
49-
assert environment is not None
50-
environment_id = environment.id
51-
assert environment_id is not None
52-
cleanup.add(lambda: asyncio.run(client.environments.delete(environment_id=environment_id)))
47+
cleanup.adda(lambda: client.environments.delete(environment_id=environment.id))
5348

5449
print("Waiting for environment to be ready")
55-
env = util.EnvironmentState(client, environment_id)
56-
cleanup.add(lambda: asyncio.run(env.close()))
50+
env = util.EnvironmentState(client, environment.id)
51+
cleanup.adda(lambda: env.close())
5752
await env.wait_until_running()
5853

5954
print("Starting Lama Service")
60-
lines = await util.run_service(client, environment_id, {
55+
lines = await util.run_service(client, environment.id, {
6156
"name":"Lama Service",
6257
"description":"Lama Service",
6358
"reference":"lama-service"
@@ -75,6 +70,4 @@ async def main(cleanup: util.Disposables) -> None:
7570
print(line)
7671

7772
if __name__ == "__main__":
78-
disposables = util.Disposables()
79-
with disposables:
80-
asyncio.run(main(disposables))
73+
asyncio.run(util.with_disposables(main))

Diff for: ‎examples/scm_auth.py

+132
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import sys
2+
from urllib.parse import urlparse
3+
4+
import gitpod
5+
import gitpod.lib as util
6+
from gitpod import AsyncGitpod
7+
from gitpod.types.runner_check_authentication_for_host_response import SupportsPat
8+
9+
10+
async def handle_pat_auth(client: AsyncGitpod, user_id: str, runner_id: str, host: str, supports_pat: SupportsPat) -> None:
11+
print("\nTo create a Personal Access Token:")
12+
create_url = supports_pat.create_url
13+
14+
if create_url:
15+
print(f"1. Visit: {create_url}")
16+
else:
17+
print(f"1. Go to {host} > Settings > Developer Settings")
18+
19+
if supports_pat.required_scopes and len(supports_pat.required_scopes) > 0:
20+
required_scopes = ", ".join(supports_pat.required_scopes)
21+
print(f"2. Create a new token with the following scopes: {required_scopes}")
22+
else:
23+
print(f"2. Create a new token")
24+
25+
if supports_pat.example:
26+
print(f"3. Copy the generated token (example: {supports_pat.example})")
27+
else:
28+
print(f"3. Copy the generated token")
29+
30+
if supports_pat.docs_url:
31+
print(f"\nFor detailed instructions, visit: {supports_pat.docs_url}")
32+
33+
pat = input("\nEnter your Personal Access Token: ").strip()
34+
if not pat:
35+
return
36+
37+
await util.set_scm_pat(client, user_id, runner_id, host, pat)
38+
39+
async def verify_context_url(client: AsyncGitpod, context_url: str, runner_id: str) -> None:
40+
"""Verify and handle authentication for a repository context URL.
41+
42+
This function checks if the user has access to the specified repository and manages
43+
the authentication process if needed. Git access to the repository is required for
44+
environments to function properly.
45+
46+
As an alternative, you can authenticate once via the Gitpod dashboard:
47+
1. Start a new environment
48+
2. Complete the browser-based authentication flow
49+
50+
See https://www.gitpod.io/docs/flex/source-control for more details.
51+
"""
52+
host = urlparse(context_url).hostname
53+
if host is None:
54+
print("Error: Invalid context URL")
55+
sys.exit(1)
56+
57+
user = (await client.users.get_authenticated_user()).user
58+
59+
# Main authentication loop
60+
first_attempt = True
61+
while True:
62+
try:
63+
# Try to access the context URL
64+
await client.runners.parse_context_url(context_url=context_url, runner_id=runner_id)
65+
print("\n✓ Authentication verified successfully")
66+
return
67+
68+
except gitpod.APIError as e:
69+
if e.code != "failed_precondition":
70+
raise e
71+
72+
# Show authentication required message only on first attempt
73+
if first_attempt:
74+
print(f"\nAuthentication required for {host}")
75+
first_attempt = False
76+
77+
# Get authentication options for the host
78+
auth_resp = await client.runners.check_authentication_for_host(
79+
host=host,
80+
runner_id=runner_id
81+
)
82+
83+
# Handle re-authentication case
84+
if auth_resp.authenticated and not first_attempt:
85+
print("\nIt looks like you are already authenticated.")
86+
if input("Would you like to re-authenticate? (y/n): ").lower().strip() != 'y':
87+
print("\nAuthentication cancelled")
88+
sys.exit(1)
89+
else:
90+
print("\nRetrying authentication...")
91+
continue
92+
93+
auth_methods: list[tuple[str, str]] = []
94+
if auth_resp.supports_oauth2:
95+
auth_methods.append(("OAuth", "Recommended"))
96+
if auth_resp.supports_pat:
97+
auth_methods.append(("Personal Access Token (PAT)", ""))
98+
99+
if not auth_methods:
100+
print(f"\nError: No authentication method available for {host}")
101+
sys.exit(1)
102+
103+
# Present authentication options
104+
if len(auth_methods) > 1:
105+
print("\nAvailable authentication methods:")
106+
for i, (method, note) in enumerate(auth_methods, 1):
107+
note_text = f" ({note})" if note else ""
108+
print(f"{i}. {method}{note_text}")
109+
110+
choice = input(f"\nChoose authentication method (1-{len(auth_methods)}): ").strip()
111+
try:
112+
method_index = int(choice) - 1
113+
if not 0 <= method_index < len(auth_methods):
114+
raise ValueError()
115+
except ValueError:
116+
method_index = 0 # Default to OAuth if invalid input
117+
else:
118+
method_index = 0
119+
120+
# Handle chosen authentication method
121+
chosen_method = auth_methods[method_index][0]
122+
if chosen_method == "Personal Access Token (PAT)":
123+
assert auth_resp.supports_pat
124+
await handle_pat_auth(client, user.id, runner_id, host, auth_resp.supports_pat)
125+
else:
126+
assert auth_resp.supports_oauth2
127+
print(f"\nPlease visit the following URL to authenticate:")
128+
print(f"{auth_resp.supports_oauth2.auth_url}")
129+
if auth_resp.supports_oauth2.docs_url:
130+
print(f"\nFor detailed instructions, visit: {auth_resp.supports_oauth2.docs_url}")
131+
print("\nWaiting for authentication to complete...")
132+
input("Press Enter after completing authentication in your browser...")

Diff for: ‎requirements-dev.lock

-1
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,6 @@ packaging==23.2
7373
# via nox
7474
# via pytest
7575
paramiko==3.5.1
76-
# via gitpod-sdk
7776
platformdirs==3.11.0
7877
# via virtualenv
7978
pluggy==1.5.0

Diff for: ‎requirements.lock

-13
Original file line numberDiff line numberDiff line change
@@ -14,16 +14,9 @@ annotated-types==0.6.0
1414
anyio==4.4.0
1515
# via gitpod-sdk
1616
# via httpx
17-
bcrypt==4.2.1
18-
# via paramiko
1917
certifi==2023.7.22
2018
# via httpcore
2119
# via httpx
22-
cffi==1.17.1
23-
# via cryptography
24-
# via pynacl
25-
cryptography==44.0.1
26-
# via paramiko
2720
distro==1.8.0
2821
# via gitpod-sdk
2922
exceptiongroup==1.2.2
@@ -37,16 +30,10 @@ httpx==0.28.1
3730
idna==3.4
3831
# via anyio
3932
# via httpx
40-
paramiko==3.5.1
41-
# via gitpod-sdk
42-
pycparser==2.22
43-
# via cffi
4433
pydantic==2.10.3
4534
# via gitpod-sdk
4635
pydantic-core==2.27.1
4736
# via pydantic
48-
pynacl==1.5.0
49-
# via paramiko
5037
sniffio==1.3.0
5138
# via anyio
5239
# via gitpod-sdk

Diff for: ‎src/gitpod/lib/__init__.py

+12-3
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,21 @@
1+
from .runner import set_scm_pat
12
from .automation import run_command, run_service
2-
from .disposables import Disposables
3-
from .environment import EnvironmentState, wait_for_environment_ready, find_most_used_environment_class
3+
from .disposables import Disposables, with_disposables
4+
from .environment import (
5+
EnvironmentState,
6+
find_environment_class_by_id,
7+
wait_for_environment_running,
8+
find_most_used_environment_class,
9+
)
410

511
__all__ = [
612
'find_most_used_environment_class',
713
'run_command',
814
'run_service',
915
'EnvironmentState',
1016
'Disposables',
11-
'wait_for_environment_ready',
17+
'wait_for_environment_running',
18+
'find_environment_class_by_id',
19+
'set_scm_pat',
20+
'with_disposables',
1221
]

Diff for: ‎src/gitpod/lib/automation.py

+8-19
Original file line numberDiff line numberDiff line change
@@ -25,21 +25,17 @@ async def run_service(
2525
}
2626
)).services
2727

28-
service_id: Optional[str] = None
2928
if not services:
3029
service = (await client.environments.automations.services.create(
3130
environment_id=environment_id,
3231
spec=spec,
3332
metadata=metadata
3433
)).service
35-
assert service is not None
36-
service_id = service.id
3734
else:
38-
service_id = services[0].id
39-
assert service_id is not None
35+
service = services[0]
4036

41-
await client.environments.automations.services.start(id=service_id)
42-
log_url = await wait_for_service_log_url(client, environment_id, service_id)
37+
await client.environments.automations.services.start(id=service.id)
38+
log_url = await wait_for_service_log_url(client, environment_id, service.id)
4339
return stream_logs(client, environment_id, log_url)
4440

4541
async def run_command(client: AsyncGitpod, environment_id: str, command: str) -> AsyncIterator[str]:
@@ -50,7 +46,6 @@ async def run_command(client: AsyncGitpod, environment_id: str, command: str) ->
5046
}
5147
)).tasks
5248

53-
task_id: Optional[str] = None
5449
if not tasks:
5550
task = (await client.environments.automations.tasks.create(
5651
spec={
@@ -63,23 +58,17 @@ async def run_command(client: AsyncGitpod, environment_id: str, command: str) ->
6358
"reference": TASK_REFERENCE,
6459
},
6560
)).task
66-
assert task is not None
67-
task_id = task.id
6861
else:
69-
task_id = tasks[0].id
70-
assert task_id is not None
62+
task = tasks[0]
7163
await client.environments.automations.tasks.update(
72-
id=task_id,
64+
id=task.id,
7365
spec={
7466
"command": command,
7567
},
7668
)
77-
assert task_id is not None
78-
task_execution = (await client.environments.automations.tasks.start(id=task_id)).task_execution
79-
assert task_execution is not None
80-
task_execution_id = task_execution.id
81-
assert task_execution_id is not None
82-
log_url = await wait_for_task_log_url(client, environment_id, task_execution_id)
69+
70+
task_execution = (await client.environments.automations.tasks.start(id=task.id)).task_execution
71+
log_url = await wait_for_task_log_url(client, environment_id, task_execution.id)
8372
return stream_logs(client, environment_id, log_url)
8473

8574
async def wait_for_task_log_url(client: AsyncGitpod, environment_id: str, task_execution_id: str) -> str:

Diff for: ‎src/gitpod/lib/disposables.py

+23-12
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import logging
2-
from typing import Any, List, Callable
2+
from typing import Any, List, Callable, Awaitable
33

44
log = logging.getLogger(__name__)
55

@@ -21,30 +21,41 @@ class Disposables:
2121
"""
2222

2323
def __init__(self) -> None:
24-
self._actions: List[Callable[[], Any]] = []
24+
self._actions: List[Callable[[], Awaitable[Any]]] = []
25+
26+
def adda(self, action: Callable[[], Awaitable[Any]]) -> None:
27+
"""Add an async cleanup action to be executed when the context exits.
28+
29+
Args:
30+
action: An async callable that performs cleanup when called
31+
"""
32+
self._actions.append(action)
2533

2634
def add(self, action: Callable[[], Any]) -> None:
2735
"""Add a cleanup action to be executed when the context exits.
2836
2937
Args:
3038
action: A callable that performs cleanup when called
3139
"""
32-
self._actions.append(action)
33-
34-
def __enter__(self) -> 'Disposables':
35-
return self
40+
async def wrapper() -> Any:
41+
return action()
42+
self._actions.append(wrapper)
3643

37-
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
38-
self.cleanup()
39-
40-
def cleanup(self) -> None:
44+
async def cleanup(self) -> None:
4145
"""Execute all cleanup actions in reverse order.
4246
4347
If any cleanup action raises an exception, it will be logged but won't prevent
4448
other cleanup actions from executing.
4549
"""
4650
for action in reversed(self._actions):
4751
try:
48-
action()
52+
await action()
4953
except BaseException:
50-
log.exception("cleanup action failed")
54+
log.exception("cleanup action failed")
55+
56+
async def with_disposables(fn: Callable[['Disposables'], Awaitable[Any]]) -> Any:
57+
disposables = Disposables()
58+
try:
59+
return await fn(disposables)
60+
finally:
61+
await disposables.cleanup()

Diff for: ‎src/gitpod/lib/environment.py

+8-6
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,6 @@ async def _update_environment(self) -> None:
3939
try:
4040
resp = await self.client.environments.retrieve(environment_id=self.environment_id)
4141
env = resp.environment
42-
assert env is not None
4342
self._environment = env
4443
self._ready.set()
4544
for listener in list(self._listeners):
@@ -135,7 +134,7 @@ def listener(env: Environment) -> None:
135134
await event.wait()
136135
if result is None:
137136
raise RuntimeError("wait_until completed but result is None")
138-
return result
137+
return result # type: ignore[unreachable]
139138
finally:
140139
self._listeners.remove(listener)
141140

@@ -144,11 +143,11 @@ def is_running(self, env: Environment) -> bool:
144143
if not env.status:
145144
return False
146145

147-
if env.status.phase in ["ENVIRONMENT_PHASE_STOPPING", "ENVIRONMENT_PHASE_STOPPED",
146+
if env.status.failure_message:
147+
raise RuntimeError(f"Environment {env.id} failed: {'; '.join(env.status.failure_message)}")
148+
elif env.status.phase in ["ENVIRONMENT_PHASE_STOPPING", "ENVIRONMENT_PHASE_STOPPED",
148149
"ENVIRONMENT_PHASE_DELETING", "ENVIRONMENT_PHASE_DELETED"]:
149150
raise RuntimeError(f"Environment {env.id} is in unexpected phase: {env.status.phase}")
150-
elif env.status.failure_message:
151-
raise RuntimeError(f"Environment {env.id} failed: {'; '.join(env.status.failure_message)}")
152151

153152
return env.status.phase == "ENVIRONMENT_PHASE_RUNNING"
154153

@@ -212,7 +211,7 @@ def check_key(env: Environment) -> Optional[bool]:
212211
return True if self.check_ssh_key_applied(env, key_id, key_value) else None
213212
await self.wait_until(check_key)
214213

215-
async def wait_for_environment_ready(client: AsyncGitpod, environment_id: str) -> None:
214+
async def wait_for_environment_running(client: AsyncGitpod, environment_id: str) -> None:
216215
env = EnvironmentState(client, environment_id)
217216
try:
218217
await env.wait_until_running()
@@ -240,6 +239,9 @@ async def find_most_used_environment_class(client: AsyncGitpod) -> Optional[Envi
240239
if not environment_class_id:
241240
return None
242241

242+
return await find_environment_class_by_id(client, environment_class_id)
243+
244+
async def find_environment_class_by_id(client: AsyncGitpod, environment_class_id: str) -> Optional[EnvironmentClass]:
243245
classes_resp = await client.environments.classes.list(filter={"can_create_environments": True})
244246
while classes_resp:
245247
for cls in classes_resp.environment_classes:

Diff for: ‎src/gitpod/lib/runner.py

+36
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
from gitpod._client import AsyncGitpod
2+
3+
4+
async def set_scm_pat(client: AsyncGitpod, user_id: str, runner_id: str, host: str, pat: str) -> None:
5+
"""Set a Personal Access Token (PAT) for source control authentication.
6+
7+
This will delete any existing tokens for the given host and create a new one.
8+
9+
Args:
10+
client: The AsyncGitpod client instance
11+
user_id: ID of the user to set the token for
12+
runner_id: ID of the runner to associate the token with
13+
host: Source control host (e.g. github.com, gitlab.com)
14+
pat: The Personal Access Token string
15+
"""
16+
tokens_response = await client.runners.configurations.host_authentication_tokens.list(
17+
filter={
18+
"user_id": user_id,
19+
"runner_id": runner_id,
20+
}
21+
)
22+
23+
if tokens_response and tokens_response.tokens:
24+
for token in tokens_response.tokens:
25+
if token.host == host:
26+
await client.runners.configurations.host_authentication_tokens.delete(
27+
id=token.id
28+
)
29+
30+
await client.runners.configurations.host_authentication_tokens.create(
31+
token=pat,
32+
host=host,
33+
runner_id=runner_id,
34+
user_id=user_id,
35+
source="HOST_AUTHENTICATION_TOKEN_SOURCE_PAT"
36+
)

0 commit comments

Comments
 (0)
Please sign in to comment.