####################################################################################################################################
# DOC EXECUTE MODULE
####################################################################################################################################
package pgBackRestDoc::Common::DocExecute;
use parent 'pgBackRestDoc::Common::DocRender';

use strict;
use warnings FATAL => qw(all);
use Carp qw(confess);
use English '-no_match_vars';

use Cwd qw(abs_path);
use Exporter qw(import);
    our @EXPORT = qw();
use File::Basename qw(dirname);
use Storable qw(dclone);

use pgBackRestTest::Common::ExecuteTest;

use pgBackRestDoc::Common::DocManifest;
use pgBackRestDoc::Common::Exception;
use pgBackRestDoc::Common::Host;
use pgBackRestDoc::Common::HostGroup;
use pgBackRestDoc::Common::Ini;
use pgBackRestDoc::Common::Log;
use pgBackRestDoc::Common::String;
use pgBackRestDoc::ProjectInfo;

####################################################################################################################################
# User that's building the docs
####################################################################################################################################
use constant DOC_USER                                              => getpwuid($UID) eq 'root' ? 'ubuntu' : getpwuid($UID) . '';

####################################################################################################################################
# Option constants
####################################################################################################################################
use constant CFGDEF_SECTION_GLOBAL                                  => 'global';
use constant CFGOPT_LOG_LEVEL_STDERR                                => 'log-level-stderr';
use constant CFGOPT_LOG_TIMESTAMP                                   => 'log-timestamp';

####################################################################################################################################
# CONSTRUCTOR
####################################################################################################################################
sub new
{
    my $class = shift;       # Class name

    # Assign function parameters, defaults, and log debug info
    my
    (
        $strOperation,
        $strType,
        $oManifest,
        $strRenderOutKey,
        $bExe
    ) =
        logDebugParam
        (
            __PACKAGE__ . '->new', \@_,
            {name => 'strType'},
            {name => 'oManifest'},
            {name => 'strRenderOutKey'},
            {name => 'bExe'}
        );

    # Create the class hash
    my $self = $class->SUPER::new($strType, $oManifest, $bExe, $strRenderOutKey);
    bless $self, $class;

    if (defined($self->{oSource}{hyCache}))
    {
        $self->{bCache} = true;
        $self->{iCacheIdx} = 0;
    }
    else
    {
        $self->{bCache} = false;
    }

    $self->{bExe} = $bExe;

    $self->{iCmdLineLen} = $self->{oDoc}->paramGet('cmd-line-len', false, 80);

    # Return from function and log return values if any
    return logDebugReturn
    (
        $strOperation,
        {name => 'self', value => $self}
    );
}

####################################################################################################################################
# executeKey
#
# Get a unique key for the execution step to determine if the cache is valid.
####################################################################################################################################
sub executeKey
{
    my $self = shift;

    # Assign function parameters, defaults, and log debug info
    my
    (
        $strOperation,
        $strHostName,
        $oCommand,
    ) =
        logDebugParam
        (
            __PACKAGE__ . '->executeKey', \@_,
            {name => 'strHostName', trace => true},
            {name => 'oCommand', trace => true},
        );

    # Add user to command
    my $bUserForce = $oCommand->paramTest('user-force', 'y') ? true : false;
    my $strCommand = $self->{oManifest}->variableReplace(trim($oCommand->fieldGet('exe-cmd')));
    my $strUser = $self->{oManifest}->variableReplace($oCommand->paramGet('user', false, DOC_USER));
    $strCommand = ($strUser eq DOC_USER || $bUserForce ? '' : ('sudo ' . ($strUser eq 'root' ? '' : "-u $strUser "))) . $strCommand;

    # Format and split command
    $strCommand =~ s/[ ]*\n[ ]*/ \\\n    /smg;
    $strCommand =~ s/ \\\@ \\//smg;
    my @stryCommand = split("\n", $strCommand);

    my $hCacheKey =
    {
        host => $strHostName,
        cmd => \@stryCommand,
        output => JSON::PP::false,
    };

    $$hCacheKey{'run-as-user'} = $bUserForce ? $strUser : undef;

    if (defined($oCommand->fieldGet('exe-cmd-extra', false)))
    {
        $$hCacheKey{'cmd-extra'} = $self->{oManifest}->variableReplace($oCommand->fieldGet('exe-cmd-extra'));
    }

    if (defined($oCommand->paramGet('err-expect', false)))
    {
        $$hCacheKey{'err-expect'} = $oCommand->paramGet('err-expect');
    }

    if ($oCommand->paramTest('output', 'y') || $oCommand->paramTest('show', 'y') || $oCommand->paramTest('variable-key'))
    {
        $$hCacheKey{'output'} = JSON::PP::true;
    }

    $$hCacheKey{'load-env'} = $oCommand->paramTest('load-env', 'n') ? JSON::PP::false : JSON::PP::true;
    $$hCacheKey{'bash-wrap'} = $oCommand->paramTest('bash-wrap', 'n') ? JSON::PP::false : JSON::PP::true;

    if (defined($oCommand->fieldGet('exe-highlight', false)))
    {
        $$hCacheKey{'output'} = JSON::PP::true;
        $$hCacheKey{highlight}{'filter'} = $oCommand->paramTest('filter', 'n') ? JSON::PP::false : JSON::PP::true;
        $$hCacheKey{highlight}{'filter-context'} = $oCommand->paramGet('filter-context', false, 2);

        my @stryHighlight;
        $stryHighlight[0] = $self->{oManifest}->variableReplace($oCommand->fieldGet('exe-highlight'));

        $$hCacheKey{highlight}{list} = \@stryHighlight;
    }

    # Return from function and log return values if any
    return logDebugReturn
    (
        $strOperation,
        {name => 'hExecuteKey', value => $hCacheKey, trace => true}
    );
}

