invoca-kubernetes_templates 0.1.0.pre.1

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 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: []