Skip to content

Commit 0b3d6df

Browse files
authored
Add repos[].if_exists run configuration option (#3341)
One of: * error - the new default since 0.20, fail with an error * skip - the non-configurable pre-0.20 action Part-of: #3124
1 parent 4bdadb5 commit 0b3d6df

File tree

13 files changed

+193
-35
lines changed

13 files changed

+193
-35
lines changed

.pre-commit-config.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@ repos:
1010
rev: v2.6.2 # Should match .github/workflows/build-artifacts.yml
1111
hooks:
1212
- id: golangci-lint-full
13+
alias: runner-fix
14+
language_version: 1.25.0 # Should match runner/go.mod
15+
entry: bash -c 'cd runner && golangci-lint run --fix'
16+
stages: [manual]
17+
- id: golangci-lint-full
18+
alias: runner-lint
1319
language_version: 1.25.0 # Should match runner/go.mod
1420
entry: bash -c 'cd runner && golangci-lint run'
1521
stages: [manual]

docs/docs/reference/dstack.yml/dev-environment.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,17 @@ The `dev-environment` configuration type allows running [dev environments](../..
112112
type:
113113
required: true
114114

115+
??? info "`if_exists` action"
116+
117+
If the `path` already exists and is a non-empty directory, by default the run is terminated with an error.
118+
This can be changed with the `if_exists` option:
119+
120+
* `error` – do not try to check out, terminate the run with an error (the default action since `0.20.0`)
121+
* `skip` – do not try to check out, skip the repo (the only action available before `0.20.0`)
122+
123+
Note, if the `path` exists and is _not_ a directory (e.g., a regular file), this is always an error that
124+
cannot be ignored with the `skip` action.
125+
115126
??? info "Short syntax"
116127

117128
The short syntax for repos is a colon-separated string in the form of `local_path_or_url:path`.

docs/docs/reference/dstack.yml/service.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,17 @@ The `service` configuration type allows running [services](../../concepts/servic
227227
type:
228228
required: true
229229

230+
??? info "`if_exists` action"
231+
232+
If the `path` already exists and is a non-empty directory, by default the run is terminated with an error.
233+
This can be changed with the `if_exists` option:
234+
235+
* `error` – do not try to check out, terminate the run with an error (the default action since `0.20.0`)
236+
* `skip` – do not try to check out, skip the repo (the only action available before `0.20.0`)
237+
238+
Note, if the `path` exists and is _not_ a directory (e.g., a regular file), this is always an error that
239+
cannot be ignored with the `skip` action.
240+
230241
??? info "Short syntax"
231242

232243
The short syntax for repos is a colon-separated string in the form of `local_path_or_url:path`.

docs/docs/reference/dstack.yml/task.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,17 @@ The `task` configuration type allows running [tasks](../../concepts/tasks.md).
112112
type:
113113
required: true
114114

115+
??? info "`if_exists` action"
116+
117+
If the `path` already exists and is a non-empty directory, by default the run is terminated with an error.
118+
This can be changed with the `if_exists` option:
119+
120+
* `error` – do not try to check out, terminate the run with an error (the default action since `0.20.0`)
121+
* `skip` – do not try to check out, skip the repo (the only action available before `0.20.0`)
122+
123+
Note, if the `path` exists and is _not_ a directory (e.g., a regular file), this is always an error that
124+
cannot be ignored with the `skip` action.
125+
115126
??? info "Short syntax"
116127

117128
The short syntax for repos is a colon-separated string in the form of `local_path_or_url:path`.

runner/internal/executor/repo.go

Lines changed: 29 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"github.com/dstackai/dstack/runner/internal/common"
1414
"github.com/dstackai/dstack/runner/internal/log"
1515
"github.com/dstackai/dstack/runner/internal/repo"
16+
"github.com/dstackai/dstack/runner/internal/schemas"
1617
)
1718

1819
// setupRepo must be called from Run
@@ -36,13 +37,27 @@ func (ex *RunExecutor) setupRepo(ctx context.Context) error {
3637
}
3738
log.Trace(ctx, "Job repo dir", "path", ex.repoDir)
3839

39-
shouldCheckout, err := ex.shouldCheckout(ctx)
40+
repoDirIsEmpty, err := ex.prepareRepoDir(ctx)
4041
if err != nil {
41-
return fmt.Errorf("check if checkout needed: %w", err)
42-
}
43-
if !shouldCheckout {
44-
log.Info(ctx, "skipping repo checkout: repo dir is not empty")
45-
return nil
42+
return fmt.Errorf("prepare repo dir: %w", err)
43+
}
44+
if !repoDirIsEmpty {
45+
var repoExistsAction schemas.RepoExistsAction
46+
if ex.jobSpec.RepoExistsAction != nil {
47+
repoExistsAction = *ex.jobSpec.RepoExistsAction
48+
} else {
49+
log.Debug(ctx, "repo_exists_action is not set, using legacy 'skip' action")
50+
repoExistsAction = schemas.RepoExistsActionSkip
51+
}
52+
switch repoExistsAction {
53+
case schemas.RepoExistsActionError:
54+
return fmt.Errorf("setup repo: repo dir is not empty: %s", ex.repoDir)
55+
case schemas.RepoExistsActionSkip:
56+
log.Info(ctx, "Skipping repo checkout: repo dir is not empty", "path", ex.repoDir)
57+
return nil
58+
default:
59+
return fmt.Errorf("setup repo: unsupported action: %s", repoExistsAction)
60+
}
4661
}
4762
// Move existing repo files from the repo dir and back to be able to git clone.
4863
// Currently, only needed for volumes mounted inside repo with lost+found present.
@@ -143,11 +158,11 @@ func (ex *RunExecutor) prepareArchive(ctx context.Context) error {
143158
return nil
144159
}
145160

146-
func (ex *RunExecutor) shouldCheckout(ctx context.Context) (bool, error) {
147-
log.Trace(ctx, "checking if repo checkout is needed")
161+
func (ex *RunExecutor) prepareRepoDir(ctx context.Context) (bool, error) {
162+
log.Trace(ctx, "Preparing repo dir")
148163
info, err := os.Stat(ex.repoDir)
149164
if err != nil {
150-
if os.IsNotExist(err) {
165+
if errors.Is(err, os.ErrNotExist) {
151166
if err = common.MkdirAll(ctx, ex.repoDir, ex.jobUid, ex.jobGid); err != nil {
152167
return false, fmt.Errorf("create repo dir: %w", err)
153168
}
@@ -157,24 +172,22 @@ func (ex *RunExecutor) shouldCheckout(ctx context.Context) (bool, error) {
157172
return false, fmt.Errorf("stat repo dir: %w", err)
158173
}
159174
if !info.IsDir() {
160-
return false, fmt.Errorf("failed to set up repo dir: %s is not a dir", ex.repoDir)
175+
return false, fmt.Errorf("stat repo dir: %s is not a dir", ex.repoDir)
161176
}
162177
entries, err := os.ReadDir(ex.repoDir)
163178
if err != nil {
164179
return false, fmt.Errorf("read repo dir: %w", err)
165180
}
166181
if len(entries) == 0 {
167-
// Repo dir existed but was empty, e.g. a volume without repo
182+
// Repo dir is empty
168183
return true, nil
169184
}
170-
if len(entries) > 1 {
171-
// Repo already checked out, e.g. a volume with repo
172-
return false, nil
173-
}
174-
if entries[0].Name() == "lost+found" {
185+
if len(entries) == 1 && entries[0].Name() == "lost+found" {
175186
// lost+found may be present on a newly created volume
187+
// We (but not Git, see `{move,restore}RepoDir`) consider such a dir "empty"
176188
return true, nil
177189
}
190+
// Repo dir is not empty
178191
return false, nil
179192
}
180193

runner/internal/schemas/schemas.go

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,9 @@ type JobSpec struct {
7171
// `RepoData` is optional for compatibility with jobs submitted before 0.19.17.
7272
// Use `RunExecutor.getRepoData()` to get non-nil `RepoData`.
7373
// TODO: make required when supporting jobs submitted before 0.19.17 is no longer relevant.
74-
RepoData *RepoData `json:"repo_data"`
75-
FileArchives []FileArchive `json:"file_archives"`
74+
RepoData *RepoData `json:"repo_data"`
75+
RepoExistsAction *RepoExistsAction `json:"repo_exists_action"`
76+
FileArchives []FileArchive `json:"file_archives"`
7677
}
7778

7879
type ClusterInfo struct {
@@ -102,6 +103,13 @@ type RepoData struct {
102103
RepoConfigEmail string `json:"repo_config_email"`
103104
}
104105

106+
type RepoExistsAction string
107+
108+
const (
109+
RepoExistsActionError RepoExistsAction = "error"
110+
RepoExistsActionSkip RepoExistsAction = "skip"
111+
)
112+
105113
type FileArchive struct {
106114
Id string `json:"id"`
107115
Path string `json:"path"`

scripts/docs/gen_schema_reference.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import inspect
77
import logging
88
import re
9+
from enum import Enum
910
from fnmatch import fnmatch
1011

1112
import mkdocs_gen_files
@@ -63,11 +64,14 @@ def generate_schema_reference(
6364
]
6465
)
6566
for name, field in cls.__fields__.items():
67+
default = field.default
68+
if isinstance(default, Enum):
69+
default = default.value
6670
values = dict(
6771
name=name,
6872
description=field.field_info.description,
6973
type=get_type(field.annotation),
70-
default=field.default,
74+
default=default,
7175
required=field.required,
7276
)
7377
# TODO: If the field doesn't have description (e.g. BaseConfiguration.type), we could fallback to docstring

src/dstack/_internal/core/compatibility/runs.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,6 @@ def get_run_spec_excludes(run_spec: RunSpec) -> IncludeExcludeDictType:
178178
configuration_excludes["schedule"] = True
179179
if profile is not None and profile.schedule is None:
180180
profile_excludes.add("schedule")
181-
configuration_excludes["repos"] = True
182181

183182
if configuration_excludes:
184183
spec_excludes["configuration"] = configuration_excludes

src/dstack/_internal/core/models/configurations.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
from dstack._internal.core.models.services import AnyModel, OpenAIChatModel
3232
from dstack._internal.core.models.unix import UnixUser
3333
from dstack._internal.core.models.volumes import MountPoint, VolumeConfiguration, parse_mount_point
34-
from dstack._internal.utils.common import has_duplicates
34+
from dstack._internal.utils.common import has_duplicates, list_enum_values_for_annotation
3535
from dstack._internal.utils.json_schema import add_extra_schema_types
3636
from dstack._internal.utils.json_utils import (
3737
pydantic_orjson_dumps_with_indent,
@@ -95,6 +95,13 @@ def parse(cls, v: str) -> "PortMapping":
9595
return PortMapping(local_port=local_port, container_port=int(container_port))
9696

9797

98+
class RepoExistsAction(str, Enum):
99+
# Don't try to check out, terminate the run with an error (the default action since 0.20.0)
100+
ERROR = "error"
101+
# Don't try to check out, skip the repo (the logic hardcoded in the pre-0.20.0 runner)
102+
SKIP = "skip"
103+
104+
98105
class RepoSpec(CoreModel):
99106
local_path: Annotated[
100107
Optional[str],
@@ -132,6 +139,15 @@ class RepoSpec(CoreModel):
132139
)
133140
),
134141
] = None
142+
if_exists: Annotated[
143+
RepoExistsAction,
144+
Field(
145+
description=(
146+
"The action to be taken if `path` exists and is not empty."
147+
f" One of: {list_enum_values_for_annotation(RepoExistsAction)}"
148+
),
149+
),
150+
] = RepoExistsAction.ERROR
135151

136152
@classmethod
137153
def parse(cls, v: str) -> Self:

src/dstack/_internal/core/models/runs.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
AnyRunConfiguration,
2222
HTTPHeaderSpec,
2323
HTTPMethod,
24+
RepoExistsAction,
2425
RunConfiguration,
2526
ServiceConfiguration,
2627
)
@@ -281,6 +282,8 @@ class JobSpec(CoreModel):
281282
repo_code_hash: Optional[str] = None
282283
# `repo_dir` was added in 0.19.27. Default value is set for backward compatibility
283284
repo_dir: str = LEGACY_REPO_DIR
285+
# None for jobs without repo and any jobs submitted by pre-0.20.0 clients
286+
repo_exists_action: Optional[RepoExistsAction] = None
284287
file_archives: list[FileArchiveMapping] = []
285288
# None for non-services and pre-0.19.19 services. See `get_service_port`
286289
service_port: Optional[int] = None

0 commit comments

Comments
 (0)