dagster-cloud-cli 1.12.16__py2.py3-none-any.whl
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.
- dagster_cloud_cli/__init__.py +5 -0
- dagster_cloud_cli/commands/__init__.py +0 -0
- dagster_cloud_cli/commands/branch_deployment/__init__.py +154 -0
- dagster_cloud_cli/commands/ci/__init__.py +1207 -0
- dagster_cloud_cli/commands/ci/checks.py +135 -0
- dagster_cloud_cli/commands/ci/report.py +49 -0
- dagster_cloud_cli/commands/ci/state.py +129 -0
- dagster_cloud_cli/commands/ci/utils.py +22 -0
- dagster_cloud_cli/commands/config.py +250 -0
- dagster_cloud_cli/commands/deployment/__init__.py +108 -0
- dagster_cloud_cli/commands/deployment/alert_policies/__init__.py +0 -0
- dagster_cloud_cli/commands/deployment/alert_policies/commands.py +52 -0
- dagster_cloud_cli/commands/integration/__init__.py +6 -0
- dagster_cloud_cli/commands/integration/atlan/__init__.py +94 -0
- dagster_cloud_cli/commands/job/__init__.py +117 -0
- dagster_cloud_cli/commands/metrics.py +114 -0
- dagster_cloud_cli/commands/organization/__init__.py +134 -0
- dagster_cloud_cli/commands/organization/saml/__init__.py +0 -0
- dagster_cloud_cli/commands/organization/saml/commands.py +85 -0
- dagster_cloud_cli/commands/run/__init__.py +27 -0
- dagster_cloud_cli/commands/serverless/Dockerfile +38 -0
- dagster_cloud_cli/commands/serverless/Dockerfile.dagster-cloud-cli +20 -0
- dagster_cloud_cli/commands/serverless/__init__.py +560 -0
- dagster_cloud_cli/commands/workspace/__init__.py +358 -0
- dagster_cloud_cli/config/__init__.py +6 -0
- dagster_cloud_cli/config/jinja_template_loader.py +45 -0
- dagster_cloud_cli/config/list_merge_strategies.py +77 -0
- dagster_cloud_cli/config/loader.py +23 -0
- dagster_cloud_cli/config/merger.py +92 -0
- dagster_cloud_cli/config/models.py +76 -0
- dagster_cloud_cli/config_utils.py +506 -0
- dagster_cloud_cli/core/__init__.py +0 -0
- dagster_cloud_cli/core/agent_queue.py +3 -0
- dagster_cloud_cli/core/alert_types.py +26 -0
- dagster_cloud_cli/core/artifacts.py +192 -0
- dagster_cloud_cli/core/docker_runner.py +57 -0
- dagster_cloud_cli/core/errors.py +32 -0
- dagster_cloud_cli/core/graphql_client.py +420 -0
- dagster_cloud_cli/core/headers/__init__.py +0 -0
- dagster_cloud_cli/core/headers/auth.py +16 -0
- dagster_cloud_cli/core/headers/impl.py +31 -0
- dagster_cloud_cli/core/headers/versioning/__init__.py +0 -0
- dagster_cloud_cli/core/headers/versioning/constants.py +2 -0
- dagster_cloud_cli/core/pex_builder/__init__.py +8 -0
- dagster_cloud_cli/core/pex_builder/code_location.py +126 -0
- dagster_cloud_cli/core/pex_builder/deploy.py +255 -0
- dagster_cloud_cli/core/pex_builder/deps.py +495 -0
- dagster_cloud_cli/core/pex_builder/git_context.py +29 -0
- dagster_cloud_cli/core/pex_builder/github_context.py +178 -0
- dagster_cloud_cli/core/pex_builder/gitlab_context.py +30 -0
- dagster_cloud_cli/core/pex_builder/parse_workspace.py +39 -0
- dagster_cloud_cli/core/pex_builder/pex_registry.py +148 -0
- dagster_cloud_cli/core/pex_builder/platforms.py +6105 -0
- dagster_cloud_cli/core/pex_builder/selftest.py +14 -0
- dagster_cloud_cli/core/pex_builder/source.py +166 -0
- dagster_cloud_cli/core/pex_builder/util.py +165 -0
- dagster_cloud_cli/core/workspace.py +145 -0
- dagster_cloud_cli/docker_utils.py +98 -0
- dagster_cloud_cli/entrypoint.py +209 -0
- dagster_cloud_cli/gql.py +1025 -0
- dagster_cloud_cli/pex_utils.py +180 -0
- dagster_cloud_cli/py.typed +0 -0
- dagster_cloud_cli/types.py +78 -0
- dagster_cloud_cli/ui.py +101 -0
- dagster_cloud_cli/utils.py +107 -0
- dagster_cloud_cli/version.py +1 -0
- dagster_cloud_cli-1.12.16.dist-info/METADATA +31 -0
- dagster_cloud_cli-1.12.16.dist-info/RECORD +71 -0
- dagster_cloud_cli-1.12.16.dist-info/WHEEL +5 -0
- dagster_cloud_cli-1.12.16.dist-info/entry_points.txt +3 -0
- dagster_cloud_cli-1.12.16.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import pathlib
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from enum import Enum
|
|
5
|
+
|
|
6
|
+
import pydantic
|
|
7
|
+
|
|
8
|
+
from dagster_cloud_cli import gql, ui
|
|
9
|
+
from dagster_cloud_cli.config.models import load_dagster_cloud_yaml
|
|
10
|
+
from dagster_cloud_cli.config_utils import TOKEN_ENV_VAR_NAME
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def get_validation_errors(validation_error: pydantic.ValidationError) -> list[str]:
|
|
14
|
+
errors = []
|
|
15
|
+
for error in validation_error.errors():
|
|
16
|
+
if "type" in error:
|
|
17
|
+
location = ".".join([str(part) for part in error["loc"] if part != "__root__"])
|
|
18
|
+
if error["type"] == "value_error.missing":
|
|
19
|
+
errors.append(f"expected '{location}': missing required field")
|
|
20
|
+
elif error["type"] == "value_error.extra":
|
|
21
|
+
errors.append(f"unexpected '{location}': unknown field")
|
|
22
|
+
else:
|
|
23
|
+
errors.append(f"{error['type']} at '{location}': {error['msg']}")
|
|
24
|
+
return errors
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class CheckResult:
|
|
29
|
+
errors: list[str] = field(default_factory=list)
|
|
30
|
+
messages: list[str] = field(default_factory=list)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def check_dagster_cloud_yaml(yaml_path: pathlib.Path) -> CheckResult:
|
|
34
|
+
result = CheckResult()
|
|
35
|
+
|
|
36
|
+
if not yaml_path.exists():
|
|
37
|
+
result.errors.append(f"No such file {yaml_path}")
|
|
38
|
+
return result
|
|
39
|
+
|
|
40
|
+
yaml_text = yaml_path.read_text()
|
|
41
|
+
if not yaml_text.strip():
|
|
42
|
+
result.errors.append(f"Unexpected blank file {yaml_path}")
|
|
43
|
+
return result
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
parsed = load_dagster_cloud_yaml(yaml_path.read_text())
|
|
47
|
+
except pydantic.ValidationError as err:
|
|
48
|
+
for error in get_validation_errors(err):
|
|
49
|
+
result.errors.append(error)
|
|
50
|
+
return result
|
|
51
|
+
|
|
52
|
+
for location in parsed.locations:
|
|
53
|
+
if location.build and location.build.directory:
|
|
54
|
+
build_path = yaml_path.parent / location.build.directory
|
|
55
|
+
if not build_path.is_dir():
|
|
56
|
+
result.errors.append(
|
|
57
|
+
f"Build directory {build_path} not found for location"
|
|
58
|
+
f" {location.location_name} at {build_path.absolute()}"
|
|
59
|
+
)
|
|
60
|
+
return result
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class Check(Enum):
|
|
64
|
+
error = "error"
|
|
65
|
+
warn = "warn"
|
|
66
|
+
skip = "skip"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class Verdict(Enum):
|
|
70
|
+
failed = "failed"
|
|
71
|
+
warning = "warning"
|
|
72
|
+
skipped = "skipped"
|
|
73
|
+
passed = "passed"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def handle_result(
|
|
77
|
+
result: CheckResult,
|
|
78
|
+
check: Check,
|
|
79
|
+
prefix_message: str,
|
|
80
|
+
success_message: str,
|
|
81
|
+
failure_message: str,
|
|
82
|
+
) -> Verdict:
|
|
83
|
+
def full_msg(msg):
|
|
84
|
+
return prefix_message + msg
|
|
85
|
+
|
|
86
|
+
def passed(msg):
|
|
87
|
+
ui.print("✅" + full_msg(msg))
|
|
88
|
+
|
|
89
|
+
def warning(msg):
|
|
90
|
+
ui.print("🟡" + ui.yellow(full_msg(msg)))
|
|
91
|
+
|
|
92
|
+
def failed(msg):
|
|
93
|
+
ui.print("🚫" + ui.red(full_msg(msg)))
|
|
94
|
+
|
|
95
|
+
def print_indented(msgs):
|
|
96
|
+
lines = "\n".join(msgs).splitlines(keepends=False)
|
|
97
|
+
for line in lines:
|
|
98
|
+
ui.print(" | " + line)
|
|
99
|
+
|
|
100
|
+
if check == Check.skip:
|
|
101
|
+
return Verdict.skipped
|
|
102
|
+
|
|
103
|
+
if result.errors:
|
|
104
|
+
if check == Check.error:
|
|
105
|
+
failed(failure_message)
|
|
106
|
+
print_indented(result.messages)
|
|
107
|
+
print_indented(result.errors)
|
|
108
|
+
ui.print("\n")
|
|
109
|
+
return Verdict.failed
|
|
110
|
+
elif check == Check.warn:
|
|
111
|
+
warning(failure_message)
|
|
112
|
+
print_indented(result.messages)
|
|
113
|
+
print_indented(result.errors)
|
|
114
|
+
ui.print("\n")
|
|
115
|
+
return Verdict.warning
|
|
116
|
+
|
|
117
|
+
passed(success_message)
|
|
118
|
+
print_indented(result.messages)
|
|
119
|
+
ui.print("\n")
|
|
120
|
+
return Verdict.passed
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def check_connect_dagster_cloud(url) -> CheckResult:
|
|
124
|
+
if TOKEN_ENV_VAR_NAME not in os.environ:
|
|
125
|
+
return CheckResult([f"{TOKEN_ENV_VAR_NAME} not set"])
|
|
126
|
+
result = CheckResult()
|
|
127
|
+
result.messages.append(f"Connecting to {url} using {TOKEN_ENV_VAR_NAME}")
|
|
128
|
+
with gql.graphql_client_from_url(url, os.environ[TOKEN_ENV_VAR_NAME]) as client:
|
|
129
|
+
try:
|
|
130
|
+
gql.get_organization_settings(client)
|
|
131
|
+
result.messages.append("Connection successful")
|
|
132
|
+
except Exception as err:
|
|
133
|
+
result.errors.append(f"Failed to connect to {url}: {err}")
|
|
134
|
+
|
|
135
|
+
return result
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from dagster_cloud_cli.commands.ci import state
|
|
2
|
+
|
|
3
|
+
STATUS_IMAGES = {
|
|
4
|
+
state.LocationStatus.success: (
|
|
5
|
+
"https://raw.githubusercontent.com/dagster-io/dagster-cloud-action/main/assets/success.png"
|
|
6
|
+
),
|
|
7
|
+
state.LocationStatus.pending: (
|
|
8
|
+
"https://raw.githubusercontent.com/dagster-io/dagster-cloud-action/main/assets/pending.png"
|
|
9
|
+
),
|
|
10
|
+
state.LocationStatus.failed: (
|
|
11
|
+
"https://raw.githubusercontent.com/dagster-io/dagster-cloud-action/main/assets/failed.png"
|
|
12
|
+
),
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
STATUS_MESSAGES = {
|
|
16
|
+
state.LocationStatus.success: "View in Cloud",
|
|
17
|
+
state.LocationStatus.pending: "Building...",
|
|
18
|
+
state.LocationStatus.failed: "Deploy failed",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def markdown_report(location_states: list[state.LocationState]):
|
|
23
|
+
markdown = []
|
|
24
|
+
markdown.append(
|
|
25
|
+
"""
|
|
26
|
+
| Location | Status | Link | Updated |
|
|
27
|
+
| ----------------- | --------------- | ------- | --------------- |
|
|
28
|
+
"""
|
|
29
|
+
)
|
|
30
|
+
for location_state in location_states:
|
|
31
|
+
if not location_state.selected or not location_state.history:
|
|
32
|
+
continue
|
|
33
|
+
last_status = location_state.history[-1]
|
|
34
|
+
image_url = STATUS_IMAGES[last_status.status]
|
|
35
|
+
message = STATUS_MESSAGES[last_status.status]
|
|
36
|
+
if last_status.status == state.LocationStatus.success or not location_state.status_url:
|
|
37
|
+
link_url = f"{location_state.url}/{location_state.deployment_name}/locations"
|
|
38
|
+
else:
|
|
39
|
+
link_url = location_state.status_url
|
|
40
|
+
status_image = f'[<img src="{image_url}" width=25 height=25/>]({link_url})'
|
|
41
|
+
status_message = f"[{message}]({link_url})"
|
|
42
|
+
|
|
43
|
+
time_str = last_status.timestamp.strftime("%b %d, %Y at %I:%M %p (%Z)")
|
|
44
|
+
markdown.append(
|
|
45
|
+
f"| {location_state.location_name} | {status_image} | {status_message} |"
|
|
46
|
+
f" {time_str} |\n"
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
return "".join(markdown)
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
from abc import ABCMeta, abstractmethod
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
from dagster_shared.serdes.objects.models import DefsStateInfo
|
|
9
|
+
from pydantic import BaseModel, Extra, Field
|
|
10
|
+
|
|
11
|
+
from dagster_cloud_cli.config import models
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DockerBuildOutput(BaseModel, extra=Extra.forbid):
|
|
15
|
+
strategy: Literal["docker"] = "docker"
|
|
16
|
+
python_version: str | None = None
|
|
17
|
+
image: str
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class PexBuildOutput(BaseModel, extra=Extra.forbid):
|
|
21
|
+
strategy: Literal["python-executable"] = "python-executable"
|
|
22
|
+
python_version: str
|
|
23
|
+
image: str | None # if None we determine the image from the python_version and agent version
|
|
24
|
+
pex_tag: str
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class BuildMetadata(BaseModel):
|
|
28
|
+
git_url: str | None
|
|
29
|
+
commit_hash: str | None
|
|
30
|
+
build_config: models.Build | None # copied from dagster_cloud.yaml
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class LocationStatus(Enum):
|
|
34
|
+
success = "success"
|
|
35
|
+
pending = "pending"
|
|
36
|
+
failed = "failed"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class StatusChange(BaseModel):
|
|
40
|
+
timestamp: datetime.datetime
|
|
41
|
+
status: LocationStatus
|
|
42
|
+
log: str
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class LocationState(BaseModel, extra=Extra.forbid):
|
|
46
|
+
# we intentionally don't save api_token here for security reasons
|
|
47
|
+
url: str
|
|
48
|
+
deployment_name: str
|
|
49
|
+
location_file: str
|
|
50
|
+
location_name: str
|
|
51
|
+
is_branch_deployment: bool
|
|
52
|
+
selected: bool = True
|
|
53
|
+
build: BuildMetadata
|
|
54
|
+
build_output: DockerBuildOutput | PexBuildOutput | None = Field(None, discriminator="strategy")
|
|
55
|
+
defs_state_info: DefsStateInfo | None = None
|
|
56
|
+
status_url: str | None # link to cicd run url when building and dagster cloud url when done
|
|
57
|
+
history: list[StatusChange] = []
|
|
58
|
+
project_dir: str | None = None
|
|
59
|
+
|
|
60
|
+
def add_status_change(self, status: LocationStatus, log: str):
|
|
61
|
+
self.history.append(
|
|
62
|
+
StatusChange(
|
|
63
|
+
timestamp=datetime.datetime.now(datetime.timezone.utc),
|
|
64
|
+
status=status,
|
|
65
|
+
log=log,
|
|
66
|
+
)
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class Store(metaclass=ABCMeta):
|
|
71
|
+
@abstractmethod
|
|
72
|
+
def load(self, location_name: str) -> LocationState: ...
|
|
73
|
+
|
|
74
|
+
@abstractmethod
|
|
75
|
+
def save(self, location_state: LocationState): ...
|
|
76
|
+
|
|
77
|
+
@abstractmethod
|
|
78
|
+
def list_locations(self) -> list[LocationState]: ...
|
|
79
|
+
|
|
80
|
+
def list_selected_locations(self) -> list[LocationState]:
|
|
81
|
+
return [location for location in self.list_locations() if location.selected]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class FileStore(Store):
|
|
85
|
+
def __init__(self, statedir: str):
|
|
86
|
+
self.statedir = os.path.abspath(statedir)
|
|
87
|
+
self.location_file_prefix = "location-"
|
|
88
|
+
if not os.path.isdir(self.statedir):
|
|
89
|
+
os.makedirs(self.statedir)
|
|
90
|
+
|
|
91
|
+
def __repr__(self):
|
|
92
|
+
return f"<FileStore(statedir={self.statedir!r})>"
|
|
93
|
+
|
|
94
|
+
def _get_filepath(self, location_name) -> str:
|
|
95
|
+
return os.path.join(self.statedir, f"{self.location_file_prefix}{location_name}.json")
|
|
96
|
+
|
|
97
|
+
def list_locations(self) -> list[LocationState]:
|
|
98
|
+
return [
|
|
99
|
+
self._location_from_file(os.path.join(self.statedir, filename))
|
|
100
|
+
for filename in os.listdir(self.statedir)
|
|
101
|
+
if filename.startswith(self.location_file_prefix)
|
|
102
|
+
]
|
|
103
|
+
|
|
104
|
+
def load(self, location_name: str) -> LocationState:
|
|
105
|
+
filepath = self._get_filepath(location_name)
|
|
106
|
+
if not filepath:
|
|
107
|
+
raise KeyError(f"No saved state for {location_name} at {filepath}")
|
|
108
|
+
return self._location_from_file(self._get_filepath(location_name))
|
|
109
|
+
|
|
110
|
+
def _location_from_file(self, filepath: str) -> LocationState:
|
|
111
|
+
with open(filepath, encoding="utf-8") as f:
|
|
112
|
+
return LocationState.parse_obj(json.load(f))
|
|
113
|
+
|
|
114
|
+
def save(self, location_state: LocationState):
|
|
115
|
+
filepath = self._get_filepath(location_state.location_name)
|
|
116
|
+
with open(filepath, "w", encoding="utf-8") as f:
|
|
117
|
+
f.write(location_state.json())
|
|
118
|
+
|
|
119
|
+
def deselect(self, location_names: list[str]):
|
|
120
|
+
locations = [self.load(location_name) for location_name in location_names]
|
|
121
|
+
for location in locations:
|
|
122
|
+
location.selected = False
|
|
123
|
+
self.save(location)
|
|
124
|
+
|
|
125
|
+
def select(self, location_names: list[str]):
|
|
126
|
+
locations = [self.load(location_name) for location_name in location_names]
|
|
127
|
+
for location in locations:
|
|
128
|
+
location.selected = True
|
|
129
|
+
self.save(location)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from contextlib import contextmanager
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from dagster_shared import check
|
|
5
|
+
|
|
6
|
+
from dagster_cloud_cli import gql
|
|
7
|
+
from dagster_cloud_cli.config_utils import get_user_token
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@contextmanager
|
|
11
|
+
def client_from_env(url: str, deployment: str | None = None):
|
|
12
|
+
if deployment:
|
|
13
|
+
url = url + "/" + deployment
|
|
14
|
+
with gql.graphql_client_from_url(
|
|
15
|
+
url, check.not_none(get_user_token()), deployment_name=deployment
|
|
16
|
+
) as client:
|
|
17
|
+
yield client
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def get_registry_info(url: str) -> dict[str, Any]:
|
|
21
|
+
with client_from_env(url) as client:
|
|
22
|
+
return gql.get_ecr_info(client)
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import webbrowser
|
|
3
|
+
from enum import Enum
|
|
4
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
5
|
+
from urllib import parse
|
|
6
|
+
|
|
7
|
+
from dagster_shared.plus.config import DagsterPlusCliConfig
|
|
8
|
+
from dagster_shared.plus.login_server import start_login_server
|
|
9
|
+
from typer import Argument, Context, Option, Typer
|
|
10
|
+
|
|
11
|
+
from dagster_cloud_cli import gql, ui
|
|
12
|
+
from dagster_cloud_cli.config_utils import (
|
|
13
|
+
available_deployment_names,
|
|
14
|
+
dagster_cloud_options,
|
|
15
|
+
read_config,
|
|
16
|
+
write_config,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
app = Typer(help="Configure the Dagster Cloud CLI.")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@app.command()
|
|
23
|
+
@dagster_cloud_options()
|
|
24
|
+
def set_deployment(
|
|
25
|
+
ctx: Context,
|
|
26
|
+
deployment: str = Argument(..., autocompletion=available_deployment_names),
|
|
27
|
+
):
|
|
28
|
+
"""Set the default deployment for CLI commands."""
|
|
29
|
+
deployments = available_deployment_names(ctx=ctx)
|
|
30
|
+
if not deployment or deployment not in deployments:
|
|
31
|
+
raise ui.error(f"Deployment {ui.as_code(deployment)} not found")
|
|
32
|
+
|
|
33
|
+
config = read_config()
|
|
34
|
+
new_config = DagsterPlusCliConfig(
|
|
35
|
+
**{**config.__dict__, "default_deployment": deployment},
|
|
36
|
+
)
|
|
37
|
+
write_config(new_config)
|
|
38
|
+
|
|
39
|
+
ui.print(f"Default deployment changed to {ui.as_code(deployment)}")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@app.command()
|
|
43
|
+
def view(
|
|
44
|
+
show_token: bool = Option(
|
|
45
|
+
False,
|
|
46
|
+
"--show-token",
|
|
47
|
+
"-s",
|
|
48
|
+
help="Whether to display the user token in plaintext.",
|
|
49
|
+
),
|
|
50
|
+
):
|
|
51
|
+
"""View the current CLI configuration."""
|
|
52
|
+
config = read_config()
|
|
53
|
+
config_to_display = {k: v for k, v in config.__dict__.items() if v is not None}
|
|
54
|
+
if not show_token and config.user_token:
|
|
55
|
+
config_to_display["user_token"] = ui.censor_token(config.user_token)
|
|
56
|
+
ui.print_yaml(config_to_display)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
app_configure = Typer()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def create_token_callback_handler(nonce: str) -> type[BaseHTTPRequestHandler]:
|
|
63
|
+
class TokenCallbackHandler(BaseHTTPRequestHandler):
|
|
64
|
+
def _send_shared_headers(self):
|
|
65
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
66
|
+
self.send_header("Access-Control-Allow-Methods", "POST,OPTIONS")
|
|
67
|
+
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
|
68
|
+
|
|
69
|
+
def _error(self, code, message=None):
|
|
70
|
+
self.send_error(code, message)
|
|
71
|
+
self._send_shared_headers()
|
|
72
|
+
self.end_headers()
|
|
73
|
+
|
|
74
|
+
def do_OPTIONS(self):
|
|
75
|
+
self.send_response(200)
|
|
76
|
+
self._send_shared_headers()
|
|
77
|
+
self.end_headers()
|
|
78
|
+
|
|
79
|
+
def do_POST(self):
|
|
80
|
+
url = parse.urlparse(self.path)
|
|
81
|
+
|
|
82
|
+
if url.path != "/callback":
|
|
83
|
+
self._error(404, "Page not found")
|
|
84
|
+
return
|
|
85
|
+
|
|
86
|
+
content = self.rfile.read(int(self.headers["Content-Length"]))
|
|
87
|
+
body = json.loads(content)
|
|
88
|
+
|
|
89
|
+
if not body.get("nonce") or body.get("nonce") != nonce:
|
|
90
|
+
self._error(400, "Invalid nonce")
|
|
91
|
+
return
|
|
92
|
+
if not body.get("token"):
|
|
93
|
+
self._error(400, "No user token provided")
|
|
94
|
+
return
|
|
95
|
+
if not body.get("organization"):
|
|
96
|
+
self._error(400, "No organization provided")
|
|
97
|
+
return
|
|
98
|
+
|
|
99
|
+
organization = body.get("organization")
|
|
100
|
+
token = body.get("token")
|
|
101
|
+
|
|
102
|
+
self.send_response(200)
|
|
103
|
+
self._send_shared_headers()
|
|
104
|
+
response = b'{ "ok": true }'
|
|
105
|
+
self.send_header("Content-length", str(len(response)))
|
|
106
|
+
self.end_headers()
|
|
107
|
+
|
|
108
|
+
self.wfile.write(response)
|
|
109
|
+
|
|
110
|
+
# Pass back result values
|
|
111
|
+
assert isinstance(self.server, TokenServer)
|
|
112
|
+
self.server.set_result(organization=organization, token=token)
|
|
113
|
+
self.server.shutdown()
|
|
114
|
+
|
|
115
|
+
# Overridden so that the webserver doesn't log requests to the console
|
|
116
|
+
|
|
117
|
+
def log_request(self, code="-", size="-"):
|
|
118
|
+
return
|
|
119
|
+
|
|
120
|
+
return TokenCallbackHandler
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class TokenServer(HTTPServer):
|
|
124
|
+
organization: str | None = None
|
|
125
|
+
token: str | None = None
|
|
126
|
+
|
|
127
|
+
def __init__(self, host: tuple[str, int], nonce: str):
|
|
128
|
+
super().__init__(host, create_token_callback_handler(nonce))
|
|
129
|
+
|
|
130
|
+
def shutdown(self):
|
|
131
|
+
# Stop serving the token server
|
|
132
|
+
# https://stackoverflow.com/a/36017741
|
|
133
|
+
setattr(self, "_BaseServer__shutdown_request", True)
|
|
134
|
+
|
|
135
|
+
def set_result(self, organization: str, token: str):
|
|
136
|
+
self.organization = organization
|
|
137
|
+
self.token = token
|
|
138
|
+
|
|
139
|
+
def get_organization(self) -> str | None:
|
|
140
|
+
return self.organization
|
|
141
|
+
|
|
142
|
+
def get_token(self) -> str | None:
|
|
143
|
+
return self.token
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class SetupAuthMethod(Enum):
|
|
147
|
+
WEB = "web"
|
|
148
|
+
CLI = "cli"
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _settings_method_input(api_token: str):
|
|
152
|
+
if api_token:
|
|
153
|
+
choices = [
|
|
154
|
+
ui.choice(SetupAuthMethod.CLI, "Authenticate using token or keep current settings"),
|
|
155
|
+
ui.choice(SetupAuthMethod.WEB, "Authenticate in browser"),
|
|
156
|
+
]
|
|
157
|
+
else:
|
|
158
|
+
choices = [
|
|
159
|
+
ui.choice(SetupAuthMethod.WEB, "Authenticate in browser"),
|
|
160
|
+
ui.choice(SetupAuthMethod.CLI, "Authenticate using token"),
|
|
161
|
+
]
|
|
162
|
+
return ui.list_input(
|
|
163
|
+
"How would you like to authenticate the CLI?",
|
|
164
|
+
choices,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _setup(organization: str, deployment: str, api_token: str):
|
|
169
|
+
setup_method = _settings_method_input(api_token)
|
|
170
|
+
|
|
171
|
+
if setup_method == SetupAuthMethod.WEB:
|
|
172
|
+
server, url = start_login_server()
|
|
173
|
+
|
|
174
|
+
try:
|
|
175
|
+
webbrowser.open(url, new=0, autoraise=True)
|
|
176
|
+
ui.print(
|
|
177
|
+
f"Opening browser...\nIf a window does not open automatically, visit {url} to"
|
|
178
|
+
" finish authorization"
|
|
179
|
+
)
|
|
180
|
+
except webbrowser.Error as e:
|
|
181
|
+
ui.warn(f"Error launching web browser: {e}\n\nTo finish authorization, visit {url}\n")
|
|
182
|
+
|
|
183
|
+
server.serve_forever()
|
|
184
|
+
new_org = server.get_organization()
|
|
185
|
+
new_api_token = server.get_token()
|
|
186
|
+
ui.print(f"Authorized for organization {ui.as_code(str(new_org))}\n")
|
|
187
|
+
else:
|
|
188
|
+
new_org = ui.input("Dagster Cloud organization:", default=organization or "") or None
|
|
189
|
+
if not api_token:
|
|
190
|
+
deployment_name = deployment if deployment else "prod"
|
|
191
|
+
ui.print(
|
|
192
|
+
"\nTo create a new user token or find an existing token, visit"
|
|
193
|
+
f" https://dagster.cloud/{new_org}/{deployment_name}/org-settings/tokens"
|
|
194
|
+
)
|
|
195
|
+
new_api_token = (
|
|
196
|
+
ui.password_input("Dagster Cloud user token:", default=api_token or "") or None
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
# Attempt to fetch deployment names from server, fallback to a text input upon failure
|
|
200
|
+
deployment_names = []
|
|
201
|
+
if new_org and new_api_token:
|
|
202
|
+
try:
|
|
203
|
+
with gql.graphql_client_from_url(gql.url_from_config(new_org), new_api_token) as client:
|
|
204
|
+
deployments = gql.fetch_full_deployments(client)
|
|
205
|
+
deployment_names = [deployment["deploymentName"] for deployment in deployments]
|
|
206
|
+
except:
|
|
207
|
+
ui.warn(
|
|
208
|
+
"Could not fetch deployment names from server - organization or user token may be"
|
|
209
|
+
" set incorrectly."
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
new_deployment: str | None = None
|
|
213
|
+
if deployment_names:
|
|
214
|
+
options = ["None"] + deployment_names
|
|
215
|
+
new_deployment = ui.list_input(
|
|
216
|
+
"Default deployment:",
|
|
217
|
+
choices=options,
|
|
218
|
+
default=deployment if deployment in options else "None",
|
|
219
|
+
)
|
|
220
|
+
if new_deployment == "None":
|
|
221
|
+
new_deployment = None
|
|
222
|
+
else:
|
|
223
|
+
new_deployment = ui.input("Default deployment:", default=deployment or "") or None
|
|
224
|
+
|
|
225
|
+
write_config(
|
|
226
|
+
DagsterPlusCliConfig(
|
|
227
|
+
organization=new_org,
|
|
228
|
+
default_deployment=new_deployment,
|
|
229
|
+
user_token=new_api_token,
|
|
230
|
+
)
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
@app.command()
|
|
235
|
+
@dagster_cloud_options(allow_empty=True)
|
|
236
|
+
def setup(organization: str, deployment: str, api_token: str):
|
|
237
|
+
"""Populate the CLI configuration."""
|
|
238
|
+
_setup(organization, deployment, api_token)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
app_configure = Typer(hidden=True)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
# Legacy, to support the old `dagster-cloud configure` path
|
|
245
|
+
# New command is `dagster-cloud config setup`
|
|
246
|
+
@app_configure.command(name="configure", hidden=True)
|
|
247
|
+
@dagster_cloud_options(allow_empty=True)
|
|
248
|
+
def configure_legacy(organization: str, deployment: str, api_token: str):
|
|
249
|
+
"""Populate the CLI configuration."""
|
|
250
|
+
_setup(organization, deployment, api_token)
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Annotated
|
|
3
|
+
|
|
4
|
+
import yaml
|
|
5
|
+
from typer import Argument, Typer
|
|
6
|
+
|
|
7
|
+
from dagster_cloud_cli import gql, ui
|
|
8
|
+
from dagster_cloud_cli.config_utils import dagster_cloud_options
|
|
9
|
+
from dagster_cloud_cli.core.artifacts import download_artifact, upload_artifact
|
|
10
|
+
from dagster_cloud_cli.core.headers.auth import DagsterCloudInstanceScope
|
|
11
|
+
from dagster_cloud_cli.utils import create_stub_app
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
from dagster_cloud_cli.commands.deployment.alert_policies.commands import (
|
|
15
|
+
app as alert_policies_app,
|
|
16
|
+
)
|
|
17
|
+
except ImportError:
|
|
18
|
+
alert_policies_app = create_stub_app("dagster-cloud")
|
|
19
|
+
|
|
20
|
+
app = Typer(help="Customize your Dagster Cloud deployment.")
|
|
21
|
+
|
|
22
|
+
settings_app = Typer(help="Customize your deployment settings.")
|
|
23
|
+
app.add_typer(settings_app, name="settings", no_args_is_help=True)
|
|
24
|
+
app.add_typer(alert_policies_app, name="alert-policies", no_args_is_help=True)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@settings_app.command(name="set-from-file")
|
|
28
|
+
@dagster_cloud_options(allow_empty=True, requires_url=True)
|
|
29
|
+
def set_from_filecommand(
|
|
30
|
+
api_token: str,
|
|
31
|
+
url: str,
|
|
32
|
+
deployment: str | None,
|
|
33
|
+
file_path: Path = Argument(..., readable=True, metavar="SETTINGS_FILE_PATH"),
|
|
34
|
+
):
|
|
35
|
+
"""Set the Dagster Cloud deployment settings from a YAML file."""
|
|
36
|
+
with open(file_path, encoding="utf8") as f:
|
|
37
|
+
settings = {"settings": yaml.safe_load(f) or {}}
|
|
38
|
+
with gql.graphql_client_from_url(url, api_token, deployment_name=deployment) as client:
|
|
39
|
+
gql.set_deployment_settings(client, settings)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@settings_app.command(name="get")
|
|
43
|
+
@dagster_cloud_options(allow_empty=True, requires_url=True)
|
|
44
|
+
def get_command(
|
|
45
|
+
api_token: str,
|
|
46
|
+
url: str,
|
|
47
|
+
deployment: str | None,
|
|
48
|
+
):
|
|
49
|
+
"""Get the Dagster Cloud deployment settings."""
|
|
50
|
+
with gql.graphql_client_from_url(url, api_token, deployment_name=deployment) as client:
|
|
51
|
+
settings = gql.get_deployment_settings(client)
|
|
52
|
+
ui.print_yaml(settings)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@app.command(name="upload-artifact")
|
|
56
|
+
@dagster_cloud_options(allow_empty=True, requires_url=True)
|
|
57
|
+
def upload_artifact_command(
|
|
58
|
+
key: Annotated[
|
|
59
|
+
str,
|
|
60
|
+
Argument(
|
|
61
|
+
help="The key for the artifact being uploaded.",
|
|
62
|
+
),
|
|
63
|
+
],
|
|
64
|
+
path: Annotated[
|
|
65
|
+
str,
|
|
66
|
+
Argument(
|
|
67
|
+
help="The path to the file to upload.",
|
|
68
|
+
),
|
|
69
|
+
],
|
|
70
|
+
api_token: str,
|
|
71
|
+
url: str,
|
|
72
|
+
):
|
|
73
|
+
"""Upload a deployment scoped artifact."""
|
|
74
|
+
upload_artifact(
|
|
75
|
+
url=url,
|
|
76
|
+
scope=DagsterCloudInstanceScope.DEPLOYMENT,
|
|
77
|
+
api_token=api_token,
|
|
78
|
+
key=key,
|
|
79
|
+
path=path,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@app.command(name="download-artifact")
|
|
84
|
+
@dagster_cloud_options(allow_empty=True, requires_url=True)
|
|
85
|
+
def download_artifact_command(
|
|
86
|
+
key: Annotated[
|
|
87
|
+
str,
|
|
88
|
+
Argument(
|
|
89
|
+
help="The key for the artifact to download.",
|
|
90
|
+
),
|
|
91
|
+
],
|
|
92
|
+
path: Annotated[
|
|
93
|
+
str,
|
|
94
|
+
Argument(
|
|
95
|
+
help="Path to the file that the contents should be written to.",
|
|
96
|
+
),
|
|
97
|
+
],
|
|
98
|
+
api_token: str,
|
|
99
|
+
url: str,
|
|
100
|
+
):
|
|
101
|
+
"""Download a deployment scoped artifact."""
|
|
102
|
+
download_artifact(
|
|
103
|
+
url=url,
|
|
104
|
+
scope=DagsterCloudInstanceScope.DEPLOYMENT,
|
|
105
|
+
api_token=api_token,
|
|
106
|
+
key=key,
|
|
107
|
+
path=path,
|
|
108
|
+
)
|
|
File without changes
|