Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- The `dr workload config` setup wizard, which `dr workload up` also opens for a project with no manifest, no longer shows a fresh directory's suggested name as one you typed. Choosing another directory on the way back, or setting up a new workload after looking at an existing one, rebuilds the draft from this run's flags, but the mark that says the name was given by hand outlived the draft it belonged to, so the new directory's suggestion came back filled in as a value. One reflexive Enter could then put a name like `src` on a deployed workload, which is what the name screen exists to prevent. The draft and that mark now start over together, so the suggestion is offered as a placeholder again; a `--name` still counts as given, because that answer belongs to the run rather than to the draft being replaced.
- `dr workload up` now deploys onto an errored workload instead of refusing it, whenever the deploy gives it something new to run: a code change, a change to the manifest or the sizing, or `--force-build`. What the workload is running has failed, so there is nothing serving for the swap to endanger, and the plan says so with the platform's own reason for the failure beside the state. A deploy with nothing new in it is still refused, and the refusal names what would work: `--force-build` for a project whose image the platform builds, a change to the image or artifact otherwise, and the delete as the last resort. An errored workload never has its image copied forward, since on a locked version the copy would be locked too, permanently, pointing at an image the registry may no longer have; the version is built instead. A stopped workload whose start comes up errored ahead of a roll is rolled anyway, and the name conflict on an errored holder advises binding to it again. Together these close a deadlock in which a locked production workload whose image the registry had pruned could be neither deployed onto, nor started, nor rebuilt.
- `dr workload up --force-build` now rebuilds and rolls even when the working tree and the manifest have not changed, which is the case the flag exists for: an image gone from the registry leaves both exactly as they were. The flag used to be read only by the build step, so a plan with nothing else in it came out empty and the run reported the workload as up to date. A forced build on a locked version locks its successor to match; on a manifest that names its image rather than building it the flag stays idle and says so.
- `dr artifact code sync` and `dr workload up` now name the File Registry entry they create `Artifact: <id>`, instead of leaving it on whatever the platform picked. The Files API takes a name only at creation and the CLI was never sending one, so what the Registry showed was an accident of which upload route ran: a change set small enough for the stage route landed as `Untitled Dataset`, and anything over 20 files or 50 MB landed as `wapi-sync.zip`, the CLI's own temporary filename, because the platform derives a title from the uploaded file when nothing else names it. Neither says whose code it is, and since the Registry accepts duplicate names without complaint, a tenant ended up with a column of identical rows and no way to tell them apart short of a hand rename. The artifact's name is deliberately not what goes in: naming is create-time only, so whatever goes in is what the entry shows for the rest of its life, and a name the project is free to change afterwards would end up telling a reader something that had stopped being true, which is the same defect as the default titles and harder to spot. It would not identify the row either, since nothing stops two projects from choosing the same artifact name and `dr workload up` derives that name from the directory. The id names the artifact that created the entry, which is not always the one deploying from it later: every successful `up` locks the artifact it deployed and mints a fresh one for the next deploy, while the catalog stays where it is. A locked artifact is kept rather than deleted, so the id goes on resolving and goes on being true about where the code came from, naming the first version of the lineage rather than the current one. A large first sync now creates its catalog before uploading, rather than in the same request: the name travels on the create call, which is the only route that tolerates it everywhere, since that route took no request body before the parameter existed while the create-from-file route validates its form strictly and would reject an unknown field outright. The cost is one extra request on a first sync, and it makes the two upload paths the same shape.
- `dr artifact code sync` and `dr workload up` now replace files in the catalog when the upload takes the zip route, which is any change set of more than 20 files or 50 MB. The overwrite mode was sent only as a query parameter of the Files API's `fromFile` upload, which the server accepts and ignores, so its rename default applied: every path already in the catalog came back as a `name (2).ext` duplicate holding the new bytes while the original kept the old ones, the version had twice the files, and an image built from it ran stale code next to junk. Smaller change sets, which go through a stage, were never affected. The mode now travels in the multipart form, ahead of the file, where the server reads it; it is still sent in the query as well until the API says which of the two is authoritative.
- `dr workload up` now says when `.env` and the manifest have parted, instead of reporting a deploy as up to date without having looked at the file you just edited. Nothing is applied and nothing is written: a deploy stays a function of the committed repo, and reading the file to say the two disagree is not deploying from it. The notice is the one `dr workload config` has printed all along, names only and never values, and it names the flag that settles it on the command you are already running rather than sending you to a different one, carrying the `--dir` this run was given because `config` looks only where it is pointed while `up` walks upward for the manifest. Silent where there is no `.env`, which is the ordinary CI case, and silent where the two files agree. What is left to `config` is everything that is true of the project rather than of this run: the values behind credential references, which nothing can compare; the names the classifier read as local-only, which no flag will ever add; an entry left naming the credential placeholder, which the deploy refuses for itself when it reaches it; and a manifest whose shape no flag can edit, whose refusal counts the whole of `.env` as missing. None of those can be settled by anything the reader is about to run, so on a deploy each would print on every run for the life of the project, and a line that always prints is one you stop reading along with the drift beside it.
- `dr workload up --update-env` and `dr workload config --update-env` now name the `.env` variables the manifest does not declare even when the run rewrote something. The notice used to arrive only on the run that found nothing to do, so reconciling every value you could left the file reading as a clean bill of health while a variable it had never carried went on reaching nothing.
Expand Down
6 changes: 1 addition & 5 deletions cmd/artifact/code/checkout/cmd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,17 +103,13 @@ func (f *fakeClient) callCounts() (all, dl int) {
}

