# cratly — publish a built site over SFTP
#
# Canonical URL:  https://cratly.io/ci/v1/deploy-sftp.yml
# Documentation:  https://cratly.io/reference/pipeline
# Licence:        MIT
#
# Required masked CI/CD variables:
#   SSH_KNOWN_HOSTS  output of `ssh-keyscan -p PORT HOST` for the target
#   and one of
#   SSH_PRIVATE_KEY  private key, PEM or base64-encoded PEM
#   SFTP_PASSWORD    password, for hosts that offer no key authentication
#
# This is the template for hosts that hand out nothing but the SFTP subsystem:
# no shell, no rsync, often no symlinks. That also caps what can be promised
# about a publication in progress — files are replaced one by one and a visitor
# arriving mid-deploy can see a mix of both versions. Where a shell exists,
# deploy-rsync-ssh.yml does strictly better; where one does not, this is the
# honest ceiling.

spec:
  inputs:
    job_name:
      default: "deploy"
    stage:
      default: "deploy"
    needs:
      type: array
      default: [ "build" ]
    image:
      default: "alpine:3.20"
      description: "Any image is fine; lftp and ssh are installed if missing."
    artifact_dir:
      default: "site"
    host:
      description: "Target host name."
    user:
      description: "SFTP user."
    port:
      default: "22"
    path:
      description: "Absolute or login-relative path of the document root on the target."
    delete:
      default: "true"
      options: [ "true", "false" ]
      description: "Remove files at the target that the build no longer contains."
    parallel:
      default: "4"
      description: "Concurrent transfers. Lower it if the host throttles or drops connections."
    exclude:
      default: ""
      description: >-
        Glob left untouched at the target, e.g. "^stats/" — for hosts that write
        into the document root themselves. lftp regex syntax.
    branch:
      default: "$CI_DEFAULT_BRANCH"
    environment:
      default: "production"

---

"$[[ inputs.job_name ]]":
  stage: $[[ inputs.stage ]]
  image: $[[ inputs.image ]]
  needs: $[[ inputs.needs ]]
  interruptible: false
  script:
    - |
      set -eu

      command -v lftp >/dev/null 2>&1 && command -v ssh >/dev/null 2>&1 || {
        if   command -v apk     >/dev/null 2>&1; then apk add --no-cache lftp openssh-client
        elif command -v apt-get >/dev/null 2>&1; then apt-get update -qq && apt-get install -y -qq lftp openssh-client
        else echo "no lftp/ssh and no known package manager in $[[ inputs.image ]]" >&2; exit 1
        fi
      }

      # Host key verification is not optional — see deploy-rsync-ssh.yml.
      if [ -z "${SSH_KNOWN_HOSTS:-}" ]; then
        echo "SSH_KNOWN_HOSTS is empty. Generate it with:" >&2
        echo "  ssh-keyscan -p $[[ inputs.port ]] $[[ inputs.host ]]" >&2
        echo "and store the output as a CI/CD variable — after checking the fingerprint" >&2
        echo "against what the host's operator published." >&2
        exit 1
      fi

      mkdir -p ~/.ssh && chmod 700 ~/.ssh
      printf '%s\n' "$SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts

      AUTH=""
      SSH_OPTS="-a -x -p $[[ inputs.port ]] -o UserKnownHostsFile=$HOME/.ssh/known_hosts -o StrictHostKeyChecking=yes"
      if [ -n "${SSH_PRIVATE_KEY:-}" ]; then
        case "$SSH_PRIVATE_KEY" in
          *"BEGIN "*) printf '%s\n' "$SSH_PRIVATE_KEY" > ~/.ssh/id_deploy ;;
          *)          printf '%s' "$SSH_PRIVATE_KEY" | base64 -d > ~/.ssh/id_deploy ;;
        esac
        chmod 600 ~/.ssh/id_deploy
        SSH_OPTS="$SSH_OPTS -i $HOME/.ssh/id_deploy -o IdentitiesOnly=yes"
      elif [ -n "${SFTP_PASSWORD:-}" ]; then
        AUTH="-u $[[ inputs.user ]],$SFTP_PASSWORD"
      else
        echo "neither SSH_PRIVATE_KEY nor SFTP_PASSWORD is set" >&2
        exit 1
      fi
      if [ -z "$AUTH" ]; then AUTH="-u $[[ inputs.user ]],"; fi

      DELETE=""
      if [ "$[[ inputs.delete ]]" = "true" ]; then DELETE="--delete"; fi

      EXCLUDE=""
      if [ -n "$[[ inputs.exclude ]]" ]; then EXCLUDE="--exclude '$[[ inputs.exclude ]]'"; fi

      # set cmd:fail-exit — lftp reports a failed transfer on stderr and would
      # otherwise still exit 0, turning a failed publication into a green pipeline.
      lftp -c "
        set cmd:fail-exit yes;
        set sftp:connect-program 'ssh $SSH_OPTS';
        set net:max-retries 3;
        open -p $[[ inputs.port ]] $AUTH sftp://$[[ inputs.host ]];
        mirror -R $DELETE $EXCLUDE --parallel=$[[ inputs.parallel ]] --verbose \
          '$[[ inputs.artifact_dir ]]' '$[[ inputs.path ]]';
      "
  environment:
    name: $[[ inputs.environment ]]
  rules:
    - if: $CI_COMMIT_BRANCH == "$[[ inputs.branch ]]"
