Config-Abstraction

 view release on metacpan or  search on metacpan

lib/Config/Abstraction.pm  view on Meta::CPAN

	}

	# config_checker_source() returns Perl source text (a sub { ... } literal)
	# that must be eval'd so it can import into our namespace.
	my $checker = eval Config::Checker::config_checker_source();	## no critic (ProhibitStringyEval)
	Carp::croak(ref($self) . ": failed to compile Config::Checker: $@") if $@;

	eval { $checker->($self->{'config'}, $prototype) };
	Carp::croak(ref($self) . ": checker validation failed: $@") if $@;
}

# Determine if a value is a plain, unblessed, non-reference scalar
# safe to use in regex/string operations.
# Args:   value to test
# Returns: 1 if plain scalar, 0 otherwise
sub _is_plain_scalar
{
	my $val = $_[0];

	return 0 if !defined($val);
	return 0 if Scalar::Util::blessed($val);
	return 0 if ref($val);
	return 1;
}

# Recursively flatten a nested hashref to dotted keys (always uses '.' as separator).
# Skips the meta-key 'config_path'. Used by explain_sources() and source tracking.
# $seen guards against circular references (e.g. those possible when
# Hash::Merge::set_clone_behavior(0) is active).
#
# _flatten_into writes into a caller-supplied hashref accumulator so that
# each leaf key is written exactly once -- O(N) total writes vs the previous
# O(N^2) pattern of %flat = (%flat, _flatten_keys(...)) at each recursion level.
sub _flatten_into
{
	my ($acc, $hash, $prefix, $seen) = @_;
	return unless ref($hash) eq 'HASH';
	my $addr = Scalar::Util::refaddr($hash);
	return if $seen->{$addr}++;
	for my $k (keys %$hash) {
		next if $k eq 'config_path';
		my $full = length($prefix) ? "$prefix.$k" : $k;
		if(ref($hash->{$k}) eq 'HASH') {
			_flatten_into($acc, $hash->{$k}, $full, $seen);
		} else {
			$acc->{$full} = $hash->{$k};
		}
	}
}