####################################################################################################################################
# execute
####################################################################################################################################
sub execute
{
    my $self = shift;

    # Assign function parameters, defaults, and log debug info
    my
    (
        $strOperation,
        $oSection,
        $strHostName,
        $oCommand,
        $iIndent,
        $bCache,
        $bShow,
    ) =
        logDebugParam
        (
            __PACKAGE__ . '->execute', \@_,
            {name => 'oSection'},
            {name => 'strHostName'},
            {name => 'oCommand'},
            {name => 'iIndent', optional => true, default => 1},
            {name => 'bCache', optional => true, default => true},
            {name => 'bShow', optional => true, default => true},
        );

    # Working variables
    my $hCacheKey = $self->executeKey($strHostName, $oCommand);
    my $strCommand = join("\n", @{$$hCacheKey{cmd}});
    my $strOutput;

    if ($bShow && $self->{bExe} && $self->isRequired($oSection))
    {
        # Make sure that no lines are greater than 80 chars
        foreach my $strLine (split("\n", $strCommand))
        {
            if (length(trim($strLine)) > $self->{iCmdLineLen})
            {
                confess &log(ERROR,
                    "command has a line > $self->{iCmdLineLen} characters:\n${strCommand}\noffending line: ${strLine}");
            }
        }
    }

    &log(DEBUG, ('    ' x $iIndent) . "execute: $strCommand");

    if ($self->{oManifest}->variableReplace($oCommand->paramGet('skip', false, 'n')) ne 'y')
    {
        if ($self->{bExe} && $self->isRequired($oSection))
        {
            my ($bCacheHit, $strCacheType, $hCacheKey, $hCacheValue) = $self->cachePop('exe', $hCacheKey);

            if ($bCacheHit)
            {
                $strOutput = defined($$hCacheValue{output}) ? join("\n", @{$$hCacheValue{output}}) : undef;
            }
            else
            {
                # Check that the host is valid
                my $oHost = $self->{host}{$strHostName};

                if (!defined($oHost))
                {
                    confess &log(ERROR, "cannot execute on host ${strHostName} because the host does not exist");
                }

                my $oExec = $oHost->execute(
                    $strCommand . (defined($$hCacheKey{'cmd-extra'}) ? ' ' . $$hCacheKey{'cmd-extra'} : ''),
                    {iExpectedExitStatus => $$hCacheKey{'err-expect'},
                     bSuppressError => $oCommand->paramTest('err-suppress', 'y'),
                     iRetrySeconds => $oCommand->paramGet('retry', false)}, $hCacheKey->{'run-as-user'},
                     {bLoadEnv => $hCacheKey->{'load-env'}, bBashWrap => $hCacheKey->{'bash-wrap'}});
                $oExec->begin();
                $oExec->end();

                if (defined($oExec->{strOutLog}) && $oExec->{strOutLog} ne '')
                {
                    $strOutput = $oExec->{strOutLog};

                    # Trim off extra linefeeds before and after
                    $strOutput =~ s/^\n+|\n$//g;
                }

                if (defined($$hCacheKey{'err-expect'}) && defined($oExec->{strErrorLog}) && $oExec->{strErrorLog} ne '')
                {
                    $strOutput .= $oExec->{strErrorLog};
                }

                if ($$hCacheKey{output} && defined($$hCacheKey{highlight}) && $$hCacheKey{highlight}{filter} && defined($strOutput))
                {
                    my $strHighLight = @{$$hCacheKey{highlight}{list}}[0];

                    if (!defined($strHighLight))
                    {
                        confess &log(ERROR, 'filter requires highlight definition: ' . $strCommand);
                    }

                    my $iFilterContext = $$hCacheKey{highlight}{'filter-context'};

                    my @stryOutput = split("\n", $strOutput);
                    undef($strOutput);
                    # my $iFiltered = 0;
                    my $iLastOutput = -1;

                    for (my $iIndex = 0; $iIndex < @stryOutput; $iIndex++)
                    {
                        if ($stryOutput[$iIndex] =~ /$strHighLight/)
                        {
                            # Determine the first line to output
                            my $iFilterFirst = $iIndex - $iFilterContext;

                            # Don't go past the beginning
                            $iFilterFirst = $iFilterFirst < 0 ? 0 : $iFilterFirst;

                            # Don't repeat lines that have already been output
                            $iFilterFirst = $iFilterFirst <= $iLastOutput ? $iLastOutput + 1 : $iFilterFirst;

                            # Determine the last line to output
                            my $iFilterLast = $iIndex + $iFilterContext;

                            # Don't got past the end
                            $iFilterLast = $iFilterLast >= @stryOutput ? @stryOutput -1 : $iFilterLast;

                            # Mark filtered lines if any
                            if ($iFilterFirst > $iLastOutput + 1)
                            {
                                my $iFiltered = $iFilterFirst - ($iLastOutput + 1);

                                if ($iFiltered > 1)
                                {
                                    $strOutput .= (defined($strOutput) ? "\n" : '') .
                                                  "       [filtered ${iFiltered} lines of output]";
                                }
                                else
                                {
                                    $iFilterFirst -= 1;
                                }
                            }

                            # Output the lines
                            for (my $iOutputIndex = $iFilterFirst; $iOutputIndex <= $iFilterLast; $iOutputIndex++)
                            {
                                    $strOutput .= (defined($strOutput) ? "\n" : '') . $stryOutput[$iOutputIndex];
                            }

                            $iLastOutput = $iFilterLast;
                        }
                    }

                    if (@stryOutput - 1 > $iLastOutput + 1)
                    {
                        my $iFiltered = (@stryOutput - 1) - ($iLastOutput + 1);

                        if ($iFiltered > 1)
                        {
                            $strOutput .= (defined($strOutput) ? "\n" : '') .
                                          "       [filtered ${iFiltered} lines of output]";
                        }
                        else
                        {
                            $strOutput .= (defined($strOutput) ? "\n" : '') . $stryOutput[-1];
                        }
                    }
                }

                if (!$$hCacheKey{output})
                {
                    $strOutput = undef;
                }

                if (defined($strOutput))
                {
                    my @stryOutput = split("\n", $strOutput);
                    $$hCacheValue{output} = \@stryOutput;
                }

                if ($bCache)
                {
                    $self->cachePush($strCacheType, $hCacheKey, $hCacheValue);
                }
            }

            # Output is assigned to a var
            if ($oCommand->paramTest('variable-key'))
            {
                $self->{oManifest}->variableSet($oCommand->paramGet('variable-key'), trim($strOutput), true);
            }
        }
        elsif ($$hCacheKey{output})
        {
            $strOutput = 'Output suppressed for testing';
        }
    }

    # Default variable output when it was not set by execution
    if ($oCommand->paramTest('variable-key') && !defined($self->{oManifest}->variableGet($oCommand->paramGet('variable-key'))))
    {
        $self->{oManifest}->variableSet($oCommand->paramGet('variable-key'), '[Test Variable]', true);
    }

    # Return from function and log return values if any
    return logDebugReturn
    (
        $strOperation,
        {name => 'strCommand', value => $strCommand, trace => true},
        {name => 'strOutput', value => $strOutput, trace => true}
    );
}


