kubernetes_template_rendering 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: bd6a61ce9b0278d451c6e402ffab894a703846d04704061be05b1bdd30ddfef1
4
+ data.tar.gz: '079c1784c5357f79fa1e6d6e5a15ef00ea6f2bdf72f37b1ff3949283d3d328ef'
5
+ SHA512:
6
+ metadata.gz: 2eee87d74893f113a7b999c312457001cc5a8df0fed38b3827b7e773b5f243f2f73625b69ceb7e330576a4438671aa7f7c6491bf80b1b3e82b2167ab31d272ed
7
+ data.tar.gz: 17698c156cc055c8d0e42c1194f88d1ba99b1152712c2ee212393222ac5a93c75ca1029f424cf01f86993f95113fec39183546f4aef04a3d46f3f02de8e6190c
@@ -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 `kubernetes_template_rendering`
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
+ # KubernetesTemplateRendering
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 kubernetes_template_rendering
12
+ ```
13
+
14
+ If bundler is not being used to manage dependencies, install the gem by executing:
15
+ ```
16
+ gem install kubernetes_template_rendering
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 kubernetes_template_rendering 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 kubernetes_template_rendering 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/kubernetes_template_rendering.
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/kubernetes_template_rendering/cli"
11
+
12
+ KubernetesTemplateRendering::CLI.parse_and_render(ARGV)
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "template_directory_renderer"
4
+ require_relative "cli_arguments"
5
+
6
+ module KubernetesTemplateRendering
7
+ class CLI
8
+ DEFINITIONS_FILENAME = "definitions.yaml"
9
+
10
+ Arguments = CLIArguments
11
+
12
+ class << self
13
+ def parse_and_render(options)
14
+ renderer, args = parse(options)
15
+
16
+ renderer.render(args)
17
+ end
18
+
19
+ private
20
+
21
+ def parse(options)
22
+ args = Arguments.new
23
+
24
+ parser = OptionParser.new do |op|
25
+ op.banner = "Usage: #{$PROGRAM_NAME} --rendered-directory=<directory> <template directory>"
26
+
27
+ op.on("--rendered-directory=RENDERED_DIRECTORY", "set the directory where rendered output is written") { args.rendered_directory = _1 }
28
+ op.on("--[no-]fork", "disable/enable fork") { |fork| args.fork = fork }
29
+ op.on("--makeflags=MAKEFLAGS", "pass through makeflags so that we can infer fork preference from -j") { args.makeflags = _1 }
30
+ op.on("--jsonnet_library_path=JSONNET_LIBRARY_PATH", "set the jsonnet library path") { args.jsonnet_library_path = _1 }
31
+ op.on("--cluster_type=CLUSTER_TYPE", "set the specific cluster type to render") { args.cluster_type = _1 }
32
+ op.on("--region=REGION", "set the specific region to render") { args.region = _1 }
33
+ op.on("--color=COLOR", "set the specific color to render") { args.color = _1 }
34
+
35
+ op.on("--variable-override=KEY:VALUE", "override a variable value set within definitions.yaml") do |override|
36
+ args.variable_overrides ||= {}
37
+ args.variable_overrides.merge!(Hash[[override.split(":", 2)]])
38
+ end
39
+
40
+ op.on("-h", "--help") do
41
+ puts op
42
+ exit
43
+ end
44
+ end
45
+
46
+ parser.parse!(options)
47
+ args.template_directory = options.first
48
+
49
+ unless args.valid?
50
+ STDERR.puts(parser)
51
+ exit(1)
52
+ end
53
+
54
+ [renderer_from_args(args), args]
55
+ end
56
+
57
+ def renderer_from_args(args)
58
+ directories = template_directories(args.template_directory, DEFINITIONS_FILENAME)
59
+
60
+ TemplateDirectoryRenderer.new(
61
+ directories: directories,
62
+ rendered_directory: args.rendered_directory,
63
+ cluster_type: args.cluster_type,
64
+ region: args.region,
65
+ color: args.color,
66
+ variable_overrides: args.variable_overrides
67
+ )
68
+ end
69
+
70
+ def template_directories(template_directory, definitions_file)
71
+ File.directory?(template_directory) or raise ArgumentError, "template directory not found--make sure to include templates/ prefix: #{template_directory}"
72
+ directories = Dir["#{template_directory}/**/#{definitions_file}"]
73
+ directories.map { |directory| File.dirname(directory) }
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KubernetesTemplateRendering
4
+ CLIArguments =
5
+ Struct.new(
6
+ :rendered_directory,
7
+ :template_directory,
8
+ :fork,
9
+ :makeflags,
10
+ :jsonnet_library_path,
11
+ :cluster_type,
12
+ :region,
13
+ :color,
14
+ :variable_overrides
15
+ ) do
16
+ def valid?
17
+ rendered_directory && template_directory
18
+ end
19
+
20
+ def fork?
21
+ if fork.nil?
22
+ makeflags&.include?('-j')
23
+ else
24
+ fork
25
+ end
26
+ end
27
+
28
+ def render_files?
29
+ true
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KubernetesTemplateRendering
4
+ module Color
5
+ class << self
6
+ def black(str)
7
+ "\e[30m#{str}\e[0m"
8
+ end
9
+
10
+ def red(str)
11
+ "\e[31m#{str}\e[0m"
12
+ end
13
+
14
+ def green(str)
15
+ "\e[32m#{str}\e[0m"
16
+ end
17
+
18
+ def brown(str)
19
+ "\e[33m#{str}\e[0m"
20
+ end
21
+
22
+ def blue(str)
23
+ "\e[34m#{str}\e[0m"
24
+ end
25
+
26
+ def magenta(str)
27
+ "\e[35m#{str}\e[0m"
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "resource"
4
+
5
+ module KubernetesTemplateRendering
6
+ class DeployGroupedResource
7
+ DEFAULT_GROUP_VARIABLE_NAME = "deploy_group"
8
+
9
+ attr_reader :groups_to_render, :variables, :template_path, :output_directory, :template_path_exclusions, :group_variable_name
10
+
11
+ def initialize(template_path:, definitions_path:, variables:, output_directory:, groups_to_render:, template_path_exclusions:, group_variable_name: nil)
12
+ @template_path = template_path
13
+ @definitions_path = definitions_path
14
+ @variables = variables
15
+ @output_directory = output_directory
16
+ @groups_to_render = groups_to_render
17
+ @template_path_exclusions = template_path_exclusions || {}
18
+ @group_variable_name = group_variable_name || DEFAULT_GROUP_VARIABLE_NAME
19
+ end
20
+
21
+ def render(args)
22
+ @resources =
23
+ groups_to_render.map do |deploy_group|
24
+ if template_is_excluded?(deploy_group)
25
+ puts "Skipping #{Color.magenta(template_path_basename)} for #{deploy_group} deploy group due to it being " \
26
+ "excluded within the deploy group config\n\n"
27
+ nil
28
+ else
29
+ vars = variables.merge(group_variable_name => deploy_group)
30
+ filename = filename_for_deploy_group(deploy_group)
31
+ Resource.new(template_path: template_path, definitions_path: @definitions_path, variables: vars, output_directory: output_directory, output_filename: filename).tap do |resource|
32
+ resource.render(args) if args.render_files?
33
+ end
34
+ end
35
+ end.compact
36
+ end
37
+
38
+ private
39
+
40
+ def template_path_basename
41
+ @template_path_basename ||= File.basename(template_path)
42
+ end
43
+
44
+ def template_is_excluded?(deploy_group)
45
+ @template_path_exclusions[deploy_group]&.include?(template_path_basename)
46
+ end
47
+
48
+ def filename_for_deploy_group(group)
49
+ case @template_path
50
+ when /.erb/
51
+ File.basename(template_path, ".erb").sub(/([^-.]+)\.yaml$/, "#{group}-\\1.yaml")
52
+ when /.jsonnet/
53
+ File.basename(template_path).sub(/([^-.]+)\.jsonnet$/, "#{group}-\\1.yaml")
54
+ else
55
+ raise "unexpected template_path #{template_path.inspect}"
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "template"
4
+
5
+ module KubernetesTemplateRendering
6
+ class ErbTemplate < Template
7
+ module Snippet
8
+ def snippet(file)
9
+ snippet = "#{File.dirname(@template_path)}/#{file}"
10
+ content = File.read(snippet)
11
+ erb = ERB.new(content, trim_mode: "-")
12
+ erb.filename = snippet
13
+ erb.result(variables_object._binding)
14
+ end
15
+ end
16
+
17
+ include Snippet
18
+
19
+ class VariablesClass < BasicObject
20
+ include Snippet
21
+
22
+ def initialize(template_path, variables)
23
+ @template_path = template_path
24
+ @variables = variables
25
+ end
26
+
27
+ def _binding
28
+ ::Kernel.binding
29
+ end
30
+
31
+ def keys
32
+ @variables.keys
33
+ end
34
+
35
+ def method_missing(sym, *args, &block)
36
+ @variables.fetch(sym.to_s) # will raise KeyError if not in @variables hash
37
+ end
38
+
39
+ private
40
+
41
+ def variables_object
42
+ self
43
+ end
44
+ end
45
+
46
+ def render(erb_binding: nil, jsonnet_library_path: nil)
47
+ rendered_erb = render_erb(erb_binding)
48
+ if template_path.end_with?("yaml.erb")
49
+ with_auto_generated_yaml_comment(sort_yaml(rendered_erb))
50
+ else
51
+ rendered_erb
52
+ end
53
+ end
54
+
55
+ private
56
+
57
+ def variables_object
58
+ VariablesClass.new(template_path, variables)
59
+ end
60
+
61
+ def render_erb(erb_binding)
62
+ content = File.read(template_path)
63
+ erb = ERB.new(content, trim_mode: "-")
64
+ erb.filename = template_path
65
+ erb_binding.nil? ? erb.result(variables_object._binding) : erb.result(erb_binding) # here is where we eval the template
66
+ end
67
+
68
+ def sort_yaml(erb_yaml)
69
+ if (yaml_docs = YAML.load_stream(erb_yaml)).any?
70
+ yaml_docs.map { |yaml_doc| sort_keys(yaml_doc).to_yaml }.join("\n")
71
+ else
72
+ erb_yaml
73
+ end
74
+ end
75
+
76
+ def sort_keys(json_doc)
77
+ case json_doc
78
+ when Array
79
+ json_doc.map { |v| sort_keys(v) }
80
+ when Hash
81
+ with_sorted_values = json_doc.transform_values { |v| sort_keys(v) }
82
+ with_sorted_values.sort.to_h
83
+ else
84
+ json_doc
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "jsonnet"
4
+ require_relative "template"
5
+
6
+ module KubernetesTemplateRendering
7
+ class JsonnetTemplate < Template
8
+ MULTI_FILE_RENDER_KEY = "MULTI_FILE_RENDER"
9
+ MULTI_FILE_RENDER_FILE_NAME_KEY = "MULTI_FILE_RENDER_NAME"
10
+
11
+ class MultiFileJsonnetRenderError < StandardError; end
12
+
13
+ def render(erb_binding: nil, jsonnet_library_path: nil)
14
+ json_doc = render_json_doc_from_template(jsonnet_library_path)
15
+ if multi_file_jsonnet_doc?(json_doc)
16
+ render_multi_file_jsonnet!(json_doc)
17
+ else
18
+ with_auto_generated_yaml_comment(json_doc.to_yaml)
19
+ end
20
+ end
21
+
22
+ private
23
+
24
+ def render_json_doc_from_template(jsonnet_library_path)
25
+ vm = Jsonnet::VM.new
26
+ vm.tla_code("vars", variables.to_json)
27
+ if jsonnet_library_path.nil?
28
+ vm.jpath_add(File.expand_path('../../../vendor-jb', __dir__))
29
+ else
30
+ vm.jpath_add(jsonnet_library_path)
31
+ end
32
+ JSON.parse(vm.evaluate_file(template_path))
33
+ end
34
+
35
+ def multi_file_jsonnet_doc?(json_doc)
36
+ json_doc[MULTI_FILE_RENDER_KEY]
37
+ end
38
+
39
+ # Multi-File JSONNET Template Structuring:
40
+ # Top Level - Multi File Hash
41
+ # Keys map to Either Hash or Array
42
+ # If Array, the values of the array must be Hashs.
43
+ # Hash can be either a normal Hash or another Multi-File Hash
44
+
45
+ def render_multi_file_jsonnet!(json_doc, file_name_to_yaml_hash = {})
46
+ json_doc.delete(MULTI_FILE_RENDER_KEY)
47
+ json_doc.each do |key, value|
48
+ case value
49
+ when Hash
50
+ render_json_doc(value, key, file_name_to_yaml_hash)
51
+ when NilClass
52
+ next
53
+ else
54
+ raise ArgumentError, "must be a Hash or NilClass, was #{value.inspect}"
55
+ end
56
+ end
57
+ file_name_to_yaml_hash
58
+ end
59
+
60
+ def render_json_doc(json_doc, default_file_name, file_name_to_yaml_hash)
61
+ if multi_file_jsonnet_doc?(json_doc)
62
+ render_multi_file_jsonnet!(json_doc, file_name_to_yaml_hash)
63
+ else
64
+ file_name = file_name_from_object(json_doc, default: default_file_name)
65
+ file_name_to_yaml_hash["#{file_name}.yaml"] = with_auto_generated_yaml_comment(json_doc.to_yaml)
66
+ end
67
+ end
68
+
69
+ def validate_json_doc!(json_doc, context)
70
+ json_doc.is_a?(Hash) or raise MultiFileJsonnetRenderError, context
71
+ end
72
+
73
+ def file_name_from_object(object, default: nil)
74
+ if (base_name = object.delete(MULTI_FILE_RENDER_FILE_NAME_KEY))
75
+ base_name
76
+ else
77
+ default
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,82 @@
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 KubernetesTemplateRendering
9
+ class Resource
10
+ class UnexpectedFileTypeError < StandardError; end
11
+
12
+ attr_reader :variables, :template_path, :output_directory
13
+
14
+ def initialize(template_path:, definitions_path:, variables:, output_directory:, output_filename: nil)
15
+ @template_path = template_path
16
+ @definitions_path = definitions_path
17
+ @variables = variables
18
+ @output_directory = output_directory
19
+ @output_filename = output_filename || template_filename(template_path)
20
+ end
21
+
22
+ def render(args)
23
+ write_template(args)
24
+ end
25
+
26
+ private
27
+
28
+ def rendered_template(args)
29
+ @rendered_template ||= template_klass.render(@template_path, variables, jsonnet_library_path: args.jsonnet_library_path)
30
+ end
31
+
32
+ def write_template(args)
33
+ print_status
34
+
35
+ # If a Hash is returned, that means this is a multi-file template, meaning we need to iterate over the hash.
36
+ # Else a String is returned and we can write it directly to the output file.
37
+ rt = rendered_template(args)
38
+
39
+ if args.render_files?
40
+ if rt.is_a?(Hash)
41
+ rt.each do |filename, contents|
42
+ File.write(output_path(filename), contents)
43
+ end
44
+ else
45
+ File.write(output_path(@output_filename), rt)
46
+ end
47
+ end
48
+ end
49
+
50
+ def template_klass
51
+ case @template_path
52
+ when /\.erb\z/
53
+ ErbTemplate
54
+ when /\.jsonnet\z/
55
+ JsonnetTemplate
56
+ else
57
+ raise UnexpectedFileTypeError, "Unexpected file type #{@template_path}"
58
+ end
59
+ end
60
+
61
+ def print_status
62
+ variable_output = variables.map { |k, v| "#{Color.magenta(k)}=#{Color.blue(v)}" }.join(', ')
63
+ puts "Writing #{Color.magenta(File.basename(output_path(@output_filename)))} with variables #{variable_output}\n\n"
64
+ end
65
+
66
+ def output_path(filename)
67
+ File.join(@output_directory, filename)
68
+ end
69
+
70
+ def template_filename(template_path)
71
+ if template_path.match(/\.erb\z/)
72
+ File.basename(template_path, '.erb')
73
+
74
+ elsif template_path.match(/\.jsonnet\z/)
75
+ Pathname.new(template_path).basename.sub_ext('.yaml')
76
+
77
+ else
78
+ raise "Unexpected template_path format: #{template_path}"
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,229 @@
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 KubernetesTemplateRendering
12
+ class ResourceSet
13
+ attr_reader :variables, :output_directory, :deploy_group_config, :omitted_resources,
14
+ :template_directory, :target_output_directory, :regions, :colors,
15
+ :definitions_path, :kubernetes_cluster_type
16
+
17
+ def initialize(config:, template_directory:, rendered_directory:, definitions_path:, kubernetes_cluster_type:)
18
+ @variables = config["variables"] || {}
19
+ @deploy_group_config = config["deploy_groups"]
20
+ @omitted_resources = config["omitted_resources"]
21
+ @template_directory = template_directory
22
+ @target_output_directory = config["directory"] or raise ArgumentError, "missing 'directory:' in #{config.inspect}"
23
+ @regions = config["regions"] || []
24
+ @colors = config["colors"] || []
25
+ @rendered_directory = rendered_directory
26
+ @definitions_path = definitions_path
27
+ @kubernetes_cluster_type = kubernetes_cluster_type
28
+ @resources = {}
29
+
30
+ if @kubernetes_cluster_type != "kube-platform"
31
+ @target_output_directory.include?("%{plain_region}") or raise "#{@template_directory}: target_output_directory #{@target_output_directory} needs %{plain_region}"
32
+ end
33
+ end
34
+
35
+ def normal_render(args)
36
+ dynamic_output_directory? and raise "Directory must not be dynamic: #{target_output_directory}"
37
+
38
+ variables["kubernetes_cluster_type"] = @kubernetes_cluster_type
39
+
40
+ if (plain_region = variables["plain_region"])
41
+ default_region_vars(plain_region)
42
+ end
43
+
44
+ output_directory = File.join(@rendered_directory, target_output_directory)
45
+ render_create_directory(args, output_directory)
46
+ end
47
+
48
+ def render(args)
49
+ @regions.any? or raise "#{template_directory}: must have at least one region"
50
+ @colors.any? or raise "#{template_directory}: must have at least one color"
51
+
52
+ variables["kubernetes_cluster_type"] = @kubernetes_cluster_type
53
+
54
+ @regions.each do |plain_region|
55
+ default_region_vars(plain_region)
56
+
57
+ @colors.each do |c|
58
+ variables["color"] = c
59
+ output_directory = File.join(@rendered_directory, format(@target_output_directory, plain_region: plain_region, color: c, type: @kubernetes_cluster_type))
60
+ render_create_directory(args, output_directory)
61
+ end
62
+ end
63
+ end
64
+
65
+ def render_create_directory(args, output_directory)
66
+ create_directory(output_directory)
67
+ puts "Rendering templates to: #{Color.magenta(output_directory)}"
68
+ puts "Variable assignments:"
69
+ variables.each { |k, v| puts "\t#{Color.magenta(k)}=#{Color.blue(v)}" }
70
+ puts
71
+ if omitted_resources
72
+ puts "Omitted resources:"
73
+ omitted_resources.each { |ot| puts "\t#{ot}" }
74
+ end
75
+ puts
76
+ resources(output_directory).each do |resource|
77
+ resource.render(args)
78
+ end
79
+ puts
80
+ end
81
+
82
+ private
83
+
84
+ CLOUD_REGION_TO_PROVIDER_AND_DATACENTER = {
85
+ # Note: The names below should match RegionDiscovery from process_settings-production.
86
+ # https://github.com/Invoca/process_settings-production/blob/main/settings/region_discovery/production_regions.yml
87
+ "us-east-1" => ['aws', "AWS-us-east-1"],
88
+ "us-east-2" => ['aws', "AWS-us-east-2"],
89
+ "us-central1" => ['gcp', "GCE-us-central1"],
90
+ "us-west2" => ['gcp', "GCE-us-west2"],
91
+ "eu-central-1" => ['aws', "AWS-eu-central-1"],
92
+ "eu-west-1" => ['aws', "AWS-eu-west-1"],
93
+ "europe-west4" => ['gcp', "GCE-europe-west4"],
94
+
95
+ # other regions
96
+ "us-east1" => ['gcp', "GCE-us-east1"],
97
+ "us-west1" => ['gcp', "GCE-us-west1"],
98
+ "local" => ['', "local"]
99
+ }.freeze
100
+
101
+ # The zone to use for failure-domain.beta.kubernetes.io/zone or topology.kubernetes.io/zone by DeployGroup
102
+ AVAILABILITY_ZONE_FOR_DEPLOY_GROUP = {
103
+ "us-east-1" => {
104
+ "primary" => "us-east-1c",
105
+ "secondary" => "us-east-1d",
106
+ "tertiary" => "us-east-1b"
107
+ },
108
+ "us-east-2" => {
109
+ "primary" => "us-east-2a",
110
+ "secondary" => "us-east-2b",
111
+ "tertiary" => "us-east-2c"
112
+ },
113
+ "us-central1" => {
114
+ "primary" => "us-central1-a",
115
+ "secondary" => "us-central1-b",
116
+ "tertiary" => "us-central1-c"
117
+ },
118
+ "us-west2" => {
119
+ "primary" => "us-west2-a",
120
+ "secondary" => "us-west2-b",
121
+ "tertiary" => "us-west2-c"
122
+ },
123
+ "eu-central-1" => {
124
+ "primary" => "eu-central-1a",
125
+ "secondary" => "eu-central-1b",
126
+ "tertiary" => "eu-central-1c"
127
+ },
128
+ "eu-west-1" => {
129
+ "primary" => "eu-west-1a",
130
+ "secondary" => "eu-west-1b",
131
+ "tertiary" => "eu-west-1c"
132
+ },
133
+ "europe-west4" => {
134
+ "primary" => "europe-west4-a",
135
+ "secondary" => "europe-west4-b",
136
+ "tertiary" => "europe-west4-c"
137
+ },
138
+ "local" => {},
139
+ "us-west1" => {},
140
+ "us-east1" => {}
141
+ }
142
+
143
+ def default_region_vars(plain_region)
144
+ variables.has_key?("region") and raise "replace region with plain_region"
145
+ variables["plain_region"] = plain_region
146
+ 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}"
147
+ variables["cloud_provider"], variables["cloud_datacenter"] = cloud_datacenter_and_provider
148
+ variables["cloud_region"] = variables["cloud_datacenter"] # for compatibility with old resource files; cloud_datacenter is preferred now
149
+ if plain_region == "local"
150
+ variables["data_silo"] ||= "local"
151
+ elsif plain_region.start_with?("us-")
152
+ variables["data_silo"] ||= "us"
153
+ end
154
+
155
+ # cannot do ||= because we want to reset when plain_region changes
156
+ 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}"
157
+ end
158
+
159
+ def create_directory(directory)
160
+ unless File.exist?(directory)
161
+ puts <<~MESSAGE
162
+
163
+ Directory #{Color.magenta(directory)} doesn't exist, #{Color.green('creating it')}
164
+
165
+ MESSAGE
166
+ FileUtils.mkdir_p(directory)
167
+ end
168
+ end
169
+
170
+ def resources(output_directory)
171
+ @resources[output_directory] ||= standard_resources(output_directory) + grouped_resources(output_directory)
172
+ end
173
+
174
+ def standard_resources(output_directory)
175
+ standard_template_paths.map do |path|
176
+ Resource.new(template_path: path, definitions_path: @definitions_path, variables: variables, output_directory: output_directory)
177
+ end
178
+ end
179
+
180
+ def grouped_resources(output_directory)
181
+ deploy_grouped_template_paths.map do |path|
182
+ DeployGroupedResource.new(
183
+ template_path: path,
184
+ definitions_path: @definitions_path,
185
+ variables: variables,
186
+ output_directory: output_directory,
187
+ groups_to_render: deploy_groups_to_render,
188
+ template_path_exclusions: deploy_group_config["exclude_files"],
189
+ group_variable_name: deploy_group_config["variable_name"]
190
+ )
191
+ end
192
+ end
193
+
194
+ def standard_template_paths
195
+ @standard_template_paths ||=
196
+ (Dir[File.join(template_directory, "*.yaml.erb")] +
197
+ Dir[File.join(template_directory, '*.jsonnet')]) -
198
+ deploy_grouped_template_paths - omitted_resource_paths
199
+ end
200
+
201
+ def deploy_grouped_template_paths
202
+ @deploy_grouped_template_paths ||=
203
+ if deploy_group_config
204
+ deploy_group_config["files"]&.map { |file| File.join(template_directory, file) } ||
205
+ (Dir[File.join(template_directory, "*-deploy.yaml.erb")] + Dir[File.join(template_directory, "*-deploy.jsonnet")]) - omitted_resource_paths
206
+ else
207
+ []
208
+ end
209
+ end
210
+
211
+ def omitted_resource_paths
212
+ @omitted_resource_paths ||= omitted_resources&.map { |file| File.join(template_directory, file) } || []
213
+ end
214
+
215
+ def deploy_groups_to_render
216
+ group_names = deploy_group_config["group_names"]
217
+ if array_of_arrays?(group_names)
218
+ first, *rest = group_names
219
+ first.product(*rest).map { |group| group.join("-") }
220
+ else
221
+ group_names
222
+ end
223
+ end
224
+
225
+ def array_of_arrays?(array)
226
+ array.all? { |item| item.is_a?(Array) }
227
+ end
228
+ end
229
+ end
@@ -0,0 +1,51 @@
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 KubernetesTemplateRendering
10
+ class Template < OpenStruct
11
+ attr_reader :template_path, :variables
12
+ def initialize(template_path, variables)
13
+ @template_path = template_path
14
+ @variables = variables
15
+ end
16
+
17
+ def render(args)
18
+ raise "must be defined by subclass"
19
+ end
20
+
21
+ private
22
+
23
+ def with_auto_generated_yaml_comment(yaml_string)
24
+ comment = <<~EOS
25
+ # WARNING: DO NO EDIT THIS FILE!
26
+ # Any changes made here will be lost.
27
+ # This file is autogenerated from #{template_path}
28
+ EOS
29
+ comment + yaml_string
30
+ end
31
+
32
+ class << self
33
+ # @param [String] template_path: file path to template file that needs to be rendered.
34
+ # @param [Hash]: variables that will be used in the template file to generate distict files.
35
+ #
36
+ # @return [String] generated YAML file
37
+ # @return [Hash] Hash of file names as keys with the values being corresponding generated YAML.
38
+ #
39
+ # @raise [UnexpectedFileTypeError] if file type doesn't match [yaml.erb, erb, or jsonnet]
40
+ # @raise [MultiFileJsonnetRenderError] if Jsonnet template files don't follow proper MultiFileJsonnet templating.
41
+
42
+ # TODO
43
+ # The ErbTemplate and JsonnetTemplate classes both inherit from the Template class and implement a render method.
44
+ # However, the erb_binding parameter is used just in ErbTemplate, while the jsonnet_library_path parameter is used just in JsonnetTemplate.
45
+ # This is a little awkward. Potentially this could be refactored.
46
+ def render(template_path, variables, erb_binding: nil, jsonnet_library_path: nil)
47
+ new(template_path, variables).render(erb_binding: erb_binding, jsonnet_library_path: jsonnet_library_path)
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,174 @@
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 KubernetesTemplateRendering
13
+ class TemplateDirectoryRenderer
14
+ DEFINITIONS_FILENAME = "definitions.yaml"
15
+
16
+ attr_reader :directories, :omitted_names, :rendered_directory, :cluster_type, :region, :color, :variable_overrides
17
+
18
+ def initialize(directories:, rendered_directory:, omitted_names: [], cluster_type: nil, region: nil, color: nil, variable_overrides: nil)
19
+ @directories = directories_with_definitions(Array(directories))
20
+ @omitted_names = Array(omitted_names)
21
+ @rendered_directory = rendered_directory
22
+ @cluster_type = cluster_type
23
+ @region = region
24
+ @color = color
25
+ @variable_overrides = variable_overrides || {}
26
+ end
27
+
28
+ def render(args)
29
+ child_pids = []
30
+
31
+ resource_sets.each do |name, resource_sets|
32
+ puts "Rendering templates for definition #{Color.red(name)}..."
33
+ resource_sets.each do |resource_set|
34
+ if args.fork?
35
+ if (pid = Process.fork)
36
+ # this is the parent
37
+ child_pids << pid
38
+ wait_if_max_forked(child_pids)
39
+ else
40
+ # this is the child
41
+ render_set(args, resource_set)
42
+ Kernel.exit! # skip at_exit handlers since parent will run those
43
+ end
44
+ else
45
+ render_set(args, resource_set)
46
+ end
47
+ end
48
+ end
49
+
50
+ if args.fork?
51
+ Process.waitall
52
+ end
53
+ end
54
+
55
+ private
56
+
57
+ def read_definitions(path)
58
+ File.read(path)
59
+ end
60
+
61
+ MAX_FORKED_PROCESSES = 9
62
+
63
+ def wait_if_max_forked(child_pids)
64
+ while child_pids.size >= MAX_FORKED_PROCESSES
65
+ begin
66
+ Process.waitpid # this is a race condition because 1 or more processes could exit before we get here
67
+ rescue SystemCallError # this will happen if they all exited before we called waitpid
68
+ end
69
+ child_pids.delete_if do |pid|
70
+ Process.waitpid(pid, Process::WNOHANG)
71
+ rescue Errno::ECHILD # No child processes
72
+ true
73
+ end
74
+ end
75
+ end
76
+
77
+ def render_set(args, resource_set)
78
+ resource_set.render(args)
79
+ rescue => ex
80
+ raise "error rendering ResourceSet from #{resource_set.definitions_path}\n#{ex.class}: #{ex.message}"
81
+ end
82
+
83
+ def directories_with_definitions(directories)
84
+ directories.select do |dir|
85
+ definitions_path = definitions_path_for_dir(dir)
86
+ File.exist?(definitions_path)
87
+ end
88
+ end
89
+
90
+ def definitions_path_for_dir(dir)
91
+ File.join(dir, DEFINITIONS_FILENAME)
92
+ end
93
+
94
+ def resource_sets
95
+ @resource_sets ||= @directories.each_with_object({}) do |dir, hash|
96
+ definitions_path = definitions_path_for_dir(dir)
97
+ config = load_config(definitions_path)
98
+
99
+ config.map do |name, config|
100
+ next if omitted_names.include?(name)
101
+
102
+ kubernetes_cluster_type = name.sub('SPP-PLACEHOLDER', 'staging').sub(/\..*/, '') # prod.gcp => prod
103
+
104
+ hash[name] ||= []
105
+ hash[name] << ResourceSet.new(config: config, template_directory: dir, rendered_directory: @rendered_directory, kubernetes_cluster_type: kubernetes_cluster_type, definitions_path: definitions_path)
106
+ end
107
+ end
108
+ end
109
+
110
+ def build_libsonnet(dir, config)
111
+ fname = File.join(dir, 'definitions.libsonnet')
112
+ existing = File.exists?(fname) ? File.read(fname) : ""
113
+ proposed = build_json(config)
114
+
115
+ if existing != proposed
116
+ puts("Generating updated #{Color.magenta(File.basename(fname))}")
117
+ File.write(fname, proposed)
118
+ end
119
+ end
120
+
121
+ def build_json(config)
122
+ hash = transform_for_jsonnet(config)
123
+ JSON.pretty_generate(hash)
124
+ end
125
+
126
+ # This method ensures that OpenStructs are
127
+ # converted to hashes to support to_json operation
128
+ # It also converts any embedded variable place holders
129
+ # into a Jsonnet friendly format:
130
+ #
131
+ # %{variable} is converted to %(variable)s which can
132
+ # then be used with the Jsonnet function std.format
133
+ def transform_for_jsonnet(hash)
134
+ hash.transform_values do |value|
135
+ case value
136
+ when OpenStruct
137
+ transform_for_jsonnet(value.to_h)
138
+ when Hash
139
+ transform_for_jsonnet(value)
140
+ else
141
+ value
142
+ end
143
+ end
144
+ end
145
+
146
+ def load_config(definitions_path)
147
+ begin
148
+ config = YAML.safe_load(read_definitions(definitions_path), aliases: true)
149
+ rescue => ex
150
+ raise "error loading YAML from #{definitions_path}:\n#{ex.class}: #{ex.message}"
151
+ end
152
+
153
+ expand_config(config).each_with_object({}) do |(name, data), hash|
154
+ if !cluster_type || cluster_type == name.sub('SPP-PLACEHOLDER', 'staging').sub(/\..*/, '') # prod.gcp => prod
155
+ cluster_type_config = OpenStruct.new(data)
156
+
157
+ cluster_type_config.regions = cluster_type_config.regions & [region] if region
158
+ cluster_type_config.colors = cluster_type_config.colors & [color] if color
159
+ cluster_type_config.variables = (cluster_type_config.variables || {}).merge(variable_overrides)
160
+
161
+ hash[name] = cluster_type_config if (region.nil? && color.nil?) || (cluster_type_config.regions.any? && cluster_type_config.colors.any?)
162
+ end
163
+ end
164
+ end
165
+
166
+ # returns a copy of the given config hash with the COMMON: k-v removed and deep merged into the other config values
167
+ # (explicit config values take precedence over COMMON: ones)
168
+ def expand_config(config)
169
+ common = config.delete('COMMON') || {}
170
+
171
+ Hash[config.map { |k, v| [k, common.deep_merge(v)] }]
172
+ end
173
+ end
174
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KubernetesTemplateRendering
4
+ VERSION = "0.1.0.pre.1"
5
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "kubernetes_template_rendering/version"
4
+
5
+ module KubernetesTemplateRendering
6
+ class Error < StandardError; end
7
+ # Your code goes here...
8
+ end
@@ -0,0 +1,6 @@
1
+ module Invoca
2
+ module KubernetesTemplateRendering
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: kubernetes_template_rendering
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-29 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_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_templates
71
+ - lib/kubernetes_template_rendering.rb
72
+ - lib/kubernetes_template_rendering/cli.rb
73
+ - lib/kubernetes_template_rendering/cli_arguments.rb
74
+ - lib/kubernetes_template_rendering/color.rb
75
+ - lib/kubernetes_template_rendering/deploy_grouped_resource.rb
76
+ - lib/kubernetes_template_rendering/erb_template.rb
77
+ - lib/kubernetes_template_rendering/jsonnet_template.rb
78
+ - lib/kubernetes_template_rendering/resource.rb
79
+ - lib/kubernetes_template_rendering/resource_set.rb
80
+ - lib/kubernetes_template_rendering/template.rb
81
+ - lib/kubernetes_template_rendering/template_directory_renderer.rb
82
+ - lib/kubernetes_template_rendering/version.rb
83
+ - sig/invoca/kubernetes_templates.rbs
84
+ homepage: https://github.com/Invoca/kubernetes_template_rendering
85
+ licenses: []
86
+ metadata:
87
+ allowed_push_host: https://rubygems.org
88
+ homepage_uri: https://github.com/Invoca/kubernetes_template_rendering
89
+ source_code_uri: https://github.com/Invoca/kubernetes_template_rendering
90
+ changelog_uri: https://github.com/Invoca/kubernetes_template_rendering/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: []