diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 93ab11f..e8b9034 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -87,6 +87,29 @@ jobs: grep -q '^POST https://console.example.com/api/action/deployments/from-config-package$' "$tmp/argv" grep -q '^console-url=https://console.example.com$' "$tmp/github-output" + - name: Validate dry-run console install + env: + APPALOFT_DEPLOY_ACTION_DRY_RUN: "true" + INPUT_COMMAND: install-console + INPUT_VERSION: v0.9.1 + INPUT_SSH_HOST: 203.0.113.10 + INPUT_CONSOLE_DOMAIN: console.example.com + INPUT_CONSOLE_DATABASE: pglite + INPUT_CONSOLE_SKIP_DOCKER_INSTALL: "true" + run: | + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' EXIT + export RUNNER_TEMP="$tmp" + export GITHUB_OUTPUT="$tmp/github-output" + export APPALOFT_DEPLOY_ACTION_ARGV_PATH="$tmp/argv" + + bash scripts/run-deploy.sh + + grep -q '^SSH root@203.0.113.10:22$' "$tmp/argv" + grep -q '^INSTALLER https://github.com/appaloft/appaloft/releases/download/v0.9.1/install.sh$' "$tmp/argv" + grep -q '^HEALTH https://console.example.com/api/health$' "$tmp/argv" + grep -q '^console-url=https://console.example.com$' "$tmp/github-output" + - name: Opt-in exact-version install smoke if: ${{ vars.APPALOFT_INSTALL_SMOKE_VERSION != '' }} env: diff --git a/README.md b/README.md index b8597d7..d0b54ba 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,45 @@ secrets: from: ci-env:DATABASE_URL ``` +## Install Self-Hosted Console + +Use `command: install-console` when a workflow should install or upgrade an Appaloft console on an +SSH host before other repositories deploy through `control-plane-mode: self-hosted`. + +```yaml +name: Install Appaloft Console + +on: + workflow_dispatch: + +jobs: + install: + runs-on: ubuntu-latest + permissions: + contents: read + environment: + name: appaloft-console + url: ${{ steps.console.outputs.console-url }} + steps: + - uses: appaloft/deploy-action@v1 + id: console + with: + command: install-console + version: latest + ssh-host: ${{ secrets.APPALOFT_CONSOLE_SSH_HOST }} + ssh-user: ${{ secrets.APPALOFT_CONSOLE_SSH_USER }} + ssh-private-key: ${{ secrets.APPALOFT_CONSOLE_SSH_PRIVATE_KEY }} + console-domain: console.example.com + console-database: pglite + console-skip-docker-install: true +``` + +The action connects to the SSH host, downloads the matching Appaloft release `install.sh`, runs the +self-hosted Docker installer with the selected public console origin, and verifies +`/api/health`. `console-url` may be supplied directly when the public origin is not +`https://`. This command is separate from `deploy`, so the original pure SSH CLI +deployment path remains available. + ## Pull Request Preview Action-only pull request previews require a workflow file. The action does not install a webhook or @@ -270,8 +309,8 @@ source-link state, or the Appaloft server, not from committed config. | Input | Default | Purpose | | --- | --- | --- | -| `command` | `deploy` | `deploy` or `preview-cleanup`. | -| `version` | `latest` | Appaloft CLI release tag such as `v0.9.0`. | +| `command` | `deploy` | `deploy`, `preview-cleanup`, or `install-console`. | +| `version` | `latest` | Appaloft release tag such as `v0.9.0`. Used for CLI install and self-hosted console install. | | `config` | empty | Optional Appaloft config path. If omitted, `appaloft.yml` is used only when present. | | `source` | `.` | Source path or locator passed to the CLI. | | `runtime-name` | empty | Trusted runtime name override for deploy. | @@ -280,6 +319,15 @@ source-link state, or the Appaloft server, not from committed config. | `ssh-port` | empty | SSH port. | | `ssh-private-key` | empty | SSH private key value, written to a temp file before invoking Appaloft. | | `ssh-private-key-file` | empty | Existing runner-local private key path. Mutually exclusive with `ssh-private-key`. | +| `console-url` | empty | Public console origin for `command: install-console`. Defaults to `https://` or `http://:`. | +| `console-domain` | empty | Public console domain used to derive `console-url` when `console-url` is empty. | +| `console-database` | `pglite` | Self-hosted console database backend for `command: install-console`; `pglite` or `postgres`. | +| `console-http-host` | `0.0.0.0` | Host bind address passed to the self-hosted console installer. | +| `console-http-port` | `3001` | Host HTTP port passed to the self-hosted console installer. | +| `console-install-dir` | empty | Remote install directory passed to the self-hosted console installer. Empty uses the installer default. | +| `console-image` | `ghcr.io/appaloft/appaloft` | Appaloft console image repository or full image reference passed to the self-hosted console installer. | +| `console-installer-url` | empty | Override URL for the self-hosted `install.sh` used by `command: install-console`. | +| `console-skip-docker-install` | `false` | Require Docker Engine to already exist on the SSH host during `command: install-console`. | | `server-provider` | `generic-ssh` | Server provider key. | | `server-proxy-kind` | empty | Server proxy kind such as `traefik` or `caddy`. | | `state-backend` | empty | Explicit state backend. SSH targets default to `ssh-pglite`. | @@ -313,7 +361,7 @@ source-link state, or the Appaloft server, not from committed config. | `preview-url` | Public preview URL when Appaloft resolves one during deploy. | | `deployment-id` | Deployment id accepted by Appaloft. | | `deployment-url` | Self-hosted Appaloft console deployment detail URL when available. | -| `console-url` | Self-hosted Appaloft console URL used by server API mode. | +| `console-url` | Self-hosted Appaloft console URL installed by `install-console` or used by server API mode. | | `preview-cleanup-status` | Cleanup status returned by server API mode for `command: preview-cleanup`. | ## Security Notes diff --git a/action.yml b/action.yml index fb0aa81..9fe7157 100644 --- a/action.yml +++ b/action.yml @@ -3,11 +3,11 @@ description: Run an Appaloft deployment from GitHub Actions through pure SSH CLI inputs: command: - description: Wrapper command to run. Use deploy for deployments or preview-cleanup for pull request cleanup. + description: Wrapper command to run. Use deploy for deployments, preview-cleanup for pull request cleanup, or install-console to install or upgrade a self-hosted console over SSH. required: false default: deploy version: - description: Appaloft CLI release tag, for example v0.9.0. Use latest for the latest stable release. + description: Appaloft release tag, for example v0.9.0. Use latest for the latest stable release. required: false default: latest config: @@ -42,6 +42,42 @@ inputs: description: Existing runner-local SSH private key file. required: false default: "" + console-url: + description: Public self-hosted console origin configured during command=install-console. Defaults to https:// or http://:. + required: false + default: "" + console-domain: + description: Public self-hosted console domain used to derive https:// when console-url is empty. + required: false + default: "" + console-database: + description: Self-hosted console database backend for command=install-console. + required: false + default: "pglite" + console-http-host: + description: Host bind address passed to the self-hosted console installer. + required: false + default: "0.0.0.0" + console-http-port: + description: Host HTTP port passed to the self-hosted console installer. + required: false + default: "3001" + console-install-dir: + description: Remote install directory passed to the self-hosted console installer. Defaults to the installer default. + required: false + default: "" + console-image: + description: Appaloft console image repository or full image reference passed to the self-hosted console installer. + required: false + default: "ghcr.io/appaloft/appaloft" + console-installer-url: + description: Override URL for the self-hosted install.sh script used by command=install-console. + required: false + default: "" + console-skip-docker-install: + description: Require Docker Engine to already exist on the SSH host when command=install-console. + required: false + default: "false" server-provider: description: Server provider key. required: false @@ -169,7 +205,7 @@ runs: run: bash "$GITHUB_ACTION_PATH/scripts/resolve-control-plane.sh" - id: install - if: ${{ steps.resolve.outputs.control-plane-mode == 'none' }} + if: ${{ steps.resolve.outputs.control-plane-mode == 'none' && inputs.command != 'install-console' }} shell: bash env: INPUT_VERSION: ${{ inputs.version }} @@ -181,6 +217,7 @@ runs: env: APPALOFT_BIN: ${{ steps.install.outputs.appaloft-bin }} INPUT_COMMAND: ${{ inputs.command }} + INPUT_VERSION: ${{ inputs.version }} INPUT_CONFIG: ${{ inputs.config }} INPUT_SOURCE: ${{ inputs.source }} INPUT_RUNTIME_NAME: ${{ inputs.runtime-name }} @@ -189,6 +226,15 @@ runs: INPUT_SSH_PORT: ${{ inputs.ssh-port }} INPUT_SSH_PRIVATE_KEY: ${{ inputs.ssh-private-key }} INPUT_SSH_PRIVATE_KEY_FILE: ${{ inputs.ssh-private-key-file }} + INPUT_CONSOLE_URL: ${{ inputs.console-url }} + INPUT_CONSOLE_DOMAIN: ${{ inputs.console-domain }} + INPUT_CONSOLE_DATABASE: ${{ inputs.console-database }} + INPUT_CONSOLE_HTTP_HOST: ${{ inputs.console-http-host }} + INPUT_CONSOLE_HTTP_PORT: ${{ inputs.console-http-port }} + INPUT_CONSOLE_INSTALL_DIR: ${{ inputs.console-install-dir }} + INPUT_CONSOLE_IMAGE: ${{ inputs.console-image }} + INPUT_CONSOLE_INSTALLER_URL: ${{ inputs.console-installer-url }} + INPUT_CONSOLE_SKIP_DOCKER_INSTALL: ${{ inputs.console-skip-docker-install }} INPUT_SERVER_PROVIDER: ${{ inputs.server-provider }} INPUT_SERVER_PROXY_KIND: ${{ inputs.server-proxy-kind }} INPUT_STATE_BACKEND: ${{ inputs.state-backend }} diff --git a/scripts/run-deploy.sh b/scripts/run-deploy.sh index af2ad78..c2acc6d 100755 --- a/scripts/run-deploy.sh +++ b/scripts/run-deploy.sh @@ -54,6 +54,10 @@ json_escape() { printf '%s' "$value" } +shell_quote() { + printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")" +} + normalized_url() { local value="$1" value="${value%/}" @@ -351,12 +355,25 @@ append_step_summary() { fi { - if [ "$wrapper_command" = "preview-cleanup" ]; then - printf '### Appaloft preview cleanup\n\n' - else - printf '### Appaloft deployment\n\n' + case "$wrapper_command" in + preview-cleanup) + printf '### Appaloft preview cleanup\n\n' + ;; + install-console) + printf '### Appaloft console install\n\n' + ;; + *) + printf '### Appaloft deployment\n\n' + ;; + esac + if [ -n "${control_plane_url:-}" ]; then + printf -- '- Console: %s\n' "$control_plane_url" + elif [ -n "${console_url:-}" ]; then + printf -- '- Console: %s\n' "$console_url" + fi + if [ "$wrapper_command" = "install-console" ] && [ -n "${console_database:-}" ]; then + printf -- '- Database: `%s`\n' "$console_database" fi - printf -- '- Console: %s\n' "$control_plane_url" if [ -n "${deployment_id:-}" ]; then if [ -n "${deployment_url:-}" ]; then printf -- '- Deployment: [%s](%s)\n' "$deployment_id" "$deployment_url" @@ -370,6 +387,130 @@ append_step_summary() { } >> "$GITHUB_STEP_SUMMARY" } +console_installer_url_for_version() { + local version="$1" + local normalized_version + + if [ -n "${INPUT_CONSOLE_INSTALLER_URL:-}" ]; then + printf '%s' "$INPUT_CONSOLE_INSTALLER_URL" + return 0 + fi + + if [ -z "$version" ] || [ "$version" = "latest" ]; then + printf 'https://github.com/appaloft/appaloft/releases/latest/download/install.sh' + return 0 + fi + + case "$version" in + v*) normalized_version="$version" ;; + *) normalized_version="v$version" ;; + esac + printf 'https://github.com/appaloft/appaloft/releases/download/%s/install.sh' "$normalized_version" +} + +validate_console_install_inputs() { + case "$console_database" in + postgres|pglite) + ;; + *) + error "console-database must be postgres or pglite" + exit 1 + ;; + esac + + case "$console_http_port" in + ''|*[!0-9]*) + error "console-http-port must be a positive integer" + exit 1 + ;; + *) + if [ "$console_http_port" -le 0 ]; then + error "console-http-port must be a positive integer" + exit 1 + fi + ;; + esac +} + +run_console_install() { + local ssh_host="${INPUT_SSH_HOST:-}" + local ssh_user="${INPUT_SSH_USER:-root}" + local ssh_port="${INPUT_SSH_PORT:-22}" + local installer_url + local remote_command + local install_args + local ssh_args + + [ -n "$ssh_host" ] || { error "ssh-host is required for command=install-console"; exit 1; } + + case "$ssh_port" in + ''|*[!0-9]*) + error "ssh-port must be numeric for command=install-console" + exit 1 + ;; + esac + + validate_console_install_inputs + + if [ -z "$console_url" ]; then + if [ -n "$console_domain" ]; then + console_url="https://${console_domain}" + else + console_url="http://${ssh_host}:${console_http_port}" + fi + fi + console_url="$(normalized_url "$console_url")" + installer_url="$(console_installer_url_for_version "$input_version")" + + install_args="--version $(shell_quote "$input_version") --web-origin $(shell_quote "$console_url") --database $(shell_quote "$console_database") --host $(shell_quote "$console_http_host") --port $(shell_quote "$console_http_port") --image $(shell_quote "$console_image")" + if [ -n "$console_install_dir" ]; then + install_args="$install_args --home $(shell_quote "$console_install_dir")" + fi + if truthy "$console_skip_docker_install"; then + install_args="$install_args --skip-docker-install" + fi + + if truthy "${APPALOFT_DEPLOY_ACTION_DRY_RUN:-false}"; then + if [ -n "${APPALOFT_DEPLOY_ACTION_ARGV_PATH:-}" ]; then + { + printf 'SSH %s@%s:%s\n' "$ssh_user" "$ssh_host" "$ssh_port" + printf 'INSTALLER %s\n' "$installer_url" + printf 'RUN sh /tmp/appaloft-install.sh %s\n' "$install_args" + printf 'HEALTH %s/api/health\n' "$console_url" + } > "$APPALOFT_DEPLOY_ACTION_ARGV_PATH" + else + printf 'SSH %s@%s:%s\n' "$ssh_user" "$ssh_host" "$ssh_port" + printf 'INSTALLER %s\n' "$installer_url" + printf 'RUN sh /tmp/appaloft-install.sh %s\n' "$install_args" + printf 'HEALTH %s/api/health\n' "$console_url" + fi + echo "console-url=$console_url" >> "${GITHUB_OUTPUT:-/dev/null}" + append_step_summary + return 0 + fi + + ssh_args=(-p "$ssh_port" -o StrictHostKeyChecking=accept-new -o ServerAliveInterval=30) + if [ -n "$ssh_private_key_file" ]; then + ssh_args=(-i "$ssh_private_key_file" "${ssh_args[@]}") + fi + + remote_command="if command -v curl >/dev/null 2>&1; then curl -fsSL $(shell_quote "$installer_url") -o /tmp/appaloft-install.sh; elif command -v wget >/dev/null 2>&1; then wget -qO /tmp/appaloft-install.sh $(shell_quote "$installer_url"); else echo 'curl or wget is required to download Appaloft installer' >&2; exit 1; fi; chmod 700 /tmp/appaloft-install.sh; sh /tmp/appaloft-install.sh $install_args" + + ssh "${ssh_args[@]}" "$ssh_user@$ssh_host" "$remote_command" + + for attempt in 1 2 3 4 5 6 7 8 9 10; do + if curl -fsS "$console_url/api/health" >/dev/null; then + echo "console-url=$console_url" >> "${GITHUB_OUTPUT:-/dev/null}" + append_step_summary + return 0 + fi + sleep 6 + done + + error "Appaloft console did not become healthy at $console_url/api/health" + exit 1 +} + pull_request_number_from_context() { local normalized_preview_id normalized_preview_id="$(printf '%s' "$preview_id" | tr '[:upper:]' '[:lower:]')" @@ -509,6 +650,7 @@ trap cleanup_key_file EXIT appaloft_bin="${APPALOFT_BIN:-appaloft}" wrapper_command="${INPUT_COMMAND:-deploy}" +input_version="${INPUT_VERSION:-latest}" source_locator="${INPUT_SOURCE:-.}" config_path="${INPUT_CONFIG:-}" input_control_plane_mode="${INPUT_CONTROL_PLANE_MODE:-}" @@ -519,6 +661,14 @@ use_oidc="${INPUT_USE_OIDC:-false}" server_config_deploy="${INPUT_SERVER_CONFIG_DEPLOY:-false}" ssh_private_key="${INPUT_SSH_PRIVATE_KEY:-}" ssh_private_key_file="${INPUT_SSH_PRIVATE_KEY_FILE:-}" +console_url="${INPUT_CONSOLE_URL:-}" +console_domain="${INPUT_CONSOLE_DOMAIN:-}" +console_database="${INPUT_CONSOLE_DATABASE:-pglite}" +console_http_host="${INPUT_CONSOLE_HTTP_HOST:-0.0.0.0}" +console_http_port="${INPUT_CONSOLE_HTTP_PORT:-3001}" +console_install_dir="${INPUT_CONSOLE_INSTALL_DIR:-}" +console_image="${INPUT_CONSOLE_IMAGE:-ghcr.io/appaloft/appaloft}" +console_skip_docker_install="${INPUT_CONSOLE_SKIP_DOCKER_INSTALL:-false}" state_backend="${INPUT_STATE_BACKEND:-}" environment_variables="${INPUT_ENVIRONMENT_VARIABLES:-}" secret_variables="${INPUT_SECRET_VARIABLES:-}" @@ -563,12 +713,31 @@ case "$wrapper_command" in ;; preview-cleanup) ;; + install-console) + ;; *) error "Unsupported deploy-action command: $wrapper_command" exit 1 ;; esac +if [ -n "$ssh_private_key" ] && [ -n "$ssh_private_key_file" ]; then + error "ssh-private-key and ssh-private-key-file are mutually exclusive" + exit 1 +fi + +if [ -n "$ssh_private_key" ]; then + generated_key_file="$(mktemp "${RUNNER_TEMP:-/tmp}/appaloft-ssh-key.XXXXXX")" + printf '%s\n' "$ssh_private_key" > "$generated_key_file" + chmod 600 "$generated_key_file" + ssh_private_key_file="$generated_key_file" +fi + +if [ "$wrapper_command" = "install-console" ]; then + run_console_install + exit 0 +fi + case "$control_plane_mode" in ""|none) ;; @@ -600,11 +769,6 @@ if truthy "$server_config_deploy" && { [ "$control_plane_mode" != "self-hosted" exit 1 fi -if [ -n "$ssh_private_key" ] && [ -n "$ssh_private_key_file" ]; then - error "ssh-private-key and ssh-private-key-file are mutually exclusive" - exit 1 -fi - if [ "$preview" = "pull-request" ] && [ -z "$preview_id" ]; then error "preview-id is required when preview=pull-request" exit 1 @@ -756,13 +920,6 @@ if [ -n "${INPUT_SSH_HOST:-}" ] && [ -z "$state_backend" ]; then state_backend="ssh-pglite" fi -if [ -n "$ssh_private_key" ]; then - generated_key_file="$(mktemp "${RUNNER_TEMP:-/tmp}/appaloft-ssh-key.XXXXXX")" - printf '%s\n' "$ssh_private_key" > "$generated_key_file" - chmod 600 "$generated_key_file" - ssh_private_key_file="$generated_key_file" -fi - if [ -n "$preview" ] && [ "$wrapper_command" = "deploy" ]; then generated_preview_output_file="$(mktemp "${RUNNER_TEMP:-/tmp}/appaloft-preview-output.XXXXXX")" preview_output_file="$generated_preview_output_file"