kran 1.0.0

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: 79c4036c8b185127e7157d85eff69305ef0d9bc124e4fbfc9f1ff336fdaae6fe
4
+ data.tar.gz: 1950703c5f3f10e734a39277ec2ede994fa6703ff2c16c3b5f174f03dc9aafab
5
+ SHA512:
6
+ metadata.gz: 93fc5a593bb95f3b212d5657d446b08d264de9e186302732320671f94eef43713cd6b84a59b690c800950c4b45d0b74501aa9557146077251d856557821fa0a4
7
+ data.tar.gz: e058664d0ede94f9fa65d6d8e59f18e9b55836b176f612cf387e597c8d030e392cc50df4118cabbe3105223c6a90fc969524cb02eff709252129db934b963f99
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Yi Feng Xie
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,49 @@
1
+ # kran
2
+
3
+ Deploy to Kubernetes with [krane](https://github.com/Shopify/krane), the simple way.
4
+ One command builds the image, pushes it, renders the krane templates and deploys them. A few more
5
+ commands cover the daily work: logs, exec, details, audit.
6
+
7
+ Kran is the German word for crane.
8
+
9
+ ## Install
10
+
11
+ ```sh
12
+ gem install kran
13
+ ```
14
+
15
+ Kran drives `docker`, `krane` and `kubectl`, and `ejson` when secrets come from krane's `secrets.ejson`.
16
+ None of them is a gem dependency. `kran version` shows which ones are found.
17
+
18
+ ## Use
19
+
20
+ ```sh
21
+ kran init # writes config/kran.yml
22
+ kran deploy --dry-run # prints every command it would run
23
+ kran deploy # docker login, docker build --push, krane render | krane deploy
24
+ kran deploy -P # skip the build and push
25
+ kran logs -f
26
+ kran exec bin/rails db:migrate
27
+ kran shell # alias for: exec --interactive bash
28
+ kran console # alias for: exec --interactive bin/rails console
29
+ ```
30
+
31
+ Every command reads `config/kran.yml`. `-d staging` layers `config/kran.staging.yml` on top of it.
32
+
33
+ If you have used [kamal](https://kamal-deploy.org), this will feel just as simple: familiar command names,
34
+ one config file, one command to deploy.
35
+
36
+ Documentation: https://kran.bincode.tw
37
+ Source: https://github.com/yfxie/kran
38
+
39
+ ## Develop
40
+
41
+ ```sh
42
+ bundle install
43
+ bundle exec rake test
44
+ bundle exec rubocop
45
+ ```
46
+
47
+ ## License
48
+
49
+ MIT
data/exe/kran ADDED
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env ruby
2
+ require "kran"
3
+
4
+ begin
5
+ Kran::CLI::Main.start
6
+ rescue Kran::Error => error
7
+ warn("ERROR: #{error.message}")
8
+ exit(1)
9
+ end
@@ -0,0 +1,23 @@
1
+ require "shellwords"
2
+
3
+ module Kran
4
+ module CLI
5
+ # Thor hands unknown command names to this class, which lets `aliases` from
6
+ # config/kran.yml expand into real commands without shadowing built-in ones.
7
+ class AliasCommand < Thor::DynamicCommand
8
+ def run(instance, args = [])
9
+ expansion = aliases(args)[name]
10
+ return super unless expansion
11
+
12
+ Main.start(Shellwords.split(expansion) + args)
13
+ end
14
+
15
+ private
16
+
17
+ def aliases(args)
18
+ destination = Thor::Options.new(Main.class_options).parse(args)["destination"]
19
+ Configuration.load(destination: destination).aliases
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,57 @@
1
+ require "thor"
2
+
3
+ module Kran
4
+ module CLI
5
+ class Base < Thor
6
+ class_option :destination, aliases: "-d", desc: "Layer config/kran.<destination>.yml over config/kran.yml"
7
+ # No default here: Thor merges a subcommand's options over the parent's,
8
+ # so a default of false would erase `--dry-run` given before `build push`.
9
+ class_option :dry_run, type: :boolean, desc: "Print the commands instead of running them"
10
+
11
+ class << self
12
+ def exit_on_failure?
13
+ true
14
+ end
15
+
16
+ def basename
17
+ "kran"
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ def config
24
+ @config ||= Configuration.load(destination: options[:destination])
25
+ end
26
+
27
+ def runner
28
+ Kran.runner.tap { |runner| runner.dry_run = options[:dry_run] }
29
+ end
30
+
31
+ def docker
32
+ Commands::Docker.new(config)
33
+ end
34
+
35
+ def krane
36
+ Commands::Krane.new(config)
37
+ end
38
+
39
+ def kubectl
40
+ Commands::Kubectl.new(config)
41
+ end
42
+
43
+ def image_tag
44
+ options[:version] || Git.new.version
45
+ end
46
+
47
+ def ensure_tools(*names)
48
+ Dependencies.new.ensure!(*names) unless options[:dry_run]
49
+ end
50
+
51
+ def push_image(tag)
52
+ runner.run(docker.login, stdin: config.registry.password) if config.registry.credentials?
53
+ runner.run(docker.build(tag))
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,19 @@
1
+ module Kran
2
+ module CLI
3
+ class Build < Base
4
+ desc "push", "Build the image and push it to the registry"
5
+ option :version, desc: "Image tag (defaults to the git HEAD sha)"
6
+ def push
7
+ ensure_tools("docker")
8
+ push_image(image_tag)
9
+ end
10
+
11
+ desc "details", "Show the Docker daemon and builders that builds run on"
12
+ def details
13
+ ensure_tools("docker")
14
+ runner.run(docker.version)
15
+ runner.run(docker.buildx_ls)
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,92 @@
1
+ require "fileutils"
2
+
3
+ module Kran
4
+ module CLI
5
+ class Main < Base
6
+ TEMPLATE = File.expand_path("../templates/kran.yml", __dir__)
7
+
8
+ class << self
9
+ def dynamic_command_class
10
+ AliasCommand
11
+ end
12
+ end
13
+
14
+ desc "init", "Create config/kran.yml"
15
+ def init
16
+ if File.exist?(Configuration::FILE)
17
+ say "#{Configuration::FILE} already exists (remove it first to create a new one)"
18
+ else
19
+ FileUtils.mkdir_p(File.dirname(Configuration::FILE))
20
+ FileUtils.cp(TEMPLATE, Configuration::FILE)
21
+ say "Created #{Configuration::FILE}"
22
+ end
23
+ end
24
+
25
+ desc "deploy", "Build and push the image, then render and deploy with krane"
26
+ option :version, desc: "Image tag (defaults to the git HEAD sha)"
27
+ option :skip_push, aliases: "-P", type: :boolean, default: false, desc: "Skip the image build and push"
28
+ def deploy
29
+ tools = options[:skip_push] ? [] : ["docker"]
30
+ ensure_tools(*tools, krane.executable, "kubectl")
31
+ tag = image_tag
32
+ push_image(tag) unless options[:skip_push]
33
+ runner.run(krane.pipeline(tag))
34
+ end
35
+
36
+ desc "build SUBCOMMAND", "Build the image (push, details)"
37
+ subcommand "build", Build
38
+
39
+ desc "logs", "Show logs from the app pods"
40
+ option :follow, aliases: "-f", type: :boolean, default: false, desc: "Stream new lines as they arrive"
41
+ option :lines, aliases: "-n", type: :numeric, desc: "Number of recent lines per pod"
42
+ option :since, aliases: "-s", desc: "Only lines newer than this, for example 10m or 1h"
43
+ option :grep, aliases: "-g", desc: "Only lines matching this pattern"
44
+ def logs
45
+ ensure_tools("kubectl")
46
+ runner.run(kubectl.logs(**options.slice("follow", "lines", "since", "grep").symbolize_keys))
47
+ end
48
+
49
+ desc "exec COMMAND...", "Run a command in a running app pod"
50
+ option :interactive, aliases: "-i", type: :boolean, default: false, desc: "Attach a terminal"
51
+ def exec(*command)
52
+ if command.empty?
53
+ raise Error, "exec needs a command to run, for example `kran exec bin/rails db:migrate`"
54
+ end
55
+
56
+ ensure_tools("kubectl")
57
+ runner.run(kubectl.exec(command, interactive: options[:interactive]))
58
+ end
59
+
60
+ desc "details", "Show every resource in the namespace"
61
+ def details
62
+ ensure_tools("kubectl")
63
+ runner.run(kubectl.details)
64
+ end
65
+
66
+ desc "audit", "Show the rollout history of the app deployments"
67
+ def audit
68
+ ensure_tools("kubectl")
69
+ runner.run(kubectl.audit)
70
+ end
71
+
72
+ desc "version", "Show the versions of kran, docker, krane and kubectl"
73
+ def version
74
+ krane_commands = Commands::Krane.new(File.exist?(Configuration::FILE) ? config : Configuration.new({}))
75
+ say "kran #{VERSION}"
76
+ say "docker #{tool_version("docker", "docker --version")}"
77
+ say "krane #{tool_version(krane_commands.executable, krane_commands.version)}"
78
+ say "kubectl #{tool_version("kubectl", "kubectl version --client")}"
79
+ end
80
+
81
+ private
82
+
83
+ def tool_version(executable, command)
84
+ return "not found" unless Kran.runner.executable?(executable)
85
+
86
+ Kran.runner.capture(command).lines.first.to_s.strip
87
+ rescue CommandFailed => error
88
+ "failed: #{error.message.lines[1].to_s.strip}"
89
+ end
90
+ end
91
+ end
92
+ end
data/lib/kran/cli.rb ADDED
@@ -0,0 +1,5 @@
1
+ require "active_support/core_ext/hash/keys"
2
+ require "kran/cli/base"
3
+ require "kran/cli/build"
4
+ require "kran/cli/alias_command"
5
+ require "kran/cli/main"
@@ -0,0 +1,43 @@
1
+ require "kran/shell"
2
+
3
+ module Kran
4
+ module Commands
5
+ class Docker
6
+ def initialize(config)
7
+ @config = config
8
+ end
9
+
10
+ def login
11
+ registry = @config.registry
12
+ docker("login", registry.server, "-u", registry.username, "--password-stdin")
13
+ end
14
+
15
+ def build(tag)
16
+ builder = @config.builder
17
+ docker("build", *platform(builder.arch), "--push", "-t", "#{@config.absolute_image}:#{tag}", builder.context)
18
+ end
19
+
20
+ def version
21
+ docker("version")
22
+ end
23
+
24
+ def buildx_ls
25
+ docker("buildx", "ls")
26
+ end
27
+
28
+ private
29
+
30
+ def docker(*args)
31
+ command = Shell.join(["docker", *args.compact])
32
+ remote = @config.builder.remote
33
+ remote ? "DOCKER_HOST=#{Shell.escape(remote)} #{command}" : command
34
+ end
35
+
36
+ def platform(archs)
37
+ return [] if archs.empty?
38
+
39
+ ["--platform", archs.map { |arch| "linux/#{arch}" }.join(",")]
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,44 @@
1
+ require "shellwords"
2
+ require "kran/shell"
3
+
4
+ module Kran
5
+ module Commands
6
+ class Krane
7
+ def initialize(config)
8
+ @config = config
9
+ end
10
+
11
+ def render(tag)
12
+ krane("render", "-f", @config.krane.templates, "--current-sha", tag,
13
+ "--bindings", "image=#{@config.absolute_image}:#{tag}")
14
+ end
15
+
16
+ def deploy
17
+ kubernetes = @config.kubernetes
18
+ # krane parses options with Thor, where a repeated -f replaces the earlier one
19
+ # instead of appending to it, so every file has to follow a single -f.
20
+ files = [@config.krane.secrets, "-"].compact
21
+ command = krane("deploy", kubernetes.namespace, kubernetes.context, "-f", *files)
22
+ kubernetes.kubeconfig ? "KUBECONFIG=#{Shell.escape(kubernetes.kubeconfig)} #{command}" : command
23
+ end
24
+
25
+ def pipeline(tag)
26
+ "#{render(tag)} | #{deploy}"
27
+ end
28
+
29
+ def version
30
+ krane("version")
31
+ end
32
+
33
+ def executable
34
+ Shellwords.split(@config.krane.command).find { |word| !word.include?("=") }
35
+ end
36
+
37
+ private
38
+
39
+ def krane(*args)
40
+ "#{@config.krane.command} #{Shell.join(args)}"
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,54 @@
1
+ require "kran/shell"
2
+
3
+ module Kran
4
+ module Commands
5
+ class Kubectl
6
+ def initialize(config)
7
+ @config = config
8
+ end
9
+
10
+ def logs(lines: nil, since: nil, follow: false, grep: nil)
11
+ command = kubectl("logs", "-l", selector, *container, "--prefix", "--timestamps",
12
+ *(["--tail", lines.to_s] if lines), *(["--since", since] if since), *("-f" if follow))
13
+ grep ? "#{command} | grep #{Shell.escape(grep)}" : command
14
+ end
15
+
16
+ def exec(command, interactive: false)
17
+ <<~SH.chomp
18
+ pod=$(#{running_pod}) && test -n "$pod" || { echo #{Shell.escape("No running pod matches #{selector}")} >&2; exit 1; }
19
+ #{kubectl("exec", *("-it" if interactive))} "$pod" #{Shell.join([*container, "--", *command])}
20
+ SH
21
+ end
22
+
23
+ def details
24
+ kubectl("get", "all", "-o", "wide")
25
+ end
26
+
27
+ def audit
28
+ kubectl("rollout", "history", "deployment", "-l", selector)
29
+ end
30
+
31
+ private
32
+
33
+ def running_pod
34
+ kubectl("get", "pods", "-l", selector, "--field-selector", "status.phase=Running",
35
+ "-o", "jsonpath={.items[0].metadata.name}")
36
+ end
37
+
38
+ def selector
39
+ @config.app.selector
40
+ end
41
+
42
+ def container
43
+ name = @config.app.container
44
+ name ? ["-c", name] : []
45
+ end
46
+
47
+ def kubectl(*args)
48
+ kubernetes = @config.kubernetes
49
+ command = Shell.join(["kubectl", "--context", kubernetes.context, "--namespace", kubernetes.namespace, *args.compact])
50
+ kubernetes.kubeconfig ? "KUBECONFIG=#{Shell.escape(kubernetes.kubeconfig)} #{command}" : command
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,13 @@
1
+ module Kran
2
+ class Configuration
3
+ class App < Section
4
+ def selector
5
+ required("selector")
6
+ end
7
+
8
+ def container
9
+ optional("container")
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,17 @@
1
+ module Kran
2
+ class Configuration
3
+ class Builder < Section
4
+ def arch
5
+ Array(optional("arch"))
6
+ end
7
+
8
+ def remote
9
+ optional("remote")
10
+ end
11
+
12
+ def context
13
+ optional("context", ".")
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,27 @@
1
+ module Kran
2
+ class Configuration
3
+ class Krane < Section
4
+ DEFAULT_TEMPLATES = "config/deploy"
5
+ SECRETS_FILE = "secrets.ejson"
6
+
7
+ def templates
8
+ optional("templates", DEFAULT_TEMPLATES)
9
+ end
10
+
11
+ def secrets
12
+ optional("secrets") || default_secrets
13
+ end
14
+
15
+ def command
16
+ optional("command", "krane")
17
+ end
18
+
19
+ private
20
+
21
+ def default_secrets
22
+ path = File.join(templates, SECRETS_FILE)
23
+ path if File.exist?(path)
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,18 @@
1
+ module Kran
2
+ class Configuration
3
+ class Kubernetes < Section
4
+ def kubeconfig
5
+ path = optional("kubeconfig")
6
+ path && File.expand_path(path)
7
+ end
8
+
9
+ def context
10
+ required("context")
11
+ end
12
+
13
+ def namespace
14
+ required("namespace")
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,49 @@
1
+ module Kran
2
+ class Configuration
3
+ class Registry < Section
4
+ def initialize(raw, ejson: nil)
5
+ super(raw)
6
+ @ejson = ejson
7
+ end
8
+
9
+ def server
10
+ value = optional("server")
11
+ value.to_s.empty? ? nil : value
12
+ end
13
+
14
+ def credentials?
15
+ return false if optional("username").nil? && optional("password").nil?
16
+ return true if optional("username") && optional("password")
17
+
18
+ raise Error, "Set registry.username and registry.password together in #{Configuration::FILE}, " \
19
+ "or leave both out to reuse the login docker already has"
20
+ end
21
+
22
+ def username
23
+ resolve("username")
24
+ end
25
+
26
+ def password
27
+ resolve("password")
28
+ end
29
+
30
+ private
31
+
32
+ def resolve(key)
33
+ case optional(key)
34
+ in Hash => reference then fetch_from_ejson(key, reference.fetch("ejson"))
35
+ in value then value
36
+ end
37
+ end
38
+
39
+ def fetch_from_ejson(key, path)
40
+ if @ejson.nil?
41
+ raise Error, "registry.#{key} refers to ejson but krane.secrets is not set and " \
42
+ "#{Krane::DEFAULT_TEMPLATES}/#{Krane::SECRETS_FILE} does not exist"
43
+ end
44
+
45
+ @ejson.fetch(path)
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,23 @@
1
+ module Kran
2
+ class Configuration
3
+ class Section
4
+ def initialize(raw)
5
+ @section = raw.fetch(name, {}) || {}
6
+ end
7
+
8
+ private
9
+
10
+ def name
11
+ self.class.name.split("::").last.downcase
12
+ end
13
+
14
+ def required(key)
15
+ @section.fetch(key) { raise Error, "Missing #{name}.#{key} in #{Configuration::FILE}" }
16
+ end
17
+
18
+ def optional(key, default = nil)
19
+ @section.fetch(key, default)
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,75 @@
1
+ require "erb"
2
+ require "yaml"
3
+ require "active_support/core_ext/hash/deep_merge"
4
+ require "kran/configuration/section"
5
+ require "kran/configuration/registry"
6
+ require "kran/configuration/builder"
7
+ require "kran/configuration/kubernetes"
8
+ require "kran/configuration/krane"
9
+ require "kran/configuration/app"
10
+
11
+ module Kran
12
+ class Configuration
13
+ FILE = "config/kran.yml"
14
+
15
+ class << self
16
+ def load(destination: nil)
17
+ raw = load_file(FILE, destination, hint: " (run `kran init` to create one)")
18
+ raw = raw.deep_merge(load_file(destination_file(destination), destination)) if destination
19
+ new(raw, destination: destination)
20
+ end
21
+
22
+ private
23
+
24
+ def destination_file(destination)
25
+ FILE.sub(/\.yml\z/, ".#{destination}.yml")
26
+ end
27
+
28
+ def load_file(file, destination, hint: "")
29
+ raise Error, "Configuration file not found in #{file}#{hint}" unless File.exist?(file)
30
+
31
+ rendered = ERB.new(File.read(file), trim_mode: "-").result_with_hash(destination: destination)
32
+ YAML.safe_load(rendered, aliases: true) || {}
33
+ end
34
+ end
35
+
36
+ attr_reader :destination
37
+
38
+ def initialize(raw, destination: nil)
39
+ @raw = raw
40
+ @destination = destination
41
+ end
42
+
43
+ def image
44
+ @raw.fetch("image") { raise Error, "Missing image in #{FILE}" }
45
+ end
46
+
47
+ def absolute_image
48
+ [registry.server, image].compact.join("/")
49
+ end
50
+
51
+ def registry
52
+ @registry ||= Registry.new(@raw, ejson: krane.secrets && Ejson.new(krane.secrets))
53
+ end
54
+
55
+ def builder
56
+ @builder ||= Builder.new(@raw)
57
+ end
58
+
59
+ def kubernetes
60
+ @kubernetes ||= Kubernetes.new(@raw)
61
+ end
62
+
63
+ def krane
64
+ @krane ||= Krane.new(@raw)
65
+ end
66
+
67
+ def app
68
+ @app ||= App.new(@raw)
69
+ end
70
+
71
+ def aliases
72
+ @raw.fetch("aliases", {})
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,23 @@
1
+ module Kran
2
+ class Dependencies
3
+ HINTS = {
4
+ "docker" => "Install it from https://docs.docker.com/get-docker/",
5
+ "krane" => "Install it with `gem install krane`, or add it to a Gemfile and set krane.command to " \
6
+ "`bundle exec krane`.",
7
+ "kubectl" => "Install it from https://kubernetes.io/docs/tasks/tools/",
8
+ "ejson" => "Install it with `gem install ejson` (krane depends on it) or `brew install ejson`.",
9
+ }.freeze
10
+ GENERIC_HINT = "Install it and try again."
11
+
12
+ def initialize(runner: Kran.runner)
13
+ @runner = runner
14
+ end
15
+
16
+ def ensure!(*names)
17
+ missing = names.reject { |name| @runner.executable?(name) }
18
+ return if missing.empty?
19
+
20
+ raise Error, missing.map { |name| "#{name} is not on PATH. #{HINTS.fetch(name, GENERIC_HINT)}" }.join("\n")
21
+ end
22
+ end
23
+ end
data/lib/kran/ejson.rb ADDED
@@ -0,0 +1,27 @@
1
+ require "json"
2
+ require "kran/shell"
3
+
4
+ module Kran
5
+ class Ejson
6
+ attr_reader :path
7
+
8
+ def initialize(path, runner: Kran.runner)
9
+ @path = path
10
+ @runner = runner
11
+ end
12
+
13
+ def fetch(dotted_path)
14
+ value = document.dig(*dotted_path.split("."))
15
+ raise Error, "#{dotted_path} not found in #{path}" if value.nil?
16
+
17
+ value
18
+ end
19
+
20
+ private
21
+
22
+ def document
23
+ Dependencies.new(runner: @runner).ensure!("ejson")
24
+ @document ||= JSON.parse(@runner.capture("ejson decrypt #{Shell.escape(path)}"))
25
+ end
26
+ end
27
+ end
data/lib/kran/git.rb ADDED
@@ -0,0 +1,27 @@
1
+ require "securerandom"
2
+
3
+ module Kran
4
+ class Git
5
+ def initialize(runner: Kran.runner)
6
+ @runner = runner
7
+ end
8
+
9
+ def version
10
+ sha = revision
11
+ uncommitted? ? "#{sha}_uncommitted_#{SecureRandom.hex(8)}" : sha
12
+ end
13
+
14
+ private
15
+
16
+ def revision
17
+ @runner.capture("git rev-parse HEAD").strip
18
+ rescue CommandFailed => error
19
+ raise Error, "Git could not provide an image tag in #{Dir.pwd}: #{error.message}\n" \
20
+ "Pass --version to set the tag explicitly."
21
+ end
22
+
23
+ def uncommitted?
24
+ !@runner.capture("git status --porcelain").strip.empty?
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,55 @@
1
+ require "open3"
2
+
3
+ module Kran
4
+ class CommandFailed < Error; end
5
+
6
+ class Runner
7
+ attr_accessor :dry_run
8
+
9
+ def initialize(dry_run: false)
10
+ @dry_run = dry_run
11
+ end
12
+
13
+ def run(command, stdin: nil)
14
+ puts(stdin ? "printf '%s' '[REDACTED]' | #{command}" : command)
15
+ return if dry_run
16
+
17
+ status = unbundled { execute(command, stdin) }
18
+ raise CommandFailed, "Command failed (exit #{status.exitstatus}): #{command}" unless status.success?
19
+ end
20
+
21
+ def capture(command)
22
+ stdout, stderr, status = unbundled { Open3.capture3(command) }
23
+ unless status.success?
24
+ raise CommandFailed, "Command failed (exit #{status.exitstatus}): #{command}\n#{stderr.strip}"
25
+ end
26
+
27
+ stdout
28
+ rescue Errno::ENOENT => error
29
+ raise CommandFailed, "Command failed: #{command}\n#{error.message}"
30
+ end
31
+
32
+ def executable?(name)
33
+ ENV.fetch("PATH").split(File::PATH_SEPARATOR).any? { |dir| File.executable?(File.join(dir, name)) }
34
+ end
35
+
36
+ private
37
+
38
+ def execute(command, stdin)
39
+ if stdin
40
+ IO.popen(command, "w") { |io| io.write(stdin) }
41
+ else
42
+ system(command)
43
+ end
44
+ $CHILD_STATUS
45
+ end
46
+
47
+ # kran is usually started through `bundle exec` or a binstub, and the tools it
48
+ # drives (krane, ejson) are Ruby programs of their own. Without this they would
49
+ # inherit RUBYOPT and BUNDLE_GEMFILE and refuse to start unless they happened to
50
+ # be in the same bundle as kran.
51
+ def unbundled(&block)
52
+ defined?(Bundler) ? Bundler.with_unbundled_env(&block) : yield
53
+ end
54
+ end
55
+ end
data/lib/kran/shell.rb ADDED
@@ -0,0 +1,19 @@
1
+ module Kran
2
+ # Shellwords escapes "=" and ":" and uses backslashes, which makes every
3
+ # printed kubectl and docker command harder to read than necessary. Safe
4
+ # words stay bare; anything else is wrapped in single quotes.
5
+ module Shell
6
+ SAFE_WORD = %r{\A[\w@%+=:,./-]+\z}
7
+
8
+ extend self
9
+
10
+ def escape(word)
11
+ word = word.to_s
12
+ word.match?(SAFE_WORD) ? word : "'#{word.gsub("'", "'\\\\''")}'"
13
+ end
14
+
15
+ def join(words)
16
+ words.map { |word| escape(word) }.join(" ")
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,52 @@
1
+ # Name of the container image, without registry and tag.
2
+ # The tag comes from `kran deploy --version`, or from git HEAD when omitted.
3
+ image: my-user/my-app
4
+
5
+ # Where the image is pushed. Leave username and password out to reuse the login docker already
6
+ # has on this machine; set both, in CI for example, and `docker login` runs before every push.
7
+ registry:
8
+ # Leave empty for Docker Hub.
9
+ server: ghcr.io
10
+ # username: my-user
11
+ # This file is ERB, so the password can come from the environment or any Ruby expression.
12
+ # password: <%= ENV["KRAN_REGISTRY_PASSWORD"] %>
13
+ # Or read it from krane's secrets.ejson (a dotted path from the top of the decrypted file):
14
+ # password:
15
+ # ejson: registry.password
16
+
17
+ # How the image is built.
18
+ builder:
19
+ # One or more of amd64, arm64. Becomes `docker build --platform linux/<arch>[,linux/<arch>]`.
20
+ arch: amd64
21
+ # Build on another Docker host over SSH. Sets DOCKER_HOST for `docker login` and `docker build`.
22
+ # remote: ssh://user@builder.example.com
23
+ # Build context. Defaults to the current directory, so uncommitted changes are included.
24
+ # context: .
25
+
26
+ # Cluster and namespace that every command targets.
27
+ kubernetes:
28
+ # Defaults to the KUBECONFIG environment variable, then ~/.kube/config.
29
+ # kubeconfig: ~/.kube/my-cluster.yml
30
+ context: my-cluster
31
+ namespace: my-app
32
+
33
+ # How krane is invoked.
34
+ krane:
35
+ # Directory of krane templates, passed to `krane render -f`.
36
+ templates: config/deploy
37
+ # ejson file passed to `krane deploy -f`. Defaults to <templates>/secrets.ejson when it exists.
38
+ # secrets: config/deploy/secrets.ejson
39
+ # Command used to run krane. Override when krane lives in a Gemfile.
40
+ # command: bundle exec krane
41
+
42
+ # Pods used by logs and exec.
43
+ app:
44
+ # Label selector for the application pods (kubectl -l).
45
+ selector: app=my-app
46
+ # Container inside the pod (kubectl -c). Defaults to the pod's default container.
47
+ # container: web
48
+
49
+ # Shortcuts run with `kran <alias>`. Extra arguments are appended to the command.
50
+ aliases:
51
+ shell: exec --interactive bash
52
+ console: exec --interactive bin/rails console
@@ -0,0 +1,3 @@
1
+ module Kran
2
+ VERSION = "1.0.0"
3
+ end
data/lib/kran.rb ADDED
@@ -0,0 +1,25 @@
1
+ require "English"
2
+ require "kran/version"
3
+
4
+ module Kran
5
+ class Error < StandardError; end
6
+
7
+ class << self
8
+ attr_writer :runner
9
+
10
+ def runner
11
+ @runner ||= Runner.new
12
+ end
13
+ end
14
+ end
15
+
16
+ require "kran/shell"
17
+ require "kran/runner"
18
+ require "kran/ejson"
19
+ require "kran/git"
20
+ require "kran/configuration"
21
+ require "kran/commands/docker"
22
+ require "kran/commands/krane"
23
+ require "kran/commands/kubectl"
24
+ require "kran/dependencies"
25
+ require "kran/cli"
metadata ADDED
@@ -0,0 +1,99 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: kran
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Yi Feng Xie
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: activesupport
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '7.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '7.1'
26
+ - !ruby/object:Gem::Dependency
27
+ name: thor
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '1.3'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '1.3'
40
+ description: One command to build, push, render and deploy with krane, plus helpers
41
+ for logs, exec, details and audit.
42
+ email:
43
+ - yfxie@me.com
44
+ executables:
45
+ - kran
46
+ extensions: []
47
+ extra_rdoc_files: []
48
+ files:
49
+ - LICENSE
50
+ - README.md
51
+ - exe/kran
52
+ - lib/kran.rb
53
+ - lib/kran/cli.rb
54
+ - lib/kran/cli/alias_command.rb
55
+ - lib/kran/cli/base.rb
56
+ - lib/kran/cli/build.rb
57
+ - lib/kran/cli/main.rb
58
+ - lib/kran/commands/docker.rb
59
+ - lib/kran/commands/krane.rb
60
+ - lib/kran/commands/kubectl.rb
61
+ - lib/kran/configuration.rb
62
+ - lib/kran/configuration/app.rb
63
+ - lib/kran/configuration/builder.rb
64
+ - lib/kran/configuration/krane.rb
65
+ - lib/kran/configuration/kubernetes.rb
66
+ - lib/kran/configuration/registry.rb
67
+ - lib/kran/configuration/section.rb
68
+ - lib/kran/dependencies.rb
69
+ - lib/kran/ejson.rb
70
+ - lib/kran/git.rb
71
+ - lib/kran/runner.rb
72
+ - lib/kran/shell.rb
73
+ - lib/kran/templates/kran.yml
74
+ - lib/kran/version.rb
75
+ homepage: https://kran.bincode.tw
76
+ licenses:
77
+ - MIT
78
+ metadata:
79
+ homepage_uri: https://kran.bincode.tw
80
+ source_code_uri: https://github.com/yfxie/kran
81
+ bug_tracker_uri: https://github.com/yfxie/kran/issues
82
+ rdoc_options: []
83
+ require_paths:
84
+ - lib
85
+ required_ruby_version: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '3.0'
90
+ required_rubygems_version: !ruby/object:Gem::Requirement
91
+ requirements:
92
+ - - ">="
93
+ - !ruby/object:Gem::Version
94
+ version: '0'
95
+ requirements: []
96
+ rubygems_version: 4.0.16
97
+ specification_version: 4
98
+ summary: Deploy to Kubernetes with krane, the simple way
99
+ test_files: []