Packages

Verified OCI/Docker registry client library for Elixir

Current section

Files

Jump to
regc lib regc mutate manifest.ex
Raw

lib/regc/mutate/manifest.ex

defmodule Regc.Mutate.Manifest do
@moduledoc """
Immutable manifest and index transformations.
OCI annotations are preserved by default. Conversion to Docker media types
rejects OCI-only fields unless `allow_lossy: true` is explicitly supplied.
"""
alias Regc.Mutate
alias Regc.Mutate.{Image, Result}
alias Regc.Oci.{Descriptor, Digest, MediaType}
@oci_index "application/vnd.oci.image.index.v1+json"
@oci_manifest "application/vnd.oci.image.manifest.v1+json"
@oci_artifact "application/vnd.oci.artifact.manifest.v1+json"
@oci_config "application/vnd.oci.image.config.v1+json"
@docker_index "application/vnd.docker.distribution.manifest.list.v2+json"
@docker_manifest "application/vnd.docker.distribution.manifest.v2+json"
@docker_config "application/vnd.docker.container.image.v1+json"
@to_docker %{
@oci_index => @docker_index,
@oci_manifest => @docker_manifest,
@oci_config => @docker_config,
"application/vnd.oci.image.layer.v1.tar" => "application/vnd.docker.image.rootfs.diff.tar",
"application/vnd.oci.image.layer.v1.tar+gzip" =>
"application/vnd.docker.image.rootfs.diff.tar.gzip",
"application/vnd.oci.image.layer.v1.tar+zstd" =>
"application/vnd.docker.image.rootfs.diff.tar.zstd",
"application/vnd.oci.image.layer.nondistributable.v1.tar+gzip" =>
"application/vnd.docker.image.rootfs.foreign.diff.tar.gzip"
}
@to_oci %{
@docker_index => @oci_index,
@docker_manifest => @oci_manifest,
@docker_config => @oci_config,
"application/vnd.docker.image.rootfs.diff.tar" => "application/vnd.oci.image.layer.v1.tar",
"application/vnd.docker.image.rootfs.diff.tar.gzip" =>
"application/vnd.oci.image.layer.v1.tar+gzip",
"application/vnd.docker.image.rootfs.diff.tar.zstd" =>
"application/vnd.oci.image.layer.v1.tar+zstd",
"application/vnd.docker.image.rootfs.foreign.diff.tar.gzip" =>
"application/vnd.oci.image.layer.nondistributable.v1.tar+gzip"
}
@doc """
Sets an OCI annotation. A `nil` value removes the annotation.
"""
@spec put_annotation(Image.t(), String.t(), String.t() | nil) ::
{:ok, Image.t()} | {:error, Regc.Error.t()}
def put_annotation(%Image{} = mutation, name, value)
when is_binary(name) and name != "" and
(is_binary(value) or is_nil(value)) do
with :ok <- require_oci_manifest(mutation) do
annotations = mutation.manifest["annotations"] || %{}
if Descriptor.annotations_valid?(annotations) do
updated_annotations =
if is_nil(value),
do: Map.delete(annotations, name),
else: Map.put(annotations, name, value)
document =
if updated_annotations == %{},
do: Map.delete(mutation.manifest, "annotations"),
else: Map.put(mutation.manifest, "annotations", updated_annotations)
Mutate.update_manifest(
mutation,
document,
:manifest_annotation,
%{name: name, action: if(is_nil(value), do: :delete, else: :set)}
)
else
Mutate.error(
:manifest_annotation,
:invalid_manifest,
"manifest annotations must contain string keys and values"
)
end
end
end
def put_annotation(%Image{}, _name, _value) do
Mutate.error(
:manifest_annotation,
:invalid_option,
"annotation name must be non-empty and value must be a string or nil"
)
end
@doc """
Removes an OCI annotation.
"""
@spec delete_annotation(Image.t(), String.t()) ::
{:ok, Image.t()} | {:error, Regc.Error.t()}
def delete_annotation(%Image{} = mutation, name),
do: put_annotation(mutation, name, nil)
@doc """
Records the OCI base-image name and digest annotations used by rebasing.
"""
@spec put_base(Image.t(), String.t(), String.t()) ::
{:ok, Image.t()} | {:error, Regc.Error.t()}
def put_base(%Image{} = mutation, name, digest)
when is_binary(name) and name != "" and is_binary(digest) do
with :ok <- require_oci_manifest(mutation),
{:ok, _algorithm, _encoded} <- parse_digest(digest) do
annotations =
(mutation.manifest["annotations"] || %{})
|> Map.put("org.opencontainers.image.base.name", name)
|> Map.put("org.opencontainers.image.base.digest", digest)
document = Map.put(mutation.manifest, "annotations", annotations)
Mutate.update_manifest(
mutation,
document,
:manifest_base,
%{name: name, digest: digest}
)
end
end
def put_base(%Image{}, _name, _digest) do
Mutate.error(
:manifest_base,
:invalid_option,
"base name and digest must be non-empty strings"
)
end
@doc """
Converts OCI image/index descriptor media types to Docker schema 2, or the
reverse.
Docker schema 2 cannot represent OCI subjects, artifact types, annotations,
inline descriptor data, or artifact manifests. OCI-only fields therefore
cause a `:lossy_conversion` error unless `allow_lossy: true` is supplied.
Artifact manifests are never converted to Docker.
Converting an index also requires a `:child_descriptors` map keyed by each
source child digest whose media type changes. Values may be finalized
mutation results, mutation states, descriptors, or descriptor maps. This
prevents relabeling a child while retaining a digest for different bytes.
"""
@spec convert_media_types(Image.t(), :oci | :docker, keyword()) ::
{:ok, Image.t()} | {:error, Regc.Error.t()}
def convert_media_types(mutation, target, opts \\ [])
def convert_media_types(%Image{} = mutation, target, opts)
when target in [:oci, :docker] and is_list(opts) do
if Keyword.keyword?(opts) do
{conversion_opts, unknown_opts} =
Keyword.split(opts, [:allow_lossy, :child_descriptors])
allow_lossy? = Keyword.get(conversion_opts, :allow_lossy, false)
child_descriptors = Keyword.get(conversion_opts, :child_descriptors, %{})
with :ok <- reject_unknown_options(unknown_opts),
:ok <- validate_allow_lossy(allow_lossy?),
:ok <- validate_child_descriptors(child_descriptors),
{:ok, document} <-
convert_document(
mutation,
target,
allow_lossy?,
child_descriptors
) do
Mutate.update_manifest(
mutation,
document,
:manifest_media_types,
%{target: target, allow_lossy: allow_lossy?}
)
end
else
invalid_conversion_options()
end
end
def convert_media_types(%Image{}, _target, _opts) do
Mutate.error(
:manifest_media_types,
:invalid_option,
"media type target must be :oci or :docker and options must be a keyword list"
)
end
defp convert_document(
%Image{kind: :artifact},
:docker,
_allow_lossy?,
_child_descriptors
) do
Mutate.error(
:manifest_media_types,
:unsupported_media_type,
"OCI artifact manifests cannot be converted to Docker schema 2"
)
end
defp convert_document(
%Image{} = mutation,
:docker,
allow_lossy?,
child_descriptors
) do
with :ok <- reject_artifact_descriptors(mutation.manifest),
{:ok, document} <-
prepare_docker_document(mutation.manifest, allow_lossy?),
{:ok, document} <-
convert_index_descriptors(
mutation.kind,
document,
@to_docker,
child_descriptors
) do
{:ok,
convert_known_media_types(
document,
@to_docker,
mutation.kind != :index
)}
end
end
defp convert_document(
%Image{} = mutation,
:oci,
_allow_lossy?,
child_descriptors
) do
with {:ok, document} <-
convert_index_descriptors(
mutation.kind,
mutation.manifest,
@to_oci,
child_descriptors
) do
{:ok,
convert_known_media_types(
document,
@to_oci,
mutation.kind != :index
)}
end
end
defp prepare_docker_document(document, false) do
case oci_only_fields(document) do
[] ->
{:ok, document}
fields ->
Mutate.error(
:manifest_media_types,
:lossy_conversion,
"Docker media types cannot represent OCI-only manifest fields",
details: %{fields: fields}
)
end
end
defp prepare_docker_document(document, true), do: {:ok, drop_oci_only_fields(document)}
defp convert_known_media_types(document, mapping, convert_manifests?) do
document =
document
|> update_media_type(mapping)
|> update_descriptor("config", mapping)
|> update_descriptor_list("layers", mapping)
if convert_manifests?,
do: update_descriptor_list(document, "manifests", mapping),
else: document
end
defp convert_index_descriptors(:index, document, mapping, replacements) do
document["manifests"]
|> Enum.reduce_while({:ok, []}, fn descriptor, {:ok, converted} ->
case convert_index_descriptor(descriptor, mapping, replacements) do
{:ok, updated} -> {:cont, {:ok, converted ++ [updated]}}
{:error, %Regc.Error{}} = failure -> {:halt, failure}
end
end)
|> case do
{:ok, descriptors} -> {:ok, Map.put(document, "manifests", descriptors)}
{:error, %Regc.Error{}} = failure -> failure
end
end
defp convert_index_descriptors(_kind, document, _mapping, _replacements),
do: {:ok, document}
defp convert_index_descriptor(
%{"mediaType" => media_type, "digest" => digest} = document,
mapping,
replacements
) do
target_media_type = Map.get(mapping, media_type, media_type)
if target_media_type == media_type do
{:ok, document}
else
case Map.fetch(replacements, digest) do
{:ok, replacement} ->
with {:ok, descriptor} <- replacement_descriptor(replacement),
:ok <-
validate_replacement_media_type(
descriptor,
target_media_type,
digest
) do
{:ok,
document
|> Map.put("mediaType", descriptor.media_type)
|> Map.put("digest", descriptor.digest)
|> Map.put("size", descriptor.size)}
end
:error ->
Mutate.error(
:manifest_media_types,
:child_results_required,
"index media conversion requires converted child descriptors",
digest: digest,
details: %{target_media_type: target_media_type}
)
end
end
end
defp convert_index_descriptor(_document, _mapping, _replacements) do
Mutate.error(
:manifest_media_types,
:invalid_manifest,
"index contains an invalid child descriptor"
)
end
defp replacement_descriptor(%Descriptor{} = descriptor), do: {:ok, descriptor}
defp replacement_descriptor(%Result{manifest_descriptor: descriptor}), do: {:ok, descriptor}
defp replacement_descriptor(%Image{manifest_descriptor: descriptor}), do: {:ok, descriptor}
defp replacement_descriptor(document) when is_map(document) do
case Descriptor.from_map(document, max_data_bytes: :infinity) do
{:ok, descriptor} ->
{:ok, descriptor}
{:error, reason} ->
Mutate.error(
:manifest_media_types,
:invalid_descriptor,
"converted child descriptor is invalid",
cause: reason
)
end
end
defp replacement_descriptor(_descriptor) do
Mutate.error(
:manifest_media_types,
:invalid_descriptor,
"converted child descriptor is invalid"
)
end
defp validate_replacement_media_type(descriptor, expected, source_digest) do
if descriptor.media_type == expected do
:ok
else
Mutate.error(
:manifest_media_types,
:media_type_mismatch,
"converted child descriptor has the wrong media type",
digest: source_digest,
details: %{
actual: descriptor.media_type,
expected: expected
}
)
end
end
defp update_media_type(%{"mediaType" => media_type} = document, mapping) do
Map.put(document, "mediaType", Map.get(mapping, media_type, media_type))
end
defp update_media_type(document, _mapping), do: document
defp update_descriptor(document, field, mapping) do
case document[field] do
descriptor when is_map(descriptor) ->
Map.put(document, field, update_media_type(descriptor, mapping))
_missing ->
document
end
end
defp update_descriptor_list(document, field, mapping) do
case document[field] do
descriptors when is_list(descriptors) ->
Map.put(document, field, Enum.map(descriptors, &update_media_type(&1, mapping)))
_missing ->
document
end
end
defp oci_only_fields(document) do
root_fields =
["subject", "artifactType", "annotations"]
|> Enum.filter(&present?(document[&1]))
|> Enum.map(&"manifest.#{&1}")
descriptor_fields =
["config", "layers", "manifests"]
|> Enum.flat_map(fn field ->
document
|> descriptor_documents(field)
|> Enum.with_index()
|> Enum.flat_map(fn {descriptor, index} ->
["artifactType", "annotations", "data"]
|> Enum.filter(&present?(descriptor[&1]))
|> Enum.map(&"#{field}[#{index}].#{&1}")
end)
end)
root_fields ++ descriptor_fields
end
defp descriptor_documents(document, field) do
case document[field] do
value when is_map(value) -> [value]
value when is_list(value) -> Enum.filter(value, &is_map/1)
_missing -> []
end
end
defp present?(nil), do: false
defp present?(%{} = value), do: map_size(value) > 0
defp present?(""), do: false
defp present?(_value), do: true
defp drop_oci_only_fields(document) do
document
|> Map.drop(["subject", "artifactType", "annotations"])
|> drop_descriptor_fields("config")
|> drop_descriptor_list_fields("layers")
|> drop_descriptor_list_fields("manifests")
end
defp drop_descriptor_fields(document, field) do
case document[field] do
descriptor when is_map(descriptor) ->
Map.put(
document,
field,
Map.drop(descriptor, ["artifactType", "annotations", "data"])
)
_missing ->
document
end
end
defp drop_descriptor_list_fields(document, field) do
case document[field] do
descriptors when is_list(descriptors) ->
Map.put(
document,
field,
Enum.map(descriptors, fn descriptor ->
if is_map(descriptor),
do: Map.drop(descriptor, ["artifactType", "annotations", "data"]),
else: descriptor
end)
)
_missing ->
document
end
end
defp reject_artifact_descriptors(document) do
artifact? =
["config", "layers", "manifests"]
|> Enum.flat_map(&descriptor_documents(document, &1))
|> Enum.any?(&(&1["mediaType"] == @oci_artifact))
if artifact? do
Mutate.error(
:manifest_media_types,
:unsupported_media_type,
"an index containing artifact manifests cannot be converted to Docker schema 2"
)
else
:ok
end
end
defp require_oci_manifest(%Image{manifest: %{"mediaType" => media_type}}) do
if MediaType.oci_manifest_document?(media_type) do
:ok
else
Mutate.error(
:manifest_annotation,
:unsupported_media_type,
"annotations require an OCI manifest media type"
)
end
end
defp parse_digest(digest) do
case Digest.parse(digest) do
{:ok, algorithm, encoded} ->
{:ok, algorithm, encoded}
{:error, reason} ->
Mutate.error(
:manifest_base,
:invalid_digest,
"base digest is invalid",
digest: digest,
cause: reason
)
end
end
defp reject_unknown_options([]), do: :ok
defp reject_unknown_options(options) do
Mutate.error(
:manifest_media_types,
:invalid_option,
"media type conversion option is not supported",
cause: {:invalid_option, options |> Keyword.keys() |> hd()}
)
end
defp validate_allow_lossy(value) when is_boolean(value), do: :ok
defp validate_allow_lossy(_value), do: invalid_conversion_options()
defp validate_child_descriptors(value) when is_map(value), do: :ok
defp validate_child_descriptors(_value), do: invalid_conversion_options()
defp invalid_conversion_options do
Mutate.error(
:manifest_media_types,
:invalid_option,
"allow_lossy must be a boolean and child_descriptors must be a map"
)
end
end