kontena-cli 1.1.0.pre1 → 1.1.0.rc1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (65) hide show
  1. checksums.yaml +4 -4
  2. data/Dockerfile +3 -9
  3. data/README.md +1 -1
  4. data/VERSION +1 -1
  5. data/examples/kontena-plugin-hello/kontena-plugin-hello.gemspec +1 -1
  6. data/kontena-cli.gemspec +3 -2
  7. data/lib/kontena/cli/cloud/master/add_command.rb +2 -2
  8. data/lib/kontena/cli/grids/common.rb +4 -0
  9. data/lib/kontena/cli/grids/create_command.rb +3 -1
  10. data/lib/kontena/cli/grids/update_command.rb +4 -0
  11. data/lib/kontena/cli/master/config/get_command.rb +4 -0
  12. data/lib/kontena/cli/master/remove_command.rb +10 -13
  13. data/lib/kontena/cli/master/ssh_command.rb +43 -0
  14. data/lib/kontena/cli/master_command.rb +3 -0
  15. data/lib/kontena/cli/nodes/ssh_command.rb +28 -13
  16. data/lib/kontena/cli/registry/create_command.rb +1 -1
  17. data/lib/kontena/cli/stack_command.rb +2 -0
  18. data/lib/kontena/cli/stacks/build_command.rb +25 -21
  19. data/lib/kontena/cli/stacks/common.rb +45 -3
  20. data/lib/kontena/cli/stacks/deploy_command.rb +5 -2
  21. data/lib/kontena/cli/stacks/install_command.rb +7 -3
  22. data/lib/kontena/cli/stacks/list_command.rb +4 -4
  23. data/lib/kontena/cli/stacks/registry/push_command.rb +7 -3
  24. data/lib/kontena/cli/stacks/show_command.rb +2 -1
  25. data/lib/kontena/cli/stacks/stacks_helper.rb +55 -5
  26. data/lib/kontena/cli/stacks/upgrade_command.rb +6 -3
  27. data/lib/kontena/cli/stacks/validate_command.rb +47 -0
  28. data/lib/kontena/cli/stacks/yaml/opto/prompt_resolver.rb +1 -6
  29. data/lib/kontena/cli/stacks/yaml/reader.rb +189 -100
  30. data/lib/kontena/debug_instrumentor.rb +1 -1
  31. data/lib/kontena_cli.rb +12 -5
  32. data/omnibus/.gitignore +12 -0
  33. data/omnibus/.kitchen.yml +44 -0
  34. data/omnibus/Berksfile +12 -0
  35. data/omnibus/Gemfile +22 -0
  36. data/omnibus/Gemfile.lock +183 -0
  37. data/omnibus/README.md +120 -0
  38. data/omnibus/config/projects/kontena.rb +33 -0
  39. data/omnibus/config/software/kontena.rb +13 -0
  40. data/omnibus/config/software/liblzma.rb +47 -0
  41. data/omnibus/omnibus.rb +56 -0
  42. data/omnibus/package-scripts/kontena/postinst +22 -0
  43. data/omnibus/package-scripts/kontena/postinstall +12 -0
  44. data/omnibus/package-scripts/kontena/postrm +9 -0
  45. data/omnibus/package-scripts/kontena/preinst +7 -0
  46. data/omnibus/package-scripts/kontena/prerm +15 -0
  47. data/omnibus/resources/kontena/pkg/distribution.xml.erb +22 -0
  48. data/omnibus/resources/kontena/pkg/license.html.erb +202 -0
  49. data/omnibus/resources/kontena/pkg/welcome.html.erb +5 -0
  50. data/omnibus/wrappers/sh/kontena +7 -0
  51. data/spec/fixtures/kontena-with-variables.yml +1 -1
  52. data/spec/fixtures/kontena_build_v3.yml +1 -1
  53. data/spec/fixtures/stack-with-invalid-liquid.yml +22 -0
  54. data/spec/fixtures/stack-with-liquid.yml +21 -0
  55. data/spec/fixtures/stack-with-prompted-variables.yml +10 -5
  56. data/spec/fixtures/stack-with-variables.yml +6 -1
  57. data/spec/kontena/cli/cloud/master/add_command_spec.rb +2 -2
  58. data/spec/kontena/cli/services/containers_command_spec.rb +2 -2
  59. data/spec/kontena/cli/stacks/build_command_spec.rb +37 -26
  60. data/spec/kontena/cli/stacks/install_command_spec.rb +6 -1
  61. data/spec/kontena/cli/stacks/upgrade_command_spec.rb +14 -18
  62. data/spec/kontena/cli/stacks/yaml/reader_spec.rb +76 -21
  63. data/spec/kontena/kontena_cli_spec.rb +28 -0
  64. data/spec/support/requirements_helper.rb +20 -2
  65. metadata +46 -5
