diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index 316ef39..0000000 --- a/.dockerignore +++ /dev/null @@ -1,8 +0,0 @@ -*.swp -test/ -img/ -.dockerignore -.git -.github -Makefile -.env* diff --git a/.github/workflows/pullpreview-multi-env.yml b/.github/workflows/pullpreview-multi-env.yml index 657bd2b..5ddddec 100644 --- a/.github/workflows/pullpreview-multi-env.yml +++ b/.github/workflows/pullpreview-multi-env.yml @@ -7,36 +7,36 @@ on: jobs: deploy_env1: - runs-on: ubuntu-latest + runs-on: ubuntu-slim if: github.event_name == 'schedule' || github.event_name == 'push' || github.event.label.name == 'pullpreview-multi-env' || contains(github.event.pull_request.labels.*.name, 'pullpreview-multi-env') timeout-minutes: 30 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: "./" with: deployment_variant: env1 label: pullpreview-multi-env admins: crohr,qbonnard app_path: ./examples/wordpress - instance_type: micro_2_0 + instance_type: micro registries: docker://${{ secrets.GHCR_PAT }}@ghcr.io env: AWS_ACCESS_KEY_ID: "${{ secrets.AWS_ACCESS_KEY_ID }}" AWS_SECRET_ACCESS_KEY: "${{ secrets.AWS_SECRET_ACCESS_KEY }}" deploy_env2: - runs-on: ubuntu-latest + runs-on: ubuntu-slim if: github.event_name == 'schedule' || github.event_name == 'push' || github.event.label.name == 'pullpreview-multi-env' || contains(github.event.pull_request.labels.*.name, 'pullpreview-multi-env') timeout-minutes: 30 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: "./" with: deployment_variant: env2 label: pullpreview-multi-env admins: crohr,qbonnard app_path: ./examples/wordpress - instance_type: micro_2_0 + instance_type: micro registries: docker://${{ secrets.GHCR_PAT }}@ghcr.io env: AWS_ACCESS_KEY_ID: "${{ secrets.AWS_ACCESS_KEY_ID }}" diff --git a/.github/workflows/pullpreview.yml b/.github/workflows/pullpreview.yml index 87963d6..1cacfad 100644 --- a/.github/workflows/pullpreview.yml +++ b/.github/workflows/pullpreview.yml @@ -7,38 +7,157 @@ on: - master - v5 pull_request: - types: [labeled, unlabeled, synchronize, closed, reopened] + types: [labeled, unlabeled, synchronize, closed, reopened, opened] concurrency: ${{ github.ref }} permissions: contents: read # to fetch code (actions/checkout) - deployments: write # to delete deployments - pull-requests: write # to remove labels - statuses: write # to create commit status + pull-requests: write # to remove labels / write PR comments jobs: - deploy: - runs-on: ubuntu-latest - if: github.event_name == 'schedule' || github.event_name == 'push' || github.event.label.name == 'pullpreview' || contains(github.event.pull_request.labels.*.name, 'pullpreview') - timeout-minutes: 30 + deploy_smoke_1: + runs-on: ubuntu-slim + if: github.event_name == 'push' || (github.event.action != 'closed' && github.event.action != 'unlabeled' && (github.event.label.name == 'pullpreview' || contains(github.event.pull_request.labels.*.name, 'pullpreview'))) + timeout-minutes: 35 steps: - - uses: actions/checkout@v4 - - uses: "./" + - uses: actions/checkout@v5 + + - name: Deploy smoke app (v1) + id: pullpreview + uses: "./" with: admins: "@collaborators/push" always_on: master,v5 - app_path: ./examples/wordpress + app_path: ./examples/workflow-smoke instance_type: micro # only required if using custom domain for your preview environments - dns: custom.preview.run + dns: preview.chunk.io max_domain_length: 30 - # only required if fetching images from private registry + # Enable HTTPS preview URL through Caddy + Let's Encrypt. + proxy_tls: web:8080 + # required here because the mysql image is private in GHCR registries: docker://${{ secrets.GHCR_PAT }}@ghcr.io # how long this instance will stay alive (each further commit will reset the timer) ttl: 1h - # preinstall script to run on the instance before docker-compose is called, relative to the app_path - pre_script: ./pre_script.sh + env: + AWS_ACCESS_KEY_ID: "${{ secrets.AWS_ACCESS_KEY_ID }}" + AWS_SECRET_ACCESS_KEY: "${{ secrets.AWS_SECRET_ACCESS_KEY }}" + + - name: Assert deploy v1 and DB seed state + shell: bash + env: + PREVIEW_URL: ${{ steps.pullpreview.outputs.url }} + run: | + set -euo pipefail + + if [[ "${PREVIEW_URL}" != https://* ]]; then + echo "::error::Expected https preview URL when proxy_tls is enabled, got ${PREVIEW_URL}" + exit 1 + fi + + response="" + for attempt in $(seq 1 60); do + response="$(curl -fsSL --max-time 15 "${PREVIEW_URL}" || true)" + if printf '%s' "${response}" | grep -q 'Hello World Deploy 1' && \ + printf '%s' "${response}" | grep -q 'seed_count=1' && \ + printf '%s' "${response}" | grep -q 'seed_label=persisted'; then + echo "Smoke v1 checks passed for ${PREVIEW_URL}" + exit 0 + fi + + echo "Attempt ${attempt}/60: waiting for v1 response from ${PREVIEW_URL}" + sleep 5 + done + + echo "::error::Unexpected response from ${PREVIEW_URL}" + printf '%s\n' "${response}" + exit 1 + + deploy_smoke_2: + runs-on: ubuntu-slim + needs: deploy_smoke_1 + if: needs.deploy_smoke_1.result == 'success' + timeout-minutes: 35 + steps: + - uses: actions/checkout@v5 + + - name: Update app payload to v2 + shell: bash + run: | + set -euo pipefail + + printf '%s\n' 'Hello World Deploy 2' > examples/workflow-smoke/web/message.txt + + # This file should be synced, but with persistent DB volume it should not run. + cat > examples/workflow-smoke/dumps/999_should_not_run.sql <<'SQL' + INSERT INTO seed_data (label) VALUES ('should-not-run'); + SQL + + - name: Redeploy smoke app (v2) + id: pullpreview + uses: "./" + with: + admins: "@collaborators/push" + always_on: master,v5 + app_path: ./examples/workflow-smoke + instance_type: micro + dns: preview.chunk.io + max_domain_length: 30 + proxy_tls: web:8080 + registries: docker://${{ secrets.GHCR_PAT }}@ghcr.io + ttl: 1h + env: + AWS_ACCESS_KEY_ID: "${{ secrets.AWS_ACCESS_KEY_ID }}" + AWS_SECRET_ACCESS_KEY: "${{ secrets.AWS_SECRET_ACCESS_KEY }}" + + - name: Assert deploy v2 and DB persistence + shell: bash + env: + PREVIEW_URL: ${{ steps.pullpreview.outputs.url }} + run: | + set -euo pipefail + + if [[ "${PREVIEW_URL}" != https://* ]]; then + echo "::error::Expected https preview URL when proxy_tls is enabled, got ${PREVIEW_URL}" + exit 1 + fi + + response="" + for attempt in $(seq 1 60); do + response="$(curl -fsSL --max-time 15 "${PREVIEW_URL}" || true)" + if printf '%s' "${response}" | grep -q 'Hello World Deploy 2' && \ + printf '%s' "${response}" | grep -q 'seed_count=1' && \ + printf '%s' "${response}" | grep -q 'seed_label=persisted'; then + echo "Smoke v2 checks passed for ${PREVIEW_URL}" + exit 0 + fi + + echo "Attempt ${attempt}/60: waiting for v2 response from ${PREVIEW_URL}" + sleep 5 + done + + echo "::error::Unexpected response from ${PREVIEW_URL}" + printf '%s\n' "${response}" + exit 1 + + cleanup: + runs-on: ubuntu-slim + if: github.event_name == 'schedule' || github.event.action == 'closed' || (github.event.action == 'unlabeled' && github.event.label.name == 'pullpreview') + timeout-minutes: 30 + steps: + - uses: actions/checkout@v5 + - uses: "./" + with: + admins: "@collaborators/push" + always_on: master,v5 + app_path: ./examples/workflow-smoke + instance_type: micro + dns: toto.preview.run + max_domain_length: 30 + proxy_tls: web:8080 + registries: docker://${{ secrets.GHCR_PAT }}@ghcr.io + ttl: 1h env: AWS_ACCESS_KEY_ID: "${{ secrets.AWS_ACCESS_KEY_ID }}" AWS_SECRET_ACCESS_KEY: "${{ secrets.AWS_SECRET_ACCESS_KEY }}" diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..adb9001 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "wiki"] + path = wiki + url = https://github.com/pullpreview/action.wiki.git diff --git a/.tool-versions b/.tool-versions index 918425e..ced5586 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1 +1 @@ -ruby 3.1.6 \ No newline at end of file +go 1.25.1 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..c24e6a2 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,80 @@ +# PullPreview Action — Current Behavior (Go) + +This repository ships a GitHub Action implemented in Go. + +## Runtime +- Action definition: `action.yml` +- Action type: `composite` +- Runtime binary: prebuilt amd64 Linux artifact in `dist/` +- No Docker image build is required during action execution. + +## Go Tooling +- Go commands should be run via `mise` for toolchain consistency. +- Examples: + - `mise exec -- go test ./...` + - `mise exec -- go run ./cmd/pullpreview up examples/example-app` + - `make dist` +- Dist workflow: + - Commit source changes first. + - Run `make dist` afterwards. + - `make dist` auto-commits the updated bundled binary with a standard commit message. + - Before merging, `make rewrite` can rewrite the current branch and drop dist-only auto-commits (force-push required). + +## CLI +Entrypoint source is `cmd/pullpreview/main.go`. + +Supported commands: +- `pullpreview up path/to/app` +- `pullpreview down --name ` +- `pullpreview list org/repo` +- `pullpreview github-sync path/to/app` + +## Deploy behavior (`up`) +- Tars app directory (excluding `.git`). +- Launches/restores Lightsail instance and waits for SSH. +- Uploads authorized keys, update script, and pre-script. +- Deploys through Docker context to the remote engine. +- Rewrites relative bind mounts under `app_path` so they resolve on the remote host. +- Optional automatic HTTPS proxying via Caddy + Let's Encrypt when `proxy_tls` is set. + - Format: `service:port` (for example `web:80`). + - Forces preview URL/output to HTTPS on port `443`. + - Opens firewall port `443` and suppresses firewall exposure for port `80`. + - Injects `pullpreview-proxy` service unless host port `443` is already published (then it logs a warning and skips proxy injection). +- Emits periodic heartbeat logs with: + - preview URL + - SSH command (`ssh user@ip`) + - authorized users info + - key-upload confirmation + +## GitHub sync behavior (`github-sync`) +- Handles PR labeled/opened/reopened/synchronize/unlabeled/closed events. +- Handles push events for `always_on` branches. +- Handles scheduled cleanup of dangling labeled preview instances. +- Updates marker-based PR status comments. +- For `admins: "@collaborators/push"`: + - loads collaborators from GitHub REST API with `affiliation=all` + `permission=push` + - uses only the first page (up to 100 users) + - emits a warning if additional pages exist + - fetches each admin's SSH public keys via GitHub API and forwards keys to the instance + - uses local key cache directory (`PULLPREVIEW_SSH_KEYS_CACHE_DIR`) to avoid refetching keys across runs +- Always posts/updates marker-based PR status comments per environment/job with building/ready/error/destroyed state and preview URL. + +## Action inputs/outputs +- Existing inputs are preserved. +- Additional input: + - `proxy_tls` (`service:port`, default empty) +- Outputs: + - `url` + - `host` + - `username` + +## Key directories +- `cmd/pullpreview`: CLI +- `internal/pullpreview`: core orchestration +- `internal/providers/lightsail`: Lightsail provider +- `internal/github`: GitHub API wrapper +- `internal/license`: license check client +- `dist/`: bundled Linux amd64 binary used by the action + +## Repo-local skill +- `skills/pullpreview-demo-flow/SKILL.md`: repeatable end-to-end demo capture workflow (PR open/label/deploy/view deployment/unlabel/destroy) with strict screenshot requirements and fixed demo PR title. diff --git a/CHANGELOG.md b/CHANGELOG.md index e6aa393..bc7c15a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ ## master +### Breaking changes + +- Removed GitHub Deployments/Environments integration from the Go action runtime. +- PullPreview now relies on workflow checks + PR comments as the deployment UX surface. +- Removed `comment_pr` input and `--comment-pr` CLI flag; PR comment updates are always enabled. + ## v5.8.0 - Switch default domain to my.preview.run (#92) diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 67b6192..0000000 --- a/Dockerfile +++ /dev/null @@ -1,12 +0,0 @@ -# Pinning, because Amazon Linux 2 doesn't support openssh 9+ clients yet, and we need to maintain compatibility with older pullpreview instances. -# FIXME: Switch back to ruby@3.1-slim after end of august 2023, when we can be sure that newer instances have been created with Amazon Linux 2023. -FROM ruby@sha256:54d09dd38d80d8b850fbff21425d9bd159f9ff7e1de1cdbcbb0b7745f5049784 - -RUN apt-get -qq update && apt-get -qq -y install openssh-client git >/dev/null -WORKDIR /app -COPY Gemfile . -COPY Gemfile.lock . -RUN bundle install -j 4 --quiet -ADD . . - -ENTRYPOINT ["/app/bin/pullpreview"] diff --git a/Gemfile b/Gemfile deleted file mode 100644 index cc95c3c..0000000 --- a/Gemfile +++ /dev/null @@ -1,7 +0,0 @@ -source 'https://rubygems.org' - -gem 'aws-sdk-lightsail' -gem 'slop' -gem 'octokit' -gem 'terminal-table' -gem 'pry' diff --git a/Gemfile.lock b/Gemfile.lock deleted file mode 100644 index a8b0f97..0000000 --- a/Gemfile.lock +++ /dev/null @@ -1,54 +0,0 @@ -GEM - remote: https://rubygems.org/ - specs: - addressable (2.8.5) - public_suffix (>= 2.0.2, < 6.0) - aws-eventstream (1.1.0) - aws-partitions (1.312.0) - aws-sdk-core (3.95.0) - aws-eventstream (~> 1, >= 1.0.2) - aws-partitions (~> 1, >= 1.239.0) - aws-sigv4 (~> 1.1) - jmespath (~> 1.0) - aws-sdk-lightsail (1.30.0) - aws-sdk-core (~> 3, >= 3.71.0) - aws-sigv4 (~> 1.1) - aws-sigv4 (1.1.3) - aws-eventstream (~> 1.0, >= 1.0.2) - base64 (0.2.0) - coderay (1.1.2) - faraday (2.7.12) - base64 - faraday-net_http (>= 2.0, < 3.1) - ruby2_keywords (>= 0.0.4) - faraday-net_http (3.0.2) - jmespath (1.6.1) - method_source (1.0.0) - octokit (8.0.0) - faraday (>= 1, < 3) - sawyer (~> 0.9) - pry (0.13.1) - coderay (~> 1.1) - method_source (~> 1.0) - public_suffix (5.0.4) - ruby2_keywords (0.0.5) - sawyer (0.9.2) - addressable (>= 2.3.5) - faraday (>= 0.17.3, < 3) - slop (4.8.1) - terminal-table (1.8.0) - unicode-display_width (~> 1.1, >= 1.1.1) - unicode-display_width (1.7.0) - -PLATFORMS - ruby - -DEPENDENCIES - aws-sdk-lightsail - octokit - pry - slop - terminal-table - -BUNDLED WITH - 2.2.22 diff --git a/Makefile b/Makefile index 45c4ea2..9a94154 100644 --- a/Makefile +++ b/Makefile @@ -1,11 +1,77 @@ -build: - docker build -t pullpreview/pullpreview:$(TAG) . +GO ?= mise exec -- go +DIST_DIR := dist +BIN_NAME := pullpreview +GO_LDFLAGS ?= -s -w +UPX ?= upx +UPX_FLAGS ?= --best --lzma +DIST_COMMIT_MESSAGE ?= chore(dist): update bundled pullpreview binary -shell: - docker run -e GITHUB_SHA=9cdcde5f00b76c97db398210dd5460b259176f9b -e GITHUB_TOKEN -e GITHUB_EVENT_PATH=/app/test/fixtures/github_event_push.json -e AWS_ACCESS_KEY_ID -e AWS_SECRET_ACCESS_KEY --entrypoint /bin/sh -it --rm -v $(shell pwd):/app pullpreview/pullpreview +DIST_BINARIES := \ + $(DIST_DIR)/$(BIN_NAME)-linux-amd64 -bundle: - docker run --rm -v $(shell pwd):/app -w /app ruby:2-alpine bundle +.PHONY: dist dist-check dist-commit clean-dist rewrite test -release: build - docker push pullpreview/pullpreview:$(TAG) +dist: dist-check clean-dist $(DIST_BINARIES) dist-commit + +dist-check: + @if [ -n "$$(git status --porcelain --untracked-files=no -- . ':(exclude)$(DIST_DIR)')" ]; then \ + echo "Refusing to build dist with uncommitted source changes."; \ + echo "Commit changes first, then run 'make dist'."; \ + git status --short -- . ':(exclude)$(DIST_DIR)'; \ + exit 1; \ + fi + +$(DIST_DIR)/$(BIN_NAME)-linux-amd64: + mkdir -p $(DIST_DIR) + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GO) build -trimpath -ldflags '$(GO_LDFLAGS)' -o $@ ./cmd/pullpreview + $(UPX) $(UPX_FLAGS) $@ + +dist-commit: + git add -A $(DIST_DIR) + @if git diff --cached --quiet -- $(DIST_DIR); then \ + echo "No dist changes to commit."; \ + else \ + git commit -m "$(DIST_COMMIT_MESSAGE)" -- $(DIST_DIR); \ + fi + +clean-dist: + rm -f $(DIST_DIR)/$(BIN_NAME)-linux-amd64 + +rewrite: + @set -eu; \ + current_branch="$$(git rev-parse --abbrev-ref HEAD)"; \ + if [ "$$current_branch" = "HEAD" ]; then \ + echo "Refusing to rewrite detached HEAD."; \ + exit 1; \ + fi; \ + if [ -n "$$(git status --porcelain)" ]; then \ + echo "Working tree must be clean before rewrite."; \ + git status --short; \ + exit 1; \ + fi; \ + base_ref="$${BASE_REF:-$$(git symbolic-ref --quiet --short refs/remotes/origin/HEAD || echo origin/master)}"; \ + merge_base="$$(git merge-base "$$base_ref" "$$current_branch")"; \ + safe_branch="$$(printf '%s' "$$current_branch" | tr '/' '-')"; \ + tmp_branch="rewrite-$$safe_branch-$$(date +%s)"; \ + commits_file="$$(mktemp)"; \ + trap 'rm -f "$$commits_file"' EXIT; \ + git rev-list --reverse "$$merge_base..$$current_branch" > "$$commits_file"; \ + echo "Rewriting $$current_branch onto $$base_ref (merge-base $$merge_base)"; \ + git checkout -b "$$tmp_branch" "$$merge_base" >/dev/null; \ + while IFS= read -r sha; do \ + [ -z "$$sha" ] && continue; \ + subject="$$(git show -s --format=%s "$$sha")"; \ + files="$$(git show --pretty=format: --name-only "$$sha" | sed '/^$$/d')"; \ + if [ "$$subject" = "$(DIST_COMMIT_MESSAGE)" ] && [ -n "$$files" ] && ! printf '%s\n' "$$files" | grep -qv '^dist/'; then \ + echo "Dropping dist commit $$sha ($$subject)"; \ + continue; \ + fi; \ + git cherry-pick "$$sha" >/dev/null; \ + done < "$$commits_file"; \ + git branch -f "$$current_branch" HEAD; \ + git checkout "$$current_branch" >/dev/null; \ + git branch -D "$$tmp_branch" >/dev/null; \ + echo "Rewrite complete. Force-push with: git push --force-with-lease origin $$current_branch" + +test: + $(GO) test ./... diff --git a/README.md b/README.md index 1f099bb..f00b1a4 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,11 @@ A GitHub Action that starts live environments for your pull requests and branche [![pullpreview](https://github.com/pullpreview/action/actions/workflows/pullpreview.yml/badge.svg)](https://github.com/pullpreview/action/actions/workflows/pullpreview.yml) Hacker News +## Breaking change (Go runtime) + +- GitHub Deployments/Environments integration has been removed. +- `comment_pr` has been removed; PullPreview PR status comments are now always enabled. + ## Spin environments in one click Once installed in your repository, this action is triggered any time a change @@ -54,14 +59,17 @@ Preview environments that: a GitHub Action, which means your code never leaves GitHub or your Lightsail instances. -- make the preview URL **easy to find** for your reviewers: Deployment statuses - and URLs are visible in the PR checks section, and on the Deployments tab in - the GitHub UI. +- make the preview URL **easy to find** for your reviewers: Marker-based PR + comments are updated with live preview state and URL. - **persist state** across deploys: Any state stored in docker volumes (e.g. database data) will be persisted across deploys, making the life of reviewers easier. +- can **auto-enable HTTPS** with Let's Encrypt: Set `proxy_tls` to inject a + Caddy reverse proxy that terminates TLS and forwards traffic to your service. + This switches the preview URL to HTTPS on port `443`. + - are **easy to troubleshoot**: You can give specific GitHub users the authorization to SSH into the preview instance (with sudo privileges) to further troubleshoot any issue. The SSH keys that they use to push to GitHub @@ -69,8 +77,7 @@ Preview environments that: - are **integrated into the GitHub UI**: Logs for each deployment run are available within the Actions section, and direct links to the preview - environments are available in the Checks section of a PR, and in the - Deployments tab of the repository. + environments are available in PullPreview PR comments. @@ -125,6 +132,8 @@ jobs: instance_type: nano # Ports to open on the server ports: 80,5432 + # Optional: automatic HTTPS forwarding via Caddy + Let's Encrypt + proxy_tls: web:80 env: AWS_ACCESS_KEY_ID: "${{ secrets.AWS_ACCESS_KEY_ID }}" AWS_SECRET_ACCESS_KEY: "${{ secrets.AWS_SECRET_ACCESS_KEY }}" diff --git a/action.yml b/action.yml index 6a1e460..36bb0db 100644 --- a/action.yml +++ b/action.yml @@ -22,7 +22,7 @@ inputs: description: "Label to use for triggering preview deployments" default: "pullpreview" github_token: - description: "The GitHub access token used to perform GitHub API operations. A custom token is only required for auto-cleanup of GitHub environment objects." + description: "The GitHub access token used to perform GitHub API operations (labels, comments, collaborator/keys lookup)." default: "${{ github.token }}" admins: description: "Logins of GitHub users that will have their SSH key installed on the instance, comma-separated" @@ -68,6 +68,10 @@ inputs: description: "Names of private registries to authenticate against. E.g. docker://username:password@ghcr.io" required: false default: "" + proxy_tls: + description: "Enable automatic HTTPS forwarding with Let's Encrypt (format: service:port, e.g. web:80)" + required: false + default: "" pre_script: description: "Path to a bash script to run on the instance, before calling docker compose, relative to the app_path" required: false @@ -80,47 +84,73 @@ inputs: outputs: url: description: "The URL of the application on the preview server" + value: "${{ steps.pullpreview.outputs.url }}" host: description: "The hostname or IP address of the preview server" + value: "${{ steps.pullpreview.outputs.host }}" username: description: "The username that can be used to SSH into the preview server" + value: "${{ steps.pullpreview.outputs.username }}" runs: - using: "docker" - image: "Dockerfile" - args: - - "github-sync" - - "${{ inputs.app_path }}" - - "--admins" - - "${{ inputs.admins }}" - - "--cidrs" - - "${{ inputs.cidrs }}" - - "--compose-files" - - "${{ inputs.compose_files }}" - - "--compose-options" - - "${{ inputs.compose_options }}" - - "--dns" - - "${{ inputs.dns }}" - - "--label" - - "${{ inputs.label }}" - - "--ports" - - "${{ inputs.ports }}" - - "--default-port" - - "${{ inputs.default_port }}" - - "--always-on" - - "${{ inputs.always_on }}" - - "--instance-type" - - "${{ inputs.instance_type }}" - - "--deployment-variant" - - "${{ inputs.deployment_variant }}" - - "--registries" - - "${{ inputs.registries }}" - - "--pre-script" - - "${{ inputs.pre_script }}" - - "--ttl" - - "${{ inputs.ttl }}" - env: - GITHUB_TOKEN: "${{ inputs.github_token }}" - PULLPREVIEW_LICENSE: "${{ inputs.license }}" - PULLPREVIEW_PROVIDER: "${{ inputs.provider }}" - PULLPREVIEW_MAX_DOMAIN_LENGTH: "${{ inputs.max_domain_length }}" + using: "composite" + steps: + - name: Restore SSH key cache + uses: actions/cache@v4 + with: + path: ${{ runner.temp }}/pullpreview-ssh-keys + key: pullpreview-ssh-keys-v1-${{ github.repository_id }}-${{ github.run_id }} + restore-keys: | + pullpreview-ssh-keys-v1-${{ github.repository_id }}- + - id: pullpreview + shell: bash + env: + GITHUB_TOKEN: "${{ inputs.github_token }}" + PULLPREVIEW_LICENSE: "${{ inputs.license }}" + PULLPREVIEW_PROVIDER: "${{ inputs.provider }}" + PULLPREVIEW_MAX_DOMAIN_LENGTH: "${{ inputs.max_domain_length }}" + PULLPREVIEW_SSH_KEYS_CACHE_DIR: "${{ runner.temp }}/pullpreview-ssh-keys" + PULLPREVIEW_GITHUB_JOB_ID: "${{ job.check_run_id }}" + run: | + set -euo pipefail + mkdir -p "${PULLPREVIEW_SSH_KEYS_CACHE_DIR}" + + os="$(echo "${RUNNER_OS}" | tr '[:upper:]' '[:lower:]')" + arch="$(echo "${RUNNER_ARCH}" | tr '[:upper:]' '[:lower:]')" + if [ "${os}" != "linux" ]; then + echo "::error::Unsupported RUNNER_OS=${RUNNER_OS}. Only Linux amd64 is supported." + exit 1 + fi + case "${arch}" in + x64) arch="amd64" ;; + amd64) arch="amd64" ;; + *) + echo "::error::Unsupported RUNNER_ARCH=${RUNNER_ARCH}. Only amd64 is supported." + exit 1 + ;; + esac + + binary="${GITHUB_ACTION_PATH}/dist/pullpreview-${os}-${arch}" + if [ ! -f "${binary}" ]; then + echo "::error::Bundled binary not found at ${binary}. Run 'make dist' and commit dist artifacts." + ls -la "${GITHUB_ACTION_PATH}/dist" || true + exit 1 + fi + chmod +x "${binary}" + + "${binary}" github-sync "${{ inputs.app_path }}" \ + --admins "${{ inputs.admins }}" \ + --cidrs "${{ inputs.cidrs }}" \ + --compose-files "${{ inputs.compose_files }}" \ + --compose-options "${{ inputs.compose_options }}" \ + --dns "${{ inputs.dns }}" \ + --label "${{ inputs.label }}" \ + --ports "${{ inputs.ports }}" \ + --default-port "${{ inputs.default_port }}" \ + --always-on "${{ inputs.always_on }}" \ + --instance-type "${{ inputs.instance_type }}" \ + --deployment-variant "${{ inputs.deployment_variant }}" \ + --registries "${{ inputs.registries }}" \ + --proxy-tls "${{ inputs.proxy_tls }}" \ + --pre-script "${{ inputs.pre_script }}" \ + --ttl "${{ inputs.ttl }}" diff --git a/bin/pullpreview b/bin/pullpreview deleted file mode 100755 index ddab8a9..0000000 --- a/bin/pullpreview +++ /dev/null @@ -1,108 +0,0 @@ -#!/usr/bin/env ruby - -require "pathname" -require "pry" -require "slop" - -require_relative "../lib/pull_preview" - -STDOUT.sync = true -STDERR.sync = true - -PullPreview.logger = Logger.new(STDOUT) -PullPreview.logger.level = Logger.const_get(ENV.fetch("PULLPREVIEW_LOGGER_LEVEL", "INFO").upcase) -PullPreview.license = ENV.fetch("PULLPREVIEW_LICENSE", "") -PullPreview.provider = ENV.fetch("PULLPREVIEW_PROVIDER", "lightsail") - -common_opts = lambda do |o| - o.bool '-v', '--verbose', 'Enable verbose mode' do - PullPreview.logger.level = Logger::DEBUG - end - o.on '--help' do - puts o - exit - end -end - -up_opts = lambda do |o| - o.array '--admins', 'Logins of GitHub users that will have their SSH key installed on the instance' - o.array '--cidrs', 'The IP address, or range of IP addresses in CIDR notation, that are allowed to connect to the instance', default: ["0.0.0.0/0"] - o.array '--registries', 'URIs of docker registries to authenticate against, e.g. docker://username:password@ghcr.io', default: [] - o.string '--dns', 'Enable DNS support for pretty-looking URLs', default: "my.preview.run" - o.array '--ports', 'Ports to open for external access on the preview server', default: [ - "80/tcp", "443/tcp" - ] - o.string '--instance-type', 'Instance type to use', default: 'small_2_0' - o.string '--default-port', 'Default port to use when displaying the instance hostname', default: "80" - o.array '--tags', 'Tags to add to the instance' - o.array '--compose-files', 'Compose files to use when running docker-compose up', default: ["docker-compose.yml"] - o.array '--compose-options', 'Additional options to pass to docker-compose up, comma-separated', default: ["--build"] - o.string '--pre-script', 'Path to a bash script to run on the instance, before calling docker compose', default: "" -end - - -begin - case ARGV.shift - when "down" - opts = Slop.parse do |o| - o.banner = "Usage: pullpreview down [options]" - o.string '--name', 'Name of the environment to destroy', required: true - common_opts.call(o) - end - - PullPreview::Down.run(opts.to_hash) - when "up" - opts = Slop.parse do |o| - o.banner = "Usage: pullpreview up path/to/app [options]" - o.string '--name', 'Unique name for the environment', required: true - common_opts.call(o) - up_opts.call(o) - end - - app_path = opts.arguments.first - if app_path.nil? - puts opts - exit 1 - end - - PullPreview::Up.run(app_path, opts.to_hash) - when "github-sync" - opts = Slop.parse do |o| - o.banner = "Usage: pullpreview github-sync path/to/app [options]" - common_opts.call(o) - o.array '--always-on', 'List of branches to always deploy', default: [] - o.string '--deployment-variant', 'Deployment variant, which allows launching multiple deployments per PR (4 chars max)', default: "" - o.string '--label', 'Label to use for triggering preview deployments', default: "pullpreview" - o.string '--ttl', 'Maximum time to live for deployments (e.g. 10h, 5d, infinite)', default: "infinite" - up_opts.call(o) - end - - app_path = opts.arguments.first - if app_path.nil? - puts opts - exit 1 - end - - PullPreview::GithubSync.run(app_path, opts.to_hash) - when "console" - binding.pry - when "list" - opts = Slop.parse do |o| - o.banner = "Usage: pullpreview list org/repo [options]" - common_opts.call(o) - o.string '--org', 'Restrict to given organization name' - o.string '--repo', 'Restrict to given repository name' - end - PullPreview::List.run(opts) - else - puts "Usage: pullpreview [up|down|list|console|github-sync] [options]" - exit 1 - end -rescue PullPreview::Error, StandardError => e - puts "Error: #{e.message}" - PullPreview.logger.error e.backtrace.join("\n") - exit 1 -rescue Slop::Error => e - puts "CLI Error: #{e.message}" - exit 1 -end diff --git a/cmd/pullpreview/main.go b/cmd/pullpreview/main.go new file mode 100644 index 0000000..f584b3c --- /dev/null +++ b/cmd/pullpreview/main.go @@ -0,0 +1,281 @@ +package main + +import ( + "context" + "flag" + "fmt" + "net/url" + "os" + "os/signal" + "path/filepath" + "strings" + "syscall" + + "github.com/pullpreview/action/internal/providers/lightsail" + "github.com/pullpreview/action/internal/pullpreview" +) + +func main() { + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer cancel() + + if len(os.Args) < 2 { + usage() + os.Exit(1) + } + cmd := os.Args[1] + args := os.Args[2:] + + logger := pullpreview.NewLogger(pullpreview.ParseLogLevel(os.Getenv("PULLPREVIEW_LOGGER_LEVEL"))) + + switch cmd { + case "up": + runUp(ctx, args, logger) + case "down": + runDown(ctx, args, logger) + case "github-sync": + runGithubSync(ctx, args, logger) + case "list": + runList(ctx, args, logger) + default: + usage() + os.Exit(1) + } +} + +func usage() { + fmt.Println("Usage: pullpreview [up|down|list|github-sync] [options]") +} + +func runUp(ctx context.Context, args []string, logger *pullpreview.Logger) { + fs := flag.NewFlagSet("up", flag.ExitOnError) + verbose := fs.Bool("verbose", false, "Enable verbose mode") + name := fs.String("name", "", "Unique name for the environment (optional for local use)") + commonFlags := registerCommonFlags(fs) + leadingPath, parseArgs := splitLeadingPositional(args) + fs.Parse(parseArgs) + if *verbose { + logger.SetLevel(pullpreview.LevelDebug) + } + appPath := strings.TrimSpace(leadingPath) + if appPath == "" && fs.NArg() > 0 { + appPath = fs.Arg(0) + } + if appPath == "" { + fmt.Println("Usage: pullpreview up path/to/app [--name ]") + os.Exit(1) + } + if strings.TrimSpace(*name) == "" { + *name = defaultUpName(appPath) + } + provider := mustProvider(ctx, logger) + _, err := pullpreview.RunUp(pullpreview.UpOptions{AppPath: appPath, Name: *name, Common: commonFlags.ToOptions(ctx)}, provider, logger) + if err != nil { + fmt.Println("Error:", err) + os.Exit(1) + } +} + +func defaultUpName(appPath string) string { + base := appPath + if parsed, err := url.Parse(appPath); err == nil && parsed.Scheme != "" { + base = parsed.Path + } + base = filepath.Base(strings.TrimSpace(base)) + if base == "" || base == "." || base == string(filepath.Separator) { + base = "app" + } + return pullpreview.NormalizeName("local-" + base) +} + +func runDown(ctx context.Context, args []string, logger *pullpreview.Logger) { + fs := flag.NewFlagSet("down", flag.ExitOnError) + verbose := fs.Bool("verbose", false, "Enable verbose mode") + name := fs.String("name", "", "Name of the environment to destroy") + fs.Parse(args) + if *verbose { + logger.SetLevel(pullpreview.LevelDebug) + } + if *name == "" { + fmt.Println("Usage: pullpreview down --name ") + os.Exit(1) + } + provider := mustProvider(ctx, logger) + if err := pullpreview.RunDown(pullpreview.DownOptions{Name: *name}, provider, logger); err != nil { + fmt.Println("Error:", err) + os.Exit(1) + } +} + +func runGithubSync(ctx context.Context, args []string, logger *pullpreview.Logger) { + fs := flag.NewFlagSet("github-sync", flag.ExitOnError) + verbose := fs.Bool("verbose", false, "Enable verbose mode") + label := fs.String("label", "pullpreview", "Label to use for triggering preview deployments") + deploymentVariant := fs.String("deployment-variant", "", "Deployment variant (4 chars max)") + alwaysOn := fs.String("always-on", "", "List of branches to always deploy") + ttl := fs.String("ttl", "infinite", "Maximum time to live for deployments (e.g. 10h, 5d, infinite)") + commonFlags := registerCommonFlags(fs) + leadingPath, parseArgs := splitLeadingPositional(args) + fs.Parse(parseArgs) + if *verbose { + logger.SetLevel(pullpreview.LevelDebug) + } + appPath := strings.TrimSpace(leadingPath) + if appPath == "" && fs.NArg() > 0 { + appPath = fs.Arg(0) + } + if appPath == "" { + fmt.Println("Usage: pullpreview github-sync path/to/app [options]") + os.Exit(1) + } + provider := mustProvider(ctx, logger) + opts := pullpreview.GithubSyncOptions{ + AppPath: appPath, + Label: *label, + AlwaysOn: splitCommaList(*alwaysOn), + DeploymentVariant: *deploymentVariant, + TTL: *ttl, + Context: ctx, + Common: commonFlags.ToOptions(ctx), + } + if err := pullpreview.RunGithubSync(opts, provider, logger); err != nil { + fmt.Println("Error:", err) + os.Exit(1) + } +} + +func runList(ctx context.Context, args []string, logger *pullpreview.Logger) { + fs := flag.NewFlagSet("list", flag.ExitOnError) + verbose := fs.Bool("verbose", false, "Enable verbose mode") + org := fs.String("org", "", "Restrict to given organization name") + repo := fs.String("repo", "", "Restrict to given repository name") + leadingTarget, parseArgs := splitLeadingPositional(args) + fs.Parse(parseArgs) + if *verbose { + logger.SetLevel(pullpreview.LevelDebug) + } + target := strings.TrimSpace(leadingTarget) + if target == "" && fs.NArg() > 0 { + target = fs.Arg(0) + } + if target != "" { + parts := strings.SplitN(target, "/", 2) + if len(parts) > 0 { + *org = parts[0] + } + if len(parts) == 2 { + *repo = parts[1] + } + } + provider := mustProvider(ctx, logger) + if err := pullpreview.RunList(pullpreview.ListOptions{Org: *org, Repo: *repo}, provider, logger); err != nil { + fmt.Println("Error:", err) + os.Exit(1) + } +} + +type commonFlagValues struct { + admins string + cidrs string + registries string + ports string + composeFiles string + composeOptions string + tags multiValue + options pullpreview.CommonOptions +} + +func registerCommonFlags(fs *flag.FlagSet) *commonFlagValues { + values := &commonFlagValues{} + fs.StringVar(&values.admins, "admins", "", "Logins of GitHub users that will have their SSH key installed on the instance") + fs.StringVar(&values.cidrs, "cidrs", "0.0.0.0/0", "CIDRs allowed to connect to the instance") + fs.StringVar(&values.registries, "registries", "", "URIs of docker registries to authenticate against") + fs.StringVar(&values.options.ProxyTLS, "proxy-tls", "", "Enable automatic HTTPS proxying with Let's Encrypt (format: service:port, e.g. web:80)") + fs.StringVar(&values.options.DNS, "dns", "my.preview.run", "DNS suffix to use") + fs.StringVar(&values.ports, "ports", "80/tcp,443/tcp", "Ports to open for external access") + fs.StringVar(&values.options.InstanceType, "instance-type", "small", "Instance type to use") + fs.StringVar(&values.options.DefaultPort, "default-port", "80", "Default port for URL") + fs.Var(&values.tags, "tags", "Tags to add to the instance (key:value), comma-separated") + fs.StringVar(&values.composeFiles, "compose-files", "docker-compose.yml", "Compose files to use") + fs.StringVar(&values.composeOptions, "compose-options", "--build", "Additional options to pass to docker-compose up") + fs.StringVar(&values.options.PreScript, "pre-script", "", "Path to a bash script to run on the instance before docker compose") + return values +} + +func (c *commonFlagValues) ToOptions(ctx context.Context) pullpreview.CommonOptions { + opts := c.options + opts.Context = ctx + opts.Admins = splitCommaList(c.admins) + opts.CIDRs = splitCommaList(c.cidrs) + opts.Registries = splitCommaList(c.registries) + opts.Ports = splitCommaList(c.ports) + opts.ComposeFiles = splitCommaList(c.composeFiles) + opts.ComposeOptions = splitCommaList(c.composeOptions) + opts.Tags = parseTags(c.tags) + return opts +} + +type multiValue []string + +func (m *multiValue) String() string { + return strings.Join(*m, ",") +} + +func (m *multiValue) Set(value string) error { + *m = append(*m, value) + return nil +} + +func splitCommaList(value string) []string { + if value == "" { + return nil + } + parts := strings.Split(value, ",") + result := []string{} + for _, part := range parts { + part = strings.TrimSpace(part) + if part != "" { + result = append(result, part) + } + } + return result +} + +func parseTags(values []string) map[string]string { + result := map[string]string{} + for _, raw := range values { + for _, part := range splitCommaList(raw) { + pair := strings.SplitN(part, ":", 2) + if len(pair) == 2 { + result[strings.TrimSpace(pair[0])] = strings.TrimSpace(pair[1]) + } + } + } + return result +} + +func splitLeadingPositional(args []string) (string, []string) { + if len(args) == 0 { + return "", args + } + first := strings.TrimSpace(args[0]) + if first == "" || strings.HasPrefix(first, "-") { + return "", args + } + return first, args[1:] +} + +func mustProvider(ctx context.Context, logger *pullpreview.Logger) pullpreview.Provider { + providerName := strings.TrimSpace(os.Getenv("PULLPREVIEW_PROVIDER")) + if providerName == "" || providerName == "lightsail" { + provider, err := lightsail.New(ctx, os.Getenv("AWS_REGION"), logger) + if err != nil { + fmt.Println("Error:", err) + os.Exit(1) + } + return provider + } + fmt.Printf("Unsupported provider: %s\n", providerName) + os.Exit(1) + return nil +} diff --git a/cmd/pullpreview/main_test.go b/cmd/pullpreview/main_test.go new file mode 100644 index 0000000..c4ad354 --- /dev/null +++ b/cmd/pullpreview/main_test.go @@ -0,0 +1,53 @@ +package main + +import "testing" + +func TestDefaultUpNameFromLocalPath(t *testing.T) { + got := defaultUpName("path/to/example-app") + want := "local-example-app" + if got != want { + t.Fatalf("defaultUpName()=%q, want %q", got, want) + } +} + +func TestDefaultUpNameFromURL(t *testing.T) { + got := defaultUpName("https://github.com/pullpreview/action.git#main") + want := "local-action-git" + if got != want { + t.Fatalf("defaultUpName()=%q, want %q", got, want) + } +} + +func TestSplitCommaList(t *testing.T) { + got := splitCommaList("a, b,,c") + if len(got) != 3 || got[0] != "a" || got[1] != "b" || got[2] != "c" { + t.Fatalf("unexpected split result: %#v", got) + } +} + +func TestParseTags(t *testing.T) { + got := parseTags([]string{"repo:action,org:pullpreview", "repo:override"}) + if got["repo"] != "override" || got["org"] != "pullpreview" { + t.Fatalf("unexpected tags: %#v", got) + } +} + +func TestSplitLeadingPositional(t *testing.T) { + first, rest := splitLeadingPositional([]string{"examples/wordpress", "--registries", "docker://token@ghcr.io"}) + if first != "examples/wordpress" { + t.Fatalf("unexpected first positional: %q", first) + } + if len(rest) != 2 || rest[0] != "--registries" { + t.Fatalf("unexpected remaining args: %#v", rest) + } +} + +func TestSplitLeadingPositionalWhenFlagsFirst(t *testing.T) { + first, rest := splitLeadingPositional([]string{"--registries", "docker://token@ghcr.io", "examples/wordpress"}) + if first != "" { + t.Fatalf("expected no leading positional when flags are first, got %q", first) + } + if len(rest) != 3 { + t.Fatalf("unexpected remaining args: %#v", rest) + } +} diff --git a/data/update_script.sh.erb b/data/update_script.sh.erb deleted file mode 100644 index df208b6..0000000 --- a/data/update_script.sh.erb +++ /dev/null @@ -1,105 +0,0 @@ -#!/bin/bash - -set -e -set -o pipefail - -APP_TARBALL="$1" -APP_PATH="<%= locals.remote_app_path %>" -PULLPREVIEW_ENV_FILE="/etc/pullpreview/env" - -lock_file="/tmp/update.lock" - -lock_cleanup() { - rm -f "$lock_file" -} - -app_cleanup() { - rm -f "$APP_TARBALL" -} - -cleanup() { - lock_cleanup - app_cleanup -} - -trap cleanup EXIT INT TERM - -if [ -f "$lock_file" ]; then - echo "Previous operation in progress. Waiting for at most 20min before proceeding..." - - for i in {1..240}; do - sleep 5 - if [ ! -f "$lock_file" ]; then - break - fi - echo "Waiting..." - done -fi - -if [ -f "$lock_file" ]; then - echo "Previous operation considered in error. Forcing new operation to start..." - lock_cleanup -fi - -exec 100>$lock_file || exit 1 -echo -n "Acquiring lock before proceeding... " -flock -n 100 || exit 1 -echo "OK" - -sudo chown -R ec2-user.ec2-user "$(dirname $PULLPREVIEW_ENV_FILE)" - -if [ -f "$PULLPREVIEW_ENV_FILE" ]; then - PULLPREVIEW_FIRST_RUN=false - rm -f "$PULLPREVIEW_ENV_FILE" -else - PULLPREVIEW_FIRST_RUN=true -fi - -echo 'PULLPREVIEW_PUBLIC_DNS=<%= locals.public_dns %>' >> $PULLPREVIEW_ENV_FILE -echo 'PULLPREVIEW_PUBLIC_IP=<%= locals.public_ip %>' >> $PULLPREVIEW_ENV_FILE -echo 'PULLPREVIEW_URL=<%= locals.url %>' >> $PULLPREVIEW_ENV_FILE -echo "PULLPREVIEW_FIRST_RUN=$PULLPREVIEW_FIRST_RUN" >> $PULLPREVIEW_ENV_FILE -echo "COMPOSE_FILE=<%= locals.compose_files.join(":") %>" >> $PULLPREVIEW_ENV_FILE - -set -o allexport -source $PULLPREVIEW_ENV_FILE -set +o allexport - -cd / - -sudo rm -rf "$APP_PATH" -sudo mkdir -p "$APP_PATH" -sudo chown -R ec2-user.ec2-user "$APP_PATH" -tar xzf "$1" -C "$APP_PATH" - -cd "$APP_PATH" - -echo "Cleaning up..." -docker volume prune -f || true - -echo "Updating dependencies..." -sudo yum update -y || true - -if ! /tmp/pre_script.sh ; then - echo "Failed to run the pre-script" - exit 1 -fi - - -pull() { - docker-compose pull -q -} - -# pulling images sometimes result in 'unexpected EOF', so retry at most 5 times -for i in {1..5}; do - if pull; then break ; fi -done - -docker-compose up \ - --wait \ - --remove-orphans \ - -d <%= locals.compose_options.join(" ") %> - -sleep 5 - -docker-compose logs --tail 1000 diff --git a/dist/pullpreview-linux-amd64 b/dist/pullpreview-linux-amd64 new file mode 100755 index 0000000..872a667 Binary files /dev/null and b/dist/pullpreview-linux-amd64 differ diff --git a/docs/demo-flow-screenshots/01-pr-open-with-label.png b/docs/demo-flow-screenshots/01-pr-open-with-label.png new file mode 100644 index 0000000..5890bb4 Binary files /dev/null and b/docs/demo-flow-screenshots/01-pr-open-with-label.png differ diff --git a/docs/demo-flow-screenshots/02-comment-deploying.png b/docs/demo-flow-screenshots/02-comment-deploying.png new file mode 100644 index 0000000..ac60f5a Binary files /dev/null and b/docs/demo-flow-screenshots/02-comment-deploying.png differ diff --git a/docs/demo-flow-screenshots/03-action-running.png b/docs/demo-flow-screenshots/03-action-running.png new file mode 100644 index 0000000..55a1af1 Binary files /dev/null and b/docs/demo-flow-screenshots/03-action-running.png differ diff --git a/docs/demo-flow-screenshots/04-view-deployment-button.png b/docs/demo-flow-screenshots/04-view-deployment-button.png new file mode 100644 index 0000000..b61c216 Binary files /dev/null and b/docs/demo-flow-screenshots/04-view-deployment-button.png differ diff --git a/docs/demo-flow-screenshots/05-unlabelled-pr.png b/docs/demo-flow-screenshots/05-unlabelled-pr.png new file mode 100644 index 0000000..da30add Binary files /dev/null and b/docs/demo-flow-screenshots/05-unlabelled-pr.png differ diff --git a/docs/demo-flow-screenshots/06-comment-destroyed.png b/docs/demo-flow-screenshots/06-comment-destroyed.png new file mode 100644 index 0000000..3be3827 Binary files /dev/null and b/docs/demo-flow-screenshots/06-comment-destroyed.png differ diff --git a/docs/demo-flow-screenshots/README.md b/docs/demo-flow-screenshots/README.md new file mode 100644 index 0000000..c318a10 --- /dev/null +++ b/docs/demo-flow-screenshots/README.md @@ -0,0 +1,23 @@ +# PullPreview Demo Flow Capture + +This folder contains screenshots captured against PR [#114](https://github.com/pullpreview/action/pull/114) on branch `codex/go-context`. + +1. `01-pr-open-with-label.png` — PR opened with `pullpreview` label applied. +2. `02-comment-deploying.png` — PullPreview PR comment while deployment is in progress. +3. `03-action-running.png` — GitHub Actions workflow run in progress. +4. `04-view-deployment-button.png` — PR UI showing the **View deployment** button after success. +5. `05-unlabelled-pr.png` — PR after removing the `pullpreview` label. +6. `06-comment-destroyed.png` — PullPreview PR comment after environment destruction. + +## Quality note + +Some earlier captures missed proper PR timeline positioning for comment screenshots. +For future captures, always scroll the PR page before taking comment screenshots so the PullPreview comment card is fully visible in viewport. + +Use the skill runbook: + +- `skills/pullpreview-demo-flow/SKILL.md` + +The demo PR title must always be: + +- `Auto-deploy app with PullPreview` diff --git a/examples/example-app/docker-compose.yml b/examples/example-app/docker-compose.yml new file mode 100644 index 0000000..e7ac0b7 --- /dev/null +++ b/examples/example-app/docker-compose.yml @@ -0,0 +1,6 @@ +version: "3.8" +services: + web: + image: nginx:1.27-alpine + ports: + - "80:80" diff --git a/examples/workflow-smoke/docker-compose.yml b/examples/workflow-smoke/docker-compose.yml new file mode 100644 index 0000000..d949608 --- /dev/null +++ b/examples/workflow-smoke/docker-compose.yml @@ -0,0 +1,34 @@ +version: "3.8" + +services: + db: + image: ghcr.io/pullpreview/mysql:5.7 + environment: + MYSQL_ROOT_PASSWORD: root_password + MYSQL_DATABASE: app + MYSQL_USER: app + MYSQL_PASSWORD: app_password + volumes: + - db_data:/var/lib/mysql + - ./dumps:/docker-entrypoint-initdb.d:ro + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-proot_password"] + interval: 5s + timeout: 5s + retries: 20 + web: + build: ./web + environment: + DB_HOST: db + DB_PORT: "3306" + DB_USER: app + DB_PASSWORD: app_password + DB_NAME: app + depends_on: + db: + condition: service_healthy + ports: + - "80:8080" + +volumes: + db_data: diff --git a/examples/workflow-smoke/dumps/001_seed.sql b/examples/workflow-smoke/dumps/001_seed.sql new file mode 100644 index 0000000..c24b4b2 --- /dev/null +++ b/examples/workflow-smoke/dumps/001_seed.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS seed_data ( + id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + label VARCHAR(255) NOT NULL +); + +INSERT INTO seed_data (label) VALUES ('persisted'); diff --git a/examples/workflow-smoke/web/Dockerfile b/examples/workflow-smoke/web/Dockerfile new file mode 100644 index 0000000..9f6c4b6 --- /dev/null +++ b/examples/workflow-smoke/web/Dockerfile @@ -0,0 +1,13 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app.py . +COPY message.txt . + +ENV PORT=8080 + +CMD ["python", "app.py"] diff --git a/examples/workflow-smoke/web/app.py b/examples/workflow-smoke/web/app.py new file mode 100644 index 0000000..50b5f5e --- /dev/null +++ b/examples/workflow-smoke/web/app.py @@ -0,0 +1,71 @@ +import os +import time + +import pymysql +from flask import Flask + +app = Flask(__name__) + + +def read_message() -> str: + message_path = os.getenv("MESSAGE_PATH", "/app/message.txt") + with open(message_path, "r", encoding="utf-8") as fh: + return fh.read().strip() + + +def fetch_seed_data() -> tuple[int, str]: + conn = pymysql.connect( + host=os.getenv("DB_HOST", "db"), + port=int(os.getenv("DB_PORT", "3306")), + user=os.getenv("DB_USER", "app"), + password=os.getenv("DB_PASSWORD", "app_password"), + database=os.getenv("DB_NAME", "app"), + connect_timeout=2, + read_timeout=2, + write_timeout=2, + autocommit=True, + ) + try: + with conn.cursor() as cursor: + cursor.execute("SELECT COUNT(*), COALESCE(MIN(label), '') FROM seed_data") + result = cursor.fetchone() + if result is None: + return 0, "" + count, label = result + return int(count), str(label) + finally: + conn.close() + + +@app.get("/") +def index(): + message = read_message() + last_error = "" + for _ in range(10): + try: + seed_count, seed_label = fetch_seed_data() + body = "\n".join( + [ + message, + f"seed_count={seed_count}", + f"seed_label={seed_label}", + "", + ] + ) + return body, 200, {"Content-Type": "text/plain; charset=utf-8"} + except Exception as exc: # noqa: BLE001 + last_error = str(exc) + time.sleep(1) + body = "\n".join( + [ + message, + f"db_error={last_error}", + "", + ] + ) + return body, 500, {"Content-Type": "text/plain; charset=utf-8"} + + +if __name__ == "__main__": + port = int(os.getenv("PORT", "8080")) + app.run(host="0.0.0.0", port=port) diff --git a/examples/workflow-smoke/web/message.txt b/examples/workflow-smoke/web/message.txt new file mode 100644 index 0000000..4baf753 --- /dev/null +++ b/examples/workflow-smoke/web/message.txt @@ -0,0 +1 @@ +Hello World Deploy 1 diff --git a/examples/workflow-smoke/web/requirements.txt b/examples/workflow-smoke/web/requirements.txt new file mode 100644 index 0000000..f5405ff --- /dev/null +++ b/examples/workflow-smoke/web/requirements.txt @@ -0,0 +1,2 @@ +Flask==3.0.3 +PyMySQL==1.1.1 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4bb3ba3 --- /dev/null +++ b/go.mod @@ -0,0 +1,28 @@ +module github.com/pullpreview/action + +go 1.25.1 + +require ( + github.com/aws/aws-sdk-go-v2 v1.41.1 + github.com/aws/aws-sdk-go-v2/config v1.32.7 + github.com/aws/aws-sdk-go-v2/service/lightsail v1.50.11 + github.com/google/go-github/v60 v60.0.0 + golang.org/x/oauth2 v0.34.0 +) + +require ( + github.com/aws/aws-sdk-go-v2/credentials v1.19.7 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // indirect + github.com/aws/smithy-go v1.24.0 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/google/go-querystring v1.2.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..7f389b8 --- /dev/null +++ b/go.sum @@ -0,0 +1,39 @@ +github.com/aws/aws-sdk-go-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6cerhU= +github.com/aws/aws-sdk-go-v2 v1.41.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0= +github.com/aws/aws-sdk-go-v2/config v1.32.7 h1:vxUyWGUwmkQ2g19n7JY/9YL8MfAIl7bTesIUykECXmY= +github.com/aws/aws-sdk-go-v2/config v1.32.7/go.mod h1:2/Qm5vKUU/r7Y+zUk/Ptt2MDAEKAfUtKc1+3U1Mo3oY= +github.com/aws/aws-sdk-go-v2/credentials v1.19.7 h1:tHK47VqqtJxOymRrNtUXN5SP/zUTvZKeLx4tH6PGQc8= +github.com/aws/aws-sdk-go-v2/credentials v1.19.7/go.mod h1:qOZk8sPDrxhf+4Wf4oT2urYJrYt3RejHSzgAquYeppw= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 h1:I0GyV8wiYrP8XpA70g1HBcQO1JlQxCMTW9npl5UbDHY= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17/go.mod h1:tyw7BOl5bBe/oqvoIeECFJjMdzXoa/dfVz3QQ5lgHGA= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 h1:xOLELNKGp2vsiteLsvLPwxC+mYmO6OZ8PYgiuPJzF8U= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17/go.mod h1:5M5CI3D12dNOtH3/mk6minaRwI2/37ifCURZISxA/IQ= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 h1:WWLqlh79iO48yLkj1v3ISRNiv+3KdQoZ6JWyfcsyQik= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17/go.mod h1:EhG22vHRrvF8oXSTYStZhJc1aUgKtnJe+aOiFEV90cM= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 h1:RuNSMoozM8oXlgLG/n6WLaFGoea7/CddrCfIiSA+xdY= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17/go.mod h1:F2xxQ9TZz5gDWsclCtPQscGpP0VUOc8RqgFM3vDENmU= +github.com/aws/aws-sdk-go-v2/service/lightsail v1.50.11 h1:VM5e5M39zRSs+aT0O9SoxHjUXqXxhbw3Yi0FdMQWPIc= +github.com/aws/aws-sdk-go-v2/service/lightsail v1.50.11/go.mod h1:0jvzYPIQGCpnY/dmdaotTk2JH4QuBlnW0oeyrcGLWJ4= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 h1:VrhDvQib/i0lxvr3zqlUwLwJP4fpmpyD9wYG1vfSu+Y= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.5/go.mod h1:k029+U8SY30/3/ras4G/Fnv/b88N4mAfliNn08Dem4M= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 h1:v6EiMvhEYBoHABfbGB4alOYmCIrcgyPPiBE1wZAEbqk= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.9/go.mod h1:yifAsgBxgJWn3ggx70A3urX2AN49Y5sJTD1UQFlfqBw= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13 h1:gd84Omyu9JLriJVCbGApcLzVR3XtmC4ZDPcAI6Ftvds= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13/go.mod h1:sTGThjphYE4Ohw8vJiRStAcu3rbjtXRsdNB0TvZ5wwo= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 h1:5fFjR/ToSOzB2OQ/XqWpZBmNvmP/pJ1jOWYlFDJTjRQ= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.6/go.mod h1:qgFDZQSD/Kys7nJnVqYlWKnh0SSdMjAi0uSwON4wgYQ= +github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk= +github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-github/v60 v60.0.0 h1:oLG98PsLauFvvu4D/YPxq374jhSxFYdzQGNCyONLfn8= +github.com/google/go-github/v60 v60.0.0/go.mod h1:ByhX2dP9XT9o/ll2yXAu2VD8l5eNVg8hD4Cr0S/LmQk= +github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= +github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= +golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= +golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= diff --git a/internal/github/client.go b/internal/github/client.go new file mode 100644 index 0000000..856bd2f --- /dev/null +++ b/internal/github/client.go @@ -0,0 +1,127 @@ +package github + +import ( + "context" + "strings" + + gh "github.com/google/go-github/v60/github" + "golang.org/x/oauth2" +) + +type Client struct { + api *gh.Client + ctx context.Context +} + +func New(token string) *Client { + return NewWithContext(context.Background(), token) +} + +func NewWithContext(ctx context.Context, token string) *Client { + if ctx == nil { + ctx = context.Background() + } + if token == "" { + return &Client{api: gh.NewClient(nil), ctx: ctx} + } + ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token}) + client := oauth2.NewClient(ctx, ts) + return &Client{api: gh.NewClient(client), ctx: ctx} +} + +func splitRepo(repo string) (string, string) { + parts := strings.SplitN(repo, "/", 2) + if len(parts) == 2 { + return parts[0], parts[1] + } + return repo, "" +} + +func (c *Client) ListIssues(repo, label string) ([]*gh.Issue, error) { + owner, name := splitRepo(repo) + opts := &gh.IssueListByRepoOptions{State: "all", Labels: []string{label}, ListOptions: gh.ListOptions{PerPage: 100}} + issues, _, err := c.api.Issues.ListByRepo(c.ctx, owner, name, opts) + return issues, err +} + +func (c *Client) GetPullRequest(repo string, number int) (*gh.PullRequest, error) { + owner, name := splitRepo(repo) + pr, _, err := c.api.PullRequests.Get(c.ctx, owner, name, number) + return pr, err +} + +func (c *Client) RemoveLabel(repo string, number int, label string) error { + owner, name := splitRepo(repo) + _, err := c.api.Issues.RemoveLabelForIssue(c.ctx, owner, name, number, label) + return err +} + +func (c *Client) ListIssueComments(repo string, number int) ([]*gh.IssueComment, error) { + owner, name := splitRepo(repo) + comments, _, err := c.api.Issues.ListComments(c.ctx, owner, name, number, &gh.IssueListCommentsOptions{ + ListOptions: gh.ListOptions{PerPage: 100}, + }) + return comments, err +} + +func (c *Client) CreateIssueComment(repo string, number int, body string) error { + owner, name := splitRepo(repo) + _, _, err := c.api.Issues.CreateComment(c.ctx, owner, name, number, &gh.IssueComment{ + Body: gh.String(body), + }) + return err +} + +func (c *Client) UpdateIssueComment(repo string, commentID int64, body string) error { + owner, name := splitRepo(repo) + _, _, err := c.api.Issues.EditComment(c.ctx, owner, name, commentID, &gh.IssueComment{ + Body: gh.String(body), + }) + return err +} + +func (c *Client) ListPullRequests(repo, head string) ([]*gh.PullRequest, error) { + owner, name := splitRepo(repo) + opts := &gh.PullRequestListOptions{State: "open", Head: head, ListOptions: gh.ListOptions{PerPage: 100}} + prs, _, err := c.api.PullRequests.List(c.ctx, owner, name, opts) + return prs, err +} + +func (c *Client) LatestCommitSHA(repo, ref string) (string, error) { + owner, name := splitRepo(repo) + opts := &gh.CommitsListOptions{SHA: ref, ListOptions: gh.ListOptions{PerPage: 1}} + commits, _, err := c.api.Repositories.ListCommits(c.ctx, owner, name, opts) + if err != nil || len(commits) == 0 { + return "", err + } + return commits[0].GetSHA(), nil +} + +func (c *Client) ListCollaborators(repo string) ([]*gh.User, bool, error) { + owner, name := splitRepo(repo) + opts := &gh.ListCollaboratorsOptions{ + Affiliation: "all", + Permission: "push", + ListOptions: gh.ListOptions{PerPage: 100}, + } + users, resp, err := c.api.Repositories.ListCollaborators(c.ctx, owner, name, opts) + if err != nil { + return nil, false, err + } + return users, resp != nil && resp.NextPage != 0, nil +} + +func (c *Client) ListUserPublicKeys(user string) ([]string, error) { + keys, _, err := c.api.Users.ListKeys(c.ctx, user, &gh.ListOptions{PerPage: 100}) + if err != nil { + return nil, err + } + result := []string{} + for _, key := range keys { + value := strings.TrimSpace(key.GetKey()) + if value != "" { + result = append(result, value) + } + } + return result, nil +} diff --git a/internal/github/client_test.go b/internal/github/client_test.go new file mode 100644 index 0000000..6d43811 --- /dev/null +++ b/internal/github/client_test.go @@ -0,0 +1,238 @@ +package github + +import ( + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + gh "github.com/google/go-github/v60/github" +) + +func newTestClient(t *testing.T, handler http.HandlerFunc) *Client { + t.Helper() + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + + api := gh.NewClient(server.Client()) + baseURL, err := url.Parse(server.URL + "/") + if err != nil { + t.Fatalf("failed to parse base URL: %v", err) + } + api.BaseURL = baseURL + api.UploadURL = baseURL + + return &Client{api: api, ctx: context.Background()} +} + +func TestNew(t *testing.T) { + if New("") == nil { + t.Fatalf("New(\"\") returned nil") + } + if New("token") == nil { + t.Fatalf("New(token) returned nil") + } +} + +func TestSplitRepo(t *testing.T) { + owner, repo := splitRepo("org/name") + if owner != "org" || repo != "name" { + t.Fatalf("splitRepo(org/name)=(%q,%q)", owner, repo) + } + owner, repo = splitRepo("single") + if owner != "single" || repo != "" { + t.Fatalf("splitRepo(single)=(%q,%q)", owner, repo) + } +} + +func TestListIssues(t *testing.T) { + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Fatalf("unexpected method: %s", r.Method) + } + if r.URL.Path != "/repos/org/repo/issues" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if got := r.URL.Query().Get("labels"); got != "pullpreview" { + t.Fatalf("unexpected labels query: %q", got) + } + _, _ = w.Write([]byte(`[{"number":10}]`)) + }) + + issues, err := client.ListIssues("org/repo", "pullpreview") + if err != nil { + t.Fatalf("ListIssues() error: %v", err) + } + if len(issues) != 1 || issues[0].GetNumber() != 10 { + t.Fatalf("unexpected issues response: %#v", issues) + } +} + +func TestGetPullRequest(t *testing.T) { + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/repos/org/repo/pulls/4" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + _, _ = w.Write([]byte(`{"number":4}`)) + }) + pr, err := client.GetPullRequest("org/repo", 4) + if err != nil { + t.Fatalf("GetPullRequest() error: %v", err) + } + if pr.GetNumber() != 4 { + t.Fatalf("unexpected PR number: %d", pr.GetNumber()) + } +} + +func TestDeleteOperations(t *testing.T) { + calls := map[string]bool{} + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + key := r.Method + " " + r.URL.Path + calls[key] = true + w.WriteHeader(http.StatusNoContent) + }) + + if err := client.RemoveLabel("org/repo", 10, "pullpreview"); err != nil { + t.Fatalf("RemoveLabel() error: %v", err) + } + + expected := []string{ + "DELETE /repos/org/repo/issues/10/labels/pullpreview", + } + for _, key := range expected { + if !calls[key] { + t.Fatalf("expected call %q missing; calls=%v", key, calls) + } + } +} + +func TestIssueCommentOperations(t *testing.T) { + calls := map[string]string{} + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/repos/org/repo/issues/10/comments": + _, _ = w.Write([]byte(`[{"id":99,"body":"old"}]`)) + case r.Method == http.MethodPost && r.URL.Path == "/repos/org/repo/issues/10/comments": + body, _ := io.ReadAll(r.Body) + calls["create"] = string(body) + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"id":100}`)) + case r.Method == http.MethodPatch && r.URL.Path == "/repos/org/repo/issues/comments/99": + body, _ := io.ReadAll(r.Body) + calls["update"] = string(body) + _, _ = w.Write([]byte(`{"id":99}`)) + default: + t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) + } + }) + + comments, err := client.ListIssueComments("org/repo", 10) + if err != nil { + t.Fatalf("ListIssueComments() error: %v", err) + } + if len(comments) != 1 || comments[0].GetID() != 99 { + t.Fatalf("unexpected issue comments: %#v", comments) + } + + if err := client.CreateIssueComment("org/repo", 10, "hello"); err != nil { + t.Fatalf("CreateIssueComment() error: %v", err) + } + if !strings.Contains(calls["create"], `"body":"hello"`) { + t.Fatalf("unexpected create comment payload: %s", calls["create"]) + } + + if err := client.UpdateIssueComment("org/repo", 99, "updated"); err != nil { + t.Fatalf("UpdateIssueComment() error: %v", err) + } + if !strings.Contains(calls["update"], `"body":"updated"`) { + t.Fatalf("unexpected update comment payload: %s", calls["update"]) + } +} + +func TestPullRequestsCommitsAndCollaborators(t *testing.T) { + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/repos/org/repo/pulls": + if got := r.URL.Query().Get("head"); got != "org:refs/heads/main" { + t.Fatalf("unexpected head query: %q", got) + } + _, _ = w.Write([]byte(`[{"number":77}]`)) + case r.Method == http.MethodGet && r.URL.Path == "/repos/org/repo/commits": + if got := r.URL.Query().Get("sha"); got != "refs/heads/main" { + t.Fatalf("unexpected sha query: %q", got) + } + _, _ = w.Write([]byte(`[{"sha":"abc123"}]`)) + case r.Method == http.MethodGet && r.URL.Path == "/repos/org/repo/collaborators": + if got := r.URL.Query().Get("affiliation"); got != "all" { + t.Fatalf("unexpected affiliation query: %q", got) + } + if got := r.URL.Query().Get("permission"); got != "push" { + t.Fatalf("unexpected permission query: %q", got) + } + if got := r.URL.Query().Get("per_page"); got != "100" { + t.Fatalf("unexpected per_page query: %q", got) + } + w.Header().Set("Link", fmt.Sprintf("; rel=\"next\"", r.Host)) + _, _ = w.Write([]byte(`[{"login":"alice"},{"login":"bob"}]`)) + case r.Method == http.MethodGet && r.URL.Path == "/users/alice/keys": + if got := r.URL.Query().Get("per_page"); got != "100" { + t.Fatalf("unexpected per_page query for user keys: %q", got) + } + _, _ = w.Write([]byte(`[{"key":"ssh-ed25519 AAAA alice@dev"},{"key":"ssh-rsa BBBB alice@dev"}]`)) + default: + t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) + } + }) + + prs, err := client.ListPullRequests("org/repo", "org:refs/heads/main") + if err != nil { + t.Fatalf("ListPullRequests() error: %v", err) + } + if len(prs) != 1 || prs[0].GetNumber() != 77 { + t.Fatalf("unexpected PR list: %#v", prs) + } + + sha, err := client.LatestCommitSHA("org/repo", "refs/heads/main") + if err != nil { + t.Fatalf("LatestCommitSHA() error: %v", err) + } + if sha != "abc123" { + t.Fatalf("unexpected sha: %q", sha) + } + + users, truncated, err := client.ListCollaborators("org/repo") + if err != nil { + t.Fatalf("ListCollaborators() error: %v", err) + } + if len(users) != 2 || users[0].GetLogin() != "alice" || users[1].GetLogin() != "bob" { + t.Fatalf("unexpected collaborators: %#v", users) + } + if !truncated { + t.Fatalf("expected collaborators list to be marked as truncated") + } + + keys, err := client.ListUserPublicKeys("alice") + if err != nil { + t.Fatalf("ListUserPublicKeys() error: %v", err) + } + if len(keys) != 2 || keys[0] != "ssh-ed25519 AAAA alice@dev" || keys[1] != "ssh-rsa BBBB alice@dev" { + t.Fatalf("unexpected user keys: %#v", keys) + } +} + +func TestLatestCommitSHAEmptyList(t *testing.T) { + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`[]`)) + }) + sha, err := client.LatestCommitSHA("org/repo", "refs/heads/main") + if err != nil { + t.Fatalf("LatestCommitSHA() error: %v", err) + } + if sha != "" { + t.Fatalf("expected empty sha, got %q", sha) + } +} diff --git a/internal/license/client.go b/internal/license/client.go new file mode 100644 index 0000000..50db42d --- /dev/null +++ b/internal/license/client.go @@ -0,0 +1,55 @@ +package license + +import ( + "fmt" + "io" + "net/http" + "net/url" + "time" +) + +type Result struct { + State string + Message string +} + +func (r Result) OK() bool { + return r.State == "ok" +} + +type Client struct { + BaseURL string +} + +func New() *Client { + return &Client{BaseURL: "https://app.pullpreview.com"} +} + +func (c *Client) Check(params map[string]string) (Result, error) { + endpoint := c.BaseURL + "/licenses/check" + u, err := url.Parse(endpoint) + if err != nil { + return Result{State: "ok", Message: "License server unreachable. Continuing..."}, nil + } + query := url.Values{} + for k, v := range params { + query.Set(k, v) + } + u.RawQuery = query.Encode() + + req, err := http.NewRequest("GET", u.String(), nil) + if err != nil { + return Result{State: "ok", Message: "License server unreachable. Continuing..."}, nil + } + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return Result{State: "ok", Message: fmt.Sprintf("License server unreachable. Continuing...")}, nil + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode == http.StatusOK { + return Result{State: "ok", Message: string(body)}, nil + } + return Result{State: "ko", Message: string(body)}, nil +} diff --git a/internal/license/client_test.go b/internal/license/client_test.go new file mode 100644 index 0000000..1dd0716 --- /dev/null +++ b/internal/license/client_test.go @@ -0,0 +1,91 @@ +package license + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestResultOK(t *testing.T) { + if !(Result{State: "ok"}.OK()) { + t.Fatalf("expected ok state to be true") + } + if (Result{State: "ko"}).OK() { + t.Fatalf("expected ko state to be false") + } +} + +func TestCheckSuccess(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/licenses/check" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if got := r.URL.Query().Get("repo_id"); got != "2" { + t.Fatalf("unexpected query repo_id=%q", got) + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok from server")) + })) + defer server.Close() + + client := &Client{BaseURL: server.URL} + result, err := client.Check(map[string]string{"org_id": "1", "repo_id": "2"}) + if err != nil { + t.Fatalf("Check() returned error: %v", err) + } + if !result.OK() { + t.Fatalf("expected ok result, got %#v", result) + } + if result.Message != "ok from server" { + t.Fatalf("unexpected message: %q", result.Message) + } +} + +func TestCheckNon200(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte("license denied")) + })) + defer server.Close() + + client := &Client{BaseURL: server.URL} + result, err := client.Check(map[string]string{"org_id": "1"}) + if err != nil { + t.Fatalf("Check() returned error: %v", err) + } + if result.OK() { + t.Fatalf("expected ko result, got %#v", result) + } + if result.Message != "license denied" { + t.Fatalf("unexpected message: %q", result.Message) + } +} + +func TestCheckInvalidBaseURLGracefulFallback(t *testing.T) { + client := &Client{BaseURL: "://bad-url"} + result, err := client.Check(map[string]string{"org_id": "1"}) + if err != nil { + t.Fatalf("Check() returned error: %v", err) + } + if !result.OK() { + t.Fatalf("expected ok fallback, got %#v", result) + } + if !strings.Contains(result.Message, "License server unreachable") { + t.Fatalf("unexpected message: %q", result.Message) + } +} + +func TestCheckUnreachableGracefulFallback(t *testing.T) { + client := &Client{BaseURL: "http://127.0.0.1:1"} + result, err := client.Check(map[string]string{"org_id": "1"}) + if err != nil { + t.Fatalf("Check() returned error: %v", err) + } + if !result.OK() { + t.Fatalf("expected ok fallback, got %#v", result) + } + if !strings.Contains(result.Message, "License server unreachable") { + t.Fatalf("unexpected message: %q", result.Message) + } +} diff --git a/internal/providers/lightsail/lightsail.go b/internal/providers/lightsail/lightsail.go new file mode 100644 index 0000000..40c11f4 --- /dev/null +++ b/internal/providers/lightsail/lightsail.go @@ -0,0 +1,398 @@ +package lightsail + +import ( + "context" + "errors" + "sort" + "strings" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/config" + ls "github.com/aws/aws-sdk-go-v2/service/lightsail" + "github.com/aws/aws-sdk-go-v2/service/lightsail/types" + "github.com/pullpreview/action/internal/pullpreview" +) + +var sizeMap = map[string]string{ + "XXS": "nano", + "XS": "micro", + "S": "small", + "M": "medium", + "L": "large", + "XL": "xlarge", + "2XL": "2xlarge", +} + +type Provider struct { + client *ls.Client + ctx context.Context + region string + logger *pullpreview.Logger +} + +func New(ctx context.Context, region string, logger *pullpreview.Logger) (*Provider, error) { + ctx = pullpreview.EnsureContext(ctx) + if region == "" { + region = "us-east-1" + } + cfg, err := config.LoadDefaultConfig(ctx, config.WithRegion(region)) + if err != nil { + return nil, err + } + return &Provider{ + client: ls.NewFromConfig(cfg), + ctx: ctx, + region: region, + logger: logger, + }, nil +} + +func (p *Provider) Running(name string) (bool, error) { + resp, err := p.client.GetInstanceState(p.ctx, &ls.GetInstanceStateInput{InstanceName: aws.String(name)}) + if err != nil { + var nf *types.NotFoundException + if errors.As(err, &nf) { + return false, nil + } + return false, err + } + return resp.State != nil && aws.ToString(resp.State.Name) == "running", nil +} + +func (p *Provider) Terminate(name string) error { + resp, err := p.client.DeleteInstance(p.ctx, &ls.DeleteInstanceInput{InstanceName: aws.String(name)}) + if err != nil { + return err + } + if len(resp.Operations) == 0 { + return nil + } + if resp.Operations[0].ErrorCode != nil { + return errors.New(*resp.Operations[0].ErrorCode) + } + return nil +} + +func (p *Provider) Launch(name string, opts pullpreview.LaunchOptions) (pullpreview.AccessDetails, error) { + running, err := p.Running(name) + if err != nil { + return pullpreview.AccessDetails{}, err + } + if !running { + if err := p.launchOrRestore(name, opts); err != nil { + return pullpreview.AccessDetails{}, err + } + if err := p.waitUntilRunning(name); err != nil { + return pullpreview.AccessDetails{}, err + } + } + if err := p.setupFirewall(name, opts.CIDRs, opts.Ports); err != nil { + return pullpreview.AccessDetails{}, err + } + return p.fetchAccessDetails(name) +} + +func (p *Provider) launchOrRestore(name string, opts pullpreview.LaunchOptions) error { + bundleID, err := p.bundleID(opts.Size) + if err != nil { + return err + } + zones, err := p.availabilityZones() + if err != nil || len(zones) == 0 { + return errors.New("no availability zones") + } + params := &ls.CreateInstancesInput{ + InstanceNames: []string{name}, + AvailabilityZone: aws.String(zones[0]), + BundleId: aws.String(bundleID), + Tags: toLightsailTags(mergeTags(map[string]string{"stack": pullpreview.StackName}, opts.Tags)), + UserData: aws.String(opts.UserData), + BlueprintId: aws.String(p.blueprintID()), + } + + snapshot := p.latestSnapshot(name) + if snapshot != nil { + if p.logger != nil { + p.logger.Infof("Found snapshot to restore from: %s", aws.ToString(snapshot.Name)) + } + _, err := p.client.CreateInstancesFromSnapshot(p.ctx, &ls.CreateInstancesFromSnapshotInput{ + InstanceNames: []string{name}, + AvailabilityZone: aws.String(zones[0]), + BundleId: aws.String(bundleID), + Tags: params.Tags, + UserData: aws.String(opts.UserData), + InstanceSnapshotName: snapshot.Name, + }) + return err + } + + _, err = p.client.CreateInstances(p.ctx, params) + return err +} + +func mergeTags(base, extra map[string]string) map[string]string { + result := map[string]string{} + for k, v := range base { + result[k] = v + } + for k, v := range extra { + result[k] = v + } + return result +} + +func (p *Provider) waitUntilRunning(name string) error { + ok := pullpreview.WaitUntilContext(p.ctx, 30, 5*time.Second, func() bool { + running, err := p.Running(name) + if err != nil { + return false + } + return running + }) + if !ok { + return errors.New("timeout while waiting for instance running") + } + return nil +} + +func (p *Provider) setupFirewall(name string, cidrs, ports []string) error { + portInfos := []types.PortInfo{} + for _, portDef := range ports { + portDef = strings.TrimSpace(portDef) + if portDef == "" { + continue + } + portRange := portDef + protocol := "tcp" + if strings.Contains(portDef, "/") { + parts := strings.SplitN(portDef, "/", 2) + portRange = parts[0] + if parts[1] != "" { + protocol = parts[1] + } + } + startEnd := strings.SplitN(portRange, "-", 2) + start := startEnd[0] + end := start + if len(startEnd) == 2 && startEnd[1] != "" { + end = startEnd[1] + } + useCIDRs := cidrs + if start == "22" { + useCIDRs = []string{"0.0.0.0/0"} + } + portInfos = append(portInfos, types.PortInfo{ + FromPort: int32(mustAtoi(start)), + ToPort: int32(mustAtoi(end)), + Protocol: types.NetworkProtocol(protocol), + Cidrs: useCIDRs, + }) + } + _, err := p.client.PutInstancePublicPorts(p.ctx, &ls.PutInstancePublicPortsInput{ + InstanceName: aws.String(name), + PortInfos: portInfos, + }) + return err +} + +func mustAtoi(value string) int { + value = strings.TrimSpace(value) + if value == "" { + return 0 + } + result := 0 + for _, r := range value { + if r < '0' || r > '9' { + return 0 + } + result = result*10 + int(r-'0') + } + return result +} + +func (p *Provider) fetchAccessDetails(name string) (pullpreview.AccessDetails, error) { + resp, err := p.client.GetInstanceAccessDetails(p.ctx, &ls.GetInstanceAccessDetailsInput{ + InstanceName: aws.String(name), + Protocol: types.InstanceAccessProtocolSsh, + }) + if err != nil { + return pullpreview.AccessDetails{}, err + } + if resp.AccessDetails == nil { + return pullpreview.AccessDetails{}, errors.New("missing access details") + } + return pullpreview.AccessDetails{ + Username: aws.ToString(resp.AccessDetails.Username), + IPAddress: aws.ToString(resp.AccessDetails.IpAddress), + CertKey: aws.ToString(resp.AccessDetails.CertKey), + PrivateKey: aws.ToString(resp.AccessDetails.PrivateKey), + }, nil +} + +func (p *Provider) latestSnapshot(name string) *types.InstanceSnapshot { + resp, err := p.client.GetInstanceSnapshots(p.ctx, &ls.GetInstanceSnapshotsInput{}) + if err != nil { + return nil + } + snapshots := resp.InstanceSnapshots + sort.Slice(snapshots, func(i, j int) bool { + if snapshots[i].CreatedAt == nil { + return false + } + if snapshots[j].CreatedAt == nil { + return true + } + return snapshots[i].CreatedAt.After(*snapshots[j].CreatedAt) + }) + for _, snap := range snapshots { + if snap.State == types.InstanceSnapshotStateAvailable && aws.ToString(snap.FromInstanceName) == name { + return &snap + } + } + return nil +} + +func (p *Provider) ListInstances(tags map[string]string) ([]pullpreview.InstanceSummary, error) { + result := []pullpreview.InstanceSummary{} + var token *string + for { + resp, err := p.client.GetInstances(p.ctx, &ls.GetInstancesInput{PageToken: token}) + if err != nil { + return nil, err + } + for _, inst := range resp.Instances { + if !matchTags(inst.Tags, tags) { + continue + } + region := "" + zone := "" + if inst.Location != nil { + region = string(inst.Location.RegionName) + zone = aws.ToString(inst.Location.AvailabilityZone) + } + result = append(result, pullpreview.InstanceSummary{ + Name: aws.ToString(inst.Name), + PublicIP: aws.ToString(inst.PublicIpAddress), + Size: reverseSizeMap(aws.ToString(inst.BundleId)), + Region: region, + Zone: zone, + CreatedAt: aws.ToTime(inst.CreatedAt), + Tags: tagsToMap(inst.Tags), + }) + } + if resp.NextPageToken == nil || *resp.NextPageToken == "" { + break + } + token = resp.NextPageToken + } + return result, nil +} + +func matchTags(actual []types.Tag, required map[string]string) bool { + if len(required) == 0 { + return true + } + lookup := map[string]string{} + for _, tag := range actual { + lookup[aws.ToString(tag.Key)] = aws.ToString(tag.Value) + } + for k, v := range required { + if lookup[k] != v { + return false + } + } + return true +} + +func tagsToMap(tags []types.Tag) map[string]string { + result := map[string]string{} + for _, tag := range tags { + result[aws.ToString(tag.Key)] = aws.ToString(tag.Value) + } + return result +} + +func toLightsailTags(tags map[string]string) []types.Tag { + result := make([]types.Tag, 0, len(tags)) + for k, v := range tags { + key := k + val := v + result = append(result, types.Tag{Key: &key, Value: &val}) + } + return result +} + +func (p *Provider) availabilityZones() ([]string, error) { + resp, err := p.client.GetRegions(p.ctx, &ls.GetRegionsInput{IncludeAvailabilityZones: aws.Bool(true)}) + if err != nil { + return nil, err + } + for _, region := range resp.Regions { + if string(region.Name) == p.region { + zones := []string{} + for _, az := range region.AvailabilityZones { + zones = append(zones, aws.ToString(az.ZoneName)) + } + return zones, nil + } + } + return nil, errors.New("region not found") +} + +func (p *Provider) blueprintID() string { + resp, err := p.client.GetBlueprints(p.ctx, &ls.GetBlueprintsInput{}) + if err != nil { + return "" + } + for _, bp := range resp.Blueprints { + if bp.Platform == types.InstancePlatformLinuxUnix && + aws.ToString(bp.Group) == "amazon_linux_2023" && + aws.ToBool(bp.IsActive) && + bp.Type == types.BlueprintTypeOs { + return aws.ToString(bp.BlueprintId) + } + } + return "" +} + +func (p *Provider) bundleID(size string) (string, error) { + instanceType := "" + if size != "" { + instanceType = sizeMap[size] + if instanceType == "" { + instanceType = strings.TrimSuffix(size, "_2_0") + } + } + resp, err := p.client.GetBundles(p.ctx, &ls.GetBundlesInput{}) + if err != nil { + return "", err + } + for _, bundle := range resp.Bundles { + if instanceType == "" { + if aws.ToInt32(bundle.CpuCount) >= 1 && + aws.ToFloat32(bundle.RamSizeInGb) >= 2 && + aws.ToFloat32(bundle.RamSizeInGb) <= 3 { + return aws.ToString(bundle.BundleId), nil + } + continue + } + if aws.ToString(bundle.InstanceType) == instanceType { + return aws.ToString(bundle.BundleId), nil + } + } + return "", errors.New("bundle not found") +} + +func reverseSizeMap(bundleID string) string { + for k, v := range sizeMap { + if v == bundleID { + return k + } + } + return bundleID +} + +func (p *Provider) Username() string { + return "ec2-user" +} diff --git a/internal/providers/lightsail/lightsail_test.go b/internal/providers/lightsail/lightsail_test.go new file mode 100644 index 0000000..594f115 --- /dev/null +++ b/internal/providers/lightsail/lightsail_test.go @@ -0,0 +1,74 @@ +package lightsail + +import ( + "testing" + + "github.com/aws/aws-sdk-go-v2/service/lightsail/types" +) + +func TestMergeTags(t *testing.T) { + merged := mergeTags( + map[string]string{"stack": "pullpreview", "repo": "action"}, + map[string]string{"repo": "fork", "env": "pr"}, + ) + if merged["stack"] != "pullpreview" || merged["repo"] != "fork" || merged["env"] != "pr" { + t.Fatalf("unexpected merged tags: %#v", merged) + } +} + +func TestMustAtoi(t *testing.T) { + cases := map[string]int{ + "22": 22, + " 443": 443, + "": 0, + "abc": 0, + "12x": 0, + } + for input, want := range cases { + if got := mustAtoi(input); got != want { + t.Fatalf("mustAtoi(%q)=%d, want %d", input, got, want) + } + } +} + +func TestMatchTags(t *testing.T) { + actual := []types.Tag{ + {Key: strPtr("stack"), Value: strPtr("pullpreview")}, + {Key: strPtr("repo"), Value: strPtr("action")}, + } + if !matchTags(actual, map[string]string{"stack": "pullpreview"}) { + t.Fatalf("expected required subset to match") + } + if matchTags(actual, map[string]string{"repo": "other"}) { + t.Fatalf("expected mismatched tag to fail") + } +} + +func TestTagsConversions(t *testing.T) { + input := map[string]string{"a": "1", "b": "2"} + lightTags := toLightsailTags(input) + if len(lightTags) != 2 { + t.Fatalf("unexpected lightsail tags: %#v", lightTags) + } + back := tagsToMap(lightTags) + if back["a"] != "1" || back["b"] != "2" { + t.Fatalf("unexpected converted map: %#v", back) + } +} + +func TestReverseSizeMap(t *testing.T) { + if got := reverseSizeMap("small"); got != "S" { + t.Fatalf("reverseSizeMap(small)=%q, want S", got) + } + if got := reverseSizeMap("custom"); got != "custom" { + t.Fatalf("reverseSizeMap(custom)=%q, want custom", got) + } +} + +func TestUsername(t *testing.T) { + if got := (&Provider{}).Username(); got != "ec2-user" { + t.Fatalf("Username()=%q, want ec2-user", got) + } +} + +func strPtr(v string) *string { return &v } diff --git a/internal/pullpreview/commands_test.go b/internal/pullpreview/commands_test.go new file mode 100644 index 0000000..936c953 --- /dev/null +++ b/internal/pullpreview/commands_test.go @@ -0,0 +1,68 @@ +package pullpreview + +import ( + "testing" + "time" +) + +type providerSpy struct { + terminatedName string + lastListTags map[string]string + instances []InstanceSummary +} + +func (p *providerSpy) Launch(name string, opts LaunchOptions) (AccessDetails, error) { + return AccessDetails{}, nil +} + +func (p *providerSpy) Terminate(name string) error { + p.terminatedName = name + return nil +} + +func (p *providerSpy) Running(name string) (bool, error) { + return false, nil +} + +func (p *providerSpy) ListInstances(tags map[string]string) ([]InstanceSummary, error) { + p.lastListTags = tags + return p.instances, nil +} + +func (p *providerSpy) Username() string { + return "ec2-user" +} + +func TestRunDownNormalizesInstanceName(t *testing.T) { + spy := &providerSpy{} + err := RunDown(DownOptions{Name: "My Feature Branch"}, spy, nil) + if err != nil { + t.Fatalf("RunDown() error: %v", err) + } + if spy.terminatedName != "My-Feature-Branch" { + t.Fatalf("unexpected terminated name: %q", spy.terminatedName) + } +} + +func TestRunListValidatesInput(t *testing.T) { + spy := &providerSpy{} + err := RunList(ListOptions{}, spy, nil) + if err == nil { + t.Fatalf("expected error when org/repo are empty") + } +} + +func TestRunListBuildsProviderTagFilters(t *testing.T) { + spy := &providerSpy{ + instances: []InstanceSummary{ + {Name: "gh-1-pr-2", PublicIP: "1.2.3.4", CreatedAt: time.Unix(0, 0)}, + }, + } + err := RunList(ListOptions{Org: "pullpreview", Repo: "action"}, spy, nil) + if err != nil { + t.Fatalf("RunList() error: %v", err) + } + if spy.lastListTags["stack"] != StackName || spy.lastListTags["org_name"] != "pullpreview" || spy.lastListTags["repo_name"] != "action" { + t.Fatalf("unexpected list tags: %#v", spy.lastListTags) + } +} diff --git a/internal/pullpreview/constants.go b/internal/pullpreview/constants.go new file mode 100644 index 0000000..5033e8b --- /dev/null +++ b/internal/pullpreview/constants.go @@ -0,0 +1,6 @@ +package pullpreview + +const ( + Version = "1.0.0" + StackName = "pullpreview" +) diff --git a/internal/pullpreview/deploy_context.go b/internal/pullpreview/deploy_context.go new file mode 100644 index 0000000..2a4776b --- /dev/null +++ b/internal/pullpreview/deploy_context.go @@ -0,0 +1,334 @@ +package pullpreview + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path" + "path/filepath" + "strings" + "time" +) + +const ( + remoteEnvPath = "/etc/pullpreview/env" + dockerProjectName = "app" +) + +func (i *Instance) DeployWithDockerContext(appPath, tarballPath string) error { + if err := i.syncRemoteAppFromTarball(tarballPath); err != nil { + return err + } + if err := i.writeRemoteEnvFile(); err != nil { + return err + } + if err := i.runRemotePreScript(); err != nil { + return err + } + + composeConfig, err := i.composeConfigForRemoteContext(appPath) + if err != nil { + return err + } + return i.runComposeOnRemoteContext(composeConfig) +} + +func (i *Instance) syncRemoteAppFromTarball(tarballPath string) error { + remotePath := fmt.Sprintf("/tmp/app-%d.tar.gz", time.Now().UTC().Unix()) + file, err := os.Open(tarballPath) + if err != nil { + return err + } + defer file.Close() + if err := i.SCP(file, remotePath, "0644"); err != nil { + return err + } + user := i.Username() + command := fmt.Sprintf( + "sudo rm -rf %s && sudo mkdir -p %s && sudo chown -R %s.%s %s && tar xzf %s -C %s && rm -f %s", + remoteAppPath, remoteAppPath, user, user, remoteAppPath, remotePath, remoteAppPath, remotePath, + ) + return i.SSH(command, nil) +} + +func (i *Instance) writeRemoteEnvFile() error { + firstRun := "true" + if i.SSH(fmt.Sprintf("test -f %s", remoteEnvPath), nil) == nil { + firstRun = "false" + } + + content := strings.Join([]string{ + fmt.Sprintf("PULLPREVIEW_PUBLIC_DNS=%s", i.PublicDNS()), + fmt.Sprintf("PULLPREVIEW_PUBLIC_IP=%s", i.PublicIP()), + fmt.Sprintf("PULLPREVIEW_URL=%s", i.URL()), + fmt.Sprintf("PULLPREVIEW_FIRST_RUN=%s", firstRun), + fmt.Sprintf("COMPOSE_FILE=%s", strings.Join(i.ComposeFiles, ":")), + "", + }, "\n") + if err := i.SCP(bytes.NewBufferString(content), "/tmp/pullpreview_env", "0644"); err != nil { + return err + } + user := i.Username() + command := fmt.Sprintf( + "sudo mkdir -p /etc/pullpreview && sudo mv /tmp/pullpreview_env %s && sudo chown %s.%s %s && sudo chmod 0644 %s", + remoteEnvPath, user, user, remoteEnvPath, remoteEnvPath, + ) + return i.SSH(command, nil) +} + +func (i *Instance) runRemotePreScript() error { + command := fmt.Sprintf("cd %s && set -a && source %s && set +a && /tmp/pre_script.sh", remoteAppPath, remoteEnvPath) + return i.SSH(command, nil) +} + +func (i *Instance) composeConfigForRemoteContext(appPath string) ([]byte, error) { + absAppPath, err := filepath.Abs(appPath) + if err != nil { + return nil, err + } + + args := []string{"compose"} + for _, composeFile := range i.ComposeFiles { + pathValue := composeFile + if !filepath.IsAbs(pathValue) { + pathValue = filepath.Join(absAppPath, composeFile) + } + args = append(args, "-f", pathValue) + } + args = append(args, "config", "--format", "json") + + cmd := exec.CommandContext(i.Context, "docker", args...) + cmd.Dir = absAppPath + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("unable to render compose config: %w (%s)", err, strings.TrimSpace(stderr.String())) + } + + rewritten, err := rewriteRelativeBindSources(stdout.Bytes(), absAppPath, remoteAppPath) + if err != nil { + return nil, err + } + return applyProxyTLS(rewritten, i.ProxyTLS, i.PublicDNS(), i.Logger) +} + +func rewriteRelativeBindSources(composeConfigJSON []byte, absAppPath, remoteRoot string) ([]byte, error) { + var config map[string]any + if err := json.Unmarshal(composeConfigJSON, &config); err != nil { + return nil, fmt.Errorf("unable to parse compose config: %w", err) + } + + rawServices, ok := config["services"].(map[string]any) + if !ok { + return composeConfigJSON, nil + } + + for serviceName, rawService := range rawServices { + service, ok := rawService.(map[string]any) + if !ok { + continue + } + rawVolumes, ok := service["volumes"].([]any) + if !ok { + continue + } + + for _, rawVolume := range rawVolumes { + volume, ok := rawVolume.(map[string]any) + if !ok { + continue + } + typeValue, _ := volume["type"].(string) + if strings.ToLower(typeValue) != "bind" { + continue + } + source, _ := volume["source"].(string) + if strings.TrimSpace(source) == "" { + continue + } + remoteSource, err := remoteBindSource(source, absAppPath, remoteRoot) + if err != nil { + return nil, fmt.Errorf("service %s bind mount %q: %w", serviceName, source, err) + } + volume["source"] = remoteSource + } + } + + return json.Marshal(config) +} + +func remoteBindSource(source, absAppPath, remoteRoot string) (string, error) { + resolvedSource := source + if !filepath.IsAbs(resolvedSource) { + resolvedSource = filepath.Join(absAppPath, resolvedSource) + } + resolvedSource = filepath.Clean(resolvedSource) + absAppPath = filepath.Clean(absAppPath) + + rel, err := filepath.Rel(absAppPath, resolvedSource) + if err != nil { + return "", err + } + if rel == "." { + return remoteRoot, nil + } + if strings.HasPrefix(rel, ".."+string(filepath.Separator)) || rel == ".." { + return "", fmt.Errorf("absolute bind mount outside app_path is unsupported in context mode") + } + return path.Join(remoteRoot, filepath.ToSlash(rel)), nil +} + +func (i *Instance) runComposeOnRemoteContext(composeConfig []byte) error { + keyFile, certFile, err := i.writeTempKeys() + if err != nil { + return err + } + defer func() { + _ = os.Remove(keyFile) + if certFile != "" { + _ = os.Remove(certFile) + } + }() + + hostAlias := fmt.Sprintf("pullpreview-%s", i.Name) + restoreSSHConfig, err := injectSSHHostAlias(hostAlias, i.PublicIP(), i.Username(), keyFile, certFile) + if err != nil { + return err + } + defer restoreSSHConfig() + + contextName := fmt.Sprintf("pullpreview-%s-%d", i.Name, time.Now().UnixNano()) + env := os.Environ() + + createContext := exec.CommandContext(i.Context, "docker", "context", "create", contextName, "--docker", "host=ssh://"+hostAlias) + createContext.Env = env + createContext.Stdout = os.Stdout + createContext.Stderr = os.Stderr + if err := createContext.Run(); err != nil { + return fmt.Errorf("unable to create docker context: %w", err) + } + defer func() { + removeCmd := exec.CommandContext(i.Context, "docker", "context", "rm", "-f", contextName) + removeCmd.Env = env + removeCmd.Stdout = os.Stdout + removeCmd.Stderr = os.Stderr + _ = removeCmd.Run() + }() + + credentials := ParseRegistryCredentials(i.Registries, i.Logger) + if err := loginRegistriesOnRunner(i.Context, credentials, env); err != nil { + return err + } + + pullErr := error(nil) + for attempt := 1; attempt <= 5; attempt++ { + pullErr = i.runComposeCommandWithConfig(env, contextName, composeConfig, "pull", "-q") + if pullErr == nil { + break + } + } + if pullErr != nil { + return pullErr + } + + upArgs := []string{"up", "--wait", "--remove-orphans", "-d"} + upArgs = append(upArgs, i.ComposeOptions...) + if err := i.runComposeCommandWithConfig(env, contextName, composeConfig, upArgs...); err != nil { + return err + } + + if err := i.runComposeCommandWithConfig(env, contextName, composeConfig, "logs", "--tail", "1000"); err != nil { + return err + } + return nil +} + +func loginRegistriesOnRunner(ctx context.Context, credentials []RegistryCredential, env []string) error { + ctx = ensureContext(ctx) + for _, cred := range credentials { + cmd := exec.CommandContext(ctx, "docker", "login", cred.Host, "-u", cred.Username, "--password-stdin") + cmd.Env = env + cmd.Stdin = strings.NewReader(cred.Password + "\n") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("docker login %s failed: %w", cred.Host, err) + } + } + return nil +} + +func injectSSHHostAlias(hostAlias, hostName, userName, keyFile, certFile string) (func(), error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return nil, err + } + sshDir := filepath.Join(homeDir, ".ssh") + if err := os.MkdirAll(sshDir, 0700); err != nil { + return nil, err + } + configPath := filepath.Join(sshDir, "config") + originalContent, readErr := os.ReadFile(configPath) + fileExisted := readErr == nil + if readErr != nil && !os.IsNotExist(readErr) { + return nil, readErr + } + + lines := []string{ + fmt.Sprintf("Host %s", hostAlias), + fmt.Sprintf(" HostName %s", hostName), + fmt.Sprintf(" User %s", userName), + fmt.Sprintf(" IdentityFile %s", keyFile), + " IdentitiesOnly yes", + " ServerAliveInterval 15", + " StrictHostKeyChecking no", + " UserKnownHostsFile /dev/null", + " LogLevel ERROR", + " ConnectTimeout 10", + } + if strings.TrimSpace(certFile) != "" { + lines = append(lines, fmt.Sprintf(" CertificateFile %s", certFile)) + } + + newContent := append([]byte{}, originalContent...) + if len(newContent) > 0 && !bytes.HasSuffix(newContent, []byte("\n")) { + newContent = append(newContent, '\n') + } + newContent = append(newContent, []byte(strings.Join(lines, "\n")+"\n")...) + if err := os.WriteFile(configPath, newContent, 0600); err != nil { + return nil, err + } + + cleanup := func() { + if fileExisted { + _ = os.WriteFile(configPath, originalContent, 0600) + return + } + _ = os.Remove(configPath) + } + return cleanup, nil +} + +func (i *Instance) runComposeCommandWithConfig(env []string, contextName string, composeConfig []byte, args ...string) error { + cmdArgs := []string{ + "--context", contextName, + "compose", + "--project-name", dockerProjectName, + "-f", "-", + } + cmdArgs = append(cmdArgs, args...) + cmd := exec.CommandContext(i.Context, "docker", cmdArgs...) + cmd.Env = env + cmd.Stdin = bytes.NewReader(composeConfig) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("docker compose %s failed: %w", strings.Join(args, " "), err) + } + return nil +} diff --git a/internal/pullpreview/deploy_context_test.go b/internal/pullpreview/deploy_context_test.go new file mode 100644 index 0000000..cfe2ff0 --- /dev/null +++ b/internal/pullpreview/deploy_context_test.go @@ -0,0 +1,102 @@ +package pullpreview + +import ( + "encoding/json" + "path/filepath" + "strings" + "testing" +) + +func TestRewriteRelativeBindSourcesUnderAppPath(t *testing.T) { + appPath := filepath.Clean("/tmp/app") + input := map[string]any{ + "services": map[string]any{ + "web": map[string]any{ + "volumes": []any{ + map[string]any{ + "type": "bind", + "source": filepath.Join(appPath, "dumps"), + "target": "/dump", + }, + map[string]any{ + "type": "volume", + "source": "db_data", + "target": "/var/lib/mysql", + }, + }, + }, + }, + } + raw, err := json.Marshal(input) + if err != nil { + t.Fatalf("marshal input: %v", err) + } + + output, err := rewriteRelativeBindSources(raw, appPath, "/app") + if err != nil { + t.Fatalf("rewriteRelativeBindSources() error: %v", err) + } + + var result map[string]any + if err := json.Unmarshal(output, &result); err != nil { + t.Fatalf("unmarshal output: %v", err) + } + services := result["services"].(map[string]any) + web := services["web"].(map[string]any) + volumes := web["volumes"].([]any) + first := volumes[0].(map[string]any) + second := volumes[1].(map[string]any) + + if first["source"] != "/app/dumps" { + t.Fatalf("expected bind source rewritten to /app/dumps, got %#v", first["source"]) + } + if second["source"] != "db_data" { + t.Fatalf("expected named volume unchanged, got %#v", second["source"]) + } +} + +func TestRewriteRelativeBindSourcesRejectsAbsoluteOutsideAppPath(t *testing.T) { + appPath := filepath.Clean("/tmp/app") + input := map[string]any{ + "services": map[string]any{ + "web": map[string]any{ + "volumes": []any{ + map[string]any{ + "type": "bind", + "source": "/tmp/other/dumps", + "target": "/dump", + }, + }, + }, + }, + } + raw, err := json.Marshal(input) + if err != nil { + t.Fatalf("marshal input: %v", err) + } + + _, err = rewriteRelativeBindSources(raw, appPath, "/app") + if err == nil { + t.Fatalf("expected error for bind mount source outside app path") + } + if !strings.Contains(err.Error(), "outside app_path") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestRemoteBindSourceRootPath(t *testing.T) { + appPath := filepath.Clean("/tmp/app") + got, err := remoteBindSource(appPath, appPath, "/app") + if err != nil { + t.Fatalf("remoteBindSource() error: %v", err) + } + if got != "/app" { + t.Fatalf("remoteBindSource()=%q, want /app", got) + } +} + +func TestLoginRegistriesOnRunnerNoop(t *testing.T) { + if err := loginRegistriesOnRunner(nil, nil, nil); err != nil { + t.Fatalf("expected empty registry list to be a no-op: %v", err) + } +} diff --git a/internal/pullpreview/down.go b/internal/pullpreview/down.go new file mode 100644 index 0000000..73c3e1b --- /dev/null +++ b/internal/pullpreview/down.go @@ -0,0 +1,9 @@ +package pullpreview + +func RunDown(opts DownOptions, provider Provider, logger *Logger) error { + instance := NewInstance(opts.Name, CommonOptions{}, provider, logger) + if logger != nil { + logger.Infof("Destroying instance name=%s", instance.Name) + } + return instance.Terminate() +} diff --git a/internal/pullpreview/github_sync.go b/internal/pullpreview/github_sync.go new file mode 100644 index 0000000..3f4bc50 --- /dev/null +++ b/internal/pullpreview/github_sync.go @@ -0,0 +1,962 @@ +package pullpreview + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + "time" + + gh "github.com/google/go-github/v60/github" + ghclient "github.com/pullpreview/action/internal/github" + "github.com/pullpreview/action/internal/license" +) + +var ( + newGitHubClient = func(ctx context.Context, token string) GitHubAPI { + return ghclient.NewWithContext(EnsureContext(ctx), token) + } + runUpFunc = RunUp + runDownFunc = RunDown +) + +type GitHubAPI interface { + ListIssues(repo, label string) ([]*gh.Issue, error) + GetPullRequest(repo string, number int) (*gh.PullRequest, error) + RemoveLabel(repo string, number int, label string) error + ListIssueComments(repo string, number int) ([]*gh.IssueComment, error) + CreateIssueComment(repo string, number int, body string) error + UpdateIssueComment(repo string, commentID int64, body string) error + ListPullRequests(repo, head string) ([]*gh.PullRequest, error) + LatestCommitSHA(repo, ref string) (string, error) + ListCollaborators(repo string) ([]*gh.User, bool, error) + ListUserPublicKeys(user string) ([]string, error) +} + +type GitHubEvent struct { + Action string `json:"action"` + Label *GitHubLabel `json:"label"` + PullRequest *GitHubPR `json:"pull_request"` + Repository GitHubRepo `json:"repository"` + Organization *GitHubOrg `json:"organization"` + Ref string `json:"ref"` + HeadCommit *GitHubCommit `json:"head_commit"` + Number int `json:"number"` +} + +type GitHubCommit struct { + ID string `json:"id"` +} + +type GitHubLabel struct { + Name string `json:"name"` +} + +type GitHubOrg struct { + Login string `json:"login"` + ID int64 `json:"id"` + Type string `json:"type"` +} + +type GitHubRepo struct { + ID int64 `json:"id"` + Name string `json:"name"` + Owner GitHubOrg `json:"owner"` +} + +type GitHubPR struct { + Number int `json:"number"` + Head GitHubPRHead `json:"head"` + Base GitHubPRBase `json:"base"` + Labels []GitHubLabel `json:"labels"` +} + +type GitHubPRHead struct { + SHA string `json:"sha"` + Ref string `json:"ref"` +} + +type GitHubPRBase struct { + Repo GitHubRepo `json:"repo"` +} + +type GithubSync struct { + event GitHubEvent + appPath string + opts GithubSyncOptions + client GitHubAPI + provider Provider + logger *Logger + prCache *gh.PullRequest + runUp func(UpOptions, Provider, *Logger) (*Instance, error) + runDown func(DownOptions, Provider, *Logger) error +} + +func RunGithubSync(opts GithubSyncOptions, provider Provider, logger *Logger) error { + opts.Context = EnsureContext(opts.Context) + if opts.Common.Context == nil { + opts.Common.Context = opts.Context + } else { + opts.Common.Context = EnsureContext(opts.Common.Context) + } + eventName := os.Getenv("GITHUB_EVENT_NAME") + if logger != nil { + logger.Debugf("github_event_name=%s", eventName) + } + repo := os.Getenv("GITHUB_REPOSITORY") + if eventName == "schedule" { + return runScheduledCleanup(repo, opts, provider, logger) + } + path := os.Getenv("GITHUB_EVENT_PATH") + if path == "" { + return errors.New("GITHUB_EVENT_PATH not set") + } + payload, err := os.ReadFile(path) + if err != nil { + return err + } + var event GitHubEvent + if err := json.Unmarshal(payload, &event); err != nil { + return err + } + + client := newGitHubClient(opts.Context, os.Getenv("GITHUB_TOKEN")) + sync := &GithubSync{event: event, appPath: opts.AppPath, opts: opts, client: client, provider: provider, logger: logger, runUp: runUpFunc, runDown: runDownFunc} + return sync.Sync() +} + +func runScheduledCleanup(repo string, opts GithubSyncOptions, provider Provider, logger *Logger) error { + opts.Context = EnsureContext(opts.Context) + client := newGitHubClient(opts.Context, os.Getenv("GITHUB_TOKEN")) + if logger != nil { + logger.Infof("[clear_dangling_deployments] start") + } + return clearDanglingDeployments(repo, opts, provider, client, logger) +} + +func prExpired(updatedAt time.Time, ttl string) bool { + ttl = strings.TrimSpace(ttl) + if strings.HasSuffix(ttl, "h") { + hours := mustParseInt(strings.TrimSuffix(ttl, "h")) + if hours <= 0 { + return false + } + return updatedAt.Before(time.Now().Add(-time.Duration(hours) * time.Hour)) + } + if strings.HasSuffix(ttl, "d") { + days := mustParseInt(strings.TrimSuffix(ttl, "d")) + if days <= 0 { + return false + } + return updatedAt.Before(time.Now().Add(-time.Duration(days) * 24 * time.Hour)) + } + return false +} + +func clearDanglingDeployments(repo string, opts GithubSyncOptions, provider Provider, client GitHubAPI, logger *Logger) error { + ttl := opts.TTL + if ttl == "" { + ttl = "infinite" + } + issues, err := client.ListIssues(repo, opts.Label) + if err != nil { + return err + } + for _, issue := range issues { + if issue.PullRequestLinks == nil { + continue + } + pr, err := client.GetPullRequest(repo, issue.GetNumber()) + if err != nil { + continue + } + fake := eventFromPR(pr) + if issue.GetState() == "closed" { + if logger != nil { + logger.Warnf("[clear_dangling_deployments] Found dangling %s label for PR#%d. Cleaning up...", opts.Label, pr.GetNumber()) + } + } else if prExpired(issue.GetUpdatedAt().Time, ttl) { + if logger != nil { + logger.Warnf("[clear_dangling_deployments] Found %s label for expired PR#%d (%s). Cleaning up...", opts.Label, pr.GetNumber(), issue.GetUpdatedAt().String()) + } + } else { + if logger != nil { + logger.Warnf("[clear_dangling_deployments] Found %s label for active PR#%d (%s). Not touching.", opts.Label, pr.GetNumber(), issue.GetUpdatedAt().String()) + } + continue + } + sync := &GithubSync{event: fake, appPath: opts.AppPath, opts: opts, client: client, provider: provider, logger: logger, runUp: runUpFunc, runDown: runDownFunc} + _ = sync.Sync() + } + if logger != nil { + logger.Infof("[clear_dangling_deployments] end") + } + return nil +} + +func mustParseInt(value string) int { + result := 0 + for _, r := range value { + if r < '0' || r > '9' { + return 0 + } + result = result*10 + int(r-'0') + } + return result +} + +func eventFromPR(pr *gh.PullRequest) GitHubEvent { + owner := pr.Base.Repo.Owner + repository := GitHubRepo{ID: pr.Base.Repo.GetID(), Name: pr.Base.Repo.GetName(), Owner: GitHubOrg{Login: owner.GetLogin(), ID: owner.GetID(), Type: owner.GetType()}} + var org *GitHubOrg + if strings.EqualFold(owner.GetType(), "Organization") { + org = &GitHubOrg{Login: owner.GetLogin(), ID: owner.GetID(), Type: owner.GetType()} + } + labels := []GitHubLabel{} + for _, label := range pr.Labels { + labels = append(labels, GitHubLabel{Name: label.GetName()}) + } + return GitHubEvent{ + Action: "closed", + PullRequest: &GitHubPR{Number: pr.GetNumber(), Head: GitHubPRHead{SHA: pr.Head.GetSHA(), Ref: pr.Head.GetRef()}, Base: GitHubPRBase{Repo: repository}, Labels: labels}, + Repository: repository, + Organization: org, + Ref: pr.Head.GetRef(), + Number: pr.GetNumber(), + } +} + +func (g *GithubSync) Sync() error { + if g.runUp == nil { + g.runUp = runUpFunc + } + if g.runDown == nil { + g.runDown = runDownFunc + } + latest := g.latestSHA() + if latest != "" && g.sha() != latest && os.Getenv("PULLPREVIEW_TEST") == "" { + if g.logger != nil { + g.logger.Infof("A newer commit is present. Skipping current run.") + } + return nil + } + if err := g.validateDeploymentVariant(); err != nil { + return err + } + action := g.guessAction() + if action == actionIgnored { + if g.logger != nil { + g.logger.Infof("Ignoring event %s", action) + } + return nil + } + if os.Getenv("PULLPREVIEW_TEST") == "" { + licenseClient := license.New() + lic, _ := licenseClient.Check(map[string]string{ + "org_id": fmt.Sprintf("%d", g.orgID()), + "repo_id": fmt.Sprintf("%d", g.repoID()), + "pp_action": string(action), + "org_slug": g.orgName(), + "repo_slug": g.repoName(), + }) + if g.logger != nil { + g.logger.Infof("%s", lic.Message) + } + if !lic.OK() { + return errors.New(lic.Message) + } + } + + switch action { + case actionPRDown, actionBranchDown: + instance := NewInstance(g.instanceName(), g.opts.Common, g.provider, g.logger) + _ = g.updateGitHubStatus(statusDestroying, "") + running, _ := instance.Running() + if running { + if g.runDown != nil { + _ = g.runDown(DownOptions{Name: instance.Name}, g.provider, g.logger) + } + } else if g.logger != nil { + g.logger.Warnf("Instance %s already down. Continuing...", instance.Name) + } + if g.prClosed() { + if g.logger != nil { + g.logger.Infof("Removing label %s from PR#%d...", g.opts.Label, g.prNumber()) + } + _ = g.client.RemoveLabel(g.repo(), g.prNumber(), g.opts.Label) + } + _ = g.updateGitHubStatus(statusDestroyed, "") + g.writeStepSummary(statusDestroyed, action, "", nil) + case actionPRUp, actionPRPush, actionBranchPush: + _ = g.updateGitHubStatus(statusDeploying, "") + instance := g.buildInstance() + var upInstance *Instance + var err error + if g.runUp != nil { + upInstance, err = g.runUp(UpOptions{AppPath: g.appPath, Name: instance.Name, Subdomain: instance.Subdomain, Common: instanceToCommon(instance)}, g.provider, g.logger) + } + if err != nil { + _ = g.updateGitHubStatus(statusError, "") + g.writeStepSummary(statusError, action, "", nil) + return err + } + if upInstance != nil { + _ = g.updateGitHubStatus(statusDeployed, upInstance.URL()) + g.writeStepSummary(statusDeployed, action, upInstance.URL(), upInstance) + } + } + return nil +} + +type actionType string + +const ( + actionIgnored actionType = "ignored" + actionPRDown actionType = "pr_down" + actionBranchDown actionType = "branch_down" + actionPRUp actionType = "pr_up" + actionPRPush actionType = "pr_push" + actionBranchPush actionType = "branch_push" +) + +type deploymentStatus string + +const ( + statusError deploymentStatus = "error" + statusDeployed deploymentStatus = "deployed" + statusDestroyed deploymentStatus = "destroyed" + statusDeploying deploymentStatus = "deploying" + statusDestroying deploymentStatus = "destroying" +) + +func (g *GithubSync) guessAction() actionType { + if g.prNumber() == 0 { + branch := strings.TrimPrefix(g.ref(), "refs/heads/") + if containsString(g.opts.AlwaysOn, branch) { + return actionBranchPush + } + return actionBranchDown + } + + if (g.prUnlabeled() && !g.prHasLabel("")) || g.prClosed() { + return actionPRDown + } + if (g.prOpened() || g.prReopened() || g.prLabeled()) && g.prHasLabel("") { + return actionPRUp + } + if g.push() || g.prSynchronize() { + if g.prHasLabel("") { + return actionPRPush + } + if g.logger != nil { + g.logger.Infof("Unable to find label %s on PR#%d", g.opts.Label, g.prNumber()) + } + return actionIgnored + } + return actionIgnored +} + +func (g *GithubSync) updateGitHubStatus(status deploymentStatus, url string) error { + g.updatePRComment(status, url) + return nil +} + +func (g *GithubSync) updatePRComment(status deploymentStatus, previewURL string) { + if g.prNumber() == 0 { + return + } + body := g.renderPRComment(status, previewURL) + if body == "" { + return + } + comments, err := g.client.ListIssueComments(g.repo(), g.prNumber()) + if err != nil { + if g.logger != nil { + g.logger.Warnf("Unable to list PR comments for PR#%d: %v", g.prNumber(), err) + } + return + } + marker := g.prCommentMarker() + for _, comment := range comments { + commentBody := comment.GetBody() + if strings.Contains(commentBody, marker) { + if err := g.client.UpdateIssueComment(g.repo(), comment.GetID(), body); err != nil && g.logger != nil { + g.logger.Warnf("Unable to update PR comment for PR#%d: %v", g.prNumber(), err) + } + return + } + } + if err := g.client.CreateIssueComment(g.repo(), g.prNumber(), body); err != nil && g.logger != nil { + g.logger.Warnf("Unable to create PR comment for PR#%d: %v", g.prNumber(), err) + } +} + +func (g *GithubSync) prCommentMarker() string { + key := g.instanceName() + if job := g.jobKey(); job != "" { + key = fmt.Sprintf("%s:%s", key, job) + } + return fmt.Sprintf("", key) +} + +func (g *GithubSync) jobName() string { + return strings.TrimSpace(os.Getenv("GITHUB_JOB")) +} + +func (g *GithubSync) jobKey() string { + job := g.jobName() + if job == "" { + return "" + } + key := NormalizeName(job) + if key == "" { + return "job" + } + return key +} + +func (g *GithubSync) renderPRComment(status deploymentStatus, previewURL string) string { + statusText := "" + switch status { + case statusDeploying: + statusText = "⏳ Deploying preview..." + case statusDeployed: + statusText = "✅ Deploy successful" + case statusError: + statusText = "❌ Deploy failed" + case statusDestroying: + statusText = "🧹 Destroying preview..." + case statusDestroyed: + statusText = "🗑️ Preview destroyed" + default: + return "" + } + commit := g.sha() + if len(commit) > 7 { + commit = commit[:7] + } + preview := "_Pending_" + if status == statusDestroying { + preview = "_Destroying_" + } + if status == statusDestroyed { + preview = "_Destroyed_" + } + if strings.TrimSpace(previewURL) != "" { + preview = fmt.Sprintf("[%s](%s)", previewURL, previewURL) + } + logs := g.workflowRunURL() + logsLine := "" + if logs != "" { + logsLine = fmt.Sprintf("\n[View logs](%s)\n", logs) + } + variantRow := "" + if variant := g.deploymentVariant(); variant != "" { + variantRow = fmt.Sprintf("| Variant | `%s` |\n", variant) + } + jobRow := "" + if job := g.jobName(); job != "" { + jobRow = fmt.Sprintf("| Job | `%s` |\n", job) + } + title := fmt.Sprintf("### Deploying %s with [⚡](https://pullpreview.com) PullPreview", g.repoName()) + return fmt.Sprintf( + "%s\n%s\n\n| Field | Value |\n|---|---|\n| Latest commit | `%s` |\n%s%s| Status | %s |\n| Preview URL | %s |\n%s", + g.prCommentMarker(), + title, + commit, + variantRow, + jobRow, + statusText, + preview, + logsLine, + ) +} + +func (g *GithubSync) statusSummaryText(status deploymentStatus) string { + switch status { + case statusDeploying: + return "Deploying preview" + case statusDeployed: + return "Deploy successful" + case statusError: + return "Deploy failed" + case statusDestroying: + return "Destroying preview" + case statusDestroyed: + return "Preview destroyed" + default: + return "Status unknown" + } +} + +func (g *GithubSync) renderStepSummary(status deploymentStatus, action actionType, previewURL string, inst *Instance) string { + commit := g.sha() + if len(commit) > 7 { + commit = commit[:7] + } + + var b strings.Builder + b.WriteString("## PullPreview Summary\n\n") + b.WriteString(fmt.Sprintf("- Repository: `%s`\n", g.repo())) + b.WriteString(fmt.Sprintf("- Branch: `%s`\n", g.branch())) + b.WriteString(fmt.Sprintf("- Commit: `%s`\n", commit)) + b.WriteString(fmt.Sprintf("- Action: `%s`\n", action)) + b.WriteString(fmt.Sprintf("- Status: `%s`\n", g.statusSummaryText(status))) + + if strings.TrimSpace(previewURL) != "" { + b.WriteString(fmt.Sprintf("- Preview URL: [%s](%s)\n", previewURL, previewURL)) + } + if logs := g.workflowRunURL(); logs != "" { + b.WriteString(fmt.Sprintf("- Logs: [%s](%s)\n", logs, logs)) + } + + if inst != nil && status == statusDeployed { + b.WriteString(fmt.Sprintf("- SSH Username: `%s`\n", inst.Username())) + b.WriteString(fmt.Sprintf("- SSH IP: `%s`\n", inst.PublicIP())) + b.WriteString(fmt.Sprintf("- SSH Command: `ssh %s`\n", inst.SSHAddress())) + } + + b.WriteString("\nPowered by [⚡](https://pullpreview.com) PullPreview.\n") + return b.String() +} + +func (g *GithubSync) writeStepSummary(status deploymentStatus, action actionType, previewURL string, inst *Instance) { + path := strings.TrimSpace(os.Getenv("GITHUB_STEP_SUMMARY")) + if path == "" { + return + } + content := g.renderStepSummary(status, action, previewURL, inst) + if strings.TrimSpace(content) == "" { + return + } + f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0644) + if err != nil { + if g.logger != nil { + g.logger.Warnf("Unable to open GITHUB_STEP_SUMMARY file: %v", err) + } + return + } + defer f.Close() + if _, err := f.WriteString(content + "\n"); err != nil && g.logger != nil { + g.logger.Warnf("Unable to write GITHUB_STEP_SUMMARY: %v", err) + } +} + +func (g *GithubSync) workflowRunURL() string { + server := strings.TrimSuffix(os.Getenv("GITHUB_SERVER_URL"), "/") + runID := strings.TrimSpace(os.Getenv("GITHUB_RUN_ID")) + if server == "" || runID == "" { + return "" + } + jobID := strings.TrimSpace(os.Getenv("PULLPREVIEW_GITHUB_JOB_ID")) + if jobID != "" { + return fmt.Sprintf("%s/%s/actions/runs/%s/job/%s", server, g.repo(), runID, jobID) + } + return fmt.Sprintf("%s/%s/actions/runs/%s", server, g.repo(), runID) +} + +func (g *GithubSync) orgName() string { + if g.event.Organization != nil && g.event.Organization.Login != "" { + return g.event.Organization.Login + } + return g.event.Repository.Owner.Login +} + +func (g *GithubSync) repoName() string { + return g.event.Repository.Name +} + +func (g *GithubSync) repo() string { + return fmt.Sprintf("%s/%s", g.orgName(), g.repoName()) +} + +func (g *GithubSync) repoID() int64 { + return g.event.Repository.ID +} + +func (g *GithubSync) orgID() int64 { + if g.event.Organization != nil && g.event.Organization.ID != 0 { + return g.event.Organization.ID + } + return g.event.Repository.Owner.ID +} + +func (g *GithubSync) ref() string { + if g.event.Ref != "" { + return g.event.Ref + } + return os.Getenv("GITHUB_REF") +} + +func (g *GithubSync) latestSHA() string { + if g.pullRequest() { + return g.event.PullRequest.Head.SHA + } + sha, _ := g.client.LatestCommitSHA(g.repo(), g.ref()) + return sha +} + +func (g *GithubSync) sha() string { + if g.pullRequest() { + return g.event.PullRequest.Head.SHA + } + if g.event.HeadCommit != nil { + return g.event.HeadCommit.ID + } + return os.Getenv("GITHUB_SHA") +} + +func (g *GithubSync) branch() string { + if g.pullRequest() { + return g.event.PullRequest.Head.Ref + } + return strings.TrimPrefix(g.ref(), "refs/heads/") +} + +func (g *GithubSync) expandedAdmins() []string { + admins, _ := g.expandedAdminsAndKeys() + return admins +} + +func (g *GithubSync) expandedAdminsAndKeys() ([]string, []string) { + admins := append([]string{}, g.opts.Common.Admins...) + final := []string{} + keyLogins := []string{} + collaboratorsToken := "@collaborators/push" + needCollabs := false + for _, admin := range admins { + admin = strings.TrimSpace(admin) + if admin == collaboratorsToken { + needCollabs = true + continue + } + if admin != "" { + final = append(final, admin) + keyLogins = append(keyLogins, admin) + } + } + if needCollabs { + collabs, truncated, err := g.client.ListCollaborators(g.repo()) + if err == nil { + for _, user := range collabs { + if user.Permissions == nil || user.Permissions["push"] { + login := strings.TrimSpace(user.GetLogin()) + if login == "" { + continue + } + final = append(final, login) + keyLogins = append(keyLogins, login) + } + } + if truncated && g.logger != nil { + g.logger.Warnf("Found more than 100 collaborators with push access. Only the first 100 will receive SSH access.") + } + } else if g.logger != nil { + g.logger.Warnf("Unable to list collaborators for %s: %v", g.repo(), err) + } + } + final = uniqueStrings(final) + keys := []string{} + for _, login := range uniqueStrings(keyLogins) { + userKeys, err := g.userPublicKeys(login) + if err != nil { + if g.logger != nil { + g.logger.Warnf("Unable to resolve SSH keys for %s: %v", login, err) + } + continue + } + keys = append(keys, userKeys...) + } + return final, uniqueStrings(keys) +} + +var sshKeyCacheFilenameSanitizer = regexp.MustCompile(`[^a-zA-Z0-9._-]+`) + +func (g *GithubSync) userPublicKeys(login string) ([]string, error) { + login = strings.TrimSpace(login) + if login == "" { + return nil, nil + } + if keys, ok := g.loadCachedUserPublicKeys(login); ok { + return keys, nil + } + keys, err := g.client.ListUserPublicKeys(login) + if err != nil { + return nil, err + } + keys = uniqueStrings(keys) + if len(keys) == 0 { + return keys, nil + } + _ = g.saveCachedUserPublicKeys(login, keys) + return keys, nil +} + +func (g *GithubSync) sshKeysCacheDir() string { + return strings.TrimSpace(os.Getenv("PULLPREVIEW_SSH_KEYS_CACHE_DIR")) +} + +func (g *GithubSync) sshKeysCachePath(login string) string { + dir := g.sshKeysCacheDir() + if dir == "" { + return "" + } + filename := sshKeyCacheFilenameSanitizer.ReplaceAllString(strings.ToLower(strings.TrimSpace(login)), "-") + filename = strings.Trim(filename, "-") + if filename == "" { + return "" + } + return filepath.Join(dir, filename+".keys") +} + +func (g *GithubSync) loadCachedUserPublicKeys(login string) ([]string, bool) { + path := g.sshKeysCachePath(login) + if path == "" { + return nil, false + } + content, err := os.ReadFile(path) + if err != nil { + return nil, false + } + keys := []string{} + for _, line := range strings.Split(string(content), "\n") { + line = strings.TrimSpace(line) + if line != "" { + keys = append(keys, line) + } + } + keys = uniqueStrings(keys) + if len(keys) == 0 { + return nil, false + } + if g.logger != nil { + g.logger.Debugf("Loaded %d SSH key(s) for %s from cache", len(keys), login) + } + return keys, true +} + +func (g *GithubSync) saveCachedUserPublicKeys(login string, keys []string) error { + path := g.sshKeysCachePath(login) + if path == "" { + return nil + } + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return err + } + content := strings.Join(uniqueStrings(keys), "\n") + if content != "" { + content += "\n" + } + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + return err + } + if g.logger != nil { + g.logger.Debugf("Cached %d SSH key(s) for %s", len(keys), login) + } + return nil +} + +func (g *GithubSync) pullRequest() bool { + return g.event.PullRequest != nil +} + +func (g *GithubSync) push() bool { + return !g.pullRequest() +} + +func (g *GithubSync) prSynchronize() bool { + return g.pullRequest() && g.event.Action == "synchronize" +} + +func (g *GithubSync) prOpened() bool { + return g.pullRequest() && g.event.Action == "opened" +} + +func (g *GithubSync) prReopened() bool { + return g.pullRequest() && g.event.Action == "reopened" +} + +func (g *GithubSync) prClosed() bool { + return g.pullRequest() && g.event.Action == "closed" +} + +func (g *GithubSync) prLabeled() bool { + return g.pullRequest() && g.event.Action == "labeled" && g.event.Label != nil && strings.EqualFold(g.event.Label.Name, g.opts.Label) +} + +func (g *GithubSync) prUnlabeled() bool { + return g.pullRequest() && g.event.Action == "unlabeled" && g.event.Label != nil && strings.EqualFold(g.event.Label.Name, g.opts.Label) +} + +func (g *GithubSync) prHasLabel(searchedLabel string) bool { + if g.pr() == nil { + return false + } + label := g.opts.Label + if searchedLabel != "" { + label = searchedLabel + } + for _, l := range g.pr().Labels { + if strings.EqualFold(l.Name, label) { + return true + } + } + return false +} + +func (g *GithubSync) prNumber() int { + if g.pullRequest() { + return g.event.PullRequest.Number + } + if pr := g.prFromRef(); pr != nil { + return pr.GetNumber() + } + return 0 +} + +func (g *GithubSync) prFromRef() *gh.PullRequest { + if g.pullRequest() { + return nil + } + if g.prCache != nil { + return g.prCache + } + prs, err := g.client.ListPullRequests(g.repo(), fmt.Sprintf("%s:%s", g.orgName(), g.ref())) + if err != nil || len(prs) == 0 { + return nil + } + g.prCache = prs[0] + return g.prCache +} + +func (g *GithubSync) pr() *GitHubPR { + if g.pullRequest() { + return g.event.PullRequest + } + if pr := g.prFromRef(); pr != nil { + labels := []GitHubLabel{} + for _, label := range pr.Labels { + labels = append(labels, GitHubLabel{Name: label.GetName()}) + } + return &GitHubPR{ + Number: pr.GetNumber(), + Head: GitHubPRHead{SHA: pr.Head.GetSHA(), Ref: pr.Head.GetRef()}, + Labels: labels, + } + } + return nil +} + +func (g *GithubSync) deploymentVariant() string { + variant := strings.TrimSpace(g.opts.DeploymentVariant) + if variant == "" { + return "" + } + if len(variant) > 4 { + return "" + } + return variant +} + +func (g *GithubSync) validateDeploymentVariant() error { + variant := strings.TrimSpace(g.opts.DeploymentVariant) + if variant != "" && len(variant) > 4 { + return fmt.Errorf("--deployment-variant must be 4 chars max") + } + return nil +} + +func (g *GithubSync) instanceName() string { + parts := []string{"gh", fmt.Sprintf("%d", g.repoID())} + if g.deploymentVariant() != "" { + parts = append(parts, g.deploymentVariant()) + } + if g.prNumber() != 0 { + parts = append(parts, "pr", fmt.Sprintf("%d", g.prNumber())) + } else { + parts = append(parts, "branch", g.branch()) + } + return NormalizeName(strings.Join(parts, "-")) +} + +func (g *GithubSync) instanceSubdomain() string { + components := []string{} + if g.deploymentVariant() != "" { + components = append(components, g.deploymentVariant()) + } + if g.prNumber() != 0 { + components = append(components, "pr", fmt.Sprintf("%d", g.prNumber())) + } + branch := g.branch() + if branch != "" { + parts := strings.Split(branch, "/") + components = append(components, strings.ToLower(parts[len(parts)-1])) + } + return NormalizeName(strings.Join(components, "-")) +} + +func (g *GithubSync) defaultInstanceTags() map[string]string { + tags := map[string]string{ + "repo_name": g.repoName(), + "repo_id": fmt.Sprintf("%d", g.repoID()), + "org_name": g.orgName(), + "org_id": fmt.Sprintf("%d", g.orgID()), + "version": Version, + } + if g.prNumber() != 0 { + tags["pr_number"] = fmt.Sprintf("%d", g.prNumber()) + } + return tags +} + +func (g *GithubSync) buildInstance() *Instance { + common := g.opts.Common + common.Tags = mergeStringMap(g.defaultInstanceTags(), common.Tags) + common.Admins, common.AdminPublicKeys = g.expandedAdminsAndKeys() + instance := NewInstance(g.instanceName(), common, g.provider, g.logger) + instance.WithSubdomain(g.instanceSubdomain()) + return instance +} + +func mergeStringMap(base, extra map[string]string) map[string]string { + result := map[string]string{} + for k, v := range base { + result[k] = v + } + for k, v := range extra { + result[k] = v + } + return result +} + +func instanceToCommon(inst *Instance) CommonOptions { + return CommonOptions{ + Admins: inst.Admins, + AdminPublicKeys: inst.AdminPublicKeys, + Context: inst.Context, + CIDRs: inst.CIDRs, + Registries: inst.Registries, + ProxyTLS: inst.ProxyTLS, + DNS: inst.DNS, + Ports: inst.Ports, + InstanceType: inst.Size, + DefaultPort: inst.DefaultPort, + Tags: inst.Tags, + ComposeFiles: inst.ComposeFiles, + ComposeOptions: inst.ComposeOptions, + PreScript: inst.PreScript, + } +} + +func containsString(list []string, value string) bool { + for _, v := range list { + if v == value { + return true + } + } + return false +} diff --git a/internal/pullpreview/github_sync_test.go b/internal/pullpreview/github_sync_test.go new file mode 100644 index 0000000..644fcc6 --- /dev/null +++ b/internal/pullpreview/github_sync_test.go @@ -0,0 +1,525 @@ +package pullpreview + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + gh "github.com/google/go-github/v60/github" +) + +type fakeGitHub struct { + latestSHA string + pullRequestsByRef map[string][]*gh.PullRequest + pullRequestsByNum map[int]*gh.PullRequest + issues []*gh.Issue + collaborators []*gh.User + removedLabels []string + comments []*gh.IssueComment + createdComments []string + updatedComments []string + userPublicKeys map[string][]string +} + +func (f *fakeGitHub) ListIssues(repo, label string) ([]*gh.Issue, error) { + return f.issues, nil +} + +func (f *fakeGitHub) GetPullRequest(repo string, number int) (*gh.PullRequest, error) { + if f.pullRequestsByNum == nil { + return nil, nil + } + return f.pullRequestsByNum[number], nil +} + +func (f *fakeGitHub) RemoveLabel(repo string, number int, label string) error { + f.removedLabels = append(f.removedLabels, label) + return nil +} + +func (f *fakeGitHub) ListIssueComments(repo string, number int) ([]*gh.IssueComment, error) { + return f.comments, nil +} + +func (f *fakeGitHub) CreateIssueComment(repo string, number int, body string) error { + f.createdComments = append(f.createdComments, body) + id := int64(len(f.comments) + 1) + f.comments = append(f.comments, &gh.IssueComment{ID: gh.Int64(id), Body: gh.String(body)}) + return nil +} + +func (f *fakeGitHub) UpdateIssueComment(repo string, commentID int64, body string) error { + f.updatedComments = append(f.updatedComments, body) + for _, comment := range f.comments { + if comment.GetID() == commentID { + comment.Body = gh.String(body) + break + } + } + return nil +} + +func (f *fakeGitHub) ListPullRequests(repo, head string) ([]*gh.PullRequest, error) { + if f.pullRequestsByRef == nil { + return nil, nil + } + return f.pullRequestsByRef[head], nil +} + +func (f *fakeGitHub) LatestCommitSHA(repo, ref string) (string, error) { + return f.latestSHA, nil +} + +func (f *fakeGitHub) ListCollaborators(repo string) ([]*gh.User, bool, error) { + return f.collaborators, false, nil +} + +func (f *fakeGitHub) ListUserPublicKeys(user string) ([]string, error) { + if f.userPublicKeys == nil { + return nil, nil + } + return f.userPublicKeys[user], nil +} + +type fakeProvider struct { + running bool +} + +func (f fakeProvider) Launch(name string, opts LaunchOptions) (AccessDetails, error) { + return AccessDetails{Username: "ec2-user", IPAddress: "1.2.3.4"}, nil +} + +func (f fakeProvider) Terminate(name string) error { return nil } + +func (f fakeProvider) Running(name string) (bool, error) { return f.running, nil } + +func (f fakeProvider) ListInstances(tags map[string]string) ([]InstanceSummary, error) { + return nil, nil +} + +func (f fakeProvider) Username() string { return "ec2-user" } + +func loadFixtureEvent(t *testing.T, filename string) GitHubEvent { + t.Helper() + path := filepath.Join("..", "..", "test", "fixtures", filename) + content, err := os.ReadFile(path) + if err != nil { + t.Fatalf("failed to read fixture %s: %v", filename, err) + } + var event GitHubEvent + if err := json.Unmarshal(content, &event); err != nil { + t.Fatalf("failed to parse fixture %s: %v", filename, err) + } + return event +} + +func newSync(event GitHubEvent, opts GithubSyncOptions, client *fakeGitHub, provider Provider) *GithubSync { + return &GithubSync{ + event: event, + appPath: "/tmp/app", + opts: opts, + client: client, + provider: provider, + runUp: func(opts UpOptions, provider Provider, logger *Logger) (*Instance, error) { + inst := NewInstance(opts.Name, opts.Common, provider, logger) + inst.Access = AccessDetails{Username: "ec2-user", IPAddress: "1.2.3.4"} + return inst, nil + }, + runDown: func(opts DownOptions, provider Provider, logger *Logger) error { return nil }, + } +} + +func TestGuessActionFromLabeledFixture(t *testing.T) { + event := loadFixtureEvent(t, "github_event_labeled.json") + sync := newSync(event, GithubSyncOptions{Label: "pullpreview", Common: CommonOptions{}}, &fakeGitHub{}, fakeProvider{running: true}) + if got := sync.guessAction(); got != actionPRUp { + t.Fatalf("guessAction()=%s, want %s", got, actionPRUp) + } +} + +func TestGuessActionFromLabeledFixtureWithCustomLabel(t *testing.T) { + event := loadFixtureEvent(t, "github_event_labeled.json") + event.Label = &GitHubLabel{Name: "pullpreview-multi-env"} + event.PullRequest.Labels = []GitHubLabel{{Name: "pullpreview-multi-env"}} + sync := newSync(event, GithubSyncOptions{Label: "pullpreview-multi-env", DeploymentVariant: "env1", Common: CommonOptions{}}, &fakeGitHub{}, fakeProvider{running: true}) + if got := sync.guessAction(); got != actionPRUp { + t.Fatalf("guessAction()=%s, want %s", got, actionPRUp) + } +} + +func TestGuessActionFromPushFixtureWithPR(t *testing.T) { + event := loadFixtureEvent(t, "github_event_push.json") + client := &fakeGitHub{ + latestSHA: event.HeadCommit.ID, + pullRequestsByRef: map[string][]*gh.PullRequest{ + "pullpreview:refs/heads/test-action": { + &gh.PullRequest{ + Number: gh.Int(10), + Head: &gh.PullRequestBranch{SHA: gh.String(event.HeadCommit.ID), Ref: gh.String("test-action")}, + Labels: []*gh.Label{{Name: gh.String("pullpreview")}}, + }, + }, + }, + } + sync := newSync(event, GithubSyncOptions{Label: "pullpreview", Common: CommonOptions{}}, client, fakeProvider{running: true}) + if got := sync.guessAction(); got != actionPRPush { + t.Fatalf("guessAction()=%s, want %s", got, actionPRPush) + } +} + +func TestGuessActionFromSoloPushAlwaysOn(t *testing.T) { + event := loadFixtureEvent(t, "github_event_push_solo_organization.json") + client := &fakeGitHub{latestSHA: event.HeadCommit.ID} + sync := newSync(event, GithubSyncOptions{Label: "pullpreview", AlwaysOn: []string{"dev"}, Common: CommonOptions{}}, client, fakeProvider{running: true}) + if got := sync.guessAction(); got != actionBranchPush { + t.Fatalf("guessAction()=%s, want %s", got, actionBranchPush) + } +} + +func TestSyncLabeledFixtureRunsUp(t *testing.T) { + t.Setenv("PULLPREVIEW_TEST", "1") + t.Setenv("GITHUB_SERVER_URL", "https://github.com") + t.Setenv("GITHUB_RUN_ID", "12345") + t.Setenv("PULLPREVIEW_GITHUB_JOB_ID", "67890") + t.Setenv("GITHUB_STEP_SUMMARY", filepath.Join(t.TempDir(), "summary.md")) + event := loadFixtureEvent(t, "github_event_labeled.json") + client := &fakeGitHub{latestSHA: event.PullRequest.Head.SHA} + upCalled := false + sync := newSync(event, GithubSyncOptions{Label: "pullpreview", Common: CommonOptions{}}, client, fakeProvider{running: true}) + sync.runUp = func(opts UpOptions, provider Provider, logger *Logger) (*Instance, error) { + upCalled = true + inst := NewInstance(opts.Name, opts.Common, provider, logger) + inst.Access = AccessDetails{Username: "ec2-user", IPAddress: "1.2.3.4"} + return inst, nil + } + if err := sync.Sync(); err != nil { + t.Fatalf("Sync() returned error: %v", err) + } + if !upCalled { + t.Fatalf("expected runUp to be called") + } + if len(client.createdComments) != 1 { + t.Fatalf("expected initial PR comment creation, got %d", len(client.createdComments)) + } + if len(client.updatedComments) == 0 { + t.Fatalf("expected PR comment update on deployed state") + } + if !strings.Contains(client.updatedComments[len(client.updatedComments)-1], "✅ Deploy successful") { + t.Fatalf("expected successful deploy text in comment, got %q", client.updatedComments[len(client.updatedComments)-1]) + } + if !strings.Contains(client.updatedComments[len(client.updatedComments)-1], "[⚡](https://pullpreview.com) PullPreview") { + t.Fatalf("expected pullpreview lightning link in comment title, got %q", client.updatedComments[len(client.updatedComments)-1]) + } + if !strings.Contains(client.updatedComments[len(client.updatedComments)-1], "/actions/runs/12345/job/67890") { + t.Fatalf("expected job URL log link in comment, got %q", client.updatedComments[len(client.updatedComments)-1]) + } +} + +func TestSyncLabeledProxyTLSUsesHTTPSURLInComment(t *testing.T) { + t.Setenv("PULLPREVIEW_TEST", "1") + t.Setenv("GITHUB_SERVER_URL", "https://github.com") + t.Setenv("GITHUB_RUN_ID", "12345") + + event := loadFixtureEvent(t, "github_event_labeled.json") + client := &fakeGitHub{latestSHA: event.PullRequest.Head.SHA} + sync := newSync(event, GithubSyncOptions{ + Label: "pullpreview", + Common: CommonOptions{ProxyTLS: "web:80"}, + }, client, fakeProvider{running: true}) + + if err := sync.Sync(); err != nil { + t.Fatalf("Sync() returned error: %v", err) + } + if len(client.updatedComments) == 0 { + t.Fatalf("expected PR comment update on deployed state") + } + if !strings.Contains(client.updatedComments[len(client.updatedComments)-1], "https://") { + t.Fatalf("expected https preview URL in PR comment, got %q", client.updatedComments[len(client.updatedComments)-1]) + } +} + +func TestSyncClosedPRRunsDownAndRemovesLabel(t *testing.T) { + t.Setenv("PULLPREVIEW_TEST", "1") + t.Setenv("GITHUB_SERVER_URL", "https://github.com") + t.Setenv("GITHUB_RUN_ID", "12345") + t.Setenv("GITHUB_STEP_SUMMARY", filepath.Join(t.TempDir(), "summary.md")) + event := GitHubEvent{ + Action: "closed", + PullRequest: &GitHubPR{ + Number: 10, + Head: GitHubPRHead{SHA: "abc", Ref: "feature"}, + Base: GitHubPRBase{Repo: GitHubRepo{ID: 1, Name: "repo", Owner: GitHubOrg{Login: "org", ID: 2, Type: "Organization"}}}, + Labels: []GitHubLabel{{Name: "pullpreview"}}, + }, + Repository: GitHubRepo{ID: 1, Name: "repo", Owner: GitHubOrg{Login: "org", ID: 2, Type: "Organization"}}, + Organization: &GitHubOrg{Login: "org", ID: 2, Type: "Organization"}, + } + client := &fakeGitHub{latestSHA: "abc"} + downCalled := false + sync := newSync(event, GithubSyncOptions{Label: "pullpreview", Common: CommonOptions{}}, client, fakeProvider{running: true}) + sync.runDown = func(opts DownOptions, provider Provider, logger *Logger) error { + downCalled = true + return nil + } + if err := sync.Sync(); err != nil { + t.Fatalf("Sync() returned error: %v", err) + } + if !downCalled { + t.Fatalf("expected runDown to be called") + } + if len(client.removedLabels) == 0 || client.removedLabels[0] != "pullpreview" { + t.Fatalf("expected pullpreview label removal, got %v", client.removedLabels) + } + if len(client.createdComments) != 1 { + t.Fatalf("expected a destroying PR comment to be created") + } + if !strings.Contains(client.createdComments[0], "🧹 Destroying preview...") { + t.Fatalf("unexpected destroying PR comment body: %q", client.createdComments[0]) + } + if len(client.updatedComments) == 0 { + t.Fatalf("expected destroyed PR comment update") + } + if !strings.Contains(client.updatedComments[len(client.updatedComments)-1], "🗑️ Preview destroyed") { + t.Fatalf("unexpected destroyed PR comment body: %q", client.updatedComments[len(client.updatedComments)-1]) + } +} + +func TestExpandedAdminsIncludesCollaboratorsWithPush(t *testing.T) { + event := loadFixtureEvent(t, "github_event_labeled.json") + client := &fakeGitHub{ + collaborators: []*gh.User{ + {Login: gh.String("alice"), Permissions: map[string]bool{"push": true}}, + {Login: gh.String("bob"), Permissions: map[string]bool{"push": false}}, + {Login: gh.String("team-user")}, + }, + } + sync := newSync(event, GithubSyncOptions{Label: "pullpreview", Common: CommonOptions{Admins: []string{"@collaborators/push", "manual"}}}, client, fakeProvider{running: true}) + admins := sync.expandedAdmins() + if len(admins) != 3 { + t.Fatalf("expected 3 admins, got %v", admins) + } + if admins[0] != "manual" && admins[1] != "manual" && admins[2] != "manual" { + t.Fatalf("expected manual admin to be preserved, got %v", admins) + } + if admins[0] != "alice" && admins[1] != "alice" && admins[2] != "alice" { + t.Fatalf("expected push collaborator to be included, got %v", admins) + } + if admins[0] != "team-user" && admins[1] != "team-user" && admins[2] != "team-user" { + t.Fatalf("expected team-derived collaborator to be included, got %v", admins) + } +} + +func TestValidateDeploymentVariant(t *testing.T) { + sync := newSync(loadFixtureEvent(t, "github_event_labeled.json"), GithubSyncOptions{ + Label: "pullpreview", + DeploymentVariant: "abcdef", + Common: CommonOptions{}, + }, &fakeGitHub{}, fakeProvider{running: true}) + + if err := sync.validateDeploymentVariant(); err == nil { + t.Fatalf("expected validation error for long deployment variant") + } +} + +func TestRunGithubSyncFromEnvironmentRunsUpForLabeledPR(t *testing.T) { + t.Setenv("PULLPREVIEW_TEST", "1") + event := loadFixtureEvent(t, "github_event_labeled.json") + path := writeFixtureToTempEventFile(t, event) + t.Setenv("GITHUB_EVENT_NAME", "pull_request") + t.Setenv("GITHUB_EVENT_PATH", path) + t.Setenv("GITHUB_REPOSITORY", "pullpreview/action") + t.Setenv("GITHUB_REF", "refs/heads/test-action") + + client := &fakeGitHub{latestSHA: event.PullRequest.Head.SHA} + originalClientFactory := newGitHubClient + originalRunUp := runUpFunc + originalRunDown := runDownFunc + defer func() { + newGitHubClient = originalClientFactory + runUpFunc = originalRunUp + runDownFunc = originalRunDown + }() + newGitHubClient = func(ctx context.Context, token string) GitHubAPI { return client } + upCalled := false + runUpFunc = func(opts UpOptions, provider Provider, logger *Logger) (*Instance, error) { + upCalled = true + inst := NewInstance(opts.Name, opts.Common, provider, logger) + inst.Access = AccessDetails{Username: "ec2-user", IPAddress: "1.2.3.4"} + return inst, nil + } + runDownFunc = func(opts DownOptions, provider Provider, logger *Logger) error { return nil } + + err := RunGithubSync(GithubSyncOptions{AppPath: "/tmp/app", Label: "pullpreview", Common: CommonOptions{}}, fakeProvider{running: true}, nil) + if err != nil { + t.Fatalf("RunGithubSync() error: %v", err) + } + if !upCalled { + t.Fatalf("expected up flow to be executed") + } +} + +func TestRenderPRCommentForErrorState(t *testing.T) { + event := loadFixtureEvent(t, "github_event_labeled.json") + sync := newSync(event, GithubSyncOptions{Label: "pullpreview", Common: CommonOptions{}}, &fakeGitHub{}, fakeProvider{running: true}) + body := sync.renderPRComment(statusError, "") + if !strings.Contains(body, "❌ Deploy failed") { + t.Fatalf("unexpected error comment body: %q", body) + } + if !strings.Contains(body, sync.prCommentMarker()) { + t.Fatalf("missing marker in rendered comment") + } +} + +func TestRenderPRCommentForDestroyedState(t *testing.T) { + event := loadFixtureEvent(t, "github_event_labeled.json") + sync := newSync(event, GithubSyncOptions{Label: "pullpreview", Common: CommonOptions{}}, &fakeGitHub{}, fakeProvider{running: true}) + body := sync.renderPRComment(statusDestroyed, "") + if !strings.Contains(body, "🗑️ Preview destroyed") { + t.Fatalf("unexpected destroyed comment body: %q", body) + } + if !strings.Contains(body, "| Preview URL | _Destroyed_ |") { + t.Fatalf("expected destroyed preview marker in comment body: %q", body) + } + if !strings.Contains(body, sync.prCommentMarker()) { + t.Fatalf("missing marker in rendered comment") + } + if !strings.Contains(body, "[⚡](https://pullpreview.com) PullPreview") { + t.Fatalf("missing pullpreview lightning title: %q", body) + } +} + +func TestRenderPRCommentIncludesVariantAndJob(t *testing.T) { + t.Setenv("GITHUB_JOB", "deploy_env1") + event := loadFixtureEvent(t, "github_event_labeled.json") + sync := newSync(event, GithubSyncOptions{ + Label: "pullpreview-multi-env", + DeploymentVariant: "env1", + Common: CommonOptions{}, + }, &fakeGitHub{}, fakeProvider{running: true}) + body := sync.renderPRComment(statusDeploying, "") + if !strings.Contains(body, "| Variant | `env1` |") { + t.Fatalf("expected variant row in comment body: %q", body) + } + if !strings.Contains(body, "| Job | `deploy_env1` |") { + t.Fatalf("expected job row in comment body: %q", body) + } +} + +func TestUpdatePRCommentTargetsMatchingVariantAndJobMarker(t *testing.T) { + event := loadFixtureEvent(t, "github_event_labeled.json") + client := &fakeGitHub{} + syncEnv1 := newSync(event, GithubSyncOptions{ + Label: "pullpreview-multi-env", + DeploymentVariant: "env1", + Common: CommonOptions{}, + }, client, fakeProvider{running: true}) + syncEnv2 := newSync(event, GithubSyncOptions{ + Label: "pullpreview-multi-env", + DeploymentVariant: "env2", + Common: CommonOptions{}, + }, client, fakeProvider{running: true}) + + t.Setenv("GITHUB_JOB", "deploy_env1") + env1Marker := syncEnv1.prCommentMarker() + t.Setenv("GITHUB_JOB", "deploy_env2") + env2Marker := syncEnv2.prCommentMarker() + t.Setenv("GITHUB_JOB", "deploy_env1") + + client.comments = []*gh.IssueComment{ + {ID: gh.Int64(101), Body: gh.String(env1Marker + "\nold env1 body")}, + {ID: gh.Int64(102), Body: gh.String(env2Marker + "\nold env2 body")}, + } + + syncEnv1.updatePRComment(statusDeployed, "https://env1.preview.example") + + if len(client.updatedComments) != 1 { + t.Fatalf("expected exactly one updated comment, got %d", len(client.updatedComments)) + } + if !strings.Contains(client.comments[0].GetBody(), "https://env1.preview.example") { + t.Fatalf("expected env1 comment update, got %q", client.comments[0].GetBody()) + } + if strings.Contains(client.comments[1].GetBody(), "https://env1.preview.example") { + t.Fatalf("env2 comment was incorrectly updated: %q", client.comments[1].GetBody()) + } +} + +func TestRenderStepSummaryForDeployedState(t *testing.T) { + t.Setenv("GITHUB_SERVER_URL", "https://github.com") + t.Setenv("GITHUB_RUN_ID", "777") + t.Setenv("PULLPREVIEW_GITHUB_JOB_ID", "888") + event := loadFixtureEvent(t, "github_event_labeled.json") + sync := newSync(event, GithubSyncOptions{Label: "pullpreview", Common: CommonOptions{}}, &fakeGitHub{}, fakeProvider{running: true}) + inst := NewInstance(sync.instanceName(), CommonOptions{}, fakeProvider{}, nil) + inst.Access = AccessDetails{Username: "ec2-user", IPAddress: "1.2.3.4"} + + body := sync.renderStepSummary(statusDeployed, actionPRUp, "https://preview.test", inst) + if !strings.Contains(body, "## PullPreview Summary") { + t.Fatalf("missing summary header: %q", body) + } + if !strings.Contains(body, "- Preview URL: [https://preview.test](https://preview.test)") { + t.Fatalf("missing preview URL link: %q", body) + } + if !strings.Contains(body, "- SSH Command: `ssh ec2-user@1.2.3.4`") { + t.Fatalf("missing ssh command: %q", body) + } + if !strings.Contains(body, "/actions/runs/777/job/888") { + t.Fatalf("missing job-level logs URL: %q", body) + } + if !strings.Contains(body, "Powered by [⚡](https://pullpreview.com) PullPreview.") { + t.Fatalf("missing powered by line: %q", body) + } +} + +func TestRunGithubSyncFromEnvironmentRunsDownForBranchPushWithoutAlwaysOn(t *testing.T) { + t.Setenv("PULLPREVIEW_TEST", "1") + event := loadFixtureEvent(t, "github_event_push_solo_organization.json") + path := writeFixtureToTempEventFile(t, event) + t.Setenv("GITHUB_EVENT_NAME", "push") + t.Setenv("GITHUB_EVENT_PATH", path) + t.Setenv("GITHUB_REPOSITORY", "pullpreview/action") + t.Setenv("GITHUB_REF", event.Ref) + + client := &fakeGitHub{latestSHA: event.HeadCommit.ID} + originalClientFactory := newGitHubClient + originalRunUp := runUpFunc + originalRunDown := runDownFunc + defer func() { + newGitHubClient = originalClientFactory + runUpFunc = originalRunUp + runDownFunc = originalRunDown + }() + newGitHubClient = func(ctx context.Context, token string) GitHubAPI { return client } + runUpFunc = func(opts UpOptions, provider Provider, logger *Logger) (*Instance, error) { + t.Fatalf("runUp should not be called for branch down action") + return nil, nil + } + downCalled := false + runDownFunc = func(opts DownOptions, provider Provider, logger *Logger) error { + downCalled = true + return nil + } + + err := RunGithubSync(GithubSyncOptions{AppPath: "/tmp/app", Label: "pullpreview", Common: CommonOptions{}}, fakeProvider{running: true}, nil) + if err != nil { + t.Fatalf("RunGithubSync() error: %v", err) + } + if !downCalled { + t.Fatalf("expected down flow to be executed") + } +} + +func writeFixtureToTempEventFile(t *testing.T, event GitHubEvent) string { + t.Helper() + path := filepath.Join(t.TempDir(), "event.json") + content, err := json.Marshal(event) + if err != nil { + t.Fatalf("failed marshalling event: %v", err) + } + if err := os.WriteFile(path, content, 0644); err != nil { + t.Fatalf("failed writing temp event file: %v", err) + } + return path +} diff --git a/internal/pullpreview/instance.go b/internal/pullpreview/instance.go new file mode 100644 index 0000000..a76353e --- /dev/null +++ b/internal/pullpreview/instance.go @@ -0,0 +1,421 @@ +package pullpreview + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "strconv" + "strings" + "time" +) + +const remoteAppPath = "/app" + +type Runner interface { + Run(cmd *exec.Cmd) error +} + +type SystemRunner struct{} + +func (r SystemRunner) Run(cmd *exec.Cmd) error { + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +type Instance struct { + Name string + Subdomain string + Admins []string + AdminPublicKeys []string + Context context.Context + CIDRs []string + ComposeFiles []string + ComposeOptions []string + DefaultPort string + DNS string + Ports []string + ProxyTLS string + Provider Provider + Registries []string + Size string + Tags map[string]string + PreScript string + Access AccessDetails + Logger *Logger + Runner Runner +} + +func NewInstance(name string, opts CommonOptions, provider Provider, logger *Logger) *Instance { + normalized := NormalizeName(name) + defaultPort := defaultString(opts.DefaultPort, "80") + proxyTLS := strings.TrimSpace(opts.ProxyTLS) + if proxyTLS != "" { + if defaultPort != "443" && logger != nil { + logger.Warnf("proxy_tls=%q enabled: overriding default_port=%s to 443", proxyTLS, defaultPort) + } + defaultPort = "443" + } + return &Instance{ + Name: normalized, + Subdomain: NormalizeName(name), + Admins: opts.Admins, + AdminPublicKeys: opts.AdminPublicKeys, + Context: ensureContext(opts.Context), + CIDRs: defaultSlice(opts.CIDRs, []string{"0.0.0.0/0"}), + ComposeFiles: defaultSlice(opts.ComposeFiles, []string{"docker-compose.yml"}), + ComposeOptions: defaultSlice(opts.ComposeOptions, []string{"--build"}), + DefaultPort: defaultPort, + DNS: defaultString(opts.DNS, "my.preview.run"), + Ports: opts.Ports, + ProxyTLS: proxyTLS, + Provider: provider, + Registries: opts.Registries, + Size: opts.InstanceType, + Tags: defaultMap(opts.Tags), + PreScript: opts.PreScript, + Logger: logger, + Runner: SystemRunner{}, + } +} + +func (i *Instance) WithSubdomain(subdomain string) { + if subdomain == "" { + return + } + i.Subdomain = NormalizeName(subdomain) +} + +func defaultSlice(value, fallback []string) []string { + if len(value) == 0 { + return fallback + } + return value +} + +func defaultString(value, fallback string) string { + if strings.TrimSpace(value) == "" { + return fallback + } + return value +} + +func defaultMap(value map[string]string) map[string]string { + if value == nil { + return map[string]string{} + } + return value +} + +func (i *Instance) LaunchAndWait() error { + userData := UserData{AppPath: remoteAppPath, SSHPublicKeys: i.SSHPublicKeys(), Username: i.Username()} + access, err := i.Provider.Launch(i.Name, LaunchOptions{ + Size: i.Size, + UserData: userData.Script(), + Ports: i.PortsWithDefaults(), + CIDRs: i.CIDRs, + Tags: i.Tags, + }) + if err != nil { + return err + } + i.Access = access + if i.Logger != nil { + i.Logger.Infof("Instance is running public_ip=%s", i.PublicIP()) + } + if ok := WaitUntilContext(i.Context, 30, 5*time.Second, func() bool { + if i.Logger != nil { + i.Logger.Infof( + "Waiting for SSH username=%s ip=%s ssh=\"ssh %s\"", + i.Username(), + i.PublicIP(), + i.SSHAddress(), + ) + } + return i.SSHReady() + }); !ok { + return errors.New("can't connect to instance over SSH") + } + if i.Logger != nil { + i.Logger.Infof("Instance ssh access OK") + } + return nil +} + +func (i *Instance) Terminate() error { + return i.Provider.Terminate(i.Name) +} + +func (i *Instance) Running() (bool, error) { + return i.Provider.Running(i.Name) +} + +func (i *Instance) SSHReady() bool { + return i.SSH("test -f /etc/pullpreview/ready", nil) == nil +} + +func (i *Instance) PublicIP() string { + return i.Access.IPAddress +} + +func (i *Instance) PublicDNS() string { + return PublicDNS(i.Subdomain, i.DNS, i.PublicIP()) +} + +func (i *Instance) URL() string { + scheme := "http" + if i.DefaultPort == "443" { + scheme = "https" + } + return fmt.Sprintf("%s://%s:%s", scheme, i.PublicDNS(), i.DefaultPort) +} + +func (i *Instance) Username() string { + if i.Access.Username != "" { + return i.Access.Username + } + return i.Provider.Username() +} + +func (i *Instance) PortsWithDefaults() []string { + proxyTLSEnabled := strings.TrimSpace(i.ProxyTLS) != "" + ports := []string{} + for _, port := range i.Ports { + if proxyTLSEnabled && firewallRuleTargetsPort(port, 80) { + continue + } + ports = append(ports, port) + } + ports = append(ports, i.DefaultPort, "22") + return uniqueStrings(ports) +} + +func firewallRuleTargetsPort(rule string, port int) bool { + value := strings.TrimSpace(rule) + if value == "" { + return false + } + if idx := strings.Index(value, "/"); idx >= 0 { + value = value[:idx] + } + if strings.Contains(value, ":") { + parts := strings.Split(value, ":") + if len(parts) > 0 { + value = parts[len(parts)-1] + } + } + parsed, err := strconv.Atoi(strings.TrimSpace(value)) + if err != nil { + return false + } + return parsed == port +} + +func uniqueStrings(values []string) []string { + seen := map[string]struct{}{} + result := []string{} + for _, v := range values { + v = strings.TrimSpace(v) + if v == "" { + continue + } + if _, ok := seen[v]; ok { + continue + } + seen[v] = struct{}{} + result = append(result, v) + } + return result +} + +func (i *Instance) SSHPublicKeys() []string { + if len(i.AdminPublicKeys) > 0 { + return uniqueStrings(i.AdminPublicKeys) + } + + keys := []string{} + client := http.Client{Timeout: 10 * time.Second} + for _, admin := range i.Admins { + admin = strings.TrimSpace(admin) + if admin == "" { + continue + } + url := fmt.Sprintf("https://github.com/%s.keys", admin) + resp, err := client.Get(url) + if err != nil { + if i.Logger != nil { + i.Logger.Warnf("Unable to fetch SSH keys for %s: %v", admin, err) + } + continue + } + body, err := io.ReadAll(resp.Body) + _ = resp.Body.Close() + if err != nil { + continue + } + for _, line := range strings.Split(string(body), "\n") { + line = strings.TrimSpace(line) + if line != "" { + keys = append(keys, line) + } + } + } + return keys +} + +func (i *Instance) SetupSSHAccess() error { + keys := i.SSHPublicKeys() + content := strings.Join(keys, "\n") + "\n" + return i.SCP(bytes.NewBufferString(content), fmt.Sprintf("/home/%s/.ssh/authorized_keys", i.Username()), "0600") +} + +func (i *Instance) SetupPreScript() error { + script := BuildPreScript(i.Registries, i.PreScript, i.Logger) + return i.SCP(bytes.NewBufferString(script), "/tmp/pre_script.sh", "0755") +} + +func (i *Instance) SCP(input io.Reader, target, mode string) error { + command := fmt.Sprintf("cat - > %s && chmod %s %s", target, mode, target) + return i.SSH(command, input) +} + +func (i *Instance) SSH(command string, input io.Reader) error { + keyFile, certFile, err := i.writeTempKeys() + if err != nil { + return err + } + defer func() { + _ = os.Remove(keyFile) + if certFile != "" { + _ = os.Remove(certFile) + } + }() + + args := []string{} + if i.Logger != nil && i.Logger.level <= LevelDebug { + args = append(args, "-v") + } + args = append(args, + "-o", "ServerAliveInterval=15", + "-o", "IdentitiesOnly=yes", + "-i", keyFile, + ) + args = append(args, i.SSHOptions()...) + args = append(args, i.SSHAddress()) + args = append(args, command) + + cmd := exec.CommandContext(i.Context, "ssh", args...) + cmd.Stdin = input + if input == nil { + cmd.Stdin = os.Stdin + } + return i.Runner.Run(cmd) +} + +func (i *Instance) SSHAddress() string { + username := i.Username() + if username == "" { + return i.PublicIP() + } + return fmt.Sprintf("%s@%s", username, i.PublicIP()) +} + +func (i *Instance) SSHOptions() []string { + return []string{ + "-o", "StrictHostKeyChecking=no", + "-o", "UserKnownHostsFile=/dev/null", + "-o", "LogLevel=ERROR", + "-o", "ConnectTimeout=10", + } +} + +func (i *Instance) writeTempKeys() (string, string, error) { + keyFile, err := os.CreateTemp("", "pullpreview-key-*") + if err != nil { + return "", "", err + } + if _, err := keyFile.WriteString(i.Access.PrivateKey + "\n"); err != nil { + _ = keyFile.Close() + return "", "", err + } + if err := keyFile.Close(); err != nil { + return "", "", err + } + if err := os.Chmod(keyFile.Name(), 0600); err != nil { + return "", "", err + } + + certFile := "" + if strings.TrimSpace(i.Access.CertKey) != "" { + certFile = keyFile.Name() + "-cert.pub" + if err := os.WriteFile(certFile, []byte(i.Access.CertKey+"\n"), 0600); err != nil { + return "", "", err + } + } + + return keyFile.Name(), certFile, nil +} + +func (i *Instance) EnsureRemoteAuthorizedKeysOwner() error { + command := fmt.Sprintf("chown %s.%s /home/%s/.ssh/authorized_keys && chmod 0600 /home/%s/.ssh/authorized_keys", i.Username(), i.Username(), i.Username(), i.Username()) + return i.SSH(command, nil) +} + +func (i *Instance) UpdateFromTarball(appPath, tarballPath string) error { + return i.DeployWithDockerContext(appPath, tarballPath) +} + +func (i *Instance) SetupScripts() error { + if err := i.SetupSSHAccess(); err != nil { + return err + } + if err := i.EnsureRemoteAuthorizedKeysOwner(); err != nil { + return err + } + if err := i.SetupPreScript(); err != nil { + return err + } + return nil +} + +func (i *Instance) LocalTarballPath(appPath string) (string, func(), error) { + return CreateTarball(appPath) +} + +func (i *Instance) CloneIfURL(appPath string) (string, func(), error) { + if strings.HasPrefix(appPath, "http://") || strings.HasPrefix(appPath, "https://") { + parts := strings.SplitN(appPath, "#", 2) + gitURL := parts[0] + ref := "master" + if len(parts) == 2 && parts[1] != "" { + ref = parts[1] + } + tmpDir, err := os.MkdirTemp("", "pullpreview-app-*") + if err != nil { + return "", nil, err + } + cleanup := func() { _ = os.RemoveAll(tmpDir) } + cmd := exec.CommandContext(i.Context, "git", "clone", gitURL, "--depth=1", "--branch", ref, tmpDir) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := i.Runner.Run(cmd); err != nil { + cleanup() + return "", nil, err + } + return tmpDir, cleanup, nil + } + return appPath, func() {}, nil +} + +func (i *Instance) AppSizeMB(path string) float64 { + info, err := os.Stat(path) + if err != nil { + return 0 + } + return float64(info.Size()) / 1024.0 / 1024.0 +} diff --git a/internal/pullpreview/instance_test.go b/internal/pullpreview/instance_test.go new file mode 100644 index 0000000..d3a9b2b --- /dev/null +++ b/internal/pullpreview/instance_test.go @@ -0,0 +1,168 @@ +package pullpreview + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +type captureRunner struct { + args [][]string +} + +func (r *captureRunner) Run(cmd *exec.Cmd) error { + r.args = append(r.args, append([]string{}, cmd.Args...)) + return nil +} + +func TestPortsWithDefaultsDeduplicatesValues(t *testing.T) { + inst := NewInstance("example", CommonOptions{ + Ports: []string{"443/tcp", "22", "443/tcp"}, + DefaultPort: "443", + }, fakeProvider{}, nil) + + got := inst.PortsWithDefaults() + expected := map[string]bool{ + "443/tcp": true, + "22": true, + "443": true, + } + if len(got) != len(expected) { + t.Fatalf("unexpected ports list: %#v", got) + } + for _, p := range got { + if !expected[p] { + t.Fatalf("unexpected port %q in %#v", p, got) + } + } +} + +func TestURLUsesHTTPSForPort443(t *testing.T) { + inst := NewInstance("my-app", CommonOptions{DNS: "my.preview.run", DefaultPort: "443"}, fakeProvider{}, nil) + inst.Access = AccessDetails{IPAddress: "1.2.3.4", Username: "ec2-user"} + url := inst.URL() + if !strings.HasPrefix(url, "https://") { + t.Fatalf("expected https URL, got %q", url) + } + if !strings.Contains(url, ":443") { + t.Fatalf("expected :443 in URL, got %q", url) + } +} + +func TestProxyTLSForcesHTTPSDefaults(t *testing.T) { + inst := NewInstance("my-app", CommonOptions{ + DNS: "my.preview.run", + DefaultPort: "8080", + Ports: []string{"1234/tcp", "80", "80/tcp"}, + ProxyTLS: "web:80", + }, fakeProvider{}, nil) + inst.Access = AccessDetails{IPAddress: "1.2.3.4", Username: "ec2-user"} + + if inst.DefaultPort != "443" { + t.Fatalf("expected default port to be forced to 443, got %q", inst.DefaultPort) + } + ports := inst.PortsWithDefaults() + expected := map[string]bool{ + "1234/tcp": true, + "443": true, + "22": true, + } + if len(ports) != len(expected) { + t.Fatalf("unexpected ports list: %#v", ports) + } + for _, port := range ports { + if !expected[port] { + t.Fatalf("unexpected port %q in %#v", port, ports) + } + } + if !strings.HasPrefix(inst.URL(), "https://") { + t.Fatalf("expected https URL with proxy_tls enabled, got %q", inst.URL()) + } +} + +func TestFirewallRuleTargetsPort(t *testing.T) { + cases := []struct { + rule string + port int + expect bool + }{ + {rule: "80", port: 80, expect: true}, + {rule: "80/tcp", port: 80, expect: true}, + {rule: "0.0.0.0:80", port: 80, expect: true}, + {rule: "443", port: 80, expect: false}, + {rule: "8080/tcp", port: 80, expect: false}, + } + + for _, tc := range cases { + got := firewallRuleTargetsPort(tc.rule, tc.port) + if got != tc.expect { + t.Fatalf("firewallRuleTargetsPort(%q, %d)=%v, want %v", tc.rule, tc.port, got, tc.expect) + } + } +} + +func TestWriteTempKeysWritesPrivateAndCertFiles(t *testing.T) { + inst := NewInstance("my-app", CommonOptions{}, fakeProvider{}, nil) + inst.Access = AccessDetails{PrivateKey: "PRIVATE", CertKey: "CERT"} + + keyPath, certPath, err := inst.writeTempKeys() + if err != nil { + t.Fatalf("writeTempKeys() error: %v", err) + } + defer os.Remove(keyPath) + defer os.Remove(certPath) + + keyContent, err := os.ReadFile(keyPath) + if err != nil { + t.Fatalf("failed reading private key file: %v", err) + } + if !strings.Contains(string(keyContent), "PRIVATE") { + t.Fatalf("private key not written correctly: %q", string(keyContent)) + } + if certPath != keyPath+"-cert.pub" { + t.Fatalf("unexpected cert file path: %q", certPath) + } + certContent, err := os.ReadFile(certPath) + if err != nil { + t.Fatalf("failed reading cert key file: %v", err) + } + if !strings.Contains(string(certContent), "CERT") { + t.Fatalf("cert key not written correctly: %q", string(certContent)) + } +} + +func TestCloneIfURLNoOpForLocalPath(t *testing.T) { + inst := NewInstance("my-app", CommonOptions{}, fakeProvider{}, nil) + path := filepath.Join(t.TempDir(), "app") + if err := os.MkdirAll(path, 0755); err != nil { + t.Fatalf("failed creating local app path: %v", err) + } + gotPath, cleanup, err := inst.CloneIfURL(path) + if err != nil { + t.Fatalf("CloneIfURL() error: %v", err) + } + cleanup() + if gotPath != path { + t.Fatalf("expected local path passthrough, got %q", gotPath) + } +} + +func TestSSHBuildsCommandWithExpectedArguments(t *testing.T) { + inst := NewInstance("my-app", CommonOptions{}, fakeProvider{}, nil) + inst.Access = AccessDetails{IPAddress: "1.2.3.4", Username: "ec2-user", PrivateKey: "PRIVATE"} + runner := &captureRunner{} + inst.Runner = runner + + if err := inst.SSH("echo ok", nil); err != nil { + t.Fatalf("SSH() error: %v", err) + } + if len(runner.args) != 1 { + t.Fatalf("expected one ssh command execution, got %d", len(runner.args)) + } + args := strings.Join(runner.args[0], " ") + if !strings.Contains(args, "ec2-user@1.2.3.4") || !strings.Contains(args, "echo ok") { + t.Fatalf("unexpected ssh command args: %s", args) + } +} diff --git a/internal/pullpreview/list.go b/internal/pullpreview/list.go new file mode 100644 index 0000000..89fd393 --- /dev/null +++ b/internal/pullpreview/list.go @@ -0,0 +1,30 @@ +package pullpreview + +import ( + "errors" + "fmt" +) + +func RunList(opts ListOptions, provider Provider, logger *Logger) error { + if opts.Org == "" && opts.Repo == "" { + return errors.New("invalid org/repo given") + } + tags := map[string]string{ + "stack": StackName, + } + if opts.Repo != "" { + tags["repo_name"] = opts.Repo + } + if opts.Org != "" { + tags["org_name"] = opts.Org + } + instances, err := provider.ListInstances(tags) + if err != nil { + return err + } + fmt.Printf("Name\tIP\tSize\tRegion\tAZ\tCreated On\tTags\n") + for _, inst := range instances { + fmt.Printf("%s\t%s\t%s\t%s\t%s\t%s\t%v\n", inst.Name, inst.PublicIP, inst.Size, inst.Region, inst.Zone, inst.CreatedAt.Format("2006-01-02T15:04:05Z"), inst.Tags) + } + return nil +} diff --git a/internal/pullpreview/logger.go b/internal/pullpreview/logger.go new file mode 100644 index 0000000..457ccad --- /dev/null +++ b/internal/pullpreview/logger.go @@ -0,0 +1,78 @@ +package pullpreview + +import ( + "fmt" + "log" + "os" + "strings" + "time" +) + +type LogLevel int + +const ( + LevelDebug LogLevel = iota + LevelInfo + LevelWarn + LevelError +) + +type Logger struct { + level LogLevel + base *log.Logger +} + +func NewLogger(level LogLevel) *Logger { + return &Logger{ + level: level, + base: log.New(os.Stdout, "", 0), + } +} + +func ParseLogLevel(value string) LogLevel { + switch strings.ToUpper(strings.TrimSpace(value)) { + case "DEBUG": + return LevelDebug + case "INFO": + return LevelInfo + case "WARN", "WARNING": + return LevelWarn + case "ERROR": + return LevelError + default: + return LevelInfo + } +} + +func (l *Logger) SetLevel(level LogLevel) { + l.level = level +} + +func (l *Logger) Debugf(format string, args ...any) { + if l.level <= LevelDebug { + l.printf("DEBUG", format, args...) + } +} + +func (l *Logger) Infof(format string, args ...any) { + if l.level <= LevelInfo { + l.printf("INFO", format, args...) + } +} + +func (l *Logger) Warnf(format string, args ...any) { + if l.level <= LevelWarn { + l.printf("WARN", format, args...) + } +} + +func (l *Logger) Errorf(format string, args ...any) { + if l.level <= LevelError { + l.printf("ERROR", format, args...) + } +} + +func (l *Logger) printf(prefix string, format string, args ...any) { + timestamp := time.Now().Format(time.RFC3339) + l.base.Printf("%s %s %s", timestamp, prefix, fmt.Sprintf(format, args...)) +} diff --git a/internal/pullpreview/naming.go b/internal/pullpreview/naming.go new file mode 100644 index 0000000..f5e6520 --- /dev/null +++ b/internal/pullpreview/naming.go @@ -0,0 +1,68 @@ +package pullpreview + +import ( + "os" + "regexp" + "strconv" + "strings" +) + +const ( + defaultMaxDomainLength = 62 +) + +var nonAlphaNum = regexp.MustCompile(`[^a-zA-Z0-9]+`) +var multiHyphen = regexp.MustCompile(`-+`) + +func NormalizeName(name string) string { + clean := nonAlphaNum.ReplaceAllString(name, "-") + clean = strings.Trim(clean, "-") + clean = strings.Join(strings.FieldsFunc(clean, func(r rune) bool { return r == '-' }), "-") + if len(clean) > 61 { + clean = clean[:61] + } + return strings.Trim(clean, "-") +} + +func MaxDomainLength() int { + value := strings.TrimSpace(os.Getenv("PULLPREVIEW_MAX_DOMAIN_LENGTH")) + if value == "" { + return defaultMaxDomainLength + } + parsed, err := strconv.Atoi(value) + if err != nil || parsed <= 0 || parsed > defaultMaxDomainLength { + return defaultMaxDomainLength + } + return parsed +} + +func ReservedSpaceForUserSubdomain(maxLen int) int { + if maxLen != defaultMaxDomainLength { + return 0 + } + return 8 +} + +func PublicDNS(subdomain, dns, publicIP string) string { + maxLen := MaxDomainLength() + reserved := ReservedSpaceForUserSubdomain(maxLen) + remaining := maxLen - reserved - len(dns) - len(publicIP) - len("ip") - 3 + if remaining < 0 { + remaining = 0 + } + if len(subdomain) > remaining { + subdomain = subdomain[:remaining] + } + ipComponent := strings.ReplaceAll(publicIP, ".", "-") + parts := []string{} + if subdomain != "" { + parts = append(parts, subdomain) + } + parts = append(parts, "ip", ipComponent) + prefix := strings.Join(parts, "-") + prefix = multiHyphen.ReplaceAllString(prefix, "-") + if dns == "" { + return prefix + } + return prefix + "." + dns +} diff --git a/internal/pullpreview/naming_test.go b/internal/pullpreview/naming_test.go new file mode 100644 index 0000000..f0e58b4 --- /dev/null +++ b/internal/pullpreview/naming_test.go @@ -0,0 +1,37 @@ +package pullpreview + +import ( + "strings" + "testing" +) + +func TestNormalizeName(t *testing.T) { + cases := map[string]string{ + "gh-123-pr-4": "gh-123-pr-4", + "My Repo!!": "My-Repo", + "foo---bar--baz": "foo-bar-baz", + "--leading--trailing-": "leading-trailing", + } + for input, expected := range cases { + if got := NormalizeName(input); got != expected { + t.Fatalf("NormalizeName(%q)=%q, want %q", input, got, expected) + } + } +} + +func TestPublicDNS(t *testing.T) { + // Default max domain length should allow full subdomain. + t.Setenv("PULLPREVIEW_MAX_DOMAIN_LENGTH", "62") + got := PublicDNS("feature-branch", "my.preview.run", "1.2.3.4") + want := "feature-branch-ip-1-2-3-4.my.preview.run" + if got != want { + t.Fatalf("PublicDNS=%q, want %q", got, want) + } + + // With a smaller limit, subdomain is truncated. + t.Setenv("PULLPREVIEW_MAX_DOMAIN_LENGTH", "40") + short := PublicDNS("verylongfeaturebranchname", "my.preview.run", "1.2.3.4") + if short == "" || !strings.HasSuffix(short, ".my.preview.run") { + t.Fatalf("PublicDNS unexpected value: %s", short) + } +} diff --git a/internal/pullpreview/pre_script.go b/internal/pullpreview/pre_script.go new file mode 100644 index 0000000..563485c --- /dev/null +++ b/internal/pullpreview/pre_script.go @@ -0,0 +1,23 @@ +package pullpreview + +import ( + "fmt" + "strings" +) + +func BuildPreScript(registries []string, preScript string, logger *Logger) string { + lines := []string{"#!/bin/bash -e"} + for _, registry := range ParseRegistryCredentials(registries, logger) { + lines = append(lines, + fmt.Sprintf("echo \"Logging into %s...\"", registry.Host), + fmt.Sprintf("echo \"%s\" | docker login \"%s\" -u \"%s\" --password-stdin", registry.Password, registry.Host, registry.Username), + ) + } + if strings.TrimSpace(preScript) != "" { + lines = append(lines, + fmt.Sprintf("echo 'Attempting to run pre-script at %s...'", preScript), + fmt.Sprintf("bash -e %s", preScript), + ) + } + return strings.Join(lines, "\n") + "\n" +} diff --git a/internal/pullpreview/pre_script_test.go b/internal/pullpreview/pre_script_test.go new file mode 100644 index 0000000..01adfa4 --- /dev/null +++ b/internal/pullpreview/pre_script_test.go @@ -0,0 +1,42 @@ +package pullpreview + +import ( + "strings" + "testing" +) + +func TestBuildPreScriptIncludesRegistriesAndPreScript(t *testing.T) { + script := BuildPreScript( + []string{ + "docker://user:pass@ghcr.io", + "docker://token@registry.example.com", + }, + "./scripts/pre.sh", + nil, + ) + + if !strings.Contains(script, `docker login "ghcr.io" -u "user" --password-stdin`) { + t.Fatalf("expected ghcr login command in script:\n%s", script) + } + if !strings.Contains(script, `docker login "registry.example.com" -u "doesnotmatter" --password-stdin`) { + t.Fatalf("expected token login command in script:\n%s", script) + } + if !strings.Contains(script, "bash -e ./scripts/pre.sh") { + t.Fatalf("expected pre-script invocation in script:\n%s", script) + } +} + +func TestBuildPreScriptSkipsInvalidRegistries(t *testing.T) { + script := BuildPreScript( + []string{ + "invalid", + "https://registry.example.com", + }, + "", + nil, + ) + + if strings.Contains(script, "docker login") { + t.Fatalf("expected no docker login commands for invalid registries:\n%s", script) + } +} diff --git a/internal/pullpreview/proxy_tls.go b/internal/pullpreview/proxy_tls.go new file mode 100644 index 0000000..b5b044e --- /dev/null +++ b/internal/pullpreview/proxy_tls.go @@ -0,0 +1,216 @@ +package pullpreview + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" +) + +type proxyTLSTarget struct { + Service string + Port int +} + +func parseProxyTLSTarget(raw string) (proxyTLSTarget, error) { + value := strings.TrimSpace(raw) + if value == "" { + return proxyTLSTarget{}, fmt.Errorf("proxy_tls value is empty") + } + + parts := strings.Split(value, ":") + if len(parts) != 2 { + return proxyTLSTarget{}, fmt.Errorf("proxy_tls must have format service:port") + } + + service := strings.TrimSpace(parts[0]) + if !validComposeServiceName(service) { + return proxyTLSTarget{}, fmt.Errorf("proxy_tls service %q is invalid", service) + } + + port, err := strconv.Atoi(strings.TrimSpace(parts[1])) + if err != nil || port < 1 || port > 65535 { + return proxyTLSTarget{}, fmt.Errorf("proxy_tls port %q is invalid", parts[1]) + } + + return proxyTLSTarget{Service: service, Port: port}, nil +} + +func validComposeServiceName(value string) bool { + if value == "" { + return false + } + for i := 0; i < len(value); i++ { + ch := value[i] + isLower := ch >= 'a' && ch <= 'z' + isUpper := ch >= 'A' && ch <= 'Z' + isDigit := ch >= '0' && ch <= '9' + if isLower || isUpper || isDigit || ch == '_' || ch == '-' || ch == '.' { + continue + } + return false + } + return true +} + +func applyProxyTLS(composeConfigJSON []byte, proxyTLS, publicDNS string, logger *Logger) ([]byte, error) { + proxyTLS = strings.TrimSpace(proxyTLS) + if proxyTLS == "" { + return composeConfigJSON, nil + } + + target, err := parseProxyTLSTarget(proxyTLS) + if err != nil { + return nil, err + } + if strings.TrimSpace(publicDNS) == "" { + return nil, fmt.Errorf("proxy_tls requires a non-empty public DNS") + } + + var config map[string]any + if err := json.Unmarshal(composeConfigJSON, &config); err != nil { + return nil, fmt.Errorf("unable to parse compose config: %w", err) + } + + rawServices, ok := config["services"].(map[string]any) + if !ok { + return nil, fmt.Errorf("compose config has no services") + } + + if _, ok := rawServices[target.Service]; !ok { + return nil, fmt.Errorf("proxy_tls target service %q not found in compose config", target.Service) + } + + if publishesHostPort443(rawServices) { + if logger != nil { + logger.Warnf("proxy_tls=%q ignored because compose already publishes host port 443", proxyTLS) + } + return composeConfigJSON, nil + } + + const proxyServiceName = "pullpreview-proxy" + if _, exists := rawServices[proxyServiceName]; exists { + return nil, fmt.Errorf("compose service %q already exists", proxyServiceName) + } + + rawServices[proxyServiceName] = map[string]any{ + "image": "caddy:2-alpine", + "restart": "unless-stopped", + "ports": []any{ + "443:443", + }, + "depends_on": []any{target.Service}, + "volumes": []any{ + "pullpreview_caddy_data:/data", + "pullpreview_caddy_config:/config", + }, + "command": []any{ + "caddy", + "reverse-proxy", + "--from", publicDNS, + "--to", fmt.Sprintf("%s:%d", target.Service, target.Port), + }, + } + + volumes, _ := config["volumes"].(map[string]any) + if volumes == nil { + volumes = map[string]any{} + config["volumes"] = volumes + } + if _, exists := volumes["pullpreview_caddy_data"]; !exists { + volumes["pullpreview_caddy_data"] = map[string]any{} + } + if _, exists := volumes["pullpreview_caddy_config"]; !exists { + volumes["pullpreview_caddy_config"] = map[string]any{} + } + + return json.Marshal(config) +} + +func publishesHostPort443(services map[string]any) bool { + for _, rawService := range services { + service, ok := rawService.(map[string]any) + if !ok { + continue + } + ports, ok := service["ports"].([]any) + if !ok { + continue + } + for _, rawPort := range ports { + if portPublishes443(rawPort) { + return true + } + } + } + return false +} + +func portPublishes443(value any) bool { + switch v := value.(type) { + case map[string]any: + published, ok := v["published"] + if !ok { + return false + } + return tokenContainsPort443(published) + case string: + raw := strings.TrimSpace(v) + if raw == "" { + return false + } + if idx := strings.Index(raw, "/"); idx >= 0 { + raw = raw[:idx] + } + parts := strings.Split(raw, ":") + hostPort := "" + if len(parts) == 1 { + hostPort = parts[0] + } else { + hostPort = parts[len(parts)-2] + } + return tokenContainsPort443(hostPort) + default: + return false + } +} + +func tokenContainsPort443(value any) bool { + switch v := value.(type) { + case int: + return v == 443 + case int32: + return v == 443 + case int64: + return v == 443 + case float64: + return int(v) == 443 + case string: + raw := strings.Trim(strings.TrimSpace(v), "[]") + if raw == "" { + return false + } + if strings.Contains(raw, "-") { + bounds := strings.SplitN(raw, "-", 2) + if len(bounds) != 2 { + return false + } + start, errStart := strconv.Atoi(strings.TrimSpace(bounds[0])) + end, errEnd := strconv.Atoi(strings.TrimSpace(bounds[1])) + if errStart != nil || errEnd != nil { + return false + } + if start > end { + start, end = end, start + } + return start <= 443 && 443 <= end + } + parsed, err := strconv.Atoi(raw) + if err != nil { + return false + } + return parsed == 443 + default: + return false + } +} diff --git a/internal/pullpreview/proxy_tls_test.go b/internal/pullpreview/proxy_tls_test.go new file mode 100644 index 0000000..c484a99 --- /dev/null +++ b/internal/pullpreview/proxy_tls_test.go @@ -0,0 +1,104 @@ +package pullpreview + +import ( + "encoding/json" + "testing" +) + +func TestParseProxyTLSTarget(t *testing.T) { + target, err := parseProxyTLSTarget("web:80") + if err != nil { + t.Fatalf("parseProxyTLSTarget() error: %v", err) + } + if target.Service != "web" || target.Port != 80 { + t.Fatalf("unexpected target: %#v", target) + } +} + +func TestParseProxyTLSTargetRejectsInvalidValue(t *testing.T) { + _, err := parseProxyTLSTarget("web") + if err == nil { + t.Fatalf("expected error for invalid proxy_tls value") + } +} + +func TestApplyProxyTLSInjectsCaddyProxyService(t *testing.T) { + input := map[string]any{ + "services": map[string]any{ + "web": map[string]any{ + "image": "nginx:alpine", + "ports": []any{ + "80:80", + }, + }, + }, + } + raw, err := json.Marshal(input) + if err != nil { + t.Fatalf("marshal input: %v", err) + } + + output, err := applyProxyTLS(raw, "web:80", "abc123.my.preview.run", nil) + if err != nil { + t.Fatalf("applyProxyTLS() error: %v", err) + } + + var result map[string]any + if err := json.Unmarshal(output, &result); err != nil { + t.Fatalf("unmarshal output: %v", err) + } + + services := result["services"].(map[string]any) + proxy := services["pullpreview-proxy"].(map[string]any) + if proxy["image"] != "caddy:2-alpine" { + t.Fatalf("unexpected proxy image: %#v", proxy["image"]) + } + ports := proxy["ports"].([]any) + if len(ports) != 1 || ports[0] != "443:443" { + t.Fatalf("unexpected proxy ports: %#v", ports) + } + command := proxy["command"].([]any) + if len(command) != 6 || command[2] != "--from" || command[3] != "abc123.my.preview.run" || command[4] != "--to" || command[5] != "web:80" { + t.Fatalf("unexpected proxy command: %#v", command) + } + volumes := result["volumes"].(map[string]any) + if _, ok := volumes["pullpreview_caddy_data"]; !ok { + t.Fatalf("expected pullpreview_caddy_data volume to be present") + } + if _, ok := volumes["pullpreview_caddy_config"]; !ok { + t.Fatalf("expected pullpreview_caddy_config volume to be present") + } +} + +func TestApplyProxyTLSSkipsWhenHostPort443AlreadyPublished(t *testing.T) { + input := map[string]any{ + "services": map[string]any{ + "web": map[string]any{ + "ports": []any{ + map[string]any{"target": 443, "published": "443", "protocol": "tcp"}, + }, + }, + }, + } + raw, err := json.Marshal(input) + if err != nil { + t.Fatalf("marshal input: %v", err) + } + + output, err := applyProxyTLS(raw, "web:80", "abc123.my.preview.run", nil) + if err != nil { + t.Fatalf("applyProxyTLS() error: %v", err) + } + + var result map[string]any + if err := json.Unmarshal(output, &result); err != nil { + t.Fatalf("unmarshal output: %v", err) + } + services := result["services"].(map[string]any) + if len(services) != 1 { + t.Fatalf("expected services to remain unchanged, got %#v", services) + } + if _, ok := services["pullpreview-proxy"]; ok { + t.Fatalf("did not expect proxy service when host port 443 is already published") + } +} diff --git a/internal/pullpreview/registries.go b/internal/pullpreview/registries.go new file mode 100644 index 0000000..0019fa2 --- /dev/null +++ b/internal/pullpreview/registries.go @@ -0,0 +1,56 @@ +package pullpreview + +import ( + "fmt" + "net/url" + "strings" +) + +type RegistryCredential struct { + Host string + Username string + Password string +} + +func ParseRegistryCredentials(registries []string, logger *Logger) []RegistryCredential { + parsed := []RegistryCredential{} + for i, raw := range registries { + value := strings.TrimSpace(raw) + if value == "" { + continue + } + cred, err := parseRegistryCredential(value) + if err != nil { + if logger != nil { + logger.Warnf("Registry #%d is invalid: %v", i, err) + } + continue + } + parsed = append(parsed, cred) + } + return parsed +} + +func parseRegistryCredential(value string) (RegistryCredential, error) { + uri, err := url.Parse(value) + if err != nil { + return RegistryCredential{}, err + } + if uri.Scheme != "docker" || strings.TrimSpace(uri.Host) == "" { + return RegistryCredential{}, fmt.Errorf("invalid registry") + } + username := uri.User.Username() + password, hasPassword := uri.User.Password() + if !hasPassword { + password = username + username = "doesnotmatter" + } + if strings.TrimSpace(password) == "" { + return RegistryCredential{}, fmt.Errorf("missing registry password/token") + } + return RegistryCredential{ + Host: uri.Host, + Username: username, + Password: password, + }, nil +} diff --git a/internal/pullpreview/registries_test.go b/internal/pullpreview/registries_test.go new file mode 100644 index 0000000..e829c41 --- /dev/null +++ b/internal/pullpreview/registries_test.go @@ -0,0 +1,32 @@ +package pullpreview + +import "testing" + +func TestParseRegistryCredentials(t *testing.T) { + got := ParseRegistryCredentials([]string{ + "docker://user:pass@ghcr.io", + "docker://token@registry.example.com", + "https://bad.example.com", + }, nil) + + if len(got) != 2 { + t.Fatalf("expected 2 valid registries, got %d", len(got)) + } + + if got[0].Host != "ghcr.io" || got[0].Username != "user" || got[0].Password != "pass" { + t.Fatalf("unexpected first credential: %#v", got[0]) + } + + if got[1].Host != "registry.example.com" || got[1].Username != "doesnotmatter" || got[1].Password != "token" { + t.Fatalf("unexpected second credential: %#v", got[1]) + } +} + +func TestParseRegistryCredentialRejectsInvalid(t *testing.T) { + if _, err := parseRegistryCredential("docker://@ghcr.io"); err == nil { + t.Fatalf("expected missing token to fail") + } + if _, err := parseRegistryCredential("https://ghcr.io"); err == nil { + t.Fatalf("expected non-docker scheme to fail") + } +} diff --git a/internal/pullpreview/tarball.go b/internal/pullpreview/tarball.go new file mode 100644 index 0000000..9706b18 --- /dev/null +++ b/internal/pullpreview/tarball.go @@ -0,0 +1,99 @@ +package pullpreview + +import ( + "archive/tar" + "compress/gzip" + "io" + "io/fs" + "os" + "path/filepath" + "strings" +) + +func CreateTarball(srcDir string) (string, func(), error) { + abs, err := filepath.Abs(srcDir) + if err != nil { + return "", nil, err + } + file, err := os.CreateTemp("", "pullpreview-app-*.tar.gz") + if err != nil { + return "", nil, err + } + cleanup := func() { + _ = os.Remove(file.Name()) + } + + gz := gzip.NewWriter(file) + tarWriter := tar.NewWriter(gz) + + walkErr := filepath.WalkDir(abs, func(path string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + rel, err := filepath.Rel(abs, path) + if err != nil { + return err + } + if rel == "." { + return nil + } + if strings.HasPrefix(rel, ".git") { + if d.IsDir() { + return filepath.SkipDir + } + return nil + } + info, err := d.Info() + if err != nil { + return err + } + header, err := tar.FileInfoHeader(info, "") + if err != nil { + return err + } + header.Name = filepath.ToSlash(rel) + if info.Mode()&os.ModeSymlink != 0 { + link, err := os.Readlink(path) + if err != nil { + return err + } + header.Linkname = link + } + if err := tarWriter.WriteHeader(header); err != nil { + return err + } + if info.Mode().IsRegular() { + f, err := os.Open(path) + if err != nil { + return err + } + _, copyErr := io.Copy(tarWriter, f) + closeErr := f.Close() + if copyErr != nil { + return copyErr + } + if closeErr != nil { + return closeErr + } + } + return nil + }) + + closeErr := tarWriter.Close() + gzipErr := gz.Close() + fileErr := file.Close() + + if walkErr != nil { + return "", cleanup, walkErr + } + if closeErr != nil { + return "", cleanup, closeErr + } + if gzipErr != nil { + return "", cleanup, gzipErr + } + if fileErr != nil { + return "", cleanup, fileErr + } + return file.Name(), cleanup, nil +} diff --git a/internal/pullpreview/tarball_test.go b/internal/pullpreview/tarball_test.go new file mode 100644 index 0000000..2dc97bb --- /dev/null +++ b/internal/pullpreview/tarball_test.go @@ -0,0 +1,78 @@ +package pullpreview + +import ( + "archive/tar" + "compress/gzip" + "io" + "os" + "path/filepath" + "testing" +) + +func TestCreateTarballExcludesGitDirectory(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "docker-compose.yml"), []byte("services: {}"), 0644); err != nil { + t.Fatalf("failed writing compose file: %v", err) + } + if err := os.MkdirAll(filepath.Join(root, "nested"), 0755); err != nil { + t.Fatalf("failed creating nested dir: %v", err) + } + if err := os.WriteFile(filepath.Join(root, "nested", "file.txt"), []byte("hello"), 0644); err != nil { + t.Fatalf("failed writing nested file: %v", err) + } + if err := os.MkdirAll(filepath.Join(root, ".git"), 0755); err != nil { + t.Fatalf("failed creating .git dir: %v", err) + } + if err := os.WriteFile(filepath.Join(root, ".git", "config"), []byte("gitconfig"), 0644); err != nil { + t.Fatalf("failed writing .git config: %v", err) + } + + tarball, cleanup, err := CreateTarball(root) + if err != nil { + t.Fatalf("CreateTarball() error: %v", err) + } + defer cleanup() + + entries, err := readTarEntries(tarball) + if err != nil { + t.Fatalf("failed reading tarball entries: %v", err) + } + + if !entries["docker-compose.yml"] { + t.Fatalf("expected docker-compose.yml entry, got %#v", entries) + } + if !entries["nested/file.txt"] { + t.Fatalf("expected nested/file.txt entry, got %#v", entries) + } + if entries[".git/config"] || entries[".git"] { + t.Fatalf("expected .git entries to be excluded, got %#v", entries) + } +} + +func readTarEntries(path string) (map[string]bool, error) { + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + + gz, err := gzip.NewReader(file) + if err != nil { + return nil, err + } + defer gz.Close() + + reader := tar.NewReader(gz) + result := map[string]bool{} + for { + header, err := reader.Next() + if err != nil { + if err == io.EOF { + break + } + return nil, err + } + result[header.Name] = true + } + return result, nil +} diff --git a/internal/pullpreview/ttl_test.go b/internal/pullpreview/ttl_test.go new file mode 100644 index 0000000..cb3f94c --- /dev/null +++ b/internal/pullpreview/ttl_test.go @@ -0,0 +1,22 @@ +package pullpreview + +import ( + "testing" + "time" +) + +func TestPrExpired(t *testing.T) { + now := time.Now() + if prExpired(now.Add(-2*time.Hour), "3h") { + t.Fatalf("expected not expired") + } + if !prExpired(now.Add(-5*time.Hour), "3h") { + t.Fatalf("expected expired") + } + if !prExpired(now.Add(-48*time.Hour), "2d") { + t.Fatalf("expected expired") + } + if prExpired(now, "infinite") { + t.Fatalf("expected not expired") + } +} diff --git a/internal/pullpreview/types.go b/internal/pullpreview/types.go new file mode 100644 index 0000000..593f639 --- /dev/null +++ b/internal/pullpreview/types.go @@ -0,0 +1,82 @@ +package pullpreview + +import ( + "context" + "time" +) + +type Provider interface { + Launch(name string, opts LaunchOptions) (AccessDetails, error) + Terminate(name string) error + Running(name string) (bool, error) + ListInstances(tags map[string]string) ([]InstanceSummary, error) + Username() string +} + +type AccessDetails struct { + Username string + IPAddress string + CertKey string + PrivateKey string +} + +type LaunchOptions struct { + Size string + UserData string + Ports []string + CIDRs []string + Tags map[string]string +} + +type InstanceSummary struct { + Name string + PublicIP string + Size string + Region string + Zone string + CreatedAt time.Time + Tags map[string]string +} + +type CommonOptions struct { + Admins []string + AdminPublicKeys []string + Context context.Context + CIDRs []string + Registries []string + ProxyTLS string + DNS string + Ports []string + InstanceType string + DefaultPort string + Tags map[string]string + ComposeFiles []string + ComposeOptions []string + PreScript string +} + +type UpOptions struct { + AppPath string + Name string + Subdomain string + Common CommonOptions +} + +type DownOptions struct { + Name string +} + +type ListOptions struct { + Org string + Repo string +} + +type GithubSyncOptions struct { + AppPath string + Label string + AlwaysOn []string + DeploymentVariant string + TTL string + Context context.Context + Common CommonOptions +} diff --git a/internal/pullpreview/up.go b/internal/pullpreview/up.go new file mode 100644 index 0000000..206ad1a --- /dev/null +++ b/internal/pullpreview/up.go @@ -0,0 +1,134 @@ +package pullpreview + +import ( + "fmt" + "os" + "strings" + "time" +) + +func RunUp(opts UpOptions, provider Provider, logger *Logger) (*Instance, error) { + if logger != nil { + logger.Debugf("options=%+v", opts) + } + instance := NewInstance(opts.Name, opts.Common, provider, logger) + if opts.Subdomain != "" { + instance.WithSubdomain(opts.Subdomain) + } + + appPath := opts.AppPath + clonePath, cloneCleanup, err := instance.CloneIfURL(appPath) + if err != nil { + return nil, err + } + defer cloneCleanup() + appPath = clonePath + + tarball, cleanup, err := instance.LocalTarballPath(appPath) + if err != nil { + return nil, err + } + defer cleanup() + + if logger != nil { + logger.Infof("Taring up repository at %s...", appPath) + } + + if err := instance.LaunchAndWait(); err != nil { + return nil, err + } + + if logger != nil { + logger.Infof("Synchronizing instance name=%s", instance.Name) + } + + if err := instance.SetupScripts(); err != nil { + return nil, err + } + if logger != nil { + logger.Infof("SSH keys and deploy scripts synced on instance") + } + + instructions := fmt.Sprintf("\nTo connect to the instance (authorized GitHub users: %s):\n ssh %s\n", join(instance.Admins, ", "), instance.SSHAddress()) + stop := make(chan struct{}) + emitDeploymentHeartbeat(instance, logger) + go func() { + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + for { + select { + case <-ticker.C: + emitDeploymentHeartbeat(instance, logger) + case <-stop: + return + } + } + }() + + if logger != nil { + if info, err := os.Stat(tarball); err == nil { + logger.Infof("Preparing to push app tarball (%.2fMB)", float64(info.Size())/1024.0/1024.0) + } + } + + if err := instance.UpdateFromTarball(appPath, tarball); err != nil { + close(stop) + return nil, err + } + close(stop) + + writeGithubOutputs(instance) + + fmt.Println("\nYou can access your application at the following URL:") + fmt.Printf(" %s\n\n", instance.URL()) + fmt.Println(instructions) + fmt.Println("Then to view the logs:") + fmt.Println(" docker-compose logs --tail 1000 -f") + fmt.Println() + + return instance, nil +} + +func join(values []string, sep string) string { + out := "" + for i, v := range values { + if i > 0 { + out += sep + } + out += v + } + return out +} + +func emitDeploymentHeartbeat(instance *Instance, logger *Logger) { + admins := join(instance.Admins, ", ") + if strings.TrimSpace(admins) == "" { + admins = "none" + } + line := fmt.Sprintf( + "Heartbeat: preview_url=%s ssh=\"ssh %s\" authorized_users=\"%s\" (keys uploaded on server)", + instance.URL(), + instance.SSHAddress(), + admins, + ) + if logger != nil { + logger.Infof("%s", line) + return + } + fmt.Println(line) +} + +func writeGithubOutputs(instance *Instance) { + path := os.Getenv("GITHUB_OUTPUT") + if path == "" { + return + } + f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0644) + if err != nil { + return + } + defer f.Close() + fmt.Fprintf(f, "url=%s\n", instance.URL()) + fmt.Fprintf(f, "host=%s\n", instance.PublicIP()) + fmt.Fprintf(f, "username=%s\n", instance.Username()) +} diff --git a/internal/pullpreview/up_test.go b/internal/pullpreview/up_test.go new file mode 100644 index 0000000..8e95baa --- /dev/null +++ b/internal/pullpreview/up_test.go @@ -0,0 +1,55 @@ +package pullpreview + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +type outputTestProvider struct{} + +func (outputTestProvider) Launch(name string, opts LaunchOptions) (AccessDetails, error) { + return AccessDetails{}, nil +} + +func (outputTestProvider) Terminate(name string) error { return nil } + +func (outputTestProvider) Running(name string) (bool, error) { return false, nil } + +func (outputTestProvider) ListInstances(tags map[string]string) ([]InstanceSummary, error) { + return nil, nil +} + +func (outputTestProvider) Username() string { return "ec2-user" } + +func TestWriteGithubOutputsUsesHTTPSURLWhenProxyTLSEnabled(t *testing.T) { + outputPath := filepath.Join(t.TempDir(), "github_output.txt") + if err := os.WriteFile(outputPath, nil, 0644); err != nil { + t.Fatalf("failed to create github output file: %v", err) + } + t.Setenv("GITHUB_OUTPUT", outputPath) + + inst := NewInstance("my-app", CommonOptions{ProxyTLS: "web:80", DNS: "my.preview.run"}, outputTestProvider{}, nil) + inst.Access = AccessDetails{IPAddress: "1.2.3.4", Username: "ec2-user"} + + writeGithubOutputs(inst) + + raw, err := os.ReadFile(outputPath) + if err != nil { + t.Fatalf("failed to read github output file: %v", err) + } + content := string(raw) + if !strings.Contains(content, "url=https://") { + t.Fatalf("expected https URL in github outputs, got %q", content) + } + if strings.Contains(content, "url=http://") { + t.Fatalf("did not expect http URL in github outputs, got %q", content) + } + if !strings.Contains(content, "host=1.2.3.4") { + t.Fatalf("expected host output, got %q", content) + } + if !strings.Contains(content, "username=ec2-user") { + t.Fatalf("expected username output, got %q", content) + } +} diff --git a/internal/pullpreview/user_data.go b/internal/pullpreview/user_data.go new file mode 100644 index 0000000..61f46b2 --- /dev/null +++ b/internal/pullpreview/user_data.go @@ -0,0 +1,39 @@ +package pullpreview + +import ( + "strings" +) + +type UserData struct { + AppPath string + SSHPublicKeys []string + Username string +} + +func (u UserData) Script() string { + lines := []string{ + "#!/bin/bash", + "set -xe ; set -o pipefail", + } + if len(u.SSHPublicKeys) > 0 { + lines = append(lines, "echo '"+strings.Join(u.SSHPublicKeys, "\n")+"' > /home/"+u.Username+"/.ssh/authorized_keys") + } + lines = append(lines, + "mkdir -p "+u.AppPath+" && chown -R "+u.Username+"."+u.Username+" "+u.AppPath, + "echo 'cd "+u.AppPath+"' > /etc/profile.d/pullpreview.sh", + "test -s /swapfile || ( fallocate -l 2G /swapfile && chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile && echo '/swapfile none swap sw 0 0' | tee -a /etc/fstab )", + "systemctl disable --now tmp.mount", + "systemctl mask tmp.mount", + "sysctl vm.swappiness=10 && sysctl vm.vfs_cache_pressure=50", + "echo 'vm.swappiness=10' | tee -a /etc/sysctl.conf", + "echo 'vm.vfs_cache_pressure=50' | tee -a /etc/sysctl.conf", + "yum install -y docker", + "curl -L \"https://github.com/docker/compose/releases/download/v2.18.1/docker-compose-$(uname -s)-$(uname -m)\" -o /usr/local/bin/docker-compose", + "chmod +x /usr/local/bin/docker-compose", + "usermod -aG docker "+u.Username, + "systemctl restart docker", + "echo 'docker system prune -f && docker image prune -a --filter=\"until=96h\" --force' > /etc/cron.daily/docker-prune && chmod a+x /etc/cron.daily/docker-prune", + "mkdir -p /etc/pullpreview && touch /etc/pullpreview/ready && chown -R "+u.Username+"."+u.Username+" /etc/pullpreview", + ) + return strings.Join(lines, "\n") +} diff --git a/internal/pullpreview/user_data_test.go b/internal/pullpreview/user_data_test.go new file mode 100644 index 0000000..73f7ce1 --- /dev/null +++ b/internal/pullpreview/user_data_test.go @@ -0,0 +1,37 @@ +package pullpreview + +import ( + "strings" + "testing" +) + +func TestUserDataScriptIncludesExpectedCommands(t *testing.T) { + script := UserData{ + AppPath: "/app", + Username: "ec2-user", + SSHPublicKeys: []string{"ssh-ed25519 AAA", "ssh-rsa BBB"}, + }.Script() + + checks := []string{ + "#!/bin/bash", + "echo 'ssh-ed25519 AAA\nssh-rsa BBB' > /home/ec2-user/.ssh/authorized_keys", + "mkdir -p /app && chown -R ec2-user.ec2-user /app", + "yum install -y docker", + "touch /etc/pullpreview/ready", + } + for _, fragment := range checks { + if !strings.Contains(script, fragment) { + t.Fatalf("expected script to contain %q, script:\n%s", fragment, script) + } + } +} + +func TestUserDataScriptWithoutSSHKeys(t *testing.T) { + script := UserData{ + AppPath: "/app", + Username: "ec2-user", + }.Script() + if strings.Contains(script, "authorized_keys") { + t.Fatalf("did not expect authorized_keys setup without keys, script:\n%s", script) + } +} diff --git a/internal/pullpreview/utils.go b/internal/pullpreview/utils.go new file mode 100644 index 0000000..1e85dbe --- /dev/null +++ b/internal/pullpreview/utils.go @@ -0,0 +1,47 @@ +package pullpreview + +import ( + "context" + "time" +) + +func WaitUntil(maxRetries int, interval time.Duration, fn func() bool) bool { + return WaitUntilContext(context.Background(), maxRetries, interval, fn) +} + +func WaitUntilContext(ctx context.Context, maxRetries int, interval time.Duration, fn func() bool) bool { + ctx = EnsureContext(ctx) + retries := 0 + for { + select { + case <-ctx.Done(): + return false + default: + } + if fn() { + return true + } + retries++ + if retries >= maxRetries { + return false + } + timer := time.NewTimer(interval) + select { + case <-ctx.Done(): + timer.Stop() + return false + case <-timer.C: + } + } +} + +func EnsureContext(ctx context.Context) context.Context { + if ctx == nil { + return context.Background() + } + return ctx +} + +func ensureContext(ctx context.Context) context.Context { + return EnsureContext(ctx) +} diff --git a/lib/pull_preview.rb b/lib/pull_preview.rb deleted file mode 100644 index 4e55e28..0000000 --- a/lib/pull_preview.rb +++ /dev/null @@ -1,53 +0,0 @@ -require "logger" -require "fileutils" - -require_relative "./pull_preview/providers" -require_relative "./pull_preview/error" -require_relative "./pull_preview/utils" -require_relative "./pull_preview/user_data" -require_relative "./pull_preview/access_details" -require_relative "./pull_preview/instance" -require_relative "./pull_preview/up" -require_relative "./pull_preview/down" -require_relative "./pull_preview/github_sync" -require_relative "./pull_preview/list" -require_relative "./pull_preview/license" - -module PullPreview - VERSION = "1.0.0" - REMOTE_APP_PATH = "/app" - STACK_NAME = "pullpreview" - - class << self - attr_accessor :logger - attr_reader :license, :provider - end - - def self.data_dir - Pathname.new(__dir__).parent.join("data") - end - - def self.octokit - @octokit ||= Octokit::Client.new(access_token: ENV.fetch("GITHUB_TOKEN")).tap do |client| - client.auto_paginate = false - end - end - - def self.faraday - @faraday ||= Faraday.new(request: { timeout: 10, open_timeout: 5 }) do |conn| - conn.request(:retry, max: 2) - conn.request :url_encoded - end - end - - def self.license=(value) - @license = value - @license = nil if @license.empty? - @license - end - - # +value+: one of lightsail,pullpreview - def self.provider=(value) - @provider = Providers.fetch(value) - end -end diff --git a/lib/pull_preview/access_details.rb b/lib/pull_preview/access_details.rb deleted file mode 100644 index 0182f55..0000000 --- a/lib/pull_preview/access_details.rb +++ /dev/null @@ -1,16 +0,0 @@ -module PullPreview - class AccessDetails - attr_reader :username, :ip_address, :private_key, :cert_key - - def initialize(username:, ip_address:, private_key: nil, cert_key: nil) - @username = username - @ip_address = ip_address - @private_key = private_key - @cert_key = cert_key - end - - def ssh_address - [username, ip_address].compact.join("@") - end - end -end \ No newline at end of file diff --git a/lib/pull_preview/down.rb b/lib/pull_preview/down.rb deleted file mode 100644 index 36464dd..0000000 --- a/lib/pull_preview/down.rb +++ /dev/null @@ -1,9 +0,0 @@ -module PullPreview - class Down - def self.run(opts) - instance = Instance.new(opts[:name]) - PullPreview.logger.info "Destroying instance name=#{instance.name}" - instance.terminate! - end - end -end diff --git a/lib/pull_preview/error.rb b/lib/pull_preview/error.rb deleted file mode 100644 index 9c15c45..0000000 --- a/lib/pull_preview/error.rb +++ /dev/null @@ -1,4 +0,0 @@ -module PullPreview - class Error < StandardError ; end - class LicenseError < Error ; end -end diff --git a/lib/pull_preview/github_sync.rb b/lib/pull_preview/github_sync.rb deleted file mode 100644 index ff86cef..0000000 --- a/lib/pull_preview/github_sync.rb +++ /dev/null @@ -1,479 +0,0 @@ -require "octokit" - -module PullPreview - class GithubSync - attr_reader :github_context - attr_reader :app_path - - # CLI options, already parsed - attr_reader :opts - attr_reader :always_on - attr_reader :label - - def self.run(app_path, opts) - github_event_name = ENV.fetch("GITHUB_EVENT_NAME") - PullPreview.logger.debug "github_event_name = #{github_event_name.inspect}" - - if ["schedule"].include?(github_event_name) - clear_dangling_deployments(ENV.fetch("GITHUB_REPOSITORY"), app_path, opts) - clear_outdated_environments(ENV.fetch("GITHUB_REPOSITORY"), app_path, opts) - return - end - - github_event_path = ENV.fetch("GITHUB_EVENT_PATH") - # https://developer.github.com/v3/activity/events/types/#pushevent - # https://help.github.com/en/actions/reference/events-that-trigger-workflows - github_context = JSON.parse(File.read(github_event_path)) - PullPreview.logger.debug "github_context = #{github_context.inspect}" - self.new(github_context, app_path, opts).sync! - end - - def self.pr_expired?(last_updated_at, ttl) - ttl = ttl.to_s - if ttl.end_with?("h") - return last_updated_at <= Time.now - ttl.sub("h", "").to_i * 3600 - elsif ttl.end_with?("d") - return last_updated_at <= Time.now - ttl.sub("d", "").to_i * 3600 * 24 - else - false - end - end - - # Go over closed pull requests that are still labelled as "pullpreview", and force the removal of the corresponding environments - # This happens sometimes, when a pull request is closed, but the environment is not destroyed due to some GitHub Action hiccup. - def self.clear_dangling_deployments(repo, app_path, opts) - ttl = opts[:ttl] || "infinite" - PullPreview.logger.info "[clear_dangling_deployments] start" - label = opts[:label] - pr_issues_labeled = PullPreview.octokit.get("repos/#{repo}/issues", labels: label, state: "all", per_page: 100) - pr_issues_labeled.each do |pr_issue| - next unless pr_issue.pull_request - pr = PullPreview.octokit.get(pr_issue.pull_request.url) - fake_github_context = OpenStruct.new( - action: "closed", - number: pr.number, - pull_request: pr, - ref: pr.head.ref, - repository: pr.base.repo, - ) - if pr.base.repo.owner.type == "Organization" - fake_github_context.organization = pr.base.repo.owner - end - if pr_issue.state == "closed" - PullPreview.logger.warn "[clear_dangling_deployments] Found dangling #{label} label for PR##{pr.number}. Cleaning up..." - elsif pr_expired?(pr_issue.updated_at, ttl) - PullPreview.logger.warn "[clear_dangling_deployments] Found #{label} label for expired PR##{pr.number} (#{pr_issue.updated_at}). Cleaning up..." - else - PullPreview.logger.warn "[clear_dangling_deployments] Found #{label} label for active PR##{pr.number} (#{pr_issue.updated_at}). Not touching." - next - end - new(fake_github_context, app_path, opts).sync! - end - - PullPreview.logger.info "[clear_dangling_deployments] end" - end - - # Clear any outdated environments, which have no corresponding PR anymore. - def self.clear_outdated_environments(repo, app_path, opts) - label = opts[:label] - environments_to_remove = Set.new - pr_numbers_with_label_assigned = PullPreview.octokit.get("repos/#{repo}/issues", labels: label, pulls: true, state: "all", per_page: 100).map(&:number) - PullPreview.octokit.list_environments(repo, per_page: 100).environments.each do |env| - # regexp must match `pr-`. We don't want to destroy branch environments (`branch-`) - environment = env.name - pr_number = environment.match(/gh\-(\d+)\-pr\-(\d+)/)&.captures&.last.to_i - next if pr_number.zero? - # don't do anything if the corresponding PR still has the label - next if pr_numbers_with_label_assigned.include?(pr_number) - environments_to_remove.add environment - end - - PullPreview.logger.warn "[clear_outdated_environments] Found #{environments_to_remove.size} environments to remove: #{environments_to_remove}." - - environments_to_remove.each do |environment| - PullPreview.logger.warn "[clear_outdated_environments] Deleting environment #{environment}..." - destroy_environment(repo, environment) - sleep 5 - end - end - - def self.destroy_environment(repo, environment) - deploys = PullPreview.octokit.list_deployments(repo, environment: environment, per_page: 100) - # make sure all deploys are marked as inactive first - deploys.each do |deployment| - PullPreview.octokit.create_deployment_status( - deployment.url, - "inactive", - headers: { accept: "application/vnd.github.ant-man-preview+json" } - ) - end - deploys.each do |deployment| - PullPreview.octokit.delete(deployment.url) - end - # This requires repository permission, which the default GitHub Action token cannot get, so this will fail if the github_token input is not used. - # Logging a warning message to let the user know that he will have to clean the environment manually. - begin - PullPreview.octokit.delete_environment(repo, environment) - rescue Octokit::Error => e - PullPreview.logger.warn "Unable to destroy the environment #{environment.inspect}: #{e.message}. To destroy the environment object on GitHub, you will have to manually delete it from the GitHub UI, or pass a GitHub token with repository permissions to the action. This does not affect the cleaning of instances, only the GitHub environment object." - end - rescue => e - PullPreview.logger.error "Unable to destroy environment #{environment.inspect}: #{e.message}" - end - - def initialize(github_context, app_path, opts = {}) - @github_context = github_context - @app_path = app_path - @label = opts.delete(:label) - @opts = opts - @always_on = opts.delete(:always_on) - end - - def octokit - PullPreview.octokit - end - - def sync! - if sha != latest_sha && !ENV.fetch("PULLPREVIEW_TEST", nil) - PullPreview.logger.info "A newer commit is present. Skipping current run." - return true - end - - pp_action = guess_action_from_event.to_sym - license = PullPreview::License.new(org_id, repo_id, pp_action, org_slug: org_name, repo_slug: repo_name).fetch! - PullPreview.logger.info license.message - - unless license.ok? - raise LicenseError, license.message - end - - case pp_action - when :pr_down, :branch_down - instance = Instance.new(instance_name) - update_github_status(:destroying) - - if instance.running? - Down.run(name: instance_name) - else - PullPreview.logger.warn "Instance #{instance_name.inspect} already down. Continuing..." - end - if pr_closed? - PullPreview.logger.info "Removing label #{label} from PR##{pr_number}..." - begin - octokit.remove_label(repo, pr_number, label) - rescue Octokit::NotFound - # ignore errors when removing absent labels - true - end - end - update_github_status(:destroyed) - when :pr_up, :pr_push, :branch_push - unless license.ok? - raise LicenseError, license.message - end - update_github_status(:deploying) - tags = default_instance_tags.push(*opts[:tags]).uniq - instance = Up.run( - app_path, - opts.merge(name: instance_name, subdomain: instance_subdomain, tags: tags, admins: expanded_admins) - ) - update_github_status(:deployed, instance.url) - else - PullPreview.logger.info "Ignoring event #{pp_action.inspect}" - end - rescue => e - update_github_status(:error) - raise e - end - - def guess_action_from_event - if pr_number.nil? - branch = ref.sub("refs/heads/", "") - if always_on.include?(branch) - return :branch_push - else - return :branch_down - end - end - - # In case of labeled & unlabeled, we recheck what the PR currently has for - # labels since actions don't execute in the order they are triggered - if (pr_unlabeled? && !pr_has_label?) || pr_closed? - return :pr_down - end - - if (pr_opened? || pr_reopened? || pr_labeled?) && pr_has_label? - return :pr_up - end - - if push? || pr_synchronize? - if pr_has_label? - return :pr_push - else - PullPreview.logger.info "Unable to find label #{label} on PR##{pr_number}" - return :ignored - end - end - - :ignored - end - - def commit_status_for(status) - case status - when :error - :error - when :deployed, :destroyed - :success - when :deploying, :destroying - :pending - end - end - - def deployment_status_for(status) - case status - when :error - :error - when :deployed - :success - when :destroyed - :inactive - when :deploying - :pending - when :destroying - nil - end - end - - def update_github_status(status, url = nil) - commit_status = commit_status_for(status) - # https://developer.github.com/v3/repos/statuses/#create-a-status - commit_status_params = { - context: ["PullPreview", deployment_variant].compact.join(" - "), - description: ["Environment", status].join(" ") - } - commit_status_params.merge!(target_url: url) if url - PullPreview.logger.info "Setting commit status for repo=#{repo.inspect}, sha=#{sha.inspect}, status=#{commit_status.inspect}, params=#{commit_status_params.inspect}" - octokit.create_status( - repo, - sha, - commit_status.to_s, - commit_status_params - ) - - deployment_status = deployment_status_for(status) - unless deployment_status.nil? - deployment_status_params = { - headers: {accept: "application/vnd.github.ant-man-preview+json"}, - auto_inactive: true - } - deployment_status_params.merge!(environment_url: url) if url - PullPreview.logger.info "Setting deployment status for repo=#{repo.inspect}, branch=#{branch.inspect}, sha=#{sha.inspect}, status=#{deployment_status.inspect}, params=#{deployment_status_params.inspect}" - octokit.create_deployment_status(deployment.url, deployment_status.to_s, deployment_status_params) - if status == :destroyed - self.class.destroy_environment(repo, instance_name) - end - end - end - - def organization? - github_context["organization"] - end - - def org_name - if organization? - github_context["organization"]["login"] - else - github_context["repository"]["owner"]["login"] - end - end - - def repo_name - github_context["repository"]["name"] - end - - def repo - [org_name, repo_name].join("/") - end - - def repo_id - github_context["repository"]["id"] - end - - def org_id - if organization? - github_context["organization"]["id"] - else - github_context["repository"]["owner"]["id"] - end - end - - def ref - github_context["ref"] || ENV.fetch("GITHUB_REF") - end - - def latest_sha - @latest_sha ||= if pull_request? - pr.head.sha - else - octokit.list_commits(repo, ref).first.sha - end - end - - def sha - if pull_request? - github_context["pull_request"]["head"]["sha"] - else - github_context.dig("head_commit", "id") || ENV.fetch("GITHUB_SHA") - end - end - - def branch - if pull_request? - github_context["pull_request"]["head"]["ref"] - else - ref.sub("refs/heads/", "") - end - end - - def expanded_admins - collaborators_with_push = "@collaborators/push" - admins = opts[:admins].dup.map(&:strip) - if admins.include?(collaborators_with_push) - admins.delete(collaborators_with_push) - admins.push(*octokit.collaborators(repo).select{|c| c.permissions&.push}.map(&:login)) - end - admins.uniq - end - - def deployment - @deployment ||= (find_deployment || create_deployment) - end - - def find_deployment - octokit.list_deployments(repo, environment: instance_name, ref: sha).first - end - - def create_deployment - octokit.create_deployment( - repo, - sha, - auto_merge: false, - environment: instance_name, - required_contexts: [] - ) - end - - def pull_request? - github_context["pull_request"] - end - - def push? - !pull_request? - end - - def pr_synchronize? - pull_request? && - github_context["action"] == "synchronize" - end - - def pr_opened? - pull_request? && - github_context["action"] == "opened" - end - - def pr_reopened? - pull_request? && - github_context["action"] == "reopened" - end - - def pr_closed? - pull_request? && - github_context["action"] == "closed" - end - - def pr_labeled? - pull_request? && - github_context["action"] == "labeled" && - github_context["label"]["name"] == label - end - - def pr_unlabeled? - pull_request? && - github_context["action"] == "unlabeled" && - github_context["label"]["name"] == label - end - - def pr_has_label?(searched_label = nil) - pr.labels.find{|l| l.name.downcase == (searched_label || label).downcase} - end - - def pr_number - @pr_number ||= if pull_request? - github_context["pull_request"]["number"] - elsif pr_from_ref - pr_from_ref[:number] - else - nil - end - end - - # only used to retrieve the PR when the event is push - def pr_from_ref - @pr_from_ref ||= octokit.pull_requests( - repo, - state: "open", - head: [org_name, ref].join(":") - ).first.tap{|o| @pr = o if o } - end - - def pr - @pr ||= octokit.pull_request( - repo, - pr_number - ) - end - - def deployment_variant - variant = opts[:deployment_variant].to_s - return nil if variant == "" - raise Error, "--deployment-variant must be 4 chars max" if variant.size > 4 - variant - end - - def instance_name - @instance_name ||= begin - name = if pr_number - ["gh", repo_id, deployment_variant, "pr", pr_number].compact.join("-") - else - # push on branch without PR - ["gh", repo_id, deployment_variant, "branch", branch].compact.join("-") - end - Instance.normalize_name(name) - end - end - - def instance_subdomain - @instance_subdomain ||= begin - components = [] - components.push(deployment_variant) if deployment_variant - components.push(*["pr", pr_number]) if pr_number - components.push(branch.split("/").last.downcase) - Instance.normalize_name(components.join("-")) - end - end - - def default_instance_tags - tags = [ - ["repo_name", repo_name], - ["repo_id", repo_id], - ["org_name", org_name], - ["org_id", org_id], - ["version", PullPreview::VERSION], - ] - if pr_number - tags << ["pr_number", pr_number] - end - tags.map{|tag| tag.join(":")} - end - end -end diff --git a/lib/pull_preview/instance.rb b/lib/pull_preview/instance.rb deleted file mode 100644 index 975bdce..0000000 --- a/lib/pull_preview/instance.rb +++ /dev/null @@ -1,260 +0,0 @@ -require "erb" -require "ostruct" - -module PullPreview - class Instance - # https://community.letsencrypt.org/t/a-certificate-for-a-63-character-domain/78870/4 - DEFAULT_MAX_DOMAIN_LENGTH = 62 - - include Utils - - attr_reader :admins - attr_reader :cidrs - attr_reader :compose_files - attr_reader :compose_options - attr_reader :default_port - attr_reader :dns - attr_reader :name - attr_reader :subdomain - attr_reader :ports - attr_reader :provider - attr_reader :registries - attr_reader :size - attr_reader :tags - attr_reader :access_details - attr_reader :pre_script - - class << self - attr_accessor :client - attr_accessor :logger - end - - def self.normalize_name(name) - name. - gsub(/[^a-z0-9]/i, "-"). - squeeze("-")[0..60]. - gsub(/(^-|-$)/, "") - end - - def initialize(name, opts = {}) - @provider = PullPreview.provider - @name = self.class.normalize_name(name) - @subdomain = opts[:subdomain] || name - @admins = opts[:admins] || [] - @cidrs = opts[:cidrs] || ["0.0.0.0/0"] - @default_port = opts[:default_port] || "80" - # TODO: normalize - @ports = (opts[:ports] || []).push(default_port).push("22").uniq.compact - @compose_files = opts[:compose_files] || ["docker-compose.yml"] - @compose_options = opts[:compose_options] || ["--build"] - @registries = opts[:registries] || [] - @dns = opts[:dns] - @size = opts[:instance_type] - @ssh_results = [] - @tags = opts[:tags] || {} - @pre_script = opts[:pre_script] - end - - def launch_and_wait_until_ready! - @access_details = provider.launch!(name, size: size, user_data: user_data, ports: ports, cidrs: cidrs, tags: tags) - logger.debug "access_details=#{@access_details.inspect}" - logger.info "Instance is running public_ip=#{public_ip} public_dns=#{public_dns}" - wait_until_ssh_ready! - end - - def terminate! - if provider.terminate!(name) - logger.info "Instance successfully destroyed" - else - logger.error "Unable to destroy instance" - end - end - - def running? - provider.running?(name) - end - - def ssh_ready? - ssh("test -f /etc/pullpreview/ready") - end - - def public_ip - access_details.ip_address - end - - def max_domain_length - value = ENV.fetch("PULLPREVIEW_MAX_DOMAIN_LENGTH", DEFAULT_MAX_DOMAIN_LENGTH).to_i - value = DEFAULT_MAX_DOMAIN_LENGTH if value <= 0 || value > DEFAULT_MAX_DOMAIN_LENGTH - value - end - - # Leave 8 chars for an additional subdomain that could be needed by the deployed app. - # Disabled if custom domain length is specified. - def reserved_space_for_user_subdomain - max_domain_length != DEFAULT_MAX_DOMAIN_LENGTH ? 0 : 8 - end - - def public_dns - remaining_chars_for_subdomain = max_domain_length - reserved_space_for_user_subdomain - dns.size - public_ip.size - "ip".size - ("." * 3).size - [ - [subdomain[0..[(remaining_chars_for_subdomain - 1), 0].max], "ip", public_ip.gsub(".", "-")].join("-").squeeze("-"), - dns - ].reject{|part| part.empty?}.join(".") - end - - def url - scheme = (default_port == "443" ? "https" : "http") - "#{scheme}://#{public_dns}:#{default_port}" - end - - def username - provider.username - end - - def ssh_public_keys - @ssh_public_keys ||= admins.map do |github_username| - URI.open("https://github.com/#{github_username}.keys").read.split("\n") - end.flatten.reject{|key| key.empty?} - end - - def user_data - @user_data ||= UserData.new(app_path: remote_app_path, username: username, ssh_public_keys: ssh_public_keys) - end - - def erb_locals - OpenStruct.new( - remote_app_path: remote_app_path, - compose_files: compose_files, - compose_options: compose_options, - public_ip: public_ip, - public_dns: public_dns, - admins: admins, - url: url, - ) - end - - def github_token - ENV.fetch("GITHUB_TOKEN", "") - end - - def github_repository_owner - ENV.fetch("GITHUB_REPOSITORY_OWNER", "") - end - - def update_script - PullPreview.data_dir.join("update_script.sh.erb") - end - - def update_script_rendered - ERB.new(File.read(update_script)).result_with_hash(locals: erb_locals) - end - - def setup_ssh_access - File.open("/tmp/authorized_keys", "w+") do |f| - f.write ssh_public_keys.join("\n") - end - scp("/tmp/authorized_keys", "/home/#{username}/.ssh/authorized_keys", mode: "0600") - # in case provider ssh user is different than the one we want to use - ssh("chown #{username}.#{username} /home/#{username}/.ssh/authorized_keys && chmod 0600 /home/#{username}/.ssh/authorized_keys") - end - - def setup_update_script - tmpfile = Tempfile.new("update_script").tap do |f| - f.write update_script_rendered - end - tmpfile.flush - unless scp(tmpfile.path, "/tmp/update_script.sh", mode: "0755") - raise Error, "Unable to copy the update script on instance. Aborting." - end - end - - def setup_prepost_scripts - tmpfile = Tempfile.new(["prescript", ".sh"]) - tmpfile.puts "#!/bin/bash -e" - registries.each_with_index do |registry, index| - begin - uri = URI.parse(registry) - raise Error, "Invalid registry" if uri.host.nil? || uri.scheme != "docker" - username = uri.user - password = uri.password - if password.nil? - password = username - username = "doesnotmatter" - end - tmpfile.puts 'echo "Logging into %{host}..."' % { host: uri.host } - # https://docs.github.com/en/packages/guides/using-github-packages-with-github-actions#upgrading-a-workflow-that-accesses-ghcrio - tmpfile.puts 'echo "%{password}" | docker login "%{host}" -u "%{username}" --password-stdin' % { - host: uri.host, - username: username, - password: password, - } - rescue URI::Error, Error => e - logger.warn "Registry ##{index} is invalid: #{e.message}" - end - end - if pre_script && !pre_script.empty? - tmpfile.puts "echo 'Attempting to run pre-script at #{pre_script}...'" - tmpfile.puts "bash -e #{pre_script}" - end - tmpfile.flush - unless scp(tmpfile.path, "/tmp/pre_script.sh", mode: "0755") - raise Error, "Unable to copy the pre script on instance. Aborting." - end - end - - def wait_until_ssh_ready! - if wait_until { logger.info "Waiting for ssh" ; ssh_ready? } - logger.info "Instance ssh access OK" - else - logger.error "Instance ssh access KO" - raise Error, "Can't connect to instance over SSH. Aborting." - end - end - - def scp(source, target, mode: "0644") - ssh("cat - > #{target} && chmod #{mode} #{target}", input: File.new(source)) - end - - def ssh(command, input: nil) - key_file_path = "/tmp/tempkey" - cert_key_path = "/tmp/tempkey-cert.pub" - File.open(key_file_path, "w+") do |f| - f.puts access_details.private_key - end - if access_details.cert_key - File.open(cert_key_path, "w+") do |f| - f.puts access_details.cert_key - end - end - [key_file_path].each{|file| FileUtils.chmod 0600, file} - - cmd = "ssh #{"-v " if logger.level == Logger::DEBUG}-o ServerAliveInterval=15 -o IdentitiesOnly=yes -i #{key_file_path} #{ssh_address} #{ssh_options.join(" ")} '#{command}'" - if input && input.respond_to?(:path) - cmd = "cat #{input.path} | #{cmd}" - end - logger.debug cmd - system(cmd).tap {|result| @ssh_results.push([cmd, result])} - end - - def ssh_address - access_details.ssh_address - end - - def ssh_options - [ - "-o StrictHostKeyChecking=no", - "-o UserKnownHostsFile=/dev/null", - "-o LogLevel=ERROR", - "-o ConnectTimeout=10", - ] - end - - private def logger - PullPreview.logger - end - - private def remote_app_path - REMOTE_APP_PATH - end - end -end diff --git a/lib/pull_preview/license.rb b/lib/pull_preview/license.rb deleted file mode 100644 index 94dbcfd..0000000 --- a/lib/pull_preview/license.rb +++ /dev/null @@ -1,43 +0,0 @@ -require "json" -require "net/http" - -module PullPreview - class License - attr_reader :state, :message - - def initialize(org_id, repo_id, action, details = {}) - @org_id = org_id - @repo_id = repo_id - @action = action - @details = details - end - - def ok? - state == "ok" - end - - def params - @details.merge({org_id: @org_id, repo_id: @repo_id, pp_action: @action}) - end - - def fetch! - uri = URI("https://app.pullpreview.com/licenses/check") - uri.query = URI.encode_www_form(params) - begin - response = Net::HTTP.get_response(uri) - if response.code == "200" - @state = "ok" - @message = response.body - else - @state = "ko" - @message = response.body - end - rescue Exception => e - PullPreview.logger.warn "License server unreachable - #{e.message}" - @state = "ok" - @message = "License server unreachable. Continuing..." - end - self - end - end -end diff --git a/lib/pull_preview/list.rb b/lib/pull_preview/list.rb deleted file mode 100644 index 6f1a385..0000000 --- a/lib/pull_preview/list.rb +++ /dev/null @@ -1,38 +0,0 @@ -require 'terminal-table' - -module PullPreview - class List - def self.run(opts) - raise Error, "Invalid org/repo given" if opts.arguments.none? - org, repo = opts.arguments.first.split("/", 2) - - table = Terminal::Table.new(headings: [ - "Name", - "IP", - "Size", - "Region", - "AZ", - "Created on", - "Tags", - ]) - tags_to_find = { - "stack" => STACK_NAME, - } - tags_to_find.merge!("repo_name" => repo) if repo - tags_to_find.merge!("org_name" => org) if org - - PullPreview.provider.list_instances(tags: tags_to_find) do |instance| - table << [ - instance.name, - instance.public_ip, - instance.size, - instance.region, - instance.zone, - instance.created_at.iso8601, - instance.tags.map{|tag| [tag.key, tag.value].join(":")}.join(","), - ] - end - puts table - end - end -end diff --git a/lib/pull_preview/providers.rb b/lib/pull_preview/providers.rb deleted file mode 100644 index 938fd85..0000000 --- a/lib/pull_preview/providers.rb +++ /dev/null @@ -1,8 +0,0 @@ -module PullPreview - module Providers - def self.fetch(name) - require_relative "./providers/#{name.downcase}" - const_get(name.capitalize).new - end - end -end \ No newline at end of file diff --git a/lib/pull_preview/providers/lightsail.rb b/lib/pull_preview/providers/lightsail.rb deleted file mode 100644 index fc39d97..0000000 --- a/lib/pull_preview/providers/lightsail.rb +++ /dev/null @@ -1,183 +0,0 @@ -module PullPreview - module Providers - class Lightsail - include Utils - - attr_reader :client - - SIZES = { - "XXS" => "nano", - "XS" => "micro", - "S" => "small", - "M" => "medium", - "L" => "large", - "XL" => "xlarge", - "2XL" => "2xlarge", - } - - def initialize - require "aws-sdk-lightsail" - @aws_region = ENV.fetch("AWS_REGION", "us-east-1") - @client = Aws::Lightsail::Client.new(region: @aws_region) - end - - def running?(name) - resp = client.get_instance_state(instance_name: name) - resp.state.name == "running" - rescue Aws::Lightsail::Errors::NotFoundException - false - end - - def terminate!(name) - operation = client.delete_instance(instance_name: name).operations.first - if operation.error_code.nil? - true - else - raise Error, "An error occurred while destroying the instance: #{operation.error_code} (#{operation.error_details})" - end - end - - def launch!(name, size:, ssh_public_keys: [], user_data: UserData.new, cidrs: [], ports: [], tags: {}) - unless running?(name) - launch_or_restore_from_snapshot(name, user_data: user_data, size: size, ssh_public_keys: ssh_public_keys, tags: tags) - sleep 2 - wait_until_running!(name) - end - setup_firewall(name, cidrs: cidrs, ports: ports) - fetch_access_details(name) - end - - def launch_or_restore_from_snapshot(name, user_data:, size:, ssh_public_keys: [], tags: {}) - params = { - instance_names: [name], - availability_zone: availability_zones.first, - bundle_id: bundle_id(size), - tags: {stack: PullPreview::STACK_NAME}.merge(tags).map{|(k,v)| {key: k.to_s, value: v.to_s}}, - } - - if latest_snapshot(name) - logger.info "Found snapshot to restore from: #{latest_snapshot.name}" - logger.info "Creating new instance name=#{name}..." - client.create_instances_from_snapshot(params.merge({ - user_data: user_data.to_s, - instance_snapshot_name: latest_snapshot.name, - })) - else - logger.info "Creating new instance name=#{name}..." - client.create_instances(params.merge({ - user_data: user_data.to_s, - blueprint_id: blueprint_id - })) - end - end - - def wait_until_running!(name) - if wait_until { logger.info "Waiting for instance to be running" ; running?(name) } - logger.debug "Instance is running" - else - logger.error "Timeout while waiting for instance running" - raise Error, "Instance still not running. Aborting." - end - end - - def setup_firewall(name, cidrs: [], ports: []) - client.put_instance_public_ports({ - port_infos: ports.map do |port_definition| - port_range, protocol = port_definition.split("/", 2) - protocol ||= "tcp" - port_range_start, port_range_end = port_range.split("-", 2) - port_range_end ||= port_range_start - cidrs_to_use = cidrs - if port_range_start.to_i == 22 - # allow SSH from anywhere - cidrs_to_use = ["0.0.0.0/0"] - end - { - from_port: port_range_start.to_i, - to_port: port_range_end.to_i, - protocol: protocol, # accepts tcp, all, udp - cidrs: cidrs_to_use, - } - end, - instance_name: name - }) - end - - def fetch_access_details(name) - result = client.get_instance_access_details({ - instance_name: name, - protocol: "ssh", # accepts ssh, rdp - }).access_details - AccessDetails.new(username: result.username, ip_address: result.ip_address, cert_key: result.cert_key, private_key: result.private_key) - end - - def latest_snapshot(name) - client.get_instance_snapshots.instance_snapshots.sort{|a,b| b.created_at <=> a.created_at}.find do |snap| - snap.state == "available" && snap.from_instance_name == name - end - end - - def list_instances(tags: {}) - next_page_token = nil - begin - result = client.get_instances(next_page_token: next_page_token) - next_page_token = result.next_page_token - result.instances.each do |instance| - matching_tags = Hash[instance.tags.select{|tag| tags.keys.include?(tag.key)}.map{|tag| [tag.key, tag.value]}] - if matching_tags == tags - yield(OpenStruct.new( - name: instance.name, - public_ip: instance.public_ip_address, - size: SIZES.invert.fetch(instance.bundle_id, instance.bundle_id), - region: instance.location.region_name, - zone: instance.location.availability_zone, - created_at: instance.created_at, - tags: instance.tags, - )) - end - end - end while not next_page_token.nil? - end - - def availability_zones - azs = client.get_regions(include_availability_zones: true).regions.find do |region| - region.name == @aws_region - end.availability_zones.map(&:zone_name) - end - - def blueprint_id - blueprint_id = client.get_blueprints.blueprints.find do |blueprint| - blueprint.platform == "LINUX_UNIX" && - blueprint.group == "amazon_linux_2023" && - blueprint.is_active && - blueprint.type == "os" - end.blueprint_id - end - - def username - "ec2-user" - end - - def bundle_id(size = "M") - instance_type = SIZES.fetch(size, size.sub("_2_0", "")) - bundle_id = client.get_bundles.bundles.find do |bundle| - if instance_type.nil? || instance_type.empty? - bundle.cpu_count >= 1 && - (2..3).include?(bundle.ram_size_in_gb) && - bundle.supported_platforms.include?("LINUX_UNIX") - else - bundle.instance_type == instance_type - end - end.bundle_id - end - - private def remote_app_path - PullPreview::REMOTE_APP_PATH - end - - private def logger - PullPreview.logger - end - end - end -end \ No newline at end of file diff --git a/lib/pull_preview/up.rb b/lib/pull_preview/up.rb deleted file mode 100644 index fddfb52..0000000 --- a/lib/pull_preview/up.rb +++ /dev/null @@ -1,92 +0,0 @@ -require "open-uri" - -module PullPreview - class Up - def self.run(app_path, opts) - STDOUT.sync = true - STDERR.sync = true - - PullPreview.logger.debug "options=#{opts.to_hash.inspect}" - opts[:tags] = Hash[opts[:tags].map{|tag| tag.split(":", 2)}] - - FileUtils.rm_rf("/tmp/app.tar.gz") - - if app_path.start_with?(/^https?/) - git_url, ref = app_path.split("#", 2) - ref ||= "master" - unless system("rm -rf /tmp/app && git clone '#{git_url}' --depth=1 --branch=#{ref} /tmp/app") - exit 1 - end - app_path = "/tmp/app" - end - - instance_name = opts[:name] - - PullPreview.logger.info "Taring up repository at #{app_path.inspect}..." - unless system("tar czf /tmp/app.tar.gz --exclude .git -C '#{app_path}' .") - exit 1 - end - - instance = Instance.new(instance_name, opts) - PullPreview.logger.info "Starting instance name=#{instance.name}" - instance.launch_and_wait_until_ready! - - PullPreview.logger.info "Synchronizing instance name=#{instance.name}" - instance.setup_ssh_access - instance.setup_update_script - instance.setup_prepost_scripts - - connection_instructions = [ - "", - "To connect to the instance (authorized GitHub users: #{instance.admins.join(", ")}):", - " ssh #{instance.ssh_address}", - "" - ].join("\n") - - heartbeat = Thread.new do - loop do - puts connection_instructions - sleep 10 - end - end - - PullPreview.logger.info "Preparing to push app tarball (#{(File.size("/tmp/app.tar.gz") / 1024.0**2).round(2)}MB)" - remote_tarball_path = "/tmp/app-#{Time.now.utc.strftime("%Y%m%d%H%M%S")}.tar.gz" - - unless instance.scp("/tmp/app.tar.gz", remote_tarball_path) - raise Error, "Unable to copy application content on instance. Aborting." - end - - PullPreview.logger.info "Launching application..." - ok = instance.ssh("/tmp/update_script.sh #{remote_tarball_path}") - - heartbeat.kill - - if github_output_file = ENV["GITHUB_OUTPUT"] - File.open(github_output_file, "a") do |f| - f.puts "url=#{instance.url}" - f.puts "host=#{instance.public_ip}" - f.puts "username=#{instance.username}" - end - end - - puts - puts "You can access your application at the following URL:" - puts " #{instance.url}" - puts - - puts connection_instructions - - puts - puts "Then to view the logs:" - puts " docker-compose logs --tail 1000 -f" - puts - - if ok - instance - else - raise Error, "Trying to launch the application failed. Please see the logs above to troubleshoot the issue and for informations on how to connect to the instance" - end - end - end -end diff --git a/lib/pull_preview/user_data.rb b/lib/pull_preview/user_data.rb deleted file mode 100644 index c2668cf..0000000 --- a/lib/pull_preview/user_data.rb +++ /dev/null @@ -1,43 +0,0 @@ -module PullPreview - class UserData - attr_reader :app_path, :ssh_public_keys, :username - - def initialize(app_path:, ssh_public_keys:, username: "ec2-user") - @app_path = app_path - @ssh_public_keys = ssh_public_keys - @username = username - end - - def instructions - result = [] - result << "#!/bin/bash" - result << "set -xe ; set -o pipefail" - if ssh_public_keys.any? - result << %{echo '#{ssh_public_keys.join("\n")}' > /home/#{username}/.ssh/authorized_keys} - end - result << "mkdir -p #{app_path} && chown -R #{username}.#{username} #{app_path}" - result << "echo 'cd #{app_path}' > /etc/profile.d/pullpreview.sh" - result << "test -s /swapfile || ( fallocate -l 2G /swapfile && chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile && echo '/swapfile none swap sw 0 0' | tee -a /etc/fstab )" - - # for Amazon Linux 2023, which by default creates a /tmp mount that is too small - result << "systemctl disable --now tmp.mount" - result << "systemctl mask tmp.mount" - - result << "sysctl vm.swappiness=10 && sysctl vm.vfs_cache_pressure=50" - result << "echo 'vm.swappiness=10' | tee -a /etc/sysctl.conf" - result << "echo 'vm.vfs_cache_pressure=50' | tee -a /etc/sysctl.conf" - result << "yum install -y docker" - result << %{curl -L "https://github.com/docker/compose/releases/download/v2.18.1/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose} - result << "chmod +x /usr/local/bin/docker-compose" - result << "usermod -aG docker #{username}" - result << "systemctl restart docker" - result << "echo 'docker system prune -f && docker image prune -a --filter=\"until=96h\" --force' > /etc/cron.daily/docker-prune && chmod a+x /etc/cron.daily/docker-prune" - result << "mkdir -p /etc/pullpreview && touch /etc/pullpreview/ready && chown -R #{username}.#{username} /etc/pullpreview" - result - end - - def to_s - instructions.join("\n") - end - end -end diff --git a/lib/pull_preview/utils.rb b/lib/pull_preview/utils.rb deleted file mode 100644 index 0d63773..0000000 --- a/lib/pull_preview/utils.rb +++ /dev/null @@ -1,17 +0,0 @@ -module PullPreview - module Utils - def wait_until(max_retries = 30, interval = 5, &block) - result = true - retries = 0 - until block.call - retries += 1 - if retries >= max_retries - result = false - break - end - sleep interval - end - result - end - end -end \ No newline at end of file diff --git a/skills/pullpreview-demo-flow/SKILL.md b/skills/pullpreview-demo-flow/SKILL.md new file mode 100644 index 0000000..370e1fb --- /dev/null +++ b/skills/pullpreview-demo-flow/SKILL.md @@ -0,0 +1,125 @@ +--- +name: pullpreview-demo-flow +description: Repeatable PullPreview demo workflow that creates a PR, applies label, captures lifecycle screenshots, and verifies deploy/destroy comment transitions. +allowed-tools: Bash(gh:*), Bash(agent-browser:*), Bash(jq:*), Bash(git:*) +--- + +# PullPreview Demo Flow (Screenshots) + +Use this skill to run a full, repeatable PullPreview demo with screenshots. + +## Non-negotiable rules + +1. Demo PR title must always be exactly: `Auto-deploy app with PullPreview`. +2. Before taking PR comment screenshots, scroll the PR page so the comment card is visible in viewport. +3. Keep the screenshot filename set stable: + - `01-pr-open-with-label.png` + - `02-comment-deploying.png` + - `03-action-running.png` + - `04-view-deployment-button.png` + - `05-unlabelled-pr.png` + - `06-comment-destroyed.png` + +## Prerequisites + +- `gh` authenticated with access to `pullpreview/action`. +- `agent-browser` installed. +- Repo label `pullpreview` exists. +- Workflow on base branch supports comment and deployment lifecycle. + +## Create demo PR + +Run helper script: + +```bash +./skills/pullpreview-demo-flow/scripts/create_demo_pr.sh +``` + +Script output includes: + +- `PR_URL=...` +- `PR_NUMBER=...` +- `BRANCH=...` + +The script always creates a PR with title `Auto-deploy app with PullPreview`. + +## Capture screenshots + +Use: + +```bash +export REPO="pullpreview/action" +export PR_NUMBER="" +export BRANCH="" +export SCREEN_DIR="docs/demo-flow-screenshots" +mkdir -p "${SCREEN_DIR}" +``` + +### 1) PR opened with label + +```bash +agent-browser --session pp-demo open "https://github.com/${REPO}/pull/${PR_NUMBER}" +agent-browser --session pp-demo wait --load networkidle +agent-browser --session pp-demo screenshot "${SCREEN_DIR}/01-pr-open-with-label.png" +``` + +### 2) Deploying PR comment (must scroll first) + +```bash +agent-browser --session pp-demo open "https://github.com/${REPO}/pull/${PR_NUMBER}" +agent-browser --session pp-demo eval "window.scrollTo(0, document.body.scrollHeight)" +agent-browser --session pp-demo wait --text "Deploying action with" +agent-browser --session pp-demo screenshot "${SCREEN_DIR}/02-comment-deploying.png" +``` + +### 3) Workflow run in progress + +```bash +RUN_URL="$(gh run list --repo "${REPO}" --branch "${BRANCH}" --workflow pullpreview --limit 1 --json url | jq -r '.[0].url')" +agent-browser --session pp-demo open "${RUN_URL}" +agent-browser --session pp-demo wait --text "in progress" +agent-browser --session pp-demo screenshot "${SCREEN_DIR}/03-action-running.png" +``` + +### 4) Successful deploy with "View deployment" + +Wait for completion: + +```bash +RUN_ID="$(echo "${RUN_URL}" | awk -F/ '{print $NF}')" +gh run watch "${RUN_ID}" --repo "${REPO}" +``` + +Then capture: + +```bash +agent-browser --session pp-demo open "https://github.com/${REPO}/pull/${PR_NUMBER}" +agent-browser --session pp-demo wait --text "View deployment" +agent-browser --session pp-demo screenshot "${SCREEN_DIR}/04-view-deployment-button.png" +``` + +### 5) Remove label and capture unlabeled PR + +```bash +gh pr edit "${PR_NUMBER}" --repo "${REPO}" --remove-label pullpreview +agent-browser --session pp-demo open "https://github.com/${REPO}/pull/${PR_NUMBER}" +agent-browser --session pp-demo wait --load networkidle +agent-browser --session pp-demo screenshot "${SCREEN_DIR}/05-unlabelled-pr.png" +``` + +### 6) Destroyed PR comment (must scroll first) + +```bash +agent-browser --session pp-demo open "https://github.com/${REPO}/pull/${PR_NUMBER}" +agent-browser --session pp-demo eval "window.scrollTo(0, document.body.scrollHeight)" +agent-browser --session pp-demo wait --text "Preview destroyed" +agent-browser --session pp-demo screenshot "${SCREEN_DIR}/06-comment-destroyed.png" +``` + +## Verification checklist + +- PR title is `Auto-deploy app with PullPreview`. +- Deploy comment shows pending then success. +- "View deployment" is visible after success. +- Destroy comment shows after label removal. +- Comment screenshots show the comment body in viewport (not off-screen). diff --git a/skills/pullpreview-demo-flow/scripts/create_demo_pr.sh b/skills/pullpreview-demo-flow/scripts/create_demo_pr.sh new file mode 100755 index 0000000..482a3fd --- /dev/null +++ b/skills/pullpreview-demo-flow/scripts/create_demo_pr.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO="${REPO:-pullpreview/action}" +BASE_BRANCH="${BASE_BRANCH:-codex/go-context}" +LABEL="${LABEL:-pullpreview}" +PR_TITLE="Auto-deploy app with PullPreview" +STAMP="$(date -u +%Y%m%d-%H%M%S)" +BRANCH="codex/demo-flow-${STAMP}" +WORKDIR="${WORKDIR:-$(mktemp -d /tmp/pullpreview-demo-XXXXXX)}" +CLONE_DIR="${WORKDIR}/repo" + +echo "Using workdir: ${WORKDIR}" + +git clone "https://github.com/${REPO}.git" "${CLONE_DIR}" +cd "${CLONE_DIR}" +git checkout "${BASE_BRANCH}" +git switch -c "${BRANCH}" + +mkdir -p demo +cat > demo/go-flow-marker.txt <