towel 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (55) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +11 -0
  3. data/.gitmodules +3 -0
  4. data/.travis.yml +7 -0
  5. data/CODE_OF_CONDUCT.md +74 -0
  6. data/Gemfile +6 -0
  7. data/LICENSE.txt +21 -0
  8. data/README.md +43 -0
  9. data/Rakefile +19 -0
  10. data/bin/console +14 -0
  11. data/bin/setup +8 -0
  12. data/extensions/towel-minitest/.gitignore +8 -0
  13. data/extensions/towel-minitest/Gemfile +6 -0
  14. data/extensions/towel-minitest/README.md +43 -0
  15. data/extensions/towel-minitest/Rakefile +10 -0
  16. data/extensions/towel-minitest/bin/console +14 -0
  17. data/extensions/towel-minitest/bin/setup +8 -0
  18. data/extensions/towel-minitest/examples/.towel.toml +37 -0
  19. data/extensions/towel-minitest/examples/parallel_test.rb +29 -0
  20. data/extensions/towel-minitest/examples/simple_spec.rb +25 -0
  21. data/extensions/towel-minitest/examples/simple_test.rb +35 -0
  22. data/extensions/towel-minitest/lib/minitest/towel_plugin.rb +12 -0
  23. data/extensions/towel-minitest/lib/towel/minitest/reporter.rb +102 -0
  24. data/extensions/towel-minitest/lib/towel/minitest/version.rb +5 -0
  25. data/extensions/towel-minitest/lib/towel/minitest.rb +13 -0
  26. data/extensions/towel-minitest/test/test_helper.rb +4 -0
  27. data/extensions/towel-minitest/test/towel/minitest_test.rb +11 -0
  28. data/extensions/towel-minitest/towel-minitest.gemspec +41 -0
  29. data/extensions/towel-rspec/.gitignore +9 -0
  30. data/extensions/towel-rspec/.rspec +1 -0
  31. data/extensions/towel-rspec/Gemfile +6 -0
  32. data/extensions/towel-rspec/README.md +43 -0
  33. data/extensions/towel-rspec/Rakefile +10 -0
  34. data/extensions/towel-rspec/bin/console +14 -0
  35. data/extensions/towel-rspec/bin/setup +8 -0
  36. data/extensions/towel-rspec/examples/.rspec +2 -0
  37. data/extensions/towel-rspec/examples/simple_spec.rb +22 -0
  38. data/extensions/towel-rspec/lib/towel/rspec/formatter.rb +63 -0
  39. data/extensions/towel-rspec/lib/towel/rspec/version.rb +5 -0
  40. data/extensions/towel-rspec/lib/towel/rspec.rb +13 -0
  41. data/extensions/towel-rspec/spec/spec_helper.rb +100 -0
  42. data/extensions/towel-rspec/towel-rspec.gemspec +41 -0
  43. data/generated/.gitignore +1 -0
  44. data/generated/towel/v1alpha/collector_pb.rb +61 -0
  45. data/generated/towel/v1alpha/collector_services_pb.rb +41 -0
  46. data/generated/towel/v1alpha/towel_pb.rb +88 -0
  47. data/lib/towel/configuration.rb +82 -0
  48. data/lib/towel/environment.rb +64 -0
  49. data/lib/towel/log.rb +100 -0
  50. data/lib/towel/log_io.rb +71 -0
  51. data/lib/towel/session.rb +199 -0
  52. data/lib/towel/version.rb +3 -0
  53. data/lib/towel.rb +30 -0
  54. data/towel.gemspec +45 -0
  55. metadata +214 -0
