diff --git a/.github/workflows/image.yml b/.github/workflows/image.yml new file mode 100644 index 0000000..2121eae --- /dev/null +++ b/.github/workflows/image.yml @@ -0,0 +1,59 @@ +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +# GitHub recommends pinning actions to a commit SHA. +# To get a newer version, you will need to update the SHA. +# You can also reference a tag or branch, but the action may change without warning. +--- +name: Create and publish a Container image + +on: + push: + branches: + - sofatutor + tags: + - "v*" + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.event.repository.owner.name }}/pullpreview + +jobs: + build-and-push-image: + runs-on: ubuntu-22.04 + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Log in to the Container registry + uses: sofatutor/docker-login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: sofatutor/docker-metadata-action@57396166ad8aefe6098280995947635806a0e6ea + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=ref,event=branch + type=ref,event=pr + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + + - name: Build and push Docker image + uses: sofatutor/docker-build-push-action@c56af957549030174b10d6867f20e78cfd7debc5 + with: + context: . + push: true + pull: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.github/workflows/pullpreview.yml b/.github/workflows/pullpreview.yml index 93439ae..ed25db9 100644 --- a/.github/workflows/pullpreview.yml +++ b/.github/workflows/pullpreview.yml @@ -11,7 +11,7 @@ on: 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') + if: github.event_name == 'schedule' || github.event_name == 'push' || github.event.label.name == 'preview' || contains(github.event.pull_request.labels.*.name, 'preview') timeout-minutes: 30 steps: - uses: actions/checkout@v2 diff --git a/CHANGELOG.md b/CHANGELOG.md index 7484683..8d1e9b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ ## master +- Add optional `PULLPREVIEW_SNAPSHOT_NAME` environment variable, which can be used to restore from a specific snapshot name rather than a snapshot for a specific instance name. +- Add optional `PULLPREVIEW_ENV_VARS` environment variable, which can be passed through, to set any environment variables during launch/update. +- Add optional `PULLPREVIEW_LAUNCH_COMMAND` environment variable, which can be passed through, to replace docker-compose launch/update commands. + ## v5 - updated on 20230110 - Switch to using amazon linux 2 since the old blueprint became unavailable (#34) diff --git a/Dockerfile b/Dockerfile index 53636fd..f465242 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,7 @@ FROM ruby:2.7-slim -RUN apt-get update -qq && apt-get install openssh-client git -y +RUN apt-get update -qq && apt-get install openssh-client git -y \ + && apt-get clean -y && rm -rf /var/lib/apt/lists/* WORKDIR /app COPY Gemfile . COPY Gemfile.lock . diff --git a/Gemfile.lock b/Gemfile.lock index 8d232fd..90c841b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -10,30 +10,30 @@ GEM aws-partitions (~> 1, >= 1.239.0) aws-sigv4 (~> 1.1) jmespath (~> 1.0) - aws-sdk-lightsail (1.30.0) + aws-sdk-lightsail (1.32.0) aws-sdk-core (~> 3, >= 3.71.0) aws-sigv4 (~> 1.1) aws-sigv4 (1.1.3) aws-eventstream (~> 1.0, >= 1.0.2) - coderay (1.1.2) + coderay (1.1.3) faraday (1.0.1) multipart-post (>= 1.2, < 3) jmespath (1.4.0) method_source (1.0.0) multipart-post (2.1.1) - octokit (4.18.0) + octokit (4.22.0) faraday (>= 0.9) sawyer (~> 0.8.0, >= 0.5.3) - pry (0.13.1) + pry (0.14.1) coderay (~> 1.1) method_source (~> 1.0) public_suffix (4.0.6) sawyer (0.8.2) addressable (>= 2.3.5) faraday (> 0.8, < 2.0) - slop (4.8.1) - terminal-table (1.8.0) - unicode-display_width (~> 1.1, >= 1.1.1) + slop (4.10.1) + terminal-table (3.0.2) + unicode-display_width (>= 1.1.1, < 3) unicode-display_width (1.7.0) PLATFORMS @@ -47,4 +47,4 @@ DEPENDENCIES terminal-table BUNDLED WITH - 2.1.4 + 2.4.22 diff --git a/action.yml b/action.yml index 06ed5ba..d350919 100644 --- a/action.yml +++ b/action.yml @@ -25,7 +25,7 @@ inputs: ports: description: "Ports to open for external access on the preview server (port 22 is always open), comma-separated" required: false - default: "80/tcp,443/tcp,1000-10000/tcp" + default: "80/tcp,443/tcp" cidrs: description: "The IP address, or range of IP addresses in CIDR notation, that are allowed to connect to the instance" required: false @@ -46,6 +46,10 @@ inputs: description: "Names of private registries to authenticate against. E.g. docker://username:password@ghcr.io" required: false default: "" + deploy_key: + description: "Additional public SSH key used for authentication" + required: false + default: "" outputs: url: @@ -57,7 +61,7 @@ outputs: runs: using: "docker" - image: "Dockerfile" + image: docker://ghcr.io/sofatutor/pullpreview:sofatutor args: - "github-sync" - "${{ inputs.app_path }}" diff --git a/bin/pullpreview b/bin/pullpreview index 70b1161..6020291 100755 --- a/bin/pullpreview +++ b/bin/pullpreview @@ -29,12 +29,14 @@ up_opts = lambda do |o| 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.pullpreview.com" o.array '--ports', 'Ports to open for external access on the preview server', default: [ - "80/tcp", "443/tcp", "1000-10000/tcp" + "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.string '--deploy-key', 'Additional public SSH key (optional)' + o.string '--subdomain', 'Instance subdomain (optional)' end begin diff --git a/data/update_script.sh.erb b/data/update_script.sh.erb index e2075a8..3979c44 100644 --- a/data/update_script.sh.erb +++ b/data/update_script.sh.erb @@ -5,6 +5,8 @@ set -o pipefail APP_TARBALL="$1" APP_PATH="<%= locals.remote_app_path %>" +PULLPREVIEW_LAUNCH_COMMAND="<%= locals.custom_launch_command %>" +PULLPREVIEW_ENV_VARS="<%= locals.custom_env_vars %>" PULLPREVIEW_ENV_FILE="/etc/pullpreview/env" lock_file="/tmp/update.lock" @@ -51,30 +53,36 @@ 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 +if [ -n "${PULLPREVIEW_ENV_VARS}" ] ; then +while read -d, -r pair; do + IFS='=' read -r key val <<<"$pair" + echo "$key=$val" >> $PULLPREVIEW_ENV_FILE +done <<<"$PULLPREVIEW_ENV_VARS," +fi + 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" +sudo rm -rf "/tmp$APP_PATH" +sudo mkdir -p "$APP_PATH" "/tmp$APP_PATH" +sudo chown -R ec2-user.ec2-user "$APP_PATH" "/tmp$APP_PATH" +tar xzf "$1" -C "/tmp$APP_PATH" +rsync -auz "/tmp$APP_PATH" / --remove-source-files --delete cd "$APP_PATH" -echo "Cleaning up..." -docker volume prune -f || true - -echo "Updating dependencies..." -yum update -y || true - if ! /tmp/pre_script.sh ; then echo "Failed to run the pre-script" exit 1 fi +if [ -n "${PULLPREVIEW_LAUNCH_COMMAND}" ] ; then + echo "Command to be executed: $PULLPREVIEW_LAUNCH_COMMAND" + bash -c "$PULLPREVIEW_LAUNCH_COMMAND" ; exit $? +fi pull() { docker-compose <%= locals.compose_files.map{|f| ["-f", f.inspect]}.flatten.join(" ") %> pull -q diff --git a/lib/pull_preview/github_sync.rb b/lib/pull_preview/github_sync.rb index e2864bb..a221c2c 100644 --- a/lib/pull_preview/github_sync.rb +++ b/lib/pull_preview/github_sync.rb @@ -8,7 +8,7 @@ class GithubSync attr_reader :opts attr_reader :always_on - LABEL = "pullpreview" + LABEL = "preview" def self.run(app_path, opts) github_event_name = ENV.fetch("GITHUB_EVENT_NAME") @@ -23,7 +23,20 @@ def self.run(app_path, opts) # 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! + github_sync = new(github_context, app_path, opts) + begin + seconds ||= 0.2 + github_sync.sync! + rescue => e + if seconds > 30 + raise e + github_sync.update_github_status(:error) + end + + sleep seconds + seconds *= 2 + retry + end end # Go over closed pull requests that are still labelled as "pullpreview", and force the destroyal of the corresponding environments @@ -116,9 +129,6 @@ def sync! else PullPreview.logger.info "Ignoring event #{pp_action.inspect}" end - rescue => e - update_github_status(:error) - raise e end def guess_action_from_event diff --git a/lib/pull_preview/instance.rb b/lib/pull_preview/instance.rb index 5cf8b9a..33f47a1 100644 --- a/lib/pull_preview/instance.rb +++ b/lib/pull_preview/instance.rb @@ -12,12 +12,59 @@ class Instance attr_reader :subdomain attr_reader :ports attr_reader :registries + attr_reader :deploy_key + attr_reader :ip_prefix + attr_reader :swap_enabled + alias swap_enabled? swap_enabled class << self attr_accessor :client attr_accessor :logger end + class Provisioner + COMPOSE_VERSION = "v2.10.2".freeze + + class << self + def ssh_access(ssh_public_key) + %{echo '#{ssh_public_key}' > /home/ec2-user/.ssh/authorized_keys} + end + + def prepare_user(remote_app_path) + [ + "mkdir -p #{remote_app_path} && chown -R ec2-user.ec2-user #{remote_app_path}", + "echo 'cd #{remote_app_path}' > /etc/profile.d/pullpreview.sh", + "echo '[[ -f /etc/pullpreview/env ]] && set -o allexport; source /etc/pullpreview/env; set +o allexport' >> /etc/profile.d/pullpreview.sh" + ] + end + + def setup_swapping + [ + "fallocate -l 2G /swapfile && chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile", + "echo '/swapfile none swap sw 0 0' | tee -a /etc/fstab", + "echo 'vm.swappiness=10' | tee - a /etc/sysctl.conf", + "echo 'vm.vfs_cache_pressure=50' | tee -a /etc/sysctl.conf" + ] + end + + def install_and_setup_docker + [ + "yum install -y docker", + %{curl -L "https://github.com/docker/compose/releases/download/#{COMPOSE_VERSION}/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose}, + "chmod +x /usr/local/bin/docker-compose", + "usermod -aG docker ec2-user", + "service docker start", + "echo 'docker image prune -a --filter=\"until=96h\" --force' > /etc/cron.daily/docker-prune && chmod a+x /etc/cron.daily/docker-prune", + "systemctl enable docker" + ] + end + + def finish_setup + "mkdir -p /etc/pullpreview && touch /etc/pullpreview/ready && chown -R ec2-user:ec2-user /etc/pullpreview" + end + end + end + def self.normalize_name(name) name. gsub(/[^a-z0-9]/i, "-"). @@ -36,7 +83,10 @@ def initialize(name, opts = {}) @compose_files = opts[:compose_files] || ["docker-compose.yml"] @registries = opts[:registries] || [] @dns = opts[:dns] + @ip_prefix = opts.key?(:ip_prefix) ? opts[:ip_prefix] : nil + @deploy_key = opts[:deploy_key] @ssh_results = [] + @swap_enabled = !opts[:disable_swap] end def remote_app_path @@ -44,7 +94,11 @@ def remote_app_path end def ssh_public_keys - @ssh_public_keys ||= admins.map do |github_username| + @ssh_public_keys ||= ([deploy_key] + github_keys).reject { |key| !key || key.empty? } + end + + def github_keys + @github_keys ||= admins.map do |github_username| URI.open("https://github.com/#{github_username}.keys").read.split("\n") end.flatten.reject{|key| key.empty?} end @@ -78,40 +132,38 @@ def launch(az, bundle_id, blueprint_id, tags = {}) if latest_snapshot 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: [ - "service docker restart" - ].join(" && "), - instance_snapshot_name: latest_snapshot.name, - })) + client.create_instances_from_snapshot(params.merge( + user_data: init_from_snapshot_command, + instance_snapshot_name: latest_snapshot.name + )) else logger.info "Creating new instance name=#{name}..." - client.create_instances(params.merge({ - user_data: [ - %{echo '#{ssh_public_keys.join("\n")}' > /home/ec2-user/.ssh/authorized_keys}, - "mkdir -p #{REMOTE_APP_PATH} && chown -R ec2-user.ec2-user #{REMOTE_APP_PATH}", - "echo 'cd #{REMOTE_APP_PATH}' > /etc/profile.d/pullpreview.sh", - "fallocate -l 2G /swapfile && chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile", - "echo '/swapfile none swap sw 0 0' | tee -a /etc/fstab", - "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/1.25.4/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose}, - "chmod +x /usr/local/bin/docker-compose", - "usermod -aG docker ec2-user", - "service docker start", - "echo '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 ec2-user:ec2-user /etc/pullpreview", - ].join(" && "), + client.create_instances(params.merge( + user_data: setup_command, blueprint_id: blueprint_id - })) + )) end end + def init_from_snapshot_command + [ + "pkill -9 docker ; pkill -9 containerd ; rm -f /var/run/docker/containerd/containerd.pid ; service docker start" + ].join(" && ") + end + + def setup_command + [ + Provisioner.ssh_access(ssh_public_keys.first), + Provisioner.prepare_user(REMOTE_APP_PATH), + swap_enabled? ? Provisioner.setup_swapping : nil, + Provisioner.install_and_setup_docker, + Provisioner.finish_setup + ].flatten.reject(&:empty?).join(" && ") + end + def latest_snapshot @latest_snapshot ||= 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 + snap.state == "available" && (snap.name == snapshot_name || snap.from_instance_name == name) end end @@ -132,9 +184,27 @@ def erb_locals public_dns: public_dns, admins: admins, url: url, + custom_launch_command: custom_launch_command, + custom_env_vars: custom_env_vars ) end + def basic_auth + ENV.fetch("BASIC_AUTH", nil) + end + + def custom_launch_command + ENV.fetch("PULLPREVIEW_LAUNCH_COMMAND", "") + end + + def custom_env_vars + ENV.fetch("PULLPREVIEW_ENV_VARS", "") + end + + def snapshot_name + ENV.fetch("PULLPREVIEW_SNAPSHOT_NAME", "") + end + def github_token ENV.fetch("GITHUB_TOKEN", "") end @@ -177,17 +247,15 @@ def setup_prepost_scripts raise Error, "Invalid registry" if uri.host.nil? || uri.scheme != "docker" username = uri.user password = uri.password - if password.nil? - password = username - username = "doesnotmatter" + if username && password + 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.end_with?('docker.io') ? nil : '"' + uri.host + '"', + username: username, + password: password, + } end - tmpfile.puts 'echo "Logging into ghcr.io..."' - # 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 @@ -253,11 +321,20 @@ def open_ports }) end + def tar_upload(remote_tarball_path) + system "tar --exclude=.git --exclude-from=.dockerignore -czf - . | #{ssh_command('cat - > ' + remote_tarball_path)}" + end + def scp(source, target, mode: "0644") ssh("cat - > #{target} && chmod #{mode} #{target}", input: File.new(source)) end def ssh(command, input: nil) + cmd = ssh_command(command, input: input) + system(cmd).tap {|result| @ssh_results.push([cmd, result])} + end + + def ssh_command(command, input: nil) key_file_path = "/tmp/tempkey" cert_key_path = "/tmp/tempkey-cert.pub" File.open(key_file_path, "w+") do |f| @@ -273,7 +350,8 @@ def ssh(command, input: nil) cmd = "cat #{input.path} | #{cmd}" end logger.debug cmd - system(cmd).tap {|result| @ssh_results.push([cmd, result])} + + cmd end def username @@ -286,16 +364,16 @@ def public_ip def public_dns # https://community.letsencrypt.org/t/a-certificate-for-a-63-character-domain/78870/4 - remaining_chars_for_subdomain = 62 - dns.size - public_ip.size - "ip".size - ("." * 3).size + remaining_chars_for_subdomain = 62 - dns.size - public_ip.size - (ip_prefix&.size || 0) - ("." * 3).size [ - [subdomain[0..remaining_chars_for_subdomain], "ip", public_ip.gsub(".", "-")].join("-"), + [subdomain[0..remaining_chars_for_subdomain], ip_prefix, public_ip.gsub(".", "-")].compact.join("-"), dns ].join(".") end def url scheme = (default_port == "443" ? "https" : "http") - "#{scheme}://#{public_dns}:#{default_port}" + "#{scheme}://#{basic_auth && basic_auth + '@'}#{public_dns}:#{default_port}" end def ssh_address diff --git a/lib/pull_preview/up.rb b/lib/pull_preview/up.rb index 662c03e..c81b693 100644 --- a/lib/pull_preview/up.rb +++ b/lib/pull_preview/up.rb @@ -19,12 +19,6 @@ def self.run(app_path, opts) aws_region = PullPreview.lightsail.config.region 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) unless instance.running? @@ -46,11 +40,11 @@ def self.run(app_path, opts) (2..3).include?(bundle.ram_size_in_gb) && bundle.supported_platforms.include?("LINUX_UNIX") else - bundle.bundle_id == opts[:instance_type] + bundle.bundle_id =~ /^#{opts[:instance_type]}/i end end.bundle_id - instance.launch(azs.first, bundle_id, blueprint_id, tags) + instance.launch(azs.sample, bundle_id, blueprint_id, tags) instance.wait_until_running! sleep 2 end @@ -64,22 +58,25 @@ def self.run(app_path, opts) puts puts "To connect to the instance (authorized GitHub users: #{instance.admins.join(", ")}):" - puts " ssh #{instance.ssh_address}" + puts "ssh #{instance.ssh_address}" puts - 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) + PullPreview.logger.info "Uploading app tarball..." + unless instance.tar_upload(remote_tarball_path) raise Error, "Unable to copy application content on instance. Aborting." end + size_fetch_command = instance.ssh_command("du -sh #{remote_tarball_path}") + PullPreview.logger.info "Successfully uploaded " + `#{size_fetch_command}`[/\S+/] + PullPreview.logger.info "Launching application..." ok = instance.ssh("/tmp/update_script.sh #{remote_tarball_path}") - puts "::set-output name=url::#{instance.url}" - puts "::set-output name=host::#{instance.public_ip}" - puts "::set-output name=username::#{instance.username}" + puts "echo url=#{instance.url} >> $GITHUB_OUTPUT" + puts "echo host=#{instance.public_ip} >> $GITHUB_OUTPUT" + puts "echo username=#{instance.username} >> $GITHUB_OUTPUT" puts puts "You can access your application at the following URL:" @@ -99,6 +96,7 @@ def self.run(app_path, opts) if ok instance else + instance.ssh 'cd /app && docker-compose logs --tail 100' 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 diff --git a/pullpreview.gemspec b/pullpreview.gemspec new file mode 100644 index 0000000..9be526b --- /dev/null +++ b/pullpreview.gemspec @@ -0,0 +1,18 @@ +Gem::Specification.new do |spec| + spec.name = "pullpreview" + spec.version = "0.0.3" + + spec.authors = ["Cyril Rohr", "Manuel Fittko"] + spec.date = "2021-10-19" + spec.description = "1-click preview environments for GitHub repositories." + spec.email = "info@mfittko.com" + spec.files = Dir.glob("lib/**/*") + spec.executables = ["pullpreview"] + spec.homepage = "https://pullpreview.com/" + spec.summary = "pullpreview!" + spec.required_ruby_version = ">= 2.4.0" + spec.add_dependency "aws-sdk-lightsail", "~> 1.32" + spec.add_dependency "slop", "~> 4.10" + spec.add_dependency "octokit", "~> 4.22" + spec.add_dependency "terminal-table", "~> 3.0" +end