koch 0.1.5
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 +13 -0
- data/README.md +50 -0
- data/bin/koch +27 -0
- data/koch.gemspec +30 -0
- data/lib/koch/create_directory.rb +27 -0
- data/lib/koch/create_file.rb +43 -0
- data/lib/koch/delete_directory.rb +20 -0
- data/lib/koch/delete_file.rb +18 -0
- data/lib/koch/group.rb +32 -0
- data/lib/koch/helpers.rb +35 -0
- data/lib/koch/install_package.rb +46 -0
- data/lib/koch/install_snap_package.rb +50 -0
- data/lib/koch/log_helper.rb +40 -0
- data/lib/koch/ogm.rb +55 -0
- data/lib/koch/resource.rb +28 -0
- data/lib/koch/resources.rb +36 -0
- data/lib/koch/run.rb +13 -0
- data/lib/koch/swapfile.rb +32 -0
- data/lib/koch/systemd_service.rb +38 -0
- data/lib/koch/systemd_timer_service.rb +85 -0
- data/lib/koch/user.rb +37 -0
- data/lib/koch/version.rb +5 -0
- data/lib/koch.rb +102 -0
- metadata +207 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 7e12ed1206655508810cc1607e3866754898e5a64c84ab85e1e7942357a8f9f3
|
4
|
+
data.tar.gz: 1cc5c71f2ac9081b75ba48621c21b60544d71057e4b67143bd5dc1d69a934e57
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 54ff19ca6f410c9c72787539ca4cadc1e98ed47957e32d1ac49288052915b962180bb783737401092c0ee4458418cabd29dcdd8496f125456ed2a5bb30d9f429
|
7
|
+
data.tar.gz: 39818ef03487967415fe4f03cf6174861ee56b6a37bf8fe11e7bed214989f012b965bba50d43e1dace13892cc5c73e1273239333a924b709f91e2638978558fa
|
data/LICENSE
ADDED
@@ -0,0 +1,13 @@
|
|
1
|
+
Copyright 2023 Marius Nünnerich
|
2
|
+
|
3
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
4
|
+
you may not use this file except in compliance with the License.
|
5
|
+
You may obtain a copy of the License at
|
6
|
+
|
7
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
8
|
+
|
9
|
+
Unless required by applicable law or agreed to in writing, software
|
10
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
11
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
12
|
+
See the License for the specific language governing permissions and
|
13
|
+
limitations under the License.
|
data/README.md
ADDED
@@ -0,0 +1,50 @@
|
|
1
|
+
# Koch
|
2
|
+
|
3
|
+
Koch is a tool to install software packages, change files and other things
|
4
|
+
on a single machine. The changes are described in a file and are written in Ruby.
|
5
|
+
|
6
|
+
The file describing a machine should be versioned, that way you create a repeatable
|
7
|
+
description of how a machine is set up, with history.
|
8
|
+
|
9
|
+
For an example of how this can look like, check out this [Rezeptfile](example/Rezeptfile)
|
10
|
+
and the other files in the directory.
|
11
|
+
|
12
|
+
## Notice
|
13
|
+
|
14
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
15
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
16
|
+
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
17
|
+
|
18
|
+
Fully **backup** any machine you run this on! This is alpha grade software and
|
19
|
+
might cause havoc, esp when run with root privileges!
|
20
|
+
I suggest you use Vagrant to try this out.
|
21
|
+
|
22
|
+
## Status
|
23
|
+
|
24
|
+