####################################################################################################################################
# configKey
####################################################################################################################################
sub configKey
{
    my $self = shift;

    # Assign function parameters, defaults, and log debug info
    my
    (
        $strOperation,
        $oConfig,
    ) =
        logDebugParam
        (
            __PACKAGE__ . '->hostKey', \@_,
            {name => 'oConfig', trace => true},
        );

    my $hCacheKey =
    {
        host => $self->{oManifest}->variableReplace($oConfig->paramGet('host')),
        file => $self->{oManifest}->variableReplace($oConfig->paramGet('file')),
    };

    if ($oConfig->paramTest('reset', 'y'))
    {
        $$hCacheKey{reset} = JSON::PP::true;
    }

    # Add all options to the key
    my $strOptionTag = $oConfig->nameGet() eq 'backrest-config' ? 'backrest-config-option' : 'postgres-config-option';

    foreach my $oOption ($oConfig->nodeList($strOptionTag))
    {
        my $hOption = {};

        if ($oOption->paramTest('remove', 'y'))
        {
            $$hOption{remove} = JSON::PP::true;
        }

        if (defined($oOption->valueGet(false)))
        {
            $$hOption{value} = $self->{oManifest}->variableReplace($oOption->valueGet());
        }

        my $strKey = $self->{oManifest}->variableReplace($oOption->paramGet('key'));

        if ($oConfig->nameGet() eq 'backrest-config')
        {
            my $strSection = $self->{oManifest}->variableReplace($oOption->paramGet('section'));

            $$hCacheKey{option}{$strSection}{$strKey} = $hOption;
        }
        else
        {
            $$hCacheKey{option}{$strKey} = $hOption;
        }
    }

    # Return from function and log return values if any
    return logDebugReturn
    (
        $strOperation,
        {name => 'hCacheKey', value => $hCacheKey, trace => true}
    );
}

