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,1207 @@
|
|
|
1
|
+
# CI/CD agnostic commands that work with the current CI/CD system
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import pathlib
|
|
6
|
+
import shutil
|
|
7
|
+
import sys
|
|
8
|
+
from collections import Counter
|
|
9
|
+
from enum import Enum
|
|
10
|
+
from typing import Annotated, Any, cast
|
|
11
|
+
|
|
12
|
+
import click
|
|
13
|
+
import typer
|
|
14
|
+
import yaml
|
|
15
|
+
from dagster_shared import check
|
|
16
|
+
from dagster_shared.utils import remove_none_recursively
|
|
17
|
+
from jinja2 import TemplateSyntaxError
|
|
18
|
+
from typer import Typer
|
|
19
|
+
|
|
20
|
+
from dagster_cloud_cli import docker_utils, gql, pex_utils, ui
|
|
21
|
+
from dagster_cloud_cli.commands import metrics
|
|
22
|
+
from dagster_cloud_cli.commands.ci import checks, report, state, utils
|
|
23
|
+
from dagster_cloud_cli.commands.workspace import wait_for_load
|
|
24
|
+
from dagster_cloud_cli.config import DagsterCloudConfigDefaultsMerger, JinjaTemplateLoader
|
|
25
|
+
from dagster_cloud_cli.config.models import load_dagster_cloud_yaml
|
|
26
|
+
from dagster_cloud_cli.config_utils import (
|
|
27
|
+
DAGSTER_ENV_OPTION,
|
|
28
|
+
LOCATION_LOAD_TIMEOUT_OPTION,
|
|
29
|
+
ORGANIZATION_OPTION,
|
|
30
|
+
URL_ENV_VAR_NAME,
|
|
31
|
+
dagster_cloud_options,
|
|
32
|
+
get_agent_heartbeat_timeout_option,
|
|
33
|
+
get_location_document,
|
|
34
|
+
get_org_url,
|
|
35
|
+
get_user_token,
|
|
36
|
+
)
|
|
37
|
+
from dagster_cloud_cli.core import pex_builder
|
|
38
|
+
from dagster_cloud_cli.core.artifacts import (
|
|
39
|
+
download_organization_artifact,
|
|
40
|
+
upload_organization_artifact,
|
|
41
|
+
)
|
|
42
|
+
from dagster_cloud_cli.core.pex_builder import (
|
|
43
|
+
code_location,
|
|
44
|
+
deps,
|
|
45
|
+
github_context,
|
|
46
|
+
gitlab_context,
|
|
47
|
+
parse_workspace,
|
|
48
|
+
)
|
|
49
|
+
from dagster_cloud_cli.gql import DagsterPlusDeploymentAgentType
|
|
50
|
+
from dagster_cloud_cli.types import CliEventTags, CliEventType, SnapshotBaseDeploymentCondition
|
|
51
|
+
from dagster_cloud_cli.utils import DEFAULT_PYTHON_VERSION
|
|
52
|
+
|
|
53
|
+
app = Typer(help="Commands for deploying code to Dagster+ from any CI/CD environment")
|
|
54
|
+
|
|
55
|
+
DEFAULT_SERVERLESS_AGENT_HEARTBEAT_TIMEOUT = 900
|
|
56
|
+
DEFAULT_HYBRID_AGENT_HEARTBEAT_TIMEOUT = 120
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@app.callback()
|
|
60
|
+
def main():
|
|
61
|
+
logging.basicConfig(
|
|
62
|
+
level=logging.getLevelName(os.getenv("DAGSTER_CLOUD_CLI_LOG_LEVEL", "WARNING"))
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@app.command(help="Print json information about current CI/CD environment")
|
|
67
|
+
def inspect(project_dir: str):
|
|
68
|
+
project_dir = os.path.abspath(project_dir)
|
|
69
|
+
source = metrics.get_source()
|
|
70
|
+
info = {"source": str(source), "project-dir": project_dir}
|
|
71
|
+
if source == CliEventTags.source.github:
|
|
72
|
+
info.update(load_github_info(project_dir))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def load_github_info(project_dir: str) -> dict[str, Any]:
|
|
76
|
+
event = github_context.get_github_event(project_dir)
|
|
77
|
+
return {
|
|
78
|
+
"git-url": event.commit_url,
|
|
79
|
+
"commit-hash": event.github_sha,
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@app.command(
|
|
84
|
+
help=(
|
|
85
|
+
"Print the branch deployment name (or nothing) for the current context. Creates a new"
|
|
86
|
+
" branch deployment if necessary. Requires DAGSTER_CLOUD_ORGANIZATION and"
|
|
87
|
+
" DAGSTER_CLOUD_API_TOKEN environment variables."
|
|
88
|
+
)
|
|
89
|
+
)
|
|
90
|
+
def branch_deployment(
|
|
91
|
+
project_dir: str,
|
|
92
|
+
organization: str | None = ORGANIZATION_OPTION,
|
|
93
|
+
dagster_env: str | None = DAGSTER_ENV_OPTION,
|
|
94
|
+
mark_closed: bool = False,
|
|
95
|
+
read_only: bool = False,
|
|
96
|
+
base_deployment_name: str | None = None,
|
|
97
|
+
snapshot_base_condition: SnapshotBaseDeploymentCondition | None = None,
|
|
98
|
+
):
|
|
99
|
+
try:
|
|
100
|
+
if organization:
|
|
101
|
+
url = get_org_url(organization, dagster_env)
|
|
102
|
+
else:
|
|
103
|
+
url = os.environ[URL_ENV_VAR_NAME]
|
|
104
|
+
|
|
105
|
+
if read_only:
|
|
106
|
+
print(get_branch_deployment_name_from_context(url, project_dir)) # noqa: T201
|
|
107
|
+
return
|
|
108
|
+
|
|
109
|
+
print( # noqa: T201
|
|
110
|
+
create_or_update_deployment_from_context(
|
|
111
|
+
url,
|
|
112
|
+
project_dir,
|
|
113
|
+
mark_closed,
|
|
114
|
+
base_deployment_name=base_deployment_name,
|
|
115
|
+
snapshot_base_condition=snapshot_base_condition,
|
|
116
|
+
require_branch_deployment=True,
|
|
117
|
+
)
|
|
118
|
+
)
|
|
119
|
+
except ValueError as err:
|
|
120
|
+
ui.error(
|
|
121
|
+
f"cannot determine branch deployment: {err}",
|
|
122
|
+
)
|
|
123
|
+
sys.exit(1)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def create_or_update_deployment_from_context(
|
|
127
|
+
url,
|
|
128
|
+
project_dir: str,
|
|
129
|
+
mark_closed: bool,
|
|
130
|
+
base_deployment_name: str | None,
|
|
131
|
+
snapshot_base_condition: SnapshotBaseDeploymentCondition | None,
|
|
132
|
+
require_branch_deployment: bool = False,
|
|
133
|
+
) -> str | None:
|
|
134
|
+
source = metrics.get_source()
|
|
135
|
+
api_token = check.not_none(get_user_token())
|
|
136
|
+
if source == CliEventTags.source.github:
|
|
137
|
+
event = github_context.get_github_event(project_dir)
|
|
138
|
+
deployment_name = code_location.create_or_update_branch_deployment_from_github_context(
|
|
139
|
+
url,
|
|
140
|
+
api_token,
|
|
141
|
+
event,
|
|
142
|
+
mark_closed,
|
|
143
|
+
base_deployment_name=base_deployment_name,
|
|
144
|
+
snapshot_base_condition=snapshot_base_condition,
|
|
145
|
+
)
|
|
146
|
+
if not deployment_name:
|
|
147
|
+
raise ValueError(
|
|
148
|
+
f"could not determine branch deployment for PR {event.pull_request_id}"
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
return deployment_name
|
|
152
|
+
elif source == CliEventTags.source.gitlab:
|
|
153
|
+
event = gitlab_context.get_gitlab_event(project_dir)
|
|
154
|
+
if not event.merge_request_iid:
|
|
155
|
+
raise ValueError("no merge request id")
|
|
156
|
+
with gql.graphql_client_from_url(url, api_token) as client:
|
|
157
|
+
deployment_name = gql.create_or_update_branch_deployment(
|
|
158
|
+
client,
|
|
159
|
+
repo_name=event.project_name,
|
|
160
|
+
branch_name=event.branch_name,
|
|
161
|
+
commit_hash=event.commit_sha,
|
|
162
|
+
timestamp=float(event.git_metadata.timestamp),
|
|
163
|
+
branch_url=event.branch_url,
|
|
164
|
+
pull_request_url=event.merge_request_url,
|
|
165
|
+
pull_request_status=("CLOSED" if mark_closed else "OPEN"),
|
|
166
|
+
pull_request_number=event.merge_request_iid,
|
|
167
|
+
author_name=event.git_metadata.name,
|
|
168
|
+
author_email=event.git_metadata.email,
|
|
169
|
+
commit_message=event.git_metadata.message,
|
|
170
|
+
base_deployment_name=base_deployment_name,
|
|
171
|
+
snapshot_base_condition=snapshot_base_condition,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
return deployment_name
|
|
175
|
+
|
|
176
|
+
elif source == CliEventTags.source.cli:
|
|
177
|
+
if require_branch_deployment:
|
|
178
|
+
deployment_name = (
|
|
179
|
+
code_location.create_or_update_branch_deployment_from_local_git_context(
|
|
180
|
+
url,
|
|
181
|
+
api_token,
|
|
182
|
+
project_dir,
|
|
183
|
+
mark_closed,
|
|
184
|
+
base_deployment_name=base_deployment_name,
|
|
185
|
+
snapshot_base_condition=snapshot_base_condition,
|
|
186
|
+
)
|
|
187
|
+
)
|
|
188
|
+
if not deployment_name:
|
|
189
|
+
raise Exception(
|
|
190
|
+
"Could not determine branch deployment to use from local git context"
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
return deployment_name
|
|
194
|
+
else:
|
|
195
|
+
return None
|
|
196
|
+
else:
|
|
197
|
+
raise ValueError(f"unsupported for {source}")
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def get_branch_deployment_name_from_context(url, project_dir: str) -> str | None:
|
|
201
|
+
source = metrics.get_source()
|
|
202
|
+
api_token = check.not_none(get_user_token())
|
|
203
|
+
if source == CliEventTags.source.github:
|
|
204
|
+
event = github_context.get_github_event(project_dir)
|
|
205
|
+
if not event.branch_name:
|
|
206
|
+
return None
|
|
207
|
+
with gql.graphql_client_from_url(url, api_token) as client:
|
|
208
|
+
return gql.get_branch_deployment_name(
|
|
209
|
+
client,
|
|
210
|
+
repo_name=event.repo_name,
|
|
211
|
+
branch_name=event.branch_name,
|
|
212
|
+
)
|
|
213
|
+
elif source == CliEventTags.source.gitlab:
|
|
214
|
+
event = gitlab_context.get_gitlab_event(project_dir)
|
|
215
|
+
with gql.graphql_client_from_url(url, api_token) as client:
|
|
216
|
+
return gql.get_branch_deployment_name(
|
|
217
|
+
client,
|
|
218
|
+
repo_name=event.project_name,
|
|
219
|
+
branch_name=event.branch_name,
|
|
220
|
+
)
|
|
221
|
+
elif source == CliEventTags.source.cli:
|
|
222
|
+
branch_name = code_location.get_local_branch_name(project_dir)
|
|
223
|
+
|
|
224
|
+
if not branch_name:
|
|
225
|
+
raise Exception("Could not determine branch name from local git context")
|
|
226
|
+
|
|
227
|
+
# Extract just the org/repo part from the git URL
|
|
228
|
+
repo_name = code_location.get_local_repo_name(project_dir)
|
|
229
|
+
|
|
230
|
+
if not repo_name:
|
|
231
|
+
raise Exception("Could not determine repo name from local git context")
|
|
232
|
+
|
|
233
|
+
with gql.graphql_client_from_url(url, api_token) as client:
|
|
234
|
+
return gql.get_branch_deployment_name(
|
|
235
|
+
client,
|
|
236
|
+
repo_name=repo_name,
|
|
237
|
+
branch_name=branch_name,
|
|
238
|
+
)
|
|
239
|
+
else:
|
|
240
|
+
raise ValueError(f"unsupported for {source}")
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
@app.command(name="check", help="Validate configuration")
|
|
244
|
+
def check_command(
|
|
245
|
+
organization: str | None = ORGANIZATION_OPTION,
|
|
246
|
+
dagster_env: str | None = DAGSTER_ENV_OPTION,
|
|
247
|
+
project_dir: str = typer.Option("."),
|
|
248
|
+
dagster_cloud_yaml_path: str = "dagster_cloud.yaml",
|
|
249
|
+
dagster_cloud_yaml_check: checks.Check = typer.Option("error"),
|
|
250
|
+
dagster_cloud_connect_check: checks.Check = typer.Option("error"),
|
|
251
|
+
):
|
|
252
|
+
ui.warn(
|
|
253
|
+
"The 'dagster-cloud ci check' command is deprecated and will be removed in a future release. "
|
|
254
|
+
"Use 'dg plus deploy start' instead, which validates configuration during deployment initialization."
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
project_path = pathlib.Path(project_dir)
|
|
258
|
+
|
|
259
|
+
verdicts = []
|
|
260
|
+
if dagster_cloud_yaml_check != checks.Check.skip:
|
|
261
|
+
yaml_path = project_path / dagster_cloud_yaml_path
|
|
262
|
+
result = checks.check_dagster_cloud_yaml(yaml_path)
|
|
263
|
+
verdicts.append(
|
|
264
|
+
checks.handle_result(
|
|
265
|
+
result,
|
|
266
|
+
dagster_cloud_yaml_check,
|
|
267
|
+
prefix_message="[dagster_cloud.yaml] ",
|
|
268
|
+
success_message="Checked OK",
|
|
269
|
+
failure_message=(
|
|
270
|
+
"Invalid dagster_cloud.yaml, please see"
|
|
271
|
+
" https://docs.dagster.io/deployment/code-locations/dagster-cloud-yaml"
|
|
272
|
+
),
|
|
273
|
+
)
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
if dagster_cloud_connect_check != checks.Check.skip:
|
|
277
|
+
if not organization:
|
|
278
|
+
raise ui.error(
|
|
279
|
+
"DAGSTER_CLOUD_ORGANIZATION or --organization required for connection check."
|
|
280
|
+
)
|
|
281
|
+
url = get_org_url(organization, dagster_env)
|
|
282
|
+
result = checks.check_connect_dagster_cloud(url)
|
|
283
|
+
verdicts.append(
|
|
284
|
+
checks.handle_result(
|
|
285
|
+
result,
|
|
286
|
+
dagster_cloud_connect_check,
|
|
287
|
+
prefix_message="[dagster.cloud connection] ",
|
|
288
|
+
success_message="Able to connect to dagster.cloud",
|
|
289
|
+
failure_message="Unable to connect to dagster.cloud",
|
|
290
|
+
)
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
verdict_counts = Counter(verdicts)
|
|
294
|
+
ui.print(f"Passed: {verdict_counts[checks.Verdict.passed]}")
|
|
295
|
+
if verdict_counts[checks.Verdict.warning]:
|
|
296
|
+
ui.print(f"Warnings: {verdict_counts[checks.Verdict.warning]}")
|
|
297
|
+
if verdict_counts[checks.Verdict.failed]:
|
|
298
|
+
ui.print(ui.red(f"Failed: {verdict_counts[checks.Verdict.failed]}"))
|
|
299
|
+
sys.exit(1)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
STATEDIR_OPTION = typer.Option(..., envvar="DAGSTER_BUILD_STATEDIR")
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
@app.command(help="Render the processed dagster cloud configuration file to the console")
|
|
306
|
+
def template(
|
|
307
|
+
project_dir: str = typer.Option(".", help="Path to the project directory"),
|
|
308
|
+
dagster_cloud_yaml_path: str = typer.Option(
|
|
309
|
+
"dagster_cloud.yaml", help="Path to the dagster cloud configuration file"
|
|
310
|
+
),
|
|
311
|
+
values_file: str | None = typer.Option(default=None, help="Path to a values file"),
|
|
312
|
+
value: list[str] = typer.Option(
|
|
313
|
+
[],
|
|
314
|
+
help="Key value pairs to override in the yaml file, value can be a str or a valid json representation",
|
|
315
|
+
),
|
|
316
|
+
merge: bool = typer.Option(
|
|
317
|
+
True,
|
|
318
|
+
help="Render the yaml file with defaults merged in each location",
|
|
319
|
+
is_flag=True,
|
|
320
|
+
),
|
|
321
|
+
) -> None:
|
|
322
|
+
project_path = pathlib.Path(project_dir)
|
|
323
|
+
yaml_path = project_path / dagster_cloud_yaml_path
|
|
324
|
+
if not yaml_path.exists():
|
|
325
|
+
raise ui.error(f"No such file {yaml_path}")
|
|
326
|
+
|
|
327
|
+
context = _create_context_from_values(value, values_file)
|
|
328
|
+
|
|
329
|
+
template_loader = JinjaTemplateLoader()
|
|
330
|
+
rendered_config_yaml: str
|
|
331
|
+
try:
|
|
332
|
+
rendered_config_yaml = template_loader.render(filepath=str(yaml_path), context=context)
|
|
333
|
+
except TemplateSyntaxError as error:
|
|
334
|
+
raise ui.error(
|
|
335
|
+
f"jinja TemplateSyntaxError: {error.message} at line {error.lineno} in {error.filename}"
|
|
336
|
+
)
|
|
337
|
+
except Exception as error:
|
|
338
|
+
raise ui.error(f"Error rendering template: {error}")
|
|
339
|
+
|
|
340
|
+
if not merge:
|
|
341
|
+
ui.print(rendered_config_yaml)
|
|
342
|
+
return
|
|
343
|
+
|
|
344
|
+
try:
|
|
345
|
+
dagster_cloud_config = load_dagster_cloud_yaml(rendered_config_yaml)
|
|
346
|
+
except Exception as err:
|
|
347
|
+
raise ui.error(f"Error parsing the configuration: {err}")
|
|
348
|
+
|
|
349
|
+
config_merger = DagsterCloudConfigDefaultsMerger()
|
|
350
|
+
processed_config = config_merger.process(dagster_cloud_config)
|
|
351
|
+
config_as_dict = remove_none_recursively(processed_config.model_dump())
|
|
352
|
+
ui.print(yaml.dump(config_as_dict))
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _create_context_from_values(values_list: list[str], values_file: str | None) -> dict[str, Any]:
|
|
356
|
+
"""Creates a collection of values by loading values from a file then
|
|
357
|
+
overlaying a value list to extend or override those values.
|
|
358
|
+
"""
|
|
359
|
+
context: dict[str, Any] = {}
|
|
360
|
+
|
|
361
|
+
# load values from file
|
|
362
|
+
values_file_path = pathlib.Path(values_file) if values_file else None
|
|
363
|
+
if values_file_path:
|
|
364
|
+
try:
|
|
365
|
+
with open(values_file_path) as f:
|
|
366
|
+
if values_file_path.suffix == ".json":
|
|
367
|
+
context.update(json.load(f))
|
|
368
|
+
elif values_file_path.suffix in [".yaml", ".yml"]:
|
|
369
|
+
context.update(yaml.safe_load(f))
|
|
370
|
+
else:
|
|
371
|
+
raise ui.error(f"Unsupported values file extension {values_file_path.suffix}")
|
|
372
|
+
except Exception as err:
|
|
373
|
+
raise ui.error(f"Error parsing values file {values_file}: {err}")
|
|
374
|
+
|
|
375
|
+
# overlay command line value arguments
|
|
376
|
+
for value_entry in values_list:
|
|
377
|
+
key, value = value_entry.split("=", 1)
|
|
378
|
+
try:
|
|
379
|
+
# command line values may be expressed as json to allow for complex values
|
|
380
|
+
val = json.loads(value)
|
|
381
|
+
except json.JSONDecodeError:
|
|
382
|
+
val = value
|
|
383
|
+
|
|
384
|
+
# command line values may specify a path to set keys in
|
|
385
|
+
key_path = key.split(".") if "." in key else [key]
|
|
386
|
+
sub_context_ref = context
|
|
387
|
+
for step in key_path:
|
|
388
|
+
if step == key_path[-1]:
|
|
389
|
+
sub_context_ref[step] = val
|
|
390
|
+
elif step not in sub_context_ref:
|
|
391
|
+
sub_context_ref[step] = {}
|
|
392
|
+
sub_context_ref = sub_context_ref[step]
|
|
393
|
+
return context
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
@app.command(help="Initialize a build session")
|
|
397
|
+
@dagster_cloud_options(allow_empty=False, allow_empty_deployment=True, requires_url=False)
|
|
398
|
+
def init(
|
|
399
|
+
organization: str,
|
|
400
|
+
deployment: str | None,
|
|
401
|
+
dagster_env: str | None = DAGSTER_ENV_OPTION,
|
|
402
|
+
project_dir: str = typer.Option("."),
|
|
403
|
+
dagster_cloud_yaml_path: str = "dagster_cloud.yaml",
|
|
404
|
+
statedir: str = STATEDIR_OPTION,
|
|
405
|
+
clean_statedir: bool = typer.Option(True, help="Delete any existing files in statedir"),
|
|
406
|
+
location_name: list[str] = typer.Option([]),
|
|
407
|
+
git_url: str | None = None,
|
|
408
|
+
commit_hash: str | None = None,
|
|
409
|
+
status_url: str | None = None,
|
|
410
|
+
snapshot_base_condition: SnapshotBaseDeploymentCondition | None = None,
|
|
411
|
+
require_branch_deployment: bool = typer.Option(
|
|
412
|
+
None, help="Whether to require that a branch deployment be created"
|
|
413
|
+
),
|
|
414
|
+
):
|
|
415
|
+
init_impl(
|
|
416
|
+
organization,
|
|
417
|
+
deployment,
|
|
418
|
+
dagster_env,
|
|
419
|
+
project_dir,
|
|
420
|
+
dagster_cloud_yaml_path,
|
|
421
|
+
statedir,
|
|
422
|
+
clean_statedir,
|
|
423
|
+
location_name,
|
|
424
|
+
git_url,
|
|
425
|
+
commit_hash,
|
|
426
|
+
status_url,
|
|
427
|
+
snapshot_base_condition,
|
|
428
|
+
require_branch_deployment,
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def init_impl(
|
|
433
|
+
organization: str,
|
|
434
|
+
deployment: str | None,
|
|
435
|
+
dagster_env: str | None,
|
|
436
|
+
project_dir: str,
|
|
437
|
+
dagster_cloud_yaml_path: str,
|
|
438
|
+
statedir: str,
|
|
439
|
+
clean_statedir: bool,
|
|
440
|
+
location_name: list[str],
|
|
441
|
+
git_url: str | None,
|
|
442
|
+
commit_hash: str | None,
|
|
443
|
+
status_url: str | None,
|
|
444
|
+
snapshot_base_condition: SnapshotBaseDeploymentCondition | None,
|
|
445
|
+
require_branch_deployment: bool,
|
|
446
|
+
):
|
|
447
|
+
yaml_path = pathlib.Path(project_dir) / dagster_cloud_yaml_path
|
|
448
|
+
if not yaml_path.exists():
|
|
449
|
+
raise ui.error(
|
|
450
|
+
f"Dagster Cloud yaml file not found at specified path {yaml_path.resolve()}."
|
|
451
|
+
)
|
|
452
|
+
locations_def = load_dagster_cloud_yaml(yaml_path.read_text())
|
|
453
|
+
locations = locations_def.locations
|
|
454
|
+
if location_name:
|
|
455
|
+
selected_locations = set(location_name)
|
|
456
|
+
unknown = selected_locations - set(location.location_name for location in locations)
|
|
457
|
+
if unknown:
|
|
458
|
+
raise ui.error(f"Locations not found in {dagster_cloud_yaml_path}: {unknown}")
|
|
459
|
+
locations = [
|
|
460
|
+
location for location in locations if location.location_name in selected_locations
|
|
461
|
+
]
|
|
462
|
+
# Check environment variable for URL first, fall back to constructing from org + env
|
|
463
|
+
env_url = os.getenv(URL_ENV_VAR_NAME)
|
|
464
|
+
if env_url:
|
|
465
|
+
url = env_url
|
|
466
|
+
else:
|
|
467
|
+
url = get_org_url(organization, dagster_env)
|
|
468
|
+
# Deploy to the branch deployment for the current context. If there is no branch deployment
|
|
469
|
+
# available (eg. if not in a PR) then we fallback to the --deployment flag.
|
|
470
|
+
|
|
471
|
+
try:
|
|
472
|
+
branch_deployment_name = create_or_update_deployment_from_context(
|
|
473
|
+
url,
|
|
474
|
+
project_dir,
|
|
475
|
+
mark_closed=False,
|
|
476
|
+
base_deployment_name=deployment,
|
|
477
|
+
snapshot_base_condition=snapshot_base_condition,
|
|
478
|
+
require_branch_deployment=require_branch_deployment,
|
|
479
|
+
)
|
|
480
|
+
if branch_deployment_name:
|
|
481
|
+
ui.print(
|
|
482
|
+
f"Deploying to branch deployment {branch_deployment_name}"
|
|
483
|
+
+ (f", using base deployment {deployment}" if deployment else "")
|
|
484
|
+
)
|
|
485
|
+
deployment = branch_deployment_name
|
|
486
|
+
is_branch_deployment = True
|
|
487
|
+
else:
|
|
488
|
+
if not deployment:
|
|
489
|
+
raise ui.error("Cannot determine deployment name. Please specify --deployment.")
|
|
490
|
+
|
|
491
|
+
ui.print(f"Deploying to {deployment}.")
|
|
492
|
+
is_branch_deployment = False
|
|
493
|
+
|
|
494
|
+
except ValueError as err:
|
|
495
|
+
if deployment:
|
|
496
|
+
ui.print(f"Deploying to {deployment}. No branch deployment ({err}).")
|
|
497
|
+
is_branch_deployment = False
|
|
498
|
+
else:
|
|
499
|
+
raise ui.error(
|
|
500
|
+
f"Cannot determine deployment name in current context ({err}). Please specify"
|
|
501
|
+
" --deployment."
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
if require_branch_deployment and not is_branch_deployment:
|
|
505
|
+
raise ui.error(
|
|
506
|
+
"--branch-deployment flag was provided, but was unable to determine a branch deployment."
|
|
507
|
+
)
|
|
508
|
+
|
|
509
|
+
if clean_statedir and os.path.exists(statedir):
|
|
510
|
+
shutil.rmtree(statedir, ignore_errors=True)
|
|
511
|
+
state_store = state.FileStore(statedir=statedir)
|
|
512
|
+
|
|
513
|
+
ui.print(f"Initializing {statedir}")
|
|
514
|
+
for location in locations:
|
|
515
|
+
location_state = state.LocationState(
|
|
516
|
+
url=url,
|
|
517
|
+
deployment_name=deployment,
|
|
518
|
+
location_file=str(yaml_path.absolute()),
|
|
519
|
+
location_name=location.location_name,
|
|
520
|
+
is_branch_deployment=is_branch_deployment,
|
|
521
|
+
build=state.BuildMetadata(
|
|
522
|
+
git_url=git_url,
|
|
523
|
+
commit_hash=commit_hash,
|
|
524
|
+
build_config=location.build,
|
|
525
|
+
),
|
|
526
|
+
build_output=None,
|
|
527
|
+
status_url=status_url,
|
|
528
|
+
project_dir=project_dir,
|
|
529
|
+
)
|
|
530
|
+
location_state.add_status_change(state.LocationStatus.pending, "initialized")
|
|
531
|
+
state_store.save(location_state)
|
|
532
|
+
|
|
533
|
+
ui.print(
|
|
534
|
+
f"Initialized {statedir} to build and deploying following locations for directory"
|
|
535
|
+
f" {project_dir}:"
|
|
536
|
+
)
|
|
537
|
+
for location in state_store.list_locations():
|
|
538
|
+
ui.print(f"- {location.location_name}")
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
class StatusOutputFormat(Enum):
|
|
542
|
+
json = "json"
|
|
543
|
+
markdown = "markdown"
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
@app.command(help="Show status of the current build session")
|
|
547
|
+
def status(
|
|
548
|
+
statedir: str = STATEDIR_OPTION,
|
|
549
|
+
output_format: StatusOutputFormat = typer.Option("json", help="Output format for build status"),
|
|
550
|
+
):
|
|
551
|
+
state_store = state.FileStore(statedir=statedir)
|
|
552
|
+
location_states = state_store.list_locations()
|
|
553
|
+
if output_format == StatusOutputFormat.json:
|
|
554
|
+
for location in location_states:
|
|
555
|
+
ui.print(location.json())
|
|
556
|
+
elif output_format == StatusOutputFormat.markdown:
|
|
557
|
+
ui.print(report.markdown_report(location_states))
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
@app.command(help="Update the PR comment with the latest status for branch deployments in Github.")
|
|
561
|
+
def notify(
|
|
562
|
+
statedir: str = STATEDIR_OPTION,
|
|
563
|
+
project_dir: str = typer.Option("."),
|
|
564
|
+
):
|
|
565
|
+
state_store = state.FileStore(statedir=statedir)
|
|
566
|
+
location_states = state_store.list_locations()
|
|
567
|
+
|
|
568
|
+
source = metrics.get_source()
|
|
569
|
+
if source == CliEventTags.source.github:
|
|
570
|
+
event = github_context.get_github_event(project_dir)
|
|
571
|
+
msg = f"Your pull request at commit `{event.github_sha}` is automatically being deployed to Dagster Cloud."
|
|
572
|
+
event.update_pr_comment(
|
|
573
|
+
msg + "\n\n" + report.markdown_report(location_states),
|
|
574
|
+
orig_author="github-actions[bot]",
|
|
575
|
+
orig_text="Dagster Cloud", # used to identify original comment
|
|
576
|
+
)
|
|
577
|
+
else:
|
|
578
|
+
raise ui.error("'dagster-cloud ci notify' is only available within Github actions.")
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
@app.command(help="List locations in the current build session")
|
|
582
|
+
def locations_list(
|
|
583
|
+
statedir: str = STATEDIR_OPTION,
|
|
584
|
+
):
|
|
585
|
+
state_store = state.FileStore(statedir=statedir)
|
|
586
|
+
for location in state_store.list_locations():
|
|
587
|
+
if location.selected:
|
|
588
|
+
ui.print(f"{location.location_name}")
|
|
589
|
+
else:
|
|
590
|
+
ui.print(f"{location.location_name} DESELECTED")
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
@app.command(help="Mark the specified locations as excluded from the current build session")
|
|
594
|
+
def locations_deselect(
|
|
595
|
+
location_names: list[str],
|
|
596
|
+
statedir: str = STATEDIR_OPTION,
|
|
597
|
+
):
|
|
598
|
+
state_store = state.FileStore(statedir=statedir)
|
|
599
|
+
state_store.deselect(location_names)
|
|
600
|
+
ui.print("Deselected locations: {locations_names}")
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
@app.command(help="Mark the specified locations as included in the current build session")
|
|
604
|
+
def locations_select(
|
|
605
|
+
location_names: list[str],
|
|
606
|
+
statedir: str = STATEDIR_OPTION,
|
|
607
|
+
):
|
|
608
|
+
state_store = state.FileStore(statedir=statedir)
|
|
609
|
+
state_store.select(location_names)
|
|
610
|
+
ui.print("Deselected locations: {locations_names}")
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def _get_selected_locations(
|
|
614
|
+
state_store: state.Store, location_name: list[str]
|
|
615
|
+
) -> dict[str, state.LocationState]:
|
|
616
|
+
requested_locations = set(location_name)
|
|
617
|
+
selected = {
|
|
618
|
+
location.location_name: location
|
|
619
|
+
for location in state_store.list_selected_locations()
|
|
620
|
+
if not requested_locations or location.location_name in requested_locations
|
|
621
|
+
}
|
|
622
|
+
unknown_locations = requested_locations - set(selected)
|
|
623
|
+
if unknown_locations:
|
|
624
|
+
raise ui.error(f"Unknown or deselected location names requested: {unknown_locations}")
|
|
625
|
+
return selected
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
class BuildStrategy(Enum):
|
|
629
|
+
pex = "python-executable"
|
|
630
|
+
docker = "docker"
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
@app.command(help="Build selected or requested locations")
|
|
634
|
+
def build(
|
|
635
|
+
statedir: str = STATEDIR_OPTION,
|
|
636
|
+
location_name: list[str] = typer.Option([]),
|
|
637
|
+
build_directory: str | None = typer.Option(
|
|
638
|
+
None,
|
|
639
|
+
help=(
|
|
640
|
+
"Directory root for building this code location. Read from dagster_cloud.yaml by"
|
|
641
|
+
" default."
|
|
642
|
+
),
|
|
643
|
+
),
|
|
644
|
+
build_strategy: BuildStrategy = typer.Option(
|
|
645
|
+
"docker",
|
|
646
|
+
help=(
|
|
647
|
+
"Build strategy used to build code locations. 'docker' builds a docker image."
|
|
648
|
+
" 'python-executable' builds a set of pex files."
|
|
649
|
+
),
|
|
650
|
+
),
|
|
651
|
+
docker_image_tag: str | None = typer.Option(
|
|
652
|
+
None, help="Tag for built docker image. Auto-generated by default."
|
|
653
|
+
),
|
|
654
|
+
docker_base_image: str | None = typer.Option(
|
|
655
|
+
None,
|
|
656
|
+
help="Base image used to build the docker image for --build-strategy=docker.",
|
|
657
|
+
),
|
|
658
|
+
docker_env: list[str] = typer.Option([], help="Env vars for docker builds."),
|
|
659
|
+
dockerfile_path: str | None = typer.Option(
|
|
660
|
+
None,
|
|
661
|
+
help=(
|
|
662
|
+
"Path to a Dockerfile to use for the docker build. If not provided, a default templated Dockerfile is used."
|
|
663
|
+
),
|
|
664
|
+
),
|
|
665
|
+
python_version: str = typer.Option(
|
|
666
|
+
DEFAULT_PYTHON_VERSION,
|
|
667
|
+
help=(
|
|
668
|
+
"Python version used to build the python-executable; or to determine the default base"
|
|
669
|
+
" image for docker."
|
|
670
|
+
),
|
|
671
|
+
),
|
|
672
|
+
pex_build_method: deps.BuildMethod = typer.Option("local"),
|
|
673
|
+
pex_deps_cache_from: str | None = None,
|
|
674
|
+
pex_deps_cache_to: str | None = None,
|
|
675
|
+
pex_base_image_tag: str | None = typer.Option(
|
|
676
|
+
None,
|
|
677
|
+
help="Base image used to run python executable for --build-strategy=python-executable.",
|
|
678
|
+
),
|
|
679
|
+
use_editable_dagster: bool = typer.Option(
|
|
680
|
+
False,
|
|
681
|
+
help="Include the editable dagster package in the Docker context for the build.",
|
|
682
|
+
),
|
|
683
|
+
) -> None:
|
|
684
|
+
build_impl(
|
|
685
|
+
statedir,
|
|
686
|
+
location_name,
|
|
687
|
+
build_directory,
|
|
688
|
+
build_strategy,
|
|
689
|
+
docker_image_tag,
|
|
690
|
+
docker_base_image,
|
|
691
|
+
docker_env,
|
|
692
|
+
dockerfile_path,
|
|
693
|
+
python_version,
|
|
694
|
+
pex_build_method,
|
|
695
|
+
pex_deps_cache_from,
|
|
696
|
+
pex_deps_cache_to,
|
|
697
|
+
pex_base_image_tag,
|
|
698
|
+
use_editable_dagster,
|
|
699
|
+
)
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
def build_impl(
|
|
703
|
+
statedir: str,
|
|
704
|
+
location_name: list[str],
|
|
705
|
+
build_directory: str | None,
|
|
706
|
+
build_strategy: BuildStrategy,
|
|
707
|
+
docker_image_tag: str | None,
|
|
708
|
+
docker_base_image: str | None,
|
|
709
|
+
docker_env: list[str],
|
|
710
|
+
dockerfile_path: str | None,
|
|
711
|
+
python_version: str,
|
|
712
|
+
pex_build_method: deps.BuildMethod,
|
|
713
|
+
pex_deps_cache_from: str | None,
|
|
714
|
+
pex_deps_cache_to: str | None,
|
|
715
|
+
pex_base_image_tag: str | None,
|
|
716
|
+
use_editable_dagster: bool,
|
|
717
|
+
):
|
|
718
|
+
if python_version:
|
|
719
|
+
# ensure version is parseable
|
|
720
|
+
pex_builder.util.parse_python_version(python_version)
|
|
721
|
+
|
|
722
|
+
if build_strategy == BuildStrategy.pex:
|
|
723
|
+
if docker_base_image or docker_image_tag:
|
|
724
|
+
raise ui.error(
|
|
725
|
+
"--base-image or --image-tag not supported for --build-strategy=python-executable."
|
|
726
|
+
)
|
|
727
|
+
|
|
728
|
+
if docker_base_image and dockerfile_path:
|
|
729
|
+
raise ui.error(
|
|
730
|
+
"--base-image and --dockerfile-path cannot both be provided. Please provide only one."
|
|
731
|
+
)
|
|
732
|
+
|
|
733
|
+
state_store = state.FileStore(statedir=statedir)
|
|
734
|
+
locations = _get_selected_locations(state_store, location_name)
|
|
735
|
+
ui.print("Going to build the following locations:")
|
|
736
|
+
for name in locations:
|
|
737
|
+
ui.print(f"- {name}")
|
|
738
|
+
|
|
739
|
+
for name, location_state in locations.items():
|
|
740
|
+
project_dir = location_state.project_dir
|
|
741
|
+
try:
|
|
742
|
+
configured_build_directory = (
|
|
743
|
+
location_state.build.build_config.directory
|
|
744
|
+
if (
|
|
745
|
+
location_state.build.build_config
|
|
746
|
+
and location_state.build.build_config.directory
|
|
747
|
+
)
|
|
748
|
+
else None
|
|
749
|
+
)
|
|
750
|
+
if build_directory and configured_build_directory:
|
|
751
|
+
ui.warn(
|
|
752
|
+
f"Overriding configured build:directory:{configured_build_directory!r} with"
|
|
753
|
+
f" cmdline provided --build-directory={build_directory!r}"
|
|
754
|
+
)
|
|
755
|
+
location_build_dir = build_directory
|
|
756
|
+
elif (not build_directory) and configured_build_directory:
|
|
757
|
+
location_build_dir = configured_build_directory
|
|
758
|
+
elif build_directory and (not configured_build_directory):
|
|
759
|
+
location_build_dir = build_directory
|
|
760
|
+
else:
|
|
761
|
+
location_build_dir = "."
|
|
762
|
+
|
|
763
|
+
if project_dir and not os.path.isabs(location_build_dir):
|
|
764
|
+
location_build_dir = str(pathlib.Path(project_dir) / location_build_dir)
|
|
765
|
+
|
|
766
|
+
url = location_state.url
|
|
767
|
+
api_token = get_user_token() or ""
|
|
768
|
+
|
|
769
|
+
if build_strategy == BuildStrategy.docker:
|
|
770
|
+
location_state.build_output = _build_docker(
|
|
771
|
+
url=url,
|
|
772
|
+
api_token=api_token,
|
|
773
|
+
name=name,
|
|
774
|
+
location_build_dir=location_build_dir,
|
|
775
|
+
docker_base_image=docker_base_image,
|
|
776
|
+
python_version=python_version,
|
|
777
|
+
docker_env=docker_env,
|
|
778
|
+
location_state=location_state,
|
|
779
|
+
dockerfile_path=dockerfile_path,
|
|
780
|
+
use_editable_dagster=use_editable_dagster,
|
|
781
|
+
)
|
|
782
|
+
state_store.save(location_state)
|
|
783
|
+
elif build_strategy == BuildStrategy.pex:
|
|
784
|
+
location_state.build_output = _build_pex(
|
|
785
|
+
url=url,
|
|
786
|
+
api_token=api_token,
|
|
787
|
+
name=name,
|
|
788
|
+
location_build_dir=location_build_dir,
|
|
789
|
+
python_version=python_version,
|
|
790
|
+
pex_build_method=pex_build_method,
|
|
791
|
+
pex_deps_cache_from=pex_deps_cache_from,
|
|
792
|
+
pex_deps_cache_to=pex_deps_cache_to,
|
|
793
|
+
pex_base_image_tag=pex_base_image_tag,
|
|
794
|
+
location_state=location_state,
|
|
795
|
+
)
|
|
796
|
+
state_store.save(location_state)
|
|
797
|
+
ui.print(
|
|
798
|
+
"Built and uploaded python executable"
|
|
799
|
+
f" {location_state.build_output.pex_tag} for location {name}"
|
|
800
|
+
)
|
|
801
|
+
except:
|
|
802
|
+
location_state.add_status_change(state.LocationStatus.failed, "build failed")
|
|
803
|
+
state_store.save(location_state)
|
|
804
|
+
raise
|
|
805
|
+
else:
|
|
806
|
+
location_state.add_status_change(state.LocationStatus.pending, "build successful")
|
|
807
|
+
state_store.save(location_state)
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
@metrics.instrument(
|
|
811
|
+
CliEventType.BUILD,
|
|
812
|
+
tags=[
|
|
813
|
+
CliEventTags.subcommand.dagster_cloud_ci,
|
|
814
|
+
CliEventTags.server_strategy.docker,
|
|
815
|
+
],
|
|
816
|
+
)
|
|
817
|
+
# url and api_token are used by the instrument decorator
|
|
818
|
+
def _build_docker(
|
|
819
|
+
url: str,
|
|
820
|
+
api_token: str,
|
|
821
|
+
name: str,
|
|
822
|
+
location_build_dir: str,
|
|
823
|
+
python_version: str,
|
|
824
|
+
docker_base_image: str | None,
|
|
825
|
+
docker_env: list[str],
|
|
826
|
+
location_state: state.LocationState,
|
|
827
|
+
use_editable_dagster: bool,
|
|
828
|
+
dockerfile_path: str | None = None,
|
|
829
|
+
) -> state.DockerBuildOutput:
|
|
830
|
+
name = location_state.location_name
|
|
831
|
+
docker_utils.verify_docker()
|
|
832
|
+
registry_info = utils.get_registry_info(url)
|
|
833
|
+
|
|
834
|
+
docker_image_tag = docker_utils.default_image_tag(
|
|
835
|
+
location_state.deployment_name, name, location_state.build.commit_hash
|
|
836
|
+
)
|
|
837
|
+
|
|
838
|
+
if not dockerfile_path and not docker_base_image:
|
|
839
|
+
docker_base_image = f"python:{python_version}-slim"
|
|
840
|
+
|
|
841
|
+
ui.print(
|
|
842
|
+
f"Building docker image for location {name}"
|
|
843
|
+
+ (f" using base image {docker_base_image}" if docker_base_image else "")
|
|
844
|
+
)
|
|
845
|
+
|
|
846
|
+
retval = docker_utils.build_image(
|
|
847
|
+
location_build_dir,
|
|
848
|
+
docker_image_tag,
|
|
849
|
+
registry_info,
|
|
850
|
+
env_vars=docker_env,
|
|
851
|
+
base_image=docker_base_image,
|
|
852
|
+
dockerfile_path=dockerfile_path,
|
|
853
|
+
use_editable_dagster=use_editable_dagster,
|
|
854
|
+
)
|
|
855
|
+
if retval != 0:
|
|
856
|
+
raise ui.error(f"Failed to build docker image for location {name}")
|
|
857
|
+
|
|
858
|
+
retval = docker_utils.upload_image(docker_image_tag, registry_info)
|
|
859
|
+
if retval != 0:
|
|
860
|
+
raise ui.error(f"Failed to upload docker image for location {name}")
|
|
861
|
+
|
|
862
|
+
image = f"{registry_info['registry_url']}:{docker_image_tag}"
|
|
863
|
+
ui.print(f"Built and uploaded image {image} for location {name}")
|
|
864
|
+
|
|
865
|
+
return state.DockerBuildOutput(image=image)
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
@metrics.instrument(
|
|
869
|
+
CliEventType.BUILD,
|
|
870
|
+
tags=[CliEventTags.subcommand.dagster_cloud_ci, CliEventTags.server_strategy.pex],
|
|
871
|
+
)
|
|
872
|
+
# url and api_token are used by the instrument decorator
|
|
873
|
+
def _build_pex(
|
|
874
|
+
url: str,
|
|
875
|
+
api_token: str,
|
|
876
|
+
name: str,
|
|
877
|
+
location_build_dir: str,
|
|
878
|
+
python_version: str,
|
|
879
|
+
pex_build_method: deps.BuildMethod,
|
|
880
|
+
pex_deps_cache_from: str | None,
|
|
881
|
+
pex_deps_cache_to: str | None,
|
|
882
|
+
pex_base_image_tag: str | None,
|
|
883
|
+
location_state: state.LocationState,
|
|
884
|
+
) -> state.PexBuildOutput:
|
|
885
|
+
pex_location = parse_workspace.Location(
|
|
886
|
+
name,
|
|
887
|
+
directory=location_build_dir,
|
|
888
|
+
build_folder=location_build_dir,
|
|
889
|
+
location_file=location_state.location_file,
|
|
890
|
+
)
|
|
891
|
+
|
|
892
|
+
location_kwargs = pex_utils.build_upload_pex(
|
|
893
|
+
url=url,
|
|
894
|
+
api_token=api_token,
|
|
895
|
+
location=pex_location,
|
|
896
|
+
build_method=pex_build_method,
|
|
897
|
+
kwargs={
|
|
898
|
+
"python_version": python_version,
|
|
899
|
+
"base_image_tag": pex_base_image_tag,
|
|
900
|
+
"deps_cache_from": pex_deps_cache_from,
|
|
901
|
+
"deps_cache_to": pex_deps_cache_to,
|
|
902
|
+
},
|
|
903
|
+
)
|
|
904
|
+
return state.PexBuildOutput(
|
|
905
|
+
python_version=python_version,
|
|
906
|
+
image=location_kwargs.get("image"),
|
|
907
|
+
pex_tag=location_kwargs["pex_tag"],
|
|
908
|
+
)
|
|
909
|
+
|
|
910
|
+
|
|
911
|
+
@app.command(help="Update the current build session for an externally built docker image.")
|
|
912
|
+
def set_build_output(
|
|
913
|
+
statedir: str = STATEDIR_OPTION,
|
|
914
|
+
location_name: list[str] = typer.Option([]),
|
|
915
|
+
image_tag: str = typer.Option(
|
|
916
|
+
...,
|
|
917
|
+
help=(
|
|
918
|
+
"Tag for the built docker image. Note the registry must be specified in"
|
|
919
|
+
" dagster_cloud.yaml."
|
|
920
|
+
),
|
|
921
|
+
),
|
|
922
|
+
) -> None:
|
|
923
|
+
set_build_output_impl(
|
|
924
|
+
statedir,
|
|
925
|
+
location_name,
|
|
926
|
+
image_tag,
|
|
927
|
+
)
|
|
928
|
+
|
|
929
|
+
|
|
930
|
+
def set_build_output_impl(
|
|
931
|
+
statedir: str,
|
|
932
|
+
location_name: list[str],
|
|
933
|
+
image_tag: str,
|
|
934
|
+
) -> None:
|
|
935
|
+
state_store = state.FileStore(statedir=statedir)
|
|
936
|
+
locations = _get_selected_locations(state_store, location_name)
|
|
937
|
+
ui.print("Going to update the following locations:")
|
|
938
|
+
for name in locations:
|
|
939
|
+
ui.print(f"- {name}")
|
|
940
|
+
|
|
941
|
+
# validation pass - computes the full image name for all locations
|
|
942
|
+
images = {}
|
|
943
|
+
for name, location_state in locations.items():
|
|
944
|
+
configured_defs = load_dagster_cloud_yaml(
|
|
945
|
+
open(location_state.location_file, encoding="utf-8").read()
|
|
946
|
+
)
|
|
947
|
+
location_defs = [loc for loc in configured_defs.locations if loc.location_name == name]
|
|
948
|
+
if not location_defs:
|
|
949
|
+
raise ui.error(f"Location {name} not found in {location_state.location_file}")
|
|
950
|
+
location_def = location_defs[0]
|
|
951
|
+
registry = location_def.build.registry if location_def.build else None
|
|
952
|
+
if not registry:
|
|
953
|
+
raise ui.error(
|
|
954
|
+
f"No build:registry: defined for location {name} in {location_state.location_file}"
|
|
955
|
+
)
|
|
956
|
+
|
|
957
|
+
images[name] = f"{registry}:{image_tag}"
|
|
958
|
+
|
|
959
|
+
# save pass - save full image name computed in the previous pass for all locations
|
|
960
|
+
for name, location_state in locations.items():
|
|
961
|
+
# Update and save build state
|
|
962
|
+
location_state.build_output = state.DockerBuildOutput(image=images[name])
|
|
963
|
+
state_store.save(location_state)
|
|
964
|
+
ui.print(f"Recorded image {images[name]} for location {name}")
|
|
965
|
+
ui.print("Use 'ci deploy' to update dagster-cloud.")
|
|
966
|
+
|
|
967
|
+
|
|
968
|
+
@app.command(help="Deploy built code locations to dagster cloud.")
|
|
969
|
+
def deploy(
|
|
970
|
+
statedir: str = STATEDIR_OPTION,
|
|
971
|
+
location_name: list[str] = typer.Option([]),
|
|
972
|
+
location_load_timeout: int = LOCATION_LOAD_TIMEOUT_OPTION,
|
|
973
|
+
agent_heartbeat_timeout: int = get_agent_heartbeat_timeout_option(default_timeout=None),
|
|
974
|
+
):
|
|
975
|
+
deploy_impl(statedir, location_name, location_load_timeout, agent_heartbeat_timeout)
|
|
976
|
+
|
|
977
|
+
|
|
978
|
+
def deploy_impl(
|
|
979
|
+
statedir: str,
|
|
980
|
+
location_name: list[str],
|
|
981
|
+
location_load_timeout: int,
|
|
982
|
+
agent_heartbeat_timeout: int | None,
|
|
983
|
+
):
|
|
984
|
+
state_store = state.FileStore(statedir=statedir)
|
|
985
|
+
locations = _get_selected_locations(state_store, location_name)
|
|
986
|
+
ui.print("Going to deploy the following locations:")
|
|
987
|
+
|
|
988
|
+
built_locations: list[state.LocationState] = []
|
|
989
|
+
unbuilt_location_names: list[str] = []
|
|
990
|
+
for name, location_state in locations.items():
|
|
991
|
+
if location_state.build_output:
|
|
992
|
+
status = "Ready to deploy"
|
|
993
|
+
built_locations.append(location_state)
|
|
994
|
+
else:
|
|
995
|
+
status = "Not ready to deploy"
|
|
996
|
+
unbuilt_location_names.append(name)
|
|
997
|
+
ui.print(f"- {name} [{status}]")
|
|
998
|
+
|
|
999
|
+
if unbuilt_location_names:
|
|
1000
|
+
raise ui.error(
|
|
1001
|
+
f"Cannot deploy because the following location{'s have' if len(unbuilt_location_names) > 1 else ' has'} "
|
|
1002
|
+
f"not been built: {', '.join(unbuilt_location_names)}. "
|
|
1003
|
+
"Use 'ci build' (in Dagster+ Serverless) or `ci set-build-output` (in Dagster+ Hybrid) to build"
|
|
1004
|
+
" locations."
|
|
1005
|
+
)
|
|
1006
|
+
|
|
1007
|
+
if not built_locations:
|
|
1008
|
+
ui.print("No locations to deploy")
|
|
1009
|
+
return
|
|
1010
|
+
|
|
1011
|
+
try:
|
|
1012
|
+
_deploy(
|
|
1013
|
+
url=built_locations[0].url,
|
|
1014
|
+
api_token=check.not_none(get_user_token()),
|
|
1015
|
+
built_locations=built_locations,
|
|
1016
|
+
location_load_timeout=location_load_timeout,
|
|
1017
|
+
agent_heartbeat_timeout=agent_heartbeat_timeout,
|
|
1018
|
+
)
|
|
1019
|
+
except:
|
|
1020
|
+
# unfortunately we do not know if only a subset of locations failed to deploy
|
|
1021
|
+
for location_state in built_locations:
|
|
1022
|
+
location_state.add_status_change(state.LocationStatus.failed, "deploy failed")
|
|
1023
|
+
state_store.save(location_state)
|
|
1024
|
+
raise
|
|
1025
|
+
else:
|
|
1026
|
+
for location_state in built_locations:
|
|
1027
|
+
location_state.add_status_change(state.LocationStatus.success, "deploy successful")
|
|
1028
|
+
state_store.save(location_state)
|
|
1029
|
+
|
|
1030
|
+
deployment_url = built_locations[0].url + "/" + built_locations[0].deployment_name
|
|
1031
|
+
ui.print(f"View the status of your locations at {deployment_url}/locations.")
|
|
1032
|
+
|
|
1033
|
+
|
|
1034
|
+
@metrics.instrument(CliEventType.DEPLOY, tags=[CliEventTags.subcommand.dagster_cloud_ci])
|
|
1035
|
+
# url and api_token are used by the instrument decorator
|
|
1036
|
+
def _deploy(
|
|
1037
|
+
*,
|
|
1038
|
+
url: str,
|
|
1039
|
+
api_token: str,
|
|
1040
|
+
built_locations: list[state.LocationState],
|
|
1041
|
+
location_load_timeout: int,
|
|
1042
|
+
agent_heartbeat_timeout: int | None,
|
|
1043
|
+
):
|
|
1044
|
+
locations_document = []
|
|
1045
|
+
for location_state in built_locations:
|
|
1046
|
+
build_output = location_state.build_output
|
|
1047
|
+
if not build_output: # not necessary but keep type checker happy
|
|
1048
|
+
continue
|
|
1049
|
+
|
|
1050
|
+
location_args = {
|
|
1051
|
+
"image": build_output.image,
|
|
1052
|
+
"location_file": location_state.location_file,
|
|
1053
|
+
"git_url": location_state.build.git_url,
|
|
1054
|
+
"commit_hash": location_state.build.commit_hash,
|
|
1055
|
+
**(
|
|
1056
|
+
{"defs_state_info": location_state.defs_state_info.model_dump()}
|
|
1057
|
+
if location_state.defs_state_info
|
|
1058
|
+
else {}
|
|
1059
|
+
),
|
|
1060
|
+
}
|
|
1061
|
+
if build_output.strategy == "python-executable":
|
|
1062
|
+
metrics.instrument_add_tags([CliEventTags.server_strategy.pex])
|
|
1063
|
+
location_args["pex_tag"] = build_output.pex_tag
|
|
1064
|
+
location_args["python_version"] = build_output.python_version
|
|
1065
|
+
else:
|
|
1066
|
+
metrics.instrument_add_tags([CliEventTags.server_strategy.docker])
|
|
1067
|
+
|
|
1068
|
+
locations_document.append(
|
|
1069
|
+
get_location_document(location_state.location_name, location_args)
|
|
1070
|
+
)
|
|
1071
|
+
|
|
1072
|
+
deployment_url = built_locations[0].url + "/" + built_locations[0].deployment_name
|
|
1073
|
+
with utils.client_from_env(
|
|
1074
|
+
built_locations[0].url, deployment=built_locations[0].deployment_name
|
|
1075
|
+
) as client:
|
|
1076
|
+
location_names = [location_state.location_name for location_state in built_locations]
|
|
1077
|
+
gql.deploy_code_locations(client, {"locations": locations_document})
|
|
1078
|
+
ui.print(
|
|
1079
|
+
f"Updated code location{'s' if len(location_names) > 1 else ''} {', '.join(location_names)} in dagster-cloud."
|
|
1080
|
+
)
|
|
1081
|
+
|
|
1082
|
+
if not agent_heartbeat_timeout:
|
|
1083
|
+
agent_type = gql.fetch_agent_type(client)
|
|
1084
|
+
# raise the agent heartbeat timeout for serverless deploys to ensure that the
|
|
1085
|
+
# deploy doesn't fail if the agent is still being spun up. A deploy is serverless if
|
|
1086
|
+
# that's the default agent type for the deployment and the locations aren't being
|
|
1087
|
+
# deployed to some other agent queue (since serverless agents only serve the default
|
|
1088
|
+
# agent queue)
|
|
1089
|
+
if agent_type != DagsterPlusDeploymentAgentType.SERVERLESS or any(
|
|
1090
|
+
location_config.get("agent_queue") for location_config in locations_document
|
|
1091
|
+
):
|
|
1092
|
+
agent_heartbeat_timeout = DEFAULT_HYBRID_AGENT_HEARTBEAT_TIMEOUT
|
|
1093
|
+
else:
|
|
1094
|
+
agent_heartbeat_timeout = DEFAULT_SERVERLESS_AGENT_HEARTBEAT_TIMEOUT
|
|
1095
|
+
|
|
1096
|
+
wait_for_load(
|
|
1097
|
+
client,
|
|
1098
|
+
location_names,
|
|
1099
|
+
location_load_timeout=location_load_timeout,
|
|
1100
|
+
agent_heartbeat_timeout=agent_heartbeat_timeout,
|
|
1101
|
+
url=deployment_url,
|
|
1102
|
+
)
|
|
1103
|
+
|
|
1104
|
+
|
|
1105
|
+
dagster_dbt_app = typer.Typer(
|
|
1106
|
+
hidden=True,
|
|
1107
|
+
help="Dagster Cloud commands for managing the `dagster-dbt` integration.",
|
|
1108
|
+
add_completion=False,
|
|
1109
|
+
)
|
|
1110
|
+
app.add_typer(dagster_dbt_app, name="dagster-dbt", no_args_is_help=True)
|
|
1111
|
+
|
|
1112
|
+
project_app = typer.Typer(
|
|
1113
|
+
name="project",
|
|
1114
|
+
no_args_is_help=True,
|
|
1115
|
+
help="Commands for using a dbt project in Dagster.",
|
|
1116
|
+
add_completion=False,
|
|
1117
|
+
)
|
|
1118
|
+
dagster_dbt_app.add_typer(project_app, name="project", no_args_is_help=True)
|
|
1119
|
+
|
|
1120
|
+
|
|
1121
|
+
@project_app.command(
|
|
1122
|
+
name="manage-state",
|
|
1123
|
+
help="""
|
|
1124
|
+
This CLI command will handle uploading and downloading dbt state, in the form of manifest.json,
|
|
1125
|
+
if `state_path` is specified on `DbtProject`.
|
|
1126
|
+
""",
|
|
1127
|
+
)
|
|
1128
|
+
def manage_state_command(
|
|
1129
|
+
statedir: str = STATEDIR_OPTION,
|
|
1130
|
+
file: Annotated[
|
|
1131
|
+
pathlib.Path | None,
|
|
1132
|
+
typer.Option(
|
|
1133
|
+
help="The file containing DbtProject definitions to prepare.",
|
|
1134
|
+
),
|
|
1135
|
+
] = None,
|
|
1136
|
+
components: Annotated[
|
|
1137
|
+
pathlib.Path | None,
|
|
1138
|
+
typer.Option(
|
|
1139
|
+
help="The path to a dg project directory containing DbtProjectComponents.",
|
|
1140
|
+
),
|
|
1141
|
+
] = None,
|
|
1142
|
+
source_deployment: Annotated[
|
|
1143
|
+
str,
|
|
1144
|
+
typer.Option(
|
|
1145
|
+
help="Which deployment should upload its manifest.json.",
|
|
1146
|
+
),
|
|
1147
|
+
] = "prod",
|
|
1148
|
+
key_prefix: Annotated[
|
|
1149
|
+
str,
|
|
1150
|
+
typer.Option(
|
|
1151
|
+
help="A key prefix for the key the manifest.json is saved with.",
|
|
1152
|
+
),
|
|
1153
|
+
] = "",
|
|
1154
|
+
):
|
|
1155
|
+
try:
|
|
1156
|
+
from dagster_dbt import DbtProject
|
|
1157
|
+
except:
|
|
1158
|
+
ui.print(
|
|
1159
|
+
"Unable to import dagster_dbt. To use `manage-state`, dagster_dbt must be installed."
|
|
1160
|
+
)
|
|
1161
|
+
return
|
|
1162
|
+
try:
|
|
1163
|
+
from dagster._core.code_pointer import load_python_file
|
|
1164
|
+
from dagster._core.definitions.module_loaders.utils import find_objects_in_module_of_types
|
|
1165
|
+
except:
|
|
1166
|
+
ui.print("Unable to import dagster. To use `manage-state`, dagster must be installed.")
|
|
1167
|
+
return
|
|
1168
|
+
|
|
1169
|
+
state_store = state.FileStore(statedir=statedir)
|
|
1170
|
+
locations = state_store.list_locations()
|
|
1171
|
+
if not locations:
|
|
1172
|
+
raise ui.error("Unable to determine deployment state.")
|
|
1173
|
+
|
|
1174
|
+
location = locations[0]
|
|
1175
|
+
deployment_name = location.deployment_name
|
|
1176
|
+
is_branch = location.is_branch_deployment
|
|
1177
|
+
if file:
|
|
1178
|
+
contents = load_python_file(file, None)
|
|
1179
|
+
projects = find_objects_in_module_of_types(contents, DbtProject)
|
|
1180
|
+
elif components:
|
|
1181
|
+
from dagster_dbt.components.dbt_project.component import get_projects_from_dbt_component
|
|
1182
|
+
|
|
1183
|
+
projects = get_projects_from_dbt_component(components)
|
|
1184
|
+
else:
|
|
1185
|
+
raise click.UsageError("Must specify --file or --components")
|
|
1186
|
+
|
|
1187
|
+
for project in projects:
|
|
1188
|
+
project = cast("DbtProject", project)
|
|
1189
|
+
if project.state_path:
|
|
1190
|
+
download_path = project.state_path.joinpath("manifest.json")
|
|
1191
|
+
key = f"{key_prefix}{os.fspath(download_path)}"
|
|
1192
|
+
if is_branch:
|
|
1193
|
+
ui.print(f"Downloading {source_deployment} manifest for branch deployment.")
|
|
1194
|
+
os.makedirs(project.state_path, exist_ok=True)
|
|
1195
|
+
download_organization_artifact(key, download_path)
|
|
1196
|
+
ui.print("Download complete.")
|
|
1197
|
+
|
|
1198
|
+
elif deployment_name == source_deployment:
|
|
1199
|
+
ui.print(f"Uploading {source_deployment} manifest.")
|
|
1200
|
+
upload_organization_artifact(key, project.manifest_path)
|
|
1201
|
+
ui.print("Upload complete")
|
|
1202
|
+
|
|
1203
|
+
else:
|
|
1204
|
+
ui.warn(
|
|
1205
|
+
f"Deployment named {deployment_name} does not match source deployment {source_deployment}, taking no action. "
|
|
1206
|
+
f"If this is the desired dbt state artifacts to upload, set the cli flags `--source-deployment {deployment_name}`."
|
|
1207
|
+
)
|