From 70dec995b99e79ac018a6d00334219b21eaf5c05 Mon Sep 17 00:00:00 2001 From: Lasith Koswatta Gamage Date: Tue, 31 Oct 2023 12:06:34 +1100 Subject: [PATCH] (feat): Initialisation --- .dockerignore | 6 + .github/workflows/build-and-test.yml | 18 ++ .gitignore | 1 + Dockerfile | 22 ++ README.md | 174 ++++++++++++++ build/docker.sh | 102 ++++++++ cmd/ebs-bootstrap.go | 44 ++++ configs/ebs-bootstrap.yml | 11 + go.mod | 7 + go.sum | 5 + internal/config/config.go | 99 ++++++++ internal/config/config_test.go | 174 ++++++++++++++ internal/config/modifers_test.go | 335 +++++++++++++++++++++++++++ internal/config/modifiers.go | 97 ++++++++ internal/service/device.go | 115 +++++++++ internal/service/device_test.go | 183 +++++++++++++++ internal/service/file.go | 72 ++++++ internal/service/file_test.go | 140 +++++++++++ internal/service/nvme.go | 166 +++++++++++++ internal/service/nvme_test.go | 103 ++++++++ internal/state/device.go | 110 +++++++++ internal/state/device_test.go | 193 +++++++++++++++ internal/state/state.go | 11 + internal/utils/exec.go | 29 +++ internal/utils/testing.go | 37 +++ 25 files changed, 2254 insertions(+) create mode 100644 .dockerignore create mode 100644 .github/workflows/build-and-test.yml create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100755 build/docker.sh create mode 100644 cmd/ebs-bootstrap.go create mode 100644 configs/ebs-bootstrap.yml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go create mode 100644 internal/config/modifers_test.go create mode 100644 internal/config/modifiers.go create mode 100644 internal/service/device.go create mode 100644 internal/service/device_test.go create mode 100644 internal/service/file.go create mode 100644 internal/service/file_test.go create mode 100644 internal/service/nvme.go create mode 100644 internal/service/nvme_test.go create mode 100644 internal/state/device.go create mode 100644 internal/state/device_test.go create mode 100644 internal/state/state.go create mode 100644 internal/utils/exec.go create mode 100644 internal/utils/testing.go diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..6a61f38 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +.github +configs +LICENSE +README.md +build/* +ebs-bootstrap* diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml new file mode 100644 index 0000000..6d4352f --- /dev/null +++ b/.github/workflows/build-and-test.yml @@ -0,0 +1,18 @@ +name: Build and Test 🔨 +on: [push] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + architecture: [amd64, arm64] + name: Build and Test (${{ matrix.architecture }}) 🔨 + steps: + - uses: actions/checkout@v4 + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + with: + platforms: ${{ matrix.architecture }} + - name: Build and Test 🔨 + run: ./build/docker.sh --architecture ${{ matrix.architecture }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ca838bd --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/ebs-bootstrap* diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3eff0e4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +# syntax=docker/dockerfile:1 +FROM golang:1.21 + +# Set destination for COPY +WORKDIR /app + +# Copy go source code +COPY ./ ./ + +# Install dependencies +RUN go mod download + +# Build application +RUN go build cmd/ebs-bootstrap.go + +# Test application +RUN go test ./... + +# ebs-bootstrap cannot run in docker as it needs to interact +# with the raw devices of the host. Therefore docker must be +# exclusively used to build the binary in host architecture agnostic manner +CMD ["tail", "-f", "/dev/null"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..f0ffcd5 --- /dev/null +++ b/README.md @@ -0,0 +1,174 @@ +# EBS Bootstrap + +## Build + +`ebs-bootstrap` can be built locally regardless of the architecture of the host machine. This is facilitated by a multi-architecture Docker build process. The currently supported architechtures are `linux/amd64` and `linux/arm64`. + +```bash +# Specific Architecture +./build/docker.sh --architecture arm64 +ls -la +... ebs-bootstrap_linux-arm64 + +# All Architectures +./build/docker.sh +ls -la +... ebs-bootstrap_linux-arm64 +... ebs-bootstrap_linux-x86_64 +``` +## Recommended Setup + +### `systemd` + +The ideal way of operating `ebs-bootstrap` is through a `systemd` service. This is so we can configure it as a `oneshot` service type that executes after the file system is ready and after `clout-init.service` writes any config files to disk. The latter is essential as `ebs-bootstrap` consumes a config file that is located at `/etc/ebs-boostrap/config.yml` by default. + +`ExecStopPost=-...` con point torwards a script that is executed when the `ebs-bootstrap` service exits on either success or failure. This is a suitable place to include logic to notify a human operator that the configured devices failed their relevant healthchecks and the underlying application failed to launch in the process. + +```ini +[Unit] +Description=EBS Bootstrap +After=local-fs.target cloud-init.service + +[Service] +Type=oneshot +RemainAfterExit=true +StandardInput=null +ExecStart=ebs-bootstrap +PrivateMounts=no +MountFlags=shared +ExecStopPost=-/etc/ebs-bootstrap/post-hook.sh + +[Install] +WantedBy=multi-user.target +``` + +``` +cat /etc/ebs-bootstrap/post-hook.sh +#!/bin/sh +if [ "${EXIT_STATUS}" = "0" ]; then + echo "🟢 Post Stop Hook: Success" +else + echo "🔴 Post Stop Hook: Failure" +fi +``` + +It is then possible to configure another `systemd` service to only start if the `ebs-bootstrap` service is successful. Certain databases support the ability to spread database chunks across multiple devices that need to be mounted to pre-defined directories with the correct ownership and permissions. In this particular use-case, the database could be configured as a `systemd` service that relies on the `ebs-bootstrap.service` to succeed before attempting to start. This can be achieved by specifiying `ebs-boostrap.service` as a dependency in the `Requires=` and `After=` parameters. + +```ini +[Unit] +Description=Example Database +Wants=network-online.target +Requires=ebs-bootstrap.service +After=network.target network-online.target ebs-bootstrap.service + +[Service] +Type=forking +User=ec2-user +Group=ec2-user +ExecStart=/usr/bin/database start +ExecStop=/usr/bin/database stop + +[Install] +WantedBy=multi-user.target +``` + +### `cloud-init` + +`cloud-init` can be configured through EC2 User Data to write a config file to `/etc/ebs-boostrap/config.yml` through the `write_files` module. + +The NVMe Driver, for Nitro-based EC2 Instances, has an [established behaviour](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/nvme-ebs-volumes.html) of dynamically renaming a block device, based on the order in which the device is attached to the EC2 instance. This order is unpredictable, thus it is recommended to label the volumes appropriately so that they can be referenced consistently in the `mounts` module. The `mounts` module is responsible for managing the `/etc/fstab` file, thus establishing a reliable mechanism for mounting external volumes at boot, independent of the block storage driver used by the EC2 instance. + +```yaml +Resources: + Instance: + Type: AWS::EC2::Instance + ... + Volumes: + - Device: /dev/sdb + VolumeId: !Ref ExternalVolumeID + UserData: + Fn::Base64: !Sub + - |+ + #cloud-config + write_files: + - content: | + global: + mode: healthcheck + devices: + /dev/sdb: + fs: ${FileSystem} + mount_point: /mnt/app + owner: ec2-user + group: ec2-user + permissions: 755 + label: external-vol + path: /etc/ebs-bootstrap/config.yml + mounts: + - [ "LABEL=external-vol", "/mnt/app", "${FileSystem}", "${MountOptions}", "0", "2" ] + - FileSystem: ext4 + MountOptions: defaults,nofail,x-systemd.device-timeout=5 +``` + +## Config + +### `global` + +#### `mode` + +Specifies the mode that `ebs-bootstrap` operates in + - `healthcheck` + - Validate whether the state of a device matches its desired configuration + - Returns an exit code of `0` 🟢, if no changes are detected + - Returns an exit code of `1` 🔴, if changes are detected + +### `devices[*]` + +#### `fs` + +The **file system** that the device has been formatted to + - If an empty string is provided, all other device properties will be ignored + +#### `mount_point` + +The **mount point** that the device has been mounted to + - If an empty string is provided, `owner`, `group` and `permissions` will be ignored + +#### `owner` + +The **user** that has been assigned ownership of the mount point + - Supports both a user **ID** and the **name** of the user + +#### `group` + +The **group** that has been assigned ownership of the mount point + - Supports both a group **ID** and the **name** of the group + +#### `permissions` + +The **permissions** that has been assigned to the mount point + - Must be specified as a three digit octal: `755`, `644`, ... + +#### `label` + +The **label** assigned to the formatted device + - Labels are constrained to the limitations of the underlying file system. + - `ext4` file systems have a maximum label size of `16` + - `xfs` file systems have a maximum label size of `12` + +#### `mode` + +Provide a device-level **override** of a global `mode` property + +```yaml +global: + mode: healthcheck +devices: + /dev/xvdf: + fs: "xfs" + mount_point: "/ifmx/dev/root" + owner: 1000 + group: 1000 + permissions: 755 + label: "external-vol" + mode: healthcheck +``` diff --git a/build/docker.sh b/build/docker.sh new file mode 100755 index 0000000..221b8a0 --- /dev/null +++ b/build/docker.sh @@ -0,0 +1,102 @@ +#!/bin/bash +set -euo pipefail + +# https://stackoverflow.com/questions/59895/get-the-source-directory-of-a-bash-script-from-within-the-script-itself +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" +cd "${SCRIPT_DIR}/.." + +IMAGE="ebs-bootstrap" +ARCHS=("amd64" "arm64") + +function map_depedencies() { + # Check if Mac-GNU alternative binaries are installed + getopt_cmd="getopt" + + if [[ "$(uname)" == "Darwin" ]] ; then + getopt_cmd="$(brew --prefix)/opt/gnu-getopt/bin/getopt" + if [[ ! -x "$(type -P "${getopt_cmd}")" ]] ; then + echo >&2 " + ERROR: GNU-enhanced version of getopt not installed + Run \"brew install gnu-getopt\"" + exit 2 + fi + fi +} + +function get_docker_platform() { + arch="${1:-}" + if [ "${arch}" = 'arm64' ]; then + echo "linux/arm64" + elif [ "${arch}" = 'amd64' ]; then + echo "linux/amd64" + else + >&2 echo "🔴 Unsupported architecture: ${arch}"; exit 1 + fi +} + +function get_binary_name() { + arch="${1:-}" + if [ "${arch}" = 'arm64' ]; then + echo "ebs-bootstrap-linux-arm64" + elif [ "${arch}" = 'amd64' ]; then + echo "ebs-bootstrap-linux-x86_64" + else + >&2 echo "🔴 Unsupported architecture: ${arch}"; exit 1 + fi +} + +function docker_build() { + for arch in "${ARCHS[@]}" + do + docker_platform="$(get_docker_platform "${arch}")" + docker build . -t "${IMAGE}:${arch}" --platform "${docker_platform}" --no-cache + echo "🟢 Built image: ${IMAGE}:${arch}" + done +} + +function copy_binaries() { + for arch in "${ARCHS[@]}" + do + name="$(get_binary_name "${arch}")" + id=$(docker create "${IMAGE}:${arch}") + # docker cp produces a tar stream + docker cp "$id:/app/ebs-bootstrap" - | tar xf - --transform "s/ebs-bootstrap/${name}/" + docker rm -v "$id" + echo "🟢 Built and copied binary: ${name}" + done +} + +function main() { + docker_build + copy_binaries +} + +map_depedencies + +ARGUMENT_LIST=( + "architecture" +) + +opts=$("${getopt_cmd}" \ + --longoptions "$(printf "%s:," "${ARGUMENT_LIST[@]}")" \ + --name "$(basename "$0")" \ + --options "" \ + -- "$@" +) + +eval set --"$opts" + +while [[ $# -gt 0 ]]; do + case "$1" in + --architecture) + ARCHS=("${2}") + shift 2 + ;; + + *) + break + ;; + esac +done + +main diff --git a/cmd/ebs-bootstrap.go b/cmd/ebs-bootstrap.go new file mode 100644 index 0000000..21f0d8e --- /dev/null +++ b/cmd/ebs-bootstrap.go @@ -0,0 +1,44 @@ +package main + +import ( + "os" + "log" + "ebs-bootstrap/internal/config" + "ebs-bootstrap/internal/service" + "ebs-bootstrap/internal/utils" + "ebs-bootstrap/internal/state" +) + +func main() { + // Disable Timetamp + log.SetFlags(0) + e := utils.NewExecRunner() + ds := &service.LinuxDeviceService{Runner: e} + ns := &service.AwsNVMeService{} + fs := &service.UnixFileService{} + dts := &service.EbsDeviceTranslator{DeviceService: ds, NVMeService: ns} + + dt, err := dts.GetTranslator() + if err != nil { + log.Fatal(err) + } + config, err := config.New(os.Args, dt, fs) + if err != nil { + log.Fatal(err) + } + + for name, device := range config.Devices { + d, err := state.NewDevice(name, ds, fs) + if err != nil { + log.Fatal(err) + } + err = d.Diff(config) + if err == nil { + log.Printf("🟢 %s: No changes detected", name) + continue + } + if device.Mode == "healthcheck" { + log.Fatal(err) + } + } +} diff --git a/configs/ebs-bootstrap.yml b/configs/ebs-bootstrap.yml new file mode 100644 index 0000000..e6df7ff --- /dev/null +++ b/configs/ebs-bootstrap.yml @@ -0,0 +1,11 @@ +global: + mode: healthcheck +devices: + /dev/xvdf: + fs: "xfs" + mount_point: "/mnt/app" + owner: 1000 + group: 1000 + permissions: 755 + label: "external-vol" + mode: healthcheck diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..88788c8 --- /dev/null +++ b/go.mod @@ -0,0 +1,7 @@ +module ebs-bootstrap + +go 1.21 + +require gopkg.in/yaml.v2 v2.4.0 + +require github.com/google/go-cmp v0.6.0 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..75c0544 --- /dev/null +++ b/go.sum @@ -0,0 +1,5 @@ +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..ed37803 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,99 @@ +package config + +import ( + "os" + "fmt" + "flag" + "bytes" + "gopkg.in/yaml.v2" + "ebs-bootstrap/internal/service" +) + +type ConfigDevice struct { + Fs string `yaml:"fs"` + MountPoint string `yaml:"mount_point"` + Owner string `yaml:"owner"` + Group string `yaml:"group"` + Label string `yaml:"label"` + Permissions string `yaml:"permissions"` + Mode string `yaml:"mode"` +} + +type ConfigGlobal struct { + Mode string `yaml:"mode"` +} + +type Config struct { + Global ConfigGlobal `yaml:"global"` + Devices map[string]ConfigDevice `yaml:"devices"` +} + +func New(args []string, dt *service.DeviceTranslator, fs service.FileService) (*Config, error) { + // Generate path of config + cp, err := parseFlags(args[0], args[1:]) + if err != nil { + fmt.Fprint(os.Stderr, err) + return nil, fmt.Errorf("🔴 Failed to parse provided flags") + } + + // Validate the path first + if err := fs.ValidateFile(cp); err != nil { + return nil, err + } + + // Create config structure + config := &Config{} + + // Load config file into memory as byte[] + file, err := os.ReadFile(cp) + if err != nil { + return nil, err + } + + // Unmarshal YAML file from memory into struct + err = yaml.UnmarshalStrict(file, config) + if err != nil { + fmt.Fprintln(os.Stderr, err) + return nil, fmt.Errorf("🔴 Failed to ingest malformed config") + } + + // Layer modifications to the Config. These modifiers will incrementally + // transform the Config until it reaches a desired state + modifiers := []Modifiers{ + &OwnerModifier{}, + &DeviceModifier{ + DeviceTranslator: dt, + }, + &GroupModifier{}, + &DeviceModeModifier{}, + } + for _, modifier := range modifiers { + err = modifier.Modify(config) + if err != nil { + return nil, err + } + } + return config, nil +} + +func parseFlags(program string, args []string) (string, error) { + flags := flag.NewFlagSet(program, flag.ContinueOnError) + var buf bytes.Buffer + flags.SetOutput(&buf) + + // String that contains the configured configuration path + var cp string + // String that contains the mode of bootstrap process + + // Set up a CLI flag called "-config" to allow users + // to supply the configuration file + flags.StringVar(&cp, "config", "/etc/ebs-bootstrap/config.yml", "path to config file") + + // Actually parse the flag + err := flags.Parse(args) + if err != nil { + return "", fmt.Errorf(buf.String()) + } + // Return the configuration path + return cp, nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..dc03b3a --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,174 @@ +package config + +import ( + "fmt" + "os" + "testing" + "ebs-bootstrap/internal/utils" + "ebs-bootstrap/internal/service" + "github.com/google/go-cmp/cmp" +) + +var dt = &service.DeviceTranslator{ + Table: map[string]string{ + "/dev/xvdf": "/dev/nvme0n1", + "/dev/nvme0n1": "/dev/nvme0n1", + }, +} + +var fs = &service.UnixFileService{} + +func TestConfigParsing(t *testing.T) { + u, g, err := utils.GetCurrentUserGroup() + if err != nil { + t.Error(err) + return + } + subtests := []struct{ + Name string + Data []byte + ExpectedOutput *Config + ExpectedErr error + }{ + { + Name: "Valid Config", + Data: []byte(fmt.Sprintf(`--- +global: + mode: healthcheck +devices: + /dev/xvdf: + fs: "xfs" + mount_point: "/ifmx/dev/root" + owner: "%s" + group: "%s" + permissions: 755 + label: "external-vol"`,u.Name, g.Name)), + ExpectedOutput: &Config{ + Global: ConfigGlobal{ + Mode: "healthcheck", + }, + Devices: map[string]ConfigDevice{ + "/dev/nvme0n1": ConfigDevice{ + Fs: "xfs", + MountPoint: "/ifmx/dev/root", + Owner: u.Uid, + Group: g.Gid, + Permissions: "755", + Label: "external-vol", + Mode: "healthcheck", + }, + }, + }, + ExpectedErr: nil, + }, + { + Name: "Malformed Config", + Data: []byte(`--- +global: + mode: healthcheck +devices:: + /dev/xvdf: + bad_attribute: false`), + ExpectedOutput: nil, + ExpectedErr: fmt.Errorf("🔴 Failed to ingest malformed config"), + }, + } + for _, subtest := range subtests { + t.Run(subtest.Name, func(t *testing.T) { + configPath, err := createConfigFile(subtest.Data) + if err != nil { + t.Errorf("createConfigFile() [error] %s", err) + } + defer os.Remove(configPath) + + c, err := New( + []string{"ebs-bootstrap-test", "-config", configPath}, + dt, + fs, + ) + if !cmp.Equal(c, subtest.ExpectedOutput) { + t.Errorf("Modify() [output] mismatch: Expected=%+v Actual=%+v", subtest.ExpectedOutput, c) + } + utils.CheckError("config.New()", t, subtest.ExpectedErr, err) + }) + } +} + +func TestFlagParsing(t *testing.T) { + u, g, err := utils.GetCurrentUserGroup() + if err != nil { + t.Error(err) + return + } + // Create a variable to the path of a valid config + c, err := createConfigFile([]byte(fmt.Sprintf(`--- +global: + mode: healthcheck +devices: + /dev/xvdf: + fs: "xfs" + mount_point: "/ifmx/dev/root" + owner: "%s" + group: "%s" + permissions: 755 + label: "external-vol"`,u.Uid, g.Gid))) + if err != nil { + t.Errorf("createConfigFile() [error] %s", err) + return + } + + // Create a variable to the current working directory + d, err := os.Getwd() + if err != nil { + t.Errorf("os.Getwd() [error] %s", err) + } + + subtests := []struct{ + Name string + Args []string + ExpectedErr error + }{ + { + Name: "Valid Config", + Args: []string{"ebs-bootstrap-test","-config",c}, + ExpectedErr: nil, + }, + { + Name: "Invalid Config (Directory)", + Args: []string{"ebs-bootstrap-test","-config",d}, + ExpectedErr: fmt.Errorf("🔴 %s is not a regular file", d), + }, + { + Name: "Invalid Config (Non-existent File)", + Args: []string{"ebs-bootstrap-test","-config","/doesnt-exist"}, + ExpectedErr: fmt.Errorf("🔴 /doesnt-exist does not exist"), + }, + { + Name: "Unsupported Flag", + Args: []string{"ebs-bootstrap-test","-unsupported-flag"}, + ExpectedErr: fmt.Errorf("🔴 Failed to parse provided flags"), + }, + } + for _, subtest := range subtests { + t.Run(subtest.Name, func(t *testing.T) { + _, err := New( + subtest.Args, + dt, + fs, + ) + utils.CheckError("config.New()", t, subtest.ExpectedErr, err) + }) + } +} + +func createConfigFile(data []byte) (string, error) { + f, err := os.CreateTemp("", "config_test_*.yml") + if err != nil { + return "", fmt.Errorf("🔴 Failed to create temporary config file: %v", err) + } + defer f.Close() + if _, err := f.Write(data); err != nil { + return "", fmt.Errorf("🔴 Failed to write to temporary config file: %v", err) + } + return f.Name(), nil +} diff --git a/internal/config/modifers_test.go b/internal/config/modifers_test.go new file mode 100644 index 0000000..c73abb7 --- /dev/null +++ b/internal/config/modifers_test.go @@ -0,0 +1,335 @@ +package config + +import ( + "testing" + "fmt" + "ebs-bootstrap/internal/utils" + "ebs-bootstrap/internal/service" + "github.com/google/go-cmp/cmp" +) + +func TestOwnerModifier(t *testing.T) { + subtests := []struct{ + Name string + Config *Config + ExpectedOutput *Config + ExpectedErr error + }{ + { + Name: "Existing Owner (Non-Int)", + Config: &Config{ + Devices: map[string]ConfigDevice{ + "/dev/xvda": ConfigDevice{ + Owner: "root", + }, + }, + }, + ExpectedOutput: &Config{ + Devices: map[string]ConfigDevice{ + "/dev/xvda": ConfigDevice{ + Owner: "0", + }, + }, + }, + ExpectedErr: nil, + }, + { + Name: "Existing Owner (Int)", + Config: &Config{ + Devices: map[string]ConfigDevice{ + "/dev/xvda": ConfigDevice{ + Owner: "0", + }, + }, + }, + ExpectedOutput: &Config{ + Devices: map[string]ConfigDevice{ + "/dev/xvda": ConfigDevice{ + Owner: "0", + }, + }, + }, + ExpectedErr: nil, + }, + { + Name: "Non-existent Owner (Non-Int)", + Config: &Config{ + Devices: map[string]ConfigDevice{ + "/dev/xvda": ConfigDevice{ + Owner: "non-existent-user", + }, + }, + }, + ExpectedOutput: nil, + ExpectedErr: fmt.Errorf("user: unknown user non-existent-user"), + }, + { + Name: "Non-existent Owner (Int)", + Config: &Config{ + Devices: map[string]ConfigDevice{ + "/dev/xvda": ConfigDevice{ + Owner: "-1", + }, + }, + }, + ExpectedOutput: nil, + ExpectedErr: fmt.Errorf("user: unknown userid -1"), + }, + } + for _, subtest := range subtests { + t.Run(subtest.Name, func(t *testing.T) { + om := &OwnerModifier{} + err := om.Modify(subtest.Config) + if subtest.ExpectedOutput != nil { + if !cmp.Equal(subtest.Config, subtest.ExpectedOutput) { + t.Errorf("Modify() [output] mismatch: Expected=%+v Actual=%+v", subtest.ExpectedOutput, subtest.Config) + } + } + utils.CheckError("Modify()", t, subtest.ExpectedErr, err) + }) + } +} + +func TestGroupModifier(t *testing.T) { + _, g, err := utils.GetCurrentUserGroup() + if err != nil { + t.Error(err) + return + } + subtests := []struct{ + Name string + Config *Config + ExpectedOutput *Config + ExpectedErr error + }{ + { + Name: "Existing Group (Non-Int)", + Config: &Config{ + Devices: map[string]ConfigDevice{ + "/dev/xvda": ConfigDevice{ + Group: g.Name, + }, + }, + }, + ExpectedOutput: &Config{ + Devices: map[string]ConfigDevice{ + "/dev/xvda": ConfigDevice{ + Group: g.Gid, + }, + }, + }, + ExpectedErr: nil, + }, + { + Name: "Existing Group (Int)", + Config: &Config{ + Devices: map[string]ConfigDevice{ + "/dev/xvda": ConfigDevice{ + Group: g.Gid, + }, + }, + }, + ExpectedOutput: &Config{ + Devices: map[string]ConfigDevice{ + "/dev/xvda": ConfigDevice{ + Group: g.Gid, + }, + }, + }, + ExpectedErr: nil, + }, + { + Name: "Non-existent Group (Non-Int)", + Config: &Config{ + Devices: map[string]ConfigDevice{ + "/dev/xvda": ConfigDevice{ + Group: "non-existent-group", + }, + }, + }, + ExpectedOutput: nil, + ExpectedErr: fmt.Errorf("group: unknown group non-existent-group"), + }, + { + Name: "Non-existent Group (Int)", + Config: &Config{ + Devices: map[string]ConfigDevice{ + "/dev/xvda": ConfigDevice{ + Group: "-1", + }, + }, + }, + ExpectedOutput: nil, + ExpectedErr: fmt.Errorf("group: unknown groupid -1"), + }, + } + for _, subtest := range subtests { + t.Run(subtest.Name, func(t *testing.T) { + gm := &GroupModifier{} + err := gm.Modify(subtest.Config) + if subtest.ExpectedOutput != nil { + if !cmp.Equal(subtest.Config, subtest.ExpectedOutput) { + t.Errorf("Modify() [output] mismatch: Expected=%+v Actual=%+v", subtest.ExpectedOutput, subtest.Config) + } + } + utils.CheckError("Modify()", t, subtest.ExpectedErr, err) + }) + } +} + +func TestDeviceModifier(t *testing.T) { + subtests := []struct{ + Name string + DeviceTranslator *service.DeviceTranslator + Config *Config + ExpectedOutput *Config + ExpectedErr error + }{ + { + Name: "DeviceTranslator() Hit", + DeviceTranslator: &service.DeviceTranslator{ + Table: map[string]string{ + "/dev/nvme0n1": "/dev/nvme0n1", + "/dev/xvdf": "/dev/nvme0n1", + }, + }, + Config: &Config{ + Devices: map[string]ConfigDevice{ + "/dev/xvdf": ConfigDevice{}, + }, + }, + ExpectedOutput: &Config{ + Devices: map[string]ConfigDevice{ + "/dev/nvme0n1": ConfigDevice{}, + }, + }, + ExpectedErr: nil, + }, + { + Name: "DeviceTranslator() Miss", + DeviceTranslator: &service.DeviceTranslator{ + Table: map[string]string{}, + }, + Config: &Config{ + Devices: map[string]ConfigDevice{ + "/dev/xvdf": ConfigDevice{}, + }, + }, + ExpectedOutput: nil, + ExpectedErr: fmt.Errorf("🔴 Could not identify a device with an alias /dev/xvdf"), + }, + } + for _, subtest := range subtests { + t.Run(subtest.Name, func(t *testing.T) { + dm := &DeviceModifier{DeviceTranslator: subtest.DeviceTranslator} + err := dm.Modify(subtest.Config) + if subtest.ExpectedOutput != nil { + if !cmp.Equal(subtest.Config, subtest.ExpectedOutput) { + t.Errorf("Modify() [output] mismatch: Expected=%+v Actual=%+v", subtest.ExpectedOutput, subtest.Config) + } + } + utils.CheckError("Modify()", t, subtest.ExpectedErr, err) + }) + } +} + +func TestDeviceModeModifier(t *testing.T) { + subtests := []struct{ + Name string + Config *Config + ExpectedOutput *Config + ExpectedErr error + }{ + { + Name: "Valid Global Mode, Empty Local Mode", + Config: &Config{ + Global: ConfigGlobal{ + Mode: ValidDeviceModes[0], + }, + Devices: map[string]ConfigDevice{ + "/dev/xvda": ConfigDevice{}, + }, + }, + ExpectedOutput: &Config{ + Global: ConfigGlobal{ + Mode: ValidDeviceModes[0], + }, + Devices: map[string]ConfigDevice{ + "/dev/xvda": ConfigDevice{ + Mode: ValidDeviceModes[0], + }, + }, + }, + ExpectedErr: nil, + }, + { + Name: "Empty Global Mode, Valid Local Mode", + Config: &Config{ + Global: ConfigGlobal{}, + Devices: map[string]ConfigDevice{ + "/dev/xvda": ConfigDevice{ + Mode: ValidDeviceModes[0], + }, + }, + }, + ExpectedOutput: &Config{ + Global: ConfigGlobal{}, + Devices: map[string]ConfigDevice{ + "/dev/xvda": ConfigDevice{ + Mode: ValidDeviceModes[0], + }, + }, + }, + ExpectedErr: nil, + }, + { + Name: "Empty Global Mode, Empty Local Mode", + Config: &Config{ + Global: ConfigGlobal{}, + Devices: map[string]ConfigDevice{ + "/dev/xvda": ConfigDevice{}, + }, + }, + ExpectedOutput: nil, + ExpectedErr: fmt.Errorf("🔴 /dev/xvda: If mode is not provided locally, it must be provided globally"), + }, + { + Name: "Invalid Global Mode, Empty Local Mode", + Config: &Config{ + Global: ConfigGlobal{ + Mode: "non-supported-mode", + }, + Devices: map[string]ConfigDevice{ + "/dev/xvda": ConfigDevice{}, + }, + }, + ExpectedOutput: nil, + ExpectedErr: fmt.Errorf("🔴 A valid global mode was not provided: Expected=%s Provided=non-supported-mode", ValidDeviceModes), + }, + { + Name: "Empty Global Mode, Invalid Local Mode", + Config: &Config{ + Global: ConfigGlobal{}, + Devices: map[string]ConfigDevice{ + "/dev/xvda": ConfigDevice{ + Mode: "non-supported-mode", + }, + }, + }, + ExpectedOutput: nil, + ExpectedErr: fmt.Errorf("🔴 /dev/xvda: A valid mode was not provided: Expected=%s Provided=non-supported-mode", ValidDeviceModes), + }, + } + for _, subtest := range subtests { + t.Run(subtest.Name, func(t *testing.T) { + gm := &DeviceModeModifier{} + err := gm.Modify(subtest.Config) + if subtest.ExpectedOutput != nil { + if !cmp.Equal(subtest.Config, subtest.ExpectedOutput) { + t.Errorf("Modify() [output] mismatch: Expected=%+v Actual=%+v", subtest.ExpectedOutput, subtest.Config) + } + } + utils.CheckError("Modify()", t, subtest.ExpectedErr, err) + }) + } +} diff --git a/internal/config/modifiers.go b/internal/config/modifiers.go new file mode 100644 index 0000000..bde2e96 --- /dev/null +++ b/internal/config/modifiers.go @@ -0,0 +1,97 @@ +package config + +import ( + "fmt" + "os/user" + "strconv" + "slices" + "ebs-bootstrap/internal/service" +) + +var ( + ValidDeviceModes = []string{"healthcheck"} +) + +type Modifiers interface { + Modify(config *Config) (error) +} + +type OwnerModifier struct {} + +func (om *OwnerModifier) Modify(config *Config) (error) { + for key, device := range config.Devices { + var u *user.User; + var err error; + if _, atoiErr := strconv.Atoi(device.Owner); atoiErr != nil { + u, err = user.Lookup(device.Owner) + } else { + u, err = user.LookupId(device.Owner) + } + if err != nil { + return err + } + device.Owner = u.Uid + config.Devices[key] = device + } + return nil +} + +type DeviceModifier struct { + DeviceTranslator *service.DeviceTranslator +} + +func (dm *DeviceModifier) Modify(config *Config) (error) { + for key, device := range config.Devices { + alias, found := dm.DeviceTranslator.Table[key] + if !found { + return fmt.Errorf("🔴 Could not identify a device with an alias %s", key) + } + delete(config.Devices, key) + config.Devices[alias] = device + } + return nil +} + +type GroupModifier struct {} + +func (gm *GroupModifier) Modify(config *Config) (error) { + for key, device := range config.Devices { + var g *user.Group; + var err error; + if _, atoiErr := strconv.Atoi(device.Group); atoiErr != nil { + g, err = user.LookupGroup(device.Group) + } else { + g, err = user.LookupGroupId(device.Group) + } + if err != nil { + return err + } + device.Group = g.Gid + config.Devices[key] = device + } + return nil +} + +type DeviceModeModifier struct {} + +func (dm *DeviceModeModifier) Modify(config *Config) (error) { + if config.Global.Mode != "" && !slices.Contains(ValidDeviceModes, config.Global.Mode) { + return fmt.Errorf("🔴 A valid global mode was not provided: Expected=%s Provided=%s", ValidDeviceModes, config.Global.Mode) + } + + for key, device := range config.Devices { + if device.Mode == "" && config.Global.Mode == "" { + return fmt.Errorf("🔴 %s: If mode is not provided locally, it must be provided globally", key) + } + + if device.Mode != "" && !slices.Contains(ValidDeviceModes, device.Mode) { + return fmt.Errorf("🔴 %s: A valid mode was not provided: Expected=%s Provided=%s", key, ValidDeviceModes, device.Mode) + } + + if device.Mode == "" { + device.Mode = config.Global.Mode + } + config.Devices[key] = device + } + return nil +} diff --git a/internal/service/device.go b/internal/service/device.go new file mode 100644 index 0000000..9908772 --- /dev/null +++ b/internal/service/device.go @@ -0,0 +1,115 @@ +package service + +import ( + "encoding/json" + "fmt" + "strings" + "ebs-bootstrap/internal/utils" +) + +// Device Service Interface [START] + +type DeviceInfo struct { + Name string + Label string + Fs string + MountPoint string +} + +type DeviceService interface { + GetBlockDevices() ([]string, error) + GetDeviceInfo(device string) (*DeviceInfo, error) +} + +// Device Service Interface [END] + +type LinuxDeviceService struct { + Runner utils.Runner +} + +type LsblkBlockDeviceResponse struct { + BlockDevices []LsblkBlockDevice `json:"blockdevices"` +} + +type LsblkBlockDevice struct { + Name string `json:"name"` + Label string `json:"label"` + FsType string `json:"fstype"` + MountPoint string `json:"mountpoint"` +} + +func (du *LinuxDeviceService) GetBlockDevices() ([]string, error) { + output, err := du.Runner.Command("lsblk", "--nodeps", "-o", "NAME,LABEL,FSTYPE,MOUNTPOINT", "-J") + if err != nil { + return nil, err + } + lbd := &LsblkBlockDeviceResponse{} + err = json.Unmarshal([]byte(output), lbd) + if err != nil { + return nil, err + } + d := make([]string,len(lbd.BlockDevices)) + for i, _ := range d { + d[i] = "/dev/" + lbd.BlockDevices[i].Name + } + return d, nil +} + +func (du *LinuxDeviceService) GetDeviceInfo(device string) (*DeviceInfo, error) { + output, err := du.Runner.Command("lsblk", "--nodeps", "-o", "NAME,LABEL,FSTYPE,MOUNTPOINT", "-J", device) + if err != nil { + return nil, err + } + bd := &LsblkBlockDeviceResponse{} + err = json.Unmarshal([]byte(output), bd) + if err != nil { + return nil, err + } + if len(bd.BlockDevices) != 1 { + return nil, fmt.Errorf("🔴 [%s] An unexpected number of block devices were returned: Expected=1 Actual=%d", device, len(bd.BlockDevices)) + } + return &DeviceInfo{ + Name: "/dev/" + bd.BlockDevices[0].Name, + Label: bd.BlockDevices[0].Label, + Fs: bd.BlockDevices[0].FsType, + MountPoint: bd.BlockDevices[0].MountPoint, + }, nil +} + +// Device Translator Service Interface [START] + +type DeviceTranslator struct { + Table map[string]string +} + +type DeviceTranslatorService interface { + GetTranslator() *DeviceTranslator +} + +type EbsDeviceTranslator struct { + DeviceService DeviceService + NVMeService NVMeService +} + +// Device Translator Service Interface [END] + +func (edt *EbsDeviceTranslator) GetTranslator() (*DeviceTranslator, error) { + dt := &DeviceTranslator{} + dt.Table = make(map[string]string) + devices, err := edt.DeviceService.GetBlockDevices() + if err != nil { + return nil, err + } + for _, device := range(devices) { + alias := device + if strings.HasPrefix(device, "/dev/nvme") { + alias, err = edt.NVMeService.GetBlockDeviceMapping(device) + if err != nil { + return nil, err + } + } + dt.Table[alias] = device + dt.Table[device] = alias + } + return dt, nil +} diff --git a/internal/service/device_test.go b/internal/service/device_test.go new file mode 100644 index 0000000..e84cebe --- /dev/null +++ b/internal/service/device_test.go @@ -0,0 +1,183 @@ +package service + +import ( + "testing" + "fmt" + "ebs-bootstrap/internal/utils" + "github.com/google/go-cmp/cmp" +) + +type deviceMockRunner struct { + Output string + Error error +} + +func (mr *deviceMockRunner) Command(name string, arg ...string) (string, error) { + return mr.Output, mr.Error +} + +func TestGetBlockDevices(t *testing.T) { + mr := &deviceMockRunner{ + Output: `{"blockdevices": [ + {"name":"nvme1n1", "label":"external-vol", "fstype":"xfs", "mountpoint":"/ifmx/dev/root"}, + {"name":"nvme0n1", "label":null, "fstype":null, "mountpoint":null} + ]}`, + Error: nil, + } + expectedOutput := []string{"/dev/nvme1n1", "/dev/nvme0n1"} + + t.Run("Get Block Devices", func(t *testing.T) { + du := &LinuxDeviceService{mr} + d, err := du.GetBlockDevices() + if !cmp.Equal(d, expectedOutput) { + t.Errorf("GetBlockDevices() [output] mismatch: Expected=%v Actual=%v", expectedOutput, d) + } + utils.CheckError("GetBlockDevices()", t, nil, err) + }) +} + +func TestGetDeviceInfo(t *testing.T) { + deviceNotFoundErr := fmt.Errorf("🔴 /dev/nvme0n1 not a block device") + + subtests := []struct { + Name string + Device string + MockRunner *deviceMockRunner + ExpectedOutput *DeviceInfo + ExpectedErr error + }{ + { + Name: "Get Device Info for /dev/nvme0n1", + Device: "/dev/nvme0n1", + MockRunner: &deviceMockRunner{ + Output: `{"blockdevices":[{"name":"nvme0n1","label":"external-vol","fstype":"xfs","mountpoint":"/mnt/app"}]}`, + Error: nil, + }, + ExpectedOutput: &DeviceInfo{ + Name: "/dev/nvme0n1", + Label: "external-vol", + Fs: "xfs", + MountPoint: "/mnt/app", + }, + ExpectedErr: nil, + }, + { + Name: "Get Device Info for /dev/nvme0n1 (No Fs,Label,Mountpoint)", + Device: "/dev/nvme0n1", + MockRunner: &deviceMockRunner{ + Output: `{"blockdevices":[{"name":"nvme0n1","label":null,"fstype":null,"mountpoint":null}]}`, + Error: nil, + }, + ExpectedOutput: &DeviceInfo{ + Name: "/dev/nvme0n1", + Label: "", + Fs: "", + MountPoint: "", + }, + ExpectedErr: nil, + }, + { + Name: "Get Device Info for Missing Device", + Device: "/dev/nvme0n1", + MockRunner: &deviceMockRunner{ + Output: "", + Error: deviceNotFoundErr, + }, + ExpectedOutput: nil, + ExpectedErr: deviceNotFoundErr, + }, + } + for _, subtest := range subtests { + t.Run(subtest.Name, func(t *testing.T) { + du := &LinuxDeviceService{subtest.MockRunner} + di, err := du.GetDeviceInfo(subtest.Device) + if !cmp.Equal(di, subtest.ExpectedOutput) { + t.Errorf("GetDeviceInfo() [output] mismatch: Expected=%+v Actual=%+v", subtest.ExpectedOutput, di) + } + utils.CheckError("GetDeviceInfo()", t, subtest.ExpectedErr, err) + }) + } +} + +type mockDeviceService struct { + getBlockDevices func() ([]string, error) +} + +func (ds *mockDeviceService) GetBlockDevices() ([]string, error) { + return ds.getBlockDevices() +} + +func (ds *mockDeviceService) GetDeviceInfo(device string) (*DeviceInfo, error) { + return nil, fmt.Errorf("🔴 GetDeviceInfo() not implemented") +} + +type mockNVMeService struct { + getBlockDeviceMapping func(device string) (string, error) +} + +func (ns *mockNVMeService) GetBlockDeviceMapping(device string) (string, error) { + return ns.getBlockDeviceMapping(device) +} + +func TestEbsDeviceTranslator(t *testing.T) { + subtests := []struct{ + Name string + DeviceService DeviceService + NVMeService NVMeService + ExpectedOutput *DeviceTranslator + ExpectedErr error + }{ + { + Name: "Get DeviceTranslator for EBS NVME Device", + DeviceService: &mockDeviceService{ + getBlockDevices: func() ([]string, error) { + return []string{"/dev/nvme0n1"}, nil + }, + }, + NVMeService: &mockNVMeService { + getBlockDeviceMapping: func(device string) (string, error) { + return "/dev/xvdf", nil + }, + }, + ExpectedOutput: &DeviceTranslator{ + Table: map[string]string{ + "/dev/nvme0n1" : "/dev/xvdf", + "/dev/xvdf": "/dev/nvme0n1", + }, + }, + ExpectedErr: nil, + }, + { + Name: "Get DeviceTranslator for Traditional EBS Device", + DeviceService: &mockDeviceService{ + getBlockDevices: func() ([]string, error) { + return []string{"/dev/xvdf"}, nil + }, + }, + NVMeService: &mockNVMeService{ + getBlockDeviceMapping: func(device string) (string, error) { + return "", fmt.Errorf("🔴 getBlockDeviceMapping() should not be called") + }, + }, + ExpectedOutput: &DeviceTranslator{ + Table: map[string]string{ + "/dev/xvdf": "/dev/xvdf", + }, + }, + ExpectedErr: nil, + }, + } + for _, subtest := range subtests { + t.Run(subtest.Name, func(t *testing.T) { + dts := &EbsDeviceTranslator{ + DeviceService: subtest.DeviceService, + NVMeService: subtest.NVMeService, + } + dt, err := dts.GetTranslator() + if !cmp.Equal(dt, subtest.ExpectedOutput) { + t.Errorf("GetTranslator() [output] mismatch: Expected=%+v Actual=%+v", subtest.ExpectedOutput, dt) + } + utils.CheckError("GetTranslator()", t, subtest.ExpectedErr, err) + }) + } +} diff --git a/internal/service/file.go b/internal/service/file.go new file mode 100644 index 0000000..b5f68df --- /dev/null +++ b/internal/service/file.go @@ -0,0 +1,72 @@ +package service + +import ( + "os" + "syscall" + "fmt" +) + +// File Service Interface [START] + +type FileInfo struct { + Owner string + Group string + Permissions string + Exists bool +} + +type FileService interface { + GetStats(file string) (*FileInfo, error) + ValidateFile(path string) (error) +} + +// File Service Interface [END] + +type UnixFileService struct {} + +func (ds *UnixFileService) GetStats(file string) (*FileInfo, error) { + info, err := os.Stat(file) + if err != nil { + if os.IsNotExist(err) { + return &FileInfo{Exists: false}, nil + } + return nil, err + } + if stat, ok := info.Sys().(*syscall.Stat_t); ok { + return &FileInfo{ + Owner: fmt.Sprintf("%d", stat.Uid), + Group: fmt.Sprintf("%d", stat.Gid), + Permissions: fmt.Sprintf("%o", info.Mode().Perm()), + Exists: true, + }, nil + } + return nil, fmt.Errorf("🔴 %s: Failed to get stats", file) +} + +func (ds *UnixFileService) ValidateFile(path string) (error) { + s, err := os.Stat(path) + if err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("🔴 %s does not exist", path) + } + return err + } + if !s.Mode().IsRegular() { + return fmt.Errorf("🔴 %s is not a regular file", path) + } + return nil +} + +func (ds *UnixFileService) ValidateDirectory(path string) (error) { + s, err := os.Stat(path) + if err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("🔴 %s does not exist", path) + } + return err + } + if !s.Mode().IsDir() { + return fmt.Errorf("🔴 %s is not a directory", path) + } + return nil +} diff --git a/internal/service/file_test.go b/internal/service/file_test.go new file mode 100644 index 0000000..a80d90d --- /dev/null +++ b/internal/service/file_test.go @@ -0,0 +1,140 @@ +package service + +import ( + "fmt" + "os" + "testing" + "ebs-bootstrap/internal/utils" + "github.com/google/go-cmp/cmp" +) + +func TestGetStats(t *testing.T) { + fs := &UnixFileService{} + t.Run("Get File Stats (Existing File)", func(t *testing.T) { + owner, group, permissions := os.Getuid(), os.Getgid(), os.FileMode(0644) + f, err := os.CreateTemp("", "sample") + utils.CheckError("CreateTemp()", t, nil, err) + defer os.Remove(f.Name()) + + err = os.Chown(f.Name(), owner, group) + utils.CheckError("Chown()", t, nil, err) + + err = os.Chmod(f.Name(), permissions) + utils.CheckError("Chmod()", t, nil, err) + + actual, err := fs.GetStats(f.Name()) + utils.CheckError("GetStats()", t, nil, err) + + expected := &FileInfo{ + Owner: fmt.Sprintf("%d", owner), + Group: fmt.Sprintf("%d", group), + Permissions: fmt.Sprintf("%o", permissions), + Exists: true, + } + if !cmp.Equal(actual, expected) { + t.Errorf("GetStats() [output] mismatch: Expected=%+v Actual=%+v", expected, actual) + } + }) + t.Run("Get File Stats (Non-Existent File)", func(t *testing.T) { + expected := &FileInfo{Exists: false} + actual, err := fs.GetStats("/non-existent-file/file.txt") + if !cmp.Equal(actual, expected) { + t.Errorf("GetStats() [output] mismatch: Expected=%+v Actual=%+v", expected, actual) + } + utils.CheckError("GetStats()", t, nil, err) + }) +} + +func TestValidateFile(t *testing.T) { + fs := &UnixFileService{} + + // Create a variable to the current working directory + d, err := os.Getwd() + if err != nil { + t.Errorf("os.Getwd() [error] %s", err) + return + } + + // Create a temporary file + f, err := os.CreateTemp("", "validate-file") + if err != nil { + t.Errorf("os.CreateTemp() [error] %s", err) + return + } + defer os.Remove(f.Name()) + + subtests := []struct{ + Name string + Path string + ExpectedErr error + }{ + { + Name: "Valid (Existing File)", + Path: f.Name(), + ExpectedErr: nil, + }, + { + Name: "Invalid (Existing Directory)", + Path: d, + ExpectedErr: fmt.Errorf("🔴 %s is not a regular file", d), + }, + { + Name: "Invalid: (Non-existing File)", + Path: "/doesnt-exist", + ExpectedErr: fmt.Errorf("🔴 /doesnt-exist does not exist"), + }, + } + for _, subtest := range subtests { + t.Run(subtest.Name, func(t *testing.T) { + err := fs.ValidateFile(subtest.Path) + utils.CheckError("ValidateFile()", t, subtest.ExpectedErr, err) + }) + } +} + +func TestValidateDirectory(t *testing.T) { + fs := &UnixFileService{} + + // Create a variable to the current working directory + d, err := os.Getwd() + if err != nil { + t.Errorf("os.Getwd() [error] %s", err) + return + } + + // Create a temporary file + f, err := os.CreateTemp("", "validate-directory") + if err != nil { + t.Errorf("os.CreateTemp() [error] %s", err) + return + } + defer os.Remove(f.Name()) + + subtests := []struct{ + Name string + Path string + ExpectedErr error + }{ + { + Name: "Valid (Existing Directory)", + Path: d, + ExpectedErr: nil, + }, + { + Name: "Invalid (Existing File)", + Path: f.Name(), + ExpectedErr: fmt.Errorf("🔴 %s is not a directory", f.Name()), + }, + { + Name: "Invalid (Non-existing Directory)", + Path: "/doesnt-exist", + ExpectedErr: fmt.Errorf("🔴 /doesnt-exist does not exist"), + }, + } + for _, subtest := range subtests { + t.Run(subtest.Name, func(t *testing.T) { + err := fs.ValidateDirectory(subtest.Path) + utils.CheckError("ValidateDirectory()", t, subtest.ExpectedErr, err) + }) + } +} diff --git a/internal/service/nvme.go b/internal/service/nvme.go new file mode 100644 index 0000000..10cd361 --- /dev/null +++ b/internal/service/nvme.go @@ -0,0 +1,166 @@ +package service + +import ( + "fmt" + "os" + "syscall" + "unsafe" + "strings" + "unicode" +) + +const ( + NVME_ADMIN_IDENTIFY = 0x06 + NVME_IOCTL_ADMIN_CMD = 0xC0484E41 + AMZN_NVME_VID = 0x1D0F + AMZN_NVME_EBS_MN = "Amazon Elastic Block Store" +) + +type nvmeAdminCommand struct { + Opcode uint8 + Flags uint8 + Cid uint16 + Nsid uint32 + Reserved0 uint64 + Mptr uint64 + Addr uint64 + Mlen uint32 + Alen uint32 + Cdw10 uint32 + Cdw11 uint32 + Cdw12 uint32 + Cdw13 uint32 + Cdw14 uint32 + Cdw15 uint32 + Reserved1 uint64 +} + +type nvmeIdentifyControllerAmznVS struct { + Bdev [32]byte + Reserved0 [1024 - 32]byte +} + +type nvmeIdentifyControllerPSD struct { + Mp uint16 + Reserved0 uint16 + Enlat uint32 + Exlat uint32 + Rrt uint8 + Rrl uint8 + Rwt uint8 + Rwl uint8 + Reserved1 [16]byte +} + +type nvmeIdentifyController struct { + Vid uint16 + Ssvid uint16 + Sn [20]byte + Mn [40]byte + Fr [8]byte + Rab uint8 + Ieee [3]uint8 + Mic uint8 + Mdts uint8 + Reserved0 [256 - 78]byte + Oacs uint16 + Acl uint8 + Aerl uint8 + Frmw uint8 + Lpa uint8 + Elpe uint8 + Npss uint8 + Avscc uint8 + Reserved1 [512 - 265]byte + Sqes uint8 + Cqes uint8 + Reserved2 uint16 + Nn uint32 + Oncs uint16 + Fuses uint16 + Fna uint8 + Vwc uint8 + Awun uint16 + Awupf uint16 + Nvscc uint8 + Reserved3 [704 - 531]byte + Reserved4 [2048 - 704]byte + Psd [32]nvmeIdentifyControllerPSD + Vs nvmeIdentifyControllerAmznVS +} + +type NVMeDevice struct { + Name string + IdCtrl nvmeIdentifyController +} + +func NewNVMeDevice(name string) (*NVMeDevice, error) { + d := &NVMeDevice{Name: name} + if err := d.nvmeIOctl(); err != nil { + return nil, err + } + return d, nil +} + +func (d *NVMeDevice) nvmeIOctl() error { + idResponse := uintptr(unsafe.Pointer(&d.IdCtrl)) + idLen := unsafe.Sizeof(d.IdCtrl) + + adminCmd := nvmeAdminCommand{ + Opcode: NVME_ADMIN_IDENTIFY, + Addr: uint64(idResponse), + Alen: uint32(idLen), + Cdw10: 1, + } + + nvmeFile, err := os.OpenFile(d.Name, os.O_RDONLY, 0) + if err != nil { + return err + } + defer nvmeFile.Close() + + _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, nvmeFile.Fd(), NVME_IOCTL_ADMIN_CMD, uintptr(unsafe.Pointer(&adminCmd))) + if errno != 0 { + return fmt.Errorf("🔴 ioctl error: %v", errno) + } + + return nil +} + +// NVMe Service [Start] + +type NVMeService interface { + GetBlockDeviceMapping(device string) (string, error) +} + +// NVMe Service [END] + +type AwsNVMeService struct {} + +func (ns *AwsNVMeService) GetBlockDeviceMapping(device string) (string, error) { + nd, err := NewNVMeDevice(device); + if err != nil { + return "", err + } + return ns.getBlockDeviceMapping(nd) +} + +func (ns *AwsNVMeService) isEBSVolume(nd *NVMeDevice) bool { + vid := nd.IdCtrl.Vid + mn := strings.TrimRightFunc(string(nd.IdCtrl.Mn[:]), unicode.IsSpace) + return vid == AMZN_NVME_VID && mn == AMZN_NVME_EBS_MN +} + +func (ns *AwsNVMeService) getBlockDeviceMapping(nd *NVMeDevice) (string, error) { + var bdm string; + if ns.isEBSVolume(nd) { + bdm = strings.TrimRightFunc(string(nd.IdCtrl.Vs.Bdev[:]), unicode.IsSpace) + } + if bdm == "" { + return "", fmt.Errorf("🔴 %s is not an AWS-managed NVME device", nd.Name) + } + if !strings.HasPrefix(bdm, "/dev/") { + bdm = "/dev/" + bdm + } + return bdm, nil +} diff --git a/internal/service/nvme_test.go b/internal/service/nvme_test.go new file mode 100644 index 0000000..237d00e --- /dev/null +++ b/internal/service/nvme_test.go @@ -0,0 +1,103 @@ +package service + +import ( + "fmt" + "testing" + "ebs-bootstrap/internal/utils" +) + +const ( + UNSUPPORTED_NVME_VID = 0xFFFF + UNSUPPORTED_NVME_MN = "External NVME Manufacturer" +) + +func TestAwsNVMeService(t *testing.T) { + subtests := []struct{ + Name string + Device string + VendorId uint16 + ModelNumber string + BlockDevice string + ExpectedOutput string + ExpectedErr error + }{ + { + Name: "EBS NVMe Device (Partial Block Device)", + Device: "/dev/nvme1n1", + VendorId: AMZN_NVME_VID, + ModelNumber: AMZN_NVME_EBS_MN, + BlockDevice: "sdb", + ExpectedOutput: "/dev/sdb", + ExpectedErr: nil, + }, + { + Name: "EBS NVMe Device (Complete Block Device)", + Device: "/dev/nvme1n1", + VendorId: AMZN_NVME_VID, + ModelNumber: AMZN_NVME_EBS_MN, + BlockDevice: "/dev/sdb", + ExpectedOutput: "/dev/sdb", + ExpectedErr: nil, + }, + { + Name: "Invalid NVMe Device (Unsupported Vendor ID)", + Device: "/dev/nvme1n1", + VendorId: UNSUPPORTED_NVME_VID, + ModelNumber: AMZN_NVME_EBS_MN, + BlockDevice: "", + ExpectedOutput: "", + ExpectedErr: fmt.Errorf("🔴 /dev/nvme1n1 is not an AWS-managed NVME device"), + }, + { + Name: "Invalid NVMe Device (Unsupported Model Number)", + Device: "/dev/nvme1n1", + VendorId: AMZN_NVME_VID, + ModelNumber: UNSUPPORTED_NVME_MN, + BlockDevice: "", + ExpectedOutput: "", + ExpectedErr: fmt.Errorf("🔴 /dev/nvme1n1 is not an AWS-managed NVME device"), + }, + } + for _, subtest := range subtests { + t.Run(subtest.Name, func(t *testing.T) { + nd := &NVMeDevice{ + Name: subtest.Device, + IdCtrl: nvmeIdentifyController{ + Vid: subtest.VendorId, + Mn: parseModelNumber(subtest.ModelNumber), + Vs: nvmeIdentifyControllerAmznVS{ + Bdev: parseBlockDevice(subtest.BlockDevice), + }, + }, + } + ns := &AwsNVMeService{} + bdm, err := ns.getBlockDeviceMapping(nd) + if bdm != subtest.ExpectedOutput { + t.Errorf("getBlockDeviceMapping() [output] mismatch: Expected=%+v Actual=%+v", subtest.ExpectedOutput, bdm) + } + utils.CheckError("getBlockDeviceMapping()", t, subtest.ExpectedErr, err) + }) + } +} + +func parseModelNumber(input string) [40]byte { + var mn [40]byte + copy(mn[:], input) + if len(input) < 40 { + for i := len(input); i < 40; i++ { + mn[i] = ' ' + } + } + return mn +} + +func parseBlockDevice(input string) [32]byte { + var bd [32]byte + copy(bd[:], input) + if len(input) < 32 { + for i := len(input); i < 32; i++ { + bd[i] = ' ' + } + } + return bd +} diff --git a/internal/state/device.go b/internal/state/device.go new file mode 100644 index 0000000..100b781 --- /dev/null +++ b/internal/state/device.go @@ -0,0 +1,110 @@ +package state + +import ( + "fmt" + "log" + "ebs-bootstrap/internal/config" + "ebs-bootstrap/internal/service" +) + +type deviceProperties struct { + Name string + Fs string + MountPoint string + Owner string + Group string + Label string + Permissions string +} + +type Device struct { + Properties deviceProperties + DeviceService service.DeviceService + FileService service.FileService +} + +func NewDevice(name string, ds service.DeviceService, fs service.FileService) (*Device, error) { + s := &Device{ + DeviceService: ds, + FileService: fs, + Properties: deviceProperties{Name: name}, + } + err := s.Pull() + if err != nil { + return nil, err + } + return s, nil +} + +func (d *Device) Pull() (error) { + name := d.Properties.Name + di, err := d.DeviceService.GetDeviceInfo(name) + if err != nil { + return err + } + p := deviceProperties{ + Name: name, + Fs: di.Fs, + Label: di.Label, + MountPoint: di.MountPoint, + } + + if p.MountPoint == "" { + log.Printf("🟡 %s: No mount-point detected. Skip further checks...", name) + d.Properties = p + return nil + } + + fi, err := d.FileService.GetStats(p.MountPoint) + if err != nil { + return err + } + if fi.Exists { + p.Owner = fi.Owner + p.Group = fi.Group + p.Permissions = fi.Permissions + } + + d.Properties = p + return nil +} + +func (d *Device) Diff(c *config.Config) (error) { + name := d.Properties.Name + if name == "" { + return fmt.Errorf("🔴 An unexpected error occured") + } + desired, found := c.Devices[name] + if !found { + return fmt.Errorf("🔴 %s: Couldn't find device in config", name) + } + + if d.Properties.Fs != string(desired.Fs) { + return fmt.Errorf("🔴 File System [%s]: Expected=%s", d.Properties.Name, desired.Fs) + } + + if d.Properties.Label != string(desired.Label) { + return fmt.Errorf("🔴 Label [%s]: Expected=%s", d.Properties.Name, desired.Label) + } + + if d.Properties.MountPoint != string(desired.MountPoint) { + return fmt.Errorf("🔴 Mount Point [%s]: Expected=%s", d.Properties.Name, desired.MountPoint) + } + + if d.Properties.Owner != string(desired.Owner) { + return fmt.Errorf("🔴 Owner [%s]: Expected=%s", d.Properties.MountPoint, desired.Owner) + } + + if d.Properties.Group != string(desired.Group) { + return fmt.Errorf("🔴 Group: [%s]: Expected=%s", d.Properties.MountPoint, desired.Group) + } + + if d.Properties.Permissions != string(desired.Permissions) { + return fmt.Errorf("🔴 Permissions [%s]: Expected=%s", d.Properties.MountPoint, desired.Permissions) + } + return nil +} + +func (d *Device) Push(c *config.Config) (error) { + return nil +} diff --git a/internal/state/device_test.go b/internal/state/device_test.go new file mode 100644 index 0000000..31ce0b7 --- /dev/null +++ b/internal/state/device_test.go @@ -0,0 +1,193 @@ +package state + +import ( + "fmt" + "testing" + "ebs-bootstrap/internal/utils" + "ebs-bootstrap/internal/service" + "ebs-bootstrap/internal/config" +) + +type mockDeviceService struct { + getDeviceInfo func(device string) (*service.DeviceInfo, error) +} + +func (ds *mockDeviceService) GetBlockDevices() ([]string, error) { + return nil, fmt.Errorf("🔴 GetBlockDevices() not implemented") +} + +func (ds *mockDeviceService) GetDeviceInfo(device string) (*service.DeviceInfo, error) { + return ds.getDeviceInfo(device) +} + +type mockFileService struct { + getStats func(file string) (*service.FileInfo, error) +} + +func (fs *mockFileService) GetStats(file string) (*service.FileInfo, error) { + return fs.getStats(file) +} + +func (fs *mockFileService) ValidateFile(path string) (error) { + return fmt.Errorf("🔴 ValidateFile() not implemented") +} + +func TestDevice(t *testing.T) { + subtests := []struct { + Name string + DeviceName string + DeviceService service.DeviceService + FileService service.FileService + ExpectedErr error + }{ + { + Name: "Non-Existent Device", + DeviceName: "/dev/doesnt-exist", + DeviceService: &mockDeviceService{ + getDeviceInfo: func(device string) (*service.DeviceInfo, error) { + return nil, fmt.Errorf("🔴 /dev/doesnt-exist not a block device") + }, + }, + FileService: &mockFileService{ + getStats: func(file string) (*service.FileInfo, error) { + return nil, fmt.Errorf("🔴 getStats() should not be called") + }, + }, + ExpectedErr: fmt.Errorf("🔴 /dev/doesnt-exist not a block device"), + }, + } + for _, subtest := range subtests { + t.Run(subtest.Name, func(t *testing.T) { + _, err := NewDevice(subtest.DeviceName, + subtest.DeviceService, + subtest.FileService) + utils.CheckError("NewDevice()", t, subtest.ExpectedErr, err) + }) + } +} + +func TestDeviceDiff(t *testing.T) { + subtests := []struct { + Name string + DeviceName string + DeviceService service.DeviceService + FileService service.FileService + Config *config.Config + ExpectedErr error + }{ + { + Name: "No Diff Expected With Mount-Point", + DeviceName: "/dev/nvme0n1", + DeviceService: &mockDeviceService{ + getDeviceInfo: func(device string) (*service.DeviceInfo, error) { + return &service.DeviceInfo{ + Name: "/dev/nvme0n1", + Label: "external-vol", + Fs: "xfs", + MountPoint: "/mnt/app", + }, nil + }, + }, + FileService: &mockFileService{ + getStats: func(file string) (*service.FileInfo, error) { + return &service.FileInfo{ + Owner: "100", + Group: "100", + Permissions: "755", + Exists: true, + }, nil + }, + }, + Config: &config.Config{ + Devices: map[string]config.ConfigDevice{ + "/dev/nvme0n1": config.ConfigDevice{ + Fs: "xfs", + MountPoint: "/mnt/app", + Owner: "100", + Group: "100", + Label: "external-vol", + Permissions: "755", + }, + }, + }, + ExpectedErr: nil, + }, + { + Name: "No Diff Expected Without Mount-Point", + DeviceName: "/dev/nvme0n1", + DeviceService: &mockDeviceService{ + getDeviceInfo: func(device string) (*service.DeviceInfo, error) { + return &service.DeviceInfo{ + Name: "/dev/nvme0n1", + Label: "external-vol", + Fs: "xfs", + MountPoint: "", + }, nil + }, + }, + FileService: &mockFileService{ + getStats: func(file string) (*service.FileInfo, error) { + return nil, fmt.Errorf("🔴 getStats() should not be called") + }, + }, + Config: &config.Config{ + Devices: map[string]config.ConfigDevice{ + "/dev/nvme0n1": config.ConfigDevice{ + Fs: "xfs", + Label: "external-vol", + }, + }, + }, + ExpectedErr: nil, + }, + { + Name: "Diff Suggesting Fs Change (xfs->ext4)", + DeviceName: "/dev/nvme0n1", + DeviceService: &mockDeviceService{ + getDeviceInfo: func(device string) (*service.DeviceInfo, error) { + return &service.DeviceInfo{ + Name: "/dev/nvme0n1", + Label: "external-vol", + Fs: "xfs", + MountPoint: "/mnt/app", + }, nil + }, + }, + FileService: &mockFileService{ + getStats: func(file string) (*service.FileInfo, error) { + return &service.FileInfo{ + Owner: "100", + Group: "100", + Permissions: "755", + Exists: true, + }, nil + }, + }, + Config: &config.Config{ + Devices: map[string]config.ConfigDevice{ + "/dev/nvme0n1": config.ConfigDevice{ + Fs: "ext4", + MountPoint: "/mnt/app", + Owner: "100", + Group: "100", + Label: "external-vol", + Permissions: "755", + }, + }, + }, + ExpectedErr: fmt.Errorf("🔴 File System [/dev/nvme0n1]: Expected=ext4"), + }, + } + for _, subtest := range subtests { + t.Run(subtest.Name, func(t *testing.T) { + d, err := NewDevice(subtest.DeviceName, + subtest.DeviceService, + subtest.FileService) + if err != nil { + t.Errorf("NewDevice() [error] %s", err) + } + err = d.Diff(subtest.Config) + utils.CheckError("Diff()", t, subtest.ExpectedErr, err) + }) + } +} diff --git a/internal/state/state.go b/internal/state/state.go new file mode 100644 index 0000000..a93e46c --- /dev/null +++ b/internal/state/state.go @@ -0,0 +1,11 @@ +package state + +import ( + "ebs-bootstrap/internal/config" +) + +type State interface { + Pull() (error) + Diff(c *config.Config) (error) + Push(c *config.Config) (error) +} diff --git a/internal/utils/exec.go b/internal/utils/exec.go new file mode 100644 index 0000000..27e7829 --- /dev/null +++ b/internal/utils/exec.go @@ -0,0 +1,29 @@ +package utils + +import ( + "os/exec" + "strings" + "fmt" +) + +type Runner interface { + Command(name string, arg ...string) (string, error) +} + +type ExecRunner struct { + Runner func(name string, arg ...string) *exec.Cmd +} + +func NewExecRunner() *ExecRunner { + return &ExecRunner{exec.Command} +} + +func (er *ExecRunner) Command(name string, arg ...string) (string, error) { + cmd := er.Runner(name, arg...) + o, err := cmd.CombinedOutput() + output := strings.TrimRight(string(o), "\n") + if err != nil { + return "", fmt.Errorf("%s: %s", err, output) + } + return output, err +} diff --git a/internal/utils/testing.go b/internal/utils/testing.go new file mode 100644 index 0000000..9bbe052 --- /dev/null +++ b/internal/utils/testing.go @@ -0,0 +1,37 @@ +package utils + +import ( + "fmt" + "strings" + "os/user" + "testing" +) + +func CheckError(name string, t *testing.T, expected error, actual error) { + if actual != nil { + if expected == nil { + t.Errorf("%s [error] undetected: Actual=%v", name, actual) + return + } + if expected.Error() != actual.Error() { + t.Errorf("%s [error] mismatch: Expected=%v Actual=%v", name, expected, actual) + } + } +} + +func GetCurrentUserGroup() (*user.User, *user.Group, error) { + u, err := user.Current() + if err != nil { + return nil, nil, fmt.Errorf("🔴 Failed to get current user") + } + g, err := user.LookupGroupId(u.Gid) + if err != nil { + return nil, nil, fmt.Errorf("🔴 Failed to get current group") + } + /* user.Current() -> From experience, this function can return a username + in a capital case. This is not valid UNIX format for usernames so force + to lowercase */ + u.Name = strings.ToLower(u.Name) + g.Name = strings.ToLower(g.Name) + return u, g, nil +}