####################################################################################################################################
# backrestConfig
####################################################################################################################################
sub backrestConfig
{
    my $self = shift;

    # Assign function parameters, defaults, and log debug info
    my
    (
        $strOperation,
        $oSection,
        $oConfig,
        $iDepth
    ) =
        logDebugParam
        (
            __PACKAGE__ . '->backrestConfig', \@_,
            {name => 'oSection'},
            {name => 'oConfig'},
            {name => 'iDepth'}
        );

    # Working variables
    my $hCacheKey = $self->configKey($oConfig);
    my $strFile = $$hCacheKey{file};
    my $strConfig = undef;

    &log(DEBUG, ('    ' x $iDepth) . 'process backrest config: ' . $$hCacheKey{file});

    if ($self->{bExe} && $self->isRequired($oSection))
    {
        my ($bCacheHit, $strCacheType, $hCacheKey, $hCacheValue) = $self->cachePop('cfg-' . PROJECT_EXE, $hCacheKey);

        if ($bCacheHit)
        {
            $strConfig = defined($$hCacheValue{config}) ? join("\n", @{$$hCacheValue{config}}) : undef;
        }
        else
        {
            # Check that the host is valid
            my $strHostName = $self->{oManifest}->variableReplace($oConfig->paramGet('host'));
            my $oHost = $self->{host}{$strHostName};

            if (!defined($oHost))
            {
                confess &log(ERROR, "cannot configure backrest on host ${strHostName} because the host does not exist");
            }

            # Reset all options
            if ($oConfig->paramTest('reset', 'y'))
            {
                delete(${$self->{config}}{$strHostName}{$$hCacheKey{file}})
            }

            foreach my $oOption ($oConfig->nodeList('backrest-config-option'))
            {
                my $strSection = $self->{oManifest}->variableReplace($oOption->paramGet('section'));
                my $strKey = $self->{oManifest}->variableReplace($oOption->paramGet('key'));
                my $strValue;

                if (!$oOption->paramTest('remove', 'y'))
                {
                    $strValue = $self->{oManifest}->variableReplace(trim($oOption->valueGet(false)));
                }

                if (!defined($strValue))
                {
                    delete(${$self->{config}}{$strHostName}{$$hCacheKey{file}}{$strSection}{$strKey});

                    if (keys(%{${$self->{config}}{$strHostName}{$$hCacheKey{file}}{$strSection}}) == 0)
                    {
                        delete(${$self->{config}}{$strHostName}{$$hCacheKey{file}}{$strSection});
                    }

                    &log(DEBUG, ('    ' x ($iDepth + 1)) . "reset ${strSection}->${strKey}");
                }
                else
                {
                    # If this option is a hash and the value is already set then append to the array
                    if ($oOption->paramTest('multi', 'y') &&
                        defined(${$self->{config}}{$strHostName}{$$hCacheKey{file}}{$strSection}{$strKey}))
                    {
                        my @oValue = ();
                        my $strHashValue = ${$self->{config}}{$strHostName}{$$hCacheKey{file}}{$strSection}{$strKey};

                        # If there is only one key/value
                        if (ref(\$strHashValue) eq 'SCALAR')
                        {
                            push(@oValue, $strHashValue);
                        }
                        # Else if there is an array of values
                        else
                        {
                            @oValue = @{$strHashValue};
                        }

                        push(@oValue, $strValue);
                        ${$self->{config}}{$strHostName}{$$hCacheKey{file}}{$strSection}{$strKey} = \@oValue;
                    }
                    # else just set the value
                    else
                    {
                        ${$self->{config}}{$strHostName}{$$hCacheKey{file}}{$strSection}{$strKey} = $strValue;
                    }

                    &log(DEBUG, ('    ' x ($iDepth + 1)) . "set ${strSection}->${strKey} = ${strValue}");
                }
            }

            my $strLocalFile = abs_path(dirname($0)) . '/output/pgbackrest.conf';

            # Save the ini file
            $self->{oManifest}->storage()->put($strLocalFile, iniRender($self->{config}{$strHostName}{$$hCacheKey{file}}, true));

            $oHost->copyTo(
                $strLocalFile, $$hCacheKey{file},
                $self->{oManifest}->variableReplace($oConfig->paramGet('owner', false, 'postgres:postgres')), '640');

            # Remove the log-console-stderr option before pushing into the cache
            # ??? This is not very pretty and should be replaced with a general way to hide config options
            my $oConfigClean = dclone($self->{config}{$strHostName}{$$hCacheKey{file}});
            delete($$oConfigClean{&CFGDEF_SECTION_GLOBAL}{&CFGOPT_LOG_LEVEL_STDERR});
            delete($$oConfigClean{&CFGDEF_SECTION_GLOBAL}{&CFGOPT_LOG_TIMESTAMP});

            if (keys(%{$$oConfigClean{&CFGDEF_SECTION_GLOBAL}}) == 0)
            {
                delete($$oConfigClean{&CFGDEF_SECTION_GLOBAL});
            }

            $self->{oManifest}->storage()->put("${strLocalFile}.clean", iniRender($oConfigClean, true));

            # Push config file into the cache
            $strConfig = ${$self->{oManifest}->storage()->get("${strLocalFile}.clean")};

            my @stryConfig = undef;

            if (trim($strConfig) ne '')
            {
                @stryConfig = split("\n", $strConfig);
            }

            $$hCacheValue{config} = \@stryConfig;
            $self->cachePush($strCacheType, $hCacheKey, $hCacheValue);
        }
    }
    else
    {
        $strConfig = 'Config suppressed for testing';
    }

    # Return from function and log return values if any
    return logDebugReturn
    (
        $strOperation,
        {name => 'strFile', value => $strFile, trace => true},
        {name => 'strConfig', value => $strConfig, trace => true},
        {name => 'bShow', value => $oConfig->paramTest('show', 'n') ? false : true, trace => true}
    );
}

