towel 0.1.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.
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