use crate::diagnostic::{Diagnostic, DiagnosticCode, DiagnosticLabel, Severity};
use crate::merge::{
EntrySyntax, MergeProvenance, MergedEntry, MergedProject, MergedScalarKind, MergedValue, MergedValueKind,
};
use crate::model::{
BindOptions, BooleanValue, Command, ComposeScalar, ConfigDefinition, HostAddress, ImageReference, Ipam, IpamConfig,
KeyValueEntry, Labels, Located, LongPort, LongVolumeMount, MountType, NetworkDefinition, Port, SecretDefinition,
SelinuxRelabel, ServiceNetwork, ServiceNetworks, ShortExtraHost, ShortPort, ShortVolumeMount, VolumeDefinition,
VolumeMount,
};
use crate::profiles::ProfileSelection;
use crate::resolution::{SELECTION_PROJECT_MISMATCH, service_in_scope};
use crate::source::{SourceId, SourceSpan};
use std::fmt;
use std::path::{Path, PathBuf};
pub const PROJECT_EXPECTED_FORM: DiagnosticCode = DiagnosticCode::new("compose.project.expected-form");
pub const PROJECT_MISSING_FIELD: DiagnosticCode = DiagnosticCode::new("compose.project.missing-field");
pub const PROJECT_INVALID_VALUE: DiagnosticCode = DiagnosticCode::new("compose.project.invalid-value");
#[derive(Clone, PartialEq, Eq)]
pub struct ProjectValue<T> {
value: T,
provenance: MergeProvenance,
sensitive: bool,
}
impl<T: fmt::Debug> fmt::Debug for ProjectValue<T> {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut debug = formatter.debug_struct("ProjectValue");
if self.sensitive {
debug.field("value", &"<redacted>");
} else {
debug.field("value", &self.value);
}
debug
.field("provenance", &self.provenance)
.field("sensitive", &self.sensitive)
.finish()
}
}
impl<T> ProjectValue<T> {
fn new(value: T, source: &MergedValue) -> Self {
Self {
value,
provenance: source.provenance().clone(),
sensitive: source.is_sensitive(),
}
}
#[must_use]
pub const fn value(&self) -> &T {
&self.value
}
#[must_use]
pub const fn provenance(&self) -> &MergeProvenance {
&self.provenance
}
#[must_use]
pub fn effective_source(&self) -> Option<SourceSpan> {
self.provenance.effective_source()
}
#[must_use]
pub const fn is_sensitive(&self) -> bool {
self.sensitive
}
#[must_use]
pub fn into_value(self) -> T {
self.value
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProjectKey {
value: String,
sources: Vec<SourceSpan>,
}
impl ProjectKey {
fn from_entry(entry: &MergedEntry) -> Self {
Self {
value: entry.key().to_owned(),
sources: entry.key_sources().to_vec(),
}
}
#[must_use]
pub fn value(&self) -> &str {
&self.value
}
#[must_use]
pub fn sources(&self) -> &[SourceSpan] {
&self.sources
}
#[must_use]
pub fn effective_source(&self) -> Option<SourceSpan> {
self.sources.last().copied()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProjectFieldReference {
path: Vec<String>,
key: ProjectKey,
provenance: MergeProvenance,
extension: bool,
sensitive: bool,
}
impl ProjectFieldReference {
#[must_use]
pub fn path(&self) -> &[String] {
&self.path
}
#[must_use]
pub const fn key(&self) -> &ProjectKey {
&self.key
}
#[must_use]
pub const fn provenance(&self) -> &MergeProvenance {
&self.provenance
}
#[must_use]
pub const fn is_extension(&self) -> bool {
self.extension
}
#[must_use]
pub const fn is_sensitive(&self) -> bool {
self.sensitive
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProjectEnvironmentEntry {
name: ProjectKey,
value: ProjectValue<ComposeScalar>,
syntax: EntrySyntax,
}
impl ProjectEnvironmentEntry {
#[must_use]
pub const fn name(&self) -> &ProjectKey {
&self.name
}
#[must_use]
pub const fn value(&self) -> &ProjectValue<ComposeScalar> {
&self.value
}
#[must_use]
pub const fn syntax(&self) -> EntrySyntax {
self.syntax
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProjectEnvironment {
entries: Vec<ProjectEnvironmentEntry>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProjectExtraHost {
hostname: ProjectKey,
address: ProjectValue<HostAddress>,
syntax: EntrySyntax,
}
impl ProjectExtraHost {
#[must_use]
pub const fn hostname(&self) -> &ProjectKey {
&self.hostname
}
#[must_use]
pub const fn address(&self) -> &ProjectValue<HostAddress> {
&self.address
}
#[must_use]
pub const fn syntax(&self) -> EntrySyntax {
self.syntax
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProjectExtraHosts {
entries: Vec<ProjectExtraHost>,
}
impl ProjectExtraHosts {
#[must_use]
pub fn entries(&self) -> &[ProjectExtraHost] {
&self.entries
}
}
impl ProjectEnvironment {
#[must_use]
pub fn entries(&self) -> &[ProjectEnvironmentEntry] {
&self.entries
}
#[must_use]
pub fn get(&self, name: &str) -> Option<&ProjectEnvironmentEntry> {
self.entries.iter().find(|entry| entry.name.value == name)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProjectService {
name: ProjectKey,
provenance: MergeProvenance,
image: Option<ProjectValue<ImageReference>>,
command: Option<ProjectValue<Command>>,
environment: Option<ProjectValue<ProjectEnvironment>>,
extra_hosts: Option<ProjectValue<ProjectExtraHosts>>,
ports: Option<ProjectValue<Vec<ProjectValue<Port>>>>,
volumes: Option<ProjectValue<Vec<ProjectValue<VolumeMount>>>>,
networks: Option<ProjectValue<ServiceNetworks>>,
profiles: Option<ProjectValue<Vec<ProjectValue<String>>>>,
unmodeled_fields: Vec<ProjectFieldReference>,
}
impl ProjectService {
#[must_use]
pub const fn name(&self) -> &ProjectKey {
&self.name
}
#[must_use]
pub const fn provenance(&self) -> &MergeProvenance {
&self.provenance
}
#[must_use]
pub const fn image(&self) -> Option<&ProjectValue<ImageReference>> {
self.image.as_ref()
}
#[must_use]
pub const fn command(&self) -> Option<&ProjectValue<Command>> {
self.command.as_ref()
}
#[must_use]
pub const fn environment(&self) -> Option<&ProjectValue<ProjectEnvironment>> {
self.environment.as_ref()
}
#[must_use]
pub const fn extra_hosts(&self) -> Option<&ProjectValue<ProjectExtraHosts>> {
self.extra_hosts.as_ref()
}
#[must_use]
pub const fn ports(&self) -> Option<&ProjectValue<Vec<ProjectValue<Port>>>> {
self.ports.as_ref()
}
#[must_use]
pub const fn volumes(&self) -> Option<&ProjectValue<Vec<ProjectValue<VolumeMount>>>> {
self.volumes.as_ref()
}
#[must_use]
pub const fn networks(&self) -> Option<&ProjectValue<ServiceNetworks>> {
self.networks.as_ref()
}
#[must_use]
pub const fn profiles(&self) -> Option<&ProjectValue<Vec<ProjectValue<String>>>> {
self.profiles.as_ref()
}
#[must_use]
pub fn unmodeled_fields(&self) -> &[ProjectFieldReference] {
&self.unmodeled_fields
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProjectResource<T> {
name: ProjectKey,
definition: ProjectValue<T>,
}
impl<T> ProjectResource<T> {
#[must_use]
pub const fn name(&self) -> &ProjectKey {
&self.name
}
#[must_use]
pub const fn definition(&self) -> &ProjectValue<T> {
&self.definition
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProjectView {
source_ids: Vec<SourceId>,
base_directory: PathBuf,
provenance: MergeProvenance,
name: Option<ProjectValue<String>>,
services: Vec<ProjectService>,
networks: Vec<ProjectResource<NetworkDefinition>>,
volumes: Vec<ProjectResource<VolumeDefinition>>,
configs: Vec<ProjectResource<ConfigDefinition>>,
secrets: Vec<ProjectResource<SecretDefinition>>,
unmodeled_fields: Vec<ProjectFieldReference>,
}
impl ProjectView {
#[must_use]
pub fn source_ids(&self) -> &[SourceId] {
&self.source_ids
}
#[must_use]
pub fn base_directory(&self) -> &Path {
&self.base_directory
}
#[must_use]
pub const fn provenance(&self) -> &MergeProvenance {
&self.provenance
}
#[must_use]
pub const fn name(&self) -> Option<&ProjectValue<String>> {
self.name.as_ref()
}
#[must_use]
pub fn services(&self) -> &[ProjectService] {
&self.services
}
#[must_use]
pub fn service(&self, name: &str) -> Option<&ProjectService> {
self.services.iter().find(|service| service.name.value == name)
}
#[must_use]
pub fn networks(&self) -> &[ProjectResource<NetworkDefinition>] {
&self.networks
}
#[must_use]
pub fn volumes(&self) -> &[ProjectResource<VolumeDefinition>] {
&self.volumes
}
#[must_use]
pub fn configs(&self) -> &[ProjectResource<ConfigDefinition>] {
&self.configs
}
#[must_use]
pub fn secrets(&self) -> &[ProjectResource<SecretDefinition>] {
&self.secrets
}
#[must_use]
pub fn unmodeled_fields(&self) -> &[ProjectFieldReference] {
&self.unmodeled_fields
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProjectViewResult {
view: Option<ProjectView>,
diagnostics: Vec<Diagnostic>,
}
impl ProjectViewResult {
#[must_use]
pub const fn view(&self) -> Option<&ProjectView> {
self.view.as_ref()
}
#[must_use]
pub fn diagnostics(&self) -> &[Diagnostic] {
&self.diagnostics
}
#[must_use]
pub fn is_valid(&self) -> bool {
self.view.is_some()
&& self
.diagnostics
.iter()
.all(|diagnostic| diagnostic.severity() != Severity::Error)
}
#[must_use]
pub fn into_parts(self) -> (Option<ProjectView>, Vec<Diagnostic>) {
(self.view, self.diagnostics)
}
}
#[must_use]
pub fn build_project_view(project: &MergedProject, selection: Option<&ProfileSelection>) -> ProjectViewResult {
if selection.is_some_and(|selection| !selection.belongs_to(project)) {
return ProjectViewResult {
view: None,
diagnostics: vec![Diagnostic::new(
SELECTION_PROJECT_MISMATCH,
Severity::Error,
"profile selection does not belong to the merged project",
)],
};
}
Builder::new(project, selection).build()
}
struct Builder<'a> {
project: &'a MergedProject,
selection: Option<&'a ProfileSelection>,
diagnostics: Vec<Diagnostic>,
root_unmodeled: Vec<ProjectFieldReference>,
pending_unmodeled: Vec<ProjectFieldReference>,
}
impl<'a> Builder<'a> {
const fn new(project: &'a MergedProject, selection: Option<&'a ProfileSelection>) -> Self {
Self {
project,
selection,
diagnostics: Vec::new(),
root_unmodeled: Vec::new(),
pending_unmodeled: Vec::new(),
}
}
fn build(mut self) -> ProjectViewResult {
let root = self.project.root();
let entries = root.as_mapping().unwrap_or_default();
let mut name = None;
let mut services = Vec::new();
let mut networks = Vec::new();
let mut volumes = Vec::new();
let mut configs = Vec::new();
let mut secrets = Vec::new();
for entry in entries {
match entry.key() {
"name" => name = self.project_string(entry.value(), "project name"),
"services" => services = self.services(entry.value()),
"networks" => networks = self.network_definitions(entry.value()),
"volumes" => volumes = self.volume_definitions(entry.value()),
"configs" => configs = self.config_definitions(entry.value()),
"secrets" => secrets = self.secret_definitions(entry.value()),
_ => self.record_root_unmodeled(&[], entry),
}
}
ProjectViewResult {
view: Some(ProjectView {
source_ids: self.project.source_ids().to_vec(),
base_directory: self.project.base_directory().to_path_buf(),
provenance: root.provenance().clone(),
name,
services,
networks,
volumes,
configs,
secrets,
unmodeled_fields: self.root_unmodeled,
}),
diagnostics: self.diagnostics,
}
}
fn services(&mut self, value: &MergedValue) -> Vec<ProjectService> {
let Some(entries) = self.mapping(value, "services must be a mapping") else {
return Vec::new();
};
let selection = self.selection;
let mut services = Vec::new();
for entry in entries {
if service_in_scope(selection, entry.key()) {
services.extend(self.service(entry));
}
}
services
}
fn service(&mut self, entry: &MergedEntry) -> Option<ProjectService> {
let pending_start = self.pending_unmodeled.len();
let value = entry.value();
let fields = self.mapping(value, "service definition must be a mapping")?;
let mut service = ProjectService {
name: ProjectKey::from_entry(entry),
provenance: value.provenance().clone(),
image: None,
command: None,
environment: None,
extra_hosts: None,
ports: None,
volumes: None,
networks: None,
profiles: None,
unmodeled_fields: Vec::new(),
};
let path = ["services".to_owned(), entry.key().to_owned()];
for field in fields {
match field.key() {
"image" => {
service.image = self
.project_string(field.value(), "service image")
.map(|value| ProjectValue {
value: ImageReference::parse(value.value),
provenance: value.provenance,
sensitive: value.sensitive,
});
}
"command" => service.command = self.command(field.value()),
"environment" => service.environment = self.environment(field.value()),
"extra_hosts" => service.extra_hosts = self.extra_hosts(field.value()),
"ports" => service.ports = self.ports(field.value(), &path),
"volumes" => service.volumes = self.volumes(field.value(), &path),
"networks" => service.networks = self.service_networks(field.value(), &path),
"profiles" => service.profiles = self.string_collection(field.value(), "profiles must be a sequence"),
_ => service.unmodeled_fields.push(field_reference(&path, field)),
}
}
service
.unmodeled_fields
.extend(self.pending_unmodeled.drain(pending_start..));
Some(service)
}
fn command(&mut self, value: &MergedValue) -> Option<ProjectValue<Command>> {
let span = effective_span(value);
let command = match value.kind() {
MergedValueKind::Null(_) => Command::Null(span),
MergedValueKind::Scalar(scalar) => Command::String(Located::new(scalar.value().to_owned(), span)),
MergedValueKind::Sequence(values) => {
let mut arguments = Vec::new();
for value in values {
arguments.push(self.located_string(value, "command list item must be a scalar")?);
}
Command::List {
span,
values: arguments,
}
}
_ => {
self.expected(value, "command must be null, a scalar, or a sequence");
return None;
}
};
Some(ProjectValue::new(command, value))
}
fn environment(&mut self, value: &MergedValue) -> Option<ProjectValue<ProjectEnvironment>> {
let mut entries = Vec::new();
match value.kind() {
MergedValueKind::Mapping(values) => {
for entry in values {
let scalar = self.compose_scalar(entry.value(), "environment value must be a scalar or null")?;
entries.push(ProjectEnvironmentEntry {
name: ProjectKey::from_entry(entry),
value: ProjectValue::new(scalar, entry.value()),
syntax: entry.syntax(),
});
}
}
MergedValueKind::Sequence(values) => {
for item in values {
let raw = self.located_string(item, "environment list item must be a scalar")?;
let (name, scalar, syntax) = raw.value().split_once('=').map_or_else(
|| (raw.value().clone(), ComposeScalar::Null, EntrySyntax::ListKeyOnly),
|(name, value)| {
(
name.to_owned(),
ComposeScalar::String(value.to_owned()),
EntrySyntax::ListKeyValue,
)
},
);
entries.push(ProjectEnvironmentEntry {
name: ProjectKey {
value: name,
sources: item.provenance().sources().to_vec(),
},
value: ProjectValue::new(scalar, item),
syntax,
});
}
}
_ => {
self.expected(value, "environment must be a mapping or sequence");
return None;
}
}
Some(ProjectValue::new(ProjectEnvironment { entries }, value))
}
fn extra_hosts(&mut self, value: &MergedValue) -> Option<ProjectValue<ProjectExtraHosts>> {
let mut entries = Vec::new();
match value.kind() {
MergedValueKind::Mapping(values) => {
for entry in values {
let scalar = self.scalar(entry.value(), "extra_hosts address must be a scalar")?;
entries.push(ProjectExtraHost {
hostname: ProjectKey::from_entry(entry),
address: ProjectValue::new(HostAddress::parse(scalar.value().to_owned()), entry.value()),
syntax: EntrySyntax::Mapping,
});
}
}
MergedValueKind::Sequence(values) => {
for item in values {
let raw = self.located_string(item, "extra_hosts list item must be a scalar")?;
let parsed = ShortExtraHost::parse(raw);
let (Some(hostname), Some(address)) = (parsed.hostname(), parsed.address()) else {
self.invalid(
effective_span(item),
"extra_hosts entry must contain a hostname and address",
);
continue;
};
entries.push(ProjectExtraHost {
hostname: ProjectKey {
value: hostname.to_owned(),
sources: item.provenance().sources().to_vec(),
},
address: ProjectValue::new(address.clone(), item),
syntax: EntrySyntax::ListKeyValue,
});
}
}
_ => {
self.expected(value, "extra_hosts must be a mapping or sequence");
return None;
}
}
Some(ProjectValue::new(ProjectExtraHosts { entries }, value))
}
fn project_string(&mut self, value: &MergedValue, description: &str) -> Option<ProjectValue<String>> {
let scalar = self.scalar(value, &format!("{description} must be a non-null scalar"))?;
Some(ProjectValue::new(scalar.value().to_owned(), value))
}
fn string_collection(
&mut self,
value: &MergedValue,
message: &str,
) -> Option<ProjectValue<Vec<ProjectValue<String>>>> {
let Some(values) = value.as_sequence() else {
self.expected(value, message);
return None;
};
let mut strings = Vec::new();
for value in values {
let scalar = self.scalar(value, "sequence item must be a non-null scalar")?;
strings.push(ProjectValue::new(scalar.value().to_owned(), value));
}
Some(ProjectValue::new(strings, value))
}
fn scalar<'value>(
&mut self,
value: &'value MergedValue,
message: &str,
) -> Option<&'value crate::merge::MergedScalar> {
let Some(scalar) = value.as_scalar() else {
self.expected(value, message);
return None;
};
Some(scalar)
}
fn located_string(&mut self, value: &MergedValue, message: &str) -> Option<Located<String>> {
let scalar = self.scalar(value, message)?;
Some(Located::new(scalar.value().to_owned(), effective_span(value)))
}
fn compose_scalar(&mut self, value: &MergedValue, message: &str) -> Option<ComposeScalar> {
match value.kind() {
MergedValueKind::Null(_) => Some(ComposeScalar::Null),
MergedValueKind::Scalar(scalar) => Some(match scalar.kind() {
MergedScalarKind::String => ComposeScalar::String(scalar.value().to_owned()),
MergedScalarKind::Boolean => ComposeScalar::Boolean(scalar.value().eq_ignore_ascii_case("true")),
MergedScalarKind::Number => ComposeScalar::Number(scalar.value().to_owned()),
}),
_ => {
self.expected(value, message);
None
}
}
}
fn mapping<'value>(&mut self, value: &'value MergedValue, message: &str) -> Option<&'value [MergedEntry]> {
let Some(entries) = value.as_mapping() else {
self.expected(value, message);
return None;
};
Some(entries)
}
fn expected(&mut self, value: &MergedValue, message: &str) {
self.diagnostics.push(
Diagnostic::new(PROJECT_EXPECTED_FORM, Severity::Error, message).with_label(DiagnosticLabel::primary(
effective_span(value),
"unexpected merged value form",
)),
);
}
fn missing(&mut self, value: &MergedValue, message: &str) {
self.diagnostics.push(
Diagnostic::new(PROJECT_MISSING_FIELD, Severity::Error, message).with_label(DiagnosticLabel::primary(
effective_span(value),
"required field is missing",
)),
);
}
fn invalid(&mut self, span: SourceSpan, message: &str) {
self.diagnostics.push(
Diagnostic::new(PROJECT_INVALID_VALUE, Severity::Error, message)
.with_label(DiagnosticLabel::primary(span, "invalid native value")),
);
}
fn record_root_unmodeled(&mut self, path: &[String], entry: &MergedEntry) {
self.root_unmodeled.push(field_reference(path, entry));
}
fn record_pending_unmodeled(&mut self, path: &[String], entry: &MergedEntry) {
self.pending_unmodeled.push(field_reference(path, entry));
}
}
impl Builder<'_> {
fn ports(&mut self, value: &MergedValue, service_path: &[String]) -> Option<ProjectValue<Vec<ProjectValue<Port>>>> {
let Some(values) = value.as_sequence() else {
self.expected(value, "service ports must be a sequence");
return None;
};
let mut ports = Vec::new();
for (index, item) in values.iter().enumerate() {
let mut path = service_path.to_vec();
path.push("ports".to_owned());
path.push(index.to_string());
let port = match item.kind() {
MergedValueKind::Scalar(scalar) => Port::Short(ShortPort::parse(Located::new(
scalar.value().to_owned(),
effective_span(item),
))),
MergedValueKind::Mapping(fields) => Port::Long(Box::new(self.long_port(item, fields, &path))),
_ => {
self.expected(item, "service port must use scalar short syntax or mapping long syntax");
continue;
}
};
ports.push(ProjectValue::new(port, item));
}
Some(ProjectValue::new(ports, value))
}
fn long_port(&mut self, value: &MergedValue, fields: &[MergedEntry], path: &[String]) -> LongPort {
let mut port = LongPort::new(effective_span(value));
let mut has_target = false;
for field in fields {
match field.key() {
"target" => {
if let Some(value) = self.located_string(field.value(), "port target must be a scalar") {
port.set_target(value);
has_target = true;
}
}
"published" => self
.located_string(field.value(), "published port must be a scalar")
.into_iter()
.for_each(|value| port.set_published(value)),
"host_ip" => self
.located_string(field.value(), "port host_ip must be a scalar")
.into_iter()
.for_each(|value| port.set_host_ip(value)),
"protocol" => self
.located_string(field.value(), "port protocol must be a scalar")
.into_iter()
.for_each(|value| port.set_protocol(value)),
"app_protocol" => self
.located_string(field.value(), "port app_protocol must be a scalar")
.into_iter()
.for_each(|value| port.set_app_protocol(value)),
"mode" => self
.located_string(field.value(), "port mode must be a scalar")
.into_iter()
.for_each(|value| port.set_mode(value)),
"name" => self
.located_string(field.value(), "port name must be a scalar")
.into_iter()
.for_each(|value| port.set_name(value)),
_ => self.record_pending_unmodeled(path, field),
}
}
if !has_target {
self.missing(value, "long-syntax port is missing `target`");
}
port
}
fn volumes(
&mut self,
value: &MergedValue,
service_path: &[String],
) -> Option<ProjectValue<Vec<ProjectValue<VolumeMount>>>> {
let Some(values) = value.as_sequence() else {
self.expected(value, "service volumes must be a sequence");
return None;
};
let mut mounts = Vec::new();
for (index, item) in values.iter().enumerate() {
let mut path = service_path.to_vec();
path.push("volumes".to_owned());
path.push(index.to_string());
let mount = match item.kind() {
MergedValueKind::Scalar(scalar) => VolumeMount::Short(ShortVolumeMount::new(Located::new(
scalar.value().to_owned(),
effective_span(item),
))),
MergedValueKind::Mapping(fields) => VolumeMount::Long(Box::new(self.long_volume(item, fields, &path))),
_ => {
self.expected(
item,
"service volume must use scalar short syntax or mapping long syntax",
);
continue;
}
};
mounts.push(ProjectValue::new(mount, item));
}
Some(ProjectValue::new(mounts, value))
}
fn long_volume(&mut self, value: &MergedValue, fields: &[MergedEntry], path: &[String]) -> LongVolumeMount {
let mut mount = LongVolumeMount::new(effective_span(value));
let mut has_type = false;
let mut has_target = false;
for field in fields {
match field.key() {
"type" => {
if let Some(value) = self.located_string(field.value(), "volume type must be a scalar") {
mount.set_mount_type(Located::new(MountType::from_text(value.value().clone()), value.span()));
has_type = true;
}
}
"source" => self
.located_string(field.value(), "volume source must be a scalar")
.into_iter()
.for_each(|value| mount.set_source(value)),
"target" => {
if let Some(value) = self.located_string(field.value(), "volume target must be a scalar") {
mount.set_target(value);
has_target = true;
}
}
"read_only" => self
.located_boolean(field.value(), "volume read_only must be a boolean")
.into_iter()
.for_each(|value| mount.set_read_only(value)),
"bind" => self
.bind_options(field.value(), path)
.into_iter()
.for_each(|value| mount.set_bind(value)),
_ => self.record_pending_unmodeled(path, field),
}
}
if !has_type {
self.missing(value, "long-syntax volume is missing `type`");
}
if !has_target {
self.missing(value, "long-syntax volume is missing `target`");
}
mount
}
fn bind_options(&mut self, value: &MergedValue, parent_path: &[String]) -> Option<BindOptions> {
let fields = self.mapping(value, "volume bind options must be a mapping")?;
let mut bind = BindOptions::new(effective_span(value));
let mut path = parent_path.to_vec();
path.push("bind".to_owned());
for field in fields {
match field.key() {
"propagation" => self
.located_string(field.value(), "bind propagation must be a scalar")
.into_iter()
.for_each(|value| bind.set_propagation(value)),
"create_host_path" => self
.located_boolean(field.value(), "bind create_host_path must be a boolean")
.into_iter()
.for_each(|value| bind.set_create_host_path(value)),
"selinux" => {
if let Some(value) = self.located_string(field.value(), "bind SELinux mode must be a scalar") {
let mode = match value.value().as_str() {
"z" => Some(SelinuxRelabel::Shared),
"Z" => Some(SelinuxRelabel::Private),
_ => None,
};
if let Some(mode) = mode {
bind.set_selinux(Located::new(mode, value.span()));
} else {
self.invalid(value.span(), "bind SELinux mode must be `z` or `Z`");
}
}
}
_ => self.record_pending_unmodeled(&path, field),
}
}
Some(bind)
}
fn service_networks(
&mut self,
value: &MergedValue,
service_path: &[String],
) -> Option<ProjectValue<ServiceNetworks>> {
let span = effective_span(value);
let networks = match value.kind() {
MergedValueKind::Sequence(values) => {
let mut names = Vec::new();
for value in values {
names.push(self.located_string(value, "service network name must be a scalar")?);
}
ServiceNetworks::Short { span, names }
}
MergedValueKind::Mapping(entries) => {
let mut networks = Vec::new();
for entry in entries {
let mut path = service_path.to_vec();
path.push("networks".to_owned());
path.push(entry.key().to_owned());
networks.push(self.service_network(entry, &path)?);
}
ServiceNetworks::Long { span, networks }
}
_ => {
self.expected(value, "service networks must be a sequence or mapping");
return None;
}
};
Some(ProjectValue::new(networks, value))
}
fn service_network(&mut self, entry: &MergedEntry, path: &[String]) -> Option<ServiceNetwork> {
let value = entry.value();
let span = effective_span(value);
let mut network = ServiceNetwork::new(Located::new(entry.key().to_owned(), entry_span(entry)), span);
let fields = match value.kind() {
MergedValueKind::Null(_) => return Some(network),
MergedValueKind::Mapping(fields) => fields,
_ => {
self.expected(value, "service network attachment must be a mapping or null");
return None;
}
};
for field in fields {
match field.key() {
"aliases" => self
.located_string_sequence(field.value(), "network aliases must be a sequence")
.into_iter()
.for_each(|value| network.set_aliases(value)),
"interface_name" => self
.located_string(field.value(), "network interface_name must be a scalar")
.into_iter()
.for_each(|value| network.set_interface_name(value)),
"ipv4_address" => self
.located_string(field.value(), "network ipv4_address must be a scalar")
.into_iter()
.for_each(|value| network.set_ipv4_address(value)),
"ipv6_address" => self
.located_string(field.value(), "network ipv6_address must be a scalar")
.into_iter()
.for_each(|value| network.set_ipv6_address(value)),
"link_local_ips" => self
.located_string_sequence(field.value(), "link_local_ips must be a sequence")
.into_iter()
.for_each(|value| network.set_link_local_ips(value)),
"mac_address" => self
.located_string(field.value(), "network mac_address must be a scalar")
.into_iter()
.for_each(|value| network.set_mac_address(value)),
"driver_opts" => self
.key_value_mapping(field.value(), "network driver_opts must be a mapping")
.into_iter()
.for_each(|value| network.set_driver_opts(value)),
"gw_priority" => self
.located_string(field.value(), "network gw_priority must be a scalar")
.into_iter()
.for_each(|value| network.set_gw_priority(value)),
"priority" => self
.located_string(field.value(), "network priority must be a scalar")
.into_iter()
.for_each(|value| network.set_priority(value)),
_ => self.record_pending_unmodeled(path, field),
}
}
Some(network)
}
fn located_boolean(&mut self, value: &MergedValue, message: &str) -> Option<Located<BooleanValue>> {
let scalar = self.scalar(value, message)?;
let boolean = if scalar.kind() == MergedScalarKind::Boolean {
BooleanValue::Literal(scalar.value().eq_ignore_ascii_case("true"))
} else if scalar.value().contains('$') {
BooleanValue::Expression(scalar.value().to_owned())
} else {
self.invalid(effective_span(value), message);
return None;
};
Some(Located::new(boolean, effective_span(value)))
}
fn located_string_sequence(&mut self, value: &MergedValue, message: &str) -> Option<Vec<Located<String>>> {
let Some(values) = value.as_sequence() else {
self.expected(value, message);
return None;
};
let mut strings = Vec::new();
for value in values {
strings.push(self.located_string(value, "sequence item must be a scalar")?);
}
Some(strings)
}
fn key_value_mapping(&mut self, value: &MergedValue, message: &str) -> Option<Vec<KeyValueEntry>> {
let Some(entries) = value.as_mapping() else {
self.expected(value, message);
return None;
};
let mut values = Vec::new();
for entry in entries {
let scalar = self.compose_scalar(entry.value(), "mapping value must be a scalar or null")?;
let value_span = effective_span(entry.value());
values.push(KeyValueEntry::new(
Located::new(entry.key().to_owned(), entry_span(entry)),
Located::new(scalar, value_span),
value_span,
));
}
Some(values)
}
fn network_definitions(&mut self, value: &MergedValue) -> Vec<ProjectResource<NetworkDefinition>> {
let Some(entries) = self.mapping(value, "top-level networks must be a mapping") else {
return Vec::new();
};
entries
.iter()
.filter_map(|entry| {
let definition = self.network_definition(entry)?;
Some(ProjectResource {
name: ProjectKey::from_entry(entry),
definition: ProjectValue::new(definition, entry.value()),
})
})
.collect()
}
fn network_definition(&mut self, entry: &MergedEntry) -> Option<NetworkDefinition> {
let value = entry.value();
let span = effective_span(value);
let mut network = NetworkDefinition::new(Located::new(entry.key().to_owned(), entry_span(entry)), span);
let fields = match value.kind() {
MergedValueKind::Null(_) => return Some(network),
MergedValueKind::Mapping(fields) => fields,
_ => {
self.expected(value, "network definition must be a mapping or null");
return None;
}
};
let path = ["networks".to_owned(), entry.key().to_owned()];
for field in fields {
match field.key() {
"driver" => self
.located_string(field.value(), "network driver must be a scalar")
.into_iter()
.for_each(|value| network.set_driver(value)),
"driver_opts" => self
.key_value_mapping(field.value(), "network driver_opts must be a mapping")
.into_iter()
.for_each(|value| network.set_driver_opts(value)),
"attachable" => self
.located_boolean(field.value(), "network attachable must be a boolean")
.into_iter()
.for_each(|value| network.set_attachable(value)),
"enable_ipv4" => self
.located_boolean(field.value(), "network enable_ipv4 must be a boolean")
.into_iter()
.for_each(|value| network.set_enable_ipv4(value)),
"enable_ipv6" => self
.located_boolean(field.value(), "network enable_ipv6 must be a boolean")
.into_iter()
.for_each(|value| network.set_enable_ipv6(value)),
"external" => self
.located_boolean(field.value(), "network external must be a boolean")
.into_iter()
.for_each(|value| network.set_external(value)),
"internal" => self
.located_boolean(field.value(), "network internal must be a boolean")
.into_iter()
.for_each(|value| network.set_internal(value)),
"ipam" => self
.ipam(field.value(), &path)
.into_iter()
.for_each(|value| network.set_ipam(value)),
"labels" => self
.labels(field.value())
.into_iter()
.for_each(|value| network.set_labels(value)),
"name" => self
.located_string(field.value(), "network custom name must be a scalar")
.into_iter()
.for_each(|value| network.set_custom_name(value)),
_ => self.record_root_unmodeled(&path, field),
}
}
Some(network)
}
fn ipam(&mut self, value: &MergedValue, parent_path: &[String]) -> Option<Ipam> {
let fields = self.mapping(value, "network IPAM must be a mapping")?;
let mut ipam = Ipam::new(effective_span(value));
let mut path = parent_path.to_vec();
path.push("ipam".to_owned());
for field in fields {
match field.key() {
"driver" => self
.located_string(field.value(), "IPAM driver must be a scalar")
.into_iter()
.for_each(|value| ipam.set_driver(value)),
"config" => self
.ipam_configs(field.value(), &path)
.into_iter()
.for_each(|value| ipam.set_config(value)),
"options" => self
.key_value_mapping(field.value(), "IPAM options must be a mapping")
.into_iter()
.for_each(|value| ipam.set_options(value)),
_ => self.record_root_unmodeled(&path, field),
}
}
Some(ipam)
}
fn ipam_configs(&mut self, value: &MergedValue, parent_path: &[String]) -> Option<Vec<IpamConfig>> {
let Some(values) = value.as_sequence() else {
self.expected(value, "IPAM config must be a sequence");
return None;
};
let mut configs = Vec::new();
for (index, value) in values.iter().enumerate() {
let Some(fields) = value.as_mapping() else {
self.expected(value, "IPAM config entry must be a mapping");
continue;
};
let mut config = IpamConfig::new(effective_span(value));
let mut path = parent_path.to_vec();
path.push("config".to_owned());
path.push(index.to_string());
for field in fields {
match field.key() {
"subnet" => self
.located_string(field.value(), "IPAM subnet must be a scalar")
.into_iter()
.for_each(|value| config.set_subnet(value)),
"ip_range" => self
.located_string(field.value(), "IPAM ip_range must be a scalar")
.into_iter()
.for_each(|value| config.set_ip_range(value)),
"gateway" => self
.located_string(field.value(), "IPAM gateway must be a scalar")
.into_iter()
.for_each(|value| config.set_gateway(value)),
"aux_addresses" => self
.key_value_mapping(field.value(), "IPAM aux_addresses must be a mapping")
.into_iter()
.for_each(|value| config.set_aux_addresses(value)),
_ => self.record_root_unmodeled(&path, field),
}
}
configs.push(config);
}
Some(configs)
}
fn volume_definitions(&mut self, value: &MergedValue) -> Vec<ProjectResource<VolumeDefinition>> {
let Some(entries) = self.mapping(value, "top-level volumes must be a mapping") else {
return Vec::new();
};
entries
.iter()
.filter_map(|entry| {
let definition = self.volume_definition(entry)?;
Some(ProjectResource {
name: ProjectKey::from_entry(entry),
definition: ProjectValue::new(definition, entry.value()),
})
})
.collect()
}
fn volume_definition(&mut self, entry: &MergedEntry) -> Option<VolumeDefinition> {
let value = entry.value();
let span = effective_span(value);
let mut volume = VolumeDefinition::new(Located::new(entry.key().to_owned(), entry_span(entry)), span);
let fields = match value.kind() {
MergedValueKind::Null(_) => return Some(volume),
MergedValueKind::Mapping(fields) => fields,
_ => {
self.expected(value, "volume definition must be a mapping or null");
return None;
}
};
let path = ["volumes".to_owned(), entry.key().to_owned()];
for field in fields {
match field.key() {
"driver" => self
.located_string(field.value(), "volume driver must be a scalar")
.into_iter()
.for_each(|value| volume.set_driver(value)),
"driver_opts" => self
.key_value_mapping(field.value(), "volume driver_opts must be a mapping")
.into_iter()
.for_each(|value| volume.set_driver_opts(value)),
"external" => self
.located_boolean(field.value(), "volume external must be a boolean")
.into_iter()
.for_each(|value| volume.set_external(value)),
"labels" => self
.labels(field.value())
.into_iter()
.for_each(|value| volume.set_labels(value)),
"name" => self
.located_string(field.value(), "volume custom name must be a scalar")
.into_iter()
.for_each(|value| volume.set_custom_name(value)),
_ => self.record_root_unmodeled(&path, field),
}
}
Some(volume)
}
fn config_definitions(&mut self, value: &MergedValue) -> Vec<ProjectResource<ConfigDefinition>> {
let Some(entries) = self.mapping(value, "top-level configs must be a mapping") else {
return Vec::new();
};
entries
.iter()
.filter_map(|entry| {
let definition = self.config_definition(entry)?;
Some(ProjectResource {
name: ProjectKey::from_entry(entry),
definition: ProjectValue::new(definition, entry.value()),
})
})
.collect()
}
fn config_definition(&mut self, entry: &MergedEntry) -> Option<ConfigDefinition> {
let value = entry.value();
let span = effective_span(value);
let mut config = ConfigDefinition::new(Located::new(entry.key().to_owned(), entry_span(entry)), span);
let fields = match value.kind() {
MergedValueKind::Null(_) => return Some(config),
MergedValueKind::Mapping(fields) => fields,
_ => {
self.expected(value, "config definition must be a mapping or null");
return None;
}
};
let path = ["configs".to_owned(), entry.key().to_owned()];
for field in fields {
match field.key() {
"file" => self
.located_string(field.value(), "config file must be a scalar")
.into_iter()
.for_each(|value| config.set_file(value)),
"environment" => self
.located_string(field.value(), "config environment must be a scalar")
.into_iter()
.for_each(|value| config.set_environment(value)),
"content" => self
.located_string(field.value(), "config content must be a scalar")
.into_iter()
.for_each(|value| config.set_content(value)),
"external" => self
.located_boolean(field.value(), "config external must be a boolean")
.into_iter()
.for_each(|value| config.set_external(value)),
"name" => self
.located_string(field.value(), "config custom name must be a scalar")
.into_iter()
.for_each(|value| config.set_custom_name(value)),
_ => self.record_root_unmodeled(&path, field),
}
}
Some(config)
}
fn secret_definitions(&mut self, value: &MergedValue) -> Vec<ProjectResource<SecretDefinition>> {
let Some(entries) = self.mapping(value, "top-level secrets must be a mapping") else {
return Vec::new();
};
entries
.iter()
.filter_map(|entry| {
let definition = self.secret_definition(entry)?;
Some(ProjectResource {
name: ProjectKey::from_entry(entry),
definition: ProjectValue::new(definition, entry.value()),
})
})
.collect()
}
fn secret_definition(&mut self, entry: &MergedEntry) -> Option<SecretDefinition> {
let value = entry.value();
let span = effective_span(value);
let mut secret = SecretDefinition::new(Located::new(entry.key().to_owned(), entry_span(entry)), span);
let fields = match value.kind() {
MergedValueKind::Null(_) => return Some(secret),
MergedValueKind::Mapping(fields) => fields,
_ => {
self.expected(value, "secret definition must be a mapping or null");
return None;
}
};
let path = ["secrets".to_owned(), entry.key().to_owned()];
for field in fields {
match field.key() {
"file" => self
.located_string(field.value(), "secret file must be a scalar")
.into_iter()
.for_each(|value| secret.set_file(value)),
"environment" => self
.located_string(field.value(), "secret environment must be a scalar")
.into_iter()
.for_each(|value| secret.set_environment(value)),
"external" => self
.located_boolean(field.value(), "secret external must be a boolean")
.into_iter()
.for_each(|value| secret.set_external(value)),
"name" => self
.located_string(field.value(), "secret custom name must be a scalar")
.into_iter()
.for_each(|value| secret.set_custom_name(value)),
_ => self.record_root_unmodeled(&path, field),
}
}
Some(secret)
}
fn labels(&mut self, value: &MergedValue) -> Option<Labels> {
let span = effective_span(value);
match value.kind() {
MergedValueKind::Sequence(_) => self
.located_string_sequence(value, "labels must be a scalar sequence")
.map(|values| Labels::List { span, values }),
MergedValueKind::Mapping(_) => self
.key_value_mapping(value, "labels must be a scalar mapping")
.map(|entries| Labels::Map { span, entries }),
_ => {
self.expected(value, "labels must be a sequence or mapping");
None
}
}
}
}
fn field_reference(path: &[String], entry: &MergedEntry) -> ProjectFieldReference {
let mut complete_path = path.to_vec();
complete_path.push(entry.key().to_owned());
ProjectFieldReference {
path: complete_path,
key: ProjectKey::from_entry(entry),
provenance: entry.value().provenance().clone(),
extension: entry.key().starts_with("x-"),
sensitive: entry.value().is_sensitive(),
}
}
fn effective_span(value: &MergedValue) -> SourceSpan {
value
.provenance()
.effective_source()
.or_else(|| value.provenance().sources().first().copied())
.unwrap_or_else(|| SourceSpan::from_valid_offsets(SourceId::new(0), 0, 0))
}
fn entry_span(entry: &MergedEntry) -> SourceSpan {
entry
.key_sources()
.last()
.copied()
.or_else(|| entry.key_sources().first().copied())
.unwrap_or_else(|| effective_span(entry.value()))
}