####################################################################################################################################
# postgresConfig
####################################################################################################################################
sub postgresConfig
{
    my $self = shift;

    # Assign function parameters, defaults, and log debug info
    my
    (
        $strOperation,
        $oSection,
        $oConfig,
        $iDepth
    ) =
        logDebugParam
        (
            __PACKAGE__ . '->postgresConfig', \@_,
            {name => 'oSection'},
            {name => 'oConfig'},
            {name => 'iDepth'}
        );

    # Working variables
    my $hCacheKey = $self->configKey($oConfig);
    my $strFile = $$hCacheKey{file};
    my $strConfig;

    if ($self->{bExe} && $self->isRequired($oSection))
    {
        my ($bCacheHit, $strCacheType, $hCacheKey, $hCacheValue) = $self->cachePop('cfg-postgresql', $hCacheKey);

        if ($bCacheHit)
        {
            $strConfig = defined($$hCacheValue{config}) ? join("\n", @{$$hCacheValue{config}}) : undef;
        }
        else
        {
            # Check that the host is valid
            my $strHostName = $self->{oManifest}->variableReplace($oConfig->paramGet('host'));
            my $oHost = $self->{host}{$strHostName};

            if (!defined($oHost))
            {
                confess &log(ERROR, "cannot configure postgres on host ${strHostName} because the host does not exist");
            }

            my $strLocalFile = abs_path(dirname($0)) . '/output/postgresql.conf';
            $oHost->copyFrom($$hCacheKey{file}, $strLocalFile);

            if (!defined(${$self->{'pg-config'}}{$strHostName}{$$hCacheKey{file}}{base}) && $self->{bExe})
            {
                ${$self->{'pg-config'}}{$strHostName}{$$hCacheKey{file}}{base} =
                    ${$self->{oManifest}->storage()->get($strLocalFile)};
            }

            my $oConfigHash = $self->{'pg-config'}{$strHostName}{$$hCacheKey{file}};
            my $oConfigHashNew;

            if (!defined($$oConfigHash{old}))
            {
                $oConfigHashNew = {};
                $$oConfigHash{old} = {}
            }
            else
            {
                $oConfigHashNew = dclone($$oConfigHash{old});
            }

            &log(DEBUG, ('    ' x $iDepth) . 'process postgres config: ' . $$hCacheKey{file});

            foreach my $oOption ($oConfig->nodeList('postgres-config-option'))
            {
                my $strKey = $oOption->paramGet('key');
                my $strValue = $self->{oManifest}->variableReplace(trim($oOption->valueGet()));

                if ($strValue eq '')
                {
                    delete($$oConfigHashNew{$strKey});

                    &log(DEBUG, ('    ' x ($iDepth + 1)) . "reset ${strKey}");
                }
                else
                {
                    $$oConfigHashNew{$strKey} = $strValue;
                    &log(DEBUG, ('    ' x ($iDepth + 1)) . "set ${strKey} = ${strValue}");
                }
            }

            # Generate config text
            foreach my $strKey (sort(keys(%$oConfigHashNew)))
            {
                if (defined($strConfig))
                {
                    $strConfig .= "\n";
                }

                $strConfig .= "${strKey} = $$oConfigHashNew{$strKey}";
            }

            # Save the conf file
            if ($self->{bExe})
            {
                $self->{oManifest}->storage()->put($strLocalFile, $$oConfigHash{base} .
                                (defined($strConfig) ? "\n# pgBackRest Configuration\n${strConfig}\n" : ''));

                $oHost->copyTo($strLocalFile, $$hCacheKey{file}, 'postgres:postgres', '640');
            }

            $$oConfigHash{old} = $oConfigHashNew;

            my @stryConfig = undef;

            if (trim($strConfig) ne '')
            {
                @stryConfig = split("\n", $strConfig);
            }

            $$hCacheValue{config} = \@stryConfig;
            $self->cachePush($strCacheType, $hCacheKey, $hCacheValue);
        }
    }
    else
    {
        $strConfig = 'Config suppressed for testing';
    }

    # Return from function and log return values if any
    return logDebugReturn
    (
        $strOperation,
        {name => 'strFile', value => $strFile, trace => true},
        {name => 'strConfig', value => $strConfig, trace => true},
        {name => 'bShow', value => $oConfig->paramTest('show', 'n') ? false : true, trace => true}
    );
}

