floe 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 379fa04920aa1757f0e189fba83310a25f4a2b2f6faa224016c570bc47a114cd
4
- data.tar.gz: d9558845724ac2ba5e0758953239fe582d90e99aa14d2e3dbdce9a51dd874c1a
3
+ metadata.gz: 3a8984568027905b79c95ab066b45f0e87872bbb290022a018f3574d47e3054e
4
+ data.tar.gz: 73390d044e75811f2b1c91acfa1aea1814b38f1d3a60ad4b9bd0eed88d131af0
5
5
  SHA512:
6
- metadata.gz: 22f2964e5eab2f3f710dab683b7540e2c9ef59b5dc4045f573ff8857283467be1855a660f80373bd349e83fb94848c16dd114db9bcfeb7e3e45b89f538cac4ae
7
- data.tar.gz: d4507d552bc4bd4415f7d3efb8620100cac8f70456fe121c014085c82103ece545d2ecb69b720d784749886fca0b16367bcd561884ba7b4740cce23838fc1419
6
+ metadata.gz: b5d6cec81ef1adc60cc5912cc34275c032f250d84361982ac556284ada3c807f3189eef5357c193ae5b76db795f328b761fd53fd5dbfcd3468e5fc439d54c9fe
7
+ data.tar.gz: d4a9ce5beea167b41ec66b1b049c6466a41c91b64076792e87b34e92787e19120965580c638a755c5a1e406398f4b1842851105dad2d388c21b7db76944c7df7
data/.rubocop.yml ADDED
@@ -0,0 +1,4 @@
1
+ inherit_gem:
2
+ manageiq-style: ".rubocop_base.yml"
3
+ inherit_from:
4
+ - ".rubocop_local.yml"
data/.rubocop_cc.yml ADDED
@@ -0,0 +1,4 @@
1
+ inherit_from:
2
+ - ".rubocop_base.yml"
3
+ - ".rubocop_cc_base.yml"
4
+ - ".rubocop_local.yml"
@@ -0,0 +1,2 @@
1
+ Rails:
2
+ Enabled: false
data/.yamllint ADDED
@@ -0,0 +1,8 @@
1
+ ---
2
+ extends: relaxed
3
+
4
+ rules:
5
+ indentation:
6
+ indent-sequences: false
7
+ line-length:
8
+ max: 120
data/CHANGELOG.md CHANGED
@@ -4,8 +4,23 @@ This project adheres to [Semantic Versioning](http://semver.org/).
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.2.0] - 2023-07-05
8
+ ### Added
9
+ - Add ability to pass options to `Floe::Workflow::Runner` (#48)
10
+ - Add kubeconfig file support to `Floe::Workflow::Runner::Kubernetes` (#53)
11
+
12
+ ### Removed
13
+ - Remove to_dot/to_svg code (#54)
14
+
15
+ ### Fixed
16
+ - Fixed default rake task to spec (#55)
17
+
18
+ ## [0.1.1] - 2023-06-05
19
+ ### Fixed
20
+ - Fix States::Wait Path initializer arguments (#47)
21
+
7
22
  ## [0.1.0] - 2023-03-13
8
23
  ### Added
9
24
  - Initial release
10
25
 
11
- [Unreleased]: https://github.com/ManageIQ/manageiq-floe/compare/v0.1.0...HEAD
26
+ [Unreleased]: https://github.com/ManageIQ/floe/compare/v0.1.0...HEAD
data/README.md CHANGED
@@ -1,6 +1,8 @@
1
1
  # Floe
2
2
 
3
3
  [![CI](https://github.com/ManageIQ/floe/actions/workflows/ci.yaml/badge.svg)](https://github.com/ManageIQ/floe/actions/workflows/ci.yaml)
4
+ [![Code Climate](https://codeclimate.com/github/ManageIQ/floe.svg)](https://codeclimate.com/github/ManageIQ/floe)
5
+ [![Test Coverage](https://codeclimate.com/github/ManageIQ/floe/badges/coverage.svg)](https://codeclimate.com/github/ManageIQ/floe/coverage)
4
6
 
5
7
  ## Overview
6
8
 
@@ -26,15 +28,49 @@ Floe can be run as a command-line utility or as a ruby class.
26
28
  bundle exec ruby exe/floe --workflow examples/workflow.asl --inputs='{"foo": 1}'
27
29
  ```
28
30
 
31
+ By default Floe will use `docker` to run `docker://` type resources, but `podman` and `kubernetes` are also supported runners.
32
+ A different runner can be specified with the `--docker-runner` option:
33
+
34
+ ```
35
+ bundle exec ruby exe/floe --workflow examples/workflow.asl --inputs='{"foo": 1}' --docker-runner podman
36
+ bundle exec ruby exe/floe --workflow examples/workflow.asl --inputs='{"foo": 1}' --docker-runner kubernetes --docker-runner-options namespace=default server=https://k8s.example.com:6443 token=my-token
37
+ ```
38
+
29
39
  ### Ruby Library
30
40
 
31
41
  ```ruby
32
42
  require 'floe'
33
43
 
34
- workflow = Floe::Workflow.load(File.read("workflow.json"))
44
+ workflow = Floe::Workflow.load(File.read("workflow.asl"))
35
45
  workflow.run!
36
46
  ```
37
47
 
48
+ You can also specify a specific docker runner and runner options:
49
+ ```ruby
50
+ require 'floe'
51
+
52
+ Floe::Workflow::Runner.docker_runner = Floe::Workflow::Runner::Podman.new
53
+ # Or
54
+ Floe::Workflow::Runner.docker_runner = Floe::Workflow::Runner::Kubernetes.new("namespace" => "default", "server" => "https://k8s.example.com:6443", "token" => "my-token")
55
+
56
+ workflow = Floe::Workflow.load(File.read("workflow.asl"))
57
+ workflow.run!
58
+ ```
59
+
60
+ ### Docker Runner Options
61
+
62
+ #### Kubernetes
63
+
64
+ Options supported by the kubernetes docker runner are:
65
+
66
+ * `kubeconfig` - Path to a kubeconfig file, defaults to `KUBECONFIG` environment variable or `~/.kube/config`
67
+ * `kubeconfig_context` - Context to use in the kubeconfig file, defaults to `"default"`
68
+ * `namespace` - Namespace to use when creating kubernetes resources, defaults to `"default"`
69
+ * `server` - A kubernetes API Server URL, overrides anything in your kubeconfig file. If set `KUBERNETES_SERVICE_HOST` and `KUBERNETES_SERVICE_PORT` will be used
70
+ * `token` - A bearer_token to use to authenticate to the kubernetes API, overrides anything in your kubeconfig file. If present, `/run/secrets/kubernetes.io/serviceaccount/token` will be used
71
+ * `ca_file` - Path to a certificate-authority file for the kubernetes API, only valid if server and token are passed. If present `/run/secrets/kubernetes.io/serviceaccount/ca.crt` will be used
72
+ * `verify_ssl` - Controls if the kubernetes API certificate-authority should be verified, defaults to "true", only vaild if server and token are passed
73
+
38
74
  ## Development
39
75
 
40
76
  After checking out the repo, run `bin/setup` to install dependencies. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
data/Rakefile CHANGED
@@ -1,4 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "bundler/gem_tasks"
4
- task default: %i[]
4
+
5
+ require "rspec/core/rake_task"
6
+ RSpec::Core::RakeTask.new("spec")
7
+ task :default => :spec
@@ -66,6 +66,12 @@
66
66
  "bar": "baz"
67
67
  },
68
68
  "ResultPath": "$.result",
69
+ "Next": "WaitState"
70
+ },
71
+
72
+ "WaitState": {
73
+ "Type": "Wait",
74
+ "Seconds": 1,
69
75
  "Next": "NextState"
70
76
  },
71
77
 
data/exe/floe CHANGED
@@ -5,17 +5,35 @@ require "floe"
5
5
  require "optimist"
6
6
 
7
7
  opts = Optimist.options do
8
- version "v#{Floe::VERSION}\n"
9
- opt :workflow, "Path to your workflow json", :type => :string, :required => true
10
- opt :inputs, "JSON payload to input to the workflow", :type => :string, :default => '{}'
11
- opt :credentials, "JSON payload with credentials", :type => :string, :default => '{}'
8
+ version("v#{Floe::VERSION}\n")
9
+ opt :workflow, "Path to your workflow json", :type => :string, :required => true
10
+ opt :input, "JSON payload to input to the workflow", :default => '{}'
11
+ opt :credentials, "JSON payload with credentials", :default => "{}"
12
+ opt :docker_runner, "Type of runner for docker images", :default => "docker"
13
+ opt :docker_runner_options, "Options to pass to the runner", :type => :strings
12
14
  end
13
15
 
16
+ Optimist.die(:docker_runner, "must be one of #{Floe::Workflow::Runner::TYPES.join(", ")}") unless Floe::Workflow::Runner::TYPES.include?(opts[:docker_runner])
17
+
14
18
  require "logger"
15
- Floe.logger = Logger.new(STDOUT)
19
+ Floe.logger = Logger.new($stdout)
20
+
21
+ context = Floe::Workflow::Context.new(input: opts[:input])
22
+ workflow = Floe::Workflow.load(opts[:workflow], context, opts[:credentials])
23
+
24
+ runner_klass = case opts[:docker_runner]
25
+ when "docker"
26
+ Floe::Workflow::Runner::Docker
27
+ when "podman"
28
+ Floe::Workflow::Runner::Podman
29
+ when "kubernetes"
30
+ Floe::Workflow::Runner::Kubernetes
31
+ end
32
+
33
+ runner_options = opts[:docker_runner_options].to_h { |opt| opt.split("=", 2) }
16
34
 
17
- workflow = Floe::Workflow.load(opts[:workflow], opts[:inputs], opts[:credentials])
35
+ Floe::Workflow::Runner.docker_runner = runner_klass.new(runner_options)
18
36
 
19
- output = workflow.run!
37
+ workflow.run!
20
38
 
21
- puts output.inspect
39
+ puts workflow.context.state["Output"].inspect
data/floe.gemspec CHANGED
@@ -31,9 +31,11 @@ Gem::Specification.new do |spec|
31
31
 
32
32
  spec.add_dependency "awesome_spawn", "~>1.0"
33
33
  spec.add_dependency "jsonpath", "~>1.1"
34
- spec.add_dependency "optimist", "~>3.0"
34
+ spec.add_dependency "kubeclient", "~>4.7"
35
35
  spec.add_dependency "more_core_extensions"
36
+ spec.add_dependency "optimist", "~>3.0"
36
37
 
37
- spec.add_development_dependency "rubocop"
38
+ spec.add_development_dependency "manageiq-style"
38
39
  spec.add_development_dependency "rspec"
40
+ spec.add_development_dependency "rubocop"
39
41
  end
@@ -4,7 +4,7 @@ require 'logger'
4
4
 
5
5
  module Floe
6
6
  class NullLogger < Logger
7
- def initialize(*_args)
7
+ def initialize(*)
8
8
  end
9
9
 
10
10
  def add(*_args, &_block)
data/lib/floe/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Floe
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
@@ -5,19 +5,18 @@ module Floe
5
5
  class ChoiceRule
6
6
  class Data < Floe::Workflow::ChoiceRule
7
7
  def true?(context, input)
8
-
9
8
  lhs = variable_value(context, input)
10
9
  rhs = compare_value(context, input)
11
10
 
12
11
  validate!(lhs)
13
12
 
14
13
  case compare_key
15
- when "IsNull"; is_null?(lhs)
16
- when "IsPresent"; is_present?(lhs)
17
- when "IsNumeric"; is_numeric?(lhs)
18
- when "IsString"; is_string?(lhs)
19
- when "IsBoolean"; is_boolean?(lhs)
20
- when "IsTimestamp"; is_timestamp?(lhs)
14
+ when "IsNull" then is_null?(lhs)
15
+ when "IsPresent" then is_present?(lhs)
16
+ when "IsNumeric" then is_numeric?(lhs)
17
+ when "IsString" then is_string?(lhs)
18
+ when "IsBoolean" then is_boolean?(lhs)
19
+ when "IsTimestamp" then is_timestamp?(lhs)
21
20
  when "StringEquals", "StringEqualsPath",
22
21
  "NumericEquals", "NumericEqualsPath",
23
22
  "BooleanEquals", "BooleanEqualsPath",
@@ -40,7 +39,7 @@ module Floe
40
39
  "TimestampGreaterThanEquals", "TimestampGreaterThanEqualsPath"
41
40
  lhs >= rhs
42
41
  when "StringMatches"
43
- lhs.match?(Regexp.escape(rhs).gsub('\*','.*?'))
42
+ lhs.match?(Regexp.escape(rhs).gsub('\*', '.*?'))
44
43
  else
45
44
  raise Floe::InvalidWorkflowError, "Invalid choice [#{compare_key}]"
46
45
  end
@@ -49,7 +48,7 @@ module Floe
49
48
  private
50
49
 
51
50
  def validate!(value)
52
- raise RuntimeError, "No such variable [#{variable}]" if value.nil? && !%w[IsNull IsPresent].include?(compare_key)
51
+ raise "No such variable [#{variable}]" if value.nil? && !%w[IsNull IsPresent].include?(compare_key)
53
52
  end
54
53
 
55
54
  def is_null?(value)
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Floe
4
+ class Workflow
5
+ class Context
6
+ def initialize(context = nil, input: {})
7
+ context = JSON.parse(context) if context.kind_of?(String)
8
+
9
+ @context = context || {
10
+ "Execution" => {
11
+ "Input" => input
12
+ },
13
+ "State" => {},
14
+ "States" => [],
15
+ "StateMachine" => {},
16
+ "Task" => {}
17
+ }
18
+ end
19
+
20
+ def execution
21
+ @context["Execution"]
22
+ end
23
+
24
+ def state
25
+ @context["State"]
26
+ end
27
+
28
+ def states
29
+ @context["States"]
30
+ end
31
+
32
+ def state_machine
33
+ @context["StateMachine"]
34
+ end
35
+
36
+ def task
37
+ @context["Task"]
38
+ end
39
+
40
+ def [](key)
41
+ @context[key]
42
+ end
43
+
44
+ def []=(key, val)
45
+ @context[key] = val
46
+ end
47
+
48
+ def dig(*args)
49
+ @context.dig(*args)
50
+ end
51
+
52
+ def to_h
53
+ @context
54
+ end
55
+ end
56
+ end
57
+ end
@@ -4,7 +4,7 @@ module Floe
4
4
  class Workflow
5
5
  class ReferencePath < Path
6
6
  class << self
7
- def set (payload, context, value)
7
+ def set(payload, context, value)
8
8
  new(payload).set(context, value)
9
9
  end
10
10
  end
@@ -24,7 +24,7 @@ module Floe
24
24
  path = JsonPath.new(payload)
25
25
  .path[1..]
26
26
  .map { |v| v.match(/\[(?<name>.+)\]/)["name"] }
27
- .map { |v| v[0] == "'" ? v.gsub("'", "") : v.to_i }
27
+ .map { |v| v[0] == "'" ? v.delete("'") : v.to_i }
28
28
  .compact
29
29
 
30
30
  # If the payload is '$' then merge the value into the context
@@ -4,15 +4,36 @@ module Floe
4
4
  class Workflow
5
5
  class Runner
6
6
  class Kubernetes < Floe::Workflow::Runner
7
- attr_reader :namespace
7
+ TOKEN_FILE = "/run/secrets/kubernetes.io/serviceaccount/token"
8
+ CA_CERT_FILE = "/run/secrets/kubernetes.io/serviceaccount/ca.crt"
8
9
 
9
- def initialize(*)
10
+ def initialize(options = {})
10
11
  require "awesome_spawn"
11
12
  require "securerandom"
12
13
  require "base64"
14
+ require "kubeclient"
13
15
  require "yaml"
14
16
 
15
- @namespace = ENV.fetch("DOCKER_RUNNER_NAMESPACE", "default")
17
+ @kubeconfig_file = ENV.fetch("KUBECONFIG", nil) || options.fetch("kubeconfig", File.join(Dir.home, ".kube", "config"))
18
+ @kubeconfig_context = options["kubeconfig_context"]
19
+
20
+ @token = options["token"]
21
+ @token ||= File.read(options["token_file"]) if options.key?("token_file")
22
+ @token ||= File.read(TOKEN_FILE) if File.exist?(TOKEN_FILE)
23
+
24
+ @server = options["server"]
25
+ @server ||= URI::HTTPS.build(:host => ENV.fetch("KUBERNETES_SERVICE_HOST"), :port => ENV.fetch("KUBERNETES_SERVICE_PORT", 6443)) if ENV.key?("KUBERNETES_SERVICE_HOST")
26
+
27
+ @ca_file = options["ca_file"]
28
+ @ca_file ||= CA_CERT_FILE if File.exist?(CA_CERT_FILE)
29
+
30
+ @verify_ssl = options["verify_ssl"] == "false" ? OpenSSL::SSL::VERIFY_NONE : OpenSSL::SSL::VERIFY_PEER
31
+
32
+ if server.nil? && token.nil? && !File.exist?(kubeconfig_file)
33
+ raise ArgumentError, "Missing connections options, provide a kubeconfig file or pass server and token via --docker-runner-options"
34
+ end
35
+
36
+ @namespace = options.fetch("namespace", "default")
16
37
 
17
38
  super
18
39
  end
@@ -20,25 +41,52 @@ module Floe
20
41
  def run!(resource, env = {}, secrets = {})
21
42
  raise ArgumentError, "Invalid resource" unless resource&.start_with?("docker://")
22
43
 
23
- image = resource.sub("docker://", "")
24
- name = pod_name(image)
25
- secret = create_secret!(secrets) unless secrets&.empty?
26
- overrides = pod_spec(image, env, secret)
44
+ image = resource.sub("docker://", "")
45
+ name = pod_name(image)
46
+ secret = create_secret!(secrets) if secrets && !secrets.empty?
47
+
48
+ begin
49
+ create_pod!(name, image, env, secret)
50
+ while running?(name)
51
+ sleep(1)
52
+ end
53
+
54
+ exit_status = success?(name) ? 0 : 1
55
+ results = output(name)
27
56
 
28
- result = kubectl_run!(image, name, overrides)
57
+ [exit_status, results]
58
+ ensure
59
+ cleanup(name, secret)
60
+ end
61
+ end
29
62
 
30
- # Kubectl prints that the pod was deleted, strip this from the output
31
- output = result.output.gsub(/pod \"#{name}\" deleted/, "")
63
+ def running?(pod_name)
64
+ %w[Pending Running].include?(pod_info(pod_name).dig("status", "phase"))
65
+ end
66
+
67
+ def success?(pod_name)
68
+ pod_info(pod_name).dig("status", "phase") == "Succeeded"
69
+ end
32
70
 
33
- [result.exit_status, output]
34
- ensure
35
- delete_secret!(secret) if secret
71
+ def output(pod)
72
+ kubeclient.get_pod_log(pod, namespace).body
73
+ end
74
+
75
+ def cleanup(pod, secret)
76
+ delete_pod(pod) if pod
77
+ delete_secret(secret) if secret
36
78
  end
37
79
 
38
80
  private
39
81
 
82
+ attr_reader :ca_file, :kubeconfig_file, :kubeconfig_context, :namespace, :server, :token, :verify_ssl
83
+
84
+ def pod_info(pod_name)
85
+ kubeclient.get_pod(pod_name, namespace)
86
+ end
87
+
40
88
  def container_name(image)
41
- image.match(%r{^(?<repository>.+\/)?(?<image>.+):(?<tag>.+)$})&.named_captures&.dig("image")
89
+ image.match(%r{^(?<repository>.+/)?(?<image>.+):(?<tag>.+)$})&.named_captures&.dig("image")
42
90
  end
43
91
 
44
92
  def pod_name(image)
@@ -48,23 +96,44 @@ module Floe
48
96
  "#{container_short_name}-#{SecureRandom.uuid}"
49
97
  end
50
98
 
51
- def pod_spec(image, env, secret = nil)
52
- container_spec = {
53
- "name" => container_name(image),
54
- "image" => image,
55
- "env" => env.to_h.map { |k, v| {"name" => k, "value" => v} }
99
+ def pod_spec(name, image, env, secret = nil)
100
+ spec = {
101
+ :kind => "Pod",
102
+ :apiVersion => "v1",
103
+ :metadata => {
104
+ :name => name,
105
+ :namespace => namespace
106
+ },
107
+ :spec => {
108
+ :containers => [
109
+ {
110
+ :name => container_name(image),
111
+ :image => image,
112
+ :env => env.map { |k, v| {:name => k, :value => v.to_s} }
113
+ }
114
+ ],
115
+ :restartPolicy => "Never"
116
+ }
56
117
  }
57
118
 
58
- spec = {"spec" => {"containers" => [container_spec]}}
59
-
60
119
  if secret
61
- spec["spec"]["volumes"] = [{"name" => "secret-volume", "secret" => {"secretName" => secret}}]
62
- container_spec["env"] << {"name" => "SECRETS", "value" => "/run/secrets/#{secret}/secret"}
63
- container_spec["volumeMounts"] = [
120
+ spec[:spec][:volumes] = [
64
121
  {
65
- "name" => "secret-volume",
66
- "mountPath" => "/run/secrets/#{secret}",
67
- "readOnly" => true
122
+ :name => "secret-volume",
123
+ :secret => {:secretName => secret}
124
+ }
125
+ ]
126
+
127
+ spec[:spec][:containers][0][:env] << {
128
+ :name => "SECRETS",
129
+ :value => "/run/secrets/#{secret}/secret"
130
+ }
131
+
132
+ spec[:spec][:containers][0][:volumeMounts] = [
133
+ {
134
+ :name => "secret-volume",
135
+ :mountPath => "/run/secrets/#{secret}",
136
+ :readOnly => true
68
137
  }
69
138
  ]
70
139
  end
@@ -72,45 +141,75 @@ module Floe
72
141
  spec
73
142
  end
74
143
 
144
+ def create_pod!(name, image, env, secret = nil)
145
+ kubeclient.create_pod(pod_spec(name, image, env, secret))
146
+ end
147
+
148
+ def delete_pod!(name)
149
+ kubeclient.delete_pod(name, namespace)
150
+ end
151
+
152
+ def delete_pod(name)
153
+ delete_pod!(name)
154
+ rescue
155
+ nil
156
+ end
157
+
75
158
  def create_secret!(secrets)
76
159
  secret_name = SecureRandom.uuid
77
160
 
78
- secret_yaml = {
79
- "kind" => "Secret",
80
- "apiVersion" => "v1",
81
- "metadata" => {
82
- "name" => secret_name,
83
- "namespace" => namespace
161
+ secret_config = {
162
+ :kind => "Secret",
163
+ :apiVersion => "v1",
164
+ :metadata => {
165
+ :name => secret_name,
166
+ :namespace => namespace
84
167
  },
85
- "data" => {
86
- "secret" => Base64.urlsafe_encode64(secrets.to_json)
168
+ :data => {
169
+ :secret => Base64.urlsafe_encode64(secrets.to_json)
87
170
  },
88
- "type" => "Opaque"
89
- }.to_yaml
171
+ :type => "Opaque"
172
+ }
90
173
 
91
- kubectl!("create", "-f", "-", :in_data => secret_yaml)
174
+ kubeclient.create_secret(secret_config)
92
175
 
93
176
  secret_name
94
177
  end
95
178
 
96
179
  def delete_secret!(secret_name)
97
- kubectl!("delete", "secret", secret_name, [:namespace, namespace])
180
+ kubeclient.delete_secret(secret_name, namespace)
98
181
  end
99
182
 
100
- def kubectl!(*params, **kwargs)
101
- AwesomeSpawn.run!("kubectl", :params => params, **kwargs)
183
+ def delete_secret(name)
184
+ delete_secret!(name)
185
+ rescue
186
+ nil
102
187
  end
103
188
 
104
- def kubectl_run!(image, name, overrides = nil)
105
- params = [
106
- "run", :rm, :attach, [:image, image], [:restart, "Never"], [:namespace, namespace], name
107
- ]
189
+ def kubeclient
190
+ return @kubeclient unless @kubeclient.nil?
191
+
192
+ if server && token
193
+ api_endpoint = server
194
+ auth_options = {:bearer_token => token}
195
+ ssl_options = {:verify_ssl => verify_ssl}
196
+ ssl_options[:ca_file] = ca_file if ca_file
197
+ else
198
+ context = kubeconfig&.context(kubeconfig_context)
199
+ raise ArgumentError, "Missing connections options, provide a kubeconfig file or pass server and token via --docker-runner-options" if context.nil?
200
+
201
+ api_endpoint = context.api_endpoint
202
+ auth_options = context.auth_options
203
+ ssl_options = context.ssl_options
204
+ end
108
205
 
109
- params << "--overrides=#{overrides.to_json}" if overrides
206
+ @kubeclient = Kubeclient::Client.new(api_endpoint, "v1", :ssl_options => ssl_options, :auth_options => auth_options).tap(&:discover)
207
+ end
110
208
 
111
- logger.debug("Running kubectl: #{AwesomeSpawn.build_command_line("kubectl", params)}")
209
+ def kubeconfig
210
+ return if kubeconfig_file.nil? || !File.exist?(kubeconfig_file)
112
211
 
113
- kubectl!(*params)
212
+ Kubeclient::Config.read(kubeconfig_file)
114
213
  end
115
214
  end
116
215
  end
@@ -5,11 +5,16 @@ module Floe
5
5
  class Runner
6
6
  include Logging
7
7
 
8
+ TYPES = %w[docker podman kubernetes].freeze
9
+
10
+ def initialize(_options = {})
11
+ end
12
+
8
13
  class << self
9
- attr_writer :docker_runner_klass
14
+ attr_writer :docker_runner
10
15
 
11
- def docker_runner_klass
12
- @docker_runner_klass ||= const_get(ENV.fetch("DOCKER_RUNNER", "docker").capitalize)
16
+ def docker_runner
17
+ @docker_runner ||= Floe::Workflow::Runner::Docker.new
13
18
  end
14
19
 
15
20
  def for_resource(resource)
@@ -18,7 +23,7 @@ module Floe
18
23
  scheme = resource.split("://").first
19
24
  case scheme
20
25
  when "docker"
21
- docker_runner_klass.new
26
+ docker_runner
22
27
  else
23
28
  raise "Invalid resource scheme [#{scheme}]"
24
29
  end
@@ -38,6 +38,10 @@ module Floe
38
38
  workflow.context
39
39
  end
40
40
 
41
+ def status
42
+ end? ? "success" : "running"
43
+ end
44
+
41
45
  def run!(input)
42
46
  logger.info("Running state: [#{name}] with input [#{input}]")
43
47
 
@@ -53,24 +57,6 @@ module Floe
53
57
 
54
58
  [next_state, output]
55
59
  end
56
-
57
- def to_dot
58
- String.new.tap do |s|
59
- s << " #{name}"
60
-
61
- attributes = to_dot_attributes
62
- s << " [ #{attributes.to_a.map { |kv| kv.join("=") }.join(" ")} ]" unless attributes.empty?
63
- end
64
- end
65
-
66
- private def to_dot_attributes
67
- end? ? {:style => "bold"} : {}
68
- end
69
-
70
- def to_dot_transitions
71
- next_state_name = payload["Next"] unless end?
72
- Array(next_state_name && " #{name} -> #{next_state_name}")
73
- end
74
60
  end
75
61
  end
76
62
  end
@@ -26,27 +26,6 @@ module Floe
26
26
  [output, next_state]
27
27
  end
28
28
  end
29
-
30
- private def to_dot_attributes
31
- super.merge(:shape => "diamond")
32
- end
33
-
34
- def to_dot_transitions
35
- [].tap do |a|
36
- choices.each do |choice|
37
- choice_label =
38
- if choice.payload["NumericEquals"]
39
- "#{choice.variable} == #{choice.payload["NumericEquals"]}"
40
- else
41
- "Unknown" # TODO
42
- end
43
-
44
- a << " #{name} -> #{choice.next} [ label=#{choice_label.inspect} ]"
45
- end
46
-
47
- a << " #{name} -> #{default} [ label=\"Default\" ]" if default
48
- end
49
- end
50
29
  end
51
30
  end
52
31
  end
@@ -28,8 +28,8 @@ module Floe
28
28
  true
29
29
  end
30
30
 
31
- private def to_dot_attributes
32
- super.merge(:color => "red")
31
+ def status
32
+ "errored"
33
33
  end
34
34
  end
35
35
  end
@@ -16,10 +16,6 @@ module Floe
16
16
  def end?
17
17
  true # TODO: Handle if this is ending a parallel or map state
18
18
  end
19
-
20
- private def to_dot_attributes
21
- super.merge(:color => "green")
22
- end
23
19
  end
24
20
  end
25
21
  end
@@ -51,15 +51,16 @@ module Floe
51
51
  return if retrier.nil?
52
52
 
53
53
  # If a different retrier is hit reset the context
54
- if !context.key?("retrier") || context["retrier"]["error_equals"] != retrier.error_equals
55
- context["retrier"] = {"error_equals" => retrier.error_equals, "retry_count" => 0}
54
+ if !context["State"].key?("RetryCount") || context["State"]["Retrier"] != retrier.error_equals
55
+ context["State"]["RetryCount"] = 0
56
+ context["State"]["Retrier"] = retrier.error_equals
56
57
  end
57
58
 
58
- context["retrier"]["retry_count"] += 1
59
+ context["State"]["RetryCount"] += 1
59
60
 
60
- return if context["retrier"]["retry_count"] > retrier.max_attempts
61
+ return if context["State"]["RetryCount"] > retrier.max_attempts
61
62
 
62
- Kernel.sleep(retrier.sleep_duration(context["retrier"]["retry_count"]))
63
+ Kernel.sleep(retrier.sleep_duration(context["State"]["RetryCount"]))
63
64
  true
64
65
  end
65
66
 
@@ -12,12 +12,15 @@ module Floe
12
12
  @next = payload["Next"]
13
13
  @seconds = payload["Seconds"].to_i
14
14
 
15
- @input_path = Path.new(payload.fetch("InputPath", "$"), context)
16
- @output_path = Path.new(payload.fetch("OutputPath", "$"), context)
15
+ @input_path = Path.new(payload.fetch("InputPath", "$"))
16
+ @output_path = Path.new(payload.fetch("OutputPath", "$"))
17
17
  end
18
18
 
19
19
  def run!(*)
20
- super { sleep(seconds); nil }
20
+ super do
21
+ sleep(seconds)
22
+ nil
23
+ end
21
24
  end
22
25
  end
23
26
  end
data/lib/floe/workflow.rb CHANGED
@@ -5,78 +5,74 @@ require "json"
5
5
  module Floe
6
6
  class Workflow
7
7
  class << self
8
- def load(path_or_io, context = {}, credentials = {})
8
+ def load(path_or_io, context = nil, credentials = {})
9
9
  payload = path_or_io.respond_to?(:read) ? path_or_io.read : File.read(path_or_io)
10
10
  new(payload, context, credentials)
11
11
  end
12
12
  end
13
13
 
14
- attr_reader :context, :credentials, :first_state, :payload, :states, :states_by_name, :start_at
14
+ attr_reader :context, :credentials, :payload, :states, :states_by_name, :current_state, :status
15
15
 
16
- def initialize(payload, context = {}, credentials = {})
16
+ def initialize(payload, context = nil, credentials = {})
17
17
  payload = JSON.parse(payload) if payload.kind_of?(String)
18
18
  context = JSON.parse(context) if context.kind_of?(String)
19
19
  credentials = JSON.parse(credentials) if credentials.kind_of?(String)
20
+ context = Context.new(context) unless context.kind_of?(Context)
21
+
22
+ @payload = payload
23
+ @context = context || {"global" => {}}
24
+ @credentials = credentials
20
25
 
21
- @payload = payload
22
- @context = context
23
- @credentials = credentials
24
26
  @states = payload["States"].to_a.map { |name, state| State.build!(self, name, state) }
25
- @states_by_name = states.to_h { |state| [state.name, state] }
26
- @start_at = @payload["StartAt"]
27
- @first_state = @states_by_name[@start_at]
27
+ @states_by_name = @states.each_with_object({}) { |state, result| result[state.name] = state }
28
+ start_at = @payload["StartAt"]
29
+
30
+ current_state_name = @context["State"]["Name"] || start_at
31
+ @current_state = @states_by_name[current_state_name]
32
+
33
+ @status = current_state_name == start_at ? "pending" : current_state.status
28
34
  rescue JSON::ParserError => err
29
35
  raise Floe::InvalidWorkflowError, err.message
30
36
  end
31
37
 
32
- def run!
33
- state = first_state
34
- input = context.dup
38
+ def step
39
+ @status = "running" if @status == "pending"
40
+ @context["Execution"]["StartTime"] ||= Time.now.utc
35
41
 
36
- until state.nil?
37
- state, output = state.run!(input)
38
- input = output
39
- end
42
+ input = @context["State"]["Output"] || @context["Execution"]["Input"].dup
40
43
 
41
- output
42
- end
44
+ tick = Process.clock_gettime(Process::CLOCK_MONOTONIC)
45
+ next_state, output = current_state.run!(input)
46
+ tock = Process.clock_gettime(Process::CLOCK_MONOTONIC)
43
47
 
44
- def to_dot
45
- String.new.tap do |s|
46
- s << "digraph {\n"
47
- states.each do |state|
48
- s << state.to_dot << "\n"
49
- end
50
- s << "\n"
51
- states.each do |state|
52
- Array(state.to_dot_transitions).each do |transition|
53
- s << transition << "\n"
54
- end
55
- end
56
- s << "}\n"
57
- end
58
- end
48
+ @context["State"] = {
49
+ "EnteredTime" => tick,
50
+ "FinishedTime" => tock,
51
+ "Duration" => tock - tick,
52
+ "Output" => output,
53
+ "Name" => next_state&.name,
54
+ "Input" => output
55
+ }
59
56
 
60
- def to_svg(path: nil)
61
- require "open3"
62
- out, err, _status = Open3.capture3("dot -Tsvg", :stdin_data => to_dot)
57
+ @context["States"] << @context["State"]
63
58
 
64
- raise "Error from graphviz:\n#{err}" if err && !err.empty?
59
+ @status = current_state.status
65
60
 
66
- File.write(path, out) if path
61
+ next_state_name = next_state&.name
62
+ @current_state = next_state_name && @states_by_name[next_state_name]
67
63
 
68
- out
64
+ self
69
65
  end
70
66
 
71
- def to_ascii(path: nil)
72
- require "open3"
73
- out, err, _status = Open3.capture3("graph-easy", :stdin_data => to_dot)
74
-
75
- raise "Error from graph-easy:\n#{err}" if err && !err.empty?
76
-
77
- File.write(path, out) if path
67
+ def run!
68
+ until end?
69
+ step
70
+ end
71
+ self
72
+ end
78
73
 
79
- out
74
+ def end?
75
+ current_state.nil?
80
76
  end
81
77
  end
82
78
  end
data/lib/floe.rb CHANGED
@@ -10,6 +10,7 @@ require_relative "floe/workflow/catcher"
10
10
  require_relative "floe/workflow/choice_rule"
11
11
  require_relative "floe/workflow/choice_rule/boolean"
12
12
  require_relative "floe/workflow/choice_rule/data"
13
+ require_relative "floe/workflow/context"
13
14
  require_relative "floe/workflow/path"
14
15
  require_relative "floe/workflow/payload_template"
15
16
  require_relative "floe/workflow/reference_path"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: floe
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - ManageIQ Developers
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2023-03-23 00:00:00.000000000 Z
11
+ date: 2023-07-06 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: awesome_spawn
@@ -39,19 +39,19 @@ dependencies:
39
39
  - !ruby/object:Gem::Version
40
40
  version: '1.1'
41
41
  - !ruby/object:Gem::Dependency
42
- name: optimist
42
+ name: kubeclient
43
43
  requirement: !ruby/object:Gem::Requirement
44
44
  requirements:
45
45
  - - "~>"
46
46
  - !ruby/object:Gem::Version
47
- version: '3.0'
47
+ version: '4.7'
48
48
  type: :runtime
49
49
  prerelease: false
50
50
  version_requirements: !ruby/object:Gem::Requirement
51
51
  requirements:
52
52
  - - "~>"
53
53
  - !ruby/object:Gem::Version
54
- version: '3.0'
54
+ version: '4.7'
55
55
  - !ruby/object:Gem::Dependency
56
56
  name: more_core_extensions
57
57
  requirement: !ruby/object:Gem::Requirement
@@ -67,7 +67,21 @@ dependencies:
67
67
  - !ruby/object:Gem::Version
68
68
  version: '0'
69
69
  - !ruby/object:Gem::Dependency
70
- name: rubocop
70
+ name: optimist
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '3.0'
76
+ type: :runtime
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '3.0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: manageiq-style
71
85
  requirement: !ruby/object:Gem::Requirement
72
86
  requirements:
73
87
  - - ">="
@@ -94,6 +108,20 @@ dependencies:
94
108
  - - ">="
95
109
  - !ruby/object:Gem::Version
96
110
  version: '0'
111
+ - !ruby/object:Gem::Dependency
112
+ name: rubocop
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - ">="
116
+ - !ruby/object:Gem::Version
117
+ version: '0'
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - ">="
123
+ - !ruby/object:Gem::Version
124
+ version: '0'
97
125
  description: Simple Workflow Runner.
98
126
  email:
99
127
  executables:
@@ -102,6 +130,10 @@ extensions: []
102
130
  extra_rdoc_files: []
103
131
  files:
104
132
  - ".rspec"
133
+ - ".rubocop.yml"
134
+ - ".rubocop_cc.yml"
135
+ - ".rubocop_local.yml"
136
+ - ".yamllint"
105
137
  - CHANGELOG.md
106
138
  - Gemfile
107
139
  - README.md
@@ -118,6 +150,7 @@ files:
118
150
  - lib/floe/workflow/choice_rule.rb
119
151
  - lib/floe/workflow/choice_rule/boolean.rb
120
152
  - lib/floe/workflow/choice_rule/data.rb
153
+ - lib/floe/workflow/context.rb
121
154
  - lib/floe/workflow/path.rb
122
155
  - lib/floe/workflow/payload_template.rb
123
156
  - lib/floe/workflow/reference_path.rb