From abbb3b618c9d5c46218ded5beb228c0739808114 Mon Sep 17 00:00:00 2001 From: Rafael Gomes Date: Fri, 10 May 2024 02:53:23 -0300 Subject: [PATCH] Updates to `run` command (#151) * test: fix duplicate apps deletion during cleanup * feat: raise error if no value is provided for a non-boolean option * feat: add option to delete specific workload to delete command * feat: add options to use different limit on number of entries and loopback window to logs command * feat: add option to display logs for specific replica to logs command * feat: add option to stop specific replica to ps:stop command * feat: improve run command * feat: improve handling of commands that accept extra options * feat: run release script in context of run command * fix: use exact same image as original workload by default * fix: wait for runner workload to be updated before starting job * chore: add note regarding stty size * test: update specs * fix: add delay before starting job if image changed * More log drain variants * Add log_method option, cleanup * Fix Kernel.system orphan process handling --- README.md | 14 +- docs/commands.md | 106 ++-- docs/migrating.md | 8 +- examples/circleci.yml | 6 +- examples/controlplane.yml | 12 +- lib/command/base.rb | 149 +++++- lib/command/delete.rb | 46 +- lib/command/deploy_image.rb | 15 +- lib/command/logs.rb | 24 +- lib/command/ps.rb | 2 +- lib/command/ps_start.rb | 3 +- lib/command/ps_stop.rb | 48 +- lib/command/ps_wait.rb | 5 +- lib/command/run.rb | 501 +++++++++++++++--- lib/command/run_cleanup.rb | 90 ---- lib/command/run_detached.rb | 181 ------- lib/core/controlplane.rb | 86 ++- lib/core/controlplane_api.rb | 4 +- lib/core/scripts.rb | 34 -- lib/cpl.rb | 40 +- lib/deprecated_commands.json | 3 +- lib/generator_templates/controlplane.yml | 4 +- spec/command/delete_spec.rb | 61 +++ spec/command/deploy_image_spec.rb | 31 +- spec/command/logs_spec.rb | 102 +++- .../command/promote_app_from_upstream_spec.rb | 37 +- spec/command/ps_stop_spec.rb | 63 ++- spec/command/run_cleanup_spec.rb | 179 ------- spec/command/run_detached_spec.rb | 94 ---- spec/command/run_spec.rb | 249 +++++---- spec/core/controlplane_api_direct_spec.rb | 2 +- spec/cpl_spec.rb | 44 +- spec/dummy/.controlplane/controlplane.yml | 10 - spec/dummy/.controlplane/release-invalid.sh | 2 +- spec/dummy/.controlplane/release.sh | 2 +- .../templates/fake-run-12345.yml | 26 - .../templates/fake-runner-12345.yml | 26 - spec/fixtures/config.yml | 61 --- spec/support/command_helpers.rb | 34 +- spec/support/log_helpers.rb | 44 ++ spec/support/spawned_command.rb | 17 + 41 files changed, 1302 insertions(+), 1163 deletions(-) delete mode 100644 lib/command/run_cleanup.rb delete mode 100644 lib/command/run_detached.rb delete mode 100644 lib/core/scripts.rb delete mode 100644 spec/command/run_cleanup_spec.rb delete mode 100644 spec/command/run_detached_spec.rb delete mode 100644 spec/dummy/.controlplane/templates/fake-run-12345.yml delete mode 100644 spec/dummy/.controlplane/templates/fake-runner-12345.yml delete mode 100644 spec/fixtures/config.yml create mode 100644 spec/support/log_helpers.rb diff --git a/README.md b/README.md index d0bd841d..1b4ce01c 100644 --- a/README.md +++ b/README.md @@ -183,7 +183,7 @@ aliases: # Control Plane offers the ability to use multiple locations. # default_location is used for commands that require a location - # including `ps`, `run`, `run:detached`, `apply-template`. + # including `ps`, `run`, `apply-template`. # This can be overridden with option --location= and # CPLN_LOCATION environment variable. default_location: aws-us-east-2 @@ -215,7 +215,7 @@ aliases: # Workloads that are for the application itself and are using application Docker images. # These are updated with the new image when running the `deploy-image` command, - # and are also used by the `info`, `ps:`, and `run:cleanup` commands in order to get all of the defined workloads. + # and are also used by the `info` and `ps:` commands in order to get all of the defined workloads. # On the other hand, if you have a workload for Redis, that would NOT use the application Docker image # and not be listed here. app_workloads: @@ -223,7 +223,7 @@ aliases: - sidekiq # Additional "service type" workloads, using non-application Docker images. - # These are only used by the `info`, `ps:` and `run:cleanup` commands in order to get all of the defined workloads. + # These are only used by the `info` and `ps:` commands in order to get all of the defined workloads. additional_workloads: - redis - postgres @@ -233,7 +233,7 @@ aliases: maintenance_workload: maintenance # Fixes the remote terminal size to match the local terminal size - # when running the commands `cpl run` or `cpl run:detached`. + # when running `cpl run`. fix_terminal_size: true # Apps with a deployed image created before this amount of days will be listed for deletion @@ -248,10 +248,6 @@ aliases: # when running the command `cpl cleanup-images` (`image_retention_max_qty` takes precedence). image_retention_days: 5 - # Run workloads created before this amount of days will be listed for deletion - # when running the command `cpl run:cleanup`. - stale_run_workload_created_days: 2 - apps: my-app-staging: # Use the values from the common section above. @@ -336,7 +332,7 @@ cpl build-image -a tutorial-app # Run database migrations (or other release tasks) with the latest image, # while the app is still running on the previous image. # This is analogous to the release phase. -cpl run:detached rails db:migrate -a tutorial-app --image latest +cpl run -a tutorial-app --image latest -- rails db:migrate # Pomote the latest image to the app. cpl deploy-image -a tutorial-app diff --git a/docs/commands.md b/docs/commands.md index 0c6bbc6b..ddd81f65 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -105,17 +105,22 @@ cpl copy-image-from-upstream -a $APP_NAME --upstream-token $UPSTREAM_TOKEN --ima ### `delete` -- Deletes the whole app (GVC with all workloads, all volumesets and all images) +- Deletes the whole app (GVC with all workloads, all volumesets and all images) or a specific workload - Will ask for explicit user confirmation ```sh +# Deletes the whole app (GVC with all workloads, all volumesets and all images). cpl delete -a $APP_NAME + +# Deletes a specific workload. +cpl delete -a $APP_NAME -w $WORKLOAD_NAME ``` ### `deploy-image` - Deploys the latest image to app workloads - Optionally runs a release script before deploying if specified through `release_script` in the `.controlplane/controlplane.yml` file and `--run-release-phase` is provided +- The release script is run in the context of `cpl run` with the latest image - The deploy will fail if the release script exits with a non-zero code or doesn't exist ```sh @@ -177,6 +182,7 @@ cpl latest-image -a $APP_NAME ### `logs` - Light wrapper to display tailed raw logs for app/workload syntax +- Defaults to showing the last 200 entries from the past 1 hour before tailing ```sh # Displays logs for the default workload (`one_off_workload`). @@ -184,6 +190,15 @@ cpl logs -a $APP_NAME # Displays logs for a specific workload. cpl logs -a $APP_NAME -w $WORKLOAD_NAME + +# Displays logs for a specific replica of a workload. +cpl logs -a $APP_NAME -w $WORKLOAD_NAME -r $REPLICA_NAME + +# Uses a different limit on number of entries. +cpl logs -a $APP_NAME --limit 100 + +# Uses a different loopback window. +cpl logs -a $APP_NAME --since 30min ``` ### `maintenance` @@ -311,6 +326,9 @@ cpl ps:stop -a $APP_NAME # Stops a specific workload in app. cpl ps:stop -a $APP_NAME -w $WORKLOAD_NAME + +# Stops a specific replica of a workload. +cpl ps:stop -a $APP_NAME -w $WORKLOAD_NAME -r $REPLICA_NAME ``` ### `ps:wait` @@ -327,26 +345,43 @@ cpl ps:swait -a $APP_NAME -w $WORKLOAD_NAME ### `run` -- Runs one-off **_interactive_** replicas (analog of `heroku run`) -- Uses `Standard` workload type and `cpln exec` as the execution method, with CLI streaming -- If `fix_terminal_size` is `true` in the `.controlplane/controlplane.yml` file, the remote terminal size will be fixed to match the local terminal size (may also be overriden through `--terminal-size`) - -> **IMPORTANT:** Useful for development where it's needed for interaction, and where network connection drops and -> task crashing are tolerable. For production tasks, it's better to use `cpl run:detached`. +- Runs one-off interactive or non-interactive replicas (analog of `heroku run`) +- Uses `Cron` workload type and either: +- - `cpln workload exec` for interactive mode, with CLI streaming +- - log async fetching for non-interactive mode +- The Dockerfile entrypoint is used as the command by default, which assumes `exec "${@}"` to be present, + and the args ["bash", "-c", cmd_to_run] are passed +- The entrypoint can be overriden through `--entrypoint`, which must be a single command or a script path that exists in the container, + and the args ["bash", "-c", cmd_to_run] are passed, + unless the entrypoint is `bash`, in which case the args ["-c", cmd_to_run] are passed +- Providing `--entrypoint none` sets the entrypoint to `bash` by default +- If `fix_terminal_size` is `true` in the `.controlplane/controlplane.yml` file, + the remote terminal size will be fixed to match the local terminal size (may also be overriden through `--terminal-size`) ```sh # Opens shell (bash by default). cpl run -a $APP_NAME -# Need to quote COMMAND if setting ENV value or passing args. -cpl run -a $APP_NAME -- 'LOG_LEVEL=warn rails db:migrate' +# Runs interactive command, keeps shell open, and stops job when exiting. +cpl run -a $APP_NAME --interactive -- rails c -# Runs command, displays output, and exits shell. -cpl run -a $APP_NAME -- ls / -cpl run -a $APP_NAME -- rails db:migrate:status +# Some commands are automatically detected as interactive, so no need to pass `--interactive`. +cpl run -a $APP_NAME -- bash + cpl run -a $APP_NAME -- rails console + cpl run -a $APP_NAME -- rails c + cpl run -a $APP_NAME -- rails dbconsole + cpl run -a $APP_NAME -- rails db -# Runs command and keeps shell open. -cpl run -a $APP_NAME -- rails c +# Runs non-interactive command, outputs logs, exits with the exit code of the command and stops job. +cpl run -a $APP_NAME -- rails db:migrate + +# Runs non-iteractive command, detaches, exits with 0, and prints commands to: +# - see logs from the job +# - stop the job +cpl run -a $APP_NAME --detached -- rails db:migrate + +# The command needs to be quoted if setting an env variable or passing args. +cpl run -a $APP_NAME -- 'SOME_ENV_VAR=some_value rails db:migrate' # Uses a different image (which may not be promoted yet). cpl run -a $APP_NAME --image appimage:123 -- rails db:migrate # Exact image name @@ -358,47 +393,12 @@ cpl run -a $APP_NAME -w other-workload -- bash # Overrides remote CPLN_TOKEN env variable with local token. # Useful when superuser rights are needed in remote container. cpl run -a $APP_NAME --use-local-token -- bash -``` - -### `run:cleanup` - -- Deletes stale run workloads for an app -- Workloads are considered stale based on how many days since created -- `stale_run_workload_created_days` in the `.controlplane/controlplane.yml` file specifies the number of days after created that the workload is considered stale -- Works for both interactive workloads (created with `cpl run`) and non-interactive workloads (created with `cpl run:detached`) -- Will ask for explicit user confirmation of deletion - -```sh -cpl run:cleanup -a $APP_NAME -``` - -### `run:detached` - -- Runs one-off **_non-interactive_** replicas (close analog of `heroku run:detached`) -- Uses `Cron` workload type with log async fetching -- Implemented with only async execution methods, more suitable for production tasks -- Has alternative log fetch implementation with only JSON-polling and no WebSockets -- Less responsive but more stable, useful for CI tasks -- Deletes the workload whenever finished with success -- Deletes the workload whenever finished with failure by default -- Use `--no-clean-on-failure` to disable cleanup to help with debugging failed runs - -```sh -cpl run:detached rails db:prepare -a $APP_NAME -# Need to quote COMMAND if setting ENV value or passing args. -cpl run:detached -a $APP_NAME -- 'LOG_LEVEL=warn rails db:migrate' +# Replaces the existing Dockerfile entrypoint with `bash`. +cpl run -a $APP_NAME --entrypoint none -- rails db:migrate -# Uses a different image (which may not be promoted yet). -cpl run:detached -a $APP_NAME --image appimage:123 -- rails db:migrate # Exact image name -cpl run:detached -a $APP_NAME --image latest -- rails db:migrate # Latest sequential image - -# Uses a different workload than `one_off_workload` from `.controlplane/controlplane.yml`. -cpl run:detached -a $APP_NAME -w other-workload -- rails db:migrate:status - -# Overrides remote CPLN_TOKEN env variable with local token. -# Useful when superuser rights are needed in remote container. -cpl run:detached -a $APP_NAME --use-local-token -- rails db:migrate:status +# Replaces the existing Dockerfile entrypoint. +cpl run -a $APP_NAME --entrypoint /app/alternative-entrypoint.sh -- rails db:migrate ``` ### `setup-app` diff --git a/docs/migrating.md b/docs/migrating.md index eebad838..85907e75 100644 --- a/docs/migrating.md +++ b/docs/migrating.md @@ -93,7 +93,7 @@ cpl setup-app -a my-app-staging cpl build-image -a my-app-staging --commit 456 # Prepare database. -cpl run:detached -a my-app-staging --image latest -- rails db:prepare +cpl run -a my-app-staging --image latest -- rails db:prepare # Deploy latest image. cpl deploy-image -a my-app-staging @@ -113,7 +113,7 @@ cpl build-image -a my-app-staging --commit ABC # Run database migrations (or other release tasks) with latest image, while app is still running on previous image. # This is analogous to the release phase. -cpl run:detached -a my-app-staging --image latest -- rails db:migrate +cpl run -a my-app-staging --image latest -- rails db:migrate # Deploy latest image. cpl deploy-image -a my-app-staging @@ -215,9 +215,9 @@ fi # The `NEW_APP` env var that we exported above can be used to either reset or migrate the database before deploying. if [ -n "${NEW_APP}" ]; then - cpl run:detached 'LOG_LEVEL=warn rails db:reset' -a ${APP_NAME} --image latest + cpl run -a ${APP_NAME} --image latest -- rails db:reset else - cpl run:detached 'LOG_LEVEL=warn rails db:migrate_and_wait_replica' -a ${APP_NAME} --image latest + cpl run -a ${APP_NAME} --image latest -- rails db:migrate fi ``` diff --git a/examples/circleci.yml b/examples/circleci.yml index b19ad0e3..a24d2800 100644 --- a/examples/circleci.yml +++ b/examples/circleci.yml @@ -27,7 +27,7 @@ build-staging: command: cpl build-image -a ${APP_NAME} - run: name: Database tasks - command: cpl run:detached -a ${APP_NAME} --image latest -- rails db:migrate + command: cpl run -a ${APP_NAME} --image latest -- rails db:migrate - run: name: Deploy image command: cpl deploy-image -a ${APP_NAME} @@ -75,9 +75,9 @@ build-review-app: name: Database tasks command: | if [ -n "${NEW_APP}" ]; then - cpl run:detached -a ${APP_NAME} --image latest -- LOG_LEVEL=warn rails db:reset + cpl run -a ${APP_NAME} --image latest -- rails db:reset else - cpl run:detached -a ${APP_NAME} --image latest -- LOG_LEVEL=warn rails db:migrate + cpl run -a ${APP_NAME} --image latest -- rails db:migrate fi - run: name: Deploy image diff --git a/examples/controlplane.yml b/examples/controlplane.yml index dd2c2a35..d534971b 100644 --- a/examples/controlplane.yml +++ b/examples/controlplane.yml @@ -17,7 +17,7 @@ aliases: # Control Plane offers the ability to use multiple locations. # default_location is used for commands that require a location - # including `ps`, `run`, `run:detached`, `apply-template`. + # including `ps`, `run`, `apply-template`. # This can be overridden with option --location= and # CPLN_LOCATION environment variable. # TODO: Allow specification of multiple locations. @@ -50,7 +50,7 @@ aliases: # Workloads that are for the application itself and are using application Docker images. # These are updated with the new image when running the `deploy-image` command, - # and are also used by the `info`, `ps:`, and `run:cleanup` commands in order to get all of the defined workloads. + # and are also used by the `info` and `ps:` commands in order to get all of the defined workloads. # On the other hand, if you have a workload for Redis, that would NOT use the application Docker image # and not be listed here. app_workloads: @@ -58,7 +58,7 @@ aliases: - sidekiq # Additional "service type" workloads, using non-application Docker images. - # These are only used by the `info`, `ps:` and `run:cleanup` commands in order to get all of the defined workloads. + # These are only used by the `info` and `ps:` commands in order to get all of the defined workloads. additional_workloads: - redis - postgres @@ -68,7 +68,7 @@ aliases: maintenance_workload: maintenance # Fixes the remote terminal size to match the local terminal size - # when running the commands `cpl run` or `cpl run:detached`. + # when running `cpl run`. fix_terminal_size: true # Apps with a deployed image created before this amount of days will be listed for deletion @@ -83,10 +83,6 @@ aliases: # when running the command `cpl cleanup-images` (`image_retention_max_qty` takes precedence). image_retention_days: 5 - # Run workloads created before this amount of days will be listed for deletion - # when running the command `cpl run:cleanup`. - stale_run_workload_created_days: 2 - apps: my-app-staging: # Use the values from the common section above. diff --git a/lib/command/base.rb b/lib/command/base.rb index 0ed75545..760f4fb5 100644 --- a/lib/command/base.rb +++ b/lib/command/base.rb @@ -51,6 +51,7 @@ def self.common_options [org_option, verbose_option, trace_option] end + # rubocop:disable Metrics/MethodLength def self.org_option(required: false) { name: :org, @@ -90,6 +91,19 @@ def self.workload_option(required: false) } end + def self.replica_option(required: false) + { + name: :replica, + params: { + aliases: ["-r"], + banner: "REPLICA_NAME", + desc: "Replica name", + type: :string, + required: required + } + } + end + def self.image_option(required: false) { name: :image, @@ -103,6 +117,20 @@ def self.image_option(required: false) } end + def self.log_method_option(required: false) + { + name: :log_method, + params: { + type: :numeric, + banner: "LOG_METHOD", + desc: "Log method", + required: required, + valid_values: [1, 2, 3], + default: 3 + } + } + end + def self.commit_option(required: false) { name: :commit, @@ -198,7 +226,8 @@ def self.terminal_size_option(required: false) banner: "ROWS,COLS", desc: "Override remote terminal size (e.g. `--terminal-size 10,20`)", type: :string, - required: required + required: required, + valid_regex: /^\d+,\d+$/ } } end @@ -237,66 +266,128 @@ def self.trace_option(required: false) } end - def self.clean_on_failure_option(required: false) + def self.skip_secret_access_binding_option(required: false) { - name: :clean_on_failure, + name: :skip_secret_access_binding, params: { - desc: "Deletes workload when finished with failure (success always deletes)", + desc: "Skips secret access binding", type: :boolean, + required: required + } + } + end + + def self.run_release_phase_option(required: false) + { + name: :run_release_phase, + params: { + desc: "Runs release phase", + type: :boolean, + required: required + } + } + end + + def self.logs_limit_option(required: false) + { + name: :limit, + params: { + banner: "NUMBER", + desc: "Limit on number of log entries to show", + type: :numeric, required: required, - default: true + default: 200 } } end - def self.skip_secret_access_binding_option(required: false) + def self.logs_since_option(required: false) { - name: :skip_secret_access_binding, + name: :since, params: { - desc: "Skips secret access binding", + banner: "DURATION", + desc: "Loopback window for showing logs " \ + "(see https://www.npmjs.com/package/parse-duration for the accepted formats, e.g., '1h')", + type: :string, + required: required, + default: "1h" + } + } + end + + def self.interactive_option(required: false) + { + name: :interactive, + params: { + desc: "Runs interactive command", type: :boolean, required: required } } end - def self.run_release_phase_option(required: false) + def self.detached_option(required: false) { - name: :run_release_phase, + name: :detached, params: { - desc: "Runs release phase", + desc: "Runs non-interactive command, detaches, and prints commands to log and stop the job", type: :boolean, required: required } } end - def self.all_options - methods.grep(/_option$/).map { |method| send(method.to_s) } + def self.cpu_option(required: false) + { + name: :cpu, + params: { + banner: "CPU", + desc: "Overrides CPU millicores " \ + "(e.g., '100m' for 100 millicores, '1' for 1 core)", + type: :string, + required: required, + valid_regex: /^\d+m?$/ + } + } end - def self.all_options_by_key_name - all_options.each_with_object({}) do |option, result| - option[:params][:aliases]&.each { |current_alias| result[current_alias.to_s] = option } - result["--#{option[:name]}"] = option - end + def self.memory_option(required: false) + { + name: :memory, + params: { + banner: "MEMORY", + desc: "Overrides memory size " \ + "(e.g., '100Mi' for 100 mebibytes, '1Gi' for 1 gibibyte)", + type: :string, + required: required, + valid_regex: /^\d+[MG]i$/ + } + } end - def wait_for_workload(workload) - step("Waiting for workload", retry_on_failure: true) do - cp.fetch_workload(workload) - end + def self.entrypoint_option(required: false) + { + name: :entrypoint, + params: { + banner: "ENTRYPOINT", + desc: "Overrides entrypoint " \ + "(must be a single command or a script path that exists in the container)", + type: :string, + required: required, + valid_regex: /^\S+$/ + } + } end + # rubocop:enable Metrics/MethodLength - def wait_for_replica(workload, location) - step("Waiting for replica", retry_on_failure: true) do - cp.workload_get_replicas_safely(workload, location: location)&.dig("items", 0) - end + def self.all_options + methods.grep(/_option$/).map { |method| send(method.to_s) } end - def ensure_workload_deleted(workload) - step("Deleting workload") do - cp.delete_workload(workload) + def self.all_options_by_key_name + all_options.each_with_object({}) do |option, result| + option[:params][:aliases]&.each { |current_alias| result[current_alias.to_s] = option } + result["--#{option[:name]}"] = option end end diff --git a/lib/command/delete.rb b/lib/command/delete.rb index ead7beba..6c2e370a 100644 --- a/lib/command/delete.rb +++ b/lib/command/delete.rb @@ -5,28 +5,54 @@ class Delete < Base NAME = "delete" OPTIONS = [ app_option(required: true), + workload_option, skip_confirm_option ].freeze - DESCRIPTION = "Deletes the whole app (GVC with all workloads, all volumesets and all images)" + DESCRIPTION = "Deletes the whole app (GVC with all workloads, all volumesets and all images) or a specific workload" LONG_DESCRIPTION = <<~DESC - - Deletes the whole app (GVC with all workloads, all volumesets and all images) + - Deletes the whole app (GVC with all workloads, all volumesets and all images) or a specific workload - Will ask for explicit user confirmation DESC + EXAMPLES = <<~EX + ```sh + # Deletes the whole app (GVC with all workloads, all volumesets and all images). + cpl delete -a $APP_NAME + + # Deletes a specific workload. + cpl delete -a $APP_NAME -w $WORKLOAD_NAME + ``` + EX def call + workload = config.options[:workload] + if workload + delete_single_workload(workload) + else + delete_whole_app + end + end + + private + + def delete_single_workload(workload) + return progress.puts("Workload '#{workload}' does not exist.") if cp.fetch_workload(workload).nil? + return unless confirm_delete(workload) + + delete_workload(workload) + end + + def delete_whole_app return progress.puts("App '#{config.app}' does not exist.") if cp.fetch_gvc.nil? check_volumesets check_images - return unless confirm_delete + return unless confirm_delete(config.app) delete_volumesets delete_gvc delete_images end - private - def check_volumesets @volumesets = cp.fetch_volumesets["items"] return progress.puts("No volumesets to delete.") unless @volumesets.any? @@ -46,10 +72,10 @@ def check_images progress.puts("#{Shell.color(message, :red)}\n#{images_list}\n\n") end - def confirm_delete + def confirm_delete(item) return true if config.options[:yes] - confirmed = Shell.confirm("Are you sure you want to delete '#{config.app}'?") + confirmed = Shell.confirm("Are you sure you want to delete '#{item}'?") return false unless confirmed progress.puts @@ -62,6 +88,12 @@ def delete_gvc end end + def delete_workload(workload) + step("Deleting workload '#{workload}'") do + cp.delete_workload(workload) + end + end + def delete_volumesets @volumesets.each do |volumeset| step("Deleting volumeset '#{volumeset['name']}'") do diff --git a/lib/command/deploy_image.rb b/lib/command/deploy_image.rb index 574725b4..faee12fb 100644 --- a/lib/command/deploy_image.rb +++ b/lib/command/deploy_image.rb @@ -11,6 +11,7 @@ class DeployImage < Base LONG_DESCRIPTION = <<~DESC - Deploys the latest image to app workloads - Optionally runs a release script before deploying if specified through `release_script` in the `.controlplane/controlplane.yml` file and `--run-release-phase` is provided + - The release script is run in the context of `cpl run` with the latest image - The deploy will fail if the release script exits with a non-zero code or doesn't exist DESC @@ -47,7 +48,7 @@ def call # rubocop:disable Metrics/MethodLength private - def run_release_script + def run_release_script # rubocop:disable Metrics/MethodLength release_script_name = config[:release_script] release_script_path = Pathname.new("#{config.app_cpln_dir}/#{release_script_name}").expand_path @@ -55,12 +56,16 @@ def run_release_script progress.puts("Running release script...\n\n") - result = Shell.cmd("bash", release_script_path, capture_stderr: true) - progress.puts(result[:output]) + release_script = File.read(release_script_path) + begin + Cpl::Cli.start(["run", "-a", config.app, "--image", "latest", "--", release_script]) + rescue SystemExit => e + progress.puts - raise "Failed to run release script." unless result[:success] + raise "Failed to run release script." if e.status.nonzero? - progress.puts + progress.puts("Finished running release script.\n\n") + end end end end diff --git a/lib/command/logs.rb b/lib/command/logs.rb index 9e468227..69135470 100644 --- a/lib/command/logs.rb +++ b/lib/command/logs.rb @@ -5,11 +5,15 @@ class Logs < Base NAME = "logs" OPTIONS = [ app_option(required: true), - workload_option + workload_option, + replica_option, + logs_limit_option, + logs_since_option ].freeze DESCRIPTION = "Light wrapper to display tailed raw logs for app/workload syntax" LONG_DESCRIPTION = <<~DESC - Light wrapper to display tailed raw logs for app/workload syntax + - Defaults to showing the last 200 entries from the past 1 hour before tailing DESC EXAMPLES = <<~EX ```sh @@ -18,12 +22,28 @@ class Logs < Base # Displays logs for a specific workload. cpl logs -a $APP_NAME -w $WORKLOAD_NAME + + # Displays logs for a specific replica of a workload. + cpl logs -a $APP_NAME -w $WORKLOAD_NAME -r $REPLICA_NAME + + # Uses a different limit on number of entries. + cpl logs -a $APP_NAME --limit 100 + + # Uses a different loopback window. + cpl logs -a $APP_NAME --since 30min ``` EX def call workload = config.options[:workload] || config[:one_off_workload] - cp.logs(workload: workload) + replica = config.options[:replica] + limit = config.options[:limit] + since = config.options[:since] + + message = replica ? "replica '#{replica}'" : "workload '#{workload}'" + progress.puts("Fetching logs for #{message}...\n\n") + + cp.logs(workload: workload, replica: replica, limit: limit, since: since) end end end diff --git a/lib/command/ps.rb b/lib/command/ps.rb index a7ea777a..1db5256b 100644 --- a/lib/command/ps.rb +++ b/lib/command/ps.rb @@ -33,7 +33,7 @@ def call workloads.each do |workload| cp.fetch_workload!(workload) - result = cp.workload_get_replicas(workload, location: location) + result = cp.fetch_workload_replicas(workload, location: location) result["items"].each { |replica| puts replica } end end diff --git a/lib/command/ps_start.rb b/lib/command/ps_start.rb index 0c5be6cf..73707998 100644 --- a/lib/command/ps_start.rb +++ b/lib/command/ps_start.rb @@ -6,6 +6,7 @@ class PsStart < Base OPTIONS = [ app_option(required: true), workload_option, + location_option, wait_option("workload to be ready") ].freeze DESCRIPTION = "Starts workloads in app" @@ -42,7 +43,7 @@ def wait_for_ready @workloads.reverse_each do |workload| step("Waiting for workload '#{workload}' to be ready", retry_on_failure: true) do - cp.workload_deployments_ready?(workload, expected_status: true) + cp.workload_deployments_ready?(workload, location: config.location, expected_status: true) end end end diff --git a/lib/command/ps_stop.rb b/lib/command/ps_stop.rb index 41a133df..1ad0306d 100644 --- a/lib/command/ps_stop.rb +++ b/lib/command/ps_stop.rb @@ -6,6 +6,8 @@ class PsStop < Base OPTIONS = [ app_option(required: true), workload_option, + replica_option, + location_option, wait_option("workload to not be ready") ].freeze DESCRIPTION = "Stops workloads in app" @@ -19,32 +21,62 @@ class PsStop < Base # Stops a specific workload in app. cpl ps:stop -a $APP_NAME -w $WORKLOAD_NAME + + # Stops a specific replica of a workload. + cpl ps:stop -a $APP_NAME -w $WORKLOAD_NAME -r $REPLICA_NAME ``` EX def call - @workloads = [config.options[:workload]] if config.options[:workload] - @workloads ||= config[:app_workloads] + config[:additional_workloads] + workload = config.options[:workload] + replica = config.options[:replica] + if replica + stop_replica(workload, replica) + else + workloads = [workload] if workload + workloads ||= config[:app_workloads] + config[:additional_workloads] + + stop_workloads(workloads) + end + end - @workloads.each do |workload| + private + + def stop_workloads(workloads) + workloads.each do |workload| step("Stopping workload '#{workload}'") do cp.set_workload_suspend(workload, true) end end - wait_for_not_ready if config.options[:wait] + wait_for_workloads_not_ready(workloads) if config.options[:wait] end - private + def stop_replica(workload, replica) + step("Stopping replica '#{replica}'", retry_on_failure: true) do + cp.stop_workload_replica(workload, replica, location: config.location) + end - def wait_for_not_ready + wait_for_replica_not_ready(workload, replica) if config.options[:wait] + end + + def wait_for_workloads_not_ready(workloads) progress.puts - @workloads.each do |workload| + workloads.each do |workload| step("Waiting for workload '#{workload}' to not be ready", retry_on_failure: true) do - cp.workload_deployments_ready?(workload, expected_status: false) + cp.workload_deployments_ready?(workload, location: config.location, expected_status: false) end end end + + def wait_for_replica_not_ready(workload, replica) + progress.puts + + step("Waiting for replica '#{replica}' to not be ready", retry_on_failure: true) do + result = cp.fetch_workload_replicas(workload, location: config.location) + !result["items"].include?(replica) + end + end end end diff --git a/lib/command/ps_wait.rb b/lib/command/ps_wait.rb index 574158bf..a6749ac3 100644 --- a/lib/command/ps_wait.rb +++ b/lib/command/ps_wait.rb @@ -5,7 +5,8 @@ class PsWait < Base NAME = "ps:wait" OPTIONS = [ app_option(required: true), - workload_option + workload_option, + location_option ].freeze DESCRIPTION = "Waits for workloads in app to be ready after re-deployment" LONG_DESCRIPTION = <<~DESC @@ -27,7 +28,7 @@ def call @workloads.reverse_each do |workload| step("Waiting for workload '#{workload}' to be ready", retry_on_failure: true) do - cp.workload_deployments_ready?(workload, expected_status: true) + cp.workload_deployments_ready?(workload, location: config.location, expected_status: true) end end end diff --git a/lib/command/run.rb b/lib/command/run.rb index d8a9aa7a..9454ca8b 100644 --- a/lib/command/run.rb +++ b/lib/command/run.rb @@ -1,7 +1,15 @@ # frozen_string_literal: true module Command - class Run < Base + class Run < Base # rubocop:disable Metrics/ClassLength + INTERACTIVE_COMMANDS = [ + "bash", + "rails console", + "rails c", + "rails dbconsole", + "rails db" + ].freeze + NAME = "run" USAGE = "run COMMAND" REQUIRES_ARGS = true @@ -9,34 +17,53 @@ class Run < Base OPTIONS = [ app_option(required: true), image_option, + log_method_option, workload_option, location_option, use_local_token_option, - terminal_size_option + terminal_size_option, + interactive_option, + detached_option, + cpu_option, + memory_option, + entrypoint_option ].freeze - DESCRIPTION = "Runs one-off **_interactive_** replicas (analog of `heroku run`)" + DESCRIPTION = "Runs one-off interactive or non-interactive replicas (analog of `heroku run`)" LONG_DESCRIPTION = <<~DESC - - Runs one-off **_interactive_** replicas (analog of `heroku run`) - - Uses `Standard` workload type and `cpln exec` as the execution method, with CLI streaming - - If `fix_terminal_size` is `true` in the `.controlplane/controlplane.yml` file, the remote terminal size will be fixed to match the local terminal size (may also be overriden through `--terminal-size`) - - > **IMPORTANT:** Useful for development where it's needed for interaction, and where network connection drops and - > task crashing are tolerable. For production tasks, it's better to use `cpl run:detached`. + - Runs one-off interactive or non-interactive replicas (analog of `heroku run`) + - Uses `Cron` workload type and either: + - - `cpln workload exec` for interactive mode, with CLI streaming + - - log async fetching for non-interactive mode + - The Dockerfile entrypoint is used as the command by default, which assumes `exec "${@}"` to be present, + and the args ["bash", "-c", cmd_to_run] are passed + - The entrypoint can be overriden through `--entrypoint`, which must be a single command or a script path that exists in the container, + and the args ["bash", "-c", cmd_to_run] are passed, + unless the entrypoint is `bash`, in which case the args ["-c", cmd_to_run] are passed + - Providing `--entrypoint none` sets the entrypoint to `bash` by default + - If `fix_terminal_size` is `true` in the `.controlplane/controlplane.yml` file, + the remote terminal size will be fixed to match the local terminal size (may also be overriden through `--terminal-size`) DESC EXAMPLES = <<~EX ```sh # Opens shell (bash by default). cpl run -a $APP_NAME - # Need to quote COMMAND if setting ENV value or passing args. - cpl run -a $APP_NAME -- 'LOG_LEVEL=warn rails db:migrate' + # Runs interactive command, keeps shell open, and stops job when exiting. + cpl run -a $APP_NAME --interactive -- rails c + + # Some commands are automatically detected as interactive, so no need to pass `--interactive`. + #{INTERACTIVE_COMMANDS.map { |cmd| "cpl run -a $APP_NAME -- #{cmd}" }.join("\n ")} - # Runs command, displays output, and exits shell. - cpl run -a $APP_NAME -- ls / - cpl run -a $APP_NAME -- rails db:migrate:status + # Runs non-interactive command, outputs logs, exits with the exit code of the command and stops job. + cpl run -a $APP_NAME -- rails db:migrate - # Runs command and keeps shell open. - cpl run -a $APP_NAME -- rails c + # Runs non-iteractive command, detaches, exits with 0, and prints commands to: + # - see logs from the job + # - stop the job + cpl run -a $APP_NAME --detached -- rails db:migrate + + # The command needs to be quoted if setting an env variable or passing args. + cpl run -a $APP_NAME -- 'SOME_ENV_VAR=some_value rails db:migrate' # Uses a different image (which may not be promoted yet). cpl run -a $APP_NAME --image appimage:123 -- rails db:migrate # Exact image name @@ -48,98 +75,432 @@ class Run < Base # Overrides remote CPLN_TOKEN env variable with local token. # Useful when superuser rights are needed in remote container. cpl run -a $APP_NAME --use-local-token -- bash + + # Replaces the existing Dockerfile entrypoint with `bash`. + cpl run -a $APP_NAME --entrypoint none -- rails db:migrate + + # Replaces the existing Dockerfile entrypoint. + cpl run -a $APP_NAME --entrypoint /app/alternative-entrypoint.sh -- rails db:migrate ``` EX - attr_reader :location, :workload_to_clone, :workload_clone, :container + MAGIC_END = "---cpl run command finished---" + + attr_reader :interactive, :detached, :location, :original_workload, :runner_workload, + :container, :image_link, :image_changed, :job, :replica, :command + + def call # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity + @interactive = config.options[:interactive] || interactive_command? + @detached = config.options[:detached] + @log_method = config.options[:log_method] - def call # rubocop:disable Metrics/MethodLength @location = config.location - @workload_to_clone = config.options["workload"] || config[:one_off_workload] - @workload_clone = "#{workload_to_clone}-run-#{random_four_digits}" + @original_workload = config.options[:workload] || config[:one_off_workload] + @runner_workload = "#{original_workload}-runner" + + unless interactive + @internal_sigint = false + + # Catch Ctrl+C in the main process + trap("SIGINT") do + unless @internal_sigint + print_detached_commands + exit(ExitCode::INTERRUPT) + end + end + end - step("Cloning workload '#{workload_to_clone}' on app '#{config.options[:app]}' to '#{workload_clone}'") do - clone_workload + if cp.fetch_workload(runner_workload).nil? + create_runner_workload + wait_for_runner_workload_create end + update_runner_workload + wait_for_runner_workload_update + + # NOTE: need to wait some time before starting the job, + # otherwise the image may not be updated yet + # TODO: need to figure out if there's a better way to do this + sleep 1 if image_changed + + start_job + wait_for_replica_for_job - wait_for_workload(workload_clone) - wait_for_replica(workload_clone, location) - run_in_replica - ensure progress.puts - ensure_workload_deleted(workload_clone) + if interactive + run_interactive + else + run_non_interactive + end end private - def clone_workload # rubocop:disable Metrics/MethodLength - # Create a base copy of workload props - spec = cp.fetch_workload!(workload_to_clone).fetch("spec") - container_spec = spec["containers"].detect { _1["name"] == workload_to_clone } || spec["containers"].first - @container = container_spec["name"] + def interactive_command? + INTERACTIVE_COMMANDS.include?(args_join(config.args)) + end + + def app_workload_replica_args + ["-a", config.app, "--workload", runner_workload, "--replica", replica] + end - # remove other containers if any - spec["containers"] = [container_spec] + def create_runner_workload # rubocop:disable Metrics/MethodLength + step("Creating runner workload '#{runner_workload}' based on '#{original_workload}'") do + spec, container_spec = base_workload_specs(original_workload) - # Stub workload command with dummy server that just responds to port - # Needed to avoid execution of ENTRYPOINT and CMD of Dockerfile - container_spec["command"] = "ruby" - container_spec["args"] = ["-e", Scripts.http_dummy_server_ruby] + # Remove other containers if any + spec["containers"] = [container_spec] - # Ensure one-off workload will be running - spec["defaultOptions"]["suspend"] = false + # Default to using existing Dockerfile entrypoint + container_spec.delete("command") - # Ensure no scaling - spec["defaultOptions"]["autoscaling"]["minScale"] = 1 - spec["defaultOptions"]["autoscaling"]["maxScale"] = 1 - spec["defaultOptions"]["capacityAI"] = false + # Remove props that conflict with job + container_spec.delete("ports") + container_spec.delete("lifecycle") + container_spec.delete("livenessProbe") + container_spec.delete("readinessProbe") - # Override image if specified - image = config.options[:image] - image = latest_image if image == "latest" - container_spec["image"] = "/org/#{config.org}/image/#{image}" if image + # Ensure cron workload won't run per schedule + spec["defaultOptions"]["suspend"] = true - # Set runner - container_spec["env"] ||= [] - container_spec["env"] << { "name" => "CONTROLPLANE_RUNNER", "value" => runner_script } + # Ensure no scaling + spec["defaultOptions"]["autoscaling"] = {} + spec["defaultOptions"]["capacityAI"] = false - if config.options["use_local_token"] - container_spec["env"] << { "name" => "CONTROLPLANE_TOKEN", - "value" => ControlplaneApiDirect.new.api_token[:token] } + # Set cron job props + spec["type"] = "cron" + + # Next job set to run on January 1st, 2029 + spec["job"] = { "schedule" => "0 0 1 1 1", "restartPolicy" => "Never" } + + # Create runner workload + cp.apply_hash("kind" => "workload", "name" => runner_workload, "spec" => spec) end + end + + def update_runner_workload # rubocop:disable Metrics/MethodLength + step("Updating runner workload '#{runner_workload}' based on '#{original_workload}'") do + _, original_container_spec = base_workload_specs(original_workload) + spec, container_spec = base_workload_specs(runner_workload) - # Create workload clone - cp.apply_hash("kind" => "workload", "name" => workload_clone, "spec" => spec) + # Override image if specified + image = config.options[:image] + if image + image = latest_image if image == "latest" + @image_link = "/org/#{config.org}/image/#{image}" + else + @image_link = original_container_spec["image"] + end + @image_changed = container_spec["image"] != image_link + container_spec["image"] = image_link + + # Container overrides + container_spec["cpu"] = config.options[:cpu] if config.options[:cpu] + container_spec["memory"] = config.options[:memory] if config.options[:memory] + + # Update runner workload + cp.apply_hash("kind" => "workload", "name" => runner_workload, "spec" => spec) + end end - def runner_script # rubocop:disable Metrics/MethodLength - script = Scripts.helpers_cleanup + def wait_for_runner_workload_create + step("Waiting for runner workload '#{runner_workload}' to be created", retry_on_failure: true) do + cp.fetch_workload(runner_workload) + end + end + + def wait_for_runner_workload_update + step("Waiting for runner workload '#{runner_workload}' to be updated", retry_on_failure: true) do + _, container_spec = base_workload_specs(runner_workload) + container_spec["image"] == image_link + end + end + + def start_job + job_start_yaml = build_job_start_yaml + + step("Starting job for runner workload '#{runner_workload}'", retry_on_failure: true) do + result = cp.start_cron_workload(runner_workload, job_start_yaml, location: location) + @job = result&.dig("items", 0, "id") + + job || false + end + end + + def wait_for_replica_for_job + step("Waiting for replica to start, which runs job '#{job}'", retry_on_failure: true) do + result = cp.fetch_workload_replicas(runner_workload, location: location) + @replica = result["items"].find { |item| item.include?(job) } + + replica || false + end + end + + def run_interactive + progress.puts("Connecting to replica '#{replica}'...\n\n") + cp.workload_exec(runner_workload, replica, location: location, container: container, command: command) + end - if config.options["use_local_token"] - script += <<~SHELL - CPLN_TOKEN=$CONTROLPLANE_TOKEN - unset CONTROLPLANE_TOKEN - SHELL + def run_non_interactive + if detached + print_detached_commands + exit(ExitCode::SUCCESS) end + case @log_method + when 1 then run_non_interactive_v1 + when 2 then run_non_interactive_v2 + when 3 then run_non_interactive_v3 + else raise "Invalid log method: #{@log_method}" + end + end + + def run_non_interactive_v1 # rubocop:disable Metrics/MethodLength + logs_pid = Process.fork do + # Catch Ctrl+C in the forked process + trap("SIGINT") do + exit(ExitCode::SUCCESS) + end + + Cpl::Cli.start(["logs", *app_workload_replica_args]) + end + Process.detach(logs_pid) + + exit_status = wait_for_job_status + + # We need to wait a bit for the logs to appear, + # otherwise it may exit without showing them + Kernel.sleep(30) + + @internal_sigint = true + Process.kill("INT", logs_pid) + exit(exit_status) + end + + def run_non_interactive_v2 + current_cpl = File.expand_path("cpl", "#{__dir__}/../..") + logs_pipe = IO.popen([current_cpl, "logs", *app_workload_replica_args]) + + exit_status = wait_for_job_status_and_log(logs_pipe) + + @internal_sigint = true + Process.kill("INT", logs_pipe.pid) + exit(exit_status) + end + + def run_non_interactive_v3 + exit(show_logs_waiting) + end + + def base_workload_specs(workload) + spec = cp.fetch_workload!(workload).fetch("spec") + container_spec = spec["containers"].detect { _1["name"] == original_workload } || spec["containers"].first + @container = container_spec["name"] + + [spec, container_spec] + end + + def build_job_start_yaml # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity + job_start_hash = { "name" => container } + + if config.options[:use_local_token] + job_start_hash["env"] ||= [] + job_start_hash["env"].push({ "name" => "CPL_TOKEN", "value" => ControlplaneApiDirect.new.api_token[:token] }) + end + + entrypoint = nil + if config.options[:entrypoint] + entrypoint = config.options[:entrypoint] == "none" ? "bash" : config.options[:entrypoint] + end + + job_start_hash["command"] = entrypoint if entrypoint + job_start_hash["args"] ||= [] + job_start_hash["args"].push("bash") unless entrypoint == "bash" + job_start_hash["args"].push("-c") + job_start_hash["env"] ||= [] + job_start_hash["env"].push({ "name" => "CPL_RUNNER_SCRIPT", "value" => runner_script }) + if interactive + job_start_hash["env"].push({ "name" => "CPL_MONITORING_SCRIPT", "value" => interactive_monitoring_script }) + + job_start_hash["args"].push('eval "$CPL_MONITORING_SCRIPT"') + @command = %(bash -c 'eval "$CPL_RUNNER_SCRIPT"') + else + job_start_hash["args"].push('eval "$CPL_RUNNER_SCRIPT"') + end + + job_start_hash.to_yaml + end + + def interactive_monitoring_script + <<~SCRIPT + primary_pid="" + + check_primary() { + if ! kill -0 $primary_pid 2>/dev/null; then + echo "Primary process has exited. Shutting down." + exit 0 + fi + } + + while true; do + if [[ -z "$primary_pid" ]]; then + primary_pid=$(ps -eo pid,etime,cmd --sort=etime | grep -v "$$" | grep -v 'ps -eo' | grep -v 'grep' | grep 'CPL_RUNNER_SCRIPT' | head -n 1 | awk '{print $1}') + if [[ ! -z "$primary_pid" ]]; then + echo "Primary process set with PID: $primary_pid" + fi + else + check_primary + fi + + sleep 1 + done + SCRIPT + end + + def interactive_runner_script + script = "" + # NOTE: fixes terminal size to match local terminal if config.current[:fix_terminal_size] || config.options[:terminal_size] if config.options[:terminal_size] rows, cols = config.options[:terminal_size].split(",") else - rows, cols = Shell.cmd("stty", "size")[:output].split(/\s+/) + # NOTE: cannot use `Shell.cmd` here, as `stty size` has to run in a terminal environment + rows, cols = `stty size`.split(/\s+/) end - script += "stty rows #{rows}\nstty cols #{cols}\n" if rows && cols + script += "stty rows #{rows}\nstty cols #{cols}\n" end - script += args_join(config.args) script end - def run_in_replica - progress.puts("Connecting...\n\n") - command = %(bash -c 'eval "$CONTROLPLANE_RUNNER"') - cp.workload_exec(workload_clone, location: location, container: container, command: command) + def runner_script # rubocop:disable Metrics/MethodLength + script = <<~SCRIPT + unset CPL_RUNNER_SCRIPT + unset CPL_MONITORING_SCRIPT + + if [ -n "$CPL_TOKEN" ]; then + CPLN_TOKEN=$CPL_TOKEN + unset CPL_TOKEN + fi + SCRIPT + + script += interactive_runner_script if interactive + + script += + if @log_method == 1 + args_join(config.args) + else + <<~SCRIPT + ( #{args_join(config.args)} ) + CPL_EXIT_CODE=$? + echo '#{MAGIC_END}' + exit $CPL_EXIT_CODE + SCRIPT + end + + script + end + + def wait_for_job_status + Kernel.sleep(1) until (exit_code = resolve_job_status) + exit_code + end + + def wait_for_job_status_and_log(logs_pipe) # rubocop:disable Metrics/MethodLength + no_logs_counter = 0 + + loop do + no_logs_counter += 1 + break if no_logs_counter > 60 # 30s + break if logs_pipe.eof? + next Kernel.sleep(0.5) unless logs_pipe.ready? + + no_logs_counter = 0 + line = logs_pipe.gets + break if line.chomp == MAGIC_END + + puts(line) + end + + resolve_job_status + end + + def print_detached_commands + app_workload_replica_config = app_workload_replica_args.join(" ") + progress.puts( + "\n\n" \ + "- To view logs from the job, run:\n `cpl logs #{app_workload_replica_config}`\n" \ + "- To stop the job, run:\n `cpl ps:stop #{app_workload_replica_config}`\n" + ) + end + + def resolve_job_status + result = cp.fetch_cron_workload(runner_workload, location: location) + job_details = result&.dig("items")&.find { |item| item["id"] == job } + status = job_details&.dig("status") + + case status + when "failed" + ExitCode::ERROR_DEFAULT + when "successful" + ExitCode::SUCCESS + end + end + + ########################################### + ### temporary extaction from run:detached + ########################################### + def show_logs_waiting # rubocop:disable Metrics/MethodLength + retries = 0 + begin + job_finished_count = 0 + loop do + case print_uniq_logs + when :finished + break + when :changed + next + else + job_finished_count += 1 if resolve_job_status + break if job_finished_count > 5 + + sleep(1) + end + end + + resolve_job_status + rescue RuntimeError => e + raise "#{e} Exiting..." unless retries < 10 # MAX_RETRIES + + progress.puts(Shell.color("ERROR: #{e} Retrying...", :red)) + retries += 1 + retry + end + end + + def print_uniq_logs + status = nil + + @printed_log_entries ||= [] + ts = Time.now.to_i + entries = normalized_log_entries(from: ts - 60, to: ts) + + (entries - @printed_log_entries).sort.each do |(_ts, val)| + status ||= :changed + val.chomp == MAGIC_END ? status = :finished : progress.puts(val) + end + + @printed_log_entries = entries # as well truncate old entries if any + + status || :unchanged + end + + def normalized_log_entries(from:, to:) + log = cp.log_get(workload: runner_workload, from: from, to: to, replica: replica) + + log["data"]["result"] + .each_with_object([]) { |obj, result| result.concat(obj["values"]) } + .select { |ts, _val| ts[..-10].to_i > from } end end end diff --git a/lib/command/run_cleanup.rb b/lib/command/run_cleanup.rb deleted file mode 100644 index 74f1543b..00000000 --- a/lib/command/run_cleanup.rb +++ /dev/null @@ -1,90 +0,0 @@ -# frozen_string_literal: true - -module Command - class RunCleanup < Base - NAME = "run:cleanup" - OPTIONS = [ - app_option(required: true), - skip_confirm_option - ].freeze - DESCRIPTION = "Deletes stale run workloads for an app" - LONG_DESCRIPTION = <<~DESC - - Deletes stale run workloads for an app - - Workloads are considered stale based on how many days since created - - `stale_run_workload_created_days` in the `.controlplane/controlplane.yml` file specifies the number of days after created that the workload is considered stale - - Works for both interactive workloads (created with `cpl run`) and non-interactive workloads (created with `cpl run:detached`) - - Will ask for explicit user confirmation of deletion - DESC - - def call # rubocop:disable Metrics/MethodLength - return progress.puts("No stale run workloads found.") if stale_run_workloads.empty? - - progress.puts("Stale run workloads:") - stale_run_workloads.each do |workload| - output = " - #{workload[:app]}: #{workload[:name]}" - output += " (#{Shell.color("#{workload[:date]} - #{workload[:days]} days ago", :red)})" - progress.puts(output) - end - - return unless confirm_delete - - progress.puts - stale_run_workloads.each do |workload| - delete_workload(workload) - end - end - - private - - def stale_run_workloads # rubocop:disable Metrics/MethodLength - @stale_run_workloads ||= - begin - defined_workloads = (config.current[:app_workloads] + - config.current[:additional_workloads] + - [config.current[:one_off_workload]]).uniq - - run_workloads = [] - - now = DateTime.now - stale_run_workload_created_days = config[:stale_run_workload_created_days] - - interactive_workloads = cp.query_workloads("-run-", partial_workload_match: true)["items"] - non_interactive_workloads = cp.query_workloads("-runner-", partial_workload_match: true)["items"] - workloads = interactive_workloads + non_interactive_workloads - - workloads.each do |workload| - app_name = workload["links"].find { |link| link["rel"] == "gvc" }["href"].split("/").last - workload_name = workload["name"] - - original_workload_name, workload_number = workload_name.split(/-run-|-runner-/) - next unless defined_workloads.include?(original_workload_name) && workload_number.match?(/^\d{4}$/) - - created_date = DateTime.parse(workload["created"]) - diff_in_days = (now - created_date).to_i - next unless diff_in_days >= stale_run_workload_created_days - - run_workloads.push({ - app: app_name, - name: workload_name, - date: created_date, - days: diff_in_days - }) - end - - run_workloads - end - end - - def confirm_delete - return true if config.options[:yes] - - Shell.confirm("\nAre you sure you want to delete these #{stale_run_workloads.length} run workloads?") - end - - def delete_workload(workload) - step("Deleting run workload '#{workload[:app]}: #{workload[:name]}'") do - cp.delete_workload(workload[:name], workload[:app]) - end - end - end -end diff --git a/lib/command/run_detached.rb b/lib/command/run_detached.rb deleted file mode 100644 index 8cfe8dd0..00000000 --- a/lib/command/run_detached.rb +++ /dev/null @@ -1,181 +0,0 @@ -# frozen_string_literal: true - -module Command - class RunDetached < Base # rubocop:disable Metrics/ClassLength - NAME = "run:detached" - USAGE = "run:detached COMMAND" - REQUIRES_ARGS = true - OPTIONS = [ - app_option(required: true), - image_option, - workload_option, - location_option, - use_local_token_option, - clean_on_failure_option - ].freeze - DESCRIPTION = "Runs one-off **_non-interactive_** replicas (close analog of `heroku run:detached`)" - LONG_DESCRIPTION = <<~DESC - - Runs one-off **_non-interactive_** replicas (close analog of `heroku run:detached`) - - Uses `Cron` workload type with log async fetching - - Implemented with only async execution methods, more suitable for production tasks - - Has alternative log fetch implementation with only JSON-polling and no WebSockets - - Less responsive but more stable, useful for CI tasks - - Deletes the workload whenever finished with success - - Deletes the workload whenever finished with failure by default - - Use `--no-clean-on-failure` to disable cleanup to help with debugging failed runs - DESC - EXAMPLES = <<~EX - ```sh - cpl run:detached rails db:prepare -a $APP_NAME - - # Need to quote COMMAND if setting ENV value or passing args. - cpl run:detached -a $APP_NAME -- 'LOG_LEVEL=warn rails db:migrate' - - # Uses a different image (which may not be promoted yet). - cpl run:detached -a $APP_NAME --image appimage:123 -- rails db:migrate # Exact image name - cpl run:detached -a $APP_NAME --image latest -- rails db:migrate # Latest sequential image - - # Uses a different workload than `one_off_workload` from `.controlplane/controlplane.yml`. - cpl run:detached -a $APP_NAME -w other-workload -- rails db:migrate:status - - # Overrides remote CPLN_TOKEN env variable with local token. - # Useful when superuser rights are needed in remote container. - cpl run:detached -a $APP_NAME --use-local-token -- rails db:migrate:status - ``` - EX - - WORKLOAD_SLEEP_CHECK = 2 - MAX_RETRIES = Float::INFINITY - - attr_reader :location, :workload_to_clone, :workload_clone, :container - - def call - @location = config.location - @workload_to_clone = config.options["workload"] || config[:one_off_workload] - @workload_clone = "#{workload_to_clone}-runner-#{random_four_digits}" - - step("Cloning workload '#{workload_to_clone}' on app '#{config.options[:app]}' to '#{workload_clone}'") do - clone_workload - end - - wait_for_workload(workload_clone) - show_logs_waiting - ensure - exit(ExitCode::ERROR_DEFAULT) if @crashed - end - - private - - def clone_workload # rubocop:disable Metrics/MethodLength - # Get base specs of workload - spec = cp.fetch_workload!(workload_to_clone).fetch("spec") - container_spec = spec["containers"].detect { _1["name"] == workload_to_clone } || spec["containers"].first - @container = container_spec["name"] - - # remove other containers if any - spec["containers"] = [container_spec] - - # Set runner - container_spec["command"] = "bash" - container_spec["args"] = ["-c", 'eval "$CONTROLPLANE_RUNNER"'] - - # Ensure one-off workload will be running - spec["defaultOptions"]["suspend"] = false - - # Ensure no scaling - spec["defaultOptions"]["autoscaling"]["minScale"] = 1 - spec["defaultOptions"]["autoscaling"]["maxScale"] = 1 - spec["defaultOptions"]["capacityAI"] = false - - # Override image if specified - image = config.options[:image] - image = latest_image if image == "latest" - container_spec["image"] = "/org/#{config.org}/image/#{image}" if image - - # Set cron job props - spec["type"] = "cron" - spec["job"] = { "schedule" => "* * * * *", "restartPolicy" => "Never" } - spec["defaultOptions"]["autoscaling"] = {} - container_spec.delete("ports") - - container_spec["env"] ||= [] - container_spec["env"] << { "name" => "CONTROLPLANE_TOKEN", - "value" => ControlplaneApiDirect.new.api_token[:token] } - container_spec["env"] << { "name" => "CONTROLPLANE_RUNNER", "value" => runner_script } - - # Create workload clone - cp.apply_hash("kind" => "workload", "name" => workload_clone, "spec" => spec) - end - - def runner_script # rubocop:disable Metrics/MethodLength - script = "echo '-- STARTED RUNNER SCRIPT --'\n" - script += Scripts.helpers_cleanup - - if config.options["use_local_token"] - script += <<~SHELL - CPLN_TOKEN=$CONTROLPLANE_TOKEN - SHELL - end - - script += <<~SHELL - crashed=0 - if ! eval "#{args_join(config.args)}"; then - crashed=1 - echo "----- CRASHED -----" - fi - clean_on_failure=#{config.options[:clean_on_failure] ? 1 : 0} - if [ $crashed -eq 0 ] || [ $clean_on_failure -eq 1 ]; then - echo "-- FINISHED RUNNER SCRIPT, DELETING WORKLOAD --" - sleep 30 # grace time for logs propagation - curl ${CPLN_ENDPOINT}${CPLN_WORKLOAD} -H "Authorization: ${CONTROLPLANE_TOKEN}" -X DELETE -s -o /dev/null - while true; do sleep 1; done # wait for SIGTERM - else - echo "-- FINISHED RUNNER SCRIPT --" - fi - SHELL - - script - end - - def show_logs_waiting # rubocop:disable Metrics/MethodLength - progress.puts("Scheduled, fetching logs (it's a cron job, so it may take up to a minute to start)...\n\n") - retries = 0 - begin - @finished = false - while cp.fetch_workload(workload_clone) && !@finished - Kernel.sleep(WORKLOAD_SLEEP_CHECK) - print_uniq_logs - end - rescue RuntimeError => e - raise "#{e} Exiting..." unless retries < MAX_RETRIES - - progress.puts(Shell.color("ERROR: #{e} Retrying...", :red)) - retries += 1 - retry - end - progress.puts("\nFinished workload and logger.") - end - - def print_uniq_logs - @printed_log_entries ||= [] - ts = Time.now.to_i - entries = normalized_log_entries(from: ts - 60, to: ts) - - (entries - @printed_log_entries).sort.each do |(_ts, val)| - @crashed = true if val.match?(/^----- CRASHED -----$/) - @finished = true if val.match?(/^-- FINISHED RUNNER SCRIPT(, DELETING WORKLOAD)? --$/) - progress.puts(val) - end - - @printed_log_entries = entries # as well truncate old entries if any - end - - def normalized_log_entries(from:, to:) - log = cp.log_get(workload: workload_clone, from: from, to: to) - - log["data"]["result"] - .each_with_object([]) { |obj, result| result.concat(obj["values"]) } - .select { |ts, _val| ts[..-10].to_i > from } - end - end -end diff --git a/lib/core/controlplane.rb b/lib/core/controlplane.rb index a34608e8..916aa639 100644 --- a/lib/core/controlplane.rb +++ b/lib/core/controlplane.rb @@ -19,7 +19,7 @@ def profile_switch(profile) def profile_exists?(profile) cmd = "cpln profile get #{profile} -o yaml" - perform_yaml(cmd).length.positive? + perform_yaml!(cmd).length.positive? end def profile_create(profile, token) @@ -96,7 +96,7 @@ def gvc_query(app_name = config.app) op = config.should_app_start_with?(app_name) ? "~" : "=" cmd = "cpln gvc query --org #{org} -o yaml --prop name#{op}#{app_name}" - perform_yaml(cmd) + perform_yaml!(cmd) end def fetch_gvc(a_gvc = gvc, a_org = org) @@ -143,40 +143,38 @@ def query_workloads(workload, a_gvc = gvc, a_org = org, partial_workload_match: api.query_workloads(org: a_org, gvc: a_gvc, workload: workload, gvc_op_type: gvc_op, workload_op_type: workload_op) end - def workload_get_replicas(workload, location:) - cmd = "cpln workload get-replicas #{workload} #{gvc_org} --location #{location} -o yaml" + def fetch_workload_replicas(workload, location:) + cmd = "cpln workload replica get #{workload} #{gvc_org} --location #{location} -o yaml" perform_yaml(cmd) end - def workload_get_replicas_safely(workload, location:) - cmd = "cpln workload get-replicas #{workload} #{gvc_org} --location #{location} -o yaml" - - Shell.debug("CMD", cmd) - - result = Shell.cmd(cmd, capture_stderr: true) - YAML.safe_load(result[:output]) if result[:success] + def stop_workload_replica(workload, replica, location:) + cmd = "cpln workload replica stop #{workload} #{gvc_org} --replica-name #{replica} --location #{location}" + perform(cmd, output_mode: :none) end def fetch_workload_deployments(workload) api.workload_deployments(workload: workload, gvc: gvc, org: org) end - def workload_deployment_version_ready?(version, next_version, expected_status:) + def workload_deployment_version_ready?(version, next_version) return false unless version["workload"] == next_version version["containers"]&.all? do |_, container| - ready = container.dig("resources", "replicas") == container.dig("resources", "replicasReady") - expected_status == true ? ready : !ready + container.dig("resources", "replicas") == container.dig("resources", "replicasReady") end end - def workload_deployments_ready?(workload, expected_status:) + def workload_deployments_ready?(workload, location:, expected_status:) + deployed_replicas = fetch_workload_replicas(workload, location: location)["items"].length + return deployed_replicas.zero? if expected_status == false + deployments = fetch_workload_deployments(workload)["items"] deployments.all? do |deployment| next_version = deployment.dig("status", "expectedDeploymentVersion") deployment.dig("status", "versions")&.all? do |version| - workload_deployment_version_ready?(version, next_version, expected_status: expected_status) + workload_deployment_version_ready?(version, next_version) end end end @@ -225,13 +223,28 @@ def workload_connect(workload, location:, container: nil, shell: nil) perform!(cmd, output_mode: :all) end - def workload_exec(workload, location:, container: nil, command: nil) - cmd = "cpln workload exec #{workload} #{gvc_org} --location #{location}" + def workload_exec(workload, replica, location:, container: nil, command: nil) + cmd = "cpln workload exec #{workload} #{gvc_org} --replica #{replica} --location #{location}" cmd += " --container #{container}" if container cmd += " -- #{command}" perform!(cmd, output_mode: :all) end + def start_cron_workload(workload, job_start_yaml, location:) + Tempfile.create do |f| + f.write(job_start_yaml) + f.rewind + + cmd = "cpln workload cron start #{workload} #{gvc_org} --file #{f.path} --location #{location} -o yaml" + perform_yaml(cmd) + end + end + + def fetch_cron_workload(workload, location:) + cmd = "cpln workload cron get #{workload} #{gvc_org} --location #{location} -o yaml" + perform_yaml(cmd) + end + # volumeset def fetch_volumesets(a_gvc = gvc) @@ -286,13 +299,17 @@ def set_domain_workload(data, workload) # logs - def logs(workload:) - cmd = "cpln logs '{workload=\"#{workload}\"}' --org #{org} -t -o raw --limit 200" + def logs(workload:, limit:, since:, replica: nil) + query_parts = ["gvc=\"#{gvc}\"", "workload=\"#{workload}\""] + query_parts.push("replica=\"#{replica}\"") if replica + query = "{#{query_parts.join(',')}}" + + cmd = "cpln logs '#{query}' --org #{org} -t -o raw --limit #{limit} --since #{since}" perform!(cmd, output_mode: :all) end - def log_get(workload:, from:, to:) - api.log_get(org: org, gvc: gvc, workload: workload, from: from, to: to) + def log_get(workload:, from:, to:, replica: nil) + api.log_get(org: org, gvc: gvc, workload: workload, replica: replica, from: from, to: to) end # identities @@ -410,7 +427,20 @@ def perform(cmd, output_mode: nil, sensitive_data_pattern: nil) Shell.debug("CMD", cmd, sensitive_data_pattern: sensitive_data_pattern) - Kernel.system(cmd) + kernel_system_with_pid_handling(cmd) + end + + # NOTE: full analogue of Kernel.system which returns pids and saves it to child_pids for proper killing + def kernel_system_with_pid_handling(cmd) + pid = Process.spawn(cmd) + $child_pids << pid # rubocop:disable Style/GlobalVars + + _, status = Process.wait2(pid) + $child_pids.delete(pid) # rubocop:disable Style/GlobalVars + + status.exited? ? status.success? : nil + rescue SystemCallError + nil end def perform!(cmd, output_mode: nil, sensitive_data_pattern: nil) @@ -422,11 +452,11 @@ def perform_yaml(cmd) Shell.debug("CMD", cmd) result = Shell.cmd(cmd) - if result[:success] - YAML.safe_load(result[:output]) - else - Shell.abort("Command exited with non-zero status.") - end + YAML.safe_load(result[:output], permitted_classes: [Time]) if result[:success] + end + + def perform_yaml!(cmd) + perform_yaml(cmd) || Shell.abort("Command exited with non-zero status.") end def gvc_org diff --git a/lib/core/controlplane_api.rb b/lib/core/controlplane_api.rb index eef8cea3..0d1e305d 100644 --- a/lib/core/controlplane_api.rb +++ b/lib/core/controlplane_api.rb @@ -33,14 +33,16 @@ def image_delete(org:, image:) api_json("/org/#{org}/image/#{image}", method: :delete) end - def log_get(org:, gvc:, workload: nil, from: nil, to: nil) + def log_get(org:, gvc:, workload: nil, replica: nil, from: nil, to: nil) # rubocop:disable Metrics/ParameterLists query = { gvc: gvc } query[:workload] = workload if workload + query[:replica] = replica if replica query = query.map { |k, v| %(#{k}="#{v}") }.join(",").then { "{#{_1}}" } params = { query: query } params[:from] = "#{from}000000000" if from params[:to] = "#{to}000000000" if to + params[:limit] = "5000" # params << "delay_for=0" # params << "limit=30" # params << "direction=forward" diff --git a/lib/core/scripts.rb b/lib/core/scripts.rb deleted file mode 100644 index 5508a6f9..00000000 --- a/lib/core/scripts.rb +++ /dev/null @@ -1,34 +0,0 @@ -# frozen_string_literal: true - -module Scripts - module_function - - def assert_replicas(gvc:, workload:, location:) - <<~SHELL - REPLICAS_QTY=$( \ - curl ${CPLN_ENDPOINT}/org/shakacode-staging/gvc/#{gvc}/workload/#{workload}/deployment/#{location} \ - -H "Authorization: ${CONTROLPLANE_TOKEN}" -s | grep -o '"replicas":[0-9]*' | grep -o '[0-9]*') - - if [ "$REPLICAS_QTY" -gt 0 ]; then - echo "-- MULTIPLE REPLICAS ATTEMPT: $REPLICAS_QTY --" - exit -1 - fi - SHELL - end - - def helpers_cleanup - <<~SHELL - unset CONTROLPLANE_RUNNER - SHELL - end - - # NOTE: please escape all '/' as '//' (as it is ruby interpolation here as well) - def http_dummy_server_ruby - 'require "socket";s=TCPServer.new(ENV["PORT"] || 80);' \ - 'loop do c=s.accept;c.puts("HTTP/1.1 200 OK\\nContent-Length: 2\\n\\nOk");c.close end' - end - - def http_ping_ruby - 'require "net/http";uri=URI(ENV["CPLN_GLOBAL_ENDPOINT"]);loop do puts(Net::HTTP.get(uri));sleep(5);end' - end -end diff --git a/lib/cpl.rb b/lib/cpl.rb index 5981a7ad..e92dbe5a 100644 --- a/lib/cpl.rb +++ b/lib/cpl.rb @@ -22,6 +22,14 @@ end modules.sort.each { require(_1) } +# NOTE: this snippet combines all subprocesses into a group and kills all on exit to avoid hanging orphans +$child_pids = [] # rubocop:disable Style/GlobalVars +at_exit do + $child_pids.each do |pid| # rubocop:disable Style/GlobalVars + Process.kill("TERM", pid) + end +end + # Fix for https://github.com/erikhuda/thor/issues/398 # Copied from https://github.com/rails/thor/issues/398#issuecomment-622988390 class Thor @@ -138,6 +146,9 @@ def self.all_base_commands ::Command::Base.all_commands.merge(deprecated_commands) end + @commands_with_required_options = [] + @commands_with_extra_options = [] + all_base_commands.each do |command_key, command_class| # rubocop:disable Metrics/BlockLength deprecated = deprecated_commands[command_key] @@ -164,12 +175,20 @@ def self.all_base_commands long_desc(long_description) command_options.each do |option| - method_option(option[:name], **option[:params]) + params = option[:params] + + # Ensures that if no value is provided for a non-boolean option (e.g., `cpl command --option`), + # it defaults to an empty string instead of the option name (which is the default Thor behavior) + params[:lazy_default] ||= "" if params[:type] != :boolean + + method_option(option[:name], **params) end # We'll handle required options manually in `Config` required_options = command_options.select { |option| option[:params][:required] }.map { |option| option[:name] } - disable_required_check! name_for_method.to_sym if required_options.any? + @commands_with_required_options.push(name_for_method.to_sym) if required_options.any? + + @commands_with_extra_options.push(name_for_method.to_sym) if accepts_extra_options define_method(name_for_method) do |*provided_args| # rubocop:disable Metrics/MethodLength if deprecated @@ -189,6 +208,8 @@ def self.all_base_commands end begin + Cpl::Cli.validate_options!(options, command_options) + config = Config.new(args, options, required_options) Cpl::Cli.show_info_header(config) if with_info_header @@ -202,6 +223,21 @@ def self.all_base_commands ::Shell.abort("Unable to load command: #{e.message}") end + disable_required_check!(*@commands_with_required_options) + check_unknown_options!(except: @commands_with_extra_options) + stop_on_unknown_option! + + def self.validate_options!(options, command_options) + options.each do |name, value| + raise "No value provided for option '#{name}'." if value.to_s.strip.empty? + + params = command_options.find { |option| option[:name].to_s == name }[:params] + next unless params[:valid_regex] + + raise "Invalid value provided for option '#{name}'." unless value.match?(params[:valid_regex]) + end + end + def self.show_info_header(config) # rubocop:disable Metrics/MethodLength return if @showed_info_header diff --git a/lib/deprecated_commands.json b/lib/deprecated_commands.json index cf61bff5..9ecdbbf9 100644 --- a/lib/deprecated_commands.json +++ b/lib/deprecated_commands.json @@ -3,6 +3,7 @@ "cleanup_old_images": "cleanup-images", "promote": "deploy-image", "promote_image": "deploy-image", - "runner": "run:detached", + "run:detached": "run", + "runner": "run", "setup": "apply-template" } diff --git a/lib/generator_templates/controlplane.yml b/lib/generator_templates/controlplane.yml index 8e10fc8d..1bee09f9 100644 --- a/lib/generator_templates/controlplane.yml +++ b/lib/generator_templates/controlplane.yml @@ -20,14 +20,14 @@ aliases: # Workloads that are for the application itself and are using application Docker images. # These are updated with the new image when running the `deploy-image` command, - # and are also used by the `info`, `ps:`, and `run:cleanup` commands in order to get all of the defined workloads. + # and are also used by the `info` and `ps:` commands in order to get all of the defined workloads. # On the other hand, if you have a workload for Redis, that would NOT use the application Docker image # and not be listed here. app_workloads: - rails # Additional "service type" workloads, using non-application Docker images. - # These are only used by the `info`, `ps:` and `run:cleanup` commands in order to get all of the defined workloads. + # These are only used by the `info` and `ps:` commands in order to get all of the defined workloads. additional_workloads: - postgres diff --git a/spec/command/delete_spec.rb b/spec/command/delete_spec.rb index 4474f140..29fcfbf1 100644 --- a/spec/command/delete_spec.rb +++ b/spec/command/delete_spec.rb @@ -87,4 +87,65 @@ expect(result[:stderr]).to match(/Deleting image '#{app}:1'[.]+? done!/) end end + + context "when workload does not exist" do + let!(:app) { dummy_test_app } + + before do + run_cpl_command!("apply-template", "gvc", "-a", app) + end + + after do + run_cpl_command!("delete", "-a", app, "--yes") + end + + it "displays message" do + result = run_cpl_command("delete", "-a", app, "--workload", "rails") + + expect(result[:status]).to eq(0) + expect(result[:stderr]).to include("Workload 'rails' does not exist") + end + end + + context "when workload exists" do + let!(:app) { dummy_test_app } + + before do + run_cpl_command!("apply-template", "gvc", "rails", "-a", app) + end + + after do + run_cpl_command!("delete", "-a", app, "--yes") + end + + it "asks for confirmation and does nothing" do + allow(Shell).to receive(:confirm).with(include("rails")).and_return(false) + + result = run_cpl_command("delete", "-a", app, "--workload", "rails") + + expect(Shell).to have_received(:confirm).once + expect(result[:status]).to eq(0) + expect(result[:stderr]).not_to include("Deleting workload") + end + + it "asks for confirmation and deletes workload" do + allow(Shell).to receive(:confirm).with(include("rails")).and_return(true) + + result = run_cpl_command("delete", "-a", app, "--workload", "rails") + + expect(Shell).to have_received(:confirm).once + expect(result[:status]).to eq(0) + expect(result[:stderr]).to match(/Deleting workload 'rails'[.]+? done!/) + end + + it "skips confirmation and deletes workload" do + allow(Shell).to receive(:confirm).and_return(false) + + result = run_cpl_command("delete", "-a", app, "--workload", "rails", "--yes") + + expect(Shell).not_to have_received(:confirm) + expect(result[:status]).to eq(0) + expect(result[:stderr]).to match(/Deleting workload 'rails'[.]+? done!/) + end + end end diff --git a/spec/command/deploy_image_spec.rb b/spec/command/deploy_image_spec.rb index bdd7d99d..88d51219 100644 --- a/spec/command/deploy_image_spec.rb +++ b/spec/command/deploy_image_spec.rb @@ -77,8 +77,9 @@ allow(Kernel).to receive(:sleep) - run_cpl_command!("apply-template", "gvc", "rails", "-a", app) + run_cpl_command!("apply-template", "gvc", "rails", "postgres", "-a", app) run_cpl_command!("build-image", "-a", app) + run_cpl_command!("ps:start", "-a", app, "--workload", "postgres", "--wait") end after do @@ -86,12 +87,15 @@ end it "fails to run release script and fails to deploy image", :slow do - result = run_cpl_command("deploy-image", "-a", app, "--run-release-phase") + result = nil - expect(result[:status]).not_to eq(0) - expect(result[:stderr]).to include("Running release script") - expect(result[:stderr]).to include("Failed to run release script") - expect(result[:stderr]).not_to include("- rails:") + spawn_cpl_command("deploy-image", "-a", app, "--run-release-phase") do |it| + result = it.read_full_output + end + + expect(result).to include("Running release script") + expect(result).to include("Failed to run release script") + expect(result).not_to include("- rails:") end end @@ -107,13 +111,16 @@ end it "runs release script and deploys image", :slow do - result = run_cpl_command("deploy-image", "-a", app, "--run-release-phase") + result = nil - expect(result[:status]).to eq(0) - expect(result[:stderr]).to include("Running release script") - expect(result[:stderr]).not_to include("Failed to run release script") - expect(result[:stderr]).to match(%r{- rails: https://rails-.+?.cpln.app}) - expect(result[:stderr]).not_to include("- rails-with-non-app-image:") + spawn_cpl_command("deploy-image", "-a", app, "--run-release-phase") do |it| + result = it.read_full_output + end + + expect(result).to include("Running release script") + expect(result).to include("Finished running release script") + expect(result).to match(%r{- rails: https://rails-.+?.cpln.app}) + expect(result).not_to include("- rails-with-non-app-image:") end end end diff --git a/spec/command/logs_spec.rb b/spec/command/logs_spec.rb index 9dfb5948..38a1ab44 100644 --- a/spec/command/logs_spec.rb +++ b/spec/command/logs_spec.rb @@ -4,6 +4,7 @@ describe Command::Logs do let!(:app) { dummy_test_app("full", create_if_not_exists: true) } + let!(:message_regex) { /Fetching logs .+?\n/ } before do run_cpl_command!("ps:start", "-a", app, "--wait") @@ -11,29 +12,116 @@ context "when no workload is provided" do it "displays logs for one-off workload", :slow do - result = nil - expected_regex = /Rails .+? application starting in production/ + message_result = nil + logs_result = nil + logs_regex = /Rails .+? application starting in production/ spawn_cpl_command("logs", "-a", app) do |it| - result = it.wait_for(expected_regex) + message_result = it.wait_for(message_regex) + logs_result = it.wait_for(logs_regex) it.kill end - expect(result).to match(expected_regex) + expect(message_result).to include("Fetching logs for workload 'rails'") + expect(logs_result).to match(logs_regex) end end context "when workload is provided" do it "displays logs for specific workload", :slow do - result = nil - expected_regex = /PostgreSQL init process complete/ + message_result = nil + logs_result = nil + logs_regex = /PostgreSQL init process complete/ spawn_cpl_command("logs", "-a", app, "--workload", "postgres") do |it| + message_result = it.wait_for(message_regex) + logs_result = it.wait_for(logs_regex) + it.kill + end + + expect(message_result).to include("Fetching logs for workload 'postgres'") + expect(logs_result).to match(logs_regex) + end + end + + context "when replica is provided" do + let!(:replica) do + run_cpl_command!("ps:stop", "-a", app, "--wait") + run_cpl_command!("ps:start", "-a", app, "--wait") + + result = run_cpl_command!("ps", "-a", app, "--workload", "postgres") + result[:stdout].strip + end + + it "displays logs for specific replica", :slow do + message_result = nil + logs_result = nil + logs_regex = /PostgreSQL init process complete/ + + spawn_cpl_command("logs", "-a", app, "--workload", "postgres", "--replica", replica) do |it| + message_result = it.wait_for(message_regex) + logs_result = it.wait_for(logs_regex) + it.kill + end + + expect(message_result).to include("Fetching logs for replica '#{replica}'") + expect(logs_result).to match(logs_regex) + end + end + + context "when using different limit on number of entries" do + let!(:workload) do + cmd = "'for i in {1..10}; do echo \"Line $i\"; done; while true; do sleep 1; done'" + create_run_workload(cmd) + end + + it "displays correct number of entries", :slow do + result = nil + expected_regex = /Line \d+/ + + spawn_cpl_command("logs", "-a", app, "--workload", workload, "--limit", "5") do |it| result = it.wait_for(expected_regex) it.kill end - expect(result).to match(expected_regex) + expect(result).to include("Line 6") + end + end + + context "when using different loopback window" do + let!(:workload) do + cmd = "'echo \"Line 1\"; sleep 30; echo \"Line 2\"; while true; do sleep 1; done'" + create_run_workload(cmd) end + + before do + Kernel.sleep(30) + end + + it "displays entries from correct duration", :slow do + result = nil + expected_regex = /Line \d+/ + + spawn_cpl_command("logs", "-a", app, "--workload", workload, "--since", "30s") do |it| + result = it.wait_for(expected_regex) + it.kill + end + + expect(result).to include("Line 2") + end + end + + def create_run_workload(cmd) + runner_workload = nil + + runner_workload_regex = /runner workload '(.+?)'/ + spawn_cpl_command("run", "-a", app, "--", cmd, wait_for_process: false) do |it| + runner_workload_result = it.wait_for(runner_workload_regex) + runner_workload = runner_workload_result.match(runner_workload_regex)[1] + + it.wait_for(message_regex) + end + + runner_workload end end diff --git a/spec/command/promote_app_from_upstream_spec.rb b/spec/command/promote_app_from_upstream_spec.rb index 9ff3f393..4ca4fcae 100644 --- a/spec/command/promote_app_from_upstream_spec.rb +++ b/spec/command/promote_app_from_upstream_spec.rb @@ -46,8 +46,9 @@ ENV["APP_NAME"] = app run_cpl_command!("apply-template", "gvc", "-a", upstream_app) - run_cpl_command!("apply-template", "gvc", "rails", "-a", app) + run_cpl_command!("apply-template", "gvc", "rails", "postgres", "-a", app) run_cpl_command!("build-image", "-a", upstream_app) + run_cpl_command!("ps:start", "-a", app, "--workload", "postgres", "--wait") end after do @@ -56,14 +57,17 @@ end it "copies latest image from upstream, fails to run release script and fails to deploy image", :slow do - result = run_cpl_command("promote-app-from-upstream", "-a", app, "--upstream-token", token) + result = nil - expect(result[:status]).not_to eq(0) - expect(result[:stderr]).to match(%r{Pulling image from '.+?/#{upstream_app}:1'}) - expect(result[:stderr]).to match(%r{Pushing image to '.+?/#{app}:1'}) - expect(result[:stderr]).to include("Running release script") - expect(result[:stderr]).to include("Failed to run release script") - expect(result[:stderr]).not_to match(%r{rails: https://rails-.+?.cpln.app}) + spawn_cpl_command("promote-app-from-upstream", "-a", app, "--upstream-token", token) do |it| + result = it.read_full_output + end + + expect(result).to match(%r{Pulling image from '.+?/#{upstream_app}:1'}) + expect(result).to match(%r{Pushing image to '.+?/#{app}:1'}) + expect(result).to include("Running release script") + expect(result).to include("Failed to run release script") + expect(result).not_to match(%r{rails: https://rails-.+?.cpln.app}) end end @@ -90,14 +94,17 @@ end it "copies latest image from upstream, runs release script and deploys image", :slow do - result = run_cpl_command("promote-app-from-upstream", "-a", app, "--upstream-token", token) + result = nil - expect(result[:status]).to eq(0) - expect(result[:stderr]).to match(%r{Pulling image from '.+?/#{upstream_app}:1'}) - expect(result[:stderr]).to match(%r{Pushing image to '.+?/#{app}:1'}) - expect(result[:stderr]).to include("Running release script") - expect(result[:stderr]).not_to include("Failed to run release script") - expect(result[:stderr]).to match(%r{rails: https://rails-.+?.cpln.app}) + spawn_cpl_command("promote-app-from-upstream", "-a", app, "--upstream-token", token) do |it| + result = it.read_full_output + end + + expect(result).to match(%r{Pulling image from '.+?/#{upstream_app}:1'}) + expect(result).to match(%r{Pushing image to '.+?/#{app}:1'}) + expect(result).to include("Running release script") + expect(result).to include("Finished running release script") + expect(result).to match(%r{rails: https://rails-.+?.cpln.app}) end end end diff --git a/spec/command/ps_stop_spec.rb b/spec/command/ps_stop_spec.rb index d079f68a..43be5e1c 100644 --- a/spec/command/ps_stop_spec.rb +++ b/spec/command/ps_stop_spec.rb @@ -9,29 +9,58 @@ run_cpl_command!("ps:start", "-a", app, "--wait") end - it "stops all workloads", :slow do - result = run_cpl_command("ps:stop", "-a", app) + context "when no workload is provided" do + it "stops all workloads", :slow do + result = run_cpl_command("ps:stop", "-a", app) - expect(result[:status]).to eq(0) - expect(result[:stderr]).to match(/Stopping workload 'rails'[.]+? done!/) - expect(result[:stderr]).to match(/Stopping workload 'postgres'[.]+? done!/) + expect(result[:status]).to eq(0) + expect(result[:stderr]).to match(/Stopping workload 'rails'[.]+? done!/) + expect(result[:stderr]).to match(/Stopping workload 'postgres'[.]+? done!/) + end + + it "stops all workloads and waits for them to not be ready", :slow do + result = run_cpl_command("ps:stop", "-a", app, "--wait") + + expect(result[:status]).to eq(0) + expect(result[:stderr]).to match(/Stopping workload 'rails'[.]+? done!/) + expect(result[:stderr]).to match(/Stopping workload 'postgres'[.]+? done!/) + expect(result[:stderr]).to match(/Waiting for workload 'rails' to not be ready[.]+? done!/) + expect(result[:stderr]).to match(/Waiting for workload 'postgres' to not be ready[.]+? done!/) + end end - it "stops specific workload", :slow do - result = run_cpl_command("ps:stop", "-a", app, "--workload", "rails") + context "when workload is provided" do + it "stops specific workload", :slow do + result = run_cpl_command("ps:stop", "-a", app, "--workload", "rails") - expect(result[:status]).to eq(0) - expect(result[:stderr]).to match(/Stopping workload 'rails'[.]+? done!/) - expect(result[:stderr]).not_to include("postgres") + expect(result[:status]).to eq(0) + expect(result[:stderr]).to match(/Stopping workload 'rails'[.]+? done!/) + expect(result[:stderr]).not_to include("postgres") + end end - it "stops all workloads and waits for them to not be ready", :slow do - result = run_cpl_command("ps:stop", "-a", app, "--wait") + context "when replica is provided" do + let!(:replica) do + run_cpl_command!("ps:stop", "-a", app, "--wait") + run_cpl_command!("ps:start", "-a", app, "--wait") + + result = run_cpl_command!("ps", "-a", app, "--workload", "rails") + result[:stdout].strip + end + + it "stops specific replica", :slow do + result = run_cpl_command("ps:stop", "-a", app, "--workload", "rails", "--replica", replica) + + expect(result[:status]).to eq(0) + expect(result[:stderr]).to match(/Stopping replica '#{replica}'[.]+? done!/) + end + + it "stops specific replica and waits for it to not be ready", :slow do + result = run_cpl_command("ps:stop", "-a", app, "--workload", "rails", "--replica", replica, "--wait") - expect(result[:status]).to eq(0) - expect(result[:stderr]).to match(/Stopping workload 'rails'[.]+? done!/) - expect(result[:stderr]).to match(/Stopping workload 'postgres'[.]+? done!/) - expect(result[:stderr]).to match(/Waiting for workload 'rails' to not be ready[.]+? done!/) - expect(result[:stderr]).to match(/Waiting for workload 'postgres' to not be ready[.]+? done!/) + expect(result[:status]).to eq(0) + expect(result[:stderr]).to match(/Stopping replica '#{replica}'[.]+? done!/) + expect(result[:stderr]).to match(/Waiting for replica '#{replica}' to not be ready[.]+? done!/) + end end end diff --git a/spec/command/run_cleanup_spec.rb b/spec/command/run_cleanup_spec.rb deleted file mode 100644 index 47a8f24f..00000000 --- a/spec/command/run_cleanup_spec.rb +++ /dev/null @@ -1,179 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -describe Command::RunCleanup do - context "when 'stale_run_workload_created_days' is not defined" do - let!(:app) { dummy_test_app("with-nothing") } - - it "raises error" do - result = run_cpl_command("run:cleanup", "-a", app) - - expect(result[:status]).not_to eq(0) - expect(result[:stderr]).to include("Can't find option 'stale_run_workload_created_days'") - end - end - - context "when there are no stale run workloads to delete" do - let!(:app) { dummy_test_app } - - it "displays message" do - result = run_cpl_command("run:cleanup", "-a", app) - - expect(result[:status]).to eq(0) - expect(result[:stderr]).to include("No stale run workloads found") - end - end - - context "when run workload matches defined workload exactly" do - let!(:app) { dummy_test_app("with-fake-run-workload") } - - before do - run_cpl_command!("apply-template", "gvc", "fake-run-12345", "-a", app) - end - - after do - run_cpl_command!("delete", "-a", app, "--yes") - end - - it "lists nothing" do - travel_to_days_later(3) - result = run_cpl_command("run:cleanup", "-a", app) - travel_back - - expect(result[:status]).to eq(0) - expect(result[:stderr]).to include("No stale run workloads found") - end - end - - context "when run workloads do not match naming pattern exactly" do - let!(:app) { dummy_test_app } - - before do - run_cpl_command!("apply-template", "gvc", "fake-run-12345", "fake-runner-12345", "-a", app) - end - - after do - run_cpl_command!("delete", "-a", app, "--yes") - end - - it "lists nothing" do - travel_to_days_later(3) - result = run_cpl_command("run:cleanup", "-a", app) - travel_back - - expect(result[:status]).to eq(0) - expect(result[:stderr]).to include("No stale run workloads found") - end - end - - context "when run workloads are not older than 'stale_run_workload_created_days'" do - let!(:app) { dummy_test_app("full", create_if_not_exists: true) } - - before do - create_run_workloads(app) - end - - it "lists nothing", :slow do - result = run_cpl_command("run:cleanup", "-a", app) - - expect(result[:status]).to eq(0) - expect(result[:stderr]).to include("No stale run workloads found") - end - end - - context "when there are stale run workloads to delete" do - let!(:app) { dummy_test_app("full", create_if_not_exists: true) } - - before do - create_run_workloads(app) - end - - it "asks for confirmation and does nothing", :slow do - allow(Shell).to receive(:confirm).with(match(/\d+ run workloads/)).and_return(false) - - travel_to_days_later(3) - result = run_cpl_command("run:cleanup", "-a", app) - travel_back - - expect(Shell).to have_received(:confirm).once - expect(result[:status]).to eq(0) - expect(result[:stderr]).not_to include("Deleting run workload") - end - - it "asks for confirmation and deletes stale run workloads", :slow do - allow(Shell).to receive(:confirm).with(match(/\d+ run workloads/)).and_return(true) - - travel_to_days_later(3) - result = run_cpl_command("run:cleanup", "-a", app) - travel_back - - expect(Shell).to have_received(:confirm).once - expect(result[:status]).to eq(0) - expect(result[:stderr]).to match(/Deleting run workload '#{app}: rails-run-\d{4}'[.]+? done!/) - expect(result[:stderr]).to match(/Deleting run workload '#{app}: rails-runner-\d{4}'[.]+? done!/) - end - - it "skips confirmation and deletes stale run workloads", :slow do - allow(Shell).to receive(:confirm).and_return(false) - - travel_to_days_later(3) - result = run_cpl_command("run:cleanup", "-a", app, "--yes") - travel_back - - expect(Shell).not_to have_received(:confirm) - expect(result[:status]).to eq(0) - expect(result[:stderr]).to match(/Deleting run workload '#{app}: rails-run-\d{4}'[.]+? done!/) - expect(result[:stderr]).to match(/Deleting run workload '#{app}: rails-runner-\d{4}'[.]+? done!/) - end - end - - context "with multiple apps" do - let!(:app_prefix) { dummy_test_app_prefix("full") } - let!(:app1) { dummy_test_app("full", "1", create_if_not_exists: true) } - let!(:app2) { dummy_test_app("full", "2", create_if_not_exists: true) } - - before do - create_run_workloads(app1) - create_run_workloads(app2) - end - - it "lists correct run workloads from exact app", :slow do - allow(Shell).to receive(:confirm).with(match(/\d+ run workloads/)).and_return(false) - - travel_to_days_later(3) - result = run_cpl_command("run:cleanup", "-a", app1) - travel_back - - expect(Shell).to have_received(:confirm).once - expect(result[:status]).to eq(0) - expect(result[:stderr]).to match(/- #{app1}: rails-run-\d{4}/) - expect(result[:stderr]).to match(/- #{app1}: rails-runner-\d{4}/) - end - - it "lists correct run workloads from all matching apps", :slow do - allow(Shell).to receive(:confirm).with(match(/\d+ run workloads/)).and_return(false) - - travel_to_days_later(3) - result = run_cpl_command("run:cleanup", "-a", app_prefix) - travel_back - - expect(Shell).to have_received(:confirm).once - expect(result[:status]).to eq(0) - expect(result[:stderr]).to match(/- #{app1}: rails-run-\d{4}/) - expect(result[:stderr]).to match(/- #{app1}: rails-runner-\d{4}/) - expect(result[:stderr]).to match(/- #{app2}: rails-run-\d{4}/) - expect(result[:stderr]).to match(/- #{app2}: rails-runner-\d{4}/) - end - end - - def create_run_workloads(app) - spawn_cpl_command("run", "-a", app, wait_for_process: false, &:wait_for_prompt) - - cmd = "\"bash -c 'while true; do sleep 1; done'\"" - expected_regex = /STARTED RUNNER SCRIPT/ - spawn_cpl_command("run:detached", "-a", app, "--", cmd, wait_for_process: false) do |it| - it.wait_for(expected_regex) - end - end -end diff --git a/spec/command/run_detached_spec.rb b/spec/command/run_detached_spec.rb deleted file mode 100644 index c68fd79b..00000000 --- a/spec/command/run_detached_spec.rb +++ /dev/null @@ -1,94 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -describe Command::RunDetached do - context "when workload to clone does not exist" do - let!(:app) { dummy_test_app("default", create_if_not_exists: true) } - - it "raises error" do - result = run_cpl_command("run:detached", "-a", app, "--", "ls") - - expect(result[:status]).not_to eq(0) - expect(result[:stderr]).to include("Can't find workload 'rails'") - end - end - - context "when workload to clone exists" do - let!(:app) { dummy_test_app("full", create_if_not_exists: true) } - - it "keeps retrying until MAX_RETRIES if runtime error happens", :slow do - stub_const("Command::RunDetached::MAX_RETRIES", 3) - allow_any_instance_of(described_class).to receive(:print_uniq_logs).and_raise(RuntimeError, "Runtime error.") # rubocop:disable RSpec/AnyInstance - - result = run_cpl_command("run:detached", "-a", app, "--", "ls") - - expect(result[:status]).not_to eq(0) - expect(result[:stderr]).to include("Retrying").exactly(3).times - expect(result[:stderr]).to include("Exiting") - end - - it "deletes workload if finished with success", :slow do - result = run_cpl_command("run:detached", "-a", app, "--", "ls") - - expect(result[:status]).to eq(0) - expect(result[:stderr]).to include("Gemfile") - expect(result[:stderr]).to include("DELETING WORKLOAD") - end - - it "deletes workload if finished with failure by default", :slow do - result = run_cpl_command("run:detached", "-a", app, "--", "nonexistent") - - expect(result[:status]).not_to eq(0) - expect(result[:stderr]).to include("CRASHED") - expect(result[:stderr]).to include("DELETING WORKLOAD") - end - - it "does not delete workload if finished with failure and --no-clean-on-failure is provided", :slow do - result = run_cpl_command("run:detached", "-a", app, "--no-clean-on-failure", "--", "nonexistent") - - expect(result[:status]).not_to eq(0) - expect(result[:stderr]).to include("CRASHED") - expect(result[:stderr]).not_to include("DELETING WORKLOAD") - end - end - - context "when specifying image" do - let!(:app) { dummy_test_app("full", create_if_not_exists: true) } - - it "clones workload and runs with latest image", :slow do - result = run_cpl_command("run:detached", "-a", app, "--image", "latest", "--", "echo $CPLN_IMAGE") - - expect(result[:status]).to eq(0) - expect(result[:stderr]).to match(%r{/org/.+?/image/#{app}:2}) - end - - it "clones workload and runs with specific image", :slow do - result = run_cpl_command("run:detached", "-a", app, "--image", "#{app}:1", "--", "echo $CPLN_IMAGE") - - expect(result[:status]).to eq(0) - expect(result[:stderr]).to match(%r{/org/.+?/image/#{app}:1}) - end - end - - context "when specifying token" do - let!(:token) { Shell.cmd("cpln", "profile", "token", "default")[:output].strip } - let!(:app) { dummy_test_app("full", create_if_not_exists: true) } - - it "clones workload and runs with remote token", :slow do - cmd = "bash -c 'if [ \"$CPLN_TOKEN\" = \"#{token}\" ]; then echo \"LOCAL\"; else echo \"REMOTE\"; fi'" - result = run_cpl_command("run:detached", "-a", app, "--", cmd) - - expect(result[:status]).to eq(0) - expect(result[:stderr]).to include("REMOTE") - end - - it "clones workload and runs with local token", :slow do - cmd = "bash -c 'if [ \"$CPLN_TOKEN\" = \"#{token}\" ]; then echo \"LOCAL\"; else echo \"REMOTE\"; fi'" - result = run_cpl_command("run:detached", "-a", app, "--use-local-token", "--", cmd) - - expect(result[:status]).to eq(0) - expect(result[:stderr]).to include("LOCAL") - end - end -end diff --git a/spec/command/run_spec.rb b/spec/command/run_spec.rb index 4ee013ad..ff68e74f 100644 --- a/spec/command/run_spec.rb +++ b/spec/command/run_spec.rb @@ -14,157 +14,196 @@ end end - context "when workload to clone exists" do - let!(:app) { dummy_test_app("full", create_if_not_exists: true) } + context "when using interactive mode" do + context "when workload to clone exists" do + let!(:app) { dummy_test_app("full", create_if_not_exists: true) } - it "clones workload and runs bash by default", :slow do - result = nil - expected_regex = /Gemfile/ - - spawn_cpl_command("run", "-a", app) do |it| - it.wait_for_prompt - it.type("ls") - result = it.wait_for(expected_regex) - it.type("exit") + before do + run_cpl_command!("ps:start", "-a", app, "--workload", "postgres", "--wait") end - expect(result).to match(expected_regex) - end + it "clones workload and runs provided command", :slow do + result = nil + expected_regex = /Gemfile/ - it "clones workload and runs provided command", :slow do - result = nil - expected_regex = /Gemfile/ + spawn_cpl_command("run", "-a", app, "--interactive", "--", "bash") do |it| + it.wait_for_prompt + it.type("ls") + result = it.wait_for(expected_regex) + it.type("exit") + end - spawn_cpl_command("run", "-a", app, "--", "ls") do |it| - result = it.wait_for(expected_regex) + expect(result).to match(expected_regex) end - - expect(result).to match(expected_regex) end - end - context "when specifying image" do - let!(:app) { dummy_test_app("full", create_if_not_exists: true) } + context "when 'fix_terminal_size' is provided" do + let!(:app) { dummy_test_app("with-fix-terminal-size") } - it "clones workload and runs with latest image", :slow do - result = nil - expected_regex = %r{/org/.+?/image/#{app}:2} + before do + run_cpl_command!("apply-template", "gvc", "rails", "-a", app) + run_cpl_command!("build-image", "-a", app) + run_cpl_command!("deploy-image", "-a", app) + end - spawn_cpl_command("run", "-a", app, "--image", "latest") do |it| - it.wait_for_prompt - it.type("echo $CPLN_IMAGE") - result = it.wait_for(expected_regex) - it.type("exit") + after do + run_cpl_command!("delete", "-a", app, "--yes") end - expect(result).to match(expected_regex) - end + it "clones workload and runs with fixed terminal size", :slow do + result = nil + expected_regex = /10 150/ - it "clones workload and runs with specific image", :slow do - result = nil - expected_regex = %r{/org/.+?/image/#{app}:1} + spawn_cpl_command("run", "-a", app, "--entrypoint", "bash", stty_rows: 10, stty_cols: 150) do |it| + it.wait_for_prompt + it.type("stty size") + result = it.wait_for(expected_regex) + it.type("exit") + end - spawn_cpl_command("run", "-a", app, "--image", "#{app}:1") do |it| - it.wait_for_prompt - it.type("echo $CPLN_IMAGE") - result = it.wait_for(expected_regex) - it.type("exit") + expect(result).to match(expected_regex) end + end + + context "when terminal size is provided" do + let!(:app) { dummy_test_app("full", create_if_not_exists: true) } + + it "clones workload and runs with provided terminal size", :slow do + result = nil + expected_regex = /20 300/ - expect(result).to match(expected_regex) + spawn_cpl_command("run", "-a", app, "--entrypoint", "bash", "--terminal-size", "20,300") do |it| + it.wait_for_prompt + it.type("stty size") + result = it.wait_for(expected_regex) + it.type("exit") + end + + expect(result).to match(expected_regex) + end end end - context "when specifying token" do - let!(:token) { Shell.cmd("cpln", "profile", "token", "default")[:output].strip } - let!(:app) { dummy_test_app("full", create_if_not_exists: true) } + context "when using non-interactive mode" do + context "when workload to clone exists" do + let!(:app) { dummy_test_app("full", create_if_not_exists: true) } - it "clones workload and runs with remote token", :slow do - result = nil - expected_regex = /REMOTE/ + it "clones workload and runs provided command with success", :slow do + result = nil - spawn_cpl_command("run", "-a", app) do |it| - it.wait_for_prompt - it.type("if [ \"$CPLN_TOKEN\" = \"#{token}\" ]; then echo \"LOCAL\"; else echo \"REMOTE\"; fi") - result = it.wait_for(expected_regex) - it.type("exit") + spawn_cpl_command("run", "-a", app, "--entrypoint", "none", "--", "ls") do |it| + result = it.read_full_output + end + + expect(result).to include("Gemfile") end - expect(result).to match(expected_regex) + it "clones workload and runs provided command with failure", :slow do + result = nil + + spawn_cpl_command("run", "-a", app, "--entrypoint", "none", "--", "nonexistent") do |it| + result = it.read_full_output + end + + expect(result).not_to include("Gemfile") + end end - it "clones workload and runs with local token", :slow do - result = nil - expected_regex = /LOCAL/ + context "when not specifying image" do + let!(:app) { dummy_test_app } + let!(:cmd) { "'echo $CPLN_IMAGE'" } - spawn_cpl_command("run", "-a", app, "--use-local-token") do |it| - it.wait_for_prompt - it.type("if [ \"$CPLN_TOKEN\" = \"#{token}\" ]; then echo \"LOCAL\"; else echo \"REMOTE\"; fi") - result = it.wait_for(expected_regex) - it.type("exit") + before do + run_cpl_command!("apply-template", "gvc", "rails", "-a", app) + run_cpl_command!("build-image", "-a", app) + run_cpl_command!("deploy-image", "-a", app) + run_cpl_command!("build-image", "-a", app) end - expect(result).to match(expected_regex) - end - end + after do + run_cpl_command!("delete", "-a", app, "--yes") + end - context "when 'fix_terminal_size' is provided" do - let!(:app) { dummy_test_app("with-fix-terminal-size") } + it "clones workload and runs with exact same image as original workload after running with latest image", :slow do + result1 = nil + result2 = nil - before do - run_cpl_command!("apply-template", "gvc", "rails", "-a", app) - run_cpl_command!("build-image", "-a", app) - run_cpl_command!("deploy-image", "-a", app) - end + spawn_cpl_command("run", "-a", app, "--entrypoint", "none", "--image", "latest", "--", cmd) do |it| + result1 = it.read_full_output + end + spawn_cpl_command("run", "-a", app, "--entrypoint", "none", "--", cmd) do |it| + result2 = it.read_full_output + end - after do - run_cpl_command!("delete", "-a", app, "--yes") + expect(result1).to match(%r{/org/.+?/image/#{app}:2}) + expect(result2).to match(%r{/org/.+?/image/#{app}:1}) + end end - it "clones workload and runs with fixed terminal size", :slow do - result = nil - expected_regex = /10 150/ + context "when specifying image" do + let!(:app) { dummy_test_app("full", create_if_not_exists: true) } + let!(:cmd) { "'echo $CPLN_IMAGE'" } + + it "clones workload and runs with latest image", :slow do + result = nil - spawn_cpl_command("run", "-a", app, stty_rows: 10, stty_cols: 150) do |it| - it.wait_for_prompt - it.type("stty size") - result = it.wait_for(expected_regex) - it.type("exit") + spawn_cpl_command("run", "-a", app, "--entrypoint", "none", "--image", "latest", "--", cmd) do |it| + result = it.read_full_output + end + + expect(result).to match(%r{/org/.+?/image/#{app}:2}) end - expect(result).to match(expected_regex) + it "clones workload and runs with specific image", :slow do + result = nil + + spawn_cpl_command("run", "-a", app, "--entrypoint", "none", "--image", "#{app}:1", "--", cmd) do |it| + result = it.read_full_output + end + + expect(result).to match(%r{/org/.+?/image/#{app}:1}) + end end - end - context "when terminal size is provided" do - let!(:app) { dummy_test_app("full", create_if_not_exists: true) } + context "when specifying token" do + let!(:token) { Shell.cmd("cpln", "profile", "token", "default")[:output].strip } + let!(:app) { dummy_test_app("full", create_if_not_exists: true) } + let!(:cmd) { "'if [ \"$CPLN_TOKEN\" = \"#{token}\" ]; then echo \"LOCAL\"; else echo \"REMOTE\"; fi'" } + + it "clones workload and runs with remote token", :slow do + result = nil - it "clones workload and runs with provided terminal size", :slow do - result = nil - expected_regex = /20 300/ + spawn_cpl_command("run", "-a", app, "--entrypoint", "none", "--", cmd) do |it| + result = it.read_full_output + end - spawn_cpl_command("run", "-a", app, "--terminal-size", "20,300") do |it| - it.wait_for_prompt - it.type("stty size") - result = it.wait_for(expected_regex) - it.type("exit") + expect(result).to include("REMOTE") end - expect(result).to match(expected_regex) - end + it "clones workload and runs with local token", :slow do + result = nil - it "clones workload and fails to run with provided terminal size due to invalid format", :slow do - result = nil - expected_regex = /0 0/ + spawn_cpl_command("run", "-a", app, "--entrypoint", "none", "--use-local-token", "--", cmd) do |it| + result = it.read_full_output + end - spawn_cpl_command("run", "-a", app, "--terminal-size", "'20 300'") do |it| - it.wait_for_prompt - it.type("stty size") - result = it.wait_for(expected_regex) - it.type("exit") + expect(result).to include("LOCAL") end + end - expect(result).to match(expected_regex) + context "when detatching" do + let!(:app) { dummy_test_app("full", create_if_not_exists: true) } + + it "prints commands to log and stop the job", :slow do + result = nil + + spawn_cpl_command("run", "-a", app, "--entrypoint", "none", "--detached", "--", "ls") do |it| + result = it.read_full_output + end + + expect(result).to include("cpl logs") + expect(result).to include("cpl ps:stop") + end end end end diff --git a/spec/core/controlplane_api_direct_spec.rb b/spec/core/controlplane_api_direct_spec.rb index 8cbe8159..59884c76 100644 --- a/spec/core/controlplane_api_direct_spec.rb +++ b/spec/core/controlplane_api_direct_spec.rb @@ -3,7 +3,7 @@ require "spec_helper" describe ControlplaneApiDirect do - let(:described_instance) { described_class.new } + let!(:described_instance) { described_class.new } describe "#api_host" do it "returns correct host for 'api' when CPLN_ENDPOINT is not set" do diff --git a/spec/cpl_spec.rb b/spec/cpl_spec.rb index 5ac040bf..3af12422 100644 --- a/spec/cpl_spec.rb +++ b/spec/cpl_spec.rb @@ -2,51 +2,21 @@ require "spec_helper" -commands = Command::Base.all_commands options_by_key_name = Command::Base.all_options_by_key_name +non_boolean_options_by_key_name = options_by_key_name + .reject { |_, option| option[:params][:type] == :boolean } describe Cpl do it "has a version number" do expect(Cpl::VERSION).not_to be_nil end - commands.each do |_command_key, command_class| - # Temporary tests to ensure nothing breaks when converting to Thor - it "calls '#{command_class.name}' for '#{command_class::NAME}' command" do - args = command_class::REQUIRES_ARGS ? ["test"] : [] - command_class::OPTIONS.each do |option| - if option[:params][:required] - args.push("--#{option[:name]}") - args.push("my-app-staging") - end - end + non_boolean_options_by_key_name.each do |option_key_name, option| + it "raises error if no value is provided for '#{option_key_name}' option" do + result = run_cpl_command("test", option_key_name) - allow_any_instance_of(Config).to receive(:config_file_path).and_return("spec/fixtures/config.yml") # rubocop:disable RSpec/AnyInstance - expect_any_instance_of(command_class).to receive(:call) # rubocop:disable RSpec/AnyInstance - - Cpl::Cli.start([command_class::NAME, *args]) - end - end - - options_by_key_name.each do |option_key_name, option| - # Temporary tests to ensure nothing breaks when converting to Thor - it "parses '#{option_key_name}' option" do - if option[:params][:type] == :boolean - option_value = true - args = [option_key_name] - else - option_value = "my-app-staging" - args = [option_key_name, option_value] - end - - allow(Config).to receive(:new) - .with([], hash_including(option[:name].to_sym => option_value), []) - .and_call_original - - allow_any_instance_of(Config).to receive(:config_file_path).and_return("spec/fixtures/config.yml") # rubocop:disable RSpec/AnyInstance - expect_any_instance_of(Command::Test).to receive(:call) # rubocop:disable RSpec/AnyInstance - - Cpl::Cli.start(["test", *args]) + expect(result[:status]).not_to eq(0) + expect(result[:stderr]).to include("No value provided for option '#{option[:name]}'") end end end diff --git a/spec/dummy/.controlplane/controlplane.yml b/spec/dummy/.controlplane/controlplane.yml index 698d03a9..2dbf3579 100644 --- a/spec/dummy/.controlplane/controlplane.yml +++ b/spec/dummy/.controlplane/controlplane.yml @@ -18,7 +18,6 @@ apps: image_retention_max_qty: 3 image_retention_days: 30 stale_app_image_deployed_days: 30 - stale_run_workload_created_days: 3 upstream: dummy-test-upstream release_script: release.sh default_domain: cpl.rafaelgomes.xyz @@ -53,7 +52,6 @@ apps: <<: *common match_if_app_name_starts_with: true - stale_run_workload_created_days: 3 default_domain: cpl.rafaelgomes.xyz setup_app_templates: - gvc @@ -151,14 +149,6 @@ apps: match_if_app_name_starts_with: true fix_terminal_size: true - dummy-test-with-fake-run-workload: - <<: *common - - match_if_app_name_starts_with: true - stale_run_workload_created_days: 3 - additional_workloads: - - fake-run-12345 - dummy-test-without-identity: <<: *common diff --git a/spec/dummy/.controlplane/release-invalid.sh b/spec/dummy/.controlplane/release-invalid.sh index 15948564..f9e4c38a 100644 --- a/spec/dummy/.controlplane/release-invalid.sh +++ b/spec/dummy/.controlplane/release-invalid.sh @@ -1,3 +1,3 @@ #!/bin/sh -cpl run:detached -a $APP_NAME --image latest -- bundle exec rake db:nonexistent +bundle exec rake db:nonexistent diff --git a/spec/dummy/.controlplane/release.sh b/spec/dummy/.controlplane/release.sh index 3cbe21c4..9b25ad83 100644 --- a/spec/dummy/.controlplane/release.sh +++ b/spec/dummy/.controlplane/release.sh @@ -1,3 +1,3 @@ #!/bin/sh -cpl run:detached -a $APP_NAME --image latest -- bundle exec rake db:prepare +bundle exec rake db:prepare diff --git a/spec/dummy/.controlplane/templates/fake-run-12345.yml b/spec/dummy/.controlplane/templates/fake-run-12345.yml deleted file mode 100644 index 0631ca5b..00000000 --- a/spec/dummy/.controlplane/templates/fake-run-12345.yml +++ /dev/null @@ -1,26 +0,0 @@ -kind: workload -name: fake-run-12345 -spec: - type: standard - containers: - - name: rails - cpu: 512m - memory: 1Gi - inheritEnv: true - image: {{APP_IMAGE_LINK}} - ports: - - number: 3000 - protocol: http - defaultOptions: - autoscaling: - minScale: 1 - maxScale: 1 - capacityAI: false - timeoutSeconds: 60 - firewallConfig: - external: - inboundAllowCIDR: - - 0.0.0.0/0 - outboundAllowCIDR: - - 0.0.0.0/0 - identityLink: {{APP_IDENTITY_LINK}} diff --git a/spec/dummy/.controlplane/templates/fake-runner-12345.yml b/spec/dummy/.controlplane/templates/fake-runner-12345.yml deleted file mode 100644 index 56440ba5..00000000 --- a/spec/dummy/.controlplane/templates/fake-runner-12345.yml +++ /dev/null @@ -1,26 +0,0 @@ -kind: workload -name: fake-runner-12345 -spec: - type: standard - containers: - - name: rails - cpu: 512m - memory: 1Gi - inheritEnv: true - image: {{APP_IMAGE_LINK}} - ports: - - number: 3000 - protocol: http - defaultOptions: - autoscaling: - minScale: 1 - maxScale: 1 - capacityAI: false - timeoutSeconds: 60 - firewallConfig: - external: - inboundAllowCIDR: - - 0.0.0.0/0 - outboundAllowCIDR: - - 0.0.0.0/0 - identityLink: {{APP_IDENTITY_LINK}} diff --git a/spec/fixtures/config.yml b/spec/fixtures/config.yml deleted file mode 100644 index df4596a7..00000000 --- a/spec/fixtures/config.yml +++ /dev/null @@ -1,61 +0,0 @@ -# Keys beginning with "cpln_" correspond to your settings in Control Plane. - -aliases: - common: &common - # Organization name for staging (customize to your needs). - # Production apps will use a different Control Plane organization, specified below, for security. - cpln_org: my-org-staging - - # Example apps use only one location. Control Plane offers the ability to use multiple locations. - # TODO -- allow specification of multiple locations - default_location: aws-us-east-2 - - # Configure the workload name used as a template for one-off scripts, like a Heroku one-off dyno. - one_off_workload: rails - - # Workloads that are for the application itself and are using application Docker images. - app_workloads: - - rails - - # Additional "service type" workloads, using non-application Docker images. - additional_workloads: - - redis - - postgres - -apps: - my-app-staging: - # Use the values from the common section above. - <<: *common - stale_run_workload_created_days: 2 - my-app-review: - <<: *common - # If `match_if_app_name_starts_with` == `true`, then use this config for app names starting with this name, - # e.g., "my-app-review-pr123", "my-app-review-anything-goes", etc. - match_if_app_name_starts_with: true - stale_run_workload_created_days: 2 - my-app-production: - <<: *common - # Use a different organization for production. - cpln_org: my-org-production - # Allows running the command `cpl pipeline-promote my-app-staging` to promote the staging app to production. - upstream: my-app-staging - my-app-other: - <<: *common - # You can specify a different `Dockerfile` relative to the `.controlplane` directory (default is just "Dockerfile"). - dockerfile: ../some_other/Dockerfile - my-app-test-1: - <<: *common - my-app-test-2: - <<: *common - image_retention_max_qty: 30 - image_retention_days: 30 - my-app-test-3: - <<: *common - image_retention_max_qty: 15 - image_retention_days: 15 - my-app-test-4: - <<: *common - image_retention_max_qty: 12 - my-app-test-5: - <<: *common - image_retention_days: 12 diff --git a/spec/support/command_helpers.rb b/spec/support/command_helpers.rb index 27cdb441..db8cb717 100644 --- a/spec/support/command_helpers.rb +++ b/spec/support/command_helpers.rb @@ -10,7 +10,6 @@ module CommandHelpers # rubocop:disable Metrics/ModuleLength DUMMY_TEST_ORG = ENV.fetch("CPLN_ORG") DUMMY_TEST_APP_PREFIX = "dummy-test" - LOG_FILE = ENV.fetch("SPEC_LOG_FILE", "spec.log") CREATE_APP_PARAMS = { "default" => { @@ -35,9 +34,6 @@ module CommandHelpers # rubocop:disable Metrics/ModuleLength } }.freeze - COMMAND_SEPARATOR = "#" * 100 - SECTION_SEPARATOR = "-" * 100 - def dummy_test_org DUMMY_TEST_ORG end @@ -85,7 +81,7 @@ def apps_to_delete end def create_app_if_not_exists(app, deploy: false, image_before_deploy_count: 0, image_after_deploy_count: 0) # rubocop:disable Metrics/MethodLength - apps_to_delete.push(app) + apps_to_delete.push(app) unless apps_to_delete.include?(app) result = run_cpl_command("exists", "-a", app) return app if result[:status].zero? @@ -106,7 +102,7 @@ def create_app_if_not_exists(app, deploy: false, image_before_deploy_count: 0, i end def run_cpl_command(*args, raise_errors: false) # rubocop:disable Metrics/MethodLength - write_command_to_log(args.join(" ")) + LogHelpers.write_command_to_log(args.join(" ")) result = { status: 0, @@ -126,7 +122,7 @@ def run_cpl_command(*args, raise_errors: false) # rubocop:disable Metrics/Method result[:stderr] = restore_stderr(original_stderr) result[:stdout] = restore_stdout(original_stdout) - write_command_result_to_log(result) + LogHelpers.write_command_result_to_log(result) raise result.to_json if result[:status].nonzero? && raise_errors @@ -143,7 +139,7 @@ def spawn_cpl_command(*args, stty_rows: nil, stty_cols: nil, wait_for_process: t cmd += "stty cols #{stty_cols} && " if stty_cols cmd += "#{cpl_executable_with_simplecov} #{args.join(' ')}" - write_command_to_log(cmd) + LogHelpers.write_command_to_log(cmd) PTY.spawn(cmd) do |output, input, pid| yield(SpawnedCommand.new(output, input, pid)) @@ -152,28 +148,6 @@ def spawn_cpl_command(*args, stty_rows: nil, stty_cols: nil, wait_for_process: t end end - def write_command_to_log(cmd) - File.open(LOG_FILE, "a") do |file| - file.puts(COMMAND_SEPARATOR) - file.puts(cmd) - end - end - - def write_command_result_to_log(result) # rubocop:disable Metrics/MethodLength - File.open(LOG_FILE, "a") do |file| - file.puts(SECTION_SEPARATOR) - file.puts("STATUS: #{result[:status]}") - file.puts(SECTION_SEPARATOR) - file.puts("STDERR:") - file.puts(SECTION_SEPARATOR) - file.puts(result[:stderr]) - file.puts(SECTION_SEPARATOR) - file.puts("STDOUT:") - file.puts(SECTION_SEPARATOR) - file.puts(result[:stdout]) - end - end - def replace_stderr original_stderr = $stderr $stderr = Tempfile.create diff --git a/spec/support/log_helpers.rb b/spec/support/log_helpers.rb new file mode 100644 index 00000000..8f190c87 --- /dev/null +++ b/spec/support/log_helpers.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module LogHelpers + module_function + + LOG_FILE = ENV.fetch("SPEC_LOG_FILE", "spec.log") + + COMMAND_SEPARATOR = "#" * 100 + SECTION_SEPARATOR = "-" * 100 + + def write_command_to_log(cmd) + File.open(LOG_FILE, "a") do |file| + file.puts(COMMAND_SEPARATOR) + file.puts(cmd) + end + end + + def write_command_result_to_log(result) # rubocop:disable Metrics/MethodLength + File.open(LOG_FILE, "a") do |file| + file.puts(SECTION_SEPARATOR) + file.puts("STATUS: #{result[:status]}") + file.puts(SECTION_SEPARATOR) + file.puts("STDERR:") + file.puts(SECTION_SEPARATOR) + file.puts(result[:stderr]) + file.puts(SECTION_SEPARATOR) + file.puts("STDOUT:") + file.puts(SECTION_SEPARATOR) + file.puts(result[:stdout]) + end + end + + def write_section_separator_to_log + File.open(LOG_FILE, "a") do |file| + file.puts(SECTION_SEPARATOR) + end + end + + def write_line_to_log(line) + File.open(LOG_FILE, "a") do |file| + file.puts(line) + end + end +end diff --git a/spec/support/spawned_command.rb b/spec/support/spawned_command.rb index f710001d..e21a1cd4 100644 --- a/spec/support/spawned_command.rb +++ b/spec/support/spawned_command.rb @@ -2,6 +2,8 @@ require "expect" +require_relative "log_helpers" + class SpawnedCommand attr_reader :output, :input, :pid @@ -13,6 +15,21 @@ def initialize(output, input, pid) @pid = pid end + def read_full_output + LogHelpers.write_section_separator_to_log + + full_output = "" + output.each do |line| + full_output += line + + LogHelpers.write_line_to_log(line) + end + + full_output + rescue Errno::EIO + full_output + end + def wait_for(regex, timeout: DEFAULT_TIMEOUT) result = nil output.expect(regex, timeout) do |matches|