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,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'cli/errors'
|
|
4
|
+
require_relative 'labels'
|
|
5
|
+
|
|
6
|
+
module Slipway
|
|
7
|
+
# A kubectl label selector.
|
|
8
|
+
class Selector
|
|
9
|
+
# `values` is sorted and holds one value for = and !=, none for existence checks.
|
|
10
|
+
Requirement = Data.define(:key, :operator, :values) do
|
|
11
|
+
def match?(labels)
|
|
12
|
+
case operator
|
|
13
|
+
when :equals, :in then labels.key?(key) && values.include?(labels[key])
|
|
14
|
+
when :not_equals, :not_in then !labels.key?(key) || !values.include?(labels[key])
|
|
15
|
+
when :exists then labels.key?(key)
|
|
16
|
+
when :does_not_exist then !labels.key?(key)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Also the sort key among requirements of one key.
|
|
21
|
+
def to_s
|
|
22
|
+
case operator
|
|
23
|
+
when :equals then "#{key}=#{values.first}"
|
|
24
|
+
when :not_equals then "#{key}!=#{values.first}"
|
|
25
|
+
when :in then "#{key} in (#{values.join(',')})"
|
|
26
|
+
when :not_in then "#{key} notin (#{values.join(',')})"
|
|
27
|
+
when :exists then key
|
|
28
|
+
when :does_not_exist then "!#{key}"
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
class Parser
|
|
34
|
+
TOKEN = /==|!=|[=!(),]|[^\s=!(),]+/
|
|
35
|
+
SYMBOLS = %w[( ) , = == != !].freeze
|
|
36
|
+
KEYWORDS = { 'in' => :in, 'notin' => :not_in }.freeze
|
|
37
|
+
EXACT = { '=' => :equals, '==' => :equals, '!=' => :not_equals }.freeze
|
|
38
|
+
|
|
39
|
+
def initialize(expression)
|
|
40
|
+
@expression = expression
|
|
41
|
+
@tokens = expression.scan(TOKEN)
|
|
42
|
+
@position = 0
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def parse
|
|
46
|
+
return [] if @tokens.empty?
|
|
47
|
+
|
|
48
|
+
requirements = [requirement]
|
|
49
|
+
until peek.nil?
|
|
50
|
+
expect(',', "expected ',' or the end of the selector")
|
|
51
|
+
fail!("expected a requirement after ','") if peek.nil?
|
|
52
|
+
requirements << requirement
|
|
53
|
+
end
|
|
54
|
+
requirements.sort_by { [it.key, it.to_s] }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def requirement
|
|
60
|
+
return build(key_after_bang, :does_not_exist, []) if peek == '!'
|
|
61
|
+
|
|
62
|
+
key = key_token
|
|
63
|
+
case peek
|
|
64
|
+
when nil, ',' then build(key, :exists, [])
|
|
65
|
+
when *EXACT.keys then build(key, EXACT.fetch(advance), [exact_value])
|
|
66
|
+
when *KEYWORDS.keys then build(key, KEYWORDS.fetch(advance), set_values)
|
|
67
|
+
else fail!("unexpected #{peek.inspect} after #{key.inspect}")
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def key_after_bang
|
|
72
|
+
advance
|
|
73
|
+
fail!("expected a key after '!'") unless identifier?(peek) && !KEYWORDS.key?(peek)
|
|
74
|
+
advance
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def key_token
|
|
78
|
+
return advance if identifier?(peek) && !KEYWORDS.key?(peek)
|
|
79
|
+
|
|
80
|
+
fail!(peek.nil? ? 'expected a key' : "expected a key, found #{peek.inspect}")
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# kubectl reads +key=+ and +key=,other+ as a comparison with the empty value.
|
|
84
|
+
def exact_value
|
|
85
|
+
return '' if peek.nil? || peek == ','
|
|
86
|
+
return advance if identifier?(peek)
|
|
87
|
+
|
|
88
|
+
fail!("expected a value, found #{peek.inspect}")
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# An empty slot, as in +()+ or +(a,)+, is the empty value.
|
|
92
|
+
def set_values
|
|
93
|
+
expect('(', "expected '(' after #{@tokens[@position - 1].inspect}")
|
|
94
|
+
values = []
|
|
95
|
+
loop do
|
|
96
|
+
values << (identifier?(peek) ? advance : '')
|
|
97
|
+
break if accept(')')
|
|
98
|
+
|
|
99
|
+
expect(',', "expected ',' or ')' in the value set")
|
|
100
|
+
end
|
|
101
|
+
values.uniq.sort
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def build(key, operator, values)
|
|
105
|
+
fail!("invalid label key #{key.inspect}") unless Labels.valid_key?(key)
|
|
106
|
+
values.each { fail!("invalid label value #{it.inspect}") unless Labels.valid_value?(it) }
|
|
107
|
+
Requirement.new(key:, operator:, values:)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def identifier?(token) = !token.nil? && !SYMBOLS.include?(token)
|
|
111
|
+
|
|
112
|
+
def peek = @tokens[@position]
|
|
113
|
+
|
|
114
|
+
def advance
|
|
115
|
+
token = peek
|
|
116
|
+
@position += 1
|
|
117
|
+
token
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def accept(token)
|
|
121
|
+
return unless peek == token
|
|
122
|
+
|
|
123
|
+
advance
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def expect(token, reason)
|
|
127
|
+
fail!(reason) unless accept(token)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def fail!(reason)
|
|
131
|
+
raise CLI::UsageError, "invalid selector #{@expression.inspect}: #{reason}"
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# nil or a blank string selects everything.
|
|
136
|
+
def self.parse(expression) = new(Parser.new(expression.to_s).parse)
|
|
137
|
+
|
|
138
|
+
def initialize(requirements)
|
|
139
|
+
@requirements = requirements.freeze
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def match?(labels) = @requirements.all? { it.match?(labels) }
|
|
143
|
+
|
|
144
|
+
def to_s = @requirements.join(',')
|
|
145
|
+
end
|
|
146
|
+
end
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'yaml'
|
|
4
|
+
require_relative 'error'
|
|
5
|
+
require_relative 'cli/theme'
|
|
6
|
+
require_relative 'cli/style'
|
|
7
|
+
require_relative 'names'
|
|
8
|
+
|
|
9
|
+
module Slipway
|
|
10
|
+
# The settings in effect for one run. Settings.load resolves each from the flag typed on the
|
|
11
|
+
# command line, then its `SLIPWAY_*` variable, then the configuration file, then its default,
|
|
12
|
+
# and raises Settings::Error naming the file or the variable whose value is refused.
|
|
13
|
+
# `variables` maps each key whose value came from its variable to the variable's name.
|
|
14
|
+
Settings = Data.define(:color, :theme, :editor, :group, :network_timeout, :parallel, :protocols, :path, :exists,
|
|
15
|
+
:variables)
|
|
16
|
+
|
|
17
|
+
class Settings
|
|
18
|
+
class Error < Slipway::Error; end
|
|
19
|
+
|
|
20
|
+
# A git transport name, spelled as a URL scheme. The names reach GIT_ALLOW_PROTOCOL joined by
|
|
21
|
+
# colons, so a name holding a colon would allow a transport nobody listed.
|
|
22
|
+
PROTOCOL = /\A[a-z][a-z0-9+.-]*\z/
|
|
23
|
+
INTEGER = ->(text) { Integer(text, 10, exception: false) }
|
|
24
|
+
PARALLEL = 1..16
|
|
25
|
+
# Capped at a day: Thread#join, which enforces the deadline, takes a timeout past about 1.8e10
|
|
26
|
+
# seconds as already passed.
|
|
27
|
+
NETWORK_TIMEOUT = 1..86_400
|
|
28
|
+
# Colon-separated like GIT_ALLOW_PROTOCOL; an empty field is kept so the check refuses it.
|
|
29
|
+
LIST = ->(text) { text.split(':', -1) }
|
|
30
|
+
# Once GIT_ALLOW_PROTOCOL is set it is git's whole policy, and git's own refusal of ext no
|
|
31
|
+
# longer applies, so these stay refused even when listed.
|
|
32
|
+
UNSAFE_PROTOCOLS = {
|
|
33
|
+
'ext' => 'runs a command named in the URL',
|
|
34
|
+
'fd' => 'reads from file descriptors'
|
|
35
|
+
}.freeze
|
|
36
|
+
|
|
37
|
+
# `parse` reads the string an environment variable holds; what it cannot read comes back as
|
|
38
|
+
# nil and fails the check with the key's expectation, or with `variable_expectation` when the
|
|
39
|
+
# variable is written in another form than the file's value. `refusal` says why a value of the
|
|
40
|
+
# right form is still refused, or returns nil.
|
|
41
|
+
Setting = Data.define(:key, :variable, :default, :description, :valid, :expectation, :parse,
|
|
42
|
+
:variable_expectation, :refusal) do
|
|
43
|
+
def initialize(parse: :itself.to_proc, variable_expectation: nil, refusal: ->(_) {}, **) = super
|
|
44
|
+
|
|
45
|
+
def attribute = key.gsub(/(?=[A-Z])/, '_').downcase.to_sym
|
|
46
|
+
|
|
47
|
+
def check(value, prefix, subject: "\"#{key}\" ", expectation: self.expectation)
|
|
48
|
+
problem = valid.call(value) ? refusal.call(value) : expectation
|
|
49
|
+
return value if problem.nil?
|
|
50
|
+
|
|
51
|
+
raise Error, "#{prefix}: #{subject}#{problem}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def from_variable(text)
|
|
55
|
+
check(parse.call(text), variable, subject: '', expectation: variable_expectation || expectation)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def documentation = [description, default && "Default: #{Array(default).join(', ')}."].compact.join(' ')
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
ALL = [
|
|
62
|
+
Setting.new(key: 'color', variable: 'SLIPWAY_COLOR', default: CLI::Style::DEFAULT_MODE,
|
|
63
|
+
description: 'When to color output: auto, always or never.',
|
|
64
|
+
valid: ->(value) { CLI::Style::MODES.include?(value) },
|
|
65
|
+
expectation: "must be one of #{CLI::Style::MODES.join(', ')}"),
|
|
66
|
+
Setting.new(key: 'editor', variable: 'SLIPWAY_EDITOR', default: nil,
|
|
67
|
+
description: 'Command line of the editor that slipway edit opens.',
|
|
68
|
+
valid: ->(value) { value.is_a?(String) }, expectation: 'must be a string'),
|
|
69
|
+
Setting.new(key: 'group', variable: 'SLIPWAY_GROUP', default: 'default',
|
|
70
|
+
description: 'Group used when -n is not given.',
|
|
71
|
+
valid: ->(value) { Names.valid?(value) },
|
|
72
|
+
expectation: "must be a valid group name: #{Names::RULE}"),
|
|
73
|
+
Setting.new(key: 'networkTimeout', variable: 'SLIPWAY_NETWORK_TIMEOUT', default: 60,
|
|
74
|
+
description: "Seconds, from #{NETWORK_TIMEOUT.min} to #{NETWORK_TIMEOUT.max}, a git network " \
|
|
75
|
+
'command may run before it is killed with the processes it started.',
|
|
76
|
+
valid: ->(value) { value.is_a?(Integer) && NETWORK_TIMEOUT.cover?(value) },
|
|
77
|
+
expectation: "must be an integer from #{NETWORK_TIMEOUT.min} to #{NETWORK_TIMEOUT.max}",
|
|
78
|
+
parse: INTEGER),
|
|
79
|
+
Setting.new(key: 'parallel', variable: 'SLIPWAY_PARALLEL', default: 4,
|
|
80
|
+
description: "How many git network commands run at once, from #{PARALLEL.min} to #{PARALLEL.max}.",
|
|
81
|
+
valid: ->(value) { value.is_a?(Integer) && PARALLEL.cover?(value) },
|
|
82
|
+
expectation: "must be an integer from #{PARALLEL.min} to #{PARALLEL.max}", parse: INTEGER),
|
|
83
|
+
Setting.new(key: 'protocols', variable: 'SLIPWAY_PROTOCOLS', default: %w[ssh https].freeze,
|
|
84
|
+
description: 'Transports git may use in network commands, as a list; any other transport is ' \
|
|
85
|
+
'refused, and so are ext and fd. Add file for local mirrors.',
|
|
86
|
+
valid: lambda { |value|
|
|
87
|
+
value.is_a?(Array) && !value.empty? && value.all? { it.is_a?(String) && PROTOCOL.match?(it) }
|
|
88
|
+
},
|
|
89
|
+
expectation: 'must be a list of lowercase git transport names, such as ssh, https or file',
|
|
90
|
+
variable_expectation: 'must be lowercase git transport names separated by colons, such as ssh:https',
|
|
91
|
+
parse: LIST,
|
|
92
|
+
refusal: lambda { |names|
|
|
93
|
+
unsafe = names.find { UNSAFE_PROTOCOLS.key?(it) }
|
|
94
|
+
"must not include #{unsafe}, which #{UNSAFE_PROTOCOLS[unsafe]}" if unsafe
|
|
95
|
+
}),
|
|
96
|
+
Setting.new(key: 'theme', variable: 'SLIPWAY_THEME', default: CLI::Theme::DEFAULT_NAME,
|
|
97
|
+
description: 'Color theme: dark or light.',
|
|
98
|
+
valid: ->(value) { CLI::Theme::NAMES.include?(value) },
|
|
99
|
+
expectation: "must be one of #{CLI::Theme::NAMES.join(', ')}")
|
|
100
|
+
].freeze
|
|
101
|
+
|
|
102
|
+
KEYS = ALL.map(&:key).freeze
|
|
103
|
+
DOCUMENTATION = ALL.to_h { [it.key, it.documentation] }.freeze
|
|
104
|
+
|
|
105
|
+
class Document
|
|
106
|
+
Contents = Data.define(:values, :exists)
|
|
107
|
+
|
|
108
|
+
def self.read(path, explicit:)
|
|
109
|
+
text = File.read(path)
|
|
110
|
+
rescue Errno::ENOENT
|
|
111
|
+
raise Error, "#{path}: no such file" if explicit
|
|
112
|
+
|
|
113
|
+
Contents.new(values: {}, exists: false)
|
|
114
|
+
rescue SystemCallError => e
|
|
115
|
+
raise Error.from_system_call(e, path)
|
|
116
|
+
else
|
|
117
|
+
Contents.new(values: new(path, text).values, exists: true)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def initialize(path, text)
|
|
121
|
+
@path = path
|
|
122
|
+
@text = text
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def values
|
|
126
|
+
document = parse
|
|
127
|
+
return {} if document.nil?
|
|
128
|
+
raise Error, "#{@path}: expected a mapping of keys to values" unless document.is_a?(Hash)
|
|
129
|
+
|
|
130
|
+
document.to_h { |key, value| [key.to_s, setting(key).check(value, @path)] }
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
private
|
|
134
|
+
|
|
135
|
+
def parse
|
|
136
|
+
Psych.safe_load(@text, filename: @path, symbolize_names: false)
|
|
137
|
+
rescue Psych::SyntaxError => e
|
|
138
|
+
raise Error, "#{@path}: #{e.message.delete_prefix("(#{@path}): ")}"
|
|
139
|
+
rescue Psych::Exception => e
|
|
140
|
+
raise Error, "#{@path}: #{e.message}"
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def setting(key)
|
|
144
|
+
ALL.find { it.key == key } ||
|
|
145
|
+
raise(Error, "#{@path}: unknown key \"#{key}\" (known keys: #{KEYS.join(', ')})")
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def self.load(paths, env:, flags: {})
|
|
150
|
+
path = paths.config_file
|
|
151
|
+
contents = Document.read(path, explicit: paths.config_explicit?)
|
|
152
|
+
resolved = ALL.to_h { |setting| [setting.attribute, resolve(setting, flags, env, contents.values)] }
|
|
153
|
+
# Each key whose value its environment variable supplied, with that variable's name, so a
|
|
154
|
+
# message can point at the variable rather than at a file it outranks.
|
|
155
|
+
variables = ALL.select { from_variable?(it, flags, env) }.to_h { [it.key, it.variable] }.freeze
|
|
156
|
+
new(path:, exists: contents.exists, variables:, **resolved)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def self.resolve(setting, flags, env, file)
|
|
160
|
+
flag = flags[setting.attribute]
|
|
161
|
+
return flag unless flag.nil?
|
|
162
|
+
return setting.from_variable(env[setting.variable]) if from_variable?(setting, flags, env)
|
|
163
|
+
|
|
164
|
+
file.fetch(setting.key, setting.default)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def self.from_variable?(setting, flags, env) = flags[setting.attribute].nil? && !env[setting.variable].to_s.empty?
|
|
168
|
+
private_class_method :resolve, :from_variable?
|
|
169
|
+
|
|
170
|
+
def exists? = exists
|
|
171
|
+
|
|
172
|
+
def to_h = ALL.to_h { [it.key, public_send(it.attribute)] }
|
|
173
|
+
end
|
|
174
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'git/runner'
|
|
4
|
+
|
|
5
|
+
module Slipway
|
|
6
|
+
# A repository can be dirty and behind at once; the STATUS column shows one word, for the
|
|
7
|
+
# fact that needs attention first.
|
|
8
|
+
module State
|
|
9
|
+
# In precedence order.
|
|
10
|
+
ROLES = {
|
|
11
|
+
'Missing' => :status_danger,
|
|
12
|
+
'NotARepo' => :status_danger,
|
|
13
|
+
'Unsafe' => :status_danger,
|
|
14
|
+
'Conflicted' => :status_danger,
|
|
15
|
+
'Detached' => :status_warning,
|
|
16
|
+
'Unborn' => :status_warning,
|
|
17
|
+
'Dirty' => :status_warning,
|
|
18
|
+
'Gone' => :status_warning,
|
|
19
|
+
'Diverged' => :status_warning,
|
|
20
|
+
'Ahead' => :status_warning,
|
|
21
|
+
'Behind' => :status_warning,
|
|
22
|
+
'Clean' => :status_success,
|
|
23
|
+
'Unknown' => :muted
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
26
|
+
# What each word means, in the same order. The help of get and describe prints these, and the
|
|
27
|
+
# README table says the same with its code spans marked.
|
|
28
|
+
MEANINGS = {
|
|
29
|
+
'Missing' => 'The registered path is relative, or is not a directory on this machine.',
|
|
30
|
+
'NotARepo' => 'The directory exists but no repository contains it.',
|
|
31
|
+
'Unsafe' => 'git refused the repository because another user owns it (safe.directory); describe, diff and ' \
|
|
32
|
+
'fetch print the git command that trusts it.',
|
|
33
|
+
'Conflicted' => 'The working tree has unmerged paths.',
|
|
34
|
+
'Detached' => 'HEAD points at a commit rather than a branch.',
|
|
35
|
+
'Unborn' => 'The branch has no commits yet.',
|
|
36
|
+
'Dirty' => 'Staged, modified or untracked files are present.',
|
|
37
|
+
'Gone' => 'An upstream is configured but its remote-tracking ref is gone, as of the last fetch --prune (or a ' \
|
|
38
|
+
'fetch with fetch.prune set).',
|
|
39
|
+
'Diverged' => 'The branch is both ahead of and behind its upstream, as of the last fetch (FETCHED).',
|
|
40
|
+
'Ahead' => 'Commits not yet pushed to the upstream, as of the last fetch (FETCHED).',
|
|
41
|
+
'Behind' => 'Commits on the upstream not yet pulled, as of the last fetch (FETCHED).',
|
|
42
|
+
'Clean' => 'Nothing to do.',
|
|
43
|
+
'Unknown' => 'git could not answer: it is not installed, it did not finish within ' \
|
|
44
|
+
"#{Git::Runner::DEFAULT_TIMEOUT.to_i} seconds, or it failed for a reason slipway does not " \
|
|
45
|
+
'classify. Each distinct reason is printed once on stderr per run.'
|
|
46
|
+
}.freeze
|
|
47
|
+
|
|
48
|
+
# A conflict outranks everything, then the states in which a commit could be lost, then
|
|
49
|
+
# uncommitted work, then the position against upstream.
|
|
50
|
+
def self.derive(status)
|
|
51
|
+
return 'Conflicted' if status.conflicted.positive?
|
|
52
|
+
return 'Detached' if status.detached?
|
|
53
|
+
return 'Unborn' if status.unborn?
|
|
54
|
+
return 'Dirty' unless status.clean?
|
|
55
|
+
return 'Gone' if status.upstream_gone?
|
|
56
|
+
|
|
57
|
+
position(status)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def self.for_error(error)
|
|
61
|
+
case error
|
|
62
|
+
when Git::MissingPath then 'Missing'
|
|
63
|
+
when Git::NotARepository then 'NotARepo'
|
|
64
|
+
when Git::UnsafeRepository then 'Unsafe'
|
|
65
|
+
else 'Unknown'
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def self.role(word) = ROLES.fetch(word)
|
|
70
|
+
|
|
71
|
+
def self.position(status)
|
|
72
|
+
ahead = status.ahead.to_i.positive?
|
|
73
|
+
behind = status.behind.to_i.positive?
|
|
74
|
+
return 'Diverged' if ahead && behind
|
|
75
|
+
return 'Ahead' if ahead
|
|
76
|
+
return 'Behind' if behind
|
|
77
|
+
|
|
78
|
+
'Clean'
|
|
79
|
+
end
|
|
80
|
+
private_class_method :position
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require_relative 'error'
|
|
5
|
+
require_relative 'names'
|
|
6
|
+
require_relative 'resources'
|
|
7
|
+
require_relative 'manifest'
|
|
8
|
+
|
|
9
|
+
module Slipway
|
|
10
|
+
# The registry on disk: one manifest per resource, `groups/NAME.yaml` and
|
|
11
|
+
# `projects/GROUP/NAME.yaml` under `root`. A write goes to a temporary file renamed over the
|
|
12
|
+
# target, so a reader sees the old manifest or the new one and never part of one. Nothing is
|
|
13
|
+
# locked: two processes that write the same resource at once leave the one that renamed last.
|
|
14
|
+
# A file is trusted only when its kind, name and group match where it was found.
|
|
15
|
+
class Store
|
|
16
|
+
class NotFound < Error
|
|
17
|
+
# kubectl's form for every kind: `projects "hldr" not found`, `groups "work" not found`.
|
|
18
|
+
def self.of(kind, name) = new("#{kind.plural} #{name.inspect} not found")
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
class Conflict < Error; end
|
|
22
|
+
|
|
23
|
+
DEFAULT_GROUP = Resources::DEFAULT_GROUP
|
|
24
|
+
PROTECTED_GROUP = 'the default group cannot be deleted'
|
|
25
|
+
|
|
26
|
+
attr_reader :root
|
|
27
|
+
|
|
28
|
+
def initialize(root:, clock: -> { Time.now.utc })
|
|
29
|
+
@root = root
|
|
30
|
+
@clock = clock
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# A file that does not hold a valid manifest raises, or is handed to the block and skipped.
|
|
34
|
+
def list(kind, group: nil, &on_problem)
|
|
35
|
+
files = kind.namespaced? ? project_files(group) : group_files
|
|
36
|
+
resources = files.filter_map { read_or_report(kind, it, &on_problem) }
|
|
37
|
+
kind.namespaced? ? resources.sort_by { [it.group, it.name] } : resources.sort_by(&:name)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# A nil `group` means the default group for projects.
|
|
41
|
+
def find(kind, name, group: nil)
|
|
42
|
+
file = path_for(kind, name, group)
|
|
43
|
+
raise NotFound.of(kind, name) unless File.file?(file)
|
|
44
|
+
|
|
45
|
+
read(kind, file)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# A nil `group` means the default group for projects.
|
|
49
|
+
def exist?(kind, name, group: nil) = File.file?(path_for(kind, name, group))
|
|
50
|
+
|
|
51
|
+
# Unique because project names repeat across groups; completion wants each once.
|
|
52
|
+
def names(kind, group: nil) = list(kind, group:).map(&:name).uniq.sort
|
|
53
|
+
|
|
54
|
+
def create(resource)
|
|
55
|
+
kind = Resources.of(resource)
|
|
56
|
+
target = path_of(kind, resource)
|
|
57
|
+
ensure_group!(resource.group) if kind.namespaced?
|
|
58
|
+
raise Conflict, "#{kind.singular} #{resource.name.inspect} already exists" if File.exist?(target)
|
|
59
|
+
|
|
60
|
+
stamped = resource.created_at ? resource : resource.with(created_at: @clock.call.getutc.floor)
|
|
61
|
+
write(target, stamped)
|
|
62
|
+
stamped
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def save(resource)
|
|
66
|
+
kind = Resources.of(resource)
|
|
67
|
+
target = path_of(kind, resource)
|
|
68
|
+
ensure_group!(resource.group) if kind.namespaced?
|
|
69
|
+
write(target, resource)
|
|
70
|
+
resource
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Deleting a group also removes the registrations of its projects.
|
|
74
|
+
def delete(kind, name, group: nil)
|
|
75
|
+
file = path_for(kind, name, group)
|
|
76
|
+
raise Error, PROTECTED_GROUP if !kind.namespaced? && name == DEFAULT_GROUP
|
|
77
|
+
raise NotFound.of(kind, name) unless File.file?(file)
|
|
78
|
+
|
|
79
|
+
File.delete(file)
|
|
80
|
+
kind.namespaced? ? prune(File.dirname(file)) : FileUtils.rm_rf(File.join(root, 'projects', name))
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def project_count(group_name) = project_files(group_name).size
|
|
84
|
+
|
|
85
|
+
def group_available?(name)
|
|
86
|
+
Names.validate!(name, what: 'group name')
|
|
87
|
+
name == DEFAULT_GROUP || File.file?(group_file(name))
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def ensure_group!(name)
|
|
93
|
+
raise NotFound.of(Resources::GROUPS, name) unless group_available?(name)
|
|
94
|
+
|
|
95
|
+
create(Group.new(name: DEFAULT_GROUP)) if name == DEFAULT_GROUP && !File.file?(group_file(name))
|
|
96
|
+
name
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def path_for(kind, name, group)
|
|
100
|
+
Names.validate!(name, what: "#{kind.singular} name")
|
|
101
|
+
kind.namespaced? ? project_file(group || DEFAULT_GROUP, name) : group_file(name)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def path_of(kind, resource) = path_for(kind, resource.name, kind.namespaced? ? resource.group : nil)
|
|
105
|
+
|
|
106
|
+
def project_file(group, name)
|
|
107
|
+
File.join(root, 'projects', Names.validate!(group, what: 'group name'), "#{name}.yaml")
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def group_file(name) = File.join(root, 'groups', "#{name}.yaml")
|
|
111
|
+
|
|
112
|
+
# A group's project directory exists only while it holds a project. rmdir refuses a
|
|
113
|
+
# directory that is not empty, which is the check itself; there is no locking to lose.
|
|
114
|
+
def prune(directory)
|
|
115
|
+
Dir.rmdir(directory)
|
|
116
|
+
rescue Errno::ENOTEMPTY, Errno::ENOENT
|
|
117
|
+
nil
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# The root is a path the user chose, so it is the glob's base rather than part of the
|
|
121
|
+
# pattern: a bracket or a star in it must not be read as a wildcard.
|
|
122
|
+
def group_files = Dir.glob(File.join('groups', '*.yaml'), base: root).map { File.join(root, it) }
|
|
123
|
+
|
|
124
|
+
def project_files(group)
|
|
125
|
+
pattern = group ? Names.validate!(group, what: 'group name') : '*'
|
|
126
|
+
Dir.glob(File.join('projects', pattern, '*.yaml'), base: root).map { File.join(root, it) }
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def read_or_report(kind, file)
|
|
130
|
+
read(kind, file)
|
|
131
|
+
rescue Error => e
|
|
132
|
+
raise unless block_given?
|
|
133
|
+
|
|
134
|
+
yield e
|
|
135
|
+
nil
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# A manifest is only trusted when its kind, name and group agree with where it was found.
|
|
139
|
+
def read(kind, file)
|
|
140
|
+
resource = Manifest.parse_yaml(read_text(file), source: file)
|
|
141
|
+
return resource if resource.is_a?(kind.klass) && path_of(kind, resource) == file
|
|
142
|
+
|
|
143
|
+
raise Manifest::Invalid.new(file, "describes #{resource.kind.downcase} #{resource.name.inspect}, " \
|
|
144
|
+
'which does not belong at this path')
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def read_text(file)
|
|
148
|
+
File.read(file)
|
|
149
|
+
rescue SystemCallError => e
|
|
150
|
+
raise Error.from_system_call(e, file)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# A temporary file in the target directory renamed over the target, so a reader never sees a partial file.
|
|
154
|
+
def write(target, resource)
|
|
155
|
+
require 'tempfile'
|
|
156
|
+
FileUtils.mkdir_p(root, mode: 0o700)
|
|
157
|
+
directory = File.dirname(target)
|
|
158
|
+
FileUtils.mkdir_p(directory)
|
|
159
|
+
Tempfile.create([".#{File.basename(target)}.", '.tmp'], directory) do |tmp|
|
|
160
|
+
tmp.write(Manifest.dump(resource))
|
|
161
|
+
tmp.fsync
|
|
162
|
+
# Tempfile creates the file 0600; a manifest gets the mode File.write would give it.
|
|
163
|
+
tmp.chmod(0o666 & ~File.umask)
|
|
164
|
+
File.rename(tmp.path, target)
|
|
165
|
+
end
|
|
166
|
+
rescue SystemCallError => e
|
|
167
|
+
raise Error.from_system_call(e, target)
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|