sub _flatten_keys
{
	my ($hash, $prefix, $seen) = @_;
	my %flat;
	_flatten_into(\%flat, $hash, $prefix // '', $seen // {});
	return %flat;
}

sub _load_config
{
	if(!UNIVERSAL::isa((caller)[0], __PACKAGE__)) {
		Carp::croak('Illegal Operation: This method can only be called by a subclass');
	}

	my $self = shift;

	# Disable Hash::Merge cloning for the duration of this method.
	# Storable::dclone (used when clone=1) cannot handle coderefs or blessed
	# objects that may appear in the 'data' argument; sharing references is
	# safe here because %merged is a fresh, method-local accumulator.
	my $saved_clone = Hash::Merge::get_clone_behavior();
	Hash::Merge::set_clone_behavior(0);

	my %merged;

	if($self->{'data'}) {
		# The data argument given to 'new' contains defaults that this routine will override
		if(ref($self->{'data'}) eq 'HASH') {
			%merged = %{$self->{'data'}};
			push @{$self->{'_source_records'}}, {
				type      => 'data',
				label     => 'constructor data argument',
				flat_data => { _flatten_keys($self->{'data'}) },
			};
		} else {
			Carp::carp(ref($self) . ': data argument must be a hashref; ignoring non-hashref value');
		}
	}

	my $logger = $self->{'logger'};
	if($logger) {
		$logger->trace(ref($self), ' ', __LINE__, ': Entered _load_config');
	}

	my $environment = $self->_get_environment();
	my @_formats    = qw(yaml yml json xml ini toml);
	my @_file_list  = (
		(map { "base.$_"              } @_formats),
		($environment ? (map { "base.$environment.$_"  } @_formats) : ()),
		(map { "local.$_"             } @_formats),
		($environment ? (map { "local.$environment.$_" } @_formats) : ()),
	);

	my @dirs = @{$self->{'config_dirs'}};
	if($self->{'config_file'} && (scalar(@dirs) > 1)) {
		if(File::Spec->file_name_is_absolute($self->{'config_file'})) {
			# Handle absolute paths
			@dirs = ('');
		} else {
			# Look in the current directory
			push @dirs, File::Spec->curdir();
		}
	}
	for my $dir (@dirs) {
		next if(!defined($dir));

		# Newcastle Connection: /../hostname/path means read from a remote host.
		# /../ is unreachable on any real filesystem, so this is unambiguous.
		# When the hostname resolves to the local machine (localhost, 127.0.0.1,
		# ::1, or the system hostname) the /../host/ wrapper is unwrapped and the
		# enclosed path is processed through the normal local pipeline instead.

lib/Config/Abstraction.pm  view on Meta::CPAN


# ---------------------------------------------------------------------------
# _parse_remote_dir -- detect Newcastle Connection style paths.
#
# Returns ($host, $dir) when $dir begins with /../, empty list otherwise.
# The /../ prefix is syntactically impossible for a real local path so no
# real directory entry is ever misidentified.
# ---------------------------------------------------------------------------
sub _parse_remote_dir
{
	my ($self, $dir) = @_;

	return unless defined($dir);
	return unless $dir =~ m{^\Q/../\E([^/]+)(/.+)?$};
	return ($1, $2 // '/');
}

# ---------------------------------------------------------------------------
# _is_local_host -- true when $host refers to the machine running this code.
#
# Strips any user@ prefix first, then checks the four common ways a caller
# might spell "here": the loopback name, the two loopback addresses, and the
# system hostname (both fully-qualified and short).  Comparison is
# case-insensitive because hostnames are case-insensitive by RFC 1034.
# ---------------------------------------------------------------------------
sub _is_local_host
{
	my ($self, $host) = @_;

	return 0 unless defined($host) && length($host);

	(my $bare = $host) =~ s/^[^@]+@//;	# strip optional user@ prefix

	return 1 if lc($bare) eq 'localhost';
	return 1 if $bare eq '127.0.0.1';
	return 1 if $bare eq '::1';

	# Cache the resolved hostname on the object so Sys::Hostname::hostname()
	# (a syscall) is only made once per Config::Abstraction instance.
	unless(defined $self->{'_cached_hostname'}) {
		require Sys::Hostname;
		$self->{'_cached_hostname'} = lc(Sys::Hostname::hostname());
		($self->{'_cached_short_hostname'} = $self->{'_cached_hostname'}) =~ s/\..*$//;
	}
	return 1 if lc($bare) eq $self->{'_cached_hostname'};
	return 1 if lc($bare) eq $self->{'_cached_short_hostname'};

	return 0;
}

# ---------------------------------------------------------------------------
# _load_remote_dir -- fetch and merge standard config files from one remote
# host/directory pair.
#
# Silently skips files that do not exist; warns (or logs) on parse errors.
# Merges into %$merged_ref using the same Hash::Merge LEFT_PRECEDENT strategy
# as the local pipeline, so remote values override earlier local values.
# ---------------------------------------------------------------------------
sub _load_remote_dir
{
	if(!UNIVERSAL::isa((caller)[0], __PACKAGE__)) {
		Carp::croak('Illegal Operation: This method can only be called by a subclass');
	}

	my ($self, $host, $remote_dir, $merged_ref, $file_list) = @_;
	my $logger = $self->{'logger'};

	unless($self->_load_driver('File::Slurp::Remote')) {
		my $msg = ref($self) . ": File::Slurp::Remote required for /../$host$remote_dir but is not installed";
		$logger ? $logger->warn($msg) : Carp::carp($msg);
		return;
	}

	# Use the same file list as the local pipeline (includes TOML and env-specific tiers).
	# Fall back to the legacy list if none was passed (e.g. from very old callers).
	my @files = $file_list ? @{$file_list}
		: qw(base.yaml base.yml base.json base.xml base.ini base.toml
		     local.yaml local.yml local.json local.xml local.ini local.toml);

	for my $file (@files) {
		my $remote_path = "/../$host$remote_dir/$file";

		if($logger) {
			$logger->debug(ref($self), ' ', __LINE__, ": Looking for remote config $remote_path");
		}

		my $raw = $self->_slurp_remote($host, "$remote_dir/$file");
		next unless defined($raw);

		if($logger) {
			$logger->debug(ref($self), ' ', __LINE__, ": Loading remote config $remote_path");
		}

		my $data = $self->_parse_config_string($raw, $file, $remote_path);
		next unless defined($data);

		if(ref($data) ne 'HASH') {
			my $msg = ref($self) . ": remote $remote_path did not yield a hash; skipping";
			$logger ? $logger->warn($msg) : Carp::carp($msg);
			next;
		}

		push @{$self->{'_source_records'}}, {
			type      => 'file',
			label     => $remote_path,
			flat_data => { _flatten_keys($data) },
		};
		%{$merged_ref} = %{ merge($data, $merged_ref) };
		push @{$merged_ref->{'config_path'}}, $remote_path;
	}
}

# ---------------------------------------------------------------------------
# _slurp_remote -- read a single file from a remote host via SSH.
#
# Wraps File::Slurp::Remote::read_file; returns the raw string on success
# or undef on any error (connection refused, file absent, permission denied).
# Errors are logged at debug level so missing files are not noisy.
# ---------------------------------------------------------------------------
sub _slurp_remote
{
	my ($self, $host, $path) = @_;
	my $logger = $self->{'logger'};

	# NOTE: verify the calling convention of your installed File::Slurp::Remote.
	# Common forms: read_file("$host:$path") or read_file($host, $path).
	my $content = eval { File::Slurp::Remote::read_file($host, $path) };

	if($@) {
		if($logger) {
			$logger->debug(ref($self), ' ', __LINE__, ": Could not read $path from $host: $@");
		}
		return undef;
	}
	return $content;
}

# ---------------------------------------------------------------------------
# _parse_config_string -- parse a raw config string by extension.
#
# Mirrors the format-detection logic used for local files.  INI content is
# written to a File::Temp scratch file because Config::IniFiles requires a
# real filesystem path; the temp file is removed as soon as parsing returns.
#
# Returns a hashref on success, undef on failure.
# ---------------------------------------------------------------------------
sub _parse_config_string
{
	if(!UNIVERSAL::isa((caller)[0], __PACKAGE__)) {
		Carp::croak('Illegal Operation: This method can only be called by a subclass');
	}

	my ($self, $raw, $filename, $label) = @_;
	my $logger = $self->{'logger'};
	my $data;

	eval {
		if($filename =~ /\.ya?ml$/i) {
			$self->_load_driver('YAML::XS', ['Load']);
			$data = YAML::XS::Load($raw);
			$data = $self->_sanitize_yaml_values($data) if defined($data) && ref($data);

		} elsif($filename =~ /\.json$/i) {
			$data = decode_json($raw);

		} elsif($filename =~ /\.xml$/i) {
			if($self->_load_driver('XML::Simple', ['XMLin'])) {
				if($raw !~ /<!ENTITY\s+\w+\s+(?:SYSTEM|PUBLIC)\b/i) {
					$data = XMLin(\$raw, ForceArray => 0, KeyAttr => []);
				}
			} elsif($self->_load_driver('XML::PP')) {
				my $pp = XML::PP->new();
				if(my $tree = $pp->parse(\$raw)) {
					$data = $pp->collapse_structure($tree);
					$data = $data->{'config'} if ($data && $data->{'config'});
				}
			}

		} elsif($filename =~ /\.ini$/i) {
			$self->_load_driver('Config::IniFiles');
			require File::Temp;
			my $tmp = File::Temp->new(SUFFIX => '.ini', UNLINK => 1);
			print {$tmp} $raw;
			$tmp->flush();
			if(my $ini = Config::IniFiles->new(-file => $tmp->filename())) {
				$data = { map {
					my $section = $_;
					$section => { map { $_ => $ini->val($section, $_) } $ini->Parameters($section) }
				} $ini->Sections() };
			}
		}
	};

	if($@) {
		my $err = $@;
		my $msg = ref($self) . ": Failed to parse $label: $err";
		$logger ? $logger->warn($msg) : Carp::carp($msg);
		return undef;
	}

	return $data;
}

sub AUTOLOAD
{
	our $AUTOLOAD;

	my $self = shift;
	my $key = $AUTOLOAD;



( run in 1.761 second using v1.01-cache-2.11-cpan-aadc1410aed )