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 +7 -0
- data/LICENSE +21 -0
- data/README.md +49 -0
- data/exe/kran +9 -0
- data/lib/kran/cli/alias_command.rb +23 -0
- data/lib/kran/cli/base.rb +57 -0
- data/lib/kran/cli/build.rb +19 -0
- data/lib/kran/cli/main.rb +92 -0
- data/lib/kran/cli.rb +5 -0
- data/lib/kran/commands/docker.rb +43 -0
- data/lib/kran/commands/krane.rb +44 -0
- data/lib/kran/commands/kubectl.rb +54 -0
- data/lib/kran/configuration/app.rb +13 -0
- data/lib/kran/configuration/builder.rb +17 -0
- data/lib/kran/configuration/krane.rb +27 -0
- data/lib/kran/configuration/kubernetes.rb +18 -0
- data/lib/kran/configuration/registry.rb +49 -0
- data/lib/kran/configuration/section.rb +23 -0
- data/lib/kran/configuration.rb +75 -0
- data/lib/kran/dependencies.rb +23 -0
- data/lib/kran/ejson.rb +27 -0
- data/lib/kran/git.rb +27 -0
- data/lib/kran/runner.rb +55 -0
- data/lib/kran/shell.rb +19 -0
- data/lib/kran/templates/kran.yml +52 -0
- data/lib/kran/version.rb +3 -0
- data/lib/kran.rb +25 -0
- metadata +99 -0
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,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,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,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
|
data/lib/kran/runner.rb
ADDED
|
@@ -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
|
data/lib/kran/version.rb
ADDED
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: []
|