#!/usr/bin/python # -*- coding: utf-8 -*- # Copyright: (c) 2024, Ralf Langebrake # GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) from __future__ import (absolute_import, division, print_function) __metaclass__ = type import os import re from ansible.module_utils.basic import AnsibleModule DOCUMENTATION = r''' --- module: artisan short_description: Laravel command line interface version_added: "1.0.0" description: - > Artisan is the command line interface included with Laravel, the PHP web application framework for artisans. The module was heavily inspired by composer, which is typically used together with artisan. extends_documentation_fragment: - community.general.attributes attributes: check_mode: support: full diff_mode: support: none options: working_dir: description: - Directory of your project that contains the Artisan executable. required: true type: path command: description: - Artisan command like "migrate", "clear-compiled" or "app:custom-command". required: true type: str options: description: - Command options like seed or force without leading hyphens. required: false type: list args: description: - Command arguments, mainly in custom commands. required: false type: list php_path: description: - Path to the PHP executable on the remote host if PHP is not included in PATH. required: false type: path requirements: - Laravel - PHP notes: - Always appended in each execution are --no-ansi, --no-interaction and --force if available. - Ansible Artisan is intended primarily, but not exclusively, for production deployments author: - Ralf Langebrake (@codebarista) ''' EXAMPLES = r''' # php artisan down --secret="t0pS3cRet" --refresh=60 --retry=60 --no-ansi --no-interaction - name: Put the application into maintenance / demo mode community.general.artisan: working_dir: "{{ project_src }}" command: down options: - secret="ToPs3cReT" - refresh=60 - retry=60 # php artisan storage:link --no-ansi --no-interaction --force - name: Connect the public/storage link to storage/app/public community.general.artisan: working_dir: "{{ project_src }}" command: storage:link # php artisan app:custom-command --no-ansi --no-interaction maybe/a/path - name: Run custom command with arguments community.general.artisan: working_dir: "{{ project_src }}" php_path: /usr/local/bin/php command: app:custom-command arguments: maybe/a/path changed_when: "'modified' in app_custom_command.stdout" register: app_custom_command # php artisan migrate --database=sqlite --isolated=true --seed --force - name: Run migrations and seed database community.general.artisan: working_dir: "{{ project_src }}" command: "{{ item }}" options: - database=sqlite - isolated=true loop: - migrate - seed # php artisan up --no-ansi --no-interaction - name: Clear caches and bring the application out of maintenance mode community.general.artisan: working_dir: "{{ project_src }}" command: "{{ item }}" loop: - cache:clear - event:clear - route:clear - view:clear - up ''' def parse(out): # remove any unicode strings pattern = re.compile(r'(?:\x1B[@-_]|[\x80-\x9F])[0-?]*[ -/]*[@-~]') # split and trim lines lines = list(filter(None, out.splitlines())) message = stderr = lines[0] # second line has the exception message if len(lines) > 1: stderr = lines[1] # message, stderr, stderr_lines return [ re.sub(pattern, '', message).strip(), re.sub(pattern, '', stderr).strip(), list(map(str.strip, lines)) ] def has_changed(message): # message fragments that Laravel gives us artisan_messages = [ 'No publishable', # resources for tag 'Nothing to', # migrate,... 'already', # up, down, exists,... 'Not modified', 'No changes' ] for info in artisan_messages: if info in message: return False return True def get_help(module, command): # get all available options and arguments from an artisan command using artisan help to json rc, out, err = artisan_command(module, 'help %s' % command, options='--format=json --no-ansi') # no command, no help, no json if rc != 0: module.fail_json(msg='%s is not part of the command definitions.' % command) return module.from_json(out) def get_options(module, command_help): # set available command options available_options = command_help['definition']['options'] # merge default options command_options = module.params['options'] + [ 'no-interaction', 'no-ansi', 'force', ] options = [] # check availability and format options for option in command_options: option = list(map(str.strip, option.lstrip('-').split('='))) if option[0] in available_options: options.append('--%s' % '='.join(option)) # return the unique options turnkey ready as a string return ' '.join(list(set(options))).strip() def get_arguments(module, command_help): # set available command arguments available_arguments = command_help['definition']['arguments'] # mostly we have no arguments arguments = '' # if arguments expected for command, join from params if len(available_arguments) > 0: arguments = ' '.join(module.params['args']) # return arguments turnkey ready as a string return arguments.strip() def get_artisan(module): artisan = '%s/artisan' % os.path.abspath(module.params['working_dir']) # check artisan executable path if not os.path.isfile(artisan): module.fail_json(msg='The artisan executable is not present.', artisan=artisan) return artisan def get_php(module): # in most cases, PHP is available by default if module.params['php_path'] is None: php = module.get_bin_path('php', True, ['/usr/local/bin']) else: php = os.path.abspath(module.params['php_path']) # check php executable path if not os.path.isfile(php): module.fail_json(msg='The php executable is not present.', php=php) return php def artisan_command(module, command, options, arguments=''): # this is the artisan executable in the project root artisan = get_artisan(module) # PHP is essential for successful execution php = get_php(module) # combine the entire command command = '%s %s %s %s %s' % (php, artisan, command, options, arguments) return module.run_command(command.strip()) def run_module(): # available arguments a user can pass to the module module_args = dict( working_dir=dict(type='path', required=True), command=dict(type='str', required=True), php_path=dict(type='path'), options=dict(type='list', default=[]), args=dict(type='list', default=[]), ) # instantiate the AnsibleModule object module = AnsibleModule( argument_spec=module_args, supports_check_mode=True ) # define the artisan command cmd = module.params['command'].strip() # get command info from help hlp = get_help(module, cmd) # merge default and param options opts = get_options(module, hlp) # merge param arguments args = get_arguments(module, hlp) # seed the result dict in the object result = dict( stdout='%s %s %s' % (cmd, opts, args), artisan=get_artisan(module), msg='Running check mode', changed=False, ) # in check mode just return the current state if module.check_mode: module.exit_json(**result) # do what it needs to do rc, out, err = artisan_command(module, cmd, opts, args) # try to extract the most meaningful output possible message, stderr, lines = parse(out) # set first stdout line as message result['msg'] = message.replace('INFO', '').strip() result['stdout'] = message # if necessary, add an error message to output if rc != 0: module.fail_json(stderr=stderr, stderr_lines=lines[1:3], **result) # set changed status result['changed'] = has_changed(message) # in the event of a success simply pass the key/value results module.exit_json(**result) def main(): run_module() if __name__ == '__main__': main()