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.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.yardopts +5 -0
- data/CHANGELOG.md +18 -0
- data/LICENSE.txt +202 -0
- data/NOTICE +16 -0
- data/README.md +3 -0
- data/Rakefile +12 -0
- data/exe/glazed +8 -0
- data/lib/glazed/cli_session.rb +178 -0
- data/lib/glazed/collector.rb +113 -0
- data/lib/glazed/command/base.rb +146 -0
- data/lib/glazed/command/orchestrate.rb +78 -0
- data/lib/glazed/command/run.rb +51 -0
- data/lib/glazed/command/send_signal.rb +69 -0
- data/lib/glazed/command/status.rb +179 -0
- data/lib/glazed/command/version.rb +23 -0
- data/lib/glazed/command.rb +39 -0
- data/lib/glazed/command_result.rb +26 -0
- data/lib/glazed/dialect/actions.rb +95 -0
- data/lib/glazed/dialect/communication_config.rb +63 -0
- data/lib/glazed/dialect/interaction.rb +32 -0
- data/lib/glazed/dialect/run.rb +84 -0
- data/lib/glazed/dialect/runbook.rb +97 -0
- data/lib/glazed/dialect/sensitive_pattern.rb +31 -0
- data/lib/glazed/dialect.rb +12 -0
- data/lib/glazed/dialects/awplus.rb +84 -0
- data/lib/glazed/dialects/base.rb +246 -0
- data/lib/glazed/dialects/ios.rb +173 -0
- data/lib/glazed/dialects/junos.rb +78 -0
- data/lib/glazed/dialects/nec_ix.rb +71 -0
- data/lib/glazed/dialects/wlc.rb +56 -0
- data/lib/glazed/dialects.rb +65 -0
- data/lib/glazed/finder.rb +62 -0
- data/lib/glazed/must.rb +54 -0
- data/lib/glazed/orchestrator/policy.rb +184 -0
- data/lib/glazed/orchestrator.rb +288 -0
- data/lib/glazed/plugin_registry.rb +70 -0
- data/lib/glazed/proto.rb +20 -0
- data/lib/glazed/proto_json.rb +34 -0
- data/lib/glazed/redactor.rb +158 -0
- data/lib/glazed/runner.rb +176 -0
- data/lib/glazed/secret.rb +74 -0
- data/lib/glazed/secrets.rb +59 -0
- data/lib/glazed/shell_command.rb +90 -0
- data/lib/glazed/sources/base.rb +79 -0
- data/lib/glazed/sources/command.rb +67 -0
- data/lib/glazed/sources/inline.rb +56 -0
- data/lib/glazed/sources/json_files.rb +87 -0
- data/lib/glazed/sources.rb +105 -0
- data/lib/glazed/state_backends/base.rb +72 -0
- data/lib/glazed/state_backends/sqlite.rb +299 -0
- data/lib/glazed/state_backends.rb +75 -0
- data/lib/glazed/step_context.rb +65 -0
- data/lib/glazed/steps/base.rb +89 -0
- data/lib/glazed/steps/batch_shell.rb +57 -0
- data/lib/glazed/steps/fetch.rb +67 -0
- data/lib/glazed/steps/publish.rb +94 -0
- data/lib/glazed/steps/redact.rb +77 -0
- data/lib/glazed/steps/shell.rb +107 -0
- data/lib/glazed/steps.rb +83 -0
- data/lib/glazed/storages/base.rb +167 -0
- data/lib/glazed/storages/filesystem.rb +144 -0
- data/lib/glazed/storages/git.rb +181 -0
- data/lib/glazed/storages/github.rb +269 -0
- data/lib/glazed/storages/memory.rb +125 -0
- data/lib/glazed/storages.rb +88 -0
- data/lib/glazed/transports/base.rb +103 -0
- data/lib/glazed/transports/ssh.rb +177 -0
- data/lib/glazed/transports/telnet.rb +100 -0
- data/lib/glazed/transports.rb +28 -0
- data/lib/glazed/v1/config_pb.rb +25 -0
- data/lib/glazed/v1/inventory_pb.rb +27 -0
- data/lib/glazed/v1/orchestrator_pb.rb +27 -0
- data/lib/glazed/v1/redaction_pb.rb +23 -0
- data/lib/glazed/v1/secret_pb.rb +22 -0
- data/lib/glazed/v1/state_pb.rb +25 -0
- data/lib/glazed/v1/steps_pb.rb +25 -0
- data/lib/glazed/v1/storage_pb.rb +34 -0
- data/lib/glazed/v1/workflow_pb.rb +29 -0
- data/lib/glazed/validation.rb +31 -0
- data/lib/glazed/version.rb +9 -0
- data/lib/glazed/workflow.rb +70 -0
- data/lib/glazed.rb +18 -0
- 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
|