|
25
|
+
|
26
|
+
## Philosophy
|
27
|
+
|
28
|
+
Koch's configuration files (Rezeptfiles) are just Ruby. All Koch does is provide
|
29
|
+
a bunch of convenience functions. Feel free to use all the Ruby you want.
|
30
|
+
|
31
|
+
## Usage
|
32
|
+
|
33
|
+
```
|
34
|
+
sudo apt -y install git
|
35
|
+
sudo gem install koch
|
36
|
+
git clone git@github.com:example/machine.git
|
37
|
+
cd machine
|
38
|
+
sudo koch
|
39
|
+
```
|
40
|
+
|
41
|
+
## Supported platforms
|
42
|
+
|
43
|
+
- Ubuntu 22.04 (amd64)
|
44
|
+
- Debian 11 (amd64)
|
45
|
+
- End of list
|
46
|
+
|
47
|
+
## TODO
|
48
|
+
|
49
|
+
- [ ] Interactive mode, ask about each change
|
50
|
+
- [ ] publish to rubygems
|
data/bin/koch
ADDED
@@ -0,0 +1,27 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
require "thor"
|
5
|
+
require_relative "../lib/koch"
|
6
|
+
|
7
|
+
# KochCLI is the glue between the commandline and the actual Koch implementation
|
8
|
+
class KochCLI < Thor
|
9
|
+
class_option :verbose, type: :boolean, aliases: "-v"
|
10
|
+
class_option :"dry-run", type: :boolean, aliases: "-d", default: true
|
11
|
+
|
12
|
+
desc "apply", "Apply a Rezeptfile to the local machine"
|
13
|
+
option :rezeptfile, aliases: "-f", default: "Rezeptfile"
|
14
|
+
def apply
|
15
|
+
logger.level = if options[:verbose]
|
16
|
+
Logger::DEBUG
|
17
|
+
else
|
18
|
+
Logger::INFO
|
19
|
+
end
|
20
|
+
@@dry_run = options[:"dry-run"]
|
21
|
+
r = Koch::Runner.new options[:rezeptfile]
|
22
|
+
r.go
|
23
|
+
end
|
24
|
+
default_task :apply
|
25
|
+
end
|
26
|
+
|
27
|
+
KochCLI.start(ARGV)
|
data/koch.gemspec
ADDED
@@ -0,0 +1,30 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "lib/koch/version"
|
4
|
+
|
5
|
+
Gem::Specification.new do |spec|
|
6
|
+
spec.name = "koch"
|
7
|
+
spec.version = Koch::VERSION
|
8
|
+
spec.authors = ["Marius Nuennerich"]
|
9
|
+
spec.email = ["marius@nuenneri.ch"]
|
10
|
+
spec.summary = "Koch automates machine setup."
|
11
|
+
spec.description = "Koch automates machine setup by providing a library of helper functions."
|
12
|
+
spec.homepage = "https://github.com/marius/koch"
|
13
|
+
spec.license = "Apache-2.0"
|
14
|
+
|
15
|
+
spec.required_ruby_version = ">= 2.7.0"
|
16
|
+
spec.files = ["README.md", "koch.gemspec", "LICENSE"]
|
17
|
+
spec.files += Dir.glob("lib/**/*.rb")
|
18
|
+
spec.executables = ["koch"]
|
19
|
+
|
20
|
+
spec.add_runtime_dependency "diffy", "~> 3.4"
|
21
|
+
spec.add_runtime_dependency "logger", "~> 1.5"
|
22
|
+
spec.add_runtime_dependency "thor", "~> 1.2"
|
23
|
+
spec.add_runtime_dependency "zeitwerk", "~> 2.6"
|
24
|
+
spec.add_runtime_dependency "zlib", "~> 1.1"
|
25
|
+
spec.add_development_dependency "guard", "~> 2.18"
|
26
|
+
spec.add_development_dependency "guard-minitest", "~> 2.4"
|
27
|
+
spec.add_development_dependency "rake", "~> 13.0"
|
28
|
+
spec.add_development_dependency "rubocop", "~> 1.48"
|
29
|
+
spec.add_development_dependency "rubocop-rake", "~> 0.6"
|
30
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "fileutils"
|
4
|
+
|
5
|
+
module Koch
|
6
|
+
# Creates a directory if it doesn't exist and/or changes its mode/owner/group
|
7
|
+
class CreateDirectory < Resource
|
8
|
+
dsl_writer :mode, :owner, :group
|
9
|
+
|
10
|
+
include Ogm
|
11
|
+
|
12
|
+
def apply!
|
13
|
+
if Dir.exist? name
|
14
|
+
debug "Not creating directory #{name}, it already exists"
|
15
|
+
else
|
16
|
+
@changed = true
|
17
|
+
maybe("Creating directory #{name}") do
|
18
|
+
FileUtils.mkdir_p name
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
apply_owner
|
23
|
+
apply_group
|
24
|
+
apply_mode
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,43 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Koch
|
4
|
+
# Creates and/or changes a files' contents, mode, owner and/or group
|
5
|
+
class CreateFile < Resource
|
6
|
+
dsl_writer :mode, :contents, :owner, :group
|
7
|
+
|
8
|
+
include Ogm
|
9
|
+
|
10
|
+
def apply!
|
11
|
+
apply_contents
|
12
|
+
apply_owner
|
13
|
+
apply_group
|
14
|
+
apply_mode
|
15
|
+
end
|
16
|
+
|
17
|
+
private
|
18
|
+
|
19
|
+
def apply_contents
|
20
|
+
if contents.nil?
|
21
|
+
debug "No contents specified for file #{name}"
|
22
|
+
return
|
23
|
+
end
|
24
|
+
|
25
|
+
old_contents = begin
|
26
|
+
File.read name
|
27
|
+
rescue Errno::ENOENT
|
28
|
+
nil
|
29
|
+
end
|
30
|
+
if old_contents == contents
|
31
|
+
debug "Contents of file #{name} unchanged"
|
32
|
+
return
|
33
|
+
end
|
34
|
+
|
35
|
+
info "Diff for #{name}:"
|
36
|
+
info diff(old_contents, contents)
|
37
|
+
@changed = true
|
38
|
+
maybe("Updating file contents of #{name}") do
|
39
|
+
File.write(name, contents)
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "fileutils"
|
4
|
+
|
5
|
+
module Koch
|
6
|
+
# Deletes a directory if it exists
|
7
|
+
class DeleteDirectory < Resource
|
8
|
+
def apply!
|
9
|
+
unless Dir.exist? name
|
10
|
+
debug "Not deleting directory #{name}, it does not exist"
|
11
|
+
return
|
12
|
+
end
|
13
|
+
|
14
|
+
@changed = true
|
15
|
+
maybe("Delete directory #{name}") do
|
16
|
+
FileUtils.remove_entry_secure name
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Koch
|
4
|
+
# Deletes a file if it exists
|
5
|
+
class DeleteFile < Resource
|
6
|
+
def apply!
|
7
|
+
unless File.exist? name
|
8
|
+
debug "Not deleting file #{name}, it does not exist"
|
9
|
+
return
|
10
|
+
end
|
11
|
+
|
12
|
+
@changed = true
|
13
|
+
maybe("Delete file #{name}") do
|
14
|
+
File.delete name
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
data/lib/koch/group.rb
ADDED
@@ -0,0 +1,32 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Koch
|
4
|
+
# Represents a Linux group
|
5
|
+
class Group < Resource
|
6
|
+
dsl_writer :gid, :system_group
|
7
|
+
|
8
|
+
def apply!
|
9
|
+
if exist? name
|
10
|
+
debug "Group #{name} already exists"
|
11
|
+
return
|
12
|
+
end
|
13
|
+
|
14
|
+
@changed = true
|
15
|
+
|
16
|
+
params = +""
|
17
|
+
params << " --gid #{gid}" if gid
|
18
|
+
params << " --system" if system_group
|
19
|
+
|
20
|
+
maybe "groupadd#{params} #{name}"
|
21
|
+
end
|
22
|
+
|
23
|
+
private
|
24
|
+
|
25
|
+
def exist?(group)
|
26
|
+
Etc.getgrnam group
|
27
|
+
true
|
28
|
+
rescue ArgumentError
|
29
|
+
false
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
data/lib/koch/helpers.rb
ADDED
@@ -0,0 +1,35 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "diffy"
|
4
|
+
|
5
|
+
module Koch
|
6
|
+
# A random collection of helpers
|
7
|
+
module Helpers
|
8
|
+
@@dry_run = true
|
9
|
+
|
10
|
+
def maybe(msg_or_cmd)
|
11
|
+
if @@dry_run
|
12
|
+
info "DRY RUN: #{msg_or_cmd}"
|
13
|
+
else
|
14
|
+
info msg_or_cmd
|
15
|
+
if block_given?
|
16
|
+
yield
|
17
|
+
else
|
18
|
+
system msg_or_cmd, exception: true, err: :out
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
def diff(old, new)
|
24
|
+
Diffy::Diff.new(old, new, include_diff_info: true, context: 3).to_s(:color).lines[2..].join
|
25
|
+
end
|
26
|
+
|
27
|
+
def debian?
|
28
|
+
!File.read("/etc/issue").match(/Debian/).nil?
|
29
|
+
end
|
30
|
+
|
31
|
+
def ubuntu?
|
32
|
+
!File.read("/etc/issue").match(/Ubuntu/).nil?
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Koch
|
4
|
+
# A list of packages
|
5
|
+
class Packages < Array
|
6
|
+
def apply!
|
7
|
+
ENV["DEBIAN_FRONTEND"] = "noninteractive"
|
8
|
+
maybe("apt -y install #{installs.join(" ")}") unless installs.empty?
|
9
|
+
maybe("apt -y purge #{deletes.join(" ")}") unless deletes.empty?
|
10
|
+
end
|
11
|
+
|
12
|
+
private
|
13
|
+
|
14
|
+
def installs
|
15
|
+
select { |pkg| pkg.is_a?(InstallPackage) && !installed_packages.include?(pkg.to_s) }
|
16
|
+
end
|
17
|
+
|
18
|
+
def deletes
|
19
|
+
select { |pkg| pkg.is_a?(DeletePackage) && installed_packages.include?(pkg.to_s) }
|
20
|
+
end
|
21
|
+
|
22
|
+
def installed_packages
|
23
|
+
return @installed_packages if @installed_packages
|
24
|
+
|
25
|
+
@installed_packages = `dpkg -l`.lines.grep(/^ii/).map { |pkg| pkg.split[1] }
|
26
|
+
debug "Installed deb packages: "
|
27
|
+
debug @installed_packages.join(" ")
|
28
|
+
@installed_packages
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
# Represents an abstract package resource, can not be used on its own
|
33
|
+
class AbstractPackage < Resource
|
34
|
+
def to_s
|
35
|
+
name
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
# Installs a package
|
40
|
+
class InstallPackage < AbstractPackage
|
41
|
+
end
|
42
|
+
|
43
|
+
# Deletes a package
|
44
|
+
class DeletePackage < AbstractPackage
|
45
|
+
end
|
46
|
+
end
|
@@ -0,0 +1,50 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "resource"
|
4
|
+
|
5
|
+
module Koch
|
6
|
+
# A list of snap packages
|
7
|
+
class SnapPackages < Array
|
8
|
+
def apply!
|
9
|
+
# We cannot install from multiple stores in one go, let's split the installs up.
|
10
|
+
installs.each do |pkg|
|
11
|
+
maybe("snap install --classic #{pkg}")
|
12
|
+
end
|
13
|
+
maybe("snap remove #{deletes.join(" ")}") unless deletes.empty?
|
14
|
+
end
|
15
|
+
|
16
|
+
private
|
17
|
+
|
18
|
+
def installs
|
19
|
+
select { |pkg| pkg.is_a?(InstallSnapPackage) && !installed_packages.include?(pkg.to_s) }
|
20
|
+
end
|
21
|
+
|
22
|
+
def deletes
|
23
|
+
select { |pkg| pkg.is_a?(DeleteSnapPackage) && installed_packages.include?(pkg.to_s) }
|
24
|
+
end
|
25
|
+
|
26
|
+
def installed_packages
|
27
|
+
return @installed_packages if @installed_packages
|
28
|
+
|
29
|
+
@installed_packages = `snap list`.lines[1..].map { |pkg| pkg.split[0] }
|
30
|
+
debug "Installed snap packages: "
|
31
|
+
debug @installed_packages.join(" ")
|
32
|
+
@installed_packages
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
# Represents an abstract package resource, can not be used on its own
|
37
|
+
class AbstractSnapPackage < Resource
|
38
|
+
def to_s
|
39
|
+
name
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
# Installs a snap package
|
44
|
+
class InstallSnapPackage < AbstractPackage
|
45
|
+
end
|
46
|
+
|
47
|
+
# Deletes a snap package
|
48
|
+
class DeleteSnapPackage < AbstractPackage
|
49
|
+
end
|
50
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "logger"
|
4
|
+
|
5
|
+
module Koch
|
6
|
+
# LogHelper provides simple logging for the whole program
|
7
|
+
module LogHelper
|
8
|
+
@@logger = Logger.new($stdout)
|
9
|
+
@@logger.formatter = proc do |_severity, datetime, _progname, msg|
|
10
|
+
_ts = datetime.strftime("%F %T")
|
11
|
+
# format("%s%s %s\n", severity[0], ts, msg)
|
12
|
+
"#{msg}\n"
|
13
|
+
end
|
14
|
+
|
15
|
+
def logger
|
16
|
+
@@logger
|
17
|
+
end
|
18
|
+
|
19
|
+
def debug(*args)
|
20
|
+
@@logger.debug(*args)
|
21
|
+
end
|
22
|
+
|
23
|
+
def info(*args)
|
24
|
+
@@logger.info(*args)
|
25
|
+
end
|
26
|
+
|
27
|
+
def warn(*args)
|
28
|
+
@@logger.warn(*args)
|
29
|
+
end
|
30
|
+
|
31
|
+
def error(*args)
|
32
|
+
@@logger.error(*args)
|
33
|
+
end
|
34
|
+
|
35
|
+
def fatal(*args)
|
36
|
+
@@logger.fatal(*args)
|
37
|
+
exit 1
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
data/lib/koch/ogm.rb
ADDED
@@ -0,0 +1,55 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "etc"
|
4
|
+
|
5
|
+
module Koch
|
6
|
+
# This module applies changes to of file's or directory's owner, group, mode.
|
7
|
+
module Ogm
|
8
|
+
def apply_owner
|
9
|
+
return if @owner.nil?
|
10
|
+
|
11
|
+
@owner = Etc.getpwnam(@owner).uid if @owner.is_a? String
|
12
|
+
return if stat.uid == @owner
|
13
|
+
|
14
|
+
@changed = true
|
15
|
+
maybe("Owner changed: #{name} #{stat.uid} to #{@owner}") do
|
16
|
+
File.chown @owner, nil, name
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
def apply_group
|
21
|
+
return if @group.nil?
|
22
|
+
|
23
|
+
@group = Etc.getgrnam(@group).gid if @group.is_a? String
|
24
|
+
return if stat.gid == @group
|
25
|
+
|
26
|
+
@changed = true
|
27
|
+
maybe("Group changed: #{name} #{stat.gid} to #{@group}") do
|
28
|
+
File.chown nil, @group, name
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
def apply_mode
|
33
|
+
return if @mode.nil?
|
34
|
+
|
35
|
+
@mode = Integer(@mode, 8) if @mode.is_a? String
|
36
|
+
curr_mode = stat.mode & 0o7777
|
37
|
+
return if curr_mode == @mode
|
38
|
+
|
39
|
+
@changed = true
|
40
|
+
maybe(format("Mode changed: #{name} old: %o new: %o", (curr_mode || 0), @mode)) do
|
41
|
+
File.chmod @mode, name
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
private
|
46
|
+
|
47
|
+
def stat
|
48
|
+
@stat ||= begin
|
49
|
+
File.stat name
|
50
|
+
rescue Errno::ENOENT
|
51
|
+
Struct.new(:uid, :gid, :mode).new
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Koch
|
4
|
+
# Resource represents a thing in the system that can be changed by Koch
|
5
|
+
class Resource
|
6
|
+
attr_reader :changed, :name
|
7
|
+
|
8
|
+
def initialize(name)
|
9
|
+
@name = name
|
10
|
+
end
|
11
|
+
|
12
|
+
def self.dsl_writer(*syms)
|
13
|
+
syms.each do |sym|
|
14
|
+
define_method sym do |*args|
|
15
|
+
if args.empty?
|
16
|
+
instance_variable_get "@#{sym}"
|
17
|
+
elsif args.size == 1
|
18
|
+
instance_variable_set "@#{sym}", args[0]
|
19
|
+
else
|
20
|
+
instance_variable_set "@#{sym}", args
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
dsl_writer :reload, :restart, :on_change
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,36 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Koch
|
4
|
+
# Represents a list of resources
|
5
|
+
class Resources < Array
|
6
|
+
def initialize
|
7
|
+
super
|
8
|
+
@reloads = []
|
9
|
+
@restarts = []
|
10
|
+
@on_changes = []
|
11
|
+
end
|
12
|
+
|
13
|
+
def apply!
|
14
|
+
each do |r|
|
15
|
+
r.apply!
|
16
|
+
next unless r.changed
|
17
|
+
|
18
|
+
@reloads << r.reload
|
19
|
+
@restarts << r.restart
|
20
|
+
@on_changes << r.on_change
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
def reloads
|
25
|
+
@reloads.compact.flatten.uniq
|
26
|
+
end
|
27
|
+
|
28
|
+
def restarts
|
29
|
+
@restarts.compact.flatten.uniq
|
30
|
+
end
|
31
|
+
|
32
|
+
def on_changes
|
33
|
+
@on_changes.compact.flatten.uniq
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
data/lib/koch/run.rb
ADDED
@@ -0,0 +1,32 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Koch
|
4
|
+
# Creates a single swap file
|
5
|
+
class Swapfile < Resource
|
6
|
+
dsl_writer :size
|
7
|
+
|
8
|
+
def initialize(name)
|
9
|
+
super
|
10
|
+
@size = "1G"
|
11
|
+
end
|
12
|
+
|
13
|
+
def apply!
|
14
|
+
if File.exist? name
|
15
|
+
debug "Swap file #{name} already exists"
|
16
|
+
return
|
17
|
+
end
|
18
|
+
|
19
|
+
@changed = true
|
20
|
+
|
21
|
+
maybe "fallocate -l #{size} #{name}"
|
22
|
+
maybe "Chmod 600 swap file #{name}" do
|
23
|
+
File.chmod 0o600, name
|
24
|
+
end
|
25
|
+
maybe "mkswap #{name}"
|
26
|
+
maybe "swapon #{name}"
|
27
|
+
maybe "Add swap file #{name} to /etc/fstab" do
|
28
|
+
File.write("/etc/fstab", "#{name} none swap sw 0 0\n", mode: "a")
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,38 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "resource"
|
4
|
+
|
5
|
+
module Koch
|
6
|
+
# A Systemd service
|
7
|
+
class SystemdService < Resource
|
8
|
+
dsl_writer :contents
|
9
|
+
|
10
|
+
def apply!
|
11
|
+
fatal "Systemd services require contents." if contents.nil?
|
12
|
+
|
13
|
+
full_name = "/etc/systemd/system/#{name}.service"
|
14
|
+
old_contents = begin
|
15
|
+
File.read(full_name)
|
16
|
+
rescue Errno::ENOENT
|
17
|
+
nil
|
18
|
+
end
|
19
|
+
if old_contents == contents
|
20
|
+
debug "Systemd service #{name} unchanged"
|
21
|
+
return
|
22
|
+
end
|
23
|
+
|
24
|
+
@changed = true
|
25
|
+
|
26
|
+
info "Diff for Systemd service #{name}:"
|
27
|
+
info diff(old_contents, contents)
|
28
|
+
maybe("Changed Systemd service #{name}") do
|
29
|
+
File.write(full_name, contents)
|
30
|
+
end
|
31
|
+
if system("systemctl is-enabled --quiet #{name}.service")
|
32
|
+
maybe "systemctl daemon-reload"
|
33
|
+
else
|
34
|
+
maybe "systemctl enable --now #{name}"
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
@@ -0,0 +1,85 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "zlib"
|
4
|
+
require_relative "resource"
|
5
|
+
|
6
|
+
module Koch
|
7
|
+
# A systemd service that is triggered by a timer
|
8
|
+
class SystemdTimerService < Resource
|
9
|
+
dsl_writer :contents, :timer
|
10
|
+
|
11
|
+
def initialize(name)
|
12
|
+
super
|
13
|
+
# Pick a pseudo-random time between 00:00 and 06:00 (360 minutes)
|
14
|
+
hash = Zlib.crc32(name) % 360
|
15
|
+
hour = hash / 60
|
16
|
+
minute = hash % 60
|
17
|
+
@timer = format("OnCalendar=%02d:%02d", hour, minute)
|
18
|
+
end
|
19
|
+
|
20
|
+
# rubocop:disable Metrics/AbcSize
|
21
|
+
# rubocop:disable Metrics/MethodLength
|
22
|
+
# rubocop:disable Metrics/CyclomaticComplexity
|
23
|
+
# rubocop:disable Metrics/PerceivedComplexity
|
24
|
+
def apply!
|
25
|
+
fatal "Systemd services require contents." if contents.nil?
|
26
|
+
|
27
|
+
name_prefix = "/etc/systemd/system/#{name}"
|
28
|
+
old_contents = begin
|
29
|
+
File.read("#{name_prefix}.service")
|
30
|
+
rescue Errno::ENOENT
|
31
|
+
nil
|
32
|
+
end
|
33
|
+
|
34
|
+
old_timer_contents = begin
|
35
|
+
File.read("#{name_prefix}.timer")
|
36
|
+
rescue Errno::ENOENT
|
37
|
+
nil
|
38
|
+
end
|
39
|
+
|
40
|
+
new_timer_contents = <<~TIMER
|
41
|
+
[Unit]
|
42
|
+
Description=Run #{name} regularly
|
43
|
+
|
44
|
+
[Timer]
|
45
|
+
#{@timer}
|
46
|
+
|
47
|
+
[Install]
|
48
|
+
WantedBy=timers.target
|
49
|
+
TIMER
|
50
|
+
if old_contents == contents && old_timer_contents == new_timer_contents
|
51
|
+
debug "Systemd #{name} service and timer unchanged"
|
52
|
+
return
|
53
|
+
end
|
54
|
+
|
55
|
+
@changed = true
|
56
|
+
|
57
|
+
if old_contents != contents
|
58
|
+
info "Diff for Systemd service #{name}:"
|
59
|
+
info diff(old_contents, contents)
|
60
|
+
maybe("Changed Systemd service #{name}") do
|
61
|
+
File.write("#{name_prefix}.service", contents)
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
if old_timer_contents != new_timer_contents
|
66
|
+
info "Diff for Systemd timer #{name}:"
|
67
|
+
info diff(old_timer_contents, new_timer_contents)
|
68
|
+
|
69
|
+
maybe("Changed Systemd timer #{name}") do
|
70
|
+
File.write("#{name_prefix}.timer", new_timer_contents)
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
if system("systemctl is-enabled --quiet #{name}.timer")
|
75
|
+
maybe "systemctl daemon-reload"
|
76
|
+
else
|
77
|
+
maybe "systemctl enable --now #{name}.timer"
|
78
|
+
end
|
79
|
+
end
|
80
|
+
# rubocop:enable Metrics/AbcSize
|
81
|
+
# rubocop:enable Metrics/MethodLength
|
82
|
+
# rubocop:enable Metrics/CyclomaticComplexity
|
83
|
+
# rubocop:enable Metrics/PerceivedComplexity
|
84
|
+
end
|
85
|
+
end
|
data/lib/koch/user.rb
ADDED
@@ -0,0 +1,37 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "etc"
|
4
|
+
|
5
|
+
module Koch
|
6
|
+
# Represents a Linux user
|
7
|
+
class User < Resource
|
8
|
+
dsl_writer :uid, :gid, :home, :shell, :system_user
|
9
|
+
|
10
|
+
def apply!
|
11
|
+
if exist? name
|
12
|
+
debug "User #{name} already exists"
|
13
|
+
return
|
14
|
+
end
|
15
|
+
|
16
|
+
@changed = true
|
17
|
+
|
18
|
+
params = +""
|
19
|
+
params << " --uid #{uid}" if uid
|
20
|
+
params << " --gid #{gid}" if gid
|
21
|
+
params << " --home-dir #{home}" if home
|
22
|
+
params << " --shell #{shell}" if shell
|
23
|
+
params << " --system" if system_user
|
24
|
+
|
25
|
+
maybe "useradd#{params} #{name}"
|
26
|
+
end
|
27
|
+
|
28
|
+
private
|
29
|
+
|
30
|
+
def exist?(user)
|
31
|
+
Etc.getpwnam user
|
32
|
+
true
|
33
|
+
rescue ArgumentError
|
34
|
+
false
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
data/lib/koch/version.rb
ADDED
data/lib/koch.rb
ADDED
@@ -0,0 +1,102 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "zeitwerk"
|
4
|
+
loader = Zeitwerk::Loader.for_gem
|
5
|
+
loader.setup
|
6
|
+
|
7
|
+
module Koch
|
8
|
+
# This class evaluates the Rezeptfile and applies the resources
|
9
|
+
class Runner
|
10
|
+
def initialize(rezeptfile)
|
11
|
+
@rezeptfile = rezeptfile
|
12
|
+
|
13
|
+
@packages = Packages.new
|
14
|
+
@snap_packages = SnapPackages.new
|
15
|
+
|
16
|
+
@resources = Resources.new
|
17
|
+
|
18
|
+
@delete = false
|
19
|
+
end
|
20
|
+
|
21
|
+
def go
|
22
|
+
info("Starting Koch runner (version #{VERSION}).")
|
23
|
+
info("DRY RUN MODE! Add --no-dry-run to apply changes.") if @@dry_run
|
24
|
+
|
25
|
+
eval_rezeptfile
|
26
|
+
apply!
|
27
|
+
|
28
|
+
@resources.reloads.each do |r|
|
29
|
+
maybe "systemctl reload #{r}"
|
30
|
+
end
|
31
|
+
@resources.restarts.each do |r|
|
32
|
+
maybe "systemctl restart #{r}"
|
33
|
+
end
|
34
|
+
@resources.on_changes.each do |r|
|
35
|
+
maybe r
|
36
|
+
end
|
37
|
+
|
38
|
+
info("Run complete.")
|
39
|
+
end
|
40
|
+
|
41
|
+
def self.make_method_body(add, delete = nil, collection = nil)
|
42
|
+
proc do |name, &block|
|
43
|
+
r = if @delete
|
44
|
+
fatal "Delete not supported for resource #{name}" if delete.nil?
|
45
|
+
|
46
|
+
delete.new(name)
|
47
|
+
else
|
48
|
+
add.new(name)
|
49
|
+
end
|
50
|
+
|
51
|
+
# block_given? does not work.
|
52
|
+
r.instance_eval(&block) if block
|
53
|
+
|
54
|
+
if collection.nil?
|
55
|
+
@resources << r
|
56
|
+
else
|
57
|
+
c = instance_variable_get(collection)
|
58
|
+
c << r
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
define_method :run, &make_method_body(Run)
|
64
|
+
define_method :file, &make_method_body(CreateFile, DeleteFile)
|
65
|
+
define_method :package, &make_method_body(InstallPackage, DeletePackage, :@packages)
|
66
|
+
define_method :snap_package, &make_method_body(InstallSnapPackage, DeleteSnapPackage, :@snap_packages)
|
67
|
+
define_method :directory, &make_method_body(CreateDirectory, DeleteDirectory)
|
68
|
+
define_method :systemd_service, &make_method_body(SystemdService)
|
69
|
+
define_method :systemd_timer_service, &make_method_body(SystemdTimerService)
|
70
|
+
define_method :swapfile, &make_method_body(Swapfile)
|
71
|
+
define_method :group, &make_method_body(Group)
|
72
|
+
define_method :user, &make_method_body(User)
|
73
|
+
|
74
|
+
private
|
75
|
+
|
76
|
+
def eval_rezeptfile
|
77
|
+
begin
|
78
|
+
rezepte = File.read(@rezeptfile)
|
79
|
+
rescue Errno::ENOENT, Errno::EISDIR
|
80
|
+
fatal "Did not find a file called #{@rezeptfile} in the current directory: #{Dir.pwd}"
|
81
|
+
end
|
82
|
+
instance_eval(rezepte)
|
83
|
+
end
|
84
|
+
|
85
|
+
def apply!
|
86
|
+
@packages.apply!
|
87
|
+
@snap_packages.apply!
|
88
|
+
@resources.apply!
|
89
|
+
end
|
90
|
+
|
91
|
+
def delete
|
92
|
+
old = @delete
|
93
|
+
@delete = true
|
94
|
+
yield
|
95
|
+
ensure
|
96
|
+
@delete = old
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
include Koch::LogHelper # rubocop:disable Style/MixinUsage
|
102
|
+
include Koch::Helpers # rubocop:disable Style/MixinUsage
|
metadata
ADDED
@@ -0,0 +1,207 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: koch
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.5
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Marius Nuennerich
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2023-05-05 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: diffy
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '3.4'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '3.4'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: logger
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '1.5'
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '1.5'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: thor
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - "~>"
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '1.2'
|
48
|
+
type: :runtime
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - "~>"
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '1.2'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: zeitwerk
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - "~>"
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '2.6'
|
62
|
+
type: :runtime
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - "~>"
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '2.6'
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: zlib
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - "~>"
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: '1.1'
|
76
|
+
type: :runtime
|
77
|
+
prerelease: false
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - "~>"
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: '1.1'
|
83
|
+
- !ruby/object:Gem::Dependency
|
84
|
+
name: guard
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
86
|
+
requirements:
|
87
|
+
- - "~>"
|
88
|
+
- !ruby/object:Gem::Version
|
89
|
+
version: '2.18'
|
90
|
+
type: :development
|
91
|
+
prerelease: false
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
93
|
+
requirements:
|
94
|
+
- - "~>"
|
95
|
+
- !ruby/object:Gem::Version
|
96
|
+
version: '2.18'
|
97
|
+
- !ruby/object:Gem::Dependency
|
98
|
+
name: guard-minitest
|
99
|
+
requirement: !ruby/object:Gem::Requirement
|
100
|
+
requirements:
|
101
|
+
- - "~>"
|
102
|
+
- !ruby/object:Gem::Version
|
103
|
+
version: '2.4'
|
104
|
+
type: :development
|
105
|
+
prerelease: false
|
106
|
+
version_requirements: !ruby/object:Gem::Requirement
|
107
|
+
requirements:
|
108
|
+
- - "~>"
|
109
|
+
- !ruby/object:Gem::Version
|
110
|
+
version: '2.4'
|
111
|
+
- !ruby/object:Gem::Dependency
|
112
|
+
name: rake
|
113
|
+
requirement: !ruby/object:Gem::Requirement
|
114
|
+
requirements:
|
115
|
+
- - "~>"
|
116
|
+
- !ruby/object:Gem::Version
|
117
|
+
version: '13.0'
|
118
|
+
type: :development
|
119
|
+
prerelease: false
|
120
|
+
version_requirements: !ruby/object:Gem::Requirement
|
121
|
+
requirements:
|
122
|
+
- - "~>"
|
123
|
+
- !ruby/object:Gem::Version
|
124
|
+
version: '13.0'
|
125
|
+
- !ruby/object:Gem::Dependency
|
126
|
+
name: rubocop
|
127
|
+
requirement: !ruby/object:Gem::Requirement
|
128
|
+
requirements:
|
129
|
+
- - "~>"
|
130
|
+
- !ruby/object:Gem::Version
|
131
|
+
version: '1.48'
|
132
|
+
type: :development
|
133
|
+
prerelease: false
|
134
|
+
version_requirements: !ruby/object:Gem::Requirement
|
135
|
+
requirements:
|
136
|
+
- - "~>"
|
137
|
+
- !ruby/object:Gem::Version
|
138
|
+
version: '1.48'
|
139
|
+
- !ruby/object:Gem::Dependency
|
140
|
+
name: rubocop-rake
|
141
|
+
requirement: !ruby/object:Gem::Requirement
|
142
|
+
requirements:
|
143
|
+
- - "~>"
|
144
|
+
- !ruby/object:Gem::Version
|
145
|
+
version: '0.6'
|
146
|
+
type: :development
|
147
|
+
prerelease: false
|
148
|
+
version_requirements: !ruby/object:Gem::Requirement
|
149
|
+
requirements:
|
150
|
+
- - "~>"
|
151
|
+
- !ruby/object:Gem::Version
|
152
|
+
version: '0.6'
|
153
|
+
description: Koch automates machine setup by providing a library of helper functions.
|
154
|
+
email:
|
155
|
+
- marius@nuenneri.ch
|
156
|
+
executables:
|
157
|
+
- koch
|
158
|
+
extensions: []
|
159
|
+
extra_rdoc_files: []
|
160
|
+
files:
|
161
|
+
- LICENSE
|
162
|
+
- README.md
|
163
|
+
- bin/koch
|
164
|
+
- koch.gemspec
|
165
|
+
- lib/koch.rb
|
166
|
+
- lib/koch/create_directory.rb
|
167
|
+
- lib/koch/create_file.rb
|
168
|
+
- lib/koch/delete_directory.rb
|
169
|
+
- lib/koch/delete_file.rb
|
170
|
+
- lib/koch/group.rb
|
171
|
+
- lib/koch/helpers.rb
|
172
|
+
- lib/koch/install_package.rb
|
173
|
+
- lib/koch/install_snap_package.rb
|
174
|
+
- lib/koch/log_helper.rb
|
175
|
+
- lib/koch/ogm.rb
|
176
|
+
- lib/koch/resource.rb
|
177
|
+
- lib/koch/resources.rb
|
178
|
+
- lib/koch/run.rb
|
179
|
+
- lib/koch/swapfile.rb
|
180
|
+
- lib/koch/systemd_service.rb
|
181
|
+
- lib/koch/systemd_timer_service.rb
|
182
|
+
- lib/koch/user.rb
|
183
|
+
- lib/koch/version.rb
|
184
|
+
homepage: https://github.com/marius/koch
|
185
|
+
licenses:
|
186
|
+
- Apache-2.0
|
187
|
+
metadata: {}
|
188
|
+
post_install_message:
|
189
|
+
rdoc_options: []
|
190
|
+
require_paths:
|
191
|
+
- lib
|
192
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
193
|
+
requirements:
|
194
|
+
- - ">="
|
195
|
+
- !ruby/object:Gem::Version
|
196
|
+
version: 2.7.0
|
197
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
198
|
+
requirements:
|
199
|
+
- - ">="
|
200
|
+
- !ruby/object:Gem::Version
|
201
|
+
version: '0'
|
202
|
+
requirements: []
|
203
|
+
rubygems_version: 3.3.5
|
204
|
+
signing_key:
|
205
|
+
specification_version: 4
|
206
|
+
summary: Koch automates machine setup.
|
207
|
+
test_files: []
|