@@ -20,16 +20,56 @@ module Kontena::Cli::Stacks
20
20
  end
21
21
  end
22
22
 
23
+ module StackFileOrNameParam
24
+ attr_accessor :from_registry
25
+
26
+ def self.included(where)
27
+ where.parameter "[FILE]", "Kontena stack file or a registry stack name (user/stack or user/stack:version)", default: "kontena.yml", attribute_name: :filename do |filename|
28
+ if !File.exist?(filename) && filename =~ /\A[a-zA-Z0-9\_\.\-]+\/[a-zA-Z0-9\_\.\-]+(?::.*)?\z/
29
+ @from_registry = true
30
+ else
31
+ @from_registry = false
32
+ require_config_file(filename)
33
+ end
34
+ filename
35
+ end
36
+ end
37
+ end
38
+
39
+ module StackNameOption
40
+ def self.included(where)
41
+ where.option ['-n', '--name'], 'NAME', 'Define stack name (by default comes from stack file)'
42
+ end
43
+ end
44
+
45
+ module StackValuesFromOption
46
+ attr_accessor :values
47
+ def self.included(where)
48
+ where.option '--values-from', '[FILE]', 'Read variable values from YAML' do |filename|
49
+ if filename
50
+ require_config_file(filename)
51
+ @values = ::YAML.safe_load(File.read(filename))
52
+ end
53
+ filename
54
+ end
55
+ end
56
+ end
57
+
23
58
  def stack_name
24
59
  @stack_name ||= self.name || stack_name_from_yaml(filename)
25
60
  end
26
61
 
27
- def stack_from_yaml(filename, from_registry: false, name: nil)
28
- reader = Kontena::Cli::Stacks::YAML::Reader.new(filename, from_registry: from_registry)
62
+ def reader_from_yaml(filename, from_registry: false, name: nil, values: nil)
63
+ reader = Kontena::Cli::Stacks::YAML::Reader.new(filename, from_registry: from_registry, values: values)
29
64
  if reader.stack_name.nil?
30
65
  exit_with_error "Stack MUST have stack name in YAML top level field 'stack'! Aborting."
31
66
  end
32
67
  set_env_variables(name || reader.stack_name, current_grid)
68
+ reader
69
+ end
70
+
71
+ def stack_from_yaml(filename, from_registry: false, name: nil, values: nil)
72
+ reader = reader_from_yaml(filename, from_registry: from_registry, name: name, values: values)
33
73
  outcome = reader.execute
34
74
 
35
75
  hint_on_validation_notifications(outcome[:notifications]) if outcome[:notifications].size > 0
@@ -42,7 +82,9 @@ module Kontena::Cli::Stacks
42
82
  'version' => outcome[:version],
43
83
  'source' => reader.raw_content,
44
84
  'registry' => 'file://',
45
- 'services' => kontena_services
85
+ 'services' => kontena_services,
86
+ 'variables' => outcome[:variables],
87
+ 'vault_keys' => outcome[:vault_keys]
46
88
  }
47
89
  stack
48
90
  end
@@ -15,10 +15,13 @@ module Kontena::Cli::Stacks
15
15
 
16
16
  def execute
17
17
  deployment = nil
18
- spinner "Deploying stack #{pastel.cyan(name)}" do
18
+ spinner "Triggering deployment of stack #{pastel.cyan(name)}" do
19
19
  deployment = deploy_stack(name)
20
- wait_for_deploy_to_finish(deployment)
21
20
  end
21
+ spinner "Waiting for deployment to start" do
22
+ wait_for_deployment_to_start(deployment)
23
+ end
24
+ wait_for_deploy_to_finish(deployment)
22
25
  end
23
26
 
24
27
  def deploy_stack(name)
@@ -8,11 +8,14 @@ module Kontena::Cli::Stacks
8
8
 
9
9
  banner "Installs a stack to a grid on Kontena Master"
10
10
 
11
- parameter "[FILE]", "Kontena stack file or a registry stack name (user/stack or user/stack:version)", default: "kontena.yml", attribute_name: :filename
11
+ include Common::StackFileOrNameParam
12
12
 
13
- option ['-n', '--name'], 'NAME', 'Define stack name (by default comes from stack file)'
13
+ include Common::StackNameOption
14
14
  option '--deploy', :flag, 'Deploy after installation'
15
15
 
16
+ include Common::StackValuesFromOption
17
+
18
+
16
19
  requires_current_master
17
20
  requires_current_master_token
18
21
 
@@ -25,7 +28,8 @@ module Kontena::Cli::Stacks
25
28
  require_config_file(filename)
26
29
  end
27
30
 
28
- stack = stack_from_yaml(filename, from_registry: from_registry, name: name)
31
+ stack = stack_from_yaml(filename, from_registry: from_registry, name: name, values: values)
32
+
29
33
  stack['name'] = name if name
