floe 0.3.0 → 0.4.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 +4 -4
- data/CHANGELOG.md +12 -1
- data/Gemfile +3 -0
- data/README.md +57 -3
- data/exe/floe +3 -3
- data/floe.gemspec +0 -4
- data/lib/floe/null_logger.rb +1 -1
- data/lib/floe/version.rb +1 -1
- data/lib/floe/workflow/choice_rule/and.rb +13 -0
- data/lib/floe/workflow/choice_rule/data.rb +6 -6
- data/lib/floe/workflow/choice_rule/not.rb +14 -0
- data/lib/floe/workflow/choice_rule/or.rb +13 -0
- data/lib/floe/workflow/choice_rule.rb +16 -12
- data/lib/floe/workflow/context.rb +51 -3
- data/lib/floe/workflow/payload_template.rb +56 -15
- data/lib/floe/workflow/runner/docker.rb +84 -15
- data/lib/floe/workflow/runner/kubernetes.rb +47 -15
- data/lib/floe/workflow/runner/podman.rb +119 -14
- data/lib/floe/workflow/runner.rb +21 -1
- data/lib/floe/workflow/state.rb +60 -0
- data/lib/floe/workflow/states/choice.rb +6 -4
- data/lib/floe/workflow/states/fail.rb +8 -4
- data/lib/floe/workflow/states/map.rb +1 -0
- data/lib/floe/workflow/states/parallel.rb +1 -0
- data/lib/floe/workflow/states/pass.rb +6 -4
- data/lib/floe/workflow/states/succeed.rb +6 -4
- data/lib/floe/workflow/states/task.rb +61 -16
- data/lib/floe/workflow/states/wait.rb +13 -6
- data/lib/floe/workflow.rb +60 -36
- data/lib/floe.rb +3 -1
- metadata +5 -45
- data/lib/floe/workflow/choice_rule/boolean.rb +0 -19
@@ -46,33 +46,65 @@ module Floe
|
|
46
46
|
secret = create_secret!(secrets) if secrets && !secrets.empty?
|
47
47
|
|
48
48
|
begin
|
49
|
+
runner_context = {"container_ref" => name}
|
50
|
+
|
49
51
|
create_pod!(name, image, env, secret)
|
50
|
-
|
51
|
-
|
52
|
+
loop do
|
53
|
+
case pod_info(name).dig("status", "phase")
|
54
|
+
when "Pending", "Running"
|
55
|
+
sleep(1)
|
56
|
+
else # also "Succeeded"
|
57
|
+
runner_context["exit_code"] = 0
|
58
|
+
output(runner_context)
|
59
|
+
break
|
60
|
+
end
|
52
61
|
end
|
53
62
|
|
54
|
-
|
55
|
-
results = output(name)
|
56
|
-
|
57
|
-
[exit_status, results]
|
63
|
+
runner_context
|
58
64
|
ensure
|
59
|
-
cleanup(name, secret)
|
65
|
+
cleanup({"container_ref" => name, "secrets_ref" => secret})
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
def run_async!(resource, env = {}, secrets = {})
|
70
|
+
raise ArgumentError, "Invalid resource" unless resource&.start_with?("docker://")
|
71
|
+
|
72
|
+
image = resource.sub("docker://", "")
|
73
|
+
name = pod_name(image)
|
74
|
+
secret = create_secret!(secrets) if secrets && !secrets.empty?
|
75
|
+
|
76
|
+
runner_context = {"container_ref" => name, "secrets_ref" => secret}
|
77
|
+
|
78
|
+
begin
|
79
|
+
create_pod!(name, image, env, secret)
|
80
|
+
rescue
|
81
|
+
cleanup(runner_context)
|
82
|
+
raise
|
60
83
|
end
|
84
|
+
|
85
|
+
runner_context
|
86
|
+
end
|
87
|
+
|
88
|
+
def status!(runner_context)
|
89
|
+
runner_context["container_state"] = pod_info(runner_context["container_ref"])["status"]
|
61
90
|
end
|
62
91
|
|
63
|
-
def running?(
|
64
|
-
%w[Pending Running].include?(
|
92
|
+
def running?(runner_context)
|
93
|
+
%w[Pending Running].include?(runner_context.dig("container_state", "phase"))
|
65
94
|
end
|
66
95
|
|
67
|
-
def success?(
|
68
|
-
|
96
|
+
def success?(runner_context)
|
97
|
+
runner_context.dig("container_state", "phase") == "Succeeded"
|
69
98
|
end
|
70
99
|
|
71
|
-
def output(
|
72
|
-
kubeclient.get_pod_log(
|
100
|
+
def output(runner_context)
|
101
|
+
output = kubeclient.get_pod_log(runner_context["container_ref"], namespace).body
|
102
|
+
runner_context["output"] = output
|
73
103
|
end
|
74
104
|
|
75
|
-
def cleanup(
|
105
|
+
def cleanup(runner_context)
|
106
|
+
pod, secret = runner_context.values_at("container_ref", "secrets_ref")
|
107
|
+
|
76
108
|
delete_pod(pod) if pod
|
77
109
|
delete_secret(secret) if secret
|
78
110
|
end
|
@@ -125,7 +157,7 @@ module Floe
|
|
125
157
|
]
|
126
158
|
|
127
159
|
spec[:spec][:containers][0][:env] << {
|
128
|
-
:name => "
|
160
|
+
:name => "_CREDENTIALS",
|
129
161
|
:value => "/run/secrets/#{secret}/secret"
|
130
162
|
}
|
131
163
|
|
@@ -10,7 +10,20 @@ module Floe
|
|
10
10
|
|
11
11
|
super
|
12
12
|
|
13
|
-
@
|
13
|
+
@identity = options["identity"]
|
14
|
+
@log_level = options["log-level"]
|
15
|
+
@network = options["network"]
|
16
|
+
@noout = options["noout"].to_s == "true" if options.key?("noout")
|
17
|
+
@root = options["root"]
|
18
|
+
@runroot = options["runroot"]
|
19
|
+
@runtime = options["runtime"]
|
20
|
+
@runtime_flag = options["runtime-flag"]
|
21
|
+
@storage_driver = options["storage-driver"]
|
22
|
+
@storage_opt = options["storage-opt"]
|
23
|
+
@syslog = options["syslog"].to_s == "true" if options.key?("syslog")
|
24
|
+
@tmpdir = options["tmpdir"]
|
25
|
+
@transient_store = !!options["transient-store"] if options.key?("transient-store")
|
26
|
+
@volumepath = options["volumepath"]
|
14
27
|
end
|
15
28
|
|
16
29
|
def run!(resource, env = {}, secrets = {})
|
@@ -18,31 +31,123 @@ module Floe
|
|
18
31
|
|
19
32
|
image = resource.sub("docker://", "")
|
20
33
|
|
21
|
-
|
22
|
-
|
23
|
-
|
34
|
+
if secrets && !secrets.empty?
|
35
|
+
secret = create_secret(secrets)
|
36
|
+
env["_CREDENTIALS"] = "/run/secrets/#{secret}"
|
37
|
+
end
|
38
|
+
|
39
|
+
output = run_container(image, env, secret)
|
40
|
+
|
41
|
+
{"exit_code" => 0, :output => output}
|
42
|
+
ensure
|
43
|
+
delete_secret(secret) if secret
|
44
|
+
end
|
45
|
+
|
46
|
+
def run_async!(resource, env = {}, secrets = {})
|
47
|
+
raise ArgumentError, "Invalid resource" unless resource&.start_with?("docker://")
|
48
|
+
|
49
|
+
image = resource.sub("docker://", "")
|
24
50
|
|
25
51
|
if secrets && !secrets.empty?
|
26
|
-
secret_guid =
|
27
|
-
|
52
|
+
secret_guid = create_secret(secrets)
|
53
|
+
env["_CREDENTIALS"] = "/run/secrets/#{secret_guid}"
|
54
|
+
end
|
28
55
|
|
29
|
-
|
30
|
-
|
56
|
+
begin
|
57
|
+
container_id = run_container(image, env, secret_guid, :detached => true)
|
58
|
+
rescue
|
59
|
+
cleanup({"container_ref" => container_id, "secrets_ref" => secret_guid})
|
60
|
+
raise
|
31
61
|
end
|
32
62
|
|
63
|
+
{"container_ref" => container_id, "secrets_ref" => secret_guid}
|
64
|
+
end
|
65
|
+
|
66
|
+
def cleanup(runner_context)
|
67
|
+
container_id, secret_guid = runner_context.values_at("container_ref", "secrets_ref")
|
68
|
+
|
69
|
+
delete_container(container_id) if container_id
|
70
|
+
delete_secret(secret_guid) if secret_guid
|
71
|
+
end
|
72
|
+
|
73
|
+
def status!(runner_context)
|
74
|
+
runner_context["container_state"] = inspect_container(runner_context["container_ref"]).first&.dig("State")
|
75
|
+
end
|
76
|
+
|
77
|
+
def running?(runner_context)
|
78
|
+
runner_context.dig("container_state", "Running")
|
79
|
+
end
|
80
|
+
|
81
|
+
def success?(runner_context)
|
82
|
+
runner_context.dig("container_state", "ExitCode") == 0
|
83
|
+
end
|
84
|
+
|
85
|
+
def output(runner_context)
|
86
|
+
output = podman!("logs", runner_context["container_ref"]).output
|
87
|
+
runner_context["output"] = output
|
88
|
+
end
|
89
|
+
|
90
|
+
private
|
91
|
+
|
92
|
+
def run_container(image, env, secret, detached: false)
|
93
|
+
params = ["run"]
|
94
|
+
params << (detached ? :detach : :rm)
|
95
|
+
params += env.map { |k, v| [:e, "#{k}=#{v}"] }
|
96
|
+
params << [:net, "host"] if @network == "host"
|
97
|
+
params << [:secret, secret] if secret
|
33
98
|
params << image
|
34
99
|
|
35
100
|
logger.debug("Running podman: #{AwesomeSpawn.build_command_line("podman", params)}")
|
36
|
-
result = AwesomeSpawn.run!("podman", :params => params)
|
37
101
|
|
38
|
-
|
39
|
-
|
40
|
-
AwesomeSpawn.run("podman", :params => ["secret", "rm", secret_guid]) if secret_guid
|
102
|
+
result = podman!(*params)
|
103
|
+
result.output
|
41
104
|
end
|
42
105
|
|
43
|
-
|
106
|
+
def inspect_container(container_id)
|
107
|
+
JSON.parse(podman!("inspect", container_id).output)
|
108
|
+
end
|
44
109
|
|
45
|
-
|
110
|
+
def delete_container(container_id)
|
111
|
+
podman!("rm", container_id)
|
112
|
+
rescue
|
113
|
+
nil
|
114
|
+
end
|
115
|
+
|
116
|
+
def create_secret(secrets)
|
117
|
+
secret_guid = SecureRandom.uuid
|
118
|
+
podman!("secret", "create", secret_guid, "-", :in_data => secrets.to_json)
|
119
|
+
secret_guid
|
120
|
+
end
|
121
|
+
|
122
|
+
def delete_secret(secret_guid)
|
123
|
+
podman!("secret", "rm", secret_guid)
|
124
|
+
rescue
|
125
|
+
nil
|
126
|
+
end
|
127
|
+
|
128
|
+
def podman!(*args, **kwargs)
|
129
|
+
params = podman_global_options + args
|
130
|
+
|
131
|
+
AwesomeSpawn.run!("podman", :params => params, **kwargs)
|
132
|
+
end
|
133
|
+
|
134
|
+
def podman_global_options
|
135
|
+
options = []
|
136
|
+
options << [:identity, @identity] if @identity
|
137
|
+
options << [:"log-level", @log_level] if @log_level
|
138
|
+
options << :noout if @noout
|
139
|
+
options << [:root, @root] if @root
|
140
|
+
options << [:runroot, @runroot] if @runroot
|
141
|
+
options << [:runtime, @runtime] if @runtime
|
142
|
+
options << [:"runtime-flag", @runtime_flag] if @runtime_flag
|
143
|
+
options << [:"storage-driver", @storage_driver] if @storage_driver
|
144
|
+
options << [:"storage-opt", @storage_opt] if @storage_opt
|
145
|
+
options << :syslog if @syslog
|
146
|
+
options << [:tmpdir, @tmpdir] if @tmpdir
|
147
|
+
options << [:"transient-store", @transient_store] if @transient_store
|
148
|
+
options << [:volumepath, @volumepath] if @volumepath
|
149
|
+
options
|
150
|
+
end
|
46
151
|
end
|
47
152
|
end
|
48
153
|
end
|
data/lib/floe/workflow/runner.rb
CHANGED
@@ -30,7 +30,27 @@ module Floe
|
|
30
30
|
end
|
31
31
|
end
|
32
32
|
|
33
|
-
def run!(
|
33
|
+
def run!(resource, env = {}, secrets = {})
|
34
|
+
raise NotImplementedError, "Must be implemented in a subclass"
|
35
|
+
end
|
36
|
+
|
37
|
+
def run_async!(_image, _env = {}, _secrets = {})
|
38
|
+
raise NotImplementedError, "Must be implemented in a subclass"
|
39
|
+
end
|
40
|
+
|
41
|
+
def running?(_ref)
|
42
|
+
raise NotImplementedError, "Must be implemented in a subclass"
|
43
|
+
end
|
44
|
+
|
45
|
+
def success?(_ref)
|
46
|
+
raise NotImplementedError, "Must be implemented in a subclass"
|
47
|
+
end
|
48
|
+
|
49
|
+
def output(_ref)
|
50
|
+
raise NotImplementedError, "Must be implemented in a subclass"
|
51
|
+
end
|
52
|
+
|
53
|
+
def cleanup(_ref, _secret)
|
34
54
|
raise NotImplementedError, "Must be implemented in a subclass"
|
35
55
|
end
|
36
56
|
end
|
data/lib/floe/workflow/state.rb
CHANGED
@@ -29,9 +29,69 @@ module Floe
|
|
29
29
|
@comment = payload["Comment"]
|
30
30
|
end
|
31
31
|
|
32
|
+
def run!(_input = nil)
|
33
|
+
run_wait until run_nonblock! == 0
|
34
|
+
end
|
35
|
+
|
36
|
+
def run_wait(timeout: 5)
|
37
|
+
start = Time.now.utc
|
38
|
+
|
39
|
+
loop do
|
40
|
+
return 0 if ready?
|
41
|
+
return Errno::EAGAIN if timeout.zero? || Time.now.utc - start > timeout
|
42
|
+
|
43
|
+
sleep(1)
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
def run_nonblock!
|
48
|
+
start(context.input) unless started?
|
49
|
+
return Errno::EAGAIN unless ready?
|
50
|
+
|
51
|
+
finish
|
52
|
+
end
|
53
|
+
|
54
|
+
def start(_input)
|
55
|
+
start_time = Time.now.utc.iso8601
|
56
|
+
|
57
|
+
context.execution["StartTime"] ||= start_time
|
58
|
+
context.state["Guid"] = SecureRandom.uuid
|
59
|
+
context.state["EnteredTime"] = start_time
|
60
|
+
|
61
|
+
logger.info("Running state: [#{context.state_name}] with input [#{context.input}]...")
|
62
|
+
end
|
63
|
+
|
64
|
+
def finish
|
65
|
+
finished_time = Time.now.utc
|
66
|
+
finished_time_iso = finished_time.iso8601
|
67
|
+
entered_time = Time.parse(context.state["EnteredTime"])
|
68
|
+
|
69
|
+
context.state["FinishedTime"] ||= finished_time_iso
|
70
|
+
context.state["Duration"] = finished_time - entered_time
|
71
|
+
context.execution["EndTime"] = finished_time_iso if context.next_state.nil?
|
72
|
+
|
73
|
+
logger.info("Running state: [#{context.state_name}] with input [#{context.input}]...Complete - next state: [#{context.next_state}] output: [#{context.output}]")
|
74
|
+
|
75
|
+
context.state_history << context.state
|
76
|
+
|
77
|
+
0
|
78
|
+
end
|
79
|
+
|
32
80
|
def context
|
33
81
|
workflow.context
|
34
82
|
end
|
83
|
+
|
84
|
+
def started?
|
85
|
+
context.state.key?("EnteredTime")
|
86
|
+
end
|
87
|
+
|
88
|
+
def ready?
|
89
|
+
!started? || !running?
|
90
|
+
end
|
91
|
+
|
92
|
+
def finished?
|
93
|
+
context.state.key?("FinishedTime")
|
94
|
+
end
|
35
95
|
end
|
36
96
|
end
|
37
97
|
end
|
@@ -16,16 +16,18 @@ module Floe
|
|
16
16
|
@output_path = Path.new(payload.fetch("OutputPath", "$"))
|
17
17
|
end
|
18
18
|
|
19
|
-
def
|
19
|
+
def start(input)
|
20
|
+
super
|
20
21
|
input = input_path.value(context, input)
|
21
22
|
next_state = choices.detect { |choice| choice.true?(context, input) }&.next || default
|
22
23
|
output = output_path.value(context, input)
|
23
24
|
|
24
|
-
|
25
|
+
context.next_state = next_state
|
26
|
+
context.output = output
|
25
27
|
end
|
26
28
|
|
27
|
-
def
|
28
|
-
|
29
|
+
def running?
|
30
|
+
false
|
29
31
|
end
|
30
32
|
|
31
33
|
def end?
|
@@ -13,12 +13,16 @@ module Floe
|
|
13
13
|
@error = payload["Error"]
|
14
14
|
end
|
15
15
|
|
16
|
-
def
|
17
|
-
|
16
|
+
def start(input)
|
17
|
+
super
|
18
|
+
context.state["Error"] = error
|
19
|
+
context.state["Cause"] = cause
|
20
|
+
context.next_state = nil
|
21
|
+
context.output = input
|
18
22
|
end
|
19
23
|
|
20
|
-
def
|
21
|
-
|
24
|
+
def running?
|
25
|
+
false
|
22
26
|
end
|
23
27
|
|
24
28
|
def end?
|
@@ -19,16 +19,18 @@ module Floe
|
|
19
19
|
@result_path = ReferencePath.new(payload.fetch("ResultPath", "$"))
|
20
20
|
end
|
21
21
|
|
22
|
-
def
|
22
|
+
def start(input)
|
23
|
+
super
|
23
24
|
output = input_path.value(context, input)
|
24
25
|
output = result_path.set(output, result) if result && result_path
|
25
26
|
output = output_path.value(context, output)
|
26
27
|
|
27
|
-
|
28
|
+
context.next_state = end? ? nil : @next
|
29
|
+
context.output = output
|
28
30
|
end
|
29
31
|
|
30
|
-
def
|
31
|
-
|
32
|
+
def running?
|
33
|
+
false
|
32
34
|
end
|
33
35
|
|
34
36
|
def end?
|
@@ -15,6 +15,7 @@ module Floe
|
|
15
15
|
@next = payload["Next"]
|
16
16
|
@end = !!payload["End"]
|
17
17
|
@resource = payload["Resource"]
|
18
|
+
@runner = Floe::Workflow::Runner.for_resource(@resource)
|
18
19
|
@timeout_seconds = payload["TimeoutSeconds"]
|
19
20
|
@retry = payload["Retry"].to_a.map { |retrier| Retrier.new(retrier) }
|
20
21
|
@catch = payload["Catch"].to_a.map { |catcher| Catcher.new(catcher) }
|
@@ -26,27 +27,37 @@ module Floe
|
|
26
27
|
@credentials = PayloadTemplate.new(payload["Credentials"]) if payload["Credentials"]
|
27
28
|
end
|
28
29
|
|
29
|
-
def
|
30
|
+
def start(input)
|
31
|
+
super
|
30
32
|
input = input_path.value(context, input)
|
31
33
|
input = parameters.value(context, input) if parameters
|
32
34
|
|
33
|
-
|
34
|
-
|
35
|
+
runner_context = runner.run_async!(resource, input, credentials&.value({}, workflow.credentials))
|
36
|
+
context.state["RunnerContext"] = runner_context
|
37
|
+
end
|
35
38
|
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
39
|
+
def status
|
40
|
+
@end ? "success" : "running"
|
41
|
+
end
|
42
|
+
|
43
|
+
def finish
|
44
|
+
results = runner.output(context.state["RunnerContext"])
|
41
45
|
|
42
|
-
|
43
|
-
|
46
|
+
if success?
|
47
|
+
context.state["Output"] = process_output!(results)
|
48
|
+
context.next_state = next_state
|
49
|
+
else
|
50
|
+
retry_state!(results) || catch_error!(results)
|
51
|
+
end
|
44
52
|
|
45
|
-
|
53
|
+
super
|
54
|
+
ensure
|
55
|
+
runner.cleanup(context.state["RunnerContext"])
|
46
56
|
end
|
47
57
|
|
48
|
-
def
|
49
|
-
|
58
|
+
def running?
|
59
|
+
runner.status!(context.state["RunnerContext"])
|
60
|
+
runner.running?(context.state["RunnerContext"])
|
50
61
|
end
|
51
62
|
|
52
63
|
def end?
|
@@ -55,7 +66,22 @@ module Floe
|
|
55
66
|
|
56
67
|
private
|
57
68
|
|
58
|
-
|
69
|
+
attr_reader :runner
|
70
|
+
|
71
|
+
def success?
|
72
|
+
runner.success?(context.state["RunnerContext"])
|
73
|
+
end
|
74
|
+
|
75
|
+
def find_retrier(error)
|
76
|
+
self.retry.detect { |r| (r.error_equals & [error, "States.ALL"]).any? }
|
77
|
+
end
|
78
|
+
|
79
|
+
def find_catcher(error)
|
80
|
+
self.catch.detect { |c| (c.error_equals & [error, "States.ALL"]).any? }
|
81
|
+
end
|
82
|
+
|
83
|
+
def retry_state!(error)
|
84
|
+
retrier = find_retrier(error)
|
59
85
|
return if retrier.nil?
|
60
86
|
|
61
87
|
# If a different retrier is hit reset the context
|
@@ -68,11 +94,26 @@ module Floe
|
|
68
94
|
|
69
95
|
return if context["State"]["RetryCount"] > retrier.max_attempts
|
70
96
|
|
71
|
-
Kernel.sleep(retrier.sleep_duration(context["State"]["RetryCount"]))
|
97
|
+
# TODO: Kernel.sleep(retrier.sleep_duration(context["State"]["RetryCount"]))
|
98
|
+
context.next_state = context.state_name
|
72
99
|
true
|
73
100
|
end
|
74
101
|
|
75
|
-
def
|
102
|
+
def catch_error!(error)
|
103
|
+
catcher = find_catcher(error)
|
104
|
+
raise error if catcher.nil?
|
105
|
+
|
106
|
+
context.next_state = catcher.next
|
107
|
+
end
|
108
|
+
|
109
|
+
def process_input(input)
|
110
|
+
input = input_path.value(context, input)
|
111
|
+
input = parameters.value(context, input) if parameters
|
112
|
+
input
|
113
|
+
end
|
114
|
+
|
115
|
+
def process_output!(results)
|
116
|
+
output = process_input(context.state["Input"])
|
76
117
|
return output if results.nil?
|
77
118
|
return if output_path.nil?
|
78
119
|
|
@@ -86,6 +127,10 @@ module Floe
|
|
86
127
|
output = result_path.set(output, results)
|
87
128
|
output_path.value(context, output)
|
88
129
|
end
|
130
|
+
|
131
|
+
def next_state
|
132
|
+
end? ? nil : @next
|
133
|
+
end
|
89
134
|
end
|
90
135
|
end
|
91
136
|
end
|
@@ -17,15 +17,22 @@ module Floe
|
|
17
17
|
@output_path = Path.new(payload.fetch("OutputPath", "$"))
|
18
18
|
end
|
19
19
|
|
20
|
-
def
|
20
|
+
def start(input)
|
21
|
+
super
|
21
22
|
input = input_path.value(context, input)
|
22
|
-
|
23
|
-
output
|
24
|
-
|
23
|
+
|
24
|
+
context.output = output_path.value(context, input)
|
25
|
+
context.next_state = end? ? nil : @next
|
25
26
|
end
|
26
27
|
|
27
|
-
def
|
28
|
-
|
28
|
+
def running?
|
29
|
+
now = Time.now.utc
|
30
|
+
if now > (Time.parse(context.state["EnteredTime"]) + @seconds)
|
31
|
+
context.state["FinishedTime"] = now.iso8601
|
32
|
+
false
|
33
|
+
else
|
34
|
+
true
|
35
|
+
end
|
29
36
|
end
|
30
37
|
|
31
38
|
def end?
|