@@ -0,0 +1,64 @@
1
+ module Towel
2
+ # Helpers to collect the environment-specific details for invocations.
3
+ module Environment
4
+ # Environment variable that, if set, defines the source control management
5
+ # revision that is reported to Towel. See `Invocation.revision` in
6
+ # towel.proto for more details.
7
+ REVISION_ENV = "TOWEL_REVISION".freeze
8
+
9
+ # Environment variable that, if set, defines what other environment
10
+ # variables should be redacted.
11
+ REDACT_ENV = "TOWEL_REDACT".freeze
12
+
13
+ # Value that is substituted for environment variables that have been
14
+ # redacted.
15
+ REDACTED = "REDACTED".freeze
16
+
17
+ # The current platform that Towel is running on.
18
+ def self.platform
19
+ platform = Towel::V1alpha::Platform.new
20
+ uname = Etc.uname
21
+ platform.os = uname[:sysname]
22
+ platform.os_version = uname[:release]
23
+ platform.arch = uname[:machine]
24
+ return platform
25
+ end
26
+
27
+ # The hostname of this machine.
28
+ def self.hostname
29
+ Socket.gethostname
30
+ end
31
+
32
+ # The OS user that this process is running as.
33
+ def self.user
34
+ Etc.getlogin
35
+ end
36
+
37
+ # The current working directory.
38
+ def self.working_dir
39
+ Dir.getwd
40
+ end
41
+
42
+ # The arguments used to launch this process.
43
+ def self.argv
44
+ [$0].concat(ARGV)
45
+ end
46
+
47
+ # The environment variables set for this process.
48
+ def self.env_vars
49
+ redacted = ENV.fetch(REDACT_ENV, "").split(",").map(&:strip)
50
+ ENV.to_h do |name, value|
51
+ if redacted.include?(name)
52
+ [name, REDACTED]
53
+ else
54
+ [name, value]
55
+ end
56
+ end
57
+ end
58
+
59
+ # The version control system (VCS) revision for the code that is running.
60
+ def self.revision
61
+ ENV.fetch(REVISION_ENV, "")
62
+ end
63
+ end
64
+ end
data/lib/towel/log.rb ADDED
@@ -0,0 +1,100 @@
1
+ require "concurrent-ruby"
2
+
3
+ module Towel
4
+ class Log
5
+ # Logs must be associated with an invocation.
6
+ NAME_FORMAT = /invocations\/[^\/]+\/logs\/[^\/]+/
7
+
8
+ # How long to wait for more log entries before calling `AppendLog`. This
9
+ # tries to batch log lines together as opposed to making a call for each
10
+ # log line individually.
11
+ FLUSH_INTERVAL_SECONDS = 0.1
12
+
13
+ # How many log lines to send at a time. If there are more than this, they
14
+ # will be broken up into several `AppendLog` calls.
15
+ FLUSH_COUNT = 100
16
+
17
+ attr_reader :name
18
+
19
+ def initialize(name, stub)
20
+ unless name =~ NAME_FORMAT
21
+ raise ArgumentError, "Log name #{name} is invalid"
22
+ end
23
+ raise ArgumentError, "Stub must be provided" unless stub
24
+ @name = name
25
+ @stub = stub
26
+ @entries = Queue.new
27
+ @entry_id = Concurrent::AtomicFixnum.new(-1)
28
+ @flush_mutex = Mutex.new
29
+ @flush_timer_mutex = Mutex.new
30
+ @flush_timer = nil
31
+
32
+ # Create the log
33
+ log = Towel::V1alpha::Log.new
34
+ log.name = @name
35
+ request = Towel::V1alpha::CreateLogRequest.new
36
+ request.log = log
37
+ @stub.create_log(request)
38
+ end
39
+
40
+ def context
41
+ Thread.current["towel_context_#{object_id}"]
42
+ end
43
+
44
+ def context=(context)
45
+ Thread.current["towel_context_#{object_id}"] = context
46
+ end
47
+
48
+ def <<(line)
49
+ entry = Towel::V1alpha::LogEntry.new
50
+ entry.entry_id = @entry_id.increment
51
+ entry.log_time = Time.now.utc
52
+ entry.parent = context if context
53
+ entry.contents = line
54
+
55
+ @entries << entry
56
+
57
+ # Set up a flush to happen momentarily, allowing other lines to possibly
58
+ # be flushed together with this one.
59
+ @flush_timer_mutex.synchronize do
60
+ unless @flush_timer
61
+ @flush_timer = Concurrent::ScheduledTask.execute(
62
+ FLUSH_INTERVAL_SECONDS
63
+ ) { flush }
64
+ end
65
+ end
66
+ end
67
+
68
+ def close
69
+ flush
70
+ Thread.current["towel_context_#{object_id}"] = nil
71
+ end
72
+
73
+ # Ensure that entries get written out to Towel.
74
+ def flush
75
+ @flush_mutex.synchronize do
76
+ # Acknowledge the flush timer by unsetting it, if it exists.
77
+ @flush_timer_mutex.synchronize do
78
+ @flush_timer.cancel if @flush_timer
79
+ @flush_timer = nil
80
+ end
81
+
82
+ until @entries.empty?
83
+ request = Towel::V1alpha::AppendLogRequest.new
84
+ request.name = @name
85
+ i = 0
86
+ while i < FLUSH_COUNT
87
+ i += 1
88
+ begin
89
+ entry = @entries.pop(true)
90
+ rescue ThreadError # Raised if the queue is empty
91
+ break
92
+ end
93
+ request.entries << entry
94
+ end
95
+ @stub.append_log(request)
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,71 @@
1
+ module Towel
2
+ # An IO-like object that logs to Towel. Sits on top of Log. This relationship
3
+ # is inverted compared to most IO class structures, but log lines/entries are
4
+ # the fundamental unit of Towel. This class is merely an adapter to collect
5
+ # logs from IO streams like `$stdout` and `$stderr`.
6
+ class LogIO
7
+ def initialize(log)
8
+ raise ArgumentError, "Log must be specified" unless log
9
+ @log = log
10
+ @buffer = ""
11
+ @write_mutex = Mutex.new
12
+ end
13
+
14
+ def context=(context)
15
+ @log.context = context
16
+ end
17
+
18
+ def putc(c)
19
+ if c.is_a?(String)
20
+ write(c[0])
21
+ else
22
+ write(c.chr)
23
+ end
24
+ end
25
+
26
+ def puts(*args)
27
+ if args.empty?
28
+ write($/)
29
+ else
30
+ args.each do |arg|
31
+ arg = arg.to_s
32
+ if arg.end_with?($/)
33
+ write(arg)
34
+ else
35
+ write(arg, $/)
36
+ end
37
+ end
38
+ end
39
+ end
40
+
41
+ def write(*args)
42
+ @write_mutex.synchronize do
43
+ bytes = 0
44
+ args.each do |arg|
45
+ @buffer << arg
46
+ bytes += arg.bytesize
47
+ end
48
+
49
+ remaining = ""
50
+ @buffer.each_line do |line|
51
+ if line.end_with?($/)
52
+ @log << line
53
+ else
54
+ remaining = line
55
+ end
56
+ end
57
+ @buffer = remaining
58
+
59
+ bytes
60
+ end
61
+ end
62
+
63
+ def flush
64
+ @log.flush
65
+ end
66
+
67
+ def close
68
+ @log.close
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,199 @@
1
+ module Towel
2
+ # A session keeps track of an invocation throughout its lifespan. It is meant
3
+ # to be a helper to framework-specific implementations by providing a more
4
+ # convenient API over the raw gRPC calls.
5
+ class Session
6
+ def initialize(config = nil)
7
+ @config = config || Towel::Configuration.read
8
+ @stub = create_stub
9
+ @project = [
10
+ "organizations",
11
+ @config["collector"]["organization"],
12
+ "projects",
13
+ @config["collector"]["project"]
14
+ ].join "/"
15
+ @invocation = nil
16
+ @groups = {}
17
+ @group_id = -1
18
+ @results = {}
19
+ @result_id = -1
20
+ @logs = {}
21
+ end
22
+
23
+ # Creates a new invocation. Returns the invocation URL at which users may
24
+ # view results. This must be called before other methods.
25
+ def create_invocation
26
+ invocation = Towel::V1alpha::Invocation.new
27
+ invocation.project = @project
28
+ invocation.labels["language"] = "ruby"
29
+ @config["labels"].each {|k, v| invocation.labels[k] = v }
30
+ invocation.start_time = Time.now.utc
31
+
32
+ # Add environment-specific information
33
+ invocation.platform = Towel::Environment.platform
34
+ invocation.hostname = Towel::Environment.hostname
35
+ invocation.user = Towel::Environment.user
36
+ invocation.working_dir = Towel::Environment.working_dir
37
+ invocation.argv.concat(Towel::Environment.argv)
38
+ Towel::Environment.env_vars.each do |name, value|
39
+ invocation.env_vars[name] = value
40
+ end
41
+ invocation.revision = Towel::Environment.revision
42
+
43
+ request = Towel::V1alpha::CreateInvocationRequest.new
44
+ request.invocation = invocation
45
+
46
+ @invocation = @stub.create_invocation(request)
47
+
48
+ @invocation.url
49
+ end
50
+
51
+ # Creates a result under a specified group and mark it as running. Returns
52
+ # the resource name of the result, which can be used to set a Log context.
53
+ def create_result(group, name, display_name: nil)
54
+ group_id = find_or_create_group(group)
55
+
56
+ result = Towel::V1alpha::Result.new
57
+
58
+ if @results.key?([group_id, name])
59
+ raise Error, "Result already has been created for '#{name}'"
60
+ else
61
+ result_id = @result_id += 1
62
+ @results[[group_id, name]] = result_id
63
+ end
64
+
65
+ result.name = [
66
+ @invocation.name,
67
+ "groups",
68
+ group_id,
69
+ "results",
70
+ result_id
71
+ ].join "/"
72
+ result.display_name = display_name || name
73
+ result.start_time = Time.now.utc
74
+ result.state = Towel::V1alpha::ResultState::RUNNING
75
+
76
+ request = Towel::V1alpha::CreateResultRequest.new
77
+ request.result = result
78
+
79
+ @stub.create_result(request)
80
+
81
+ ["groups", group_id, "results", result_id].join "/"
82
+ end
83
+
84
+ # Updates a result with its outcome.
85
+ def update_result(group, name, state:, duration: nil, description: nil)
86
+ group_id = find_group(group)
87
+ unless @results.key?([group_id, name])
88
+ raise Error, "Cannot find result '#{name}'"
89
+ end
90
+ result_id = @results[[group_id, name]]
91
+
92
+ result = Towel::V1alpha::Result.new
93
+ update_mask = Google::Protobuf::FieldMask.new
94
+
95
+ result.name = [
96
+ @invocation.name,
97
+ "groups",
98
+ group_id,
99
+ "results",
100
+ result_id
101
+ ].join "/"
102
+
103
+ update_mask.paths << "state"
104
+ result.state = state
105
+
106
+ if !duration.nil?
107
+ update_mask.paths << "duration"
108
+ result.duration = duration
109
+ end
110
+
111
+ if !description.nil?
112
+ update_mask.paths << "description"
113
+ result.description = description
114
+ end
115
+
116
+ request = Towel::V1alpha::UpdateResultRequest.new
117
+ request.result = result
118
+ request.update_mask = update_mask
119
+
120
+ @stub.update_result(request)
121
+ end
122
+
123
+ # Create a new log with a given name. This can be used as-is or wrapped in a
124
+ # LogIO wrapper and used in place of a IO stream.
125
+ def create_log(name)
126
+ raise Error, "Log '#{name}' already exists" if @logs.key?(name)
127
+ resource_name = [@invocation.name, "logs", name].join "/"
128
+ @logs[name] = Log.new(resource_name, @stub)
129
+ end
130
+
131
+ # Mark an invocation as finished (ran to termination without being
132
+ # cancelled). Do not call this and `#cancel` on a given Session. All
133
+ # outstanding logs will be flushed and closed as part of this.
134
+ def finish_invocation
135
+ @logs.each { |name, log| log.close }
136
+
137
+ request = Towel::V1alpha::FinishInvocationRequest.new
138
+ request.name = @invocation.name
139
+ request.end_time = Time.now.utc
140
+
141
+ @stub.finish_invocation(request)
142
+ end
143
+
144
+ # Mark an invocation as cancelled. Do not call this and `#finish` on a
145
+ # given Session.
146
+ def cancel_invocation
147
+ request = Towel::V1alpha::CancelInvocationRequest.new
148
+ request.name = @invocation.name
149
+
150
+ @stub.cancel_invocation(request)
151
+ end
152
+
153
+ private
154
+
155
+ # Returns a Collector stub that the rest of this class can use to
156
+ # communicate with Towel.
157
+ def create_stub
158
+ ssl_credentials = GRPC::Core::ChannelCredentials.new
159
+ bearer_credentials = GRPC::Core::CallCredentials.new(proc {
160
+ { authorization: "Bearer #{@config["auth"]["api_key"]}" }
161
+ })
162
+ credentials = ssl_credentials.compose(bearer_credentials)
163
+
164
+ Towel::V1alpha::Collector::Stub.new(
165
+ @config["collector"]["address"],
166
+ credentials
167
+ )
168
+ end
169
+
170
+ # Finds a group by name and attempts to create it if it does not exist.
171
+ # Returns the ID of the group. Most users of Session will call
172
+ # `#create_result` instead, which calls this behind the scenes.
173
+ def find_or_create_group(name)
174
+ if @groups.key?(name)
175
+ @groups[name]
176
+ else
177
+ # This group doesn't exist yet, so let's create it.
178
+ group_id = @group_id += 1
179
+
180
+ group = Towel::V1alpha::Group.new
181
+ group.name = [@invocation.name, "groups", group_id].join "/"
182
+ group.display_name = name
183
+ request = Towel::V1alpha::CreateGroupRequest.new
184
+ request.group = group
185
+
186
+ @stub.create_group(request)
187
+ @groups[name] = group_id
188
+ group_id
189
+ end
190
+ end
191
+
192
+ # Finds a previously created group by name. Raises `Towel::Error` if the
193
+ # group does not exist.
194
+ def find_group(name)
195
+ raise Error, "Cannot find group '#{name}'" unless @groups.key?(name)
196
+ @groups[name]
197
+ end
198
+ end
199
+ end
@@ -0,0 +1,3 @@
1
+ module Towel
2
+ VERSION = "0.1.0"
3
+ end
data/lib/towel.rb ADDED
@@ -0,0 +1,30 @@
1
+ # Standard library
2
+ require "etc"
3
+ require "socket"
4
+
5
+ # Gems
6
+ require "grpc"
7
+ require "toml"
8
+
9
+ # Generated Protocol Buffer/gRPC definitions
10
+ require "towel/v1alpha/collector_pb"
11
+ require "towel/v1alpha/collector_services_pb"
12
+ require "towel/v1alpha/towel_pb"
13
+
14
+ require "towel/configuration"
15
+ require "towel/environment"
16
+ require "towel/log"
17
+ require "towel/log_io"
18
+ require "towel/session"
19
+ require "towel/version"
20
+
21
+ module Towel
22
+ class Error < StandardError; end
23
+
24
+ # Returns whether Towel has been disabled.
25
+ def self.disabled?
26
+ %w[DISABLE_TOWEL TOWEL_DISABLE].any? do |key|
27
+ ENV.key?(key) && ENV[key] =~ /1|t(rue)?|y(es)?|disabled?/i
28
+ end
29
+ end
30
+ end
data/towel.gemspec ADDED
@@ -0,0 +1,45 @@
1
+
2
+ lib = File.expand_path("../lib", __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require "towel/version"
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "towel"
8
+ spec.version = Towel::VERSION
9
+ spec.authors = ["Andrew Smith"]
10
+ spec.email = ["andrew@velvet.software"]
11
+
12
+ spec.summary = "Towel is a test result collector"
13
+ spec.description = <<END
14
+ Towel collects test results as they run, reporting them to towel.dev for easier
15
+ inspection, debugging, and sharing.
16
+ END
17
+ spec.homepage = "https://towel.dev"
18
+ spec.license = "MIT"
19
+
20
+ if spec.respond_to?(:metadata)
21
+ spec.metadata["homepage_uri"] = spec.homepage
22
+ spec.metadata["source_code_uri"] = "https://bitbucket.org/VelvetSoftware/towel-ruby"
23
+ spec.metadata["changelog_uri"] = "https://bitbucket.org/VelvetSoftware/towel-ruby/src/master/CHANGELOG.md"
24
+ end
25
+
26
+ # Specify which files should be added to the gem when it is released.
27
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
28
+ spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do
29
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
30
+ end
31
+ spec.files += Dir["generated/**/*"]
32
+ spec.bindir = "exe"
33
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
34
+ spec.require_paths = ["lib", "generated"]
35
+
36
+ spec.add_dependency "concurrent-ruby", "~> 1.1"
37
+ spec.add_dependency "grpc", "~> 1.22"
38
+ spec.add_dependency "toml", "~> 0.2"
39
+
40
+ spec.add_development_dependency "bundler", "~> 1.17"
41
+ spec.add_development_dependency "fakefs", "~> 0.20"
42
+ spec.add_development_dependency "grpc-tools", "~> 1.22"
43
+ spec.add_development_dependency "rake", "~> 10.0"
44
+ spec.add_development_dependency "minitest", "~> 5.0"
45
+ end