30
34
  spinner "Creating stack #{pastel.cyan(stack['name'])} " do
31
35
  create_stack(stack)
@@ -18,8 +18,8 @@ module Kontena::Cli::Stacks
18
18
  def list_stacks
19
19
  response = client.get("grids/#{current_grid}/stacks")
20
20
 
21
- titles = ['NAME', 'VERSION', 'SERVICES', 'STATE', 'EXPOSED PORTS']
22
- puts "%-60s %-10s %-10s %-10s %-50s" % titles
21
+ titles = ['NAME', 'STACK', 'SERVICES', 'STATE', 'EXPOSED PORTS']
22
+ puts "%-30s %-40s %-10s %-10s %-50s" % titles
23
23
 
24
24
  response['stacks'].each do |stack|
25
25
  ports = stack_ports(stack)
@@ -41,13 +41,13 @@ module Kontena::Cli::Stacks
41
41
  vars = [
42
42
  icon.colorize(color),
43
43
  "#{stack['name']}",
44
- "#{stack['version']}",
44
+ "#{stack['stack']}:#{stack['version']}",
45
45
  stack['services'].size,
46
46
  stack['state'],
47
47
  ports.join(",")
48
48
  ]
49
49
 
50
- puts "%s %-58s %-10s %-10s %-10s %-50s" % vars
50
+ puts "%s %-28s %-40s %-10s %-10s %-50s" % vars
51
51
  end
52
52
  end
53
53
 
@@ -12,11 +12,15 @@ module Kontena::Cli::Stacks::Registry
12
12
  requires_current_account_token
13
13
 
14
14
  def execute
15
- file = Kontena::Cli::Stacks::YAML::Reader.new(filename, skip_variables: true, replace_missing: "filler")
15
+ file = Kontena::Cli::Stacks::YAML::Reader.new(
16
+ filename,
17
+ skip_variables: true,
18
+ skip_validation: true
19
+ )
16
20
  file.execute
17
- name = "#{file.yaml['stack']}:#{file.yaml['version']}"
21
+ name = "#{file.stack_name}:#{file.stack_version}"
18
22
  spinner("Pushing #{pastel.cyan(name)} to stacks registry") do
19
- stacks_client.push(file.yaml['stack'], file.yaml['version'], file.raw_content)
23
+ stacks_client.push(file.stack_name, file.stack_version, file.raw_content)
20
24
  end
21
25
  end
22
26
  end
@@ -26,9 +26,10 @@ module Kontena::Cli::Stacks
26
26
 
27
27
  puts "#{stack['name']}:"
28
28
  puts " state: #{stack['state']}"
29
+ puts " stack: #{stack['stack']}"
30
+ puts " version: #{stack['version']}"
29
31
  puts " created_at: #{stack['created_at']}"
30
32
  puts " updated_at: #{stack['updated_at']}"
31
- puts " version: #{stack['version']}"
32
33
  puts " expose: #{stack['expose'] || '-'}"
33
34
  puts " services:"
34
35
  stack['services'].each do |service|
@@ -2,25 +2,60 @@
2
2
  module Kontena::Cli::Stacks
3
3
  module StacksHelper
4
4
 
5
+ def wait_for_deployment_to_start(deployment, timeout = 600)
6
+ started = false
7
+ Timeout::timeout(timeout) do
8
+ until started
9
+ deployment = client.get("stacks/#{deployment['stack_id']}/deploys/#{deployment['id']}")
10
+ started = true if deployment['service_deploys'].size > 0
11
+ sleep 1
12
+ end
13
+ if deployment['state'] == 'error'
14
+ deployment['service_deploys'].each do |service_deploy|
15
+ if service_deploy['state'] == 'error'
16
+ puts " #{service_deploy['reason']}"
17
+ end
18
+ end
19
+
20
+ raise 'deploy failed'
21
+ end
22
+ end
23
+
24
+ started
25
+ rescue Timeout::Error
26
+ raise 'deploy timed out'
27
+ end
28
+
5
29
  # @param [Hash] deployment
6
30
  # @return [Boolean]
7
31
  def wait_for_deploy_to_finish(deployment, timeout = 600)
8
32
  deployed = false
9
33
  states = %w(success error)
34
+ tracked_services = []
10
35
  Timeout::timeout(timeout) do
11
36
  until deployed
12
37
  deployment = client.get("stacks/#{deployment['stack_id']}/deploys/#{deployment['id']}")
