Skip to content

Commit 954ae91

Browse files
committed
feat(auth): Add SCM authentication support for repository access
1 parent c4f1725 commit 954ae91

File tree

11 files changed

+240
-100
lines changed

11 files changed

+240
-100
lines changed

examples/anthropic_tool_use.py

Lines changed: 11 additions & 12 deletions
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.add(lambda: asyncio.run(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"])

examples/fs_access.py

Lines changed: 8 additions & 12 deletions
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,12 +55,9 @@ 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.add(lambda: asyncio.run(client.environments.delete(environment_id=environment.id)))
6359

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

6763
print("Waiting for environment to be running")

examples/run_command.py

Lines changed: 10 additions & 14 deletions
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,16 +44,13 @@ 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.add(lambda: asyncio.run(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

examples/run_service.py

Lines changed: 16 additions & 21 deletions
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.add(lambda: asyncio.run(client.environments.delete(environment_id=environment.id)))
5348

5449
print("Waiting for environment to be ready")
55-
env = util.EnvironmentState(client, environment_id)
50+
env = util.EnvironmentState(client, environment.id)
5651
cleanup.add(lambda: asyncio.run(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"
@@ -77,4 +72,4 @@ async def main(cleanup: util.Disposables) -> None:
7772
if __name__ == "__main__":
7873
disposables = util.Disposables()
7974
with disposables:
80-
asyncio.run(main(disposables))
75+
asyncio.run(main(disposables))

examples/scm_auth.py

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
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+
assert user.id is not None
59+
60+
# Main authentication loop
61+
first_attempt = True
62+
while True:
63+
try:
64+
# Try to access the context URL
65+
await client.runners.parse_context_url(context_url=context_url, runner_id=runner_id)
66+
print("\n✓ Authentication verified successfully")
67+
return
68+
69+
except gitpod.APIError as e:
70+
if e.code != "failed_precondition":
71+
raise e
72+
73+
# Show authentication required message only on first attempt
74+
if first_attempt:
75+
print(f"\nAuthentication required for {host}")
76+
first_attempt = False
77+
78+
# Get authentication options for the host
79+
auth_resp = await client.runners.check_authentication_for_host(
80+
host=host,
81+
runner_id=runner_id
82+
)
83+
84+
# Handle re-authentication case
85+
if auth_resp.authenticated and not first_attempt:
86+
print("\nIt looks like you are already authenticated.")
87+
if input("Would you like to re-authenticate? (y/n): ").lower().strip() != 'y':
88+
print("\nAuthentication cancelled")
89+
sys.exit(1)
90+
else:
91+
print("\nRetrying authentication...")
92+
continue
93+
94+
auth_methods: list[tuple[str, str]] = []
95+
if auth_resp.supports_oauth2:
96+
auth_methods.append(("OAuth", "Recommended"))
97+
if auth_resp.supports_pat:
98+
auth_methods.append(("Personal Access Token (PAT)", ""))
99+
100+
if not auth_methods:
101+
print(f"\nError: No authentication method available for {host}")
102+
sys.exit(1)
103+
104+
# Present authentication options
105+
if len(auth_methods) > 1:
106+
print("\nAvailable authentication methods:")
107+
for i, (method, note) in enumerate(auth_methods, 1):
108+
note_text = f" ({note})" if note else ""
109+
print(f"{i}. {method}{note_text}")
110+
111+
choice = input(f"\nChoose authentication method (1-{len(auth_methods)}): ").strip()
112+
try:
113+
method_index = int(choice) - 1
114+
if not 0 <= method_index < len(auth_methods):
115+
raise ValueError()
116+
except ValueError:
117+
method_index = 0 # Default to OAuth if invalid input
118+
else:
119+
method_index = 0
120+
121+
# Handle chosen authentication method
122+
chosen_method = auth_methods[method_index][0]
123+
if chosen_method == "Personal Access Token (PAT)":
124+
assert auth_resp.supports_pat
125+
await handle_pat_auth(client, user.id, runner_id, host, auth_resp.supports_pat)
126+
else:
127+
assert auth_resp.supports_oauth2
128+
print(f"\nPlease visit the following URL to authenticate:")
129+
print(f"{auth_resp.supports_oauth2.auth_url}")
130+
if auth_resp.supports_oauth2.docs_url:
131+
print(f"\nFor detailed instructions, visit: {auth_resp.supports_oauth2.docs_url}")
132+
print("\nWaiting for authentication to complete...")
133+
input("Press Enter after completing authentication in your browser...")

requirements-dev.lock

Lines changed: 0 additions & 1 deletion
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

0 commit comments

Comments
 (0)