invoca-kubernetes_templates 0.1.0.pre.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 56823d5fc55ba306d3ab46f15c1f5228fa3a57f1edcb930461c66be155291b6c
4
+ data.tar.gz: 99c85338f9c8f68433bb0443677204121a58947b87621a34c419cd3f81072b89
5
+ SHA512:
6
+ metadata.gz: 7d7c3c7d72c82e8d6360217bf552f4bee43b019072707e34832500a1ab56874b0072a8fc448d4856b0465a0a85071aabfd266732c8ca2642d712a38cb477d965
7
+ data.tar.gz: aa8dc2d7092e56247436689f1ef6572e4401a7fb07eabced192475f1c0858ef719e0483f855d071337e0ecbc3cc7d26a9b512c69c184e074d11af65c5f9f8c37
@@ -0,0 +1,13 @@
1
+ ---
2
+ common_config: &common_config
3
+ agents:
4
+ queue: "ruby-3-1"
5
+ timeout_in_minutes: 5
6
+ steps:
7
+ - label: ":ruby: Render Test Matrix"
8
+ timeout_in_minutes: 5
9
+ plugins:
10
+ - ssh://git@github.com/Invoca/invoca-ruby-test-matrix-buildkite-plugin.git#main:
11
+ min_ruby_version: '3.1'
12
+ slack_notification_channel: '#dev-team-release-backend-tools-octothorpe'
13
+ test_command: "bundle exec rspec"
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/.rubocop.yml ADDED
@@ -0,0 +1,6 @@
1
+ # Inherit from the Invoca style guide
2
+ inherit_from: https://raw.githubusercontent.com/Invoca/style-guide/master/ruby/.rubocop.yml
3
+
4
+ AllCops:
5
+ TargetRubyVersion: 3.1
6
+ Gemspec/RequireMFA: false
data/.ruby-version ADDED
@@ -0,0 +1 @@
1
+ 3.1.4
data/CHANGELOG.md ADDED
@@ -0,0 +1,9 @@
1
+ # CHANGELOG for `invoca-kubernetes_templates`
2
+
3
+ Inspired by [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
4
+
5
+ Note: this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [0.1.0] - Unreleased
8
+
9
+ - Initial release
data/README.md ADDED
@@ -0,0 +1,48 @@
1
+ # Invoca::KubernetesTemplates
2
+
3
+ The `invoca-kubernetes_template` gem is a thin wrapper around `jsonnet` and `erb` to allow for the generation of
4
+ Kubernetes manifests from a set of templates combined with a `definitions.yaml` file which stores environmental
5
+ configuration for various deployment environments.
6
+
7
+ ## Installation
8
+
9
+ Install the gem and add to the application's Gemfile by executing:
10
+ ```
11
+ bundle add invoca-kubernetes_templates
12
+ ```
13
+
14
+ If bundler is not being used to manage dependencies, install the gem by executing:
15
+ ```
16
+ gem install invoca-kubernetes_templates
17
+ ```
18
+
19
+ ## Usage
20
+
21
+ This gem is meant to be used as a command line tool for rendering templates that are either written in `jsonnet` or `erb`.
22
+ To use this gem you can either install it, and use the `render_kubernetes_template` executable directly, or you can use
23
+ `gem exec` to execute the command without first installing the gem.
24
+
25
+ ### Example Usage
26
+ ```bash
27
+ gem exec -g invoca-kubernetes_templates render_kubernetes_templates -- \
28
+ --jsonnet-library-path deployment/vendor \
29
+ --rendered_directory path/to/resources \
30
+ deployment/templates
31
+ ```
32
+
33
+ ### Options
34
+
35
+ To see a full list of options and how to use them, run the following command:
36
+ ```bash
37
+ gem exec -g invoca-kubernetes_templates render_kubernetes_templates -- --help
38
+ ```
39
+
40
+ ## Development
41
+
42
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
43
+
44
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
45
+
46
+ ## Contributing
47
+
48
+ Bug reports and pull requests are welcome on GitHub at https://github.com/invoca/invoca-kubernetes_templates.
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "erb"
5
+ require "fileutils"
6
+ require "optparse"
7
+ require "ostruct"
8
+ require "pathname"
9
+ require "yaml"
10
+ require_relative "../lib/invoca/kubernetes_templates/cli"
11
+
12
+ Invoca::KubernetesTemplates::CLI.parse_and_render(ARGV)
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "template_directory_renderer"
4
+ require_relative "cli_arguments"
5
+
6
+ module Invoca
7
+ module KubernetesTemplates
8
+ class CLI
9
+ DEFINITIONS_FILENAME = "definitions.yaml"
10
+
11
+ Arguments = CLIArguments
12
+
13
+ class << self
14
+ def parse_and_render(options)
15
+ renderer, args = parse(options)
16
+
17
+ renderer.render(args)
18
+ end
19
+
20
+ private
21
+
22
+ def parse(options)
23
+ args = Arguments.new
24
+
25
+ parser = OptionParser.new do |op|
26
+ op.banner = "Usage: #{$PROGRAM_NAME} --rendered-directory=<directory> <template directory>"
27
+
28
+ op.on("--rendered-directory=RENDERED_DIRECTORY", "set the directory where rendered output is written") { args.rendered_directory = _1 }
29
+ op.on("--[no-]fork", "disable/enable fork") { |fork| args.fork = fork }
30
+ op.on("--makeflags=MAKEFLAGS", "pass through makeflags so that we can infer fork preference from -j") { args.makeflags = _1 }
31
+ op.on("--jsonnet_library_path=JSONNET_LIBRARY_PATH", "set the jsonnet library path") { args.jsonnet_library_path = _1 }
32
+ op.on("--cluster_type=CLUSTER_TYPE", "set the specific cluster type to render") { args.cluster_type = _1 }
33
+ op.on("--region=REGION", "set the specific region to render") { args.region = _1 }
34
+ op.on("--color=COLOR", "set the specific color to render") { args.color = _1 }
35
+
36
+ op.on("--variable-override=KEY:VALUE", "override a variable value set within definitions.yaml") do |override|
37
+ args.variable_overrides ||= {}
38
+ args.variable_overrides.merge!(Hash[[override.split(":", 2)]])
39
+ end
40
+
41
+ op.on("-h", "--help") do
42
+ puts op
43
+ exit
44
+ end
45
+ end
46
+
47
+ parser.parse!(options)
48
+ args.template_directory = options.first
49
+
50
+ unless args.valid?
51
+ STDERR.puts(parser)
52
+ exit(1)
53
+ end
54
+
55
+ [renderer_from_args(args), args]
56
+ end
57
+
58
+ def renderer_from_args(args)
59
+ directories = template_directories(args.template_directory, DEFINITIONS_FILENAME)
60
+
61
+ TemplateDirectoryRenderer.new(
62
+ directories: directories,
63
+ rendered_directory: args.rendered_directory,
64
+ cluster_type: args.cluster_type,
65
+ region: args.region,
66
+ color: args.color,
67
+ variable_overrides: args.variable_overrides
68
+ )
69
+ end
70
+
71
+ def template_directories(template_directory, definitions_file)
72
+ File.directory?(template_directory) or raise ArgumentError, "template directory not found--make sure to include templates/ prefix: #{template_directory}"
73
+ directories = Dir["#{template_directory}/**/#{definitions_file}"]
74
+ directories.map { |directory| File.dirname(directory) }
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Invoca
4
+ module KubernetesTemplates
5
+ CLIArguments =
6
+ Struct.new(
7
+ :rendered_directory,
8
+ :template_directory,
9
+ :fork,
10
+ :makeflags,
11
+ :jsonnet_library_path,
12
+ :cluster_type,
13
+ :region,
14
+ :color,
15
+ :variable_overrides
16
+ ) do
17
+ def valid?
18
+ rendered_directory && template_directory
19
+ end
20
+
21
+ def fork?
22
+ if fork.nil?
23
+ makeflags&.include?('-j')
24
+ else
25
+ fork
26
+ end
27
+ end
28
+
29
+ def render_files?
30
+ true
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Invoca
4
+ module KubernetesTemplates
5
+ module Color
6
+ class << self
7
+ def black(str)
8
+ "\e[30m#{str}\e[0m"
9
+ end
10
+
11
+ def red(str)
12
+ "\e[31m#{str}\e[0m"
13
+ end
14
+
15
+ def green(str)
16
+ "\e[32m#{str}\e[0m"
17
+ end
18
+
19
+ def brown(str)
20
+ "\e[33m#{str}\e[0m"
21
+ end
22
+
23
+ def blue(str)
24
+ "\e[34m#{str}\e[0m"
25
+ end
26
+
27
+ def magenta(str)
28
+ "\e[35m#{str}\e[0m"
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "resource"
4
+
5
+ module Invoca
6
+ module KubernetesTemplates
7
+ class DeployGroupedResource
8
+ DEFAULT_GROUP_VARIABLE_NAME = "deploy_group"
9
+
10
+ attr_reader :groups_to_render, :variables, :template_path, :output_directory, :template_path_exclusions, :group_variable_name
11
+
12
+ def initialize(template_path:, definitions_path:, variables:, output_directory:, groups_to_render:, template_path_exclusions:, group_variable_name: nil)
13
+ @template_path = template_path
14
+ @definitions_path = definitions_path
15
+ @variables = variables
16
+ @output_directory = output_directory
17
+ @groups_to_render = groups_to_render
18
+ @template_path_exclusions = template_path_exclusions || {}
19
+ @group_variable_name = group_variable_name || DEFAULT_GROUP_VARIABLE_NAME
20
+ end
21
+
22
+ def render(args)
23
+ @resources =
24
+ groups_to_render.map do |deploy_group|
25
+ if template_is_excluded?(deploy_group)
26
+ puts "Skipping #{Color.magenta(template_path_basename)} for #{deploy_group} deploy group due to it being " \
27
+ "excluded within the deploy group config\n\n"
28
+ nil
29
+ else
30
+ vars = variables.merge(group_variable_name => deploy_group)
31
+ filename = filename_for_deploy_group(deploy_group)
32
+ Resource.new(template_path: template_path, definitions_path: @definitions_path, variables: vars, output_directory: output_directory, output_filename: filename).tap do |resource|
33
+ resource.render(args) if args.render_files?
34
+ end
35
+ end
36
+ end.compact
37
+ end
38
+
39
+ private
40
+
41
+ def template_path_basename
42
+ @template_path_basename ||= File.basename(template_path)
43
+ end
44
+
45
+ def template_is_excluded?(deploy_group)
46
+ @template_path_exclusions[deploy_group]&.include?(template_path_basename)
47
+ end
48
+
49
+ def filename_for_deploy_group(group)
50
+ case @template_path
51
+ when /.erb/
52
+ File.basename(template_path, ".erb").sub(/([^-.]+)\.yaml$/, "#{group}-\\1.yaml")
53
+ when /.jsonnet/
54
+ File.basename(template_path).sub(/([^-.]+)\.jsonnet$/, "#{group}-\\1.yaml")
55
+ else
56
+ raise "unexpected template_path #{template_path.inspect}"
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "template"
4
+
5
+ module Invoca
6
+ module KubernetesTemplates
7
+ class ErbTemplate < Template
8
+ module Snippet
9
+ def snippet(file)
10
+ snippet = "#{File.dirname(@template_path)}/#{file}"
11
+ content = File.read(snippet)
12
+ erb = ERB.new(content, trim_mode: "-")
13
+ erb.filename = snippet
14
+ erb.result(variables_object._binding)
15
+ end
16
+ end
17
+
18
+ include Snippet
19
+
20
+ class VariablesClass < BasicObject
21
+ include Snippet
22
+
23
+ def initialize(template_path, variables)
24
+ @template_path = template_path
25
+ @variables = variables
26
+ end
27
+
28
+ def _binding
29
+ ::Kernel.binding
30
+ end
31
+
32
+ def keys
33
+ @variables.keys
34
+ end
35
+
36
+ def method_missing(sym, *args, &block)
37
+ @variables.fetch(sym.to_s) # will raise KeyError if not in @variables hash
38
+ end
39
+
40
+ private
41
+
42
+ def variables_object
43
+ self
44
+ end
45
+ end
46
+
47
+ def render(erb_binding: nil, jsonnet_library_path: nil)
48
+ rendered_erb = render_erb(erb_binding)
49
+ if template_path.end_with?("yaml.erb")
50
+ with_auto_generated_yaml_comment(sort_yaml(rendered_erb))
51
+ else
52
+ rendered_erb
53
+ end
54
+ end
55
+
56
+ private
57
+
58
+ def variables_object
59
+ VariablesClass.new(template_path, variables)
60
+ end
61
+
62
+ def render_erb(erb_binding)
63
+ content = File.read(template_path)
64
+ erb = ERB.new(content, trim_mode: "-")
65
+ erb.filename = template_path
66
+ erb_binding.nil? ? erb.result(variables_object._binding) : erb.result(erb_binding) # here is where we eval the template
67
+ end
68
+
69
+ def sort_yaml(erb_yaml)
70
+ if (yaml_docs = YAML.load_stream(erb_yaml)).any?
71
+ yaml_docs.map { |yaml_doc| sort_keys(yaml_doc).to_yaml }.join("\n")
72
+ else
73
+ erb_yaml
74
+ end
75
+ end
76
+
77
+ def sort_keys(json_doc)
78
+ case json_doc
79
+ when Array
80
+ json_doc.map { |v| sort_keys(v) }
81
+ when Hash
82
+ with_sorted_values = json_doc.transform_values { |v| sort_keys(v) }
83
+ with_sorted_values.sort.to_h
84
+ else
85
+ json_doc
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "jsonnet"
4
+ require_relative "template"
5
+
6
+ module Invoca
7
+ module KubernetesTemplates
8
+ class JsonnetTemplate < Template
9
+ MULTI_FILE_RENDER_KEY = "MULTI_FILE_RENDER"
10
+ MULTI_FILE_RENDER_FILE_NAME_KEY = "MULTI_FILE_RENDER_NAME"
11
+
12
+ class MultiFileJsonnetRenderError < StandardError; end
13
+
14
+ def render(erb_binding: nil, jsonnet_library_path: nil)
15
+ json_doc = render_json_doc_from_template(jsonnet_library_path)
16
+ if multi_file_jsonnet_doc?(json_doc)
17
+ render_multi_file_jsonnet!(json_doc)
18
+ else
19
+ with_auto_generated_yaml_comment(json_doc.to_yaml)
20
+ end
21
+ end
22
+
23
+ private
24
+
25
+ def render_json_doc_from_template(jsonnet_library_path)
26
+ vm = Jsonnet::VM.new
27
+ vm.tla_code("vars", variables.to_json)
28
+ if jsonnet_library_path.nil?
29
+ vm.jpath_add(File.expand_path('../../../../../vendor-jb', __dir__))
30
+ else
31
+ vm.jpath_add(jsonnet_library_path)
32
+ end
33
+ JSON.parse(vm.evaluate_file(template_path))
34
+ end
35
+
36
+ def multi_file_jsonnet_doc?(json_doc)
37
+ json_doc[MULTI_FILE_RENDER_KEY]
38
+ end
39
+
40
+ # Multi-File JSONNET Template Structuring:
41
+ # Top Level - Multi File Hash
42
+ # Keys map to Either Hash or Array
43
+ # If Array, the values of the array must be Hashs.
44
+ # Hash can be either a normal Hash or another Multi-File Hash
45
+
46
+ def render_multi_file_jsonnet!(json_doc, file_name_to_yaml_hash = {})
47
+ json_doc.delete(MULTI_FILE_RENDER_KEY)
48
+ json_doc.each do |key, value|
49
+ case value
50
+ when Hash
51
+ render_json_doc(value, key, file_name_to_yaml_hash)
52
+ when NilClass
53
+ next
54
+ else
55
+ raise ArgumentError, "must be a Hash or NilClass, was #{value.inspect}"
56
+ end
57
+ end
58
+ file_name_to_yaml_hash
59
+ end
60
+
61
+ def render_json_doc(json_doc, default_file_name, file_name_to_yaml_hash)
62
+ if multi_file_jsonnet_doc?(json_doc)
63
+ render_multi_file_jsonnet!(json_doc, file_name_to_yaml_hash)
64
+ else
65
+ file_name = file_name_from_object(json_doc, default: default_file_name)
66
+ file_name_to_yaml_hash["#{file_name}.yaml"] = with_auto_generated_yaml_comment(json_doc.to_yaml)
67
+ end
68
+ end
69
+
70
+ def validate_json_doc!(json_doc, context)
71
+ json_doc.is_a?(Hash) or raise MultiFileJsonnetRenderError, context
72
+ end
73
+
74
+ def file_name_from_object(object, default: nil)
75
+ if (base_name = object.delete(MULTI_FILE_RENDER_FILE_NAME_KEY))
76
+ base_name
77
+ else
78
+ default
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require_relative "color"
5
+ require_relative "erb_template"
6
+ require_relative "jsonnet_template"
7
+
8
+ module Invoca
9
+ module KubernetesTemplates
10
+ class Resource
11
+ class UnexpectedFileTypeError < StandardError; end
12
+
13
+ attr_reader :variables, :template_path, :output_directory
14
+
15
+ def initialize(template_path:, definitions_path:, variables:, output_directory:, output_filename: nil)
16
+ @template_path = template_path
17
+ @definitions_path = definitions_path
18
+ @variables = variables
19
+ @output_directory = output_directory
20
+ @output_filename = output_filename || template_filename(template_path)
21
+ end
22
+
23
+ def render(args)
24
+ write_template(args)
25
+ end
26
+
27
+ private
28
+
29
+ def rendered_template(args)
30
+ @rendered_template ||= template_klass.render(@template_path, variables, jsonnet_library_path: args.jsonnet_library_path)
31
+ end
32
+
33
+ def write_template(args)
34
+ print_status
35
+
36
+ # If a Hash is returned, that means this is a multi-file template, meaning we need to iterate over the hash.
37
+ # Else a String is returned and we can write it directly to the output file.
38
+ rt = rendered_template(args)
39
+
40
+ if args.render_files?
41
+ if rt.is_a?(Hash)
42
+ rt.each do |filename, contents|
43
+ File.write(output_path(filename), contents)
44
+ end
45
+ else
46
+ File.write(output_path(@output_filename), rt)
47
+ end
48
+ end
49
+ end
50
+
51
+ def template_klass
52
+ case @template_path
53
+ when /\.erb\z/
54
+ ErbTemplate
55
+ when /\.jsonnet\z/
56
+ JsonnetTemplate
57
+ else
58
+ raise UnexpectedFileTypeError, "Unexpected file type #{@template_path}"
59
+ end
60
+ end
61
+
62
+ def print_status
63
+ variable_output = variables.map { |k, v| "#{Color.magenta(k)}=#{Color.blue(v)}" }.join(', ')
64
+ puts "Writing #{Color.magenta(File.basename(output_path(@output_filename)))} with variables #{variable_output}\n\n"
65
+ end
66
+
67
+ def output_path(filename)
68
+ File.join(@output_directory, filename)
69
+ end
70
+
71
+ def template_filename(template_path)
72
+ if template_path.match(/\.erb\z/)
73
+ File.basename(template_path, '.erb')
74
+
75
+ elsif template_path.match(/\.jsonnet\z/)
76
+ Pathname.new(template_path).basename.sub_ext('.yaml')
77
+
78
+ else
79
+ raise "Unexpected template_path format: #{template_path}"
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,231 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "resource"
4
+ require_relative "deploy_grouped_resource"
5
+
6
+ # This class points to the resources in a given template_directory for a given kubernetes_cluster_type like 'ops' or 'prod' or 'ci'.
7
+ # `config` contains the definitions found in `definitions_path`.
8
+ # The most important config is "directory" which is a pattern like "%{region}/%{type}/%{color}/staging-ops".
9
+ # The config "regions", "colors" are the sets of regions and colors to render for.
10
+ # It renders into `rendered_directory`.
11
+ module Invoca
12
+ module KubernetesTemplates
13
+ class ResourceSet
14
+ attr_reader :variables, :output_directory, :deploy_group_config, :omitted_resources,
15
+ :template_directory, :target_output_directory, :regions, :colors,
16
+ :definitions_path, :kubernetes_cluster_type
17
+
18
+ def initialize(config:, template_directory:, rendered_directory:, definitions_path:, kubernetes_cluster_type:)
19
+ @variables = config["variables"] || {}
20
+ @deploy_group_config = config["deploy_groups"]
21
+ @omitted_resources = config["omitted_resources"]
22
+ @template_directory = template_directory
23
+ @target_output_directory = config["directory"] or raise ArgumentError, "missing 'directory:' in #{config.inspect}"
24
+ @regions = config["regions"] || []
25
+ @colors = config["colors"] || []
26
+ @rendered_directory = rendered_directory
27
+ @definitions_path = definitions_path
28
+ @kubernetes_cluster_type = kubernetes_cluster_type
29
+ @resources = {}
30
+
31
+ if @kubernetes_cluster_type != "kube-platform"
32
+ @target_output_directory.include?("%{plain_region}") or raise "#{@template_directory}: target_output_directory #{@target_output_directory} needs %{plain_region}"
33
+ end
34
+ end
35
+
36
+ def normal_render(args)
37
+ dynamic_output_directory? and raise "Directory must not be dynamic: #{target_output_directory}"
38
+
39
+ variables["kubernetes_cluster_type"] = @kubernetes_cluster_type
40
+
41
+ if (plain_region = variables["plain_region"])
42
+ default_region_vars(plain_region)
43
+ end
44
+
45
+ output_directory = File.join(@rendered_directory, target_output_directory)
46
+ render_create_directory(args, output_directory)
47
+ end
48
+
49
+ def render(args)
50
+ @regions.any? or raise "#{template_directory}: must have at least one region"
51
+ @colors.any? or raise "#{template_directory}: must have at least one color"
52
+
53
+ variables["kubernetes_cluster_type"] = @kubernetes_cluster_type
54
+
55
+ @regions.each do |plain_region|
56
+ default_region_vars(plain_region)
57
+
58
+ @colors.each do |c|
59
+ variables["color"] = c
60
+ output_directory = File.join(@rendered_directory, format(@target_output_directory, plain_region: plain_region, color: c, type: @kubernetes_cluster_type))
61
+ render_create_directory(args, output_directory)
62
+ end
63
+ end
64
+ end
65
+
66
+ def render_create_directory(args, output_directory)
67
+ create_directory(output_directory)
68
+ puts "Rendering templates to: #{Color.magenta(output_directory)}"
69
+ puts "Variable assignments:"
70
+ variables.each { |k, v| puts "\t#{Color.magenta(k)}=#{Color.blue(v)}" }
71
+ puts
72
+ if omitted_resources
73
+ puts "Omitted resources:"
74
+ omitted_resources.each { |ot| puts "\t#{ot}" }
75
+ end
76
+ puts
77
+ resources(output_directory).each do |resource|
78
+ resource.render(args)
79
+ end
80
+ puts
81
+ end
82
+
83
+ private
84
+
85
+ CLOUD_REGION_TO_PROVIDER_AND_DATACENTER = {
86
+ # Note: The names below should match RegionDiscovery from process_settings-production.
87
+ # https://github.com/Invoca/process_settings-production/blob/main/settings/region_discovery/production_regions.yml
88
+ "us-east-1" => ['aws', "AWS-us-east-1"],
89
+ "us-east-2" => ['aws', "AWS-us-east-2"],
90
+ "us-central1" => ['gcp', "GCE-us-central1"],
91
+ "us-west2" => ['gcp', "GCE-us-west2"],
92
+ "eu-central-1" => ['aws', "AWS-eu-central-1"],
93
+ "eu-west-1" => ['aws', "AWS-eu-west-1"],
94
+ "europe-west4" => ['gcp', "GCE-europe-west4"],
95
+
96
+ # other regions
97
+ "us-east1" => ['gcp', "GCE-us-east1"],
98
+ "us-west1" => ['gcp', "GCE-us-west1"],
99
+ "local" => ['', "local"]
100
+ }.freeze
101
+
102
+ # The zone to use for failure-domain.beta.kubernetes.io/zone or topology.kubernetes.io/zone by DeployGroup
103
+ AVAILABILITY_ZONE_FOR_DEPLOY_GROUP = {
104
+ "us-east-1" => {
105
+ "primary" => "us-east-1c",
106
+ "secondary" => "us-east-1d",
107
+ "tertiary" => "us-east-1b"
108
+ },
109
+ "us-east-2" => {
110
+ "primary" => "us-east-2a",
111
+ "secondary" => "us-east-2b",
112
+ "tertiary" => "us-east-2c"
113
+ },
114
+ "us-central1" => {
115
+ "primary" => "us-central1-a",
116
+ "secondary" => "us-central1-b",
117
+ "tertiary" => "us-central1-c"
118
+ },
119
+ "us-west2" => {
120
+ "primary" => "us-west2-a",
121
+ "secondary" => "us-west2-b",
122
+ "tertiary" => "us-west2-c"
123
+ },
124
+ "eu-central-1" => {
125
+ "primary" => "eu-central-1a",
126
+ "secondary" => "eu-central-1b",
127
+ "tertiary" => "eu-central-1c"
128
+ },
129
+ "eu-west-1" => {
130
+ "primary" => "eu-west-1a",
131
+ "secondary" => "eu-west-1b",
132
+ "tertiary" => "eu-west-1c"
133
+ },
134
+ "europe-west4" => {
135
+ "primary" => "europe-west4-a",
136
+ "secondary" => "europe-west4-b",
137
+ "tertiary" => "europe-west4-c"
138
+ },
139
+ "local" => {},
140
+ "us-west1" => {},
141
+ "us-east1" => {}
142
+ }
143
+
144
+ def default_region_vars(plain_region)
145
+ variables.has_key?("region") and raise "replace region with plain_region"
146
+ variables["plain_region"] = plain_region
147
+ cloud_datacenter_and_provider = CLOUD_REGION_TO_PROVIDER_AND_DATACENTER[plain_region] or raise "no CLOUD_REGION_TO_PROVIDER_AND_DATACENTER entry found for #{plain_region.inspect}"
148
+ variables["cloud_provider"], variables["cloud_datacenter"] = cloud_datacenter_and_provider
149
+ variables["cloud_region"] = variables["cloud_datacenter"] # for compatibility with old resource files; cloud_datacenter is preferred now
150
+ if plain_region == "local"
151
+ variables["data_silo"] ||= "local"
152
+ elsif plain_region.start_with?("us-")
153
+ variables["data_silo"] ||= "us"
154
+ end
155
+
156
+ # cannot do ||= because we want to reset when plain_region changes
157
+ variables["availability_zone_for_deploy_group"] = AVAILABILITY_ZONE_FOR_DEPLOY_GROUP[plain_region] or raise "no AVAILABILITY_ZONE_FOR_DEPLOY_GROUP mapping found for #{plain_region.inspect}"
158
+ end
159
+
160
+ def create_directory(directory)
161
+ unless File.exist?(directory)
162
+ puts <<~MESSAGE
163
+
164
+ Directory #{Color.magenta(directory)} doesn't exist, #{Color.green('creating it')}
165
+
166
+ MESSAGE
167
+ FileUtils.mkdir_p(directory)
168
+ end
169
+ end
170
+
171
+ def resources(output_directory)
172
+ @resources[output_directory] ||= standard_resources(output_directory) + grouped_resources(output_directory)
173
+ end
174
+
175
+ def standard_resources(output_directory)
176
+ standard_template_paths.map do |path|
177
+ Resource.new(template_path: path, definitions_path: @definitions_path, variables: variables, output_directory: output_directory)
178
+ end
179
+ end
180
+
181
+ def grouped_resources(output_directory)
182
+ deploy_grouped_template_paths.map do |path|
183
+ DeployGroupedResource.new(
184
+ template_path: path,
185
+ definitions_path: @definitions_path,
186
+ variables: variables,
187
+ output_directory: output_directory,
188
+ groups_to_render: deploy_groups_to_render,
189
+ template_path_exclusions: deploy_group_config["exclude_files"],
190
+ group_variable_name: deploy_group_config["variable_name"]
191
+ )
192
+ end
193
+ end
194
+
195
+ def standard_template_paths
196
+ @standard_template_paths ||=
197
+ (Dir[File.join(template_directory, "*.yaml.erb")] +
198
+ Dir[File.join(template_directory, '*.jsonnet')]) -
199
+ deploy_grouped_template_paths - omitted_resource_paths
200
+ end
201
+
202
+ def deploy_grouped_template_paths
203
+ @deploy_grouped_template_paths ||=
204
+ if deploy_group_config
205
+ deploy_group_config["files"]&.map { |file| File.join(template_directory, file) } ||
206
+ (Dir[File.join(template_directory, "*-deploy.yaml.erb")] + Dir[File.join(template_directory, "*-deploy.jsonnet")]) - omitted_resource_paths
207
+ else
208
+ []
209
+ end
210
+ end
211
+
212
+ def omitted_resource_paths
213
+ @omitted_resource_paths ||= omitted_resources&.map { |file| File.join(template_directory, file) } || []
214
+ end
215
+
216
+ def deploy_groups_to_render
217
+ group_names = deploy_group_config["group_names"]
218
+ if array_of_arrays?(group_names)
219
+ first, *rest = group_names
220
+ first.product(*rest).map { |group| group.join("-") }
221
+ else
222
+ group_names
223
+ end
224
+ end
225
+
226
+ def array_of_arrays?(array)
227
+ array.all? { |item| item.is_a?(Array) }
228
+ end
229
+ end
230
+ end
231
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ostruct"
4
+ require "open3"
5
+ require "shellwords"
6
+ require "yaml"
7
+
8
+ # This is a base class for all Templates. Derived classes must implement the render method.
9
+ module Invoca
10
+ module KubernetesTemplates
11
+ class Template < OpenStruct
12
+ attr_reader :template_path, :variables
13
+ def initialize(template_path, variables)
14
+ @template_path = template_path
15
+ @variables = variables
16
+ end
17
+
18
+ def render(args)
19
+ raise "must be defined by subclass"
20
+ end
21
+
22
+ private
23
+
24
+ def with_auto_generated_yaml_comment(yaml_string)
25
+ comment = <<~EOS
26
+ # WARNING: DO NO EDIT THIS FILE!
27
+ # Any changes made here will be lost.
28
+ # This file is autogenerated from #{template_path}
29
+ EOS
30
+ comment + yaml_string
31
+ end
32
+
33
+ class << self
34
+ # @param [String] template_path: file path to template file that needs to be rendered.
35
+ # @param [Hash]: variables that will be used in the template file to generate distict files.
36
+ #
37
+ # @return [String] generated YAML file
38
+ # @return [Hash] Hash of file names as keys with the values being corresponding generated YAML.
39
+ #
40
+ # @raise [UnexpectedFileTypeError] if file type doesn't match [yaml.erb, erb, or jsonnet]
41
+ # @raise [MultiFileJsonnetRenderError] if Jsonnet template files don't follow proper MultiFileJsonnet templating.
42
+
43
+ # TODO
44
+ # The ErbTemplate and JsonnetTemplate classes both inherit from the Template class and implement a render method.
45
+ # However, the erb_binding parameter is used just in ErbTemplate, while the jsonnet_library_path parameter is used just in JsonnetTemplate.
46
+ # This is a little awkward. Potentially this could be refactored.
47
+ def render(template_path, variables, erb_binding: nil, jsonnet_library_path: nil)
48
+ new(template_path, variables).render(erb_binding: erb_binding, jsonnet_library_path: jsonnet_library_path)
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support'
4
+ require 'active_support/core_ext' # for deep_merge
5
+ require 'invoca/utils'
6
+ require 'yaml'
7
+ require_relative 'resource_set'
8
+ require 'set'
9
+
10
+ # This class points to a collection of template directories to render, and a rendered_directory to render into.
11
+ # Optionally, some of the template directories may be omitted by including them in `omitted_names`.
12
+ module Invoca
13
+ module KubernetesTemplates
14
+ class TemplateDirectoryRenderer
15
+ DEFINITIONS_FILENAME = "definitions.yaml"
16
+
17
+ attr_reader :directories, :omitted_names, :rendered_directory, :cluster_type, :region, :color, :variable_overrides
18
+
19
+ def initialize(directories:, rendered_directory:, omitted_names: [], cluster_type: nil, region: nil, color: nil, variable_overrides: nil)
20
+ @directories = directories_with_definitions(Array(directories))
21
+ @omitted_names = Array(omitted_names)
22
+ @rendered_directory = rendered_directory
23
+ @cluster_type = cluster_type
24
+ @region = region
25
+ @color = color
26
+ @variable_overrides = variable_overrides || {}
27
+ end
28
+
29
+ def render(args)
30
+ child_pids = []
31
+
32
+ resource_sets.each do |name, resource_sets|
33
+ puts "Rendering templates for definition #{Color.red(name)}..."
34
+ resource_sets.each do |resource_set|
35
+ if args.fork?
36
+ if (pid = Process.fork)
37
+ # this is the parent
38
+ child_pids << pid
39
+ wait_if_max_forked(child_pids)
40
+ else
41
+ # this is the child
42
+ render_set(args, resource_set)
43
+ Kernel.exit! # skip at_exit handlers since parent will run those
44
+ end
45
+ else
46
+ render_set(args, resource_set)
47
+ end
48
+ end
49
+ end
50
+
51
+ if args.fork?
52
+ Process.waitall
53
+ end
54
+ end
55
+
56
+ private
57
+
58
+ def read_definitions(path)
59
+ File.read(path)
60
+ end
61
+
62
+ MAX_FORKED_PROCESSES = 9
63
+
64
+ def wait_if_max_forked(child_pids)
65
+ while child_pids.size >= MAX_FORKED_PROCESSES
66
+ begin
67
+ Process.waitpid # this is a race condition because 1 or more processes could exit before we get here
68
+ rescue SystemCallError # this will happen if they all exited before we called waitpid
69
+ end
70
+ child_pids.delete_if do |pid|
71
+ Process.waitpid(pid, Process::WNOHANG)
72
+ rescue Errno::ECHILD # No child processes
73
+ true
74
+ end
75
+ end
76
+ end
77
+
78
+ def render_set(args, resource_set)
79
+ resource_set.render(args)
80
+ rescue => ex
81
+ raise "error rendering ResourceSet from #{resource_set.definitions_path}\n#{ex.class}: #{ex.message}"
82
+ end
83
+
84
+ def directories_with_definitions(directories)
85
+ directories.select do |dir|
86
+ definitions_path = definitions_path_for_dir(dir)
87
+ File.exist?(definitions_path)
88
+ end
89
+ end
90
+
91
+ def definitions_path_for_dir(dir)
92
+ File.join(dir, DEFINITIONS_FILENAME)
93
+ end
94
+
95
+ def resource_sets
96
+ @resource_sets ||= @directories.each_with_object({}) do |dir, hash|
97
+ definitions_path = definitions_path_for_dir(dir)
98
+ config = load_config(definitions_path)
99
+
100
+ config.map do |name, config|
101
+ next if omitted_names.include?(name)
102
+
103
+ kubernetes_cluster_type = name.sub('SPP-PLACEHOLDER', 'staging').sub(/\..*/, '') # prod.gcp => prod
104
+
105
+ hash[name] ||= []
106
+ hash[name] << ResourceSet.new(config: config, template_directory: dir, rendered_directory: @rendered_directory, kubernetes_cluster_type: kubernetes_cluster_type, definitions_path: definitions_path)
107
+ end
108
+ end
109
+ end
110
+
111
+ def build_libsonnet(dir, config)
112
+ fname = File.join(dir, 'definitions.libsonnet')
113
+ existing = File.exists?(fname) ? File.read(fname) : ""
114
+ proposed = build_json(config)
115
+
116
+ if existing != proposed
117
+ puts("Generating updated #{Color.magenta(File.basename(fname))}")
118
+ File.write(fname, proposed)
119
+ end
120
+ end
121
+
122
+ def build_json(config)
123
+ hash = transform_for_jsonnet(config)
124
+ JSON.pretty_generate(hash)
125
+ end
126
+
127
+ # This method ensures that OpenStructs are
128
+ # converted to hashes to support to_json operation
129
+ # It also converts any embedded variable place holders
130
+ # into a Jsonnet friendly format:
131
+ #
132
+ # %{variable} is converted to %(variable)s which can
133
+ # then be used with the Jsonnet function std.format
134
+ def transform_for_jsonnet(hash)
135
+ hash.transform_values do |value|
136
+ case value
137
+ when OpenStruct
138
+ transform_for_jsonnet(value.to_h)
139
+ when Hash
140
+ transform_for_jsonnet(value)
141
+ else
142
+ value
143
+ end
144
+ end
145
+ end
146
+
147
+ def load_config(definitions_path)
148
+ begin
149
+ config = YAML.safe_load(read_definitions(definitions_path), aliases: true)
150
+ rescue => ex
151
+ raise "error loading YAML from #{definitions_path}:\n#{ex.class}: #{ex.message}"
152
+ end
153
+
154
+ expand_config(config).each_with_object({}) do |(name, data), hash|
155
+ if !cluster_type || cluster_type == name.sub('SPP-PLACEHOLDER', 'staging').sub(/\..*/, '') # prod.gcp => prod
156
+ cluster_type_config = OpenStruct.new(data)
157
+
158
+ cluster_type_config.regions = cluster_type_config.regions & [region] if region
159
+ cluster_type_config.colors = cluster_type_config.colors & [color] if color
160
+ cluster_type_config.variables = cluster_type_config.variables.merge(variable_overrides)
161
+
162
+ hash[name] = cluster_type_config if (region.nil? && color.nil?) || (cluster_type_config.regions.any? && cluster_type_config.colors.any?)
163
+ end
164
+ end
165
+ end
166
+
167
+ # returns a copy of the given config hash with the COMMON: k-v removed and deep merged into the other config values
168
+ # (explicit config values take precedence over COMMON: ones)
169
+ def expand_config(config)
170
+ common = config.delete('COMMON') || {}
171
+
172
+ Hash[config.map { |k, v| [k, common.deep_merge(v)] }]
173
+ end
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Invoca
4
+ module KubernetesTemplates
5
+ VERSION = "0.1.0.pre.1"
6
+ end
7
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "kubernetes_templates/version"
4
+
5
+ module Invoca
6
+ module KubernetesTemplates
7
+ class Error < StandardError; end
8
+ # Your code goes here...
9
+ end
10
+ end
@@ -0,0 +1,6 @@
1
+ module Invoca
2
+ module KubernetesTemplates
3
+ VERSION: String
4
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
5
+ end
6
+ end
metadata ADDED
@@ -0,0 +1,110 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: invoca-kubernetes_templates
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0.pre.1
5
+ platform: ruby
6
+ authors:
7
+ - Octothorpe
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2024-04-24 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activesupport
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: jsonnet
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: invoca-utils
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ description: Tool for rendering ERB and Jsonnet templates
56
+ email:
57
+ - octothorpe@invoca.com
58
+ executables:
59
+ - render_kubernetes_templates
60
+ extensions: []
61
+ extra_rdoc_files: []
62
+ files:
63
+ - ".buildkite/pipeline.yml"
64
+ - ".rspec"
65
+ - ".rubocop.yml"
66
+ - ".ruby-version"
67
+ - CHANGELOG.md
68
+ - README.md
69
+ - Rakefile
70
+ - exe/render_kubernetes_templates
71
+ - lib/invoca/kubernetes_templates.rb
72
+ - lib/invoca/kubernetes_templates/cli.rb
73
+ - lib/invoca/kubernetes_templates/cli_arguments.rb
74
+ - lib/invoca/kubernetes_templates/color.rb
75
+ - lib/invoca/kubernetes_templates/deploy_grouped_resource.rb
76
+ - lib/invoca/kubernetes_templates/erb_template.rb
77
+ - lib/invoca/kubernetes_templates/jsonnet_template.rb
78
+ - lib/invoca/kubernetes_templates/resource.rb
79
+ - lib/invoca/kubernetes_templates/resource_set.rb
80
+ - lib/invoca/kubernetes_templates/template.rb
81
+ - lib/invoca/kubernetes_templates/template_directory_renderer.rb
82
+ - lib/invoca/kubernetes_templates/version.rb
83
+ - sig/invoca/kubernetes_templates.rbs
84
+ homepage: https://github.com/Invoca/inovca-kubernetes_templates
85
+ licenses: []
86
+ metadata:
87
+ allowed_push_host: https://rubygems.org
88
+ homepage_uri: https://github.com/Invoca/inovca-kubernetes_templates
89
+ source_code_uri: https://github.com/Invoca/inovca-kubernetes_templates
90
+ changelog_uri: https://github.com/Invoca/inovca-kubernetes_templates/blob/main/CHANGELOG.md
91
+ post_install_message:
92
+ rdoc_options: []
93
+ require_paths:
94
+ - lib
95
+ required_ruby_version: !ruby/object:Gem::Requirement
96
+ requirements:
97
+ - - ">="
98
+ - !ruby/object:Gem::Version
99
+ version: 3.1.0
100
+ required_rubygems_version: !ruby/object:Gem::Requirement
101
+ requirements:
102
+ - - ">"
103
+ - !ruby/object:Gem::Version
104
+ version: 1.3.1
105
+ requirements: []
106
+ rubygems_version: 3.4.21
107
+ signing_key:
108
+ specification_version: 4
109
+ summary: Tool for rendering ERB and Jsonnet templates
110
+ test_files: []