defmodule AudioProxy.S3 do @moduledoc """ The five S3 operations this proxy needs, over `ex_aws_s3`. Not a general S3 client and not on the way to becoming one. An object-storage deployment needs exactly this much: * `presign_get/4` — a URL ffmpeg can read a source from, and a URL a client can be redirected to on a cache hit. Presigned because neither of them will send an `Authorization` header for us. * `head/3` — does this object exist, how big is it, what is its ETag. The `/info` and cache-hit paths. * `put_stream/5` — write a variant back, streaming, without knowing its length in advance (a render's output length is not known until it ends). * `get_stream/4` — read a variant back for proxy-mode serving. * `delete/3` — remove one object. Nothing on the request path deletes anything; this exists for `AudioProxy.Config`'s boot-time writability probe, which proves a variant bucket accepts writes by performing one and then taking it back. A probe that only wrote would leave a small object behind on every restart. ## Why a facade and not `ExAws.S3` at the call sites Three reasons, none of them dogma. Callers get `{:error, :not_found}` instead of `{:error, {:http_error, 404, _}}`, which is the vocabulary the rest of the codebase already speaks. Credentials come from `AudioProxy.Config` rather than `ex_aws`'s own resolution — see below. And the surface stays four functions wide, so "we only do these four things" is enforced rather than merely intended. ## Configuration is ours, not `ex_aws`'s `ex_aws` resolves credentials from application env, `AWS_*` variables, and on EC2 from IMDS, in an order it decides. This proxy validates its whole configuration once at boot and keeps it in one map, so every call here passes an explicit config override built from `AudioProxy.Config`. The effect worth naming: **there is no IMDS lookup**, so an EC2 instance role does not work and credentials must be supplied. That is a documented limitation (README), not an oversight, and the place to change it is `config/1` here. ## Addressing is configured, not inferred A request names its bucket in the host (`bucket.host/key`, virtual-hosted) or in the path (`host/bucket/key`, path-style). `AP_S3_ADDRESSING` picks, defaulting to virtual-hosted with no custom endpoint and path-style with one — AWS requires the former in regions launched after 2019, and every S3-compatible store this project documents is deployed on the latter. It is threaded into both places `ex_aws` reads it, which are not the same place: the request path takes it from the config map, `presigned_url/5` from its own options. See `sign_get/4`. ## Two profiles, and `:source` is the default Fetching a source and running the variant store are two jobs that a deployment may hand to different providers or different principals, so every function here takes the profile to run under as its **last** argument: `:source` (the default, the `AWS_*`/`AP_S3_*` group) or `:store` (that group with `AP_VARIANT_S3_*` overlaid). `AudioProxy.Config` builds both and documents the fallback; all this module does is pick one. Defaulting to `:source` rather than requiring the argument is deliberate: the store is the *small* side — one module and the boot probe — and every other caller is a source fetch, so an omitted profile is right far more often than it is wrong. ## Errors are data Every function returns `{:ok, _}` or `{:error, t:error/0}`. `:not_found` and `:access_denied` stay distinct: AWS answers 403 for a missing object when the caller cannot list the bucket, so they are genuinely ambiguous — but folding them would turn an expired credential into a permanent cache miss and re-render every request forever. """ alias AudioProxy.Config require Logger @typedoc "A bucket name." @type bucket :: String.t() @typedoc """ Which configuration profile an operation runs under. `:source` is the shared `AWS_*`/`AP_S3_*` group; `:store` is that group with the `AP_VARIANT_S3_*` overrides applied. See the moduledoc. """ @type profile :: :source | :store @typedoc "An object key, as raw bytes — never percent-encoded." @type key :: String.t() @typedoc """ What `head/2` reports. `metadata` holds the `x-amz-meta-*` headers with the prefix stripped and names lowercased, which is how the variant store round-trips its own response headers through an object. """ @type object :: %{ size: non_neg_integer(), etag: String.t() | nil, content_type: String.t() | nil, cache_control: String.t() | nil, metadata: %{optional(String.t()) => String.t()} } @typedoc """ Why an S3 call failed. * `:not_found` — the object is not there (404). * `:access_denied` — credentials rejected (403). Distinct from `:not_found` on purpose; see the moduledoc. * `{:http, status, body}` — any other status. * `{:transport, reason}` — nothing came back. * `:not_configured` — no credentials. Refused here rather than passed to `ex_aws` as nils, which it reads as "not provided" and answers by walking its own provider chain — ending at an instance-role lookup against 169.254.169.254 that, on a host where that address is not routed, hangs rather than failing. * `:invalid_range` — `get_stream/3` only. """ @type error :: :not_found | :access_denied | :not_configured | :invalid_range | {:http, non_neg_integer(), binary()} | {:transport, term()} # Read granularity for `get_stream/3`. `ex_aws_s3` has no in-memory # streaming GET — `download_file/4` streams to disk and `get_object/3` # returns the whole body — so a bounded read is a sequence of ranged GETs. # 1 MiB keeps the request count sane for a full-length variant while # bounding memory well below one. @read_chunk 1_048_576 # S3's minimum for a non-final part. A stream that ends inside this goes as # one `PutObject` instead — see `buffer_first_part/1`. @part_size 5 * 1024 * 1024 # Set when the chunk source unwinds itself, so its cleanup is not run twice. # See `resume_next/1`. @source_unwound :audio_proxy_s3_source_unwound @doc """ A presigned GET URL for an object. Options: `:expires_in` (seconds, defaulting to `AP_PRESIGN_TTL`). """ @spec presign_get(bucket(), key(), keyword(), profile()) :: {:ok, String.t()} | {:error, error()} def presign_get(bucket, key, opts \\ [], profile \\ :source) do if configured?(profile), do: sign_get(bucket, key, opts, profile), else: {:error, :not_configured} end defp sign_get(bucket, key, opts, profile) do expires_in = Keyword.get(opts, :expires_in, Config.get(:presign_ttl)) overrides = config(profile) # `presigned_url/5` signs locally rather than issuing a request, so it # wants a built `%ExAws.Config{}` where the request path takes overrides. signing_config = ExAws.Config.new(:s3, overrides) # Addressing is read from the *options* here and from the *config map* on # the request path — two separate readers inside `ex_aws`. Both are # threaded from the one keyword list so they cannot drift: the host is # covered by the SigV4 signature, so a presigned URL addressed differently # from the request that signed it is not merely wrong, it is unverifiable. signing_opts = [expires_in: expires_in, virtual_host: overrides[:virtual_host]] case ExAws.S3.presigned_url(signing_config, :get, bucket, key, signing_opts) do {:ok, url} -> {:ok, url} {:error, reason} -> {:error, {:transport, reason}} end end @doc "Object existence, size, ETag and metadata." @spec head(bucket(), key(), profile()) :: {:ok, object()} | {:error, error()} def head(bucket, key, profile \\ :source) do if configured?(profile), do: head_object(bucket, key, profile), else: {:error, :not_configured} end defp head_object(bucket, key, profile) do bucket |> ExAws.S3.head_object(key) |> request(profile) |> case do {:ok, %{headers: headers}} -> {:ok, object(headers)} {:error, reason} -> {:error, reason} end end @doc """ Uploads `chunks` — an enumerable of binaries of unknown total length. Options: `:content_type`, `:cache_control`, and `:metadata` (a map written as `x-amz-meta-*`). A stream that ends inside one part is a single `PutObject`; anything longer is a multipart upload, **aborted** on every failure path so a failed write leaves neither a partial object nor billable orphan parts. ## Why the multipart protocol is driven here rather than by `ExAws.S3.upload/4` Because `upload/4` does not abort. `ExAws.S3.Upload.perform/2` initiates, uploads its parts, and on a part error simply returns that error — the upload id goes out of scope and the initiated upload, with every part already sent, stays in the bucket. `ex_aws_s3` defines `abort_multipart_upload/3` and never calls it. Incomplete multipart uploads do not appear in a bucket listing and are billed until a lifecycle rule removes them, which makes this the one place in the S3 surface where trusting the library costs money quietly. `upload/4` has a second problem on the same path: it collects task results with `Enum.map(fn {:ok, val} -> val end)`, which has no clause for the `{:exit, reason}` a part-level timeout produces, so a slow part raises `FunctionClauseError` from inside the dependency instead of returning an error. So the four operations are sequenced here. Everything underneath — signing, request building, XML parsing, retries — is still `ex_aws`; what is ours is the guarantee that an upload which does not complete is aborted. Parts go up one at a time, which keeps the memory bound honest at one part plus one chunk and costs nothing worth having: the render produces bytes far slower than S3 accepts them. Operators should still set a lifecycle rule expiring incomplete multipart uploads. This code aborts on every path it can see; a hard kill of the VM is not one of them. """ @spec put_stream(bucket(), key(), Enumerable.t(), keyword(), profile()) :: :ok | {:error, error() | term()} def put_stream(bucket, key, chunks, opts \\ [], profile \\ :source) do if configured?(profile) do write(bucket, key, chunks, opts, profile) else {:error, :not_configured} end end defp write(bucket, key, chunks, opts, profile) do case buffer_first_part(chunks) do {:whole, iodata} -> put_object(bucket, key, IO.iodata_to_binary(iodata), opts, profile) # The upload is initiated *before* the stream is composed, so there is # exactly one path on which the suspended source is abandoned without # ever being enumerated — and it halts it explicitly. Compose first and # a failed initiate would drop the continuation on the floor, leaving # the source's `after_fun` unrun and whatever it holds open leaked. {:streamed, buffered, continuation} -> case initiate(bucket, key, opts, profile) do {:ok, upload_id} -> buffered |> Stream.concat(resume(continuation)) |> into_parts() |> multipart(bucket, key, upload_id, profile) {:error, reason} -> halt(continuation) {:error, reason} end end rescue # The chunk stream raising is the tee's signal that a render failed or was # cancelled. Reached only for a raise *before* the upload is initiated — # once it is, `multipart/4` owns the abort. exception -> {:error, exception} end @doc """ Removes an object. A delete of a key that is not there is `:ok` — S3 answers 204 either way, and the caller wanted the object gone rather than an inventory of what was there first. """ @spec delete(bucket(), key(), profile()) :: :ok | {:error, error()} def delete(bucket, key, profile \\ :source) do if configured?(profile), do: delete_object(bucket, key, profile), else: {:error, :not_configured} end defp delete_object(bucket, key, profile) do bucket |> ExAws.S3.delete_object(key) |> request(profile) |> case do {:ok, _response} -> :ok {:error, reason} -> {:error, reason} end end @doc """ Streams an object's bytes, or an inclusive byte range of them. Lazy and bounded-memory: a sequence of ranged GETs of `#{@read_chunk}` bytes, never the whole object at once. `ex_aws_s3` offers no in-memory streaming read, so this is assembled here — see `@read_chunk`. Only proxy-mode serving needs it. A redirect hands the client a presigned URL and the bytes never enter the BEAM at all, which is the default and the reason this costs more round trips than it might. """ @spec get_stream(bucket(), key(), {non_neg_integer(), non_neg_integer()} | nil, profile()) :: {:ok, Enumerable.t()} | {:error, error()} def get_stream(bucket, key, range \\ nil, profile \\ :source) do with true <- configured?(profile) or {:error, :not_configured}, {:ok, %{size: size}} <- head(bucket, key, profile), {:ok, slice} <- slice(range, size) do {:ok, stream_slice(bucket, key, slice, profile)} end end # The range is resolved against the object's actual size *before* any read, # because getting this wrong is not a clean failure: an unclamped `last` # past the end means the first GET returns fewer bytes than asked for, the # offset advances short of `last`, and the *second* GET starts past the end # and draws a 416 — raised from inside a stream whose caller has already # begun sending a 200. # # Semantics match `AudioProxy.VariantStore.Local`, which is what makes the # two backends interchangeable: a `last` past the end is truncated (RFC 9110 # §14), a `first` at or past the end is invalid, and an inverted range is # invalid rather than silently empty. defp slice(nil, 0), do: {:ok, :empty} defp slice(nil, size), do: {:ok, {0, size - 1}} defp slice({first, last}, size) when is_integer(first) and is_integer(last) and first >= 0 and first <= last and first < size do {:ok, {first, min(last, size - 1)}} end defp slice(_range, _size), do: {:error, :invalid_range} ## Writing defp put_object(bucket, key, body, opts, profile) do bucket |> ExAws.S3.put_object(key, body, upload_opts(opts)) |> request(profile) |> case do {:ok, _response} -> :ok {:error, reason} -> {:error, reason} end end # Initiate, then parts, then complete — and abort on anything that is not a # completion, including an exception, a `throw` or an `exit` from the chunk # stream. See the `put_stream/4` doc for why this is not `ExAws.S3.upload/4`. defp multipart(stream, bucket, key, upload_id, profile) do try do with {:ok, parts} <- upload_parts(bucket, key, upload_id, stream, profile), :ok <- complete(bucket, key, upload_id, parts, profile) do :ok else {:error, reason} -> abort(bucket, key, upload_id, profile) {:error, reason} end rescue # The chunk stream raising mid-upload: a render that failed or was # cancelled. This is the path that leaves orphaned parts if nobody # aborts, which is exactly what `ExAws.S3.upload/4` does not do. exception -> abort(bucket, key, upload_id, profile) {:error, exception} catch # `throw` and `exit` unwind past `rescue`, and `exit` is the realistic # one — a task timeout or a linked crash inside the chunk stream. # Aborted, then re-raised: the point is the cleanup, not swallowing a # shutdown something else initiated. kind, reason -> abort(bucket, key, upload_id, profile) :erlang.raise(kind, reason, __STACKTRACE__) end end defp initiate(bucket, key, opts, profile) do bucket |> ExAws.S3.initiate_multipart_upload(key, upload_opts(opts)) |> request(profile) |> case do {:ok, %{body: %{upload_id: upload_id}}} when is_binary(upload_id) and upload_id != "" -> {:ok, upload_id} {:ok, _response} -> {:error, {:http, 200, "InitiateMultipartUpload response carried no UploadId"}} {:error, reason} -> {:error, reason} end end # Sequential on purpose: one part in flight keeps the memory bound at one # part plus one chunk, and a render never produces bytes faster than S3 # accepts them. defp upload_parts(bucket, key, upload_id, stream, profile) do stream |> Stream.with_index(1) |> Enum.reduce_while({:ok, []}, fn {part, number}, {:ok, parts} -> case upload_part(bucket, key, upload_id, number, part, profile) do {:ok, etag} -> {:cont, {:ok, [{number, etag} | parts]}} {:error, reason} -> {:halt, {:error, reason}} end end) |> case do {:ok, parts} -> {:ok, Enum.reverse(parts)} {:error, reason} -> {:error, reason} end end defp upload_part(bucket, key, upload_id, number, body, profile) do bucket |> ExAws.S3.upload_part(key, upload_id, number, body) |> request(profile) |> case do {:ok, %{headers: headers}} -> case Enum.find(headers, fn {name, _value} -> String.downcase(name) == "etag" end) do {_name, etag} -> {:ok, etag} # Completing without the ETag S3 gave us would be rejected, so this # fails here rather than building a completion body with a nil in it. nil -> {:error, {:http, 200, "UploadPart response carried no ETag"}} end {:error, reason} -> {:error, reason} end end defp complete(bucket, key, upload_id, parts, profile) do bucket |> ExAws.S3.complete_multipart_upload(key, upload_id, parts) |> request(profile) |> case do {:ok, _response} -> :ok {:error, reason} -> {:error, reason} end end # Best-effort by necessity: if the abort itself fails there is nothing left # to try, and the caller is already reporting a failure. Logged at warning # so an operator can find the orphan, since a bucket listing will not show # it. defp abort(bucket, key, upload_id, profile) do bucket |> ExAws.S3.abort_multipart_upload(key, upload_id) |> request(profile) |> case do {:ok, _response} -> :ok {:error, reason} -> Logger.warning( "s3: could not abort multipart upload, parts may be orphaned and billed " <> "(bucket=#{bucket} key=#{key} upload_id=#{upload_id} reason=#{inspect(reason)})" ) :ok end end # Reads up to one part from `chunks` without committing to a strategy. # # This exists because `ExAws.S3.upload/4` *always* runs the multipart # protocol, and S3 rejects a multipart upload whose only part is under # 5 MiB with `EntityTooSmall` — so it cannot write a small object at all. # Previews, which are most of what this proxy renders, are all small # objects. A single `PutObject` is also one request instead of three. # # The enumerable is consumed with an explicit `Enumerable.reduce/3` # suspension rather than `Enum.take/2` so the tail is never re-enumerated: # `chunks` is a live render, and asking it to start over is not a thing it # can do. defp buffer_first_part(chunks) do case Enumerable.reduce(chunks, {:cont, {[], 0}}, &buffer_reducer/2) do # The stream ended inside one part: send it whole. {:done, {buffered, _size}} -> {:whole, Enum.reverse(buffered)} # There is more, so it is a genuine multipart upload. The pieces are # handed back uncomposed: the caller initiates first, and must halt the # continuation if it decides not to stream after all. {:suspended, {buffered, _size}, continuation} -> {:streamed, Enum.reverse(buffered), continuation} {:halted, {buffered, _size}} -> {:whole, Enum.reverse(buffered)} end end defp buffer_reducer(chunk, {buffered, size}) do buffered = [chunk | buffered] size = size + byte_size(chunk) if size >= @part_size do {:suspend, {buffered, size}} else {:cont, {buffered, size}} end end # `ExAws.S3.upload/4` uploads **one part per element** — it does no # regrouping of its own. Render chunks are far smaller than S3's 5 MiB # minimum, so handing it the raw stream produces a hundred undersized parts # and `EntityTooSmall` at completion. Grouping is therefore ours to do. # # Every part but the last is *exactly* `@part_size`, not merely at least it. # Flushing whatever the buffer held when it crossed the threshold would make # a part's size depend on where chunk boundaries happen to fall, and some # stores — Cloudflare R2 among them — reject a multipart upload whose parts # are not all the same size. A chunk straddling the boundary is split, and # its tail carried into the next part. # # Memory stays bounded at one part plus one chunk, which is the same bound # the buffering above already establishes: the accumulator is iodata and is # flattened once per part, not once per chunk. defp into_parts(stream) do Stream.transform( stream, fn -> {[], 0} end, fn chunk, {buffered, size} -> buffered = [buffered, chunk] size = size + byte_size(chunk) if size >= @part_size do split_parts(IO.iodata_to_binary(buffered), []) else {[], {buffered, size}} end end, fn # A zero-length tail is not emitted: S3 rejects an empty part, and the # parts already sent are the whole object. {_buffered, 0} -> {[], {[], 0}} {buffered, _size} -> {[IO.iodata_to_binary(buffered)], {[], 0}} end, fn _acc -> :ok end ) end # Peels whole parts off the front of the buffer — more than one when a # single chunk is larger than a part — and hands the remainder back as the # accumulator for the next chunk. defp split_parts(<>, parts) do split_parts(rest, [part | parts]) end # The remainder is copied rather than carried as a sub-binary. Pattern # matching a binary yields a reference into the original, so holding the # 10 KiB tail of a 5 MiB buffer holds the whole 5 MiB until the next part # flushes — which would make the bound above two parts rather than one. One # memcpy of at most a chunk's worth of bytes buys the buffer back. defp split_parts(rest, parts) do rest = :binary.copy(rest) {Enum.reverse(parts), {[rest], byte_size(rest)}} end # Resuming with `{:halt, _}` is what lets the *source* clean up: a suspended # `Enumerable.reduce/3` leaves the enumerable open, and a # `Stream.resource/3` — which is what a render tee is — runs its `after_fun` # only when its own reduce finishes or is halted. defp halt(continuation), do: continuation.({:halt, {[], 0}}) # One chunk per resumption. `into_parts/1` regroups them. # # The state is `{:live, continuation}` until the source reports it is # finished, then `{:done, nil}` — which is what tells `after_fun` whether # there is still something suspended to halt. defp resume(continuation) do Stream.resource( fn -> Process.delete(@source_unwound) {:live, continuation} end, &resume_next/1, &resume_cleanup/1 ) end defp resume_next({:done, _finished}), do: {:halt, {:done, nil}} defp resume_next({:live, continuation}) do case continuation.({:cont, {[], 0}}) do {:suspended, {buffered, _size}, next} -> {Enum.reverse(buffered), {:live, next}} {:done, {buffered, _size}} -> {Enum.reverse(buffered), {:done, nil}} {:halted, {buffered, _size}} -> {Enum.reverse(buffered), {:done, nil}} end catch # An exception, `throw` or `exit` raised *by the source* unwinds the # source's own `Stream.resource`, which means its `after_fun` has already # run. Recorded so `resume_cleanup/1` does not then halt it a second time # and run that cleanup twice — a double `Port.close/1` raises, and doing # it here would raise *during* unwinding and mask the original error. # # The process dictionary is the one place a flag can survive an unwind # without threading state the `Stream.resource` contract has nowhere to # put. This is single-process, single-upload scope, which is the narrow # case it is actually good for. kind, reason -> Process.put(@source_unwound, true) :erlang.raise(kind, reason, __STACKTRACE__) end # Reached whether the stream ran out, was abandoned mid-upload, or the # reduction raised — `Stream.resource/3` guarantees the `after_fun` on all # three. The source is halted only when it is still suspended *and* did not # unwind itself, so its own cleanup runs exactly once on every path. defp resume_cleanup({:done, _finished}) do Process.delete(@source_unwound) :ok end defp resume_cleanup({:live, continuation}) do unwound? = Process.get(@source_unwound, false) Process.delete(@source_unwound) unless unwound?, do: halt(continuation) :ok end ## Reading # A zero-length object is a legitimate variant — a render can produce no # bytes — and has no satisfiable range, so it streams as nothing rather # than asking S3 for a byte that is not there. defp stream_slice(_bucket, _key, :empty, _profile), do: [] # One ranged GET per chunk. `Stream.resource/3` is not needed — there is no # resource to release, only arithmetic — so this is a plain unfold. defp stream_slice(bucket, key, {first, last}, profile) do Stream.unfold(first, fn offset when offset > last -> nil offset -> chunk_last = min(offset + @read_chunk - 1, last) case get_range(bucket, key, offset, chunk_last, profile) do # A conforming store cannot answer a satisfiable range with nothing, # and `slice/2` has already established the range is satisfiable — # but advancing by zero would spin this unfold forever, so it ends # instead. {:ok, ""} -> nil {:ok, body} -> {body, offset + byte_size(body)} # Mid-stream, with a response already going out to the client, there # is nothing to return an error value *to*. Reachable only if the # object changed between the `head/2` above and this read. {:error, reason} -> raise "s3: read failed at byte #{offset}: #{inspect(reason)}" end end) end defp get_range(bucket, key, first, last, profile) do bucket |> ExAws.S3.get_object(key, range: "bytes=#{first}-#{last}") |> request(profile) |> case do {:ok, %{body: body}} -> {:ok, body} {:error, reason} -> {:error, reason} end end ## Requests defp request(operation, profile) do case ExAws.request(operation, config(profile)) do {:ok, response} -> {:ok, response} {:error, reason} -> {:error, translate(reason)} end end # `ex_aws`'s error shapes, in this codebase's vocabulary. # # A 404 is only a miss when it is the *object* that is missing. S3 answers # 404 for a missing bucket too, and reporting that as a cache miss would # make a misconfigured `AP_VARIANT_STORE` look like a permanently cold # cache: every request a miss, every miss a re-render, every write-back # failing silently. The distinction is in the error code, so it is read. defp translate({:http_error, 404, %{body: body}}) when is_binary(body) do if String.contains?(body, "NoSuchBucket") do {:http, 404, body} else :not_found end end defp translate({:http_error, 404, _response}), do: :not_found defp translate({:http_error, 403, _response}), do: :access_denied defp translate({:http_error, status, %{body: body}}) when is_binary(body), do: {:http, status, body} defp translate({:http_error, status, _response}), do: {:http, status, ""} defp translate(reason), do: {:transport, reason} defp upload_opts(opts) do metadata = opts |> Keyword.get(:metadata, %{}) |> Enum.map(fn {name, value} -> {to_string(name), to_string(value)} end) Enum.reject( [ content_type: opts[:content_type], cache_control: opts[:cache_control], meta: if(metadata == [], do: nil, else: metadata) ], fn {_name, value} -> is_nil(value) end ) end defp object(headers) do headers = for {name, value} <- headers, into: %{}, do: {String.downcase(name), value} %{ size: headers |> Map.get("content-length") |> to_integer(), etag: Map.get(headers, "etag"), content_type: Map.get(headers, "content-type"), cache_control: Map.get(headers, "cache-control"), metadata: user_metadata(headers) } end defp user_metadata(headers) do for {"x-amz-meta-" <> name, value} <- headers, into: %{}, do: {String.downcase(name), value} end defp to_integer(nil), do: 0 defp to_integer(value) do case Integer.parse(to_string(value)) do {integer, _rest} -> integer :error -> 0 end end ## Configuration @doc """ Reports whether credentials are configured. Checked before every operation, because `ex_aws` treats a nil credential as "not provided" rather than as an error — see `t:error/0`. """ @spec configured?(profile()) :: boolean() def configured?(profile \\ :source), do: profile_config(profile).access_key_id != nil @doc """ The `ex_aws` config overrides for one profile of this deployment. Public so a test can assert on the addressing decision without issuing a request. """ @spec config(profile()) :: keyword() def config(profile \\ :source) do s3 = profile_config(profile) [ access_key_id: s3.access_key_id, secret_access_key: s3.secret_access_key, region: s3.region, # `ex_aws` defaults to Jason, which this project does not depend on — # it uses OTP/Elixir's own `JSON`. Only reached when `ex_aws` decodes # an error body, which is exactly when a missing module would be most # confusing: the real failure disappears behind an UndefinedFunctionError. json_codec: JSON, # Ours rather than `ex_aws`'s bundled hackney adapter, which cannot # read hackney 4.0's bodyless responses. See `AudioProxy.S3.HttpClient`. http_client: AudioProxy.S3.HttpClient, # Read by `ExAws.Operation.S3`'s `add_bucket_to_path/2` on the request # path, and passed to `presigned_url/5` by `sign_get/4` — see there for # why the two must agree. virtual_host: s3.addressing == :virtual ] ++ security_token(s3.session_token) ++ endpoint(s3.endpoint) end # The one place the two profiles are told apart. Everything above takes a # profile only to hand it here. defp profile_config(:source), do: Config.get(:s3) defp profile_config(:store), do: Config.get(:variant_s3) # Omitted entirely rather than passed as `nil`: `ex_aws` sends the header # whenever the key is present, and a store reads an empty security token as # an invalid one — which fails every request with a message about temporary # credentials nobody configured. defp security_token(nil), do: [] defp security_token(token), do: [security_token: token] # The origin only; addressing is `virtual_host` above and independent of it. # `ex_aws` wants the scheme with its `://` attached. defp endpoint(nil), do: [] defp endpoint(%URI{scheme: scheme, host: host, port: port}) do [scheme: scheme <> "://", host: host, port: port] end end