13
- deployed = true if states.include?(deployment['state'])
14
- sleep 1
38
+ service_deploy = deployment['service_deploys'].find{ |s| s['state'] == 'ongoing' }
39
+ if service_deploy
40
+ tracked_services << service_deploy['id']
41
+ wait_for_service_deploy(service_deploy, states)
42
+ end
43
+ if states.include?(deployment['state'])
44
+ deployed = true
45
+ deployment['service_deploys'].select{ |s| !tracked_services.include?(s['id']) }.each do |s|
46
+ wait_for_service_deploy(s, states)
47
+ end
48
+ else
49
+ sleep 1
50
+ end
15
51
  end
16
52
  if deployment['state'] == 'error'
17
53
  deployment['service_deploys'].each do |service_deploy|
18
54
  if service_deploy['state'] == 'error'
19
- puts " #{service_deploy['reason']}"
55
+ error service_deploy['reason']
20
56
  end
21
57
  end
22
-
23
- raise 'deploy failed'
58
+ abort
24
59
  end
25
60
  end
26
61
 
@@ -28,5 +63,20 @@ module Kontena::Cli::Stacks
28
63
  rescue Timeout::Error
29
64
  raise 'deploy timed out'
30
65
  end
66
+
67
+ def wait_for_service_deploy(service_deploy, states)
68
+ service_deployed = false
69
+ name = service_deploy['service_id'].split('/')[-1]
70
+ spinner "Deploying service #{pastel.cyan(name)}" do
71
+ until service_deployed
72
+ r = client.get("services/#{service_deploy['service_id']}/deploys/#{service_deploy['id']}")
73
+ if states.include?(r['state'])
74
+ service_deployed = true
75
+ else
76
+ sleep 1
77
+ end
78
+ end
79
+ end
80
+ end
31
81
  end
32
82
  end
@@ -9,15 +9,18 @@ module Kontena::Cli::Stacks
9
9
  banner "Upgrades a stack in a grid on Kontena Master"
10
10
 
11
11
  parameter "NAME", "Stack name"
12
- parameter "[FILE]", "Kontena stack file", default: "kontena.yml"
12
+
13
+ include Common::StackFileOrNameParam
14
+ include Common::StackValuesFromOption
15
+
13
16
  option '--deploy', :flag, 'Deploy after upgrade'
14
17
 
15
18
  requires_current_master
16
19
  requires_current_master_token
17
20
 
18
21
  def execute
19
- require_config_file(file)
20
- stack = stack_from_yaml(file, name: name)
22
+ require_config_file(filename)
23
+ stack = stack_from_yaml(filename, name: name, values: values, from_registry: from_registry)
21
24
  spinner "Upgrading stack #{pastel.cyan(name)} " do
22
25
  update_stack(stack)
23
26
  end
@@ -0,0 +1,47 @@
1
+ require_relative 'common'
2
+
3
+ module Kontena::Cli::Stacks
4
+ class ValidateCommand < Kontena::Command
5
+ include Kontena::Cli::Common
6
+ include Kontena::Cli::GridOptions
7
+ include Common
8
+
9
+ banner "Validates a YAML file"
10
+
11
+ include Common::StackFileOrNameParam
12
+ include Common::StackNameOption
13
+
14
+ option '--values-to', '[FILE]', 'Output variable values as YAML to file'
15
+
16
+ include Common::StackValuesFromOption
17
+
18
+ requires_current_master # the stack may use a vault resolver
19
+ requires_current_master_token
20
+
21
+ def execute
22
+
23
+ if !File.exist?(filename) && filename =~ /\A[a-zA-Z0-9\_\.\-]+\/[a-zA-Z0-9\_\.\-]+(?::.*)?\z/
24
+ from_registry = true
25
+ else
26
+ from_registry = false
27
+ require_config_file(filename)
28
+ end
29
+
30
+ reader = reader_from_yaml(filename, from_registry: from_registry, name: name, values: values)
31
+ outcome = reader.execute
32
+ hint_on_validation_notifications(outcome[:notifications]) if outcome[:notifications].size > 0
33
+ abort_on_validation_errors(outcome[:errors]) if outcome[:errors].size > 0
34
+
35
+ if values_to
36
+ vals = reader.variables.to_h(values_only: true).reject {|k,_| k == 'STACK' || k == 'GRID' }
37
+ File.write(values_to, ::YAML.dump(vals))
38
+ end
39
+ result = reader.fully_interpolated_yaml.merge(
40
+ # simplest way to stringify keys in a hash
41
+ 'variables' => JSON.parse(reader.variables.to_h(with_values: true, with_errors: true).to_json)
42
+ )
43
+ puts ::YAML.dump(result)
44
+ end
45
+ end
46
+ end
47
+
@@ -1,14 +1,9 @@
1
- if RUBY_VERSION < '2.1'
2
- require 'opto/extensions/hash_string_or_symbol_key'
3
- using Opto::Extension::HashStringOrSymbolKey
4
- end
5
-
6
1
  module Kontena::Cli::Stacks
