slipway 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/.yardopts +7 -0
- data/CHANGELOG.md +45 -0
- data/LICENSE.txt +21 -0
- data/README.md +1013 -0
- data/exe/slipway +10 -0
- data/lib/slipway/cli/builtins.rb +241 -0
- data/lib/slipway/cli/completer.rb +158 -0
- data/lib/slipway/cli/completion_scripts.rb +163 -0
- data/lib/slipway/cli/context.rb +67 -0
- data/lib/slipway/cli/errors.rb +19 -0
- data/lib/slipway/cli/globals.rb +27 -0
- data/lib/slipway/cli/help_renderer.rb +135 -0
- data/lib/slipway/cli/manpage.rb +226 -0
- data/lib/slipway/cli/parser.rb +45 -0
- data/lib/slipway/cli/registry.rb +191 -0
- data/lib/slipway/cli/runner.rb +186 -0
- data/lib/slipway/cli/style.rb +82 -0
- data/lib/slipway/cli/theme.rb +85 -0
- data/lib/slipway/cli/validator.rb +61 -0
- data/lib/slipway/cli.rb +22 -0
- data/lib/slipway/command_line.rb +22 -0
- data/lib/slipway/commands/api_resources.rb +82 -0
- data/lib/slipway/commands/apply.rb +172 -0
- data/lib/slipway/commands/base.rb +50 -0
- data/lib/slipway/commands/config.rb +73 -0
- data/lib/slipway/commands/create.rb +218 -0
- data/lib/slipway/commands/delete.rb +82 -0
- data/lib/slipway/commands/describe.rb +74 -0
- data/lib/slipway/commands/diff.rb +122 -0
- data/lib/slipway/commands/edit.rb +130 -0
- data/lib/slipway/commands/explain.rb +97 -0
- data/lib/slipway/commands/fetch.rb +112 -0
- data/lib/slipway/commands/from_dir.rb +141 -0
- data/lib/slipway/commands/get.rb +167 -0
- data/lib/slipway/commands/label.rb +114 -0
- data/lib/slipway/commands/manual.rb +67 -0
- data/lib/slipway/commands/options.rb +73 -0
- data/lib/slipway/commands/results.rb +57 -0
- data/lib/slipway/commands/rollout.rb +114 -0
- data/lib/slipway/commands/rollout_spec.rb +99 -0
- data/lib/slipway/commands/rollout_undo.rb +126 -0
- data/lib/slipway/commands/scope.rb +156 -0
- data/lib/slipway/commands/sync.rb +140 -0
- data/lib/slipway/commands.rb +54 -0
- data/lib/slipway/drift.rb +87 -0
- data/lib/slipway/editor.rb +71 -0
- data/lib/slipway/error.rb +27 -0
- data/lib/slipway/fetcher.rb +99 -0
- data/lib/slipway/field_selector.rb +86 -0
- data/lib/slipway/git/branch_name.rb +32 -0
- data/lib/slipway/git/commit.rb +13 -0
- data/lib/slipway/git/distance.rb +13 -0
- data/lib/slipway/git/errors.rb +125 -0
- data/lib/slipway/git/fake.rb +147 -0
- data/lib/slipway/git/fast_forward.rb +12 -0
- data/lib/slipway/git/fast_forwarding.rb +148 -0
- data/lib/slipway/git/fetch_result.rb +22 -0
- data/lib/slipway/git/move_back.rb +12 -0
- data/lib/slipway/git/reflog.rb +25 -0
- data/lib/slipway/git/repository.rb +288 -0
- data/lib/slipway/git/rolling_back.rb +98 -0
- data/lib/slipway/git/runner.rb +175 -0
- data/lib/slipway/git/status.rb +110 -0
- data/lib/slipway/git/url.rb +95 -0
- data/lib/slipway/git.rb +20 -0
- data/lib/slipway/inspector.rb +103 -0
- data/lib/slipway/labels.rb +126 -0
- data/lib/slipway/manifest.rb +265 -0
- data/lib/slipway/names.rb +22 -0
- data/lib/slipway/outcome.rb +45 -0
- data/lib/slipway/output/age.rb +70 -0
- data/lib/slipway/output/describe.rb +71 -0
- data/lib/slipway/output/explain.rb +75 -0
- data/lib/slipway/output/serializer.rb +35 -0
- data/lib/slipway/output/table.rb +67 -0
- data/lib/slipway/output.rb +28 -0
- data/lib/slipway/paths.rb +65 -0
- data/lib/slipway/plan.rb +227 -0
- data/lib/slipway/pool.rb +94 -0
- data/lib/slipway/resources.rb +91 -0
- data/lib/slipway/rollback.rb +236 -0
- data/lib/slipway/rollout_history.rb +69 -0
- data/lib/slipway/runtime.rb +65 -0
- data/lib/slipway/scanner.rb +54 -0
- data/lib/slipway/schema.rb +128 -0
- data/lib/slipway/selector.rb +146 -0
- data/lib/slipway/settings.rb +174 -0
- data/lib/slipway/state.rb +82 -0
- data/lib/slipway/store.rb +170 -0
- data/lib/slipway/syncer.rb +139 -0
- data/lib/slipway/version.rb +5 -0
- data/lib/slipway/views/group.rb +35 -0
- data/lib/slipway/views/project.rb +148 -0
- data/lib/slipway/views.rb +10 -0
- data/lib/slipway/yaml.rb +14 -0
- data/lib/slipway.rb +32 -0
- data/man/man1/slipway-api-resources.1 +53 -0
- data/man/man1/slipway-apply.1 +45 -0
- data/man/man1/slipway-completion.1 +29 -0
- data/man/man1/slipway-config-path.1 +20 -0
- data/man/man1/slipway-config-view.1 +25 -0
- data/man/man1/slipway-config.1 +22 -0
- data/man/man1/slipway-create.1 +89 -0
- data/man/man1/slipway-delete.1 +46 -0
- data/man/man1/slipway-describe.1 +95 -0
- data/man/man1/slipway-diff.1 +120 -0
- data/man/man1/slipway-edit.1 +34 -0
- data/man/man1/slipway-explain.1 +36 -0
- data/man/man1/slipway-fetch.1 +77 -0
- data/man/man1/slipway-get.1 +155 -0
- data/man/man1/slipway-help.1 +19 -0
- data/man/man1/slipway-label.1 +53 -0
- data/man/man1/slipway-man.1 +36 -0
- data/man/man1/slipway-rollout-history.1 +28 -0
- data/man/man1/slipway-rollout-pause.1 +21 -0
- data/man/man1/slipway-rollout-resume.1 +21 -0
- data/man/man1/slipway-rollout-undo.1 +73 -0
- data/man/man1/slipway-rollout-unpin.1 +21 -0
- data/man/man1/slipway-rollout.1 +36 -0
- data/man/man1/slipway-sync.1 +90 -0
- data/man/man1/slipway-version.1 +17 -0
- data/man/man1/slipway.1 +243 -0
- metadata +173 -0
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'psych'
|
|
4
|
+
require 'time'
|
|
5
|
+
require_relative 'error'
|
|
6
|
+
require_relative 'git/branch_name'
|
|
7
|
+
require_relative 'git/url'
|
|
8
|
+
require_relative 'names'
|
|
9
|
+
require_relative 'labels'
|
|
10
|
+
require_relative 'resources'
|
|
11
|
+
require_relative 'schema'
|
|
12
|
+
require_relative 'yaml'
|
|
13
|
+
|
|
14
|
+
module Slipway
|
|
15
|
+
module Manifest
|
|
16
|
+
class Invalid < Error
|
|
17
|
+
attr_reader :source, :problem
|
|
18
|
+
|
|
19
|
+
def initialize(source, problem)
|
|
20
|
+
@source = source
|
|
21
|
+
@problem = problem
|
|
22
|
+
super("#{source}: #{problem}")
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Turns one parsed document into a Project or a Group, or raises Invalid with the source and
|
|
27
|
+
# the first field that breaks its rule. Which fields exist, which are required, their defaults
|
|
28
|
+
# and the words of each refusal come from Schema, so a field Schema does not name is refused.
|
|
29
|
+
# Every field that can reach git or a command slipway prints is checked here, so a resource
|
|
30
|
+
# read from the store or applied from a file can be handed to Git::Repository as it is.
|
|
31
|
+
class Reader
|
|
32
|
+
TIMESTAMP = /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})\z/
|
|
33
|
+
# SHA-1 or SHA-256; an abbreviation can become ambiguous as the repository grows.
|
|
34
|
+
REVISION = /\A(?:[0-9a-f]{40}|[0-9a-f]{64})\z/
|
|
35
|
+
|
|
36
|
+
def initialize(document, source, default_group)
|
|
37
|
+
@document = document
|
|
38
|
+
@source = source
|
|
39
|
+
@default_group = default_group
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def resource
|
|
43
|
+
invalid('document is not a mapping') unless @document.is_a?(Hash)
|
|
44
|
+
@schema = Schema::KINDS.fetch(kind)
|
|
45
|
+
reject_unknown(@document, @schema, nil)
|
|
46
|
+
@metadata = mapping('metadata')
|
|
47
|
+
@spec = mapping('spec')
|
|
48
|
+
kind == 'Project' ? project : group
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def kind
|
|
54
|
+
case @document['kind']
|
|
55
|
+
in String => kind if Schema::KINDS.key?(kind) then kind
|
|
56
|
+
in nil then required('kind')
|
|
57
|
+
in String => other then invalid("\"kind\" must be #{Schema::KINDS.keys.join(' or ')}, not #{other.inspect}")
|
|
58
|
+
else mistyped('kind', Schema::STRING)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def project
|
|
63
|
+
Project.new(name:, group: group_name, labels:, created_at:, path:, description:, remote:, branch:, revision:,
|
|
64
|
+
sync_policy:, paused:)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def group = Group.new(name:, labels:, created_at:, description:)
|
|
68
|
+
|
|
69
|
+
def name
|
|
70
|
+
checked { Names.validate!(string(@metadata, 'metadata', 'name'), what: "#{kind.downcase} name") }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def group_name
|
|
74
|
+
checked { Names.validate!(string(@metadata, 'metadata', 'group') || @default_group, what: 'group name') }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# An empty path would be resolved against whatever directory the reader happens to be in.
|
|
78
|
+
def path
|
|
79
|
+
value = string(@spec, 'spec', 'path')
|
|
80
|
+
broken('spec', 'path') if value.strip.empty?
|
|
81
|
+
value
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def description = string(@spec, 'spec', 'description')
|
|
85
|
+
|
|
86
|
+
def remote
|
|
87
|
+
url = string(@spec, 'spec', 'remote')
|
|
88
|
+
url && checked { Git::Url.validate!(url, field: '"spec.remote"') }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def branch
|
|
92
|
+
name = string(@spec, 'spec', 'branch')
|
|
93
|
+
name && checked { Git::BranchName.validate!(name) }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def revision
|
|
97
|
+
case @spec['revision']
|
|
98
|
+
in nil then nil
|
|
99
|
+
in String => sha if REVISION.match?(sha) then sha
|
|
100
|
+
else broken('spec', 'revision')
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def sync_policy
|
|
105
|
+
field = @schema.dig('spec', 'syncPolicy')
|
|
106
|
+
case @spec['syncPolicy']
|
|
107
|
+
in nil then field.default
|
|
108
|
+
in String => policy if field.enum.include?(policy) then policy
|
|
109
|
+
in String => other then invalid("\"spec.syncPolicy\" #{field.rule}, not #{other.inspect}")
|
|
110
|
+
else mistyped('spec.syncPolicy', Schema::STRING)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def paused
|
|
115
|
+
case @spec['paused']
|
|
116
|
+
in nil then @schema.dig('spec', 'paused').default
|
|
117
|
+
in true | false => paused then paused
|
|
118
|
+
else mistyped('spec.paused', Schema::BOOLEAN)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def labels
|
|
123
|
+
case @metadata['labels']
|
|
124
|
+
in nil then {}
|
|
125
|
+
in Hash => labels then checked { Labels.validate!(string_pairs(labels)) }
|
|
126
|
+
else mistyped('metadata.labels', Schema::LABEL_MAP)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def string_pairs(labels)
|
|
131
|
+
labels.each do |key, value|
|
|
132
|
+
invalid('"metadata.labels" keys must be strings') unless key.is_a?(String)
|
|
133
|
+
mistyped("metadata.labels.#{key}", Schema::STRING) unless value.is_a?(String)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def created_at
|
|
138
|
+
case @metadata['creationTimestamp']
|
|
139
|
+
in nil then nil
|
|
140
|
+
in String => text if TIMESTAMP.match?(text) then time(text)
|
|
141
|
+
else broken('metadata', 'creationTimestamp')
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Second precision, so the value read back equals the value written.
|
|
146
|
+
def time(text)
|
|
147
|
+
Time.iso8601(text).getutc.floor
|
|
148
|
+
rescue ArgumentError
|
|
149
|
+
broken('metadata', 'creationTimestamp')
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def mapping(key)
|
|
153
|
+
case @document[key]
|
|
154
|
+
in nil then {}
|
|
155
|
+
in Hash => section then reject_unknown(section, @schema.field(key), key)
|
|
156
|
+
else mistyped(key, Schema::OBJECT)
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def reject_unknown(section, schema, prefix)
|
|
161
|
+
section.each_key do |key|
|
|
162
|
+
next if schema.field(key)
|
|
163
|
+
|
|
164
|
+
invalid("unknown field #{[prefix, key].compact.join('.').inspect}")
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def string(section, prefix, key)
|
|
169
|
+
case section[key]
|
|
170
|
+
in String => value then value
|
|
171
|
+
in nil if @schema.dig(prefix, key).required then required("#{prefix}.#{key}")
|
|
172
|
+
in nil then nil
|
|
173
|
+
else mistyped("#{prefix}.#{key}", Schema::STRING)
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def checked
|
|
178
|
+
yield
|
|
179
|
+
rescue Names::Invalid, Labels::Invalid, Git::Url::Invalid, Git::BranchName::Invalid => e
|
|
180
|
+
invalid(e.message)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def required(path) = invalid("\"#{path}\" is required")
|
|
184
|
+
|
|
185
|
+
def mistyped(path, type) = invalid("\"#{path}\" must be #{Schema::NOUNS.fetch(type)}")
|
|
186
|
+
|
|
187
|
+
# For the fields whose rule this class checks itself rather than a module such as Names.
|
|
188
|
+
def broken(prefix, key) = invalid("\"#{prefix}.#{key}\" #{@schema.dig(prefix, key).rule}")
|
|
189
|
+
|
|
190
|
+
def invalid(problem)
|
|
191
|
+
raise Invalid.new(@source, problem)
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
private_constant :Reader
|
|
196
|
+
|
|
197
|
+
LIST_KIND = 'List'
|
|
198
|
+
LIST_FIELDS = %w[kind items].freeze
|
|
199
|
+
|
|
200
|
+
# `hash` must have string keys.
|
|
201
|
+
def self.parse(hash, source:, default_group: 'default') = Reader.new(hash, source, default_group).resource
|
|
202
|
+
|
|
203
|
+
def self.load_documents(text, source:)
|
|
204
|
+
documents(text, source).each_with_index.filter_map do |document, index|
|
|
205
|
+
case document
|
|
206
|
+
in nil then nil
|
|
207
|
+
in Hash then document
|
|
208
|
+
else raise Invalid.new(source, "document #{index + 1} is not a mapping")
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
rescue Psych::SyntaxError => e
|
|
212
|
+
raise Invalid.new(source, "#{e.problem} at line #{e.line}, column #{e.column}")
|
|
213
|
+
rescue Psych::DisallowedClass => e
|
|
214
|
+
raise Invalid.new(source, disallowed(e))
|
|
215
|
+
rescue Psych::BadAlias => e
|
|
216
|
+
raise Invalid.new(source, e.message)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# kubectl prints several objects as one List and applies such a List back item by item, so
|
|
220
|
+
# each item stands for a document of its own.
|
|
221
|
+
def self.load_objects(text, source:)
|
|
222
|
+
load_documents(text, source:).flat_map { it['kind'] == LIST_KIND ? list_items(it, source) : [it] }
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def self.list_items(list, source)
|
|
226
|
+
unknown = list.keys - LIST_FIELDS
|
|
227
|
+
raise Invalid.new(source, "unknown field #{unknown.first.inspect} in a List") unless unknown.empty?
|
|
228
|
+
|
|
229
|
+
case list['items']
|
|
230
|
+
in nil then []
|
|
231
|
+
in Array => items if items.all?(Hash) then items
|
|
232
|
+
else raise Invalid.new(source, '"items" of a List must be a sequence of mappings')
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
private_class_method :list_items
|
|
236
|
+
|
|
237
|
+
def self.parse_yaml(text, source:, default_group: 'default')
|
|
238
|
+
case load_documents(text, source:)
|
|
239
|
+
in [document] then parse(document, source:, default_group:)
|
|
240
|
+
in [] then raise Invalid.new(source, 'expected one document, found none')
|
|
241
|
+
in documents then raise Invalid.new(source, "expected one document, found #{documents.size}")
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Psych.safe_load_stream only exists from psych 5.3 and the gem supports Ruby 3.4, so the
|
|
246
|
+
# stream is parsed first and every document re-emitted alone for Psych.safe_load.
|
|
247
|
+
def self.documents(text, source)
|
|
248
|
+
Psych.parse_stream(text, filename: source).children.map do |document|
|
|
249
|
+
stream = Psych::Nodes::Stream.new
|
|
250
|
+
stream.children << document
|
|
251
|
+
Psych.safe_load(stream.to_yaml, filename: source)
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
private_class_method :documents
|
|
255
|
+
|
|
256
|
+
# YAML types an unquoted 2026-09-29T00:12:33Z as a Time, which the safe loader refuses.
|
|
257
|
+
def self.disallowed(error)
|
|
258
|
+
klass = error.message.delete_prefix('Tried to load unspecified class: ')
|
|
259
|
+
"#{klass} values are not accepted; timestamps, dates and symbols must be quoted strings"
|
|
260
|
+
end
|
|
261
|
+
private_class_method :disallowed
|
|
262
|
+
|
|
263
|
+
def self.dump(resource) = Yaml.dump(resource.to_manifest)
|
|
264
|
+
end
|
|
265
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'error'
|
|
4
|
+
|
|
5
|
+
module Slipway
|
|
6
|
+
# The RFC 1123 label rule kubectl applies to object names.
|
|
7
|
+
module Names
|
|
8
|
+
# Callers decide whether the name came from the command line or a file.
|
|
9
|
+
class Invalid < Error; end
|
|
10
|
+
|
|
11
|
+
PATTERN = /\A[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\z/
|
|
12
|
+
RULE = 'lowercase letters, digits and dashes, starting and ending with a letter or digit, at most 63 characters'
|
|
13
|
+
|
|
14
|
+
def self.valid?(name) = name.is_a?(String) && PATTERN.match?(name)
|
|
15
|
+
|
|
16
|
+
def self.validate!(name, what: 'name')
|
|
17
|
+
return name if valid?(name)
|
|
18
|
+
|
|
19
|
+
raise Invalid, "#{name.inspect} is not a valid #{what}: #{RULE}"
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'git'
|
|
4
|
+
require_relative 'state'
|
|
5
|
+
|
|
6
|
+
module Slipway
|
|
7
|
+
# One project's result line in a verb that acts on each selected repository: `word` starts it,
|
|
8
|
+
# `reason` follows in parentheses and `details` are printed under it as they are; the printer
|
|
9
|
+
# redacts them and makes them plain.
|
|
10
|
+
Outcome = Data.define(:project, :word, :reason, :details)
|
|
11
|
+
|
|
12
|
+
class Outcome
|
|
13
|
+
# The words more than one verb prints. A word only one verb prints stays with that verb.
|
|
14
|
+
FETCHED = 'fetched'
|
|
15
|
+
UNCHANGED = 'unchanged'
|
|
16
|
+
SKIPPED = 'skipped'
|
|
17
|
+
PAUSED = 'paused'
|
|
18
|
+
DENIED = 'denied'
|
|
19
|
+
FAILED = 'failed'
|
|
20
|
+
ERROR_WORDS = { Git::AuthRequired => DENIED, Git::LocalUpstream => SKIPPED }.freeze
|
|
21
|
+
REASONS = { Git::AuthRequired => 'AuthRequired', Git::LocalUpstream => 'LocalUpstream',
|
|
22
|
+
Git::Timeout => 'Timeout', Git::WriteTimeout => 'Timeout',
|
|
23
|
+
Git::ProtocolNotAllowed => 'ProtocolNotAllowed' }.freeze
|
|
24
|
+
|
|
25
|
+
def initialize(project:, word:, reason: nil, details: []) = super
|
|
26
|
+
|
|
27
|
+
# The line above already names the project, so the path is cut from the message.
|
|
28
|
+
def self.failure(project, error)
|
|
29
|
+
reason = REASONS.fetch(error.class) { State.for_error(error) }
|
|
30
|
+
new(project:, word: ERROR_WORDS.fetch(error.class, FAILED), reason:,
|
|
31
|
+
details: [detail(error), error.hint].compact)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Nothing else in the result says which directory could not be read, so it leads the detail,
|
|
35
|
+
# as the manifest writes it.
|
|
36
|
+
def self.unreadable(project, inspection)
|
|
37
|
+
error = inspection.error
|
|
38
|
+
new(project:, word: SKIPPED, reason: inspection.state,
|
|
39
|
+
details: ["#{project.path}: #{detail(error)}", error.hint].compact)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.detail(error) = error.message.delete_prefix("#{error.path}: ")
|
|
43
|
+
private_class_method :detail
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slipway
|
|
4
|
+
module Output
|
|
5
|
+
# Formats an elapsed time the way kubectl's AGE column does: two units at most, the
|
|
6
|
+
# precision dropping as the duration grows, and integer truncation at every step.
|
|
7
|
+
module Age
|
|
8
|
+
INVALID = '<invalid>'
|
|
9
|
+
MINUTE = 60
|
|
10
|
+
HOUR = 60
|
|
11
|
+
DAY = 24
|
|
12
|
+
YEAR = 365
|
|
13
|
+
|
|
14
|
+
def self.humanize(from, to)
|
|
15
|
+
return nil if from.nil?
|
|
16
|
+
|
|
17
|
+
format(to - from)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Two or more seconds in the future is invalid; a little clock skew rounds to zero.
|
|
21
|
+
def self.format(seconds)
|
|
22
|
+
seconds = seconds.to_i
|
|
23
|
+
return INVALID if seconds < -1
|
|
24
|
+
return '0s' if seconds.negative?
|
|
25
|
+
return "#{seconds}s" if seconds < 2 * MINUTE
|
|
26
|
+
|
|
27
|
+
from_minutes(seconds)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.from_minutes(seconds)
|
|
31
|
+
minutes = seconds / MINUTE
|
|
32
|
+
return pair(minutes, 'm', seconds % MINUTE, 's') if minutes < 10
|
|
33
|
+
return "#{minutes}m" if minutes < 3 * HOUR
|
|
34
|
+
|
|
35
|
+
from_hours(minutes)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.from_hours(minutes)
|
|
39
|
+
hours = minutes / HOUR
|
|
40
|
+
return pair(hours, 'h', minutes % HOUR, 'm') if hours < 8
|
|
41
|
+
return "#{hours}h" if hours < 2 * DAY
|
|
42
|
+
|
|
43
|
+
from_days(hours)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.from_days(hours)
|
|
47
|
+
days = hours / DAY
|
|
48
|
+
return pair(days, 'd', hours % DAY, 'h') if days < 8
|
|
49
|
+
return "#{days}d" if days < 2 * YEAR
|
|
50
|
+
|
|
51
|
+
from_years(days)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def self.from_years(days)
|
|
55
|
+
years = days / YEAR
|
|
56
|
+
return pair(years, 'y', days % YEAR, 'd') if years < 8
|
|
57
|
+
|
|
58
|
+
"#{years}y"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def self.pair(major, major_unit, minor, minor_unit)
|
|
62
|
+
return "#{major}#{major_unit}" if minor.zero?
|
|
63
|
+
|
|
64
|
+
"#{major}#{major_unit}#{minor}#{minor_unit}"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private_class_method :from_minutes, :from_hours, :from_days, :from_years, :pair
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slipway
|
|
4
|
+
module Output
|
|
5
|
+
# A value whose color the caller has already chosen, such as a STATUS word.
|
|
6
|
+
Painted = Data.define(:role, :text)
|
|
7
|
+
|
|
8
|
+
class Describe
|
|
9
|
+
NONE = '<none>'
|
|
10
|
+
INDENT = ' '
|
|
11
|
+
GAP = 2
|
|
12
|
+
TIME_FORMAT = '%Y-%m-%dT%H:%M:%SZ'
|
|
13
|
+
|
|
14
|
+
def initialize(context)
|
|
15
|
+
@context = context
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# A value that is a non-empty list of [key, value] pairs renders as a nested block; any
|
|
19
|
+
# other list prints one item per line.
|
|
20
|
+
def render(entries)
|
|
21
|
+
return '' if entries.empty?
|
|
22
|
+
|
|
23
|
+
"#{block(entries, 0).join("\n")}\n"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def print(entries) = @context.print(render(entries))
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def block(entries, depth)
|
|
31
|
+
width = entries.map { |key, _| key.to_s.size + 1 }.max + GAP
|
|
32
|
+
entries.flat_map { |key, value| entry(key.to_s, value, depth, width) }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def entry(key, value, depth, width)
|
|
36
|
+
indent = INDENT * depth
|
|
37
|
+
label = "#{indent}#{@context.style.paint_cycle(:describe_keys, depth, key)}:"
|
|
38
|
+
return [label, *block(value, depth + 1)] if section?(value)
|
|
39
|
+
|
|
40
|
+
first, *rest = values(value)
|
|
41
|
+
column = ' ' * (indent.size + width)
|
|
42
|
+
[label + (' ' * (width - key.size - 1)) + first, *rest.map { column + it }]
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def section?(value)
|
|
46
|
+
value.is_a?(Array) && !value.empty? && value.all? { it.is_a?(Array) && it.size == 2 }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Never empty: #entry puts the first line beside the key.
|
|
50
|
+
def values(value)
|
|
51
|
+
case value
|
|
52
|
+
when nil, [], {} then [@context.paint(:none, NONE)]
|
|
53
|
+
when Hash then value.sort.map { |key, item| Output.plain("#{key}=#{item}") }
|
|
54
|
+
when Array then value.map { Output.plain(it) }
|
|
55
|
+
else [scalar(value)]
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def scalar(value)
|
|
60
|
+
case value
|
|
61
|
+
when Painted then @context.paint(value.role, Output.plain(value.text))
|
|
62
|
+
when Numeric then @context.paint(:number, value)
|
|
63
|
+
when true then @context.paint(:boolean_true, value)
|
|
64
|
+
when false then @context.paint(:boolean_false, value)
|
|
65
|
+
when Time then value.utc.strftime(TIME_FORMAT)
|
|
66
|
+
else Output.plain(value)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slipway
|
|
4
|
+
module Output
|
|
5
|
+
# The plaintext layout of kubectl explain: the KIND line, the FIELD line when a field was named,
|
|
6
|
+
# its DESCRIPTION wrapped at 80 columns and the FIELDS under it, each with its description or,
|
|
7
|
+
# with `recursive`, as a tree of names and types. The types line up in one column, where
|
|
8
|
+
# kubectl puts a tab. Colors follow kubecolor: the labels, the field names and the -required-
|
|
9
|
+
# mark.
|
|
10
|
+
class Explain
|
|
11
|
+
# `description` is the text to print whole; `fields` are the fields under this one.
|
|
12
|
+
Field = Data.define(:name, :type, :required, :description, :fields)
|
|
13
|
+
|
|
14
|
+
WIDTH = 80
|
|
15
|
+
INDENT = ' '
|
|
16
|
+
GAP = 3
|
|
17
|
+
REQUIRED = '-required-'
|
|
18
|
+
|
|
19
|
+
def initialize(context)
|
|
20
|
+
@context = context
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# `named` prints the FIELD line, for a field below the resource type itself.
|
|
24
|
+
def render(kind:, field:, named: false, recursive: false)
|
|
25
|
+
sections = ["#{label('KIND')} #{kind}"]
|
|
26
|
+
sections << "#{label('FIELD')} #{field.name} #{type(field)}" if named
|
|
27
|
+
sections << "#{label('DESCRIPTION')}\n#{paragraph(field.description, INDENT * 2)}"
|
|
28
|
+
sections << fields(field, recursive) unless field.fields.empty?
|
|
29
|
+
"#{sections.join("\n\n")}\n"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def print(**) = @context.print(render(**))
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def label(word) = "#{@context.style.paint_cycle(:describe_keys, 0, word)}:"
|
|
37
|
+
|
|
38
|
+
def type(field)
|
|
39
|
+
mark = " #{@context.paint(:explain_required, REQUIRED)}" if field.required
|
|
40
|
+
"<#{field.type}>#{mark}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Each row is [depth, field], with depth 1 for the fields right under the one explained.
|
|
44
|
+
def fields(field, recursive)
|
|
45
|
+
rows = recursive ? tree(field.fields, 1) : field.fields.map { [1, it] }
|
|
46
|
+
width = rows.map { |depth, child| (INDENT * depth).size + child.name.size }.max + GAP
|
|
47
|
+
entries = rows.map { |depth, child| entry(child, depth, width, recursive) }
|
|
48
|
+
"#{label('FIELDS')}\n#{entries.join(recursive ? "\n" : "\n\n")}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def tree(fields, depth) = fields.flat_map { [[depth, it], *tree(it.fields, depth + 1)] }
|
|
52
|
+
|
|
53
|
+
# A recursive listing cycles the name colors by depth, as kubecolor does.
|
|
54
|
+
def entry(field, depth, width, recursive)
|
|
55
|
+
indent = INDENT * depth
|
|
56
|
+
name = @context.style.paint_cycle(:describe_keys, recursive ? depth - 1 : 0, field.name)
|
|
57
|
+
line = "#{indent}#{name}#{' ' * (width - indent.size - field.name.size)}#{type(field)}"
|
|
58
|
+
recursive ? line : "#{line}\n#{paragraph(field.description, indent + INDENT)}"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def paragraph(text, indent) = wrap(text, WIDTH - indent.size).map { "#{indent}#{it}" }.join("\n")
|
|
62
|
+
|
|
63
|
+
# A word longer than the width gets a line of its own rather than being cut.
|
|
64
|
+
def wrap(text, width)
|
|
65
|
+
text.split.each_with_object([]) do |word, lines|
|
|
66
|
+
if lines.empty? || lines.last.size + word.size >= width
|
|
67
|
+
lines << word
|
|
68
|
+
else
|
|
69
|
+
lines[-1] = "#{lines.last} #{word}"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../yaml'
|
|
4
|
+
|
|
5
|
+
module Slipway
|
|
6
|
+
module Output
|
|
7
|
+
# Callers pass Hashes with string keys and strings for timestamps, because the YAML dump
|
|
8
|
+
# admits only the core scalar types.
|
|
9
|
+
module Serializer
|
|
10
|
+
STRUCTURED = %w[json yaml].freeze
|
|
11
|
+
LIST_KIND = 'List'
|
|
12
|
+
|
|
13
|
+
def self.render(format, items, single:)
|
|
14
|
+
document = single ? items.first : { 'kind' => LIST_KIND, 'items' => items }
|
|
15
|
+
case format
|
|
16
|
+
when 'json' then json(document)
|
|
17
|
+
when 'yaml' then Yaml.dump(document)
|
|
18
|
+
else raise ArgumentError, unknown_format(format)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Required lazily: most runs print a table and never need json.
|
|
23
|
+
def self.json(document)
|
|
24
|
+
require 'json'
|
|
25
|
+
"#{JSON.pretty_generate(document)}\n"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.unknown_format(format)
|
|
29
|
+
"unknown structured format #{format.inspect} (known formats: #{STRUCTURED.join(', ')})"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private_class_method :json, :unknown_format
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slipway
|
|
4
|
+
module Output
|
|
5
|
+
# Widths come from the plain text, so color never shifts a column.
|
|
6
|
+
class Table
|
|
7
|
+
NONE = '<none>'
|
|
8
|
+
GAP = ' '
|
|
9
|
+
|
|
10
|
+
# `roles` is called with the header and the plain cell text and may return a theme role
|
|
11
|
+
# that replaces the column color. `color_offset` leading columns stay out of the color cycle.
|
|
12
|
+
def initialize(context, headers:, show_headers: true, roles: nil, color_offset: 0)
|
|
13
|
+
@context = context
|
|
14
|
+
@headers = headers
|
|
15
|
+
@show_headers = show_headers
|
|
16
|
+
@roles = roles
|
|
17
|
+
@color_offset = color_offset
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def render(rows)
|
|
21
|
+
cells = rows.map { |row| @headers.each_index.map { text(row[it]) } }
|
|
22
|
+
widths = column_widths(cells)
|
|
23
|
+
lines = cells.map { row_line(it, widths) }
|
|
24
|
+
lines.unshift(header_line(widths)) if @show_headers
|
|
25
|
+
lines.map { "#{it}\n" }.join
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def print(rows) = @context.print(render(rows))
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def text(value)
|
|
33
|
+
value = Output.plain(value)
|
|
34
|
+
value.empty? ? NONE : value
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def column_widths(cells)
|
|
38
|
+
@headers.each_index.map do |index|
|
|
39
|
+
sizes = cells.map { it[index].size }
|
|
40
|
+
sizes << @headers[index].to_s.size if @show_headers
|
|
41
|
+
sizes.max
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def header_line(widths)
|
|
46
|
+
titles = @headers.each_with_index.map { |header, index| header.to_s.upcase.ljust(widths[index]) }
|
|
47
|
+
@context.paint(:table_header, titles.join(GAP).rstrip)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def row_line(cells, widths)
|
|
51
|
+
painted = cells.each_with_index.map do |cell, index|
|
|
52
|
+
paint_cell(cell, index) + (' ' * (widths[index] - cell.size))
|
|
53
|
+
end
|
|
54
|
+
painted.join(GAP).rstrip
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def paint_cell(cell, index)
|
|
58
|
+
return @context.paint(:none, cell) if cell == NONE
|
|
59
|
+
|
|
60
|
+
role = @roles&.call(@headers[index], cell)
|
|
61
|
+
return @context.paint(role, cell) if role
|
|
62
|
+
|
|
63
|
+
@context.style.paint_cycle(:table_columns, index - @color_offset, cell)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'cli/style'
|
|
4
|
+
require_relative 'output/age'
|
|
5
|
+
require_relative 'output/table'
|
|
6
|
+
require_relative 'output/describe'
|
|
7
|
+
require_relative 'output/explain'
|
|
8
|
+
require_relative 'output/serializer'
|
|
9
|
+
|
|
10
|
+
module Slipway
|
|
11
|
+
module Output
|
|
12
|
+
TABLE = 'table'
|
|
13
|
+
WIDE = 'wide'
|
|
14
|
+
NAME = 'name'
|
|
15
|
+
# Help lists the formats in this order.
|
|
16
|
+
FORMATS = [TABLE, WIDE, *Serializer::STRUCTURED, NAME].freeze
|
|
17
|
+
|
|
18
|
+
# The rule lives in the command layer, whose runner prints the error lines and cannot
|
|
19
|
+
# require Output.
|
|
20
|
+
def self.plain(text) = CLI::Style.plain(text)
|
|
21
|
+
|
|
22
|
+
# A warning can quote a manifest path or git's stderr. Every warning line is built here, and
|
|
23
|
+
# the conventions test refuses the prefix anywhere else, so none reaches stderr raw.
|
|
24
|
+
def self.warning(context, message)
|
|
25
|
+
context.warn("#{context.paint_err(:warning, 'warning:')} #{plain(message)}")
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|