diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..d09eca9a --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,17 @@ +version: 2 +updates: + - package-ecosystem: gomod + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 10 + + - package-ecosystem: docker + directory: / + schedule: + interval: weekly + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..7702eda9 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,285 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + branches: [master] + +concurrency: + group: ci-${{ github.event.pull_request.number || github.sha }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: "1.26" + + - name: golangci-lint + uses: golangci/golangci-lint-action@v7 + with: + version: v2.10.1 + + test: + name: Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: "1.26" + + - name: Run tests + run: go test -v -race -coverprofile=coverage.out -count=1 ./... + + - name: Upload coverage + uses: actions/upload-artifact@v4 + with: + name: coverage + path: coverage.out + + e2e: + name: E2E Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: "1.26" + + - name: Run E2E tests + run: go test -tags e2e -v -race -count=1 ./test/e2e/... + + build: + name: Build + runs-on: ubuntu-latest + needs: [lint, test, e2e] + strategy: + matrix: + goarch: [amd64, arm64] + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: "1.26" + + - name: Install cross-compilation tools + if: matrix.goarch == 'arm64' + run: sudo apt-get update && sudo apt-get install -y gcc-aarch64-linux-gnu + + - name: Build + env: + GOOS: linux + GOARCH: ${{ matrix.goarch }} + CGO_ENABLED: 1 + CC: ${{ matrix.goarch == 'arm64' && 'aarch64-linux-gnu-gcc' || 'gcc' }} + run: go build -ldflags "-s -w -linkmode external -extldflags '-static'" -o booty-${{ matrix.goarch }} + + - name: Upload binary + uses: actions/upload-artifact@v4 + with: + name: booty-${{ matrix.goarch }} + path: booty-${{ matrix.goarch }} + + kvm-boot: + name: KVM Boot Validation + runs-on: ubuntu-latest + needs: [build] + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: "1.26" + + - name: Install QEMU and dependencies + run: | + sudo apt-get update + sudo apt-get install -y qemu-system-x86 cpio busybox-static zstd + + - name: Build initramfs image + run: | + # Build BOOTy as a static binary for linux/amd64 + CGO_ENABLED=1 GOOS=linux GOARCH=amd64 \ + go build -ldflags "-linkmode external -extldflags '-static' -s -w" -o booty + + # Create initramfs directory structure + mkdir -p initramfs/{bin,sbin,dev,proc,sys,etc,tmp,usr/bin,lib/modules,mnt,home} + + # Create device nodes so the kernel can give init a working console + sudo mknod initramfs/dev/console c 5 1 + sudo mknod initramfs/dev/ttyS0 c 4 64 + sudo mknod initramfs/dev/null c 1 3 + + # Install busybox with shell and utility symlinks + cp /bin/busybox initramfs/bin/busybox + cd initramfs/bin + for cmd in sh mount umount insmod setsid cttyhack; do + ln -sf busybox "$cmd" + done + cd ../.. + cd initramfs/usr/bin && ln -sf ../../bin/busybox setsid && cd ../../.. + + # Copy BOOTy binary + cp booty initramfs/booty + + # Copy e1000 kernel module (QEMU default virtual NIC) + MOD_PATH=$(find /lib/modules/$(uname -r) -name "e1000.ko*" | head -1) + if [ -n "$MOD_PATH" ]; then + case "$MOD_PATH" in + *.zst) zstd -d "$MOD_PATH" -o initramfs/lib/modules/e1000.ko ;; + *.xz) xz -dc "$MOD_PATH" > initramfs/lib/modules/e1000.ko ;; + *.gz) gzip -dc "$MOD_PATH" > initramfs/lib/modules/e1000.ko ;; + *) cp "$MOD_PATH" initramfs/lib/modules/e1000.ko ;; + esac + echo "Copied e1000 module from $MOD_PATH" + else + echo "WARNING: e1000 module not found" + fi + + # Create init wrapper: loads NIC driver and sets BOOTYURL before starting BOOTy + cat > initramfs/init << 'INITEOF' + #!/bin/sh + /bin/insmod /lib/modules/e1000.ko 2>/dev/null || true + export BOOTYURL="http://10.0.2.2:3000/booty" + exec /booty + INITEOF + chmod +x initramfs/init + + # Package initramfs + cd initramfs + sudo find . -print0 | sudo cpio --null -ov --format=newc 2>/dev/null | gzip > ../test-initramfs.cpio.gz + cd .. + + - name: Prepare kernel + run: | + sudo cp /boot/vmlinuz-$(uname -r) vmlinuz + sudo chmod 644 vmlinuz + + - name: Start test provisioning server + run: | + mkdir -p images + dd if=/dev/urandom bs=1024 count=1 of=images/test.img 2>/dev/null + + # Config for QEMU default MAC 52:54:00:12:34:56 (dash-format: 52-54-00-12-34-56) + cat > test-config.json << 'EOF' + { + "action": "writeImage", + "sourceImage": "http://10.0.2.2:3000/images/test.img", + "destinationDevice": "/dev/sda", + "compressed": false, + "dryRun": false, + "dropToShell": false, + "wipeDevice": false, + "growPartition": 1, + "lvmRootName": "/dev/ubuntu-vg/root" + } + EOF + + python3 << 'PYEOF' & + import http.server + + class Handler(http.server.SimpleHTTPRequestHandler): + def do_GET(self): + if self.path == '/booty/52-54-00-12-34-56.bty': + self.send_response(200) + self.send_header('Content-Type', 'application/json') + self.end_headers() + with open('test-config.json', 'rb') as f: + self.wfile.write(f.read()) + elif self.path.startswith('/images/'): + super().do_GET() + else: + self.send_response(404) + self.end_headers() + def log_message(self, format, *args): + print(f"[server] {format % args}") + + server = http.server.HTTPServer(('0.0.0.0', 3000), Handler) + print("[server] Listening on :3000") + server.serve_forever() + PYEOF + echo $! > server.pid + sleep 2 + curl -sf http://localhost:3000/booty/52-54-00-12-34-56.bty | python3 -m json.tool + echo "Provisioning server ready" + + - name: Boot with QEMU and validate + timeout-minutes: 5 + run: | + timeout 120 qemu-system-x86_64 \ + -kernel vmlinuz \ + -initrd test-initramfs.cpio.gz \ + -append "console=ttyS0 panic=1" \ + -m 512 \ + -nographic \ + -no-reboot \ + -net nic,macaddr=52:54:00:12:34:56,model=e1000 \ + -net user \ + -serial file:serial.log \ + 2>&1 || true + + echo "" + echo "=======================================" + echo " Serial Console Output " + echo "=======================================" + cat serial.log || true + echo "" + echo "=======================================" + + PASS=true + + if grep -q "Starting DHCP client" serial.log 2>/dev/null; then + echo "PASS: BOOTy init started — mount and device setup successful" + else + echo "FAIL: BOOTy init did not reach DHCP client" + PASS=false + fi + + if grep -q "Starting BOOTy" serial.log 2>/dev/null; then + echo "PASS: BOOTy main flow reached" + else + echo "FAIL: BOOTy did not reach main flow" + PASS=false + fi + + if grep -q "Connecting to provisioning server" serial.log 2>/dev/null; then + echo "PASS: Network operational — provisioning server contacted" + else + echo "FAIL: Provisioning server not contacted" + PASS=false + fi + + if [ "$PASS" = "false" ]; then + echo "" + echo "E2E BOOT VALIDATION FAILED" + exit 1 + fi + + echo "" + echo "ALL E2E CHECKS PASSED" + + - name: Upload serial log + if: always() + uses: actions/upload-artifact@v4 + with: + name: kvm-boot-serial-log + path: serial.log + if-no-files-found: ignore + + - name: Cleanup + if: always() + run: | + [ -f server.pid ] && kill $(cat server.pid) 2>/dev/null || true diff --git a/.github/workflows/kvm-test.yml b/.github/workflows/kvm-test.yml new file mode 100644 index 00000000..e72f9770 --- /dev/null +++ b/.github/workflows/kvm-test.yml @@ -0,0 +1,199 @@ +name: KVM Boot Test + +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + kvm-boot: + name: KVM Boot Validation + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: "1.26" + + - name: Install QEMU and dependencies + run: | + sudo apt-get update + sudo apt-get install -y qemu-system-x86 cpio busybox-static zstd + + - name: Build initramfs image + run: | + # Build BOOTy as a static binary for linux/amd64 + CGO_ENABLED=1 GOOS=linux GOARCH=amd64 \ + go build -ldflags "-linkmode external -extldflags '-static' -s -w" -o booty + + # Create initramfs directory structure + mkdir -p initramfs/{bin,sbin,dev,proc,sys,etc,tmp,usr/bin,lib/modules,mnt,home} + + # Create device nodes so the kernel can give init a working console + sudo mknod initramfs/dev/console c 5 1 + sudo mknod initramfs/dev/ttyS0 c 4 64 + sudo mknod initramfs/dev/null c 1 3 + + # Install busybox with shell and utility symlinks + cp /bin/busybox initramfs/bin/busybox + cd initramfs/bin + for cmd in sh mount umount insmod setsid cttyhack; do + ln -sf busybox "$cmd" + done + cd ../.. + cd initramfs/usr/bin && ln -sf ../../bin/busybox setsid && cd ../../.. + + # Copy BOOTy binary + cp booty initramfs/booty + + # Copy e1000 kernel module (QEMU default virtual NIC) + MOD_PATH=$(find /lib/modules/$(uname -r) -name "e1000.ko*" | head -1) + if [ -n "$MOD_PATH" ]; then + case "$MOD_PATH" in + *.zst) zstd -d "$MOD_PATH" -o initramfs/lib/modules/e1000.ko ;; + *.xz) xz -dc "$MOD_PATH" > initramfs/lib/modules/e1000.ko ;; + *.gz) gzip -dc "$MOD_PATH" > initramfs/lib/modules/e1000.ko ;; + *) cp "$MOD_PATH" initramfs/lib/modules/e1000.ko ;; + esac + echo "Copied e1000 module from $MOD_PATH" + else + echo "WARNING: e1000 module not found" + fi + + # Create init wrapper: loads NIC driver and sets BOOTYURL before starting BOOTy + cat > initramfs/init << 'INITEOF' + #!/bin/sh + /bin/insmod /lib/modules/e1000.ko 2>/dev/null || true + export BOOTYURL="http://10.0.2.2:3000/booty" + exec /booty + INITEOF + chmod +x initramfs/init + + # Package initramfs + cd initramfs + sudo find . -print0 | sudo cpio --null -ov --format=newc 2>/dev/null | gzip > ../test-initramfs.cpio.gz + cd .. + + - name: Prepare kernel + run: | + sudo cp /boot/vmlinuz-$(uname -r) vmlinuz + sudo chmod 644 vmlinuz + + - name: Start test provisioning server + run: | + mkdir -p images + dd if=/dev/urandom bs=1024 count=1 of=images/test.img 2>/dev/null + + # Config for QEMU default MAC 52:54:00:12:34:56 (dash-format: 52-54-00-12-34-56) + cat > test-config.json << 'EOF' + { + "action": "writeImage", + "sourceImage": "http://10.0.2.2:3000/images/test.img", + "destinationDevice": "/dev/sda", + "compressed": false, + "dryRun": false, + "dropToShell": false, + "wipeDevice": false, + "growPartition": 1, + "lvmRootName": "/dev/ubuntu-vg/root" + } + EOF + + python3 << 'PYEOF' & + import http.server + + class Handler(http.server.SimpleHTTPRequestHandler): + def do_GET(self): + if self.path == '/booty/52-54-00-12-34-56.bty': + self.send_response(200) + self.send_header('Content-Type', 'application/json') + self.end_headers() + with open('test-config.json', 'rb') as f: + self.wfile.write(f.read()) + elif self.path.startswith('/images/'): + super().do_GET() + else: + self.send_response(404) + self.end_headers() + def log_message(self, format, *args): + print(f"[server] {format % args}") + + server = http.server.HTTPServer(('0.0.0.0', 3000), Handler) + print("[server] Listening on :3000") + server.serve_forever() + PYEOF + echo $! > server.pid + sleep 2 + curl -sf http://localhost:3000/booty/52-54-00-12-34-56.bty | python3 -m json.tool + echo "Provisioning server ready" + + - name: Boot with QEMU and validate + timeout-minutes: 5 + run: | + timeout 120 qemu-system-x86_64 \ + -kernel vmlinuz \ + -initrd test-initramfs.cpio.gz \ + -append "console=ttyS0 panic=1" \ + -m 512 \ + -nographic \ + -no-reboot \ + -net nic,macaddr=52:54:00:12:34:56,model=e1000 \ + -net user \ + -serial file:serial.log \ + 2>&1 || true + + echo "" + echo "=======================================" + echo " Serial Console Output " + echo "=======================================" + cat serial.log || true + echo "" + echo "=======================================" + + PASS=true + + if grep -q "Starting DHCP client" serial.log 2>/dev/null; then + echo "PASS: BOOTy init started — mount and device setup successful" + else + echo "FAIL: BOOTy init did not reach DHCP client" + PASS=false + fi + + if grep -q "Starting BOOTy" serial.log 2>/dev/null; then + echo "PASS: BOOTy main flow reached" + else + echo "FAIL: BOOTy did not reach main flow" + PASS=false + fi + + if grep -q "Connecting to provisioning server" serial.log 2>/dev/null; then + echo "PASS: Network operational — provisioning server contacted" + else + echo "FAIL: Provisioning server not contacted" + PASS=false + fi + + if [ "$PASS" = "false" ]; then + echo "" + echo "E2E BOOT VALIDATION FAILED" + exit 1 + fi + + echo "" + echo "ALL E2E CHECKS PASSED" + + - name: Upload serial log + if: always() + uses: actions/upload-artifact@v4 + with: + name: kvm-boot-serial-log + path: serial.log + if-no-files-found: ignore + + - name: Cleanup + if: always() + run: | + [ -f server.pid ] && kill $(cat server.pid) 2>/dev/null || true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..40bdae99 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,68 @@ +name: Release + +on: + push: + tags: + - "v*" + +permissions: + contents: write + packages: write + +jobs: + release: + name: Release + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-go@v5 + with: + go-version: "1.26" + + - name: Install cross-compilation tools + run: sudo apt-get update && sudo apt-get install -y gcc-aarch64-linux-gnu + + - name: Build binaries + run: | + VERSION=${GITHUB_REF_NAME#v} + BUILD=${{ github.sha }} + + # amd64 + CGO_ENABLED=1 GOOS=linux GOARCH=amd64 CC=gcc \ + go build -ldflags "-s -w -X=main.Version=${VERSION} -X=main.Build=${BUILD} -linkmode external -extldflags '-static'" \ + -o booty-linux-amd64 + + # arm64 + CGO_ENABLED=1 GOOS=linux GOARCH=arm64 CC=aarch64-linux-gnu-gcc \ + go build -ldflags "-s -w -X=main.Version=${VERSION} -X=main.Build=${BUILD} -linkmode external -extldflags '-static'" \ + -o booty-linux-arm64 + + - name: Build and push initramfs image + run: | + docker buildx build --platform linux/amd64 --load \ + -t ghcr.io/telekom/booty:${GITHUB_REF_NAME#v} \ + -t ghcr.io/telekom/booty:latest \ + -f initrd.Dockerfile . + + - name: Login to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Push container image + run: | + docker push ghcr.io/telekom/booty:${GITHUB_REF_NAME#v} + docker push ghcr.io/telekom/booty:latest + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + generate_release_notes: true + files: | + booty-linux-amd64 + booty-linux-arm64 diff --git a/.gitignore b/.gitignore index b627dbac..cc260498 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,18 @@ +# Binaries +booty +init +*.exe + +# Build artifacts images/* +coverage.out + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 00000000..d0b823be --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,127 @@ +version: "2" + +linters: + default: none + enable: + # Bug detection + - govet + - staticcheck + - gosec + - nilerr + - nilnil + - bodyclose + - noctx + # Error handling + - errcheck + - errorlint + - wrapcheck + # Code quality + - ineffassign + - unused + - unconvert + - misspell + - revive + - gocritic + - cyclop + - gocognit + - nestif + - funlen + # Performance + - prealloc + # Style + - godot + - usestdlibvars + - thelper + settings: + errorlint: + errorf: true + asserts: true + comparison: true + misspell: + locale: US + staticcheck: + checks: + - "all" + - "-ST1018" + - "-ST1000" + cyclop: + max-complexity: 15 + gocognit: + min-complexity: 25 + funlen: + lines: 80 + statements: 50 + nestif: + min-complexity: 5 + gocritic: + enabled-tags: + - diagnostic + - style + - performance + gosec: + excludes: + - G204 # subprocess launched with variable — intentional for exec.Command usage + - G304 # file path from variable — intentional for device operations + - G306 # poor file permissions — we handle explicitly + revive: + rules: + - name: blank-imports + - name: context-as-argument + - name: dot-imports + - name: error-return + - name: error-strings + - name: error-naming + - name: exported + arguments: + - disableStutteringCheck + - name: increment-decrement + - name: var-naming + - name: package-comments + disabled: true + - name: range + - name: receiver-naming + - name: time-naming + - name: unexported-return + - name: indent-error-flow + - name: errorf + - name: empty-block + - name: superfluous-else + - name: unreachable-code + wrapcheck: + ignore-sigs: + - ".Errorf(" + - "errors.New(" + - "errors.Join(" + ignore-package-globs: + - github.com/telekom/BOOTy/* + exclusions: + rules: + - path: _test\.go + linters: + - errcheck + - wrapcheck + - funlen + - cyclop + - gocognit + - gosec + - gocritic + - path: pkg/ux/captain\.go + linters: + - funlen + - path: main\.go + linters: + - cyclop + - gocognit + - funlen + - linters: + - revive + text: "var-naming: avoid" + +formatters: + enable: + - gofmt + - goimports + +issues: + max-issues-per-linter: 0 + max-same-issues: 0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..8da73d39 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,68 @@ +# Contributing to BOOTy + +Thank you for your interest in contributing! This document covers the development workflow and coding standards. + +## Development Setup + +1. Install Go 1.26+ +2. Clone the repository: + ```bash + git clone https://github.com/telekom/BOOTy.git + cd BOOTy + ``` +3. Install dependencies: + ```bash + go mod download + ``` + +## Building + +```bash +# Build the binary +make build + +# Build the initramfs Docker image +docker build -t booty -f initrd.Dockerfile . +``` + +## Testing + +```bash +# Run all tests +make test + +# Run tests with coverage +go test -cover ./... + +# Run a specific package's tests +go test ./pkg/image/... +``` + +Note: Many packages in `pkg/realm/` use the `//go:build linux` build tag and will only compile/test on Linux. + +## Linting + +```bash +make lint +``` + +This runs [golangci-lint](https://golangci-lint.run/) with the configuration in `.golangci.yml`. + +## Coding Standards + +- **Logging**: Use `log/slog` — never `fmt.Print` for operational logs or `logrus`. +- **Errors**: Use `%w` in `fmt.Errorf` for error wrapping. Start error messages with a lowercase letter. +- **Imports**: Group into stdlib, external, and internal blocks separated by blank lines. +- **Build tags**: Linux-specific code must have `//go:build linux` at the top of the file. + +## Pull Request Process + +1. Fork the repository and create a feature branch from `main`. +2. Make your changes with clear, focused commits. +3. Ensure `make lint` and `make test` pass. +4. Open a PR with a description of what changed and why. +5. A maintainer will review and merge once CI is green. + +## License + +By contributing, you agree that your contributions will be licensed under the [Apache License 2.0](LICENSE). diff --git a/Makefile b/Makefile index 4ded083d..46d64dea 100644 --- a/Makefile +++ b/Makefile @@ -1,29 +1,24 @@ SHELL := /bin/sh -# The name of the executable (default is current directory name) TARGET := booty .DEFAULT_GOAL: $(TARGET) -# These will be provided to the target VERSION := 0.0.0 BUILD := `git rev-parse HEAD` -# Operating System Default (LINUX) TARGETOS=linux -# Use linker flags to provide version/build settings to the target LDFLAGS=-ldflags "-s -w -X=main.Version=$(VERSION) -X=main.Build=$(BUILD) -extldflags -static" -# go source files, ignore vendor directory SRC = $(shell find . -type f -name '*.go' -not -path "./vendor/*") DOCKERTAG ?= $(VERSION) -REPOSITORY = plndr +REPOSITORY = ghcr.io/telekom/booty -.PHONY: all build clean install uninstall fmt simplify check run +.PHONY: all build clean install uninstall fmt lint test docker dockerx86 -all: check install +all: lint test install $(TARGET): $(SRC) @go build $(LDFLAGS) -o $(TARGET) @@ -44,20 +39,18 @@ uninstall: clean fmt: @gofmt -l -w $(SRC) -demo: - @cd demo - @docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 --push -t $(REPOSITORY)/$(TARGET):$(DOCKERTAG) . - @echo New Multi Architecture Docker image created - @cd .. +lint: + @golangci-lint run ./... + +test: + @go test -race -coverprofile=coverage.out ./... + @go tool cover -func=coverage.out -# This is typically only for quick testing dockerx86: - @docker buildx build --platform linux/amd64 --load -t $(REPOSITORY)/$(TARGET):$(DOCKERTAG) -f initrd.Dockerfile . - @echo New Multi Architecture Docker image created + @docker buildx build --platform linux/amd64 --load -t $(REPOSITORY):$(DOCKERTAG) -f initrd.Dockerfile . docker: - @docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 --push -t $(REPOSITORY)/$(TARGET):$(DOCKERTAG) -f initrd.Dockerfile . - @echo New Multi Architecture Docker image created + @docker buildx build --platform linux/amd64,linux/arm64 --push -t $(REPOSITORY):$(DOCKERTAG) -f initrd.Dockerfile . # This is typically only for quick testing getramdisk: @@ -69,10 +62,13 @@ getramdisk: simplify: @gofmt -s -l -w $(SRC) +test-e2e: + @echo Running E2E tests + @go test -tags e2e -race -v ./test/e2e/... + check: @test -z $(shell gofmt -l main.go | tee /dev/stderr) || echo "[WARN] Fix formatting issues with 'make fmt'" - @for d in $$(go list ./... | grep -v /vendor/); do golint $${d}; done - @go tool vet ${SRC} + @go vet ./... run: install @$(TARGET) \ No newline at end of file diff --git a/README.md b/README.md index 6ee4b794..9bdb9da5 100644 --- a/README.md +++ b/README.md @@ -1,102 +1,162 @@ # BOOTy -A simple initrd that is used by plunder for Operating System image deployment. -It should go without saying that this it an early version of this software. It comes with **no guard rails** and if used incorrectly could break an existing Operating System +[![CI](https://github.com/telekom/BOOTy/actions/workflows/ci.yml/badge.svg)](https://github.com/telekom/BOOTy/actions/workflows/ci.yml) +[![Go Report Card](https://goreportcard.com/badge/github.com/telekom/BOOTy)](https://goreportcard.com/report/github.com/telekom/BOOTy) +[![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](LICENSE) -## Example deployment +A lightweight initrd for Operating System image deployment over the network. -[![asciicast](https://asciinema.org/a/326011.svg)](https://asciinema.org/a/326011) +BOOTy boots as the init process inside a minimal initramfs, contacts a provisioning server, and either **writes** a disk image to a local device or **reads** a local disk and uploads it to the server. -## BOOTy build +> **Warning** — This software has **no guard rails**. Incorrect use can overwrite an existing Operating System. -At the moment the most simple method of building `BOOTy` is to use the `initrd.Dockerfile` to build all the components that are required and compile in `BOOTy` as the init process. +## Architecture ``` -docker build -t init -f ./initrd.Dockerfile . ; \ -docker run init:latest tar -cf - /initramfs.cpio.gz | tar xf - +┌──────────────┐ ┌──────────────────┐ +│ PXE / iPXE │────────▶│ BOOTy initrd │ +│ Boot loader │ │ (kernel + cpio) │ +└──────────────┘ └───────┬──────────┘ + │ DHCP / HTTP + ┌───────▼──────────┐ + │ BOOTy Server │ + │ (config + images)│ + └──────────────────┘ ``` -**to-do** Mulit-arch builds may work with something like the following: +1. A bare-metal server PXE-boots with a kernel and the BOOTy initramfs. +2. BOOTy obtains an IP via DHCP and fetches its configuration from the provisioning server using its MAC address. +3. Depending on the `action` field in the config, BOOTy either writes an image to disk or reads a disk and uploads it. -` docker buildx build --platform linux/amd64 -o local -t init -f ./initrd.Dockerfile . ; \ -docker run init:latest tar -cf - /initramfs.cpio.gz | tar xf - ` +## Prerequisites -The above command will build these components: +- Go **1.26+** +- Docker (for building the initramfs) +- A DHCP/PXE environment for network booting -- BusyBox -- LVM -- BOOTy +## Building -It will then produce a simple `initramfs` that can be booted with a kernel and then finally it will copy the new `initrams` from the Docker image to the local file system. +### Initramfs (recommended) -## BOOTy boot +Build the complete initramfs with Docker: -Create a boot configuration (the below example uses `plunder`/[plndr.io](plndr.io)): +```bash +make build +``` -`pldrctl create boot -i initramfs.cpio.gz -k kernel -c "console=tty0 console=ttyS0,9600" -n booty` +This compiles BOOTy for `linux/amd64` and `linux/arm64`, then packages BusyBox, LVM2, and cloud-utils into a bootable initramfs. -Create a deployment configuration: +To extract the initramfs to the local filesystem: -`pldrctl create deployment -a a -m 00:50:56:a5:0e:0f -c booty` +```bash +docker run ghcr.io/telekom/booty:latest tar -cf - /initramfs.cpio.gz | tar xf - +``` +### Binary only -## Example Server +```bash +GOOS=linux go build -o booty . +``` -Until the server components are implemented into [plndr.io](plndr.io) the server is an external component built for testing. +## Usage -The two actions dictate the direction of Operating system images. +### Server -The `writeImage` action will instruct the new server on boot to pull the `-sourceImage` and write the contents to the `-destinationDevice`. +The provisioning server serves configuration files and (optionally) disk images over HTTP. +#### Write an image to a remote server -``` -go run server/server.go -action writeImage \ --mac 00:50:56:a5:0e:0f \ --sourceImage http://192.168.0.95:3000/images/ubuntu.img \ --destinationDevice /dev/sda +```bash +go run server/server.go \ + -action writeImage \ + -mac 00:50:56:a5:0e:0f \ + -sourceImage http://192.168.0.95:3000/images/ubuntu.img \ + -destinationDevice /dev/sda ``` -The `readImage` action should be used when network booting a server that already has an Operating System installed. The `-destinationAddress` should be the address of the machine that is running the server and should be in the format `http://
/image` as the `/image` is a specific handler for receiving the disk image. +#### Read a disk from a remote server -``` -go run server/server.go -action readImage \ --mac 00:50:56:a5:0e:0f \ --destinationAddress http://192.168.0.95:3000/image \ --sourceDevice /dev/sda +```bash +go run server/server.go \ + -action readImage \ + -mac 00:50:56:a5:0e:0f \ + -destinationAddress http://192.168.0.95:3000/image \ + -sourceDevice /dev/sda ``` -### Disk Support +### LVM & Disk Growth -The below command will write the Image `http://192.168.0.95:3000/images/ubuntu.img` to `/dev/sda`, it will then grow the partition `1` (which is `/dev/sda1`) and it will grow the root volume `/dev/ubuntu-vg/root` to the full size of the underlying disk. Also for development purposes `-shell` will drop to a shell if the process fails. +Write an image, grow partition 1, and expand the root LVM volume: +```bash +go run server/server.go \ + -action writeImage \ + -mac 00:50:56:a5:0e:0f \ + -sourceImage http://192.168.0.95:3000/images/ubuntu.img \ + -destinationDevice /dev/sda \ + -growPartition 1 \ + -lvmRoot /dev/ubuntu-vg/root \ + -shell ``` -go run server/server.go -action writeImage \ --mac 00:50:56:a5:0e:0f \ --sourceImage http://192.168.0.95:3000/images/ubuntu.img \ --destinationDevice /dev/sda \ --growPartition 1 \ --lvmRoot /dev/ubuntu-vg/root \ --shell + +### Static Network Configuration + +Set a static IP and gateway on the provisioned OS: + +```bash +go run server/server.go \ + -action writeImage \ + -mac 00:50:56:a5:0e:0f \ + -sourceImage http://192.168.0.95:3000/images/ubuntu.img \ + -destinationDevice /dev/sda \ + -growPartition 1 \ + -lvmRoot /dev/ubuntu-vg/root \ + -address 172.16.1.126/24 \ + -gateway 172.16.1.1 ``` -## Network Support +### Debugging -With `BOOTy` we can now configure all of the required network settings that are needed to set a static address for a host. +| Flag | Description | +|------|-------------| +| `-shell` | Drop to a BusyBox shell if something fails | +| `-wipe` | Wipe the provisioned disk on failure | +| `-dryRun` | Log actions without writing to disk | + +## Development + +```bash +# Run tests +make test + +# Run linter +make lint + +# Build binary +make build +``` + +## Project Structure ``` - go run server/server.go -action writeImage \ - -mac 00:50:56:a5:0e:0f \ - -sourceImage http://192.168.0.95:3000/images/ubuntu.img \ - -destinationDevice /dev/sda \ - -growPartition 1 \ - -lvmRoot /dev/ubuntu-vg/root \ - -address 172.16.1.126/24 \ - -gateway 172.16.1.1 +├── cmd/booty.go # CLI entry point & orchestration +├── main.go # Binary entry point +├── server/server.go # Provisioning server +├── pkg/ +│ ├── image/ # Disk image read/write (HTTP, gzip) +│ ├── plunderclient/ # HTTP client for config retrieval +│ ├── realm/ # Device, disk, mount, network, shell ops +│ ├── utils/ # Cmdline parsing, helpers +│ └── ux/ # ASCII art & system info display +├── initrd.Dockerfile # Multi-stage initramfs build +├── .github/workflows/ # CI, KVM test, release pipelines +└── .golangci.yml # Linter configuration ``` -### Debugging +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, coding standards, and the PR process. -Two additional flags can be passed to debug: +## License -- `-shell` - drop to a shell if something failes -- `-wipe` - wipe the provisioned disk if something fails +This project is licensed under the Apache License 2.0 — see [LICENSE](LICENSE) for details. diff --git a/cmd/booty.go b/cmd/booty.go index 3721aa67..6dd9f308 100644 --- a/cmd/booty.go +++ b/cmd/booty.go @@ -7,7 +7,7 @@ import ( "github.com/spf13/cobra" ) -// Release - this struct contains the release information populated when building booty +// Release - this struct contains the release information populated when building booty. var Release struct { Version string Build string @@ -19,14 +19,10 @@ var bootyCmd = &cobra.Command{ } func init() { - // bootyCmd.AddCommand(bootyPull) - // bootyCmd.AddCommand(bootyPush) - // bootyCmd.AddCommand(bootyServer) bootyCmd.AddCommand(bootyVersion) - } -// Execute - starts the command parsing process +// Execute starts the command parsing process. func Execute() { if err := bootyCmd.Execute(); err != nil { fmt.Println(err) @@ -34,30 +30,6 @@ func Execute() { } } -// var bootyPull = &cobra.Command{ -// Use: "pull", -// Short: "This is will direct BOOTy to pull and image from a remote server", -// Run: func(cmd *cobra.Command, args []string) { -// pull.Image() -// }, -// } - -// var bootyPush = &cobra.Command{ -// Use: "push", -// Short: "This is will direct BOOTy to push the contents of a disk to a remote server", -// Run: func(cmd *cobra.Command, args []string) { -// push.Image() -// }, -// } - -// var bootyServer = &cobra.Command{ -// Use: "server", -// Short: "This is for starting BOOTy as a simple (test) web server", -// Run: func(cmd *cobra.Command, args []string) { -// server.Serve() -// }, -// } - var bootyVersion = &cobra.Command{ Use: "version", Short: "Version and Release information about the BOOTy image manager", diff --git a/go.mod b/go.mod index 013af49c..6f8022a3 100644 --- a/go.mod +++ b/go.mod @@ -1,17 +1,23 @@ -module github.com/plunder-app/BOOTy +module github.com/telekom/BOOTy -go 1.14 +go 1.26 require ( github.com/digineo/go-dhclient v1.0.2 - github.com/dustin/go-humanize v1.0.0 - github.com/google/gopacket v1.1.17 - github.com/mdlayher/raw v0.0.0-20191009151244-50f2db8cc065 // indirect - github.com/sirupsen/logrus v1.6.0 - github.com/spf13/cobra v1.0.0 - github.com/vishvananda/netlink v1.1.0 - github.com/zcalusic/sysinfo v0.0.0-20200228145645-a159d7cc708b - golang.org/x/net v0.0.0-20200506145744-7e3656a0809f // indirect - golang.org/x/sys v0.0.0-20200513112337-417ce2331b5c // indirect - gopkg.in/yaml.v2 v2.3.0 + github.com/dustin/go-humanize v1.0.1 + github.com/google/gopacket v1.1.19 + github.com/spf13/cobra v1.8.1 + github.com/vishvananda/netlink v1.3.0 + github.com/zcalusic/sysinfo v1.1.3 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/google/uuid v1.6.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/mdlayher/raw v0.0.0-20191004140158-e1402808046b // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/vishvananda/netns v0.0.4 // indirect + golang.org/x/net v0.33.0 // indirect + golang.org/x/sys v0.28.0 // indirect ) diff --git a/go.sum b/go.sum index 2b26304e..79f043f6 100644 --- a/go.sum +++ b/go.sum @@ -1,183 +1,62 @@ -cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= -github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= -github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= -github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= -github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= -github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= -github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= -github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= -github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= -github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= -github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= -github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= -github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= -github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 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/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= -github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= github.com/digineo/go-dhclient v1.0.2 h1:69ZRY+AZnAx+BjO7UWTYWCGgnxW6oBOGwJXgciuLSEU= github.com/digineo/go-dhclient v1.0.2/go.mod h1:DPvyqGEW8irJvp2lrnGfQWpjj6VidXX9STLBTfNing4= -github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo= -github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= -github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= -github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= -github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= -github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= -github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= -github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= -github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= -github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= -github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= -github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/google/go-cmp v0.2.0 h1:+dTQ8DZQJz0Mb/HjFlkptS1FeQ4cWSnN941F8aEG4SQ= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= -github.com/google/gopacket v1.1.17 h1:rMrlX2ZY2UbvT+sdz3+6J+pp2z+msCq9MxTU6ymxbBY= github.com/google/gopacket v1.1.17/go.mod h1:UdDNZ1OO62aGYVnPhxT1U6aI7ukYtA/kB8vaU0diBUM= -github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= -github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= -github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= -github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= -github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= -github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= -github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= -github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= -github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= -github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= -github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= -github.com/konsorten/go-windows-terminal-sequences v1.0.3 h1:CE8S1cTafDpPvMhIxNJKvHsGVBgn1xWYf1NbHQhywc8= -github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= -github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= -github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= -github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= -github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8= +github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/mdlayher/raw v0.0.0-20191004140158-e1402808046b h1:8Oryv4wHvBHAxi9Swzu1zyN4BFrJKvv5pOnN0scSTw8= github.com/mdlayher/raw v0.0.0-20191004140158-e1402808046b/go.mod h1:7EpbotpCmVZcu+KCX4g9WaRNuu11uyhiW7+Le1dKawg= -github.com/mdlayher/raw v0.0.0-20191009151244-50f2db8cc065 h1:aFkJ6lx4FPip+S+Uw4aTegFMct9shDvP+79PsSxpm3w= -github.com/mdlayher/raw v0.0.0-20191009151244-50f2db8cc065/go.mod h1:7EpbotpCmVZcu+KCX4g9WaRNuu11uyhiW7+Le1dKawg= -github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= -github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= -github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= -github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= -github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= -github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/plunder-app/BOOTy v0.0.0-20200502180251-9281b75111f7/go.mod h1:EhhJilCAJNAXb/ZvNPPUb+VOnMF5F47KiSjJxQzBzNw= 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/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= -github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso= -github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= -github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= -github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= -github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= -github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= -github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU= -github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= -github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= -github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= -github.com/sirupsen/logrus v1.6.0 h1:UBcNElsrwanuuMsnGSlYmtmgbb23qDR5dG+6X6Oo89I= -github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= -github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM= -github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= -github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= -github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= -github.com/spf13/cobra v1.0.0 h1:6m/oheQuQ13N9ks4hubMG6BnvwOeaJrqSPLahSnczz8= -github.com/spf13/cobra v1.0.0/go.mod h1:/6GTrnGXV9HjY+aR4k0oJ5tcvakLuG6EuKReYlHNrgE= -github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= -github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg= -github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= -github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= +github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/thebsdbox/BOOTy v0.0.0-20200502180251-9281b75111f7 h1:JoIg/v1aNo63WlSymWuEtlTbpXYZn5PaS5JugpKjEcg= -github.com/thebsdbox/BOOTy v0.0.0-20200502180251-9281b75111f7/go.mod h1:EFEFAzEU43lBnvEwtyzSl48Gow5uklA3IS4ay5rzuUc= -github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= -github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc= -github.com/vishvananda/netlink v1.1.0 h1:1iyaYNBLmP6L0220aDnYQpo1QEV4t4hJ+xEEhhJH8j0= -github.com/vishvananda/netlink v1.1.0/go.mod h1:cTgwzPIzzgDAYoQrMm0EdrjRUBkTqKYppBueQtXaqoE= -github.com/vishvananda/netns v0.0.0-20191106174202-0a2b9b5464df h1:OviZH7qLw/7ZovXvuNyL3XQl8UFofeikI1NW1Gypu7k= -github.com/vishvananda/netns v0.0.0-20191106174202-0a2b9b5464df/go.mod h1:JP3t17pCcGlemwknint6hfoeCVQrEMVwxRLRjXpq+BU= -github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= -github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= -github.com/zcalusic/sysinfo v0.0.0-20200228145645-a159d7cc708b h1:P22UCgZoo9xZHYw33Cceo6wEr68Xodth9t+QFbNuNgk= -github.com/zcalusic/sysinfo v0.0.0-20200228145645-a159d7cc708b/go.mod h1:WGLNaWsjKQ2gXmAHh+MQztgu3FLFAnOFJjFzhpgShCY= -go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= -go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= -go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= -go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= -golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +github.com/vishvananda/netlink v1.3.0 h1:X7l42GfcV4S6E4vHTsw48qbrV+9PVojNfIhZcwQdrZk= +github.com/vishvananda/netlink v1.3.0/go.mod h1:i6NetklAujEcC6fK0JPjT8qSwWyO0HLn4UKG+hGqeJs= +github.com/vishvananda/netns v0.0.4 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1YX8= +github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= +github.com/zcalusic/sysinfo v1.1.3 h1:u/AVENkuoikKuIZ4sUEJ6iibpmQP6YpGD8SSMCrqAF0= +github.com/zcalusic/sysinfo v1.1.3/go.mod h1:NX+qYnWGtJVPV0yWldff9uppNKU4h40hJIRPf/pGLv4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190419010253-1f3472d942ba/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190522155817-f3200d17e092/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= -golang.org/x/net v0.0.0-20191003171128-d98b1b443823 h1:Ypyv6BNJh07T1pUSrehkLemqPKXhus2MkfktJ91kRh4= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20191003171128-d98b1b443823/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200506145744-7e3656a0809f h1:QBjCr1Fz5kw158VqdE9JfI9cJnl/ymnJWAdMuinqL7Y= -golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200513185701-a91f0712d120 h1:EZ3cVSzKOlJxAd8e8YAJ7no8nNypTxexh/YE/xW3ZEY= -golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= +golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190405154228-4b34438f7a67/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190418153312-f0ce4c0180be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190606203320-7fc4e5ec1444/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9 h1:L2auWcuQIvxz9xSEqzESnV/QN/gNRXNApHi3fYwl2w0= golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25 h1:OKbAoGs4fGM5cPLlVQLZGYkFC8OnOfgo6tt0Smf9XhM= -golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200513112337-417ce2331b5c h1:kISX68E8gSkNYAFRFiDU8rl5RIn1sJYKYb/r2vMLDrU= -golang.org/x/sys v0.0.0-20200513112337-417ce2331b5c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= -google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= -google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= -google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= -gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= +golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +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/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= -gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= -gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= -gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU= -gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/initrd.Dockerfile b/initrd.Dockerfile index 7cf6ce8c..29377525 100644 --- a/initrd.Dockerfile +++ b/initrd.Dockerfile @@ -1,61 +1,53 @@ # syntax=docker/dockerfile:experimental # Build LVM2 as an init -FROM gcc:10.1.0 as LVM -RUN wget https://mirrors.kernel.org/sourceware/lvm2/LVM2.2.03.09.tgz -RUN tar -xf LVM2.2.03.09.tgz -WORKDIR LVM2.2.03.09 -RUN apt-get update; apt-get install -y libaio-dev libdevmapper-dev +FROM gcc:14 AS lvm +RUN wget https://mirrors.kernel.org/sourceware/lvm2/LVM2.2.03.27.tgz +RUN tar -xf LVM2.2.03.27.tgz +WORKDIR LVM2.2.03.27 +RUN apt-get update && apt-get install -y libaio-dev libdevmapper-dev RUN ./configure --enable-static_link --disable-selinux -# UGLY HaCk RUN sed -i '/DMLIBS = -ldevmapper/ s/$/ -lm -lpthread/' libdm/dm-tools/Makefile -# GroSS HaCK RUN make; exit 0 WORKDIR tools -# My EyEs .. They bl33d HaCk -RUN gcc -O2 -fPIC -static -L command.o dumpconfig.o formats.o lvchange.o lvconvert.o lvconvert_poll.o lvcreate.o lvdisplay.o lvextend.o lvmcmdline.o lvmdiskscan.o lvpoll.o lvreduce.o lvremove.o lvrename.o lvresize.o lvscan.o polldaemon.o pvchange.o pvck.o pvcreate.o pvdisplay.o pvmove.o pvmove_poll.o pvremove.o pvresize.o pvscan.o reporter.o segtypes.o tags.o toollib.o vgcfgbackup.o vgcfgrestore.o vgchange.o vgck.o vgcreate.o vgdisplay.o vgexport.o vgextend.o vgimport.o vgimportclone.o vgmerge.o vgmknodes.o vgreduce.o vgremove.o vgrename.o vgscan.o vgsplit.o lvm-static.o ../lib/liblvm-internal.a ../libdaemon/client/libdaemonclient.a ../device_mapper/libdevice-mapper.a ../base/libbase.a -lm -lblkid -laio -o lvm -lpthread -luuid ./liblvm2cmd.a +RUN gcc -O2 -fPIC -static -L command.o dumpconfig.o formats.o lvchange.o lvconvert.o lvconvert_poll.o lvcreate.o lvdisplay.o lvextend.o lvmcmdline.o lvmdiskscan.o lvpoll.o lvreduce.o lvremove.o lvrename.o lvresize.o lvscan.o polldaemon.o pvchange.o pvck.o pvcreate.o pvdisplay.o pvmove.o pvmove_poll.o pvremove.o pvresize.o pvscan.o reporter.o segtypes.o tags.o toollib.o vgcfgbackup.o vgcfgrestore.o vgchange.o vgck.o vgcreate.o vgdisplay.o vgexport.o vgextend.o vgimport.o vgimportclone.o vgmerge.o vgmknodes.o vgreduce.o vgremove.o vgrename.o vgscan.o vgsplit.o lvm-static.o ../lib/liblvm-internal.a ../libdaemon/client/libdaemonclient.a ../device_mapper/libdevice-mapper.a ../base/libbase.a -lm -lblkid -laio -o lvm -lpthread -luuid ./liblvm2cmd.a # Build scripted fdisk (sfdisk) -FROM gcc:10.1.0 as sfdisk -RUN apt-get update -y; apt-get install -y bison autopoint gettext +FROM gcc:14 AS sfdisk +RUN apt-get update -y && apt-get install -y bison autopoint gettext RUN git clone https://github.com/karelzak/util-linux.git WORKDIR util-linux RUN ./autogen.sh && ./configure --enable-static-programs=sfdisk && make - # Build BOOTy as an init -FROM golang:1.14-alpine as dev +FROM golang:1.26-alpine AS dev RUN apk add --no-cache git ca-certificates gcc linux-headers musl-dev -COPY . /go/src/github.com/thebsdbox/BOOTy/ -WORKDIR /go/src/github.com/thebsdbox/BOOTy -ENV GO111MODULE=on +COPY . /go/src/github.com/telekom/BOOTy/ +WORKDIR /go/src/github.com/telekom/BOOTy RUN --mount=type=cache,sharing=locked,id=gomod,target=/go/pkg/mod/cache \ --mount=type=cache,sharing=locked,id=goroot,target=/root/.cache/go-build \ CGO_ENABLED=1 GOOS=linux go build -a -ldflags "-linkmode external -extldflags '-static' -s -w" -o init - -#RUN go get; CGO_ENABLED=1 GOOS=linux go build -a -ldflags "-linkmode external -extldflags '-static' -s -w" -o init # Build Busybox -FROM gcc:10.1.0 as Busybox -RUN apt-get update; apt-get install -y cpio -RUN curl -O https://busybox.net/downloads/busybox-1.31.1.tar.bz2 +FROM gcc:14 AS busybox +RUN apt-get update && apt-get install -y cpio +RUN curl -O https://busybox.net/downloads/busybox-1.37.0.tar.bz2 RUN tar -xf busybox*bz2 -WORKDIR busybox-1.31.1 -RUN make defconfig; make LDFLAGS=-static CONFIG_PREFIX=./initramfs install +WORKDIR busybox-1.37.0 +RUN make defconfig && make LDFLAGS=-static CONFIG_PREFIX=./initramfs install -#RUN make LDFLAGS=-static -WORKDIR initramfs -RUN wget -qO- https://launchpad.net/cloud-utils/trunk/0.31/+download/cloud-utils-0.31.tar.gz | tar -xvz -C /tmp; mv /tmp/cloud-utils-0.31/bin/growpart ./bin +WORKDIR initramfs +RUN wget -qO- https://launchpad.net/cloud-utils/trunk/0.33/+download/cloud-utils-0.33.tar.gz | tar -xvz -C /tmp && mv /tmp/cloud-utils-0.33/bin/growpart ./bin -# Copy build contents from previous build -COPY --from=LVM /LVM2.2.03.09/tools/lvm sbin +# Copy build contents from previous builds +COPY --from=lvm /LVM2.2.03.27/tools/lvm sbin COPY --from=sfdisk /util-linux/sfdisk.static bin/sfdisk -COPY --from=dev /go/src/github.com/thebsdbox/BOOTy/init . +COPY --from=dev /go/src/github.com/telekom/BOOTy/init . # Package initramfs -RUN find . -print0 | cpio --null -ov --format=newc > ../initramfs.cpio +RUN find . -print0 | cpio --null -ov --format=newc > ../initramfs.cpio RUN gzip ../initramfs.cpio RUN mv ../initramfs.cpio.gz / FROM scratch -COPY --from=Busybox /initramfs.cpio.gz . +COPY --from=busybox /initramfs.cpio.gz . diff --git a/main.go b/main.go index 48de2ec1..4866b2c0 100644 --- a/main.go +++ b/main.go @@ -1,23 +1,24 @@ +//go:build linux + package main import ( + "log/slog" + "os" "time" - "github.com/plunder-app/BOOTy/pkg/image" - "github.com/plunder-app/BOOTy/pkg/plunderclient" - "github.com/plunder-app/BOOTy/pkg/plunderclient/types" - "github.com/plunder-app/BOOTy/pkg/utils" - log "github.com/sirupsen/logrus" + "github.com/telekom/BOOTy/pkg/image" + "github.com/telekom/BOOTy/pkg/plunderclient" + "github.com/telekom/BOOTy/pkg/plunderclient/types" + "github.com/telekom/BOOTy/pkg/utils" - "github.com/plunder-app/BOOTy/pkg/realm" - "github.com/plunder-app/BOOTy/pkg/ux" + "github.com/telekom/BOOTy/pkg/realm" + "github.com/telekom/BOOTy/pkg/ux" ) func main() { + slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, nil))) - // Fuck it - - //cmd.Execute() m := realm.DefaultMounts() d := realm.DefaultDevices() dev := m.GetMount("dev") @@ -37,154 +38,155 @@ func main() { sys.EnableMount = true // Create all folders - m.CreateFolder() + if err := m.CreateFolder(); err != nil { + slog.Error("Failed to create folders", "error", err) + } // Ensure that /dev is mounted (first) - m.MountNamed("dev", true) + if err := m.MountNamed("dev", true); err != nil { + slog.Error("Failed to mount dev", "error", err) + } // Create all devices - d.CreateDevice() + if err := d.CreateDevice(); err != nil { + slog.Error("Failed to create devices", "error", err) + } // Mount any additional mounts - m.MountAll() + if err := m.MountAll(); err != nil { + slog.Error("Failed to mount filesystems", "error", err) + } - log.Println("Starting DHCP client") - go realm.DHCPClient() + slog.Info("Starting DHCP client") + go func() { + if err := realm.DHCPClient(); err != nil { + slog.Error("DHCP client error", "error", err) + } + }() - // HERE IS WHERE THE MAIN CODE GOES - log.Infoln("Starting BOOTy") + slog.Info("Starting BOOTy") time.Sleep(time.Second * 2) ux.Captain() ux.SysInfo() - log.Infoln("Beginning provisioning process") - - // What is needed - - // 1. Disk to read/write to - // 2. Source/Destination to read/write from - // 3. Post tasks - // --- 1. Disk stretch - // --- 2. Post config? + slog.Info("Beginning provisioning process") mac, err := realm.GetMAC() if err != nil { - log.Errorln(err) + slog.Error("Failed to get MAC address", "error", err) realm.Shell() } cfg, err := plunderclient.GetConfigForAddress(utils.DashMac(mac)) if err != nil { - log.Errorf("Error with remote server [%v]", err) - log.Errorln("Rebooting in 10 seconds") + slog.Error("Error with remote server", "error", err) + slog.Info("Rebooting in 10 seconds") time.Sleep(time.Second * 10) realm.Reboot() } switch cfg.Action { case types.ReadImage: - err = image.Read(cfg.SourceDevice, cfg.DesintationAddress, mac, cfg.Compressed) + err = image.Read(cfg.SourceDevice, cfg.DestinationAddress, mac, cfg.Compressed) if err != nil { - log.Errorf("Read Image Error: [%v]", err) + slog.Error("Read Image Error", "error", err) onError(cfg) } - log.Infoln("Image written succesfully, restarting in 5 seconds") + slog.Info("Image written successfully, restarting in 5 seconds") time.Sleep(time.Second * 5) realm.Reboot() case types.WriteImage: err = image.Write(cfg.SourceImage, cfg.DestinationDevice, cfg.Compressed) if err != nil { - log.Errorf("Write Image Error: [%v]", err) + slog.Error("Write Image Error", "error", err) onError(cfg) } - // log.Infoln("Image written succesfully, restarting in 5 seconds") - // time.Sleep(time.Second * 5) - // realm.Reboot() default: - log.Errorf("Unknown action [%s] passed to deployment image, restarting in 10 seconds", cfg.Action) + slog.Error("Unknown action passed to deployment image, restarting in 10 seconds", "action", cfg.Action) time.Sleep(time.Second * 10) realm.Reboot() } - log.Infoln("Beginning Disk Management") + slog.Info("Beginning Disk Management") err = realm.PartProbe(cfg.DestinationDevice) if err != nil { - log.Errorf("Disk Error: [%v]", err) + slog.Error("Disk Error", "error", err) onError(cfg) } err = realm.EnableLVM() if err != nil { - log.Errorf("Disk Error: [%v]", err) + slog.Error("Disk Error", "error", err) onError(cfg) } rv, err := realm.MountRootVolume(cfg.LVMRootName) if err != nil { - log.Errorf("Disk Error: [%v]", err) + slog.Error("Disk Error", "error", err) onError(cfg) } err = realm.GrowLVMRoot(cfg.DestinationDevice, cfg.LVMRootName, cfg.GrowPartition) if err != nil { - log.Errorf("Disk Error: [%v]", err) + slog.Error("Disk Error", "error", err) onError(cfg) } - // Start the networking configuration (UBUNTU ONLY) - log.Infoln("Starting Networking configuration") + slog.Info("Starting Networking configuration") err = realm.WriteNetPlan("/mnt", cfg) if err != nil { - log.Errorf("Network Error: [%v]", err) + slog.Error("Network Error", "error", err) onError(cfg) } - // Apply the networking configuration (UBUNTU ONLY) - log.Infoln("Applying Networking configuration") + slog.Info("Applying Networking configuration") err = realm.ApplyNetplan("/mnt") if err != nil { - log.Errorf("Network Error: [%v]", err) + slog.Error("Network Error", "error", err) onError(cfg) } - log.Infoln("Un Mounting boot volume") + slog.Info("Un Mounting boot volume") err = rv.UnMountNamed("dev") if err != nil { - log.Errorf("UnMounting Error: [%v]", err) + slog.Error("UnMounting Error", "error", err) onError(cfg) } err = rv.UnMountNamed("proc") if err != nil { - log.Errorf("UnMounting Error: [%v]", err) + slog.Error("UnMounting Error", "error", err) onError(cfg) } err = rv.UnMountAll() if err != nil { - log.Errorf("UnMounting Error: [%v]", err) + slog.Error("UnMounting Error", "error", err) onError(cfg) } - if cfg.DropToShell == true { + if cfg.DropToShell { realm.Shell() } - log.Infoln("BOOTy is now exiting, system will reboot") + slog.Info("BOOTy is now exiting, system will reboot") time.Sleep(time.Second * 2) realm.Reboot() } -// on Error we will execute the following steps +// onError handles error recovery by optionally wiping the device, +// dropping to a shell, or rebooting. func onError(cfg *types.BootyConfig) { - if cfg.WipeDevice == true { - realm.Wipe(cfg.DestinationDevice) + if cfg.WipeDevice { + if err := realm.Wipe(cfg.DestinationDevice); err != nil { + slog.Error("Wipe error", "error", err) + } } - if cfg.DropToShell == true { + if cfg.DropToShell { realm.Shell() } // Time to see the error diff --git a/pkg/image/image.go b/pkg/image/image.go index fd3e08c3..46938b6d 100644 --- a/pkg/image/image.go +++ b/pkg/image/image.go @@ -1,33 +1,32 @@ package image -// This package handles the pulling and management of images - import ( "compress/gzip" + "context" + "errors" "fmt" "io" + "log/slog" "mime/multipart" "net/http" "os" "path/filepath" "strings" + "sync/atomic" "time" "github.com/dustin/go-humanize" - log "github.com/sirupsen/logrus" ) -var tick chan time.Time - // WriteCounter counts the number of bytes written to it. It implements to the io.Writer interface // and we can pass this into io.TeeReader() which will report progress on each write cycle. type WriteCounter struct { - Total uint64 + Total atomic.Uint64 } func (wc *WriteCounter) Write(p []byte) (int, error) { n := len(p) - wc.Total += uint64(n) + wc.Total.Add(uint64(n)) return n, nil } @@ -41,7 +40,7 @@ func tickerProgress(byteCounter uint64) { fmt.Printf("\rDownloading... %s complete", humanize.Bytes(byteCounter)) } -// Read - will take a local disk and copy an image to a remote server +// Read will take a local disk and copy an image to a remote server. func Read(sourceDevice, destinationAddress, mac string, compressed bool) error { var fileName string @@ -59,21 +58,24 @@ func Read(sourceDevice, destinationAddress, mac string, compressed bool) error { fmt.Println("--------------------------------------------------------------------------------") client := &http.Client{} - _, err := UploadMultipartFile(client, destinationAddress, fileName, sourceDevice, compressed) + resp, err := UploadMultipartFile(client, destinationAddress, fileName, sourceDevice, compressed) if err != nil { return err } + if resp != nil { + _ = resp.Body.Close() + } return nil } -//UploadMultipartFile - +// UploadMultipartFile uploads the contents of path as a multipart form file. func UploadMultipartFile(client *http.Client, uri, key, path string, compressed bool) (*http.Response, error) { body, writer := io.Pipe() - req, err := http.NewRequest(http.MethodPost, uri, body) + req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, uri, body) if err != nil { - return nil, err + return nil, fmt.Errorf("creating upload request: %w", err) } mwriter := multipart.NewWriter(writer) @@ -84,8 +86,8 @@ func UploadMultipartFile(client *http.Client, uri, key, path string, compressed // GO routine for the copy operation go func() { defer close(errchan) - defer writer.Close() - defer mwriter.Close() + defer func() { _ = writer.Close() }() + defer func() { _ = mwriter.Close() }() // BootyImage is the key that the client will lookfor and // key is the renamed file @@ -101,30 +103,26 @@ func UploadMultipartFile(client *http.Client, uri, key, path string, compressed return } - defer diskIn.Close() + defer func() { _ = diskIn.Close() }() if !compressed { // Without compression read raw output if written, err := io.Copy(w, diskIn); err != nil { - errchan <- fmt.Errorf("error copying %s (%d bytes written): %v", path, written, err) + errchan <- fmt.Errorf("error copying %s (%d bytes written): %w", path, written, err) return } } else { // With compression run data through gzip writer zipWriter := gzip.NewWriter(w) - if err != nil { - errchan <- fmt.Errorf("[ERROR] New gzip reader: %s", err) - return - } // run an io.Copy on the disk into the zipWriter if written, err := io.Copy(zipWriter, diskIn); err != nil { - errchan <- fmt.Errorf("error copying %s (%d bytes written): %v", path, written, err) + errchan <- fmt.Errorf("error copying %s (%d bytes written): %w", path, written, err) return } // Ensure we close our zipWriter (otherwise we will get "unexpected EOF") - err = zipWriter.Close() + _ = zipWriter.Close() } @@ -135,35 +133,35 @@ func UploadMultipartFile(client *http.Client, uri, key, path string, compressed }() - resp, err := client.Do(req) + resp, err := client.Do(req) //nolint:gosec // URI is passed from caller, intentional merr := <-errchan if err != nil || merr != nil { - return resp, fmt.Errorf("http error: %v, multipart error: %v", err, merr) + return resp, errors.Join(err, merr) } return resp, nil } -// Write will pull an image and write it to local storage device -// with compress set to true it will use gzip compression to expand the data before -// writing to an underlying device +// Write will pull an image and write it to local storage device. +// With compress set to true it will use gzip compression to expand the data before +// writing to an underlying device. func Write(sourceImage, destinationDevice string, compressed bool) error { - req, err := http.NewRequest("GET", sourceImage, nil) + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, sourceImage, http.NoBody) if err != nil { - return err + return fmt.Errorf("creating image request: %w", err) } - resp, err := http.DefaultClient.Do(req) + resp, err := http.DefaultClient.Do(req) //nolint:gosec // sourceImage URL is from trusted config if err != nil { - return err + return fmt.Errorf("fetching image: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode > 300 { - // Customise response for the 404 to make degugging simpler - if resp.StatusCode == 404 { + // Customize response for the 404 to make debugging simpler. + if resp.StatusCode == http.StatusNotFound { return fmt.Errorf("%s not found", sourceImage) } return fmt.Errorf("%s", resp.Status) @@ -171,47 +169,41 @@ func Write(sourceImage, destinationDevice string, compressed bool) error { var out io.Reader - fileOut, err := os.OpenFile(destinationDevice, os.O_CREATE|os.O_WRONLY, 0644) + fileOut, err := os.OpenFile(destinationDevice, os.O_CREATE|os.O_WRONLY, 0o644) //nolint:gosec // device files need world-readable permissions if err != nil { - return err + return fmt.Errorf("opening destination device: %w", err) } - defer fileOut.Close() + defer func() { _ = fileOut.Close() }() if !compressed { // Without compression send raw output out = resp.Body } else { - // With compression run data through gzip writer + // With compression run data through gzip reader zipOUT, err := gzip.NewReader(resp.Body) if err != nil { - fmt.Println("[ERROR] New gzip reader:", err) + return fmt.Errorf("new gzip reader: %w", err) } - defer zipOUT.Close() + defer func() { _ = zipOUT.Close() }() out = zipOUT } - log.Infof("Beginning write of image [%s] to disk [%s]", filepath.Base(sourceImage), destinationDevice) + slog.Info("Beginning write of image to disk", "image", filepath.Base(sourceImage), "device", destinationDevice) // Create our progress reporter and pass it to be used alongside our writer ticker := time.NewTicker(500 * time.Millisecond) counter := &WriteCounter{} go func() { for ; true; <-ticker.C { - tickerProgress(counter.Total) + tickerProgress(counter.Total.Load()) } }() if _, err = io.Copy(fileOut, io.TeeReader(out, counter)); err != nil { ticker.Stop() - return err + return fmt.Errorf("writing image to disk: %w", err) } - count, err := io.Copy(fileOut, out) - if err != nil { - ticker.Stop() - return fmt.Errorf("Error writing %d bytes to disk [%s] -> %v", count, destinationDevice, err) - } fmt.Printf("\n") - ticker.Stop() return nil } diff --git a/pkg/image/image_test.go b/pkg/image/image_test.go new file mode 100644 index 00000000..97e59fea --- /dev/null +++ b/pkg/image/image_test.go @@ -0,0 +1,242 @@ +package image + +import ( + "bytes" + "compress/gzip" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" +) + +func TestWriteCounter(t *testing.T) { + wc := &WriteCounter{} + data := []byte("hello world") + n, err := wc.Write(data) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if n != len(data) { + t.Errorf("expected %d bytes written, got %d", len(data), n) + } + if wc.Total.Load() != uint64(len(data)) { + t.Errorf("expected Total=%d, got %d", len(data), wc.Total.Load()) + } + + // Write more data + n2, err := wc.Write(data) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if n2 != len(data) { + t.Errorf("expected %d bytes written, got %d", len(data), n2) + } + if wc.Total.Load() != uint64(2*len(data)) { + t.Errorf("expected Total=%d, got %d", 2*len(data), wc.Total.Load()) + } +} + +func TestWriteUncompressed(t *testing.T) { + content := []byte("test image content for write") + + // Create a test HTTP server that serves the content + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write(content) + })) + defer ts.Close() + + // Create a temp file as the destination device + tmpDir := t.TempDir() + destFile := filepath.Join(tmpDir, "disk.img") + + err := Write(ts.URL+"/image.img", destFile, false) + if err != nil { + t.Fatalf("Write() error: %v", err) + } + + // Verify written content + got, err := os.ReadFile(destFile) + if err != nil { + t.Fatalf("error reading output: %v", err) + } + if !bytes.Equal(got, content) { + t.Errorf("written content mismatch: got %q, want %q", got, content) + } +} + +func TestWriteCompressed(t *testing.T) { + content := []byte("test image content for compressed write") + + // Gzip the content + var buf bytes.Buffer + gw := gzip.NewWriter(&buf) + if _, err := gw.Write(content); err != nil { + t.Fatal(err) + } + if err := gw.Close(); err != nil { + t.Fatal(err) + } + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write(buf.Bytes()) + })) + defer ts.Close() + + tmpDir := t.TempDir() + destFile := filepath.Join(tmpDir, "disk.img") + + err := Write(ts.URL+"/image.zmg", destFile, true) + if err != nil { + t.Fatalf("Write() error: %v", err) + } + + got, err := os.ReadFile(destFile) + if err != nil { + t.Fatalf("error reading output: %v", err) + } + if !bytes.Equal(got, content) { + t.Errorf("written content mismatch: got %q, want %q", got, content) + } +} + +func TestWrite404(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer ts.Close() + + tmpDir := t.TempDir() + destFile := filepath.Join(tmpDir, "disk.img") + + err := Write(ts.URL+"/missing.img", destFile, false) + if err == nil { + t.Fatal("expected error for 404 response, got nil") + } + if got := err.Error(); got != ts.URL+"/missing.img not found" { + t.Errorf("unexpected error message: %s", got) + } +} + +func TestWriteServerError(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer ts.Close() + + tmpDir := t.TempDir() + destFile := filepath.Join(tmpDir, "disk.img") + + err := Write(ts.URL+"/error.img", destFile, false) + if err == nil { + t.Fatal("expected error for 500 response, got nil") + } +} + +func TestWriteInvalidURL(t *testing.T) { + tmpDir := t.TempDir() + destFile := filepath.Join(tmpDir, "disk.img") + + err := Write("http://127.0.0.1:0/invalid", destFile, false) + if err == nil { + t.Fatal("expected error for unreachable server, got nil") + } +} + +func TestWriteBadDestination(t *testing.T) { + content := []byte("data") + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write(content) + })) + defer ts.Close() + + err := Write(ts.URL+"/image.img", "/nonexistent/path/disk.img", false) + if err == nil { + t.Fatal("expected error for bad destination path, got nil") + } +} + +func TestReadUncompressed(t *testing.T) { + // Create a server that accepts multipart uploads + var received []byte + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseMultipartForm(32 << 20); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + file, _, err := r.FormFile("BootyImage") + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + defer file.Close() + received, _ = io.ReadAll(file) + w.WriteHeader(http.StatusOK) + })) + defer ts.Close() + + // Create a temp file as the source device + tmpDir := t.TempDir() + srcFile := filepath.Join(tmpDir, "source.img") + content := []byte("source disk data for read test") + if err := os.WriteFile(srcFile, content, 0644); err != nil { + t.Fatal(err) + } + + err := Read(srcFile, ts.URL+"/image", "aa-bb-cc-dd-ee-ff", false) + if err != nil { + t.Fatalf("Read() error: %v", err) + } + + if !bytes.Equal(received, content) { + t.Errorf("received content mismatch: got %d bytes, want %d bytes", len(received), len(content)) + } +} + +func TestReadCompressed(t *testing.T) { + var received []byte + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseMultipartForm(32 << 20); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + file, _, err := r.FormFile("BootyImage") + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + defer file.Close() + received, _ = io.ReadAll(file) + w.WriteHeader(http.StatusOK) + })) + defer ts.Close() + + tmpDir := t.TempDir() + srcFile := filepath.Join(tmpDir, "source.img") + content := []byte("source disk data for compressed read test") + if err := os.WriteFile(srcFile, content, 0644); err != nil { + t.Fatal(err) + } + + err := Read(srcFile, ts.URL+"/image", "aa-bb-cc-dd-ee-ff", true) + if err != nil { + t.Fatalf("Read() error: %v", err) + } + + // The received data should be gzip compressed + gr, err := gzip.NewReader(bytes.NewReader(received)) + if err != nil { + t.Fatalf("failed to create gzip reader: %v", err) + } + defer gr.Close() + decompressed, err := io.ReadAll(gr) + if err != nil { + t.Fatalf("failed to decompress: %v", err) + } + if !bytes.Equal(decompressed, content) { + t.Errorf("decompressed content mismatch: got %q, want %q", decompressed, content) + } +} diff --git a/pkg/plunderclient/client.go b/pkg/plunderclient/client.go index 41813fac..738f0e6a 100644 --- a/pkg/plunderclient/client.go +++ b/pkg/plunderclient/client.go @@ -1,25 +1,26 @@ package plunderclient import ( + "context" "encoding/json" "fmt" - "io/ioutil" + "io" + "log/slog" "net/http" "os" "time" - "github.com/plunder-app/BOOTy/pkg/plunderclient/types" - log "github.com/sirupsen/logrus" + "github.com/telekom/BOOTy/pkg/plunderclient/types" ) -// GetConfigForAddress will retrieve the configuraiton for a server (mac address) +// GetConfigForAddress will retrieve the configuration for a server (mac address). func GetConfigForAddress(mac string) (*types.BootyConfig, error) { // Attempt to find the Server URL url := os.Getenv("BOOTYURL") if url == "" { - return nil, fmt.Errorf("The flag BOOTYURL is empty") + return nil, fmt.Errorf("the flag BOOTYURL is empty") } - log.Infof("Connecting to provisioning server [%s]", url) + slog.Info("Connecting to provisioning server", "url", url) //nolint:gosec // url is from trusted env var, not user input // Address format @@ -31,37 +32,38 @@ func GetConfigForAddress(mac string) (*types.BootyConfig, error) { Timeout: time.Second * 5, // Maximum of 5 secs } - req, err := http.NewRequest(http.MethodGet, configURL, nil) + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, configURL, http.NoBody) //nolint:gosec // URL is constructed from trusted env var if err != nil { - return nil, err + return nil, fmt.Errorf("creating config request: %w", err) } req.Header.Set("User-Agent", "BOOTy-client") - res, err := plunderClient.Do(req) + res, err := plunderClient.Do(req) //nolint:gosec // URL is constructed from trusted env var if err != nil { - return nil, err + return nil, fmt.Errorf("executing config request: %w", err) } + defer func() { _ = res.Body.Close() }() if res.StatusCode > 300 { - // Customise response for the 404 to make degugging simpler - if res.StatusCode == 404 { + // Customize response for the 404 to make debugging simpler + if res.StatusCode == http.StatusNotFound { return nil, fmt.Errorf("%s not found", configURL) } return nil, fmt.Errorf("%s", res.Status) } - body, err := ioutil.ReadAll(res.Body) + body, err := io.ReadAll(res.Body) if err != nil { - return nil, err + return nil, fmt.Errorf("reading config response: %w", err) } var config types.BootyConfig err = json.Unmarshal(body, &config) if err != nil { - log.Errorf("Error reading [%s]", configURL) - return nil, err + slog.Error("Error reading config", "url", configURL) //nolint:gosec // configURL is from trusted env var, not user input + return nil, fmt.Errorf("unmarshaling config: %w", err) } return &config, nil diff --git a/pkg/plunderclient/client_test.go b/pkg/plunderclient/client_test.go new file mode 100644 index 00000000..9249c63a --- /dev/null +++ b/pkg/plunderclient/client_test.go @@ -0,0 +1,74 @@ +package plunderclient + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/telekom/BOOTy/pkg/plunderclient/types" +) + +func TestGetConfigForAddress(t *testing.T) { + cfg := types.BootyConfig{ + Action: types.WriteImage, + SourceImage: "http://example.com/image.img", + DestinationDevice: "/dev/sda", + Compressed: true, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.HasSuffix(r.URL.Path, "/00-11-22-33-44-55.bty") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(cfg) + })) + defer server.Close() + + os.Setenv("BOOTYURL", server.URL) + defer os.Unsetenv("BOOTYURL") + + result, err := GetConfigForAddress("00-11-22-33-44-55") + if err != nil { + t.Fatalf("GetConfigForAddress() error: %v", err) + } + if result.Action != types.WriteImage { + t.Errorf("Action = %q, want %q", result.Action, types.WriteImage) + } + if result.SourceImage != cfg.SourceImage { + t.Errorf("SourceImage = %q, want %q", result.SourceImage, cfg.SourceImage) + } + if result.DestinationDevice != cfg.DestinationDevice { + t.Errorf("DestinationDevice = %q, want %q", result.DestinationDevice, cfg.DestinationDevice) + } + if result.Compressed != cfg.Compressed { + t.Errorf("Compressed = %v, want %v", result.Compressed, cfg.Compressed) + } +} + +func TestGetConfigForAddressNoURL(t *testing.T) { + os.Unsetenv("BOOTYURL") + _, err := GetConfigForAddress("00-11-22-33-44-55") + if err == nil { + t.Error("GetConfigForAddress() with no BOOTYURL should return error") + } +} + +func TestGetConfigForAddress404(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.NotFound(w, r) + })) + defer server.Close() + + os.Setenv("BOOTYURL", server.URL) + defer os.Unsetenv("BOOTYURL") + + _, err := GetConfigForAddress("nonexistent-mac") + if err == nil { + t.Error("GetConfigForAddress() with 404 response should return error") + } +} diff --git a/pkg/plunderclient/types/types.go b/pkg/plunderclient/types/types.go index 35334b45..66c4df07 100644 --- a/pkg/plunderclient/types/types.go +++ b/pkg/plunderclient/types/types.go @@ -21,14 +21,14 @@ package types // ------------------ // const ( - //ReadImage means that this is a read only action + // ReadImage means that this is a read only action. ReadImage = "readImage" - // WriteImage means that this is a read/write action + // WriteImage means that this is a read/write action. WriteImage = "writeImage" ) -// BootyConfig defines the data passed to the BOOTy initramdisk +// BootyConfig defines the data passed to the BOOTy initramdisk. type BootyConfig struct { // Defines what action the deployment will take Action string `json:"action"` @@ -44,7 +44,7 @@ type BootyConfig struct { // Read Image from Disk and write to remote address SourceDevice string `json:"sourceDevice,omitempty"` - DesintationAddress string `json:"desintationAddress,omitempty"` + DestinationAddress string `json:"destinationAddress,omitempty"` // Post tasks - Once the image has been deployed @@ -55,7 +55,7 @@ type BootyConfig struct { GrowPartition int `json:"growPartition"` LVMRootName string `json:"lvmRootName"` - // Network modifcations + // Network modifications Address string `json:"address,omitempty"` Gateway string `json:"gateway,omitempty"` NameServer string `json:"nameserver,omitempty"` diff --git a/pkg/plunderclient/types/types_test.go b/pkg/plunderclient/types/types_test.go new file mode 100644 index 00000000..e3ca75bd --- /dev/null +++ b/pkg/plunderclient/types/types_test.go @@ -0,0 +1,38 @@ +package types + +import "testing" + +func TestConstants(t *testing.T) { + if ReadImage != "readImage" { + t.Errorf("ReadImage = %q, want %q", ReadImage, "readImage") + } + if WriteImage != "writeImage" { + t.Errorf("WriteImage = %q, want %q", WriteImage, "writeImage") + } +} + +func TestBootyConfigDefaults(t *testing.T) { + cfg := BootyConfig{} + + if cfg.Action != "" { + t.Errorf("default Action = %q, want empty", cfg.Action) + } + if cfg.Compressed { + t.Error("default Compressed should be false") + } + if cfg.DryRun { + t.Error("default DryRun should be false") + } + if cfg.DropToShell { + t.Error("default DropToShell should be false") + } + if cfg.WipeDevice { + t.Error("default WipeDevice should be false") + } + if cfg.GrowDisk { + t.Error("default GrowDisk should be false") + } + if cfg.GrowPartition != 0 { + t.Errorf("default GrowPartition = %d, want 0", cfg.GrowPartition) + } +} diff --git a/pkg/realm/device.go b/pkg/realm/device.go index d5687f42..1ea57714 100644 --- a/pkg/realm/device.go +++ b/pkg/realm/device.go @@ -1,12 +1,11 @@ package realm import ( + "log/slog" "syscall" - - log "github.com/sirupsen/logrus" ) -// DefaultDevices will return the defult mounts +// DefaultDevices will return the defult mounts. func DefaultDevices() *Devices { d := &Devices{} @@ -49,21 +48,21 @@ func DefaultDevices() *Devices { return d } -// CreateDevice - +// CreateDevice -. func (d *Devices) CreateDevice() error { for x := range d.Device { - if d.Device[x].CreateDevice == true { + if d.Device[x].CreateDevice { err := syscall.Mknod(d.Device[x].Path, d.Device[x].Mode, makedev(d.Device[x].Major, d.Device[x].Minor)) if err != nil { - log.Errorf("Device Error [%v]", err) + slog.Error("Device Error", "error", err) } } } return nil } -// GetDevice - +// GetDevice -. func (d *Devices) GetDevice(name string) *Device { for x := range d.Device { diff --git a/pkg/realm/device_test.go b/pkg/realm/device_test.go new file mode 100644 index 00000000..459519d4 --- /dev/null +++ b/pkg/realm/device_test.go @@ -0,0 +1,63 @@ +package realm + +import "testing" + +func TestDefaultDevices(t *testing.T) { + d := DefaultDevices() + + if len(d.Device) != 3 { + t.Fatalf("DefaultDevices() returned %d devices, want 3", len(d.Device)) + } + + expected := []struct { + name string + path string + major int64 + minor int64 + }{ + {"null", "/dev/null", 1, 3}, + {"random", "/dev/random", 1, 8}, + {"urandom", "/dev/urandom", 1, 9}, + } + + for i, e := range expected { + if d.Device[i].Name != e.name { + t.Errorf("Device[%d].Name = %q, want %q", i, d.Device[i].Name, e.name) + } + if d.Device[i].Path != e.path { + t.Errorf("Device[%d].Path = %q, want %q", i, d.Device[i].Path, e.path) + } + if d.Device[i].Major != e.major { + t.Errorf("Device[%d].Major = %d, want %d", i, d.Device[i].Major, e.major) + } + if d.Device[i].Minor != e.minor { + t.Errorf("Device[%d].Minor = %d, want %d", i, d.Device[i].Minor, e.minor) + } + } +} + +func TestGetDevice(t *testing.T) { + d := DefaultDevices() + + dev := d.GetDevice("null") + if dev == nil { + t.Fatal("GetDevice(\"null\") returned nil") + } + if dev.Name != "null" { + t.Errorf("GetDevice(\"null\").Name = %q", dev.Name) + } + + dev = d.GetDevice("nonexistent") + if dev != nil { + t.Error("GetDevice(\"nonexistent\") should return nil") + } +} + +func TestMakedev(t *testing.T) { + // Test that makedev produces expected device numbers + result := makedev(1, 3) + expected := (1 << 8) | 3 + if result != expected { + t.Errorf("makedev(1, 3) = %d, want %d", result, expected) + } +} diff --git a/pkg/realm/disk.go b/pkg/realm/disk.go index 6d94949e..473a4931 100644 --- a/pkg/realm/disk.go +++ b/pkg/realm/disk.go @@ -1,13 +1,15 @@ +//go:build linux + package realm import ( + "context" "fmt" + "log/slog" "os" "os/exec" "syscall" "time" - - log "github.com/sirupsen/logrus" ) // Update partitions @@ -32,43 +34,43 @@ import ( // chroot /mnt /sbin/lvresize -l +100%FREE /dev/ubuntu-vg/root // chroot /mnt /sbin/resize2fs /dev/ubuntu-vg/root -// PartProbe will update partitions - will enable any volumes +// PartProbe will update partitions - will enable any volumes. func PartProbe(device string) error { // TTY hack to support ctrl+c - cmd := exec.Command("/usr/sbin/partprobe", device) + cmd := exec.CommandContext(context.Background(), "/usr/sbin/partprobe", device) cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr err := cmd.Start() if err != nil { - return fmt.Errorf("Partition Probe command error [%v]", err) + return fmt.Errorf("partition probe command error: %w", err) } err = cmd.Wait() if err != nil { - return fmt.Errorf("Partition Probe error [%v]", err) + return fmt.Errorf("partition probe error: %w", err) } // Ensure that disks are mounted and we're in a position to mount them time.Sleep(time.Second * 2) return nil } -// EnableLVM - will enable any volumes +// EnableLVM - will enable any volumes. func EnableLVM() error { // TTY hack to support ctrl+c - cmd := exec.Command("/sbin/lvm", "vgchange", "-ay") + cmd := exec.CommandContext(context.Background(), "/sbin/lvm", "vgchange", "-ay") cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr err := cmd.Start() if err != nil { - return fmt.Errorf("Linux Volume command error [%v]", err) + return fmt.Errorf("linux volume command error: %w", err) } err = cmd.Wait() if err != nil { - return fmt.Errorf("Linux Volume error [%v]", err) + return fmt.Errorf("linux volume error: %w", err) } return nil } -// MountRootVolume - will create a mountpoint and mount the root volume +// MountRootVolume - will create a mountpoint and mount the root volume. func MountRootVolume(rootVolume string) (*Mounts, error) { m := Mounts{} root := Mount{ @@ -89,7 +91,7 @@ func MountRootVolume(rootVolume string) (*Mounts, error) { Path: "/mnt/dev", FSType: "devtmpfs", Flags: syscall.MS_MGC_VAL, - Mode: 0777, + Mode: 0o777, } m.Mount = append(m.Mount, dev) @@ -100,7 +102,7 @@ func MountRootVolume(rootVolume string) (*Mounts, error) { Source: "proc", Path: "/mnt/proc", FSType: "proc", - Mode: 0777, + Mode: 0o777, } m.Mount = append(m.Mount, proc) @@ -116,13 +118,13 @@ func MountRootVolume(rootVolume string) (*Mounts, error) { return &m, nil } -// GrowLVMRoot will grow the root filesystem +// GrowLVMRoot will grow the root filesystem. func GrowLVMRoot(drive, volume string, partition int) error { // chroot /mnt /usr/bin/growpart /dev/sda 1 // chroot /mnt /sbin/pvresize /dev/sda1 // chroot /mnt /sbin/lvresize -l +100%FREE /dev/ubuntu-vg/root // chroot /mnt /sbin/resize2fs /dev/ubuntu-vg/root - var chrootCommands [][]string + chrootCommands := make([][]string, 0, 4) growpartition := []string{"/mnt", "/usr/bin/growpart", drive, fmt.Sprintf("%d", partition)} chrootCommands = append(chrootCommands, growpartition) @@ -136,42 +138,42 @@ func GrowLVMRoot(drive, volume string, partition int) error { resizeFilesystem := []string{"/mnt", "/sbin/resize2fs", volume} chrootCommands = append(chrootCommands, resizeFilesystem) for x := range chrootCommands { - cmd := exec.Command("/usr/sbin/chroot", chrootCommands[x]...) + cmd := exec.CommandContext(context.Background(), "/usr/sbin/chroot", chrootCommands[x]...) cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr err := cmd.Start() if err != nil { - return fmt.Errorf("Partition Probe command error [%v]", err) + return fmt.Errorf("chroot command error: %w", err) } err = cmd.Wait() if err != nil { - return fmt.Errorf("Partition Probe error [%v]", err) + return fmt.Errorf("chroot error: %w", err) } } return nil } -//Wipe will clean the beginning of the disk +// Wipe will clean the beginning of the disk. func Wipe(device string) error { // wipe // dd if=/dev/zero of=/dev/sda bs=1024k count=100 - log.Println("Wiping disk") + slog.Info("Wiping disk") input := "if=/dev/zero" output := fmt.Sprintf("of=%s", device) blockSize := "bs=1024k" count := "count=100" - cmd := exec.Command("/bin/dd", input, output, blockSize, count) + cmd := exec.CommandContext(context.Background(), "/bin/dd", input, output, blockSize, count) cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr err := cmd.Start() if err != nil { - return fmt.Errorf("Disk Wipe command error [%v]", err) + return fmt.Errorf("disk wipe command error: %w", err) } - log.Printf("Waiting for command to finish...") + slog.Info("Waiting for command to finish...") err = cmd.Wait() if err != nil { - return fmt.Errorf("Disk Wipe [%v]", err) + return fmt.Errorf("disk wipe: %w", err) } return nil } diff --git a/pkg/realm/exit.go b/pkg/realm/exit.go index 226a5777..56729494 100644 --- a/pkg/realm/exit.go +++ b/pkg/realm/exit.go @@ -1,56 +1,50 @@ -//+build linux +//go:build linux package realm import ( + "log/slog" "os" "syscall" - - log "github.com/sirupsen/logrus" ) -// This contains all methods for managing the final steps with a host - -// Reboot a host +// Reboot a host. func Reboot() { err := syscall.Reboot(syscall.LINUX_REBOOT_CMD_RESTART) if err != nil { - log.Errorf("reboot off failed: %v", err) + slog.Error("Reboot failed", "error", err) Shell() } - // Should cause a panic os.Exit(1) } -// PowerOff will result in the host using an ACPI power off +// PowerOff will result in the host using an ACPI power off. func PowerOff() { err := syscall.Reboot(syscall.LINUX_REBOOT_CMD_POWER_OFF) if err != nil { - log.Errorf("power off failed: %v", err) + slog.Error("Power off failed", "error", err) Shell() } - // Should cause a panic os.Exit(1) } -// Halt will instruct the CPU to enter a halt state (no-power off (usually)) +// Halt will instruct the CPU to enter a halt state. func Halt() { err := syscall.Reboot(syscall.LINUX_REBOOT_CMD_HALT) if err != nil { - log.Errorf("halt failed: %v", err) + slog.Error("Halt failed", "error", err) Shell() } - // Should cause a panic os.Exit(1) } -// Suspend will instruct the CPU to enter a suspended state (no-power off (usually)) +// Suspend will instruct the CPU to enter a suspended state. func Suspend() { err := syscall.Reboot(syscall.LINUX_REBOOT_CMD_SW_SUSPEND) if err != nil { - log.Errorf("suspend failed: %v", err) + slog.Error("Suspend failed", "error", err) Shell() - log.Warnln("Attempting a reboot") + slog.Warn("Attempting a reboot") Reboot() } } diff --git a/pkg/realm/mount.go b/pkg/realm/mount.go index b03e0a06..2a5f5614 100644 --- a/pkg/realm/mount.go +++ b/pkg/realm/mount.go @@ -1,208 +1,119 @@ -//+build linux +//go:build linux package realm import ( "fmt" + "log/slog" "os" "syscall" - - log "github.com/sirupsen/logrus" ) -// DefaultMounts will return the defult mounts +// DefaultMounts will return the default mounts. func DefaultMounts() *Mounts { - m := &Mounts{} - - // bin Mount - bin := Mount{ - CreateMount: false, - EnableMount: false, - Name: "bin", - Path: "/bin", - Mode: 0777, - } - m.Mount = append(m.Mount, bin) - - // - dev := Mount{ - CreateMount: false, - EnableMount: false, - Name: "dev", - Source: "devtmpfs", - Path: "/dev", - FSType: "devtmpfs", - Flags: syscall.MS_MGC_VAL, - Mode: 0777, - } - m.Mount = append(m.Mount, dev) - - // - etc := Mount{ - CreateMount: false, - EnableMount: false, - Name: "etc", - Path: "/etc", - Mode: 0777, - } - m.Mount = append(m.Mount, etc) - - // - home := Mount{ - CreateMount: false, - EnableMount: false, - Name: "home", - Path: "/home", - Mode: 0777, - } - m.Mount = append(m.Mount, home) - - // - mnt := Mount{ - CreateMount: false, - EnableMount: false, - Name: "mnt", - Path: "/mnt", - Mode: 0777, - } - m.Mount = append(m.Mount, mnt) - - // - proc := Mount{ - CreateMount: false, - EnableMount: false, - Name: "proc", - Source: "proc", - Path: "/proc", - FSType: "proc", - Mode: 0777, - } - m.Mount = append(m.Mount, proc) - - // - sys := Mount{ - CreateMount: false, - EnableMount: false, - Name: "sys", - Source: "sysfs", - Path: "/sys", - FSType: "sysfs", - Mode: 0777, - } - m.Mount = append(m.Mount, sys) - - // - tmp := Mount{ - CreateMount: false, - EnableMount: false, - Name: "tmp", - Source: "tmpfs", - Path: "/tmp", - FSType: "tmpfs", - Mode: 0777, + return &Mounts{ + Mount: []Mount{ + {Name: "bin", Path: "/bin", Mode: 0o777}, + {Name: "dev", Source: "devtmpfs", Path: "/dev", FSType: "devtmpfs", Flags: syscall.MS_MGC_VAL, Mode: 0o777}, + {Name: "etc", Path: "/etc", Mode: 0o777}, + {Name: "home", Path: "/home", Mode: 0o777}, + {Name: "mnt", Path: "/mnt", Mode: 0o777}, + {Name: "proc", Source: "proc", Path: "/proc", FSType: "proc", Mode: 0o777}, + {Name: "sys", Source: "sysfs", Path: "/sys", FSType: "sysfs", Mode: 0o777}, + {Name: "tmp", Source: "tmpfs", Path: "/tmp", FSType: "tmpfs", Mode: 0o777}, + {Name: "usr", Path: "/usr", Mode: 0o777}, + }, } - m.Mount = append(m.Mount, tmp) - - // - usr := Mount{ - CreateMount: false, - EnableMount: false, - Name: "usr", - Path: "/usr", - Mode: 0777, - } - m.Mount = append(m.Mount, usr) - - return m } -// CreateFolder - +// CreateFolder creates directories for all mounts that have CreateMount set. func (m *Mounts) CreateFolder() error { for x := range m.Mount { - if m.Mount[x].CreateMount == true { + if m.Mount[x].CreateMount { err := os.MkdirAll(m.Mount[x].Path, m.Mount[x].Mode) if err != nil { - return fmt.Errorf("Folder[%s] create error [%v]", m.Mount[x].Path, err) + return fmt.Errorf("folder [%s] create error: %w", m.Mount[x].Path, err) } - log.Infof("Folder created [%s] -> [%s]", m.Mount[x].Name, m.Mount[x].Path) + slog.Info("Folder created", "name", m.Mount[x].Name, "path", m.Mount[x].Path) } } return nil } -// MountAll - +// MountAll mounts all enabled partitions. func (m *Mounts) MountAll() error { for x := range m.Mount { - if m.Mount[x].EnableMount == true { + if m.Mount[x].EnableMount { err := syscall.Mount(m.Mount[x].Source, m.Mount[x].Path, m.Mount[x].FSType, m.Mount[x].Flags, m.Mount[x].Options) if err != nil { - return fmt.Errorf("Mounting [%s] -> [%s] error [%v]", m.Mount[x].Source, m.Mount[x].Path, err) + return fmt.Errorf("mounting [%s] -> [%s]: %w", m.Mount[x].Source, m.Mount[x].Path, err) } - log.Infof("Mounted [%s] -> [%s]", m.Mount[x].Name, m.Mount[x].Path) + slog.Info("Mounted", "name", m.Mount[x].Name, "path", m.Mount[x].Path) } } return nil } -// MountNamed - +// MountNamed mounts a single named partition. func (m *Mounts) MountNamed(name string, remove bool) error { for x := range m.Mount { - if m.Mount[x].Name == name && m.Mount[x].EnableMount == true { - err := syscall.Mount(m.Mount[x].Source, m.Mount[x].Path, m.Mount[x].FSType, m.Mount[x].Flags, m.Mount[x].Options) - if err != nil { - return fmt.Errorf("Mounting [%s] -> [%s] error [%v]", m.Mount[x].Source, m.Mount[x].Path, err) - } + if m.Mount[x].Name != name || !m.Mount[x].EnableMount { + continue + } - log.Infof("Mounted [%s] -> [%s]", m.Mount[x].Name, m.Mount[x].Path) - // Remove this element - if remove { - m.Mount = append(m.Mount[:x], m.Mount[x+1:]...) - } - return nil + err := syscall.Mount(m.Mount[x].Source, m.Mount[x].Path, m.Mount[x].FSType, m.Mount[x].Flags, m.Mount[x].Options) + if err != nil { + return fmt.Errorf("mounting [%s] -> [%s]: %w", m.Mount[x].Source, m.Mount[x].Path, err) } + + slog.Info("Mounted", "name", m.Mount[x].Name, "path", m.Mount[x].Path) + // Remove this element + if remove { + m.Mount = append(m.Mount[:x], m.Mount[x+1:]...) + } + return nil } return nil } -// UnMountAll - will unmount all partitions +// UnMountAll will unmount all partitions. func (m *Mounts) UnMountAll() error { for x := range m.Mount { - err := syscall.Unmount(m.Mount[x].Path, int(m.Mount[x].Flags)) + err := syscall.Unmount(m.Mount[x].Path, int(m.Mount[x].Flags)) //nolint:gosec // G115: flags are small values, no overflow risk if err != nil { - return fmt.Errorf("Unmounting [%s] -> [%s] error [%v]", m.Mount[x].Source, m.Mount[x].Path, err) + return fmt.Errorf("unmounting [%s] -> [%s]: %w", m.Mount[x].Source, m.Mount[x].Path, err) } - log.Infof("Unmounted [%s] -> [%s]", m.Mount[x].Name, m.Mount[x].Path) - return nil + slog.Info("Unmounted", "name", m.Mount[x].Name, "path", m.Mount[x].Path) } return nil } -// UnMountNamed - will unmount a partition +// UnMountNamed will unmount a named partition. func (m *Mounts) UnMountNamed(name string) error { for x := range m.Mount { - if m.Mount[x].Name == name { - err := syscall.Unmount(m.Mount[x].Path, syscall.MNT_FORCE) - - if err != nil { - return fmt.Errorf("Unmounting [%s] -> [%s] error [%v]", m.Mount[x].Source, m.Mount[x].Path, err) - } - - log.Infof("Unmounted [%s] -> [%s]", m.Mount[x].Name, m.Mount[x].Path) - // Remove this element - m.Mount = append(m.Mount[:x], m.Mount[x+1:]...) - return nil + if m.Mount[x].Name != name { + continue + } + err := syscall.Unmount(m.Mount[x].Path, syscall.MNT_FORCE) + if err != nil { + return fmt.Errorf("unmounting [%s] -> [%s]: %w", m.Mount[x].Source, m.Mount[x].Path, err) } + + slog.Info("Unmounted", "name", m.Mount[x].Name, "path", m.Mount[x].Path) + // Remove this element + m.Mount = append(m.Mount[:x], m.Mount[x+1:]...) + return nil } - return fmt.Errorf("Unable to find mount [%s]", name) + return fmt.Errorf("unable to find mount [%s]", name) } -// GetMount - +// GetMount returns a pointer to the named mount. func (m *Mounts) GetMount(name string) *Mount { for x := range m.Mount { diff --git a/pkg/realm/networking.go b/pkg/realm/networking.go index f64d2598..d145252f 100644 --- a/pkg/realm/networking.go +++ b/pkg/realm/networking.go @@ -1,19 +1,19 @@ -//+build linux +//go:build linux package realm import ( "fmt" + "log/slog" "net" "os" "os/signal" "syscall" - "github.com/plunder-app/BOOTy/pkg/plunderclient/types" - "github.com/plunder-app/BOOTy/pkg/utils" - log "github.com/sirupsen/logrus" + "github.com/telekom/BOOTy/pkg/plunderclient/types" + "github.com/telekom/BOOTy/pkg/utils" "github.com/vishvananda/netlink" - "gopkg.in/yaml.v2" + "gopkg.in/yaml.v3" "github.com/digineo/go-dhclient" "github.com/google/gopacket/layers" @@ -23,20 +23,20 @@ const ifname = "eth0" const netplanPath = "/etc/netplan/plunder_netplan.yaml" -// LeasedAddress is the currently leased address +// LeasedAddress is the currently leased address. var LeasedAddress string -// GetMAC will return a mac address +// GetMAC will return a mac address. func GetMAC() (string, error) { // retrieve interface from name iface, err := net.InterfaceByName(ifname) if err != nil { - return "", err + return "", fmt.Errorf("finding interface %s: %w", ifname, err) } return iface.HardwareAddr.String(), nil } -// WriteNetPlan - will write a netplan to disk +// WriteNetPlan will write a netplan to disk. func WriteNetPlan(chroot string, cfg *types.BootyConfig) error { // Find the mac address of the adapter (interface) @@ -69,73 +69,69 @@ func WriteNetPlan(chroot string, cfg *types.BootyConfig) error { n.Network.Ethernets["eth0"] = e b, err := yaml.Marshal(n) if err != nil { - return err + return fmt.Errorf("marshaling netplan: %w", err) } // TODO - remove netplan output fmt.Printf("\n%s\n", b) f, err := os.Create(chrootPath) if err != nil { - return err + return fmt.Errorf("creating netplan file: %w", err) } - defer f.Close() + defer func() { _ = f.Close() }() _, err = f.Write(b) if err != nil { - return err + return fmt.Errorf("writing netplan: %w", err) } return nil } -// ApplyNetplan - this will be done through an /etc/rc.local (TODO) +// ApplyNetplan - this will be done through an /etc/rc.local (TODO). func ApplyNetplan(chroot string) error { chrootPath := fmt.Sprintf("%s%s", chroot, "/etc/rc.local") - //rclocal := "#!/bin/sh -e\n/usr/sbin/netplan apply\ndd if=/dev/zero of=/dev/sda bs=1024k count=50" rclocal := "#!/bin/sh -e\n/usr/sbin/netplan apply\nrm /etc/rc.local" f, err := os.Create(chrootPath) if err != nil { - return err + return fmt.Errorf("creating rc.local: %w", err) } - defer f.Close() + defer func() { _ = f.Close() }() - _, err = f.Write([]byte(rclocal)) + _, err = f.WriteString(rclocal) if err != nil { - return err + return fmt.Errorf("writing rc.local: %w", err) } // set executable - err = os.Chmod(chrootPath, 0777) + err = os.Chmod(chrootPath, 0o755) //nolint:gosec // G302: executable script needs 0755 if err != nil { - return err + return fmt.Errorf("setting rc.local permissions: %w", err) } return nil } -// DHCPClient starts the DHCP client listening for a lease +// DHCPClient starts the DHCP client listening for a lease. func DHCPClient() error { // Bring up interface ifaceDev, err := netlink.LinkByName(ifname) if err != nil { - log.Errorf("Error finding adapter [%v]", err) - - return err + slog.Error("Error finding adapter", "error", err) + return fmt.Errorf("finding adapter %s: %w", ifname, err) } if err := netlink.LinkSetUp(ifaceDev); err != nil { - log.Errorf("Error bringing up adapter [%v]", err) + slog.Error("Error bringing up adapter", "error", err) } - // Setup interface to recieve DHCP traffic iface, err := net.InterfaceByName(ifname) if err != nil { - log.Errorf("Error finding interface by name [%v]", err) - - return err + slog.Error("Error finding interface by name", "error", err) + return fmt.Errorf("finding interface %s: %w", ifname, err) } client := dhclient.Client{ Iface: iface, @@ -154,9 +150,9 @@ func DHCPClient() error { err = netlink.AddrAdd(link, addr) if err != nil { - log.Errorf("Error adding %s to link %s", cidr.String(), iface.Name) + slog.Error("Error adding address to link", "address", cidr.String(), "link", iface.Name) } else { - log.Printf("Adding address %s to link %s", cidr.String(), iface.Name) + slog.Info("Adding address to link", "address", cidr.String(), "link", iface.Name) } // Apply default gateway so we can route outside @@ -165,56 +161,42 @@ func DHCPClient() error { Gw: lease.ServerID, } if err := netlink.RouteAdd(&route); err != nil { - log.Errorf("Error setting gateway [%v]", err) + slog.Error("Error setting gateway", "error", err) } else { - log.Printf("Adding gateway %s to link %s", lease.ServerID.String(), iface.Name) + slog.Info("Adding gateway to link", "gateway", lease.ServerID.String(), "link", iface.Name) } }, } // Add requests for default options for _, param := range dhclient.DefaultParamsRequestList { - log.Printf("Requesting default option %d", param) - client.AddParamRequest(layers.DHCPOpt(param)) + slog.Info("Requesting default option", "option", param) + client.AddParamRequest(param) } - // // Add requests for custom options - // for _, param := range requestParams { - // log.Printf("Requesting custom option %d", param) - // client.AddParamRequest(layers.DHCPOpt(param)) - // } - // Add hostname option hostname, _ := os.Hostname() client.AddOption(layers.DHCPOptHostname, []byte(hostname)) - // // Add custom options - // for _, option := range options { - // log.Printf("Adding option %d=0x%x", option.Type, option.Data) - // client.AddOption(option.Type, option.Data) - // } - client.Start() defer client.Stop() // Below will sit - c := make(chan os.Signal) + c := make(chan os.Signal, 1) signal.Notify(c, os.Interrupt, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP, syscall.SIGUSR1) for { sig := <-c - log.Println("received", sig) + slog.Info("Received signal", "signal", sig) switch sig { case syscall.SIGINT, syscall.SIGTERM: return nil case syscall.SIGHUP: - log.Println("renew lease") + slog.Info("Renewing lease") client.Renew() case syscall.SIGUSR1: - log.Println("acquire new lease") + slog.Info("Acquiring new lease") client.Rebind() } } - //log.Errorf("DHCP client has ended") - //return nil } diff --git a/pkg/realm/shell.go b/pkg/realm/shell.go index 2f078905..4b0c3653 100644 --- a/pkg/realm/shell.go +++ b/pkg/realm/shell.go @@ -1,28 +1,28 @@ +//go:build linux + package realm import ( + "context" + "log/slog" "os" "os/exec" - - log "github.com/sirupsen/logrus" ) -// Shell will Start a userland shell +// Shell will Start a userland shell. func Shell() { - // Shell stuff - log.Println("Starting Shell") + slog.Info("Starting Shell") - // TTY hack to support ctrl+c - cmd := exec.Command("/usr/bin/setsid", "cttyhack", "/bin/sh") + cmd := exec.CommandContext(context.Background(), "/usr/bin/setsid", "cttyhack", "/bin/sh") cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr err := cmd.Start() if err != nil { - log.Errorf("Shell error [%v]", err) + slog.Error("Shell error", "error", err) } - log.Printf("Waiting for command to finish...") + slog.Info("Waiting for command to finish...") err = cmd.Wait() if err != nil { - log.Errorf("Shell error [%v]", err) + slog.Error("Shell error", "error", err) } } diff --git a/pkg/realm/types.go b/pkg/realm/types.go index afe3f2b2..d1d0ac65 100644 --- a/pkg/realm/types.go +++ b/pkg/realm/types.go @@ -2,7 +2,7 @@ package realm import "os" -// Mount contains the configuration for a single mount within the initramfs +// Mount contains the configuration for a single mount within the initramfs. type Mount struct { // Create the location on disk CreateMount bool @@ -20,12 +20,12 @@ type Mount struct { Options string } -// Mounts are the paths that can be mounted or created on boot +// Mounts are the paths that can be mounted or created on boot. type Mounts struct { Mount []Mount } -// Device contains the configuration for a single device within the initramfs +// Device contains the configuration for a single device within the initramfs. type Device struct { // Create the device within the ramdisk CreateDevice bool @@ -38,12 +38,12 @@ type Device struct { Minor int64 } -// Devices are the devices that can be created on boot +// Devices are the devices that can be created on boot. type Devices struct { Device []Device } -// Netplan outlines the Debian netplan configuration +// Netplan outlines the Debian netplan configuration. type Netplan struct { Network struct { Ethernets map[string]interface{} `yaml:"ethernets"` @@ -52,7 +52,7 @@ type Netplan struct { } `yaml:"network"` } -// Ethernets defines a connection +// Ethernets defines a connection. type Ethernets struct { Match struct { Macaddress string `yaml:"macaddress,omitempty"` diff --git a/pkg/realm/types_test.go b/pkg/realm/types_test.go new file mode 100644 index 00000000..56bbba80 --- /dev/null +++ b/pkg/realm/types_test.go @@ -0,0 +1,46 @@ +package realm + +import ( + "testing" +) + +func TestMountTypes(t *testing.T) { + m := Mount{ + CreateMount: true, + EnableMount: false, + Name: "test", + Source: "tmpfs", + Path: "/tmp", + FSType: "tmpfs", + } + + if m.Name != "test" { + t.Errorf("Mount.Name = %q, want %q", m.Name, "test") + } + if !m.CreateMount { + t.Error("Mount.CreateMount should be true") + } + if m.EnableMount { + t.Error("Mount.EnableMount should be false") + } +} + +func TestDeviceTypes(t *testing.T) { + d := Device{ + CreateDevice: true, + Name: "null", + Path: "/dev/null", + Major: 1, + Minor: 3, + } + + if d.Name != "null" { + t.Errorf("Device.Name = %q, want %q", d.Name, "null") + } + if d.Major != 1 { + t.Errorf("Device.Major = %d, want 1", d.Major) + } + if d.Minor != 3 { + t.Errorf("Device.Minor = %d, want 3", d.Minor) + } +} diff --git a/pkg/utils/utils.go b/pkg/utils/utils.go index 700efa3e..cb6b42ec 100644 --- a/pkg/utils/utils.go +++ b/pkg/utils/utils.go @@ -1,27 +1,27 @@ package utils import ( + "errors" "fmt" - "io/ioutil" "os" "path" "strconv" "strings" ) -//CmdlinePath is the default location for the cmdline +// CmdlinePath is the default location for the cmdline. const CmdlinePath = "/proc/cmdline" -// ParseCmdLine will read through the command line and return the source and destination -func ParseCmdLine(path string) (m map[string]string, err error) { +// ParseCmdLine will read through the command line and return the source and destination. +func ParseCmdLine(cmdlinePath string) (m map[string]string, err error) { // allow path override - if path == "" { - path = CmdlinePath + if cmdlinePath == "" { + cmdlinePath = CmdlinePath } m = make(map[string]string) // Read the file - b, err := ioutil.ReadFile(path) + b, err := os.ReadFile(cmdlinePath) if err != nil { return } @@ -39,41 +39,47 @@ func ParseCmdLine(path string) (m map[string]string, err error) { return } -//ClearScreen will clear the screen of all text +// ClearScreen will clear the screen of all text. func ClearScreen() { fmt.Print("\033[2J") } -// GetBlockDeviceSize will read the size from the /sys/block for a specific block device +// GetBlockDeviceSize will read the size from the /sys/block for a specific block device. func GetBlockDeviceSize(device string) (int64, error) { // This should return the path to the block device and it's size (in sectores) // Each sector is 512 bytes - path := fmt.Sprintf("/sys/block/%s/size", device) + devPath := fmt.Sprintf("/sys/block/%s/size", device) - data, err := ioutil.ReadFile(path) + data, err := os.ReadFile(devPath) if err != nil { - return 0, err + return 0, fmt.Errorf("reading block device size: %w", err) } parsedData := strings.TrimSpace(string(data)) - size, _ := strconv.ParseInt(parsedData, 10, 64) + size, err := strconv.ParseInt(parsedData, 10, 64) + if err != nil { + return 0, fmt.Errorf("parsing block device size %q: %w", parsedData, err) + } return size * 512, nil } -// DashMac makes a mac address something that can be used in a URL +// DashMac makes a mac address something that can be used in a URL. func DashMac(mac string) string { - return strings.Replace(mac, ":", "-", -1) + return strings.ReplaceAll(mac, ":", "-") } -// ClearDir is a helper function to remove all files in a directory +// ClearDir is a helper function to remove all files in a directory. func ClearDir(dir string) error { - names, err := ioutil.ReadDir(dir) + names, err := os.ReadDir(dir) if err != nil { - return err + return fmt.Errorf("reading directory %q: %w", dir, err) } - for _, entery := range names { - os.RemoveAll(path.Join([]string{dir, entery.Name()}...)) + var errs []error + for _, entry := range names { + if err := os.RemoveAll(path.Join(dir, entry.Name())); err != nil { + errs = append(errs, err) + } } - return nil + return errors.Join(errs...) } diff --git a/pkg/utils/utils_test.go b/pkg/utils/utils_test.go new file mode 100644 index 00000000..47e0b36a --- /dev/null +++ b/pkg/utils/utils_test.go @@ -0,0 +1,131 @@ +package utils + +import ( + "os" + "path/filepath" + "testing" +) + +func TestDashMac(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"00:11:22:33:44:55", "00-11-22-33-44-55"}, + {"aa:bb:cc:dd:ee:ff", "aa-bb-cc-dd-ee-ff"}, + {"no-colons", "no-colons"}, + {"", ""}, + } + for _, tt := range tests { + result := DashMac(tt.input) + if result != tt.expected { + t.Errorf("DashMac(%q) = %q, want %q", tt.input, result, tt.expected) + } + } +} + +func TestParseCmdLine(t *testing.T) { + // Create a temporary file with cmdline content + tmpDir := t.TempDir() + tmpFile := filepath.Join(tmpDir, "cmdline") + + content := "root=/dev/sda1 console=ttyS0 quiet splash" + if err := os.WriteFile(tmpFile, []byte(content), 0644); err != nil { + t.Fatalf("failed to write temp file: %v", err) + } + + m, err := ParseCmdLine(tmpFile) + if err != nil { + t.Fatalf("ParseCmdLine() error: %v", err) + } + + expected := map[string]string{ + "root": "/dev/sda1", + "console": "ttyS0", + } + + for k, v := range expected { + if m[k] != v { + t.Errorf("ParseCmdLine()[%q] = %q, want %q", k, m[k], v) + } + } + + // "quiet" and "splash" have no = so should not appear + if _, ok := m["quiet"]; ok { + t.Error("ParseCmdLine() should not include entries without '='") + } +} + +func TestParseCmdLineEmptyPath(t *testing.T) { + // With empty path, it defaults to /proc/cmdline which may not exist in test env + _, err := ParseCmdLine("") + // We just verify it doesn't panic; error is OK if /proc/cmdline doesn't exist + _ = err +} + +func TestParseCmdLineNonExistent(t *testing.T) { + _, err := ParseCmdLine("/nonexistent/path/cmdline") + if err == nil { + t.Error("ParseCmdLine() with non-existent path should return error") + } +} + +func TestClearDir(t *testing.T) { + tmpDir := t.TempDir() + + // Create some files + for _, name := range []string{"a.txt", "b.txt", "c.txt"} { + if err := os.WriteFile(filepath.Join(tmpDir, name), []byte("test"), 0644); err != nil { + t.Fatalf("failed to create test file: %v", err) + } + } + + // Create a subdirectory + subDir := filepath.Join(tmpDir, "subdir") + if err := os.Mkdir(subDir, 0755); err != nil { + t.Fatalf("failed to create subdirectory: %v", err) + } + + if err := ClearDir(tmpDir); err != nil { + t.Fatalf("ClearDir() error: %v", err) + } + + entries, err := os.ReadDir(tmpDir) + if err != nil { + t.Fatalf("failed to read dir after ClearDir: %v", err) + } + if len(entries) != 0 { + t.Errorf("ClearDir() left %d entries, want 0", len(entries)) + } +} + +func TestClearDirNonExistent(t *testing.T) { + err := ClearDir("/nonexistent/dir") + if err == nil { + t.Error("ClearDir() with non-existent dir should return error") + } +} + +func TestGetBlockDeviceSize(t *testing.T) { + // Create a fake /sys/block//size file + tmpDir := t.TempDir() + sizeDir := filepath.Join(tmpDir, "fakedev") + if err := os.MkdirAll(sizeDir, 0755); err != nil { + t.Fatal(err) + } + // Write "2048\n" meaning 2048 sectors * 512 = 1048576 bytes + if err := os.WriteFile(filepath.Join(sizeDir, "size"), []byte("2048\n"), 0644); err != nil { + t.Fatal(err) + } + + // We can't easily override the /sys/block path, so just test the error case + _, err := GetBlockDeviceSize("nonexistent_device_xyz") + if err == nil { + t.Error("GetBlockDeviceSize() with non-existent device should return error") + } +} + +func TestClearScreen(t *testing.T) { + // Smoke test: should not panic + ClearScreen() +} diff --git a/pkg/ux/captain.go b/pkg/ux/captain.go index 43975d82..7461d4a6 100644 --- a/pkg/ux/captain.go +++ b/pkg/ux/captain.go @@ -2,7 +2,7 @@ package ux import "fmt" -// Captain will display over the top Ascii art +// Captain will display over the top Ascii art. func Captain() { fmt.Println("                                         ,   .                             ") fmt.Println("                                      *..,       ,,,.                      ") diff --git a/pkg/ux/captain_test.go b/pkg/ux/captain_test.go new file mode 100644 index 00000000..e9e5a0c3 --- /dev/null +++ b/pkg/ux/captain_test.go @@ -0,0 +1,8 @@ +package ux + +import "testing" + +func TestCaptain(t *testing.T) { + // Smoke test: Captain() should not panic + Captain() +} diff --git a/pkg/ux/sysinfo.go b/pkg/ux/sysinfo.go index a02fc671..11325d37 100644 --- a/pkg/ux/sysinfo.go +++ b/pkg/ux/sysinfo.go @@ -1,4 +1,4 @@ -// +build linux +//go:build linux package ux @@ -10,6 +10,7 @@ import ( "github.com/zcalusic/sysinfo" ) +// SysInfo prints system hardware information to stdout. func SysInfo() { var si sysinfo.SysInfo @@ -17,26 +18,21 @@ func SysInfo() { fmt.Println("") fmt.Println("------------ BOOTy System Information ------------") w := tabwriter.NewWriter(os.Stdout, 0, 0, 1, ' ', 0) - // 44 fmt.Fprintln(w, "a\tb\tc") - // 45 fmt.Fprintln(w, "aa\tbb\tcc") - // 46 fmt.Fprintln(w, "aaa\t") // trailing tab - // 47 fmt.Fprintln(w, "aaaa\tdddd\teeee") - // 48 w.Flush() - - fmt.Fprintf(w, "CPU:\t %s\n", si.CPU.Model) - fmt.Fprintf(w, "CPU speed:\t %dMHz\n", si.CPU.Speed) - fmt.Fprintf(w, "MEM size:\t %dMB\n", si.Memory.Size) + + _, _ = fmt.Fprintf(w, "CPU:\t %s\n", si.CPU.Model) + _, _ = fmt.Fprintf(w, "CPU speed:\t %dMHz\n", si.CPU.Speed) + _, _ = fmt.Fprintf(w, "MEM size:\t %dMB\n", si.Memory.Size) for x := range si.Network { - fmt.Fprintf(w, "Network device:\t %s\n", si.Network[x].Name) - fmt.Fprintf(w, "Network driver:\t %s\n", si.Network[x].Driver) - fmt.Fprintf(w, "Network address:\t %s\n", si.Network[x].MACAddress) + _, _ = fmt.Fprintf(w, "Network device:\t %s\n", si.Network[x].Name) + _, _ = fmt.Fprintf(w, "Network driver:\t %s\n", si.Network[x].Driver) + _, _ = fmt.Fprintf(w, "Network address:\t %s\n", si.Network[x].MACAddress) } for x := range si.Storage { - fmt.Fprintf(w, "Storage device:\t %s\n", si.Storage[x].Name) - fmt.Fprintf(w, "Storage driver:\t %s\n", si.Storage[x].Driver) - fmt.Fprintf(w, "Storage size:\t %dGB\n", si.Storage[x].Size) + _, _ = fmt.Fprintf(w, "Storage device:\t %s\n", si.Storage[x].Name) + _, _ = fmt.Fprintf(w, "Storage driver:\t %s\n", si.Storage[x].Driver) + _, _ = fmt.Fprintf(w, "Storage size:\t %dGB\n", si.Storage[x].Size) } - w.Flush() + _ = w.Flush() fmt.Println("--------------------------------------------------") fmt.Println("") diff --git a/server/server.go b/server/server.go index e77282a9..a350824c 100644 --- a/server/server.go +++ b/server/server.go @@ -5,25 +5,27 @@ import ( "flag" "fmt" "io" + "log/slog" "net/http" "os" "strings" - - log "github.com/sirupsen/logrus" + "time" "github.com/dustin/go-humanize" - "github.com/plunder-app/BOOTy/pkg/plunderclient/types" - "github.com/plunder-app/BOOTy/pkg/utils" + "github.com/telekom/BOOTy/pkg/plunderclient/types" + "github.com/telekom/BOOTy/pkg/utils" ) -// WriteCounter counts the number of bytes written to it. It implements to the io.Writer interface -// and we can pass this into io.TeeReader() which will report progress on each write cycle. +// Server holds the state for the BOOTy provisioning server. +type Server struct { + configData []byte +} + +// WriteCounter counts the number of bytes written to it and reports progress. type WriteCounter struct { Total uint64 } -var data []byte - func (wc *WriteCounter) Write(p []byte) (int, error) { n := len(p) wc.Total += uint64(n) @@ -31,58 +33,56 @@ func (wc *WriteCounter) Write(p []byte) (int, error) { return n, nil } -//PrintProgress - -func (wc WriteCounter) PrintProgress() { - // Clear the line by using a character return to go back to the start and remove - // the remaining characters by filling it with spaces +// PrintProgress displays write progress. +func (wc *WriteCounter) PrintProgress() { fmt.Printf("\r%s", strings.Repeat(" ", 35)) - - // Return again and print current status of download - // We use the humanize package to print the bytes in a meaningful way (e.g. 10 MB) fmt.Printf("\rDownloading... %s complete", humanize.Bytes(wc.Total)) fmt.Println("") } -func imageHandler(w http.ResponseWriter, r *http.Request) { - +func (s *Server) imageHandler(w http.ResponseWriter, r *http.Request) { imageName := fmt.Sprintf("%s.img", r.RemoteAddr) - r.ParseMultipartForm(32 << 20) + if err := r.ParseMultipartForm(32 << 20); err != nil { + slog.Error("Error parsing multipart form", "error", err) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } file, _, err := r.FormFile("BootyImage") if err != nil { - fmt.Println(err) + slog.Error("Error getting form file", "error", err) + http.Error(w, err.Error(), http.StatusBadRequest) return } - defer file.Close() + defer func() { _ = file.Close() }() - out, err := os.OpenFile(imageName, os.O_CREATE|os.O_WRONLY, 0644) + out, err := os.OpenFile(imageName, os.O_CREATE|os.O_WRONLY, 0o644) //nolint:gosec // image files need standard read permissions if err != nil { - log.Fatalf("%v", err) + slog.Error("Error opening file", "error", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return } - defer out.Close() + defer func() { _ = out.Close() }() // Create our progress reporter and pass it to be used alongside our writer counter := &WriteCounter{} if _, err = io.Copy(out, io.TeeReader(file, counter)); err != nil { - log.Errorf("%v", err) + slog.Error("Error copying image", "error", err) } - fmt.Printf("Beginning write of image [%s] to disk", imageName) - + slog.Info("Image received", "image", imageName) //nolint:gosec // imageName is derived from RemoteAddr, not arbitrary user input w.WriteHeader(http.StatusOK) } -func configHandler(w http.ResponseWriter, r *http.Request) { - w.Write(data) +func (s *Server) configHandler(w http.ResponseWriter, r *http.Request) { + if _, err := w.Write(s.configData); err != nil { //nolint:gosec // configData is server-controlled JSON, not user input + slog.Error("Error writing config response", "error", err) + } } -// Serve will start the webserver for BOOTy func main() { - - // Server Address rawAddress := flag.String("mac", "", "The mac address of a server") - // Build configuration from flags var config types.BootyConfig flag.StringVar(&config.Action, "action", "", "The action that is being performed [readImage/writeImage]") flag.BoolVar(&config.DryRun, "dryRun", false, "Only demonstrate the output from the actions") @@ -95,38 +95,49 @@ func main() { flag.StringVar(&config.SourceImage, "sourceImage", "", "The source for the image, typically a URL") flag.StringVar(&config.SourceDevice, "sourceDevice", "", "The device that will be the source of the image [/dev/sda]") - flag.StringVar(&config.DesintationAddress, "destinationAddress", "", "The destination that the image will be writen too [url]") - flag.StringVar(&config.DestinationDevice, "destinationDevice", "", "The destination devicethat the image will be writen too [/dev/sda]") + flag.StringVar(&config.DestinationAddress, "destinationAddress", "", "The destination that the image will be written to [url]") + flag.StringVar(&config.DestinationDevice, "destinationDevice", "", "The destination device that the image will be written to [/dev/sda]") flag.StringVar(&config.Address, "address", "", "The network address to set on the provisioned OS [address/subnet]") flag.StringVar(&config.Gateway, "gateway", "", "The gateway address to be set on the provisioned OS") flag.Parse() + srv := &Server{} + if *rawAddress == "" { - log.Warnln("No Mac address passed for BOOTy configuration") + slog.Warn("No Mac address passed for BOOTy configuration") } else { - dashmac := utils.DashMac(*rawAddress) - http.HandleFunc(fmt.Sprintf("/booty/%s.bty", dashmac), configHandler) - log.Infof("handler for [%s.bty] generated", dashmac) - data, _ = json.Marshal(config) + http.HandleFunc(fmt.Sprintf("/booty/%s.bty", dashmac), srv.configHandler) + slog.Info("Handler generated", "config", dashmac+".bty") + var err error + srv.configData, err = json.Marshal(config) + if err != nil { + slog.Error("Error marshaling config", "error", err) + os.Exit(1) + } } switch config.Action { case types.ReadImage: case types.WriteImage: default: - log.Fatalf("Unknown action [%s]", config.Action) + slog.Error("Unknown action", "action", config.Action) + os.Exit(1) } fs := http.FileServer(http.Dir("./images")) - http.HandleFunc("/image", imageHandler) + http.HandleFunc("/image", srv.imageHandler) http.Handle("/images/", http.StripPrefix("/images/", fs)) - log.Println("Listening on :3000...") - err := http.ListenAndServe(":3000", nil) + slog.Info("Listening on :3000...") + server := &http.Server{ + Addr: ":3000", + ReadHeaderTimeout: 10 * time.Second, + } + err := server.ListenAndServe() if err != nil { - log.Fatal(err) + slog.Error("Server error", "error", err) + os.Exit(1) } - } diff --git a/server/server_test.go b/server/server_test.go new file mode 100644 index 00000000..af758c7f --- /dev/null +++ b/server/server_test.go @@ -0,0 +1,177 @@ +package main + +import ( + "encoding/json" + "io" + "mime/multipart" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/telekom/BOOTy/pkg/plunderclient/types" +) + +func TestConfigHandler(t *testing.T) { + cfg := types.BootyConfig{ + Action: types.WriteImage, + SourceImage: "http://example.com/image.img", + DryRun: true, + } + configData, err := json.Marshal(cfg) + if err != nil { + t.Fatal(err) + } + srv := &Server{configData: configData} + + req := httptest.NewRequest(http.MethodGet, "/booty/aa-bb-cc.bty", nil) + w := httptest.NewRecorder() + + srv.configHandler(w, req) + + resp := w.Result() + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("expected status 200, got %d", resp.StatusCode) + } + + body, _ := io.ReadAll(resp.Body) + var got types.BootyConfig + if err := json.Unmarshal(body, &got); err != nil { + t.Fatalf("error unmarshalling response: %v", err) + } + if got.Action != types.WriteImage { + t.Errorf("expected action %q, got %q", types.WriteImage, got.Action) + } + if !got.DryRun { + t.Error("expected DryRun=true") + } +} + +func TestImageHandler(t *testing.T) { + tmpDir := t.TempDir() + origDir, _ := os.Getwd() + if err := os.Chdir(tmpDir); err != nil { + t.Fatal(err) + } + defer os.Chdir(origDir) + + // Build a multipart request + content := []byte("test image data") + body := new(strings.Builder) + writer := multipart.NewWriter(body) + part, err := writer.CreateFormFile("BootyImage", "test.img") + if err != nil { + t.Fatal(err) + } + part.Write(content) + writer.Close() + + req := httptest.NewRequest(http.MethodPost, "/image", strings.NewReader(body.String())) + req.Header.Set("Content-Type", writer.FormDataContentType()) + // Set RemoteAddr to a safe filename + req.RemoteAddr = "127.0.0.1:1234" + + w := httptest.NewRecorder() + srv := &Server{} + srv.imageHandler(w, req) + + resp := w.Result() + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("expected status 200, got %d", resp.StatusCode) + } + + // Verify the file was written + imgFile := filepath.Join(tmpDir, "127.0.0.1:1234.img") + got, err := os.ReadFile(imgFile) + if err != nil { + t.Fatalf("image file not written: %v", err) + } + if string(got) != string(content) { + t.Errorf("image content mismatch: got %q, want %q", got, content) + } +} + +func TestImageHandlerBadRequest(t *testing.T) { + // Send a non-multipart request + req := httptest.NewRequest(http.MethodPost, "/image", strings.NewReader("not multipart")) + req.Header.Set("Content-Type", "text/plain") + + w := httptest.NewRecorder() + srv := &Server{} + srv.imageHandler(w, req) + + resp := w.Result() + defer resp.Body.Close() + + if resp.StatusCode == http.StatusOK { + t.Error("expected non-200 status for bad request") + } +} + +func TestWriteCounterServer(t *testing.T) { + wc := &WriteCounter{} + d := []byte("hello world") + n, err := wc.Write(d) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if n != len(d) { + t.Errorf("expected %d, got %d", len(d), n) + } + if wc.Total != uint64(len(d)) { + t.Errorf("expected Total=%d, got %d", len(d), wc.Total) + } +} + +func TestWriteCounterMultipleWrites(t *testing.T) { + wc := &WriteCounter{} + wc.Write([]byte("hello")) + wc.Write([]byte(" world")) + if wc.Total != 11 { + t.Errorf("expected Total=11, got %d", wc.Total) + } +} + +func TestPrintProgress(t *testing.T) { + wc := WriteCounter{Total: 1024} + // Smoke test: should not panic + wc.PrintProgress() +} + +func TestImageHandlerMissingFormField(t *testing.T) { + tmpDir := t.TempDir() + origDir, _ := os.Getwd() + if err := os.Chdir(tmpDir); err != nil { + t.Fatal(err) + } + defer os.Chdir(origDir) + + body := new(strings.Builder) + writer := multipart.NewWriter(body) + part, err := writer.CreateFormFile("WrongField", "test.img") + if err != nil { + t.Fatal(err) + } + part.Write([]byte("data")) + writer.Close() + + req := httptest.NewRequest(http.MethodPost, "/image", strings.NewReader(body.String())) + req.Header.Set("Content-Type", writer.FormDataContentType()) + req.RemoteAddr = "127.0.0.1:9999" + + w := httptest.NewRecorder() + srv := &Server{} + srv.imageHandler(w, req) + + resp := w.Result() + defer resp.Body.Close() + if resp.StatusCode == http.StatusOK { + t.Error("expected non-200 status for missing BootyImage field") + } +} diff --git a/test/e2e/e2e_test.go b/test/e2e/e2e_test.go new file mode 100644 index 00000000..e51d68d2 --- /dev/null +++ b/test/e2e/e2e_test.go @@ -0,0 +1,4 @@ +//go:build e2e + +// Package e2e contains end-to-end tests for BOOTy. +package e2e diff --git a/test/e2e/server_test.go b/test/e2e/server_test.go new file mode 100644 index 00000000..40f34769 --- /dev/null +++ b/test/e2e/server_test.go @@ -0,0 +1,362 @@ +//go:build e2e + +package e2e + +import ( + "bytes" + "compress/gzip" + "crypto/rand" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/telekom/BOOTy/pkg/plunderclient/types" +) + +// newTestServer returns an httptest.Server wired up with image upload, +// config serving, and static file serving endpoints. +func newTestServer(t *testing.T, dir string, config *types.BootyConfig) *httptest.Server { + t.Helper() + + mux := http.NewServeMux() + + // Image upload handler — writes multipart form file to dir. + mux.HandleFunc("/image", func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseMultipartForm(32 << 20); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + file, header, err := r.FormFile("BootyImage") + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + defer func() { _ = file.Close() }() + + dst := filepath.Join(dir, header.Filename) + out, err := os.Create(dst) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + defer func() { _ = out.Close() }() + + if _, err := io.Copy(out, file); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusOK) + }) + + // Config endpoint. + if config != nil { + data, err := json.Marshal(config) + if err != nil { + t.Fatal(err) + } + mux.HandleFunc("/booty/test.bty", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(data) + }) + } + + // Static file server for images directory. + mux.Handle("/images/", http.StripPrefix("/images/", http.FileServer(http.Dir(dir)))) + + return httptest.NewServer(mux) +} + +func TestImageUploadRoundTrip(t *testing.T) { + dir := t.TempDir() + srv := newTestServer(t, dir, nil) + defer srv.Close() + + // Create random test data (1 KB). + payload := make([]byte, 1024) + if _, err := rand.Read(payload); err != nil { + t.Fatal(err) + } + + // Upload via multipart form. + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + part, err := writer.CreateFormFile("BootyImage", "test-upload.img") + if err != nil { + t.Fatal(err) + } + if _, err := part.Write(payload); err != nil { + t.Fatal(err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + + resp, err := http.Post(srv.URL+"/image", writer.FormDataContentType(), body) //nolint:gosec // test URL + if err != nil { + t.Fatal(err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("upload failed: status %d", resp.StatusCode) + } + + // Verify uploaded file matches. + got, err := os.ReadFile(filepath.Join(dir, "test-upload.img")) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, payload) { + t.Error("uploaded file content does not match original payload") + } +} + +func TestImageDownloadRoundTrip(t *testing.T) { + dir := t.TempDir() + srv := newTestServer(t, dir, nil) + defer srv.Close() + + // Write a file directly to the images directory. + payload := []byte("this is a test disk image") + if err := os.WriteFile(filepath.Join(dir, "download.img"), payload, 0o600); err != nil { + t.Fatal(err) + } + + // Download via static file server. + resp, err := http.Get(srv.URL + "/images/download.img") //nolint:gosec // test URL + if err != nil { + t.Fatal(err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("download failed: status %d", resp.StatusCode) + } + + got, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, payload) { + t.Error("downloaded content does not match original file") + } +} + +func TestCompressedUploadRoundTrip(t *testing.T) { + dir := t.TempDir() + srv := newTestServer(t, dir, nil) + defer srv.Close() + + // Create test data and gzip it. + original := []byte("repeated data for compression test - repeated data for compression test") + var compressed bytes.Buffer + gzw := gzip.NewWriter(&compressed) + if _, err := gzw.Write(original); err != nil { + t.Fatal(err) + } + if err := gzw.Close(); err != nil { + t.Fatal(err) + } + + // Upload compressed data. + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + part, err := writer.CreateFormFile("BootyImage", "test.zmg") + if err != nil { + t.Fatal(err) + } + if _, err := part.Write(compressed.Bytes()); err != nil { + t.Fatal(err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + + resp, err := http.Post(srv.URL+"/image", writer.FormDataContentType(), body) //nolint:gosec // test URL + if err != nil { + t.Fatal(err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("upload failed: status %d", resp.StatusCode) + } + + // Download and decompress. + dlResp, err := http.Get(srv.URL + "/images/test.zmg") //nolint:gosec // test URL + if err != nil { + t.Fatal(err) + } + defer func() { _ = dlResp.Body.Close() }() + + gzr, err := gzip.NewReader(dlResp.Body) + if err != nil { + t.Fatal(err) + } + defer func() { _ = gzr.Close() }() + + decompressed, err := io.ReadAll(gzr) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(decompressed, original) { + t.Error("decompressed content does not match original") + } +} + +func TestConfigEndpoint(t *testing.T) { + dir := t.TempDir() + cfg := &types.BootyConfig{ + Action: types.WriteImage, + SourceImage: "http://example.com/test.img", + DestinationDevice: "/dev/sda", + DryRun: true, + DropToShell: false, + GrowPartition: 1, + } + srv := newTestServer(t, dir, cfg) + defer srv.Close() + + resp, err := http.Get(srv.URL + "/booty/test.bty") //nolint:gosec // test URL + if err != nil { + t.Fatal(err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("config fetch failed: status %d", resp.StatusCode) + } + + var got types.BootyConfig + if err := json.NewDecoder(resp.Body).Decode(&got); err != nil { + t.Fatal(err) + } + + if got.Action != types.WriteImage { + t.Errorf("expected action %q, got %q", types.WriteImage, got.Action) + } + if got.SourceImage != cfg.SourceImage { + t.Errorf("expected sourceImage %q, got %q", cfg.SourceImage, got.SourceImage) + } + if got.DestinationDevice != cfg.DestinationDevice { + t.Errorf("expected destinationDevice %q, got %q", cfg.DestinationDevice, got.DestinationDevice) + } + if !got.DryRun { + t.Error("expected DryRun=true") + } +} + +func TestConfigNotFound(t *testing.T) { + dir := t.TempDir() + srv := newTestServer(t, dir, nil) // no config registered + defer srv.Close() + + resp, err := http.Get(srv.URL + "/booty/nonexistent.bty") //nolint:gosec // test URL + if err != nil { + t.Fatal(err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode == http.StatusOK { + t.Error("expected non-200 for missing config") + } +} + +func TestLargeImageUpload(t *testing.T) { + dir := t.TempDir() + srv := newTestServer(t, dir, nil) + defer srv.Close() + + // Create 1 MB of random data. + payload := make([]byte, 1<<20) + if _, err := rand.Read(payload); err != nil { + t.Fatal(err) + } + + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + part, err := writer.CreateFormFile("BootyImage", "large.img") + if err != nil { + t.Fatal(err) + } + if _, err := part.Write(payload); err != nil { + t.Fatal(err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + + resp, err := http.Post(srv.URL+"/image", writer.FormDataContentType(), body) //nolint:gosec // test URL + if err != nil { + t.Fatal(err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("upload failed: status %d", resp.StatusCode) + } + + got, err := os.ReadFile(filepath.Join(dir, "large.img")) + if err != nil { + t.Fatal(err) + } + if len(got) != len(payload) { + t.Errorf("size mismatch: got %d, want %d", len(got), len(payload)) + } + if !bytes.Equal(got, payload) { + t.Error("large file content does not match") + } +} + +func TestUploadDownloadIntegrity(t *testing.T) { + dir := t.TempDir() + srv := newTestServer(t, dir, nil) + defer srv.Close() + + // Upload. + payload := make([]byte, 4096) + if _, err := rand.Read(payload); err != nil { + t.Fatal(err) + } + + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + part, err := writer.CreateFormFile("BootyImage", "integrity.img") + if err != nil { + t.Fatal(err) + } + if _, err := part.Write(payload); err != nil { + t.Fatal(err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + + uploadResp, err := http.Post(srv.URL+"/image", writer.FormDataContentType(), body) //nolint:gosec // test URL + if err != nil { + t.Fatal(err) + } + _ = uploadResp.Body.Close() + + // Download the same file. + dlResp, err := http.Get(fmt.Sprintf("%s/images/integrity.img", srv.URL)) //nolint:gosec // test URL + if err != nil { + t.Fatal(err) + } + defer func() { _ = dlResp.Body.Close() }() + + downloaded, err := io.ReadAll(dlResp.Body) + if err != nil { + t.Fatal(err) + } + + if !bytes.Equal(downloaded, payload) { + t.Error("downloaded file does not match uploaded payload — data integrity failure") + } +}