orchestra-cli 0.5.1__tar.gz → 0.5.3__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.
- {orchestra_cli-0.5.1 → orchestra_cli-0.5.3}/PKG-INFO +1 -1
- {orchestra_cli-0.5.1 → orchestra_cli-0.5.3}/orchestra_cli/src/login.py +4 -0
- {orchestra_cli-0.5.1 → orchestra_cli-0.5.3}/orchestra_cli/src/migrate_pipeline.py +4 -2
- {orchestra_cli-0.5.1 → orchestra_cli-0.5.3}/orchestra_cli/src/run_pipeline.py +3 -2
- {orchestra_cli-0.5.1 → orchestra_cli-0.5.3}/orchestra_cli/src/validate_pipeline.py +9 -3
- {orchestra_cli-0.5.1 → orchestra_cli-0.5.3}/orchestra_cli/tests/test_import_pipeline.py +2 -0
- {orchestra_cli-0.5.1 → orchestra_cli-0.5.3}/orchestra_cli/tests/test_migrate_pipeline.py +8 -8
- {orchestra_cli-0.5.1 → orchestra_cli-0.5.3}/orchestra_cli/tests/test_run_pipeline.py +4 -4
- {orchestra_cli-0.5.1 → orchestra_cli-0.5.3}/orchestra_cli/tests/utils/test_api.py +61 -1
- {orchestra_cli-0.5.1 → orchestra_cli-0.5.3}/orchestra_cli/tests/utils/test_git.py +43 -0
- {orchestra_cli-0.5.1 → orchestra_cli-0.5.3}/orchestra_cli/utils/api.py +27 -3
- {orchestra_cli-0.5.1 → orchestra_cli-0.5.3}/orchestra_cli/utils/git.py +25 -2
- {orchestra_cli-0.5.1 → orchestra_cli-0.5.3}/orchestra_cli/utils/yaml_loader.py +8 -2
- {orchestra_cli-0.5.1 → orchestra_cli-0.5.3}/orchestra_cli.egg-info/PKG-INFO +1 -1
- {orchestra_cli-0.5.1 → orchestra_cli-0.5.3}/pyproject.toml +1 -1
- {orchestra_cli-0.5.1 → orchestra_cli-0.5.3}/README.md +0 -0
- {orchestra_cli-0.5.1 → orchestra_cli-0.5.3}/orchestra_cli/src/__init__.py +0 -0
- {orchestra_cli-0.5.1 → orchestra_cli-0.5.3}/orchestra_cli/src/build_pipeline.py +0 -0
- {orchestra_cli-0.5.1 → orchestra_cli-0.5.3}/orchestra_cli/src/cli.py +0 -0
- {orchestra_cli-0.5.1 → orchestra_cli-0.5.3}/orchestra_cli/src/create_pipeline.py +0 -0
- {orchestra_cli-0.5.1 → orchestra_cli-0.5.3}/orchestra_cli/src/delete_pipeline.py +0 -0
- {orchestra_cli-0.5.1 → orchestra_cli-0.5.3}/orchestra_cli/src/fetch_pipelines.py +0 -0
- {orchestra_cli-0.5.1 → orchestra_cli-0.5.3}/orchestra_cli/src/get_pipeline.py +0 -0
- {orchestra_cli-0.5.1 → orchestra_cli-0.5.3}/orchestra_cli/src/import_pipeline.py +0 -0
- {orchestra_cli-0.5.1 → orchestra_cli-0.5.3}/orchestra_cli/src/list_accounts.py +0 -0
- {orchestra_cli-0.5.1 → orchestra_cli-0.5.3}/orchestra_cli/src/login_page.html +0 -0
- {orchestra_cli-0.5.1 → orchestra_cli-0.5.3}/orchestra_cli/src/pipeline_upsert.py +0 -0
- {orchestra_cli-0.5.1 → orchestra_cli-0.5.3}/orchestra_cli/src/task_logs.py +0 -0
- {orchestra_cli-0.5.1 → orchestra_cli-0.5.3}/orchestra_cli/src/update_pipeline.py +0 -0
- {orchestra_cli-0.5.1 → orchestra_cli-0.5.3}/orchestra_cli/src/use_account.py +0 -0
- {orchestra_cli-0.5.1 → orchestra_cli-0.5.3}/orchestra_cli/tests/__init__.py +0 -0
- {orchestra_cli-0.5.1 → orchestra_cli-0.5.3}/orchestra_cli/tests/conftest.py +0 -0
- {orchestra_cli-0.5.1 → orchestra_cli-0.5.3}/orchestra_cli/tests/test_build_pipeline.py +0 -0
- {orchestra_cli-0.5.1 → orchestra_cli-0.5.3}/orchestra_cli/tests/test_cli.py +0 -0
- {orchestra_cli-0.5.1 → orchestra_cli-0.5.3}/orchestra_cli/tests/test_create_pipeline.py +0 -0
- {orchestra_cli-0.5.1 → orchestra_cli-0.5.3}/orchestra_cli/tests/test_delete_pipeline.py +0 -0
- {orchestra_cli-0.5.1 → orchestra_cli-0.5.3}/orchestra_cli/tests/test_fetch_pipelines.py +0 -0
- {orchestra_cli-0.5.1 → orchestra_cli-0.5.3}/orchestra_cli/tests/test_get_pipeline.py +0 -0
- {orchestra_cli-0.5.1 → orchestra_cli-0.5.3}/orchestra_cli/tests/test_list_accounts.py +0 -0
- {orchestra_cli-0.5.1 → orchestra_cli-0.5.3}/orchestra_cli/tests/test_login.py +0 -0
- {orchestra_cli-0.5.1 → orchestra_cli-0.5.3}/orchestra_cli/tests/test_pipeline_selector.py +0 -0
- {orchestra_cli-0.5.1 → orchestra_cli-0.5.3}/orchestra_cli/tests/test_task_logs.py +0 -0
- {orchestra_cli-0.5.1 → orchestra_cli-0.5.3}/orchestra_cli/tests/test_update_pipeline.py +0 -0
- {orchestra_cli-0.5.1 → orchestra_cli-0.5.3}/orchestra_cli/tests/test_use_account.py +0 -0
- {orchestra_cli-0.5.1 → orchestra_cli-0.5.3}/orchestra_cli/tests/test_validate_pipeline.py +0 -0
- {orchestra_cli-0.5.1 → orchestra_cli-0.5.3}/orchestra_cli/tests/utils/__init__.py +0 -0
- {orchestra_cli-0.5.1 → orchestra_cli-0.5.3}/orchestra_cli/tests/utils/test_picker.py +0 -0
- {orchestra_cli-0.5.1 → orchestra_cli-0.5.3}/orchestra_cli/utils/accounts.py +0 -0
- {orchestra_cli-0.5.1 → orchestra_cli-0.5.3}/orchestra_cli/utils/constants.py +0 -0
- {orchestra_cli-0.5.1 → orchestra_cli-0.5.3}/orchestra_cli/utils/credentials.py +0 -0
- {orchestra_cli-0.5.1 → orchestra_cli-0.5.3}/orchestra_cli/utils/picker.py +0 -0
- {orchestra_cli-0.5.1 → orchestra_cli-0.5.3}/orchestra_cli/utils/pipeline_lookup.py +0 -0
- {orchestra_cli-0.5.1 → orchestra_cli-0.5.3}/orchestra_cli/utils/pipeline_selector.py +0 -0
- {orchestra_cli-0.5.1 → orchestra_cli-0.5.3}/orchestra_cli/utils/pipeline_update.py +0 -0
- {orchestra_cli-0.5.1 → orchestra_cli-0.5.3}/orchestra_cli/utils/styling.py +0 -0
- {orchestra_cli-0.5.1 → orchestra_cli-0.5.3}/orchestra_cli.egg-info/SOURCES.txt +0 -0
- {orchestra_cli-0.5.1 → orchestra_cli-0.5.3}/orchestra_cli.egg-info/dependency_links.txt +0 -0
- {orchestra_cli-0.5.1 → orchestra_cli-0.5.3}/orchestra_cli.egg-info/entry_points.txt +0 -0
- {orchestra_cli-0.5.1 → orchestra_cli-0.5.3}/orchestra_cli.egg-info/requires.txt +0 -0
- {orchestra_cli-0.5.1 → orchestra_cli-0.5.3}/orchestra_cli.egg-info/top_level.txt +0 -0
- {orchestra_cli-0.5.1 → orchestra_cli-0.5.3}/setup.cfg +0 -0
|
@@ -15,6 +15,7 @@ from ..utils.api import (
|
|
|
15
15
|
api_client,
|
|
16
16
|
fail_with_response,
|
|
17
17
|
request_or_exit,
|
|
18
|
+
support_id_headers,
|
|
18
19
|
token_response_to_credentials,
|
|
19
20
|
)
|
|
20
21
|
from ..utils.constants import get_base_url
|
|
@@ -83,6 +84,7 @@ def login():
|
|
|
83
84
|
response = request_or_exit(
|
|
84
85
|
httpx.get,
|
|
85
86
|
f"{base_url}/.well-known/oauth-authorization-server",
|
|
87
|
+
headers=support_id_headers(),
|
|
86
88
|
timeout=30,
|
|
87
89
|
)
|
|
88
90
|
if response.status_code != 200:
|
|
@@ -111,6 +113,7 @@ def login():
|
|
|
111
113
|
"response_types": ["code"],
|
|
112
114
|
"token_endpoint_auth_method": "none",
|
|
113
115
|
},
|
|
116
|
+
headers=support_id_headers(),
|
|
114
117
|
timeout=30,
|
|
115
118
|
)
|
|
116
119
|
if response.status_code not in (200, 201):
|
|
@@ -151,6 +154,7 @@ def login():
|
|
|
151
154
|
"client_id": client_id,
|
|
152
155
|
"code_verifier": verifier,
|
|
153
156
|
},
|
|
157
|
+
headers=support_id_headers(),
|
|
154
158
|
timeout=30,
|
|
155
159
|
)
|
|
156
160
|
if response.status_code != 200:
|
|
@@ -138,7 +138,7 @@ def _download_pipeline_yaml(
|
|
|
138
138
|
|
|
139
139
|
response = request_or_exit(
|
|
140
140
|
client.get,
|
|
141
|
-
get_api_url("
|
|
141
|
+
get_api_url("pipelines/data"),
|
|
142
142
|
params=params,
|
|
143
143
|
)
|
|
144
144
|
if response.status_code != 200:
|
|
@@ -364,7 +364,8 @@ def migrate_pipeline(
|
|
|
364
364
|
typer.echo(red("Could not detect repository URL from git"))
|
|
365
365
|
raise typer.Exit(code=1)
|
|
366
366
|
|
|
367
|
-
|
|
367
|
+
remote_url = get_remote_url(repo_root)
|
|
368
|
+
storage_provider_value = detect_storage_provider(remote_url)
|
|
368
369
|
if not storage_provider_value:
|
|
369
370
|
typer.echo(red("Could not detect storage provider - no matching host"))
|
|
370
371
|
raise typer.Exit(code=1)
|
|
@@ -437,6 +438,7 @@ def migrate_pipeline(
|
|
|
437
438
|
repository_slug,
|
|
438
439
|
default_branch,
|
|
439
440
|
pushed_branch,
|
|
441
|
+
remote_url,
|
|
440
442
|
)
|
|
441
443
|
if compare_link:
|
|
442
444
|
typer.echo(yellow(f"Open PR / compare link: {compare_link}"))
|
|
@@ -18,6 +18,7 @@ from ..utils.api import (
|
|
|
18
18
|
fail_with_response,
|
|
19
19
|
request_or_exit,
|
|
20
20
|
require_credential,
|
|
21
|
+
response_error_text,
|
|
21
22
|
)
|
|
22
23
|
from ..utils.constants import get_api_url, get_base_url
|
|
23
24
|
from ..utils.git import GitAction, confirm_git_warnings_or_exit, prepare_git_backed_run_target
|
|
@@ -163,7 +164,7 @@ def _load_pipeline_data_for_task_resolution(
|
|
|
163
164
|
|
|
164
165
|
response = request_or_exit(
|
|
165
166
|
client.get,
|
|
166
|
-
get_api_url("
|
|
167
|
+
get_api_url("pipelines/data"),
|
|
167
168
|
params=selector.to_payload(),
|
|
168
169
|
)
|
|
169
170
|
if response.status_code != 200:
|
|
@@ -535,7 +536,7 @@ def _poll_until_terminal(
|
|
|
535
536
|
poll_status_display = None
|
|
536
537
|
typer.echo(red(f"❌ Status check failed with HTTP {status_resp.status_code}"))
|
|
537
538
|
try:
|
|
538
|
-
typer.echo(yellow(indent_message(status_resp
|
|
539
|
+
typer.echo(yellow(indent_message(response_error_text(status_resp))))
|
|
539
540
|
except Exception:
|
|
540
541
|
pass
|
|
541
542
|
raise typer.Exit(code=1)
|
|
@@ -6,7 +6,7 @@ import httpx
|
|
|
6
6
|
import typer
|
|
7
7
|
import yaml
|
|
8
8
|
|
|
9
|
-
from ..utils.api import request_or_exit
|
|
9
|
+
from ..utils.api import request_or_exit, response_error_text, support_id_headers
|
|
10
10
|
from ..utils.constants import get_api_url
|
|
11
11
|
from ..utils.styling import bold, green, indent_message, red, yellow
|
|
12
12
|
from ..utils.yaml_loader import echo_invalid_yaml, exit_if_unsupported_extension, load_yaml
|
|
@@ -67,7 +67,13 @@ def validate(
|
|
|
67
67
|
echo_invalid_yaml(file, err)
|
|
68
68
|
raise typer.Exit(code=1)
|
|
69
69
|
|
|
70
|
-
response = request_or_exit(
|
|
70
|
+
response = request_or_exit(
|
|
71
|
+
httpx.post,
|
|
72
|
+
get_api_url("pipelines/schema"),
|
|
73
|
+
json=data,
|
|
74
|
+
headers=support_id_headers(),
|
|
75
|
+
timeout=10,
|
|
76
|
+
)
|
|
71
77
|
|
|
72
78
|
if response.status_code == 200:
|
|
73
79
|
typer.echo(green("✅ Validation passed!"))
|
|
@@ -82,5 +88,5 @@ def validate(
|
|
|
82
88
|
else:
|
|
83
89
|
typer.echo(errors)
|
|
84
90
|
except Exception:
|
|
85
|
-
typer.echo(response
|
|
91
|
+
typer.echo(response_error_text(response))
|
|
86
92
|
raise typer.Exit(code=1)
|
|
@@ -36,6 +36,8 @@ def mock_env(monkeypatch):
|
|
|
36
36
|
[
|
|
37
37
|
("https://github.com/org/repo.git", "GITHUB", "org/repo"),
|
|
38
38
|
("git@github.com:org/repo.git", "GITHUB", "org/repo"),
|
|
39
|
+
("https://acme.ghe.com/org/repo.git", "GITHUB", "org/repo"),
|
|
40
|
+
("git@acme.ghe.com:org/repo.git", "GITHUB", "org/repo"),
|
|
39
41
|
("https://gitlab.com/org/repo.git", "GITLAB", "org/repo"),
|
|
40
42
|
("git@gitlab.com:org/repo.git", "GITLAB", "org/repo"),
|
|
41
43
|
("https://dev.azure.com/org/project/_git/repo", "AZURE_DEVOPS", "project/repo"),
|
|
@@ -38,7 +38,7 @@ def test_migrate_success_uses_default_branch_without_working_branch(
|
|
|
38
38
|
)
|
|
39
39
|
httpx_mock.add_response(
|
|
40
40
|
method="GET",
|
|
41
|
-
url="https://app.getorchestra.io/api/engine/public/
|
|
41
|
+
url="https://app.getorchestra.io/api/engine/public/pipelines/data?alias=demo&version=3",
|
|
42
42
|
text="name: remote\nversion: 1\n",
|
|
43
43
|
status_code=200,
|
|
44
44
|
)
|
|
@@ -100,7 +100,7 @@ def test_migrate_prompts_for_latest_version_and_prints_compare_link(
|
|
|
100
100
|
)
|
|
101
101
|
httpx_mock.add_response(
|
|
102
102
|
method="GET",
|
|
103
|
-
url="https://app.getorchestra.io/api/engine/public/
|
|
103
|
+
url="https://app.getorchestra.io/api/engine/public/pipelines/data?alias=demo&version=5",
|
|
104
104
|
text="name: remote\n",
|
|
105
105
|
status_code=200,
|
|
106
106
|
)
|
|
@@ -170,7 +170,7 @@ def test_migrate_uses_local_file_when_conflict_choice_is_local(
|
|
|
170
170
|
)
|
|
171
171
|
httpx_mock.add_response(
|
|
172
172
|
method="GET",
|
|
173
|
-
url="https://app.getorchestra.io/api/engine/public/
|
|
173
|
+
url="https://app.getorchestra.io/api/engine/public/pipelines/data?alias=demo&version=1",
|
|
174
174
|
text="name: remote\n",
|
|
175
175
|
status_code=200,
|
|
176
176
|
)
|
|
@@ -271,7 +271,7 @@ def test_migrate_branch_protection_offer_retries_with_suggested_branch(
|
|
|
271
271
|
)
|
|
272
272
|
httpx_mock.add_response(
|
|
273
273
|
method="GET",
|
|
274
|
-
url="https://app.getorchestra.io/api/engine/public/
|
|
274
|
+
url="https://app.getorchestra.io/api/engine/public/pipelines/data?alias=demo&version=1",
|
|
275
275
|
text="name: remote\n",
|
|
276
276
|
status_code=200,
|
|
277
277
|
)
|
|
@@ -337,7 +337,7 @@ def test_migrate_accepts_nonexistent_parent_directory_inside_repo(
|
|
|
337
337
|
)
|
|
338
338
|
httpx_mock.add_response(
|
|
339
339
|
method="GET",
|
|
340
|
-
url="https://app.getorchestra.io/api/engine/public/
|
|
340
|
+
url="https://app.getorchestra.io/api/engine/public/pipelines/data?alias=demo&version=1",
|
|
341
341
|
text="name: remote\n",
|
|
342
342
|
status_code=200,
|
|
343
343
|
)
|
|
@@ -398,7 +398,7 @@ def test_migrate_force_skips_version_prompt(monkeypatch, tmp_path: Path, httpx_m
|
|
|
398
398
|
)
|
|
399
399
|
httpx_mock.add_response(
|
|
400
400
|
method="GET",
|
|
401
|
-
url="https://app.getorchestra.io/api/engine/public/
|
|
401
|
+
url="https://app.getorchestra.io/api/engine/public/pipelines/data?alias=demo&version=2",
|
|
402
402
|
text="name: remote\n",
|
|
403
403
|
status_code=200,
|
|
404
404
|
)
|
|
@@ -469,7 +469,7 @@ def test_migrate_force_skips_branch_recovery_prompt(
|
|
|
469
469
|
)
|
|
470
470
|
httpx_mock.add_response(
|
|
471
471
|
method="GET",
|
|
472
|
-
url="https://app.getorchestra.io/api/engine/public/
|
|
472
|
+
url="https://app.getorchestra.io/api/engine/public/pipelines/data?alias=demo&version=1",
|
|
473
473
|
text="name: remote\n",
|
|
474
474
|
status_code=200,
|
|
475
475
|
)
|
|
@@ -536,7 +536,7 @@ def test_migrate_fails_before_writing_when_reprompted_path_is_outside_repo(
|
|
|
536
536
|
)
|
|
537
537
|
httpx_mock.add_response(
|
|
538
538
|
method="GET",
|
|
539
|
-
url="https://app.getorchestra.io/api/engine/public/
|
|
539
|
+
url="https://app.getorchestra.io/api/engine/public/pipelines/data?alias=demo&version=1",
|
|
540
540
|
text="name: remote\n",
|
|
541
541
|
status_code=200,
|
|
542
542
|
)
|
|
@@ -288,7 +288,7 @@ def test_run_with_task_and_overrides(httpx_mock: HTTPXMock, monkeypatch, tmp_pat
|
|
|
288
288
|
|
|
289
289
|
httpx_mock.add_response(
|
|
290
290
|
method="GET",
|
|
291
|
-
url="https://app.getorchestra.io/api/engine/public/
|
|
291
|
+
url="https://app.getorchestra.io/api/engine/public/pipelines/data?alias=demo",
|
|
292
292
|
text="\n".join(
|
|
293
293
|
[
|
|
294
294
|
"name: demo",
|
|
@@ -366,7 +366,7 @@ def test_run_task_name_resolves_from_remote_pipeline_data(
|
|
|
366
366
|
|
|
367
367
|
httpx_mock.add_response(
|
|
368
368
|
method="GET",
|
|
369
|
-
url="https://app.getorchestra.io/api/engine/public/
|
|
369
|
+
url="https://app.getorchestra.io/api/engine/public/pipelines/data?alias=demo",
|
|
370
370
|
text="\n".join(
|
|
371
371
|
[
|
|
372
372
|
"name: demo",
|
|
@@ -419,7 +419,7 @@ def test_run_task_name_resolves_from_remote_json_pipeline_definition(
|
|
|
419
419
|
|
|
420
420
|
httpx_mock.add_response(
|
|
421
421
|
method="GET",
|
|
422
|
-
url="https://app.getorchestra.io/api/engine/public/
|
|
422
|
+
url="https://app.getorchestra.io/api/engine/public/pipelines/data?alias=demo",
|
|
423
423
|
json={
|
|
424
424
|
"name": "demo",
|
|
425
425
|
"pipeline": {
|
|
@@ -471,7 +471,7 @@ def test_run_task_name_ignores_metadata_only_wrapper_and_uses_top_level_pipeline
|
|
|
471
471
|
|
|
472
472
|
httpx_mock.add_response(
|
|
473
473
|
method="GET",
|
|
474
|
-
url="https://app.getorchestra.io/api/engine/public/
|
|
474
|
+
url="https://app.getorchestra.io/api/engine/public/pipelines/data?alias=demo",
|
|
475
475
|
json={
|
|
476
476
|
"data": {"page": 1, "total": 1},
|
|
477
477
|
"pipeline": {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import re
|
|
1
2
|
import time
|
|
2
3
|
from types import SimpleNamespace
|
|
3
4
|
from urllib.parse import parse_qs
|
|
@@ -8,7 +9,7 @@ import typer
|
|
|
8
9
|
from pytest_httpx import HTTPXMock
|
|
9
10
|
|
|
10
11
|
import orchestra_cli.utils.api as api_module
|
|
11
|
-
from orchestra_cli.utils.api import require_credential
|
|
12
|
+
from orchestra_cli.utils.api import SUPPORT_ID_HEADER, api_client, require_credential
|
|
12
13
|
from orchestra_cli.utils.credentials import load_credentials, save_credentials
|
|
13
14
|
|
|
14
15
|
TOKEN_ENDPOINT = "https://app.getorchestra.io/oauth/token"
|
|
@@ -125,6 +126,25 @@ def test_refresh_server_error_is_not_reported_as_expired_login(httpx_mock: HTTPX
|
|
|
125
126
|
assert "expired" not in output
|
|
126
127
|
|
|
127
128
|
|
|
129
|
+
def test_waf_blocked_refresh_keeps_login_and_shows_support_id(httpx_mock: HTTPXMock, capsys):
|
|
130
|
+
cache_login(time.time() - 1)
|
|
131
|
+
httpx_mock.add_response(
|
|
132
|
+
method="POST",
|
|
133
|
+
url=TOKEN_ENDPOINT,
|
|
134
|
+
status_code=403,
|
|
135
|
+
headers={"content-type": "text/html"},
|
|
136
|
+
text="<html><head><title>403 Forbidden</title></head></html>",
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
with pytest.raises(typer.Exit):
|
|
140
|
+
require_credential()
|
|
141
|
+
request = httpx_mock.get_request(url=TOKEN_ENDPOINT)
|
|
142
|
+
assert request is not None
|
|
143
|
+
support_id = request.headers[SUPPORT_ID_HEADER]
|
|
144
|
+
assert f"Contact Orchestra support with ID: {support_id}" in capsys.readouterr().out
|
|
145
|
+
assert load_credentials() is not None
|
|
146
|
+
|
|
147
|
+
|
|
128
148
|
def test_rejected_refresh_uses_token_another_process_just_rotated_in(httpx_mock: HTTPXMock):
|
|
129
149
|
cache_login(time.time() - 1)
|
|
130
150
|
|
|
@@ -227,3 +247,43 @@ def test_other_errors_get_no_account_hint(response, capsys):
|
|
|
227
247
|
api_module.fail_with_response("Fetch pipelines", response)
|
|
228
248
|
|
|
229
249
|
assert ACCOUNT_HINT not in capsys.readouterr().out
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def test_requests_carry_a_support_id(httpx_mock: HTTPXMock, monkeypatch):
|
|
253
|
+
monkeypatch.setenv("ORCHESTRA_API_KEY", "account-key")
|
|
254
|
+
httpx_mock.add_response(url="https://app.getorchestra.io/api/engine/public/pipelines")
|
|
255
|
+
|
|
256
|
+
api_client().get("https://app.getorchestra.io/api/engine/public/pipelines")
|
|
257
|
+
|
|
258
|
+
request = httpx_mock.get_request()
|
|
259
|
+
assert request is not None
|
|
260
|
+
assert re.fullmatch(r"[0-9a-f]{12}", request.headers[SUPPORT_ID_HEADER])
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def test_waf_block_shows_support_id_instead_of_its_page(capsys):
|
|
264
|
+
response = httpx.Response(
|
|
265
|
+
403,
|
|
266
|
+
headers={"content-type": "text/html"},
|
|
267
|
+
text="<html><head><title>403 Forbidden</title></head></html>",
|
|
268
|
+
request=httpx.Request(
|
|
269
|
+
"GET",
|
|
270
|
+
"https://app.getorchestra.io",
|
|
271
|
+
headers={SUPPORT_ID_HEADER: "0123456789ab"},
|
|
272
|
+
),
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
api_module.fail_with_response("Fetch pipelines", response)
|
|
276
|
+
|
|
277
|
+
out = capsys.readouterr().out
|
|
278
|
+
assert "Your request was blocked. Contact Orchestra support with ID: 0123456789ab" in out
|
|
279
|
+
assert "<html>" not in out
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def test_json_403_is_not_treated_as_waf_block(capsys):
|
|
283
|
+
response = httpx.Response(403, json={"detail": "Not allowed"})
|
|
284
|
+
|
|
285
|
+
api_module.fail_with_response("Fetch pipelines", response)
|
|
286
|
+
|
|
287
|
+
out = capsys.readouterr().out
|
|
288
|
+
assert "Not allowed" in out
|
|
289
|
+
assert "blocked" not in out
|
|
@@ -114,3 +114,46 @@ def test_require_repo_root_walks_up_from_nonexistent_parent(monkeypatch, tmp_pat
|
|
|
114
114
|
monkeypatch.setattr(subprocess, "run", make_git_subprocess_mock(mapping))
|
|
115
115
|
|
|
116
116
|
assert git_module.require_repo_root(target_file, git_module.GitAction.MIGRATE) == tmp_path
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@pytest.mark.parametrize(
|
|
120
|
+
"remote_url, expected",
|
|
121
|
+
[
|
|
122
|
+
("https://github.com/org/repo.git", "GITHUB"),
|
|
123
|
+
("git@github.com:org/repo.git", "GITHUB"),
|
|
124
|
+
("https://acme.ghe.com/org/repo.git", "GITHUB"),
|
|
125
|
+
("https://user:token@acme.ghe.com/org/repo.git", "GITHUB"),
|
|
126
|
+
("git@acme.ghe.com:org/repo.git", "GITHUB"),
|
|
127
|
+
("ssh://git@acme.ghe.com/org/repo.git", "GITHUB"),
|
|
128
|
+
("https://gitlab.com/org/repo.git", "GITLAB"),
|
|
129
|
+
("https://git.example.com/org/repo.git", None),
|
|
130
|
+
("https://example.com/ghe.com/repo.git", None),
|
|
131
|
+
("https://[malformed/org/repo.git", None),
|
|
132
|
+
(None, None),
|
|
133
|
+
],
|
|
134
|
+
)
|
|
135
|
+
def test_detect_storage_provider(remote_url: str | None, expected: str | None):
|
|
136
|
+
assert git_module.detect_storage_provider(remote_url) == expected
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@pytest.mark.parametrize(
|
|
140
|
+
"remote_url, expected",
|
|
141
|
+
[
|
|
142
|
+
(
|
|
143
|
+
"git@github.com:org/repo.git",
|
|
144
|
+
"https://github.com/org/repo/compare/main...feature?expand=1",
|
|
145
|
+
),
|
|
146
|
+
(
|
|
147
|
+
"git@acme.ghe.com:org/repo.git",
|
|
148
|
+
"https://acme.ghe.com/org/repo/compare/main...feature?expand=1",
|
|
149
|
+
),
|
|
150
|
+
(
|
|
151
|
+
"https://user:token@acme.ghe.com/org/repo.git",
|
|
152
|
+
"https://acme.ghe.com/org/repo/compare/main...feature?expand=1",
|
|
153
|
+
),
|
|
154
|
+
(None, "https://github.com/org/repo/compare/main...feature?expand=1"),
|
|
155
|
+
],
|
|
156
|
+
)
|
|
157
|
+
def test_build_compare_link_github(remote_url: str | None, expected: str):
|
|
158
|
+
link = git_module.build_compare_link("GITHUB", "org/repo", "main", "feature", remote_url)
|
|
159
|
+
assert link == expected
|
|
@@ -8,6 +8,7 @@ transport errors, or hand-roll JSON-vs-text error rendering themselves.
|
|
|
8
8
|
|
|
9
9
|
import json
|
|
10
10
|
import os
|
|
11
|
+
import secrets
|
|
11
12
|
import time
|
|
12
13
|
from collections.abc import Callable
|
|
13
14
|
from typing import Any
|
|
@@ -22,6 +23,8 @@ from .styling import indent_message, red, yellow
|
|
|
22
23
|
# Refresh this long before expiry so a token cannot lapse mid-request.
|
|
23
24
|
_REFRESH_MARGIN_SECONDS = 60
|
|
24
25
|
|
|
26
|
+
SUPPORT_ID_HEADER = "X-Orchestra-Support-Id"
|
|
27
|
+
|
|
25
28
|
|
|
26
29
|
def account_id_option() -> Any:
|
|
27
30
|
"""Return the ``--account-id`` option, whose value a command passes to ``api_client()``."""
|
|
@@ -33,6 +36,11 @@ def account_id_option() -> Any:
|
|
|
33
36
|
)
|
|
34
37
|
|
|
35
38
|
|
|
39
|
+
def support_id_headers() -> dict[str, str]:
|
|
40
|
+
"""Tag a request with a fresh ID that support can look up in the WAF logs."""
|
|
41
|
+
return {SUPPORT_ID_HEADER: secrets.token_hex(6)}
|
|
42
|
+
|
|
43
|
+
|
|
36
44
|
def require_credential() -> str:
|
|
37
45
|
"""Return the bearer credential for API calls, or exit with code 1.
|
|
38
46
|
|
|
@@ -77,6 +85,7 @@ def _refresh(credentials: dict) -> dict | None:
|
|
|
77
85
|
"refresh_token": credentials["refresh_token"],
|
|
78
86
|
"client_id": credentials["client_id"],
|
|
79
87
|
},
|
|
88
|
+
headers=support_id_headers(),
|
|
80
89
|
timeout=30,
|
|
81
90
|
)
|
|
82
91
|
except httpx.HTTPError as e:
|
|
@@ -85,7 +94,7 @@ def _refresh(credentials: dict) -> dict | None:
|
|
|
85
94
|
return credentials
|
|
86
95
|
typer.echo(red(f"HTTP request failed: {e}"))
|
|
87
96
|
raise typer.Exit(code=1)
|
|
88
|
-
if 400 <= response.status_code < 500:
|
|
97
|
+
if 400 <= response.status_code < 500 and not is_waf_block(response):
|
|
89
98
|
# Another CLI process may have refreshed first, rotating the refresh token
|
|
90
99
|
# this one sent; its saved result is then still good. Otherwise the login
|
|
91
100
|
# itself is unusable (invalid_grant, or a registration that has since expired)
|
|
@@ -128,6 +137,7 @@ class _OrchestraAuth(httpx.Auth):
|
|
|
128
137
|
self._scoped = scoped
|
|
129
138
|
|
|
130
139
|
def auth_flow(self, request: httpx.Request):
|
|
140
|
+
request.headers.update(support_id_headers())
|
|
131
141
|
request.headers["Authorization"] = f"Bearer {require_credential()}"
|
|
132
142
|
# After require_credential(), which drops a dead login, so a key never gets its default.
|
|
133
143
|
if self._scoped:
|
|
@@ -172,8 +182,22 @@ def echo_response_error_body(response: httpx.Response) -> None:
|
|
|
172
182
|
return
|
|
173
183
|
except Exception:
|
|
174
184
|
pass
|
|
175
|
-
|
|
176
|
-
|
|
185
|
+
text = response_error_text(response)
|
|
186
|
+
if text:
|
|
187
|
+
typer.echo(yellow(indent_message(text)))
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def is_waf_block(response: httpx.Response) -> bool:
|
|
191
|
+
"""Orchestra's own 403s are JSON, so an HTML 403 is the WAF's block page."""
|
|
192
|
+
return response.status_code == 403 and "text/html" in response.headers.get("content-type", "")
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def response_error_text(response: httpx.Response) -> str:
|
|
196
|
+
"""Return the body, or the support ID to quote if the WAF blocked the request."""
|
|
197
|
+
if is_waf_block(response):
|
|
198
|
+
support_id = response.request.headers.get(SUPPORT_ID_HEADER)
|
|
199
|
+
return f"Your request was blocked. Contact Orchestra support with ID: {support_id}"
|
|
200
|
+
return response.text
|
|
177
201
|
|
|
178
202
|
|
|
179
203
|
def fail_with_response(action: str, response: httpx.Response) -> typer.Exit:
|
|
@@ -4,11 +4,14 @@ import string
|
|
|
4
4
|
import subprocess
|
|
5
5
|
from enum import StrEnum
|
|
6
6
|
from pathlib import Path
|
|
7
|
+
from urllib.parse import urlsplit
|
|
7
8
|
|
|
8
9
|
import typer
|
|
9
10
|
|
|
10
11
|
from .styling import bold, red, yellow
|
|
11
12
|
|
|
13
|
+
GHE_COM_HOST_SUFFIX = ".ghe.com"
|
|
14
|
+
|
|
12
15
|
|
|
13
16
|
class GitAction(StrEnum):
|
|
14
17
|
BUILD = "Build"
|
|
@@ -84,12 +87,30 @@ def detect_current_branch(repo_root: Path, allow_detached: bool = True) -> str |
|
|
|
84
87
|
return out
|
|
85
88
|
|
|
86
89
|
|
|
90
|
+
def _remote_host(remote_url: str) -> str | None:
|
|
91
|
+
"""The host of a URL remote, or of the SSH shorthand ``[user@]host:path``."""
|
|
92
|
+
remote_url = remote_url.strip()
|
|
93
|
+
if "://" in remote_url:
|
|
94
|
+
try:
|
|
95
|
+
return urlsplit(remote_url).hostname
|
|
96
|
+
except ValueError:
|
|
97
|
+
return None
|
|
98
|
+
user_and_host, separator, _ = remote_url.partition(":")
|
|
99
|
+
return user_and_host.rpartition("@")[2].lower() if separator else None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _ghe_com_host(remote_url: str | None) -> str | None:
|
|
103
|
+
"""The remote's host if it's on GitHub Enterprise Cloud with data residency."""
|
|
104
|
+
host = _remote_host(remote_url) if remote_url else None
|
|
105
|
+
return host if host and host.endswith(GHE_COM_HOST_SUFFIX) else None
|
|
106
|
+
|
|
107
|
+
|
|
87
108
|
def detect_storage_provider(repository_url: str | None) -> str | None:
|
|
88
109
|
if not repository_url:
|
|
89
110
|
return None
|
|
90
111
|
|
|
91
112
|
url = repository_url.lower()
|
|
92
|
-
if "github.com" in url:
|
|
113
|
+
if "github.com" in url or _ghe_com_host(repository_url):
|
|
93
114
|
return "GITHUB"
|
|
94
115
|
if "gitlab.com" in url:
|
|
95
116
|
return "GITLAB"
|
|
@@ -103,10 +124,12 @@ def build_compare_link(
|
|
|
103
124
|
repository_slug: str,
|
|
104
125
|
default_branch: str,
|
|
105
126
|
branch_name: str,
|
|
127
|
+
remote_url: str | None,
|
|
106
128
|
) -> str | None:
|
|
107
129
|
provider = storage_provider.upper()
|
|
108
130
|
if provider == "GITHUB":
|
|
109
|
-
|
|
131
|
+
host = _ghe_com_host(remote_url) or "github.com"
|
|
132
|
+
return f"https://{host}/{repository_slug}/compare/{default_branch}...{branch_name}?expand=1"
|
|
110
133
|
if provider == "GITLAB":
|
|
111
134
|
return f"https://gitlab.com/{repository_slug}/-/compare/{default_branch}...{branch_name}"
|
|
112
135
|
return None
|
|
@@ -13,6 +13,7 @@ import httpx
|
|
|
13
13
|
import typer
|
|
14
14
|
import yaml
|
|
15
15
|
|
|
16
|
+
from .api import response_error_text, support_id_headers
|
|
16
17
|
from .constants import get_api_url
|
|
17
18
|
from .styling import indent_message, red, yellow
|
|
18
19
|
|
|
@@ -114,7 +115,12 @@ def load_yaml(file: Path) -> tuple[dict | None, str | None]:
|
|
|
114
115
|
def validate_yaml_with_api(data: dict) -> tuple[bool, str | None]:
|
|
115
116
|
"""POST a YAML payload to the schema endpoint and return ``(ok, err_message)``."""
|
|
116
117
|
try:
|
|
117
|
-
response = httpx.post(
|
|
118
|
+
response = httpx.post(
|
|
119
|
+
get_api_url("pipelines/schema"),
|
|
120
|
+
json=data,
|
|
121
|
+
headers=support_id_headers(),
|
|
122
|
+
timeout=15,
|
|
123
|
+
)
|
|
118
124
|
except Exception as e:
|
|
119
125
|
return False, f"HTTP request failed: {e}"
|
|
120
126
|
|
|
@@ -124,7 +130,7 @@ def validate_yaml_with_api(data: dict) -> tuple[bool, str | None]:
|
|
|
124
130
|
errors = response.json()
|
|
125
131
|
return False, json.dumps(errors, indent=2)
|
|
126
132
|
except Exception:
|
|
127
|
-
return False, response
|
|
133
|
+
return False, response_error_text(response)
|
|
128
134
|
|
|
129
135
|
|
|
130
136
|
def load_validated_pipeline_data(path: Path) -> dict:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|