From 81943cc16c9a498bc1185a836f1325e23db963e1 Mon Sep 17 00:00:00 2001 From: pmeinhardt Date: Thu, 17 Dec 2020 08:35:19 +0100 Subject: [PATCH] Prototype new upload implementation using SFTP --- examples/upload.exs | 5 +++ lib/sshkit.ex | 11 +++-- lib/sshkit/upload.ex | 105 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 118 insertions(+), 3 deletions(-) create mode 100644 examples/upload.exs create mode 100644 lib/sshkit/upload.ex diff --git a/examples/upload.exs b/examples/upload.exs new file mode 100644 index 00000000..ed12658c --- /dev/null +++ b/examples/upload.exs @@ -0,0 +1,5 @@ +{:ok, conn} = SSHKit.connect("127.0.0.1", port: 2222, user: "deploy", password: "deploy", silently_accept_hosts: true) + +:ok = SSHKit.upload(conn, "test/fixtures", "/tmp/fixtures", recursive: true) + +:ok = SSHKit.close(conn) diff --git a/lib/sshkit.ex b/lib/sshkit.ex index 1d1ed835..5719aa6e 100644 --- a/lib/sshkit.ex +++ b/lib/sshkit.ex @@ -22,6 +22,7 @@ defmodule SSHKit do alias SSHKit.Context alias SSHKit.Host + alias SSHKit.Upload @doc """ TODO @@ -245,8 +246,12 @@ defmodule SSHKit do |> SSHKit.upload("local.txt", as: "remote.txt") ``` """ - def upload(context, source, options \\ []) do - # TODO + def upload(conn, source, target, options \\ []) do + upload = Upload.init(source, target, options) + + with {:ok, upload} <- Upload.start(upload, conn) do + Upload.loop(upload) + end end @doc ~S""" @@ -280,7 +285,7 @@ defmodule SSHKit do |> SSHKit.download("remote.txt", as: "local.txt") ``` """ - def download(context, source, options \\ []) do + def download(conn, source, options \\ []) do # TODO end end diff --git a/lib/sshkit/upload.ex b/lib/sshkit/upload.ex new file mode 100644 index 00000000..f6e78953 --- /dev/null +++ b/lib/sshkit/upload.ex @@ -0,0 +1,105 @@ +defmodule SSHKit.Upload do + @moduledoc """ + TODO + """ + + defstruct [:source, :target, :options, :cwd, :stack, :channel] + + def init(source, target, options \\ []) do + %__MODULE__{source: Path.expand(source), target: target, options: options} + end + + def start(%__MODULE__{} = upload, connection) do + with {:ok, upload} <- prepare(upload) do + {:ok, channel} = :ssh_sftp.start_channel(connection.ref) # accepts options like timeout… http://erlang.org/doc/man/ssh_sftp.html#start_channel-1 + {:ok, %{upload | channel: channel}} + end + end + + defp prepare(%__MODULE__{source: source, options: options} = upload) do + # TODO: Support globs, https://hexdocs.pm/elixir/Path.html#wildcard/2 + if !Keyword.get(options, :recursive, false) && File.dir?(source) do + {:error, "Option :recursive not specified, but local file is a directory (#{source})"} # TODO: Better error + else + {:ok, %{upload | cwd: Path.dirname(source), stack: [[Path.basename(source)]]}} + end + end + + def stop(%__MODULE__{channel: nil} = upload), do: {:ok, upload} + def stop(%__MODULE__{channel: channel} = upload) do + with :ok <- :ssh_sftp.stop_channel(channel) do + {:ok, %{upload | channel: nil}} + end + end + + # TODO: Handle unstarted uploads w/o channel, cwd, stack… and provide helpful error? + + def continue(%__MODULE__{stack: []} = upload) do + {:ok, upload} + end + + def continue(%__MODULE__{stack: [[] | paths]} = upload) do + {:ok, %{upload | cwd: Path.dirname(upload.cwd), stack: paths}} + end + + def continue(%__MODULE__{stack: [[name | rest] | paths]} = upload) do + path = Path.join(upload.cwd, name) + relpath = Path.relative_to(path, Path.expand(upload.source)) + relpath = if relpath == path, do: ".", else: relpath + + remote = + upload.target + |> Path.join(relpath) + |> Path.expand() + + with {:ok, stat} <- File.stat(path, time: :posix) do + # TODO: Set timestamps… if :preserve option is true, http://erlang.org/doc/man/ssh_sftp.html#write_file_info-3 + + channel = upload.channel + + case stat.type do + :directory -> + # TODO: Timeouts + :ok = :ssh_sftp.make_dir(channel, remote) + {:ok, names} = File.ls(path) + {:ok, %{upload | cwd: path, stack: [names | [rest | paths]]}} + + :regular -> + # TODO: Timeouts + {:ok, handle} = :ssh_sftp.open(channel, remote, [:write, :binary]) + + path + |> File.stream!([], 16_384) + |> Stream.each(fn data -> :ok = :ssh_sftp.write(channel, handle, data) end) + |> Stream.run() + + :ok = :ssh_sftp.close(channel, handle) + {:ok, %{upload | stack: [rest | paths]}} + + :symlink -> + nil + + _ -> + {:error, {:unkown_file_type, path}} + end + end + end + + # TODO: Make `loop` return a stream? Possibly rename to "stream" then + def loop(%__MODULE__{stack: []}) do + :ok + end + + def loop(%__MODULE__{} = upload) do + case continue(upload) do + {:ok, upload} -> + loop(upload) + + error -> + error + end + end + + def done?(%__MODULE__{stack: []}), do: true + def done?(%__MODULE__{}), do: false +end