moonraker-bosun 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/CHANGELOG.md +16 -0
- data/LICENSE +21 -0
- data/README.ja.md +467 -0
- data/README.md +124 -0
- data/docs/design.md +358 -0
- data/docs/moonraker-api.md +108 -0
- data/exe/bosun +7 -0
- data/lib/moonraker/bosun/cli.rb +64 -0
- data/lib/moonraker/bosun/client.rb +147 -0
- data/lib/moonraker/bosun/commands/add.rb +38 -0
- data/lib/moonraker/bosun/commands/audit.rb +48 -0
- data/lib/moonraker/bosun/commands/backup.rb +135 -0
- data/lib/moonraker/bosun/commands/base.rb +61 -0
- data/lib/moonraker/bosun/commands/diff.rb +29 -0
- data/lib/moonraker/bosun/commands/export.rb +103 -0
- data/lib/moonraker/bosun/commands/fetching.rb +76 -0
- data/lib/moonraker/bosun/commands/forget.rb +26 -0
- data/lib/moonraker/bosun/commands/get.rb +57 -0
- data/lib/moonraker/bosun/commands/ls.rb +43 -0
- data/lib/moonraker/bosun/commands/pull.rb +52 -0
- data/lib/moonraker/bosun/commands/push.rb +136 -0
- data/lib/moonraker/bosun/commands/removing.rb +38 -0
- data/lib/moonraker/bosun/commands/rm.rb +105 -0
- data/lib/moonraker/bosun/commands/roots.rb +31 -0
- data/lib/moonraker/bosun/commands/status.rb +22 -0
- data/lib/moonraker/bosun/commands/status_report.rb +75 -0
- data/lib/moonraker/bosun/config.rb +31 -0
- data/lib/moonraker/bosun/context.rb +26 -0
- data/lib/moonraker/bosun/error.rb +8 -0
- data/lib/moonraker/bosun/file_rules.rb +37 -0
- data/lib/moonraker/bosun/git.rb +139 -0
- data/lib/moonraker/bosun/host_tracking.rb +214 -0
- data/lib/moonraker/bosun/messages/catalog.rb +837 -0
- data/lib/moonraker/bosun/messages.rb +42 -0
- data/lib/moonraker/bosun/push_plan.rb +89 -0
- data/lib/moonraker/bosun/sync_status.rb +130 -0
- data/lib/moonraker/bosun/version.rb +7 -0
- data/lib/moonraker/bosun.rb +31 -0
- metadata +86 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
require "net/http"
|
|
6
|
+
require "openssl"
|
|
7
|
+
require "uri"
|
|
8
|
+
|
|
9
|
+
module Moonraker
|
|
10
|
+
module Bosun
|
|
11
|
+
# Moonraker APIのクライアント
|
|
12
|
+
#
|
|
13
|
+
# APIの動作は docs/moonraker-api.md を参照。
|
|
14
|
+
class Client
|
|
15
|
+
FileInfo = Data.define(:path, :modified, :size, :permissions) do
|
|
16
|
+
def writable? = permissions == "rw"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
RootInfo = Data.define(:name, :path, :permissions)
|
|
20
|
+
|
|
21
|
+
# MoonrakerがエラーのHTTPステータスを返した場合のエラー
|
|
22
|
+
class APIError < Error
|
|
23
|
+
attr_reader :code
|
|
24
|
+
|
|
25
|
+
def initialize(code, message)
|
|
26
|
+
@code = code
|
|
27
|
+
super(Messages.t(:"client.api_error", code: code, message: message))
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
attr_reader :base_url
|
|
32
|
+
|
|
33
|
+
def initialize(base_url, open_timeout: 5, read_timeout: 30)
|
|
34
|
+
@base_url = base_url
|
|
35
|
+
@open_timeout = open_timeout
|
|
36
|
+
@read_timeout = read_timeout
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def roots
|
|
40
|
+
get_json("/server/files/roots").map do |item|
|
|
41
|
+
RootInfo.new(name: item.fetch("name"), path: item.fetch("path"), permissions: item.fetch("permissions"))
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def list_files(root)
|
|
46
|
+
get_json("/server/files/list", root: root).map do |item|
|
|
47
|
+
FileInfo.new(
|
|
48
|
+
path: item.fetch("path"),
|
|
49
|
+
modified: item.fetch("modified"),
|
|
50
|
+
size: item.fetch("size"),
|
|
51
|
+
permissions: item.fetch("permissions")
|
|
52
|
+
)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Moonraker DBのnamespaceの一覧
|
|
57
|
+
def db_namespaces
|
|
58
|
+
get_json("/server/database/list").fetch("namespaces")
|
|
59
|
+
rescue KeyError => e
|
|
60
|
+
raise Error, Messages.t(:"client.invalid_response", context: "database/list", message: e.message)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# namespaceの内容(Hash)
|
|
64
|
+
def db_namespace(namespace)
|
|
65
|
+
get_json("/server/database/item", namespace: namespace).fetch("value")
|
|
66
|
+
rescue KeyError => e
|
|
67
|
+
raise Error, Messages.t(:"client.invalid_response", context: "database/item", message: e.message)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# print_stats.state を返す。Klipperがreadyでない場合(print_statsが空の場合)はnil
|
|
71
|
+
def print_state
|
|
72
|
+
get_json("/printer/objects/query", print_stats: nil).dig("status", "print_stats", "state")
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# ファイルの内容をバイナリの文字列で返す
|
|
76
|
+
def download(root, path)
|
|
77
|
+
request(Net::HTTP::Get.new(uri(file_path(root, path)))).body.b
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def delete_file(root, path)
|
|
81
|
+
request(Net::HTTP::Delete.new(uri(file_path(root, path))))
|
|
82
|
+
nil
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# ファイルをアップロードする。同名のファイルがあれば上書きする(呼び出し側で確認すること)
|
|
86
|
+
#
|
|
87
|
+
# checksumにより、Moonraker側で内容が一致しなければ書き込まれない。
|
|
88
|
+
def upload(root, path, content)
|
|
89
|
+
dir = File.dirname(path)
|
|
90
|
+
fields = [["root", root]]
|
|
91
|
+
fields << ["path", dir] unless dir == "."
|
|
92
|
+
fields << ["checksum", Digest::SHA256.hexdigest(content)]
|
|
93
|
+
fields << ["file", content.b, { filename: File.basename(path), content_type: "application/octet-stream" }]
|
|
94
|
+
req = Net::HTTP::Post.new(uri("/server/files/upload"))
|
|
95
|
+
req.set_form(fields, "multipart/form-data")
|
|
96
|
+
JSON.parse(request(req).body)
|
|
97
|
+
rescue JSON::ParserError => e
|
|
98
|
+
raise Error, Messages.t(:"client.invalid_response", context: "upload", message: e.message)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
def get_json(path, **query)
|
|
104
|
+
response = request(Net::HTTP::Get.new(uri(path, query)))
|
|
105
|
+
JSON.parse(response.body).fetch("result")
|
|
106
|
+
rescue JSON::ParserError, KeyError => e
|
|
107
|
+
raise Error, Messages.t(:"client.invalid_response", context: path, message: e.message)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def file_path(root, path)
|
|
111
|
+
segments = [root, *path.split("/")]
|
|
112
|
+
"/server/files/#{segments.map { |s| URI.encode_uri_component(s) }.join('/')}"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def uri(path, query = {})
|
|
116
|
+
uri = @base_url.dup
|
|
117
|
+
uri.path = @base_url.path.chomp("/") + path
|
|
118
|
+
uri.query = query.empty? ? nil : URI.encode_www_form(query)
|
|
119
|
+
uri
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def request(req)
|
|
123
|
+
response = Net::HTTP.start(
|
|
124
|
+
req.uri.host, req.uri.port,
|
|
125
|
+
use_ssl: req.uri.scheme == "https",
|
|
126
|
+
open_timeout: @open_timeout, read_timeout: @read_timeout
|
|
127
|
+
) { |http| http.request(req) }
|
|
128
|
+
raise api_error(response) unless response.is_a?(Net::HTTPSuccess)
|
|
129
|
+
|
|
130
|
+
response
|
|
131
|
+
rescue SystemCallError, SocketError, IOError, Timeout::Error,
|
|
132
|
+
Net::HTTPBadResponse, Net::ProtocolError, OpenSSL::SSL::SSLError => e
|
|
133
|
+
raise Error, Messages.t(:"client.connection_failed", url: @base_url, message: e.message)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# エラーの応答にはMoonraker内部のトレースバックが含まれるので、messageだけを使う
|
|
137
|
+
def api_error(response)
|
|
138
|
+
message = begin
|
|
139
|
+
JSON.parse(response.body).dig("error", "message")
|
|
140
|
+
rescue JSON::ParserError, TypeError
|
|
141
|
+
nil
|
|
142
|
+
end
|
|
143
|
+
APIError.new(response.code.to_i, message || response.message)
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Moonraker
|
|
4
|
+
module Bosun
|
|
5
|
+
module Commands
|
|
6
|
+
class Add < Base
|
|
7
|
+
include Fetching
|
|
8
|
+
|
|
9
|
+
def run(args)
|
|
10
|
+
args = parse_options(args)
|
|
11
|
+
return 0 unless args
|
|
12
|
+
raise Error, t(:"add.no_files") if args.empty?
|
|
13
|
+
|
|
14
|
+
args.each { |path| validate_path!(path) }
|
|
15
|
+
paths = args.uniq
|
|
16
|
+
tracking.check_workspace!(paths.map { |path| HostTracking.repo_path(path) })
|
|
17
|
+
|
|
18
|
+
infos = list_config_files
|
|
19
|
+
paths.each { |path| check_target!(path, infos[path]) }
|
|
20
|
+
|
|
21
|
+
commit_and_merge("add", fetch(paths, infos))
|
|
22
|
+
0
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def check_target!(path, info)
|
|
28
|
+
raise Error, t(:"common.file_not_on_host", path: "#{FileRules::CONFIG_ROOT}/#{path}") unless info
|
|
29
|
+
|
|
30
|
+
reason = FileRules.exclusion(FileRules::CONFIG_ROOT, info)
|
|
31
|
+
return unless reason
|
|
32
|
+
|
|
33
|
+
raise Error, t(:"add.excluded", reason: FileRules.reason_text(reason), path: path)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Moonraker
|
|
4
|
+
module Bosun
|
|
5
|
+
module Commands
|
|
6
|
+
class Audit < Base
|
|
7
|
+
include StatusReport
|
|
8
|
+
|
|
9
|
+
def run(args)
|
|
10
|
+
args = parse_options(args)
|
|
11
|
+
return 0 unless args
|
|
12
|
+
|
|
13
|
+
check_no_arguments!(args)
|
|
14
|
+
|
|
15
|
+
git = @context.git
|
|
16
|
+
tracking = HostTracking.new(git)
|
|
17
|
+
problems = tracking.check_invariants
|
|
18
|
+
status = SyncStatus.new(git: git, client: client, tracking: tracking, strict: true)
|
|
19
|
+
|
|
20
|
+
report(git, status)
|
|
21
|
+
print_invariants(problems)
|
|
22
|
+
|
|
23
|
+
# トラッキングしていないファイルは、あえてトラッキングしないこともあるので問題としない
|
|
24
|
+
ok = problems.empty? && !status.unmerged? && status.renames.empty? &&
|
|
25
|
+
status.entries.all? { |entry| %i[synced untracked].include?(entry.state) }
|
|
26
|
+
out.puts
|
|
27
|
+
out.puts ok ? t(:"audit.ok") : t(:"audit.ng")
|
|
28
|
+
ok ? 0 : 1
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def print_invariants(problems)
|
|
34
|
+
out.puts
|
|
35
|
+
if problems.empty?
|
|
36
|
+
out.puts t(:"audit.invariants_ok", branch: HostTracking::BRANCH)
|
|
37
|
+
return
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
out.puts t(:"audit.invariants_violated", branch: HostTracking::BRANCH)
|
|
41
|
+
problems.each { |problem| out.puts " #{problem}" }
|
|
42
|
+
out.puts " → #{t(:"audit.repair_guide", branch: HostTracking::BRANCH)}"
|
|
43
|
+
out.puts " → #{t(:"audit.design_guide")}"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Moonraker
|
|
6
|
+
module Bosun
|
|
7
|
+
module Commands
|
|
8
|
+
class Backup < Base
|
|
9
|
+
include Fetching
|
|
10
|
+
|
|
11
|
+
# まだ何もバックアップしていないときに、ホストにあればバックアップするnamespace
|
|
12
|
+
# (Web UIなどの設定と、Moonrakerが管理するユーザの設定)
|
|
13
|
+
DEFAULT_NAMESPACES = %w[mainsail fluidd mobileraker maintenance webcams timelapse].freeze
|
|
14
|
+
NAMESPACE_PATTERN = /\A[A-Za-z0-9_.-]+\z/
|
|
15
|
+
|
|
16
|
+
def run(args)
|
|
17
|
+
status = false
|
|
18
|
+
args = parse_options(args) do |o|
|
|
19
|
+
o.on("-s", "--status", t(:"backup.status_option")) { status = true }
|
|
20
|
+
end
|
|
21
|
+
return 0 unless args
|
|
22
|
+
|
|
23
|
+
if status
|
|
24
|
+
check_no_arguments!(args, "--status")
|
|
25
|
+
|
|
26
|
+
print_status
|
|
27
|
+
return 0
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
args.each do |namespace|
|
|
31
|
+
raise Error, t(:"backup.invalid_namespace", namespace: namespace) unless NAMESPACE_PATTERN.match?(namespace)
|
|
32
|
+
end
|
|
33
|
+
backup(args.uniq)
|
|
34
|
+
0
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.repo_path(namespace) = "#{HostTracking::DB_BACKUP_DIR}/#{namespace}.json"
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
# host-trackingでバックアップしているnamespace
|
|
42
|
+
def tracked_namespaces
|
|
43
|
+
tip = tracking.tip
|
|
44
|
+
return [] unless tip
|
|
45
|
+
|
|
46
|
+
@context.git.ls_tree_paths(tip, HostTracking::DB_BACKUP_DIR)
|
|
47
|
+
.map { |path| File.basename(path, ".json") }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def backup(requested)
|
|
51
|
+
tracked = tracked_namespaces
|
|
52
|
+
host = client.db_namespaces
|
|
53
|
+
missing = []
|
|
54
|
+
targets =
|
|
55
|
+
if !requested.empty?
|
|
56
|
+
missing_requested = requested - host
|
|
57
|
+
unless missing_requested.empty?
|
|
58
|
+
raise Error, t(:"backup.missing_requested", namespaces: missing_requested.join(", "))
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
requested
|
|
62
|
+
elsif tracked.empty?
|
|
63
|
+
out.puts t(:"backup.first", namespaces: DEFAULT_NAMESPACES.join(", "))
|
|
64
|
+
DEFAULT_NAMESPACES & host
|
|
65
|
+
else
|
|
66
|
+
missing = tracked - host
|
|
67
|
+
tracked & host
|
|
68
|
+
end
|
|
69
|
+
raise Error, t(:"backup.no_targets") if targets.empty?
|
|
70
|
+
|
|
71
|
+
tracking.check_workspace!(targets.map { |namespace| self.class.repo_path(namespace) })
|
|
72
|
+
|
|
73
|
+
fetched = targets.map do |namespace|
|
|
74
|
+
f = HostTracking::Fetched.new(repo_path: self.class.repo_path(namespace),
|
|
75
|
+
content: serialize(client.db_namespace(namespace)), info: nil)
|
|
76
|
+
out.puts t(:"common.fetched", path: f.repo_path)
|
|
77
|
+
f
|
|
78
|
+
end
|
|
79
|
+
print_notices(missing, requested.empty? && !tracked.empty? ? (DEFAULT_NAMESPACES & host) - tracked : [])
|
|
80
|
+
commit_and_merge("backup", fetched)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# キーを辞書順に並べた、整形したJSON(Moonrakerが返すキーの順が変わっても差分が出ないようにする)
|
|
84
|
+
def serialize(value)
|
|
85
|
+
"#{JSON.pretty_generate(sort_keys(value))}\n"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def sort_keys(value)
|
|
89
|
+
case value
|
|
90
|
+
when Hash then value.keys.sort.to_h { |key| [key, sort_keys(value[key])] }
|
|
91
|
+
when Array then value.map { |item| sort_keys(item) }
|
|
92
|
+
else value
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def print_notices(missing, new_defaults)
|
|
97
|
+
unless missing.empty?
|
|
98
|
+
out.puts
|
|
99
|
+
out.puts t(:"backup.missing")
|
|
100
|
+
missing.each { |namespace| out.puts " #{namespace}" }
|
|
101
|
+
out.puts " → #{t(:"backup.missing_guide")}"
|
|
102
|
+
out.puts
|
|
103
|
+
end
|
|
104
|
+
return if new_defaults.empty?
|
|
105
|
+
|
|
106
|
+
out.puts
|
|
107
|
+
out.puts t(:"backup.recommended")
|
|
108
|
+
new_defaults.each { |namespace| out.puts " #{namespace}" }
|
|
109
|
+
out.puts " → #{t(:"backup.recommended_guide")}"
|
|
110
|
+
out.puts
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def print_status
|
|
114
|
+
tracked = tracked_namespaces
|
|
115
|
+
host = client.db_namespaces
|
|
116
|
+
names = (host | tracked).sort
|
|
117
|
+
width = names.map(&:size).max || 0
|
|
118
|
+
names.each do |namespace|
|
|
119
|
+
notes = status_notes(namespace, tracked, host)
|
|
120
|
+
out.puts notes.empty? ? namespace : format("%-*s %s", width, namespace, notes)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# namespaceの状態(バックアップ中、ホストにない、推奨)
|
|
125
|
+
def status_notes(namespace, tracked, host)
|
|
126
|
+
notes = []
|
|
127
|
+
notes << :tracked if tracked.include?(namespace)
|
|
128
|
+
notes << :not_on_host unless host.include?(namespace)
|
|
129
|
+
notes << :recommended if DEFAULT_NAMESPACES.include?(namespace) && !tracked.include?(namespace)
|
|
130
|
+
notes.map { |note| t(:"backup.note.#{note}") }.join(t(:"backup.note_separator"))
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
|
|
5
|
+
module Moonraker
|
|
6
|
+
module Bosun
|
|
7
|
+
module Commands
|
|
8
|
+
class Base
|
|
9
|
+
include Messages
|
|
10
|
+
|
|
11
|
+
class << self
|
|
12
|
+
# 一覧に表示する説明(<コマンド名>.summary)
|
|
13
|
+
def summary = Messages.t(:"#{command_name}.summary")
|
|
14
|
+
|
|
15
|
+
def command_name = name.split("::").last.downcase
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def initialize(context)
|
|
19
|
+
@context = context
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def out = @context.out
|
|
25
|
+
def client = @context.client
|
|
26
|
+
|
|
27
|
+
# オプションを解析する。--help を表示した場合はnilを返す
|
|
28
|
+
def parse_options(args)
|
|
29
|
+
parser = OptionParser.new
|
|
30
|
+
parser.banner = t(:"#{self.class.command_name}.usage")
|
|
31
|
+
yield parser if block_given?
|
|
32
|
+
parser.on("-h", "--help", t(:"common.help_option")) do
|
|
33
|
+
out.print parser.help
|
|
34
|
+
return nil
|
|
35
|
+
end
|
|
36
|
+
parser.parse!(args)
|
|
37
|
+
args
|
|
38
|
+
rescue OptionParser::ParseError => e
|
|
39
|
+
raise Error, t(:"common.invalid_option", message: e.message)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# 引数を取らないコマンドで、引数が指定されていないことを確認する
|
|
43
|
+
def check_no_arguments!(args, command = self.class.command_name)
|
|
44
|
+
raise Error, t(:"common.no_arguments", command: command, args: args.join(" ")) unless args.empty?
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# 手順の案内を、1行ずつ「→」を付けて表示する
|
|
48
|
+
def print_guide(guide)
|
|
49
|
+
guide.each_line(chomp: true) { |line| out.puts " → #{line}" }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def validate_path!(path)
|
|
53
|
+
segments = path.split("/")
|
|
54
|
+
return unless path.empty? || path.start_with?("/") || segments.include?("..")
|
|
55
|
+
|
|
56
|
+
raise Error, t(:"common.invalid_path", path: path)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Moonraker
|
|
4
|
+
module Bosun
|
|
5
|
+
module Commands
|
|
6
|
+
class Diff < Base
|
|
7
|
+
def run(args)
|
|
8
|
+
stat = false
|
|
9
|
+
args = parse_options(args) do |o|
|
|
10
|
+
o.on("--stat", t(:"diff.stat_option")) { stat = true }
|
|
11
|
+
end
|
|
12
|
+
return 0 unless args
|
|
13
|
+
|
|
14
|
+
args.each { |path| validate_path!(path) }
|
|
15
|
+
git = @context.git
|
|
16
|
+
tip = HostTracking.new(git).tip
|
|
17
|
+
raise Error, t(:"common.no_tracked_files") unless tip
|
|
18
|
+
|
|
19
|
+
paths = args.empty? ? [HostTracking::CONFIG_DIR] : args.map { |path| HostTracking.repo_path(path) }
|
|
20
|
+
options = ["--no-ext-diff", "--no-renames"]
|
|
21
|
+
options << "--stat" if stat
|
|
22
|
+
options << "--color=always" if out.respond_to?(:tty?) && out.tty?
|
|
23
|
+
out.print git.run("diff", *options, HostTracking::BRANCH, "HEAD", "--", *paths)
|
|
24
|
+
0
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Moonraker
|
|
7
|
+
module Bosun
|
|
8
|
+
module Commands
|
|
9
|
+
class Export < Base
|
|
10
|
+
def run(args)
|
|
11
|
+
at = nil
|
|
12
|
+
rev = nil
|
|
13
|
+
output = nil
|
|
14
|
+
args = parse_options(args) do |o|
|
|
15
|
+
o.on("--at DATETIME", t(:"export.at_option")) { |v| at = v }
|
|
16
|
+
o.on("--rev REV", t(:"export.rev_option")) { |v| rev = v }
|
|
17
|
+
o.on("-o", "--output DIR", t(:"export.output_option")) { |v| output = v }
|
|
18
|
+
end
|
|
19
|
+
return 0 unless args
|
|
20
|
+
raise Error, t(:"export.at_and_rev") if at && rev
|
|
21
|
+
|
|
22
|
+
git = @context.git
|
|
23
|
+
tracking = HostTracking.new(git)
|
|
24
|
+
raise Error, t(:"export.no_branch", branch: HostTracking::BRANCH) unless tracking.tip
|
|
25
|
+
|
|
26
|
+
commit = if at
|
|
27
|
+
commit_at(git, at)
|
|
28
|
+
elsif rev
|
|
29
|
+
commit_of_rev(git, rev)
|
|
30
|
+
else
|
|
31
|
+
tracking.tip
|
|
32
|
+
end
|
|
33
|
+
tree = git.ls_tree(commit)
|
|
34
|
+
paths = args.empty? ? tree.keys.sort : args.map { |arg| resolve_path(tree, arg) }.uniq
|
|
35
|
+
|
|
36
|
+
dir = File.expand_path(output || default_dir(git, commit), @context.cwd)
|
|
37
|
+
raise Error, t(:"export.exists", dir: dir) if File.exist?(dir) || File.symlink?(dir)
|
|
38
|
+
|
|
39
|
+
write(git, tree, paths, dir)
|
|
40
|
+
print_result(git, commit, paths, dir)
|
|
41
|
+
0
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def commit_at(git, at)
|
|
47
|
+
time = begin
|
|
48
|
+
Time.parse(at)
|
|
49
|
+
rescue ArgumentError
|
|
50
|
+
raise Error, t(:"export.invalid_time", at: at)
|
|
51
|
+
end
|
|
52
|
+
commit = git.run("rev-list", "-1", "--before=#{time.iso8601}", HostTracking::REF).strip
|
|
53
|
+
raise Error, t(:"export.none_before", time: time.strftime("%Y-%m-%d %H:%M:%S")) if commit.empty?
|
|
54
|
+
|
|
55
|
+
commit
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# host-trackingは一直線の履歴なので、指定したコミットとの共通の祖先が、
|
|
59
|
+
# そのコミットの時点で取り込んでいた最新の取得の時点になる
|
|
60
|
+
def commit_of_rev(git, rev)
|
|
61
|
+
commit = git.rev_parse(rev)
|
|
62
|
+
raise Error, t(:"export.no_commit", rev: rev) unless commit
|
|
63
|
+
|
|
64
|
+
base = git.merge_base(commit, HostTracking::REF)
|
|
65
|
+
raise Error, t(:"export.not_merged", rev: rev, branch: HostTracking::BRANCH) unless base
|
|
66
|
+
|
|
67
|
+
base
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Root configからのパスと、リポジトリ上のパスのどちらでも指定できる
|
|
71
|
+
def resolve_path(tree, arg)
|
|
72
|
+
validate_path!(arg)
|
|
73
|
+
[HostTracking.repo_path(arg), arg].find { |path| tree.key?(path) } or
|
|
74
|
+
raise Error, t(:"export.not_in_snapshot", path: arg)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def default_dir(git, commit)
|
|
78
|
+
date = git.run("log", "-1", "--format=%cd", "--date=format-local:%Y%m%d-%H%M%S", commit).strip
|
|
79
|
+
"export-#{date}-#{commit[0, 7]}"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def write(git, tree, paths, dir)
|
|
83
|
+
FileUtils.mkdir_p(dir)
|
|
84
|
+
paths.each do |path|
|
|
85
|
+
dest = File.join(dir, path)
|
|
86
|
+
FileUtils.mkdir_p(File.dirname(dest))
|
|
87
|
+
File.open(dest, File::WRONLY | File::CREAT | File::EXCL | File::BINARY) do |f|
|
|
88
|
+
f.write(git.cat_blob(tree.fetch(path)))
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def print_result(git, commit, paths, dir)
|
|
94
|
+
info = git.run("log", "-1", "--format=%cd%x00%s", "--date=format-local:%Y-%m-%d %H:%M:%S", commit).strip
|
|
95
|
+
date, subject = info.split("\0", 2)
|
|
96
|
+
out.puts "#{HostTracking::BRANCH}: #{commit[0, 7]} (#{date}) #{subject}"
|
|
97
|
+
out.puts t(:"export.written", dir: dir)
|
|
98
|
+
paths.each { |path| out.puts " #{path}" }
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Moonraker
|
|
4
|
+
module Bosun
|
|
5
|
+
module Commands
|
|
6
|
+
# ホストから取得してhost-trackingにコミットし、現在のブランチにマージする処理
|
|
7
|
+
module Fetching
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def tracking
|
|
11
|
+
@tracking ||= HostTracking.new(@context.git)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def list_config_files
|
|
15
|
+
client.list_files(FileRules::CONFIG_ROOT).to_h { |info| [info.path, info] }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# pathsはRoot configからのパス
|
|
19
|
+
def fetch(paths, infos)
|
|
20
|
+
paths.map do |path|
|
|
21
|
+
fetched = HostTracking::Fetched.new(
|
|
22
|
+
repo_path: HostTracking.repo_path(path),
|
|
23
|
+
content: client.download(FileRules::CONFIG_ROOT, path),
|
|
24
|
+
info: infos.fetch(path)
|
|
25
|
+
)
|
|
26
|
+
out.puts t(:"common.fetched", path: fetched.repo_path)
|
|
27
|
+
fetched
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# removedはリポジトリ上のパス
|
|
32
|
+
def commit_and_merge(op, fetched, removed: [])
|
|
33
|
+
parent = tracking.tip
|
|
34
|
+
commit = fetched.empty? && removed.empty? ? nil : tracking.commit(op, fetched, removed: removed)
|
|
35
|
+
if commit
|
|
36
|
+
changed = parent ? @context.git.diff_paths(parent, commit) : fetched.map(&:repo_path).sort
|
|
37
|
+
out.puts t(:"tracking.committed", branch: HostTracking::BRANCH, commit: commit[0, 7])
|
|
38
|
+
changed.each do |path|
|
|
39
|
+
out.puts t(removed.include?(path) ? :"tracking.removed" : :"tracking.changed", path: path)
|
|
40
|
+
end
|
|
41
|
+
else
|
|
42
|
+
changed = []
|
|
43
|
+
out.puts t(:"tracking.no_change", branch: HostTracking::BRANCH)
|
|
44
|
+
end
|
|
45
|
+
merge(op, changed)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def check_not_printing!
|
|
49
|
+
state = client.print_state
|
|
50
|
+
return unless %w[printing paused].include?(state)
|
|
51
|
+
|
|
52
|
+
raise Error, t(:"common.printing", state: state)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# トラッキング中のファイル(Root configからのパス)
|
|
56
|
+
def tracked_paths
|
|
57
|
+
tip = tracking.tip
|
|
58
|
+
raise Error, t(:"common.no_tracked_files") unless tip
|
|
59
|
+
|
|
60
|
+
@context.git.ls_tree_paths(tip, HostTracking::CONFIG_DIR).map { |path| HostTracking.host_path(path) }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def merge(op, changed)
|
|
64
|
+
detail = changed.empty? ? op : "#{op}: #{changed.join(', ')}"
|
|
65
|
+
# gitに記録するメッセージなので、表示の言語によらず英語にする
|
|
66
|
+
return unless tracking.merge("Merge #{HostTracking::BRANCH} (#{detail})")
|
|
67
|
+
|
|
68
|
+
out.puts t(:"tracking.merged", branch: @context.git.current_branch)
|
|
69
|
+
rescue HostTracking::MergeError => e
|
|
70
|
+
@context.err.puts "bosun: #{e.message}"
|
|
71
|
+
raise Error, t(:"tracking.merge_incomplete")
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Moonraker
|
|
4
|
+
module Bosun
|
|
5
|
+
module Commands
|
|
6
|
+
class Forget < Base
|
|
7
|
+
include Fetching
|
|
8
|
+
include Removing
|
|
9
|
+
|
|
10
|
+
def run(args)
|
|
11
|
+
args = parse_options(args)
|
|
12
|
+
return 0 unless args
|
|
13
|
+
raise Error, t(:"forget.no_files") if args.empty?
|
|
14
|
+
|
|
15
|
+
paths = args.uniq
|
|
16
|
+
check_removable!(paths)
|
|
17
|
+
|
|
18
|
+
commit_and_merge("forget", [], removed: paths.map { |path| HostTracking.repo_path(path) })
|
|
19
|
+
out.puts
|
|
20
|
+
out.puts t(:"forget.kept")
|
|
21
|
+
0
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|