Skip to content

Commit

Permalink
Configure NetworkManager using static network interface definitions (#1)
Browse files Browse the repository at this point in the history
* Initial implementation

* Add content verification tests

* Fix destination path

* Add CI workflows (#2)

* Add build & test CI workflow

* Add release CI workflow

* Fix build command

* Add content write permissions to the release workflow

* Add documentation (#3)

* Update README

* Fix examples and styling

* Use lower case formatting for MAC addresses (#4)

* Fix connection files permissions (#5)

* Improve error handling (#6)

* Add missing error formatting

* Collect copying errors instead of terminating the execution

* Improve logging

* Add configuration logs

* Improve log formatting

* Update README
  • Loading branch information
atanasdinov authored Sep 5, 2023
1 parent 0fb074e commit f86471f
Show file tree
Hide file tree
Showing 19 changed files with 854 additions and 1 deletion.
16 changes: 16 additions & 0 deletions .github/workflows/build_and_test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
on: [push, pull_request]
name: Build & Test

jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '1.20'
- name: Build
run: go build -v ./...
- name: Test
run: go test -v ./...
31 changes: 31 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
on:
push:
tags:
- 'v*'

name: Release

permissions:
contents: write

jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '1.20'
- name: Run tests
run: go test -v ./...
- name: Build binaries
run: |
GOOS=linux GOARCH=arm64 go build -o nm-configurator-arm64 main.go
GOOS=linux GOARCH=amd64 go build -o nm-configurator-amd64 main.go
- name: Create a release
uses: softprops/action-gh-release@v1
with:
files: |
nm-configurator-arm64
nm-configurator-amd64
111 changes: 110 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,111 @@
# nm-configurator
NetworkManager configuration tool

A tool capable of identifying & storing the relevant NetworkManager settings
for a given host out of a pool of predefined desired configurations.

Typically used with [Combustion](https://documentation.suse.com/sle-micro/5.4/single-html/SLE-Micro-deployment/#cha-images-combustion)
in order to bootstrap multiple nodes using the same provisioning artefact instead of depending on different custom images per machine.

## What are the prerequisites?

### Desired network configurations per host

`nm-configurator` depends on having the desired network state for all known nodes beforehand.

[NetworkManager](https://documentation.suse.com/sle-micro/5.4/html/SLE-Micro-all/cha-nm-configuration.html)
is using connection profiles defined as files stored under `/etc/NetworkManager/system-connections`.
These config files (*.nmconnection) can be generated using [nmstate](https://nmstate.io/features/gen_conf.html).

Each file contains the desired state for a single network interface (e.g. `eth0`).
Configurations for all interfaces for all known hosts must be generated using `nmstate`.

### Network interface mapping

Network interface mapping is required in order for `nm-configurator`
to identify the proper configurations for each host it is running on.

This additional config must be provided in a YAML format mapping the logical name of the interface to its MAC address:

```yaml
host_config:
- hostname: node1.example.com
interfaces:
- logical_name: eth0
mac_address: 00:10:20:30:40:50
- logical_name: eth1
mac_address: 10:20:30:40:50:60
- hostname: node2.example.com
interfaces:
- logical_name: eth0
mac_address: 00:11:22:33:44:55
```
**NOTE:** Interface names during the installation of nodes might differ from the preconfigured logical ones.
This is expected and `nm-configurator` will rely on the MAC addresses and use the actual names for the
NetworkManager configurations instead e.g. settings for interface with a predefined logical name `eth0` but
actually named `eth0.101` will automatically be adjusted and stored to `/etc/NetworkManager/eth0.101.nmconnection`.

## How to install it?

### Standard method:

Each release is published with `nm-configurator` already built for `amd64` and `arm64` Linux systems:

For AMD64 / x86_64 based systems:
```shell
$ curl -o nm-configurator -L https://github.com/suse-edge/nm-configurator/releases/latest/download/nm-configurator-amd64
$ chmod +x nm-configurator
```

For ARM64 based systems:
```shell
$ curl -o nm-configurator -L https://github.com/suse-edge/nm-configurator/releases/latest/download/nm-configurator-arm64
$ chmod +x nm-configurator
```

### Manual method:

```shell
$ git clone https://github.com/suse-edge/nm-configurator.git
$ cd nm-configurator
$ go build . # optionally specify GOOS and GOARCH flags if cross compiling
```

## How to run it?

Using an example configuration of three known nodes (with hostnames `node1.example.com`, `node2.example.com`
and `node3.example.com` and their respective NetworkManager settings) and interface mapping defined in `host_config.yaml`:

```text
config
├── node1.example.com
│ ├── eth0.nmconnection
│ └── eth1.nmconnection
├── node2.example.com
│ └── eth0.nmconnection
├── node3.example.com
│ ├── bond0.nmconnection
│ └── eth1.nmconnection
└── host_config.yaml
```

```shell
$ ./nm-configurator -config-dir=config -hosts-config-file=host_config.yaml
INFO[2023-08-17T17:32:23+03:00] starting network manager configurator...
INFO[2023-08-17T17:32:23+03:00] successfully identified host: node1.example.com
INFO[2023-08-17T17:32:23+03:00] storing file /etc/NetworkManager/system-connections/eth0.nmconnection...
INFO[2023-08-17T17:32:23+03:00] storing file /etc/NetworkManager/system-connections/eth1.nmconnection...
INFO[2023-08-17T17:32:23+03:00] successfully configured network manager
```

*Note:* The default values for `-config-dir` and `-hosts-config-file` flags are `config` and `host_config.yaml`
respectively so providing them is not necessary with the file structure in the example:

```shell
$ ./nm-configurator
INFO[2023-08-17T17:45:41+03:00] starting network manager configurator...
INFO[2023-08-17T17:45:41+03:00] successfully identified host: node1.example.com
INFO[2023-08-17T17:45:41+03:00] storing file /etc/NetworkManager/system-connections/eth0.nmconnection...
INFO[2023-08-17T17:45:41+03:00] storing file /etc/NetworkManager/system-connections/eth1.nmconnection...
INFO[2023-08-17T17:45:41+03:00] successfully configured network manager
```
16 changes: 16 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
module github.com/suse-edge/nm-configurator

go 1.20

require (
github.com/sirupsen/logrus v1.9.3
github.com/stretchr/testify v1.7.0
gopkg.in/ini.v1 v1.67.0
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c
)

require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 // indirect
)
18 changes: 18 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
63 changes: 63 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package main

import (
"flag"
"os"

log "github.com/sirupsen/logrus"
"github.com/suse-edge/nm-configurator/pkg/config"
"github.com/suse-edge/nm-configurator/pkg/configurator"
)

const systemConnectionsDir = "/etc/NetworkManager/system-connections"

func init() {
log.SetFormatter(&log.TextFormatter{
FullTimestamp: true,
QuoteEmptyFields: true,
})
log.SetOutput(os.Stdout)
}

func main() {
var (
configDir string
hostsConfigFile string
verbose bool
)

flag.StringVar(&configDir, "config-dir", "config", "directory storing host mapping ('host_config.yaml') and *.nmconnection files per host")
flag.StringVar(&hostsConfigFile, "hosts-config-file", "host_config.yaml", "name of the hosts config file mapping interfaces to the respective MAC addresses")
flag.BoolVar(&verbose, "verbose", false, "enables DEBUG log level")
flag.Parse()

if verbose {
log.SetLevel(log.DebugLevel)
}

log.Info("starting network manager configurator...")

if err := os.MkdirAll(systemConnectionsDir, 0755); err != nil {
log.Fatalf("failed to create \"system-connections\" dir: %s", err)
}

conf, err := config.Load(configDir, hostsConfigFile, systemConnectionsDir)
if err != nil {
log.Fatalf("failed to load static host configuration: %s", err)
}

log.Debugf("loaded static configuration: %+v", conf)

networkInterfaces, err := configurator.GetNetworkInterfaces()
if err != nil {
log.Fatalf("failed to list system network interfaces: %s", err)
}

log.Debugf("fetched system network interfaces: %+v", networkInterfaces)

c := configurator.New(conf, networkInterfaces)
if err = c.Run(); err != nil {
log.Fatalf("failed to configure network manager: %s", err)
}
log.Info("successfully configured network manager")
}
69 changes: 69 additions & 0 deletions main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package main

import (
"os"
"path/filepath"
"strings"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/suse-edge/nm-configurator/pkg/config"
"github.com/suse-edge/nm-configurator/pkg/configurator"
)

const (
sourceDir = "testdata"
configFile = "host_config.yaml"
destDir = "testdata/out"
)

func setupDestDir(t *testing.T) func(t *testing.T) {
require.NoError(t, os.MkdirAll(destDir, 0755))

return func(t *testing.T) {
assert.NoError(t, os.RemoveAll(destDir))
}
}

func TestConfigurator(t *testing.T) {
teardown := setupDestDir(t)
defer teardown(t)

conf, err := config.Load(sourceDir, configFile, destDir)
require.Nil(t, err)

networkInterfaces := map[string]string{
"00:11:22:33:44:55": "eth0",
"00:11:22:33:44:56": "eth0.202", // Defined as "eth0.101" in eth0.101.nmconnection
"00:11:22:33:44:57": "eth1",
//"00:11:22:33:44:58": "bond0", Excluded on purpose, "bond0.nmconnection" should still be copied
}

c := configurator.New(conf, networkInterfaces)
require.NoError(t, c.Run())

// Verify the content of the copied files.
hostDir := filepath.Join(sourceDir, "node1.example.com")
entries, err := os.ReadDir(hostDir)
require.Nil(t, err)

assert.Len(t, entries, 4)

for _, entry := range entries {
filename := entry.Name()
input, err := os.ReadFile(filepath.Join(hostDir, filename))
require.Nil(t, err)

// Adjust the name and content for the "eth0.101"->"eth0.202" edge case.
if filename == "eth0.101.nmconnection" {
filename = "eth0.202.nmconnection"
input = []byte(strings.ReplaceAll(string(input), "eth0.101", "eth0.202"))
}

output, err := os.ReadFile(filepath.Join(destDir, filename))
require.Nil(t, err)

assert.Equal(t, string(input), string(output))
}
}
62 changes: 62 additions & 0 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package config

import (
"fmt"
"os"
"path/filepath"
"strings"

"gopkg.in/yaml.v3"
)

type Config struct {
// Configuration directory storing the preconfigured *.nmconnection files per host.
SourceDir string
// Destination directory to store the final *.nmconnection files for NetworkManager.
// Default "/etc/NetworkManager/system-connections".
DestinationDir string
Hosts []*Host `yaml:"host_config"`
}

type Host struct {
Name string `yaml:"hostname"`
Interfaces []*Interface `yaml:"interfaces"`
}

func (h *Host) String() string {
return fmt.Sprintf("{Name: %s Interfaces: %+v}", h.Name, h.Interfaces)
}

type Interface struct {
LogicalName string `yaml:"logical_name"`
MACAddress string `yaml:"mac_address"`
}

func (i *Interface) String() string {
return fmt.Sprintf("{LogicalName: %s MACAddress: %s}", i.LogicalName, i.MACAddress)
}

func Load(sourceDir, configFilename, destinationDir string) (*Config, error) {
configFile := filepath.Join(sourceDir, configFilename)
file, err := os.ReadFile(configFile)
if err != nil {
return nil, err
}

var c Config
if err = yaml.Unmarshal(file, &c); err != nil {
return nil, err
}

// Ensure lower case formatting.
for _, host := range c.Hosts {
for _, i := range host.Interfaces {
i.MACAddress = strings.ToLower(i.MACAddress)
}
}

c.SourceDir = sourceDir
c.DestinationDir = destinationDir

return &c, nil
}
Loading

0 comments on commit f86471f

Please sign in to comment.