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 },