putpaws 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/bin/putpaws +3 -0
- data/lib/Putpawsfile +6 -0
- data/lib/putpaws/all.rb +10 -0
- data/lib/putpaws/application.rb +26 -0
- data/lib/putpaws/application_config.rb +48 -0
- data/lib/putpaws/cloud_watch/default_log_formatter.rb +11 -0
- data/lib/putpaws/cloud_watch/log_command.rb +98 -0
- data/lib/putpaws/cloud_watch/log_task.rb +1 -0
- data/lib/putpaws/cloud_watch/tasks/log_task.rake +52 -0
- data/lib/putpaws/dsl.rb +14 -0
- data/lib/putpaws/ecs/ecs_task.rb +1 -0
- data/lib/putpaws/ecs/task_command.rb +64 -0
- data/lib/putpaws/ecs/tasks/ecs_task.rake +29 -0
- data/lib/putpaws/env.rb +29 -0
- data/lib/putpaws/hello.rb +1 -0
- data/lib/putpaws/setup.rb +10 -0
- data/lib/putpaws/tasks/hello.rake +4 -0
- data/lib/putpaws/version.rb +3 -0
- data/lib/putpaws.rb +0 -0
- metadata +118 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 2ac7d1e3c9a05d7cabf6ed3ddb76b68eebf5e238ee14cd2d220bfeaf88226570
|
4
|
+
data.tar.gz: cbcff987bb94042328f0b65d5f693d8aabd75231db1766456d4fe4ed803433e8
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 76acfa6f6d0b7a514c31a0f63819cc6e1ea587a48a07a465a321afc6743bdc2b645a1d1f22aa2eb3c7c460e44cdf11d51abbc6bc11f5efbc222950a4231ae56e
|
7
|
+
data.tar.gz: daeea4af00b61ebd35b17daf562343b8839791d94dbb5ae356635d3f39d8d09d754ef971b325e979a24c846c1ea0efe878b0c910c54459a507306759a4448d47
|
data/bin/putpaws
ADDED
data/lib/Putpawsfile
ADDED
data/lib/putpaws/all.rb
ADDED
@@ -0,0 +1,26 @@
|
|
1
|
+
module Putpaws
|
2
|
+
class Application < Rake::Application
|
3
|
+
def initialize
|
4
|
+
super
|
5
|
+
@rakefiles = %w{putpawsfile Putpawsfile putpawsfile.rb Putpawsfile.rb}
|
6
|
+
end
|
7
|
+
|
8
|
+
def name
|
9
|
+
"putpaws"
|
10
|
+
end
|
11
|
+
|
12
|
+
def run(argv = ARGV)
|
13
|
+
Rake.application = self
|
14
|
+
super
|
15
|
+
end
|
16
|
+
|
17
|
+
def find_rakefile_location
|
18
|
+
putpawsfile = File.expand_path(File.join(File.dirname(__FILE__), "..", "Putpawsfile"))
|
19
|
+
if (location = super).nil?
|
20
|
+
[putpawsfile, Dir.pwd]
|
21
|
+
else
|
22
|
+
location
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,48 @@
|
|
1
|
+
require 'json'
|
2
|
+
require 'pathname'
|
3
|
+
|
4
|
+
class Putpaws::ApplicationConfig < Struct.new(
|
5
|
+
:name, :region,
|
6
|
+
:cluster, :service, :task_name_prefix, :ecs_region,
|
7
|
+
:log_group_prefix, :log_region,
|
8
|
+
keyword_init: true)
|
9
|
+
def self.load(path_prefix: '.putpaws')
|
10
|
+
@application_data ||= begin
|
11
|
+
path = Pathname.new(path_prefix).join("application.json").to_s
|
12
|
+
data = File.exists?(path) ?
|
13
|
+
JSON.parse(File.read(path), symbolize_names: true).to_h :
|
14
|
+
{}
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
def self.all
|
19
|
+
load.map{|k,v|
|
20
|
+
data = v.slice(*self.members)
|
21
|
+
new(data.merge(name: k.to_s))
|
22
|
+
}
|
23
|
+
end
|
24
|
+
|
25
|
+
def self.find(name)
|
26
|
+
application_data = load
|
27
|
+
data = application_data[name.to_sym]
|
28
|
+
return nil unless data
|
29
|
+
data = data.slice(*self.members)
|
30
|
+
new(data.merge({name: name.to_s}))
|
31
|
+
end
|
32
|
+
|
33
|
+
def ecs_command_params
|
34
|
+
{
|
35
|
+
region: ecs_region || region,
|
36
|
+
cluster: cluster,
|
37
|
+
# service: service,
|
38
|
+
task_name_prefix: task_name_prefix,
|
39
|
+
}
|
40
|
+
end
|
41
|
+
|
42
|
+
def log_command_params
|
43
|
+
{
|
44
|
+
region: log_region || region,
|
45
|
+
log_group_prefix: log_group_prefix,
|
46
|
+
}
|
47
|
+
end
|
48
|
+
end
|
@@ -0,0 +1,11 @@
|
|
1
|
+
class Putpaws::CloudWatch::DefaultLogFormatter
|
2
|
+
attr_accessor :datetime_format
|
3
|
+
def initialize(datetime_format: nil)
|
4
|
+
@datetime_format = datetime_format || "%FT%T%:z"
|
5
|
+
end
|
6
|
+
|
7
|
+
def call(event)
|
8
|
+
time = Time.at(0, event.timestamp, :millisecond)
|
9
|
+
"%s %s\n" % [time.strftime(datetime_format), event.message]
|
10
|
+
end
|
11
|
+
end
|
@@ -0,0 +1,98 @@
|
|
1
|
+
require 'aws-sdk-cloudwatchlogs'
|
2
|
+
|
3
|
+
module Putpaws::CloudWatch
|
4
|
+
class LogCommand
|
5
|
+
SECONDS = {
|
6
|
+
's' => 1,
|
7
|
+
'm' => 60,
|
8
|
+
'h' => 60 * 60,
|
9
|
+
'd' => 60 * 60 * 24,
|
10
|
+
'w' => 60 * 60 * 24 * 7,
|
11
|
+
}
|
12
|
+
|
13
|
+
def self.config(config)
|
14
|
+
new(config.log_command_params)
|
15
|
+
end
|
16
|
+
|
17
|
+
def self.parse_unit_time(ut)
|
18
|
+
return nil unless ut
|
19
|
+
matched, number, unit = ut.match(/\A(\d+)([smhdw])\z/).to_a
|
20
|
+
return nil unless matched
|
21
|
+
number.to_i * SECONDS[unit]
|
22
|
+
end
|
23
|
+
|
24
|
+
def self.filter_args(since: nil, since_for: nil)
|
25
|
+
since_sec = parse_unit_time(since)
|
26
|
+
since_for_sec = parse_unit_time(since_for)
|
27
|
+
start_time = Time.now - (since_sec || (60*1))
|
28
|
+
end_time = if since_sec
|
29
|
+
since_for_sec && (start_time + since_for_sec)
|
30
|
+
else
|
31
|
+
nil
|
32
|
+
end
|
33
|
+
args = {
|
34
|
+
start_time: start_time.to_f * 1000,
|
35
|
+
end_time: end_time && (end_time.to_f * 1000),
|
36
|
+
}
|
37
|
+
args.select{|k,v| v}
|
38
|
+
end
|
39
|
+
|
40
|
+
attr_reader :client
|
41
|
+
attr_reader :region, :log_group_prefix
|
42
|
+
attr_accessor :log_group
|
43
|
+
def initialize(region:, log_group_prefix: nil)
|
44
|
+
@client = Aws::CloudWatchLogs::Client.new({region: region})
|
45
|
+
@log_group_prefix = log_group_prefix
|
46
|
+
@log_group = nil
|
47
|
+
end
|
48
|
+
|
49
|
+
def list_log_groups
|
50
|
+
res = client.describe_log_groups(log_group_name_prefix: log_group_prefix)
|
51
|
+
res.log_groups
|
52
|
+
end
|
53
|
+
|
54
|
+
def log_events(**args)
|
55
|
+
raise "Log group Not Set" unless log_group
|
56
|
+
res = client.filter_log_events({
|
57
|
+
log_group_name: log_group,
|
58
|
+
**args
|
59
|
+
})
|
60
|
+
end
|
61
|
+
|
62
|
+
def tail_log_events(**args)
|
63
|
+
res = log_events(**args)
|
64
|
+
nt = newest_timestamp([args[:start_time], *res.events.map(&:timestamp)].compact.max)
|
65
|
+
events = filter_same_moment_events(res.events, args[:start_time])
|
66
|
+
next_args = if res.next_token
|
67
|
+
args.merge(next_token: res.next_token)
|
68
|
+
else
|
69
|
+
args.merge(
|
70
|
+
next_token: nil,
|
71
|
+
start_time: nt,
|
72
|
+
)
|
73
|
+
end
|
74
|
+
[events, next_args]
|
75
|
+
end
|
76
|
+
|
77
|
+
def newest_timestamp(newest)
|
78
|
+
newest = newest.to_i
|
79
|
+
if @newest_timestamp
|
80
|
+
@newest_timestamp = [@newest_timestamp, newest].max
|
81
|
+
else
|
82
|
+
@newest_timestamp = newest
|
83
|
+
end
|
84
|
+
@newest_timestamp
|
85
|
+
end
|
86
|
+
|
87
|
+
def filter_same_moment_events(events, timestamp)
|
88
|
+
@event_ids_already_shown ||= []
|
89
|
+
filtered_events = events.reject{|e| @event_ids_already_shown.include?(e.event_id)}
|
90
|
+
event_ids = events.map(&:event_id).uniq
|
91
|
+
unless event_ids.empty?
|
92
|
+
@event_ids_already_shown = event_ids
|
93
|
+
end
|
94
|
+
# pp @event_ids_already_shown
|
95
|
+
filtered_events
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|
@@ -0,0 +1 @@
|
|
1
|
+
load File.expand_path("../tasks/log_task.rake", __FILE__)
|
@@ -0,0 +1,52 @@
|
|
1
|
+
require "tty-prompt"
|
2
|
+
require "putpaws/cloud_watch/log_command"
|
3
|
+
require "putpaws/cloud_watch/default_log_formatter"
|
4
|
+
|
5
|
+
namespace :log do
|
6
|
+
desc "Set Log Group."
|
7
|
+
task :set_log_group do
|
8
|
+
aws = Putpaws::CloudWatch::LogCommand.config(fetch(:app))
|
9
|
+
log_groups = aws.list_log_groups.map{|a|
|
10
|
+
[a.log_group_name, a]
|
11
|
+
}.to_h
|
12
|
+
raise "Log group not found on your permission" if log_groups.empty?
|
13
|
+
|
14
|
+
log_group = if log_groups.length == 1
|
15
|
+
log_groups.first
|
16
|
+
else
|
17
|
+
prompt = TTY::Prompt.new
|
18
|
+
selected = prompt.select("Choose a log_group you're going to operate", log_groups.keys)
|
19
|
+
log_groups[selected]
|
20
|
+
end
|
21
|
+
|
22
|
+
set :log_group, log_group
|
23
|
+
end
|
24
|
+
|
25
|
+
desc "Tail log with follow."
|
26
|
+
task tailf: :set_log_group do
|
27
|
+
ENV['follow'] = '1'
|
28
|
+
Rake::Task['log:tail'].invoke
|
29
|
+
end
|
30
|
+
|
31
|
+
# Check: https://github.com/aws/aws-cli/blob/v2/awscli/customizations/logs/tail.py
|
32
|
+
desc "Tail log."
|
33
|
+
task tail: :set_log_group do
|
34
|
+
log_group = fetch(:log_group)
|
35
|
+
log_formatter = fetch(:log_formatter) {Putpaws::CloudWatch::DefaultLogFormatter.new}
|
36
|
+
aws = Putpaws::CloudWatch::LogCommand.config(fetch(:app))
|
37
|
+
aws.log_group = log_group.log_group_name
|
38
|
+
|
39
|
+
log_event_args = Putpaws::CloudWatch::LogCommand.filter_args(since: ENV['since'], since_for: ENV['for'])
|
40
|
+
|
41
|
+
loop do
|
42
|
+
events, next_args = aws.tail_log_events(**log_event_args)
|
43
|
+
events.each {|a| puts log_formatter.call(a)}
|
44
|
+
log_event_args = next_args
|
45
|
+
unless log_event_args[:next_token]
|
46
|
+
ENV['follow'] ? sleep(5) : break
|
47
|
+
end
|
48
|
+
rescue Interrupt
|
49
|
+
exit
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
data/lib/putpaws/dsl.rb
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
load File.expand_path("../tasks/ecs_task.rake", __FILE__)
|
@@ -0,0 +1,64 @@
|
|
1
|
+
require 'aws-sdk-ecs'
|
2
|
+
|
3
|
+
module Putpaws::Ecs
|
4
|
+
class TaskCommand
|
5
|
+
def self.config(config)
|
6
|
+
new(config.ecs_command_params)
|
7
|
+
end
|
8
|
+
|
9
|
+
attr_reader :ecs_client
|
10
|
+
attr_reader :region, :cluster, :task_name_prefix
|
11
|
+
attr_accessor :ecs_task
|
12
|
+
def initialize(region:, cluster:, task_name_prefix: nil)
|
13
|
+
@ecs_client = Aws::ECS::Client.new({region: region})
|
14
|
+
@region = region
|
15
|
+
@cluster = cluster
|
16
|
+
@task_name_prefix = task_name_prefix
|
17
|
+
@ecs_task = nil
|
18
|
+
end
|
19
|
+
|
20
|
+
def list_ecs_tasks
|
21
|
+
res = ecs_client.list_tasks(cluster: cluster)
|
22
|
+
res = ecs_client.describe_tasks(tasks: res.task_arns, cluster: cluster)
|
23
|
+
return res.tasks unless task_name_prefix
|
24
|
+
res.tasks.select{|t|
|
25
|
+
_, name = t.task_definition_arn.split('task-definition/')
|
26
|
+
name.start_with?(task_name_prefix)
|
27
|
+
}
|
28
|
+
end
|
29
|
+
|
30
|
+
def get_attach_command(container: 'app')
|
31
|
+
raise "ECS Task Not Set" unless ecs_task
|
32
|
+
ctn = ecs_task.containers.detect{|c| c.name == container}
|
33
|
+
task_id = ecs_task.task_arn.split('/').last
|
34
|
+
raise "Container: #{container} not found" unless ctn
|
35
|
+
res = ecs_client.execute_command({
|
36
|
+
cluster: cluster,
|
37
|
+
container: container,
|
38
|
+
command: '/bin/bash',
|
39
|
+
interactive: true,
|
40
|
+
task: ecs_task.task_arn,
|
41
|
+
})
|
42
|
+
|
43
|
+
# https://github.com/aws/aws-cli/blob/2a6136010d8656a605d41d1e7b5fdab3c2930cad/awscli/customizations/ecs/executecommand.py#L105
|
44
|
+
session_json = {
|
45
|
+
"SessionId" => res.session.session_id,
|
46
|
+
"StreamUrl" => res.session.stream_url,
|
47
|
+
"TokenValue" => res.session.token_value,
|
48
|
+
}.to_json
|
49
|
+
target_json = {
|
50
|
+
"Target" => "ecs:#{cluster}_#{task_id}_#{ctn.runtime_id}"
|
51
|
+
}.to_json
|
52
|
+
cmd = [
|
53
|
+
"session-manager-plugin",
|
54
|
+
session_json.dump,
|
55
|
+
@region,
|
56
|
+
"StartSession",
|
57
|
+
'test',
|
58
|
+
target_json.dump,
|
59
|
+
"https://ssm.ap-northeast-1.amazonaws.com"
|
60
|
+
]
|
61
|
+
cmd.join(' ')
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
require "tty-prompt"
|
2
|
+
require "putpaws/ecs/task_command"
|
3
|
+
|
4
|
+
namespace :ecs do
|
5
|
+
desc "Set ECS task."
|
6
|
+
task :set_task do
|
7
|
+
aws = Putpaws::Ecs::TaskCommand.config(fetch(:app))
|
8
|
+
ecs_tasks = aws.list_ecs_tasks.map{|t|
|
9
|
+
task_id = t.task_arn.split('/').last
|
10
|
+
task_def = t.task_definition_arn.split('/').last
|
11
|
+
["#{task_id} (#{task_def}) #{t.last_status}", t]
|
12
|
+
}.to_h
|
13
|
+
prompt = TTY::Prompt.new
|
14
|
+
selected = prompt.select("Choose a task you're going to operate", ecs_tasks.keys)
|
15
|
+
ecs_task = ecs_tasks[selected]
|
16
|
+
|
17
|
+
set :ecs_task, ecs_task
|
18
|
+
end
|
19
|
+
|
20
|
+
desc "Attach on ECS task. You need to enable ECS Exec on a specified task."
|
21
|
+
task attach: :set_task do
|
22
|
+
ecs_task = fetch(:ecs_task)
|
23
|
+
aws = Putpaws::Ecs::TaskCommand.config(fetch(:app))
|
24
|
+
aws.ecs_task = ecs_task
|
25
|
+
cmd = aws.get_attach_command
|
26
|
+
puts cmd
|
27
|
+
system(cmd)
|
28
|
+
end
|
29
|
+
end
|
data/lib/putpaws/env.rb
ADDED
@@ -0,0 +1,29 @@
|
|
1
|
+
# require 'delegate'
|
2
|
+
|
3
|
+
class Putpaws::Env
|
4
|
+
def self.default
|
5
|
+
@env ||= new({})
|
6
|
+
end
|
7
|
+
|
8
|
+
attr_reader :values
|
9
|
+
|
10
|
+
def initialize(values)
|
11
|
+
@values = values
|
12
|
+
end
|
13
|
+
|
14
|
+
def set(key, value=nil, &block)
|
15
|
+
values[key] = block || value
|
16
|
+
end
|
17
|
+
|
18
|
+
def fetch(key, default=nil, &block)
|
19
|
+
fetch_for(key, default, &block)
|
20
|
+
end
|
21
|
+
|
22
|
+
def fetch_for(key, default, &block)
|
23
|
+
block ? values.fetch(key, &block) : values.fetch(key, default)
|
24
|
+
end
|
25
|
+
|
26
|
+
def delete(key)
|
27
|
+
values.delete(key)
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1 @@
|
|
1
|
+
load File.expand_path("../tasks/hello.rake", __FILE__)
|
data/lib/putpaws.rb
ADDED
File without changes
|
metadata
ADDED
@@ -0,0 +1,118 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: putpaws
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.1
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- metheglin
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2023-01-26 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: rake
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '13.0'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '13.0'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: tty-prompt
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '0.0'
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '0.0'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: aws-sdk-ecs
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - "~>"
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '1.0'
|
48
|
+
type: :runtime
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - "~>"
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '1.0'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: aws-sdk-cloudwatchlogs
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - "~>"
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '1.0'
|
62
|
+
type: :runtime
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - "~>"
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '1.0'
|
69
|
+
description: aws fargate based infra management
|
70
|
+
email: pigmybank@gmail.com
|
71
|
+
executables:
|
72
|
+
- putpaws
|
73
|
+
extensions: []
|
74
|
+
extra_rdoc_files: []
|
75
|
+
files:
|
76
|
+
- bin/putpaws
|
77
|
+
- lib/Putpawsfile
|
78
|
+
- lib/putpaws.rb
|
79
|
+
- lib/putpaws/all.rb
|
80
|
+
- lib/putpaws/application.rb
|
81
|
+
- lib/putpaws/application_config.rb
|
82
|
+
- lib/putpaws/cloud_watch/default_log_formatter.rb
|
83
|
+
- lib/putpaws/cloud_watch/log_command.rb
|
84
|
+
- lib/putpaws/cloud_watch/log_task.rb
|
85
|
+
- lib/putpaws/cloud_watch/tasks/log_task.rake
|
86
|
+
- lib/putpaws/dsl.rb
|
87
|
+
- lib/putpaws/ecs/ecs_task.rb
|
88
|
+
- lib/putpaws/ecs/task_command.rb
|
89
|
+
- lib/putpaws/ecs/tasks/ecs_task.rake
|
90
|
+
- lib/putpaws/env.rb
|
91
|
+
- lib/putpaws/hello.rb
|
92
|
+
- lib/putpaws/setup.rb
|
93
|
+
- lib/putpaws/tasks/hello.rake
|
94
|
+
- lib/putpaws/version.rb
|
95
|
+
homepage: https://rubygems.org/gems/putpaws
|
96
|
+
licenses:
|
97
|
+
- MIT
|
98
|
+
metadata: {}
|
99
|
+
post_install_message:
|
100
|
+
rdoc_options: []
|
101
|
+
require_paths:
|
102
|
+
- lib
|
103
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
104
|
+
requirements:
|
105
|
+
- - ">="
|
106
|
+
- !ruby/object:Gem::Version
|
107
|
+
version: '2.7'
|
108
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
109
|
+
requirements:
|
110
|
+
- - ">="
|
111
|
+
- !ruby/object:Gem::Version
|
112
|
+
version: '0'
|
113
|
+
requirements: []
|
114
|
+
rubygems_version: 3.1.4
|
115
|
+
signing_key:
|
116
|
+
specification_version: 4
|
117
|
+
summary: Put your paws up!!
|
118
|
+
test_files: []
|