// Panicking stubs for the rest of filesapi.Client.
func (*fakeClient) CreateCatalog() (*filesapi.CatalogResp, error) { panic("unused") }
func (*fakeClient) CreateCatalog(string) (*filesapi.CatalogResp, error) { panic("unused") }
func (*fakeClient) CreateStage(string) (*filesapi.StageResp, error) { panic("unused") }
func (*fakeClient) UploadToStage(string, string, string, int64, io.Reader) error { panic("unused") }
func (*fakeClient) ApplyStage(string, string, string) (*filesapi.ApplyStageResp, error) {
panic("unused")
}

func (*fakeClient) UploadFromZipNew(string, int64, io.Reader) (*filesapi.FromFileResp, error) {
panic("unused")
}

func (*fakeClient) UploadFromZipExisting(string, string, string, int64, io.Reader) (*filesapi.FromFileResp, error) {
panic("unused")
}
Expand Down
6 changes: 1 addition & 5 deletions cmd/artifact/code/versions/cmd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ func (f *fakeClient) ListVersions(catalogID string, limit int) ([]filesapi.Catal
}

// Unused interface methods.
func (*fakeClient) CreateCatalog() (*filesapi.CatalogResp, error) { panic("unused") }
func (*fakeClient) CreateCatalog(string) (*filesapi.CatalogResp, error) { panic("unused") }

