Skip to content
Snippets Groups Projects
Select Git revision
  • 4b554489e816d68512f8779f56e24725e8933add
  • master default protected
  • simple-task/7248-eol-check-add-node-22
  • 6877_check_iml_deployment
4 results

check_netstat

Blame
  • project.class.php NaN GiB
    <?php
    
    define("OPTION_DEFAULT", -999);
    define("OPTION_NONE", -1);
    
    require_once __DIR__ . '/../inc_functions.php';
    require_once 'base.class.php';
    
    require_once 'messenger.class.php';
    
    // plugins
    // require_once 'plugins.class.php';
    require_once 'build_base.class.php';
    require_once 'rollout_base.class.php';
    
    require_once 'htmlguielements.class.php';
    
    /* ######################################################################
    
      IML DEPLOYMENT
    
      class project for all actions for single project
     * actions
     * rendering html
    
      ---------------------------------------------------------------------
      2013-11-08  Axel <axel.hahn@iml.unibe.ch>
      (...)
      2024-08-28  Axel   php8 only; added variable types; short array syntax
      2024-09-20  Axel   build(): check if checkout of sources failed
      2024-11-29  Axel   multiple instances for rollout plugins
      ###################################################################### */
    
    /**
     * class for single project
     */
    // class project {
    class project extends base
    {
        // ----------------------------------------------------------------------
        // CONFIG
        // ----------------------------------------------------------------------
    
        /**
         * configuration ($aConfig in the config file)
         * @var array
         */
        protected array $_aConfig = [];
    
        /**
         * configuration of the project (= $aProjects[ID] in the config file)
         * @var array
         */
        protected array $_aPrjConfig = [];
    
        /**
         * version infos of all phases
         * @var array
         */
        protected array $_aData = [];
    
        /**
         * existing versions in the archive dir
         * @var array
         */
        protected array $_aVersions = [];
    
        /**
         * output file to fetch processing content with ajax request
         * @var string
         */
        protected string $_sProcessTempOut = '';
    
        /**
         * places of version infos in each deployment phase
         * @var array 
         */
        protected array $_aPlaces = [
            "onhold" => "Queue",
            "ready2install" => "Puppet",
            "deployed" => "Installiert",
        ];
    
        /**
         * collector for returncodes of multiple exec calls
         * @var int
         */
        protected int $_iRcAll = 0;
    
    
        /**
         * reference to html renderer class to draw output items
         * @var object
         */
        protected object $_oHtml;
    
        /**
         * object to access a version control, .e. git
         * @var object
         */
        protected object $_oVcs;
    
        /**
         * object for rollout
         * @var object
         */
        public object $oRolloutPlugin;
    
        /**
         * Name of the current branch
         * @var string
         */
        protected string $_sBranchname = '';
    
        /**
         * messenger object to send messages
         * @var object
         */
        protected object $oMessenger;
    
        /**
         * collected errors
         * @var array
         */
        protected array $_errors = [];
    
        // ----------------------------------------------------------------------
        // constructor
        // ----------------------------------------------------------------------
    
        /**
         * constructor
         * @param string $sId  id of the project
         */
        public function __construct(string $sId = '')
        {
            $this->oUser = new user();
            $this->_oHtml = new htmlguielements();
            $this->_readConfig();
            if ($sId) {
                $this->setProjectById($sId);
            }
        }
    
        // ----------------------------------------------------------------------
        // private functions
        // ----------------------------------------------------------------------
        /**
         * add a log messsage
         * @global object $oLog
         * @param  string $sMessage  messeage text
         * @param  string $sLevel    warnlevel of the given message
         * @return bool
         */
        protected function log(string $sMessage, string $sLevel = "info")
        {
            global $oCLog;
            return $oCLog->add(basename(__FILE__) . " class " . __CLASS__ . " - " . $sMessage, $sLevel);
        }
    
        /**
         * send info messages to project specific targets (Slack, E-Mail)
         * @param string $sMessage
         * @return boolean
         */
        protected function _sendMessage($sMessage)
        {
            $aConfig = [];
    
            if (isset($this->_aPrjConfig['messenger']['slack'])) {
                $sSlack = $this->_aPrjConfig['messenger']['slack'];
                $aConfig['slack'] = ['incomingurl' => $sSlack];
                foreach (['user', 'icon'] as $sKey) {
                    if (isset($this->_aConfig['messenger']['slack']['presets'][$sSlack][$sKey])) {
                        $aConfig['slack'][$sKey] = $this->_aConfig['messenger']['slack']['presets'][$sSlack][$sKey];
                    }
                }
            }
    
            if (isset($this->_aConfig['messenger']['email'])) {
                $aConfig['email'] = $this->_aConfig['messenger']['email'];
                $aConfig['email']['to'] = $this->_aPrjConfig['messenger']['email'];
            }
            if (!count($aConfig)) {
                return false;
            }
    
            // init on first usage
            if (!isset($this->oMessenger) || !$this->oMessenger) {
                $this->oMessenger = new messenger($aConfig);
            }
    
            // add some metadata to the message body
            $sText = $this->getLabel() . ': ' . html_entity_decode($sMessage) . "\n"
                . t('page-login-username') . ": " . $this->oUser->getUsername() . "\n";
            if (isset($_SERVER) && is_array($_SERVER)) {
                if (isset($_SERVER['HTTP_HOST'])) {
                    $sText .= t('project-home') . ': ' . $_SERVER['REQUEST_SCHEME'] . '://' . $_SERVER['HTTP_HOST'] . '/deployment/' . $this->getId() . "\n";
                }
                /*
                if(array_key_exists('HTTP_ORIGIN', $_SERVER)){
                    $sText.= t('project-home').": <".$_SERVER['HTTP_ORIGIN'].'/deployment/'.$this->getId()."/>\n";
            }
                 */
            }
            $this->oMessenger->sendMessage($sText);
            return true;
        }
        /**
         * read default config file
         * @return boolean
         */
        protected function _readConfig(): bool
        {
            global $aConfig;
            $this->_aConfig = $aConfig;
            return true;
        }
    
        /**
         * validate config data
         * @return boolean
         */
        protected function _verifyConfig(): bool
        {
            if (!is_array($this->_aPrjConfig) || !count($this->_aPrjConfig)) {
                // die(t("class-project-error-no-config"));
                throw new Exception(t("class-project-error-no-config"));
            }
    
            if (!isset($this->_aConfig["packageDir"])) {
                die(t("class-project-error-no-packagedir"));
            }
            if (!$this->_aConfig["packageDir"]) {
                die(t("class-project-error-packagedir-empty"));
            }
            if (!file_exists($this->_aConfig["packageDir"])) {
                die(sprintf(t("class-project-error-packagedir-does-not-exist"), $this->_aConfig['packageDir']));
            }
            if (!isset($this->_aConfig["archiveDir"])) {
                die(t("class-project-error-no-archivedir"));
            }
            if (!$this->_aConfig["archiveDir"]) {
                die(t("class-project-error-archivedir-empty"));
            }
            if (!file_exists($this->_aConfig["archiveDir"])) {
                die(sprintf(t("class-project-error-packagedir-does-not-exist"), $this->_aConfig['archiveDir']));
            }
    
            foreach (["fileprefix", "build", "phases"] as $sKey) {
                if (!isset($this->_aPrjConfig[$sKey])) {
                    die(sprintf(t("class-project-error-missing-prjkey"), $sKey, print_r($this->_aPrjConfig, true)));
                }
            }
    
            // TODO: verify ausbauen
            /*
              if (!$this->_aConfig["dataDir"]) {
              die(t("class-project-error-datadir-empty"));
              }
              if (!file_exists($this->_aConfig["dataDir"])) {
              die(sprintf(t("class-project-error-data-does-not-exist"), $this->_aConfig['dataDir']));
              }
    
              foreach (["database", "projects", "sshkeys"] as $sKey) {
              $sTestDir=$this->_aConfig["dataDir"]."/$sKey";
              if (!file_exists($sTestDir)) {
              mkdir($sTestDir);
              // die(sprintf(t("class-project-error-missing-prjkey"), $sKey, print_r($this->_aPrjConfig, true)));
              }
              }
             */
            return true;
        }
    
        /**
         * execute a commandline; returns a string of output of timestamp, command, output and returncode
         * @param string $sCommand  command to execute
         * @param bool   $bFlush    flush content of output buffer
         * @return string
         */
        protected function _execAndSend(string $sCommand, bool $bFlush = false): string
        {
            $this->log(__FUNCTION__ . " start");
            $sReturn = '';
            $bUseHtml = $_SERVER ? true : false;
    
            if ($bFlush) {
                ob_implicit_flush(true);
            }
            // ob_end_flush();
            // stderr ausgeben
            $sCommand .= ' 2>&1';
            $sReturn .= "[" . date("H:i:s d.m.Y") . "] ";
            $sReturn .= $bUseHtml ? "<strong>$sCommand</strong>" : "$sCommand";
            $sReturn .= $bUseHtml ? "<br>" : "\n";
    
            $sOutput = false;
            $this->log(__FUNCTION__ . " start $sCommand");
            exec($sCommand, $aOutput, $iRc);
            $this->log(__FUNCTION__ . " ended command $sCommand");
            $sReturn .= (count($aOutput)) ? htmlentities(implode("\n", $aOutput)) . "\n" : "";
            /*
              $descriptorspec = [
              0 => ["pipe", "r"], // stdin is a pipe that the child will read from
              1 => ["pipe", "w"], // stdout is a pipe that the child will write to
              2 => ["pipe", "w"]    // stderr is a pipe that the child will write to
              ];
              if ($bFlush) {
              flush();
              }
              $process = proc_open($sCommand, $descriptorspec, $pipes, realpath('./'), []);
    
    
              $sErrors = false;
              if (is_resource($process)) {
              while ($s = fgets($pipes[1])) {
              $sReturn.=$s;
              if ($bFlush) {
              flush();
              }
              }
              while ($s = fgets($pipes[2])) {
              $sErrors.=$s;
              if ($bFlush) {
              flush();
              }
              }
              }
              if ($sErrors) {
              $sReturn.="STDERR:\n" . $sErrors;
              }
              $oStatus = proc_get_status($process);
              $iRc = $oStatus['exitcode'];
             */
    
            if ($iRc != 0) {
                $this->_logaction("command failed: $sCommand - rc=" . $iRc, __FUNCTION__, "error");
            }
    
            $this->_iRcAll += $iRc;
            $sReturn .= "[" . date("H:i:s d.m.Y") . "] " . t("exitcode") . " " . $iRc;
            if ($bUseHtml) {
                if ($iRc == 0) {
                    $sReturn = '<pre class="cli">' . $sReturn;
                } else {
                    $sReturn = '<pre class="cli error">' . $sReturn;
                }
                $sReturn .= '</pre>';
            }
    
            if ($bFlush) {
                flush();
            }
            return $sReturn;
        }
    
        /**
         * add an action log message for the current project
         * @param string $sMessage    message text
         * @param string $sAction     project action i.e. build, deploy, ...
         * @param string $sLoglevel   loglevel; default: info
         * @return void
         */
        protected function _logaction(string $sMessage, string $sAction = "", string $sLoglevel = "info"): void
        {
            require_once("actionlog.class.php");
            $oLog = new Actionlog($this->_aConfig["id"]);
            $oLog->add($sMessage, (__CLASS__ . "->" . $sAction), $sLoglevel);
        }
    
        // ----------------------------------------------------------------------
        // GETTER
        // ----------------------------------------------------------------------
    
        /**
         * Get filename of fonfigfile for this project
         * @param string $sId  project id
         * @return string
         */
        protected function _getConfigFile(string $sId): string
        {
            if (!$sId) {
                die(t("class-project-error-_getConfigFile-requires-id"));
            }
            return $this->_aConfig["dataDir"] . '/projects/' . $sId . ".json";
        }
    
        /**
         * Get a full ath for temp directory (for a build)
         * @return string
         */
        protected function _getTempDir(): string
        {
            return $s = $this->_getBuildDir() . '/' . $this->_aPrjConfig["fileprefix"] . "_" . date("Ymd-His");
        }
    
        /**
         * Get full path where the project builds are (a build setes a subdir)
         * @return string
         */
        protected function _getBuildDir(): string
        {
            return $this->_aConfig['buildDir'] . '/' . $this->_aConfig["id"];
        }
    
        /**
         * Get full path where the project default files are
         * This is an optional step in build() - you can sync default files into 
         * the build directory. It returns false if the directory with default 
         * files doesn't exist.
         * 
         * @return string|bool
         */
        protected function _getDefaultsDir(): string|bool
        {
            $s = $this->_aConfig['buildDefaultsDir'] . '/' . $this->_aConfig["id"];
            return file_exists($s) ? $s : false;
        }
    
        /**
         * Get directory for infofile and package (without extension)
         * 
         * @param string $sPhase  one of preview|stage|live ...
         * @param string $sPlace  one of onhold|ready2install|deployed
         * @return string
         */
        protected function _getFileBase(string $sPhase, string $sPlace): string
        {
            if (!isset($this->_aConfig["phases"][$sPhase])) {
                die(sprintf(t("class-project-error-wrong-phase"), $sPhase));
            }
            if (!isset($this->_aPlaces[$sPlace])) {
                die(sprintf(t("class-project-error-wrong-place"), $sPlace));
            }
    
            // local file for onhold|ready2install
            $sBase = $this->_aConfig['packageDir'] . "/" . $sPhase . "/" . $this->_aPrjConfig["fileprefix"];
            if (!file_exists($this->_aConfig['packageDir'] . "/" . $sPhase)) {
                mkdir($this->_aConfig['packageDir'] . "/" . $sPhase);
            }
    
            if ($sPlace == "onhold") {
                $sBase .= "_onhold";
            }
            // $sBase .= "/" . $this->_aPrjConfig["fileprefix"];
            // url for deployed
            if ($sPlace == "deployed") {
                if ($this->isActivePhase($sPhase) && isset($this->_aPrjConfig["phases"][$sPhase]["url"])) {
                    // $sBase = $this->_aPrjConfig["phases"][$sPhase]["url"] . $this->_aPrjConfig["fileprefix"];
                    $sBase = $this->_aPrjConfig["phases"][$sPhase]["url"];
                } else {
                    $sBase = '';
                }
            }
    
            // echo "DEBUG: ".__METHOD__."($sPhase, $sPlace) --> $sBase<br>";
            return $sBase;
        }
    
        /**
         * Get filename for info/ meta file (.json file).
         * It returns false if the base directory doesn't exist
         * 
         * @param string $sPhase  one of preview|stage|live ...
         * @param string $sPlace  one of onhold|ready2install|deployed
         * @return string
         */
        protected function _getInfofile(string $sPhase, string $sPlace): string|bool
        {
            $sBase = $this->_getFileBase($sPhase, $sPlace);
            return $sBase ? $sBase . '/' . $this->_aPrjConfig["fileprefix"] . '.json' : false;
        }
    
        /**
         * Get filename for package file (without file extension)
         * It returns false if the base directory doesn't exist
         * 
         * @param string $sPhase  one of preview|stage|live ...
         * @param string $sPlace  one of onhold|ready2install|deployed
         * @return string
         */
        protected function _getPackagefile(string $sPhase, string $sPlace)
        {
            $sBase = $this->_getFileBase($sPhase, $sPlace);
            return $sBase ? $sBase . '/' . $this->_aPrjConfig["fileprefix"] : false;
        }
    
        /**
         * Get a list of files of a given phase and place.
         * It returns false if the base directory doesn't exist
         * 
         * @param string $sBase  base directory from where to get files (archive dir of a build)
         * @return bool|array
         */
        public function _getBuildfilesByDir(string $sBase): bool|array
        {
            $aReturn = [];
            if (!$sBase || !is_dir($sBase)) {
                return false;
            }
            $iTotalSize = 0;
            $aReturn = [
                'dir' => $sBase,
                'filecount' => false,
                'totalsize' => false,
                'totalsize-hr' => false,
            ];
    
            foreach (glob($sBase . '/*') as $sFile) {
                $sFileBase = basename($sFile);
                $sExt = pathinfo($sFile, PATHINFO_EXTENSION);
                $aStat = stat($sFile);
                switch ($sExt) {
                    case 'erb':
                        $sType = 'templates';
                        $sIcon = 'file-template';
                        break;
                    case 'tgz':
                    case 'zip':
                        $sType = 'package';
                        $sIcon = 'file-archive';
                        break;
                    case 'json':
                        $sType = 'metadata';
                        $sIcon = 'file-meta';
                        break;
                    default:
                        $sType = 'any';
                        $sIcon = 'file-any';
                        break;
                }
                $iTotalSize += $aStat['size'];
                $aReturn['files'][$sFileBase] = [
                    'type' => $sType,
                    'icon' => $this->_oHtml->getIcon($sIcon),
                    'extension' => $sExt,
                    'size' => $aStat['size'],
                ];
                $aReturn['types'][$sType][] = $sFileBase;
            }
            $aReturn['totalsize'] = $iTotalSize;
            $aReturn['totalsize-hr'] = (round($iTotalSize / 1024 / 102.4) / 10) . ' MB';
            $aReturn['filecount'] = count($aReturn['files']);
    
            return $aReturn;
        }
    
        /**
         * Get a list of files of a given phase and place;
         * It returns false if the base directory for phase + base doesn't exist
         * 
         * @param string $sPhase  one of preview|stage|live ...
         * @param string $sPlace  one of onhold|ready2install|deployed
         * @return bool|array
         */
        public function getBuildfilesByPlace(string $sPhase, string $sPlace): bool|array
        {
            $sBase = $this->_getFileBase($sPhase, $sPlace);
            return $this->_getBuildfilesByDir($sBase);
        }
    
        /**
         * Get a list of files of a given version number
         * It returns false if the version directory doesn't exist
         * 
         * @param string $sVersion  name of version 
         * @return array
         */
        public function getBuildfilesByVersion(string $sVersion): bool|array
        {
            return $this->_getBuildfilesByDir($this->_getProjectArchiveDir() . '/' . $sVersion);
        }
    
        /**
         * Get the group id of the project
         * It returns false if the group wasn't set
         * 
         * @return bool|string
         */
        public function getProjectGroup(): bool|string
        {
            return isset($this->_aPrjConfig["projectgroup"]) && $this->_aPrjConfig["projectgroup"] != '-1' ? $this->_aPrjConfig["projectgroup"] : false;
        }
    
        /**
         * Get the group label (description) of the project
         * It returns false if the group wasn't set
         * 
         * @return bool|string
         */
        public function getProjectGroupLabel(): bool|string
        {
            $sGroupid = $this->getProjectGroup();
            return isset($this->_aConfig["projectgroups"][$sGroupid]) ? $this->_aConfig["projectgroups"][$sGroupid] : false;
        }
    
        /**
         * Get full path of a packed project archive
         * 
         * @param string $sVersion  version number of the build
         * @return string
         */
        protected function _getArchiveDir(string $sVersion): string
        {
            if (!$sVersion) {
                die(t("class-project-error-_getArchiveDir-requires-id"));
            }
            return $this->_getProjectArchiveDir() . '/' . $sVersion;
        }
    
        /**
         * get array of metadata of a given version. it returns 
         * - key "ok" anddata
         * or 
         * - key "error" with the message
         * 
         * @param string $sTimestamp
         * @return array
         */
        protected function _getArchiveInfos(string $sTimestamp): array
        {
            if (!$sTimestamp) {
                die(t("class-project-error-_getArchiveInfos-requires-id"));
            }
            $sInfoFile = $this->_getArchiveDir($sTimestamp) . '/' . $this->_aPrjConfig["fileprefix"] . '.json';
            $aReturn['infofile'] = $sInfoFile;
            $sPackageFile = $this->_getArchiveDir($sTimestamp) . '/' . $this->_aPrjConfig["fileprefix"] . '.tgz';
            $aReturn['packagefile'] = $sPackageFile;
            if (!file_exists($sInfoFile)) {
                $aReturn['error'] = sprintf(t("class-project-error-metafile-does-not-exist"), $sInfoFile);
                return $aReturn;
            }
            $aJson = json_decode(file_get_contents($sInfoFile), true);
            if (is_array($aJson) && isset($aJson["version"])) {
                $aReturn = array_merge($aReturn, $aJson);
                $aReturn['ok'] = 1;
                /*
                  if (!file_exists($sPackageFile)) {
                  $aReturn['error'] = sprintf(t("class-project-error-datafile-does-not-exist"), $sInfoFile);
                  } else {
                  $aReturn['filesizes']['packagefile']=filesize($sPackageFile);
                  }
                 * 
                 */
                return $aReturn;
            }
            $aReturn['error'] = sprintf(t("class-project-error-metafile-wrong-format"), $sInfoFile);
            return $aReturn;
        }
    
        /**
         * Get the directory for archive files of this project
         * 
         * @return string
         */
        public function _getProjectArchiveDir(): string
        {
            return $this->_aConfig["archiveDir"] . '/' . $this->_aConfig["id"];
        }
    
        /**
         * Get all existing versions in archive and its usage
         * versions are array keys; where they are used is written in values
         * 
         * @return array
         */
        public function getVersions(): array
        {
    
            // --- read all file entries
            $aReturn = [];
            $sDir = $this->_getProjectArchiveDir();
            if (is_dir($sDir)) {
                foreach (scandir($sDir) as $sEntry) {
                    if (is_dir($sDir . '/' . $sEntry) && $sEntry != '.' && $sEntry != '..')
                        $aReturn[$sEntry] = false;
                }
            }
            $this->_aVersions = $aReturn;
    
            // --- check version in all phases 
            $this->getAllPhaseInfos();
            foreach ($this->_aData["phases"] as $sPhase => $aData) {
                foreach (array_keys($this->_aPlaces) as $sPlace) {
                    if (isset($aData[$sPlace]["version"])) {
                        $this->_aVersions[$aData[$sPlace]["version"]][] = ['phase' => $sPhase, 'place' => $sPlace];
                    }
                }
            }
            ksort($this->_aVersions);
            return $this->_aVersions;
        }
    
        /**
         * Get an array with all existing build error output files (html)
         * 
         * @return array
         */
        public function getBuildErrors(string $sProject = ''): array
        {
            // --- read all file entries
            $aReturn = [];
            if (!$sProject) {
                $sProject = $this->_aPrjConfig["fileprefix"] . '_*';
            }
            foreach (glob($this->_getBuildDir() . '/' . $sProject . "/_output.html") as $sBuildDir) {
                $aReturn[] = basename(dirname($sBuildDir)) . '/' . basename($sBuildDir);
            }
            return $aReturn;
        }
    
        /**
         * Get an array with all existing build error output files (html)
         * It returns false when path of given logfile contains ".." or the logfile doesn't exist.
         * 
         * @return bool|string
         */
        public function getBuildErrorContent($sLogfile): bool|string
        {
            if (!strpos('..', $sLogfile) === false) {
                return false;
            }
            $sFilename = $this->_getBuildDir() . '/' . $sLogfile;
            if (file_exists($sFilename)) {
                return file_get_contents($sFilename);
            }
            return false;
        }
    
        /**
         * Get an array of all versions, metainfos, in which phases they are in use 
         * and a rollback ist possible or not
         * 
         * return array
         */
        protected function _getVersionUsage(): array
        {
            $aVersionData = [];
            $sLastVersion = false;
            if (!count($this->getVersions())) {
                return [];
            }
    
            foreach ($this->getVersions() as $sVersion => $aData) {
    
                $aVersionData[$sVersion]["info"] = $this->_getArchiveInfos($sVersion);
                foreach ($this->getActivePhases() as $sPhase) {
                    $bCanRollback = false;
                    foreach (array_keys($this->_aPlaces) as $sPlace) {
                        $bFound = false;
                        if (is_array($aData) && count($aData)) {
                            foreach ($aData as $i => $aPhaseUsage) {
                                if (
                                    $aPhaseUsage["phase"] == $sPhase && $aPhaseUsage["place"] == $sPlace
                                )
                                    $bFound = true;
                            }
                        }
                        $aVersionData[$sVersion]["usage"][$sPhase][$sPlace] = $bFound;
                        if ($bFound) {
                            $bCanRollback = false;
                        } else {
                            $bCanRollback = true;
                            if (
                                $sLastVersion && !$aVersionData[$sLastVersion]["rollback"][$sPhase]
                            ) {
                                $bCanRollback = false;
                            }
                            /*
                              if (!isset($aVersionData[$sVersion]["info"]["ok"])){
                              $bCanRollback = false;
                              }
                             */
                        }
                        $aVersionData[$sVersion]["rollback"][$sPhase] = $bCanRollback;
                    }
                }
                $sLastVersion = $sVersion;
            }
            return $aVersionData;
        }
    
        /**
         * Recursive delete of a given directory
         * 
         * @param string $dir  directory to delete
         * @return bool
         */
        protected function _rmdir(string $dir): bool
        {
            foreach (scandir($dir) as $sEntry) {
                if (is_dir($dir . '/' . $sEntry) && $sEntry != '.' && $sEntry != '..') {
                    $this->_rmdir($dir . '/' . $sEntry);
                } elseif (is_file($dir . '/' . $sEntry) || is_link($dir . '/' . $sEntry))
                    unlink($dir . '/' . $sEntry);
            }
            return rmdir($dir);
        }
    
        /**
         * Cleanup of archive directory; it returns the list of deleted
         * directories as array.
         * It returns a string with the error message in case of missing permission
         * Otherwise it returns an array with all deleted directories.
         * 
         * @param bool $bDeleteAll  flag to delete all; default: false = it keeps a few versions
         * @return string|array
         */
        public function cleanupArchive(bool $bDeleteAll = false): string|array
        {
            if (!$this->oUser->hasPermission("project-action-cleanup")) {
                return $this->oUser->showDenied();
            }
            $aDelete = [];
            $aUnused = [];
    
            $sDir = $this->_getProjectArchiveDir();
            $this->getVersions();
            if (!$this->_aVersions) {
                return $aDelete;
            }
    
            // find unused versions
            foreach ($this->_aVersions as $sVersion => $aUsage) {
                if (!$aUsage || count($aUsage) == 0) {
                    $aUnused[] = $sVersion;
                }
            }
    
            // keep a few
            $iKeep = $bDeleteAll ? 0 : $this->_aConfig["versionsToKeep"];
            while (count($aUnused) && count($aUnused) > $iKeep) {
                $sVersion = array_shift($aUnused);
                $sDir2 = $sDir . '/' . $sVersion;
                if (is_dir($sDir2)) {
                    if ($this->_rmdir($sDir2)) {
                        $aDelete[] = $sDir2;
                        echo t('ok') . ': ' . $sDir2;
                    } else {
                        echo sprintf(t("class-project-warning-cannot-delete-archive-dir"), $sDir2);
                    }
                } else {
                    echo t('skip') . ': ' . $sDir2;
                }
                echo '<br>';
            }
    
            // rescan versions
            if (count($aDelete)) {
                $this->getVersions();
            }
    
            return $aDelete;
        }
    
        /**
         * Cleanup of kept build directories (builds with errors) except the last N builds; 
         * It returns a string with the error message in case of missing permission
         * Otherwise it returns an array with all deleted directories.
         * 
         * @return string|array
         */
        public function cleanupBuilds(): string|array
        {
            $this->log(__FUNCTION__ . " start");
            if (!$this->oUser->hasPermission("project-action-cleanup")) {
                return $this->oUser->showDenied();
            }
            $sDir = $this->_getBuildDir();
            $aDirlist = [];
            $aDelete = [];
            if (is_dir($sDir)) {
                foreach (scandir($sDir) as $sEntry) {
                    if (is_dir($sDir . '/' . $sEntry) && $sEntry != '.' && $sEntry != '..')
                        $aDirlist[] = $sEntry;
                }
            }
    
            // keep a few
            while (count($aDirlist) > $this->_aConfig["builtsToKeep"]) {
                $sVersion = array_shift($aDirlist);
                $sDir2 = $sDir . '/' . $sVersion;
                if ($this->_rmdir($sDir2)) {
                    $aDelete[] = $sDir2;
                } else {
                    echo sprintf(t("class-project-warning-cannot-delete-build-dir"), $sDir2);
                }
                ;
            }
    
            return $aDelete;
        }
    
        /**
         * Cleanup cache of vcs
         * It returns a string with error message in case of missing permission
         * Otherwise it returns the success as true or false
         * 
         * @param int $iAge  max age in sec
         * @return string|bool
         */
        public function cleanupVcsCache($iAge = 0): string|bool
        {
            $this->log(__FUNCTION__ . " start");
            if (!$this->oUser->hasPermission("project-action-cleanup")) {
                return $this->oUser->showDenied();
            }
            $this->_initVcs();
            if (isset($this->_oVcs) && $this->_oVcs) {
                if (!method_exists($this->_oVcs, "cleanupCache")) {
                    // the version control class does not have this method
                    $this->log(__FUNCTION__ . " sorry, Method cleanupCache does not exist in this VCS class.");
                    return false;
                }
                return $this->_oVcs->cleanupCache($iAge);
            }
            return false;
        }
    
        /**
         * Get conmplete config of the project
         * 
         * @return array
         */
        public function getConfig(): array
        {
            return $this->_aPrjConfig;
        }
    
        /**
         * Get name/ label of the project
         * 
         * @return string
         */
        public function getLabel(): string
        {
            return isset($this->_aPrjConfig["label"]) ? $this->_aPrjConfig["label"] : '';
        }
    
        /**
         * Get description of the project
         * 
         * @return string
         */
        public function getDescription(): string
        {
            return isset($this->_aPrjConfig["description"]) ? $this->_aPrjConfig["description"] : '';
        }
    
        /**
         * Get the id of the current project
         * @return string
         */
        public function getId(): string
        {
            return isset($this->_aConfig["id"]) ? $this->_aConfig["id"] : '';
        }
    
        /**
         * Get deploy and queue infos for all phases
         * It build up a subkey "progress" with info if a build is queued
         * or an installation of a new package is going on
         * 
         * @return array
         */
        public function getAllPhaseInfos(): array
        {
    
            $bHasQueue = false;
            $bHasDifferentVersions = false;
            $bFirstVersion = false;
    
            if (!isset($this->_aData["phases"])) {
                $this->_aData["phases"] = [];
            }
            if (!isset($this->_aData["progress"])) {
                $this->_aData["progress"] = [];
            }
    
            foreach (array_keys($this->_aConfig["phases"]) as $sPhase) {
                if (!isset($this->_aData["phases"][$sPhase])) {
                    $this->getPhaseInfos($sPhase);
                }
                // detect progress
                $aDataPhase = $this->_aData["phases"][$sPhase];
                foreach (array_keys($this->getPlaces()) as $sPlace) {
                    if (
                        $sPlace !== 'onhold'
                        && isset($aDataPhase[$sPlace]['version'])
                    ) {
                        if ($bFirstVersion && !$bHasDifferentVersions && $bFirstVersion !== $aDataPhase[$sPlace]['version']) {
                            $bHasDifferentVersions = true;
                        }
                        if (!$bFirstVersion) {
                            $bFirstVersion = $aDataPhase[$sPlace]['version'];
                        }
                    }
                }
                // check queue
                if (!$bHasQueue && isset($aDataPhase['onhold']['version']) && $aDataPhase['onhold']['version']) {
                    $bHasQueue = true;
                }
            }
            $this->_aData["progress"] = [
                'inprogress' => $bHasDifferentVersions,
                'hasQueue' => $bHasQueue,
            ];
            return $this->_aData["phases"];
        }
    
        /**
         * Get statusinfos of a named phase
         * 
         * @param string $sPhase name of the phase; one of preview|stage|live
         * @return array
         */
        public function getPhaseInfos(string $sPhase): array
        {
            if (!$sPhase) {
                die(t("class-project-error-getPhaseInfos-requires-phase"));
            }
            if (!isset($this->_aData["phases"]))
                $this->_aData["phases"] = [];
    
            if (!isset($this->_aData["phases"][$sPhase])) {
                if ($this->isActivePhase($sPhase)) {
    
                    $this->_aData["phases"][$sPhase] = [];
                    $aTmp = [];
    
                    // a blocked package is waiting for deployment timeslot?
                    $sKey = "onhold";
                    $sJsonfile = $this->_getInfofile($sPhase, $sKey);
                    $aTmp[$sKey] = [];
                    if (file_exists($sJsonfile)) {
                        $aJson = json_decode(file_get_contents($sJsonfile), true);
                        if (isset($aJson["version"])) {
                            $aTmp[$sKey] = $aJson;
                            $aTmp[$sKey]["infofile"] = $sJsonfile;
                            $aTmp[$sKey]["ok"] = 1;
                        } else {
                            $aTmp[$sKey]["error"] = sprintf(t("class-project-error-metafile-has-no-version"), $sJsonfile, print_r($aJson, true));
                        }
                    } else {
                        $aTmp[$sKey]["info"] = t("class-project-info-no-package-in-queue");
                        $aTmp[$sKey]["ok"] = 1;
                    }
    
                    // package for puppet
                    $sKey = "ready2install";
                    $sJsonfile = $this->_getInfofile($sPhase, $sKey);
                    $aTmp[$sKey] = [];
                    if (file_exists($sJsonfile)) {
                        // $sPkgfile = $this->_getPackagefile($sPhase, $sKey);
                        // if (file_exists($sPkgfile)) {
                        $aJson = json_decode(file_get_contents($sJsonfile), true);
                        if (isset($aJson["version"])) {
                            $aTmp[$sKey] = $aJson;
                            $aTmp[$sKey]["infofile"] = $sJsonfile;
                            // $aTmp[$sKey]["packagefile"] = $sPkgfile;
                            $aTmp[$sKey]["ok"] = 1;
                        } else {
                            $aTmp[$sKey]["error"] = sprintf(t("class-project-error-metafile-has-no-version"), $sJsonfile, print_r($aJson, true));
                        }
                        // } else {
                        //    $aTmp[$sKey]["error"] = sprintf(t("class-project-error-getPhaseInfos-package-not-found"), $sPkgfile);
                        // }
                    } else {
                        $aTmp[$sKey]["error"] = sprintf(t("class-project-error-metafile-does-not-exist"), $sJsonfile);
                    }
    
                    // published data
                    $sKey = "deployed";
                    $sJsonfile = $this->_getInfofile($sPhase, $sKey);
                    $aTmp[$sKey] = [];
    
                    // use version cache
                    require_once(__DIR__ . '/../../valuestore/classes/valuestore.class.php');
                    $oVersion = new valuestore();
                    $oVersion->setProject("", $this->_aPrjConfig["fileprefix"], $sPhase, $sKey);
                    $aVersions = $oVersion->getVersion();
                    // echo "Place: <pre>" . print_r($oVersion->whereiam(), 1) . "</pre>";
                    // echo "Versionen: <pre>" . print_r($aVersions, 1) . "</pre>";
                    if (count($aVersions)) {
                        $aTmp[$sKey] = [];
                        $aTmp[$sKey] = $aVersions[0]['_data'];
                        $aTmp[$sKey]["infofile"] = '[versioncache]';
    
                        $aTmp[$sKey]['_hosts'] = [];
                        foreach ($aVersions as $sHostname => $aHostdata) {
                            $aTmp[$sKey]['_hosts'][$aHostdata['host']] = $aHostdata;
                        }
                        $aTmp[$sKey]["ok"] = 1;
                        $aTmp[$sKey]["infofile"] = '[versioncache]';
                    }
    
                    /*
                      $sJsonData = $this->_httpGet($sJsonfile);
                      if ($sJsonData) {
                      $aJson = json_decode($sJsonData, true);
                      if (is_array($aJson) && array_key_exists("version", $aJson)) {
                      $aTmp[$sKey] = $aJson;
                      $aTmp[$sKey]["infofile"] = $sJsonfile;
                      $aTmp[$sKey]["ok"] = 1;
                      } else {
                      $aTmp[$sKey]["error"] = sprintf(t("class-project-error-metafile-has-no-version"), $sJsonfile, print_r($aJson, true));
                      }
                      } else {
                      $aTmp[$sKey]["error"] = sprintf(t("class-project-error-metafile-wrong-format"), $sJsonfile);
                      }
                     * 
                     */
                } else {
                    $aTmp['onhold']["warning"] = sprintf(t("class-project-warning-phase-not-active"), $sPhase);
                    $aTmp['ready2install']["warning"] = sprintf(t("class-project-warning-phase-not-active"), $sPhase);
                    $aTmp['deployed']["warning"] = sprintf(t("class-project-warning-phase-not-active"), $sPhase);
                }
    
                $this->_aData["phases"][$sPhase] = $aTmp;
            }
            // echo '<pre>'.print_r($this->_aData["phases"][$sPhase], 1).'</pre>'.__METHOD__.'<br>';
            return $this->_aData["phases"][$sPhase];
        }
    
        /**
         * get a list of all existing projects as a flat array;
         * it can be ordered by "id" or "label"
         * <code>
         * print_r($oPrj->getProjects("label"));
         * </code>
         * returns<br>
         * Array ( [0] => project1 [1] => project2 ) 
         * 
         * @param string $sort sort by "id" (default) or "label"
         * @return array
         */
        public function getProjects(string $sort = 'id'): array
        {
            $aReturn = [];
            foreach (glob(dirname($this->_getConfigFile("dummy")) . "/*.json") as $filename) {
                $aReturn[] = str_replace(".json", "", basename($filename));
            }
            if ($sort == "label") {
                $aProjectLabels = [];
                foreach ($aReturn as $sPrj) {
                    $oPrj = new project($sPrj);
                    $aProjectLabels[strtolower($oPrj->getLabel() . '-' . $sPrj)] = [
                        'id' => $sPrj,
                        'label' => $oPrj->getLabel(),
                        'description' => $oPrj->getDescription(),
                    ];
                }
                ksort($aProjectLabels);
                return array_values($aProjectLabels);
            }
            if ($sort == "id") {
                sort($aReturn);
            }
            return $aReturn;
        }
    
        /**
         * Check if the given phase is active for this project
         * 
         * @param string $sPhase name of the phase; one of preview|stage|live
         * @return bool
         */
        public function isActivePhase(string $sPhase): bool
        {
            return (
                $this->_aPrjConfig && isset($this->_aPrjConfig["phases"][$sPhase]["active"][0])
                ? $this->_aPrjConfig["phases"][$sPhase]["active"][0]
                : false
            );
        }
    
        /**
         * Get array of all (active and inactive) phases
         * @return array
         */
        public function getPhases(): array
        {
            return $this->_aConfig["phases"];
        }
    
        /**
         * Get array of all (active and inactive) phases
         * @return array
         */
        public function getPlaces(): array
        {
            return $this->_aPlaces;
        }
        /**
         * Get a flat array with active phases of the project
         * @return array
         */
        public function getActivePhases(): array
        {
            $aReturn = [];
            foreach (array_keys($this->_aConfig["phases"]) as $s) {
                if ($this->isActivePhase($s)) {
                    $aReturn[] = $s;
                }
            }
            return $aReturn;
        }
    
        /**
         * find the next active phase of a project
         * @param string $sPhase current phase; if empty the function sends back the first phase
         * @return string
         */
        public function getNextPhase(string $sPhase = ''): string
        {
            if ($sPhase) {
                if (!isset($this->_aConfig["phases"][$sPhase])) {
                    die(sprintf(t("class-project-error-wrong-phase"), $sPhase));
                }
            }
    
            $sNextPhase = false;
            $bUseNextPhase = $sPhase ? false : true;
            foreach (array_keys($this->_aConfig["phases"]) as $s) {
                if ($bUseNextPhase) {
                    if ($this->isActivePhase($s)) {
                        $sNextPhase = $s;
                        $bUseNextPhase = false;
                        continue;
                    }
                }
                if ($sPhase == $s) {
                    $bUseNextPhase = true;
                }
            }
    
            return $sNextPhase;
        }
    
        /**
         * Get an array with deploy status ...  
         *    'inprogress'=>do versions differ from phase to phase = rollout of a version is in progress
              'hasQueue'=>is there a package in a queue (waiting for deployment time to get ready to be installed)
         * @return array
         */
        public function getProgress(): array
        {
            $this->getAllPhaseInfos();
            return $this->_aData['progress'];
        }
        /**
         * check: is the deployment to the next phase enabled for this phase?
         * It returns a string when current user has no permissions.
         * Otherwise it returns true or false.
         * 
         * @param string $sPhase  current phase
         * @return string|bool
         */
        public function canAcceptPhase(string $sPhase = ''): string|bool
        {
            if (
                !$this->oUser->hasPermission("project-action-accept") && !$this->oUser->hasPermission("project-action-accept-$sPhase")
            ) {
                // echo $this->oUser->showDenied();
                return '<span class="btn" title="no permission [project-action-accept] for user [' . $this->oUser->getUsername() . ']">' . $sPhase . '</span>';
            }
    
            if (!$sPhase) {
                // for better performance: skip check on overview page
                /*
                  $aRepodata = $this->getRepoRevision();
                  if (!isset($aRepodata["revision"])) {
                  return false;
                  }
                 */
                $sNext = $this->getNextPhase($sPhase);
                return $sNext > '';
            }
    
    
            if (!isset($this->_aConfig["phases"][$sPhase])) {
                die(sprintf(t("class-project-error-wrong-phase"), $sPhase));
            }
            if (!$this->isActivePhase($sPhase)) {
                // die("ERROR: the phase $sPhase is not active in this project.");
                return false;
            }
            $sNext = $this->getNextPhase($sPhase);
            if (!$sNext) {
                return false;
            }
    
            // ensure that _aData is filled
            $this->getPhaseInfos($sPhase);
    
            // array key "ok" must be in the ready2install and deployed info
            // and a version must be installed
            if (
                isset($this->_aData["phases"][$sPhase])
                && isset($this->_aData["phases"][$sPhase]["onhold"])
                && isset($this->_aData["phases"][$sPhase]["ready2install"])
                && isset($this->_aData["phases"][$sPhase]["deployed"])
                && isset($this->_aData["phases"][$sPhase]["onhold"]["ok"])
                && isset($this->_aData["phases"][$sPhase]["ready2install"]["ok"])
                && isset($this->_aData["phases"][$sPhase]["deployed"]["ok"])
                && isset($this->_aData["phases"][$sPhase]["deployed"]["version"])
            ) {
                return true;
            }
            return false;
        }
    
        /**
         * Get list of remote branches and tags.
         * It returns false if the VCS was not initialize or has no method getRemoteBranches()
         * 
         * @param bool $bIgnoreCache  flag to ignore exiting cached data
         * @return bool|array
         */
        public function getRemoteBranches(bool $bIgnoreCache = false): bool|array
        {
            $this->log(__FUNCTION__ . "($bIgnoreCache) start");
            $this->_initVcs();
            if (isset($this->_oVcs) && $this->_oVcs) {
                if (!method_exists($this->_oVcs, "getRemoteBranches")) {
                    // the version control class does not have this method
                    return false;
                }
                return $this->_oVcs->getRemoteBranches($bIgnoreCache);
            }
            return false;
        }
    
        /**
         * Get current revision and log message from remote repo
         * 
         * @param boolean  $bRefresh  optional: refresh data; default: use cache
         * @return array
         */
        public function getRepoRevision(bool $bRefresh = false): array
        {
            $this->log(__FUNCTION__ . "($bRefresh) start");
    
            if (!$this->_aPrjConfig["build"]["type"]) {
                $this->_aData["phases"]["source"] = ["error" => t("class-project-error-repo-type-not-set"),];
            } else {
                $this->_initVcs();
                if (isset($this->_oVcs) && $this->_oVcs) {
                    $this->_aData["phases"]["source"] = $this->_oVcs->getRepoRevision($bRefresh);
                } else {
                    $this->_aData["phases"]["source"] = [
                        "error" => sprintf(t("class-project-error-repo-type-not-supported"), $this->_aPrjConfig["build"]["type"]),
                    ];
                }
            }
            $this->log(__FUNCTION__ . " result:<pre>" . print_r($this->_aData, 1) . "</pre>");
            return $this->_aData["phases"]["source"];
        }
    
        /**
         * Initialize version control system (git, ...) if it is not initialized yet
         * it sets the object $this->_oVcs
         */
        protected function _initVcs(): void
        {
            $this->log(__FUNCTION__ . " start");
            if (!isset($this->_oVcs)) {
                if (!$this->_aPrjConfig["build"]["type"]) {
                    $this->_aData["phases"]["source"] = ["error" => t("class-project-error-repo-type-not-set"),];
                } else {
                    if (!@include_once("vcs." . $this->_aPrjConfig["build"]["type"] . ".class.php")) {
                        $this->_aData["phases"]["source"] = [
                            "error" => sprintf(t("class-project-error-repo-type-not-supported"), $this->_aPrjConfig["build"]["type"]),
                        ];
                    } else {
                        $aConfig = $this->_aPrjConfig["build"];
                        // for vcs classes
                        $aConfig["appRootDir"] = $this->_aConfig["appRootDir"];
                        $aConfig["dataDir"] = $this->_aConfig["dataDir"];
                        $aConfig["tmpDir"] = $this->_aConfig["tmpDir"];
                        $this->_oVcs = new vcs($aConfig);
                        if ($this->_sBranchname) {
                            if (method_exists($this->_oVcs, "setCurrentBranch")) {
                                $this->_oVcs->setCurrentBranch($this->_sBranchname);
                            }
                        }
                    }
                }
            }
            // return $this->_oVcs;
        }
    
        /**
         * Get an array of enabled plugins
         * @param string  $sSection  one of false|"rollout"|...
         * @return array
         */
        public function getConfiguredPlugins(string $sSection = ''): array
        {
            $aReturn = [];
            if (!$sSection) {
                $aReturn = $this->_aConfig["plugins"];
            } else {
                foreach ($this->_aConfig["plugins"][$sSection] as $sPluginName => $aItem) {
                    $aReturn[$sPluginName] = $aItem;
                }
            }
            return $aReturn;
        }
    
        /**
         * Get a location of a plugin file with full path
         * 
         * @param string  $sType         type of plugin, i.e. "rollout"
         * @param string  $sPluginName   Name of plugin
         * @return string
         */
        protected function _getPluginFilename(string $sType, string $sPluginName): string
        {
            return __DIR__ . '/../plugins/' . $sType . '/' . $sPluginName . '/' . $sType . '_' . $sPluginName . '.php';
        }
    
        /**
         * Get a flat array of all existing ssh keys
         * @return array
         */
        protected function _getSshKeys(): array
        {
            $aReturn = [];
            foreach (glob($this->_aConfig["dataDir"] . "/sshkeys/*.pub") as $filename) {
                $aReturn[] = str_replace(".pub", "", basename($filename));
            }
            sort($aReturn);
            return $aReturn;
        }
    
        /**
         * Get a flat array with regexes of deploy times.
         * It returns false if the given phase is not active
         * 
         * @param string $sPhase  phase
         * @return bool|array
         */
        protected function _getDeploytimes(string $sPhase): bool|array
        {
            if (!$this->isActivePhase($sPhase)) {
                $sError = sprintf(t("class-project-warning-phase-not-active"), $sPhase);
                $this->_logaction($sError, __FUNCTION__, "error");
                return false;
            }
    
            $aDeploytimes = $this->_aConfig["phases"][$sPhase]["deploytimes"] ?? [];
    
            if (
                isset($this->_aPrjConfig["phases"][$sPhase]["deploytimes"])
                && $this->_aPrjConfig["phases"][$sPhase]["deploytimes"]
            ) {
                $aDeploytimes = [$this->_aPrjConfig["phases"][$sPhase]["deploytimes"]];
            }
            return $aDeploytimes;
        }
    
        // ----------------------------------------------------------------------
        // SETTER
        // ----------------------------------------------------------------------
    
        /**
         * Generate a filename for the process output.
         * The last output file will be deleted if it exists.
         * It returns false if no pram for new basename was given.
         * Otherwise it returns a filename with full path in temp folder.
         * 
         * @param string $sNewTempfile
         * @return bool|string
         */
        protected function _setProcessOutFile(string $sNewTempfile = ''): bool|string
        {
            if ($this->_sProcessTempOut && file_exists($this->_sProcessTempOut)) {
                unlink($this->_sProcessTempOut);
            }
            // $sNewTempfile = sys_get_temp_dir() . "/" . basename($sNewTempfile);
            $this->_sProcessTempOut = $sNewTempfile ? sys_get_temp_dir() . "/" . basename($sNewTempfile) : false;
            return $this->_sProcessTempOut;
        }
    
        /**
         * Get projects from ldap; it returns ldap search items with cn as 
         * array key.
         * It returns false if no result was found
         * 
         * @param string  $sSearchFilter  LDAP search filter
         * @return bool|array
         */
        protected function _ldapProjectSearch(string $sSearchFilter): bool|array
        {
            $aReturn = [];
            require_once("ldap.class.php");
            $oLdapIML = new imlldap($this->_aConfig['projects']['ldap']);
            // $oLdapIML->debugOn();
            $aResultsIml = $oLdapIML->searchDn(
                $this->_aConfig['projects']['ldap']['DnProjects'],
                $sSearchFilter,
                ["*"]
            );
            if (!$aResultsIml['count']) {
                return false;
            }
            $oLdapIML->close();
    
            /*
              unset($aResultsIml['count']);
              foreach ($aResultsIml as $aItem) {
              $aReturn[$aItem['cn'][0]] = [
              'dn' => $aItem['dn'],
              'cn' => $aItem['cn'][0],
              '_description' => $aItem['description'][0],
              'title' => $sTitle,
              'description' => $sDescription,
              ];
              }
              $oLdapIML->close();
              ksort($aReturn);
              return $aReturn;
             * 
             */
            return $aResultsIml;
        }
    
        /**
         * Load config of a project with given project id and turn a bool for success
         * 
         * @param  string $sId  new project id of project to load
         * @return boolean Success
         */
        public function setProjectById(string $sId): bool
        {
            if ($sId !== preg_replace('/[^a-z0-9\-\_]/i', '', $sId)) {
                $this->_errors[] = sprintf(t("class-project-error-config-wrongid"), htmlentities($sId));
                return false;
            }
            $this->_aPrjConfig = [];
            $this->_aConfig["id"] = $sId;
            $this->_errors = [];
    
            if (isset($this->_aConfig['projects']['json']['active']) && $this->_aConfig['projects']['json']['active']) {
                $sCfgfile = $this->_getConfigFile($sId);
                if (!$sCfgfile || !file_exists($sCfgfile)) {
                    return false;
                }
                $_aPrjConfigTmp = json_decode(file_get_contents($this->_getConfigFile($sId)), true);
                if (!$_aPrjConfigTmp) {
                    $this->_errors[] = sprintf(t("class-project-error-config-invalid"), $sId, $this->_getConfigFile($sId));
                    return false;
                }
                $this->_aPrjConfig = $_aPrjConfigTmp;
            }
            if (isset($this->_aConfig['projects']['ldap']['active']) && $this->_aConfig['projects']['ldap']['active']) {
                // TODO: read project after saving it - @see $this->saveConfig()
                $sQuery = '(&(objectclass=hieraSource)(documentIdentifier=' . $sId . '))';
                $aResult = $this->_ldapProjectSearch($sQuery);
                // echo '<pre>$aResult = ' . print_r($aResult, 1) . '</pre>';
                if (isset($aResult[0]['hieradata'])) {
                    foreach ($aResult[0]['hieradata'] as $sLine) {
                        // echo $sLine.'<br>';
                        if (preg_match('/^cfg=/', $sLine)) {
    
                            // echo $sLine.'<br>';
                            $this->_aPrjConfig = json_decode(preg_replace('/^cfg=/', '', $sLine), 1);
                        }
                    }
                }
                // return $this->objAdd($sDn, $aItem);
            }
    
            // $aData=json_decode(file_get_contents($this->_getConfigFile($sId)), true);
            // echo "<pre>" . print_r($aData, true) . "</pre>";
    
            $this->_verifyConfig();
    
            // ----- init rollout plugin
            // set name of the activated plugin for this project
            $sPluginId = (isset($this->_aPrjConfig['deploy']['enabled_rollout_plugin']) && $this->_aPrjConfig['deploy']['enabled_rollout_plugin'])
                ? $this->_aPrjConfig['deploy']['enabled_rollout_plugin']
                : 'default';
            $sPluginName=$this->_aConfig['plugins']['rollout'][$sPluginId]['plugin'];
            unset($this->oRolloutPlugin);
            try {
                require_once $this->_getPluginFilename('rollout', $sPluginName);
                $sPluginClassname = 'rollout_' . $sPluginName;
                $this->oRolloutPlugin = new $sPluginClassname([
                    'id' => $sPluginId,
                    'lang' => $this->_aConfig['lang'],
                    'phase' => false,
                    'globalcfg' => isset($this->_aConfig['plugins']['rollout'][$sPluginId]) ? $this->_aConfig['plugins']['rollout'][$sPluginId] : [],
                    'projectcfg' => $this->_aPrjConfig,
                ]);
                // print_r($this->_oRolloutPlugin->getPluginfos());
                // print_r($this->_oRolloutPlugin->getName());
            } catch (Exception $ex) {
            }
            return true;
        }
    
        /**
         * set a branchname
         * @param string $sBranchname name of the branch, i.e. "origin/master"
         * @return string
         */
        public function setBranchname(string $sBranchname): string
        {
            $this->_sBranchname = $sBranchname;
            if (isset($this->_oVcs) && $this->_oVcs) {
                if (method_exists($this->_oVcs, "setCurrentBranch")) {
                    $this->_oVcs->setCurrentBranch($sBranchname);
                }
            }
            return $this->_sBranchname;
        }
    
        // ----------------------------------------------------------------------
        // ACTIONS
        // ----------------------------------------------------------------------
    
        /**
         * Store data to a tempfile (read by for ajax polling) and update actions box
         * 
         * @param  string  $sData     full output of all so far executed shell commands
         * @param  array   $aActions  for right output box: Array of actions with marker of current action
         *                            see build() method for the structure
         * @return bool|int
         */
        protected function _TempFill(string $sData, array $aActions = []): bool|int
        {
            if (!$this->_sProcessTempOut) {
                return false;
            }
            $sActions = '';
            if (count($aActions)) {
                for ($i = 0; $i < count($aActions["actions"]); $i++) {
                    $sActions .= '<li';
                    if ($i == $aActions["iActive"]) {
                        $sActions .= ' class="active"';
                    }
                    $sActions .= '>' . $aActions["actions"][$i]['label'] . '</li>';
                }
                if ($sActions) {
                    $sData = '<div style="float: right; background: #f8f8f8; padding: 1em;">'
                        . '<strong>' . $aActions["label"] . '</strong>'
                        . '<ol class="actions">'
                        . $sActions
                        . '</ol></div>'
                        . $sData;
                }
            }
            return file_put_contents($this->_sProcessTempOut, $sData);
        }
    
        /**
         * Delete tempfile for ajax polling; if a directory is given as parameter
         * the tmp file will be moved there.
         * 
         * @param string  $sTempDir  optional; target dir to copy; default=false (=delete file)
         * @return boolean
         */
        protected function _TempDelete(string $sTempDir = ''): bool
        {
            if (!$this->_sProcessTempOut) {
                return false;
            }
            if (file_exists($this->_sProcessTempOut)) {
                if ($sTempDir && is_dir($sTempDir)) {
                    $sKeepOutfile = $sTempDir . '/_output.html';
                    copy($this->_sProcessTempOut, $sKeepOutfile);
                }
                unlink($this->_sProcessTempOut);
            }
            return file_exists($this->_sProcessTempOut);
        }
    
        /**
         * Get the name of the current branch (or default branch).
         * It returns false if the vcs was not initialized yet.
         * 
         * @return string|bool
         */
        public function getBranchname(): string|bool
        {
            $this->log(__FUNCTION__ . " start");
            $this->_initVcs();
            if (isset($this->_oVcs) && $this->_oVcs) {
                if (method_exists($this->_oVcs, "getCurrentBranch")) {
                    $sCurrentBranch = $this->_oVcs->getCurrentBranch(true); // true means search for master branch if empty
                    if ($sCurrentBranch) {
                        $this->setBranchname($sCurrentBranch);
                        return $sCurrentBranch;
                    }
                }
            }
            return false;
        }
    
        /**
         * Build a new package for the deployment. It will be put to the queue
         * of the first active phase (i.e. preview).
         * If there is no deployment time range it will be deployed too.
         * 
         * @global string $sTmpFile
         * @return boolean|string false or HTML code
         */
        public function build(string $sTmpFile = ''): bool|string
        {
            $this->log(__FUNCTION__ . " start");
            if (!$this->oUser->hasPermission("project-action-build")) {
                return $this->oUser->showDenied();
            }
            global $aParams;
            $sReturn = false;
    
            $aActionList = [
                'iActive' => 0,
                'label' => t('build'),
                'actions' => [
                    ['label' => t('class-project-build-label-cleanup-builds')],
                    ['label' => t('class-project-build-label-create-workdir')],
                    ['label' => t('class-project-build-label-get-sources-from-version-control')],
                    ['label' => t('class-project-build-label-execute-hook-postclone')],
                    ['label' => t('class-project-build-label-copy-default-structure')],
                    ['label' => t('class-project-build-label-execute-hook-precompress')],
                    ['label' => t('class-project-build-label-cleanup-project')],
                    ['label' => t('class-project-build-label-create-package')],
                    ['label' => t('class-project-build-label-remove-workdir')],
                    ['label' => t('class-project-build-label-queue-to-first-active-phase')],
                ],
            ];
            $this->_setProcessOutFile($sTmpFile);
    
            $this->_iRcAll = 0;
            // return $this->_execAndSend("bash --login -c 'ruby --version' " . $sTempDir);
    
            $this->_logaction(t('starting') . " build()", __FUNCTION__);
            $sReturn = "<h2>" . t("build") . " " . $this->getLabel() . "</h2>";
    
            // --------------------------------------------------
            // cleanup
            // --------------------------------------------------
            $aDirs = $this->cleanupBuilds();
            if (count($aDirs)) {
                $sReturn .= '<h3>' . t('class-project-build-label-cleanup-builds') . '</h3><pre>' . print_r($aDirs, true) . '</pre>';
            }
            $aActionList['iActive']++;
            $this->_TempFill($sReturn, $aActionList);
    
            $this->_initVcs();
            if (!isset($this->_oVcs) || !$this->_oVcs) {
                $sError = sprintf(t('class-project-error-build-type-not-supported'), $this->_aPrjConfig["build"]["type"]);
                $this->_logaction($sError, __FUNCTION__, "error");
                return $this->_oHtml->getBox("error", $sError . $sReturn);
            }
    
            // --------------------------------------------------
            // create workdir
            // --------------------------------------------------
            $sTempBuildDir = $this->_getTempDir();
            $sFirstLevel = $this->getNextPhase();
            if (!$sFirstLevel) {
                $this->_TempDelete();
                $this->_logaction(t('page-overview-no-phase'), __FUNCTION__, "error");
                return false;
            }
    
            $sReturn .= '<h3>' . t('class-project-build-label-create-workdir') . '</h3>';
            if (!file_exists($sTempBuildDir)) {
                $sReturn .= $this->_execAndSend("mkdir -p " . $sTempBuildDir);
            }
            $sReturn .= $this->_execAndSend("ls -ld " . $sTempBuildDir);
            if (!file_exists($sTempBuildDir)) {
                $this->_TempDelete();
                $sError = sprintf(t('"class-project-error-build-dir-was-not-created"'), $sTempBuildDir);
                $this->_logaction($sError, __FUNCTION__, "error");
                return $this->_oHtml->getBox("error", $sError . $sReturn);
            }
            // $this->_iRcAll = 0;
            $aActionList['iActive']++;
            $this->_TempFill($sReturn, $aActionList);
    
            // --------------------------------------------------
            // checkout
            // --------------------------------------------------
            $sReturn .= '<h3>' . t('class-project-build-label-get-sources-from-version-control') . '</h3>';
    
            $sCheckout=$this->_oVcs->getSources($sTempBuildDir);
            $sReturn .= '<pre>' . $sCheckout . '</pre>';
    
            // fetch last line "rc=NNN"
            preg_match("/rc=([0-9]+)$/", $sCheckout, $aMatches);
            $iRc=$aMatches[1] ?? 0;
            if ($iRc != 0) {
                return $this->_oHtml->getBox("error", "" . $sReturn);
            }
    
    
            $aRepodata = $this->getRepoRevision();
            $sRevisionShort = substr($aRepodata['revision'], 0, 8);
    
            $sReturn .= $this->_oHtml->getBox("info", t('commitmessage') . '<pre>' . htmlentities($aRepodata['message']) . '</pre>');
            $sReturn .= $this->_execAndSend("ls -lisa $sTempBuildDir");
    
            if (!$this->_iRcAll == 0) {
                $sError = sprintf(t('class-project-error-command-failed'), $sTempBuildDir);
                $this->_logaction($sError, __FUNCTION__, "error");
                $this->_TempFill($sError . $sReturn, $aActionList);
                $this->_TempDelete($sTempBuildDir);
                return $this->_oHtml->getBox("error", $sError . $sReturn);
            }
    
            // --------------------------------------------------
            foreach (glob($sTempBuildDir . '/hooks/on*') as $filename) {
                $sReturn .= 'chmod 755 ' . $filename . '<br>';
                $sReturn .= $this->_execAndSend('chmod 755 ' . $filename);
                $sReturn .= $this->_execAndSend('ls -l ' . $filename);
            }
            // --------------------------------------------------
            $sCfgout = $sTempBuildDir . '/ci-custom-vars';
            $sCfgContent = '';
            if (isset($this->_aPrjConfig['deploy']["configfile"]) && $this->_aPrjConfig['deploy']["configfile"]) {
    
                # task#5047 - FIX EOL
                $sCfgContent .= $this->_aPrjConfig['deploy']["configfile"];
                // detect unix, linux, mac
                if (DIRECTORY_SEPARATOR === '/') {
                    $sCfgContent = str_replace("\r\n", "\n", $sCfgContent);
                }
                # /task#5047
    
            }
            $aCivars = [
                'branch' => $aRepodata['branch'],
                'branch_short' => $aRepodata['shortname'],
                'branch_type' => $aRepodata['type'],
                'revision' => $aRepodata['revision'],
                'revision_short' => $sRevisionShort,
                'imagepart' => $aRepodata['type'] == 'tags'
                    ? $aRepodata['shortname']
                    : $sRevisionShort
                ,
                // 'message'=>$aRepodata['message'],
            ];
    
            $sCfgContent .= "\n"
                . "# ---------- generated CI SERVER variables\n"
                . "\n"
            ;
            foreach ($aCivars as $sKey => $value) {
                $sCfgContent .= "export CI_$sKey=\"$value\"; \n";
            }
    
            file_put_contents($sCfgout, $sCfgContent);
            $sReturn .= $this->_execAndSend('ls -l ' . $sCfgout);
            $sReturn .= $this->_execAndSend('cat ' . $sCfgout);
    
            $sReturn .= $this->_oHtml->getBox("success", t('class-project-info-build-checkout-ok'));
            $aActionList['iActive']++;
            $this->_TempFill($sReturn, $aActionList);
    
    
            // --------------------------------------------------
            // execute hook postclone
            // --------------------------------------------------
            // task#1726 - add environment
            $sSetEnv = ''
                . 'export GIT_SSH="' . $this->_aConfig['appRootDir'] . '/shellscripts/gitsshwrapper.sh";'
                . 'export DIR_SSH_KEYS="' . $this->_aConfig['dataDir'] . '/sshkeys";'
                . 'export DIR_APPROOT="' . $sTempBuildDir . '";'
                . 'export NVMINIT="' . $this->_aConfig['appRootDir'] . '/shellscripts/nvm_init.sh";'
                . (isset($this->_aConfig['build']['env']) ? $this->_aConfig['build']['env'] : '');
    
            $sHookfile = $this->_aConfig['build']['hooks']['build-postclone'];
            $sReturn .= '<h3>' . t('class-project-build-label-execute-hook-postclone') . ' (' . $sHookfile . ')</h3>';
    
            if (file_exists($sTempBuildDir . '/' . $sHookfile)) {
                // $sReturn.=$this->_execAndSend('chmod 755 ' . $sTempDir . '/hooks/on*');
                // $this->_iRcAll = 0;
                $sReturn .= $this->_execAndSend('bash --login -c \'' . $sSetEnv . ' ' . $sTempBuildDir . '/' . $sHookfile . '\'');
                if (!$this->_iRcAll == 0) {
                    $sError = sprintf(t('class-project-error-command-failed'), $sTempBuildDir);
                    $this->_logaction($sError, __FUNCTION__, "error");
                    $this->_TempFill($sError . $sReturn, $aActionList);
                    $this->_TempDelete($sTempBuildDir);
                    return $this->_oHtml->getBox("error", $sError . $sReturn);
                }
            } else {
                $sReturn .= t('skip') . '<br>';
            }
            $aActionList['iActive']++;
            $this->_TempFill($sReturn, $aActionList);
    
    
            // --------------------------------------------------
            // copy default structure
            // --------------------------------------------------
            $sReturn .= '<h3>' . t('class-project-build-label-copy-default-structure') . '</h3>';
            if ($this->_getDefaultsDir()) {
                $sReturn .= $this->_execAndSend("find " . $this->_getDefaultsDir() . " | head -15");
                $sReturn .= $this->_execAndSend("rsync -r " . $this->_getDefaultsDir() . "/* $sTempBuildDir");
                // $sReturn.=$this->_execAndSend("find $sTempDir");
            } else {
                $sReturn .= t('skip') . '<br>';
            }
            $aActionList['iActive']++;
            $this->_TempFill($sReturn, $aActionList);
    
            // --------------------------------------------------
            // execute hook
            // --------------------------------------------------
            $sHookfile = $this->_aConfig['build']['hooks']['build-precompress'];
            $sReturn .= '<h3>' . t('class-project-build-label-execute-hook-precompress') . ' (' . $sHookfile . ')</h3>';
            if (file_exists($sTempBuildDir . '/' . $sHookfile)) {
                // $sReturn.=$this->_execAndSend('chmod 755 ' . $sTempDir . '/hooks/on*');
                // $this->_iRcAll = 0;
                $sReturn .= $this->_execAndSend('bash --login -c \'' . $sSetEnv . ' ' . $sTempBuildDir . '/' . $sHookfile . '\'');
                if (!$this->_iRcAll == 0) {
                    $sError = sprintf(t('class-project-error-command-failed'), $sTempBuildDir);
                    $this->_logaction($sError, __FUNCTION__, "error");
                    $this->_TempFill($sError . $sReturn, $aActionList);
                    $this->_TempDelete($sTempBuildDir);
                    return $this->_oHtml->getBox("error", $sError . $sReturn);
                }
            } else {
                $sReturn .= t('skip') . '<br>';
            }
            $aActionList['iActive']++;
            $this->_TempFill($sReturn, $aActionList);
    
    
            // --------------------------------------------------
            // cleanup .git, .svn, ...
            // --------------------------------------------------
            $sReturn .= '<h3>' . t('class-project-build-label-cleanup-project') . '</h3>';
            if (isset($this->_oVcs) && $this->_oVcs) {
                $this->_oVcs->cleanupWorkdir($sTempBuildDir);
            }
    
            // $sReturn.=$this->_execAndSend("cd $sTempDir && rm -rf .git");
            // $sReturn.=$this->_execAndSend("cd $sTempDir && rm -rf .svn");
            // $sReturn.=$this->_execAndSend("find  $sTempDir -type d -name '.svn' -exec rm -rf {} \;");
            $aActionList['iActive']++;
            $this->_TempFill($sReturn, $aActionList);
    
            // --------------------------------------------------
            // create package
            // --------------------------------------------------
            $sReturn .= '<h3>' . t('class-project-build-label-create-package') . '</h3>';
            // public_html must exist
            if (isset($this->_aPrjConfig["build"]['haspublic'][0])) {
                $sWebroot = false;
                $sWebroot1 = "$sTempBuildDir/public_html";
                $sWebroot2 = "$sTempBuildDir/public";
                if (file_exists($sWebroot1)) {
                    $sWebroot = $sWebroot1;
                }
                if (file_exists($sWebroot2)) {
                    $sWebroot = $sWebroot2;
                }
    
                if (!$sWebroot) {
                    $sError = t('class-project-error-build-docroot-not-found');
                    $this->_logaction($sError, __FUNCTION__, "error");
                    $this->_TempFill($sError . $sReturn, $aActionList);
                    $this->_TempDelete($sTempBuildDir);
                    return $this->_oHtml->getBox("error", $sError . $sReturn . $sError);
                }
            }
            if (!$this->_iRcAll == 0) {
                $sError = sprintf(t('class-project-error-command-failed'), $sTempBuildDir);
                $this->_logaction($sError, __FUNCTION__, "error");
                $this->_TempFill($sError . $sReturn, $aActionList);
                $this->_TempDelete($sTempBuildDir);
                return $this->_oHtml->getBox("error", $sError . $sReturn);
            }
            // $sReturn.=$this->_oHtml->getBox("success", "preparations ok - directory is ready for packaging now.");
    
            // --- generate info file
            $sTs = date("Y-m-d H:i:s");
            $sTs2 = date("Ymd_His");
            // $sBranch = ($this->_sBranchname ? $this->_sBranchname : t("defaultbranch"));
            $sInfoFileWebroot = $sTempBuildDir . '/' . basename($this->_getInfofile($sFirstLevel, "deployed"));
            $sInfoFileArchiv = $this->_getArchiveDir($sTs2) . '/' . basename($this->_getInfofile($sFirstLevel, "deployed"));
            $sPackageFileArchiv = $this->_getArchiveDir($sTs2) . '/' . basename($this->_getPackagefile($sFirstLevel, "deployed"));
    
            $aInfos = [
                'date' => $sTs,
                'version' => $sTs2,
                // 'branch' => $sBranch,
                'branch' => $aRepodata['branch'],
                'branch_short' => $aRepodata['shortname'],
                'branch_type' => $aRepodata['type'],
                'revision' => $aRepodata['revision'],
                'revision_short' => $sRevisionShort,
    
                'imagepart' => $aCivars['imagepart'],
    
                'message' => $aRepodata['message'],
            ];
            /*
              "user": "' . $aParams["inputUser"] . '",
              "remark": "' . $aParams["inputComment"] . '"
             */
    
            $sReturn .= t("class-project-info-build-write-meta-to-webroot") . "<br>";
            // file_put_contents($sInfoFileWebroot, $sInfos);
            file_put_contents($sInfoFileWebroot, json_encode($aInfos));
            $sReturn .= $this->_execAndSend("ls -l $sInfoFileWebroot");
            $sReturn .= $this->_execAndSend("cat $sInfoFileWebroot");
    
            if (!file_exists(dirname($sPackageFileArchiv))) {
                $sReturn .= sprintf(t("creating-directory"), dirname($sPackageFileArchiv)) . "<br>";
                mkdir(dirname($sPackageFileArchiv), 0775, true);
            }
            $sReturn .= $this->_execAndSend("ls -ld " . dirname($sPackageFileArchiv));
            if (!file_exists(dirname($sPackageFileArchiv))) {
    
                $sError = sprintf(t('"class-project-error-build-dir-was-not-created"'), $sTempBuildDir);
                $this->_logaction($sError, __FUNCTION__, "error");
                $this->_TempFill($sError . $sReturn, $aActionList);
                $this->_TempDelete($sTempBuildDir);
                return $this->_oHtml->getBox("error", $sError . $sReturn);
            }
            $this->_TempFill($sReturn, $aActionList);
    
            // ----- loop over enabled build plugins
            // WIP
            // set name of the activated plugin for this project
            $aPlugins = (isset($this->_aPrjConfig['build']['enabled_build_plugins']) && $this->_aPrjConfig['build']['enabled_build_plugins'])
                ? $this->_aPrjConfig['build']['enabled_build_plugins']
                : ['tgz'];
            foreach ($aPlugins as $sPluginName) {
                $oPlugin = false;
                $sReturn .= '<h4>' . $sPluginName . '</h4>';
                try {
                    include_once $this->_getPluginFilename('build', $sPluginName);
                    $sPluginClassname = 'build_' . $sPluginName;
                    $oPlugin = new $sPluginClassname([
                        'lang' => $this->_aConfig['lang'],
                        'workdir' => $sTempBuildDir,
                        'outfile' => $sPackageFileArchiv,
                    ]);
                } catch (Exception $ex) {
                    return $this->_oHtml->getBox(
                        "error",
                        "FAILED to initialize build plugin " . $sPluginName . '<br>'
                        . $sReturn
                    );
                }
    
                $sReturn .= sprintf(t("creating-file"), $oPlugin->getOutfile()) . "<br>";
                foreach ($oPlugin->checkRequirements() as $sCommand) {
                    $sReturn .= $this->_execAndSend($sCommand);
                    $this->_TempFill($sReturn, $aActionList);
                }
    
                foreach ($oPlugin->getBuildCommands() as $sCommand) {
                    $sReturn .= $this->_execAndSend($sCommand);
                    $this->_TempFill($sReturn, $aActionList);
                }
            }
    
            // write info file (.json)
            $sReturn .= sprintf(t("creating-file"), $sInfoFileArchiv) . "<br>";
            // file_put_contents($sInfoFileArchiv, $sInfos);
            file_put_contents($sInfoFileArchiv, json_encode($aInfos));
    
            // copy template files
            if (file_exists($sTempBuildDir . '/hooks/templates/')) {
                $sReturn .= t("class-project-info-build-write-templatefiles-to-archive") . "<br>";
                $sReturn .= $this->_execAndSend("cp $sTempBuildDir/hooks/templates/* " . $this->_getArchiveDir($sTs2));
            } else {
                $sReturn .= t("class-project-info-build-write-templatefiles-to-archive-skipped") . "<br>";
            }
            $this->_TempFill($sReturn, $aActionList);
    
            $sReturn .= "<br>" . t("info") . ":<br>";
            $sReturn .= $this->_execAndSend("ls -l " . $this->_getArchiveDir($sTs2));
    
            // TEST
            // $this->_iRcAll=1;
            if (!$this->_iRcAll == 0) {
                $sError = t('class-project-error-build-packaging-failed');
                $this->_logaction($sError, __FUNCTION__, "error");
                $this->_TempFill($sError . $sReturn, $aActionList);
                $this->_TempDelete($sTempBuildDir);
                return $this->_oHtml->getBox("error", $sError . $sReturn);
            }
            $aActionList['iActive']++;
            $this->_TempFill($sReturn, $aActionList);
    
            $sReturn .= '<h3>' . t("class-project-build-label-remove-workdir") . '</h3>';
            $sReturn .= $this->_execAndSend("rm -rf $sTempBuildDir");
            $sReturn .= t("class-project-info-build-remove-oldest-archives");
            $sReturn .= '<pre>' . print_r($this->cleanupArchive(), true) . '</pre>';
    
            $sInfo = t("class-project-info-build-successful");
            $this->_logaction(t('finished') . ' ' . $sInfo, __FUNCTION__, "success");
            $sReturn .= $this->_oHtml->getBox("success", $sInfo);
            $aActionList['iActive']++;
            $this->_TempFill($sReturn, $aActionList);
    
            $sReturn .= $this->queue($sFirstLevel, $sTs2);
            $this->_TempDelete();
            $this->_setProcessOutFile(false);
    
    
            return $sReturn;
        }
    
        /**
         * Put a packaged version into the queue of a specified phase
         * 
         * @param string $sPhase    name of the phase that gets the new version
         * @param string $sVersion  version 
         * @return string The HTML code
         */
        public function queue(string $sPhase, string $sVersion): string
        {
            $aActionList = [
                'iActive' => 0,
                'label' => t("queue"),
                'actions' => [
                    ['label' => t("class-project-queue-label-checks")],
                    ['label' => t("class-project-queue-label-remove-existing-version")],
                    ['label' => t("class-project-queue-label-link-new-version")],
                    ['label' => t("class-project-queue-label-deploy")],
                ],
            ];
            $this->_logaction(t('starting') . " queue($sPhase, $sVersion)", __FUNCTION__);
            $sReturn = "<h2> " . t("queue") . " " . $this->getLabel() . " :: $sPhase</h2>";
            $this->_TempFill($sReturn, $aActionList);
    
            if (!$this->isActivePhase($sPhase)) {
                $sError = sprintf(t("class-project-warning-phase-not-active"), $sPhase);
                $this->_logaction($sError, __FUNCTION__, "error");
                return $this->_oHtml->getBox("error", $sError . $sReturn);
            }
    
            $sPlace = "onhold";
    
            $sLinkTarget = $this->_getArchiveDir($sVersion);
            $sLinkName = $this->_getFileBase($sPhase, $sPlace);
    
            // --------------------------------------------------
            // Checks
            // --------------------------------------------------
            if (!$sLinkName) {
                die(t("class-project-error-queue-sLinkName-is-empty"));
            }
            if (!$sLinkTarget) {
                die(t("class-project-error-queue-sLinkTarget-is-empty"));
            }
            if (!file_exists($sLinkTarget)) {
                die(sprintf(t("class-project-error-queue-wrong-version"), $sVersion, $sLinkTarget));
            }
            $aActionList['iActive']++;
            $this->_TempFill($sReturn, $aActionList);
    
            // --------------------------------------------------
            // remove existing version
            // --------------------------------------------------
            $this->_iRcAll = 0;
            if (file_exists($sLinkName)) {
                $sReturn .= t("class-project-queue-label-remove-existing-version") . "<br>";
                $sReturn .= $this->_execAndSend("rm -f $sLinkName");
            }
            $aActionList['iActive']++;
            $this->_TempFill($sReturn, $aActionList);
    
            // --------------------------------------------------
            // create the new link
            // --------------------------------------------------
            $sReturn .= t("class-project-queue-label-link-new-version") . "<br>";
    
            $sReturn .= $this->_execAndSend("ln -s $sLinkTarget $sLinkName");
            $sReturn .= $this->_execAndSend("ls -l $sLinkName | fgrep $sLinkTarget");
            $aActionList['iActive']++;
            $this->_TempFill($sReturn, $aActionList);
    
    
            if (!$this->_iRcAll == 0) {
                $this->_TempDelete();
                $sError = t("class-project-error-command-failed");
                $this->_logaction($sError, __FUNCTION__, "error");
                return $this->_oHtml->getBox("error", $sError . $sReturn);
            }
            $this->_logaction(t('finished') . " queue($sPhase, $sVersion) " . t("class-project-info-queue-successful"), __FUNCTION__);
            $sReturn .= $this->_oHtml->getBox("success", t("class-project-info-queue-successful"));
            $sReturn .= $this->deploy($sPhase);
            $this->_TempDelete();
    
            return $sReturn;
        }
    
        /**
         * Deploy a queued package - this moves the queue into the repo directory.
         * This method checks the deploy times
         * It returns the output to show in browser
         * 
         * @param string $sPhase              the queue of which phase we want to install in server
         * @param bool   $bIgnoreDeploytimes  flag; if true it will override time windows
         * @return string The HTML output
         */
        public function deploy(string $sPhase, bool $bIgnoreDeploytimes = false): string
        {
            $this->log(__FUNCTION__ . " start");
            if (
                !$this->oUser->hasPermission("project-action-deploy") && !$this->oUser->hasPermission("project-action-deploy-$sPhase")
            ) {
                return $this->oUser->showDenied();
            }
            $aActionList = [
                'iActive' => 0,
                'label' => t("deploy"),
                'actions' => [
                    ['label' => t("class-project-deploy-label-checks")],
                    ['label' => t("class-project-deploy-label-activate-queued-version")],
                    ['label' => t("class-project-deploy-label-synch-packages")],
                    ['label' => t("class-project-deploy-label-install-on-target")],
                ],
            ];
            $sReturn = "<h2>" . t("deploy") . " " . $this->getLabel() . " :: $sPhase</h2>";
            $this->_TempFill($sReturn, $aActionList);
    
            if (!$this->isActivePhase($sPhase)) {
                $sError = sprintf(t("class-project-warning-phase-not-active"), $sPhase);
                $this->_logaction($sError, __FUNCTION__, "error");
                return $sReturn . $this->_oHtml->getBox("error", $sError);
            }
    
            $sQueueLink = $this->_getFileBase($sPhase, "onhold");
            $sRepoLink = $this->_getFileBase($sPhase, "ready2install");
    
            // --------------------------------------------------
            // checks
            // --------------------------------------------------
            $sReturn .= "<h3>" . t("class-project-deploy-label-checks") . "</h3>";
    
            $aDeploytimes = $this->_getDeploytimes($sPhase);
            if (count($aDeploytimes)) {
                // check if the a deploy time is reached
                $sNow = date("D H:i:s");
                $sReturn .= sprintf(t("class-project-info-deploy-check-deployment-times"), $sNow) . "<br>";
                $bCanDeploy = false;
                foreach ($aDeploytimes as $sRegex) {
                    $sReturn .= sprintf(t("class-project-info-deploy-test-regex"), $sRegex);
    
                    if (preg_match($sRegex, $sNow)) {
                        $sReturn .= t("ok");
                        $bCanDeploy = true;
                    } else {
                        $sReturn .= t("no");
                    }
                    $sReturn .= "<br>";
                }
                if (!$bCanDeploy) {
                    if (!$bIgnoreDeploytimes) {
                        $sError = t("class-project-info-deploy-time-not-reached");
                        // $this->_logaction($sError, __FUNCTION__);
                        $sReturn .= $this->_oHtml->getBox("info", $sError);
                        $this->_TempDelete();
                        // removed: cronjob sends this message too
                        // $this->_sendMessage($sError."\n".t('phase').': '.$sPhase);
                        return $sReturn;
                    } else {
                        $sReturn .= t("class-project-info-deploy-time-not-reached-and-ignored") . "<br>";
                    }
                } else {
                    $sReturn .= t("class-project-info-deploy-time-ok") . "<br>";
                }
                // if ()
            }
            $this->_logaction(t('starting') . " deploy($sPhase, $bIgnoreDeploytimes)", __FUNCTION__);
            if (!file_exists($sQueueLink)) {
                $sError = sprintf(t("class-project-info-deploy-nothing-in-queue"), $sQueueLink);
                $this->_logaction($sError, __FUNCTION__, "error");
                $sReturn .= $this->_oHtml->getBox("info", $sError);
                $this->_TempDelete();
                $this->_sendMessage($sError . "\n" . t('phase') . ': ' . $sPhase);
                return $sReturn;
            }
            $this->_TempFill($sReturn);
            $aActionList['iActive']++;
            $this->_TempFill($sReturn, $aActionList);
    
    
            // --------------------------------------------------
            // move the queue link to the repo name
            // --------------------------------------------------
            $this->_iRcAll = 0;
            if (file_exists($sRepoLink)) {
                $sReturn .= t("class-project-info-deploy-removing-existing-version") . "<br>";
                $sReturn .= $this->_execAndSend("rm -f $sRepoLink");
            }
            $this->_TempFill($sReturn);
            $sReturn .= t("class-project-info-deploy-moving-queue-to-repo") . "<br>";
            $sReturn .= $this->_execAndSend("mv $sQueueLink $sRepoLink");
    
    
            if (!$this->_iRcAll == 0) {
                $this->_TempDelete();
                $sError = t("class-project-error-command-failed");
                $this->_logaction($sError, __FUNCTION__, "error");
                $sReturn .= $this->_oHtml->getBox("error", $sError . $sReturn);
                $this->_sendMessage($sError . "\n" . t('phase') . ': ' . $sPhase);
                return $sReturn;
            }
    
            $aActionList['iActive']++;
            $this->_TempFill($sReturn, $aActionList);
    
    
            // --------------------------------------------------
            // synch packages
            // --------------------------------------------------
            // $sReturn.=$this->_execAndSend("ln -s $sLinkTarget $sLinkName");
            if (isset($this->_aConfig['mirrorPackages']) && count($this->_aConfig['mirrorPackages'])) {
                foreach ($this->_aConfig['mirrorPackages'] as $sLabel => $aTarget) {
                    $sReturn .= '<h3>' . sprintf(t("class-project-info-deploy-synching-package"), $sLabel) . "</h3>";
                    if (isset($aTarget['type'])) {
                        $sCmd = false;
                        // $sSource=$this->_aConfig["packageDir"]."/$sPhase/*";
                        $sSource = $sRepoLink;
                        $sTarget = $aTarget['target'] . "/$sPhase";
                        switch ($aTarget['type']) {
                            case 'rsync':
                                $sCmd = "ls -l $sSource 2>/dev/null && /usr/bin/rsync --delete -rLvt  $sSource $sTarget";
                                break;
                            default:
                                $sReturn .= sprintf(t("class-project-info-deploy-skip-sync"), $aTarget['type']) . "<br>";
                                break;
                        } // switch
                        if ($sCmd) {
                            /*
                              if ($aTarget['runas']) {
                              $sCmd="su - " . $aTarget['runas'] . " -c \"" . $sCmd . "\"";
                              }
                             * 
                             */
                            $sReturn .= $this->_execAndSend($sCmd);
                            $this->_TempFill($sReturn);
                        }
                    }
                } // foreach
            }
            $aActionList['iActive']++;
            $this->_TempFill($sReturn, $aActionList);
    
    
            // --------------------------------------------------
            // run action to install
            // --------------------------------------------------
            $sDeploymethod = isset($this->_aPrjConfig["phases"][$sPhase]["deploymethod"]) ? $this->_aPrjConfig["phases"][$sPhase]["deploymethod"] : "none";
            // $sTargethosts = array_key_exists("hosts", $this->_aPrjConfig["phases"][$sPhase]) ? $this->_aPrjConfig["phases"][$sPhase]["hosts"] : '';
    
            $sReturn .= '<h3>' . t("class-project-info-deploy-start-by-method") . ' :: ' . $sDeploymethod . '</h3>'
                . '<p>'
                . t("deploymethod-$sDeploymethod") . '<br>'
                // . t("phase-targethosts") . ': ' . ($sTargethosts ? $sTargethosts : t("none"))
                . '</p>';
            if ($sDeploymethod === "none") {
                $sReturn .= t("class-project-info-deploy-start-by-method-skip") . "<br>";
            } else {
    
                $sReturn .= '<p>Plugin: ' . $this->oRolloutPlugin->getId() . '</p>';
    
                foreach ($this->oRolloutPlugin->getDeployCommands($sPhase) as $sCmd) {
                    $sReturn .= $this->_execAndSend("$sCmd");
                }
    
                /*
                $aTargethosts = explode(',', $sTargethosts);
                foreach ($aTargethosts as $sTargethost) {
                    $sReturn.='<h4>' . $sDeploymethod . ' - ' . $sTargethost . '</h4>';
                    $sCmd = '';
                    switch ($sDeploymethod) {
                        case 'puppet':
                            $sCmd = 'ssh ' . $this->_aConfig["installPackages"]["user"]
                                    . '@' . $sTargethost
                                    . ' ' . $this->_aConfig["installPackages"]["command"];
                            break;
                            ;
                        // TODO: we don't have any proxy yet
                        case 'sshproxy__AS_EXAMPLE_ONLY':
                            $sCmd = 'ssh ' . $this->_aConfig["installPackages"]["sshproxy"]["user"]
                                    . '@' . $this->_aConfig["installPackages"]["sshproxy"]["host"]
                                    . ' ' . sprintf($this->_aConfig["installPackages"]["sshproxy"]["command"], $sTargethost);
                            break;
                            ;
                    }
                    if ($sCmd) {
                        // $sReturn.=$sCmd.'<br>';
                        $sReturn.=$this->_execAndSend("$sCmd");
                    }
                }
                 * 
                 */
            }
            $aActionList['iActive']++;
            $this->_TempFill($sReturn, $aActionList);
    
            $sReturn .= "<br>";
    
            if (!$this->_iRcAll == 0) {
                $sWarnlevel = 'error';
                $sMessage = sprintf(t('class-project-info-deploy-failed'), $sPhase);
            } else {
                $sWarnlevel = 'success';
                $sMessage = sprintf(t("class-project-info-deploy-successful"), $sPhase);
            }
            $sReturn .= $this->_oHtml->getBox($sWarnlevel, $sMessage);
            $this->_sendMessage($sMessage);
            $this->_logaction(t('finished') . " deploy($sPhase, $bIgnoreDeploytimes) " . $sMessage, __FUNCTION__, $sWarnlevel);
    
            $this->_TempDelete();
            return $sReturn;
        }
    
        /**
         * Accept a the installed version of the given phase and put this version
         * to the queue of the next phase.
         * 
         * @param  string  $sPhase  phase to accept to be rolled out on the next phase
         * @return string The HTML code
         */
        public function accept(string $sPhase): string
        {
            $this->log(__FUNCTION__ . " start");
            if (
                !$this->oUser->hasPermission("project-action-accept") && !$this->oUser->hasPermission("project-action-accept-$sPhase")
            ) {
                return $this->oUser->showDenied();
            }
    
            $sReturn = "<h2>" . t("accept") . " " . $this->getLabel() . " :: $sPhase</h2>";
            $this->_logaction(t('starting') . " accept($sPhase)", __FUNCTION__);
    
            if (!$this->canAcceptPhase($sPhase)) {
                $sError = sprintf(t("class-project-error-accept-impossible"), $sPhase);
                $this->_logaction($sError, __FUNCTION__, "error");
                return $sReturn . $this->_oHtml->getBox("error", $sError);
            }
    
            $sReturn .= "<h3>" . sprintf(t("class-project-info-accept-overview"), $sPhase) . "</h3>";
            $this->_TempFill($sReturn);
            $aInfos = $this->getPhaseInfos($sPhase);
            $sVersion = $aInfos["deployed"]["version"];
            $sNext = $this->getNextPhase($sPhase);
    
    
            // $sReturn.='<pre>' . print_r($aInfos["deployed"], true) . '</pre>';
            $sReturn .= $this->_oHtml->getBox("info", sprintf(t("class-project-info-accept-version-and-next-phase"), $sVersion, $sNext));
            $this->_logaction(t('finished') . " accept($sPhase) " . sprintf(t("class-project-info-accept-version-and-next-phase"), $sVersion, $sNext), __FUNCTION__, "success");
            $sReturn .= $this->queue($sNext, $sVersion);
            $this->_TempFill($sReturn);
            $this->_TempDelete();
    
            return $sReturn;
        }
    
        /**
         * Save data as project config.
         * If no data were given then $_POST is used.
         * It returns bool with success state or a string with deny error message
         * 
         * @param  array  $aData  optional: data to write
         * @return boolean|string
         */
        public function saveConfig(array $aData = []): bool|string
        {
            $this->log(__FUNCTION__ . " start");
            if (!$this->oUser->hasPermission("project-action-setup")) {
                return $this->oUser->showDenied();
            }
            $this->_logaction(t('starting') . " saveConfig(...)", __FUNCTION__);
            if (!count($aData)) {
                $aData = $_POST;
            }
    
            foreach (['id', 'label', 'description', 'contact', 'build', 'fileprefix', 'phases'] as $sKey) {
                if (!isset($aData[$sKey])) {
                    $this->_logaction(t('abortet') . " missing key $sKey in savedata", __FUNCTION__, "error");
                    return false;
                }
            }
            $sId = $aData["id"];
    
            // remove unwanted items
            foreach (["setupaction", "prj", "id"] as $s) {
                if (isset($aData[$s])) {
                    unset($aData[$s]);
                }
            }
    
            // save json file
            if ($this->_aConfig['projects']['json']['active']) {
                // echo "IST <pre>" . print_r($this->_aPrjConfig, true) . "</pre>"; echo "NEU <pre>" . print_r($aData, true) . "</pre>"; die();
                // make a backup of a working config
                $sCfgFile = $this->_getConfigFile($sId);
                $sBakFile = $this->_getConfigFile($sId) . ".ok";
                copy($sCfgFile, $sBakFile);
    
                $bReturn = file_put_contents($sCfgFile, json_encode($aData, JSON_PRETTY_PRINT));
                $this->_aPrjConfig = json_decode(file_get_contents($this->_getConfigFile($sId)), true);
            }
            // save in ldap
            if ($this->_aConfig['projects']['ldap']['active']) {
                // TODO: 
                echo "TODO: save in LDAP<br><pre>" . print_r($aData, 1) . "</pre>";
    
    
                $sDn = 'documentIdentifier=' . $sId . ',' . $this->_aConfig['projects']['ldap']['DnProjects'];
                $aItem = [
                    'objectClass' => [
                        'document',
                        'hieraSource',
                        'top',
                    ],
                    'hieraData' => [
                        'cfg=' . json_encode($aData),
                        'updated=' . date("Y-m-d H:i:s") . ' by ' . $this->oUser->getUsername(),
                    ]
                ];
    
                require_once("ldap.class.php");
                $oLdapIML = new imlldap($this->_aConfig['projects']['ldap']);
                //$oLdapIML->debugOn();
                if (!$oLdapIML->DnExists($sDn)) {
                    if ($oLdapIML->objAdd($sDn, $aItem)) {
                        echo 'OK, created in LDAP.<br>';
                        $bReturn = true;
                    } else {
                        echo 'ERROR, DN ' . $sDn . ' was not created in LDAP :-/<br>';
                        $bReturn = false;
                    }
                } else {
                    if ($oLdapIML->objUpdate($sDn, $aItem)) {
                        echo 'OK, updated in LDAP.<br>';
                        $bReturn = true;
                    } else {
                        echo 'ERROR, DN ' . $sDn . ' was not updated in LDAP :-/<br>';
                        $bReturn = false;
                    }
                }
                $oLdapIML->close();
            }
    
            $this->_logaction(t('finished') . " saveConfig(...)", __FUNCTION__, "success");
            $this->setProjectById($sId);
    
            $sMessage = ($bReturn
                ? t("page-setup-info-settings-were-saved")
                : t("page-setup-error-settings-were-not-saved")
            );
            $this->_sendMessage($sMessage);
            return $bReturn;
        }
    
        /**
         * Create a new project; it returns the error message if it fails and
         * an empty string if it was successful.
         * 
         * @param string  $sId  id
         * @return string
         */
        public function create(string $sId): string
        {
            $this->log(__FUNCTION__ . " start");
            if (!$this->oUser->hasPermission("project-action-create")) {
                return $this->oUser->showDenied();
            }
            $this->_logaction(t('starting') . " create($sId)", __FUNCTION__);
            if (!$sId) {
                $sError = t("class-project-error-create-missing-id");
                $this->_logaction(t('aborted') . " create($sId)" . $sError, __FUNCTION__, "error");
                return $sError;
            }
            $s = preg_replace('/[a-z\-\_0-9]*/', "", $sId);
            if ($s) {
                $sError = sprintf(t("class-project-error-create-wrcng-chars-in-id"), $sId);
                $this->_logaction(t('aborted') . " create($sId)" . $sError, __FUNCTION__, "error");
                return $sError;
            }
            if ($sId == "all") {
                $sError = sprintf(t("class-project-error-create-id-has-reserved-name"), $sId);
                $this->_logaction(t('aborted') . " create($sId)" . $sError, __FUNCTION__, "error");
                return $sError;
            }
            if (array_search($sId, $this->getProjects()) !== false) {
                $sError = sprintf(t("class-project-error-create-id-exists"), $sId);
                $this->_logaction(t('aborted') . " create($sId)" . $sError, __FUNCTION__, "error");
                return $sError;
            }
    
            // reset config and create a skeleton
            $this->_readConfig();
            $this->_aConfig["id"] = $sId;
    
            $this->_aPrjConfig = [
                "id" => $sId, // for saveConfig
                "label" => "$sId",
                "fileprefix" => "$sId",
                "description" => '',
                "contact" => '',
                "build" => [
                    "type" => "",
                    "ssh" => "",
                    "auth" => "",
                    "webaccess" => "",
                ],
                "phases" => [
                    "preview" => [],
                    "stage" => [],
                    "live" => [],
                ],
            ];
            $this->_verifyConfig(); // check skeleton
            $bReturn = $this->saveConfig($this->_aPrjConfig);
            if (!$bReturn) {
                $sError = t("class-project-error-create-save-failed");
                $this->_logaction(t('aborted') . " create($sId)" . $sError, __FUNCTION__, "error");
                return $sError;
            }
    
            // alles OK - dann leeren String
            $this->_logaction(t('finished') . " create($sId)", __FUNCTION__, "success");
            return "";
        }
    
        /**
         * Delete a project; it returns a string with errormessage; empty string = no error
         * 
         * @param array $aOptions  array with enabled actions
         *                         - bRemoveRepolinks
         *                         - bRemoveArchive
         *                         - bRemoveConfig
         * @return string
         */
        public function delete(array $aOptions = []): string
        {
            $this->log(__FUNCTION__ . " start");
            if (!$this->oUser->hasPermission("project-action-delete")) {
                return $this->oUser->showDenied();
            }
            $sCfgfile = $this->_getConfigFile($this->_aConfig["id"]);
            if (!file_exists($sCfgfile)) {
                return t("class-project-error-delete-project-no-configfile");
            }
            $this->_logaction(t('starting') . " delete()", __FUNCTION__);
    
            // ["bRemoveRepolinks", "bRemoveArchive", "bRemoveConfig"]
            // --- remove links in phases directory to built archives
            if (isset($aOptions["bRemoveRepolinks"]) && $aOptions["bRemoveRepolinks"]) {
                echo "DELETE Repo-Links ...<br>";
    
                foreach (array_keys($this->getPhases()) as $sPhase) {
                    foreach (array_keys($this->_aPlaces) as $sPlace) {
                        $sLink = $this->_getFileBase($sPhase, $sPlace);
                        if (file_exists($sLink)) {
                            echo "Removing $sLink ($sPhase - $sPlace)...<br>";
                            if (!unlink($sLink)) {
                                $sError = t("class-project-error-delete-project-deletion-failed-data");
                                $this->_logaction(t('aborted') . " " . $sError, __FUNCTION__);
                                return $sError;
                            }
                        }
                    }
                }
            }
            if (isset($aOptions["bRemoveArchive"]) && $aOptions["bRemoveArchive"]) {
                echo "DELETE built Archives ...<br>";
                $this->cleanupArchive(true); // true to delete all
            }
            if (isset($aOptions["bRemoveConfig"]) && $aOptions["bRemoveConfig"]) {
                echo "DELETE Config ...<br>";
                // echo "config file: $sCfgfile<br>";
                if (file_exists($sCfgfile . ".ok")) {
                    // echo "Delete $sCfgfile.ok<br>";
                    unlink($sCfgfile . ".ok");
                }
                if (file_exists($sCfgfile)) {
                    // echo "Delete $sCfgfile<br>";
                    if (!unlink($sCfgfile)) {
                        $sError = t("class-project-error-delete-project-deletion-failed-configfile");
                        $this->_logaction(t('aborted') . " " . $sError, __FUNCTION__);
                        return $sError;
                    }
                }
            }
            $this->_sendMessage(t('finished') . " delete()");
            $this->_logaction(t('finished') . " delete()", __FUNCTION__, "success");
            return '';
        }
    }