glazed 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 (85) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.yardopts +5 -0
  4. data/CHANGELOG.md +18 -0
  5. data/LICENSE.txt +202 -0
  6. data/NOTICE +16 -0
  7. data/README.md +3 -0
  8. data/Rakefile +12 -0
  9. data/exe/glazed +8 -0
  10. data/lib/glazed/cli_session.rb +178 -0
  11. data/lib/glazed/collector.rb +113 -0
  12. data/lib/glazed/command/base.rb +146 -0
  13. data/lib/glazed/command/orchestrate.rb +78 -0
  14. data/lib/glazed/command/run.rb +51 -0
  15. data/lib/glazed/command/send_signal.rb +69 -0
  16. data/lib/glazed/command/status.rb +179 -0
  17. data/lib/glazed/command/version.rb +23 -0
  18. data/lib/glazed/command.rb +39 -0
  19. data/lib/glazed/command_result.rb +26 -0
  20. data/lib/glazed/dialect/actions.rb +95 -0
  21. data/lib/glazed/dialect/communication_config.rb +63 -0
  22. data/lib/glazed/dialect/interaction.rb +32 -0
  23. data/lib/glazed/dialect/run.rb +84 -0
  24. data/lib/glazed/dialect/runbook.rb +97 -0
  25. data/lib/glazed/dialect/sensitive_pattern.rb +31 -0
  26. data/lib/glazed/dialect.rb +12 -0
  27. data/lib/glazed/dialects/awplus.rb +84 -0
  28. data/lib/glazed/dialects/base.rb +246 -0
  29. data/lib/glazed/dialects/ios.rb +173 -0
  30. data/lib/glazed/dialects/junos.rb +78 -0
  31. data/lib/glazed/dialects/nec_ix.rb +71 -0
  32. data/lib/glazed/dialects/wlc.rb +56 -0
  33. data/lib/glazed/dialects.rb +65 -0
  34. data/lib/glazed/finder.rb +62 -0
  35. data/lib/glazed/must.rb +54 -0
  36. data/lib/glazed/orchestrator/policy.rb +184 -0
  37. data/lib/glazed/orchestrator.rb +288 -0
  38. data/lib/glazed/plugin_registry.rb +70 -0
  39. data/lib/glazed/proto.rb +20 -0
  40. data/lib/glazed/proto_json.rb +34 -0
  41. data/lib/glazed/redactor.rb +158 -0
  42. data/lib/glazed/runner.rb +176 -0
  43. data/lib/glazed/secret.rb +74 -0
  44. data/lib/glazed/secrets.rb +59 -0
  45. data/lib/glazed/shell_command.rb +90 -0
  46. data/lib/glazed/sources/base.rb +79 -0
  47. data/lib/glazed/sources/command.rb +67 -0
  48. data/lib/glazed/sources/inline.rb +56 -0
  49. data/lib/glazed/sources/json_files.rb +87 -0
  50. data/lib/glazed/sources.rb +105 -0
  51. data/lib/glazed/state_backends/base.rb +72 -0
  52. data/lib/glazed/state_backends/sqlite.rb +299 -0
  53. data/lib/glazed/state_backends.rb +75 -0
  54. data/lib/glazed/step_context.rb +65 -0
  55. data/lib/glazed/steps/base.rb +89 -0
  56. data/lib/glazed/steps/batch_shell.rb +57 -0
  57. data/lib/glazed/steps/fetch.rb +67 -0
  58. data/lib/glazed/steps/publish.rb +94 -0
  59. data/lib/glazed/steps/redact.rb +77 -0
  60. data/lib/glazed/steps/shell.rb +107 -0
  61. data/lib/glazed/steps.rb +83 -0
  62. data/lib/glazed/storages/base.rb +167 -0
  63. data/lib/glazed/storages/filesystem.rb +144 -0
  64. data/lib/glazed/storages/git.rb +181 -0
  65. data/lib/glazed/storages/github.rb +269 -0
  66. data/lib/glazed/storages/memory.rb +125 -0
  67. data/lib/glazed/storages.rb +88 -0
  68. data/lib/glazed/transports/base.rb +103 -0
  69. data/lib/glazed/transports/ssh.rb +177 -0
  70. data/lib/glazed/transports/telnet.rb +100 -0
  71. data/lib/glazed/transports.rb +28 -0
  72. data/lib/glazed/v1/config_pb.rb +25 -0
  73. data/lib/glazed/v1/inventory_pb.rb +27 -0
  74. data/lib/glazed/v1/orchestrator_pb.rb +27 -0
  75. data/lib/glazed/v1/redaction_pb.rb +23 -0
  76. data/lib/glazed/v1/secret_pb.rb +22 -0
  77. data/lib/glazed/v1/state_pb.rb +25 -0
  78. data/lib/glazed/v1/steps_pb.rb +25 -0
  79. data/lib/glazed/v1/storage_pb.rb +34 -0
  80. data/lib/glazed/v1/workflow_pb.rb +29 -0
  81. data/lib/glazed/validation.rb +31 -0
  82. data/lib/glazed/version.rb +9 -0
  83. data/lib/glazed/workflow.rb +70 -0
  84. data/lib/glazed.rb +18 -0
  85. metadata +245 -0
