From 2ee612a7b3e5a4167e7f8b9fed241dc0fd8bc093 Mon Sep 17 00:00:00 2001 From: Riviera Taylor Date: Wed, 19 Feb 2025 13:29:32 +0100 Subject: [PATCH] second commit --- .gitattributes | 2 + README.md | 172 ++++++++++++++++++++- action.yml | 397 +++++++++++++++++++++++++++++++++++++++++++++++++ screenshot.png | Bin 0 -> 7480 bytes 4 files changed, 569 insertions(+), 2 deletions(-) create mode 100644 .gitattributes create mode 100644 action.yml create mode 100644 screenshot.png diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..5602e72 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +* text=auto +*.yml text eol=lf \ No newline at end of file diff --git a/README.md b/README.md index b89573c..41e287b 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,171 @@ -# actions-file-deployer +# FTP/SFTP file deployer -Gitea Action to upload to SFTP / FTP server, optionally via a proxy \ No newline at end of file +This is a fork of https://github.com/milanmk/actions-file-deployer with gitea customisation. + +Fast and customizable deployment with parallel connections and proxy support. Deploy only changed files or do full sync/mirror of repository content. + +This is a composite Gitea Action (Linux runner) for deploying repository content to a remote server. + +## Features + +- Support for FTP and SFTP (SSH) protocols +- Use password or SSH private key for authentication of SFTP connection +- Delta file synchronization for faster deployment of only changed files since last revision +- Mirroring feature to copy entire file and folder structure of repository content +- Optimized for faster file transfers via parallel connections +- Connect to remote server via [SOCKS proxy](https://en.wikipedia.org/wiki/SOCKS) using [SSH tunneling](https://www.ssh.com/academy/ssh/tunneling) to bypass firewall / NAT / IP whitelist / VPC +- Uses [composite action](https://docs.github.com/en/actions/creating-actions/about-actions#types-of-actions) without Docker container for faster deployments and shorter run time +- Pass additional command arguments to SSH and FTP client for custom configurations and settings +- Step runs messages categorized nicely in log groups +- Run additional FTP commands after synchronization + +![Workflow screenshot](./screenshot.png) + +## Usage + +```yml +- name: "Checkout" + uses: actions/checkout@v4 + with: + fetch-depth: 0 +- name: "Deploy" + uses: milanmk/actions-file-deployer@master + with: + remote-protocol: "sftp" + remote-host: "ftp.example.com" + remote-user: "username" + ssh-private-key: ${{ secrets.DEPLOY_PRIVATE_KEY }} + remote-path: "/var/www/example.com" +``` + +Workflow example `.github/workflows/main.yml`. + +```yml +name: Deploy Files + +on: + push: + branches: + - master + # Enables manually triggering of Workflow with file synchronization option + workflow_dispatch: + inputs: + sync: + description: "File synchronization" + required: true + default: "delta" + +jobs: + deploy-master: + name: "master branch" + if: ${{ github.ref == 'refs/heads/master' }} + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: "Checkout" + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: "Deploy" + uses: milanmk/actions-file-deployer@master + with: + remote-protocol: "sftp" + remote-host: "ftp.example.com" + remote-user: "username" + ssh-private-key: ${{ secrets.DEPLOY_PRIVATE_KEY }} + remote-path: "/var/www/example.com" +``` + +## Inputs + +| Name | Required | Default | Description | +|------------------------|----------------------|---------|-----------------------------------------------| +| remote-protocol | yes | sftp | Remote file transfer protocol (ftp, sftp) | +| remote-host | yes | | Remote host | +| remote-port | yes | 22 | Remote port | +| remote-user | yes | | FTP/SSH username | +| remote-password | no | | FTP/SSH password | +| ssh-private-key | no | | SSH private key of user | +| proxy | yes | false | Enable proxy for FTP connection (true, false) | +| proxy-host | yes (if proxy: true) | | Proxy host | +| proxy-port | yes (if proxy: true) | 22 | Proxy port | +| proxy-forwarding-port | yes (if proxy: true) | 1080 | Proxy forwarding port | +| proxy-user | yes (if proxy: true) | | Proxy username | +| proxy-private-key | yes (if proxy: true) | | Proxy SSH private key of user | +| local-path | yes | . | Local path to repository | +| remote-path | yes | . | Remote path on host | +| sync | yes | delta | File synchronization (delta, full) | +| sync-delta-excludes | no | | Files to exclude from delta sync | +| ssh-options | no | | Additional arguments for SSH client | +| ftp-options | no | | Additional arguments for FTP client | +| ftp-mirror-options | no | | Additional arguments for mirroring | +| ftp-post-sync-commands | no | | Additionnal FTP command to run after sync | +| webhook | no | | Send webhook event notifications | +| artifacts | no | false | Upload logs/files to artifacts (true, false) | +| debug | no | false | Publish secrets on the internet (true, false) | + +### Notes + +- Character support for `remote-user` and `remote-password` is limited due to its usage in [.netrc file](https://www.gnu.org/software/inetutils/manual/html_node/The-_002enetrc-file.html) + - It should not contain shell/URL special characters, use [percent encoding](https://en.wikipedia.org/wiki/Percent-encoding) instead +- File synchronization options + - `delta`: Transfer only changed files (upload and delete) since last revision + - Only supported for `push`, `pull_request` and `workflow_dispatch` [events](https://docs.github.com/en/actions/reference/events-that-trigger-workflows) + - Requires `fetch-depth: 0` option in [checkout action](https://gitea.com/actions/checkout) + - It is recommended to initially do a full synchronization and then switch to delta + - `full`: Transfer all files (upload) + - Does not delete files on remote host + - Default glob exclude pattern is `.git*/` +- `sync-delta-excludes` accepts [pathspec](https://git-scm.com/docs/gitglossary#Documentation/gitglossary.txt-aiddefpathspecapathspec) patterns to exclude files from delta sync. +- For `ftp-options` and `ftp-mirror-options` command arguments please refer to [LFTP manual](https://lftp.yar.ru/lftp-man.html) +- `ftp-post-sync-commands` can be used to run additional LFTP commands after the synchronization. For example, to upload a file watched by a process manager on the server in order to restart a deamon: + ``` + ftp-post-sync-commands: | + !touch watched_file + put watched_file + ``` +- Setting `webhook` to a URL will send start and finish event notifications in JSON format + - start event payload: + ``` + { + "timestamp": "1234567890", + "status": "start", + "repository": "owner/repository", + "workflow": "workflow name", + "job": "deploy", + "run_id": "1234567890", + "ref": "refs/heads/master", + "event_name": "push", + "actor": "username", + "message": "commit message", + "revision": "da39a3ee5e6b4b0d3255bfef95601890afd80709" + } + ``` + - finish event payload: + ``` + { + "timestamp": "1234567890", + "status": "finish", + "repository": "owner/repository", + "workflow": "workflow name", + "job": "deploy", + "run_id": "1234567890", + "ref": "refs/heads/master", + "event_name": "push", + "actor": "username", + "message": "commit message", + "revision": "da39a3ee5e6b4b0d3255bfef95601890afd80709" + } + ``` +- Enabling `artifacts` will upload transfer log and modified files to artifacts + - Modified files are only added for delta file synchronization +- It is strongly recommended to use [Encrypted Secrets](https://docs.github.com/en/actions/reference/encrypted-secrets) to store sensitive data like passwords and private keys +- It is strongly recommended not to use the `debug` option because it will output your encrypted secrets in the Action logs. + +## Planned features +- [ ] Stop the debug option from publishing encrypted secrets +- [x] Add transfer log to artifacts +- [x] Add modified files to artifacts +- [ ] Add steps logging to file +- [ ] Add steps log to artifacts +- [x] Trigger webhook at start and end of step runs diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..3f5af74 --- /dev/null +++ b/action.yml @@ -0,0 +1,397 @@ +name: "FTP/SFTP file deployer" + +description: "Fast and customizable deployment with parallel connections and proxy support. Deploy only changed files or do full sync." + +branding: + icon: "upload" + color: "black" + +inputs: + remote-protocol: + description: "Remote file transfer protocol (ftp, sftp)" + required: true + default: "sftp" + remote-host: + description: "Remote host" + required: true + remote-port: + description: "Remote port" + required: true + default: 22 + remote-user: + description: "FTP/SSH username" + required: true + remote-password: + description: "FTP/SSH password" + required: false + default: "" + ssh-private-key: + description: "SSH private key of user" + required: false + proxy: + description: "Enable proxy for FTP connection (true, false)" + required: true + default: false + proxy-host: + description: "Proxy host" + required: false + proxy-port: + description: "Proxy port" + required: false + default: 22 + proxy-forwarding-port: + description: "Proxy forwarding port" + required: false + default: 1080 + proxy-user: + description: "Proxy username" + required: false + proxy-private-key: + description: "Proxy SSH private key of user" + required: false + local-path: + description: "Local path to repository" + required: true + default: . + remote-path: + description: "Remote path on host" + required: true + default: . + sync: + description: "File synchronization (delta, full)" + required: true + default: "delta" + sync-delta-excludes: + description: "Files to exclude from delta sync" + required: false + ssh-options: + description: "Additional command arguments for SSH client" + required: false + ftp-options: + description: "Additional command arguments for FTP client (lftp)" + required: false + ftp-mirror-options: + description: "Additional command arguments for mirroring (lftp)" + required: false + ftp-post-sync-commands: + description: "Additional ftp commands to run after synchronization (lftp)" + required: false + webhook: + description: "Send webhook event notifications" + required: false + artifacts: + description: "Upload logs to artifacts" + required: false + debug: + description: "Enable debug information (true, false)" + required: false + +runs: + using: "composite" + steps: + - name: "Deploy" + shell: bash + run: | + echo "::group::Initialization" + + function show_hr() { + printf '%.s_' {1..100} && echo "" + } + + function send_webhook() { + local status="$1" + local post_data=$(jq --null-input \ + --arg timestamp `date +%s` \ + --arg status "${status}" \ + --arg repository "${{ gitea.repository }}" \ + --arg workflow "${{ gitea.workflow }}" \ + --arg job "${{ gitea.job }}" \ + --arg run_id "${{ gitea.run_id }}" \ + --arg ref "${{ gitea.ref }}" \ + --arg event_name "${{ gitea.event_name }}" \ + --arg actor "${{ gitea.actor }}" \ + --arg message "${{ gitea.event.head_commit.message }}" \ + --arg revision "${{ gitea.sha }}" \ + '{"timestamp": $timestamp, "status": $status, "repository": $repository, "workflow": $workflow, "job": $job, "run_id": $run_id, "ref": $ref, "event_name": $event_name, "actor": $actor, "message": $message, "revision": $revision}') + + curl --data "${post_data}" --header "Content-Type: application/json" --max-time 30 --show-error --silent --user-agent "Gitea Workflow" "${{inputs.webhook}}" + } + + [ "${{inputs.webhook}}" != "" ] && echo "Webhook notification (start): $(send_webhook "start")" + + echo "Check repository" + + if [ "$(git rev-parse --is-inside-work-tree 2>/dev/null)" != "true" ]; then + echo "::error::Git repository not found. Please ensure you have a checkout step before this step." + exit 1 + fi + + echo "Initialize inputs" + + local_path_unslash=$(echo "${{inputs.local-path}}" | sed 's:/*$::') + local_path_slash="${local_path_unslash}/" + + remote_path_unslash=$(realpath --canonicalize-missing '${{inputs.remote-path}}') + remote_path_slash="${remote_path_unslash}/" + + input_remote_password="${{inputs.remote-password}}" + if [ "${{inputs.remote-password}}" == "" ]; then + input_remote_password="dummypassword" + fi + + input_proxy="${{inputs.proxy}}" + + proxy_cmd="" + if [ "${input_proxy}" == "true" ]; then + proxy_cmd="proxychains" + fi + + input_sync=${{inputs.sync}} + if [ "${{gitea.event_name}}" == "workflow_dispatch" ] && [ -n "${{gitea.event.inputs.sync}}" ]; then + input_sync=${{gitea.event.inputs.sync}} + fi + + echo "Validate inputs" + + if [ "${{inputs.remote-protocol}}" != "sftp" ] && [ "${{inputs.remote-protocol}}" != "ftp" ]; then + echo "::error::Invalid protocol: ${{inputs.remote-protocol}}. Valid protocols are 'ftp' and 'sftp'." + exit 1 + fi + + if [ "${input_sync}" != "delta" ] && [ "${input_sync}" != "full" ]; then + echo "::error::Invalid synchronization: ${input_sync}. Valid types are 'delta' and 'full'." + exit 1 + fi + + echo "::endgroup::" + + + + if [ "${{inputs.debug}}" == "true" ]; then + echo "::group::Debug" + + echo "Context: gitea.event" && cat ${{gitea.event_path}} && show_hr + + echo "Context: env" && echo "${{toJSON(env)}}" && show_hr + + echo "Inputs:" && echo "${{toJSON(inputs)}}" + + echo "::endgroup::" + fi + + + + echo "::group::Install packages" + + apt_install="" + apt_quiet="--quiet --quiet" + if [ "${{inputs.debug}}" == "true" ]; then + apt_quiet="" + fi + + sudo apt-get ${apt_quiet} update && sudo apt-get ${apt_quiet} --no-install-recommends --yes install lftp ${proxy_cmd} + + echo "::endgroup::" + + + + echo "::group::Configurations" + + config_ssh=~/.ssh/config + mkdir ~/.ssh && echo -e "ExitOnForwardFailure=yes\nStrictHostKeyChecking=no" > ${config_ssh} && chmod 600 ${config_ssh} && echo "File created: ${config_ssh}" + [ "${{inputs.debug}}" == "true" ] && cat ${config_ssh} + show_hr + + netrc=~/.netrc + echo "machine ${{inputs.remote-host}} login ${{inputs.remote-user}} password ${input_remote_password}" > ${netrc} && chmod 600 ${netrc} && echo "File created: ${netrc}" + [ "${{inputs.debug}}" == "true" ] && cat ${netrc} + show_hr + + if [ "${{inputs.remote-protocol}}" == "sftp" ] && [ "${{inputs.ssh-private-key}}" != "" ]; then + key_ssh=~/ssh_private_key + echo "${{inputs.ssh-private-key}}" > ${key_ssh} && chmod 600 ${key_ssh} && echo "File created: ${key_ssh}" && show_hr + fi + + if [ "${input_proxy}" == "true" ]; then + if [ "${{inputs.proxy-private-key}}" != "" ]; then + key_proxy=~/proxy_private_key + echo "${{inputs.proxy-private-key}}" > ${key_proxy} && chmod 600 ${key_proxy} && echo "File created: ${key_proxy}" && show_hr + + config_proxychains=~/.proxychains/proxychains.conf + mkdir ~/.proxychains && echo "strict_chain + quiet_mode + tcp_read_time_out 15000 + tcp_connect_time_out 10000 + [ProxyList] + socks5 127.0.0.1 ${{inputs.proxy-forwarding-port}}" > ${config_proxychains} && echo "File created: ${config_proxychains}" + [ "${{inputs.debug}}" == "true" ] && cat ${config_proxychains} + show_hr + else + input_proxy="false" + echo "::warning::Invalid input 'proxy-private-key'. Skipping proxy connection." + fi + fi + + echo "debug $([ "${{inputs.debug}}" == "true" ] && echo "9" || echo "false") + set cmd:trace $([ "${{inputs.debug}}" == "true" ] && echo "true" || echo "false") + set ftp:ssl-protect-data true + set ftp:sync-mode false + set log:enabled/xfer true + set log:file/xfer ~/transfer_log.txt + set log:show-time/xfer false + set mirror:overwrite true + set mirror:parallel-transfer-count 3 + set mirror:set-permissions false + set net:max-retries 1 + set net:persist-retries 0 + set net:timeout 10 + set sftp:auto-confirm true + set ssl:check-hostname false + set ssl:verify-certificate false + set xfer:parallel 3 + ${{inputs.ftp-options}}" > ~/.lftprc + if [ "${{inputs.remote-protocol}}" == "sftp" ] && [ "${{inputs.ssh-private-key}}" != "" ]; then + echo "set sftp:connect-program /usr/bin/ssh -a -x -i ~/ssh_private_key ${{inputs.ssh-options}}" >> ~/.lftprc + else + echo "set sftp:connect-program /usr/bin/ssh -a -x ${{inputs.ssh-options}}" >> ~/.lftprc + fi + echo "open ${{inputs.remote-protocol}}://${{inputs.remote-user}}@${{inputs.remote-host}}:${{inputs.remote-port}}" >> ~/.lftprc + echo "File created: ~/.lftprc" + [ "${{inputs.debug}}" == "true" ] && cat ~/.lftprc + + echo "::endgroup::" + + + + if [ "${input_proxy}" == "true" ]; then + echo "::group::Setup proxy" + + if [ "${{inputs.proxy-user}}" != "" ] && [ "${{inputs.proxy-host}}" != "" ]; then + if ssh -A -D ${{inputs.proxy-forwarding-port}} -f -N -p ${{inputs.proxy-port}} -i ~/proxy_private_key ${{inputs.proxy-user}}@${{inputs.proxy-host}}; then + echo "Proxy connected" && show_hr && echo "Proxy IP address: $(${proxy_cmd} curl --max-time 10 --show-error --silent "http://checkip.amazonaws.com/")" + else + echo "::error::Proxy connection failed." + exit 1 + fi + else + input_proxy="false" + echo "::warning::Invalid input 'proxy-user', 'proxy-host'. Skipping proxy connection." + fi + + echo "::endgroup::" + fi + + + + echo "::group::Prepare files" + + echo "Event: ${{gitea.event_name}} + Revision: https://gitea.com/${{gitea.repository}}/commit/${{gitea.sha}} + Committer: ${{gitea.actor}} + Message: ${{gitea.event.head_commit.message}}" && show_hr + + echo "${{gitea.sha}}" > "${local_path_slash}.deploy-revision" && echo "File created: ${local_path_slash}.deploy-revision" && cat "${local_path_slash}.deploy-revision" && show_hr + + if [ "${input_sync}" == "delta" ]; then + touch ~/files_to_upload ~/files_to_delete + git_depth=$(git rev-list --count --all) + git_previous_commit="" + + if [ "${git_depth}" -gt 1 ]; then + if [ "${{gitea.event_name}}" == "push" ]; then + git_previous_commit=${{gitea.event.before}} + elif [ "${{gitea.event_name}}" == "pull_request" ]; then + git_previous_commit=${{gitea.event.pull_request.base.sha}} + elif [ "${{gitea.event_name}}" == "workflow_dispatch" ]; then + git_previous_commit=$(git rev-parse ${{gitea.sha}}^) + else + echo "::error::Event not supported for delta synchronization: ${{gitea.event_name}}. Supported events are 'push', 'pull_request' and 'workflow_dispatch'." + exit 1 + fi + else + echo "::error::Commit history not found for delta synchronization. Please ensure you have 'fetch-depth: 0' option in checkout action. Please ignore if this is an initial commit or newly created branch." + exit 1 + fi + domain="code.klank.school" + echo "Previous Revision: https://${domain}/${{gitea.repository}}/commit/${git_previous_commit}" && show_hr + + # ${proxy_cmd} lftp -c "set log:enabled/xfer false; get -O ~ \"${remote_path_slash}.deploy-revision\"; exit 0" + # echo -n "Remote Revision: " && [ -f ~/.deploy-revision ] && cat ~/.deploy-revision || echo "" + # show_hr + + if git cat-file -t ${git_previous_commit} &>/dev/null; then + git diff --diff-filter=ACMRT --name-only ${git_previous_commit}..${{gitea.sha}} -- ${local_path_unslash} ':!/.git*' ${{inputs.sync-delta-excludes}} > ~/files_to_upload + git diff-tree --diff-filter=D --name-only -t ${git_previous_commit}..${{gitea.sha}} -- ${local_path_unslash} ':!/.git*' ${{inputs.sync-delta-excludes}} > ~/files_to_delete + + sed --in-place --regexp-extended "s#(.*)#realpath --canonicalize-missing --relative-to=$local_path_unslash \1#e" ~/files_to_upload + sed --in-place --regexp-extended "s#(.*)#realpath --canonicalize-missing --relative-to=$local_path_unslash \1#e" ~/files_to_delete + + echo "File created: ~/files_to_upload" && cat ~/files_to_upload && show_hr + echo "File created: ~/files_to_delete" && cat ~/files_to_delete && show_hr + + if [ "${{inputs.artifacts}}" == "true" ]; then + echo "Copy transfer artifacts" && mkdir ~/transfer_files && rsync --verbose --files-from=$HOME/files_to_upload ${local_path_slash} ~/transfer_files/ + fi + else + echo "::warning::Invalid base commit for delta synchronization: ${git_previous_commit}. Please ignore if this is an initial commit or newly created branch." + fi + fi + + echo "::endgroup::" + + + + echo "::group::Transfer files" + + echo "Protocol: ${{inputs.remote-protocol}} + Synchronization: ${input_sync} + Local path: ${local_path_unslash} + Remote path: ${remote_path_unslash}" + + [ "${input_sync}" == "delta" ] && echo -e "Upload files: $(wc --lines < ~/files_to_upload)\nDelete files: $(wc --lines < ~/files_to_delete)" + + show_hr + + touch "${local_path_slash}.deploy-running" + + if [ "${input_sync}" == "full" ]; then + ${proxy_cmd} lftp -c "put -O \"${remote_path_unslash}\" \"${local_path_slash}.deploy-running\"; + mirror --exclude-glob=.git*/ --max-errors=10 --reverse ${{inputs.ftp-mirror-options}} ${local_path_unslash} ${remote_path_unslash}; + rm -f \"${remote_path_slash}.deploy-running\"; + ${{inputs.ftp-post-sync-commands}}" + else + ${proxy_cmd} lftp -c "lcd \"${local_path_unslash}\"; + put -O \"${remote_path_unslash}\" \"${local_path_slash}.deploy-running\"; + mput -d -O \"${remote_path_unslash}\" .deploy-revision $(awk '{ printf "\"%s\" ", $0 }' ~/files_to_upload); + rm -f \"${remote_path_slash}.deploy-check\" $(awk -v REMOTEPATH=\"${remote_path_slash}\" '{ printf "\"%s%s\" ", REMOTEPATH, $0 }' ~/files_to_delete); + rm -f \"${remote_path_slash}.deploy-running\"; + ${{inputs.ftp-post-sync-commands}}" + fi + + [ -f ~/transfer_log.txt ] && cat ~/transfer_log.txt + + echo "::endgroup::" + + + + echo "::group::Cleanup" + + [ "${input_proxy}" == "true" ] && sudo pkill ssh + + rm --force --verbose ~/.netrc ~/proxy_private_key ~/ssh_private_key + + [ "${{inputs.artifacts}}" != "true" ] && rm --force --verbose ~/transfer_log.txt + + [ "${{inputs.webhook}}" != "" ] && echo "Webhook notification (finish): $(send_webhook "finish")" + + echo "::endgroup::" + + exit 0 + - name: "Upload artifacts" + uses: actions/upload-artifact@v4 + with: + name: "transfer_artifacts" + path: | + ~/transfer_log.txt + ~/transfer_files + if-no-files-found: ignore diff --git a/screenshot.png b/screenshot.png new file mode 100644 index 0000000000000000000000000000000000000000..99344848c860f66ffd18f46f3854848c7aee7763 GIT binary patch literal 7480 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 TUne`E0HCg_jVMyFdi}oui+nqQ literal 0 HcmV?d00001