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,172 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
require_relative '../manifest'
|
|
5
|
+
require_relative '../store'
|
|
6
|
+
|
|
7
|
+
module Slipway
|
|
8
|
+
module Commands
|
|
9
|
+
class Apply < Base
|
|
10
|
+
# Raised once, after every document that could be applied was.
|
|
11
|
+
class Failed < Error; end
|
|
12
|
+
|
|
13
|
+
DESCRIPTION = "Apply a configuration to a resource by file name or stdin.\n\n" \
|
|
14
|
+
'The resource name must be specified in the manifest. A resource is created when it does ' \
|
|
15
|
+
'not exist yet, configured when its manifest differs from the one stored, and reported ' \
|
|
16
|
+
'unchanged otherwise. A directory applies every *.yaml and *.yml file it holds, sorted by ' \
|
|
17
|
+
'name and without descending into subdirectories. A project manifest without ' \
|
|
18
|
+
"metadata.group lands in the current group.\n\n" \
|
|
19
|
+
'YAML is accepted, with several documents per file, and a document of kind List stands ' \
|
|
20
|
+
'for each of its items. Documents are applied in order, so a group may be created by the ' \
|
|
21
|
+
'document before the projects that use it.'
|
|
22
|
+
STDIN_FLAG = '-'
|
|
23
|
+
NO_OBJECTS = 'no objects passed to apply'
|
|
24
|
+
|
|
25
|
+
FILENAME = CLI::Option.new(long: 'filename', short: 'f', argument: 'FILE', repeatable: true, required: true,
|
|
26
|
+
completer: ->(_given, _current) { CLI::Completer::FILES },
|
|
27
|
+
description: 'The file that contains the manifests to apply; may be repeated. ' \
|
|
28
|
+
"A directory reads its *.yaml and *.yml files, '-' reads stdin.")
|
|
29
|
+
|
|
30
|
+
def self.command(factory)
|
|
31
|
+
CLI::Command.new(
|
|
32
|
+
name: 'apply', summary: 'Apply a configuration to a resource by file name or stdin',
|
|
33
|
+
section: 'Basic Commands', description: DESCRIPTION, examples:,
|
|
34
|
+
options: [FILENAME, Options::DRY_RUN],
|
|
35
|
+
handler: new(factory)
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.examples
|
|
40
|
+
[
|
|
41
|
+
CLI::Example.new(comment: 'Apply the configuration in hldr.yaml to a project',
|
|
42
|
+
command: 'apply -f hldr.yaml'),
|
|
43
|
+
CLI::Example.new(comment: 'Apply every manifest in a directory', command: 'apply -f ./projects'),
|
|
44
|
+
CLI::Example.new(comment: 'Apply the YAML passed into stdin', command: 'apply -f - < hldr.yaml'),
|
|
45
|
+
CLI::Example.new(comment: 'Show what would change without writing anything',
|
|
46
|
+
command: 'apply -f hldr.yaml --dry-run')
|
|
47
|
+
]
|
|
48
|
+
end
|
|
49
|
+
private_class_method :examples
|
|
50
|
+
|
|
51
|
+
def kinds = Resources::KINDS
|
|
52
|
+
|
|
53
|
+
def run(runtime, context, _args, opts)
|
|
54
|
+
dry_run = opts[:dry_run] == true
|
|
55
|
+
session = Session.new(runtime.store, default_group: scope(runtime, context, opts).group, dry_run:)
|
|
56
|
+
opts[:filename].each do |file|
|
|
57
|
+
session.apply(file) { |kind, name, word, role| result_line(context, kind, name, word, role, dry_run:) }
|
|
58
|
+
end
|
|
59
|
+
finish(session)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def finish(session)
|
|
65
|
+
raise Failed, NO_OBJECTS if session.problems.empty? && session.applied.zero?
|
|
66
|
+
raise Failed.new(problems: session.problems) unless session.problems.empty?
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# A failed document is remembered in `problems` so the rest still run.
|
|
70
|
+
class Session
|
|
71
|
+
STDIN_SOURCE = 'STDIN'
|
|
72
|
+
EXTENSIONS = %w[.yaml .yml].freeze
|
|
73
|
+
|
|
74
|
+
attr_reader :problems, :applied
|
|
75
|
+
|
|
76
|
+
def initialize(store, default_group:, dry_run:)
|
|
77
|
+
@store = store
|
|
78
|
+
@default_group = default_group
|
|
79
|
+
@dry_run = dry_run
|
|
80
|
+
@problems = []
|
|
81
|
+
@applied = 0
|
|
82
|
+
@dry_run_groups = []
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def apply(name, &)
|
|
86
|
+
read(name).each { |source, text| apply_stream(source, text, &) }
|
|
87
|
+
rescue Error => e
|
|
88
|
+
@problems << e.message
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
# The Context carries no input stream, so `-` reads the process's standard input.
|
|
94
|
+
def read(name)
|
|
95
|
+
return [[STDIN_SOURCE, $stdin.read]] if name == STDIN_FLAG
|
|
96
|
+
return directory(name) if File.directory?(name)
|
|
97
|
+
|
|
98
|
+
[[name, File.read(name)]]
|
|
99
|
+
rescue Errno::ENOENT
|
|
100
|
+
raise Error, "#{name}: no such file"
|
|
101
|
+
rescue SystemCallError => e
|
|
102
|
+
raise Error.from_system_call(e, name)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def directory(name)
|
|
106
|
+
files = Dir.children(name).select { EXTENSIONS.include?(File.extname(it)) }.sort
|
|
107
|
+
files = files.map { File.join(name, it) }.select { File.file?(it) }
|
|
108
|
+
raise Error, "#{name}: no .yaml or .yml files" if files.empty?
|
|
109
|
+
|
|
110
|
+
files.map { [it, File.read(it)] }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def apply_stream(source, text, &)
|
|
114
|
+
documents = Manifest.load_objects(text, source:)
|
|
115
|
+
documents.each_with_index do |document, index|
|
|
116
|
+
label = documents.size > 1 ? "#{source}:#{index + 1}" : source
|
|
117
|
+
collect(label) { apply_resource(Manifest.parse(document, source: label, default_group: @default_group), &) }
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# A problem the manifest reader found already names its source; a store error is
|
|
122
|
+
# prefixed with it so every collected line reads `<source>: <problem>`.
|
|
123
|
+
def collect(source)
|
|
124
|
+
yield
|
|
125
|
+
rescue Manifest::Invalid => e
|
|
126
|
+
@problems << e.message
|
|
127
|
+
rescue Error => e
|
|
128
|
+
@problems << "#{source}: #{e.message}"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def apply_resource(resource)
|
|
132
|
+
kind = Resources.of(resource)
|
|
133
|
+
group = kind.namespaced? ? resource.group : nil
|
|
134
|
+
existing = @store.find(kind, resource.name, group:) if @store.exist?(kind, resource.name, group:)
|
|
135
|
+
word, role = existing ? update(existing, resource) : create(kind, resource)
|
|
136
|
+
@applied += 1
|
|
137
|
+
yield kind, resource.name, word, role
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# The stored creationTimestamp is kept; anything else that differs is a change.
|
|
141
|
+
def update(existing, incoming)
|
|
142
|
+
incoming = incoming.with(created_at: existing.created_at)
|
|
143
|
+
return ['unchanged', :apply_unchanged] if Manifest.dump(existing) == Manifest.dump(incoming)
|
|
144
|
+
|
|
145
|
+
@store.save(incoming) unless @dry_run
|
|
146
|
+
['configured', :apply_configured]
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def create(kind, resource)
|
|
150
|
+
if @dry_run
|
|
151
|
+
check_group(resource) if kind.namespaced?
|
|
152
|
+
@dry_run_groups << resource.name unless kind.namespaced?
|
|
153
|
+
else
|
|
154
|
+
@store.create(resource)
|
|
155
|
+
end
|
|
156
|
+
['created', :apply_created]
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# A dry run writes nothing, so a group created earlier in the same run is remembered
|
|
160
|
+
# here for the projects that follow it.
|
|
161
|
+
def check_group(project)
|
|
162
|
+
group = project.group
|
|
163
|
+
return if @dry_run_groups.include?(group) || @store.group_available?(group)
|
|
164
|
+
|
|
165
|
+
raise Store::NotFound.of(Resources::GROUPS, group)
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
private_constant :Session
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../cli'
|
|
4
|
+
require_relative '../resources'
|
|
5
|
+
require_relative '../output'
|
|
6
|
+
require_relative 'options'
|
|
7
|
+
require_relative 'scope'
|
|
8
|
+
|
|
9
|
+
module Slipway
|
|
10
|
+
module Commands
|
|
11
|
+
# Subclasses define `self.command(factory)`, the registry entry whose handler is an
|
|
12
|
+
# instance, and `run(runtime, context, args, opts)`, which raises Slipway::Error to fail.
|
|
13
|
+
class Base
|
|
14
|
+
# Prints the warnings here so neither read verb can forget them.
|
|
15
|
+
def self.examine(runtime, context, resources)
|
|
16
|
+
batch = runtime.inspector.examine_all(resources)
|
|
17
|
+
batch.warnings.each { Output.warning(context, it) }
|
|
18
|
+
batch.inspections
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Shared by the verbs that print one line per resource, so the name is neutralized whichever
|
|
22
|
+
# of them prints it.
|
|
23
|
+
def self.result_text(context, kind, name, verb_word, role, reason: nil, dry_run: false)
|
|
24
|
+
line = "#{kind.singular}/#{Output.plain(name)} #{context.paint(role, verb_word)}"
|
|
25
|
+
line = "#{line} (#{reason})" if reason
|
|
26
|
+
dry_run ? "#{line} #{context.paint(:dry_run, '(dry run)')}" : line
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def initialize(factory)
|
|
30
|
+
@factory = factory
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def call(context, args, opts)
|
|
34
|
+
runtime = @factory.call(context, opts)
|
|
35
|
+
run(runtime, context, args, opts)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# The resource types the verb acts on, the ones api-resources lists it under.
|
|
39
|
+
def kinds = []
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def scope(runtime, context, opts) = Scope.new(runtime, context, opts)
|
|
44
|
+
|
|
45
|
+
def result_line(context, kind, name, verb_word, role, dry_run: false)
|
|
46
|
+
context.puts(Base.result_text(context, kind, name, verb_word, role, dry_run:))
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
require_relative '../yaml'
|
|
5
|
+
|
|
6
|
+
module Slipway
|
|
7
|
+
module Commands
|
|
8
|
+
module Config
|
|
9
|
+
DESCRIPTION = 'Inspect the configuration that slipway resolved from flags, environment variables and ' \
|
|
10
|
+
'the configuration file.'
|
|
11
|
+
NOT_FOUND = ' (not found)'
|
|
12
|
+
|
|
13
|
+
def self.command(factory)
|
|
14
|
+
CLI::Command.new(
|
|
15
|
+
name: 'config', summary: 'Inspect the configuration in effect', section: 'Settings Commands',
|
|
16
|
+
description: DESCRIPTION, subcommands: [View.command(factory), Path.command(factory)]
|
|
17
|
+
)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
class View < Base
|
|
21
|
+
DESCRIPTION = "Display the configuration in effect.\n\n" \
|
|
22
|
+
'Prints every setting as YAML after applying the precedence flag, then SLIPWAY_* ' \
|
|
23
|
+
'environment variable, then configuration file, then built-in default. The first line ' \
|
|
24
|
+
'names the configuration file that was consulted, and says (not found) when the default ' \
|
|
25
|
+
'file does not exist. A file named by --config or SLIPWAY_CONFIG must exist.'
|
|
26
|
+
|
|
27
|
+
def self.command(factory)
|
|
28
|
+
CLI::Command.new(
|
|
29
|
+
name: 'view', summary: 'Display the configuration in effect', description: DESCRIPTION,
|
|
30
|
+
examples: [
|
|
31
|
+
CLI::Example.new(comment: 'Show the settings in effect', command: 'config view'),
|
|
32
|
+
CLI::Example.new(comment: 'Show the settings another file would give',
|
|
33
|
+
command: 'config view --config ~/work/slipway.yaml')
|
|
34
|
+
],
|
|
35
|
+
handler: new(factory)
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def run(runtime, context, _args, _opts)
|
|
40
|
+
settings = runtime.settings
|
|
41
|
+
comment = "# #{settings.path}"
|
|
42
|
+
comment += NOT_FOUND unless settings.exists?
|
|
43
|
+
context.puts(context.paint(:muted, comment))
|
|
44
|
+
context.print(Yaml.dump(settings.to_h))
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
class Path < Base
|
|
49
|
+
DESCRIPTION = "Display the path of the configuration file.\n\n" \
|
|
50
|
+
'Prints the file that slipway reads: --config, then SLIPWAY_CONFIG, then ' \
|
|
51
|
+
'$XDG_CONFIG_HOME/slipway/config.yaml. When the default file does not exist, its path is ' \
|
|
52
|
+
'still printed and a note on stderr says so; a file named by --config or SLIPWAY_CONFIG ' \
|
|
53
|
+
'must exist.'
|
|
54
|
+
MISSING = 'The file does not exist; slipway uses its defaults.'
|
|
55
|
+
|
|
56
|
+
def self.command(factory)
|
|
57
|
+
CLI::Command.new(
|
|
58
|
+
name: 'path', summary: 'Display the path of the configuration file', description: DESCRIPTION,
|
|
59
|
+
examples: [CLI::Example.new(comment: 'Print the path of the configuration file', command: 'config path')],
|
|
60
|
+
handler: new(factory)
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# stdout stays the bare path so "$(slipway config path)" can open the file before it exists.
|
|
65
|
+
def run(runtime, context, _args, _opts)
|
|
66
|
+
settings = runtime.settings
|
|
67
|
+
context.puts(settings.path)
|
|
68
|
+
context.warn(context.paint_err(:muted, MISSING)) unless settings.exists?
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
require_relative 'from_dir'
|
|
5
|
+
require_relative '../git/branch_name'
|
|
6
|
+
require_relative '../git/url'
|
|
7
|
+
require_relative '../labels'
|
|
8
|
+
require_relative '../scanner'
|
|
9
|
+
require_relative '../store'
|
|
10
|
+
|
|
11
|
+
module Slipway
|
|
12
|
+
module Commands
|
|
13
|
+
class Create < Base
|
|
14
|
+
DESCRIPTION = "Create a resource by name.\n\n" \
|
|
15
|
+
'A project registers the git repository at --path in the current group, which must ' \
|
|
16
|
+
'exist unless it is the default group. A group is created empty and holds the projects ' \
|
|
17
|
+
"you register with --group later.\n\n" \
|
|
18
|
+
'With --from-dir DIR in place of NAME, create project registers every git repository at DIR ' \
|
|
19
|
+
'or under it, down to --depth levels. Each project is named after its directory, lowercased ' \
|
|
20
|
+
'with each run of characters other than ASCII letters, digits and dashes made one dash, and ' \
|
|
21
|
+
'records its path and the URL of its origin without any credentials; the checked-out branch ' \
|
|
22
|
+
'is not recorded. A path the group already holds is reported unchanged, so running it again ' \
|
|
23
|
+
"adds only new clones.\n\n" \
|
|
24
|
+
'Use --dry-run to check the arguments without writing anything, and -o yaml with it ' \
|
|
25
|
+
"to print the manifest instead, ready for apply -f.\n\n" \
|
|
26
|
+
"#{Options::TYPES_SENTENCE}".freeze
|
|
27
|
+
PATH_MISSING = 'required flag(s) "--path" not set'
|
|
28
|
+
PATH_EMPTY = 'flag --path must not be empty'
|
|
29
|
+
PROJECT_ONLY = 'flag --%s applies to projects only'
|
|
30
|
+
PROJECT_FLAGS = %i[path remote branch].freeze
|
|
31
|
+
EITHER = 'give either NAME or --from-dir, not both'
|
|
32
|
+
NOT_WITH_FROM_DIR = 'flag --%s cannot be used with --from-dir'
|
|
33
|
+
FROM_DIR_EXCLUSIVE = %i[path description remote branch].freeze
|
|
34
|
+
FROM_DIR_EMPTY = 'flag --from-dir must not be empty'
|
|
35
|
+
DEPTHS = 1..Scanner::MAX_DEPTH
|
|
36
|
+
DEPTH_INVALID = "invalid argument %p for --depth: must be an integer from #{DEPTHS.min} to #{DEPTHS.max}".freeze
|
|
37
|
+
DEPTH_WITHOUT_FROM_DIR = 'flag --depth requires --from-dir'
|
|
38
|
+
USAGE = '(TYPE NAME | project --from-dir DIR)'
|
|
39
|
+
|
|
40
|
+
# The resource as written, or as it would be on a dry run, and the word and role of its result line.
|
|
41
|
+
Result = Data.define(:resource, :word, :role)
|
|
42
|
+
|
|
43
|
+
PATH = CLI::Option.new(long: 'path', argument: 'DIR',
|
|
44
|
+
description: 'Directory of the git repository to register; required for a project ' \
|
|
45
|
+
'given by NAME. ' \
|
|
46
|
+
'A relative directory is stored resolved against the current ' \
|
|
47
|
+
'directory, a ~ path as written.')
|
|
48
|
+
TEXT = CLI::Option.new(long: 'description', argument: 'TEXT',
|
|
49
|
+
description: 'A short description of the resource.')
|
|
50
|
+
LABEL = CLI::Option.new(long: 'label', argument: 'KEY=VALUE', repeatable: true,
|
|
51
|
+
description: 'A label to set on the new resource; may be repeated.')
|
|
52
|
+
REMOTE = CLI::Option.new(long: 'remote', argument: 'URL',
|
|
53
|
+
description: 'The URL the origin remote is expected to have, written to spec.remote. ' \
|
|
54
|
+
'A URL that embeds credentials is refused; use a credential helper.')
|
|
55
|
+
BRANCH = CLI::Option.new(long: 'branch', argument: 'NAME',
|
|
56
|
+
description: 'The branch the project is expected to have checked out, written to ' \
|
|
57
|
+
'spec.branch.')
|
|
58
|
+
FROM_DIR = CLI::Option.new(long: 'from-dir', argument: 'DIR',
|
|
59
|
+
completer: ->(_given, _current) { CLI::Completer::FILES },
|
|
60
|
+
description: 'Register every git repository at or under DIR as a project, in ' \
|
|
61
|
+
'place of NAME.')
|
|
62
|
+
# No option default: one would hide whether --depth was typed without --from-dir.
|
|
63
|
+
DEPTH = CLI::Option.new(long: 'depth', argument: 'N',
|
|
64
|
+
description: "How many directory levels under --from-dir to search, from #{DEPTHS.min} " \
|
|
65
|
+
"to #{DEPTHS.max}. The search stops at a repository and never follows " \
|
|
66
|
+
"a symbolic link under --from-dir. (default #{Scanner::DEFAULT_DEPTH})")
|
|
67
|
+
OUTPUT = CLI::Option.new(long: 'output', short: 'o', argument: 'FORMAT',
|
|
68
|
+
enum: [*Output::Serializer::STRUCTURED, Output::NAME],
|
|
69
|
+
description: 'Output format; without it, each resource prints a result line such as ' \
|
|
70
|
+
'project/hldr created.')
|
|
71
|
+
|
|
72
|
+
def self.command(factory)
|
|
73
|
+
CLI::Command.new(
|
|
74
|
+
name: 'create', summary: 'Create a resource by name', section: 'Basic Commands',
|
|
75
|
+
description: DESCRIPTION, examples:, usage: USAGE,
|
|
76
|
+
positionals: [Options::TYPE, Options.name_positional(factory, variadic: false, required: false)],
|
|
77
|
+
options: [PATH, TEXT, LABEL, REMOTE, BRANCH, FROM_DIR, DEPTH, Options::DRY_RUN, OUTPUT],
|
|
78
|
+
handler: new(factory)
|
|
79
|
+
)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def self.examples = named_examples + directory_examples
|
|
83
|
+
|
|
84
|
+
def self.named_examples
|
|
85
|
+
[
|
|
86
|
+
CLI::Example.new(comment: 'Register the repository at ~/dev/hldr as a project in the current group',
|
|
87
|
+
command: "create project hldr --path '~/dev/hldr'"),
|
|
88
|
+
CLI::Example.new(comment: 'Register a project in the work group with two labels',
|
|
89
|
+
command: "create project api --path '~/work/api' -n work --label lang=go --label tier=api"),
|
|
90
|
+
CLI::Example.new(comment: 'Register a project with the remote and the branch it is expected to have',
|
|
91
|
+
command: "create project hldr --path '~/dev/hldr' " \
|
|
92
|
+
'--remote git@github.com:hvpaiva/hldr.git --branch main'),
|
|
93
|
+
CLI::Example.new(comment: 'Create a group with a description',
|
|
94
|
+
command: 'create group work --description "Projects for the day job"'),
|
|
95
|
+
CLI::Example.new(comment: 'Check the arguments without writing the project',
|
|
96
|
+
command: "create project hldr --path '~/dev/hldr' --dry-run"),
|
|
97
|
+
CLI::Example.new(comment: 'Print the manifest of a project without registering it',
|
|
98
|
+
command: "create project hldr --path '~/dev/hldr' --dry-run -o yaml")
|
|
99
|
+
]
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def self.directory_examples
|
|
103
|
+
[
|
|
104
|
+
CLI::Example.new(comment: 'Register every repository in ~/dev/personal in the personal group',
|
|
105
|
+
command: 'create project --from-dir ~/dev/personal -n personal'),
|
|
106
|
+
CLI::Example.new(comment: 'Write the manifests of the repositories two levels under ~/work to a file',
|
|
107
|
+
command: 'create project --from-dir ~/work --depth 2 --dry-run -o yaml > work.yaml')
|
|
108
|
+
]
|
|
109
|
+
end
|
|
110
|
+
private_class_method :examples, :named_examples, :directory_examples
|
|
111
|
+
|
|
112
|
+
def kinds = Resources::KINDS
|
|
113
|
+
|
|
114
|
+
def run(runtime, context, args, opts)
|
|
115
|
+
scope = scope(runtime, context, opts)
|
|
116
|
+
kind, names = scope.targets(args)
|
|
117
|
+
return register_directory(runtime, context, scope, opts) if from_dir?(kind, names, opts)
|
|
118
|
+
raise CLI::UsageError, DEPTH_WITHOUT_FROM_DIR unless opts[:depth].nil?
|
|
119
|
+
|
|
120
|
+
_, name = scope.target(args)
|
|
121
|
+
resource = build(kind, name, scope.group, opts)
|
|
122
|
+
resource = dry_run?(opts) ? check(runtime.store, kind, resource) : runtime.store.create(resource)
|
|
123
|
+
report(context, kind, [Result.new(resource, 'created', :create_created)], opts, single: true)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
private
|
|
127
|
+
|
|
128
|
+
def dry_run?(opts) = opts[:dry_run] == true
|
|
129
|
+
|
|
130
|
+
# NAME and --from-dir are the two ways to say which project to register; a group has only NAME.
|
|
131
|
+
def from_dir?(kind, names, opts)
|
|
132
|
+
return false if opts[:from_dir].nil?
|
|
133
|
+
raise CLI::UsageError, format(PROJECT_ONLY, 'from-dir') unless kind.namespaced?
|
|
134
|
+
raise CLI::UsageError, EITHER unless names.empty?
|
|
135
|
+
|
|
136
|
+
true
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def register_directory(runtime, context, scope, opts)
|
|
140
|
+
check_directory_flags(opts)
|
|
141
|
+
labels = Labels.parse_pairs(opts[:label] || [])
|
|
142
|
+
registrar = FromDir.new(runtime, context, group: scope.group, labels:, dry_run: dry_run?(opts))
|
|
143
|
+
entries = registrar.register(opts[:from_dir], depth: depth(opts[:depth] || Scanner::DEFAULT_DEPTH))
|
|
144
|
+
report(context, Resources::PROJECTS, entries.map { result_of(it) }, opts, single: false)
|
|
145
|
+
raise Error.new(problems: registrar.problems) unless registrar.problems.empty?
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def check_directory_flags(opts)
|
|
149
|
+
flag = FROM_DIR_EXCLUSIVE.find { !opts[it].nil? }
|
|
150
|
+
raise CLI::UsageError, format(NOT_WITH_FROM_DIR, flag) if flag
|
|
151
|
+
raise CLI::UsageError, FROM_DIR_EMPTY if opts[:from_dir].strip.empty?
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def depth(value)
|
|
155
|
+
depth = Integer(value.to_s, 10, exception: false)
|
|
156
|
+
DEPTHS.cover?(depth) ? depth : raise(CLI::UsageError, format(DEPTH_INVALID, value.to_s))
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def result_of(entry)
|
|
160
|
+
if entry.created then Result.new(entry.project, 'created', :create_created)
|
|
161
|
+
else Result.new(entry.project, 'unchanged', :apply_unchanged)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def report(context, kind, results, opts, single:)
|
|
166
|
+
dry_run = dry_run?(opts)
|
|
167
|
+
case opts[:output]
|
|
168
|
+
when nil then results.each { result_line(context, kind, it.resource.name, it.word, it.role, dry_run:) }
|
|
169
|
+
when Output::NAME then results.each { context.puts("#{kind.singular}/#{it.resource.name}") }
|
|
170
|
+
else context.print(Output::Serializer.render(opts[:output], results.map { it.resource.to_manifest }, single:))
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def build(kind, name, group, opts)
|
|
175
|
+
labels = Labels.parse_pairs(opts[:label] || [])
|
|
176
|
+
return project(name, group, labels, opts) if kind.namespaced?
|
|
177
|
+
|
|
178
|
+
flag = PROJECT_FLAGS.find { !opts[it].nil? }
|
|
179
|
+
raise CLI::UsageError, format(PROJECT_ONLY, flag) if flag
|
|
180
|
+
|
|
181
|
+
Group.new(name:, labels:, description: opts[:description])
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def project(name, group, labels, opts)
|
|
185
|
+
Project.new(name:, group:, labels:, path: project_path(opts[:path]), description: opts[:description],
|
|
186
|
+
remote: opts[:remote] && usage { Git::Url.validate!(opts[:remote], field: 'flag --remote') },
|
|
187
|
+
branch: opts[:branch] && usage { Git::BranchName.validate!(opts[:branch]) })
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# A manifest has no working directory, so a relative --path is resolved here, against
|
|
191
|
+
# the directory the command was typed in; a ~ path stays portable as written.
|
|
192
|
+
def project_path(path)
|
|
193
|
+
raise CLI::UsageError, PATH_MISSING if path.nil?
|
|
194
|
+
raise CLI::UsageError, PATH_EMPTY if path.strip.empty?
|
|
195
|
+
return path if path.start_with?('~') || File.absolute_path?(path)
|
|
196
|
+
|
|
197
|
+
File.absolute_path(path)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# The checks a manifest's spec.remote and spec.branch pass, so a refusal reads the same; typed on
|
|
201
|
+
# the command line, it is a usage error.
|
|
202
|
+
def usage
|
|
203
|
+
yield
|
|
204
|
+
rescue Git::Url::Invalid, Git::BranchName::Invalid => e
|
|
205
|
+
raise CLI::UsageError, e.message
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# What a real create would reject before writing.
|
|
209
|
+
def check(store, kind, resource)
|
|
210
|
+
if kind.namespaced? && !store.group_available?(resource.group)
|
|
211
|
+
raise Store::NotFound.of(Resources::GROUPS, resource.group)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
resource
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
require_relative '../store'
|
|
5
|
+
|
|
6
|
+
module Slipway
|
|
7
|
+
module Commands
|
|
8
|
+
class Delete < Base
|
|
9
|
+
DESCRIPTION = "Delete resources by type and name.\n\n" \
|
|
10
|
+
'Every name is resolved before anything is deleted, so a name that does not exist leaves ' \
|
|
11
|
+
'the others untouched unless --ignore-not-found is set. Deleting a group also deletes the ' \
|
|
12
|
+
"registrations of its projects; the default group cannot be deleted.\n\n" \
|
|
13
|
+
"Deleting a project removes its registration only. The repository on disk is not touched.\n\n" \
|
|
14
|
+
"#{Options::TYPES_SENTENCE}".freeze
|
|
15
|
+
USAGE = '(TYPE NAME... | TYPE/NAME...)'
|
|
16
|
+
NO_NAMES = 'resource(s) were provided, but no name was specified'
|
|
17
|
+
|
|
18
|
+
IGNORE_NOT_FOUND = CLI::Option.new(long: 'ignore-not-found',
|
|
19
|
+
description: 'Treat "resource not found" as a successful delete.')
|
|
20
|
+
|
|
21
|
+
def self.command(factory)
|
|
22
|
+
CLI::Command.new(
|
|
23
|
+
name: 'delete', summary: 'Delete resources by type and name', section: 'Basic Commands',
|
|
24
|
+
description: DESCRIPTION, examples:, usage: USAGE,
|
|
25
|
+
positionals: [Options::TYPE, Options.name_positional(factory, variadic: true, required: false)],
|
|
26
|
+
options: [Options::DRY_RUN, IGNORE_NOT_FOUND],
|
|
27
|
+
handler: new(factory)
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.examples
|
|
32
|
+
[
|
|
33
|
+
CLI::Example.new(comment: 'Delete a project from the current group', command: 'delete project hldr'),
|
|
34
|
+
CLI::Example.new(comment: 'Delete two projects from the work group',
|
|
35
|
+
command: 'delete projects api web -n work'),
|
|
36
|
+
CLI::Example.new(comment: 'Delete a group and the registrations of its projects',
|
|
37
|
+
command: 'delete group work'),
|
|
38
|
+
CLI::Example.new(comment: 'Delete a project if it exists, quietly otherwise',
|
|
39
|
+
command: 'delete project hldr --ignore-not-found')
|
|
40
|
+
]
|
|
41
|
+
end
|
|
42
|
+
private_class_method :examples
|
|
43
|
+
|
|
44
|
+
def kinds = Resources::KINDS
|
|
45
|
+
|
|
46
|
+
def run(runtime, context, args, opts)
|
|
47
|
+
scope = scope(runtime, context, opts)
|
|
48
|
+
kind, names = scope.targets(args)
|
|
49
|
+
raise CLI::UsageError, NO_NAMES if names.empty?
|
|
50
|
+
|
|
51
|
+
group = kind.namespaced? ? scope.group : nil
|
|
52
|
+
dry_run = opts[:dry_run] == true
|
|
53
|
+
resolve(runtime.store, kind, names.uniq, group, ignore: opts[:ignore_not_found] == true).each do |resource|
|
|
54
|
+
runtime.store.delete(kind, resource.name, group:) unless dry_run
|
|
55
|
+
context.puts(line(context, kind, resource, dry_run:))
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
# Runs before anything is removed, so one bad name deletes nothing.
|
|
62
|
+
def resolve(store, kind, names, group, ignore:)
|
|
63
|
+
names.filter_map do |name|
|
|
64
|
+
raise Error, Store::PROTECTED_GROUP if !kind.namespaced? && name == Store::DEFAULT_GROUP
|
|
65
|
+
|
|
66
|
+
store.find(kind, name, group:)
|
|
67
|
+
rescue Store::NotFound
|
|
68
|
+
raise unless ignore
|
|
69
|
+
|
|
70
|
+
nil
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def line(context, kind, resource, dry_run:)
|
|
75
|
+
parts = ["#{kind.singular} #{resource.name.inspect}", context.paint(:delete_deleted, 'deleted')]
|
|
76
|
+
parts << "from #{resource.group} group" if kind.namespaced?
|
|
77
|
+
parts << context.paint(:dry_run, '(dry run)') if dry_run
|
|
78
|
+
parts.join(' ')
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
require_relative '../views'
|
|
5
|
+
|
|
6
|
+
module Slipway
|
|
7
|
+
module Commands
|
|
8
|
+
class Describe < Base
|
|
9
|
+
DESCRIPTION = "Show details of one or many resources.\n\n" \
|
|
10
|
+
'Print a detailed description of the selected resources, including the state of the ' \
|
|
11
|
+
'repository at the registered path and where it differs from the manifest. You may select ' \
|
|
12
|
+
"a single object by name, all objects of that type, or use a label or field selector.\n\n" \
|
|
13
|
+
'Status is one of the words listed below. Last Fetch reads ' \
|
|
14
|
+
"#{Views::Project::NEVER} when git answered and no fetch is on record, and a field with no " \
|
|
15
|
+
"value reads #{Output::Describe::NONE}. When git cannot read the repository, the Repository " \
|
|
16
|
+
"block holds git's reason instead of the fields.\n\n" \
|
|
17
|
+
"#{Options::TYPES_SENTENCE}".freeze
|
|
18
|
+
|
|
19
|
+
def self.command(factory)
|
|
20
|
+
CLI::Command.new(
|
|
21
|
+
name: 'describe', summary: 'Show details of one or many resources',
|
|
22
|
+
section: 'Basic Commands', description: DESCRIPTION, examples:, usage: Get::USAGE,
|
|
23
|
+
glossaries: [Get::STATUS_WORDS],
|
|
24
|
+
positionals: [Options::TYPE, Options.name_positional(factory, variadic: true, required: false)],
|
|
25
|
+
options: [Options::SELECTOR, Options::FIELD_SELECTOR, Options::ALL_GROUPS],
|
|
26
|
+
handler: new(factory)
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.examples
|
|
31
|
+
[
|
|
32
|
+
CLI::Example.new(comment: 'Describe a project', command: 'describe project hldr'),
|
|
33
|
+
CLI::Example.new(comment: 'Describe every project in the work group', command: 'describe projects -n work'),
|
|
34
|
+
CLI::Example.new(comment: 'Describe the projects labeled lang=rust',
|
|
35
|
+
command: 'describe projects -l lang=rust'),
|
|
36
|
+
CLI::Example.new(comment: 'Describe the projects on the main branch',
|
|
37
|
+
command: 'describe projects --field-selector status.branch=main'),
|
|
38
|
+
CLI::Example.new(comment: 'Describe a group', command: 'describe group work')
|
|
39
|
+
]
|
|
40
|
+
end
|
|
41
|
+
private_class_method :examples
|
|
42
|
+
|
|
43
|
+
def kinds = Resources::KINDS
|
|
44
|
+
|
|
45
|
+
def run(runtime, context, args, opts)
|
|
46
|
+
scope = scope(runtime, context, opts)
|
|
47
|
+
kind, names = scope.targets(args)
|
|
48
|
+
fields = scope.field_selector(kind, names)
|
|
49
|
+
scope.select(kind, names) do |resources|
|
|
50
|
+
blocks = kind.namespaced? ? projects(runtime, context, resources, fields) : groups(runtime, resources, fields)
|
|
51
|
+
next scope.report_none(kind) if blocks.empty?
|
|
52
|
+
|
|
53
|
+
renderer = Output::Describe.new(context)
|
|
54
|
+
context.print(blocks.map { renderer.render(it) }.join("\n"))
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def projects(runtime, context, resources, fields)
|
|
61
|
+
inspections = fields.filter(Base.examine(runtime, context, resources)) { Views::Project.object(it) }
|
|
62
|
+
now = runtime.clock.call
|
|
63
|
+
inspections.map { Views::Project.describe(it, now:) }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def groups(runtime, groups, fields)
|
|
67
|
+
now = runtime.clock.call
|
|
68
|
+
counted = groups.map { [it, runtime.store.project_count(it.name)] }
|
|
69
|
+
fields.filter(counted) { |group, count| Views::Group.object(group, count:) }
|
|
70
|
+
.map { |group, count| Views::Group.describe(group, count:, now:) }
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|