7
2
  module YAML
8
3
  class Prompt < Opto::Resolver
9
4
  include Kontena::Cli::Common
10
5
 
11
- using Opto::Extension::HashStringOrSymbolKey unless RUBY_VERSION < '2.1'
6
+ using Opto::Extension::HashStringOrSymbolKey
12
7
 
13
8
  def enum?
14
9
  option.type == 'enum'
@@ -6,9 +6,9 @@ module Kontena::Cli::Stacks
6
6
  include Kontena::Util
7
7
  include Kontena::Cli::Common
8
8
 
9
- attr_reader :file, :raw_content, :result, :errors, :notifications, :variables, :yaml
9
+ attr_reader :file, :raw_content, :errors, :notifications
10
10
 
11
- def initialize(file, skip_validation: false, skip_variables: false, replace_missing: nil, from_registry: false)
11
+ def initialize(file, skip_validation: false, skip_variables: false, from_registry: false, variables: nil, values: nil)
12
12
  require 'yaml'
13
13
  require_relative 'service_extender'
14
14
  require_relative 'validator_v3'
@@ -17,6 +17,7 @@ module Kontena::Cli::Stacks
17
17
  require_relative 'opto/vault_resolver'
18
18
  require_relative 'opto/prompt_resolver'
19
19
  require_relative 'opto/service_instances_resolver'
20
+ require 'liquid'
20
21
 
21
22
  @file = file
22
23
  @from_registry = from_registry
@@ -29,105 +30,137 @@ module Kontena::Cli::Stacks
29
30
  @raw_content = File.read(File.expand_path(file))
30
31
  end
31
32
 
32
- @errors = []
33
- @notifications = []
33
+ @errors = []
34
+ @notifications = []
34
35
  @skip_validation = skip_validation
35
36
  @skip_variables = skip_variables
36
- @replace_missing = replace_missing
37
+ @variables = variables
38
+ @values = values
37
39
  end
38
40
 
39
- def from_registry?
40
- @from_registry == true
41
+ def internals_interpolated_yaml
42
+ @internals_interpolated_yaml ||= ::YAML.safe_load(
43
+ replace_dollar_dollars(
44
+ interpolate(
45
+ raw_content,
46
+ use_opto: false,
47
+ substitutions: {
48
+ 'GRID' => env['GRID'],
49
+ 'STACK' => env['STACK']
50
+ },
51
+ warnings: false
52
+ )
53
+ )
54
+ )
55
+ rescue Psych::SyntaxError => e
56
+ raise "Error while parsing #{file}".colorize(:red)+ " " + e.message
57
+ end
58
+
59
+ def fully_interpolated_yaml
60
+ return @fully_interpolated_yaml if @fully_interpolated_yaml
61
+ vars = variables.to_h(values_only: true)
62
+ @fully_interpolated_yaml = ::YAML.safe_load(
63
+ replace_dollar_dollars(
64
+ interpolate(
65
+ interpolate_liquid(
66
+ raw_content,
67
+ vars
68
+ ),
69
+ use_opto: true,
70
+ raise_on_unknown: true
71
+ )
72
+ )
73
+ )
74
+ rescue Psych::SyntaxError => e
75
+ raise "Error while parsing #{file}".colorize(:red)+ " " + e.message
76
+ end
77
+
78
+ def raw_yaml
79
+ @raw_yaml ||= ::YAML.safe_load(raw_content)
41
80
  end
42
81
 
43
82
  # @return [Opto::Group]
44
83
  def variables
45
84
  return @variables if @variables
46
- if yaml && yaml.has_key?('variables')
47
- variables_yaml = yaml['variables'].to_yaml
48
- variables_hash = ::YAML.safe_load(replace_dollar_dollars(interpolate(variables_yaml, use_opto: false)))
49
- @variables = Opto::Group.new(variables_hash, defaults: { from: :env, to: :env })
50
- else
51
- @variables = Opto::Group.new(defaults: { from: :env, to: :env })
85
+ @variables = Opto::Group.new(
86
+ (internals_interpolated_yaml['variables'] || {}).merge('STACK' => { type: :string, value: env['STACK']}, 'GRID' => {type: :string, value: env['GRID']}),
87
+ defaults: {
88
+ from: :env,
89
+ to: :env
90
+ }
91
+ )
92
+ if @values
93
+ @values.each do |key, val|
94
+ var = @variables.option(key)
95
+ var.set(val) if var
96
+ end
52
97
  end
53
98
  @variables
54
99
  end
55
100
 
56
- def parse_variables
57
- raise RuntimeError, "Variable validation failed: #{variables.errors.inspect}" unless variables.valid?
58
- variables.run
59
- end
60
-
61
- ##
62
101
  # @param [String] service_name
63
102
  # @return [Hash]
