diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml index 5f2506a..c6865b9 100644 --- a/.github/workflows/static-analysis.yml +++ b/.github/workflows/static-analysis.yml @@ -6,6 +6,7 @@ on: branches: - main - develop + - feature/* jobs: build: runs-on: ubuntu-latest @@ -13,14 +14,14 @@ jobs: strategy: matrix: include: - - php: "8.1" - phpunit: "10" - phpunit-config: "phpunit-10.xml.dist" - php: "8.2" phpunit: "11" - phpunit-config: "phpunit.xml.dist" + phpunit-config: "phpunit-11.xml.dist" - php: "8.3" - phpunit: "11" + phpunit: "12" + phpunit-config: "phpunit-12.xml.dist" + - php: "8.4" + phpunit: "13" phpunit-config: "phpunit.xml.dist" steps: diff --git a/.gitignore b/.gitignore index dd969ed..72a28b5 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,5 @@ Tests/data/** !Tests/data/temp/.empty composer.lock .phpunit.cache +GEMINI.md +AGENTS.md diff --git a/Asm/Ansible/Ansible.php b/Asm/Ansible/Ansible.php index 49ec365..c7c38d0 100644 --- a/Asm/Ansible/Ansible.php +++ b/Asm/Ansible/Ansible.php @@ -6,6 +6,8 @@ use Asm\Ansible\Command\AnsibleGalaxy; use Asm\Ansible\Command\AnsibleGalaxyInterface; +use Asm\Ansible\Command\AnsibleGalaxyCollection; +use Asm\Ansible\Command\AnsibleGalaxyCollectionInterface; use Asm\Ansible\Command\AnsiblePlaybook; use Asm\Ansible\Command\AnsiblePlaybookInterface; use Asm\Ansible\Exception\CommandException; @@ -44,8 +46,11 @@ final class Ansible implements LoggerAwareInterface * @param string $playbookCommand path to playbook executable, default ansible-playbook * @param string $galaxyCommand path to galaxy executable, default ansible-galaxy */ - public function __construct(string $ansibleBaseDir, string $playbookCommand = '', string $galaxyCommand = '') - { + public function __construct( + string $ansibleBaseDir, + string $playbookCommand = '', + string $galaxyCommand = '' + ) { $this->ansibleBaseDir = $this->checkDir($ansibleBaseDir); $this->playbookCommand = $this->checkCommand($playbookCommand, 'ansible-playbook'); $this->galaxyCommand = $this->checkCommand($galaxyCommand, 'ansible-galaxy'); @@ -80,6 +85,19 @@ public function galaxy(): AnsibleGalaxyInterface ); } + /** + * AnsibleGalaxyCollection instance creator + * + * @return AnsibleGalaxyCollectionInterface + */ + public function galaxyCollection(): AnsibleGalaxyCollectionInterface + { + return new AnsibleGalaxyCollection( + $this->createProcess($this->galaxyCommand), + $this->logger + ); + } + /** * Set process timeout in seconds. * diff --git a/Asm/Ansible/Collection/AnsibleCollection.php b/Asm/Ansible/Collection/AnsibleCollection.php new file mode 100644 index 0000000..8b71403 --- /dev/null +++ b/Asm/Ansible/Collection/AnsibleCollection.php @@ -0,0 +1,61 @@ +processRunner->run($command); + + if (!$result->isSuccessful()) { + throw new CollectionException( + "Failed to install collection: $collection", + $result->getExitCode(), + null, + implode(' ', $command), + $result->getOutput() + ); + } + + return $result; + } + + public function list(): ProcessResult + { + return $this->processRunner->run(['ansible-galaxy', 'collection', 'list']); + } + + public function uninstall(string $collection): ProcessResult + { + $command = ['ansible-galaxy', 'collection', 'remove', $collection]; + + $result = $this->processRunner->run($command); + + if (!$result->isSuccessful()) { + throw new CollectionException( + "Failed to uninstall collection: $collection", + $result->getExitCode(), + null, + implode(' ', $command), + $result->getOutput() + ); + } + + return $result; + } +} diff --git a/Asm/Ansible/Command/AbstractAnsibleCommand.php b/Asm/Ansible/Command/AbstractAnsibleCommand.php index 41cbefa..eea3887 100644 --- a/Asm/Ansible/Command/AbstractAnsibleCommand.php +++ b/Asm/Ansible/Command/AbstractAnsibleCommand.php @@ -40,7 +40,7 @@ abstract class AbstractAnsibleCommand * @param ProcessBuilderInterface $processBuilder * @param LoggerInterface|null $logger */ - public function __construct(ProcessBuilderInterface $processBuilder, LoggerInterface $logger = null) + public function __construct(ProcessBuilderInterface $processBuilder, ?LoggerInterface $logger = null) { $this->processBuilder = $processBuilder; $this->options = []; @@ -59,7 +59,7 @@ public function __construct(ProcessBuilderInterface $processBuilder, LoggerInter protected function prepareArguments(bool $asArray = true): string|array { $arguments = array_merge( - [$this->getBaseOptions()], + $this->getBaseOptionsAsArray(), $this->getOptions(), $this->getParameters() ); @@ -142,6 +142,16 @@ protected function getBaseOptions(): string return implode(' ', $this->baseOptions); } + /** + * Get base options as array. + * + * @return array + */ + protected function getBaseOptionsAsArray(): array + { + return $this->baseOptions; + } + /** * Check if param is array or string and implode with glue if necessary. * diff --git a/Asm/Ansible/Command/AnsibleGalaxy.php b/Asm/Ansible/Command/AnsibleGalaxy.php index 9c0157d..e39407d 100644 --- a/Asm/Ansible/Command/AnsibleGalaxy.php +++ b/Asm/Ansible/Command/AnsibleGalaxy.php @@ -33,6 +33,7 @@ public function execute(?callable $callback = null): int|string public function init(string $roleName): AnsibleGalaxyInterface { $this + ->addBaseOption('role') ->addBaseOption('init') ->addBaseOption($roleName); @@ -51,6 +52,7 @@ public function info(string $role, string $version = ''): AnsibleGalaxyInterface } $this + ->addBaseOption('role') ->addBaseOption('info') ->addBaseOption($role); @@ -70,10 +72,16 @@ public function install(string|array $roles = ''): AnsibleGalaxyInterface { $roles = $this->checkParam($roles, ' '); + $this->addBaseOption('role'); $this->addBaseOption('install'); if ('' !== $roles) { - $this->addBaseOption($roles); + $rolesArray = explode(' ', $roles); + foreach ($rolesArray as $role) { + if ($role !== '') { + $this->addBaseOption($role); + } + } } return $this; @@ -87,6 +95,7 @@ public function install(string|array $roles = ''): AnsibleGalaxyInterface */ public function modulelist(string $roleName = ''): AnsibleGalaxyInterface { + $this->addBaseOption('role'); $this->addBaseOption('list'); if ('' !== $roleName) { @@ -106,9 +115,17 @@ public function remove(string|array $roles = ''): AnsibleGalaxyInterface { $roles = $this->checkParam($roles, ' '); - $this - ->addBaseOption('remove') - ->addBaseOption($roles); + $this->addBaseOption('role'); + $this->addBaseOption('remove'); + + if ('' !== $roles) { + $rolesArray = explode(' ', $roles); + foreach ($rolesArray as $role) { + if ($role !== '') { + $this->addBaseOption($role); + } + } + } return $this; } diff --git a/Asm/Ansible/Command/AnsibleGalaxyCollection.php b/Asm/Ansible/Command/AnsibleGalaxyCollection.php new file mode 100644 index 0000000..eec8cf5 --- /dev/null +++ b/Asm/Ansible/Command/AnsibleGalaxyCollection.php @@ -0,0 +1,149 @@ +runProcess($callback); + } + + /** + * Initialize a new collection with base structure. + * + * @param string $collectionName + * @return AnsibleGalaxyCollectionInterface + */ + public function init(string $collectionName): AnsibleGalaxyCollectionInterface + { + $this + ->addBaseOption('collection') + ->addBaseOption('init') + ->addBaseOption($collectionName); + + return $this; + } + + /** + * Build an Ansible collection artifact that can be published to Ansible Galaxy. + * + * @param string $collectionPath + * @return AnsibleGalaxyCollectionInterface + */ + public function build(string $collectionPath = ''): AnsibleGalaxyCollectionInterface + { + $this->addBaseOption('collection'); + $this->addBaseOption('build'); + + if ('' !== $collectionPath) { + $this->addBaseOption($collectionPath); + } + + return $this; + } + + /** + * Publish a collection artifact to Ansible Galaxy. + * + * @param string $artifactPath + * @return AnsibleGalaxyCollectionInterface + */ + public function publish(string $artifactPath): AnsibleGalaxyCollectionInterface + { + $this->addBaseOption('collection'); + $this->addBaseOption('publish'); + $this->addBaseOption($artifactPath); + + return $this; + } + + /** + * Install collection(s). + * + * @param string|array $collections collection_name(s) | path/to/collection.tar.gz + * @return AnsibleGalaxyCollectionInterface + */ + public function install(string|array $collections = ''): AnsibleGalaxyCollectionInterface + { + $collections = $this->checkParam($collections, ' '); + + $this->addBaseOption('collection'); + $this->addBaseOption('install'); + + if ('' !== $collections) { + $collectionsArray = explode(' ', $collections); + foreach ($collectionsArray as $collection) { + if ($collection !== '') { + $this->addBaseOption($collection); + } + } + } + + return $this; + } + + /** + * The path in which the skeleton collection will be created. + * The default is the current working directory. + * + * @param string $path + * @return AnsibleGalaxyCollectionInterface + */ + public function initPath(string $path = ''): AnsibleGalaxyCollectionInterface + { + $this->addOption('--init-path', $path); + + return $this; + } + + /** + * Force overwriting an existing collection. + * + * @return AnsibleGalaxyCollectionInterface + */ + public function force(): AnsibleGalaxyCollectionInterface + { + $this->addParameter('--force'); + + return $this; + } + + /** + * The path to the directory containing your collections. + * + * @param string $collectionsPath + * @return AnsibleGalaxyCollectionInterface + */ + public function collectionsPath(string $collectionsPath): AnsibleGalaxyCollectionInterface + { + $this->addOption('--collections-path', $collectionsPath); + + return $this; + } + + /** + * Get parameter string which will be used to call ansible. + * + * @param bool $asArray + * @return string|array + */ + public function getCommandlineArguments(bool $asArray = true): string|array + { + return $this->prepareArguments($asArray); + } +} diff --git a/Asm/Ansible/Command/AnsibleGalaxyCollectionInterface.php b/Asm/Ansible/Command/AnsibleGalaxyCollectionInterface.php new file mode 100644 index 0000000..7111c8a --- /dev/null +++ b/Asm/Ansible/Command/AnsibleGalaxyCollectionInterface.php @@ -0,0 +1,68 @@ +addParameter('--ask-su-pass'); - - return $this; - } - /** * Ask for sudo password. * @@ -118,6 +106,45 @@ public function becomeUser(string $user = 'root'): AnsiblePlaybookInterface return $this; } + /** + * privilege escalation method to use (default=sudo) + * + * @param string $method + * @return AnsiblePlaybookInterface + */ + public function becomeMethod(string $method = 'sudo'): AnsiblePlaybookInterface + { + $this->addOption('--become-method', $method); + + return $this; + } + + /** + * Become password file + * + * @param string $file + * @return AnsiblePlaybookInterface + */ + public function becomePasswordFile(string $file): AnsiblePlaybookInterface + { + $this->addOption('--become-password-file', $file); + + return $this; + } + + /** + * Connection password file + * + * @param string $file + * @return AnsiblePlaybookInterface + */ + public function connectionPasswordFile(string $file): AnsiblePlaybookInterface + { + $this->addOption('--connection-password-file', $file); + + return $this; + } + /** * Don't make any changes; instead, try to predict some of the changes that may occur. * @@ -361,6 +388,18 @@ public function listTasks(): AnsiblePlaybookInterface return $this; } + /** + * List all available tags. + * + * @return AnsiblePlaybookInterface + */ + public function listTags(): AnsiblePlaybookInterface + { + $this->addParameter('--list-tags'); + + return $this; + } + /** * Specify path(s) to module library (default=/usr/share/ansible/). * @@ -465,31 +504,6 @@ public function step(): AnsiblePlaybookInterface return $this; } - /** - * Run operations with su. - * - * @return AnsiblePlaybookInterface - */ - public function su(): AnsiblePlaybookInterface - { - $this->addParameter('--su'); - - return $this; - } - - /** - * Run operations with su as this user (default=root). - * - * @param string $user - * @return AnsiblePlaybookInterface - */ - public function suUser(string $user = 'root'): AnsiblePlaybookInterface - { - $this->addOption('--su-user', $user); - - return $this; - } - /** * Perform a syntax check on the playbook, but do not execute it. * @@ -592,32 +606,6 @@ public function flushCache(): AnsiblePlaybookInterface return $this; } - /** - * the new vault identity to use for rekey - * - * @param string $vaultId - * @return AnsiblePlaybookInterface - */ - public function newVaultId(string $vaultId): AnsiblePlaybookInterface - { - $this->addOption('--new-vault-id', $vaultId); - - return $this; - } - - /** - * new vault password file for rekey - * - * @param string $passwordFile - * @return AnsiblePlaybookInterface - */ - public function newVaultPasswordFile(string $passwordFile): AnsiblePlaybookInterface - { - $this->addOption('--new-vault-password-file', $passwordFile); - - return $this; - } - /** * specify extra arguments to pass to scp only (e.g. -l) * @@ -751,8 +739,12 @@ public function sshPipelining(bool $enable = false): AnsiblePlaybookInterface private function checkInventory(): void { if (!$this->hasInventory) { - $inventory = str_replace('.yml', '', $this->getBaseOptions()); - $this->inventoryFile($inventory); + $baseOptions = $this->getBaseOptionsAsArray(); + $playbook = isset($baseOptions[0]) ? $baseOptions[0] : ''; + $inventory = str_replace('.yml', '', $playbook); + if ($inventory !== '') { + $this->inventoryFile($inventory); + } } } } diff --git a/Asm/Ansible/Command/AnsiblePlaybookInterface.php b/Asm/Ansible/Command/AnsiblePlaybookInterface.php index 6412ecf..299d716 100644 --- a/Asm/Ansible/Command/AnsiblePlaybookInterface.php +++ b/Asm/Ansible/Command/AnsiblePlaybookInterface.php @@ -27,13 +27,6 @@ public function play(string $playbook): AnsiblePlaybookInterface; */ public function askPass(): AnsiblePlaybookInterface; - /** - * Ask for su password. - * - * @return AnsiblePlaybookInterface - */ - public function askSuPass(): AnsiblePlaybookInterface; - /** * Ask for sudo password. * @@ -64,6 +57,30 @@ public function become(): AnsiblePlaybookInterface; */ public function becomeUser(string $user = 'root'): AnsiblePlaybookInterface; + /** + * privilege escalation method to use (default=sudo) + * + * @param string $method + * @return AnsiblePlaybookInterface + */ + public function becomeMethod(string $method = 'sudo'): AnsiblePlaybookInterface; + + /** + * Become password file + * + * @param string $file + * @return AnsiblePlaybookInterface + */ + public function becomePasswordFile(string $file): AnsiblePlaybookInterface; + + /** + * Connection password file + * + * @param string $file + * @return AnsiblePlaybookInterface + */ + public function connectionPasswordFile(string $file): AnsiblePlaybookInterface; + /** * Don't make any changes; instead, try to predict some of the changes that may occur. * @@ -191,28 +208,19 @@ public function listHosts(): AnsiblePlaybookInterface; public function listTasks(): AnsiblePlaybookInterface; /** - * Specify path(s) to module library (default=/usr/share/ansible/). + * List all available tags. * - * @param array $path list of paths for modules * @return AnsiblePlaybookInterface */ - public function modulePath(array $path = ['/usr/share/ansible/']): AnsiblePlaybookInterface; + public function listTags(): AnsiblePlaybookInterface; /** - * the new vault identity to use for rekey - * - * @param string $vaultId - * @return AnsiblePlaybookInterface - */ - public function newVaultId(string $vaultId): AnsiblePlaybookInterface; - - /** - * new vault password file for rekey + * Specify path(s) to module library (default=/usr/share/ansible/). * - * @param string $passwordFile + * @param array $path list of paths for modules * @return AnsiblePlaybookInterface */ - public function newVaultPasswordFile(string $passwordFile): AnsiblePlaybookInterface; + public function modulePath(array $path = ['/usr/share/ansible/']): AnsiblePlaybookInterface; /** * Disable cowsay @@ -267,21 +275,6 @@ public function startAtTask(string $task): AnsiblePlaybookInterface; */ public function step(): AnsiblePlaybookInterface; - /** - * Run operations with su. - * - * @return AnsiblePlaybookInterface - */ - public function su(): AnsiblePlaybookInterface; - - /** - * Run operations with su as this user (default=root). - * - * @param string $user - * @return AnsiblePlaybookInterface - */ - public function suUser(string $user = 'root'): AnsiblePlaybookInterface; - /** * specify extra arguments to pass to scp only (e.g. -l) * diff --git a/Asm/Ansible/Exception/AnsibleException.php b/Asm/Ansible/Exception/AnsibleException.php new file mode 100644 index 0000000..0240dcb --- /dev/null +++ b/Asm/Ansible/Exception/AnsibleException.php @@ -0,0 +1,28 @@ +command; + } + + public function getOutput(): ?string + { + return $this->output; + } +} diff --git a/Asm/Ansible/Exception/CollectionException.php b/Asm/Ansible/Exception/CollectionException.php new file mode 100644 index 0000000..8ba63ac --- /dev/null +++ b/Asm/Ansible/Exception/CollectionException.php @@ -0,0 +1,9 @@ +hosts[$host] = $variables; + return $this; + } + + public function addGroup(string $group, array $hosts = []): self + { + $this->groups[$group] = $hosts; + return $this; + } + + public function setGroupVariables(string $group, array $variables): self + { + $this->variables[$group] = $variables; + return $this; + } + + public function toYaml(): string + { + $inventory = ['all' => ['hosts' => $this->hosts]]; + + foreach ($this->groups as $group => $hosts) { + $inventory['all']['children'][$group] = ['hosts' => []]; + foreach ($hosts as $host) { + $inventory['all']['children'][$group]['hosts'][$host] = null; + } + } + + foreach ($this->variables as $group => $vars) { + if (isset($inventory['all']['children'][$group])) { + $inventory['all']['children'][$group]['vars'] = $vars; + } + } + + return Yaml::dump($inventory); + } + + public function saveToFile(string $filename): void + { + $yaml = $this->toYaml(); + if (file_put_contents($filename, $yaml) === false) { + throw new InventoryException("Failed to save inventory to file: $filename"); + } + } +} diff --git a/Asm/Ansible/Process/ProcessBuilder.php b/Asm/Ansible/Process/ProcessBuilder.php index b1002fe..062fd93 100644 --- a/Asm/Ansible/Process/ProcessBuilder.php +++ b/Asm/Ansible/Process/ProcessBuilder.php @@ -64,6 +64,17 @@ public function setArguments(array $arguments): ProcessBuilderInterface return $this; } + /** + * @param string $path + * @return ProcessBuilderInterface + */ + public function setWorkingDirectory(string $path): ProcessBuilderInterface + { + $this->path = $path; + + return $this; + } + /** * @param int $timeout * @return ProcessBuilderInterface diff --git a/Asm/Ansible/Process/ProcessBuilderInterface.php b/Asm/Ansible/Process/ProcessBuilderInterface.php index 12bb73f..5d68e56 100644 --- a/Asm/Ansible/Process/ProcessBuilderInterface.php +++ b/Asm/Ansible/Process/ProcessBuilderInterface.php @@ -18,6 +18,12 @@ interface ProcessBuilderInterface */ public function setArguments(array $arguments): ProcessBuilderInterface; + /** + * @param string $path + * @return ProcessBuilderInterface + */ + public function setWorkingDirectory(string $path): ProcessBuilderInterface; + /** * @param int $timeout * @return ProcessBuilderInterface diff --git a/Asm/Ansible/Process/ProcessResult.php b/Asm/Ansible/Process/ProcessResult.php new file mode 100644 index 0000000..b1d8227 --- /dev/null +++ b/Asm/Ansible/Process/ProcessResult.php @@ -0,0 +1,38 @@ +exitCode === 0; + } + + public function getExitCode(): int + { + return $this->exitCode; + } + + public function getOutput(): string + { + return $this->output; + } + + public function getErrorOutput(): string + { + return $this->errorOutput; + } +} diff --git a/Asm/Ansible/Process/ProcessRunner.php b/Asm/Ansible/Process/ProcessRunner.php new file mode 100644 index 0000000..0bd32ec --- /dev/null +++ b/Asm/Ansible/Process/ProcessRunner.php @@ -0,0 +1,55 @@ +logger = $logger ?? new NullLogger(); + } + + public function run(array $command, ?string $workingDirectory = null, ?int $timeout = null): ProcessResult + { + $this->logger->info('Executing command: ' . implode(' ', $command)); + + $process = $this->processBuilder->setArguments($command); + + if ($workingDirectory !== null) { + $process->setWorkingDirectory($workingDirectory); + } + + if ($timeout !== null) { + $process->setTimeout($timeout); + } + + $result = $process->getProcess()->run(); + + $processResult = new ProcessResult( + $result, + $process->getProcess()->getOutput(), + $process->getProcess()->getErrorOutput() + ); + + if (!$processResult->isSuccessful()) { + $this->logger->error('Command failed', [ + 'command' => implode(' ', $command), + 'exit_code' => $processResult->getExitCode(), + 'output' => $processResult->getOutput(), + 'error' => $processResult->getErrorOutput() + ]); + } + + return $processResult; + } +} diff --git a/Asm/Ansible/Validation/PlaybookValidator.php b/Asm/Ansible/Validation/PlaybookValidator.php new file mode 100644 index 0000000..daba1df --- /dev/null +++ b/Asm/Ansible/Validation/PlaybookValidator.php @@ -0,0 +1,43 @@ +processRunner->run([ + 'ansible-playbook', '--syntax-check', $playbookPath + ]); + + $isValid = $result->isSuccessful(); + $errors = []; + $warnings = []; + + if (!$isValid) { + $errors = $this->parseErrors($result->getOutput()); + } + + return new ValidationResult($isValid, $errors, $warnings); + } + + private function parseErrors(array $output): array + { + $errors = []; + foreach ($output as $line) { + if (str_contains($line, 'ERROR!')) { + $errors[] = trim(str_replace('ERROR!', '', $line)); + } + } + return $errors; + } +} diff --git a/Asm/Ansible/Validation/ValidationResult.php b/Asm/Ansible/Validation/ValidationResult.php new file mode 100644 index 0000000..2660a90 --- /dev/null +++ b/Asm/Ansible/Validation/ValidationResult.php @@ -0,0 +1,30 @@ +isValid; + } + + public function getErrors(): array + { + return $this->errors; + } + + public function getWarnings(): array + { + return $this->warnings; + } +} diff --git a/Asm/Ansible/Vault/VaultManager.php b/Asm/Ansible/Vault/VaultManager.php new file mode 100644 index 0000000..a30daf8 --- /dev/null +++ b/Asm/Ansible/Vault/VaultManager.php @@ -0,0 +1,69 @@ +processRunner->run($command); + + if (!$result->isSuccessful()) { + throw new VaultException( + "Failed to encrypt file: $file", + $result->getExitCode(), + null, + implode(' ', $command), + $result->getOutput() + ); + } + + return $result; + } + + public function decrypt(string $file, string $vaultPasswordFile): ProcessResult + { + $command = [ + 'ansible-vault', 'decrypt', $file, + '--vault-password-file', $vaultPasswordFile + ]; + + $result = $this->processRunner->run($command); + + if (!$result->isSuccessful()) { + throw new VaultException( + "Failed to decrypt file: $file", + $result->getExitCode(), + null, + implode(' ', $command), + $result->getOutput() + ); + } + + return $result; + } + + public function view(string $file, string $vaultPasswordFile): ProcessResult + { + return $this->processRunner->run([ + 'ansible-vault', 'view', $file, + '--vault-password-file', $vaultPasswordFile + ]); + } +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 905149b..6defae2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,27 @@ # Changelog -## v4.0 +## Unreleased + * Updated API compatibility to modern Ansible (e.g., explicitly prepending 'role' to ansible-galaxy commands). + * Introduced `AnsibleGalaxyCollectionInterface` for full `ansible-galaxy collection` support (init, build, publish, install). + * Resolved `Symfony\Process` argument passing issues to ensure reliable escaping of options. + * Modernized `ansible-playbook` wrapper by dropping deprecated `--su` parameters and adding `--become-method`, `--become-password-file`, `--connection-password-file`, and `--list-tags`. + * Refactored codebase for PHP 8.4 compatibility (e.g., explicitly nullable `LoggerInterface` parameter). + * Fixed GitHub Actions workflow dependency resolution for PHP 8.2 and 8.3 by broadening `phpunit/phpunit` version constraints. + * Upgraded Docker development environment to simultaneously support PHP 8.2, 8.3, and 8.4 via `compose.yaml` profiles/services. + * Created robust DTOs (e.g., `ProcessResult`) for process output and exit code management. + * Resolved over 30 static analysis and code style errors (`phpstan`, `phpcs`). + * General library, static analysis tooling (PHPUnit 13), and test suite upgrades. + +## v5.0.0 + * Wrapping each host into quotes instead of having double quotes around all hosts. + * Fixed error output parsing (removed extra escaping). + * Permitted JSON format strings for the `--extra-vars` parameter. + +## v4.1.0 + * Allowed all psr/log versions to be used. + * Feature/build and version cleanup. + +## v4.0.0 * drop php7.x compat, 8.0+ only * removed travis, scrutinizer, replace with github actions * add symfony process v6.x, drop process <5.x diff --git a/Dockerfile b/Dockerfile index b6aff1b..5ce12e8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,23 +1,19 @@ -FROM php:8.3-cli +ARG PHP_VERSION=8.4 +FROM php:${PHP_VERSION}-cli WORKDIR /app ENV ANSIBLE_VERSION 2.9.17 # composer -RUN php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');" \ - && php -r "if (hash_file('sha384', 'composer-setup.php') === 'dac665fdc30fdd8ec78b38b9800061b4150413ff2e3b6f88543c636f7cd84f6db9189d43a81e5503cda447da73c7e5b6') { echo 'Installer verified'; } else { echo 'Installer corrupt'; unlink('composer-setup.php'); } echo PHP_EOL;" \ - && php composer-setup.php \ - && php -r "unlink('composer-setup.php');" \ - && mv composer.phar /usr/bin/composer \ - && chmod +x /usr/bin/composer +COPY --from=composer:latest /usr/bin/composer /usr/bin/composer # python, pipx & ansible RUN apt-get update \ && apt-get install -y gcc python3 git zip 7zip unzip pipx \ && apt-get clean all; \ pipx install --upgrade pip; \ - pipx install "ansible==${ANSIBLE_VERSION}"; \ + pipx install ansible-core; \ pipx install ansible; # keep container running diff --git a/Makefile b/Makefile index ad88589..e176032 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,9 @@ # Executables (local) DOCKER_COMP = docker compose +PHP_VERSION ?= 8.4 # Docker containers -PHP_CONT = $(DOCKER_COMP) exec php-ansible +PHP_CONT = $(DOCKER_COMP) exec php-ansible-$(PHP_VERSION) # Executables PHP = $(PHP_CONT) php @@ -30,10 +31,10 @@ down: ## Stop the docker hub logs: ## Show live logs @$(DOCKER_COMP) logs --tail=0 --follow -sh: ## Connect to the php-fpm container +sh: ## Connect to the php container (use PHP_VERSION=8.2|8.3|8.4 to specify) @$(PHP_CONT) sh -bash: ## Connect to the php-fpm container via bash so up and down arrows go to previous commands +bash: ## Connect to the php container via bash (use PHP_VERSION=8.2|8.3|8.4 to specify) @$(PHP_CONT) bash test: ## Start tests with phpunit, pass the parameter "c=" to add options to phpunit, example: make test c="--group e2e --stop-on-failure" @@ -42,15 +43,15 @@ test: ## Start tests with phpunit, pass the parameter "c=" to add options to php analyze: ## Start analysis with phpstan, pass the parameter "c=" to add options to phpstan. Default config ist always used, example: make analyze c="--group e2e" @$(eval c ?=) - @$(PHP_CONT) vendor/bin/phpstan --configuration=phpstan.neon $(c) + @$(PHP_CONT) vendor/bin/phpstan --configuration=phpstan.neon --memory-limit=256M $(c) codestyle: ## Start codestyle analysis with phpcs, pass the parameter "c=" to add options to phpcs. Default config ist always used. Example: make codestyle c="--parallel=2" @$(eval c ?=) - @$(PHP_CONT) vendor/bin/phpcs --standard=phpcs.xml.dist $(c) + @$(PHP_CONT) vendor/bin/phpcs --standard=phpcs.xml $(c) codestyle-fix: ## Start codestyle analysis with phpcbf, pass the parameter "c=" to add options to phpcbf. Default config ist always used. Example: make codestyle c="--parallel=2" @$(eval c ?=) - @$(PHP_CONT) vendor/bin/phpcbf --standard=phpcs.xml.dist $(c) + @$(PHP_CONT) vendor/bin/phpcbf --standard=phpcs.xml $(c) psalm: ## Start code analysis with psalm, pass the parameter "c=" to add options to psalm. Default config ist always used. Example: make codestyle c="--level=2" @$(eval c ?=) @@ -63,4 +64,4 @@ composer: ## Run composer, pass the parameter "c=" to run a given command, examp vendor: ## Install vendors according to the current composer.lock file vendor: c=install --prefer-dist --no-dev --no-progress --no-scripts --no-interaction -vendor: composer \ No newline at end of file +vendor: composer diff --git a/README.md b/README.md index 44d619d..84ed7b4 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # php-ansible library -![Build PHP 8.0/8.1/8.2](https://github.com/maschmann/php-ansible/actions/workflows/static-analysis.yml/badge.svg) +![Build PHP 8.2/8.3/8.4](https://github.com/maschmann/php-ansible/actions/workflows/static-analysis.yml/badge.svg) This library is a OOP-wrapper for the ansible provisioning tool. I intend to use this library for a symfony2 bundle and also a deployment GUI, based on php/symfony2. @@ -111,8 +111,10 @@ $ansible ### Galaxy -The syntax follows ansible's syntax with one deviation: list is a reserved keyword in php (array context) and -therefore I had to rename it to "modulelist()". +The `ansible-galaxy` commands support both the `role` and `collection` functionality. + +#### Role Commands +The syntax follows ansible's syntax with one deviation: `list` is a reserved keyword in php (array context) and therefore I had to rename it to "modulelist()". ```php $ansible @@ -124,10 +126,10 @@ $ansible would generate: ```bash -$ ansible-galaxy init my_role --init-path=/tmp/my_path +$ ansible-galaxy role init my_role --init-path=/tmp/my_path ``` -You can access all galaxy commands: +You can access all galaxy role commands: * `init()` * `info()` @@ -136,6 +138,28 @@ You can access all galaxy commands: * `modulelist()` * `remove()` +#### Collection Commands + +```php +$ansible + ->galaxyCollection() + ->install('my_namespace.my_collection') + ->collectionsPath('/tmp/my_path') + ->execute(); +``` +would generate: + +```bash +$ ansible-galaxy collection install my_namespace.my_collection --collections-path=/tmp/my_path +``` + +You can access all galaxy collection commands: + + * `init()` + * `build()` + * `publish()` + * `install()` + You can combine the calls with their possible arguments, though I don't have any logic preventing e.g. ```--force``` from being applied to e.g. info(). Possible arguments/options: @@ -164,10 +188,11 @@ $ansible ## Development -You can use the provided docker image with ```make build``` which uses a default php-cli docker image and ansible 2.x. See the ```Dockerfile``` for more info. -Start the container with ```make up```. -Composer install: ```make vendor``` -You can run code or the tests within the container: ```make test c="--testdox"``` +You can use the provided docker setup with ```make build``` which creates PHP 8.2, 8.3, and 8.4 containers with modern ansible versions. See the ```Dockerfile``` and ```compose.yaml``` for more info. +Start the containers with ```make up```. +Composer install: ```make vendor``` (defaults to PHP 8.4, use `make vendor PHP_VERSION=8.2` etc.) +You can run code or the tests within the container: ```make test``` +To run tools on a specific PHP version, specify `PHP_VERSION`: ```make test PHP_VERSION=8.3``` ## Thank you for your contributions! @@ -181,8 +206,6 @@ thank you for reviewing, bug reporting, suggestions and PRs :-) The Next steps for implementation are: - improve type handling and structure, due to overall complexity of the playbook at the moment -- scalar typehints all over the place -- provide docker support for development - wrapping the library into a bundle -> maybe - provide commandline-capabilities -> maybe diff --git a/Tests/Asm/Ansible/Command/AnsibleGalaxyCollectionTest.php b/Tests/Asm/Ansible/Command/AnsibleGalaxyCollectionTest.php new file mode 100644 index 0000000..920c4b8 --- /dev/null +++ b/Tests/Asm/Ansible/Command/AnsibleGalaxyCollectionTest.php @@ -0,0 +1,137 @@ +getGalaxyUri(), $this->getProjectUri()); + $ansibleGalaxyCollection = new AnsibleGalaxyCollection($process); + + $this->assertInstanceOf(AnsibleGalaxyCollection::class, $ansibleGalaxyCollection); + + return $ansibleGalaxyCollection; + } + + public function testExecute(): void + { + // Skipped on Windows + if (Env::isWindows()) { + $this->assertTrue(true); + return; + } + + $command = $this->testCreateInstance(); + $command->execute(); + + // if command executes without exception + $this->assertTrue(true); + } + + public function testInit(): void + { + $command = $this->testCreateInstance(); + + $command + ->init('test_collection') + ->initPath('/tmp/php-ansible') + ->force(); + + $arguments = array_flip($command->getCommandlineArguments()); + + $this->assertArrayHasKey('collection', $arguments); + $this->assertArrayHasKey('init', $arguments); + $this->assertArrayHasKey('test_collection', $arguments); + $this->assertArrayHasKey('--init-path=/tmp/php-ansible', $arguments); + $this->assertArrayHasKey('--force', $arguments); + } + + public function testBuild(): void + { + $command = $this->testCreateInstance(); + $command->build(); + $arguments = array_flip($command->getCommandlineArguments()); + + $this->assertArrayHasKey('collection', $arguments); + $this->assertArrayHasKey('build', $arguments); + } + + public function testBuildWithPath(): void + { + $command = $this->testCreateInstance(); + $command->build('/path/to/collection'); + $arguments = array_flip($command->getCommandlineArguments()); + + $this->assertArrayHasKey('collection', $arguments); + $this->assertArrayHasKey('build', $arguments); + $this->assertArrayHasKey('/path/to/collection', $arguments); + } + + public function testPublish(): void + { + $command = $this->testCreateInstance(); + $command->publish('namespace-collection-1.0.0.tar.gz'); + $arguments = array_flip($command->getCommandlineArguments()); + + $this->assertArrayHasKey('collection', $arguments); + $this->assertArrayHasKey('publish', $arguments); + $this->assertArrayHasKey('namespace-collection-1.0.0.tar.gz', $arguments); + } + + public function testInstall(): void + { + $command = $this->testCreateInstance(); + $command->install('test_collection'); + $arguments = array_flip($command->getCommandlineArguments()); + + $this->assertArrayHasKey('collection', $arguments); + $this->assertArrayHasKey('install', $arguments); + $this->assertArrayHasKey('test_collection', $arguments); + } + + public function testInstallWithCollections(): void + { + $command = $this->testCreateInstance(); + $command->install( + [ + 'test_collection', + 'another_collection', + 'yet_another_collection' + ] + ); + $arguments = array_flip($command->getCommandlineArguments()); + + $this->assertArrayHasKey('collection', $arguments); + $this->assertArrayHasKey('install', $arguments); + $this->assertArrayHasKey('test_collection', $arguments); + $this->assertArrayHasKey('another_collection', $arguments); + $this->assertArrayHasKey('yet_another_collection', $arguments); + } + + public function testInstallWithOptions(): void + { + $command = $this->testCreateInstance(); + + $command + ->install() + ->collectionsPath('/tmp/collections') + ->force(); + + $arguments = array_flip($command->getCommandlineArguments()); + + $this->assertArrayHasKey('collection', $arguments); + $this->assertArrayHasKey('install', $arguments); + $this->assertArrayHasKey('--collections-path=/tmp/collections', $arguments); + $this->assertArrayHasKey('--force', $arguments); + } +} diff --git a/Tests/Asm/Ansible/Command/AnsibleGalaxyTest.php b/Tests/Asm/Ansible/Command/AnsibleGalaxyTest.php index 98d1821..1258452 100644 --- a/Tests/Asm/Ansible/Command/AnsibleGalaxyTest.php +++ b/Tests/Asm/Ansible/Command/AnsibleGalaxyTest.php @@ -51,7 +51,9 @@ public function testInit(): void $arguments = array_flip($command->getCommandlineArguments()); - $this->assertArrayHasKey('init test_role', $arguments); + $this->assertArrayHasKey('role', $arguments); + $this->assertArrayHasKey('init', $arguments); + $this->assertArrayHasKey('test_role', $arguments); $this->assertArrayHasKey('--init-path=/tmp/php-ansible', $arguments); $this->assertArrayHasKey('--force', $arguments); $this->assertArrayHasKey('--offline', $arguments); @@ -64,7 +66,9 @@ public function testInfo(): void $command->info('test_role'); $arguments = array_flip($command->getCommandlineArguments()); - $this->assertArrayHasKey('info test_role', $arguments); + $this->assertArrayHasKey('role', $arguments); + $this->assertArrayHasKey('info', $arguments); + $this->assertArrayHasKey('test_role', $arguments); } public function testInfoWithVersion(): void @@ -73,7 +77,9 @@ public function testInfoWithVersion(): void $command->info('test_role', '1.0'); $arguments = array_flip($command->getCommandlineArguments()); - $this->assertArrayHasKey('info test_role,1.0', $arguments); + $this->assertArrayHasKey('role', $arguments); + $this->assertArrayHasKey('info', $arguments); + $this->assertArrayHasKey('test_role,1.0', $arguments); } public function testInstall(): void @@ -82,7 +88,9 @@ public function testInstall(): void $command->install('test_role'); $arguments = array_flip($command->getCommandlineArguments()); - $this->assertArrayHasKey('install test_role', $arguments); + $this->assertArrayHasKey('role', $arguments); + $this->assertArrayHasKey('install', $arguments); + $this->assertArrayHasKey('test_role', $arguments); } public function testInstallWithRoles(): void @@ -97,7 +105,11 @@ public function testInstallWithRoles(): void ); $arguments = array_flip($command->getCommandlineArguments()); - $this->assertArrayHasKey('install test_role another_role yet_another_role', $arguments); + $this->assertArrayHasKey('role', $arguments); + $this->assertArrayHasKey('install', $arguments); + $this->assertArrayHasKey('test_role', $arguments); + $this->assertArrayHasKey('another_role', $arguments); + $this->assertArrayHasKey('yet_another_role', $arguments); } public function testInstallWithOptions(): void @@ -116,6 +128,7 @@ public function testInstallWithOptions(): void $arguments = array_flip($command->getCommandlineArguments()); + $this->assertArrayHasKey('role', $arguments); $this->assertArrayHasKey('install', $arguments); $this->assertArrayHasKey('--role-file=test_role', $arguments); $this->assertArrayHasKey('--roles-path=/tmp/roles', $arguments); @@ -130,7 +143,7 @@ public function testList(): void $command = $this->testCreateInstance(); $command->modulelist(); - $this->assertEquals('list', $command->getCommandlineArguments(false)); + $this->assertEquals('role list', $command->getCommandlineArguments(false)); } public function testListWithRole(): void @@ -138,7 +151,7 @@ public function testListWithRole(): void $command = $this->testCreateInstance(); $command->modulelist('test_role'); - $this->assertEquals('list test_role', $command->getCommandlineArguments(false)); + $this->assertEquals('role list test_role', $command->getCommandlineArguments(false)); } public function testListWithHelp(): void @@ -148,7 +161,7 @@ public function testListWithHelp(): void ->modulelist() ->help(); - $this->assertEquals('list --help', $command->getCommandlineArguments(false)); + $this->assertEquals('role list --help', $command->getCommandlineArguments(false)); } public function testRemove(): void @@ -156,7 +169,7 @@ public function testRemove(): void $command = $this->testCreateInstance(); $command->remove('test_role'); - $this->assertEquals('remove test_role', $command->getCommandlineArguments(false)); + $this->assertEquals('role remove test_role', $command->getCommandlineArguments(false)); } public function testRemoveRoles(): void @@ -169,6 +182,6 @@ public function testRemoveRoles(): void ] ); - $this->assertEquals('remove test_role another_role', $command->getCommandlineArguments(false)); + $this->assertEquals('role remove test_role another_role', $command->getCommandlineArguments(false)); } } diff --git a/Tests/Asm/Ansible/Command/AnsiblePlaybookTest.php b/Tests/Asm/Ansible/Command/AnsiblePlaybookTest.php index 32cc499..b9b33f8 100644 --- a/Tests/Asm/Ansible/Command/AnsiblePlaybookTest.php +++ b/Tests/Asm/Ansible/Command/AnsiblePlaybookTest.php @@ -61,18 +61,6 @@ public function testAskPassArgumentPresent() $this->assertArrayHasKey('--ask-pass', $arguments); } - public function testAskSuPassArgumentPresent() - { - $command = $this->testCreateInstance(); - $command - ->play($this->getPlayUri()) - ->askSuPass(); - - $arguments = array_flip($command->getCommandlineArguments()); - $this->assertArrayHasKey('--ask-su-pass', $arguments); - } - - public function testAskBecomePassArgumentPresent() { $command = $this->testCreateInstance(); @@ -217,6 +205,18 @@ public function testListTasksArgumentPresent() } + public function testListTagsArgumentPresent() + { + $command = $this->testCreateInstance(); + $command + ->play($this->getPlayUri()) + ->listTags(); + + $arguments = array_flip($command->getCommandlineArguments()); + $this->assertArrayHasKey('--list-tags', $arguments); + } + + public function testModulePathArgumentPresent() { $command = $this->testCreateInstance(); @@ -295,63 +295,75 @@ public function testStepArgumentPresent() } - public function testSuArgumentPresent() + public function testBecomeArgumentPresent() { $command = $this->testCreateInstance(); $command ->play($this->getPlayUri()) - ->su(); + ->become(); $arguments = array_flip($command->getCommandlineArguments()); - $this->assertArrayHasKey('--su', $arguments); + $this->assertArrayHasKey('--become', $arguments); } - public function testSuUserArgumentPresent() + public function testBecomeUserArgumentPresent() { $command = $this->testCreateInstance(); $command ->play($this->getPlayUri()) - ->suUser(); + ->becomeUser(); $arguments = array_flip($command->getCommandlineArguments()); - $this->assertArrayHasKey('--su-user=root', $arguments); + $this->assertArrayHasKey('--become-user=root', $arguments); $command - ->suUser('maschmann'); + ->becomeUser('maschmann'); $arguments = array_flip($command->getCommandlineArguments()); - $this->assertArrayHasKey('--su-user=maschmann', $arguments); + $this->assertArrayHasKey('--become-user=maschmann', $arguments); } - public function testBecomeArgumentPresent() + public function testBecomeMethodArgumentPresent() { $command = $this->testCreateInstance(); $command ->play($this->getPlayUri()) - ->become(); + ->becomeMethod(); $arguments = array_flip($command->getCommandlineArguments()); - $this->assertArrayHasKey('--become', $arguments); + $this->assertArrayHasKey('--become-method=sudo', $arguments); + + $command + ->becomeMethod('su'); + + $arguments = array_flip($command->getCommandlineArguments()); + $this->assertArrayHasKey('--become-method=su', $arguments); } - public function testBecomeUserArgumentPresent() + public function testBecomePasswordFileArgumentPresent() { $command = $this->testCreateInstance(); $command ->play($this->getPlayUri()) - ->becomeUser(); + ->becomePasswordFile('/path/to/become_pass'); $arguments = array_flip($command->getCommandlineArguments()); - $this->assertArrayHasKey('--become-user=root', $arguments); + $this->assertArrayHasKey('--become-password-file=/path/to/become_pass', $arguments); + } + + public function testConnectionPasswordFileArgumentPresent() + { + $command = $this->testCreateInstance(); $command - ->becomeUser('maschmann'); + ->play($this->getPlayUri()) + ->connectionPasswordFile('/path/to/conn_pass'); $arguments = array_flip($command->getCommandlineArguments()); - $this->assertArrayHasKey('--become-user=maschmann', $arguments); + $this->assertArrayHasKey('--connection-password-file=/path/to/conn_pass', $arguments); } @@ -475,30 +487,6 @@ public function testFlushCacheParameterPresent() } - public function testNewVaultIdArgumentPresent() - { - $command = $this->testCreateInstance(); - $command - ->play($this->getPlayUri()) - ->newVaultId('someId'); - - $arguments = array_flip($command->getCommandlineArguments()); - $this->assertArrayHasKey('--new-vault-id=someId', $arguments); - } - - - public function testNewVaultPasswordFileArgumentPresent() - { - $command = $this->testCreateInstance(); - $command - ->play($this->getPlayUri()) - ->newVaultPasswordFile('/path/to/vault'); - - $arguments = array_flip($command->getCommandlineArguments()); - $this->assertArrayHasKey('--new-vault-password-file=/path/to/vault', $arguments); - } - - public function testScpExtraArgsArgumentPresent() { $command = $this->testCreateInstance(); diff --git a/compose.yaml b/compose.yaml index 2f0ea52..c4cbe7f 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,6 +1,27 @@ services: - php-ansible: - build: . - container_name: php-ansible + php-ansible-8.4: + build: + context: . + args: + PHP_VERSION: "8.4" + container_name: php-ansible-8.4 volumes: - - ./:/app \ No newline at end of file + - ./:/app + + php-ansible-8.3: + build: + context: . + args: + PHP_VERSION: "8.3" + container_name: php-ansible-8.3 + volumes: + - ./:/app + + php-ansible-8.2: + build: + context: . + args: + PHP_VERSION: "8.2" + container_name: php-ansible-8.2 + volumes: + - ./:/app diff --git a/composer.json b/composer.json index af82267..ac127d0 100644 --- a/composer.json +++ b/composer.json @@ -15,14 +15,16 @@ } ], "require": { - "php": "^8.1.0|^8.2.0|^8.3.0", - "psr/log": "^2.0|^3.0", - "symfony/process": "^5.3|^6.0|^7.0" + "php": "^8.2.0|^8.3.0|^8.4.0", + "psr/log": "^3.0", + "symfony/process": "^6.0|^7.0", + "symfony/yaml": "^6.0|^7.0" }, "require-dev": { - "phpunit/phpunit": "^10.0|^11.0", + "phpunit/phpunit": "^11.0 || ^12.0 || ^13.0", "mikey179/vfsstream": "^1.6", - "phpstan/phpstan": "^1.12" + "phpstan/phpstan": "^2.1", + "squizlabs/php_codesniffer": "^4.0" }, "autoload": { "psr-4": { diff --git a/phpunit-10.xml.dist b/phpunit-11.xml.dist similarity index 86% rename from phpunit-10.xml.dist rename to phpunit-11.xml.dist index 189c807..bc9cb59 100644 --- a/phpunit-10.xml.dist +++ b/phpunit-11.xml.dist @@ -1,6 +1,11 @@ - - + + + + ./Tests + + + . @@ -8,10 +13,5 @@ ./Tests ./vendor - - - - ./Tests - - + diff --git a/phpunit-12.xml.dist b/phpunit-12.xml.dist new file mode 100644 index 0000000..455f80d --- /dev/null +++ b/phpunit-12.xml.dist @@ -0,0 +1,17 @@ + + + + + ./Tests + + + + + . + + + ./Tests + ./vendor + + + diff --git a/phpunit.xml.dist b/phpunit.xml.dist index bc9cb59..79fcadc 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,5 +1,5 @@ - + ./Tests