kdep 0.4.1 → 0.4.3

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 450b677af3690ea40e247b8d68f1c368dc111022188672592c17225b4e5845be
4
- data.tar.gz: 90a0f5d3d879ecc76a86a8f10948a5b3a4ebe3a30a291043231a15df4186d771
3
+ metadata.gz: ff3707147a8d4a32a335ba5ea6637e05275705d0a91c0fec08bdf649b506ba34
4
+ data.tar.gz: f3b17aeb70128be4e3b4ae9e3b46d359a11698657159a04f386a849daef6c468
5
5
  SHA512:
6
- metadata.gz: c6dd760c8f39d27b1ac34d1778703ac1135aac8cedb815a5c7447f9b7a7eaca7927021c349b7afe32055169bd7cc956af88473f6ea0a1103ea8bb7e9e599a143
7
- data.tar.gz: ae26cf5e39c5eafefcb90ca5c3d3b952c912a54ea424a0239db2c64a64b5015941f0df5a743550dce56c0b581bb70b79b41eb59a4442d36e3fd4b6320ada5ba1
6
+ metadata.gz: 5ca718ac0ff7e9f29fcafeecaae8a77e68abbaa90d6ac7c8dcea130523358e615775c8a6605becc1b7c46d9bee687f33ddf65d599eacf4d2ace92226ff7a7df2
7
+ data.tar.gz: a1750b835fee48cd3de6926e8178e904872e24a92b0a7a95433725212c4daa71b5d4cbff678db9c81ae49f21a1f0d52a45b5e05b93497c31f6852e3a48fe7b00
@@ -1,53 +1,79 @@
1
- #!/usr/bin/env bash
2
- # Update kdep in every installed rbenv Ruby.
3
- # Skips 'system' (not managed by rbenv) and any Ruby that can't install the
4
- # current kdep gem (e.g. because it's below kdep's minimum Ruby requirement).
5
-
6
- set -eo pipefail
7
-
8
- eval "$(rbenv init -)"
9
-
10
- target_gem="${1:-kdep}"
11
- want_version="${2:-}" # optional — defaults to latest on rubygems
12
-
13
- echo "== updating $target_gem${want_version:+ to $want_version} across rbenv rubies =="
14
- echo
15
-
16
- ok=()
17
- fail=()
18
- skipped=()
19
-
20
- for ruby in $(rbenv versions --bare); do
21
- # Skip 'system' and non-MRI builds you probably don't care about by default.
22
- if [[ "$ruby" == "system" ]]; then
23
- skipped+=("$ruby (system ruby)")
24
- continue
25
- fi
26
-
27
- printf -- "---- %s ----\n" "$ruby"
28
- if ! RBENV_VERSION="$ruby" ruby -v >/dev/null 2>&1; then
29
- echo " ruby binary missing, skipping"
30
- skipped+=("$ruby (binary missing)")
31
- echo
32
- continue
33
- fi
34
-
35
- cmd=(gem install --no-document "$target_gem")
36
- [[ -n "$want_version" ]] && cmd+=(-v "$want_version")
37
-
38
- if RBENV_VERSION="$ruby" "${cmd[@]}"; then
39
- installed_ver=$(RBENV_VERSION="$ruby" gem list -i "$target_gem" -e --silent >/dev/null 2>&1 && \
40
- RBENV_VERSION="$ruby" gem list "$target_gem" | awk -v g="$target_gem" '$1==g {print $2}' | tr -d '(),' | head -1)
41
- ok+=("$ruby -> $installed_ver")
1
+ #!/usr/bin/env ruby
2
+ # Update a gem (default: kdep) in every installed rbenv Ruby.
3
+ # Iterates `rbenv versions --bare`, skips `system`, and `gem install`s into each.
4
+ #
5
+ # Usage:
6
+ # kdep-update-all-rubies # latest kdep into every rbenv ruby
7
+ # kdep-update-all-rubies kdep 0.4.1 # pin to a version
8
+ # kdep-update-all-rubies rubocop # works for any gem
9
+
10
+ require "open3"
11
+
12
+ gem_name = ARGV[0] || "kdep"
13
+ gem_version = ARGV[1] # optional
14
+
15
+ def rbenv_available?
16
+ out, _err, status = Open3.capture3("sh", "-c", "rbenv --version")
17
+ status.success? && !out.strip.empty?
18
+ end
19
+
20
+ def list_rubies
21
+ out, _err, status = Open3.capture3("rbenv", "versions", "--bare")
22
+ return [] unless status.success?
23
+ out.lines.map(&:strip).reject(&:empty?)
24
+ end
25
+
26
+ def run_in_ruby(ruby, cmd)
27
+ # Resolve the target Ruby's bin dir via rbenv prefix, then exec commands
28
+ # from there. Avoids needing `eval "$(rbenv init -)"` in this script.
29
+ prefix, _err, status = Open3.capture3({ "RBENV_VERSION" => ruby }, "rbenv", "prefix")
30
+ return [nil, "rbenv prefix failed", status] unless status.success?
31
+ bin_dir = File.join(prefix.strip, "bin")
32
+ env = { "PATH" => "#{bin_dir}:#{ENV["PATH"]}", "RBENV_VERSION" => ruby }
33
+ Open3.capture3(env, *cmd)
34
+ end
35
+
36
+ unless rbenv_available?
37
+ warn "rbenv not found on PATH — this script requires rbenv"
38
+ exit 1
39
+ end
40
+
41
+ rubies = list_rubies
42
+ rubies.delete("system") # not managed by rbenv
43
+ if rubies.empty?
44
+ warn "no rbenv rubies installed"
45
+ exit 1
46
+ end
47
+
48
+ label = gem_version ? "#{gem_name} #{gem_version}" : gem_name
49
+ puts "== updating #{label} across rbenv rubies =="
50
+ puts
51
+
52
+ ok = []
53
+ failed = []
54
+
55
+ rubies.each do |ruby|
56
+ puts "---- #{ruby} ----"
57
+ cmd = ["gem", "install", "--no-document", gem_name]
58
+ cmd += ["-v", gem_version] if gem_version
59
+
60
+ stdout, stderr, status = run_in_ruby(ruby, cmd)
61
+ print stdout
62
+ $stderr.print stderr unless stderr.strip.empty?
63
+
64
+ if status&.success?
65
+ list_stdout, _ls, _lst = run_in_ruby(ruby, ["gem", "list", gem_name])
66
+ version_line = list_stdout.lines.find { |l| l =~ /^#{Regexp.escape(gem_name)}\s+/ } if list_stdout
67
+ installed = version_line ? version_line[/\(([^)]+)\)/, 1]&.split(",")&.first&.strip : "?"
68
+ ok << "#{ruby} -> #{installed}"
42
69
  else