64
103
  def execute(service_name = nil)
65
- load_yaml(false)
66
- parse_variables unless skip_variables?
67
- load_yaml
104
+ process_variables unless skip_variables?
68
105
  validate unless skip_validation?
69
106
 
70
107
  result = {}
71
108
  Dir.chdir(from_registry? ? Dir.pwd : File.dirname(File.expand_path(file))) do
72
- result[:stack] = yaml['stack']
109
+ result[:stack] = raw_yaml['stack']
73
110
  result[:version] = self.stack_version
74
111
  result[:name] = self.stack_name
75
112
  result[:registry] = @registry if from_registry?
76
- result[:expose] = yaml['expose']
113
+ result[:expose] = fully_interpolated_yaml['expose']
77
114
  result[:errors] = errors unless skip_validation?
78
115
  result[:notifications] = notifications
79
116
  result[:services] = errors.count == 0 ? parse_services(service_name) : {}
80
- result[:variables] = variables.to_h(values_only: true).reject { |k,_| variables.option(k).to.has_key?(:vault) } unless skip_variables?
117
+ unless skip_variables?
118
+ result[:variables] = variables.to_h(values_only: true).reject do |k,_|
119
+ k == 'GRID' || k == 'STACK' || variables.option(k).to.has_key?(:vault) || variables.option(k).from.has_key?(:vault)
120
+ end
121
+ end
122
+ result[:vault_keys] = extract_vault_keys(result[:services])
81
123
  end
82
124
  result
83
125
  end
84
126
 
85
- def stack_name
86
- yaml = ::YAML.safe_load(raw_content)
87
- yaml['stack'].split('/').last.split(':').first if yaml['stack']
88
- end
89
127
 
90
- def stack_version
91
- yaml['version'] || yaml['stack'].to_s[/:(.*)/, 1] || '1'
128
+ def process_variables
129
+ variables.run
130
+ raise RuntimeError, "Variable validation failed: #{variables.errors.inspect}" unless variables.valid?
92
131
  end
93
132
 
94
- private
133
+ def interpolate_liquid(content, vars)
134
+ Liquid::Template.error_mode = :strict
135
+ template = Liquid::Template.parse(content)
136
+ template.render(vars, strict_variables: true, strict_filters: true)
137
+ end
95
138
 
96
- # A hash such as { "${MYSQL_IMAGE}" => "MYSQL_IMAGE } where the key is the
97
- # string to be substituted and value is the pure name part
98
- # @return [Hash]
99
- def yaml_substitutables
100
- @content_variables ||= raw_content.scan(/((?<!\$)\$(?!\$)\{?(\w+)\}?)/m)
139
+ def stack_name
140
+ @stack_name ||= parse_stack_name(::YAML.safe_load(raw_content)['stack'].to_s)[:stack]
101
141
  end
102
142
 
103
- def load_yaml(interpolate = true)
104
- if interpolate
105
- @yaml = ::YAML.safe_load(replace_dollar_dollars(interpolate(raw_content)))
106
- else
107
- @yaml = ::YAML.safe_load(raw_content)
108
- end
109
- rescue Psych::SyntaxError => e
110
- raise "Error while parsing #{file}".colorize(:red)+ " "+e.message
143
+ def stack_version
144
+ @stack_version ||= raw_yaml['version'] || parse_stack_name(raw_yaml['stack'].to_s)[:version] || '0.0.1'
111
145
  end
112
146
 
113
147
  # @return [Array] array of validation errors
114
148
  def validate
115
- result = validator.validate(yaml)
149
+ result = validator.validate(fully_interpolated_yaml)
116
150
  store_failures(result)
117
151
  result
118
152
  end
119
153
 
120
154
  def skip_validation?
121
- @skip_validation == true
155
+ !!@skip_validation
122
156
  end
123
157
 
124
158
  def skip_variables?
125
- @skip_variables == true
159
+ !!@skip_variables
126
160
  end
127
161
 
128
- def store_failures(data)
129
- errors << { file => data[:errors] } unless data[:errors].empty?
130
- notifications << { file => data[:notifications] } unless data[:notifications].empty?
162
+ def from_registry?
163
+ !!@from_registry
131
164
  end
132
165
 
133
166
  # @return [Kontena::Cli::Stacks::YAML::ValidatorV3]
@@ -142,7 +175,7 @@ module Kontena::Cli::Stacks
142
175
  if service_name.nil?
143
176
  services.each do |name, config|
144
177
  services[name] = process_config(config)
145
- if process_service?(config)
178
+ if process_hash?(config)
146
179
  services[name].delete('only_if')
147
180
  services[name].delete('skip_if')
148
181
  else
@@ -156,12 +189,16 @@ module Kontena::Cli::Stacks
156
189
  end
157
190
  end