####################################################################################################################################
# hostKey
####################################################################################################################################
sub hostKey
{
    my $self = shift;

    # Assign function parameters, defaults, and log debug info
    my
    (
        $strOperation,
        $oHost,
    ) =
        logDebugParam
        (
            __PACKAGE__ . '->hostKey', \@_,
            {name => 'oHost', trace => true},
        );

    my $hCacheKey =
    {
        name => $self->{oManifest}->variableReplace($oHost->paramGet('name')),
        image => $self->{oManifest}->variableReplace($oHost->paramGet('image')),
    };

    if (defined($oHost->paramGet('id', false)))
    {
        $hCacheKey->{id} = $self->{oManifest}->variableReplace($oHost->paramGet('id'));
    }
    else
    {
        $hCacheKey->{id} = $hCacheKey->{name};
    }

    if (defined($oHost->paramGet('option', false)))
    {
        $$hCacheKey{option} = $self->{oManifest}->variableReplace($oHost->paramGet('option'));
    }

    if (defined($oHost->paramGet('param', false)))
    {
        $$hCacheKey{param} = $self->{oManifest}->variableReplace($oHost->paramGet('param'));
    }

    if (defined($oHost->paramGet('os', false)))
    {
        $$hCacheKey{os} = $self->{oManifest}->variableReplace($oHost->paramGet('os'));
    }

    $$hCacheKey{'update-hosts'} = $oHost->paramTest('update-hosts', 'n') ? JSON::PP::false : JSON::PP::true;

    # Return from function and log return values if any
    return logDebugReturn
    (
        $strOperation,
        {name => 'hCacheKey', value => $hCacheKey, trace => true}
    );
}

