bard 1.7.4 → 2.0.0.beta
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 +4 -4
- data/ARCHITECTURE.md +957 -0
- data/CUSTOM_STRATEGIES.md +701 -0
- data/MIGRATION_GUIDE.md +498 -0
- data/README.md +489 -0
- data/lib/bard/cli/deploy.rb +12 -3
- data/lib/bard/command.rb +25 -9
- data/lib/bard/config.rb +118 -43
- data/lib/bard/copy.rb +57 -13
- data/lib/bard/default_config.rb +35 -0
- data/lib/bard/deploy_strategy/github_pages.rb +135 -0
- data/lib/bard/deploy_strategy/ssh.rb +19 -0
- data/lib/bard/deploy_strategy.rb +60 -0
- data/lib/bard/ssh_server.rb +100 -0
- data/lib/bard/target.rb +239 -0
- data/lib/bard/version.rb +1 -1
- data/spec/bard/capability_spec.rb +97 -0
- data/spec/bard/config_spec.rb +1 -1
- data/spec/bard/deploy_strategy/ssh_spec.rb +67 -0
- data/spec/bard/deploy_strategy_spec.rb +107 -0
- data/spec/bard/dynamic_dsl_spec.rb +126 -0
- data/spec/bard/ssh_server_spec.rb +169 -0
- data/spec/bard/target_spec.rb +239 -0
- metadata +24 -2
data/lib/bard/config.rb
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
require "bard/server"
|
|
2
|
+
require "bard/target"
|
|
2
3
|
|
|
3
4
|
module Bard
|
|
4
5
|
class Config
|
|
@@ -8,69 +9,75 @@ module Bard
|
|
|
8
9
|
new(project_name, path: path)
|
|
9
10
|
end
|
|
10
11
|
|
|
11
|
-
|
|
12
|
+
attr_reader :project_name, :targets
|
|
13
|
+
|
|
14
|
+
def initialize(project_name = nil, path: nil, source: nil)
|
|
15
|
+
# Support both positional and keyword argument for project_name
|
|
12
16
|
@project_name = project_name
|
|
13
|
-
@servers = {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
project_name,
|
|
23
|
-
:gubs,
|
|
24
|
-
"botandrose@cloud.hackett.world:22022",
|
|
25
|
-
"Sites/#{project_name}",
|
|
26
|
-
false,
|
|
27
|
-
),
|
|
28
|
-
ci: Server.new(
|
|
29
|
-
project_name,
|
|
30
|
-
:ci,
|
|
31
|
-
"jenkins@staging.botandrose.com:22022",
|
|
32
|
-
"jobs/#{project_name}/workspace",
|
|
33
|
-
false,
|
|
34
|
-
),
|
|
35
|
-
staging: Server.new(
|
|
36
|
-
project_name,
|
|
37
|
-
:staging,
|
|
38
|
-
"www@staging.botandrose.com:22022",
|
|
39
|
-
project_name,
|
|
40
|
-
["#{project_name}.botandrose.com"],
|
|
41
|
-
),
|
|
42
|
-
}
|
|
17
|
+
@servers = {} # Unified hash for both Server and Target instances
|
|
18
|
+
@data_paths = []
|
|
19
|
+
@backup = nil
|
|
20
|
+
@ci_system = nil
|
|
21
|
+
|
|
22
|
+
# Load default configuration (creates Server instances for backward compat)
|
|
23
|
+
load_defaults if project_name
|
|
24
|
+
|
|
25
|
+
# Load user configuration
|
|
43
26
|
if path && File.exist?(path)
|
|
44
27
|
source = File.read(path)
|
|
45
28
|
end
|
|
46
29
|
if source
|
|
47
|
-
instance_eval
|
|
30
|
+
instance_eval(source)
|
|
48
31
|
end
|
|
49
32
|
end
|
|
50
33
|
|
|
51
|
-
|
|
34
|
+
# Backward compatible accessor
|
|
35
|
+
def servers
|
|
36
|
+
@servers
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# New v2.0 accessor (same as servers)
|
|
40
|
+
def targets
|
|
41
|
+
@servers
|
|
42
|
+
end
|
|
52
43
|
|
|
53
|
-
|
|
44
|
+
# Old v1.x API - creates Server instances
|
|
45
|
+
def server(key, &block)
|
|
54
46
|
key = key.to_sym
|
|
55
47
|
@servers[key] = Server.define(project_name, key, &block)
|
|
56
48
|
end
|
|
57
49
|
|
|
58
|
-
|
|
50
|
+
# New v2.0 API - creates Target instances
|
|
51
|
+
def target(key, &block)
|
|
59
52
|
key = key.to_sym
|
|
53
|
+
@servers[key] ||= Target.new(key, self)
|
|
54
|
+
@servers[key].instance_eval(&block) if block
|
|
55
|
+
@servers[key]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Get a server/target by key
|
|
59
|
+
def [](key)
|
|
60
|
+
key = key.to_sym
|
|
61
|
+
# Fallback to staging if production not defined
|
|
60
62
|
if @servers[key].nil? && key == :production
|
|
61
63
|
key = :staging
|
|
62
64
|
end
|
|
63
65
|
@servers[key]
|
|
64
66
|
end
|
|
65
67
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
68
|
+
# Data paths configuration
|
|
69
|
+
def data(*paths)
|
|
70
|
+
if paths.empty?
|
|
71
|
+
@data_paths
|
|
69
72
|
else
|
|
70
|
-
@
|
|
73
|
+
@data_paths = paths
|
|
71
74
|
end
|
|
72
75
|
end
|
|
73
76
|
|
|
77
|
+
def data_paths
|
|
78
|
+
@data_paths
|
|
79
|
+
end
|
|
80
|
+
|
|
74
81
|
def backup(value = nil, &block)
|
|
75
82
|
if block_given?
|
|
76
83
|
@backup = BackupConfig.new(&block)
|
|
@@ -83,7 +90,9 @@ module Bard
|
|
|
83
90
|
end
|
|
84
91
|
end
|
|
85
92
|
|
|
86
|
-
|
|
93
|
+
def backup_enabled?
|
|
94
|
+
backup == true
|
|
95
|
+
end
|
|
87
96
|
|
|
88
97
|
def github_pages url
|
|
89
98
|
urls = []
|
|
@@ -94,14 +103,80 @@ module Bard
|
|
|
94
103
|
urls << "www.#{hostname}"
|
|
95
104
|
end
|
|
96
105
|
|
|
97
|
-
|
|
98
|
-
github_pages
|
|
106
|
+
target :production do
|
|
107
|
+
github_pages url
|
|
99
108
|
ssh false
|
|
100
|
-
ping
|
|
109
|
+
ping(*urls) if urls.any?
|
|
101
110
|
end
|
|
102
111
|
|
|
103
112
|
backup false
|
|
104
113
|
end
|
|
114
|
+
|
|
115
|
+
# CI configuration
|
|
116
|
+
def ci(system = nil)
|
|
117
|
+
if system.nil?
|
|
118
|
+
@ci_system
|
|
119
|
+
else
|
|
120
|
+
@ci_system = system
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def ci_system
|
|
125
|
+
@ci_system
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def ci_instance(branch)
|
|
129
|
+
return nil if @ci_system == false
|
|
130
|
+
|
|
131
|
+
require "bard/ci"
|
|
132
|
+
|
|
133
|
+
# Use the existing CI class which handles auto-detection
|
|
134
|
+
case @ci_system
|
|
135
|
+
when :local
|
|
136
|
+
CI.new(project_name, branch, local: true)
|
|
137
|
+
when :github_actions, :jenkins, nil
|
|
138
|
+
# CI class auto-detects between github_actions and jenkins
|
|
139
|
+
CI.new(project_name, branch)
|
|
140
|
+
when false
|
|
141
|
+
nil
|
|
142
|
+
else
|
|
143
|
+
CI.new(project_name, branch)
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
private
|
|
148
|
+
|
|
149
|
+
# Load default server configurations (v1.x compatible)
|
|
150
|
+
def load_defaults
|
|
151
|
+
@servers[:local] = Server.new(
|
|
152
|
+
project_name,
|
|
153
|
+
:local,
|
|
154
|
+
false,
|
|
155
|
+
"./",
|
|
156
|
+
["#{project_name}.local"],
|
|
157
|
+
)
|
|
158
|
+
@servers[:gubs] = Server.new(
|
|
159
|
+
project_name,
|
|
160
|
+
:gubs,
|
|
161
|
+
"botandrose@cloud.hackett.world:22022",
|
|
162
|
+
"Sites/#{project_name}",
|
|
163
|
+
false,
|
|
164
|
+
)
|
|
165
|
+
@servers[:ci] = Server.new(
|
|
166
|
+
project_name,
|
|
167
|
+
:ci,
|
|
168
|
+
"jenkins@staging.botandrose.com:22022",
|
|
169
|
+
"jobs/#{project_name}/workspace",
|
|
170
|
+
false,
|
|
171
|
+
)
|
|
172
|
+
@servers[:staging] = Server.new(
|
|
173
|
+
project_name,
|
|
174
|
+
:staging,
|
|
175
|
+
"www@staging.botandrose.com:22022",
|
|
176
|
+
project_name,
|
|
177
|
+
["#{project_name}.botandrose.com"],
|
|
178
|
+
)
|
|
179
|
+
end
|
|
105
180
|
end
|
|
106
181
|
|
|
107
182
|
class BackupConfig
|
data/lib/bard/copy.rb
CHANGED
|
@@ -21,12 +21,15 @@ module Bard
|
|
|
21
21
|
end
|
|
22
22
|
end
|
|
23
23
|
|
|
24
|
-
def scp_using_local direction,
|
|
25
|
-
|
|
24
|
+
def scp_using_local direction, target_or_server
|
|
25
|
+
# Support both new Target (with server attribute) and old Server
|
|
26
|
+
ssh_server = target_or_server.respond_to?(:server) ? target_or_server.server : target_or_server
|
|
26
27
|
|
|
27
|
-
|
|
28
|
+
gateway = ssh_server.gateway ? "-oProxyCommand='ssh #{ssh_server.gateway} -W %h:%p'" : ""
|
|
28
29
|
|
|
29
|
-
|
|
30
|
+
ssh_key = ssh_server.ssh_key ? "-i #{ssh_server.ssh_key}" : ""
|
|
31
|
+
|
|
32
|
+
from_and_to = [path, target_or_server.scp_uri(path)]
|
|
30
33
|
from_and_to.reverse! if direction == :from
|
|
31
34
|
|
|
32
35
|
command = ["scp", gateway, ssh_key, *from_and_to].join(" ")
|
|
@@ -34,7 +37,10 @@ module Bard
|
|
|
34
37
|
end
|
|
35
38
|
|
|
36
39
|
def scp_as_mediator
|
|
37
|
-
|
|
40
|
+
from_server = from.respond_to?(:server) ? from.server : from
|
|
41
|
+
to_server = to.respond_to?(:server) ? to.server : to
|
|
42
|
+
|
|
43
|
+
raise NotImplementedError if from_server.gateway || to_server.gateway || from_server.ssh_key || to_server.ssh_key
|
|
38
44
|
command = "scp -o ForwardAgent=yes #{from.scp_uri(path)} #{to.scp_uri(path)}"
|
|
39
45
|
Bard::Command.run! command, verbose: verbose
|
|
40
46
|
end
|
|
@@ -49,13 +55,29 @@ module Bard
|
|
|
49
55
|
end
|
|
50
56
|
end
|
|
51
57
|
|
|
52
|
-
def rsync_using_local direction,
|
|
53
|
-
|
|
58
|
+
def rsync_using_local direction, target_or_server
|
|
59
|
+
# Support both new Target (with server attribute) and old Server
|
|
60
|
+
ssh_server = target_or_server.respond_to?(:server) ? target_or_server.server : target_or_server
|
|
61
|
+
|
|
62
|
+
# Get ssh_uri - it might be a URI object (old Server), string (new SSHServer), or mock
|
|
63
|
+
ssh_uri_value = ssh_server.respond_to?(:ssh_uri) ? ssh_server.ssh_uri : nil
|
|
64
|
+
if ssh_uri_value.respond_to?(:port)
|
|
65
|
+
# Already a URI-like object (old Server or mock)
|
|
66
|
+
ssh_uri = ssh_uri_value
|
|
67
|
+
elsif ssh_uri_value.is_a?(String)
|
|
68
|
+
# String from new SSHServer
|
|
69
|
+
ssh_uri = URI("ssh://#{ssh_uri_value}")
|
|
70
|
+
else
|
|
71
|
+
# Fallback
|
|
72
|
+
ssh_uri = ssh_uri_value
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
gateway = ssh_server.gateway ? "-oProxyCommand=\"ssh #{ssh_server.gateway} -W %h:%p\"" : ""
|
|
54
76
|
|
|
55
|
-
ssh_key =
|
|
56
|
-
ssh = "-e'ssh #{gateway} -p#{
|
|
77
|
+
ssh_key = ssh_server.ssh_key ? "-i #{ssh_server.ssh_key}" : ""
|
|
78
|
+
ssh = "-e'ssh #{gateway} -p#{ssh_uri.port || 22}'"
|
|
57
79
|
|
|
58
|
-
from_and_to = ["./#{path}",
|
|
80
|
+
from_and_to = ["./#{path}", target_or_server.rsync_uri(path)]
|
|
59
81
|
from_and_to.reverse! if direction == :from
|
|
60
82
|
from_and_to[-1].sub! %r(/[^/]+$), '/'
|
|
61
83
|
|
|
@@ -64,12 +86,34 @@ module Bard
|
|
|
64
86
|
end
|
|
65
87
|
|
|
66
88
|
def rsync_as_mediator
|
|
67
|
-
|
|
89
|
+
from_server = from.respond_to?(:server) ? from.server : from
|
|
90
|
+
to_server = to.respond_to?(:server) ? to.server : to
|
|
91
|
+
|
|
92
|
+
raise NotImplementedError if from_server.gateway || to_server.gateway || from_server.ssh_key || to_server.ssh_key
|
|
93
|
+
|
|
94
|
+
# Get ssh_uri - it might be a URI object (old Server), string (new SSHServer), or mock
|
|
95
|
+
from_uri_value = from_server.respond_to?(:ssh_uri) ? from_server.ssh_uri : nil
|
|
96
|
+
if from_uri_value.respond_to?(:port)
|
|
97
|
+
from_uri = from_uri_value
|
|
98
|
+
elsif from_uri_value.is_a?(String)
|
|
99
|
+
from_uri = URI("ssh://#{from_uri_value}")
|
|
100
|
+
else
|
|
101
|
+
from_uri = from_uri_value
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
to_uri_value = to_server.respond_to?(:ssh_uri) ? to_server.ssh_uri : nil
|
|
105
|
+
if to_uri_value.respond_to?(:port)
|
|
106
|
+
to_uri = to_uri_value
|
|
107
|
+
elsif to_uri_value.is_a?(String)
|
|
108
|
+
to_uri = URI("ssh://#{to_uri_value}")
|
|
109
|
+
else
|
|
110
|
+
to_uri = to_uri_value
|
|
111
|
+
end
|
|
68
112
|
|
|
69
|
-
from_str = "-p#{
|
|
113
|
+
from_str = "-p#{from_uri.port || 22} #{from_uri.user}@#{from_uri.host}"
|
|
70
114
|
to_str = to.rsync_uri(path).sub(%r(/[^/]+$), '/')
|
|
71
115
|
|
|
72
|
-
command = %(ssh -A #{from_str} 'rsync -e \"ssh -A -p#{
|
|
116
|
+
command = %(ssh -A #{from_str} 'rsync -e \"ssh -A -p#{to_uri.port || 22} -o StrictHostKeyChecking=no\" --delete --info=progress2 -az #{from.path}/#{path} #{to_str}')
|
|
73
117
|
Bard::Command.run! command, verbose: verbose
|
|
74
118
|
end
|
|
75
119
|
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
module Bard
|
|
2
|
+
# Default configuration that is loaded before user's bard.rb
|
|
3
|
+
# Users can override any of these targets in their bard.rb
|
|
4
|
+
DEFAULT_CONFIG = lambda do |config, project_name|
|
|
5
|
+
# Local development target
|
|
6
|
+
config.instance_eval do
|
|
7
|
+
target :local do
|
|
8
|
+
ssh false
|
|
9
|
+
path "./"
|
|
10
|
+
ping "#{project_name}.local"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Bot and Rose cloud server
|
|
14
|
+
target :gubs do
|
|
15
|
+
ssh "botandrose@cloud.hackett.world:22022",
|
|
16
|
+
path: "Sites/#{project_name}"
|
|
17
|
+
ping false
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# CI target (Jenkins)
|
|
21
|
+
target :ci do
|
|
22
|
+
ssh "jenkins@staging.botandrose.com:22022",
|
|
23
|
+
path: "jobs/#{project_name}/workspace"
|
|
24
|
+
ping false
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Staging server
|
|
28
|
+
target :staging do
|
|
29
|
+
ssh "www@staging.botandrose.com:22022",
|
|
30
|
+
path: project_name
|
|
31
|
+
ping "#{project_name}.botandrose.com"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
require "bard/deploy_strategy"
|
|
2
|
+
require "bard/git"
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
module Bard
|
|
7
|
+
class DeployStrategy
|
|
8
|
+
class GithubPages < DeployStrategy
|
|
9
|
+
def initialize(target, url = nil, **options)
|
|
10
|
+
super(target)
|
|
11
|
+
@url = url
|
|
12
|
+
@options = options
|
|
13
|
+
|
|
14
|
+
# Auto-configure ping URL if provided
|
|
15
|
+
target.ping(url) if url
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def deploy
|
|
19
|
+
@sha = Git.sha_of(Git.current_branch)
|
|
20
|
+
@build_dir = "tmp/github-build-#{@sha}"
|
|
21
|
+
@branch = "gh-pages"
|
|
22
|
+
@domain = extract_domain
|
|
23
|
+
|
|
24
|
+
puts "Starting deployment to GitHub Pages..."
|
|
25
|
+
|
|
26
|
+
build_site
|
|
27
|
+
tree_sha = create_tree_from_build
|
|
28
|
+
new_commit = create_commit(tree_sha)
|
|
29
|
+
commit_and_push(new_commit)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def extract_domain
|
|
35
|
+
return nil unless @url
|
|
36
|
+
domain = @url
|
|
37
|
+
domain = URI.parse(domain).hostname if domain =~ /^http/
|
|
38
|
+
domain
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def build_site
|
|
42
|
+
system "rm -rf #{@build_dir.sub(@sha, "*")}"
|
|
43
|
+
run! <<~SH
|
|
44
|
+
set -e
|
|
45
|
+
RAILS_ENV=production bundle exec rails s -p 3000 -d --pid tmp/pids/server.pid
|
|
46
|
+
OUTPUT=$(bundle exec rake assets:clean assets:precompile 2>&1) || echo "$OUTPUT"
|
|
47
|
+
|
|
48
|
+
# Create the output directory and enter it
|
|
49
|
+
BUILD=#{@build_dir}
|
|
50
|
+
rm -rf $BUILD
|
|
51
|
+
mkdir -p $BUILD
|
|
52
|
+
cp -R public/assets $BUILD/
|
|
53
|
+
cd $BUILD
|
|
54
|
+
|
|
55
|
+
# wait until server responds
|
|
56
|
+
echo waiting...
|
|
57
|
+
curl -s --retry 5 --retry-delay 2 http://localhost:3000 >/dev/null 2>&1
|
|
58
|
+
|
|
59
|
+
echo copying...
|
|
60
|
+
# Mirror the site to the build folder, ignoring links with query params
|
|
61
|
+
wget -nv -r -l inf --no-remove-listing -FEnH --reject-regex "(\\.*)\\?(.*)" http://localhost:3000/ 2>&1
|
|
62
|
+
|
|
63
|
+
echo #{@domain} > CNAME
|
|
64
|
+
SH
|
|
65
|
+
ensure
|
|
66
|
+
# cleanup
|
|
67
|
+
run! <<~SH
|
|
68
|
+
cat tmp/pids/server.pid | xargs -I {} kill {}
|
|
69
|
+
rm -rf public/assets
|
|
70
|
+
SH
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def create_tree_from_build
|
|
74
|
+
# Create temporary index
|
|
75
|
+
git_index_file = ".git/tmp-index"
|
|
76
|
+
git = "GIT_INDEX_FILE=#{git_index_file} git"
|
|
77
|
+
run! "#{git} read-tree --empty"
|
|
78
|
+
|
|
79
|
+
# Add build files to temporary index
|
|
80
|
+
Dir.chdir(@build_dir) do
|
|
81
|
+
Dir.glob('**/*', File::FNM_DOTMATCH).each do |file|
|
|
82
|
+
next if file == '.' || file == '..'
|
|
83
|
+
if File.file?(file)
|
|
84
|
+
run! "#{git} update-index --add --cacheinfo 100644 $(#{git} hash-object -w #{file}) #{file}"
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Create tree object from index
|
|
90
|
+
tree_sha = `#{git} write-tree`.chomp
|
|
91
|
+
|
|
92
|
+
# Clean up temporary index
|
|
93
|
+
FileUtils.rm_f(git_index_file)
|
|
94
|
+
|
|
95
|
+
tree_sha
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def create_commit(tree_sha)
|
|
99
|
+
# Get parent commit if branch exists
|
|
100
|
+
parent = get_parent_commit
|
|
101
|
+
|
|
102
|
+
# Create commit object
|
|
103
|
+
message = "'Deploying to #{@branch} from @ #{@sha} 🚀'"
|
|
104
|
+
args = ['commit-tree', tree_sha]
|
|
105
|
+
args += ['-p', parent] if parent
|
|
106
|
+
args += ['-m', message]
|
|
107
|
+
|
|
108
|
+
commit_sha = `git #{args.join(' ')}`.chomp
|
|
109
|
+
|
|
110
|
+
commit_sha
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def get_parent_commit
|
|
114
|
+
Git.sha_of("#{@branch}^{commit}")
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def commit_and_push(commit_sha)
|
|
118
|
+
if branch_exists?
|
|
119
|
+
run! "git update-ref refs/heads/#{@branch} #{commit_sha}"
|
|
120
|
+
else
|
|
121
|
+
run! "git branch #{@branch} #{commit_sha}"
|
|
122
|
+
end
|
|
123
|
+
run! "git push -f origin #{@branch}:refs/heads/#{@branch}"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def branch_exists?
|
|
127
|
+
system("git show-ref --verify --quiet refs/heads/#{@branch}")
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def cleanup
|
|
131
|
+
# Cleanup method for tests
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
require "bard/deploy_strategy"
|
|
2
|
+
|
|
3
|
+
module Bard
|
|
4
|
+
class DeployStrategy
|
|
5
|
+
class SSH < DeployStrategy
|
|
6
|
+
def deploy
|
|
7
|
+
# Require SSH capability
|
|
8
|
+
target.require_capability!(:ssh)
|
|
9
|
+
|
|
10
|
+
# Determine branch
|
|
11
|
+
branch = target.instance_variable_get(:@branch) || "master"
|
|
12
|
+
|
|
13
|
+
# Run git pull and setup on remote server
|
|
14
|
+
target.run! "git pull origin #{branch}"
|
|
15
|
+
target.run! "bin/setup"
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
require "bard/command"
|
|
2
|
+
|
|
3
|
+
module Bard
|
|
4
|
+
class DeployStrategy
|
|
5
|
+
@strategies = {}
|
|
6
|
+
|
|
7
|
+
class << self
|
|
8
|
+
attr_reader :strategies
|
|
9
|
+
|
|
10
|
+
def inherited(subclass)
|
|
11
|
+
super
|
|
12
|
+
# Extract strategy name from class name
|
|
13
|
+
# e.g., Bard::DeployStrategy::SSH -> :ssh
|
|
14
|
+
name = extract_strategy_name(subclass)
|
|
15
|
+
strategies[name] = subclass
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def [](name)
|
|
19
|
+
strategies[name.to_sym]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def extract_strategy_name(klass)
|
|
25
|
+
# Get the class name without module prefix
|
|
26
|
+
class_name = klass.name.split('::').last
|
|
27
|
+
# Convert from CamelCase to snake_case
|
|
28
|
+
class_name
|
|
29
|
+
.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
|
|
30
|
+
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
|
|
31
|
+
.downcase
|
|
32
|
+
.to_sym
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
attr_reader :target
|
|
37
|
+
|
|
38
|
+
def initialize(target)
|
|
39
|
+
@target = target
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def deploy
|
|
43
|
+
raise NotImplementedError, "Subclasses must implement #deploy"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Helper methods for strategies
|
|
47
|
+
def run!(command)
|
|
48
|
+
Command.run!(command)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def run(command)
|
|
52
|
+
Command.run(command)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def system!(command)
|
|
56
|
+
result = Kernel.system(command)
|
|
57
|
+
raise "Command failed: #{command}" unless result
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
require "uri"
|
|
2
|
+
require "bard/command"
|
|
3
|
+
|
|
4
|
+
module Bard
|
|
5
|
+
class SSHServer
|
|
6
|
+
attr_reader :user, :host, :port, :path, :gateway, :ssh_key, :env
|
|
7
|
+
|
|
8
|
+
def initialize(uri_string, **options)
|
|
9
|
+
@uri_string = uri_string
|
|
10
|
+
@options = options
|
|
11
|
+
|
|
12
|
+
# Parse URI
|
|
13
|
+
uri = parse_uri(uri_string)
|
|
14
|
+
@user = uri.user || ENV['USER']
|
|
15
|
+
@host = uri.host
|
|
16
|
+
@port = uri.port ? uri.port.to_s : "22"
|
|
17
|
+
|
|
18
|
+
# Store options
|
|
19
|
+
@path = options[:path]
|
|
20
|
+
@gateway = options[:gateway]
|
|
21
|
+
@ssh_key = options[:ssh_key]
|
|
22
|
+
@env = options[:env]
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def ssh_uri
|
|
26
|
+
"#{user}@#{host}:#{port}"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def hostname
|
|
30
|
+
host
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def connection_string
|
|
34
|
+
"#{user}@#{host}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def run(command)
|
|
38
|
+
full_command = build_command(command)
|
|
39
|
+
Open3.capture3(full_command)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def run!(command)
|
|
43
|
+
output, error, status = run(command)
|
|
44
|
+
if status.to_i.nonzero?
|
|
45
|
+
raise Command::Error, "Command failed: #{command}\n#{error}"
|
|
46
|
+
end
|
|
47
|
+
output
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def exec!(command)
|
|
51
|
+
full_command = build_command(command)
|
|
52
|
+
exec(full_command)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def parse_uri(uri_string)
|
|
58
|
+
# Handle user@host:port format
|
|
59
|
+
if uri_string =~ /^([^@]+@)?([^:]+)(?::(\d+))?$/
|
|
60
|
+
user_part = $1&.chomp('@')
|
|
61
|
+
host_part = $2
|
|
62
|
+
port_part = $3
|
|
63
|
+
|
|
64
|
+
URI::Generic.build(
|
|
65
|
+
scheme: 'ssh',
|
|
66
|
+
userinfo: user_part,
|
|
67
|
+
host: host_part,
|
|
68
|
+
port: port_part&.to_i
|
|
69
|
+
)
|
|
70
|
+
else
|
|
71
|
+
URI.parse("ssh://#{uri_string}")
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def build_command(command)
|
|
76
|
+
cmd = "ssh -tt"
|
|
77
|
+
|
|
78
|
+
# Add port
|
|
79
|
+
cmd += " -p #{port}" if port != "22"
|
|
80
|
+
|
|
81
|
+
# Add gateway
|
|
82
|
+
cmd += " -o ProxyJump=#{gateway}" if gateway
|
|
83
|
+
|
|
84
|
+
# Add SSH key
|
|
85
|
+
cmd += " -i #{ssh_key}" if ssh_key
|
|
86
|
+
|
|
87
|
+
# Add user@host
|
|
88
|
+
cmd += " #{user}@#{host}"
|
|
89
|
+
|
|
90
|
+
# Add command with path and env
|
|
91
|
+
remote_cmd = ""
|
|
92
|
+
remote_cmd += "#{env} " if env
|
|
93
|
+
remote_cmd += "cd #{path} && " if path
|
|
94
|
+
remote_cmd += command
|
|
95
|
+
|
|
96
|
+
cmd += " '#{remote_cmd}'"
|
|
97
|
+
cmd
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|