158
191
 
159
- def process_service?(config)
160
- return true unless config['skip_if'] || config['only_if']
192
+ # If the supplied hash contains skip_if/only_if conditionals, process that conditional and return true/false
193
+ #
194
+ # @param [Hash]
195
+ # @return [Boolean]
196
+ def process_hash?(hash)
197
+ return true unless hash['skip_if'] || hash['only_if']
161
198
  return true if skip_variables? || variables.empty?
162
199
 
163
- skip_lambdas = normalize_ifs(config['skip_if'])
164
- only_lambdas = normalize_ifs(config['only_if'])
200
+ skip_lambdas = normalize_ifs(hash['skip_if'])
201
+ only_lambdas = normalize_ifs(hash['only_if'])
165
202
 
166
203
  if skip_lambdas
167
204
  return false if skip_lambdas.any? { |s| s.call }
@@ -174,33 +211,6 @@ module Kontena::Cli::Stacks
174
211
  true
175
212
  end
176
213
 
177
- # Generates an array of lambdas that return true if a condition is true
178
- # Possible syntaxes:
179
- # @example
180
- # normalize_ifs( 'wp' ) # lambdas return true if variable wp is not null or false or 'false'
181
- # normalize_ifs( wp: 1 ) # lambdas return true if value of wp is 1
182
- # normalize_ifs( [:wp, :ws] ) # lambdas return true if wp and ws are not not null or false or 'false'
183
- # normalize_ifs( wp: 1, ws: 1) # lambdas return true if wp and ws are 1
184
- # normalize_ifs(nil) # returns nil
185
- def normalize_ifs(ifs)
186
- case ifs
187
- when NilClass
188
- nil
189
- when Array
190
- ifs.map do |iff|
191
- lambda { val = variables.value_of(iff.to_s); !val.nil? && !val.kind_of?(FalseClass) && val != 'false' }
192
- end
193
- when Hash
194
- ifs.each_with_object([]) do |(k, v), arr|
195
- arr << lambda { variables.value_of(k.to_s) == v }
196
- end
197
- when String, Symbol
198
- [lambda { val = variables.value_of(ifs.to_s); !val.nil? && !val.kind_of?(FalseClass) && val != 'false' }]
199
- else
200
- raise TypeError, "Invalid syntax for if: #{ifs.inspect}"
201
- end
202
- end
203
-
204
214
  # @param [Hash] service_config
205
215
  def process_config(service_config)
206
216
  normalize_env_vars(service_config)
@@ -216,14 +226,23 @@ module Kontena::Cli::Stacks
216
226
 
217
227
  # @return [Hash] - services from YAML file
218
228
  def services
219
- yaml['services']
229
+ @services ||= fully_interpolated_yaml['services']
220
230
  end
221
231
 
232
+ def from_external_file(filename, service_name, from_registry: false)
233
+ outcome = Reader.new(filename, skip_validation: skip_validation?, skip_variables: true, from_registry: from_registry, variables: variables).execute(service_name)
234
+ errors.concat outcome[:errors] unless errors.any? { |item| item.has_key?(filename) }
235
+ notifications.concat outcome[:notifications] unless notifications.any? { |item| item.has_key?(filename) }
236
+ outcome[:services]
237
+ end
238
+
239
+ private
240
+
222
241
  ##
223
- # @param [String] text - content of YAML file
224
- def interpolate(text, use_opto: true)
225
- text.split(/[\r\n]/).map do |row|
226
- # skip lines that opto is interpolating
242
+ # @param [String] content - content of YAML file
243
+ def interpolate(content, use_opto: true, substitutions: {}, raise_on_unknown: false, warnings: true)
244
+ content.split(/[\r\n]/).map.with_index do |row, line_num|
245
+ # skip lines that opto may be interpolating
227
246
  if row.strip.start_with?('interpolate:') || row.strip.start_with?('evaluate:')
228
247
  row
229
248
  else
@@ -231,16 +250,22 @@ module Kontena::Cli::Stacks
231
250
  var = v.tr('${}', '')
232
251
 
233
252
  if use_opto
234
- val = variables.value_of(var) || ENV[var]
253
+ opt = variables.option(var)
254
+ if opt.nil?
255
+ raise RuntimeError, "Undeclared variable '#{var}' in #{file}:#{line_num} -- #{row}" if raise_on_unknown
256
+ val = nil
257
+ else
258
+ val = opt.value
259
+ end
235
260
  else
236
- val = ENV[var]
261
+ val = substitutions[var]
237
262
  end
238
263
 
