Packages
livebook
0.11.2
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.ex
defmodule Livebook.FileSystem.S3 do
use Ecto.Schema
import Ecto.Changeset
# File system backed by an S3 bucket.
@type t :: %__MODULE__{
id: String.t(),
bucket_url: String.t(),
external_id: String.t() | nil,
region: String.t(),
access_key_id: String.t(),
secret_access_key: String.t(),
hub_id: String.t()
}
embedded_schema do
field :bucket_url, :string
field :external_id, :string
field :region, :string
field :access_key_id, :string
field :secret_access_key, :string
field :hub_id, :string
end
@doc """
Infers region from the given bucket URL.
"""
@spec region_from_uri(String.t()) :: String.t()
# TODO: make it private again on Livebook v0.12
def region_from_uri(uri) do
# For many services the API host is of the form *.[region].[rootdomain].com
%{host: host} = URI.parse(uri)
splitted_host = host |> String.split(".") |> Enum.reverse()
case Enum.at(splitted_host, 2, "auto") do
"s3" -> "us-east-1"
"r2" -> "auto"
region -> region
end
end
@doc """
Returns an `%Ecto.Changeset{}` for tracking file system changes.
"""
@spec change_file_system(t(), map()) :: Ecto.Changeset.t()
def change_file_system(s3, attrs \\ %{}) do
changeset(s3, attrs)
end
defp changeset(s3, attrs) do
s3
|> cast(attrs, [
:bucket_url,
:external_id,
:region,
:access_key_id,
:secret_access_key,
:hub_id
])
|> put_region_from_uri()
|> validate_required([:bucket_url, :region, :access_key_id, :secret_access_key, :hub_id])
|> Livebook.Utils.validate_url(:bucket_url)
|> put_id()
end
defp put_region_from_uri(changeset) do
case get_field(changeset, :bucket_url) do
nil -> changeset
bucket_url -> put_change(changeset, :region, region_from_uri(bucket_url))
end
end
defp put_id(changeset) do
hub_id = get_field(changeset, :hub_id)
bucket_url = get_field(changeset, :bucket_url)
if get_field(changeset, :id) do
changeset
else
put_change(changeset, :id, id(hub_id, bucket_url))
end
end
@personal_id Livebook.Hubs.Personal.id()
def id(_, nil), do: nil
def id(nil, bucket_url), do: hashed_id(bucket_url)
def id(@personal_id, bucket_url), do: hashed_id(bucket_url)
def id(hub_id, bucket_url), do: "#{hub_id}-#{hashed_id(bucket_url)}"
defp hashed_id(bucket_url) do
hash = :crypto.hash(:sha256, bucket_url)
encrypted_hash = Base.url_encode64(hash, padding: false)
"s3-#{encrypted_hash}"
end
end
defimpl Livebook.FileSystem, for: Livebook.FileSystem.S3 do
alias Livebook.FileSystem
alias Livebook.FileSystem.S3
def type(_file_system) do
:global
end
def default_path(_file_system) do
"/"
end
def list(file_system, path, recursive) do
FileSystem.Utils.assert_dir_path!(path)
"/" <> dir_key = path
delimiter = if recursive, do: nil, else: "/"
opts = [prefix: dir_key, delimiter: delimiter]
with {:ok, %{keys: keys}} <- S3.Client.list_objects(file_system, opts) do
if keys == [] and dir_key != "" do
FileSystem.Utils.posix_error(:enoent)
else
paths = keys |> List.delete(dir_key) |> Enum.map(&("/" <> &1))
{:ok, paths}
end
end
end
def read(file_system, path) do
FileSystem.Utils.assert_regular_path!(path)
"/" <> key = path
S3.Client.get_object(file_system, key)
end
def write(file_system, path, content) do
FileSystem.Utils.assert_regular_path!(path)
"/" <> key = path
S3.Client.put_object(file_system, key, content)
end
def access(_file_system, _path) do
{:ok, :read_write}
end
def create_dir(file_system, path) do
FileSystem.Utils.assert_dir_path!(path)
"/" <> key = path
# S3 has no concept of directories, but keys with trailing
# slash are interpreted as such, so we create an empty
# object for the given key
S3.Client.put_object(file_system, key, nil)
end
def remove(file_system, path) do
"/" <> key = path
if FileSystem.Utils.dir_path?(path) do
with {:ok, %{keys: keys}} <- S3.Client.list_objects(file_system, prefix: key) do
if keys == [] do
FileSystem.Utils.posix_error(:enoent)
else
S3.Client.delete_objects(file_system, keys)
end
end
else
S3.Client.delete_object(file_system, key)
end
end
def copy(file_system, source_path, destination_path) do
FileSystem.Utils.assert_same_type!(source_path, destination_path)
"/" <> source_key = source_path
"/" <> destination_key = destination_path
if FileSystem.Utils.dir_path?(source_path) do
with {:ok, %{bucket: bucket, keys: keys}} <-
S3.Client.list_objects(file_system, prefix: source_key) do
if keys == [] do
FileSystem.Utils.posix_error(:enoent)
else
keys
|> Enum.map(fn key ->
renamed_key = String.replace_prefix(key, source_key, destination_key)
Task.async(fn ->
S3.Client.copy_object(file_system, bucket, key, renamed_key)
end)
end)
|> Task.await_many(:infinity)
|> Enum.filter(&match?({:error, _}, &1))
|> case do
[] -> :ok
[error | _] -> error
end
end
end
else
with {:ok, bucket} <- S3.Client.get_bucket_name(file_system) do
S3.Client.copy_object(file_system, bucket, source_key, destination_key)
end
end
end
def rename(file_system, source_path, destination_path) do
FileSystem.Utils.assert_same_type!(source_path, destination_path)
"/" <> destination_key = destination_path
with {:ok, destination_exists?} <- S3.Client.object_exists(file_system, destination_key) do
if destination_exists? do
FileSystem.Utils.posix_error(:eexist)
else
# S3 doesn't support an atomic rename/move operation,
# so we implement it as copy followed by remove
with :ok <- copy(file_system, source_path, destination_path) do
remove(file_system, source_path)
end
end
end
end
def etag_for(file_system, path) do
FileSystem.Utils.assert_regular_path!(path)
"/" <> key = path
with {:ok, %{etag: etag}} <- S3.Client.head_object(file_system, key) do
{:ok, etag}
end
end
def exists?(file_system, path) do
"/" <> key = path
S3.Client.object_exists(file_system, key)
end
def resolve_path(_file_system, dir_path, subject) do
FileSystem.Utils.resolve_unix_like_path(dir_path, subject)
end
def write_stream_init(_file_system, path, opts) do
opts = Keyword.validate!(opts, part_size: 50_000_000)
FileSystem.Utils.assert_regular_path!(path)
"/" <> key = path
{:ok,
%{
key: key,
parts: 0,
etags: [],
current_chunks: [],
current_size: 0,
part_size: opts[:part_size],
upload_id: nil
}}
end
def write_stream_chunk(file_system, state, chunk) when is_binary(chunk) do
chunk_size = byte_size(chunk)
state = update_in(state.current_size, &(&1 + chunk_size))
state = update_in(state.current_chunks, &[chunk | &1])
if state.current_size >= state.part_size do
maybe_state =
if state.upload_id do
{:ok, state}
else
with {:ok, upload_id} <- S3.Client.create_multipart_upload(file_system, state.key) do
{:ok, %{state | upload_id: upload_id}}
end
end
with {:ok, state} <- maybe_state do
upload_part_from_state(file_system, state, state.part_size)
end
else
{:ok, state}
end
end
defp upload_part_from_state(file_system, state, part_size) do
<<part::binary-size(part_size), rest::binary>> =
state.current_chunks
|> Enum.reverse()
|> IO.iodata_to_binary()
parts = state.parts + 1
with {:ok, %{etag: etag}} <-
S3.Client.upload_part(file_system, state.key, state.upload_id, parts, part) do
{:ok,
%{
state
| current_chunks: [rest],
current_size: byte_size(rest),
etags: [etag | state.etags],
parts: parts
}}
end
end
def write_stream_finish(file_system, state) do
if state.upload_id do
maybe_state =
if state.current_size > 0 do
upload_part_from_state(file_system, state, state.current_size)
else
{:ok, state}
end
with {:ok, state} <- maybe_state,
:ok <-
S3.Client.complete_multipart_upload(
file_system,
state.key,
state.upload_id,
Enum.reverse(state.etags)
) do
:ok
else
{:error, error} ->
S3.Client.abort_multipart_upload(file_system, state.key, state.upload_id)
{:error, error}
end
else
content = state.current_chunks |> Enum.reverse() |> IO.iodata_to_binary()
S3.Client.put_object(file_system, state.key, content)
end
end
def write_stream_halt(file_system, state) do
if state.upload_id do
S3.Client.abort_multipart_upload(file_system, state.key, state.upload_id)
else
:ok
end
end
def read_stream_into(file_system, path, collectable) do
FileSystem.Utils.assert_regular_path!(path)
"/" <> key = path
S3.Client.multipart_get_object(file_system, key, collectable)
end
def load(file_system, %{"bucket_url" => _} = fields) do
load(file_system, %{
bucket_url: fields["bucket_url"],
external_id: fields["external_id"],
region: fields["region"],
access_key_id: fields["access_key_id"],
secret_access_key: fields["secret_access_key"],
id: fields["id"],
hub_id: fields["hub_id"]
})
end
def load(file_system, fields) do
%{
file_system
| id: fields.id,
bucket_url: fields.bucket_url,
external_id: fields.external_id,
region: fields.region,
access_key_id: fields.access_key_id,
secret_access_key: fields.secret_access_key,
hub_id: fields.hub_id
}
end
def dump(file_system) do
file_system
|> Map.from_struct()
|> Map.take([
:id,
:bucket_url,
:region,
:access_key_id,
:secret_access_key,
:hub_id,
:external_id
])
end
def external_metadata(file_system) do
%{name: file_system.bucket_url, error_field: "bucket_url"}
end
end