diff --git a/Makefile b/Makefile
new file mode 100644
index 00000000..54afb17d
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,5 @@
+build:
+ go build -ldflags "-s -w" -trimpath -o kubitect ./cmd
+
+test:
+ go test ./... -v
diff --git a/docs/user-guide/configuration/kubernetes.md b/docs/user-guide/configuration/kubernetes.md
index 1f0fc247..92d137c6 100644
--- a/docs/user-guide/configuration/kubernetes.md
+++ b/docs/user-guide/configuration/kubernetes.md
@@ -1,6 +1,7 @@
[tag 2.0.0]: https://github.com/MusicDin/kubitect/releases/tag/v2.0.0
[tag 2.2.0]: https://github.com/MusicDin/kubitect/releases/tag/v2.2.0
[tag 3.0.0]: https://github.com/MusicDin/kubitect/releases/tag/v3.0.0
+[tag 3.4.0]: https://github.com/MusicDin/kubitect/releases/tag/v3.4.0
# Kubernetes configuration
@@ -12,6 +13,23 @@ The Kubernetes section of the configuration file contains properties that are sp
## Configuration
+### Kubernetes manager
+
+:material-tag-arrow-up-outline: [v3.4.0][tag 3.4.0]
+
+:octicons-file-symlink-file-24: Default: `kubespray`
+
+Specify manager that is used for deploying Kubernetes cluster. Supported values are `kubespray` and `k3s`.
+
+```yaml
+kubernetes:
+ manager: k3s
+```
+
+!!! warning "Warning"
+
+ Support for K3s manager has been added recently, therefore, it may not be fully stable.
+
### Kubernetes version
:material-tag-arrow-up-outline: [v3.0.0][tag 3.0.0]
@@ -55,6 +73,10 @@ The following table shows the compatibility matrix of supported network plugins
| **1.27** | :material-check: | :material-check: | :material-check: | :material-check: |
| **1.28** | :material-check: | :material-check: | :material-check: | :material-check: |
+!!! note "Note"
+
+ K3s manager supports only `flannel` network plugin.
+
### Kubernetes DNS mode
:material-tag-arrow-up-outline: [v2.0.0][tag 2.0.0]
diff --git a/docs/user-guide/reference/configuration.md b/docs/user-guide/reference/configuration.md
index 2d9154c4..5156226d 100644
--- a/docs/user-guide/reference/configuration.md
+++ b/docs/user-guide/reference/configuration.md
@@ -740,6 +740,19 @@ Each configuration property is documented with 5 columns: Property name, descrip
+
+ kubernetes.manager |
+ string |
+ kubespray |
+ |
+
+ Manager that is used for deploying
+ Kubernetes cluster. Possible values are:
+
+ |
kubernetes.networkPlugin |
string |
@@ -753,6 +766,7 @@ Each configuration property is documented with 5 columns: Property name, descrip
flannel
kube-router
+ Note: k3s manager currently supports only flannel.
diff --git a/embed/ansible/kubitect/finalize.yaml b/embed/ansible/kubitect/finalize.yaml
index 147f6f8a..6d51657a 100644
--- a/embed/ansible/kubitect/finalize.yaml
+++ b/embed/ansible/kubitect/finalize.yaml
@@ -10,6 +10,17 @@
dest: "{{ config_dir }}/admin.conf"
flat: true
+- name: Fetch kubeconfig from first master node
+ hosts: server[0]
+ gather_facts: false
+ any_errors_fatal: true
+ tasks:
+ - name: Fetch Kubeconfig
+ fetch:
+ src: "/home/{{ ansible_user }}/.kube/config"
+ dest: "{{ config_dir }}/admin.conf"
+ flat: true
+
- name: Finalize cluster deployment
hosts: localhost
gather_facts: false
@@ -46,22 +57,23 @@
gather_facts: false
any_errors_fatal: true
become: false
+ vars:
+ addons_enabled: "{{ config.addons.rook.enabled | default(false) | bool }}"
pre_tasks:
- - name: Get system architecture fact
- setup:
- filter:
- - ansible_architecture
+ - block:
+ - name: Get system architecture fact
+ setup:
+ filter:
+ - ansible_architecture
- - name: Resolve system architecture
- set_fact:
- arch: "{{ 'amd64' if ansible_architecture == 'x86_64' else ansible_architecture }}"
+ - name: Resolve system architecture
+ set_fact:
+ arch: "{{ 'amd64' if ansible_architecture == 'x86_64' else ansible_architecture }}"
+ when: addons_enabled
roles:
- - role: config/cluster/import
- - role: config/infra/import
- - role: addons/helm
- - role: addons/rook
- when:
- - config.addons.rook.enabled is defined
- - config.addons.rook.enabled == true
+ - { role: config/cluster/import, when: addons_enabled }
+ - { role: config/infra/import, when: addons_enabled }
+ - { role: addons/helm, when: addons_enabled }
+ - { role: addons/rook, when: addons_enabled }
diff --git a/embed/ansible/kubitect/hosts-setup.yaml b/embed/ansible/kubitect/hosts-setup.yaml
deleted file mode 100644
index 1d26b6eb..00000000
--- a/embed/ansible/kubitect/hosts-setup.yaml
+++ /dev/null
@@ -1,6 +0,0 @@
-- name: Ensure target hosts meet the requirements
- hosts: kubitect_hosts
- gather_facts: false
- any_errors_fatal: true
- roles:
- - role: hosts-setup
diff --git a/embed/ansible/kubitect/requirements.txt b/embed/ansible/kubitect/requirements.txt
index 44fdf63b..50fd7132 100644
--- a/embed/ansible/kubitect/requirements.txt
+++ b/embed/ansible/kubitect/requirements.txt
@@ -1,4 +1,3 @@
-ansible==8.5.0
ansible-core==2.16.2
jinja2==3.1.2
netaddr==0.9.0
diff --git a/embed/ansible/kubitect/roles/hosts-setup/tasks/main.yaml b/embed/ansible/kubitect/roles/hosts-setup/tasks/main.yaml
deleted file mode 100644
index e7bc39ae..00000000
--- a/embed/ansible/kubitect/roles/hosts-setup/tasks/main.yaml
+++ /dev/null
@@ -1,6 +0,0 @@
----
-- name: Ensure libvirt is running
- service:
- name: libvirtd
- state: started
- enabled: true
diff --git a/embed/embed_test.go b/embed/embed_test.go
index dce504b7..8f213b4c 100644
--- a/embed/embed_test.go
+++ b/embed/embed_test.go
@@ -8,7 +8,7 @@ import (
)
func TestGetTemplate(t *testing.T) {
- tpl, err := GetTemplate("etcd.yaml.tpl")
+ tpl, err := GetTemplate("k3s/inventory.yaml")
assert.NoError(t, err)
assert.NotNil(t, tpl)
}
diff --git a/embed/templates/hosts.yaml.tpl b/embed/templates/hosts.yaml.tpl
deleted file mode 100644
index 5c142754..00000000
--- a/embed/templates/hosts.yaml.tpl
+++ /dev/null
@@ -1,21 +0,0 @@
-all:
- hosts:
- {{- range .Hosts }}
- {{ .Name }}:
- {{- if isRemoteHost . }}
- ansible_connection: ssh
- ansible_user: {{ .Connection.User }}
- ansible_host: {{ .Connection.IP }}
- ansible_port: {{ .Connection.SSH.Port }}
- ansible_private_key_file: {{ .Connection.SSH.Keyfile }}
- {{- else }}
- ansible_connection: local
- ansible_host: localhost
- {{- end }}
- {{- end }}
- children:
- kubitect_hosts:
- hosts:
- {{- range .Hosts }}
- {{ .Name }}:
- {{- end }}
\ No newline at end of file
diff --git a/embed/templates/k3s/inventory.yaml b/embed/templates/k3s/inventory.yaml
new file mode 100644
index 00000000..8202cf00
--- /dev/null
+++ b/embed/templates/k3s/inventory.yaml
@@ -0,0 +1,70 @@
+{{- $cfgNodes := .Values.ConfigNodes -}}
+{{- $infNodes := .Values.InfraNodes -}}
+---
+all:
+ hosts:
+ {{- range $infNodes.LoadBalancer.Instances }}
+ {{- $i := $cfgNodes.LoadBalancer.Instances | select "Id" .Id | first }}
+ {{ .Name }}:
+ ansible_host: {{ .IP }}
+ priority: {{ $i.Priority }}
+ {{- end }}
+ {{- range $infNodes.Master.Instances }}
+ {{- $i := $cfgNodes.Master.Instances | select "Id" .Id | first }}
+ {{ .Name }}:
+ ansible_host: {{ .IP }}
+ server_config_yaml: |-
+ ---
+ tls-san: {{ $infNodes.LoadBalancer.VIP }}
+ {{- if $i.Labels }}
+ node-label:
+ {{- range $k, $v := $i.Labels }}
+ - "{{ $k }}={{ $v }}"
+ {{- end }}
+ {{- end }}
+ {{- if $i.Taints }}
+ node-taint:
+ {{- range $i.Taints }}
+ - "{{ . }}"
+ {{- end }}
+ {{- end }}
+ {{- end }}
+ {{- range $infNodes.Worker.Instances }}
+ {{- $i := $cfgNodes.Worker.Instances | select "Id" .Id | first }}
+ {{ .Name }}:
+ ansible_host: {{ .IP }}
+ server_config_yaml: |-
+ ---
+ {{- if $i.Labels }}
+ node-label:
+ {{- range $k, $v := $i.Labels }}
+ - "{{ $k }}={{ $v }}"
+ {{- end }}
+ {{- end }}
+ {{- if $i.Taints }}
+ node-taint:
+ {{- range $i.Taints }}
+ - "{{ . }}"
+ {{- end }}
+ {{- end }}
+ {{- end }}
+ children:
+ haproxy:
+ hosts:
+ {{- range $infNodes.LoadBalancer.Instances }}
+ {{ .Name }}:
+ {{- end }}
+ k3s_cluster:
+ children:
+ server:
+ hosts:
+ {{- range $infNodes.Master.Instances }}
+ {{ .Name }}:
+ {{- end }}
+ agent:
+ hosts:
+ {{- if $infNodes.Worker.Instances }}
+ {{- range $infNodes.Worker.Instances }}
+ {{ .Name }}:
+ {{- end }}
+ {{- end }}
diff --git a/embed/templates/k3s/inventory_partial.yaml b/embed/templates/k3s/inventory_partial.yaml
new file mode 100644
index 00000000..7307e80b
--- /dev/null
+++ b/embed/templates/k3s/inventory_partial.yaml
@@ -0,0 +1,20 @@
+{{- $nodes := .Values -}}
+---
+k3s_cluster:
+ children:
+ server:
+ hosts:
+ {{ range $name, $node := $nodes }}
+ {{- if eq $node.GetTypeName "master" }}
+ {{ $name }}:
+ ansible_host: {{ $node.IP }}
+ {{- end }}
+ {{- end }}
+ agent:
+ hosts:
+ {{ range $name, $node := $nodes }}
+ {{- if eq $node.GetTypeName "worker" }}
+ {{ $name }}:
+ ansible_host: {{ $node.IP }}
+ {{- end }}
+ {{- end }}
diff --git a/embed/templates/k8s-cluster.yaml.tpl b/embed/templates/k8s-cluster.yaml.tpl
deleted file mode 100644
index 76bd84ea..00000000
--- a/embed/templates/k8s-cluster.yaml.tpl
+++ /dev/null
@@ -1,12 +0,0 @@
-##
-# Kubesprays's source file (v2.17.1):
-# https://github.com/kubernetes-sigs/kubespray/blob/v2.17.1/inventory/sample/group_vars/k8s_cluster/k8s-cluster.yml
-##
----
-auto_renew_certificates: {{ .Config.Kubernetes.Other.AutoRenewCertificates }}
-cluster_name: cluster.local
-dns_mode: {{ .Config.Kubernetes.DnsMode }}
-kube_version: {{ .Config.Kubernetes.Version }}
-kube_network_plugin: {{ .Config.Kubernetes.NetworkPlugin }}
-kube_proxy_strict_arp: true
-resolvconf_mode: host_resolvconf
\ No newline at end of file
diff --git a/embed/templates/all.yaml.tpl b/embed/templates/kubespray/all.yaml
similarity index 72%
rename from embed/templates/all.yaml.tpl
rename to embed/templates/kubespray/all.yaml
index 47767425..155b77d9 100644
--- a/embed/templates/all.yaml.tpl
+++ b/embed/templates/kubespray/all.yaml
@@ -3,11 +3,11 @@
# https://github.com/kubernetes-sigs/kubespray/blob/v2.17.1/inventory/sample/group_vars/all/all.yml
##
---
-apiserver_loadbalancer_domain_name: "{{ .InfraNodes.LoadBalancer.VIP }}"
+apiserver_loadbalancer_domain_name: "{{ .Values.LoadBalancer.VIP }}"
deploy_container_engine: true
etcd_kubeadm_enabled: false
loadbalancer_apiserver:
- address: "{{ .InfraNodes.LoadBalancer.VIP }}"
+ address: "{{ .Values.LoadBalancer.VIP }}"
port: 6443
## Upstream dns servers
# upstream_dns_servers:
diff --git a/embed/templates/etcd.yaml.tpl b/embed/templates/kubespray/etcd.yaml
similarity index 100%
rename from embed/templates/etcd.yaml.tpl
rename to embed/templates/kubespray/etcd.yaml
diff --git a/embed/templates/nodes.yaml.tpl b/embed/templates/kubespray/inventory.yaml
similarity index 73%
rename from embed/templates/nodes.yaml.tpl
rename to embed/templates/kubespray/inventory.yaml
index 0e2fb9f9..43088184 100644
--- a/embed/templates/nodes.yaml.tpl
+++ b/embed/templates/kubespray/inventory.yaml
@@ -1,15 +1,16 @@
-{{- $cfgNodes := .ConfigNodes -}}
+{{- $cfgNodes := .Values.ConfigNodes -}}
+{{- $infNodes := .Values.InfraNodes -}}
all:
hosts:
{{- /* Load balancers */ -}}
- {{- range .InfraNodes.LoadBalancer.Instances }}
+ {{- range $infNodes.LoadBalancer.Instances }}
{{- $i := $cfgNodes.LoadBalancer.Instances | select "Id" .Id | first }}
{{ .Name }}:
ansible_host: {{ .IP }}
priority: {{ $i.Priority }}
{{- end }}
{{- /* Master nodes */ -}}
- {{- range .InfraNodes.Master.Instances }}
+ {{- range $infNodes.Master.Instances }}
{{- $i := $cfgNodes.Master.Instances | select "Id" .Id | first }}
{{ .Name }}:
ansible_host: {{ .IP }}
@@ -27,7 +28,7 @@ all:
{{- end }}
{{- end }}
{{- /* Worker nodes */ -}}
- {{- range .InfraNodes.Worker.Instances }}
+ {{- range $infNodes.Worker.Instances }}
{{- $i := $cfgNodes.Worker.Instances | select "Id" .Id | first }}
{{ .Name }}:
ansible_host: {{ .IP }}
@@ -47,30 +48,30 @@ all:
children:
haproxy:
hosts:
- {{- range .InfraNodes.LoadBalancer.Instances }}
+ {{- range $infNodes.LoadBalancer.Instances }}
{{ .Name }}:
{{- end }}
etcd:
hosts:
- {{- range .InfraNodes.Master.Instances }}
+ {{- range $infNodes.Master.Instances }}
{{ .Name }}:
{{- end }}
k8s_cluster:
children:
kube_control_plane:
hosts:
- {{- range .InfraNodes.Master.Instances }}
+ {{- range $infNodes.Master.Instances }}
{{ .Name }}:
{{- end }}
kube_node:
hosts:
- {{- if .InfraNodes.Worker.Instances }}
- {{- range .InfraNodes.Worker.Instances }}
+ {{- if $infNodes.Worker.Instances }}
+ {{- range $infNodes.Worker.Instances }}
{{ .Name }}:
{{- end }}
{{- else }}
{{- /* No worker nodes -> masters also become workers */ -}}
- {{- range .InfraNodes.Master.Instances }}
+ {{- range $infNodes.Master.Instances }}
{{ .Name }}:
{{- end }}
- {{- end }}
\ No newline at end of file
+ {{- end }}
diff --git a/embed/templates/kubespray/k8s-cluster.yaml b/embed/templates/kubespray/k8s-cluster.yaml
new file mode 100644
index 00000000..7055ecd2
--- /dev/null
+++ b/embed/templates/kubespray/k8s-cluster.yaml
@@ -0,0 +1,12 @@
+##
+# Kubesprays's source file (v2.17.1):
+# https://github.com/kubernetes-sigs/kubespray/blob/v2.17.1/inventory/sample/group_vars/k8s_cluster/k8s-cluster.yml
+##
+---
+auto_renew_certificates: {{ .Values.Kubernetes.Other.AutoRenewCertificates }}
+cluster_name: cluster.local
+dns_mode: {{ .Values.Kubernetes.DnsMode }}
+kube_version: {{ .Values.Kubernetes.Version }}
+kube_network_plugin: {{ .Values.Kubernetes.NetworkPlugin }}
+kube_proxy_strict_arp: true
+resolvconf_mode: host_resolvconf
diff --git a/pkg/cluster/action_apply.go b/pkg/cluster/action_apply.go
index 61c28b1a..19388308 100644
--- a/pkg/cluster/action_apply.go
+++ b/pkg/cluster/action_apply.go
@@ -132,15 +132,6 @@ func (c *Cluster) plan(action ApplyAction) (event.Events, error) {
return nil, nil
}
- fmtOptions := cmp.FormatOptions{
- ShowColor: ui.HasColor(),
- ShowDiffOnly: true,
- ShowChangeTypePrefix: true,
- }
-
- fmt.Printf("Following changes have been detected:\n\n")
- fmt.Println(res.ToYaml(fmtOptions))
-
// Generate events from detected configuration changes and provided rules.
events, err := event.GenerateEvents(res.Tree(), action.rules())
if err != nil {
@@ -206,15 +197,15 @@ func (c *Cluster) create() error {
return err
}
- if err := c.Executor().Init(); err != nil {
+ if err := c.Manager().Init(); err != nil {
return err
}
- if err := c.Executor().Sync(); err != nil {
+ if err := c.Manager().Sync(); err != nil {
return err
}
- return c.Executor().Create()
+ return c.Manager().Create()
}
// upgrade upgrades an existing cluster.
@@ -231,24 +222,24 @@ func (c *Cluster) upgrade() error {
return err
}
- if err := c.Executor().Init(); err != nil {
+ if err := c.Manager().Init(); err != nil {
return err
}
- if err := c.Executor().Sync(); err != nil {
+ if err := c.Manager().Sync(); err != nil {
return err
}
- return c.Executor().Upgrade()
+ return c.Manager().Upgrade()
}
// scale scales an existing cluster.
func (c *Cluster) scale(events []event.Event) error {
- if err := c.Executor().Init(); err != nil {
+ if err := c.Manager().Init(); err != nil {
return err
}
- if err := c.Executor().ScaleDown(events); err != nil {
+ if err := c.Manager().ScaleDown(events); err != nil {
return err
}
@@ -264,11 +255,11 @@ func (c *Cluster) scale(events []event.Event) error {
return err
}
- if err := c.Executor().Sync(); err != nil {
+ if err := c.Manager().Sync(); err != nil {
return err
}
- return c.Executor().ScaleUp(events)
+ return c.Manager().ScaleUp(events)
}
// prepare prepares the cluster directory. It ensures all required project
diff --git a/pkg/cluster/cluster.go b/pkg/cluster/cluster.go
index 24899665..723f4840 100644
--- a/pkg/cluster/cluster.go
+++ b/pkg/cluster/cluster.go
@@ -8,14 +8,12 @@ import (
"strings"
"github.com/MusicDin/kubitect/pkg/app"
- "github.com/MusicDin/kubitect/pkg/cluster/executors"
- "github.com/MusicDin/kubitect/pkg/cluster/executors/kubespray"
+ "github.com/MusicDin/kubitect/pkg/cluster/interfaces"
+ "github.com/MusicDin/kubitect/pkg/cluster/managers"
"github.com/MusicDin/kubitect/pkg/cluster/provisioner"
"github.com/MusicDin/kubitect/pkg/cluster/provisioner/terraform"
- "github.com/MusicDin/kubitect/pkg/env"
"github.com/MusicDin/kubitect/pkg/models/config"
"github.com/MusicDin/kubitect/pkg/models/infra"
- "github.com/MusicDin/kubitect/pkg/tools/virtualenv"
"github.com/MusicDin/kubitect/pkg/ui"
"github.com/MusicDin/kubitect/pkg/utils/defaults"
"github.com/MusicDin/kubitect/pkg/utils/file"
@@ -111,28 +109,37 @@ func (c *Cluster) Sync() error {
return nil
}
-// Executor returns an executor instance that is responsible for configuring
-// cluster nodes provisioned by the provisioner.
-func (c *Cluster) Executor() executors.Executor {
+// Manager returns a manager instance that is responsible for managing
+// Kubernetes cluster on provisioned instances.
+func (c *Cluster) Manager() interfaces.Manager {
if c.exec != nil {
return c.exec
}
- veReqPath := "ansible/kubespray/requirements.txt"
- vePath := path.Join(c.ShareDir(), "venv", "kubespray", env.ConstKubesprayVersion)
- ve := virtualenv.NewVirtualEnv(vePath, c.Path, veReqPath)
-
- c.exec = kubespray.NewKubesprayExecutor(
- c.Name,
- c.Path,
- c.PrivateSshKeyPath(),
- c.ConfigDir(),
- c.CacheDir(),
- c.ShareDir(),
- c.NewConfig,
- c.InfraConfig,
- ve,
- )
+ switch c.NewConfig.Kubernetes.Manager {
+ case config.ManagerK3s:
+ c.exec = managers.NewK3sManager(
+ c.Name,
+ c.Path,
+ c.PrivateSshKeyPath(),
+ c.ConfigDir(),
+ c.CacheDir(),
+ c.ShareDir(),
+ c.NewConfig,
+ c.InfraConfig,
+ )
+ case config.ManagerKubespray:
+ c.exec = managers.NewKubesprayManager(
+ c.Name,
+ c.Path,
+ c.PrivateSshKeyPath(),
+ c.ConfigDir(),
+ c.CacheDir(),
+ c.ShareDir(),
+ c.NewConfig,
+ c.InfraConfig,
+ )
+ }
return c.exec
}
@@ -168,10 +175,13 @@ func (c *Cluster) ApplyNewConfig() error {
// StoreNewConfig makes a copy of the provided (new) configuration file in
// cluster directory.
func (c *Cluster) StoreNewConfig() error {
- src := c.NewConfigPath
- dst := filepath.Join(c.Path, DefaultConfigDir, DefaultNewConfigFilename)
+ c.NewConfigPath = filepath.Join(c.Path, DefaultConfigDir, DefaultNewConfigFilename)
- c.NewConfigPath = dst
+ // Ensure config directory exists.
+ err := os.MkdirAll(path.Dir(c.NewConfigPath), 0744)
+ if err != nil {
+ return err
+ }
- return file.ForceCopy(src, dst, 0644)
+ return file.WriteYaml(c.NewConfig, c.NewConfigPath, 0644)
}
diff --git a/pkg/cluster/cluster_mock.go b/pkg/cluster/cluster_mock.go
index 8debba02..32c8eeb2 100644
--- a/pkg/cluster/cluster_mock.go
+++ b/pkg/cluster/cluster_mock.go
@@ -7,7 +7,7 @@ import (
"testing"
"github.com/MusicDin/kubitect/pkg/app"
- "github.com/MusicDin/kubitect/pkg/cluster/executors"
+ "github.com/MusicDin/kubitect/pkg/cluster/interfaces"
"github.com/MusicDin/kubitect/pkg/cluster/provisioner"
"github.com/MusicDin/kubitect/pkg/env"
"github.com/MusicDin/kubitect/pkg/models/config"
@@ -53,7 +53,7 @@ func MockCluster(t *testing.T) *ClusterMock {
os.Create(keyPath + ".pub")
c.NewConfig.Cluster.NodeTemplate.SSH.PrivateKeyPath = config.File(keyPath)
- c.exec = executors.MockExecutor(t)
+ c.exec = interfaces.MockManager(t)
c.prov = provisioner.MockProvisioner(t)
return &ClusterMock{c, ctx}
@@ -80,25 +80,27 @@ func (c *ConfigMock) SetDefaults() {
c.ClusterName = defaults.Default(c.ClusterName, "cluster-mock")
}
-func (c ConfigMock) Template() string {
- return template.TrimTemplate(fmt.Sprintf(`
- hosts:
- - name: localhost
- connection:
- type: local
-
- cluster:
- name: {{ .ClusterName }}
- network:
- cidr: 192.168.113.0/24
- nodes:
- master:
- instances:
- - id: 1
-
- kubernetes:
- version: %s
+func (c ConfigMock) Template() (string, error) {
+ tpl := template.TrimTemplate(fmt.Sprintf(`
+ hosts:
+ - name: localhost
+ connection:
+ type: local
+
+ cluster:
+ name: {{ .ClusterName }}
+ network:
+ cidr: 192.168.113.0/24
+ nodes:
+ master:
+ instances:
+ - id: 1
+
+ kubernetes:
+ version: %s
`, env.ConstKubernetesVersion))
+
+ return tpl, nil
}
func (c ConfigMock) Write(t *testing.T) string {
diff --git a/pkg/cluster/cluster_test.go b/pkg/cluster/cluster_test.go
index 9d0b99aa..e3364e7e 100644
--- a/pkg/cluster/cluster_test.go
+++ b/pkg/cluster/cluster_test.go
@@ -51,7 +51,7 @@ func TestNewCluster_ConfigNotExists(t *testing.T) {
assert.EqualError(t, err, "file 'config.yaml' does not exist")
}
-func TestNewCluster_InvalidConfig(t *testing.T) {
+func TestNewCluster_EmptyConfig(t *testing.T) {
// Create empty configuration file
cfgPath := path.Join(t.TempDir(), "config.yaml")
_, err := os.Create(cfgPath)
diff --git a/pkg/cluster/executors/executor_mock.go b/pkg/cluster/executors/executor_mock.go
deleted file mode 100644
index 1fdd84c8..00000000
--- a/pkg/cluster/executors/executor_mock.go
+++ /dev/null
@@ -1,20 +0,0 @@
-package executors
-
-import (
- "testing"
-
- "github.com/MusicDin/kubitect/pkg/cluster/event"
-)
-
-type executorMock struct{}
-
-func (m executorMock) Init() error { return nil }
-func (m executorMock) Sync() error { return nil }
-func (m executorMock) Create() error { return nil }
-func (m executorMock) Upgrade() error { return nil }
-func (m executorMock) ScaleDown(event.Events) error { return nil }
-func (m executorMock) ScaleUp(event.Events) error { return nil }
-
-func MockExecutor(t *testing.T) Executor {
- return executorMock{}
-}
diff --git a/pkg/cluster/executors/kubespray/kubespray.go b/pkg/cluster/executors/kubespray/kubespray.go
deleted file mode 100644
index 7e288224..00000000
--- a/pkg/cluster/executors/kubespray/kubespray.go
+++ /dev/null
@@ -1,230 +0,0 @@
-package kubespray
-
-import (
- "fmt"
- "os"
- "path"
-
- "github.com/MusicDin/kubitect/pkg/cluster/event"
- "github.com/MusicDin/kubitect/pkg/cluster/executors"
- "github.com/MusicDin/kubitect/pkg/env"
- "github.com/MusicDin/kubitect/pkg/models/config"
- "github.com/MusicDin/kubitect/pkg/models/infra"
- "github.com/MusicDin/kubitect/pkg/tools/ansible"
- "github.com/MusicDin/kubitect/pkg/tools/git"
- "github.com/MusicDin/kubitect/pkg/tools/virtualenv"
- "github.com/MusicDin/kubitect/pkg/ui"
- "gopkg.in/yaml.v3"
-)
-
-type kubespray struct {
- ClusterName string
- ClusterPath string
- SshPrivateKeyPath string
- ConfigDir string
- CacheDir string
- SharedDir string
- Config *config.Config
- InfraConfig *infra.Config
- VirtualEnv virtualenv.VirtualEnv
- Ansible ansible.Ansible
-}
-
-func (e *kubespray) K8sVersion() string {
- return string(e.Config.Kubernetes.Version)
-}
-
-func (e *kubespray) SshUser() string {
- return string(e.Config.Cluster.NodeTemplate.User)
-}
-
-func (e *kubespray) SshPKey() string {
- return e.SshPrivateKeyPath
-}
-
-func NewKubesprayExecutor(
- clusterName string,
- clusterPath string,
- sshPrivateKeyPath string,
- configDir string,
- cacheDir string,
- sharedDir string,
- cfg *config.Config,
- infraCfg *infra.Config,
- virtualEnv virtualenv.VirtualEnv,
-) executors.Executor {
- return &kubespray{
- ClusterName: clusterName,
- ClusterPath: clusterPath,
- SshPrivateKeyPath: sshPrivateKeyPath,
- ConfigDir: configDir,
- CacheDir: cacheDir,
- SharedDir: sharedDir,
- Config: cfg,
- InfraConfig: infraCfg,
- VirtualEnv: virtualEnv,
- }
-}
-
-// Init clones Kubespray project, initializes virtual environment
-// and generates Ansible hosts inventory.
-func (e *kubespray) Init() error {
- url := env.ConstKubesprayUrl
- ver := env.ConstKubesprayVersion
- dst := path.Join(e.ClusterPath, "ansible", "kubespray")
-
- if err := os.RemoveAll(dst); err != nil {
- return err
- }
-
- ui.Printf(ui.INFO, "Cloning Kubespray (%s)...\n", ver)
- if err := git.NewGitProject(url, ver).Clone(dst); err != nil {
- return err
- }
-
- if err := e.VirtualEnv.Init(); err != nil {
- return fmt.Errorf("kubespray exec: initialize virtual environment: %v", err)
- }
-
- if e.Ansible == nil {
- ansibleBinDir := path.Join(e.VirtualEnv.Path(), "bin")
- e.Ansible = ansible.NewAnsible(ansibleBinDir, e.CacheDir)
- }
-
- return e.KubitectHostsSetup()
-}
-
-// Sync regenerates required Ansible inventories and Kubespray group
-// variables.
-func (e *kubespray) Sync() error {
- if err := e.generateHostsInventory(); err != nil {
- return err
- }
-
- if err := e.generateNodesInventory(); err != nil {
- return err
- }
-
- return e.generateGroupVars()
-}
-
-// Create creates a Kubernetes cluster by calling appropriate Kubespray
-// playbooks.
-func (e *kubespray) Create() error {
- if err := e.HAProxy(); err != nil {
- return err
- }
-
- if err := e.KubesprayCreate(); err != nil {
- return err
- }
-
- return e.KubitectFinalize()
-}
-
-// Upgrades upgrades a Kubernetes cluster by calling appropriate Kubespray
-// playbooks.
-func (e *kubespray) Upgrade() error {
- if err := e.KubesprayUpgrade(); err != nil {
- return err
- }
-
- return e.KubitectFinalize()
-}
-
-// ScaleUp adds new nodes to the cluster.
-func (e *kubespray) ScaleUp(events event.Events) error {
- events = events.FilterByAction(event.Action_ScaleUp)
-
- if len(events) == 0 {
- return nil
- }
-
- if err := e.HAProxy(); err != nil {
- return err
- }
-
- return e.KubesprayScale()
-}
-
-// ScaleDown gracefully removes nodes from the cluster.
-func (e *kubespray) ScaleDown(events event.Events) error {
- events = events.FilterByAction(event.Action_ScaleDown)
-
- if len(events) == 0 {
- return nil
- }
-
- rmNodes, err := extractRemovedNodes(events)
- if err != nil || len(rmNodes) == 0 {
- return err
- }
-
- var names []string
-
- for _, n := range rmNodes {
- name := fmt.Sprintf("%s-%s-%s", e.ClusterName, n.GetTypeName(), n.GetID())
- names = append(names, name)
- }
-
- if err := e.generateGroupVars(); err != nil {
- return err
- }
-
- if err := e.KubesprayRemoveNodes(names); err != nil {
- return err
- }
-
- return e.generateNodesInventory()
-}
-
-// extractRemovedNodes returns node instances from the event changes.
-func extractRemovedNodes(events []event.Event) ([]config.Instance, error) {
- var nodes []config.Instance
-
- for _, e := range events {
- if i, ok := e.Change.ValueBefore.(config.Instance); ok {
- nodes = append(nodes, i)
- continue
- }
-
- return nil, fmt.Errorf("%v cannot be scaled", e.Change.ValueType.Name())
- }
-
- return nodes, nil
-}
-
-// generateNodesInventory creates an Ansible inventory of target nodes.
-func (e *kubespray) generateNodesInventory() error {
- return NewNodesTemplate(e.ConfigDir, e.Config.Cluster.Nodes, e.InfraConfig.Nodes).Write()
-}
-
-// generateHostsInventory creates an Ansible inventory of target hosts.
-func (e *kubespray) generateHostsInventory() error {
- return NewHostsTemplate(e.ConfigDir, e.Config.Hosts).Write()
-}
-
-// generateGroupVars creates a directory of Kubespray group variables.
-func (e *kubespray) generateGroupVars() error {
- err := NewKubesprayAllTemplate(e.ConfigDir, e.InfraConfig.Nodes).Write()
- if err != nil {
- return err
- }
-
- err = NewKubesprayK8sClusterTemplate(e.ConfigDir, *e.Config).Write()
- if err != nil {
- return err
- }
-
- addons, err := yaml.Marshal(e.Config.Addons.Kubespray)
- if err != nil {
- return err
- }
-
- err = NewKubesprayAddonsTemplate(e.ConfigDir, string(addons)).Write()
- if err != nil {
- return err
- }
-
- return NewKubesprayEtcdTemplate(e.ConfigDir).Write()
-}
diff --git a/pkg/cluster/executors/kubespray/template.go b/pkg/cluster/executors/kubespray/template.go
deleted file mode 100644
index 12bdc52b..00000000
--- a/pkg/cluster/executors/kubespray/template.go
+++ /dev/null
@@ -1,183 +0,0 @@
-package kubespray
-
-import (
- "path"
-
- "github.com/MusicDin/kubitect/embed"
- "github.com/MusicDin/kubitect/pkg/models/config"
- "github.com/MusicDin/kubitect/pkg/utils/template"
-)
-
-const groupVarsDir = "group_vars"
-
-// fetchTemplate fetches an embedded template with a given name
-// and returns it as a string.
-//
-// It panics if the resource is not found.
-func fetchTemplate(name string) string {
- tpl, err := embed.GetTemplate(name + ".tpl")
- if err != nil {
- panic(err)
- }
-
- return template.TrimTemplate(string(tpl.Content))
-}
-
-type KubesprayAllTemplate struct {
- InfraNodes config.Nodes
- configDir string
-}
-
-func NewKubesprayAllTemplate(configDir string, infraNodes config.Nodes) KubesprayAllTemplate {
- return KubesprayAllTemplate{
- configDir: configDir,
- InfraNodes: infraNodes,
- }
-}
-
-func (t KubesprayAllTemplate) Name() string {
- return "all.yaml"
-}
-
-func (t KubesprayAllTemplate) Write() error {
- dstPath := path.Join(t.configDir, groupVarsDir, "all", t.Name())
- return template.Write(t, dstPath)
-}
-
-func (t KubesprayAllTemplate) Template() string {
- return fetchTemplate(t.Name())
-}
-
-type KubesprayK8sClusterTemplate struct {
- Config config.Config
- configDir string
-}
-
-func NewKubesprayK8sClusterTemplate(configDir string, config config.Config) KubesprayK8sClusterTemplate {
- return KubesprayK8sClusterTemplate{
- configDir: configDir,
- Config: config,
- }
-}
-
-func (t KubesprayK8sClusterTemplate) Name() string {
- return "k8s-cluster.yaml"
-}
-
-func (t KubesprayK8sClusterTemplate) Write() error {
- dstPath := path.Join(t.configDir, groupVarsDir, "k8s_cluster", t.Name())
- return template.Write(t, dstPath)
-}
-
-func (t KubesprayK8sClusterTemplate) Template() string {
- return fetchTemplate(t.Name())
-}
-
-type KubesprayAddonsTemplate struct {
- configDir string
- Addons string
-}
-
-func NewKubesprayAddonsTemplate(configDir string, addons string) KubesprayAddonsTemplate {
- return KubesprayAddonsTemplate{
- configDir: configDir,
- Addons: addons,
- }
-}
-
-func (t KubesprayAddonsTemplate) Name() string {
- return "addons.yaml"
-}
-
-func (t KubesprayAddonsTemplate) Write() error {
- dstPath := path.Join(t.configDir, groupVarsDir, "k8s_cluster", t.Name())
- return template.Write(t, dstPath)
-}
-
-func (t KubesprayAddonsTemplate) Template() string {
- return "{{ .Addons }}"
-}
-
-type KubesprayEtcdTemplate struct {
- configDir string
-}
-
-func NewKubesprayEtcdTemplate(configDir string) KubesprayEtcdTemplate {
- return KubesprayEtcdTemplate{configDir}
-}
-
-func (t KubesprayEtcdTemplate) Name() string {
- return "etcd.yaml"
-}
-
-func (t KubesprayEtcdTemplate) Write() error {
- dstPath := path.Join(t.configDir, groupVarsDir, t.Name())
- return template.Write(t, dstPath)
-}
-
-func (t KubesprayEtcdTemplate) Template() string {
- return fetchTemplate(t.Name())
-}
-
-type HostsTemplate struct {
- configDir string
- Hosts []config.Host
-}
-
-func NewHostsTemplate(configDir string, hosts []config.Host) HostsTemplate {
- return HostsTemplate{
- configDir: configDir,
- Hosts: hosts,
- }
-}
-
-func (t HostsTemplate) Name() string {
- return "hosts.yaml"
-}
-
-func (t HostsTemplate) Write() error {
- dstPath := path.Join(t.configDir, t.Name())
- return template.Write(t, dstPath)
-}
-
-func (t HostsTemplate) Functions() map[string]interface{} {
- return map[string]interface{}{
- "isRemoteHost": isRemoteHost,
- }
-}
-
-// isRemoteHost returns true id host's connection type equals REMOTE.
-func isRemoteHost(host config.Host) bool {
- return host.Connection.Type == config.REMOTE
-}
-
-func (t HostsTemplate) Template() string {
- return fetchTemplate(t.Name())
-}
-
-type NodesTemplate struct {
- configDir string
- ConfigNodes config.Nodes
- InfraNodes config.Nodes
-}
-
-func NewNodesTemplate(configDir string, configNodes, infraNodes config.Nodes) NodesTemplate {
- return NodesTemplate{
- configDir: configDir,
- ConfigNodes: configNodes,
- InfraNodes: infraNodes,
- }
-}
-
-func (t NodesTemplate) Name() string {
- return "nodes.yaml"
-}
-
-func (t NodesTemplate) Write() error {
- dstPath := path.Join(t.configDir, t.Name())
- return template.Write(t, dstPath)
-}
-
-func (t NodesTemplate) Template() string {
- return fetchTemplate(t.Name())
-}
diff --git a/pkg/cluster/executors/executor.go b/pkg/cluster/interfaces/manager.go
similarity index 80%
rename from pkg/cluster/executors/executor.go
rename to pkg/cluster/interfaces/manager.go
index 8c7f2728..6f10254b 100644
--- a/pkg/cluster/executors/executor.go
+++ b/pkg/cluster/interfaces/manager.go
@@ -1,8 +1,8 @@
-package executors
+package interfaces
import "github.com/MusicDin/kubitect/pkg/cluster/event"
-type Executor interface {
+type Manager interface {
Init() error
Sync() error
Create() error
diff --git a/pkg/cluster/interfaces/manager_mock.go b/pkg/cluster/interfaces/manager_mock.go
new file mode 100644
index 00000000..dbc28ac8
--- /dev/null
+++ b/pkg/cluster/interfaces/manager_mock.go
@@ -0,0 +1,20 @@
+package interfaces
+
+import (
+ "testing"
+
+ "github.com/MusicDin/kubitect/pkg/cluster/event"
+)
+
+type managerMock struct{}
+
+func (m managerMock) Init() error { return nil }
+func (m managerMock) Sync() error { return nil }
+func (m managerMock) Create() error { return nil }
+func (m managerMock) Upgrade() error { return nil }
+func (m managerMock) ScaleDown(event.Events) error { return nil }
+func (m managerMock) ScaleUp(event.Events) error { return nil }
+
+func MockManager(t *testing.T) Manager {
+ return managerMock{}
+}
diff --git a/pkg/cluster/managers/common.go b/pkg/cluster/managers/common.go
new file mode 100644
index 00000000..7acce9fe
--- /dev/null
+++ b/pkg/cluster/managers/common.go
@@ -0,0 +1,75 @@
+package managers
+
+import (
+ "fmt"
+
+ "github.com/MusicDin/kubitect/pkg/cluster/event"
+ "github.com/MusicDin/kubitect/pkg/models/config"
+ "github.com/MusicDin/kubitect/pkg/models/infra"
+ "github.com/MusicDin/kubitect/pkg/tools/ansible"
+)
+
+type common struct {
+ ClusterName string
+ ClusterPath string
+ SshPrivateKeyPath string
+ ConfigDir string
+ CacheDir string
+ SharedDir string
+ Config *config.Config
+ InfraConfig *infra.Config
+
+ Ansible ansible.Ansible
+}
+
+func (e common) K8sVersion() string {
+ return string(e.Config.Kubernetes.Version)
+}
+
+func (e common) SshUser() string {
+ return string(e.Config.Cluster.NodeTemplate.User)
+}
+
+func (e common) SshPKey() string {
+ return e.SshPrivateKeyPath
+}
+
+// extractRemovedNodes returns removed node instances extracted from the event changes.
+func extractRemovedNodes(events []event.Event) ([]config.Instance, error) {
+ var nodes []config.Instance
+ for _, e := range events {
+ if e.Rule.ActionType != event.Action_ScaleDown {
+ continue
+ }
+
+ node, ok := e.Change.ValueBefore.(config.Instance)
+ if ok {
+ nodes = append(nodes, node)
+ continue
+ }
+
+ return nil, fmt.Errorf("%v cannot be scaled", e.Change.ValueType.Name())
+ }
+
+ return nodes, nil
+}
+
+// extractNewNodes returns new node instances extracted from the event changes.
+func extractNewNodes(events []event.Event) ([]config.Instance, error) {
+ var nodes []config.Instance
+ for _, e := range events {
+ if e.Rule.ActionType != event.Action_ScaleUp {
+ continue
+ }
+
+ node, ok := e.Change.ValueAfter.(config.Instance)
+ if ok {
+ nodes = append(nodes, node)
+ continue
+ }
+
+ return nil, fmt.Errorf("%v cannot be scaled", e.Change.ValueType.Name())
+ }
+
+ return nodes, nil
+}
diff --git a/pkg/cluster/managers/common_playbooks.go b/pkg/cluster/managers/common_playbooks.go
new file mode 100644
index 00000000..bee49a76
--- /dev/null
+++ b/pkg/cluster/managers/common_playbooks.go
@@ -0,0 +1,41 @@
+package managers
+
+import (
+ "path/filepath"
+
+ "github.com/MusicDin/kubitect/pkg/tools/ansible"
+)
+
+// haproxy calls playbook that configures external HAProxy load balancers.
+func (e common) HAProxy() error {
+ pb := ansible.Playbook{
+ Path: filepath.Join(e.ClusterPath, "ansible/kubitect/haproxy.yaml"),
+ Inventory: filepath.Join(e.ClusterPath, "config/nodes.yaml"),
+ Become: true,
+ User: e.SshUser(),
+ PrivateKey: e.SshPKey(),
+ Timeout: 3000,
+ }
+
+ return e.Ansible.Exec(pb)
+}
+
+// finalize calls playbook that finalizes Kubernetes cluster installation.
+// This includes exp
+func (e common) Finalize() error {
+ vars := map[string]string{
+ "bin_dir": e.SharedDir,
+ }
+
+ pb := ansible.Playbook{
+ Path: filepath.Join(e.ClusterPath, "ansible/kubitect/finalize.yaml"),
+ Inventory: filepath.Join(e.ClusterPath, "config/nodes.yaml"),
+ Become: true,
+ User: e.SshUser(),
+ PrivateKey: e.SshPKey(),
+ Timeout: 3000,
+ ExtraVars: vars,
+ }
+
+ return e.Ansible.Exec(pb)
+}
diff --git a/pkg/cluster/managers/k3s.go b/pkg/cluster/managers/k3s.go
new file mode 100644
index 00000000..8817f23e
--- /dev/null
+++ b/pkg/cluster/managers/k3s.go
@@ -0,0 +1,205 @@
+package managers
+
+import (
+ "fmt"
+ "os"
+ "path"
+ "path/filepath"
+
+ "github.com/MusicDin/kubitect/pkg/cluster/event"
+ "github.com/MusicDin/kubitect/pkg/env"
+ "github.com/MusicDin/kubitect/pkg/models/config"
+ "github.com/MusicDin/kubitect/pkg/models/infra"
+ "github.com/MusicDin/kubitect/pkg/tools/ansible"
+ "github.com/MusicDin/kubitect/pkg/tools/git"
+ "github.com/MusicDin/kubitect/pkg/tools/virtualenv"
+ "github.com/MusicDin/kubitect/pkg/utils/exec"
+)
+
+type k3s struct {
+ common
+
+ ProjectDir string
+}
+
+func (e *k3s) K8sVersion() string {
+ return string(e.Config.Kubernetes.Version)
+}
+
+func (e *k3s) SshUser() string {
+ return string(e.Config.Cluster.NodeTemplate.User)
+}
+
+func (e *k3s) SshPKey() string {
+ return e.SshPrivateKeyPath
+}
+
+func NewK3sManager(
+ clusterName string,
+ clusterPath string,
+ sshPrivateKeyPath string,
+ configDir string,
+ cacheDir string,
+ sharedDir string,
+ cfg *config.Config,
+ infraCfg *infra.Config,
+) *k3s {
+ return &k3s{
+ common: common{
+ ClusterName: clusterName,
+ ClusterPath: clusterPath,
+ SshPrivateKeyPath: sshPrivateKeyPath,
+ ConfigDir: configDir,
+ CacheDir: cacheDir,
+ SharedDir: sharedDir,
+ Config: cfg,
+ InfraConfig: infraCfg,
+ },
+ ProjectDir: filepath.Join(clusterPath, "ansible", "k3s"),
+ }
+}
+
+// Init clones k3s project, initializes virtual environment
+// and generates Ansible hosts inventory.
+func (e *k3s) Init() error {
+ err := os.RemoveAll(e.ProjectDir)
+ if err != nil {
+ return err
+ }
+
+ // Clone repository with k3s playbooks.
+ url := env.ConstK3sURL
+ commitHash := env.ConstK3sVersion
+ err = git.NewGitRepo(url).WithCommitHash(commitHash).Clone(e.ProjectDir)
+ if err != nil {
+ return err
+ }
+
+ if e.Ansible == nil {
+ // Virtual environment.
+ reqPath := filepath.Join(e.ClusterPath, "ansible/kubitect/requirements.txt")
+ venvPath := path.Join(e.SharedDir, "venv", "k3s", env.ConstK3sVersion)
+ err = virtualenv.NewVirtualEnv(venvPath, reqPath).Init()
+ if err != nil {
+ return fmt.Errorf("k3s: initialize virtual environment: %v", err)
+ }
+
+ ansibleBinDir := path.Join(venvPath, "bin")
+ e.Ansible = ansible.NewAnsible(ansibleBinDir, e.CacheDir)
+ }
+
+ return nil
+}
+
+// Sync regenerates Ansible inventory.
+func (e *k3s) Sync() error {
+ nodes := struct {
+ ConfigNodes config.Nodes
+ InfraNodes config.Nodes
+ }{
+ ConfigNodes: e.Config.Cluster.Nodes,
+ InfraNodes: e.InfraConfig.Nodes,
+ }
+
+ return NewTemplate("k3s/inventory.yaml", nodes).Write(filepath.Join(e.ConfigDir, "nodes.yaml"))
+}
+
+// Create creates a Kubernetes cluster by calling appropriate k3s
+// playbooks.
+func (e *k3s) Create() error {
+ if err := e.HAProxy(); err != nil {
+ return err
+ }
+
+ inventory := filepath.Join(e.ConfigDir, "nodes.yaml")
+ err := e.K3sCreate(inventory)
+ if err != nil {
+ return err
+ }
+
+ return e.Finalize()
+}
+
+// Upgrades upgrades a Kubernetes cluster by calling appropriate k3s
+// playbooks.
+func (e *k3s) Upgrade() error {
+ err := e.K3sUpgrade()
+ if err != nil {
+ return err
+ }
+
+ return e.Finalize()
+}
+
+// ScaleUp adds new nodes to the cluster.
+func (e *k3s) ScaleUp(events event.Events) error {
+ newNodes, err := extractNewNodes(events)
+ if err != nil {
+ return err
+ }
+
+ if len(newNodes) == 0 {
+ // No removed nodes.
+ return nil
+ }
+
+ nodes := make(map[string]config.Instance, len(newNodes))
+ for _, n := range newNodes {
+ name := fmt.Sprintf("%s-%s-%s", e.ClusterName, n.GetTypeName(), n.GetID())
+ nodes[name] = n
+ }
+
+ inventory := filepath.Join(e.ConfigDir, "nodes_tmp.yaml")
+ err = NewTemplate("k3s/inventory_partial.yaml", nodes).Write(inventory)
+ if err != nil {
+ return err
+ }
+
+ defer os.Remove(inventory)
+ return e.K3sCreate(inventory)
+}
+
+// ScaleDown gracefully removes nodes from the cluster.
+func (e *k3s) ScaleDown(events event.Events) error {
+ rmNodes, err := extractRemovedNodes(events)
+ if err != nil {
+ return err
+ }
+
+ if len(rmNodes) == 0 {
+ // No removed nodes.
+ return nil
+ }
+
+ // Establish connection with one of the master nodes.
+ leader := e.Config.Cluster.Nodes.Master.Instances[0]
+ ssh := exec.NewSSHClient(e.SshUser(), string(leader.IP)).
+ WithPrivateKeyFile(e.SshPKey()).
+ WithSuperUser(true)
+
+ ssh.SetCombinedStdout(os.Stdout)
+
+ defer ssh.Close()
+
+ for _, n := range rmNodes {
+ name := fmt.Sprintf("%s-%s-%s", e.ClusterName, n.GetTypeName(), n.GetID())
+
+ err = ssh.Run(exec.NewCommand("kubectl", "cordon", name))
+ if err != nil {
+ return fmt.Errorf("cordon node %q: %v", name, err)
+ }
+
+ err = ssh.Run(exec.NewCommand("kubectl", "drain", name, "--ignore-daemonsets", "--force"))
+ if err != nil {
+ return fmt.Errorf("drain node %q: %v", name, err)
+ }
+
+ err = ssh.Run(exec.NewCommand("kubectl", "delete", "node", name))
+ if err != nil {
+ return fmt.Errorf("delete node %q: %v", name, err)
+ }
+ }
+
+ // No need for further cleanup. This instance will be removed.
+ return nil
+}
diff --git a/pkg/cluster/managers/k3s_playbooks.go b/pkg/cluster/managers/k3s_playbooks.go
new file mode 100644
index 00000000..04b2e8cc
--- /dev/null
+++ b/pkg/cluster/managers/k3s_playbooks.go
@@ -0,0 +1,68 @@
+package managers
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "fmt"
+ "path/filepath"
+
+ "github.com/MusicDin/kubitect/pkg/tools/ansible"
+)
+
+// K3sCreate function calls an Ansible playbook that configures Kubernetes
+// cluster.
+func (e *k3s) K3sCreate(inventory string) error {
+ // Use hashed cluster name as token. This is not perfect, as it makes
+ // the token predictable, but removes the necessity to set it manually.
+ token := hex.EncodeToString(sha256.New().Sum([]byte(e.ClusterName)))
+
+ vars := map[string]string{
+ "k3s_version": fmt.Sprintf("%s+k3s1", e.K8sVersion()),
+ "token": string(token),
+ "api_endpoint": string(e.InfraConfig.Nodes.LoadBalancer.VIP),
+ "api_port": "6443",
+ "user_kubectl": "true", // Set to false to kubectl via root user.
+ "cluster_context": e.ClusterName,
+ "extra_server_args": "",
+ "extra_agent_args": "",
+ }
+
+ pb := ansible.Playbook{
+ WorkingDir: e.ProjectDir,
+ Path: filepath.Join(e.ProjectDir, "playbook/site.yml"),
+ Inventory: inventory,
+ Become: true,
+ User: e.SshUser(),
+ PrivateKey: e.SshPKey(),
+ Timeout: 600,
+ ExtraVars: vars,
+ }
+
+ return e.Ansible.Exec(pb)
+}
+
+// K3sUpdate function calls an Ansible playbook that configures Kubernetes
+// cluster.
+func (e *k3s) K3sUpgrade() error {
+ vars := map[string]string{
+ "k3s_version": fmt.Sprintf("%s+k3s1", e.K8sVersion()),
+ "api_endpoint": string(e.InfraConfig.Nodes.LoadBalancer.VIP),
+ "api_port": "6443",
+ "user_kubectl": "true", // Set to false to kubectl via root user.
+ "extra_server_args": "",
+ "extra_agent_args": "",
+ }
+
+ pb := ansible.Playbook{
+ WorkingDir: e.ProjectDir,
+ Path: filepath.Join(e.ProjectDir, "playbook/upgrade.yml"),
+ Inventory: filepath.Join(e.ConfigDir, "nodes.yaml"),
+ Become: true,
+ User: e.SshUser(),
+ PrivateKey: e.SshPKey(),
+ Timeout: 600,
+ ExtraVars: vars,
+ }
+
+ return e.Ansible.Exec(pb)
+}
diff --git a/pkg/cluster/managers/kubespray.go b/pkg/cluster/managers/kubespray.go
new file mode 100644
index 00000000..ddd63be5
--- /dev/null
+++ b/pkg/cluster/managers/kubespray.go
@@ -0,0 +1,209 @@
+package managers
+
+import (
+ "fmt"
+ "os"
+ "path"
+ "path/filepath"
+
+ "github.com/MusicDin/kubitect/pkg/cluster/event"
+ "github.com/MusicDin/kubitect/pkg/env"
+ "github.com/MusicDin/kubitect/pkg/models/config"
+ "github.com/MusicDin/kubitect/pkg/models/infra"
+ "github.com/MusicDin/kubitect/pkg/tools/ansible"
+ "github.com/MusicDin/kubitect/pkg/tools/git"
+ "github.com/MusicDin/kubitect/pkg/tools/virtualenv"
+ "gopkg.in/yaml.v3"
+)
+
+type kubespray struct {
+ common
+}
+
+func NewKubesprayManager(
+ clusterName string,
+ clusterPath string,
+ sshPrivateKeyPath string,
+ configDir string,
+ cacheDir string,
+ sharedDir string,
+ cfg *config.Config,
+ infraCfg *infra.Config,
+) *kubespray {
+ return &kubespray{
+ common: common{
+ ClusterName: clusterName,
+ ClusterPath: clusterPath,
+ SshPrivateKeyPath: sshPrivateKeyPath,
+ ConfigDir: configDir,
+ CacheDir: cacheDir,
+ SharedDir: sharedDir,
+ Config: cfg,
+ InfraConfig: infraCfg,
+ },
+ }
+}
+
+// Init clones Kubespray project, initializes virtual environment
+// and generates Ansible hosts inventory.
+func (e *kubespray) Init() error {
+ url := env.ConstKubesprayUrl
+ ver := env.ConstKubesprayVersion
+
+ dst := path.Join(e.ClusterPath, "ansible", "kubespray")
+ err := os.RemoveAll(dst)
+ if err != nil {
+ return err
+ }
+
+ // Clone repository with Kubespray playbooks.
+ err = git.NewGitRepo(url).WithRef(ver).Clone(dst)
+ if err != nil {
+ return err
+ }
+
+ if e.Ansible == nil {
+ // Virtual environment.
+ reqPath := filepath.Join(e.ClusterPath, "ansible/kubespray/requirements.txt")
+ venvPath := filepath.Join(e.SharedDir, "venv", "kubespray", env.ConstKubesprayVersion)
+ err = virtualenv.NewVirtualEnv(venvPath, reqPath).Init()
+ if err != nil {
+ return fmt.Errorf("kubespray: initialize virtual environment: %v", err)
+ }
+
+ ansibleBinDir := path.Join(venvPath, "bin")
+ e.Ansible = ansible.NewAnsible(ansibleBinDir, e.CacheDir)
+ }
+
+ return nil
+}
+
+// Sync regenerates required Ansible inventories and Kubespray group
+// variables.
+func (e *kubespray) Sync() error {
+ err := e.generateInventory()
+ if err != nil {
+ return err
+ }
+
+ return e.generateGroupVars()
+}
+
+// Create creates a Kubernetes cluster by calling appropriate Kubespray
+// playbooks.
+func (e *kubespray) Create() error {
+ err := e.HAProxy()
+ if err != nil {
+ return err
+ }
+
+ err = e.KubesprayCreate()
+ if err != nil {
+ return err
+ }
+
+ return e.Finalize()
+}
+
+// Upgrades upgrades a Kubernetes cluster by calling appropriate Kubespray
+// playbooks.
+func (e *kubespray) Upgrade() error {
+ err := e.KubesprayUpgrade()
+ if err != nil {
+ return err
+ }
+
+ return e.Finalize()
+}
+
+// ScaleUp adds new nodes to the cluster.
+func (e *kubespray) ScaleUp(events event.Events) error {
+ events = events.FilterByAction(event.Action_ScaleUp)
+ if len(events) == 0 {
+ return nil
+ }
+
+ err := e.HAProxy()
+ if err != nil {
+ return err
+ }
+
+ return e.KubesprayScale()
+}
+
+// ScaleDown gracefully removes nodes from the cluster.
+func (e *kubespray) ScaleDown(events event.Events) error {
+ rmNodes, err := extractRemovedNodes(events)
+ if err != nil {
+ return err
+ }
+
+ if len(rmNodes) == 0 {
+ // No removed nodes.
+ return nil
+ }
+
+ var names []string
+ for _, n := range rmNodes {
+ name := fmt.Sprintf("%s-%s-%s", e.ClusterName, n.GetTypeName(), n.GetID())
+ names = append(names, name)
+ }
+
+ err = e.generateGroupVars()
+ if err != nil {
+ return err
+ }
+
+ err = e.KubesprayRemoveNodes(names)
+ if err != nil {
+ return err
+ }
+
+ return e.generateInventory()
+}
+
+// generateInventory creates an Ansible inventory containing cluster nodes.
+func (e *kubespray) generateInventory() error {
+ nodes := struct {
+ ConfigNodes config.Nodes
+ InfraNodes config.Nodes
+ }{
+ ConfigNodes: e.Config.Cluster.Nodes,
+ InfraNodes: e.InfraConfig.Nodes,
+ }
+
+ return NewTemplate("kubespray/inventory.yaml", nodes).Write(filepath.Join(e.ConfigDir, "nodes.yaml"))
+}
+
+// generateGroupVars creates a directory of Kubespray group variables.
+func (e *kubespray) generateGroupVars() error {
+ groupVarsDir := filepath.Join(e.ConfigDir, "group_vars")
+
+ err := NewTemplate("kubespray/all.yaml", e.InfraConfig.Nodes).Write(filepath.Join(groupVarsDir, "all", "all.yml"))
+ if err != nil {
+ return err
+ }
+
+ err = NewTemplate("kubespray/k8s-cluster.yaml", *e.Config).Write(filepath.Join(groupVarsDir, "k8s_cluster", "k8s-cluster.yaml"))
+ if err != nil {
+ return err
+ }
+
+ addons, err := yaml.Marshal(e.Config.Addons.Kubespray)
+ if err != nil {
+ return err
+ }
+
+ addonsPath := filepath.Join(groupVarsDir, "k8s_cluster", "addons.yaml")
+ err = os.WriteFile(addonsPath, addons, 0644)
+ if err != nil {
+ return err
+ }
+
+ err = NewTemplate("kubespray/etcd.yaml", "").Write(filepath.Join(groupVarsDir, "etcd.yaml"))
+ if err != nil {
+ return err
+ }
+
+ return nil
+}
diff --git a/pkg/cluster/executors/kubespray/playbooks.go b/pkg/cluster/managers/kubespray_playbooks.go
similarity index 52%
rename from pkg/cluster/executors/kubespray/playbooks.go
rename to pkg/cluster/managers/kubespray_playbooks.go
index b562588a..685d3eda 100644
--- a/pkg/cluster/executors/kubespray/playbooks.go
+++ b/pkg/cluster/managers/kubespray_playbooks.go
@@ -1,4 +1,4 @@
-package kubespray
+package managers
import (
"path/filepath"
@@ -7,66 +7,11 @@ import (
"github.com/MusicDin/kubitect/pkg/tools/ansible"
)
-type PlaybookTag string
-
-const (
- TAG_INIT PlaybookTag = "init"
- TAG_KUBESPRAY PlaybookTag = "kubespray"
- TAG_GEN_NODES PlaybookTag = "gen_nodes"
-)
-
-// KubitectHostsSetup function calls an Ansible playbook that ensures Kubitect target
-// hosts meet all the requirements before cluster is created.
-func (e *kubespray) KubitectHostsSetup() error {
- pb := ansible.Playbook{
- Path: filepath.Join(e.ClusterPath, "ansible/kubitect/hosts-setup.yaml"),
- Inventory: filepath.Join(e.ClusterPath, "config/hosts.yaml"),
- Local: true,
- }
-
- return e.Ansible.Exec(pb)
-}
-
-// KubitectFinalize function calls an Ansible playbook that finalizes Kubernetes
-// cluster installation.
-func (e *kubespray) KubitectFinalize() error {
- vars := []string{
- "bin_dir=" + e.SharedDir,
- }
-
- pb := ansible.Playbook{
- Path: filepath.Join(e.ClusterPath, "ansible/kubitect/finalize.yaml"),
- Inventory: filepath.Join(e.ClusterPath, "config/nodes.yaml"),
- Become: true,
- User: e.SshUser(),
- PrivateKey: e.SshPKey(),
- Timeout: 3000,
- ExtraVars: vars,
- }
-
- return e.Ansible.Exec(pb)
-}
-
-// HAProxy function calls an Ansible playbook that configures HAProxy
-// load balancers.
-func (e *kubespray) HAProxy() error {
- pb := ansible.Playbook{
- Path: filepath.Join(e.ClusterPath, "ansible/kubitect/haproxy.yaml"),
- Inventory: filepath.Join(e.ClusterPath, "config/nodes.yaml"),
- Become: true,
- User: e.SshUser(),
- PrivateKey: e.SshPKey(),
- Timeout: 3000,
- }
-
- return e.Ansible.Exec(pb)
-}
-
// KubesprayCreate function calls an Ansible playbook that configures Kubernetes
// cluster.
func (e *kubespray) KubesprayCreate() error {
- vars := []string{
- "kube_version=" + e.K8sVersion(),
+ vars := map[string]string{
+ "kube_version": e.K8sVersion(),
}
pb := ansible.Playbook{
@@ -85,8 +30,8 @@ func (e *kubespray) KubesprayCreate() error {
// KubesprayUpgrade function calls an Ansible playbook that upgrades Kubernetes
// nodes to a newer version.
func (e *kubespray) KubesprayUpgrade() error {
- vars := []string{
- "kube_version=" + e.K8sVersion(),
+ vars := map[string]string{
+ "kube_version": e.K8sVersion(),
}
pb := ansible.Playbook{
@@ -105,8 +50,8 @@ func (e *kubespray) KubesprayUpgrade() error {
// KubesprayScale function calls an Ansible playbook that configures virtual machines
// that are freshly added to the cluster.
func (e *kubespray) KubesprayScale() error {
- vars := []string{
- "kube_version=" + e.K8sVersion(),
+ vars := map[string]string{
+ "kube_version": e.K8sVersion(),
}
pb := ansible.Playbook{
@@ -125,10 +70,10 @@ func (e *kubespray) KubesprayScale() error {
// KubesprayRemoveNodes function calls an Ansible playbook that removes the nodes with
// the provided names.
func (e *kubespray) KubesprayRemoveNodes(removedNodeNames []string) error {
- vars := []string{
- "skip_confirmation=yes",
- "delete_nodes_confirmation=yes",
- "node=" + strings.Join(removedNodeNames, ","),
+ vars := map[string]string{
+ "skip_confirmation": "yes",
+ "delete_nodes_confirmation": "yes",
+ "node": strings.Join(removedNodeNames, ","),
}
pb := ansible.Playbook{
diff --git a/pkg/cluster/executors/kubespray/kubespray_test.go b/pkg/cluster/managers/kubespray_test.go
similarity index 71%
rename from pkg/cluster/executors/kubespray/kubespray_test.go
rename to pkg/cluster/managers/kubespray_test.go
index 2ebe4abf..7515dfbe 100644
--- a/pkg/cluster/executors/kubespray/kubespray_test.go
+++ b/pkg/cluster/managers/kubespray_test.go
@@ -1,4 +1,4 @@
-package kubespray
+package managers
import (
"fmt"
@@ -7,7 +7,6 @@ import (
"testing"
"github.com/MusicDin/kubitect/pkg/cluster/event"
- "github.com/MusicDin/kubitect/pkg/cluster/executors"
"github.com/MusicDin/kubitect/pkg/env"
"github.com/MusicDin/kubitect/pkg/models/config"
"github.com/MusicDin/kubitect/pkg/models/infra"
@@ -31,7 +30,7 @@ type invalidAnsibleMock struct{}
func (a *ansibleMock) Exec(ansible.Playbook) error { return nil }
func (a *invalidAnsibleMock) Exec(ansible.Playbook) error { return fmt.Errorf("error") }
-func MockExecutor(t *testing.T) *kubespray {
+func MockManager(t *testing.T) *kubespray {
tmpDir := t.TempDir()
cfg := &config.Config{}
@@ -42,23 +41,17 @@ func MockExecutor(t *testing.T) *kubespray {
iCfg := &infra.Config{}
return &kubespray{
- ClusterName: "mock",
- ClusterPath: tmpDir,
- Config: cfg,
- ConfigDir: path.Join(tmpDir, "config"),
- InfraConfig: iCfg,
- VirtualEnv: &virtualEnvMock{},
- Ansible: &ansibleMock{},
+ common: common{
+ ClusterName: "mock",
+ ClusterPath: tmpDir,
+ Config: cfg,
+ ConfigDir: path.Join(tmpDir, "config"),
+ InfraConfig: iCfg,
+ Ansible: &ansibleMock{},
+ },
}
}
-func MockInvalidExecutor(t *testing.T) executors.Executor {
- ks := MockExecutor(t)
- ks.VirtualEnv = &invalidVirtualEnvMock{}
- ks.Ansible = &invalidAnsibleMock{}
- return ks
-}
-
func MockEvents(t *testing.T, obj interface{}, action event.ActionType) []event.Event {
change := cmp.Change{
ValueType: reflect.TypeOf(obj),
@@ -78,10 +71,10 @@ func MockEvents(t *testing.T, obj interface{}, action event.ActionType) []event.
return []event.Event{e}
}
-func TestNewExecutor(t *testing.T) {
+func TestNewManager(t *testing.T) {
tmpDir := t.TempDir()
clsName := "clsName"
- e := NewKubesprayExecutor(
+ e := NewKubesprayManager(
clsName,
path.Join(tmpDir, clsName),
path.Join(tmpDir, "id_rsa"),
@@ -90,33 +83,21 @@ func TestNewExecutor(t *testing.T) {
path.Join(tmpDir, "share"),
&config.Config{},
&infra.Config{},
- &virtualEnvMock{},
)
assert.NotNil(t, e)
}
func TestInit(t *testing.T) {
- e := MockExecutor(t)
+ e := MockManager(t)
assert.NoError(t, e.Init())
}
-func TestInit_InvalidVenv(t *testing.T) {
- e := MockInvalidExecutor(t)
- assert.EqualError(t, e.Init(), "kubespray exec: initialize virtual environment: error")
-}
-
func TestCreateAndUpgrade(t *testing.T) {
- e := MockExecutor(t)
+ e := MockManager(t)
assert.NoError(t, e.Create())
assert.NoError(t, e.Upgrade())
}
-func TestCreateAndUpgrade_Invalid(t *testing.T) {
- e := MockInvalidExecutor(t)
- assert.EqualError(t, e.Create(), "error")
- assert.EqualError(t, e.Upgrade(), "error")
-}
-
func TestExtractRemovedNodes(t *testing.T) {
w := config.WorkerInstance{
Id: "worker",
@@ -134,18 +115,18 @@ func TestScaleDown(t *testing.T) {
}
events := MockEvents(t, w, event.Action_ScaleDown)
- err := MockExecutor(t).ScaleDown(events)
+ err := MockManager(t).ScaleDown(events)
assert.NoError(t, err)
}
func TestScaleDown_NoEvents(t *testing.T) {
- err := MockExecutor(t).ScaleDown(nil)
+ err := MockManager(t).ScaleDown(nil)
assert.NoError(t, err)
}
func TestScaleDown_InvalidEvent(t *testing.T) {
events := MockEvents(t, config.Host{}, event.Action_ScaleDown)
- err := MockExecutor(t).ScaleDown(events)
+ err := MockManager(t).ScaleDown(events)
assert.EqualError(t, err, "Host cannot be scaled")
}
@@ -155,11 +136,11 @@ func TestScaleUp(t *testing.T) {
}
events := MockEvents(t, w, event.Action_ScaleUp)
- err := MockExecutor(t).ScaleUp(events)
+ err := MockManager(t).ScaleUp(events)
assert.NoError(t, err)
}
func TestScaleUp_NoEvents(t *testing.T) {
- err := MockExecutor(t).ScaleUp(nil)
+ err := MockManager(t).ScaleUp(nil)
assert.NoError(t, err)
}
diff --git a/pkg/cluster/managers/template.go b/pkg/cluster/managers/template.go
new file mode 100644
index 00000000..78e2719c
--- /dev/null
+++ b/pkg/cluster/managers/template.go
@@ -0,0 +1,54 @@
+package managers
+
+import (
+ "fmt"
+ "path/filepath"
+
+ "github.com/MusicDin/kubitect/embed"
+ "github.com/MusicDin/kubitect/pkg/utils/template"
+)
+
+type Template[T any] struct {
+ path string
+ values T
+}
+
+// NewTemplate initializes new embedded template on the given path
+// with the provided values.
+func NewTemplate[T any](templatePath string, values T) Template[T] {
+ return Template[T]{
+ path: filepath.Clean(templatePath),
+ values: values,
+ }
+}
+
+func (t Template[T]) Name() string {
+ return filepath.Base(t.path)
+}
+
+func (t Template[T]) Path() string {
+ return t.path
+}
+
+func (t Template[T]) Values() T {
+ return t.values
+}
+
+func (t Template[T]) Template() (string, error) {
+ tpl, err := embed.GetTemplate(t.path)
+ if err != nil {
+ return "", err
+ }
+
+ return template.TrimTemplate(string(tpl.Content)), nil
+}
+
+// Write writes the populated template to the given path.
+func (t Template[T]) Write(dstPath string) error {
+ err := template.Write(t, dstPath)
+ if err != nil {
+ return fmt.Errorf("Failed writing template %q: %v", t.Name(), err)
+ }
+
+ return nil
+}
diff --git a/pkg/cluster/executors/kubespray/template_test.go b/pkg/cluster/managers/template_test.go
similarity index 61%
rename from pkg/cluster/executors/kubespray/template_test.go
rename to pkg/cluster/managers/template_test.go
index 7760fa9c..f4d666fc 100644
--- a/pkg/cluster/executors/kubespray/template_test.go
+++ b/pkg/cluster/managers/template_test.go
@@ -1,113 +1,61 @@
-package kubespray
+package managers
import (
"fmt"
+ "path/filepath"
"testing"
"github.com/MusicDin/kubitect/pkg/env"
"github.com/MusicDin/kubitect/pkg/models/config"
"github.com/MusicDin/kubitect/pkg/utils/template"
- "gopkg.in/yaml.v3"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
-func TestKubesprayAllTemplate(t *testing.T) {
- nodes := config.MockNodes(t)
-
- tpl := NewKubesprayAllTemplate(t.TempDir(), nodes)
+func TestKubesprayTemplate_All(t *testing.T) {
+ tpl := NewTemplate("kubespray/all.yaml", config.MockNodes(t))
pop, err := template.Populate(tpl)
require.NoError(t, err)
- require.NoError(t, tpl.Write())
+ require.NoError(t, tpl.Write(filepath.Join(t.TempDir(), "tpl")))
assert.Contains(t, pop, "apiserver_loadbalancer_domain_name: \"192.168.113.200\"")
assert.Contains(t, pop, "loadbalancer_apiserver:\n address: \"192.168.113.200\"\n port: 6443")
}
-func TestKubesprayK8sClusterTemplate(t *testing.T) {
- cfg := config.MockConfig(t)
-
- tpl := NewKubesprayK8sClusterTemplate(t.TempDir(), cfg)
+func TestKubesprayTemplate_K8sCluster(t *testing.T) {
+ tpl := NewTemplate("kubespray/k8s-cluster.yaml", config.MockConfig(t))
pop, err := template.Populate(tpl)
require.NoError(t, err)
- require.NoError(t, tpl.Write())
+ require.NoError(t, tpl.Write(filepath.Join(t.TempDir(), "tpl")))
assert.Contains(t, pop, fmt.Sprintf("kube_version: %s", env.ConstKubernetesVersion))
assert.Contains(t, pop, "kube_network_plugin: calico")
assert.Contains(t, pop, "dns_mode: coredns")
assert.Contains(t, pop, "auto_renew_certificates: false")
}
-func TestKubesprayAddonsTemplate(t *testing.T) {
- addons := map[string]any{
- "test": "test",
- }
-
- bytes, err := yaml.Marshal(addons)
- require.NoError(t, err)
-
- tpl := NewKubesprayAddonsTemplate(t.TempDir(), string(bytes))
+func TestKubesprayTemplate_Etcd(t *testing.T) {
+ tpl := NewTemplate("kubespray/etcd.yaml", "")
pop, err := template.Populate(tpl)
require.NoError(t, err)
- require.NoError(t, tpl.Write())
- assert.Equal(t, "test: test\n", pop)
-}
-
-func TestKubesprayEtcdTemplate(t *testing.T) {
- tmpDir := t.TempDir()
-
- tpl := NewKubesprayEtcdTemplate(tmpDir)
- pop, err := template.Populate(tpl)
-
- require.NoError(t, err)
- require.NoError(t, tpl.Write())
+ require.NoError(t, tpl.Write(filepath.Join(t.TempDir(), "tpl")))
assert.Contains(t, pop, "etcd_deployment_type: host")
}
-func TestHostsTemplate(t *testing.T) {
- hosts := []config.Host{
- config.MockLocalHost(t, "local", true),
- config.MockLocalHost(t, "localhost", false),
- config.MockRemoteHost(t, "remote", false, false),
- }
-
- tpl := NewHostsTemplate(t.TempDir(), hosts)
- pop, err := template.Populate(tpl)
-
- expect := fmt.Sprintf(template.TrimTemplate(`
- all:
- hosts:
- local:
- ansible_connection: local
- ansible_host: localhost
- localhost:
- ansible_connection: local
- ansible_host: localhost
- remote:
- ansible_connection: ssh
- ansible_user: mocked-user
- ansible_host: 192.168.113.42
- ansible_port: 22
- ansible_private_key_file: %s
- children:
- kubitect_hosts:
- hosts:
- local:
- localhost:
- remote:
- `), hosts[2].Connection.SSH.Keyfile)
-
- require.NoError(t, err)
- require.NoError(t, tpl.Write())
- assert.Equal(t, expect, pop)
-}
-
-func TestNodesTemplate(t *testing.T) {
+func TestKubesprayTemplate_Inventory(t *testing.T) {
nodes := config.MockNodes(t)
- tpl := NewNodesTemplate(t.TempDir(), nodes, nodes)
+ values := struct {
+ ConfigNodes config.Nodes
+ InfraNodes config.Nodes
+ }{
+ ConfigNodes: nodes,
+ InfraNodes: nodes,
+ }
+
+ tpl := NewTemplate("kubespray/inventory.yaml", values)
pop, err := template.Populate(tpl)
expect := template.TrimTemplate(`
@@ -164,15 +112,23 @@ func TestNodesTemplate(t *testing.T) {
`)
require.NoError(t, err)
- require.NoError(t, tpl.Write())
+ require.NoError(t, tpl.Write(filepath.Join(t.TempDir(), "tpl")))
assert.Equal(t, expect, pop)
}
-func TestNodesTemplate_NoWorkers(t *testing.T) {
+func TestTemplate_Inventory_NoWorkers(t *testing.T) {
nodes := config.MockNodes(t)
nodes.Worker = config.Worker{}
- tpl := NewNodesTemplate(t.TempDir(), nodes, nodes)
+ values := struct {
+ ConfigNodes config.Nodes
+ InfraNodes config.Nodes
+ }{
+ ConfigNodes: nodes,
+ InfraNodes: nodes,
+ }
+
+ tpl := NewTemplate("kubespray/inventory.yaml", values)
pop, err := template.Populate(tpl)
expect := template.TrimTemplate(`
diff --git a/pkg/cluster/meta.go b/pkg/cluster/meta.go
index e0d52eb0..c3b3640a 100644
--- a/pkg/cluster/meta.go
+++ b/pkg/cluster/meta.go
@@ -4,7 +4,7 @@ import (
"path/filepath"
"github.com/MusicDin/kubitect/pkg/app"
- "github.com/MusicDin/kubitect/pkg/cluster/executors"
+ "github.com/MusicDin/kubitect/pkg/cluster/interfaces"
"github.com/MusicDin/kubitect/pkg/cluster/provisioner"
"github.com/MusicDin/kubitect/pkg/cluster/provisioner/terraform"
"github.com/MusicDin/kubitect/pkg/utils/file"
@@ -30,7 +30,7 @@ type ClusterMeta struct {
Path string
Local bool
- exec executors.Executor
+ exec interfaces.Manager
prov provisioner.Provisioner
}
diff --git a/pkg/cluster/provisioner/terraform/terraform.go b/pkg/cluster/provisioner/terraform/terraform.go
index 25f919ca..34440031 100644
--- a/pkg/cluster/provisioner/terraform/terraform.go
+++ b/pkg/cluster/provisioner/terraform/terraform.go
@@ -235,9 +235,6 @@ func installTerraform(ver, binDir string) (string, error) {
return "", err
}
- fmt.Printf("==> Installing Terraform %v\n", ver)
- fmt.Printf("==> Installing Terraform %v\n", version.Must(version.NewVersion(ver)))
-
installer := &releases.ExactVersion{
Product: product.Terraform,
Version: version.Must(version.NewVersion(ver)),
diff --git a/pkg/env/constants.go b/pkg/env/constants.go
index 5a67425a..2838126d 100644
--- a/pkg/env/constants.go
+++ b/pkg/env/constants.go
@@ -8,6 +8,8 @@ package env
const (
ConstProjectUrl = "https://github.com/MusicDin/kubitect"
ConstProjectVersion = "v3.3.1"
+ ConstK3sURL = "https://github.com/k3s-io/k3s-ansible"
+ ConstK3sVersion = "7ec16a8d53363ace979e5585323311ab1bd1641d" // K3s has no tags, therefore use specific commit hash.
ConstKubesprayUrl = "https://github.com/kubernetes-sigs/kubespray"
ConstKubesprayVersion = "v2.24.1"
ConstKubernetesVersion = "v1.28.6"
diff --git a/pkg/models/config/cluster_nodes_lb.go b/pkg/models/config/cluster_nodes_lb.go
index 32a3404b..83c518a0 100644
--- a/pkg/models/config/cluster_nodes_lb.go
+++ b/pkg/models/config/cluster_nodes_lb.go
@@ -61,10 +61,7 @@ func (lb *LB) SetDefaults() {
lb.Instances[i].CPU = defaults.Default(lb.Instances[i].CPU, lb.Default.CPU)
lb.Instances[i].RAM = defaults.Default(lb.Instances[i].RAM, lb.Default.RAM)
lb.Instances[i].MainDiskSize = defaults.Default(lb.Instances[i].MainDiskSize, lb.Default.MainDiskSize)
-
- if len(lb.Instances) > 1 {
- lb.Instances[i].Priority = defaults.Default(lb.Instances[i].Priority, &defaultPriority)
- }
+ lb.Instances[i].Priority = defaults.Default(lb.Instances[i].Priority, &defaultPriority)
}
}
diff --git a/pkg/models/config/cluster_nodes_lb_test.go b/pkg/models/config/cluster_nodes_lb_test.go
index 26a0d8e0..503e6228 100644
--- a/pkg/models/config/cluster_nodes_lb_test.go
+++ b/pkg/models/config/cluster_nodes_lb_test.go
@@ -85,7 +85,6 @@ func TestLB_Defaults(t *testing.T) {
defaults.Assign(&lb2)
assert.Nil(t, lb1.VirtualRouterId, "LB VRID is set even if only one instance is configured!")
- assert.Nil(t, lb1.Instances[0].Priority, "LB instance priority is set even if only one instance is configured!")
assert.Equal(t, &defaultVRID, lb2.VirtualRouterId, "Default LB VRID is not set when multiple instances are configured!")
assert.Equal(t, &defaultPriority, lb2.Instances[0].Priority, "Default LB instance priority is not set when multiple instances are configured!")
}
diff --git a/pkg/models/config/kubernetes.go b/pkg/models/config/kubernetes.go
index e1080978..4f1b3042 100644
--- a/pkg/models/config/kubernetes.go
+++ b/pkg/models/config/kubernetes.go
@@ -11,6 +11,7 @@ import (
type Kubernetes struct {
Version KubernetesVersion `yaml:"version"`
+ Manager KubernetesManager `yaml:"manager"`
DnsMode DnsMode `yaml:"dnsMode"`
NetworkPlugin NetworkPlugin `yaml:"networkPlugin"`
Other Other `yaml:"other"`
@@ -27,6 +28,7 @@ func (k Kubernetes) Validate() error {
func (k *Kubernetes) SetDefaults() {
k.Version = defaults.Default(k.Version, env.ConstKubernetesVersion)
+ k.Manager = defaults.Default(k.Manager, ManagerKubespray)
k.DnsMode = defaults.Default(k.DnsMode, COREDNS)
k.NetworkPlugin = defaults.Default(k.NetworkPlugin, CALICO)
}
@@ -59,6 +61,17 @@ func (ver KubernetesVersion) Validate() error {
return err
}
+type KubernetesManager string
+
+const (
+ ManagerKubespray = "kubespray"
+ ManagerK3s = "k3s"
+)
+
+func (m KubernetesManager) Validate() error {
+ return v.Var(m, v.OneOf(ManagerKubespray, ManagerK3s))
+}
+
type DnsMode string
const (
diff --git a/pkg/tools/ansible/ansible.go b/pkg/tools/ansible/ansible.go
index e40cd0c7..a0070fb3 100644
--- a/pkg/tools/ansible/ansible.go
+++ b/pkg/tools/ansible/ansible.go
@@ -15,7 +15,6 @@ import (
)
type Playbook struct {
- Path string
Inventory string
Tags []string
User string
@@ -23,7 +22,13 @@ type Playbook struct {
Become bool
Local bool
Timeout int
- ExtraVars []string
+ ExtraVars map[string]string
+
+ // Playbook's path.
+ Path string
+
+ // Working directory. Defaults to playbook's directory.
+ WorkingDir string
}
type (
@@ -78,13 +83,9 @@ func (a *ansible) Exec(pb Playbook) error {
Forks: "50",
}
- vars, err := extraVarsToMap(pb.ExtraVars)
- if err != nil {
- return err
- }
-
- for keyVar, valueVar := range vars {
- playbookOptions.AddExtraVar(keyVar, valueVar)
+ for k, v := range pb.ExtraVars {
+ playbookOptions.AddExtraVar(k, v)
+ ui.Printf(ui.WARN, "%s=%s\n", k, v)
}
executor := &execute.DefaultExecute{
@@ -93,6 +94,10 @@ func (a *ansible) Exec(pb Playbook) error {
WriterError: ui.Streams().Err().File(),
}
+ if pb.WorkingDir != "" {
+ executor.CmdRunDir = pb.WorkingDir
+ }
+
playbook := &playbook.AnsiblePlaybookCmd{
Binary: a.binPath,
Exec: executor,
@@ -128,29 +133,10 @@ func (a *ansible) Exec(pb Playbook) error {
options.AnsibleSetEnv("ANSIBLE_STDOUT_CALLBACK", "yaml")
options.AnsibleSetEnv("ANSIBLE_STDERR_CALLBACK", "yaml")
- err = playbook.Run(context.TODO())
-
+ err := playbook.Run(context.TODO())
if err != nil {
- pb := filepath.Base(pb.Path)
- return fmt.Errorf("ansible-playbook (%s): %v", pb, err)
+ return fmt.Errorf("ansible-playbook (%s): %v", filepath.Base(pb.Path), err)
}
return nil
}
-
-// extraVarsToMap converts slice of "key=value" strings into map.
-func extraVarsToMap(extraVars []string) (map[string]string, error) {
- evMap := make(map[string]string)
-
- for _, v := range extraVars {
- tokens := strings.Split(v, "=")
-
- if len(tokens) != 2 {
- return nil, fmt.Errorf("extraVarsToMap: variable (%s) must be in 'key=value' format", v)
- }
-
- evMap[tokens[0]] = tokens[1]
- }
-
- return evMap, nil
-}
diff --git a/pkg/tools/ansible/ansible_test.go b/pkg/tools/ansible/ansible_test.go
index a3b136e6..d783bbf3 100644
--- a/pkg/tools/ansible/ansible_test.go
+++ b/pkg/tools/ansible/ansible_test.go
@@ -6,34 +6,8 @@ import (
"github.com/MusicDin/kubitect/pkg/ui"
"github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
)
-func TestExtraVarsToMap(t *testing.T) {
- vars := []string{"key=value"}
- expect := map[string]string{"key": "value"}
-
- varsMap, err := extraVarsToMap(vars)
- require.NoError(t, err)
- assert.Equal(t, expect, varsMap)
-}
-
-func TestExtraVarsToMap_Empty(t *testing.T) {
- vars := []string{}
- expect := map[string]string{}
-
- varsMap, err := extraVarsToMap(vars)
- require.NoError(t, err)
- assert.Equal(t, expect, varsMap)
-}
-
-func TestExtraVarsToMap_Invalid(t *testing.T) {
- vars := []string{"invalid"}
-
- _, err := extraVarsToMap(vars)
- assert.EqualError(t, err, "extraVarsToMap: variable (invalid) must be in 'key=value' format")
-}
-
func TestAnsible_InvalidPath(t *testing.T) {
a := NewAnsible(t.TempDir(), "")
@@ -51,18 +25,6 @@ func TestAnsible_InvalidInventory(t *testing.T) {
assert.EqualError(t, a.Exec(pb), "ansible-playbook (pb.yaml): inventory not set")
}
-func TestAnsible_InvalidExtraVar(t *testing.T) {
- a := NewAnsible(t.TempDir(), "")
-
- pb := Playbook{
- Path: "pb.yaml",
- Local: true,
- ExtraVars: []string{"invalid"},
- }
-
- assert.EqualError(t, a.Exec(pb), "extraVarsToMap: variable (invalid) must be in 'key=value' format")
-}
-
func TestAnsible_InvalidBinPath(t *testing.T) {
a := NewAnsible(t.TempDir(), "")
@@ -82,7 +44,7 @@ func TestAnsible_InvalidBinPath2(t *testing.T) {
pb := Playbook{
Path: "pb.yaml",
Local: true,
- ExtraVars: []string{"key=value"},
+ ExtraVars: map[string]string{"key": "value"},
}
assert.ErrorContains(t, a.Exec(pb), "ansible-playbook (pb.yaml): Binary file")
diff --git a/pkg/tools/git/git.go b/pkg/tools/git/git.go
index 5fca3cab..a646447c 100644
--- a/pkg/tools/git/git.go
+++ b/pkg/tools/git/git.go
@@ -1,9 +1,11 @@
package git
import (
+ "errors"
"fmt"
"os"
"regexp"
+ "strings"
"github.com/MusicDin/kubitect/pkg/ui"
@@ -11,76 +13,108 @@ import (
"github.com/go-git/go-git/v5/plumbing"
)
-// Version regex ("v" any number "dot" any number "dot" any number)
-var versionRegex = regexp.MustCompile("^v(\\d+)(.{1}\\d+){2}$")
+var (
+ ErrInvalidRepositoryURL = errors.New("repository URL must start with https://")
+ ErrCloneFailed = errors.New("failed to clone repository")
+ ErrCheckoutFailed = errors.New("failed to checkout")
+ ErrFetchingFailed = errors.New("failed to fetch")
+)
-type (
- GitProject interface {
- Clone(path string) error
- Url() string
- Version() string
- }
+// tagRegex instructs git to clone repository by tag instead of
+// branch (if matched).
+var tagRegex = regexp.MustCompile("^v(\\d+)(.{1}\\d+){2}$")
- gitProject struct {
- url string
- version string
- }
-)
+type GitRepo struct {
+ url string
+ version string
+ commitHash string
+}
-func NewGitProject(url, version string) GitProject {
- return &gitProject{
- url: url,
- version: version,
+// NewGitRepo returns new instance of Git repository linked to the
+// given URL.
+func NewGitRepo(url string) GitRepo {
+ return GitRepo{
+ url: url,
}
}
-func (p gitProject) Url() string {
- return p.url
+// WithRef sets the repository reference to the given branch or tag.
+// Reference is used when cloning a repository.
+func (r GitRepo) WithRef(branchOrTag string) GitRepo {
+ r.version = branchOrTag
+ return r
}
-func (p gitProject) Version() string {
- return p.version
+// WithCommitHash sets the repository checkout commit hash which is
+// used when repository is cloned.
+func (r GitRepo) WithCommitHash(commitHash string) GitRepo {
+ r.commitHash = commitHash
+ return r
+}
+
+func (p GitRepo) Url() string {
+ return p.url
}
// Clone clones a git project with the given URL and version into
// a specific directory.
-func (g *gitProject) Clone(dstPath string) error {
- if len(g.url) < 1 {
- return fmt.Errorf("git clone: project URL not set")
- }
-
- if len(g.version) < 1 {
- return fmt.Errorf("git clone: project version not set")
- }
-
- // If version matches version regex, set reference name to tag,
- // otherwise set it to branch.
- var refName plumbing.ReferenceName
- if versionRegex.MatchString(g.version) {
- refName = plumbing.NewTagReferenceName(g.version)
- } else {
- refName = plumbing.NewBranchReferenceName(g.version)
+func (g GitRepo) Clone(dstPath string) error {
+ if !strings.HasPrefix(g.url, "https://") {
+ return ErrInvalidRepositoryURL
}
opts := &git.CloneOptions{
URL: g.url,
- ReferenceName: refName,
Tags: git.NoTags,
RecurseSubmodules: git.NoRecurseSubmodules,
SingleBranch: true,
- Depth: 1,
+ }
+
+ if g.version != "" {
+ // If version matches version regex, set reference
+ // name to tag, otherwise set it to branch.
+ opts.ReferenceName = plumbing.NewBranchReferenceName(g.version)
+ if tagRegex.MatchString(g.version) {
+ opts.ReferenceName = plumbing.NewTagReferenceName(g.version)
+ }
+ }
+
+ if g.commitHash == "" {
+ opts.Depth = 1
}
if ui.Debug() {
opts.Progress = ui.Streams().Out().File()
}
- if err := os.MkdirAll(dstPath, 0700); err != nil {
- return fmt.Errorf("git clone: %v", err)
+ // Ensure destination directory exists.
+ err := os.MkdirAll(dstPath, 0700)
+ if err != nil {
+ return err
+ }
+
+ // Clone repository.
+ repo, err := git.PlainClone(dstPath, false, opts)
+ if err != nil {
+ return fmt.Errorf("%w (url: %s, version: %s): %v", ErrCloneFailed, g.url, g.version, err)
}
- if _, err := git.PlainClone(dstPath, false, opts); err != nil {
- return fmt.Errorf("git clone: failed to clone project (url: %s, version: %s): %v", g.url, g.version, err)
+ if g.commitHash != "" {
+ // Fetch repository work tree.
+ tree, err := repo.Worktree()
+ if err != nil {
+ return err
+ }
+
+ opts := &git.CheckoutOptions{
+ Hash: plumbing.NewHash(g.commitHash),
+ }
+
+ // Checkout to specific commit hash.
+ err = tree.Checkout(opts)
+ if err != nil {
+ return fmt.Errorf("%w (commitHash: %s): %v", ErrCheckoutFailed, g.commitHash, err)
+ }
}
return nil
diff --git a/pkg/tools/git/git_test.go b/pkg/tools/git/git_test.go
index 0735734f..b3175ca5 100644
--- a/pkg/tools/git/git_test.go
+++ b/pkg/tools/git/git_test.go
@@ -4,51 +4,49 @@ import (
"testing"
"github.com/MusicDin/kubitect/pkg/env"
- "github.com/MusicDin/kubitect/pkg/ui"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewGit(t *testing.T) {
- p := NewGitProject(env.ConstProjectUrl, env.ConstProjectVersion)
- assert.Equal(t, env.ConstProjectUrl, p.Url())
- assert.Equal(t, env.ConstProjectVersion, p.Version())
+ repo := NewGitRepo(env.ConstProjectUrl).WithRef(env.ConstProjectVersion)
+ assert.Equal(t, env.ConstProjectUrl, repo.url)
+ assert.Equal(t, env.ConstProjectVersion, repo.version)
}
func TestClone_Branch(t *testing.T) {
- u := ui.MockGlobalUi(t)
- p := NewGitProject(env.ConstProjectUrl, "main")
-
- require.NoError(t, p.Clone(t.TempDir()))
- assert.Equal(t, "", u.ReadStdout(t))
+ repo := NewGitRepo(env.ConstProjectUrl).WithRef("main")
+ require.NoError(t, repo.Clone(t.TempDir()))
}
-func TestClone_Version(t *testing.T) {
- o := ui.UiOptions{Debug: true}
- u := ui.MockGlobalUi(t, o)
- p := NewGitProject(env.ConstProjectUrl, "v2.0.0")
+func TestClone_Tag(t *testing.T) {
+ repo := NewGitRepo(env.ConstProjectUrl).WithRef("v2.0.0")
+ require.NoError(t, repo.Clone(t.TempDir()))
+}
- require.NoError(t, p.Clone(t.TempDir()))
- assert.Contains(t, u.ReadStdout(t), "Compressing objects")
+func TestClone_CommitHash(t *testing.T) {
+ repo := NewGitRepo(env.ConstProjectUrl).WithCommitHash("c45d60ebc11e6925be8aebfaef1f6b025772c509")
+ assert.NoError(t, repo.Clone(t.TempDir()))
}
-func TestClone_EmptyVersion(t *testing.T) {
- p := NewGitProject(env.ConstProjectUrl, "")
- assert.EqualError(t, p.Clone(t.TempDir()), "git clone: project version not set")
+func TestClone_HEAD(t *testing.T) {
+ // HEAD = empty ref.
+ repo := NewGitRepo(env.ConstProjectUrl)
+ assert.NoError(t, repo.Clone(t.TempDir()))
}
func TestClone_EmptyURL(t *testing.T) {
- p := NewGitProject("", "master")
- assert.EqualError(t, p.Clone(t.TempDir()), "git clone: project URL not set")
+ repo := NewGitRepo("").WithRef("master")
+ assert.ErrorIs(t, repo.Clone(t.TempDir()), ErrInvalidRepositoryURL)
}
func TestClone_InvalidURL(t *testing.T) {
- p := NewGitProject(env.ConstProjectUrl+"invalid", "master")
- assert.ErrorContains(t, p.Clone(t.TempDir()), ": authentication required")
+ repo := NewGitRepo(env.ConstProjectUrl + "invalid").WithRef("master")
+ assert.ErrorContains(t, repo.Clone(t.TempDir()), "authentication required")
}
func TestClone_InvalidDestination(t *testing.T) {
- p := NewGitProject(env.ConstProjectUrl, "master")
- assert.ErrorContains(t, p.Clone(""), ": no such file or directory")
+ repo := NewGitRepo(env.ConstProjectUrl).WithRef("master")
+ assert.ErrorContains(t, repo.Clone(""), ": no such file or directory")
}
diff --git a/pkg/tools/virtualenv/virtualenv.go b/pkg/tools/virtualenv/virtualenv.go
index efe231a4..5ece70ef 100644
--- a/pkg/tools/virtualenv/virtualenv.go
+++ b/pkg/tools/virtualenv/virtualenv.go
@@ -2,55 +2,49 @@ package virtualenv
import (
"fmt"
+ "os"
"os/exec"
+ "path"
"path/filepath"
"github.com/MusicDin/kubitect/pkg/ui"
)
-type (
- VirtualEnv interface {
- Init() error
- Path() string
- }
-
- virtualEnv struct {
- path string
- workingDir string
- requirementsPath string
- initialized bool
- }
-)
+type VirtualEnv struct {
+ path string
+ requirementsPath string
+ initialized bool
+}
-func NewVirtualEnv(path, workingDir, reqPath string) VirtualEnv {
- return &virtualEnv{
- path: path,
- workingDir: workingDir,
+// NewVirtualEnv returns new virtual environment (VE). It expects VE path
+// to point on the directory where VE is created and 'reqPath' to the
+// requirements.txt file.
+func NewVirtualEnv(virtualEnvPath, reqPath string) *VirtualEnv {
+ return &VirtualEnv{
+ path: virtualEnvPath,
requirementsPath: reqPath,
}
}
-func (e *virtualEnv) Path() string {
- return e.path
-}
-
// Init creates virtual environment in the cluster path
// and installs required pip3 and ansible dependencies.
-func (e *virtualEnv) Init() error {
+func (e *VirtualEnv) Init() error {
if e.initialized {
return nil
}
ui.Println(ui.INFO, "Setting up virtual environment...")
- if err := e.create(); err != nil {
+ err := e.create()
+ if err != nil {
return err
}
ui.Println(ui.INFO, "Installing pip3 dependencies...")
ui.Println(ui.INFO, "This can take up to a minute when the virtual environment is initialized for the first time...")
- if err := e.installPipReq(); err != nil {
+ err = e.installPipReq()
+ if err != nil {
return err
}
@@ -60,17 +54,23 @@ func (e *virtualEnv) Init() error {
}
// create creates virtual environment if it does not yet exist.
-func (e *virtualEnv) create() error {
+func (e *VirtualEnv) create() error {
+ wd := path.Dir(e.path)
+
+ err := os.MkdirAll(wd, os.ModePerm)
+ if err != nil {
+ return err
+ }
+
cmd := exec.Command("virtualenv", "-p", "python3", e.path)
- cmd.Dir = e.workingDir
+ cmd.Dir = wd
if ui.Debug() {
cmd.Stdout = ui.Streams().Out().File()
cmd.Stderr = ui.Streams().Err().File()
}
- err := cmd.Run()
-
+ err = cmd.Run()
if err != nil {
return fmt.Errorf("failed to create virtual environment: %v", err)
}
@@ -79,10 +79,10 @@ func (e *virtualEnv) create() error {
}
// installPipReq installs pip3 requirements into virtual environment.
-func (e *virtualEnv) installPipReq() error {
+func (e *VirtualEnv) installPipReq() error {
cmd := exec.Command("pip3", "install", "-r", e.requirementsPath)
cmd.Path = filepath.Join(e.path, "bin", "pip3")
- cmd.Dir = e.workingDir
+ cmd.Dir = filepath.Dir(e.path)
if ui.Debug() {
cmd.Stdout = ui.Streams().Out().File()
diff --git a/pkg/tools/virtualenv/virtualenv_test.go b/pkg/tools/virtualenv/virtualenv_test.go
index 4c78048e..f3aedae0 100644
--- a/pkg/tools/virtualenv/virtualenv_test.go
+++ b/pkg/tools/virtualenv/virtualenv_test.go
@@ -3,11 +3,11 @@ package virtualenv
import (
"os"
"path"
+ "path/filepath"
"testing"
"github.com/MusicDin/kubitect/pkg/ui"
- "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -21,12 +21,9 @@ func MockReqFile(t *testing.T) string {
return reqPath
}
-func MockVirtualEnv(t *testing.T) *virtualEnv {
- tmpDir := t.TempDir()
-
- return &virtualEnv{
- path: path.Join(tmpDir, "env"),
- workingDir: tmpDir,
+func MockVirtualEnv(t *testing.T) *VirtualEnv {
+ return &VirtualEnv{
+ path: path.Join(t.TempDir(), "env"),
requirementsPath: MockReqFile(t),
}
}
@@ -36,8 +33,8 @@ func TestCreate(t *testing.T) {
env := MockVirtualEnv(t)
- assert.NoError(t, env.create())
- assert.Equal(t, "env", path.Base(env.Path()))
+ require.NoError(t, env.create())
+ require.Equal(t, "env", path.Base(env.path))
}
func TestInstallPipReq(t *testing.T) {
@@ -45,28 +42,20 @@ func TestInstallPipReq(t *testing.T) {
env := MockVirtualEnv(t)
- assert.NoError(t, env.create())
- assert.NoError(t, env.installPipReq())
+ require.NoError(t, env.create())
+ require.NoError(t, env.installPipReq())
+
+ require.DirExists(t, filepath.Join(env.path, "bin"))
+ require.DirExists(t, filepath.Join(env.path, "lib"))
}
func TestInit(t *testing.T) {
- tmpDir := t.TempDir()
- env := NewVirtualEnv(tmpDir, tmpDir, MockReqFile(t))
-
- assert.NoError(t, env.Init())
- assert.NoError(t, env.Init()) // Instant, since environment already exists
+ env := NewVirtualEnv(t.TempDir(), MockReqFile(t))
+ require.NoError(t, env.Init())
+ require.NoError(t, env.Init()) // Instant, since environment already exists
}
func TestInit_InvalidReqPath(t *testing.T) {
- tmpDir := t.TempDir()
- env := NewVirtualEnv(tmpDir, tmpDir, "")
-
- assert.ErrorContains(t, env.Init(), "failed to install pip3 requirements:")
-}
-
-func TestInit_InvalidWorkingDir(t *testing.T) {
- tmpDir := t.TempDir()
- env := NewVirtualEnv(tmpDir, tmpDir+"invalid", "")
-
- assert.ErrorContains(t, env.Init(), "failed to create virtual environment:")
+ env := NewVirtualEnv(t.TempDir(), "")
+ require.ErrorContains(t, env.Init(), "failed to install pip3 requirements:")
}
diff --git a/pkg/utils/exec/client.go b/pkg/utils/exec/client.go
new file mode 100644
index 00000000..12d69a37
--- /dev/null
+++ b/pkg/utils/exec/client.go
@@ -0,0 +1,296 @@
+package exec
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "os"
+ "os/exec"
+ "strings"
+ "sync"
+ "time"
+
+ "golang.org/x/crypto/ssh"
+)
+
+// Ensure all clients implement Client interface.
+var _ Client = LocalClient{}
+var _ Client = RemoteClient{}
+
+type Client interface {
+ Run(Command) error
+ RunCtx(context.Context, Command) error
+ Close() error
+}
+
+// commonClient provides functions that are common for all clients.
+type commonClient struct {
+ stdin io.Reader
+ stdout io.Writer
+ stderr io.Writer
+}
+
+func (c *commonClient) SetStdin(stdin io.Reader) {
+ c.stdin = stdin
+}
+
+func (c *commonClient) SetStdout(stdout io.Writer) {
+ c.stdout = stdout
+}
+
+func (c *commonClient) SetStderr(stderr io.Writer) {
+ c.stderr = stderr
+}
+
+func (c *commonClient) SetCombinedStdout(writer io.Writer) {
+ c.stdout = writer
+ c.stderr = writer
+}
+
+func (c commonClient) Close() error {
+ return nil
+}
+
+type LocalClient struct {
+ commonClient
+}
+
+// NewLocalClient initializes a client for running local commands.
+func NewLocalClient() LocalClient {
+ return LocalClient{}
+}
+
+// Run runs command locally.
+func (c LocalClient) Run(command Command) error {
+ return c.RunCtx(context.Background(), command)
+}
+
+// RunCtx runs command locally.
+func (c LocalClient) RunCtx(ctx context.Context, command Command) error {
+ cmd := exec.CommandContext(ctx, command.command, command.args...)
+ cmd.Stdin = c.stdin
+ cmd.Stdout = c.stdout
+ cmd.Stderr = c.stderr
+
+ if len(command.envs) > 0 {
+ env := make([]string, 0, len(command.envs))
+ for k, v := range command.envs {
+ env = append(env, fmt.Sprintf("%s=%s", k, v))
+ }
+
+ cmd.Env = env
+ }
+
+ if command.workingDir != "" {
+ cmd.Dir = command.workingDir
+ }
+
+ return cmd.Run()
+}
+
+type RemoteClient struct {
+ commonClient
+
+ user string
+ host string
+ port string
+ privateKeyPath string
+ publicKeyPath string
+ initialized bool
+ sudo bool
+
+ client *ssh.Client
+ mux *sync.Mutex
+}
+
+// NewSSHClient initializes a new remote SSH client.
+func NewSSHClient(user string, host string) RemoteClient {
+ return RemoteClient{
+ user: user,
+ host: host,
+ port: "22",
+ mux: &sync.Mutex{},
+ }
+}
+
+// WithPort sets the host port to given value. By default, port 22 is used.
+func (c RemoteClient) WithPort(port uint16) RemoteClient {
+ // Immutable once client is initialized.
+ if !c.isInitialized() {
+ c.port = fmt.Sprint(port)
+ }
+ return c
+}
+
+// WithPrivateKeyFile sets the path to the private key file that is used
+// for authentication..
+func (c RemoteClient) WithPrivateKeyFile(privateKeyPath string) RemoteClient {
+ // Immutable once client is initialized.
+ if !c.isInitialized() {
+ c.privateKeyPath = privateKeyPath
+ }
+ return c
+}
+
+// WithPublicKeyFile sets the path to the public key file that is used
+// for host verification. If not set, known hosts are ignored.
+func (c RemoteClient) WithPublicKeyFile(publicKeyPath string) RemoteClient {
+ // Immutable once client is initialized.
+ if !c.isInitialized() {
+ c.publicKeyPath = publicKeyPath
+ }
+ return c
+}
+
+func (c RemoteClient) WithSuperUser(sudo bool) RemoteClient {
+ // Immutable once client is initialized.
+ if !c.isInitialized() {
+ c.sudo = sudo
+ }
+ return c
+}
+
+// Endpoint returns SSH endpoint in format "user@host:port".
+func (c RemoteClient) Endpoint() string {
+ return fmt.Sprintf("%s@%s:%s", c.user, c.host, c.port)
+}
+
+// Close closes potentially initialized the SSH client.
+func (c RemoteClient) Close() error {
+ if !c.isInitialized() {
+ return nil
+ }
+
+ return c.client.Close()
+}
+
+// Run establishes new connection with the remote host and executes
+// the given command.
+func (c RemoteClient) Run(command Command) error {
+ return c.RunCtx(context.Background(), command)
+}
+
+// RunCtx establishes new connection with the remote host and executes
+// the given command.
+func (c RemoteClient) RunCtx(ctx context.Context, command Command) error {
+ // Ensure SSH client is initialized.
+ if c.client == nil {
+ err := c.initClient(ctx)
+ if err != nil {
+ c.mux.Unlock()
+ return err
+ }
+ }
+
+ // Initiate new SSH session.
+ c.mux.Lock()
+ session, err := c.client.NewSession()
+ if err != nil {
+ c.mux.Unlock()
+ return fmt.Errorf("create session for %q: %v", c.Endpoint(), err)
+ }
+ defer session.Close()
+ c.mux.Unlock()
+
+ // Prepare command.
+ session.Stdin = c.stdin
+ session.Stdout = c.stdout
+ session.Stderr = c.stderr
+
+ for k, v := range command.envs {
+ err := session.Setenv(k, v)
+ if err != nil {
+ return fmt.Errorf("set env variable %q: %v", k, err)
+ }
+ }
+
+ // Run the command.
+ cmd := command.command
+ if len(command.args) > 0 {
+ cmd = fmt.Sprintf("%s %s", command.command, strings.Join(command.args, " "))
+ }
+
+ if c.sudo {
+ cmd = fmt.Sprintf("sudo --preserve-env %s", cmd)
+ }
+
+ return session.Run(cmd)
+}
+
+func (c *RemoteClient) initClient(ctx context.Context) error {
+ c.mux.Lock()
+ defer c.mux.Unlock()
+
+ config := &ssh.ClientConfig{}
+ config.User = c.user
+
+ // Inherit command timeout from context.
+ timeout, ok := ctx.Deadline()
+ if ok {
+ config.Timeout = time.Until(timeout)
+ }
+
+ // Read public key and set it as valid authorization keys.
+ if c.publicKeyPath != "" {
+ file, err := os.ReadFile(c.publicKeyPath)
+ if err != nil {
+ return fmt.Errorf("read public key: %v", err)
+ }
+
+ publicKey, _, _, _, err := ssh.ParseAuthorizedKey(file)
+ if err != nil {
+ return fmt.Errorf("parse public key: %v", err)
+ }
+
+ config.HostKeyCallback = ssh.FixedHostKey(publicKey)
+ } else {
+ config.HostKeyCallback = ssh.InsecureIgnoreHostKey()
+ }
+
+ // Read private key from the file and set it as a signer for
+ // public keys.
+ if c.privateKeyPath != "" {
+ file, err := os.ReadFile(c.privateKeyPath)
+ if err != nil {
+ return fmt.Errorf("read private key: %v", err)
+ }
+
+ privateKey, err := ssh.ParsePrivateKey(file)
+ if err != nil {
+ return fmt.Errorf("parse private key: %v", err)
+ }
+
+ config.Auth = append(config.Auth, ssh.PublicKeys(privateKey))
+ }
+
+ // Connect to the host and store reference to the initialized client.
+ client, err := ssh.Dial("tcp", fmt.Sprintf("%s:%s", c.host, c.port), config)
+ if err != nil {
+ return fmt.Errorf("dial %q: %v", c.Endpoint(), err)
+ }
+
+ c.client = client
+ c.initialized = true
+
+ return nil
+}
+
+func (c RemoteClient) isInitialized() bool {
+ c.mux.Lock()
+ defer c.mux.Unlock()
+ return c.initialized
+}
+
+// Run is a shorthand for running the given command locally.
+func Run(command Command) error {
+ return RunCtx(context.Background(), command)
+}
+
+// RunCtx is a shorthand for running the given command locally.
+func RunCtx(ctx context.Context, command Command) error {
+ c := NewLocalClient()
+ c.SetStdout(os.Stdout)
+ c.SetStderr(os.Stderr)
+
+ return c.RunCtx(ctx, command)
+}
diff --git a/pkg/utils/exec/command.go b/pkg/utils/exec/command.go
new file mode 100644
index 00000000..da13955c
--- /dev/null
+++ b/pkg/utils/exec/command.go
@@ -0,0 +1,56 @@
+package exec
+
+import (
+ "strings"
+)
+
+type Command struct {
+ command string
+ args []string
+ envs map[string]string
+ workingDir string
+}
+
+func NewCommand(command string, args ...string) Command {
+ if len(args) == 0 {
+ // Split command by spaces, and treat it as command
+ // with arguments. This prevents spaces in commands
+ // but allows passing commands as a single string.
+ split := strings.Split(command, " ")
+ command = split[0]
+ if len(split) > 1 {
+ args = split[1:]
+ }
+ }
+
+ return Command{
+ command: command,
+ args: args,
+ }
+}
+
+func (c Command) WithWorkingDir(workingDir string) Command {
+ c.workingDir = workingDir
+ return c
+}
+
+func (c Command) WithEnv(key string, value string) Command {
+ if c.envs == nil {
+ c.envs = make(map[string]string)
+ }
+
+ c.envs[key] = value
+ return c
+}
+
+func (c Command) WithEnvMap(envs map[string]string) Command {
+ if c.envs == nil {
+ c.envs = make(map[string]string)
+ }
+
+ for k, v := range envs {
+ c.envs[k] = v
+ }
+
+ return c
+}
diff --git a/pkg/utils/template/templates.go b/pkg/utils/template/templates.go
index 035e90db..d36bfcf7 100644
--- a/pkg/utils/template/templates.go
+++ b/pkg/utils/template/templates.go
@@ -29,7 +29,7 @@ type Template interface {
type TextTemplate interface {
Template
- Template() string
+ Template() (string, error)
}
// Populate populates the template and returns it as a string.
@@ -62,7 +62,12 @@ func populate(t Template, content string) (string, error) {
// Populate populates the template and returns it as a string.
func Populate(t TextTemplate) (string, error) {
- return populate(t, t.Template())
+ tpl, err := t.Template()
+ if err != nil {
+ return "", err
+ }
+
+ return populate(t, tpl)
}
// PopulateFrom reads the template from a given path and returns
diff --git a/pkg/utils/template/templates_test.go b/pkg/utils/template/templates_test.go
index ac3b1ea6..c2ff331b 100644
--- a/pkg/utils/template/templates_test.go
+++ b/pkg/utils/template/templates_test.go
@@ -21,18 +21,28 @@ func MockTemplateFile(t *testing.T, content string) string {
}
type TemplateMock struct{ Value string }
+
+func (t TemplateMock) Name() string { return "test.tpl" }
+func (t TemplateMock) Template() (string, error) { return "Test {{ .Value }}", nil }
+
type InvalidTemplateMock struct{ TemplateMock }
+
+func (t InvalidTemplateMock) Template() (string, error) { return "{{ \\ }}", nil }
+
type InvalidFieldTemplateMock struct{ TemplateMock }
+
+func (t InvalidFieldTemplateMock) Template() (string, error) { return "Test {{ .Invalid }}", nil }
+
type CustomDelimsTemplateMock struct{ TemplateMock }
-type CustomFuncsTemplateMock struct{ TemplateMock }
-func (t TemplateMock) Name() string { return "test.tpl" }
-func (t TemplateMock) Template() string { return "Test {{ .Value }}" }
-func (t InvalidTemplateMock) Template() string { return "{{ \\ }}" }
-func (t InvalidFieldTemplateMock) Template() string { return "Test {{ .Invalid }}" }
-func (t CustomDelimsTemplateMock) Template() string { return "<< if true >>success<< end >>" }
func (t CustomDelimsTemplateMock) Delimiters() (string, string) { return "<<", ">>" }
-func (t CustomFuncsTemplateMock) Template() string { return "{{ alwaysTrue }}" }
+func (t CustomDelimsTemplateMock) Template() (string, error) {
+ return "<< if true >>success<< end >>", nil
+}
+
+type CustomFuncsTemplateMock struct{ TemplateMock }
+
+func (t CustomFuncsTemplateMock) Template() (string, error) { return "{{ alwaysTrue }}", nil }
func (t CustomFuncsTemplateMock) Functions() map[string]any {
return map[string]any{
"alwaysTrue": func() bool { return true },