43
- fail+=("$ruby")
44
- fi
45
- echo
46
- done
70
+ failed << ruby
71
+ end
72
+ puts
73
+ end
47
74
 
48
- echo "== summary =="
49
- for line in "${ok[@]-}"; do [[ -n "$line" ]] && echo " ok: $line"; done
50
- for line in "${skipped[@]-}"; do [[ -n "$line" ]] && echo " skipped: $line"; done
51
- for line in "${fail[@]-}"; do [[ -n "$line" ]] && echo " FAILED: $line"; done
75
+ puts "== summary =="
76
+ ok.each { |line| puts " ok: #{line}" }
77
+ failed.each { |line| puts " FAILED: #{line}" }
52
78
 
53
- exit $(( ${#fail[@]} > 0 ? 1 : 0 ))
79
+ exit(failed.empty? ? 0 : 1)
data/lib/kdep/cli.rb CHANGED
@@ -22,6 +22,8 @@ module Kdep
22
22
  "dashboard" => Commands::Dashboard,
23
23
  "doctor" => Commands::Doctor,
24
24
  "check" => Commands::Check,
25
+ "env" => Commands::Env,
26
+ "config" => Commands::Config,
25
27
  }.freeze
26
28
 
27
29
  def initialize(argv)
@@ -59,7 +59,7 @@ module Kdep
59
59
 
60
60
  writer = Kdep::Writer.new(output_dir)
61
61
  writer.clean
62
- renderer = Kdep::Renderer.new(config, deploy_dir)
62
+ renderer = Kdep::Renderer.new(config, deploy_dir, env: env)
63
63
  validator = Kdep::Validator.new
64
64
  validation_errors = []
65
65
 
@@ -42,9 +42,15 @@ module Kdep
42
42
  # Load config
43
43
  config = Kdep::Config.new(deploy_dir, env).load
44
44
 
45
- # Construct full image tag
45
+ # Resolve tag from state.yml — app.yml has no tag field by design;
46
+ # state.yml is the source of truth for the last bumped version.
47
+ tag = Kdep::State.tag(deploy_dir)
48
+ unless tag
49
+ @ui.error("No tag in state.yml for #{deploy_dir}. Run `kdep bump` first.")
50
+ exit 1
51
+ end
52
+
46
53
  image = config["image"] || config["name"]
47
- tag = config["tag"] || "latest"
48
54
  registry = config["registry"]
49
55
 
50
56
  if registry && !registry.to_s.empty?
@@ -60,7 +60,7 @@ module Kdep
60
60
  run_helm_pipeline(deploy_dir, env)
61
61
  return
62
62
  elsif preset == "custom"
63
- run_custom_pipeline(deploy_dir, config, kdep_dir)
63
+ run_custom_pipeline(deploy_dir, config, kdep_dir, env)
64
64
  return
65
65
  end
66
66
 
@@ -138,7 +138,7 @@ module Kdep
138
138
 
139
139
  writer = Kdep::Writer.new(output_dir)
140
140
  writer.clean
141
- renderer = Kdep::Renderer.new(config, deploy_dir)
141
+ renderer = Kdep::Renderer.new(config, deploy_dir, env: env)
142
142
  validator = Kdep::Validator.new
143
143
  validation_errors = []
144
144
 
@@ -230,7 +230,7 @@ module Kdep
230
230
  end
231
231
 
232
232
  # Feature E: custom-preset bump = render + apply, no docker, no state.
233
- def run_custom_pipeline(deploy_dir, config, kdep_dir)
233
+ def run_custom_pipeline(deploy_dir, config, kdep_dir, env = nil)
234
234
  # Context guard for safety.
235
235
  begin
236
236
  Kdep::ContextGuard.new(config["context"]).validate!
@@ -245,7 +245,7 @@ module Kdep
245
245
 
246
246
  writer = Kdep::Writer.new(output_dir)
247
247
  writer.clean
248
- renderer = Kdep::Renderer.new(config, deploy_dir)
248
+ renderer = Kdep::Renderer.new(config, deploy_dir, env: env)
249
249
  preset_resources = Kdep::Preset.new("custom", deploy_dir).resources
250
250
 
251
251
  preset_resources.each_with_index do |resource, idx|
@@ -0,0 +1,56 @@
1
+ require "optparse"
2
+
3
+ module Kdep
4
+ module Commands
5
+ # `kdep config set <key> <value>` — writes a dotted key into ~/.kdep/config.yml.
6
+ # `kdep config get <key>` — reads a dotted key.
7
+ #
8
+ # Currently used to seed Infisical settings:
9
+ # kdep config set infisical.identity_id <uuid>
10
+ # kdep config set infisical.host_api https://dev-env.leadfy.xyz/api
11
+ class Config
12
+ SUBCOMMANDS = %w[set get show].freeze
13
+
14
+ def self.option_parser
15
+ OptionParser.new do |opts|
16
+ opts.banner = "Usage: kdep config <set|get|show> [key] [value]"
17
+ end
18
+ end
19
+
20
+ def initialize(global_options:, command_options:, args:)
21
+ @global_options = global_options
22
+ @command_options = command_options
23
+ @args = args
24
+ @ui = Kdep::UI.new
25
+ end
26
+
27
+ def execute
28
+ sub = @args.shift
29
+ unless SUBCOMMANDS.include?(sub)
30
+ @ui.error("Unknown subcommand: #{sub.inspect}. Available: #{SUBCOMMANDS.join(', ')}")
31
+ exit 2
32
+ end
33
+
34
+ case sub
35
+ when "set"
36
+ key, value = @args[0], @args[1]
37
+ unless key && value
38
+ @ui.error("Usage: kdep config set <key> <value>")
39
+ exit 2
40
+ end
41
+ path = Kdep::UserConfig.set(key, value)
42
+ @ui.success("Set #{key} -> #{value} (#{path})")
43
+ when "get"
44
+ key = @args[0]
45
+ unless key
46
+ @ui.error("Usage: kdep config get <key>")
47
+ exit 2
48
+ end
49
+ puts Kdep::UserConfig.get(*key.split("."))
50
+ when "show"
51
+ puts Kdep::UserConfig.load.to_yaml
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -60,7 +60,7 @@ module Kdep
60
60
  Dir.mktmpdir("kdep-diff-") do |tmpdir|
61
61
  preset = Kdep::Preset.new(config["preset"], deploy_dir)
62
62
  writer = Kdep::Writer.new(tmpdir)
63
- renderer = Kdep::Renderer.new(config, deploy_dir)
63
+ renderer = Kdep::Renderer.new(config, deploy_dir, env: env)
64
64
 
65
65
  preset.resources.each_with_index do |resource_name, idx|
66
66
  begin
@@ -0,0 +1,46 @@
1
+ require "optparse"
2
+
3
+ module Kdep
4
+ module Commands
5
+ # Dispatcher for `kdep env <subcommand>`. Currently only `check` is wired.
6
+ class Env
7
+ SUBCOMMANDS = %w[check].freeze
8
+
9
+ def self.option_parser
10
+ OptionParser.new do |opts|
11
+ opts.banner = "Usage: kdep env <subcommand> [options] [deploy] [env]"
12
+ opts.separator ""
13
+ opts.separator "Subcommands:"
14
+ opts.separator " check Validate ConfigMap+Secret in cluster against env.spec"
15
+ opts.separator ""
16
+ opts.on("--env=ENV", "Environment scope (production|staging|dev)")
17
+ end
18
+ end
19
+
20
+ def initialize(global_options:, command_options:, args:)
21
+ @global_options = global_options
22
+ @command_options = command_options
23
+ @args = args
24
+ @ui = Kdep::UI.new
25
+ end
26
+
27
+ def execute
28
+ sub = @args.shift
29
+ unless SUBCOMMANDS.include?(sub)
30
+ @ui.error("Unknown subcommand: #{sub.inspect}. Available: #{SUBCOMMANDS.join(', ')}")
31
+ exit 2
32
+ end
33
+
34
+ case sub
35
+ when "check"
36
+ require "kdep/commands/env_check"
37
+ Kdep::Commands::EnvCheck.new(
38
+ global_options: @global_options,
39
+ command_options: @command_options,
40
+ args: @args
41
+ ).execute
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,104 @@
1
+ require "yaml"
2
+ require "base64"
3
+
4
+ module Kdep
5
+ module Commands
6
+ # Validates the live ConfigMap+Secret in the cluster against the contract
7
+ # declared in `env.spec` at the repo root.
8
+ #
9
+ # Reports three buckets:
10
+ # - missing : keys declared (and required) in env.spec but absent in
11
+ # cluster ConfigMap/Secret
12
+ # - extra : keys present in cluster but not declared in env.spec
13
+ # - invalid : declared keys whose value fails type validation
14
+ #
15
+ # Independent of Infisical: just compares declared shape vs live K8s state.
16
+ class EnvCheck
17
+ def initialize(global_options:, command_options:, args:)
18
+ @global_options = global_options
19
+ @command_options = command_options
20
+ @args = args
21
+ @ui = Kdep::UI.new
22
+ end
23
+
24
+ def execute
25
+ deploy_name = @args[0]
26
+ env_arg = @args[1] || @command_options[:env]
27
+
28
+ discovery = Kdep::Discovery.new
29
+ kdep_dir = discovery.find_kdep_dir
30
+ unless kdep_dir
31
+ @ui.error("No kdep/ directory found")
32
+ exit 1
33
+ end
34
+
35
+ deploy_dir = resolve_deploy_dir(kdep_dir, deploy_name, discovery)
36
+ exit 1 unless deploy_dir
37
+
38
+ repo_root = File.expand_path("..", kdep_dir)
39
+ spec_path = File.join(repo_root, "env.spec")
40
+ unless File.exist?(spec_path)
41
+ @ui.error("env.spec not found at #{spec_path}")
42
+ exit 1
43
+ end
44
+
45
+ spec = EnvSpec.parse_file(spec_path)
46
+ config = Kdep::Config.new(deploy_dir, env_arg).load
47
+ namespace = config["namespace"]
48
+ unless namespace
49
+ @ui.error("namespace missing in app.yml -- cannot query cluster")
50
+ exit 1
51
+ end
52
+
53
+ configmap_name = config["configmap_name"] || "config-#{config["name"]}"
54
+ secret_name = config["secret_name"] || "#{File.basename(deploy_dir)}-secrets"
55
+
56
+ live_cm = fetch_data("configmap", configmap_name, namespace)
57
+ live_sec = fetch_data("secret", secret_name, namespace, decode: true)
58
+
59
+ merged = live_cm.merge(live_sec)
60
+ problems = spec.validate(merged, env: env_arg || "*", strict: true)
61
+
62
+ scope_label = env_arg || "shared"
63
+ if problems.empty?
64
+ @ui.success("env satisfies env.spec (#{scope_label}, namespace=#{namespace})")
65
+ exit 0
66
+ end
67
+
68
+ @ui.error("env.spec mismatches in #{namespace} (scope=#{scope_label}):")
69
+ problems.each { |p| @ui.error(" - #{p}") }
70
+ exit 1
71
+ end
72
+
73
+ private
74
+
75
+ def resolve_deploy_dir(kdep_dir, deploy_name, discovery)
76
+ if deploy_name
77
+ dir = File.join(kdep_dir, deploy_name)
78
+ unless File.directory?(dir)
79
+ @ui.error("Deploy not found: #{deploy_name}")
80
+ return nil
81
+ end
82
+ dir
83
+ else
84
+ deploys = discovery.find_deploys
85
+ if deploys.length == 1
86
+ File.join(kdep_dir, deploys[0])
87
+ else
88
+ @ui.error("Specify a deploy: #{deploys.join(', ')}")
89
+ nil
90
+ end
91
+ end
92
+ end
93
+
94
+ def fetch_data(kind, name, namespace, decode: false)
95
+ yaml = Kdep::Kubectl.get(kind, name, namespace: namespace)
96
+ return {} unless yaml
97
+ parsed = YAML.safe_load(yaml) || {}
98
+ data = parsed["data"] || {}
99
+ return data unless decode
100
+ data.each_with_object({}) { |(k, v), out| out[k] = Base64.decode64(v.to_s) }
101
+ end
102
+ end
103
+ end
104
+ end
@@ -26,6 +26,7 @@ module Kdep
26
26
  def execute
27
27
  deploy_name = @args[0]
28
28
  env = @args[1]
29
+ @env = env
29
30
  @dry_run = @command_options[:"dry-run"]
30
31
 
31
32
  # Discover kdep/ directory
@@ -239,7 +240,7 @@ module Kdep
239
240
 
240
241
  writer = Kdep::Writer.new(output_dir)
241
242
  writer.clean
242
- renderer = Kdep::Renderer.new(config, @deploy_dir)
243
+ renderer = Kdep::Renderer.new(config, @deploy_dir, env: @env)
243
244
  validator = Kdep::Validator.new
244
245
 
245
246
  files_written = 0
@@ -72,12 +72,20 @@ module Kdep
72
72
  File.write(File.join(target_dir, "app.yml"), app_yml_content)
73
73
  @ui.file_written("kdep/#{deploy_name}/app.yml")
74
74
 
75
- # Copy secrets.yml
76
- FileUtils.cp(
77
- File.join(Kdep.templates_dir, "init", "secrets.yml"),
78
- File.join(target_dir, "secrets.yml")
79
- )
80
- @ui.file_written("kdep/#{deploy_name}/secrets.yml")
75
+ # Drop env.spec at the repo root (one per repo, not per deploy).
76
+ # Skip if it already exists -- another deploy in this repo may have
77
+ # created it, and the spec is shared.
78
+ repo_root = Dir.pwd
79
+ env_spec_path = File.join(repo_root, "env.spec")
80
+ if File.exist?(env_spec_path)
81
+ @ui.info("env.spec already exists at repo root -- leaving as-is")
82
+ else
83
+ FileUtils.cp(
84
+ File.join(Kdep.templates_dir, "init", "env.spec"),
85
+ env_spec_path
86
+ )
87
+ @ui.file_written("env.spec")
88
+ end
81
89
 
82
90
  # Copy .gitignore
83
91
  FileUtils.cp(
@@ -157,7 +157,7 @@ module Kdep
157
157
  output_dir = File.join(tmpdir, ".rendered")
158
158
  writer = Kdep::Writer.new(output_dir)
159
159
  writer.clean
160
- renderer = Kdep::Renderer.new(config, target_dir)
160
+ renderer = Kdep::Renderer.new(config, target_dir, env: env)
161
161
 
162
162
  rendered_files = []
163
163
  preset.resources.each_with_index do |res, idx|
@@ -41,9 +41,15 @@ module Kdep
41
41
  # Load config
42
42
  config = Kdep::Config.new(deploy_dir, env).load
43
43
 
44
- # Construct full image tag
44
+ # Resolve tag from state.yml — app.yml has no tag field by design;
45
+ # state.yml is the source of truth for the last bumped version.
46
+ tag = Kdep::State.tag(deploy_dir)
47
+ unless tag
48
+ @ui.error("No tag in state.yml for #{deploy_dir}. Run `kdep bump` first.")
49
+ exit 1
50
+ end
51
+
45
52
  image = config["image"] || config["name"]
46
- tag = config["tag"] || "latest"
47
53
  registry = config["registry"]
48
54
 
49
55
  if registry && !registry.to_s.empty?
@@ -57,7 +57,7 @@ module Kdep
57
57
  # Set up writer and renderer
58
58
  writer = Kdep::Writer.new(output_dir)
59
59
  writer.clean
60
- renderer = Kdep::Renderer.new(config, deploy_dir)
60
+ renderer = Kdep::Renderer.new(config, deploy_dir, env: env)
61
61
  validator = Kdep::Validator.new
62
62
 
63
63
  files_written = 0
data/lib/kdep/renderer.rb CHANGED
@@ -3,28 +3,46 @@ require "yaml"
3
3
 
4
4
  module Kdep
5
5
  class Renderer
6
- def initialize(config, deploy_dir)
6
+ # Convention: Infisical project slug = K8s namespace stripped of -stage/-dev
7
+ # suffix; envSlug = stage/dev/prod derived from the same suffix.
8
+ NS_ENV_SUFFIXES = { "-stage" => "stage", "-staging" => "stage", "-dev" => "dev" }.freeze
9
+
10
+ def initialize(config, deploy_dir, env: nil)
7
11
  @config = config
8
12
  @deploy_dir = deploy_dir
13
+ @env = env
9
14
  end
10
15
 
11
16
  def render_resource(resource_name)
12
- template_path = resolve_template(resource_name)
13
- raise "Template not found: #{resource_name}.yml.erb" unless template_path
17
+ # When env.spec is present, the `secret` slot becomes an InfisicalSecret CR
18
+ # instead of a native K8s Secret -- the Infisical operator materializes
19
+ # the K8s Secret in-cluster. Same env_from: secretRef wiring, no template
20
+ # changes in user-overridden resources.
21
+ effective_resource = resource_name
22
+ if resource_name == "secret" && env_spec
23
+ effective_resource = "infisical_secret"
24
+ end
25
+
26
+ template_path = resolve_template(effective_resource)
27
+ raise "Template not found: #{effective_resource}.yml.erb" unless template_path
14
28
 
15
29
  template_content = File.read(template_path)
16
- erb = if RUBY_VERSION >= "2.6"
17
- ERB.new(template_content, trim_mode: "-")
18
- else
19
- ERB.new(template_content, nil, "-")
20
- end
30
+ erb = build_erb(template_content)
21
31
 
32
+ effective_config = @config
22
33
  secrets = {}
23
- if resource_name == "secret"
24
- secrets = load_secrets
34
+
35
+ case effective_resource
36
+ when "configmap"
37
+ effective_config = @config.merge("configmap" => resolved_configmap)
38
+ when "infisical_secret"
39
+ return nil if secret_keys_for_env.empty?
40
+ effective_config = @config.merge("infisical" => infisical_render_context)
41
+ when "secret"
42
+ secrets = legacy_load_secrets
25
43
  end
26
44
 
27
- context = TemplateContext.new(@config, secrets)
45
+ context = TemplateContext.new(effective_config, secrets)
28
46
  erb.result(context.get_binding)
29
47
  end
30
48
 
@@ -36,12 +54,7 @@ module Kdep
36
54
 
37
55
  template_path = File.join(Kdep.templates_dir, "resources", "helm_ingress.yml.erb")
38
56
  raise "helm_ingress template not found at #{template_path}" unless File.exist?(template_path)
39
- template_content = File.read(template_path)
40
- erb = if RUBY_VERSION >= "2.6"
41
- ERB.new(template_content, trim_mode: "-")
42
- else
43
- ERB.new(template_content, nil, "-")
44
- end
57
+ erb = build_erb(File.read(template_path))
45
58
 
46
59
  entries.map do |ingress|
47
60
  context = TemplateContext.new(@config)
@@ -50,8 +63,101 @@ module Kdep
50
63
  end
51
64
  end
52
65
 
66
+ # Public so commands (e.g. `kdep env check`) can introspect the contract.
67
+ def env_spec
68
+ return @env_spec if defined?(@env_spec)
69
+ path = env_spec_path
70
+ @env_spec = path && File.exist?(path) ? EnvSpec.parse_file(path) : nil
71
+ end
72
+
53
73
  private
54
74
 
75
+ def build_erb(content)
76
+ if RUBY_VERSION >= "2.6"
77
+ ERB.new(content, trim_mode: "-")
78
+ else
79
+ ERB.new(content, nil, "-")
80
+ end
81
+ end
82
+
83
+ def env_spec_path
84
+ File.join(repo_root, "env.spec")
85
+ end
86
+
87
+ def repo_root
88
+ # deploy_dir is <repo>/kdep/<deploy>
89
+ File.expand_path("../..", @deploy_dir)
90
+ end
91
+
92
+ def env_scope
93
+ @env || "*"
94
+ end
95
+
96
+ def resolved_configmap
97
+ app_values = @config["configmap"] || {}
98
+ return app_values unless env_spec
99
+
100
+ declared = env_spec.configmap_for(env_scope)
101
+ out = {}
102
+ declared.each do |key, entry|
103
+ if app_values.key?(key)
104
+ out[key] = app_values[key]
105
+ elsif !entry.default.nil?
106
+ out[key] = entry.default
107
+ elsif !entry.optional
108
+ raise "env.spec: required ConfigMap key '#{key}' has no value " \
109
+ "in app.yml configmap and no default (declared at line #{entry.line})"
110
+ end
111
+ end
112
+ # Allow app.yml to carry keys not in spec (e.g. legacy or transitional)
113
+ app_values.each { |k, v| out[k] = v unless out.key?(k) }
114
+ out
115
+ end
116
+
117
+ def secret_keys_for_env
118
+ return [] unless env_spec
119
+ env_spec.secrets_for(env_scope).keys
120
+ end
121
+
122
+ def infisical_render_context
123
+ ns = @config["namespace"] || @config["secret_namespace"]
124
+ raise "infisical_secret: namespace missing in app.yml" unless ns
125
+ project, env_from_ns = split_namespace(ns)
126
+ env = env_from_ns || "prod"
127
+
128
+ # The materialized K8s Secret name defaults to <deploy>-secrets but
129
+ # respects an explicit app.yml secret_name override -- this preserves
130
+ # legacy env_from references (e.g. `secret/secret-foo`) so a repo
131
+ # can adopt env.spec without rewriting its deployment manifests.
132
+ managed_secret_name = @config["secret_name"] || "#{deploy_slug}-secrets"
133
+
134
+ {
135
+ "deploy" => deploy_slug,
136
+ "secret_name" => managed_secret_name,
137
+ "namespace" => ns,
138
+ "identity_id" => Kdep::UserConfig.get("infisical", "identity_id"),
139
+ "project_slug" => project,
140
+ "secrets_path" => "/#{deploy_slug}",
141
+ "env_slug" => env,
142
+ "host_api" => Kdep::UserConfig.get("infisical", "host_api") || "https://dev-env.leadfy.xyz/api",
143
+ "managed_keys" => secret_keys_for_env,
144
+ }
145
+ end
146
+
147
+ def deploy_slug
148
+ File.basename(@deploy_dir)
149
+ end
150
+
151
+ # "leadfy-app" -> ["leadfy-app", nil] (-> envSlug=prod)
152
+ # "leadfy-app-stage" -> ["leadfy-app", "stage"]
153
+ # "selly-dev" -> ["selly", "dev"]
154
+ def split_namespace(ns)
155
+ NS_ENV_SUFFIXES.each do |suffix, env|
156
+ return [ns.sub(/#{Regexp.escape(suffix)}\z/, ""), env] if ns.end_with?(suffix)
157
+ end
158
+ [ns, nil]
159
+ end
160
+
55
161
  def resolve_template(resource_name)
56
162
  # Check user override first
57
163
  user_path = File.join(@deploy_dir, "resources", "#{resource_name}.yml.erb")
@@ -64,7 +170,9 @@ module Kdep
64
170
  nil
65
171
  end
66
172
 
67
- def load_secrets
173
+ # Legacy: kdep/<deploy>/secrets.yml -- only used if env.spec is absent and
174
+ # the project still ships the native K8s Secret template.
175
+ def legacy_load_secrets
68
176
  secrets_path = File.join(@deploy_dir, "secrets.yml")
69
177
  return {} unless File.exist?(secrets_path)
70
178
 
@@ -0,0 +1,38 @@
1
+ require "yaml"
2
+ require "fileutils"
3
+
4
+ module Kdep
5
+ # Per-user kdep config persisted at ~/.kdep/config.yml. Currently holds
6
+ # only Infisical settings (identity_id, host_api), but kept generic so
7
+ # other CLI-wide knobs can land here.
8
+ module UserConfig
9
+ PATH = File.expand_path("~/.kdep/config.yml").freeze
10
+
11
+ def self.load
12
+ return {} unless File.exist?(PATH)
13
+ YAML.safe_load(File.read(PATH)) || {}
14
+ end
15
+
16
+ def self.get(*keys)
17
+ load.dig(*keys.map(&:to_s))
18
+ end
19
+
20
+ def self.set(dotted_key, value)
21
+ keys = dotted_key.to_s.split(".")
22
+ data = load
23
+ cursor = data
24
+ keys[0..-2].each do |k|
25
+ cursor[k] = {} unless cursor[k].is_a?(Hash)
26
+ cursor = cursor[k]
27
+ end
28
+ cursor[keys.last] = value
29
+ write(data)
30
+ end
31
+
32
+ def self.write(data)
33
+ FileUtils.mkdir_p(File.dirname(PATH))
34
+ File.write(PATH, data.to_yaml)
35
+ PATH
36
+ end
37
+ end
38
+ end
data/lib/kdep/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Kdep
2
- VERSION = "0.4.1"
2
+ VERSION = "0.4.3"
3
3
  end
data/lib/kdep.rb CHANGED
@@ -1,3 +1,4 @@
1
+ require "envspec"
1
2
  require "kdep/version"
2
3
  require "kdep/ui"
3
4
  require "kdep/yaml_compat"
@@ -18,6 +19,7 @@ require "kdep/helm"
18
19
  require "kdep/registry"
19
20
  require "kdep/version_tagger"
20
21
  require "kdep/state"
22
+ require "kdep/user_config"
21
23
  require "kdep/path_resolver"
22
24
  require "kdep/configmap_overlay"
23
25
  require "kdep/rollout_tracker"
@@ -42,6 +44,8 @@ require "kdep/commands/helm_install"
42
44
  require "kdep/commands/dashboard"
43
45
  require "kdep/commands/doctor"
44
46
  require "kdep/commands/check"
47
+ require "kdep/commands/env"
48
+ require "kdep/commands/config"
45
49
  require "kdep/dashboard/screen"
46
50
  require "kdep/dashboard/layout"
47
51
  require "kdep/dashboard/panel"
@@ -0,0 +1,33 @@
1
+ # env.spec -- declarative env var contract for this repo.
2
+ #
3
+ # This file lives at the repo root, NOT inside kdep/. One spec per repo.
4
+ # Values come from app.yml configmap: (for ConfigMap keys) and from
5
+ # Infisical (for [secrets] keys -- materialized into the K8s Secret by
6
+ # the Infisical operator at deploy time).
7
+ #
8
+ # Syntax:
9
+ # KEY # required str, ConfigMap, shared (all envs)
10
+ # KEY? # optional
11
+ # KEY: int # typed (str | int | bool | dsn | enum(a,b))
12
+ # KEY: str = default # ConfigMap default (secrets cannot have defaults)
13
+ #
14
+ # [secrets] # MODIFIER: subsequent keys become Secrets
15
+ # [env: production] # SELECTOR: scope -> production (resets type to ConfigMap)
16
+ # [env: production, staging]# multi-env scope
17
+ #
18
+ # Validate against live cluster: kdep env check <deploy> --env=production
19
+ # Lint syntax: envspec lint env.spec
20
+ #
21
+ # Example:
22
+ # APP_NAME
23
+ # LOG_LEVEL: enum(debug, info, warn, error) = info
24
+ # PORT: int = 3000
25
+ #
26
+ # [secrets]
27
+ # DATABASE_URL: dsn
28
+ # OPENAI_API_KEY
29
+ #
30
+ # [env: production]
31
+ # SENTRY_DSN: dsn
32
+ # [secrets]
33
+ # STRIPE_LIVE_KEY
@@ -0,0 +1,38 @@
1
+ <%
2
+ inf = @config["infisical"] || {}
3
+ managed_keys = inf["managed_keys"] || []
4
+ raise "infisical_secret: identity_id missing -- run 'kdep config set infisical.identity_id <uuid>'" unless inf["identity_id"]
5
+ raise "infisical_secret: env_slug missing -- pass --env=<production|staging|dev>" unless inf["env_slug"]
6
+ -%>
7
+ apiVersion: secrets.infisical.com/v1alpha1
8
+ kind: InfisicalSecret
9
+ metadata:
10
+ name: <%= inf["deploy"] %>
11
+ namespace: <%= inf["namespace"] %>
12
+ spec:
13
+ hostAPI: <%= inf["host_api"] %>
14
+ resyncInterval: 60
15
+ authentication:
16
+ kubernetesAuth:
17
+ identityId: <%= inf["identity_id"] %>
18
+ # K8s >=1.24 no longer auto-creates legacy SA token Secrets. Tell
19
+ # the operator to mint short-lived projected tokens via TokenRequest.
20
+ autoCreateServiceAccountToken: true
21
+ serviceAccountRef:
22
+ name: default
23
+ namespace: <%= inf["namespace"] %>
24
+ secretsScope:
25
+ projectSlug: <%= inf["project_slug"] %>
26
+ envSlug: <%= inf["env_slug"] %>
27
+ secretsPath: "<%= inf["secrets_path"] %>"
28
+ recursive: false
29
+ managedSecretReference:
30
+ secretName: <%= inf["secret_name"] %>
31
+ secretNamespace: <%= inf["namespace"] %>
32
+ creationPolicy: Owner
33
+ <% if managed_keys.any? -%>
34
+ # Managed keys (for human reference; operator pulls live from Infisical):
35
+ <% managed_keys.sort.each do |k| -%>
36
+ # - <%= k %>
37
+ <% end -%>
38
+ <% end -%>
metadata CHANGED
@@ -1,15 +1,29 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kdep
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.1
4
+ version: 0.4.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Leadfy
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-04-22 00:00:00.000000000 Z
11
+ date: 2026-05-06 00:00:00.000000000 Z
12
12
  dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: envspec
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '0.1'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '0.1'
13
27
  - !ruby/object:Gem::Dependency
14
28
  name: bundler
15
29
  requirement: !ruby/object:Gem::Requirement
@@ -72,10 +86,13 @@ files:
72
86
  - lib/kdep/commands/build.rb
73
87
  - lib/kdep/commands/bump.rb
74
88
  - lib/kdep/commands/check.rb
89
+ - lib/kdep/commands/config.rb
75
90
  - lib/kdep/commands/dashboard.rb
76
91
  - lib/kdep/commands/diff.rb
77
92
  - lib/kdep/commands/doctor.rb
78
93
  - lib/kdep/commands/eject.rb
94
+ - lib/kdep/commands/env.rb
95
+ - lib/kdep/commands/env_check.rb
79
96
  - lib/kdep/commands/helm_install.rb
80
97
  - lib/kdep/commands/init.rb
81
98
  - lib/kdep/commands/log.rb
@@ -121,12 +138,14 @@ files:
121
138
  - lib/kdep/template_context.rb
122
139
  - lib/kdep/ui.rb
123
140
  - lib/kdep/update_check.rb
141
+ - lib/kdep/user_config.rb
124
142
  - lib/kdep/validator.rb
125
143
  - lib/kdep/version.rb
126
144
  - lib/kdep/version_tagger.rb
127
145
  - lib/kdep/writer.rb
128
146
  - lib/kdep/yaml_compat.rb
129
147
  - templates/init/app.yml.erb
148
+ - templates/init/env.spec
130
149
  - templates/init/gitignore
131
150
  - templates/init/secrets.yml
132
151
  - templates/presets/cronjob
@@ -141,6 +160,7 @@ files:
141
160
  - templates/resources/cronjob.yml.erb
142
161
  - templates/resources/deployment.yml.erb
143
162
  - templates/resources/helm_ingress.yml.erb
163
+ - templates/resources/infisical_secret.yml.erb
144
164
  - templates/resources/ingress.yml.erb
145
165
  - templates/resources/job.yml.erb
146
166
  - templates/resources/secret.yml.erb