Packages
livebook
0.19.8
0.19.8
0.19.7
0.19.6
0.19.5
0.19.4
0.19.3
0.19.2
0.19.1
0.19.0
0.18.6
0.18.5
0.18.4
0.18.3
0.18.2
0.18.1
0.18.0
0.17.3
0.17.2
0.17.1
0.17.0
0.16.4
0.16.3
0.16.2
0.16.1
0.16.0
0.15.5
0.15.4
0.15.3
0.15.2
0.15.1
0.15.0
0.14.7
0.14.6
0.14.5
0.14.4
0.14.3
0.14.2
0.14.1
0.14.0
0.14.0-rc.1
0.14.0-rc.0
0.13.3
0.13.2
0.13.1
0.13.0
0.12.1
0.12.0
0.11.4
0.11.3
0.11.2
0.11.1
0.11.0
0.10.0
0.9.3
0.9.2
0.9.1
0.9.0
0.8.2
0.8.1
0.8.0
0.7.2
0.7.1
0.7.0
0.6.3
0.6.2
0.6.1
0.6.0
0.5.2
0.5.1
0.5.0
0.4.1
0.4.0
0.3.2
0.3.1
0.3.0
0.2.3
0.2.2
0.2.1
0.2.0
0.1.2
0.1.1
0.1.0
Automate code & data workflows with interactive notebooks
Current section
Files
Jump to
Current section
Files
lib/livebook/file_system/s3/client.ex
defmodule Livebook.FileSystem.S3.Client do
alias Livebook.FileSystem
alias Livebook.FileSystem.S3
@doc """
Sends a request to the bucket to get list of objects.
"""
@spec list_objects(S3.t(), keyword()) :: {:ok, map()} | {:error, String.t()}
def list_objects(file_system, opts) do
prefix = opts[:prefix]
delimiter = opts[:delimiter]
params = %{"list-type" => "2", "prefix" => prefix, "delimiter" => delimiter}
case request(file_system, "/", params: params) do
{:ok, %{status: 200, body: %{"ListBucketResult" => result}}} ->
file_keys = xml_get_list(result, "Contents", "Key")
prefix_keys = xml_get_list(result, "CommonPrefixes", "Prefix")
{:ok, %{bucket: result["Name"], keys: file_keys ++ prefix_keys}}
other ->
request_response_to_error(other)
end
end
@doc """
Sends a request to the bucket to get the bucket name.
"""
@spec get_bucket_name(S3.t()) :: {:ok, String.t()} | {:error, String.t()}
def get_bucket_name(file_system) do
# We have the bucket URL, but it's not straightforward to extract
# the bucket name from the URL, because it may be either the path
# or a part of the host.
#
# Endpoints that return bucket information doesn't include the
# name, but the listing endpoint does, so we just list keys
# with an upper limit of 0 and retrieve the bucket name.
case request(file_system, "/", params: %{"list-type" => "2", "max-keys" => "0"}) do
{:ok, %{status: 200, body: %{"ListBucketResult" => result}}} -> {:ok, result["Name"]}
other -> request_response_to_error(other)
end
end
@doc """
Sends a request to the bucket to get an object.
"""
@spec get_object(S3.t(), String.t()) :: {:ok, map()} | {:error, String.t()}
def get_object(file_system, key) do
case request(file_system, "/" <> encode_key(key), long: true, decode: false) do
{:ok, %{status: 200, body: body}} -> {:ok, body}
{:ok, %{status: 404}} -> FileSystem.Utils.posix_error(:enoent)
other -> request_response_to_error(other)
end
end
@doc """
Sends a request to the bucket to download an object with multipart.
"""
@spec multipart_get_object(S3.t(), String.t(), Collectable.t()) ::
{:ok, map()} | {:error, String.t()}
def multipart_get_object(file_system, key, collectable) do
case download(file_system, "/" <> encode_key(key), collectable) do
{:ok, collectable} -> {:ok, collectable}
{:error, _message, 404} -> FileSystem.Utils.posix_error(:enoent)
{:error, message, _status} -> {:error, message}
end
end
@doc """
Sends a request to the bucket to update an object's content.
"""
@spec put_object(S3.t(), String.t(), String.t() | nil) :: :ok | {:error, String.t()}
def put_object(file_system, key, content) do
case request(file_system, "/" <> encode_key(key), method: :put, body: content, long: true) do
{:ok, %{status: 200}} -> :ok
other -> request_response_to_error(other)
end
end
@doc """
Sends a request to the bucket to get the object's ETAG from given key.
"""
@spec head_object(S3.t(), String.t()) :: {:ok, map()} | {:error, String.t()}
def head_object(file_system, key) do
with {:ok, %{status: 200, headers: headers}} <-
request(file_system, "/" <> encode_key(key), method: :head),
{:ok, [etag]} <- Map.fetch(headers, "etag") do
{:ok, %{etag: etag}}
else
{:ok, %{status: 404}} -> FileSystem.Utils.posix_error(:enoent)
other -> request_response_to_error(other)
end
end
@doc """
Sends a request to the bucket to copy an object to given destination.
"""
@spec copy_object(S3.t(), String.t(), String.t(), String.t()) :: :ok | {:error, String.t()}
def copy_object(file_system, bucket, source_key, destination_key) do
copy_source = bucket <> "/" <> encode_key(source_key)
headers = [{"x-amz-copy-source", copy_source}]
case request(file_system, "/" <> encode_key(destination_key), method: :put, headers: headers) do
{:ok, %{status: 200}} -> :ok
{:ok, %{status: 404}} -> FileSystem.Utils.posix_error(:enoent)
other -> request_response_to_error(other)
end
end
@doc """
Sends a request to the bucket to delete an object.
"""
@spec delete_object(S3.t(), String.t()) :: :ok | {:error, String.t()}
def delete_object(file_system, key) do
case request(file_system, "/" <> encode_key(key), method: :delete) do
{:ok, %{status: 204}} -> :ok
{:ok, %{status: 404}} -> :ok
other -> request_response_to_error(other)
end
end
@doc """
Sends a request to the bucket to delete a list of objects.
"""
@spec delete_objects(S3.t(), list(String.t())) :: :ok | {:error, String.t()}
def delete_objects(file_system, keys) do
objects = Enum.map(keys, &%{"Key" => &1})
body =
%{"Delete" => %{"Object" => objects, "Quiet" => "true"}}
|> S3.XML.encode_to_iodata!()
|> IO.iodata_to_binary()
body_md5 = :crypto.hash(:md5, body)
headers = [{"Content-MD5", Base.encode64(body_md5)}]
params = %{"delete" => ""}
case request(file_system, "/", params: params, method: :post, headers: headers, body: body) do
{:ok, %{status: 200, body: %{"Error" => _}}} = result -> request_response_to_error(result)
{:ok, %{status: 200}} -> :ok
other -> request_response_to_error(other)
end
end
@doc """
Sends a request to the bucket to check if object exists.
"""
@spec object_exists(S3.t(), String.t()) :: {:ok, boolean()} | {:error, String.t()}
def object_exists(file_system, key) do
# It is possible for /dir/obj to exist without the /dir/ object,
# but we still consider it as existing. That's why we list
# objects instead of checking the key directly.
with {:ok, %{keys: keys}} <- list_objects(file_system, prefix: key, delimiter: "/") do
exists? =
if String.ends_with?(key, "/"),
do: keys != [],
else: key in keys
{:ok, exists?}
end
end
@doc """
Sends a request to the bucket to multipart upload an object.
"""
@spec create_multipart_upload(S3.t(), String.t()) :: {:ok, String.t()} | {:error, String.t()}
def create_multipart_upload(file_system, key) do
params = %{"uploads" => ""}
case request(file_system, "/" <> encode_key(key), method: :post, params: params, body: "") do
{:ok, %{status: 200, body: %{"InitiateMultipartUploadResult" => result}}} ->
{:ok, result["UploadId"]}
other ->
request_response_to_error(other)
end
end
@doc """
Sends a request to the bucket to upload an object partially.
"""
@spec upload_part(S3.t(), String.t(), String.t(), pos_integer(), String.t()) ::
{:ok, map()} | {:error, String.t()}
def upload_part(file_system, key, upload_id, part_number, content) do
params = %{"uploadId" => upload_id, "partNumber" => part_number}
opts = [method: :put, params: params, body: content, long: true]
with {:ok, %{status: 200, headers: headers}} <-
request(file_system, "/" <> encode_key(key), opts),
{:ok, [etag]} <- Map.fetch(headers, "etag") do
{:ok, %{etag: etag}}
else
other -> request_response_to_error(other)
end
end
@doc """
Sends a request to the bucket to finish the multipart object upload.
"""
@spec complete_multipart_upload(S3.t(), String.t(), String.t(), list(String.t())) ::
:ok | {:error, String.t()}
def complete_multipart_upload(file_system, key, upload_id, etags) do
params = %{"uploadId" => upload_id}
parts = for {etag, n} <- Enum.with_index(etags, 1), do: %{"PartNumber" => n, "ETag" => etag}
body =
%{"CompleteMultipartUpload" => %{"Part" => parts}}
|> S3.XML.encode_to_iodata!()
|> IO.iodata_to_binary()
case request(file_system, "/" <> encode_key(key), method: :post, params: params, body: body) do
{:ok, %{status: 200}} -> :ok
other -> request_response_to_error(other)
end
end
@doc """
Sends a request to the bucket to abort the multipart object upload.
"""
@spec abort_multipart_upload(S3.t(), String.t(), String.t()) :: :ok | {:error, String.t()}
def abort_multipart_upload(file_system, key, upload_id) do
params = %{"uploadId" => upload_id}
case request(file_system, "/" <> encode_key(key), method: :delete, params: params) do
{:ok, %{status: 204}} -> :ok
other -> request_response_to_error(other)
end
end
defp download(file_system, path, collectable) do
req =
Req.new(base_url: file_system.bucket_url)
|> Livebook.Utils.req_attach_defaults()
|> with_aws_sigv4(file_system)
case Req.get(req, url: path, into: collectable) do
{:ok, %{status: 200, body: collected}} ->
{:ok, collected}
{:ok, %{status: status}} ->
{:error, "download failed, HTTP status #{status}", status}
{:error, exception} ->
{:error, "download failed, reason: #{Exception.message(exception)}}", nil}
end
end
defp with_aws_sigv4(req, file_system) do
credentials = S3.credentials(file_system)
Req.merge(req,
aws_sigv4: [
access_key_id: credentials.access_key_id,
secret_access_key: credentials.secret_access_key,
token: credentials.token,
region: file_system.region,
service: :s3
]
)
end
defp encode_key(key) do
key
|> String.split("/")
|> Enum.map_join("/", fn segment -> URI.encode(segment, &URI.char_unreserved?/1) end)
end
defp request(file_system, path, opts) do
{long, opts} = Keyword.pop(opts, :long, false)
{decode?, opts} = Keyword.pop(opts, :decode, true)
timeout = if long, do: 60_000, else: 30_000
req =
Req.new(base_url: file_system.bucket_url)
|> Req.merge(opts)
|> Livebook.Utils.req_attach_defaults()
|> with_aws_sigv4(file_system)
result = Req.request(req, url: path, receive_timeout: timeout)
if decode?, do: decode(result), else: result
end
defp decode({:ok, response}) do
if xml?(response) do
{:ok, update_in(response.body, &S3.XML.decode!/1)}
else
{:ok, response}
end
end
defp decode({:error, _} = error), do: error
defp xml?(response) do
guess_xml? = String.starts_with?(response.body, "<?xml")
case Livebook.Utils.fetch_content_type(response) do
{:ok, content_type} when content_type in ["text/xml", "application/xml"] -> true
# Apparently some requests return XML without content-type
:error when guess_xml? -> true
_otherwise -> false
end
end
defp request_response_to_error(
{:ok, %{status: 403, body: %{"Error" => %{"Message" => message}}}}
) do
{:error, "access denied, " <> Livebook.Utils.downcase_first(message)}
end
defp request_response_to_error({:ok, %{status: 403}}) do
{:error, "access denied"}
end
defp request_response_to_error({:ok, %{body: %{"Error" => %{"Message" => message}}}}) do
{:error, Livebook.Utils.downcase_first(message)}
end
defp request_response_to_error({:ok, %{body: %{"Error" => [_ | _] = errors}}}) do
[%{"Message" => message} | errors] = errors
{:error, Livebook.Utils.downcase_first(message) <> ", and #{length(errors)} more errors"}
end
defp request_response_to_error({:ok, _response}) do
{:error, "unexpected response"}
end
defp request_response_to_error(_otherwise) do
{:error, "failed to make an http request"}
end
defp xml_get_list(result, xml_key, map_key) do
items =
case result do
%{^xml_key => item} when is_map(item) -> [item]
%{^xml_key => items} when is_list(items) -> items
_ -> []
end
Enum.map(items, & &1[map_key])
end
end