Skip to content

Commit 8a79052

Browse files
authored
dg deploy initial serverless + docker implementation (#28704)
Lots to do here as evidenced by the many TODOs, but this is sufficient to deploy a dg project to serverless. It: - checks that you are logged in - Creates a dagster_cloud.yaml (only used under the hood) derived from the pyproject.toml for the project - scaffolds a uv-friendly Dockerfile if none exists - Builds the project using that Dockerfile and pushes it to the remote serverless repository - Deploys the project, at which point it will be available in Dagster+. Also would like to improve the testing coverage here with a real test harness that deploys to a locally available cloud instance (just mocking out the call to the dagster-cloud ci leaves a lot of things untested, although each of those underlying commands also have separate unite tests)
1 parent 1ffdcfa commit 8a79052

File tree

8 files changed

+281
-3
lines changed

8 files changed

+281
-3
lines changed

python_modules/libraries/dagster-dg/dagster_dg/cli/plus.py

+134
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,19 @@
1+
import os
2+
import sys
3+
import tempfile
14
import webbrowser
25
from collections.abc import Mapping
6+
from contextlib import ExitStack
37
from pathlib import Path
8+
from typing import Optional
49

510
import click
11+
import jinja2
612
from dagster_shared.plus.config import DagsterPlusCliConfig
713
from dagster_shared.plus.login_server import start_login_server
814

915
from dagster_dg.cli.shared_options import dg_global_options
16+
from dagster_dg.cli.utils import create_temp_dagster_cloud_yaml_file
1017
from dagster_dg.config import normalize_cli_config
1118
from dagster_dg.context import DgContext
1219
from dagster_dg.env import ProjectEnvVars
@@ -144,3 +151,130 @@ def pull_env_command(**global_options: object) -> None:
144151
click.echo(
145152
f"Environment variables not found for projects: {', '.join(projects_without_secrets)}"
146153
)
154+
155+
156+
def _create_temp_deploy_dockerfile(dst_path, python_version):
157+
dockerfile_template_path = (
158+
Path(__file__).parent.parent / "templates" / "deploy_uv_Dockerfile.jinja"
159+
)
160+
161+
loader = jinja2.FileSystemLoader(searchpath=os.path.dirname(dockerfile_template_path))
162+
env = jinja2.Environment(loader=loader)
163+
164+
template = env.get_template(os.path.basename(dockerfile_template_path))
165+
166+
with open(dst_path, "w", encoding="utf8") as f:
167+
f.write(template.render(python_version=python_version))
168+
f.write("\n")
169+
170+
171+
@plus_group.command(name="deploy", cls=DgClickCommand)
172+
@click.option(
173+
"--organization",
174+
"organization",
175+
help="Dagster+ organization to which to deploy. If not set, defaults to the value set by `dg plus login`.",
176+
envvar="DAGSTER_PLUS_ORGANIZATION",
177+
)
178+
@click.option(
179+
"--deployment",
180+
"deployment",
181+
help="Name of the Dagster+ deployment to which to deploy. If not set, defaults to the value set by `dg plus login`.",
182+
envvar="DAGSTER_PLUS_DEPLOYMENT",
183+
)
184+
@click.option(
185+
"--python-version",
186+
"python_version",
187+
type=click.Choice(["3.9", "3.10", "3.11", "3.12"]),
188+
help=(
189+
"Python version used to deploy the project. If not set, defaults to the calling process's Python minor version."
190+
),
191+
)
192+
@dg_global_options
193+
@cli_telemetry_wrapper
194+
def deploy_command(
195+
organization: Optional[str],
196+
deployment: Optional[str],
197+
python_version: Optional[str],
198+
**global_options: object,
199+
) -> None:
200+
"""Deploy a project to Dagster Plus."""
201+
cli_config = normalize_cli_config(global_options, click.get_current_context())
202+
203+
if not python_version:
204+
python_version = f"3.{sys.version_info.minor}"
205+
206+
plus_config = DagsterPlusCliConfig.get()
207+
208+
organization = organization or plus_config.organization
209+
if not organization:
210+
raise click.UsageError(
211+
"Organization not specified. To specify an organization, use the --organization option "
212+
"or run `dg plus login`."
213+
)
214+
215+
deployment = deployment or plus_config.default_deployment
216+
if not deployment:
217+
raise click.UsageError(
218+
"Deployment not specified. To specify a deployment, use the --deployment option "
219+
"or run `dg plus login`."
220+
)
221+
222+
# TODO This command should work in a workspace context too and apply to multiple projects
223+
dg_context = DgContext.for_project_environment(Path.cwd(), cli_config)
224+
225+
# TODO Confirm that dagster-cloud is packaged in the project
226+
227+
with ExitStack() as stack:
228+
# TODO Once this is split out into multiple commands, we need a default statedir
229+
# that can be persisted across commands.
230+
statedir = stack.enter_context(tempfile.TemporaryDirectory())
231+
232+
# Construct a dagster_cloud.yaml file based on info in the pyproject.toml
233+
dagster_cloud_yaml_file = stack.enter_context(
234+
create_temp_dagster_cloud_yaml_file(dg_context)
235+
)
236+
237+
dg_context.external_dagster_cloud_cli_command(
238+
[
239+
"ci",
240+
"init",
241+
"--statedir",
242+
str(statedir),
243+
"--dagster-cloud-yaml-path",
244+
dagster_cloud_yaml_file,
245+
"--project-dir",
246+
str(dg_context.root_path),
247+
"--deployment",
248+
deployment,
249+
"--organization",
250+
organization,
251+
],
252+
)
253+
254+
dockerfile_path = dg_context.root_path / "Dockerfile"
255+
if not os.path.exists(dockerfile_path):
256+
click.echo(f"No Dockerfile found - scaffolding a default one at {dockerfile_path}.")
257+
_create_temp_deploy_dockerfile(dockerfile_path, python_version)
258+
else:
259+
click.echo(f"Building using Dockerfile at {dockerfile_path}.")
260+
261+
# TODO This command is serverless-specific, support hybrid as well
262+
dg_context.external_dagster_cloud_cli_command(
263+
[
264+
"ci",
265+
"build",
266+
"--statedir",
267+
str(statedir),
268+
"--dockerfile-path",
269+
str(dg_context.root_path / "Dockerfile"),
270+
],
271+
)
272+
273+
dg_context.external_dagster_cloud_cli_command(
274+
[
275+
"ci",
276+
"deploy",
277+
"--statedir",
278+
str(statedir),
279+
],
280+
)

python_modules/libraries/dagster-dg/dagster_dg/cli/utils.py

+26
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,32 @@ def create_temp_workspace_file(dg_context: DgContext) -> Iterator[str]:
176176
yield temp_workspace_file.name
177177

178178

179+
def _dagster_cloud_entry_for_project(dg_context: DgContext) -> dict[str, Any]:
180+
return {
181+
"location_name": dg_context.code_location_name,
182+
"code_source": {
183+
"module_name": str(dg_context.code_location_target_module_name),
184+
},
185+
# TODO build, container_context
186+
}
187+
188+
189+
@contextmanager
190+
def create_temp_dagster_cloud_yaml_file(dg_context: DgContext) -> Iterator[str]:
191+
with NamedTemporaryFile(mode="w+", delete=True) as temp_dagster_cloud_yaml_file:
192+
entries = []
193+
if dg_context.is_project:
194+
entries.append(_dagster_cloud_entry_for_project(dg_context))
195+
elif dg_context.is_workspace:
196+
for spec in dg_context.project_specs:
197+
project_root = dg_context.root_path / spec.path
198+
project_context: DgContext = dg_context.with_root_path(project_root)
199+
entries.append(_dagster_cloud_entry_for_project(project_context))
200+
yaml.dump({"locations": entries}, temp_dagster_cloud_yaml_file)
201+
temp_dagster_cloud_yaml_file.flush()
202+
yield temp_dagster_cloud_yaml_file.name
203+
204+
179205
class DagsterCliCmd(NamedTuple):
180206
cmd_location: str
181207
cmd: list[str]

python_modules/libraries/dagster-dg/dagster_dg/context.py

+14
Original file line numberDiff line numberDiff line change
@@ -434,6 +434,20 @@ def _dagster_components_entry_points(self) -> Mapping[str, str]:
434434
# ##### HELPERS
435435
# ########################
436436

437+
def external_dagster_cloud_cli_command(
438+
self, command: list[str], log: bool = True, env: Optional[dict[str, str]] = None
439+
):
440+
# TODO Match dagster-cloud-cli version with calling dg version
441+
command = ["uv", "tool", "run", "--from", "dagster-cloud-cli", "dagster-cloud", *command]
442+
with pushd(self.root_path):
443+
result = subprocess.run(command, check=False, env={**os.environ, **(env or {})})
444+
if result.returncode != 0:
445+
exit_with_error(f"""
446+
An error occurred while executing a `dagster-cloud` command via `uv tool run`.
447+
448+
`{shlex.join(command)}` exited with code {result.returncode}. Aborting.
449+
""")
450+
437451
def external_components_command(
438452
self,
439453
command: list[str],
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# Based on: https://github.com/astral-sh/uv-docker-example/blob/main/multistage.Dockerfile
2+
3+
# First, build the application in the `/app` directory.
4+
FROM ghcr.io/astral-sh/uv:python{{ python_version }}-bookworm-slim AS builder
5+
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
6+
7+
# Disable Python downloads, because we want to use the system interpreter
8+
# across both images. If using a managed Python version, it needs to be
9+
# copied from the build image into the final image; see `standalone.Dockerfile`
10+
# for an example.
11+
ENV UV_PYTHON_DOWNLOADS=0
12+
13+
WORKDIR /app
14+
RUN --mount=type=cache,target=/root/.cache/uv \
15+
--mount=type=bind,source=uv.lock,target=uv.lock \
16+
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
17+
uv sync --frozen --no-install-project --no-dev
18+
ADD . /app
19+
RUN --mount=type=cache,target=/root/.cache/uv \
20+
uv sync --frozen --no-dev
21+
22+
23+
# Then, use a final image without uv
24+
FROM python:{{ python_version }}-slim-bookworm
25+
# It is important to use the image that matches the builder, as the path to the
26+
# Python executable must be the same, e.g., using `python:3.11-slim-bookworm`
27+
# will fail.
28+
29+
# Copy the application from the builder
30+
COPY --from=builder --chown=app:app /app /app
31+
32+
# Place executables in the environment at the front of the path
33+
ENV PATH="/app/.venv/bin:$PATH"

python_modules/libraries/dagster-dg/dagster_dg_tests/cli_tests/plus_tests/__init__.py

Whitespace-only changes.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import tempfile
2+
from pathlib import Path
3+
from unittest.mock import patch
4+
5+
import pytest
6+
from click.testing import CliRunner
7+
from dagster_dg.cli.plus import plus_group
8+
from dagster_shared.plus.config import DagsterPlusCliConfig
9+
10+
from dagster_dg_tests.utils import isolated_example_project_foo_bar
11+
12+
13+
@pytest.fixture
14+
def logged_in_dg_cli_config(empty_dg_cli_config):
15+
config = DagsterPlusCliConfig(
16+
organization="hooli",
17+
user_token="fake-user-token",
18+
default_deployment="prod",
19+
)
20+
config.write()
21+
yield
22+
23+
24+
@pytest.fixture
25+
def empty_dg_cli_config(monkeypatch):
26+
with (
27+
tempfile.TemporaryDirectory() as tmp_dg_dir,
28+
):
29+
config_path = Path(tmp_dg_dir) / "dg.toml"
30+
monkeypatch.setenv("DG_CLI_CONFIG", config_path)
31+
config = DagsterPlusCliConfig(
32+
organization="",
33+
user_token="",
34+
default_deployment="",
35+
)
36+
config.write()
37+
yield config_path
38+
39+
40+
@pytest.fixture(scope="module")
41+
def runner():
42+
yield CliRunner()
43+
44+
45+
@pytest.fixture(scope="module")
46+
def project(runner):
47+
with isolated_example_project_foo_bar(runner, use_editable_dagster=False, in_workspace=False):
48+
yield
49+
50+
51+
def test_plus_deploy_command(logged_in_dg_cli_config, project, runner):
52+
with patch(
53+
"dagster_dg.context.DgContext.external_dagster_cloud_cli_command",
54+
):
55+
result = runner.invoke(plus_group, ["deploy"])
56+
assert result.exit_code == 0, result.output + " : " + str(result.exception)
57+
assert "No Dockerfile found - scaffolding a default one" in result.output
58+
59+
result = runner.invoke(plus_group, ["deploy"])
60+
assert "Building using Dockerfile at" in result.output
61+
assert result.exit_code == 0, result.output + " : " + str(result.exception)
62+
63+
64+
def test_plus_deploy_command_no_login(empty_dg_cli_config, runner, project):
65+
with patch(
66+
"dagster_dg.context.DgContext.external_dagster_cloud_cli_command",
67+
):
68+
result = runner.invoke(plus_group, ["deploy"])
69+
assert result.exit_code != 0
70+
assert "Organization not specified" in result.output

python_modules/libraries/dagster-dg/dagster_dg_tests/cli_tests/plus_tests/test_plus_login_command.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,10 @@
1717
from dagster_dg.utils.plus import gql
1818

1919
ensure_dagster_dg_tests_import()
20-
from dagster_dg_tests.cli_tests.plus_tests.utils import mock_gql_response
2120
from dagster_shared.plus.config import DagsterPlusCliConfig
2221

22+
from dagster_dg_tests.cli_tests.plus_tests.utils import mock_gql_response
23+
2324

2425
@pytest.fixture()
2526
def setup_cloud_cli_config(monkeypatch):

python_modules/libraries/dagster-dg/dagster_dg_tests/utils.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,7 @@ def isolated_example_project_foo_bar(
198198
component_dirs: Sequence[Path] = [],
199199
config_file_type: ConfigFileType = "pyproject.toml",
200200
package_layout: PackageLayoutType = "src",
201+
use_editable_dagster: bool = True,
201202
) -> Iterator[None]:
202203
"""Scaffold a project named foo_bar in an isolated filesystem.
203204
@@ -218,8 +219,7 @@ def isolated_example_project_foo_bar(
218219
args = [
219220
"scaffold",
220221
"project",
221-
"--use-editable-dagster",
222-
dagster_git_repo_dir,
222+
*(["--use-editable-dagster", dagster_git_repo_dir] if use_editable_dagster else []),
223223
*(["--skip-venv"] if skip_venv else []),
224224
*(["--no-populate-cache"] if not populate_cache else []),
225225
"foo-bar",

0 commit comments

Comments
 (0)