func (*fakeClient) CreateStage(string) (*filesapi.StageResp, error) {
panic("unused")
Expand All @@ -64,10 +64,6 @@ func (*fakeClient) ApplyStage(string, string, string) (*filesapi.ApplyStageResp,
panic("unused")
}

func (*fakeClient) UploadFromZipNew(string, int64, io.Reader) (*filesapi.FromFileResp, error) {
panic("unused")
}

func (*fakeClient) UploadFromZipExisting(string, string, string, int64, io.Reader) (*filesapi.FromFileResp, error) {
panic("unused")
}
Expand Down
1 change: 1 addition & 0 deletions docs/commands/artifact.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ dr artifact code checkout [<ver>] [--dir <path>] [--clean]
- `init` creates the `.datarobot/workload/` state directory and binds it to an existing draft artifact. The artifact must already exist (`dr artifact create` or the DataRobot UI); these commands manage an artifact's code, not its lifecycle. It also drops a starter `.drignore` at the project root, in gitignore syntax, listing what `sync` should leave out. Edit it and commit it. A project that already has an ignore file under either name keeps it, and no new one is written.
- Projects created before the file was renamed have a `.wapiignore` instead. It is still read when there is no `.drignore` beside it, and `sync` says so once per run, but the name is deprecated: rename the file when convenient. If both are present, `.drignore` is the one that applies and `sync` warns that the other file's patterns are not in effect. Merge them and delete the old one: two ignore files at a project root is a state where patterns you wrote silently stop filtering. The ignore file is uploaded with your code, so if you work with others, agree on the rename rather than doing it alone.
- `sync` computes a three-way diff against the last synced state and applies it in one versioned step. Conflicts resolve to the remote copy, and your version is kept as a `*.LOCAL.<timestamp>` file. Preview with `--dry-run`, or use `--diff` to also see per-file diffs. Both exit before any remote write.
- The first `sync` of a project creates the File Registry entry that holds its code, named `Artifact: <id>` so the row identifies its project in the Registry's Files list. It carries the artifact id rather than the artifact name because naming is create-time only: the entry keeps whatever name it was created with, so a name the project later changes would go stale in place, and artifact names are not unique to begin with (`dr workload up` derives one from the directory). The id is the artifact that first pushed the code; after a deploy locks that artifact and `up` mints its successor, the entry keeps naming the first version of the lineage, which still resolves. Later syncs add versions to the entry and leave its name alone, so an entry created before the CLI sent a name keeps whatever the platform picked (`Untitled Dataset`, or `wapi-sync.zip` if the first sync was large enough to take the zip route). Rename it in the UI, or with `PATCH /api/v2/catalogItems/<catalog-id>/`; the catalog id is in `.datarobot/workload/config.json`.
- For Python projects, the image build requires a `uv.lock` next to `pyproject.toml`. When your project has `pyproject.toml` but no `uv.lock`, `sync` generates one automatically by running your local `uv lock` (your uv configuration, private indexes, and credentials apply) and uploads it with the rest of your code — commit the generated file to your repo. If `uv` is not installed or lock generation fails, sync still completes and prints what to do (`uv lock`, then re-sync); the image build will fail until a lock file is added. This also happens on `--dry-run`/`--diff`, so the preview matches what a real sync would upload. An existing `uv.lock` is never modified, and sync warns if your `.drignore` excludes it.
- `versions` lists the artifact's catalog versions, marking the one the artifact currently points at (`*`) and noting the one you last synced.
- `checkout` downloads a version into `.datarobot/workload/.checkouts/<version-id>/` for read-only inspection; your working directory is left untouched. `--clean` removes checkout directories instead of downloading.
Expand Down
42 changes: 42 additions & 0 deletions internal/drapi/filesapi/catalogname.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// Copyright 2026 DataRobot, Inc. and its affiliates.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package filesapi

// CatalogNameMaxLen is the Files API's ceiling on a catalog entry name,
// counted in characters rather than bytes because the server validates a
// decoded Python string. Overshooting it fails the whole create with a
// 422, so the name is clamped here instead.
const CatalogNameMaxLen = 255

// ClampCatalogName trims a name to what the Files API accepts. An empty
// name stays empty and callers leave it off the request, which is what
// puts the platform's own default on the entry.
//
// Exported so a caller assembling a name out of parts measures it the
// same way the server does, rather than keeping a second copy of the rule
// and its reasoning.
//
// Clamping rather than erroring: the name is a label on an entry whose
// contents are the point, and the artifact names it mirrors are allowed
// to be twenty times longer. Refusing the upload over a cosmetic field
// would trade a slightly shortened label for a failed sync.
func ClampCatalogName(name string) string {
runes := []rune(name)
if len(runes) <= CatalogNameMaxLen {
return name
}

return string(runes[:CatalogNameMaxLen])
}
5 changes: 2 additions & 3 deletions internal/drapi/filesapi/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,11 @@ import (
)

type Client interface {
CreateCatalog() (*CatalogResp, error)
CreateCatalog(name string) (*CatalogResp, error)
CreateStage(catalogID string) (*StageResp, error)
UploadToStage(catalogID, stageID, name string, size int64, body io.Reader) error
ApplyStage(catalogID, stageID, overwrite string) (*ApplyStageResp, error)
UploadFromZipNew(name string, size int64, body io.Reader) (*FromFileResp, error)
UploadFromZipExisting(catalogID, name, overwrite string, size int64, body io.Reader) (*FromFileResp, error)
UploadFromZipExisting(catalogID, filename, overwrite string, size int64, body io.Reader) (*FromFileResp, error)
PollStatus(statusID string) (*StatusResp, error)
AllFiles(catalogID, versionID string) (map[string]FileMeta, error)
DownloadFile(catalogID, versionID, path string, w io.Writer) (string, int64, error)
Expand Down
97 changes: 53 additions & 44 deletions internal/drapi/filesapi/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,20 +64,72 @@ func startServer(t *testing.T, handler http.Handler) *httptest.Server {
return srv
}

// TestCreateCatalog pins the name onto the JSON body. Sent anywhere else
// it is dropped and the entry lands in the File Registry as "Untitled
// Dataset", which is the whole defect this parameter exists to fix.
func TestCreateCatalog(t *testing.T) {
var gotBody map[string]any

startServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodPost, r.Method)
assert.Equal(t, "/api/v2/files/", r.URL.Path)
assert.NoError(t, json.NewDecoder(r.Body).Decode(&gotBody))
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"catalogId":"cid-1","catalogVersionId":"v0"}`))
}))

c := New()
got, err := c.CreateCatalog()
got, err := c.CreateCatalog("my-agent-artifact")

require.NoError(t, err)
assert.Equal(t, "cid-1", got.CatalogID)
assert.Equal(t, "v0", got.CatalogVersionID)
assert.Equal(t, map[string]any{"name": "my-agent-artifact"}, gotBody)
}

// TestCreateCatalog_NoName checks that an unnamed create sends no name key
// at all rather than an empty string. The server reads the field as `name
// or <derived title>`, where a blank string is falsy, so "omitted" and
// "empty" are not interchangeable.
func TestCreateCatalog_NoName(t *testing.T) {
var gotBody map[string]any

startServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.NoError(t, json.NewDecoder(r.Body).Decode(&gotBody))
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"catalogId":"cid-1","catalogVersionId":"v0"}`))
}))

c := New()
_, err := c.CreateCatalog("")

require.NoError(t, err)
assert.Empty(t, gotBody, "an unnamed create must not carry a name key")
}

// TestCreateCatalog_ClampsName covers the one input that would otherwise
// fail the create outright: workload artifact names are allowed to run to
// 5000 characters, the Files API stops at 255.
func TestCreateCatalog_ClampsName(t *testing.T) {
var gotName string

startServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body map[string]any

assert.NoError(t, json.NewDecoder(r.Body).Decode(&body))

gotName, _ = body["name"].(string)

w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"catalogId":"cid-1","catalogVersionId":"v0"}`))
}))