@@ -0,0 +1,181 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ # SPDX-License-Identifier: Apache-2.0
5
+
6
+ require 'fileutils'
7
+ require 'open3'
8
+ require 'glazed'
9
+ require 'glazed/must'
10
+ require 'glazed/proto'
11
+ require 'glazed/storages/base'
12
+
13
+ module Glazed
14
+ module Storages
15
+ # Local git repository driven by the git command. Each write is one commit
16
+ # on the configured branch, optionally pulled before and pushed after.
17
+ #
18
+ # @example
19
+ # storage = Glazed::Storages::Git.new(path: '/var/lib/glazed/raw')
20
+ # storage.write(key: 'core/rt1', content: config, message: 'rt1')
21
+ class Git < Base
22
+ # Raised when a git command fails.
23
+ class CommandFailed < Glazed::Error; end
24
+
25
+ # Branch used when none is configured.
26
+ DEFAULT_BRANCH = 'main' #: String
27
+ # Author name used when none is configured.
28
+ DEFAULT_AUTHOR_NAME = 'glazed' #: String
29
+ # Author email used when none is configured.
30
+ DEFAULT_AUTHOR_EMAIL = 'glazed@localhost' #: String
31
+
32
+ # @param path [String] working tree, initialised when missing
33
+ # @param branch [String] branch to commit to
34
+ # @param remote [String, nil] remote name or URL to pull from and push to
35
+ # @param author_name [String] commit author and committer name
36
+ # @param author_email [String] commit author and committer email
37
+ #: (path: String, ?branch: String, ?remote: String?, ?author_name: String, ?author_email: String) -> void
38
+ def initialize(path:, branch: DEFAULT_BRANCH, remote: nil, author_name: DEFAULT_AUTHOR_NAME, author_email: DEFAULT_AUTHOR_EMAIL)
39
+ super()
40
+ @path = path #: String
41
+ @branch = branch #: String
42
+ @remote = remote #: String?
43
+ @author_name = author_name #: String
44
+ @author_email = author_email #: String
45
+ @lock = Mutex.new #: Thread::Mutex
46
+ end
47
+
48
+ # Builds a storage from {Proto::V1::GitStorage}.
49
+ #
50
+ # @param settings [Google::Protobuf::MessageExts] a {Proto::V1::GitStorage}
51
+ # @return [Git] the storage
52
+ # @raise [Must::Violation] when settings is another message
53
+ #: (Google::Protobuf::MessageExts settings) -> Git
54
+ def self.from_proto(settings)
55
+ git = Must.be_a(settings, Proto::V1::GitStorage)
56
+ new(
57
+ path: git.path,
58
+ branch: git.branch.empty? ? DEFAULT_BRANCH : git.branch,
59
+ remote: git.remote.empty? ? nil : git.remote,
60
+ author_name: git.author_name.empty? ? DEFAULT_AUTHOR_NAME : git.author_name,
61
+ author_email: git.author_email.empty? ? DEFAULT_AUTHOR_EMAIL : git.author_email,
62
+ )
63
+ end
64
+
65
+ # @return [String] working tree
66
+ #: String
67
+ attr_reader :path
68
+ # @return [String] branch to commit to
69
+ #: String
70
+ attr_reader :branch
71
+ # @return [String, nil] remote name or URL
72
+ #: String?
73
+ attr_reader :remote
74
+
75
+ # Writes files in one commit. Nothing is committed when no content
76
+ # changed, and the pointers then name the existing HEAD.
77
+ #
78
+ # @param files [Array<Array(String, String)>] keys and contents
79
+ # @param message [Proc] builds the commit message from the changed keys
80
+ # @return [Array<WriteResult>] the commit holding each file, and whether the file changed
81
+ # @raise [InvalidKey] for an unsafe key
82
+ # @raise [CommandFailed] when a git command fails
83
+ #: (Array[Base::file] files, message: Base::message_builder) -> Array[WriteResult]
84
+ def write_all(files, message:)
85
+ keys = files.map { |key, _| validate_key(key) }
86
+ @lock.synchronize do
87
+ prepare
88
+ files.each do |key, content|
89
+ file = File.join(path, key)
90
+ FileUtils.mkdir_p(File.dirname(file))
91
+ File.write(file, content)
92
+ end
93
+ git(['add', '--', *keys])
94
+ changed = git(['diff', '--cached', '--name-only', '-z', '--', *keys]).split("\0")
95
+ unless changed.empty?
96
+ git(['commit', '--quiet', '--no-verify', '-m', message.call(changed)])
97
+ git(['push', '--quiet', Must.expect(remote), "HEAD:refs/heads/#{branch}"]) if remote
98
+ end
99
+ commit = git(%w[rev-parse HEAD]).strip
100
+ keys.map do |key|
101
+ WriteResult.new(pointer: pointer_to(Proto::V1::GitLocation.new(path: key, commit:)), changed: changed.include?(key))
102
+ end
103
+ end
104
+ end
105
+
106
+ # Reads a file at the commit a pointer names.
107
+ #
108
+ # @param pointer [Proto::V1::Pointer] pointer with a git location
109
+ # @return [String] the content
110
+ # @raise [ArgumentError] when the pointer is not a git location
111
+ # @raise [CommandFailed] when the commit or path does not exist
112
+ #: (Proto::V1::Pointer pointer) -> String
113
+ def read(pointer)
114
+ location = location_of(pointer, Proto::V1::GitLocation)
115
+ git(['show', "#{location.commit}:#{validate_key(location.path)}"])
116
+ end
117
+
118
+ # Initialises the repository when missing, switches to the branch and
119
+ # pulls it from the remote when it exists there.
120
+ #
121
+ # @return [void]
122
+ # @raise [CommandFailed] when a git command fails
123
+ #: () -> void
124
+ private def prepare
125
+ unless File.exist?(File.join(path, '.git'))
126
+ FileUtils.mkdir_p(path)
127
+ git(['init', '--quiet', "--initial-branch=#{branch}"])
128
+ end
129
+ unless run_git(%w[symbolic-ref --quiet --short HEAD])[0].strip == branch
130
+ git_success?(['switch', '--quiet', branch]) || git(['switch', '--quiet', '--create', branch])
131
+ end
132
+ pull if remote
133
+ end
134
+
135
+ # @return [void]
136
+ # @raise [CommandFailed] when the pull fails
137
+ #: () -> void
138
+ private def pull
139
+ remote = Must.expect(self.remote)
140
+ return unless git_success?(['ls-remote', '--exit-code', '--heads', remote, branch])
141
+
142
+ git(['pull', '--quiet', '--ff-only', remote, branch])
143
+ end
144
+
145
+ # @param args [Array<String>] git arguments
146
+ # @return [String] standard output
147
+ # @raise [CommandFailed] when git exits non-zero, with its error output
148
+ #: (Array[String] args) -> String
149
+ private def git(args)
150
+ output, status = run_git(args)
151
+ raise CommandFailed, "git #{args.first} failed in #{path}: #{output.strip}" unless status.success?
152
+
153
+ output
154
+ end
155
+
156
+ # @param args [Array<String>] git arguments
157
+ # @return [Boolean] whether git exits zero
158
+ #: (Array[String] args) -> bool
159
+ private def git_success?(args) = run_git(args)[1].success? || false
160
+
161
+ # @param args [Array<String>] git arguments
162
+ # @return [Array(String, Process::Status)] output with errors merged in, and exit status
163
+ #: (Array[String] args) -> [String, Process::Status]
164
+ private def run_git(args)
165
+ output = IO.popen(git_env, ['git', *args], chdir: path, err: %i[child out], &:read)
166
+ [output, Process.last_status]
167
+ end
168
+
169
+ # @return [Hash{String => String}] environment fixing the identity used for commits
170
+ #: () -> Hash[String, String]
171
+ private def git_env
172
+ {
173
+ 'GIT_AUTHOR_NAME' => @author_name,
174
+ 'GIT_AUTHOR_EMAIL' => @author_email,
175
+ 'GIT_COMMITTER_NAME' => @author_name,
176
+ 'GIT_COMMITTER_EMAIL' => @author_email,
177
+ }
178
+ end
179
+ end
180
+ end
181
+ end
@@ -0,0 +1,269 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ # SPDX-License-Identifier: Apache-2.0
5
+
6
+ require 'digest'
7
+ require 'httpx'
8
+ require 'uri'
9
+ require 'glazed'
10
+ require 'glazed/must'
11
+ require 'glazed/proto'
12
+ require 'glazed/secret'
13
+ require 'glazed/storages/base'
14
+ require 'glazed/version'
15
+
16
+ module Glazed
17
+ module Storages
18
+ # GitHub repository written through the Git Data API. Each write is one
19
+ # commit on the branch, which is created from the base branch when missing.
20
+ #
21
+ # @example
22
+ # storage = Glazed::Storages::Github.new(repository: 'org/netconf', branch: 'glazed/auto', token:)
23
+ # storage.write(key: 'core/rt1', content: config, message: 'rt1')
24
+ class Github < Base
25
+ # Raised when the API answers with an error status.
26
+ class ApiError < Glazed::Error
27
+ # @param status [Integer] HTTP status
28
+ # @param message [String] error description
29
+ #: (status: Integer, message: String) -> void
30
+ def initialize(status:, message:)
31
+ super(message)
32
+ @status = status #: Integer
33
+ end
34
+
35
+ # @return [Integer] HTTP status
36
+ #: Integer
37
+ attr_reader :status
38
+ end
39
+
40
+ # API endpoint used when none is configured.
41
+ DEFAULT_API_URL = 'https://api.github.com' #: String
42
+ # Attempts at moving the branch when another writer moved it first.
43
+ MAX_ATTEMPTS = 3 #: Integer
44
+ # REST API version requested.
45
+ API_VERSION = '2022-11-28' #: String
46
+
47
+ # @param repository [String] repository as "owner/name"
48
+ # @param branch [String] branch to commit to
49
+ # @param token [Secret] token with contents write permission
50
+ # @param base_branch [String, nil] branch to start a missing branch from; the default branch when nil
51
+ # @param api_url [String] API endpoint
52
+ # @param author_name [String, nil] commit author name; the token owner when nil
53
+ # @param author_email [String, nil] commit author email; the token owner when nil
54
+ #: (repository: String, branch: String, token: Secret, ?base_branch: String?, ?api_url: String, ?author_name: String?, ?author_email: String?) -> void
55
+ def initialize(repository:, branch:, token:, base_branch: nil, api_url: DEFAULT_API_URL, author_name: nil, author_email: nil)
56
+ super()
57
+ @repository = repository #: String
58
+ @branch = branch #: String
59
+ @token = token #: Secret
60
+ @base_branch = base_branch #: String?
61
+ @api_url = api_url.chomp('/') #: String
62
+ @author = (author_name && author_email ? {name: author_name, email: author_email} : nil) #: Hash[Symbol, String]?
63
+ @http = nil #: HTTPX::Session?
64
+ end
65
+
66
+ # Builds a storage from {Proto::V1::GithubStorage}.
67
+ #
68
+ # @param settings [Google::Protobuf::MessageExts] a {Proto::V1::GithubStorage}
69
+ # @return [Github] the storage
70
+ # @raise [Must::Violation] when settings is another message
71
+ # @raise [ArgumentError] when the token is missing
72
+ #: (Google::Protobuf::MessageExts settings) -> Github
73
+ def self.from_proto(settings)
74
+ github = Must.be_a(settings, Proto::V1::GithubStorage)
75
+ new(
76
+ repository: github.repository,
77
+ branch: github.branch,
78
+ token: Secret.maybe(github.token) || raise(ArgumentError, 'github storage needs a token'),
79
+ base_branch: github.base_branch.empty? ? nil : github.base_branch,
80
+ api_url: github.api_url.empty? ? DEFAULT_API_URL : github.api_url,
81
+ author_name: github.author_name.empty? ? nil : github.author_name,
82
+ author_email: github.author_email.empty? ? nil : github.author_email,
83
+ )
84
+ end
85
+
86
+ # @return [String] repository as "owner/name"
87
+ #: String
88
+ attr_reader :repository
89
+ # @return [String] branch to commit to
90
+ #: String
91
+ attr_reader :branch
92
+
93
+ # Writes the changed files in one commit. Nothing is committed when no
94
+ # content changed, and the pointers then name the existing head.
95
+ #
96
+ # @param files [Array<Array(String, String)>] keys and contents
97
+ # @param message [Proc] builds the commit message from the changed keys
98
+ # @return [Array<WriteResult>] the commit holding each file, and whether the file changed
99
+ # @raise [InvalidKey] for an unsafe key
100
+ # @raise [ApiError] when the API fails, or the branch keeps moving under concurrent writers
101
+ #: (Array[Base::file] files, message: Base::message_builder) -> Array[WriteResult]
102
+ def write_all(files, message:)
103
+ keys = files.map { |key, _| validate_key(key) }
104
+ commit, changed = with_retries { commit_files(files, message) }
105
+ keys.map do |key|
106
+ pointer = pointer_to(Proto::V1::GithubLocation.new(repository:, path: key, commit:))
107
+ WriteResult.new(pointer:, changed: changed.include?(key))
108
+ end
109
+ end
110
+
111
+ # Reads a file at the commit a pointer names.
112
+ #
113
+ # @param pointer [Proto::V1::Pointer] pointer with a github location
114
+ # @return [String] the content
115
+ # @raise [ArgumentError] when the pointer is not a github location
116
+ # @raise [ApiError] when the file or commit does not exist
117
+ #: (Proto::V1::Pointer pointer) -> String
118
+ def read(pointer)
119
+ location = location_of(pointer, Proto::V1::GithubLocation)
120
+ path = "/repos/#{location.repository}/contents/#{escape(validate_key(location.path))}?ref=#{escape(location.commit)}"
121
+ check(http.get(url(path), headers: {'accept' => 'application/vnd.github.raw+json'})).body.to_s
122
+ end
123
+
124
+ # @yieldreturn [Object] result of one attempt
125
+ # @return [Object] result of the first attempt that succeeded
126
+ # @raise [ApiError] when attempts run out
127
+ #: [T] () { () -> T } -> T
128
+ private def with_retries(&attempt)
129
+ attempts = 0
130
+ begin
131
+ attempts += 1
132
+ attempt.call
133
+ rescue ApiError => e
134
+ # 422 means the branch moved or appeared between reading and updating it.
135
+ retry if e.status == 422 && attempts < MAX_ATTEMPTS
136
+ raise
137
+ end
138
+ end
139
+
140
+ # @param files [Array<Array(String, String)>] keys and contents
141
+ # @param message [Proc] builds the commit message from the changed keys
142
+ # @return [Array(String, Array<String>)] SHA of the commit holding the files, and the changed keys
143
+ # @raise [ApiError] when the API fails
144
+ #: (Array[Base::file] files, Base::message_builder message) -> [String, Array[String]]
145
+ private def commit_files(files, message)
146
+ head = ref_sha(branch)
147
+ parent = head || ref_sha(base_branch) || raise(ApiError.new(status: 404, message: "#{repository}: base branch #{base_branch} not found"))
148
+ base_tree = json(get("/repos/#{repository}/git/commits/#{parent}")).dig('tree', 'sha')
149
+ existing = blob_shas(base_tree)
150
+ candidates = existing ? files.reject { |key, content| existing[key] == blob_sha(content) } : files
151
+
152
+ commit = parent
153
+ changed = [] #: Array[String]
154
+ unless candidates.empty?
155
+ entries = candidates.map { |key, content| {path: key, mode: '100644', type: 'blob', content:} }
156
+ tree = json(post("/repos/#{repository}/git/trees", {base_tree:, tree: entries})).fetch('sha')
157
+ unless tree == base_tree
158
+ changed = candidates.map(&:first)
159
+ body = {message: message.call(changed), tree:, parents: [parent], author: @author}.compact
160
+ commit = json(post("/repos/#{repository}/git/commits", body)).fetch('sha')
161
+ end
162
+ end
163
+
164
+ if head.nil?
165
+ post("/repos/#{repository}/git/refs", {ref: "refs/heads/#{branch}", sha: commit})
166
+ elsif commit != head
167
+ patch("/repos/#{repository}/git/refs/heads/#{escape(branch)}", {sha: commit, force: false})
168
+ end
169
+ [commit, changed]
170
+ end
171
+
172
+ # @param tree [String] tree SHA
173
+ # @return [Hash{String => String}, nil] blob SHAs by path, or nil when GitHub truncates the listing
174
+ # @raise [ApiError] when the tree cannot be read
175
+ #: (String tree) -> Hash[String, String]?
176
+ private def blob_shas(tree)
177
+ listing = json(get("/repos/#{repository}/git/trees/#{tree}?recursive=1"))
178
+ return nil if listing['truncated']
179
+
180
+ entries = listing.fetch('tree') #: Array[Hash[String, String]]
181
+ entries.select { it['type'] == 'blob' }.to_h { [it.fetch('path'), it.fetch('sha')] }
182
+ end
183
+
184
+ # @param content [String] file content
185
+ # @return [String] git blob SHA-1 of the content
186
+ #: (String content) -> String
187
+ private def blob_sha(content) = Digest::SHA1.hexdigest("blob #{content.bytesize}\0".b + content.b)
188
+
189
+ # @param name [String] branch name
190
+ # @return [String, nil] SHA the branch points to, or nil when it does not exist
191
+ # @raise [ApiError] for errors other than not found
192
+ #: (String name) -> String?
193
+ private def ref_sha(name)
194
+ json(get("/repos/#{repository}/git/ref/heads/#{escape(name)}")).dig('object', 'sha')
195
+ rescue ApiError => e
196
+ raise unless e.status == 404
197
+
198
+ nil
199
+ end
200
+
201
+ # @return [String] the configured base branch, or the repository default branch
202
+ # @raise [ApiError] when the repository cannot be read
203
+ #: () -> String
204
+ private def base_branch
205
+ @base_branch ||= json(get("/repos/#{repository}")).fetch('default_branch')
206
+ end
207
+
208
+ # @param path [String] API path
209
+ # @return [HTTPX::Response] the successful response
210
+ # @raise [ApiError] for an error status
211
+ #: (String path) -> HTTPX::Response
212
+ private def get(path) = check(http.get(url(path)))
213
+
214
+ # @param path [String] API path
215
+ # @param body [Hash] JSON body
216
+ # @return [HTTPX::Response] the successful response
217
+ # @raise [ApiError] for an error status
218
+ #: (String path, Hash[Symbol, untyped] body) -> HTTPX::Response
219
+ private def post(path, body) = check(http.post(url(path), json: body))
220
+
221
+ # @param path [String] API path
222
+ # @param body [Hash] JSON body
223
+ # @return [HTTPX::Response] the successful response
224
+ # @raise [ApiError] for an error status
225
+ #: (String path, Hash[Symbol, untyped] body) -> HTTPX::Response
226
+ private def patch(path, body) = check(http.patch(url(path), json: body))
227
+
228
+ # @param response [HTTPX::Response] successful response
229
+ # @return [Hash] its decoded JSON body
230
+ #: (HTTPX::Response response) -> Hash[String, untyped]
231
+ private def json(response) = response.json
232
+
233
+ # @param response [HTTPX::Response, HTTPX::ErrorResponse] response to check
234
+ # @return [HTTPX::Response] the response when its status is below 400
235
+ # @raise [ApiError] for an error status
236
+ # @raise [HTTPX::Error] for a transport failure
237
+ #: (HTTPX::Response | HTTPX::ErrorResponse response) -> HTTPX::Response
238
+ private def check(response)
239
+ raise response.error if response.is_a?(HTTPX::ErrorResponse)
240
+ return response if response.status < 400
241
+
242
+ raise ApiError.new(status: response.status, message: "#{repository}: #{response.uri.path} returned #{response.status}: #{response.body}")
243
+ end
244
+
245
+ # @param path [String] API path starting with "/"
246
+ # @return [String] absolute URL
247
+ #: (String path) -> String
248
+ private def url(path) = "#{@api_url}#{path}"
249
+
250
+ # @param value [String] slash-separated value
251
+ # @return [String] each segment percent-encoded
252
+ #: (String value) -> String
253
+ private def escape(value) = value.split('/', -1).map { URI.encode_uri_component(it) }.join('/')
254
+
255
+ # @return [HTTPX::Session] session with authentication headers
256
+ #: () -> HTTPX::Session
257
+ private def http
258
+ @http ||= HTTPX.plugin(:retries).with(
259
+ headers: {
260
+ 'accept' => 'application/vnd.github+json',
261
+ 'authorization' => "Bearer #{@token.value}",
262
+ 'user-agent' => "glazed/#{Glazed::VERSION}",
263
+ 'x-github-api-version' => API_VERSION,
264
+ },
265
+ )
266
+ end
267
+ end
268
+ end
269
+ end
@@ -0,0 +1,125 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ # SPDX-License-Identifier: Apache-2.0
5
+
6
+ require 'digest'
7
+ require 'glazed'
8
+ require 'glazed/must'
9
+ require 'glazed/proto'
10
+ require 'glazed/storages/base'
11
+
12
+ module Glazed
13
+ module Storages
14
+ # Memory of the running process, for content that must not be persisted,
15
+ # such as raw configuration when only the redacted copy is kept. Content
16
+ # lives until the process exits, so only steps of the same run can read it.
17
+ #
18
+ # @example Storage config
19
+ # {"memory": {}}
20
+ class Memory < Base
21
+ # Raised when a pointer names content this process does not hold.
22
+ class MissingContent < Glazed::Error; end
23
+
24
+ #: () -> void
25
+ def initialize
26
+ super
27
+ @contents = {} #: Hash[String, String]
28
+ @latest = {} #: Hash[String, String]
29
+ @lock = Mutex.new #: Thread::Mutex
30
+ end
31
+
32
+ # Builds a storage from {Proto::V1::MemoryStorage}.
33
+ #
34
+ # @param settings [Google::Protobuf::MessageExts] a {Proto::V1::MemoryStorage}
35
+ # @return [Memory] the storage
36
+ # @raise [Must::Violation] when settings is another message
37
+ #: (Google::Protobuf::MessageExts settings) -> Memory
38
+ def self.from_proto(settings)
39
+ Must.be_a(settings, Proto::V1::MemoryStorage)
40
+ new
41
+ end
42
+
43
+ # Keeps each file in memory. A file is changed unless this process
44
+ # already wrote the same content under its key.
45
+ #
46
+ # @param files [Array<Array(String, String)>] keys and contents
47
+ # @param message [Proc] builds a description of the change, unused
48
+ # @return [Array<WriteResult>] the content's key and digest, and whether it changed
49
+ # @raise [InvalidKey] for an unsafe key
50
+ #: (Array[Base::file] files, message: Base::message_builder) -> Array[WriteResult]
51
+ def write_all(files, message:)
52
+ files.each { |key, _| validate_key(key) }
53
+ files.map do |key, content|
54
+ sha256 = Digest::SHA256.hexdigest(content)
55
+ changed = @lock.synchronize do
56
+ @contents[sha256] = content.dup.freeze
57
+ previous = @latest[key]
58
+ @latest[key] = sha256
59
+ previous != sha256
60
+ end
61
+ WriteResult.new(pointer: pointer_to(Proto::V1::MemoryLocation.new(key:, sha256:)), changed:)
62
+ end
63
+ end
64
+
65
+ # @return [Boolean] true; versions are content digests
66
+ #: () -> bool
67
+ def conditional? = true
68
+
69
+ # @param key [String] file key
70
+ # @return [String, nil] SHA-256 of the latest content, or nil when nothing was written
71
+ # @raise [InvalidKey] for an unsafe key
72
+ #: (String key) -> String?
73
+ def version_of(key)
74
+ validate_key(key)
75
+ @lock.synchronize { @latest[key] }
76
+ end
77
+
78
+ # @param key [String] file key
79
+ # @return [Versioned, nil] the latest content and its SHA-256, or nil when nothing was written
80
+ # @raise [InvalidKey] for an unsafe key
81
+ #: (String key) -> Versioned?
82
+ def read_versioned(key)
83
+ validate_key(key)
84
+ @lock.synchronize do
85
+ sha256 = @latest[key]
86
+ Versioned.new(content: @contents.fetch(sha256), version: sha256) if sha256
87
+ end
88
+ end
89
+
90
+ # @param key [String] file key
91
+ # @param content [String] new content
92
+ # @param expected_version [String, nil] SHA-256 read earlier; nil to create a missing key only
93
+ # @return [String] SHA-256 of the content
94
+ # @raise [Conflict] when the key holds other content
95
+ # @raise [InvalidKey] for an unsafe key
96
+ #: (key: String, content: String, expected_version: String?) -> String
97
+ def write_if(key:, content:, expected_version:)
98
+ validate_key(key)
99
+ sha256 = Digest::SHA256.hexdigest(content)
100
+ @lock.synchronize do
101
+ current = @latest[key]
102
+ raise Conflict, "#{key} is at #{current || 'nothing'}, not #{expected_version || 'nothing'}" unless current == expected_version
103
+
104
+ @contents[sha256] = content.dup.freeze
105
+ @latest[key] = sha256
106
+ end
107
+ sha256
108
+ end
109
+
110
+ # Reads content written earlier by this process.
111
+ #
112
+ # @param pointer [Proto::V1::Pointer] pointer with a memory location
113
+ # @return [String] the content
114
+ # @raise [ArgumentError] when the pointer is not a memory location
115
+ # @raise [MissingContent] when this process did not write the content, for
116
+ # example when the pointer comes from another process
117
+ #: (Proto::V1::Pointer pointer) -> String
118
+ def read(pointer)
119
+ location = location_of(pointer, Proto::V1::MemoryLocation)
120
+ content = @lock.synchronize { @contents[location.sha256] }
121
+ content or raise MissingContent, "#{location.key} is not held in this process; memory storage only works within one glazed run"
122
+ end
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,88 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ # SPDX-License-Identifier: Apache-2.0
5
+
6
+ require 'glazed'
7
+ require 'glazed/finder'
8
+ require 'glazed/must'
9
+ require 'glazed/plugin_registry'
10
+ require 'glazed/proto'
11
+ require 'glazed/storages/base'
12
+ require 'glazed/validation'
13
+
14
+ module Glazed
15
+ # Namespace of storage implementations, looked up by name.
16
+ module Storages
17
+ # Raised when a storage config names no backend.
18
+ class MissingBackend < Glazed::Error; end
19
+
20
+ # Finds a storage class by snake_case name.
21
+ #
22
+ # @param name [String, Symbol] storage name, such as "git"
23
+ # @return [Class<Base>] the storage class
24
+ # @raise [Finder::NotFound] when there is none, for example when the gem providing it is not installed
25
+ # @raise [Must::Violation] when the name resolves to something other than a storage
26
+ #: (String | Symbol name) -> singleton(Base)
27
+ def self.find(name)
28
+ found = Finder.find(self, 'glazed/storages', name)
29
+ return found if found.is_a?(Class) && found < Base
30
+
31
+ raise Must::Violation, "#{found} is not a storage"
32
+ end
33
+
34
+ # Storages implemented by plugins, by settings message type.
35
+ PLUGINS = PluginRegistry.new('storage') #: PluginRegistry
36
+
37
+ # Registers a plugin storage, selected by `plugin` configs whose settings
38
+ # are of the given type. Its pointers carry their own location message in
39
+ # the `plugin` case of {Proto::V1::Pointer}.
40
+ #
41
+ # @param settings_class [Class] generated message class of the storage settings
42
+ # @param implementation [Class<Base>] storage built with {Base.from_proto}
43
+ # @return [void]
44
+ #: (singleton(Google::Protobuf::AbstractMessage) settings_class, singleton(Base) implementation) -> void
45
+ def self.register(settings_class, implementation)
46
+ PLUGINS.register(settings_class, implementation)
47
+ end
48
+
49
+ # Builds the storage a config selects.
50
+ #
51
+ # @param config [Proto::V1::StorageConfig] storage settings
52
+ # @return [Base] the storage
53
+ # @raise [InvalidMessage] when the config breaks the rules of its schema
54
+ # @raise [Finder::NotFound] when the backend is not available
55
+ # @raise [PluginRegistry::UnknownPlugin] when no plugin is registered for the settings type
56
+ #: (Proto::V1::StorageConfig config) -> Base
57
+ def self.build(config)
58
+ Validation.validate!(config)
59
+ backend = config.backend or raise MissingBackend, 'storage config sets no backend'
60
+ if (packed = config.plugin)
61
+ implementation, settings = PLUGINS.resolve(packed)
62
+ return storage_class(implementation).from_proto(settings)
63
+ end
64
+
65
+ settings = Must.be_a(config[backend.to_s], Google::Protobuf::MessageExts)
66
+ find(backend).from_proto(settings)
67
+ end
68
+
69
+ # @param implementation [Module] registered implementation
70
+ # @return [Class<Base>] the implementation as a storage class
71
+ # @raise [Must::Violation] when it is not a storage
72
+ #: (Module[untyped] implementation) -> singleton(Base)
73
+ private_class_method def self.storage_class(implementation)
74
+ return implementation if implementation.is_a?(Class) && implementation < Base
75
+
76
+ raise Must::Violation, "#{implementation} is not a storage"
77
+ end
78
+
79
+ # Storage key of a node, "group/name" or just "name" without a group.
80
+ #
81
+ # @param node [Proto::V1::Node] the node
82
+ # @return [String] the key
83
+ #: (Proto::V1::Node node) -> String
84
+ def self.key_for(node)
85
+ node.group.empty? ? node.name : "#{node.group}/#{node.name}"
86
+ end
87
+ end
88
+ end