####################################################################################################################################
# cachePop
####################################################################################################################################
sub cachePop
{
    my $self = shift;

    # Assign function parameters, defaults, and log debug info
    my
    (
        $strOperation,
        $strCacheType,
        $hCacheKey,
    ) =
        logDebugParam
        (
            __PACKAGE__ . '->hostKey', \@_,
            {name => 'strCacheType', trace => true},
            {name => 'hCacheKey', trace => true},
        );

    my $bCacheHit = false;
    my $oCacheValue = undef;

    if ($self->{bCache})
    {
        my $oJSON = JSON::PP->new()->canonical()->allow_nonref();
        # &log(WARN, "checking cache for\ncurrent key: " . $oJSON->encode($hCacheKey));

        my $hCache = ${$self->{oSource}{hyCache}}[$self->{iCacheIdx}];

        if (!defined($hCache))
        {
            confess &log(ERROR, 'unable to get index from cache', ERROR_FILE_INVALID);
        }

        if (!defined($$hCache{key}))
        {
            confess &log(ERROR, 'unable to get key from cache', ERROR_FILE_INVALID);
        }

        if (!defined($$hCache{type}))
        {
            confess &log(ERROR, 'unable to get type from cache', ERROR_FILE_INVALID);
        }

        if ($$hCache{type} ne $strCacheType)
        {
            confess &log(ERROR, 'types do not match, cache is invalid', ERROR_FILE_INVALID);
        }

        if ($oJSON->encode($$hCache{key}) ne $oJSON->encode($hCacheKey))
        {
            confess &log(ERROR,
                "keys at index $self->{iCacheIdx} do not match, cache is invalid." .
                "\n  cache key: " . $oJSON->encode($$hCache{key}) .
                "\ncurrent key: " . $oJSON->encode($hCacheKey), ERROR_FILE_INVALID);
        }

        $bCacheHit = true;
        $oCacheValue = $$hCache{value};
        $self->{iCacheIdx}++;
    }
    else
    {
        if ($self->{oManifest}{bCacheOnly})
        {
            confess &log(ERROR, 'Cache only operation forced by --cache-only option');
        }
    }

    # Return from function and log return values if any
    return logDebugReturn
    (
        $strOperation,
        {name => 'bCacheHit', value => $bCacheHit, trace => true},
        {name => 'strCacheType', value => $strCacheType, trace => true},
        {name => 'hCacheKey', value => $hCacheKey, trace => true},
        {name => 'oCacheValue', value => $oCacheValue, trace => true},
    );
}

####################################################################################################################################
# cachePush
####################################################################################################################################
sub cachePush
{
    my $self = shift;

    # Assign function parameters, defaults, and log debug info
    my
    (
        $strOperation,
        $strType,
        $hCacheKey,
        $oCacheValue,
    ) =
        logDebugParam
        (
            __PACKAGE__ . '->hostKey', \@_,
            {name => 'strType', trace => true},
            {name => 'hCacheKey', trace => true},
            {name => 'oCacheValue', required => false, trace => true},
        );

    if ($self->{bCache})
    {
        confess &log(ASSERT, "cachePush should not be called when cache is already present");
    }

    # Create the cache entry
    my $hCache =
    {
        key => $hCacheKey,
        type => $strType,
    };

    if (defined($oCacheValue))
    {
        $$hCache{value} = $oCacheValue;
    }

    push @{$self->{oSource}{hyCache}}, $hCache;

    # Return from function and log return values if any
    return logDebugReturn($strOperation);
}