c := New()
_, err := c.CreateCatalog(strings.Repeat("é", 300))

require.NoError(t, err)
assert.Equal(t, strings.Repeat("é", 255), gotName,
"the limit counts characters, so multi-byte names must not be cut mid-rune")
}

func TestCreateStage_ApplyStage(t *testing.T) {
Expand Down Expand Up @@ -480,49 +532,6 @@ func TestUploadFromZipExisting_ContentLengthWithFormFields(t *testing.T) {
require.NoError(t, err)
}

// TestUploadFromZipNew_HitsFromFileEndpoint locks in the (post-2026-04-30)
// fix that the new-catalog-from-zip path posts to /files/fromFile/ rather
// than /files/. The bare /files/ endpoint silently created an empty catalog
// without extracting the zip, so smoke-tested syncs reported success but
// the remote was empty.
func TestUploadFromZipNew_HitsFromFileEndpoint(t *testing.T) {
startServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v2/files/fromFile/", r.URL.Path)
assert.Equal(t, "true", r.URL.Query().Get("useArchiveContents"))
assert.Contains(t, r.Header.Get("Content-Type"), "multipart/form-data")

mr, err := r.MultipartReader()
if !assert.NoError(t, err) {
return
}

part, err := mr.NextPart()
if !assert.NoError(t, err) {
return
}

assert.Equal(t, "file", part.FormName())
assert.Equal(t, "wapi-sync.zip", part.FileName())

// A new catalog has no paths to collide with, so no overwrite
// field travels: the file is the only part.
_, err = mr.NextPart()
assert.ErrorIs(t, err, io.EOF)

w.WriteHeader(http.StatusAccepted)
_, _ = w.Write([]byte(`{"catalogId":"new-cid","catalogVersionId":"new-ver","statusId":"sid-new"}`))
}))

c := New()

zipBody := bytes.NewReader([]byte("PK\x03\x04fake-zip"))
resp, err := c.UploadFromZipNew("wapi-sync.zip", int64(zipBody.Len()), zipBody)
require.NoError(t, err)
assert.Equal(t, "new-cid", resp.CatalogID)
assert.Equal(t, "new-ver", resp.CatalogVersionID)
assert.Equal(t, "sid-new", resp.StatusID)
}

// Ensure the package's mime/multipart writer references compile (helps catch
// import drift if someone removes the import after extracting helpers).
var _ = multipart.ErrMessageTooLarge
Loading
Loading