diff --git a/core/directory.go b/core/directory.go index 3a8c7783cc8..8680fc0d168 100644 --- a/core/directory.go +++ b/core/directory.go @@ -702,7 +702,7 @@ func (dir *Directory) Without(ctx context.Context, path string) (*Directory, err return dir, nil } -func (dir *Directory) Export(ctx context.Context, destPath string) (rerr error) { +func (dir *Directory) Export(ctx context.Context, destPath string, merge bool) (rerr error) { svcs := dir.Query.Services bk := dir.Query.Buildkit @@ -735,7 +735,7 @@ func (dir *Directory) Export(ctx context.Context, destPath string) (rerr error) } defer detach() - return bk.LocalDirExport(ctx, defPB, destPath) + return bk.LocalDirExport(ctx, defPB, destPath, merge) } // Root removes any relative path from the directory. diff --git a/core/integration/directory_test.go b/core/integration/directory_test.go index 8241323ef84..ae33d2748fa 100644 --- a/core/integration/directory_test.go +++ b/core/integration/directory_test.go @@ -678,6 +678,26 @@ func TestDirectoryExport(t *testing.T) { entries, err := ls(wd) require.NoError(t, err) require.Equal(t, []string{"20locale.sh", "README", "color_prompt.sh.disabled"}, entries) + + t.Run("wipe flag", func(t *testing.T) { + dir := dir.WithoutFile("README") + + // by default a delete in the source dir won't overwrite the destination on the host + ok, err := dir.Export(ctx, ".") + require.NoError(t, err) + require.True(t, ok) + entries, err = ls(wd) + require.NoError(t, err) + require.Equal(t, []string{"20locale.sh", "README", "color_prompt.sh.disabled"}, entries) + + // wipe results in the destination being replaced with the source entirely, including deletes + ok, err = dir.Export(ctx, ".", dagger.DirectoryExportOpts{Wipe: true}) + require.NoError(t, err) + require.True(t, ok) + entries, err = ls(wd) + require.NoError(t, err) + require.Equal(t, []string{"20locale.sh", "color_prompt.sh.disabled"}, entries) + }) }) t.Run("to outer dir", func(t *testing.T) { diff --git a/core/schema/directory.go b/core/schema/directory.go index f9db9e185c1..bf0e6145dd0 100644 --- a/core/schema/directory.go +++ b/core/schema/directory.go @@ -80,7 +80,8 @@ func (s *directorySchema) Install() { dagql.Func("export", s.export). Impure("Writes to the local host."). Doc(`Writes the contents of the directory to a path on the host.`). - ArgDoc("path", `Location of the copied directory (e.g., "logs/").`), + ArgDoc("path", `Location of the copied directory (e.g., "logs/").`). + ArgDoc("wipe", `If true, then the host directory will be wiped clean before exporting so that it exactly matches the directory being exported; this means it will delete any files on the host that aren't in the exported dir. If false (the default), the contents of the directory will be merged with any existing contents of the host directory, leaving any existing files on the host that aren't in the exported directory alone.`), dagql.Func("dockerBuild", s.dockerBuild). Doc(`Builds a new Docker container from this directory.`). ArgDoc("dockerfile", `Path to the Dockerfile to use (e.g., "frontend.Dockerfile").`). @@ -262,10 +263,11 @@ func (s *directorySchema) diff(ctx context.Context, parent *core.Directory, args type dirExportArgs struct { Path string + Wipe bool `default:"false"` } func (s *directorySchema) export(ctx context.Context, parent *core.Directory, args dirExportArgs) (dagql.Boolean, error) { - err := parent.Export(ctx, args.Path) + err := parent.Export(ctx, args.Path, !args.Wipe) if err != nil { return false, err } diff --git a/docs/docs-graphql/schema.graphqls b/docs/docs-graphql/schema.graphqls index 0b8e1134b5b..45a6af90a6c 100644 --- a/docs/docs-graphql/schema.graphqls +++ b/docs/docs-graphql/schema.graphqls @@ -993,6 +993,16 @@ type Directory { export( """Location of the copied directory (e.g., "logs/").""" path: String! + + """ + If true, then the host directory will be wiped clean before exporting so + that it exactly matches the directory being exported; this means it will + delete any files on the host that aren't in the exported dir. If false (the + default), the contents of the directory will be merged with any existing + contents of the host directory, leaving any existing files on the host that + aren't in the exported directory alone. + """ + wipe: Boolean = false ): Boolean! """Retrieves a file at the given path.""" diff --git a/engine/buildkit/filesync.go b/engine/buildkit/filesync.go index 096f71c2266..90a61aaa1c9 100644 --- a/engine/buildkit/filesync.go +++ b/engine/buildkit/filesync.go @@ -179,6 +179,7 @@ func (c *Client) LocalDirExport( ctx context.Context, def *bksolverpb.Definition, destPath string, + merge bool, ) (rerr error) { ctx = bklog.WithLogger(ctx, bklog.G(ctx).WithField("export_path", destPath)) bklog.G(ctx).Debug("exporting local dir") @@ -226,7 +227,8 @@ func (c *Client) LocalDirExport( } ctx = engine.LocalExportOpts{ - Path: destPath, + Path: destPath, + Merge: merge, }.AppendToOutgoingContext(ctx) _, descRef, err := expInstance.Export(ctx, cacheRes, nil, clientMetadata.ClientID) diff --git a/engine/client/client.go b/engine/client/client.go index a6b278920b1..dc2fac4dbc1 100644 --- a/engine/client/client.go +++ b/engine/client/client.go @@ -763,7 +763,7 @@ func (AnyDirTarget) DiffCopy(stream filesync.FileSend_DiffCopyServer) (rerr erro } err := fsutil.Receive(stream.Context(), stream, opts.Path, fsutil.ReceiveOpt{ - Merge: true, + Merge: opts.Merge, Filter: func(path string, stat *fstypes.Stat) bool { stat.Uid = uint32(os.Getuid()) stat.Gid = uint32(os.Getgid()) diff --git a/engine/opts.go b/engine/opts.go index b8a14fc6c5e..306aab98076 100644 --- a/engine/opts.go +++ b/engine/opts.go @@ -178,6 +178,10 @@ type LocalExportOpts struct { FileOriginalName string `json:"file_original_name"` AllowParentDirPath bool `json:"allow_parent_dir_path"` FileMode os.FileMode `json:"file_mode"` + // whether to just merge in contents of a directory to the target on the host + // or to replace the target entirely such that it matches the source directory, + // which includes deleting any files that are not in the source directory + Merge bool } func (o LocalExportOpts) ToGRPCMD() metadata.MD { diff --git a/sdk/elixir/lib/dagger/gen/directory.ex b/sdk/elixir/lib/dagger/gen/directory.ex index f66ca95dce1..a2a6e6de549 100644 --- a/sdk/elixir/lib/dagger/gen/directory.ex +++ b/sdk/elixir/lib/dagger/gen/directory.ex @@ -118,11 +118,20 @@ defmodule Dagger.Directory do ) ( - @doc "Writes the contents of the directory to a path on the host.\n\n## Required Arguments\n\n* `path` - Location of the copied directory (e.g., \"logs/\")." - @spec export(t(), Dagger.String.t()) :: {:ok, Dagger.Boolean.t()} | {:error, term()} - def export(%__MODULE__{} = directory, path) do + @doc "Writes the contents of the directory to a path on the host.\n\n## Required Arguments\n\n* `path` - Location of the copied directory (e.g., \"logs/\").\n\n## Optional Arguments\n\n* `wipe` - If true, then the host directory will be wiped clean before exporting so that it exactly matches the directory being exported; this means it will delete any files on the host that aren't in the exported dir. If false (the default), the contents of the directory will be merged with any existing contents of the host directory, leaving any existing files on the host that aren't in the exported directory alone." + @spec export(t(), Dagger.String.t(), keyword()) :: + {:ok, Dagger.Boolean.t()} | {:error, term()} + def export(%__MODULE__{} = directory, path, optional_args \\ []) do selection = select(directory.selection, "export") selection = arg(selection, "path", path) + + selection = + if is_nil(optional_args[:wipe]) do + selection + else + arg(selection, "wipe", optional_args[:wipe]) + end + execute(selection, directory.client) end ) diff --git a/sdk/go/dagger.gen.go b/sdk/go/dagger.gen.go index dae54582d83..0a1312892cf 100644 --- a/sdk/go/dagger.gen.go +++ b/sdk/go/dagger.gen.go @@ -1938,12 +1938,24 @@ func (r *Directory) Entries(ctx context.Context, opts ...DirectoryEntriesOpts) ( return response, q.Execute(ctx) } +// DirectoryExportOpts contains options for Directory.Export +type DirectoryExportOpts struct { + // If true, then the host directory will be wiped clean before exporting so that it exactly matches the directory being exported; this means it will delete any files on the host that aren't in the exported dir. If false (the default), the contents of the directory will be merged with any existing contents of the host directory, leaving any existing files on the host that aren't in the exported directory alone. + Wipe bool +} + // Writes the contents of the directory to a path on the host. -func (r *Directory) Export(ctx context.Context, path string) (bool, error) { +func (r *Directory) Export(ctx context.Context, path string, opts ...DirectoryExportOpts) (bool, error) { if r.export != nil { return *r.export, nil } q := r.query.Select("export") + for i := len(opts) - 1; i >= 0; i-- { + // `wipe` optional argument + if !querybuilder.IsZeroValue(opts[i].Wipe) { + q = q.Arg("wipe", opts[i].Wipe) + } + } q = q.Arg("path", path) var response bool diff --git a/sdk/php/generated/Directory.php b/sdk/php/generated/Directory.php index 22c8f356fb9..cd06a9255cb 100644 --- a/sdk/php/generated/Directory.php +++ b/sdk/php/generated/Directory.php @@ -90,10 +90,13 @@ public function entries(?string $path = null): array /** * Writes the contents of the directory to a path on the host. */ - public function export(string $path): bool + public function export(string $path, ?bool $wipe = false): bool { $leafQueryBuilder = new \Dagger\Client\QueryBuilder('export'); $leafQueryBuilder->setArgument('path', $path); + if (null !== $wipe) { + $leafQueryBuilder->setArgument('wipe', $wipe); + } return (bool)$this->queryLeaf($leafQueryBuilder, 'export'); } diff --git a/sdk/python/src/dagger/client/gen.py b/sdk/python/src/dagger/client/gen.py index 446167e849e..96192b1534d 100644 --- a/sdk/python/src/dagger/client/gen.py +++ b/sdk/python/src/dagger/client/gen.py @@ -2197,13 +2197,26 @@ async def entries(self, *, path: str | None = None) -> list[str]: return await _ctx.execute(list[str]) @typecheck - async def export(self, path: str) -> bool: + async def export( + self, + path: str, + *, + wipe: bool | None = False, + ) -> bool: """Writes the contents of the directory to a path on the host. Parameters ---------- path: Location of the copied directory (e.g., "logs/"). + wipe: + If true, then the host directory will be wiped clean before + exporting so that it exactly matches the directory being exported; + this means it will delete any files on the host that aren't in the + exported dir. If false (the default), the contents of the + directory will be merged with any existing contents of the host + directory, leaving any existing files on the host that aren't in + the exported directory alone. Returns ------- @@ -2219,6 +2232,7 @@ async def export(self, path: str) -> bool: """ _args = [ Arg("path", path), + Arg("wipe", wipe, False), ] _ctx = self._select("export", _args) return await _ctx.execute(bool) diff --git a/sdk/rust/crates/dagger-sdk/src/gen.rs b/sdk/rust/crates/dagger-sdk/src/gen.rs index 313f91020f2..dfa25f43e06 100644 --- a/sdk/rust/crates/dagger-sdk/src/gen.rs +++ b/sdk/rust/crates/dagger-sdk/src/gen.rs @@ -2624,6 +2624,12 @@ pub struct DirectoryEntriesOpts<'a> { pub path: Option<&'a str>, } #[derive(Builder, Debug, PartialEq)] +pub struct DirectoryExportOpts { + /// If true, then the host directory will be wiped clean before exporting so that it exactly matches the directory being exported; this means it will delete any files on the host that aren't in the exported dir. If false (the default), the contents of the directory will be merged with any existing contents of the host directory, leaving any existing files on the host that aren't in the exported directory alone. + #[builder(setter(into, strip_option), default)] + pub wipe: Option, +} +#[derive(Builder, Debug, PartialEq)] pub struct DirectoryPipelineOpts<'a> { /// Description of the sub-pipeline. #[builder(setter(into, strip_option), default)] @@ -2799,11 +2805,30 @@ impl Directory { /// # Arguments /// /// * `path` - Location of the copied directory (e.g., "logs/"). + /// * `opt` - optional argument, see inner type for documentation, use _opts to use pub async fn export(&self, path: impl Into) -> Result { let mut query = self.selection.select("export"); query = query.arg("path", path.into()); query.execute(self.graphql_client.clone()).await } + /// Writes the contents of the directory to a path on the host. + /// + /// # Arguments + /// + /// * `path` - Location of the copied directory (e.g., "logs/"). + /// * `opt` - optional argument, see inner type for documentation, use _opts to use + pub async fn export_opts( + &self, + path: impl Into, + opts: DirectoryExportOpts, + ) -> Result { + let mut query = self.selection.select("export"); + query = query.arg("path", path.into()); + if let Some(wipe) = opts.wipe { + query = query.arg("wipe", wipe); + } + query.execute(self.graphql_client.clone()).await + } /// Retrieves a file at the given path. /// /// # Arguments diff --git a/sdk/typescript/api/client.gen.ts b/sdk/typescript/api/client.gen.ts index 6606f1a73b2..b9bfe8e2e24 100644 --- a/sdk/typescript/api/client.gen.ts +++ b/sdk/typescript/api/client.gen.ts @@ -526,6 +526,13 @@ export type DirectoryEntriesOpts = { path?: string } +export type DirectoryExportOpts = { + /** + * If true, then the host directory will be wiped clean before exporting so that it exactly matches the directory being exported; this means it will delete any files on the host that aren't in the exported dir. If false (the default), the contents of the directory will be merged with any existing contents of the host directory, leaving any existing files on the host that aren't in the exported directory alone. + */ + wipe?: boolean +} + export type DirectoryPipelineOpts = { /** * Description of the sub-pipeline. @@ -2916,8 +2923,12 @@ export class Directory extends BaseClient { /** * Writes the contents of the directory to a path on the host. * @param path Location of the copied directory (e.g., "logs/"). + * @param opts.wipe If true, then the host directory will be wiped clean before exporting so that it exactly matches the directory being exported; this means it will delete any files on the host that aren't in the exported dir. If false (the default), the contents of the directory will be merged with any existing contents of the host directory, leaving any existing files on the host that aren't in the exported directory alone. */ - export = async (path: string): Promise => { + export = async ( + path: string, + opts?: DirectoryExportOpts, + ): Promise => { if (this._export) { return this._export } @@ -2927,7 +2938,7 @@ export class Directory extends BaseClient { ...this._queryTree, { operation: "export", - args: { path }, + args: { path, ...opts }, }, ], await this._ctx.connection(),