####################################################################################################################################
# sectionChildProcess
####################################################################################################################################
sub sectionChildProcess
{
    my $self = shift;

    # Assign function parameters, defaults, and log debug info
    my
    (
        $strOperation,
        $oSection,
        $oChild,
        $iDepth
    ) =
        logDebugParam
        (
            __PACKAGE__ . '->sectionChildProcess', \@_,
            {name => 'oSection'},
            {name => 'oChild'},
            {name => 'iDepth'}
        );

    &log(DEBUG, ('    ' x ($iDepth + 1)) . 'process child: ' . $oChild->nameGet());

    # Execute a command
    if ($oChild->nameGet() eq 'host-add')
    {
        if ($self->{bExe} && $self->isRequired($oSection))
        {
            my ($bCacheHit, $strCacheType, $hCacheKey, $hCacheValue) = $self->cachePop('host', $self->hostKey($oChild));

            if ($bCacheHit)
            {
                $self->{oManifest}->variableSet('host-' . $hCacheKey->{id} . '-ip', $hCacheValue->{ip}, true);
            }
            else
            {
                if (defined($self->{host}{$$hCacheKey{name}}))
                {
                    confess &log(ERROR, 'cannot add host ${strName} because the host already exists');
                }

                executeTest("rm -rf ~/data/$$hCacheKey{name}");
                executeTest("mkdir -p ~/data/$$hCacheKey{name}/etc");

                my $strHost = $hCacheKey->{name};
                my $strImage = $hCacheKey->{image};
                my $strHostUser = $self->{oManifest}->variableReplace($oChild->paramGet('user'));

                # Determine if a pre-built image should be created
                if (defined($self->preExecute($strHost)))
                {
                    my $strPreImage = "${strImage}-${strHost}";
                    my $strFrom = $strImage;

                    &log(INFO, "Build vm '${strPreImage}' from '${strFrom}'");

                    my $strCommandList;

                    # Add all pre commands
                    foreach my $oExecute ($self->preExecute($strHost))
                    {
                        my $hExecuteKey = $self->executeKey($strHost, $oExecute);

                        my $strCommand =
                            join("\n", @{$hExecuteKey->{cmd}}) .
                            (defined($hExecuteKey->{'cmd-extra'}) ? ' ' . $hExecuteKey->{'cmd-extra'} : '');
                        $strCommand =~ s/'/'\\''/g;

                        $strCommand =
                            "sudo -u ${strHostUser}" .
                            ($hCacheKey->{'bash-wrap'} ?
                                " bash" . ($hCacheKey->{'load-env'} ? ' -l' : '') . " -c '${strCommand}'" : " ${strCommand}");

                        if (defined($strCommandList))
                        {
                            $strCommandList .= "\n";
                        }

                        $strCommandList .= "RUN ${strCommand}";

                        &log(DETAIL, "    Pre command $strCommand");
                    }

                    # Build container
                    my $strDockerfile = $self->{oManifest}{strDocPath} . "/output/doc-host.dockerfile";

                    $self->{oManifest}{oStorage}->put(
                        $strDockerfile,
                        "FROM ${strFrom}\n\n" . trim($self->{oManifest}->variableReplace($strCommandList)) . "\n");
                    executeTest(
                        "docker build -f ${strDockerfile} -t ${strPreImage} " . $self->{oManifest}{oStorage}->pathGet(),
                        {bSuppressStdErr => true});

                    # Use the pre-built image
                    $strImage = $strPreImage;
                }

                my $strHostRepoPath = dirname(dirname(abs_path($0)));

                # Replace host repo path in mounts with if present
                my $strMount = undef;

                if (defined($oChild->paramGet('mount', false)))
                {
                    $strMount = $self->{oManifest}->variableReplace($oChild->paramGet('mount'));
                    $strMount =~ s/\{\[host\-repo\-path\]\}/${strHostRepoPath}/g;
                }

                # Replace host repo mount in params if present
                my $strOption = $$hCacheKey{option};

                if (defined($strOption))
                {
                    $strOption =~ s/\{\[host\-repo\-path\]\}/${strHostRepoPath}/g;
                }

                my $oHost = new pgBackRestDoc::Common::Host(
                    $$hCacheKey{name}, "doc-$$hCacheKey{name}", $strImage, $strHostUser,
                    defined($strMount) ? [$strMount] : undef, $strOption, $$hCacheKey{param}, $$hCacheKey{'update-hosts'});

                $self->{host}{$$hCacheKey{name}} = $oHost;
                $self->{oManifest}->variableSet('host-' . $hCacheKey->{id} . '-ip', $oHost->{strIP}, true);
                $$hCacheValue{ip} = $oHost->{strIP};

                # Add to the host group
                my $oHostGroup = hostGroupGet();
                $oHostGroup->hostAdd($oHost);

                # Execute initialize commands
                foreach my $oExecute ($oChild->nodeList('execute', false))
                {
                    $self->execute(
                        $oSection, $$hCacheKey{name}, $oExecute, {iIndent => $iDepth + 1, bCache => false, bShow => false});
                }

                $self->cachePush($strCacheType, $hCacheKey, $hCacheValue);
            }
        }
    }
    # Skip children that have already been processed and error on others
    elsif ($oChild->nameGet() ne 'title')
    {
        confess &log(ASSERT, 'unable to process child type ' . $oChild->nameGet());
    }

    # Return from function and log return values if any
    return logDebugReturn
    (
        $strOperation
    );
}

1;