239
- if val
240
- val.to_s =~ /[\r\n\"\'\|]/ ? val.inspect : val
264
+ if val && !val.to_s.empty?
265
+ val.to_s =~ /[\r\n\"\'\|]/ ? val.inspect : val.to_s
241
266
  else
242
- puts "Value for #{var} is not set. Substituting with an empty string." unless skip_validation?
243
- @replace_missing || ''
267
+ puts "Value for #{var} is not set. Substituting with an empty string." if warnings
268
+ ''
244
269
  end
245
270
  end
246
271
  end
@@ -253,6 +278,33 @@ module Kontena::Cli::Stacks
253
278
  text.gsub('$$', '$')
254
279
  end
255
280
 
281
+ # Generates an array of lambdas that return true if a condition is true
282
+ # Possible syntaxes:
283
+ # @example
284
+ # normalize_ifs( 'wp' ) # lambdas return true if variable wp is not null or false or 'false'
285
+ # normalize_ifs( wp: 1 ) # lambdas return true if value of wp is 1
286
+ # normalize_ifs( [:wp, :ws] ) # lambdas return true if wp and ws are not not null or false or 'false'
287
+ # normalize_ifs( wp: 1, ws: 1) # lambdas return true if wp and ws are 1
288
+ # normalize_ifs(nil) # returns nil
289
+ def normalize_ifs(ifs)
290
+ case ifs
291
+ when NilClass
292
+ nil
293
+ when Array
294
+ ifs.map do |iff|
295
+ lambda { val = variables.value_of(iff.to_s); !val.nil? && !val.kind_of?(FalseClass) && val != 'false' }
296
+ end
297
+ when Hash
298
+ ifs.each_with_object([]) do |(k, v), arr|
299
+ arr << lambda { variables.value_of(k.to_s) == v }
300
+ end
301
+ when String, Symbol
302
+ [lambda { val = variables.value_of(ifs.to_s); !val.nil? && !val.kind_of?(FalseClass) && val != 'false' }]
303
+ else
304
+ raise TypeError, "Invalid syntax for if: #{ifs.inspect}"
305
+ end
306
+ end
307
+
256
308
  # @param [Hash] service_config
257
309
  # @return [Hash] updated service config
258
310
  def extend_config(service_config)
@@ -281,13 +333,12 @@ module Kontena::Cli::Stacks
281
333
  end
282
334
  end
283
335
 
284
- def from_external_file(filename, service_name, from_registry: false)
285
- outcome = Reader.new(filename, skip_validation: @skip_validation, skip_variables: true, replace_missing: @replace_missing, from_registry: from_registry).execute(service_name)
286
- errors.concat outcome[:errors] unless errors.any? { |item| item.has_key?(filename) }
287
- notifications.concat outcome[:notifications] unless notifications.any? { |item| item.has_key?(filename) }
288
- outcome[:services]
336
+ def store_failures(data)
337
+ errors << { file => data[:errors] } unless data[:errors].empty?
338
+ notifications << { file => data[:notifications] } unless data[:notifications].empty?
289
339
  end
290
340
 
341
+
291
342
  # @param [Hash] options - service config
292
343
  def normalize_env_vars(options)
293
344
  if options['environment'].kind_of?(Hash)
@@ -310,7 +361,7 @@ module Kontena::Cli::Stacks
310
361
 
311
362
  # @param [String] path
312
363
  def read_env_file(path)
313
- File.readlines(path).map { |line| line.strip }.delete_if { |line| line.start_with?('#') || line.empty? }
364
+ File.readlines(path).map { |line| line.strip }.reject { |line| line.start_with?('#') || line.empty? }
314
365
  end
315
366
 
316
367
  def expand_build_context(options)
@@ -332,6 +383,44 @@ module Kontena::Cli::Stacks
332
383
  end
333
384
  end
334
385
  end
386
+
387
+ # Goes through an array of service hashes and extracts vault secret key names
388
+ # @param [Hash] services_array
389
+ # @return [Array] keys
390
+ def extract_vault_keys(services)
391
+ keys = []
392
+ services.each do |_, data|
393
+ Array(services['secrets']).each do |secret|
394
+ keys << secret['secret']
395
+ end
396
+ end
397
+ keys.uniq.compact
398
+ end
399
+
400
+ # Takes a stack name such as user/foo:1.0.0 and breaks it into components
401
+ # @param [String] stack_name
402
+ # @return [Hash] a hash with :user, :stack and :version
403
+ def parse_stack_name(stack_name)
404
+ return {} if stack_name.nil?
405
+ return {} if stack_name.empty?
406
+ name, version = stack_name.split(':', 2)
407
+ if name.include?('/')
408
+ user, stack = name.split('/', 2)
409
+ else
410
+ user = nil
411
+ stack = name
412
+ end
413
+ {
414
+ user: user,
415
+ stack: stack,
416
+ version: version
417
+ }
418
+ end
419
+
420
+
421
+ def env
422
+ ENV
423
+ end
335
424
  end
336
425
  end
337
426
  end