From 1d1d7debe3b34821c940e63433e3c2eef84ac92b Mon Sep 17 00:00:00 2001 From: "persipkapps-klicksplit[bot]" <212581896+persipkapps-klicksplit[bot]@users.noreply.github.com> Date: Sun, 3 May 2026 20:24:10 +0000 Subject: [PATCH] sync: import from monorepo perspikapps/flekskit --- .devcontainer/create.sh | 10 + .devcontainer/devcontainer.json | 15 ++ .devcontainer/start.sh | 9 + .gitattributes | 8 + .php_cs | 26 -- .styleci.yml | 1 - .vscode/tasks.json | 15 ++ CHANGELOG.md | 35 +++ actions/clean-history/action.yml | 28 +++ actions/clean-history/delete.sh | 69 ++++++ actions/clean-history/list.sh | 48 ++++ actions/create-or-replace-ref/action.yml | 41 +++ actions/deploy/action.yml | 73 ++++++ actions/list-hosts/action.yml | 80 ++++++ actions/list-hosts/selector.awk | 153 ++++++++++++ actions/run-deployer/action.yml | 116 +++++++++ actions/run-gitversion/action.yml | 74 ++++++ actions/unlock/action.yml | 39 +++ changelog.md | 11 - composer.json | 97 ++++++-- contributing.md | 30 --- dist/.github/workflows/clean.yml | 35 +++ dist/.github/workflows/deploy.yml | 83 +++++++ dist/.github/workflows/unlock.yml | 33 +++ package.json | 147 +++-------- phpunit.xml | 22 -- readme.md | 63 +++-- src/func_defineHost.php | 63 ----- src/func_invokeHook.php | 20 -- src/func_loadHostsMap.php | 25 -- src/func_updateEnv.php | 13 - src/main.php | 37 +++ src/strategy.php | 80 ------ src/strategy_laravel.php | 24 -- src/strategy_shared.php | 43 ---- src/strategy_update.php | 11 - src/strategy_upload.php | 34 --- src/task_setenv.php | 14 -- src/tasks/artisan.php | 11 + src/tasks/artisan/artisan_migrate.php | 17 ++ src/tasks/auto_unlock.php | 15 ++ src/tasks/cpanel.php | 78 ++++++ src/tasks/cpanel/cpanel_database.php | 78 ++++++ src/tasks/cpanel/cpanel_domain.php | 100 ++++++++ src/tasks/cpanel/cpanel_htaccess.php | 59 +++++ src/tasks/cpanel/cpanel_mail.php | 105 ++++++++ src/tasks/cpanel/cpanel_writable.php | 19 ++ src/tasks/crypto.php | 64 +++++ src/tasks/helpers.php | 11 + src/tasks/modules.php | 11 + src/tasks/modules/modules_activate.php | 51 ++++ src/tasks/platform.php | 16 ++ src/tasks/platform/platform_crontab.php | 40 +++ src/tasks/platform/platform_decrypt.php | 45 ++++ src/tasks/platform/platform_encrypt.php | 42 ++++ src/tasks/platform/platform_knownhosts.php | 15 ++ src/tasks/platform/platform_listhosts.php | 19 ++ src/tasks/platform/platform_savepub.php | 65 +++++ src/tasks/set_env.php | 275 +++++++++++++++++++++ src/tasks/set_version.php | 37 +++ src/tasks/upload_assets.php | 70 ++++++ 61 files changed, 2402 insertions(+), 566 deletions(-) create mode 100755 .devcontainer/create.sh create mode 100755 .devcontainer/devcontainer.json create mode 100755 .devcontainer/start.sh create mode 100644 .gitattributes delete mode 100644 .php_cs delete mode 100644 .styleci.yml create mode 100755 .vscode/tasks.json create mode 100644 CHANGELOG.md create mode 100644 actions/clean-history/action.yml create mode 100755 actions/clean-history/delete.sh create mode 100755 actions/clean-history/list.sh create mode 100644 actions/create-or-replace-ref/action.yml create mode 100644 actions/deploy/action.yml create mode 100644 actions/list-hosts/action.yml create mode 100644 actions/list-hosts/selector.awk create mode 100644 actions/run-deployer/action.yml create mode 100644 actions/run-gitversion/action.yml create mode 100644 actions/unlock/action.yml delete mode 100644 changelog.md delete mode 100644 contributing.md create mode 100755 dist/.github/workflows/clean.yml create mode 100644 dist/.github/workflows/deploy.yml create mode 100644 dist/.github/workflows/unlock.yml delete mode 100644 phpunit.xml delete mode 100644 src/func_defineHost.php delete mode 100644 src/func_invokeHook.php delete mode 100644 src/func_loadHostsMap.php delete mode 100644 src/func_updateEnv.php create mode 100644 src/main.php delete mode 100644 src/strategy.php delete mode 100644 src/strategy_laravel.php delete mode 100644 src/strategy_shared.php delete mode 100644 src/strategy_update.php delete mode 100644 src/strategy_upload.php delete mode 100644 src/task_setenv.php create mode 100644 src/tasks/artisan.php create mode 100644 src/tasks/artisan/artisan_migrate.php create mode 100644 src/tasks/auto_unlock.php create mode 100644 src/tasks/cpanel.php create mode 100644 src/tasks/cpanel/cpanel_database.php create mode 100644 src/tasks/cpanel/cpanel_domain.php create mode 100644 src/tasks/cpanel/cpanel_htaccess.php create mode 100644 src/tasks/cpanel/cpanel_mail.php create mode 100644 src/tasks/cpanel/cpanel_writable.php create mode 100644 src/tasks/crypto.php create mode 100644 src/tasks/helpers.php create mode 100644 src/tasks/modules.php create mode 100644 src/tasks/modules/modules_activate.php create mode 100644 src/tasks/platform.php create mode 100644 src/tasks/platform/platform_crontab.php create mode 100644 src/tasks/platform/platform_decrypt.php create mode 100644 src/tasks/platform/platform_encrypt.php create mode 100644 src/tasks/platform/platform_knownhosts.php create mode 100644 src/tasks/platform/platform_listhosts.php create mode 100644 src/tasks/platform/platform_savepub.php create mode 100644 src/tasks/set_env.php create mode 100644 src/tasks/set_version.php create mode 100644 src/tasks/upload_assets.php diff --git a/.devcontainer/create.sh b/.devcontainer/create.sh new file mode 100755 index 0000000..eca705c --- /dev/null +++ b/.devcontainer/create.sh @@ -0,0 +1,10 @@ +#!/bin/sh + +### Add ssh-keyscan to known_hosts +mkdir -p /home/vscode/.ssh +chmod 700 /home/vscode/.ssh +ssh-keyscan github.com >>/home/vscode/.ssh/known_hosts + +### Ensure correct access rights +sudo chown -Rf vscode:vscode ${containerWorkspaceFolder:-.}/* ${containerWorkspaceFolder:-.}/.* +sudo chmod -Rf 755 ${containerWorkspaceFolder:-.}/* ${containerWorkspaceFolder:-.}/.* diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100755 index 0000000..619f91c --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,15 @@ +{ + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "name": "", + "features": { + "ghcr.io/devcontainers/features/docker-in-docker": {}, + "ghcr.io/devcontainers/features/node": "lts", + "ghcr.io/devcontainers/features/php": "8.2", + "ghcr.io/tomgrv/devcontainer-features/githooks": {}, + "ghcr.io/tomgrv/devcontainer-features/gitutils": {} + }, + "remoteEnv": {}, + "postCreateCommand": ".devcontainer/create.sh", + "postStartCommand": ".devcontainer/start.sh", + "customizations": {} +} diff --git a/.devcontainer/start.sh b/.devcontainer/start.sh new file mode 100755 index 0000000..fb225d0 --- /dev/null +++ b/.devcontainer/start.sh @@ -0,0 +1,9 @@ +#!/bin/sh + +### Make sure the workspace folder is owned by the user +git config --global --add safe.directory ${containerWorkspaceFolder:-.} + +### Define gpg configuration +if [ -z "$CODESPACES" ]; then + git config --global gpg.program gpg2 +fi diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..3d34a90 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,8 @@ +* text=auto eol=lf +*.{cmd,[cC][mM][dD]} text eol=crlf +*.{bat,[bB][aA][tT]} text eol=crlf +*.css linguist-vendored +*.scss linguist-vendored +*.js linguist-vendored +*.json merge=json +CHANGELOG.md export-ignore diff --git a/.php_cs b/.php_cs deleted file mode 100644 index 7c7f4d3..0000000 --- a/.php_cs +++ /dev/null @@ -1,26 +0,0 @@ -exclude($excluded_folders) - ->notName('AcceptanceTester.php') - ->notName('FunctionalTester.php') - ->notName('UnitTester.php') - ->in('src');; - -return PhpCsFixer\Config::create() - ->setRules(array( - '@Symfony' => true, - 'binary_operator_spaces' => ['align_double_arrow' => true], - 'array_syntax' => ['syntax' => 'short'], - 'linebreak_after_opening_tag' => true, - 'not_operator_with_successor_space' => true, - 'ordered_imports' => true, - 'phpdoc_order' => true, - )) - ->setFinder($finder); diff --git a/.styleci.yml b/.styleci.yml deleted file mode 100644 index c3bb259..0000000 --- a/.styleci.yml +++ /dev/null @@ -1 +0,0 @@ -preset: laravel \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100755 index 0000000..45110b2 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,15 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "⏬ Upd. DevEnv", + "type": "shell", + "command": "npm exec --yes -- tomgrv/devcontainer-features -u", + "presentation": { + "panel": "shared", + "close": true, + "reveal": "always" + } + } + ] +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..4da7e7b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,35 @@ + + +# Changelog + +All notable changes to `klick-deploy` will be documented in this file. + +## [Unreleased] + +### Added + +- **Automatic Crontab Management**: Added support for automatic crontab job configuration using Deployer's crontab contrib package + - `php artisan queue:restart` scheduled every hour to prevent memory leaks + - `php artisan schedule:run` scheduled every minute for Laravel's task scheduler + - Crontab setup runs automatically just before deployment unlock + - Jobs are properly configured to use the current deployment path +- New platform task `platform:crontab` for manual crontab configuration +- New platform task `platform:crontab:remove` for cleaning up crontab jobs + +### Changed + +- Updated platform.php to include crontab task file +- Enhanced documentation with crontab features + +## [1.0.0] - Initial Release + +### Features + +- Initial klick-deploy package with URL-based deployment configuration +- Support for Laravel deployment strategies +- cPanel integration tasks +- Platform management tasks +- Module activation/deactivation support +- Asset upload functionality +- Environment variable management +- Version setting capabilities diff --git a/actions/clean-history/action.yml b/actions/clean-history/action.yml new file mode 100644 index 0000000..c318ab3 --- /dev/null +++ b/actions/clean-history/action.yml @@ -0,0 +1,28 @@ +# @format + +name: 'Cleanup past deployment' +description: 'Remove all past deployments' +branding: + icon: 'check' + color: 'blue' +inputs: + workflows: + description: 'Comma separated list of workflow files to delete' + required: false + default: '' + status: + description: 'Comma separated list of workflow status to delete' + required: false + default: '' + older: + description: 'Specify the number of days to delete workflow runs older than this value' + required: false + default: '' +runs: + using: composite + steps: + - name: Run Delete Script with List + if: ${{ inputs.workflows != '' }} + shell: sh + run: | + ${{ github.action_path }}/delete.sh ${{ inputs.older != '' && '-o' }} ${{ inputs.older }} ${{ inputs.workflows != '' && '-w' }} ${{ inputs.workflows }} ${{ inputs.status != '' && '-s' }} ${{ inputs.status }} diff --git a/actions/clean-history/delete.sh b/actions/clean-history/delete.sh new file mode 100755 index 0000000..60fd47e --- /dev/null +++ b/actions/clean-history/delete.sh @@ -0,0 +1,69 @@ +#!/bin/sh + +# Init default values +older=30 +status="failure,cancelled" +repo=$(git config --get remote.origin.url | sed -E 's/.*[:\/]([^\/]+\/[^\.]+)(\.git)?$/\1/') + +# handle arguments with getopt +OPTSTRING="w:o:s:r:" +while getopts ${OPTSTRING} opt; do + case ${opt} in + w) + workflows=$(echo ${OPTARG} | tr ',' ' ') + echo "Selected workflow: ${workflows}" >&2 + ;; + o) + older=${OPTARG} + echo "Select older than: ${older} days" >&2 + ;; + s) + status=${OPTARG} + echo "Select status: ${status}" >&2 + ;; + r) + repo=${OPTARG} + echo "Select repository: ${repo}" >&2 + ;; + :) + echo "Option -${OPTARG} requires an argument." >&2 + exit 1 + ;; + ?) + echo "Invalid option: -${OPTARG}." >&2 + exit 1 + ;; + esac +done + +if [ -z "$workflows" ]; then + echo "No workflow specified. Listing all workflow runs for $repo..." >&2 + + # Creates a temporary file to store workflow data. + workflows_temp=$(mktemp) + + # Lookup workflow + gh api repos/$repo/actions/workflows | jq -r '.workflows[] | [.id, .path] | @tsv' >$workflows_temp + cat "$workflows_temp" + + # Get the list of workflow names that are not successful or failed + workflows_names=$(awk '{print $2}' $workflows_temp | grep -v "main") + + # Save a comma separated list of workflow names using basename + workflows=$(echo "$workflows_names" | xargs -I{} basename {} | tr '\n' ' ') +fi + +if [ -z "$workflows" ]; then + echo "Nothing to remove" >&2 +else + echo "Removing all selected workflows that are not successful or failed" >&2 + + for workflow in $workflows; do + + echo "Deleting <$workflow> history, please wait..." >&2 + $(dirname $0)/list.sh -w $workflow -o $older -s $status -r $repo | xargs -I{} gh run delete {} + done +fi + +rm -rf $workflows_temp +echo "Done." >&2 diff --git a/actions/clean-history/list.sh b/actions/clean-history/list.sh new file mode 100755 index 0000000..488cc0e --- /dev/null +++ b/actions/clean-history/list.sh @@ -0,0 +1,48 @@ +#!/bin/sh + +# Init default values +workflow="" +older=30 +status="failure,cancelled" +repo=$(git config --get remote.origin.url | sed -E 's/.*[:\/]([^\/]+\/[^\.]+)(\.git)?$/\1/') + +# handle arguments with getopt +OPTSTRING="w:o:s:r:" +while getopts ${OPTSTRING} opt; do + case ${opt} in + w) + workflow=${OPTARG} + echo "Selected workflow: ${workflow}" >&2 + ;; + o) + older=${OPTARG} + echo "Select older than: ${older} days" >&2 + ;; + s) + status=${OPTARG} + echo "Select status: ${status}" >&2 + ;; + r) + repo=${OPTARG} + echo "Select repository: ${repo}" >&2 + ;; + :) + echo "Option -${OPTARG} requires an argument." >&2 + exit 1 + ;; + ?) + echo "Invalid option: -${OPTARG}." >&2 + exit 1 + ;; + esac +done + +# Format the status string to be used in jq +status=$(echo $status | tr '[:upper:]' '[:lower:]' | sed 's/,/|/g') + +# Log the selected options +echo "Listing ${workflow:-all} workflows older than ${older} days with status ${status}" >&2 + +gh run list --limit 500 ${workflow:+--workflow $workflow} --jq " .[]| select(.conclusion| test(\"$status\"))| + select(.createdAt < (now-( $older * 86400) | strftime(\"%Y-%m-%dT%H-%M-%SZ\") )) | + .databaseId" --json conclusion,databaseId,createdAt -R $repo diff --git a/actions/create-or-replace-ref/action.yml b/actions/create-or-replace-ref/action.yml new file mode 100644 index 0000000..3bf7a46 --- /dev/null +++ b/actions/create-or-replace-ref/action.yml @@ -0,0 +1,41 @@ +# @format + +name: 'Create or Replace Ref' +description: 'Creates a git ref (tag/branch). If it already exists, updates it to the given SHA.' +branding: + icon: 'tag' + color: 'blue' +inputs: + ref: + description: 'Full ref name (e.g. refs/tags/deploy/myhost)' + required: true + sha: + description: 'The SHA to point the ref at' + required: true + token: + description: 'GitHub token with contents write permission' + required: false + default: ${{ github.token }} +runs: + using: composite + steps: + - name: Create or Replace Ref + uses: actions/github-script@v8 + with: + github-token: ${{ inputs.token }} + script: | + const { owner, repo } = context.repo; + const ref = '${{ inputs.ref }}'; + const sha = '${{ inputs.sha }}'; + + + try { + await github.rest.git.createRef({ owner, repo, ref, sha }); + core.info(`Created ref ${ref} → ${sha}`); + } catch (err) { + if (err.status !== 422) throw err; + // 422 = ref already exists; force-update it + const shortRef = ref.replace(/^refs\//, ''); + await github.rest.git.updateRef({ owner, repo, ref: shortRef, sha, force: true }); + core.info(`Updated ref ${ref} → ${sha}`); + } diff --git a/actions/deploy/action.yml b/actions/deploy/action.yml new file mode 100644 index 0000000..be65942 --- /dev/null +++ b/actions/deploy/action.yml @@ -0,0 +1,73 @@ +# @format + +name: 'Deploy PHP Project' +description: 'Deploy a PHP project using Deployer' +branding: + icon: 'check' + color: 'blue' +inputs: + php-version: + description: 'PHP version to use' + required: false + default: '8.3' + app-version: + description: 'Full semantic version string' + required: false + default: '' + ssh-private-key: + description: 'SSH private key for deployment' + required: true + default: '' + known-hosts: + description: 'Known hosts for SSH' + required: true + default: '' + ssh-config: + description: 'SSH configuration' + required: false + default: '' + selector: + description: 'Selector for the deployment' + required: false + default: 'all' + environment: + description: 'Force deployment to a specific environment' + required: false + default: '' + gitversion-configFilePath: + description: 'Path to the GitVersion configuration file' + required: false + default: 'gitversion.yml' + default-user-mail: + description: 'Default user mail for db seeding' + required: false + default: '' + default-user-name: + description: 'Default user name db seeding' + required: false + default: '' +runs: + using: composite + steps: + # Run GitVersion to get the version number + - name: Run GitVersion + id: gitversion + uses: ./packages/perspikapps/klick-deploy/actions/run-gitversion + with: + configFilePath: ${{ inputs.gitversion-configFilePath }} + + # Deploy the application to the development environment or force deployment + - name: Deploy Application + uses: ./packages/perspikapps/klick-deploy/actions/run-deployer + with: + dep: deploy + options: + --app-version=${{ inputs.app-version || steps.gitversion.outputs.SemVer }} + --default-user-mail=${{ inputs.default-user-mail }} + --default-user-name=${{ inputs.default-user-name }} + selector: ${{ inputs.selector || 'all' }} + environment: ${{ inputs.environment }} + ssh-private-key: ${{ inputs.ssh-private-key }} + known-hosts: ${{ inputs.known-hosts }} + ssh-config: ${{ inputs.ssh-config }} # ssh_config; optional + diff --git a/actions/list-hosts/action.yml b/actions/list-hosts/action.yml new file mode 100644 index 0000000..e6ef116 --- /dev/null +++ b/actions/list-hosts/action.yml @@ -0,0 +1,80 @@ +# @format + +name: 'List eligible hosts' +description: 'List eligible hosts for deployment' +branding: + icon: 'check' + color: 'blue' +inputs: + deploy-file: + description: 'Path to the deploy.yml file' + required: true + default: 'deploy.yml' + selector: + description: 'Selector to filter hosts. Format: label1=value1&label2=value2' + required: false + default: 'all' + labels: + description: 'Include specified labels in the result' + required: false + default: '' + environment: + description: 'Force deployment to a specific environment' + required: false + default: '' +outputs: + hosts: + description: 'List of hostnames' + value: ${{ steps.parse.outputs.hosts }} +runs: + using: composite + steps: + # PR review → unstable (alpha URL, fresh DB) + - if: ${{ github.event_name == 'pull_request' && github.event.action == 'review_requested' }} + id: unstable + shell: sh + run: | + echo "Running in an unstable environment" + echo "selector=${{ inputs.selector}}\&env=${{ inputs.environment || 'unstable' }}" >> $GITHUB_OUTPUT + # Release branches → staging (beta URL, migration-based) + - if: ${{ startsWith(github.ref, 'refs/heads/release') }} + id: staging + shell: sh + run: | + echo "Running in a staging environment" + echo "selector=${{ inputs.selector}}\&env=${{ inputs.environment || 'staging' }}" >> $GITHUB_OUTPUT + # Main branch → production + - if: ${{ github.ref == 'refs/heads/main' }} + id: prod + shell: sh + run: | + echo "Running in a production environment" + echo "selector=${{ inputs.selector}}\&env=${{ inputs.environment || 'production' }}" >> $GITHUB_OUTPUT + # Parse the deploy file to get the hostnames + - name: Parse Deploy Config + id: parse + shell: sh + run: | + # Build the selector string + selector='${{steps.unstable.outputs.selector}}${{steps.staging.outputs.selector}}${{steps.prod.outputs.selector}}' + + # Validate: at least one environment-detection step must have produced a selector, + # or inputs.environment must be explicitly provided. + if [ -z "$selector" ]; then + if [ -z "${{ inputs.environment }}" ]; then + echo "Error: No deployment environment detected. The workflow is not running on a pull_request review_requested event, a release/* branch, or the main branch. Provide the 'environment' input to override." >&2 + exit 1 + fi + # Build a fallback selector from the provided environment input + selector="env=${{ inputs.environment }}" + # Prepend any extra criteria from inputs.selector, but skip 'all' (the default + # no-op value) to avoid injecting a spurious key into the awk selector array. + if [ "${{ inputs.selector }}" != "all" ] && [ -n "${{ inputs.selector }}" ]; then + selector="${{ inputs.selector }}&$selector" + fi + fi + + echo "Parsing deploy file: ${{ inputs.deploy-file }} with selector: $selector and labels: ${{ inputs.labels }}" + hosts=$(awk -v selector="$selector" -v labels=${{ inputs.labels }} -f ${{ github.action_path }}/selector.awk ${{ inputs.deploy-file }}) + echo "Selected hosts: $hosts" + echo "hosts=$hosts" >> $GITHUB_OUTPUT diff --git a/actions/list-hosts/selector.awk b/actions/list-hosts/selector.awk new file mode 100644 index 0000000..c3250b2 --- /dev/null +++ b/actions/list-hosts/selector.awk @@ -0,0 +1,153 @@ +# awk script to filter host entries based on a selector string +# Usage: selector.awk < input_file +# selector format: key1=value1&key2=value2 + +# -- + + +# Initialize the script and parse the selector input +BEGIN { + + # if selector contains |, generate an error + if (index(selector, "|") > 0) { + print "Error: selector operator '|' is not supported" > "/dev/stderr"; + exit 1; + } + + # Treat "all" as an empty selector + if (selector == "all") { + selector = ""; + } + + # Split the selector into key-value pairs + split(selector, kv_pairs, "&"); + for (i in kv_pairs) { + split(kv_pairs[i], kv, "="); + if (kv[1] != "all") { + selectors[kv[1]] = kv[2]; + } + } + + # Split labels into an array + split(labels, label_list, ","); + + printf("["); +} + +# Detect the start of the "hosts" section +/hosts:/ {h=1; next} + +# Process host entries +/:/ && h { + hi=match($0, /[^ ]/)-1; + h=0; +} +/:/ && hi { + s=match($0, /[^ ]/)-1; + if (s==0) {hi=0; next} + if (s==hi) { + gsub("[ ]+|[: ]+$", "", $0); + host=$0; + } +} + +# Detect the start of the "labels" section for a host +/labels:/ && host { + l=1; + next; +} + +# Process label entries +/:/ && l { + li=match($0, /[^ ]/)-1; + l=0; +} + +/:/ && li { + s=match($0, /[^ ]/)-1; + if (s==li) { + gsub("[ ]+|[: ]+$", "", $0); + + # Extract the label name and value + split($0, label_current, ":"); + + # add label_current to label_array + label_array[label_current[1]] = label_current[2]; + + # Skip validation if selectors array is empty + if (length(selectors) == 0) { + selected=length(selectors); + } + else { + # Check if the label is selector criteria + if (label_current[1] in selectors && selectors[label_current[1]] == label_current[2] ){ + selected=selected+1; + } + } + } + + if (s!=li) { + # if host is selected, print the host and labe + if (selected == length(selectors)) { + + if (count > 0) { + printf(","); + } + else { + count=0; + } + + printf("{\"host\":\"%s\"", host); + + if (length(label_list) > 0) { + + for (label in label_list) { + + # if label_list[label] is empty, skip it + if (label_array[label_list[label]] == "") { + continue; + } + + # if label_array[label_list[label]] is a json array + if (label_array[label_list[label]] ~ /^\[.*\]$/) { + printf(", \"%s\":%s", label_list[label], label_array[label_list[label]]); + continue; + } + + # if label_array[label_list[label]] contains a comma, print it as a json array + if (label_array[label_list[label]] ~ /,/) { + + printf(", \"%s\":[", label_list[label]); + n = split(label_array[label_list[label]], label_values, ","); + for (i = 1; i <= n; i++) { + printf("\"%s\"", label_values[i]); + if (i < n) { + printf(", "); + } + } + printf("]"); + continue; + } + + # if label_array[label_list[label]] does not contain a comma, print it as a string + printf(", \"%s\":\"%s\"", label_list[label], label_array[label_list[label]]); + } + } + + printf("}"); + + count++; + } + + # Resset variables for the next host + li=0; + selected=0; + delete label_array; + } + +} + +# End of the script +END { + printf("]\n"); +} diff --git a/actions/run-deployer/action.yml b/actions/run-deployer/action.yml new file mode 100644 index 0000000..6d4a177 --- /dev/null +++ b/actions/run-deployer/action.yml @@ -0,0 +1,116 @@ +# @format + +name: 'Run PHP Deployer' +description: 'Setup & run PHP Deployer' +branding: + icon: 'check' + color: 'blue' +inputs: + version: + description: 'The version of Deployer to install. If not specified, the latest version will be used.' + required: false + dep: + description: 'The deployer command to run. If not specified, the default is "deploy".' + required: true + default: 'deploy' + options: + description: 'Options to pass to the deployer command.' + required: false + selector: + description: 'The selector to use for the deployer command. If not specified, the default is "all".' + required: false + default: 'all' + environment: + description: 'The environment to use for the deployer command. If not specified, the default is determined by git branch.' + required: false + default: '' + ssh-private-key: + description: 'SSH private key for deployment' + required: true + known-hosts: + description: 'Known hosts for SSH' + required: true + default: '' + ssh-config: + description: 'SSH configuration' + required: false + default: '' +runs: + using: composite + steps: + - name: Check environment + id: check + shell: bash + run: | + ( + command -v rsync &> /dev/null && echo "rsync=true" || echo "rsync=false" + command -v php &> /dev/null && echo "php=true" || echo "php=false" + test -d vendor && echo "packages=true" || echo "php-packages=false" + ) >> $GITHUB_OUTPUT + + # Setup PHP if it is not already installed + - name: Setup PHP + if: ${{ steps.check.outputs.php == 'false' }} + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ inputs.php-version || '8.3' }} + extensions: mbstring, intl, zip, xml, curl, gd + env: + update: true + # Install Composer dependencies if the vendor directory is missing + - name: Install Composer Dependencies + if: ${{ steps.check.outputs.packages == 'false' }} + uses: ramsey/composer-install@v3 + with: + composer-options: '--no-dev' + require-lock-file: true + + # Install deployer/deployer globally and add it to the path + - name: Install Deployer + shell: sh + run: | + if [ -z "${{ inputs.version }}" ]; then + echo "No version specified, installing the latest version of Deployer" + composer global require deployer/deployer + else + echo "Installing Deployer version ${{ inputs.version }}" + composer global require deployer/deployer:${{ inputs.version }} + fi + echo "Adding Deployer to PATH" + echo "$HOME/.composer/vendor/bin" >> $GITHUB_PATH + + # Add the SSH private key to the SSH agent + - name: Install SSH key + uses: shimataro/ssh-key-action@v2 + with: + key: ${{ inputs.ssh-private-key }} + known_hosts: ${{ inputs.known-hosts }} + config: ${{ inputs.ssh-config }} # ssh_config; optional + if_key_exists: ignore # replace / ignore / fail + + # Run the deployer command + - name: Run Deployer on pull_request review_requested + if: ${{ github.event_name == 'pull_request' && github.event.action == 'review_requested' }} + shell: sh + run: | + echo "Running Deployer command: ${{ inputs.dep }} with selector ${{ inputs.selector || 'all' }} and environment ${{ inputs.environment || 'unstable' }}" + $HOME/.composer/vendor/bin/dep ${{ inputs.dep }} ${{ inputs.options }} --branch=${{ github.sha }} -- ${{ inputs.selector || 'all' }}\&env=${{ inputs.environment || 'unstable'}} + + - name: Run Deployer on release branches + if: ${{ startsWith(github.ref, 'refs/heads/release') }} + shell: sh + run: | + echo "Running Deployer command: ${{ inputs.dep }} with selector ${{ inputs.selector || 'all' }} and environment ${{ inputs.environment || 'staging' }}" + $HOME/.composer/vendor/bin/dep ${{ inputs.dep }} ${{ inputs.options }} --branch=${{ github.ref_name }} -- ${{ inputs.selector || 'all' }}\&env=${{ inputs.environment || 'staging'}} + - name: Run Deployer on tags with pre-release identifiers + if: ${{ startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, '-') }} + shell: sh + run: | + echo "Running Deployer command: ${{ inputs.dep }} with selector ${{ inputs.selector || 'all' }} and environment ${{ inputs.environment || 'staging' }}" + $HOME/.composer/vendor/bin/dep ${{ inputs.dep }} ${{ inputs.options }} --tag=${{ github.ref_name }} -- ${{ inputs.selector || 'all' }}\&env=${{ inputs.environment || 'staging'}} + - name: Run Deployer on tags without pre-release identifiers + if: ${{ startsWith(github.ref, 'refs/tags/') && !contains(github.ref_name, '-') }} + shell: sh + run: | + echo "Running Deployer command: ${{ inputs.dep }} with selector ${{ inputs.selector || 'all' }} and environment ${{ inputs.environment || 'production' }}" + $HOME/.composer/vendor/bin/dep ${{ inputs.dep }} ${{ inputs.options }} --tag=${{ github.ref_name }} -- ${{ inputs.selector || 'all' }}\&env=${{ inputs.environment || 'production'}} diff --git a/actions/run-gitversion/action.yml b/actions/run-gitversion/action.yml new file mode 100644 index 0000000..8229a7a --- /dev/null +++ b/actions/run-gitversion/action.yml @@ -0,0 +1,74 @@ +# @format + +name: 'Run GitVersion' +description: 'Run GitVersion to calculate semantic versioning' +branding: + icon: 'check' + color: 'blue' +inputs: + configFilePath: + description: 'Path to the GitVersion configuration file' + required: false + default: '.gitversion' +outputs: + fullSemVer: + description: 'Full semantic version string' + value: ${{ steps.gitversion.outputs.fullSemVer }} + SemVer: + description: 'Semantic version string' + value: ${{ steps.gitversion.outputs.SemVer }} + Major: + description: 'Major version number' + value: ${{ steps.gitversion.outputs.Major }} + Minor: + description: 'Minor version number' + value: ${{ steps.gitversion.outputs.Minor }} + Patch: + description: 'Patch version number' + value: ${{ steps.gitversion.outputs.Patch }} + PreReleaseTag: + description: 'Pre-release tag' + value: ${{ steps.gitversion.outputs.PreReleaseTag }} + MajorMinorPatch: + description: 'Major.Minor.Patch version string' + value: ${{ steps.gitversion.outputs.MajorMinorPatch }} +runs: + using: composite + steps: + # Checkout the repository with full history to ensure all commits are available + - name: Checkout Repository with full history + uses: actions/checkout@v6 + with: + fetch-depth: 0 + clean: false + + # Check the environment for required tools and dependencies + - name: Check environment + id: check + shell: bash + run: | + ( + command -v gitversion &> /dev/null && echo "gitversion=true" || echo "gitversion=false" + command -v dotnet &> /dev/null && echo "dotnet=true" || echo "dotnet=false" + ) >> $GITHUB_OUTPUT + + # Setup .NET if it is not already installed + - name: Setup dotnet + if: ${{ steps.check.outputs.dotnet == 'false' }} + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 6.x + + # Setup GitVersion if it is not already installed + - name: Setup GitVersion + if: ${{ steps.check.outputs.gitversion == 'false' }} + uses: gittools/actions/gitversion/setup@v0 + with: + versionSpec: '5.x' + + # Run GitVersion to calculate semantic versioning + - name: Run GitVersion + id: gitversion + uses: gittools/actions/gitversion/execute@v0 + with: + configFilePath: ${{ inputs.configFilePath }} diff --git a/actions/unlock/action.yml b/actions/unlock/action.yml new file mode 100644 index 0000000..700ac6f --- /dev/null +++ b/actions/unlock/action.yml @@ -0,0 +1,39 @@ +name: 'Unlock PHP Project' +description: 'Unlock hosts managed with Deployer' +branding: + icon: 'check' + color: 'blue' +inputs: + ssh-private-key: + description: 'SSH private key for deployment' + required: true + default: "" + known-hosts: + description: 'Known hosts for SSH' + required: true + default: "" + ssh-config: + description: 'SSH configuration' + required: false + default: "" + selector: + description: 'Selector for the deployment' + required: false + default: 'all' + environment: + description: 'Force deployment to a specific environment' + required: false + default: "" +runs: + using: composite + steps: + - name: Unlock Hosts + uses: ./packages/perspikapps/klick-deploy/actions/run-deployer + with: + dep: deploy:unlock + selector: ${{ inputs.selector || 'all' }} + environment: ${{ inputs.environment }} + ssh-private-key: ${{ inputs.ssh-private-key }} + known-hosts: ${{ inputs.known-hosts }} + ssh-config: ${{ inputs.ssh-config }} # ssh_config; optional + diff --git a/changelog.md b/changelog.md deleted file mode 100644 index 11cd222..0000000 --- a/changelog.md +++ /dev/null @@ -1,11 +0,0 @@ - - -# Changelog - -All notable changes to `LaravelEnvRibbon` will be documented in this file. - -## Version 1.0 - -### Added - -- Everything diff --git a/composer.json b/composer.json index 2d368d9..eada87a 100644 --- a/composer.json +++ b/composer.json @@ -1,60 +1,109 @@ { - "name": "perspikapps/php-easy-deployer", + "name": "perspikapps/klick-deploy", "description": "A deployer.org recipe with url-based easy configuration", "license": "MIT", + "type": "library", + "version": "1.0.0", + "keywords": [ + "laravel", + "deploy", + "deployer" + ], "authors": [ { "name": "@tomgrv", "email": "tomgrv@users.perpikapps.fr" } ], - "homepage": "https://github.com/perspikapps/php-easy-deploy", - "keywords": [ - "laravel", - "deploy", - "deployer" - ], + "homepage": "https://github.com/perspikapps/klick-deploy", + "support": { + "issues": "https://github.com/perspikapps/klick-deploy/issues", + "source": "https://github.com/perspikapps/klick-deploy" + }, "require": { - "php": "^7.4", - "deployer/deployer": "7.0.*@dev" + "deployer/deployer": "^7.0", + "php": "^8.1" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^2.16", - "nunomaduro/phpinsights": "dev-master", - "orchestra/testbench": "~5 || ~6", - "phpunit/phpunit": "^9.0", - "squizlabs/php_codesniffer": "^3.5" + "laravel/pint": "^1.0", + "nunomaduro/collision": "^8.0", + "nunomaduro/phpinsights": "^2.12", + "pestphp/pest": "^4.1", + "pestphp/pest-plugin-arch": "^4.0", + "pestphp/pest-plugin-laravel": "^4.0" }, + "prefer-stable": true, "autoload": { "psr-4": { - "Perspikapps\\EasyDeployer\\": "src/" + "Perspikapps\\KlickDeploy\\": "src/" } }, "autoload-dev": { "psr-4": { - "Perspikapps\\EasyDeployer\\Tests\\": "tests" + "Perspikapps\\KlickDeploy\\Tests\\": "tests" } }, "config": { + "allow-plugins": { + "wikimedia/composer-merge-plugin": true + }, + "optimize-autoloader": true, "preferred-install": "dist", - "sort-packages": true, - "optimize-autoloader": true + "sort-packages": true }, "scripts": { + "base": [ + "composer @additional_args --ignore-platform-reqs --with-all-dependencies --minimal-changes --ansi" + ], + "helpers": [ + "@php artisan ide-helper:generate --ansi", + "@php artisan ide-helper:meta --ansi" + ], + "inst": [ + "composer install --ignore-platform-reqs" + ], + "link": [ + "composer config repositories.local '{\"type\": \"path\", \"url\": \"@additional_args\"}' --file composer.json" + ], + "lint": [ + "vendor/bin/pint --ansi --dirty" + ], + "lock": [ + "composer validate --no-check-all --strict 2>&1 | grep -oP 'Required package \"\\K[^\"]+' | while read -r package; do composer require --ignore-platform-reqs --no-scripts --no-interaction --no-progress --no-install \"$package\"; done; composer update --lock --ignore-platform-reqs --no-scripts --no-interaction --no-progress --no-install" + ], "post-autoload-dump": [ "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump" ], + "post-create-project-cmd": [ + "@php artisan key:generate --ansi", + "@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"", + "@php artisan migrate --graceful --ansi" + ], + "post-root-package-install": [ + "@php -r \"file_exists('.env') || copy('.env.example', '.env');\"" + ], "post-update-cmd": [ - "Illuminate\\Foundation\\ComposerScripts::postUpdate" + "@php artisan vendor:publish --tag=laravel-assets --ansi --force" ], - "lint": [ - "php-cs-fixer fix -v --config=.php_cs --using-cache=no" + "req": [ + "composer require --ignore-platform-reqs --with-all-dependencies" ], - "lint-check": [ - "php-cs-fixer fix -v --config=.php_cs --using-cache=no --dry-run" + "reqdev": [ + "composer require --ignore-platform-reqs --with-all-dependencies --dev" + ], + "rmv": [ + "@composer remove --ignore-platform-reqs --with-all-dependencies", + "@composer helpers" ], "test": [ - "phpunit --coverage-text" + "vendor/bin/pest" + ], + "test-coverage": [ + "vendor/bin/pest --coverage" + ], + "upg": [ + "@composer update --ignore-platform-reqs --with-all-dependencies", + "@composer helpers" ] } } diff --git a/contributing.md b/contributing.md deleted file mode 100644 index 92c99cf..0000000 --- a/contributing.md +++ /dev/null @@ -1,30 +0,0 @@ - - -# Contributing - -Contributions are welcome and will be fully credited. - -Contributions are accepted via Pull Requests on [Github](https://github.com/perspikapps/laravel-envribbon). - -# Things you could do - -If you want to contribute but do not know where to start, this list provides some starting points. - -- Add license text -- Remove rewriteRules.php -- Set up TravisCI, StyleCI, ScrutinizerCI -- Write a comprehensive ReadMe - -## Pull Requests - -- **Add tests!** - Your patch won't be accepted if it doesn't have tests. - -- **Document any change in behaviour** - Make sure the `readme.md` and any other relevant documentation are kept up-to-date. - -- **Consider our release cycle** - We try to follow [SemVer v2.0.0](http://semver.org/). Randomly breaking public APIs is not an option. - -- **One pull request per feature** - If you want to do more than one thing, send multiple pull requests. - -- **Send coherent history** - Make sure each individual commit in your pull request is meaningful. If you had to make multiple intermediate commits while developing, please [squash them](http://www.git-scm.com/book/en/v2/Git-Tools-Rewriting-History#Changing-Multiple-Commit-Messages) before submitting. - -**Happy coding**! diff --git a/dist/.github/workflows/clean.yml b/dist/.github/workflows/clean.yml new file mode 100755 index 0000000..25d201a --- /dev/null +++ b/dist/.github/workflows/clean.yml @@ -0,0 +1,35 @@ +# @format + +name: Clean Deploy History + +on: + schedule: [{ cron: '30 1 * * *' }] # Schedule to run the workflow every day at 1:30 AM + workflow_dispatch: + inputs: + workflows: + description: 'Comma separated list of workflow files to clean (empty = all)' + required: false + default: '' + min_days: + description: 'Minimum number of days of history to keep per workflow' + required: false + default: '10' + min_runs: + description: 'Minimum number of most-recent runs to keep per workflow' + required: false + default: '10' + +jobs: + clean-history: + name: Clean Deploy History + runs-on: ubuntu-latest + steps: + - name: Checkout Repository + uses: actions/checkout@v6 + - name: Clean History + uses: ./packages/tomgrv/actions/clean-history + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + workflows: ${{ inputs.workflows }} + min-days: ${{ inputs.min_days }} + min-runs: ${{ inputs.min_runs }} diff --git a/dist/.github/workflows/deploy.yml b/dist/.github/workflows/deploy.yml new file mode 100644 index 0000000..6eeecf0 --- /dev/null +++ b/dist/.github/workflows/deploy.yml @@ -0,0 +1,83 @@ +# @format + +name: Deploy + +on: + push: + branches: + - main + - release/* + tags: + - 'v[0-9]+.[0-9]+.[0-9]+' + - 'v[0-9]+.[0-9]+.[0-9]+-*' + pull_request: + types: + - review_requested +run-name: "${{ github.event_name == 'pull_request' && format('Publish alpha for PR #{0}', github.event.pull_request.number) || format('Publish on {0}', github.ref_name) }}" +jobs: + list-hosts: + runs-on: ubuntu-latest + outputs: + hosts: ${{ steps.parse.outputs.hosts }} + steps: + - name: Checkout Repository + uses: actions/checkout@v6 + - name: Parse Deploy Config + id: parse + uses: ./packages/perspikapps/klick-deploy/actions/list-hosts + with: + selector: ${{ vars.SELECTOR || 'all'}} + environment: ${{ vars.ENVIRONMENT }} + labels: runner + deploy-hosts: + cancel-timeout-minutes: 30 + concurrency: deploy-${{ matrix.hosts.host }} + runs-on: ${{ matrix.hosts.runner || 'ubuntu-latest' }} + strategy: + fail-fast: false + matrix: + hosts: ${{ fromJson(needs.list-hosts.outputs.hosts) }} + needs: list-hosts + steps: + - name: Checkout Repository + uses: actions/checkout@v6 + - name: Get commit author email + id: committer + run: echo "email=$(git log -1 --pretty=format:'%ae')" >> "$GITHUB_OUTPUT" + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + extensions: mbstring, intl, xml, zip, curl, gd + env: + update: true + - name: Install Composer Dependencies + uses: ramsey/composer-install@v3 + with: + composer-options: '--no-dev' + - uses: actions/setup-node@v3 + with: + node-version: '24' + cache: 'npm' + - name: Node Install Dependencies + run: npm install + - name: Node Build Assets + run: npm run build + - name: Deploy with perspikapps/klick-deploy + uses: ./packages/perspikapps/klick-deploy/actions/deploy + with: + ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }} + ssh-config: | + Host * + StrictHostKeyChecking no + UserKnownHostsFile /dev/null + known-hosts: ${{ secrets.KNOWN_HOSTS }} + environment: ${{ vars.ENVIRONMENT }} + selector: alias=${{ matrix.hosts.host }}\&${{ vars.SELECTOR || 'all'}} + default-user-mail: ${{ steps.committer.outputs.email || secrets.DEFAULT_USER_MAIL }} + default-user-name: ${{ secrets.DEFAULT_USER_NAME }} + - name: Tag Deployed Version + uses: ./packages/perspikapps/klick-deploy/actions/create-or-replace-ref + with: + ref: refs/tags/deploy/${{ matrix.hosts.host }} + sha: ${{ github.sha }} diff --git a/dist/.github/workflows/unlock.yml b/dist/.github/workflows/unlock.yml new file mode 100644 index 0000000..be44e91 --- /dev/null +++ b/dist/.github/workflows/unlock.yml @@ -0,0 +1,33 @@ +# @format + +name: Unlock Hosts + +on: + workflow_dispatch: + inputs: + environment: + description: 'Environment to unlock' + required: false + default: 'alpha' + selector: + description: 'Selector to filter hosts (default: all)' + required: false + default: 'all' + +jobs: + unlock-hosts: + runs-on: ubuntu-latest + steps: + - name: Checkout Repository + uses: actions/checkout@v6 + - name: Unlock with perspikapps/klick-deploy + uses: ./packages/perspikapps/klick-deploy/actions/unlock + with: + ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }} + ssh-config: | + Host * + StrictHostKeyChecking no + UserKnownHostsFile /dev/null + known-hosts: ${{ secrets.KNOWN_HOSTS }} + environment: ${{ vars.ENVIRONMENT || inputs.environment }} + selector: ${{ vars.SELECTOR || inputs.selector || 'all'}} diff --git a/package.json b/package.json index 81cf2d5..6c0a173 100644 --- a/package.json +++ b/package.json @@ -1,89 +1,42 @@ { - "private": true, + "name": "@perspikapps/klick-deploy", "repository": { - "url": "https://github.com/perspikapps/php-easy-deployer.git" - }, - "dependencies": {}, - "devDependencies": { - "@commitlint/cli": "^11.0.0", - "@commitlint/config-conventional": "^11.0.0", - "@commitlint/core": "^11.0.0", - "commitiquette": "^1.0.8", - "commitizen": "^4.2.2", - "conventional-changelog-cli": "^2.1.0", - "cross-env": "^7.0", - "devmoji": "^2.1.10", - "env-cmd": "^10.1.0", - "git-precommit-checks": "^3.0.6", - "husky": "^4.3.0", - "lint-staged": "^10.5.0", - "lodash": "^4.17.20", - "maildev": "^1.1.0", - "npm-run-all": "^4.1.5", - "prettier": "^2.1.2", - "standard-version": "^9.0.0", - "subpkg": "^4.1.0" - }, - "peerDependencies": { - "ntl": "^5.1.0", - "dotnet-run": "1.3.1", - "vue": "^2.6.12", - "vue-template-compiler": "^2.6.12" + "url": "https://github.com/perspikapps/klick-deploy.git" }, "scripts": { - "lint": "lint-staged", - "postlint": "subpkg run lint", - "postinstall": "subpkg install", - "update": "npx npm-check-updates -i -u", - "postupdate": "subpkg run update", - "init": "run-s -c init:* git:version:inst lint", - "init:gitflow": "git flow init -d -f && (git branch develop || echo skip) && git checkout develop", - "init:husky": "husky install", - "release": "npm run git:version:shell -- npm run git:ifclean -- standard-version --release-as $SemVer", - "release:push": "npm run release && git push", - "release:first": "standard-version --first-release", - "git:list": "git log --graph --pretty=\"format:%Cred%h%Creset -%C(yellow)%d%Creset %s %Cgreen(%cr) %C(bold blue)<%an>%Creset\" --abbrev-commit --decorate --date=short --color develop@{push}.. | devmoji --log --color", - "git:amend": "run-s git:check && (git diff-index --cached --quiet HEAD || git commit --no-verify --amend -C HEAD)", - "git:amend:push": "npm run git:amend && git push --force", - "git:check": "git-precommit-checks && lint-staged", - "git:commit": "cross-env GIT_EDITOR=: git commit", - "git:commit:push": "npm run git:commit && git push", - "git:ifclean": "(git fetch --dry-run && git diff-index --quiet HEAD -- || chalk red bold \"Repo not clean, cannot continue\" && exit 0) && chalk green bold \"Repo is clean, proceed\" && cross-env-shell", - "git:version:install": "dotnet tool install -g gitversion.tool", - "git:version:shell": "dotnet-gitversion > .git/version.json && env-cmd -f .git/version.json -x cross-env-shell", - "flow:feature:start": "npm run git:ifclean -- git flow feature start", - "flow:feature:finish": "npm run git:ifclean -- git flow feature finish", - "flow:release:start": "npm run git:version:shell -- npm run git:ifclean -- git flow release start $MajorMinorPatch", - "flow:release:finish": "npm run git:ifclean -- git flow release finish -m Release", - "flow:hotfix:start": "npm run git:ifclean -- git flow hotfix start from-$npm_package_version", - "flow:hotfix:finish": "npm run git:ifclean -- git flow hotfix finish -m Hotfix'", - "flow:bugfix:start": "npm run git:ifclean -- git flow bugfix start", - "flow:bugfix:finish": "npm run git:ifclean -- git flow bugfix finish'" + "lint": "npx --yes lint-staged", + "test": "echo \"Warning: no test specified\"", + "update": "npx --yes npm-check-updates -i -u", + "update-all": "npm run update -ws --root" }, - "subPackages": [], "config": { "commitizen": { - "path": "commitiquette" - } - }, - "husky": { - "hooks": { - "pre-commit": "git-precommit-checks && lint-staged", - "prepare-commit-msg": "[[ $(grep -cv -e '^#' -e '^$' .git/COMMIT_EDITMSG) -eq 0 ]] && (exec < /dev/tty && git cz --hook && devmoji -e || echo skip commit) || echo skip prepare", - "commit-msg": "commitlint -E HUSKY_GIT_PARAMS" + "path": "@commitlint/cz-commitlint" } }, - "lint-staged": { - "*.{js,jsx,ts,tsx,md,html,css,json,vue}": [ - "prettier --write" + "prettier": { + "insertPragma": true, + "overrides": [ + { + "files": "*.yml", + "options": { + "useTabs": true, + "tabWidth": 2 + } + } ], - "*.php": [ - "composer lint" - ] + "plugins": [ + "prettier-plugin-sh" + ], + "semi": false, + "singleQuote": true, + "tabWidth": 4, + "trailingComma": "es5" }, "commitlint": { "extends": [ - "@commitlint/config-conventional" + "@commitlint/config-conventional", + "@commitlint/config-workspace-scopes" ], "rules": { "subject-case": [ @@ -94,57 +47,33 @@ "pascal-case", "upper-case" ] - ], - "scope-enum": [ - 2, - "always", - [ - "deps", - "release", - "security", - "i18n", - "config", - "add", - "remove", - "breaking", - "auth", - "ui-ux", - "api", - "model" - ] ] } }, "git-precommit-checks": { "rules": [ { - "message": "You’ve got leftover conflict markers", + "message": "You've got leftover conflict markers", "regex": "/^[<>|=]{4,}/m" }, { + "filter": "(^package\\.json|\\.git-precommit-checks.json)$", "message": "You have unfinished devs", "nonBlocking": "true", "regex": "(?:FIXME|TODO)" } ] }, - "prettier": { - "trailingComma": "es5", - "tabWidth": 4, - "semi": false, - "singleQuote": true, - "insertPragma": true - }, - "standard-version": { - "bumpFiles": [ - { - "filename": "composer.json", - "type": "json" - }, - { - "filename": "VERSION", - "type": "plain-text" - } + "lint-staged": { + "*.json": [ + "normalize-json -c -w -a -i -f local -l true", + "npx --yes prettier --write" + ], + "*.php": [ + "composer lint" + ], + "*.{js,jsx,ts,tsx,md,html,css,vue,yaml,yml}": [ + "npx --yes prettier --write" ] } } diff --git a/phpunit.xml b/phpunit.xml deleted file mode 100644 index ce34605..0000000 --- a/phpunit.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - ./tests/ - - - - - src/ - - - diff --git a/readme.md b/readme.md index 2a97866..a83f99c 100644 --- a/readme.md +++ b/readme.md @@ -2,9 +2,9 @@ # Easy Deployer -[![Packagist](https://img.shields.io/packagist/v/perspikapps/php-easy-deployer.svg)](https://packagist.org/packages/perspikapps/php-easy-deployer) -[![Packagist](https://poser.pugx.org/perspikapps/php-easy-deployer/d/total.svg)](https://packagist.org/packages/perspikapps/php-easy-deployer) -[![Packagist](https://img.shields.io/packagist/l/perspikapps/php-easy-deployer.svg)](https://packagist.org/packages/perspikapps/php-easy-deployer) +[![Packagist](https://img.shields.io/packagist/v/perspikapps/klick-deploy.svg)](https://packagist.org/packages/perspikapps/klick-deploy) +[![Packagist](https://poser.pugx.org/perspikapps/klick-deploy/d/total.svg)](https://packagist.org/packages/perspikapps/klick-deploy) +[![Packagist](https://img.shields.io/packagist/l/perspikapps/klick-deploy.svg)](https://packagist.org/packages/perspikapps/klick-deploy) [![Commitizen friendly](https://img.shields.io/badge/commitizen-friendly-brightgreen.svg)](http://commitizen.github.io/cz-cli/) [![semantic-release](https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg)](https://github.com/semantic-release/semantic-release) @@ -17,7 +17,7 @@ This package handles deployement configuration via a `deploy.yaml` file to defin Install via composer ```bash -composer require perspikapps/php-easy-deployer +composer require perspikapps/klick-deploy ``` ## Usage @@ -30,11 +30,11 @@ _See [deployer](https://github.com/deployphp/deployer) config for details_ ```yaml import: - - vendor/perspikapps/php-easy-deployer/src/strategy_laravel.php - - vendor/perspikapps/php-easy-deployer/src/strategy_upload.php - - vendor/perspikapps/php-easy-deployer/src/strategy_update.php - - vendor/perspikapps/php-easy-deployer/src/strategy_shared.php - - vendor/perspikapps/php-easy-deployer/src/strategy.php + - vendor/perspikapps/klick-deploy/src/strategy_laravel.php + - vendor/perspikapps/klick-deploy/src/strategy_upload.php + - vendor/perspikapps/klick-deploy/src/strategy_update.php + - vendor/perspikapps/klick-deploy/src/strategy_shared.php + - vendor/perspikapps/klick-deploy/src/strategy.php config: source_path: './' @@ -54,17 +54,37 @@ config: - storage/logs log_files: - storage/logs/*.log + +hosts: + example.com: + hostname: ssh.example.com + labels: + env: production + secrets: + APP_KEY: ENCRYPTED_APP_KEY + DB_PASSWORD: ENCRYPTED_DB_PASSWORD + MAIL_PASSWORD: ``` +Host entries may define a `secrets` map. Each key is the environment variable name that should be written to the remote `.env` file. + +Supported value formats are: + +- `APP_KEY: %APP_KEY%` uses the current process environment variable `APP_KEY` without decryption. +- `APP_KEY: ENCRYPTED_APP_KEY` decrypts the inline value remotely with the `decrypt` helper. +- `APP_KEY: { secret: ENCRYPTED_APP_KEY, env: APP_KEY }` uses explicit encrypted value with optional env fallback when `secret` is empty. + +This keeps secret values out of the repository while still letting each host declare exactly which secrets it needs. + ### .hostname Specify ONE deployement target per line as url: -- url scheme = strategies to activate (`+` separated, each must be loaded in `import` section of `deploy.yaml` file) -- url user/host/port = server to deploy to -- url path = path on server to deploy to -- url query = deploy options to use -- url anchor = variables to set in .env file after deployment +- url scheme = strategies to activate (`+` separated, each must be loaded in `import` section of `deploy.yaml` file) +- url user/host/port = server to deploy to +- url path = path on server to deploy to +- url query = deploy options to use +- url anchor = variables to set in .env file after deployment ```ini upload+laravel://user@dev.exemple.com/var/home/{{hostname}}?bin/php=/opt/plesk/php/7.4/bin/php&writable_mode=chmod#debug=true&env=staging @@ -72,6 +92,17 @@ upload+laravel://user@beta.exemple.com/var/home/{{hostname}}?bin/php=/opt/plesk/ upload+laravel://user@www.exemple.com/var/home/{{hostname}}?bin/php=/opt/plesk/php/7.4/bin/php&writable_mode=chmod#debug=false&env=production ``` +## Features + +### Automatic Crontab Setup + +The package automatically configures essential Laravel cron jobs during deployment: + +- **Queue Restart**: `php artisan queue:restart` runs every hour to prevent memory leaks +- **Schedule Runner**: `php artisan schedule:run` runs every minute to execute Laravel's task scheduler + +These cron jobs are automatically set up just before the deployment unlock phase and use the deployment path to ensure they point to the current release. + ## Security If you discover any security related issues, please email @@ -79,5 +110,5 @@ instead of using the issue tracker. ## Credits -- [tomgrv](https://github.com/tomgrv) -- [deployphp](https://github.com/deployphp) +- [tomgrv](https://github.com/tomgrv) +- [deployphp](https://github.com/deployphp) diff --git a/src/func_defineHost.php b/src/func_defineHost.php deleted file mode 100644 index 789a0d6..0000000 --- a/src/func_defineHost.php +++ /dev/null @@ -1,63 +0,0 @@ -setSshMultiplexing(false) - ->setDeployPath($data['path']) - ->setLabels($labels) - ->set('strategies', $strategies); - - if (isset($data['pass']) && str_starts_with($data['pass'], 'identity=')) { - $host->setIdentityFile(substr($data['pass'], 9)); - } - - $host->setSshArguments([ - '-o UserKnownHostsFile=/dev/null', - '-o StrictHostKeyChecking=no', - ]); - - if (isset($data['user'])) { - $host->setRemoteUser($data['user']) - ->set('http_user', $data['user']); - } - - foreach ($params as $key => $value) { - $host->set($key, $value); - } - - return $host; -} diff --git a/src/func_invokeHook.php b/src/func_invokeHook.php deleted file mode 100644 index a86e72d..0000000 --- a/src/func_invokeHook.php +++ /dev/null @@ -1,20 +0,0 @@ -get('strategies') as $strategy) { - try { - invoke('strategy:'.$strategy.':'.$name); - } catch (InvalidArgumentException $e) { - writeln('Strategy <'.$strategy.':'.$name.'> not declared, skip'); - } - } -} diff --git a/src/func_loadHostsMap.php b/src/func_loadHostsMap.php deleted file mode 100644 index 0f9daad..0000000 --- a/src/func_loadHostsMap.php +++ /dev/null @@ -1,25 +0,0 @@ -info Apply '.$key.'='.$value.''); - run("grep -q '^$key' {{deploy_path}}/shared/.env && sed -i -e '/$key/ s/=.*$/=$value/' {{deploy_path}}/shared/.env || echo '$key=$value' >> {{deploy_path}}/shared/.env"); -} diff --git a/src/main.php b/src/main.php new file mode 100644 index 0000000..512baaf --- /dev/null +++ b/src/main.php @@ -0,0 +1,37 @@ +hidden(); - -// Strategy before symlink -task( - 'strategy:released', - static function () { - invokeHook('released'); - } -)->hidden(); - -// Strategy before unlock -task( - 'strategy:done', - static function () { - invokeHook('done'); - } -)->hidden(); - -// Strategy after failure -task( - 'strategy:failed', - function () { - invokeHook('failed'); - } -)->hidden(); - -// Strategy after rollback -task( - 'strategy:rollback', - function () { - invokeHook('rollback'); - } -)->hidden(); - -// Deploy Task -task( - 'deploy', - [ - 'deploy:info', - 'deploy:setup', - 'deploy:lock', - 'deploy:release', - 'strategy:release', - 'deploy:shared', - 'deploy:writable', - 'strategy:released', - 'deploy:symlink', - 'deploy:setenv', - 'strategy:done', - 'deploy:unlock', - 'deploy:cleanup', - 'deploy:success', - ] -)->hidden(); - -after('deploy:failed', 'deploy:unlock'); -after('deploy:failed', 'strategy:failed'); -after('rollback', 'strategy:rollback'); - -// Load map -loadHostsMap(); diff --git a/src/strategy_laravel.php b/src/strategy_laravel.php deleted file mode 100644 index ac425cf..0000000 --- a/src/strategy_laravel.php +++ /dev/null @@ -1,24 +0,0 @@ -desc('Configure and Run migrations'); - -task('strategy:laravel:done', [ - 'artisan:config:cache', - 'artisan:route:cache', - 'artisan:view:cache', - 'artisan:event:cache', -])->desc('Clear all caches'); - -task('strategy:laravel:rollback', [ - 'artisan:optimize:clear', - 'artisan:config:cache', -])->desc('Clear all caches'); diff --git a/src/strategy_shared.php b/src/strategy_shared.php deleted file mode 100644 index b286c7f..0000000 --- a/src/strategy_shared.php +++ /dev/null @@ -1,43 +0,0 @@ - - - Options -MultiViews -Indexes - - - RewriteEngine On - - # Enable symbolic links - Options +FollowSymLinks - - # Handle Authorization MemberHeader - RewriteCond %{HTTP:Authorization} . - RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] - - # Redirect Trailing Slashes If Not A Folder... - RewriteCond %{REQUEST_FILENAME} !-d - RewriteCond %{REQUEST_URI} (.+)/$ - RewriteRule ^ %1 [L,R=301] - - # Remove public URL from the path - RewriteCond %{REQUEST_URI} !^/current/public/ - RewriteRule ^(.*)$ /current/public/\$1 [L,QSA] - - # Handle Front Controller... - RewriteCond %{REQUEST_FILENAME} !-d - RewriteCond %{REQUEST_FILENAME} !-f - RewriteRule ^ index.php [L] - -EOT; - - writeln('info Apply htaccess file'); - cd('{{deploy_path}}'); - run('echo "'.$hta.'" > ./.htaccess'); - - writeln('info Apply shared mode'); - updateEnv('APP_SHARED', 'true'); -})->desc('Sets shared config'); diff --git a/src/strategy_update.php b/src/strategy_update.php deleted file mode 100644 index e10d0ab..0000000 --- a/src/strategy_update.php +++ /dev/null @@ -1,11 +0,0 @@ -desc('Uploads local files'); - -task('strategy:update:released', [ - 'deploy:vendor', -])->desc('Uploads vendor files'); diff --git a/src/strategy_upload.php b/src/strategy_upload.php deleted file mode 100644 index 36ad2cc..0000000 --- a/src/strategy_upload.php +++ /dev/null @@ -1,34 +0,0 @@ -desc('Uploads local files'); diff --git a/src/task_setenv.php b/src/task_setenv.php deleted file mode 100644 index 37ef8c4..0000000 --- a/src/task_setenv.php +++ /dev/null @@ -1,14 +0,0 @@ -getLabels() as $label => $value) { - updateEnv('APP_'.strtoupper($label), $value); - } - } -)->desc('Apply remote env variables'); diff --git a/src/tasks/artisan.php b/src/tasks/artisan.php new file mode 100644 index 0000000..a734cc7 --- /dev/null +++ b/src/tasks/artisan.php @@ -0,0 +1,11 @@ +desc('Execute artisan migrate (migrate:fresh on unstable, migrate on all other environments)'); diff --git a/src/tasks/auto_unlock.php b/src/tasks/auto_unlock.php new file mode 100644 index 0000000..4913f90 --- /dev/null +++ b/src/tasks/auto_unlock.php @@ -0,0 +1,15 @@ +result; + + if (isset($result->errors) && ! empty($result->errors)) { + // Log and throw if API returns errors + throw new RuntimeException('Error: '.implode(', ', $result->errors)); + } + + return $result; +} + +function getRootDomain($fqdn) +{ + $parts = explode('.', $fqdn); + $numParts = count($parts); + + if ($numParts > 2) { + // Combine the last two parts to create the root domain + $rootDomain = $parts[$numParts - 2].'.'.$parts[$numParts - 1]; + } else { + $rootDomain = $fqdn; + } + + return $rootDomain; +} + +function getSubDomain($fqdn) +{ + $rootDomain = getRootDomain(trim($fqdn)); + $subDomain = trim(str_replace($rootDomain, '', $fqdn), '.'); + + return $subDomain; +} + +function searchDomain($domains, $fdqn) +{ + return array_search($fdqn, array_column($domains, 'domain')); +} + +// Load additional cPanel-related deployment tasks +require_once __DIR__.'/crypto.php'; +require_once __DIR__.'/set_env.php'; +require_once __DIR__.'/cpanel/cpanel_writable.php'; +require_once __DIR__.'/cpanel/cpanel_database.php'; +require_once __DIR__.'/cpanel/cpanel_domain.php'; +require_once __DIR__.'/cpanel/cpanel_mail.php'; +require_once __DIR__.'/cpanel/cpanel_htaccess.php'; diff --git a/src/tasks/cpanel/cpanel_database.php b/src/tasks/cpanel/cpanel_database.php new file mode 100644 index 0000000..de264c2 --- /dev/null +++ b/src/tasks/cpanel/cpanel_database.php @@ -0,0 +1,78 @@ +get('remote_user'); + if (empty($user)) { + throw new RuntimeException('No remote user found.'); + } + + // Retrieve the application name + $name = currentHost()->getLabels()['name'] ?? ''; + if (empty($name)) { + throw new RuntimeException('No application name found.'); + } + + // Retrieve the environment or use a default value + $env = currentHost()->getLabels()['env'] ?? 'default'; + if (empty($env)) { + throw new RuntimeException('No environment found.'); + } + + // Build the database name + $db_name = $user.'_'.strtolower($name).'_'.strtolower($env); + + // Set environment variables for the database + setenv('DB_CONNECTION', 'mysql'); + setenv('DB_HOST', 'localhost'); + setenv('DB_PORT', '3306'); + setenv('DB_DATABASE', $db_name); + + // Check if the database exists + $bases = uapi('Mysql', 'list_databases', null); + if (! in_array($db_name, array_column($bases->data, 'database'))) { + // Create the database if it does not exist + info('Create database '.$db_name.''); + uapi('Mysql', 'create_database', ['name' => $db_name]); + } else { + info('Database '.$db_name.' already exists'); + } + + // Check if the user exists + $users = uapi('Mysql', 'list_users', null); + $password = bin2hex(random_bytes(8)); // Generate a random password + + if (! in_array($db_name, array_column($users->data, 'user'))) { + // Create the user if it does not exist + info('Create database user '.$db_name.''); + uapi('Mysql', 'create_user', ['name' => $db_name, 'password' => $password]); + + // Set environment variables for the user + setenv('DB_USERNAME', $db_name); + setenv('DB_PASSWORD', $password); + } else { + info('Database user '.$db_name.' already exists'); + } + + // Grant all privileges to the user on the database + uapi('Mysql', 'set_privileges_on_database', [ + 'user' => $db_name, + 'database' => $db_name, + 'privileges' => 'ALL PRIVILEGES', + ]); +})->select('type=cpanel&db=mysql'); + +// Ensure the task runs after the release step +after('deploy:release', 'cpanel:database'); diff --git a/src/tasks/cpanel/cpanel_domain.php b/src/tasks/cpanel/cpanel_domain.php new file mode 100644 index 0000000..44bebdc --- /dev/null +++ b/src/tasks/cpanel/cpanel_domain.php @@ -0,0 +1,100 @@ +data->main_domain->domain ?? null) == $alias; + + // If the alias exists as a main domain, check if the document root matches the deployment path + if ($is_main !== false) { + info('Domain '.$alias.' already exists as main domain'); + + if ($domains->data->main_domain->documentroot !== $deploy_path) { + throw new RuntimeException('Domain '.$alias." already exists but the document root is different.\nPlease align the deploy paths."); + } + + return; + } + + // Check if the alias exists as an addon domain + $idx = searchDomain($domains->data->addon_domains, $alias); + + // If the alias exists as an addon domain, check if the document root matches the deployment path + if ($idx !== false) { + info('Domain '.$alias.' already exists as addon domain'); + + if ($domains->data->addon_domains[$idx]->documentroot !== $deploy_path) { + throw new RuntimeException('Domain '.$alias." already exists but the document root is different.\nPlease align the deploy paths."); + } + + return; + } + + // Check if the alias exists as a subdomain + $idx = searchDomain($domains->data->sub_domains, $alias); + + // If the alias exists as a subdomain, check if the document root matches the deployment path + if ($idx !== false) { + info('Domain '.$alias.' already exists as subdomain'); + + if ($domains->data->sub_domains[$idx]->documentroot !== $deploy_path) { + throw new RuntimeException('Subdomain '.$alias." already exists but the document root is different.\nPlease align the deploy paths."); + } + + return; + } + + // find subdomain and root domain + $rootdomain = getRootDomain($alias); + $subdomain = getSubDomain($alias); + + // Check if the subdomain is empty + if (empty($subdomain)) { + throw new RuntimeException('Subdomain '.$alias.' cannot be empty.'); + } + + // Check if the root domain exists in the main domain or subdomains + $idx = ($domains->data->main_domain->domain ?? null) == $rootdomain || searchDomain($domains->data->addon_domains, $rootdomain); + + // If the root domain does not exist, throw an error + if (! $idx) { + throw new RuntimeException('Subdomain '.$alias." does not have a root domain defined on this server.\nPlease check the domain configuration."); + } + + // Log the creation of the subdomain + info('Create subdomain '.$alias.' for '.$deploy_path.''); + + // Use the cPanel API to add the subdomain + uapi('SubDomain', 'addsubdomain', [ + 'domain' => $subdomain, + 'rootdomain' => $rootdomain, + 'dir' => $deploy_path, // Set the document root + ]); +})->select('type=cpanel'); + +// Ensure the subdomain task runs after the release task +after('deploy:release', 'cpanel:subdomain'); diff --git a/src/tasks/cpanel/cpanel_htaccess.php b/src/tasks/cpanel/cpanel_htaccess.php new file mode 100644 index 0000000..65f49ec --- /dev/null +++ b/src/tasks/cpanel/cpanel_htaccess.php @@ -0,0 +1,59 @@ + + + Options -MultiViews -Indexes + + + RewriteEngine On + + # Handle Http to Https redirection + RewriteCond %{HTTPS} off + RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301] + + # Enable symbolic links + Options +FollowSymLinks + + # Handle Authorization MemberHeader + RewriteCond %{HTTP:Authorization} . + RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] + + # Redirect Trailing Slashes If Not A Folder... + RewriteCond %{REQUEST_FILENAME} !-d + RewriteCond %{REQUEST_URI} (.+)/$ + RewriteRule ^ %1 [L,R=301] + + # Remove public URL from the path + RewriteCond %{REQUEST_URI} !^/current/public/ + RewriteRule ^(.*)$ /current/public/\$1 [L,QSA] + + # Handle Front Controller... + RewriteCond %{REQUEST_FILENAME} !-d + RewriteCond %{REQUEST_FILENAME} !-f + RewriteRule ^ index.php [L] + + +EOT; + + // Log the application of the .htaccess file + info('Apply htaccess file'); + + // Write the .htaccess content to the deploy path + run('echo "'.$hta.'" > {{deploy_path}}/.htaccess'); +})->select('type=cpanel'); + +// Ensure the task runs after the release step +after('deploy:release', 'cpanel:htaccess'); diff --git a/src/tasks/cpanel/cpanel_mail.php b/src/tasks/cpanel/cpanel_mail.php new file mode 100644 index 0000000..1552d7a --- /dev/null +++ b/src/tasks/cpanel/cpanel_mail.php @@ -0,0 +1,105 @@ +data->smtp_host ?? $appDomain; + $smtpPort = $clientSettings->data->smtp_port ?? null; + + if (empty($smtpPort)) { + $servers = uapi('Chkservd', 'get_exim_ports', null); + $smtpPort = $servers->data->ports[0] ?? 25; + warning('Could not resolve SMTP port from Email::get_client_settings, falling back to Exim port lookup.'); + } + + $mailerEmail = 'no-reply@'.$appDomain; + + // Check if mailer email account exists + info("Checking if email account {$mailerEmail} exists..."); + $emailAccounts = uapi('Email', 'list_pops', ['domain' => $appDomain]); + $mailerExists = false; + + if (isset($emailAccounts->data)) { + info('Found '.count($emailAccounts->data)." email accounts for domain {$appDomain}"); + foreach ($emailAccounts->data as $account) { + + if ($account->email === $mailerEmail) { + $mailerExists = true; + info("Found existing mailer account: {$mailerEmail}"); + break; + } + } + if (! $mailerExists) { + warning("Mailer account {$mailerEmail} not found in existing accounts"); + } + } else { + warning("No email accounts found for domain {$appDomain}"); + } + + // Generate a random password for the mailer account + $randomPassword = bin2hex(random_bytes(16)); // Generate random 32-character password + // Set environment variables for the smtp server + setenv('MAIL_USERNAME', $mailerEmail); + setenv('MAIL_PASSWORD', $randomPassword); + setenv('MAIL_MAILER', 'smtp'); + setenv('MAIL_HOST', $smtpHost); + setenv('MAIL_PORT', $smtpPort); + setenv('MAIL_ENCRYPTION', null); + + // Create mailer account if it doesn't exist + if (! $mailerExists) { + + $createResult = uapi('Email', 'add_pop', [ + 'domain' => $appDomain, + 'email' => 'no-reply', + 'password' => $randomPassword, + 'quota' => 0, // Unlimited quota + ]); + + if ($createResult->status === 1) { + info("Created email account {$mailerEmail} with random password"); + } else { + throw new Exception("Failed to create email account {$mailerEmail}: ".($createResult->errors[0] ?? 'Unknown error')); + } + } else { + info("Email account {$mailerEmail} already exists"); + + $updateResult = uapi('Email', 'passwd_pop', [ + 'domain' => $appDomain, + 'email' => 'no-reply', + 'password' => $randomPassword, + ]); + } + + // Suspend incoming mail + $suspendResult = uapi('Email', 'suspend_incoming', [ + 'email' => $mailerEmail, + ]); + + if ($suspendResult->status === 1) { + info("Suspended incoming mail for {$mailerEmail}"); + } else { + warning("Failed to suspend incoming mail for {$mailerEmail}: ".($suspendResult->errors[0] ?? 'Unknown error')); + } + + // Email config + setenv('MAIL_FROM_ADDRESS', $mailerEmail); + setenv('MAIL_FROM_NAME', 'Mailer'); + + info('Mail server '.$smtpHost.':'.$smtpPort.' configured with account '.$mailerEmail.''); +})->select('type=cpanel&mail=smtp'); + +// Ensure the mail task runs after the release task +after('deploy:release', 'cpanel:mail'); diff --git a/src/tasks/cpanel/cpanel_writable.php b/src/tasks/cpanel/cpanel_writable.php new file mode 100644 index 0000000..6834515 --- /dev/null +++ b/src/tasks/cpanel/cpanel_writable.php @@ -0,0 +1,19 @@ +select('type=cpanel'); + +// Ensure the task runs after the setup step +before('deploy:writable', 'cpanel:writable'); diff --git a/src/tasks/crypto.php b/src/tasks/crypto.php new file mode 100644 index 0000000..01e6d23 --- /dev/null +++ b/src/tasks/crypto.php @@ -0,0 +1,64 @@ +getLabels()['env'] ?? 'production') === 'unstable'; +} diff --git a/src/tasks/modules.php b/src/tasks/modules.php new file mode 100644 index 0000000..8d71146 --- /dev/null +++ b/src/tasks/modules.php @@ -0,0 +1,11 @@ +'.$module.''); + artisan('module:disable '.substr($module, 1))(); + } + } else { + info('Disabling all modules'); + artisan('module:disable --all')(); + + foreach ($enable as $module) { + info('Enabling module: '.$module.''); + artisan('module:enable '.$module)(); + } + } + + artisan('module:list', ['showOutput'])(); +}); + +// Ensure the task runs after the setup step +before('artisan:config:cache', 'modules:activate'); diff --git a/src/tasks/platform.php b/src/tasks/platform.php new file mode 100644 index 0000000..0479077 --- /dev/null +++ b/src/tasks/platform.php @@ -0,0 +1,16 @@ +limit(1); + +after('deploy:symlink', 'platform:crontab'); diff --git a/src/tasks/platform/platform_decrypt.php b/src/tasks/platform/platform_decrypt.php new file mode 100644 index 0000000..543d50e --- /dev/null +++ b/src/tasks/platform/platform_decrypt.php @@ -0,0 +1,45 @@ +'.$alias.''); + + // Load environment variables from context + $env = get('env'); + if (empty($env)) { + // Log and return if no environment variables are found + error('No environment variables found to decrypt.'); + + return; + } + + // Prompt user to select the environment variable to decrypt + $list = array_keys($env); + $name = askchoice('Name of the environment variable ', $list); + + // Get the encrypted value, prompt if not present + $data = $env[$name]; + if (empty($data)) { + $data = ask('Encrypted value of the environment variable:'); + } + + info("Decrypting environment variable:\n\n".$data.''); + + // Decrypt the variable with the server private key + $decrypted = decrypt($data); + + // Show the decrypted value to the user + info("Decrypted value:\n\n".$decrypted."\n\n"); +}); diff --git a/src/tasks/platform/platform_encrypt.php b/src/tasks/platform/platform_encrypt.php new file mode 100644 index 0000000..e5437ff --- /dev/null +++ b/src/tasks/platform/platform_encrypt.php @@ -0,0 +1,42 @@ +'.$alias.''); + + // Prompt user for the value to encrypt + $data = ask('Value to encrypt: '); + if (empty($data)) { + // Log and throw if no value is provided + throw new RuntimeException('No value provided. Please provide a value to encrypt.'); + } + + // Encrypt the variable with the server public key + $encrypted = encrypt($data); + + // Show the encrypted value to the user + info("Encrypted entry generated:\n\n".$encrypted."\n\n"); + + // Verify the encrypted variable by decrypting it + $decrypted = decrypt($encrypted); + if ($decrypted != $data) { + // Log and throw if verification fails + throw new RuntimeException('Verification failed. The encrypted variable does not match the original value.'); + } + + info('Variable successfully encrypted and verified.'); +})->oncePerNode(); diff --git a/src/tasks/platform/platform_knownhosts.php b/src/tasks/platform/platform_knownhosts.php new file mode 100644 index 0000000..828dcc6 --- /dev/null +++ b/src/tasks/platform/platform_knownhosts.php @@ -0,0 +1,15 @@ +{{hostname}}...'); + runLocally('ssh-keygen -F {{hostname}} || ssh-keyscan -H {{hostname}} >> ~/.ssh/known_hosts'); +})->oncePerNode(); diff --git a/src/tasks/platform/platform_listhosts.php b/src/tasks/platform/platform_listhosts.php new file mode 100644 index 0000000..725dfea --- /dev/null +++ b/src/tasks/platform/platform_listhosts.php @@ -0,0 +1,19 @@ +get('alias'); + }, selectedHosts()); + + echo json_encode($host, JSON_PRETTY_PRINT); +})->once(); diff --git a/src/tasks/platform/platform_savepub.php b/src/tasks/platform/platform_savepub.php new file mode 100644 index 0000000..9a92fea --- /dev/null +++ b/src/tasks/platform/platform_savepub.php @@ -0,0 +1,65 @@ +'.$alias.''); + } + + // Present options to the user for managing the public key + $choices = [ + 'Regenerate public key', + 'Download public key', + 'View openssh public key', + ]; + $gen = askChoice('What would you like to do?', $choices, 2); + + // get position of the selected choice in the array + $pos = array_search($gen, $choices); + + if ($pos == 0) { + // Regenerate public key from private key + run('openssl rsa -in ~/.ssh/id_rsa -pubout -out ~/.ssh/id_rsa.pub'); + info('Public key regenerated on the server'); + } + + if ($pos == 1) { + // Download the public key to local machine + $publicKeyName = str_replace('.', '_', $alias).'.id_rsa.pub'; + download('~/.ssh/id_rsa.pub', './'.$publicKeyName); + info('Public key saved as '.$publicKeyName.''); + } + + if ($pos == 2) { + // Display the public key in OpenSSH format + $publicKey = run('ssh-keygen -y -f ~/.ssh/id_rsa '); + info("Public key is: \n\n".$publicKey."\n\n"); + } +})->oncePerNode(); diff --git a/src/tasks/set_env.php b/src/tasks/set_env.php new file mode 100644 index 0000000..b6947c7 --- /dev/null +++ b/src/tasks/set_env.php @@ -0,0 +1,275 @@ + $definitions + * @param null|callable(string): string|false $resolver + * @return array + */ +function resolveHostSecrets(array $definitions, ?callable $resolver = null): array +{ + $resolver ??= static fn (string $name): string|false => getenv($name); + + $resolved = []; + + foreach ($definitions as $key => $value) { + $secretKey = is_int($key) ? trim((string) $value) : trim((string) $key); + + if ($secretKey === '') { + throw new RuntimeException('Host secret keys can not be empty.'); + } + + if (is_array($value)) { + $inlineSecret = trim((string) ($value['secret'] ?? '')); + + if ($inlineSecret !== '') { + $resolved[$secretKey] = ['value' => $inlineSecret, 'encrypted' => true, 'source' => 'secret']; + + continue; + } + + $source = trim((string) ($value['env'] ?? $secretKey)); + } elseif (is_int($key)) { + $source = $secretKey; + } elseif (is_string($value)) { + $trimmedValue = trim($value); + + if (preg_match('/^%([A-Z0-9_]+)%$/', $trimmedValue, $matches) === 1) { + $source = trim($matches[1]); + + if ($source === '') { + throw new RuntimeException("Invalid %VAR% reference for host secret [{$secretKey}]."); + } + + $resolvedValue = $resolver($source); + + if ($resolvedValue === false) { + warning("Missing environment variable [{$source}] for host secret [{$secretKey}]."); + + continue; + } + + $resolved[$secretKey] = ['value' => $resolvedValue, 'encrypted' => false, 'source' => $source]; + + continue; + } + + if (str_starts_with($trimmedValue, '%')) { + throw new RuntimeException("Invalid %VAR% reference for host secret [{$secretKey}]."); + } + + $inlineSecret = $trimmedValue; + + if ($inlineSecret === '') { + $source = $secretKey; + } else { + $resolved[$secretKey] = ['value' => $inlineSecret, 'encrypted' => true, 'source' => 'secret']; + + continue; + } + } elseif ($value === null) { + $source = $secretKey; + } else { + throw new RuntimeException('Host secrets must be a list of env names, a map of env key to encrypted value, or {secret?, env?}.'); + } + + if ($source === '') { + $source = $secretKey; + } + + $resolvedValue = $resolver($source); + + if ($resolvedValue === false) { + throw new RuntimeException("Missing environment variable [{$source}] for host secret [{$secretKey}]."); + } + + $resolved[$secretKey] = ['value' => $resolvedValue, 'encrypted' => false, 'source' => $source]; + } + + return $resolved; +} + +/** + * Helper function to ensure an environment variable exists in the .env file. + * + * This function checks if the specified environment variable key exists in the .env file. + * If it does not exist, it appends the key with an empty value to the file. + * + * @param string $key The environment variable key. + */ +function touch($key) +{ + run("grep -c '^$key=' {{deploy_path}}/shared/.env || echo '$key=' >> {{deploy_path}}/shared/.env"); +} + +/** + * Helper function to set or update an environment variable in the .env file. + * + * This function checks if the specified environment variable key exists in the .env file. + * If it exists, it updates the value of the key. If it does not exist, it appends the key with the specified value to the file. + * + * @param string $key The environment variable key. + * @param string|null $value The value to set for the environment variable. + */ +function setenv($key, $value = null) +{ + $value = $value ?? ''; + run("if grep -q '^$key=' {{deploy_path}}/shared/.env; then sed -i -e '/^$key=/d' {{deploy_path}}/shared/.env; fi; printf '%s=%s\\n' '$key' ".escapeshellarg($value).' >> {{deploy_path}}/shared/.env'); +} + +/** + * Helper function to set or update a secret environment variable in the .env file without logging its value. + * + * @param string $key The environment variable key. + * @param string $value The value to set for the environment variable. + */ +function setSecretEnv($key, $value) +{ + run("if grep -q '^$key=' {{deploy_path}}/shared/.env; then sed -i -e '/^$key=/d' {{deploy_path}}/shared/.env; fi; printf '%s=%s\\n' '$key' %secret% >> {{deploy_path}}/shared/.env", secret: escapeshellarg($value)); +} + +/** + * Task to set environment variables on the remote server. + * + * This task iterates over the labels of the current host and updates the environment variables accordingly. + * It ensures that essential environment variables such as APP_KEY and APP_URL are set. + */ +desc('Apply remote env variables'); +task('deploy:set_env', function () { + // Iterate over the labels of the current host and set environment variables + foreach (currentHost()->getLabels() as $label => $value) { + // Sanitize label: add prefix, put uppercase, only alpha and underscore + $key = 'APP_'.strtoupper(preg_replace('/[^a-z0-9A-Z_]/', '', $label)); + + // Sanitize value: remove newlines and trim whitespace. If spaces are present, put the value in quotes + $value = normalizeEnvFileValue((string) $value); + + info('Apply '.$key.'='.$value.''); + setenv($key, $value); + } + + // Ensure APP_KEY is set, generate a new one if not + info('Ensure APP_KEY is set'); + touch('APP_KEY'); + + // Ensure APP_URL is set, default to the current host alias + info('Ensure APP_URL is set'); + setenv('APP_URL', '{{alias}}'); + + // Define APP_DEBUG based on the environment + writeln(''); + $env = currentHost()->getLabels()['env'] ?? 'production'; + + switch ($env) { + case 'local': + case 'staging': + warning('Force APP_DEBUG=true'); + setenv('APP_DEBUG', 'true'); + break; + case 'production': + info('Force APP_DEBUG=false'); + setenv('APP_DEBUG', 'false'); + break; + default: + warning('Unknown environment: '.$env.', defaulting to APP_DEBUG=false'); + setenv('APP_DEBUG', 'false'); + } + + // Add an empty line for better readability in logs + writeln(''); + + // Set DEFAULT_USER_MAIL from the --default-user-mail deployer option + $defaultUserMail = input()->getOption('default-user-mail'); + if (! empty($defaultUserMail)) { + info('Apply DEFAULT_USER_MAIL='.$defaultUserMail.''); + setenv('DEFAULT_USER_MAIL', $defaultUserMail); + } + + // Set DEFAULT_USER_NAME from the --default-user-name deployer option + $defaultUserName = input()->getOption('default-user-name'); + if (! empty($defaultUserName)) { + info('Apply DEFAULT_USER_NAME='.$defaultUserName.''); + setenv('DEFAULT_USER_NAME', $defaultUserName); + } + + // Add an empty line for better readability in logs + writeln(''); + + $hostSecrets = currentHost()->get('secrets', []); + + if (! is_array($hostSecrets)) { + throw new RuntimeException('Host secrets must be declared as an array.'); + } + + foreach (resolveHostSecrets($hostSecrets) as $key => $secret) { + $value = $secret['value']; + + if ($secret['encrypted'] === true) { + info('Apply '.$key.' from encrypted host secret'); + $value = decrypt($value); + } else { + info('Apply '.$key.' from environment'); + } + + setSecretEnv($key, normalizeSecretEnvFileValue($value)); + } + + writeln(''); +})->addAfter('artisan:key:generate'); + +// Run this task before caching the configuration +before('artisan:config:cache', 'deploy:set_env'); diff --git a/src/tasks/set_version.php b/src/tasks/set_version.php new file mode 100644 index 0000000..9c2bd97 --- /dev/null +++ b/src/tasks/set_version.php @@ -0,0 +1,37 @@ +hasOption('app-version') && ! empty(input()->getOption('app-version'))) { + $version = input()->getOption('app-version'); // Use the version provided as an option + info('Version given '.$version.$build.''); + } elseif (getenv('APP_VERSION') !== false) { + $version = getenv('APP_VERSION'); // Use the version from the environment variable + info('Version from env '.$version.$build.''); + } else { + $version = runLocally('gitversion /showvariable FullSemVer'); // Calculate the version using GitVersion + info('Version calculated '.$version.$build.''); + } + + // Write the version to the VERSION file on the remote server + run("echo $version$build > {{release_or_current_path}}/VERSION"); +}); + +// Run this task after setting the environment variables +after('deploy:env', 'deploy:set_version'); diff --git a/src/tasks/upload_assets.php b/src/tasks/upload_assets.php new file mode 100644 index 0000000..ed64a84 --- /dev/null +++ b/src/tasks/upload_assets.php @@ -0,0 +1,70 @@ +'.get('hostname').' repository.'); + + // Upload the built assets to the remote server + foreach (get('public_assets_paths') as $path) { + + // Check if the asset directory exists locally + // If it does not exist, throw an exception to notify the user to build the assets first. + if (runLocally("test -d $path; echo $?") != 0) { + throw new Exception("Directory $path does not exist.\nPlease build the assets first."); + } + + // Log a message indicating the upload process for the current directory + info('Uploading '.$path.' to '.get('hostname').''); + + // Upload the directory to the remote server + upload( + './'.$path.'/', // Local build directory + '{{release_or_current_path}}/'.$path, // Remote public directory + ['progress_bar' => false] // Disable progress bar for cleaner output + ); + } +}); +// Run this task after the code update step +// This ensures that the assets are uploaded after the latest code is deployed. +after('deploy:update_code', 'deploy:upload_assets');