├── .gitignore ├── .rspec ├── .rubocop.yml ├── .ruby-gemset ├── .ruby-version ├── Appraisals ├── CHANGELOG.md ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── bin ├── console ├── cron_kubernetes └── setup ├── cron-kubernetes.gemspec ├── gemfiles ├── kubeclient_3.gemfile └── kubeclient_4.gemfile ├── lib ├── cron-kubernetes.rb ├── cron_kubernetes.rb └── cron_kubernetes │ ├── configurable.rb │ ├── context │ ├── kubectl.rb │ └── well_known.rb │ ├── cron_job.rb │ ├── cron_tab.rb │ ├── kubeclient_context.rb │ ├── kubernetes_client.rb │ ├── scheduler.rb │ └── version.rb └── spec ├── cron_kubernetes ├── cron_job_spec.rb ├── cron_tab_spec.rb ├── kubeclient_context_spec.rb └── scheduler_spec.rb ├── cron_kubernetes_spec.rb └── spec_helper.rb /.gitignore: -------------------------------------------------------------------------------- 1 | **/.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | /Gemfile.lock 10 | **/*.gemfile.lock 11 | 12 | # rspec failure tracking 13 | .rspec_status 14 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | --require spec_helper 4 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | Exclude: 3 | - "gemfiles/*" 4 | SuggestExtensions: false 5 | TargetRubyVersion: 3.2 6 | 7 | Documentation: 8 | Exclude: 9 | - "**/railtie.rb" 10 | - "spec/**/*" 11 | 12 | Style/StringLiterals: 13 | EnforcedStyle: double_quotes 14 | Layout/LineLength: 15 | Max: 120 16 | Layout/HashAlignment: 17 | EnforcedHashRocketStyle: table 18 | EnforcedColonStyle: table 19 | Layout/SpaceInsideHashLiteralBraces: 20 | EnforcedStyle: no_space 21 | Style/RaiseArgs: 22 | EnforcedStyle: compact 23 | Style/EmptyMethod: 24 | EnforcedStyle: expanded 25 | Layout/FirstArrayElementIndentation: 26 | IndentationWidth: 4 27 | Layout/FirstHashElementIndentation: 28 | IndentationWidth: 4 29 | Style/ConditionalAssignment: 30 | EnforcedStyle: assign_inside_condition 31 | Layout/FirstParameterIndentation: 32 | IndentationWidth: 4 33 | Layout/MultilineOperationIndentation: 34 | IndentationWidth: 4 35 | EnforcedStyle: indented 36 | Style/FormatStringToken: 37 | EnforcedStyle: template 38 | Style/AsciiComments: 39 | Enabled: false 40 | Naming/FileName: 41 | Exclude: 42 | - "lib/cron-kubernetes.rb" 43 | Metrics/BlockLength: 44 | Exclude: 45 | - "*.gemspec" 46 | - "spec/**/*" 47 | 48 | Layout/EmptyLinesAroundBlockBody: 49 | Exclude: 50 | - "spec/**/*" 51 | -------------------------------------------------------------------------------- /.ruby-gemset: -------------------------------------------------------------------------------- 1 | cron-kubernetes-ruby 2 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 3.2.2 2 | -------------------------------------------------------------------------------- /Appraisals: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | appraise "kubeclient-3" do 4 | gem "kubeclient", "3.1.2" 5 | end 6 | 7 | appraise "kubeclient-4" do 8 | gem "kubeclient", "4.0.0" 9 | end 10 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # v3.0.0 2 | - Change syntax for ruby 3.2.2 3 | 4 | # v2.0.0 5 | **Breaking Change:** 6 | - Requires ruby 2.7 or later 7 | 8 | **Changes:** 9 | - Fix issue where running `cron_kubernetes` would fail with "ResourceNotFoundError" 10 | 11 | # v1.1.0 12 | - Fix issue where all cron jobs in a cluster would be removed, not just ones matching `identifier` 13 | 14 | # v1.0.0 15 | **Breaking Change:** 16 | - Requires `kubeclient` 3.1.2 or 4.x 17 | 18 | **Changes:** 19 | - Add `kubeclient` configuration option for connecting to any Kubernetes server 20 | - Add Appraisal for testing with kubeclient 3.1.2 and 4.x 21 | 22 | # v0.1.0 23 | - Initial Release 24 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | git_source(:github) { |repo_name| "https://github.com/#{repo_name}" } 6 | 7 | gemspec 8 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 Jeremy Wadsack 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CronKubernetes 2 | 3 | Configue and deploy Kubernetes [CronJobs](https://kubernetes.io/docs/concepts/workloads/controllers/cron-jobs/) 4 | from ruby. 5 | 6 | ## Installation 7 | 8 | Add this line to your application's Gemfile: 9 | 10 | ```ruby 11 | gem "cron-kubernetes" 12 | ``` 13 | 14 | And then execute: 15 | 16 | $ bundle 17 | 18 | Or install it yourself as: 19 | 20 | $ gem install cron_kubernetes 21 | 22 | ## Configuration 23 | 24 | You can configure global settings for your cron jobs. Add a file to your source like the example 25 | below. If you are using Rails, you can add this to something like `config/initializers/cron_kubernetes.rb`. 26 | 27 | You _must_ configure the `identifier` and `manifest` settings. The other settings are optional 28 | and default values are shown below. 29 | 30 | ```ruby 31 | CronKubernetes.configuration do |config| 32 | # Required 33 | config.identifier = "my-application" 34 | config.manifest = YAML.load_file(File.join(Rails.root, "deploy", "kubernetes-job.yml")) 35 | 36 | # Optional 37 | config.output = nil 38 | config.job_template = %w[/bin/bash -l -c :job] 39 | end 40 | ``` 41 | 42 | ### `identifier` 43 | Provide an identifier for this schedule. For example, you might use your application name. 44 | This is used by `CronKubernetes` to know which CronJobs are associated with this schedule 45 | so you should make sure it's unique within your cluster. 46 | 47 | `identifier` must be valid for a Kubernetes resource name and label value. Specifically, 48 | lowercase alphanumeric characters (`[a-z0-9A-Z]`), `-`, and `.`, and 63 characters or less. 49 | 50 | ### `manifest` 51 | 52 | This is a Kubernetes Job manifest used as the job template within the Kubernetes 53 | CronJob. That is, this is the job that's started at the specified schedule. For 54 | example: 55 | 56 | ```yaml 57 | apiVersion: batch/v1 58 | kind: Job 59 | metadata: 60 | name: scheduled-job 61 | spec: 62 | template: 63 | metadata: 64 | name: scheduled-job 65 | spec: 66 | containers: 67 | - name: my-shell 68 | image: ubuntu 69 | restartPolicy: OnFailure 70 | ``` 71 | 72 | In the example above we show the manifest loading a file, just to make it 73 | simple. But you could also read use a HEREDOC, parse a template and insert 74 | values, or anything else you want to do in the method, as long as you return 75 | a valid Kubernetes Job manifest as a `Hash`. 76 | 77 | When the job is run, the default command in the Docker instance is replaced with 78 | the command specified in the cron schedule (see below). The command is run on the 79 | first container in the pod. 80 | 81 | ### `output` 82 | 83 | By default no redirection is done; cron behaves as normal. If you would like you 84 | can specify an option here to redirect as you would on a shell command. For example, 85 | `"2>&1` to collect STDERR in STDOUT or `>> /var/log/task.log` to append to a log file. 86 | 87 | ### `job_template` 88 | 89 | This is a template that we use to execute your rake, rails runner, or shell command 90 | in the container. The default template executes it in a login shell so that environment 91 | variables (and profile) are loaded. 92 | 93 | You can modify this. The value should be an array with a command and arguments that will 94 | replace both `ENTRYPOINT` and `CMD` in the Docker image. See 95 | [Define a Command and Arguments for a Container](https://kubernetes.io/docs/tasks/inject-data-application/define-command-argument-container/) 96 | for a discussion of how `command` works in Kubernetes. 97 | 98 | ### kubeclient 99 | The gem will automatically connect to the Kubernetes server in the following cases: 100 | - You are running this in [a standard Kubernetes cluster](https://kubernetes.io/docs/tasks/access-application-cluster/access-cluster/#accessing-the-api-from-a-pod) 101 | - You are running on a system with `kubeclient` installed and 102 | - the default cluster context has credentials 103 | - the default cluster is GKE and your system has 104 | [Google application default credentials](https://developers.google.com/identity/protocols/application-default-credentials) 105 | installed 106 | 107 | There are many other ways to connect and you can do so by providing your own 108 | [configured `kubeclient`](https://github.com/abonas/kubeclient#usage): 109 | 110 | ```ruby 111 | # config/initializers/resque-kubernetes.rb 112 | 113 | CronKubernetes.configuration do |config| 114 | config.kubeclient = Kubeclient::Client.new("http://localhost:8080/apis/batch", "v1") 115 | end 116 | ``` 117 | 118 | Because this uses the `CronJob` resource, make sure to connect to the `/apis/batch` API endpoint and 119 | API version `v1` in your client. 120 | 121 | ## Usage 122 | 123 | ### Create a Schedule 124 | Add a file to your source that defines the scheduled tasks. If you are using Rails, you could 125 | put this in `config/initializers/cron_kuberentes.rb`. Or, if you want to make it work like the 126 | `whenever` gem you could add these lines to `config/schedule.rb` and then `require` that from your 127 | initializer. 128 | 129 | ```ruby 130 | CronKubernetes.schedule do 131 | command "ls -l", schedule: "0 0 1 1 *" 132 | rake "audit:state", schedule: "0 20 1 * *", name: "audit-state" 133 | runner "CleanSweeper.run", schedule: "30 3 * * *" 134 | end 135 | ``` 136 | 137 | For all jobs the command will change directories to either `Rails.root` if Rails is installed 138 | or the current working directory. These are evaluated when the scheduled tasks are loaded. 139 | 140 | For all jobs you may provide a `name` that will be used with the `identifier` to name the 141 | CronJob. If you do not provide a name `CronKubernetes` will try to figure one out from the job and 142 | pod templates plus a hash of the schedule and command. 143 | 144 | #### Shell Commands 145 | 146 | A `command` runs any arbitrary shell command on a schedule. The first argument is the command to run. 147 | 148 | #### Rake Tasks 149 | 150 | A `rake` call runs a `rake` task on the schedule. Rake and Bundler must be installed and on the path 151 | in the container. The command it executes is `bundle exec rake ...`. 152 | 153 | #### Runners 154 | 155 | A `runner` runs arbitrary ruby code under Rails. Rails must be installed at `bin/rails` from the 156 | working folder. The command it executes is `bin/rails runner '...'`. 157 | 158 | ### Update Your Cluster 159 | 160 | Once you have configuration and cluster, then you can run the `cron_kubernetes` command 161 | to update your cluster. 162 | 163 | ```bash 164 | bundle exec cron_kubernetes --configuration config/initializers/cron_kubernetes.rb --schedule config/schedule.rb 165 | ``` 166 | 167 | The command will read the provided configuration and current schedule, compare to any 168 | CronJobs already in your cluster for this project (base on the `identifier`) and then 169 | add/remove/update the CronJobs to bring match the schedule. 170 | 171 | You can provide either `--configuration` or `--schedule`, as long as between the files you have 172 | loaded both a configuration and a schedule. For example, if they are in the same file, you would 173 | just pass a single value: 174 | 175 | ```bash 176 | bundle exec cron_kubernetes --schedule schedule.rb 177 | ``` 178 | 179 | If you are running in a Rails application where the initializers are auto-loaded, and your 180 | schedule is defined in (or in a file required by) your initializer, you could run this within 181 | your Rails environment: 182 | 183 | ```bash 184 | bin/rails runner cron_kubernetes 185 | ``` 186 | 187 | ## To Do 188 | - In place of `schedule`, support `every`/`at` syntax: 189 | ``` 190 | every: :minute, :hour, :day, :month, :year 191 | 3.minutes, 1.hour, 1.day, 1.week, 1.month, 1.year 192 | at: "[H]H:mm[am|pm]" 193 | ``` 194 | 195 | ## Contributing 196 | 197 | Bug reports and pull requests are welcome on GitHub at 198 | https://github.com/keylime-toolbox/cron-kubernetes-ruby. 199 | 200 | 1. Fork it (`https://github.com/[my-github-username]/cron-kubernetes-ruby/fork`) 201 | 2. Create your feature branch (`git checkout -b my-new-feature`) 202 | 3. Test your changes with `rake`, add new tests if needed 203 | 4. Commit your changes (`git commit -am 'Add some feature'`) 204 | 6. Push to the branch (`git push origin my-new-feature`) 205 | 7. Open a new Pull Request 206 | 207 | ### Development 208 | 209 | After checking out the repo, run `bin/setup` to install dependencies. Then, 210 | run `rake` to run the test suite. 211 | 212 | You can run `bin/console` for an interactive prompt that will allow you to 213 | experiment. 214 | 215 | Write test for any code that you add. Test all changes by running `bin/rake`. 216 | This does the following, which you can also run separately while working. 217 | 1. Run unit tests: `appraisal rake spec` 218 | 2. Make sure that your code matches the styles: `rubocop` 219 | 3. Verify if any dependent gems have open CVEs (you must update these): 220 | `rake bundle:audit` 221 | 222 | ## Release 223 | 224 | To release a new version, update the version number in 225 | `lib/cron_kubernetes/version.rb` and the `CHANGELOG.md`, then run 226 | `bundle exec rake release`, which will create a git tag for the version, 227 | push git commits and tags, and push the `.gem` file to 228 | [rubygems.org](https://rubygems.org). 229 | 230 | ## Acknowledgments 231 | 232 | We have used the [`whenever` gem](https://github.com/javan/whenever) for years and we love it. 233 | Much of the ideas for scheduling here were inspired by the great work that @javan and team 234 | have put into that gem. 235 | 236 | ## License 237 | 238 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 239 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "appraisal/task" 4 | require "bundler/audit/task" 5 | require "bundler/gem_tasks" 6 | require "rspec/core/rake_task" 7 | require "rubocop/rake_task" 8 | 9 | RSpec::Core::RakeTask.new(:spec) 10 | RuboCop::RakeTask.new 11 | Bundler::Audit::Task.new 12 | Appraisal::Task.new 13 | 14 | # Remove default and replace with a series of test tasks 15 | task default: [] 16 | Rake::Task[:default].clear 17 | 18 | if ENV["APPRAISAL_INITIALIZED"] 19 | task default: %i[spec] 20 | else 21 | task default: %i[rubocop bundle:audit appraisal] 22 | end 23 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require "bundler/setup" 5 | require "cron_kubernetes" 6 | 7 | # You can add fixtures and/or initialization code here to make experimenting 8 | # with your gem easier. You can also use a different console, if you like. 9 | 10 | # (If you use this, don't forget to add pry to your Gemfile!) 11 | # require "pry" 12 | # Pry.start 13 | 14 | require "irb" 15 | IRB.start(__FILE__) 16 | -------------------------------------------------------------------------------- /bin/cron_kubernetes: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require "cron_kubernetes" 5 | require "optparse" 6 | 7 | # Support looking up Google Default Application Credentials, if the gem is installed 8 | begin 9 | require "googleauth" 10 | rescue LoadError 11 | nil 12 | end 13 | 14 | OptionParser.new do |opts| 15 | opts.banner = "Usage: cron_kubernetes [options]" 16 | opts.on("-c", "--configuration [file]", "Location of your configuration file") do |file| 17 | require File.join(Dir.pwd, file) if file 18 | end 19 | opts.on("-c", "--schedule [file]", "Location of your schedule file") do |file| 20 | require File.join(Dir.pwd, file) if file 21 | end 22 | 23 | opts.on("-v", "--version") do 24 | puts "CronKubernetes v#{CronKubernetes::VERSION}" 25 | exit(0) 26 | end 27 | end.parse! 28 | 29 | CronKubernetes::CronTab.new.update 30 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | appraisal install -------------------------------------------------------------------------------- /cron-kubernetes.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | lib = File.expand_path("lib", __dir__) 4 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 5 | require "cron_kubernetes/version" 6 | 7 | Gem::Specification.new do |spec| 8 | spec.name = "cron-kubernetes" 9 | spec.version = CronKubernetes::VERSION 10 | spec.authors = ["Jeremy Wadsack"] 11 | spec.email = ["jeremy.wadsack@gmail.com"] 12 | 13 | spec.summary = "Configure and deploy Kubernetes CronJobs from ruby." 14 | spec.description = "Configure and deploy Kubernetes CronJobs from ruby with a single schedule." 15 | spec.homepage = "https://github.com/keylimetoolbox/cron-kubernetes" 16 | spec.license = "MIT" 17 | spec.required_ruby_version = ">= 3.2" 18 | 19 | spec.files = `git ls-files -z`.split("\x0").reject do |f| 20 | f.match(%r{^(test|spec|features)/}) 21 | end 22 | spec.require_paths = ["lib"] 23 | 24 | spec.bindir = "bin" 25 | spec.executables << "cron_kubernetes" 26 | 27 | spec.add_dependency "kubeclient", ">= 3.1.2", "< 5.0" 28 | 29 | spec.add_development_dependency "appraisal" 30 | spec.add_development_dependency "bundler", "~> 2.4" 31 | spec.add_development_dependency "bundler-audit", "~> 0" 32 | spec.add_development_dependency "mocha", "~> 1.3" 33 | spec.add_development_dependency "rake", "~> 13.1" 34 | spec.add_development_dependency "rspec", "~> 3.12" 35 | spec.add_development_dependency "rubocop", "~> 1.57" 36 | 37 | # For connecting to a GKE cluster in development/test 38 | spec.add_development_dependency "googleauth" 39 | end 40 | -------------------------------------------------------------------------------- /gemfiles/kubeclient_3.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "kubeclient", "3.1.2" 6 | 7 | gemspec path: "../" 8 | -------------------------------------------------------------------------------- /gemfiles/kubeclient_4.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "kubeclient", "4.0.0" 6 | 7 | gemspec path: "../" 8 | -------------------------------------------------------------------------------- /lib/cron-kubernetes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "cron_kubernetes" 4 | -------------------------------------------------------------------------------- /lib/cron_kubernetes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "cron_kubernetes/configurable" 4 | require "cron_kubernetes/context/kubectl" 5 | require "cron_kubernetes/context/well_known" 6 | require "cron_kubernetes/cron_job" 7 | require "cron_kubernetes/cron_tab" 8 | require "cron_kubernetes/kubeclient_context" 9 | require "cron_kubernetes/kubernetes_client" 10 | require "cron_kubernetes/scheduler" 11 | require "cron_kubernetes/version" 12 | 13 | # Configure and deploy Kubernetes CronJobs from ruby 14 | module CronKubernetes 15 | extend Configurable 16 | 17 | # Provide a CronJob manifest as a Hash 18 | define_setting :manifest 19 | 20 | # Provide shell output redirection (e.g. "2>&1" or ">> log") 21 | define_setting :output 22 | 23 | # For RVM support, and to load PATH and such, jobs are run through a bash shell. 24 | # You can alter this with your own template, add `:job` where the job should go. 25 | # Note that the job will be treated as a single shell argument or command. 26 | define_setting :job_template, %w[/bin/bash -l -c :job] 27 | 28 | # Provide an identifier for this schedule (e.g. your application name) 29 | define_setting :identifier 30 | 31 | # A `kubeclient` for connection context, default attempts to read from cluster or `~/.kube/config` 32 | define_setting :kubeclient, nil 33 | 34 | class << self 35 | def schedule(&block) 36 | CronKubernetes::Scheduler.instance.instance_eval(&block) 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/cron_kubernetes/configurable.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module CronKubernetes 4 | # Provides configuration settings, with default values, for the gem. 5 | module Configurable 6 | def configuration 7 | yield self 8 | end 9 | 10 | # Define a configuration setting and its default value. 11 | # 12 | # name: The name of the setting. 13 | # default: A default value for the setting. (Optional) 14 | # rubocop: disable Style/ClassVars 15 | def define_setting(name, default = nil) 16 | class_variable_set("@@#{name}", default) 17 | 18 | define_class_method "#{name}=" do |value| 19 | class_variable_set("@@#{name}", value) 20 | end 21 | 22 | define_class_method name do 23 | class_variable_get("@@#{name}") 24 | end 25 | end 26 | # rubocop: enable Style/ClassVars 27 | 28 | private 29 | 30 | def define_class_method(name, &block) 31 | (class << self; self; end).instance_eval do 32 | define_method(name, &block) 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/cron_kubernetes/context/kubectl.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module CronKubernetes 4 | module Context 5 | # Kubeclient Context from `kubectl` config file. 6 | class Kubectl 7 | def applicable? 8 | File.exist?(kubeconfig) 9 | end 10 | 11 | def context 12 | config = Kubeclient::Config.read(kubeconfig) 13 | 14 | CronKubernetes::KubeclientContext::Context.new( 15 | config.context.api_endpoint, 16 | config.context.api_version, 17 | config.context.namespace, 18 | auth_options: auth_options(config), 19 | ssl_options: config.context.ssl_options 20 | ) 21 | end 22 | 23 | private 24 | 25 | def kubeconfig 26 | File.join(ENV["HOME"], ".kube", "config") 27 | end 28 | 29 | def auth_options(config) 30 | options = config.context.auth_options 31 | return options unless options.empty? 32 | 33 | google_application_default_credentials 34 | end 35 | 36 | def google_application_default_credentials 37 | return unless defined?(Google) && defined?(Google::Auth) 38 | 39 | {bearer_token: Kubeclient::GoogleApplicationDefaultCredentials.token} 40 | end 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/cron_kubernetes/context/well_known.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module CronKubernetes 4 | module Context 5 | # Kubeclient Context from well-known locations within a Kubernetes cluster. 6 | class WellKnown 7 | TOKEN_FILE = "/var/run/secrets/kubernetes.io/serviceaccount/token" 8 | CA_FILE = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt" 9 | NAMESPACE_FILE = "/var/run/secrets/kubernetes.io/serviceaccount/namespace" 10 | 11 | def applicable? 12 | File.exist?(TOKEN_FILE) 13 | end 14 | 15 | def context 16 | CronKubernetes::KubeclientContext::Context.new( 17 | "https://kubernetes.default.svc", 18 | "v1", 19 | namespace, 20 | auth_options: {bearer_token_file: TOKEN_FILE}, 21 | ssl_options: 22 | ) 23 | end 24 | 25 | private 26 | 27 | def namespace 28 | return nil unless File.exist?(NAMESPACE_FILE) 29 | 30 | File.read(NAMESPACE_FILE) 31 | end 32 | 33 | def ssl_options 34 | return {} unless File.exist?(CA_FILE) 35 | 36 | {ca_file: CA_FILE} 37 | end 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/cron_kubernetes/cron_job.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "digest/sha1" 4 | 5 | module CronKubernetes 6 | # A single job to run on a given schedule. 7 | class CronJob 8 | attr_accessor :schedule, :command, :job_manifest, :name, :identifier 9 | 10 | def initialize(schedule: nil, command: nil, job_manifest: nil, name: nil, identifier: nil) 11 | @schedule = schedule 12 | @command = command 13 | @job_manifest = job_manifest 14 | @name = name 15 | @identifier = identifier 16 | end 17 | 18 | # rubocop:disable Metrics/MethodLength 19 | def cron_job_manifest 20 | { 21 | "apiVersion" => "batch/v1", 22 | "kind" => "CronJob", 23 | "metadata" => { 24 | "name" => "#{identifier}-#{cron_job_name}", 25 | "namespace" => namespace, 26 | "labels" => {"cron-kubernetes-identifier" => identifier} 27 | }, 28 | "spec" => { 29 | "schedule" => schedule, 30 | "jobTemplate" => { 31 | "metadata" => job_metadata, 32 | "spec" => job_spec 33 | } 34 | } 35 | } 36 | end 37 | # rubocop:enable Metrics/MethodLength 38 | 39 | private 40 | 41 | def namespace 42 | return job_manifest["metadata"]["namespace"] if job_manifest["metadata"] && job_manifest["metadata"]["namespace"] 43 | 44 | "default" 45 | end 46 | 47 | def job_spec 48 | spec = job_manifest["spec"].dup 49 | first_container = spec["template"]["spec"]["containers"][0] 50 | first_container["command"] = command 51 | spec 52 | end 53 | 54 | def job_metadata 55 | job_manifest["metadata"] 56 | end 57 | 58 | def cron_job_name 59 | return name if name 60 | return job_hash(job_manifest["metadata"]["name"]) if job_manifest["metadata"] 61 | 62 | pod_template_name 63 | end 64 | 65 | def pod_template_name 66 | return nil unless job_manifest["spec"] && 67 | job_manifest["spec"]["template"] && 68 | job_manifest["spec"]["template"]["metadata"] 69 | 70 | job_hash(job_manifest["spec"]["template"]["metadata"]["name"]) 71 | end 72 | 73 | def job_hash(name) 74 | "#{name}-#{Digest::SHA1.hexdigest(schedule + command.join)[0..7]}" 75 | end 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /lib/cron_kubernetes/cron_tab.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module CronKubernetes 4 | # The "table" of Kubernetes CronJobs that we manage in the cluster. 5 | class CronTab 6 | # "Apply" the new configuration 7 | # - remove from cluster any cron_jobs that are no longer in the schedule 8 | # - add new jobs 9 | # - update cron_jobs that exist (deleting a cron_job deletes the job and pod) 10 | def update(schedule = nil) 11 | schedule ||= CronKubernetes::Scheduler.instance.schedule 12 | add, change, remove = diff_schedules(schedule, current_cron_jobs) 13 | remove.each { |job| remove_cron_job(job) } 14 | add.each { |job| add_cron_job(job) } 15 | change.each { |job| update_cron_job(job) } 16 | end 17 | 18 | private 19 | 20 | def client 21 | @client ||= CronKubernetes::KubernetesClient.new.batch_client 22 | end 23 | 24 | # Define a label for our jobs based on an identifier 25 | def label_selector 26 | {label_selector: "cron-kubernetes-identifier=#{CronKubernetes.identifier}"} 27 | end 28 | 29 | # Find all k8s CronJobs by our label for the identifier 30 | def current_cron_jobs 31 | client.get_cron_jobs(label_selector) 32 | end 33 | 34 | def diff_schedules(new, existing) 35 | new_index = index_cron_jobs(new) 36 | existing_index = index_kubernetes_cron_jobs(existing) 37 | add_keys = new_index.keys - existing_index.keys 38 | remove_keys = existing_index.keys - new_index.keys 39 | change_keys = new_index.keys & existing_index.keys 40 | 41 | [ 42 | new_index.values_at(*add_keys), 43 | new_index.values_at(*change_keys), 44 | existing_index.values_at(*remove_keys) 45 | ] 46 | end 47 | 48 | # Remove a Kubeclient::Resource::CronJob from the Kubernetes cluster 49 | def remove_cron_job(job) 50 | client.delete_cron_job(job.metadata.name, job.metadata.namespace) 51 | end 52 | 53 | # Add a CronKubernetes::CronJob to the Kubernetes cluster 54 | def add_cron_job(job) 55 | client.create_cron_job(Kubeclient::Resource.new(job.cron_job_manifest)) 56 | end 57 | 58 | def update_cron_job(job) 59 | client.update_cron_job(Kubeclient::Resource.new(job.cron_job_manifest)) 60 | end 61 | 62 | def index_cron_jobs(jobs) 63 | jobs.map { |job| ["#{job.identifier}-#{job.name}", job] }.to_h 64 | end 65 | 66 | def index_kubernetes_cron_jobs(jobs) 67 | jobs.map { |job| [job.metadata.name, job] }.to_h 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /lib/cron_kubernetes/kubeclient_context.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "kubeclient" 4 | 5 | module CronKubernetes 6 | # Create a context for `Kubeclient` depending on the environment. 7 | class KubeclientContext 8 | Context = Struct.new(:endpoint, :version, :namespace, :options) 9 | 10 | class << self 11 | def context 12 | [ 13 | CronKubernetes::Context::WellKnown, 14 | CronKubernetes::Context::Kubectl 15 | ].each do |context_type| 16 | context = context_type.new 17 | return context.context if context.applicable? 18 | end 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/cron_kubernetes/kubernetes_client.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module CronKubernetes 4 | # Encapsulate access to Kubernetes API for different API versions. 5 | class KubernetesClient 6 | def batch_client 7 | @batch_client ||= client("/apis/batch", "v1") 8 | end 9 | 10 | def namespace 11 | context&.namespace 12 | end 13 | 14 | private 15 | 16 | def client(scope, version = nil) 17 | return CronKubernetes.kubeclient if CronKubernetes.kubeclient 18 | return unless context 19 | 20 | Kubeclient::Client.new(context.endpoint + scope, version || context.version, **context.options) 21 | end 22 | 23 | def context 24 | return nil if CronKubernetes.kubeclient 25 | 26 | @context ||= KubeclientContext.context 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/cron_kubernetes/scheduler.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "singleton" 4 | 5 | module CronKubernetes 6 | # A singleton that creates and holds the scheduled commands. 7 | class Scheduler 8 | include Singleton 9 | attr_reader :schedule 10 | 11 | def initialize 12 | @schedule = [] 13 | @identifier = CronKubernetes.identifier 14 | end 15 | 16 | def rake(task, schedule:, name: nil) 17 | rake_command = "bundle exec rake #{task} --silent" 18 | rake_command = "RAILS_ENV=#{rails_env} #{rake_command}" if rails_env 19 | @schedule << new_cron_job(schedule, rake_command, name) 20 | end 21 | 22 | def runner(ruby_command, schedule:, name: nil) 23 | env = nil 24 | env = "-e #{rails_env} " if rails_env 25 | runner_command = "bin/rails runner #{env}'#{ruby_command}'" 26 | @schedule << new_cron_job(schedule, runner_command, name) 27 | end 28 | 29 | def command(command, schedule:, name: nil) 30 | @schedule << new_cron_job(schedule, command, name) 31 | end 32 | 33 | private 34 | 35 | def make_command(command) 36 | CronKubernetes.job_template.map do |arg| 37 | if arg == ":job" 38 | "cd #{root} && #{command} #{CronKubernetes.output}" 39 | else 40 | arg 41 | end 42 | end 43 | end 44 | 45 | def new_cron_job(schedule, command, name) 46 | CronJob.new( 47 | schedule:, 48 | command: make_command(command), 49 | job_manifest: CronKubernetes.manifest, 50 | name:, 51 | identifier: @identifier 52 | ) 53 | end 54 | 55 | def rails_env 56 | ENV["RAILS_ENV"] 57 | end 58 | 59 | def root 60 | return Rails.root if defined? Rails 61 | 62 | Dir.pwd 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /lib/cron_kubernetes/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module CronKubernetes 4 | VERSION = "3.0.0" 5 | end 6 | -------------------------------------------------------------------------------- /spec/cron_kubernetes/cron_job_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe CronKubernetes::CronJob do 4 | subject { CronKubernetes::CronJob.new } 5 | 6 | let(:manifest) do 7 | YAML.safe_load <<~MANIFEST 8 | apiVersion: batch/v1 9 | kind: Job 10 | spec: 11 | template: 12 | spec: 13 | containers: 14 | - name: hello 15 | image: ubuntu 16 | restartPolicy: OnFailure 17 | MANIFEST 18 | end 19 | 20 | context "initialization" do 21 | it "accepts no parameters" do 22 | expect { CronKubernetes::CronJob.new }.not_to raise_error 23 | end 24 | 25 | it "accepts schedule, command, job_manifest, name parameters" do 26 | job = CronKubernetes::CronJob.new( 27 | schedule: "30 0 * * *", 28 | command: "/bin/bash -l -c ls\\ -l", 29 | job_manifest: manifest, 30 | name: "cron-job", 31 | identifier: "my-app" 32 | ) 33 | expect(job.schedule).to eq "30 0 * * *" 34 | expect(job.command).to eq "/bin/bash -l -c ls\\ -l" 35 | expect(job.job_manifest).to eq manifest 36 | expect(job.name).to eq "cron-job" 37 | expect(job.identifier).to eq "my-app" 38 | end 39 | end 40 | 41 | context "accessors" do 42 | it "has a schedule accessor" do 43 | subject.schedule = "30 0 * * *" 44 | expect(subject.schedule).to eq "30 0 * * *" 45 | end 46 | 47 | it "has a command accessor" do 48 | subject.schedule = "/bin/bash -l -c ls\\ -l" 49 | expect(subject.schedule).to eq "/bin/bash -l -c ls\\ -l" 50 | end 51 | 52 | it "has a job_manifest accessor" do 53 | subject.job_manifest = manifest 54 | expect(subject.job_manifest).to eq manifest 55 | end 56 | 57 | it "has a name accessor" do 58 | subject.name = "cron-job" 59 | expect(subject.name).to eq "cron-job" 60 | end 61 | 62 | it "has an identifier accessor" do 63 | subject.name = "my-app" 64 | expect(subject.name).to eq "my-app" 65 | end 66 | end 67 | 68 | context "#cron_job_manifest" do 69 | subject do 70 | CronKubernetes::CronJob.new( 71 | schedule: "*/1 * * * *", 72 | command: ["/bin/bash", "-l", "-c", "echo Hello from the Kubernetes cluster"], 73 | job_manifest: manifest, 74 | name: "hello", 75 | identifier: "my-app" 76 | ) 77 | end 78 | 79 | it "generates a Kubernetes CronJob manifest for the scheduled command" do 80 | expect(subject.cron_job_manifest.to_yaml).to eq <<~MANIFEST 81 | --- 82 | apiVersion: batch/v1 83 | kind: CronJob 84 | metadata: 85 | name: my-app-hello 86 | namespace: default 87 | labels: 88 | cron-kubernetes-identifier: my-app 89 | spec: 90 | schedule: "*/1 * * * *" 91 | jobTemplate: 92 | metadata: 93 | spec: 94 | template: 95 | spec: 96 | containers: 97 | - name: hello 98 | image: ubuntu 99 | command: 100 | - "/bin/bash" 101 | - "-l" 102 | - "-c" 103 | - echo Hello from the Kubernetes cluster 104 | restartPolicy: OnFailure 105 | MANIFEST 106 | end 107 | 108 | context "when no name is provided" do 109 | subject do 110 | CronKubernetes::CronJob.new( 111 | schedule: "*/1 * * * *", 112 | command: ["/bin/bash", "-l", "-c", "echo Hello from the Kubernetes cluster"], 113 | job_manifest: manifest, 114 | identifier: "my-app" 115 | ) 116 | end 117 | 118 | context "but exists in the Job template metadata" do 119 | let(:manifest) do 120 | YAML.safe_load <<~MANIFEST 121 | apiVersion: batch/v1 122 | kind: Job 123 | metadata: 124 | name: hello-job 125 | spec: 126 | template: 127 | spec: 128 | containers: 129 | - name: hello 130 | image: ubuntu 131 | restartPolicy: OnFailure 132 | MANIFEST 133 | end 134 | 135 | it "pulls the name from the Job metadata" do 136 | expect(subject.cron_job_manifest["metadata"]["name"]).to eq "my-app-hello-job-51e2eaa4" 137 | expect(subject.cron_job_manifest["spec"]["jobTemplate"]["metadata"]["name"]).to eq "hello-job" 138 | end 139 | end 140 | 141 | context "but exists in the Pod template metadata" do 142 | let(:manifest) do 143 | YAML.safe_load <<~MANIFEST 144 | apiVersion: batch/v1 145 | kind: Job 146 | spec: 147 | template: 148 | metadata: 149 | name: hello-pod 150 | spec: 151 | containers: 152 | - name: hello 153 | image: ubuntu 154 | restartPolicy: OnFailure 155 | MANIFEST 156 | end 157 | 158 | it "pulls the name from the Pod metadata" do 159 | expect(subject.cron_job_manifest["metadata"]["name"]).to eq "my-app-hello-pod-51e2eaa4" 160 | job_template = subject.cron_job_manifest["spec"]["jobTemplate"] 161 | pod_template = job_template["spec"]["template"] 162 | expect(pod_template["metadata"]["name"]).to eq "hello-pod" 163 | end 164 | end 165 | end 166 | end 167 | end 168 | -------------------------------------------------------------------------------- /spec/cron_kubernetes/cron_tab_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe CronKubernetes::CronTab do 4 | subject { CronKubernetes::CronTab.new } 5 | 6 | let(:client) { stub "Kubeclient::Client" } 7 | let(:job_manifest) do 8 | YAML.safe_load <<~MANIFEST 9 | apiVersion: batch/v1 10 | kind: Job 11 | spec: 12 | template: 13 | spec: 14 | containers: 15 | - image: ubuntu 16 | restartPolicy: OnFailure 17 | MANIFEST 18 | end 19 | let(:cron_job_manifest) do 20 | { 21 | apiVersion: "batch/v1", 22 | kind: "CronJob", 23 | metadata: { 24 | name: "spec-minutely", 25 | namespace: "default", 26 | labels: {"cron-kubernetes-identifier": "spec"} 27 | }, 28 | spec: { 29 | schedule: "*/1 * * * *", 30 | jobTemplate: { 31 | metadata: nil, 32 | spec: { 33 | template: { 34 | spec: { 35 | containers: [{image: "ubuntu", command: "ls -l"}], 36 | restartPolicy: "OnFailure" 37 | } 38 | } 39 | } 40 | } 41 | } 42 | } 43 | end 44 | let(:job) do 45 | CronKubernetes::CronJob.new( 46 | schedule: "*/1 * * * *", 47 | command: "ls -l", 48 | job_manifest:, 49 | name: "minutely", 50 | identifier: "spec" 51 | ) 52 | end 53 | let(:cron_job) do 54 | Kubeclient::Resource.new(cron_job_manifest) 55 | end 56 | 57 | before do 58 | CronKubernetes::KubernetesClient.any_instance.stubs(:batch_client).returns client 59 | client.stubs(:get_cron_jobs).returns existing 60 | CronKubernetes::Scheduler.instance.stubs(:schedule).returns schedule 61 | CronKubernetes.stubs(:identifier).returns("spec") 62 | end 63 | 64 | context "#udpate" do 65 | context "when new jobs are added to the schedule" do 66 | let(:existing) { [] } 67 | let(:schedule) { [job] } 68 | 69 | it "creates the Kubernetes cron job" do 70 | client.expects(:create_cron_job).with do |resource| 71 | resource.kind == "CronJob" && 72 | resource.metadata.name == "spec-minutely" 73 | end 74 | subject.update 75 | end 76 | end 77 | 78 | context "when jobs are removed from the schedule" do 79 | let(:existing) { [cron_job] } 80 | let(:schedule) { [] } 81 | 82 | it "removes the Kubernetes cron job" do 83 | client.expects(:delete_cron_job).with("spec-minutely", "default") 84 | subject.update 85 | end 86 | 87 | it "does not remove jobs from other schedules" do 88 | client.unstub(:get_cron_jobs) 89 | client.expects(:get_cron_jobs).with(label_selector: "cron-kubernetes-identifier=spec").returns existing 90 | client.stubs(:delete_cron_job) 91 | subject.update 92 | end 93 | end 94 | 95 | context "for jobs that have not changed in the schedule" do 96 | let(:existing) { [cron_job] } 97 | let(:schedule) { [job] } 98 | 99 | it "updates them to ensure they are up-to-date" do 100 | client.expects(:update_cron_job).with do |resource| 101 | resource.kind == "CronJob" && 102 | resource.metadata.name == "spec-minutely" 103 | end 104 | subject.update 105 | end 106 | end 107 | end 108 | end 109 | -------------------------------------------------------------------------------- /spec/cron_kubernetes/kubeclient_context_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "googleauth" 5 | 6 | RSpec.describe CronKubernetes::KubeclientContext do 7 | let(:context) { CronKubernetes::KubeclientContext.context } 8 | 9 | context "when run from a cluster" do 10 | before do 11 | File.stubs(:exist?).returns(false) 12 | File.stubs(:exist?).with(CronKubernetes::Context::WellKnown::TOKEN_FILE).returns(true) 13 | end 14 | 15 | it "returns a context using the available token file" do 16 | expect(context.endpoint).to eq "https://kubernetes.default.svc" 17 | expect(context.version).to eq "v1" 18 | expect(context.namespace).to be_nil 19 | expect(context.options[:auth_options].keys).to eq %i[bearer_token_file] 20 | token_file = CronKubernetes::Context::WellKnown::TOKEN_FILE 21 | expect(context.options[:auth_options][:bearer_token_file]).to eq(token_file) 22 | expect(context.options[:ssl_options]).to be_empty 23 | end 24 | 25 | context "with a CA file" do 26 | before do 27 | File.stubs(:exist?).with(CronKubernetes::Context::WellKnown::CA_FILE).returns(true) 28 | end 29 | 30 | it "includes the CA in the SSL options" do 31 | expect(context.options[:ssl_options].keys).to eq %i[ca_file] 32 | ca_file = CronKubernetes::Context::WellKnown::CA_FILE 33 | expect(context.options[:ssl_options][:ca_file]).to eq(ca_file) 34 | end 35 | end 36 | 37 | context "with a namespace file" do 38 | before do 39 | File.stubs(:exist?).with(CronKubernetes::Context::WellKnown::NAMESPACE_FILE).returns(true) 40 | File.stubs(:read).with(CronKubernetes::Context::WellKnown::NAMESPACE_FILE).returns("name") 41 | end 42 | 43 | it "includes the namespace" do 44 | expect(context.namespace).to eq "name" 45 | end 46 | end 47 | end 48 | 49 | context "when run from a kubectl machine" do 50 | let(:kubectl_file) { CronKubernetes::Context::Kubectl.new.send(:kubeconfig) } 51 | 52 | before do 53 | File.stubs(:exist?).returns(false) 54 | File.stubs(:exist?).with(kubectl_file).returns(true) 55 | Kubeclient::Config.stubs(:read).with(kubectl_file).returns(config) 56 | end 57 | 58 | context "without Google default credentials" do 59 | let(:config) do 60 | OpenStruct.new( 61 | context: OpenStruct.new( 62 | api_endpoint: "https://127.0.0.1:8443", 63 | api_version: "v1", 64 | namespace: nil, 65 | auth_options: {bearer_token: "token"}, 66 | ssl_options: {ca_file: "/path/to/ca.crt"} 67 | ) 68 | ) 69 | end 70 | 71 | it "returns a context from the kubectl configuration" do 72 | expect(context.endpoint).to eq "https://127.0.0.1:8443" 73 | expect(context.version).to eq "v1" 74 | expect(context.namespace).to be_nil 75 | expect(context.options[:auth_options].keys).to eq %i[bearer_token] 76 | expect(context.options[:auth_options][:bearer_token]).to eq("token") 77 | expect(context.options[:ssl_options].keys).to eq %i[ca_file] 78 | expect(context.options[:ssl_options][:ca_file]).to eq("/path/to/ca.crt") 79 | end 80 | end 81 | 82 | context "with Google default credentials" do 83 | let(:config) do 84 | OpenStruct.new( 85 | context: OpenStruct.new( 86 | api_endpoint: "https://127.0.0.1:8443", 87 | api_version: "v1", 88 | namespace: nil, 89 | auth_options: {}, 90 | ssl_options: {} 91 | ) 92 | ) 93 | end 94 | 95 | it "retrieves authentication from the Google application default credentials" do 96 | Kubeclient::GoogleApplicationDefaultCredentials.expects(:token).returns("token") 97 | 98 | expect(context.endpoint).to eq "https://127.0.0.1:8443" 99 | expect(context.version).to eq "v1" 100 | expect(context.namespace).to be_nil 101 | expect(context.options[:auth_options].keys).to eq %i[bearer_token] 102 | expect(context.options[:auth_options][:bearer_token]).to eq("token") 103 | expect(context.options[:ssl_options].keys).to be_empty 104 | end 105 | end 106 | end 107 | end 108 | -------------------------------------------------------------------------------- /spec/cron_kubernetes/scheduler_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe CronKubernetes::Scheduler do 4 | subject { CronKubernetes::Scheduler.instance } 5 | 6 | after do 7 | subject.schedule.clear 8 | end 9 | 10 | shared_examples "common" do 11 | context "when output is configured" do 12 | before do 13 | @output = CronKubernetes.output 14 | CronKubernetes.configuration do |config| 15 | config.output = "2>&1" 16 | end 17 | end 18 | 19 | after do 20 | CronKubernetes.configuration do |config| 21 | config.output = @output 22 | end 23 | end 24 | 25 | it "includes a redirection for the output" do 26 | expect(CronKubernetes.output).to eq "2>&1" 27 | 28 | action 29 | expect(subject.schedule.length).to eq 1 30 | command = subject.schedule.first.command 31 | expect(command.join).to end_with " 2>&1" 32 | end 33 | end 34 | 35 | it "includes the job template" do 36 | expect(CronKubernetes.job_template).to eq %w[/bin/bash -l -c :job] 37 | 38 | action 39 | expect(subject.schedule.length).to eq 1 40 | command = subject.schedule.first.command 41 | expect(command).to start_with %w[/bin/bash -l -c] 42 | end 43 | end 44 | 45 | context "#rake" do 46 | let(:action) { subject.rake("audit:state", schedule: "0 20 1 * *") } 47 | 48 | it "adds a rake task to the cron list" do 49 | action 50 | expect(subject.schedule.length).to eq 1 51 | job = subject.schedule.first 52 | expect(job.schedule).to eq "0 20 1 * *" 53 | expect(job.command.join).to include "bundle exec rake audit:state" 54 | end 55 | 56 | it "properly escapes quotes in the rake task" do 57 | subject.rake("audit:state MAIL_TO='notice@example.com'", schedule: "0 20 1 * *") 58 | expect(subject.schedule.length).to eq 1 59 | command = subject.schedule.first.command 60 | expect(command.join).to include "MAIL_TO='notice@example.com'" 61 | end 62 | 63 | context "when RAILS_ENV is defined" do 64 | before do 65 | @rails_env = ENV["RAILS_ENV"] 66 | ENV["RAILS_ENV"] = "production" 67 | end 68 | 69 | after do 70 | ENV["RAILS_ENV"] = @rails_env 71 | end 72 | 73 | it "includes the RAILS_ENV" do 74 | action 75 | expect(subject.schedule.length).to eq 1 76 | command = subject.schedule.first.command 77 | expect(command.join).to include "RAILS_ENV=production" 78 | end 79 | end 80 | 81 | context "when RAILS_ENV is not defined" do 82 | it "does not include the RAILS_ENV" do 83 | action 84 | expect(subject.schedule.length).to eq 1 85 | command = subject.schedule.first.command 86 | expect(command.join).not_to include "RAILS_ENV" 87 | end 88 | end 89 | 90 | include_examples "common" 91 | end 92 | 93 | context "#runner" do 94 | let(:action) { subject.runner("puts CronKubernetes.name", schedule: "30 3 * * *") } 95 | 96 | it "adds a runner task to the cron list that invokes the code in the block" do 97 | action 98 | expect(subject.schedule.length).to eq 1 99 | job = subject.schedule.first 100 | expect(job.schedule).to eq "30 3 * * *" 101 | expect(job.command.join).to include "bin/rails runner 'puts CronKubernetes.name'" 102 | end 103 | 104 | context "when RAILS_ENV is defined" do 105 | before do 106 | @rails_env = ENV["RAILS_ENV"] 107 | ENV["RAILS_ENV"] = "production" 108 | end 109 | 110 | after do 111 | ENV["RAILS_ENV"] = @rails_env 112 | end 113 | 114 | it "includes the -e parameter" do 115 | action 116 | expect(subject.schedule.length).to eq 1 117 | command = subject.schedule.first.command 118 | expect(command.join).to include " -e production " 119 | end 120 | end 121 | 122 | context "when RAILS_ENV is not defined" do 123 | it "does not include the RAILS_ENV" do 124 | action 125 | expect(subject.schedule.length).to eq 1 126 | command = subject.schedule.first.command 127 | expect(command.join).not_to include " -e " 128 | end 129 | end 130 | 131 | include_examples "common" 132 | end 133 | 134 | context "#command" do 135 | let(:action) { subject.command("ls -l", schedule: "0 1 1 1 *") } 136 | 137 | it "adds any shell command to the cron list" do 138 | action 139 | expect(subject.schedule.length).to eq 1 140 | job = subject.schedule.first 141 | expect(job.schedule).to eq "0 1 1 1 *" 142 | expect(job.command.join).to include "ls -l" 143 | end 144 | 145 | include_examples "common" 146 | end 147 | 148 | end 149 | -------------------------------------------------------------------------------- /spec/cron_kubernetes_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe CronKubernetes do 4 | it "has a version number" do 5 | expect(CronKubernetes::VERSION).not_to be nil 6 | end 7 | 8 | context "::configuration" do 9 | context "manifest" do 10 | it "defaults nothing" do 11 | expect(CronKubernetes.output).to be_nil 12 | end 13 | 14 | context do 15 | before do 16 | @manifest = CronKubernetes.manifest 17 | CronKubernetes.configuration do |config| 18 | config.manifest = {spec: :template} 19 | end 20 | end 21 | 22 | after do 23 | CronKubernetes.configuration do |config| 24 | config.manifest = @manifest 25 | end 26 | end 27 | 28 | it "can be configured for anything" do 29 | expect(CronKubernetes.manifest).to eq(spec: :template) 30 | end 31 | end 32 | end 33 | 34 | context "output" do 35 | it "defaults nothing" do 36 | expect(CronKubernetes.output).to be_nil 37 | end 38 | 39 | context do 40 | before do 41 | @output = CronKubernetes.output 42 | CronKubernetes.configuration do |config| 43 | config.output = ">> log" 44 | end 45 | end 46 | 47 | after do 48 | CronKubernetes.configuration do |config| 49 | config.output = @output 50 | end 51 | end 52 | 53 | it "can be configured for anything" do 54 | expect(CronKubernetes.output).to eq(">> log") 55 | end 56 | end 57 | end 58 | end 59 | 60 | context "job_template" do 61 | it "defaults to a bash shell" do 62 | expect(CronKubernetes.job_template).to eq %w[/bin/bash -l -c :job] 63 | end 64 | 65 | context do 66 | before do 67 | @job_template = CronKubernetes.job_template 68 | CronKubernetes.configuration do |config| 69 | config.job_template = %w[/bin/zsh -c :job] 70 | end 71 | end 72 | 73 | after do 74 | CronKubernetes.configuration do |config| 75 | config.job_template = @job_template 76 | end 77 | end 78 | 79 | it "can be configured for anything" do 80 | expect(CronKubernetes.job_template).to eq(%w[/bin/zsh -c :job]) 81 | end 82 | end 83 | end 84 | 85 | context "::schedule" do 86 | after do 87 | CronKubernetes::Scheduler.instance.schedule.clear 88 | end 89 | 90 | it "invokes the block in a CronKubernetes::Scheduler instance context" do 91 | expect do 92 | CronKubernetes.schedule do 93 | nil 94 | end 95 | end.not_to raise_error 96 | end 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/setup" 4 | require "cron_kubernetes" 5 | 6 | # Requires supporting ruby files with custom matchers and macros, etc, 7 | # in spec/support/ and its subdirectories. 8 | Dir[File.expand_path("support/**/*.rb", __dir__)].sort.each { |f| require f } 9 | 10 | RSpec.configure do |config| 11 | # Enable flags like --only-failures and --next-failure 12 | config.example_status_persistence_file_path = ".rspec_status" 13 | 14 | # Disable RSpec exposing methods globally on `Module` and `main` 15 | config.disable_monkey_patching! 16 | 17 | config.expect_with :rspec do |c| 18 | c.syntax = :expect 19 | end 20 | 21 | config.mock_with :mocha 22 | config.order = "random" 23 | end 24 | --------------------------------------------------------------------------------