mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-21 03:36:35 +00:00
dd7bce20e6
Internal development plans, roadmap, and detailed dev process docs moved to private mulga repo. CLAUDE.md trimmed to remove SSH credentials, node IPs, and internal deployment workflows. Gosec fixes for file permissions and unhandled error.
943 lines
23 KiB
Go
943 lines
23 KiB
Go
package utils
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/tls"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"encoding/xml"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"reflect"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/aws/aws-sdk-go/aws"
|
|
"github.com/aws/aws-sdk-go/aws/credentials"
|
|
"github.com/aws/aws-sdk-go/aws/session"
|
|
"github.com/aws/aws-sdk-go/private/protocol/xml/xmlutil"
|
|
"github.com/aws/aws-sdk-go/service/ec2"
|
|
"github.com/aws/aws-sdk-go/service/s3"
|
|
"github.com/mulgadc/hive/hive/awserrors"
|
|
"github.com/mulgadc/hive/hive/config"
|
|
"github.com/pterm/pterm"
|
|
"golang.org/x/net/http2"
|
|
)
|
|
|
|
// GenerateResourceID generates a unique resource ID with the given prefix.
|
|
// Format: {prefix}-{17 hex chars} using crypto/rand.
|
|
func GenerateResourceID(prefix string) string {
|
|
b := make([]byte, 9)
|
|
if _, err := rand.Read(b); err != nil {
|
|
panic("crypto/rand failed: " + err.Error())
|
|
}
|
|
return prefix + "-" + hex.EncodeToString(b)[:17]
|
|
}
|
|
|
|
// Helper functions for OS images
|
|
|
|
var ErrQCOWDetected = errors.New("qcow format detected")
|
|
|
|
type Images struct {
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
Distro string `json:"distro"`
|
|
Version string `json:"version"`
|
|
Arch string `json:"arch"`
|
|
Platform string `json:"platform"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
URL string `json:"url"`
|
|
Checksum string `json:"checksum"`
|
|
ChecksumType string `json:"checksum_type"`
|
|
BootMode string `json:"boot_mode"`
|
|
Starred bool `json:"starred"`
|
|
}
|
|
|
|
var AvailableImages = map[string]Images{
|
|
|
|
"debian-12-x86_64": {
|
|
Name: "debian-12-x86_64",
|
|
Description: "Debian 12 (Bookworm) x86_64 cloud image (generic cloud)",
|
|
Distro: "debian",
|
|
Version: "12",
|
|
Arch: "x86_64",
|
|
Platform: "Linux/UNIX",
|
|
CreatedAt: time.Date(2025, 10, 6, 0, 0, 0, 0, time.UTC),
|
|
URL: "https://cdimage.debian.org/cdimage/cloud/bookworm/latest/debian-12-generic-amd64.tar.xz",
|
|
Checksum: "https://cdimage.debian.org/cdimage/cloud/bookworm/latest/SHA512SUMS",
|
|
ChecksumType: "sha512",
|
|
BootMode: "bios",
|
|
Starred: true,
|
|
},
|
|
|
|
"debian-12-arm64": {
|
|
Name: "debian-12-arm64",
|
|
Description: "Debian 12 (Bookworm) arm64 cloud image (generic cloud)",
|
|
Distro: "debian",
|
|
Version: "12",
|
|
Arch: "arm64",
|
|
Platform: "Linux/UNIX",
|
|
CreatedAt: time.Date(2025, 10, 6, 0, 0, 0, 0, time.UTC),
|
|
URL: "https://cdimage.debian.org/cdimage/cloud/bookworm/latest/debian-12-generic-arm64.tar.xz",
|
|
Checksum: "https://cdimage.debian.org/cdimage/cloud/bookworm/latest/SHA512SUMS",
|
|
ChecksumType: "sha512",
|
|
BootMode: "bios",
|
|
Starred: true,
|
|
},
|
|
|
|
// Ubuntu
|
|
"ubuntu-24.04-x86_64": {
|
|
//Ubuntu 24.04 LTS (Noble Numbat)
|
|
Name: "ubuntu-24.04-x86_64",
|
|
Description: "Ubuntu 24.04 LTS (Noble Numbat) x86_64 cloud image",
|
|
Distro: "ubuntu",
|
|
Version: "24.04",
|
|
Arch: "x86_64",
|
|
Platform: "Linux/UNIX",
|
|
CreatedAt: time.Date(2025, 10, 6, 0, 0, 0, 0, time.UTC),
|
|
URL: "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img",
|
|
Checksum: "https://cloud-images.ubuntu.com/noble/current/SHA256SUMS",
|
|
ChecksumType: "sha256",
|
|
BootMode: "bios",
|
|
Starred: false,
|
|
},
|
|
|
|
"ubuntu-24.04-arm64": {
|
|
//Ubuntu 24.04 LTS (Noble Numbat)
|
|
Name: "ubuntu-24.04-arm64",
|
|
Description: "Ubuntu 24.04 LTS (Noble Numbat) arm64 cloud image",
|
|
Distro: "ubuntu",
|
|
Version: "24.04",
|
|
Arch: "arm64",
|
|
Platform: "Linux/UNIX",
|
|
CreatedAt: time.Date(2025, 10, 6, 0, 0, 0, 0, time.UTC),
|
|
URL: "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-arm64.img",
|
|
Checksum: "https://cloud-images.ubuntu.com/noble/current/SHA256SUMS",
|
|
ChecksumType: "sha256",
|
|
BootMode: "bios",
|
|
Starred: false,
|
|
},
|
|
|
|
"alpine-3.22.2-x86_64":
|
|
// Alpine Linux (cloud init) x86_64
|
|
{
|
|
Name: "alpine-3.22.2-x86_64",
|
|
Description: "Alpine Linux 3.22.2 x86_64 cloud image",
|
|
Distro: "alpine",
|
|
Version: "3.22.2",
|
|
Arch: "x86_64",
|
|
Platform: "Linux/UNIX",
|
|
CreatedAt: time.Date(2025, 10, 6, 0, 0, 0, 0, time.UTC),
|
|
URL: "https://dl-cdn.alpinelinux.org/alpine/v3.22/releases/cloud/generic_alpine-3.22.2-x86_64-bios-cloudinit-r0.qcow2",
|
|
Checksum: "https://dl-cdn.alpinelinux.org/alpine/v3.22/releases/cloud/generic_alpine-3.22.2-x86_64-bios-cloudinit-r0.qcow2.sha512",
|
|
ChecksumType: "sha512",
|
|
BootMode: "bios",
|
|
Starred: false,
|
|
},
|
|
|
|
/*
|
|
"alpine-3.22.2-arm64":
|
|
// Alpine Linux (cloud init) arm64 (Requires UEFI boot, TODO)
|
|
{
|
|
Name: "alpine-3.22.2-arm64",
|
|
Description: "Alpine Linux 3.22.2 arm64 cloud image",
|
|
Distro: "alpine",
|
|
Version: "3.22.2",
|
|
Arch: "arm64",
|
|
Platform: "Linux/UNIX",
|
|
CreatedAt: time.Date(2025, 10, 6, 0, 0, 0, 0, time.UTC),
|
|
URL: "https://dl-cdn.alpinelinux.org/alpine/v3.22/releases/cloud/gcp_alpine-3.22.2-aarch64-uefi-cloudinit-metal-r0.raw.tar.gz",
|
|
Checksum: "https://dl-cdn.alpinelinux.org/alpine/v3.22/releases/cloud/gcp_alpine-3.22.2-aarch64-uefi-cloudinit-metal-r0.raw.tar.gz.sha512",
|
|
ChecksumType: "sha512",
|
|
BootMode: "uefi",
|
|
Starred: false,
|
|
},
|
|
*/
|
|
|
|
}
|
|
|
|
// SetOOMScore sets the OOM score adjustment for a process.
|
|
// Score range: -1000 (never kill) to 1000 (always kill first).
|
|
// Linux-only; returns an error on non-Linux systems.
|
|
func SetOOMScore(pid int, score int) error {
|
|
if runtime.GOOS != "linux" {
|
|
return fmt.Errorf("OOM score adjustment is only supported on Linux")
|
|
}
|
|
path := fmt.Sprintf("/proc/%d/oom_score_adj", pid)
|
|
return os.WriteFile(path, []byte(strconv.Itoa(score)), 0600)
|
|
}
|
|
|
|
func ReadPidFile(name string) (int, error) {
|
|
|
|
pidPath := pidPath()
|
|
|
|
pidFile, err := os.ReadFile(filepath.Join(pidPath, fmt.Sprintf("%s.pid", name)))
|
|
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
// Strip whitespace and /r or /n
|
|
pidFile = bytes.TrimSpace(pidFile)
|
|
|
|
return strconv.Atoi(string(pidFile))
|
|
}
|
|
|
|
func GeneratePidFile(name string) (string, error) {
|
|
|
|
if name == "" {
|
|
return "", errors.New("name is required")
|
|
}
|
|
|
|
pidPath := pidPath()
|
|
|
|
if pidPath == "" {
|
|
return "", errors.New("pid path is empty")
|
|
}
|
|
|
|
return filepath.Join(pidPath, fmt.Sprintf("%s.pid", name)), nil
|
|
}
|
|
|
|
// GenerateSocketFile generates a socket file path for the given name.
|
|
// Deprecated: Use GenerateUniqueSocketFile for new code to ensure uniqueness.
|
|
func GenerateSocketFile(name string) (string, error) {
|
|
|
|
if name == "" {
|
|
return "", errors.New("name is required")
|
|
}
|
|
|
|
pidPath := pidPath()
|
|
|
|
if pidPath == "" {
|
|
return "", errors.New("pid path is empty")
|
|
}
|
|
|
|
return filepath.Join(pidPath, fmt.Sprintf("%s.sock", name)), nil
|
|
}
|
|
|
|
// GenerateUniqueSocketFile generates a unique socket file path with unix nano timestamp.
|
|
// Format: nbd-{volname}-{unixnano}.sock
|
|
// This ensures each mount operation gets a unique socket path.
|
|
func GenerateUniqueSocketFile(volname string) (string, error) {
|
|
if volname == "" {
|
|
return "", errors.New("volume name is required")
|
|
}
|
|
|
|
pidPath := pidPath()
|
|
if pidPath == "" {
|
|
return "", errors.New("pid path is empty")
|
|
}
|
|
|
|
timestamp := time.Now().UnixNano()
|
|
filename := fmt.Sprintf("nbd-%s-%d.sock", volname, timestamp)
|
|
return filepath.Join(pidPath, filename), nil
|
|
}
|
|
|
|
// IsSocketURI returns true if the NBD URI is a Unix socket path.
|
|
// Socket URIs end with ".sock" or contain "unix:".
|
|
func IsSocketURI(nbdURI string) bool {
|
|
return strings.HasSuffix(nbdURI, ".sock") || strings.Contains(nbdURI, "unix:")
|
|
}
|
|
|
|
// FormatNBDSocketURI formats a socket path as an NBD URI for QEMU.
|
|
// Returns format: nbd:unix:/path/to/socket.sock
|
|
func FormatNBDSocketURI(socketPath string) string {
|
|
return fmt.Sprintf("nbd:unix:%s", socketPath)
|
|
}
|
|
|
|
// FormatNBDTCPURI formats a host:port as an NBD URI for QEMU.
|
|
// Returns format: nbd://host:port
|
|
func FormatNBDTCPURI(host string, port int) string {
|
|
return fmt.Sprintf("nbd://%s:%d", host, port)
|
|
}
|
|
|
|
// ParseNBDURI parses an NBD URI into its components for QMP blockdev-add.
|
|
// Supported formats:
|
|
// - "nbd:unix:/path/to/socket.sock" → serverType="unix", path="/path/to/socket.sock"
|
|
// - "nbd://host:port" → serverType="inet", host="host", port=<port>
|
|
func ParseNBDURI(nbdURI string) (serverType, path, host string, port int, err error) {
|
|
if after, ok := strings.CutPrefix(nbdURI, "nbd:unix:"); ok {
|
|
path = after
|
|
if path == "" {
|
|
return "", "", "", 0, fmt.Errorf("empty socket path in NBD URI: %s", nbdURI)
|
|
}
|
|
return "unix", path, "", 0, nil
|
|
}
|
|
|
|
if after, ok := strings.CutPrefix(nbdURI, "nbd://"); ok {
|
|
hostPort := after
|
|
colonIdx := strings.LastIndex(hostPort, ":")
|
|
if colonIdx < 0 {
|
|
return "", "", "", 0, fmt.Errorf("missing port in NBD TCP URI: %s", nbdURI)
|
|
}
|
|
host = hostPort[:colonIdx]
|
|
port, err = strconv.Atoi(hostPort[colonIdx+1:])
|
|
if err != nil {
|
|
return "", "", "", 0, fmt.Errorf("invalid port in NBD URI: %s", nbdURI)
|
|
}
|
|
return "inet", "", host, port, nil
|
|
}
|
|
|
|
return "", "", "", 0, fmt.Errorf("unsupported NBD URI format: %s", nbdURI)
|
|
}
|
|
|
|
func WritePidFile(name string, pid int) error {
|
|
|
|
// Write PID to file, check XDG, otherwise user home directory ~/hive/
|
|
pidFilename, err := GeneratePidFile(name)
|
|
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
pidFile, err := os.Create(pidFilename)
|
|
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
defer pidFile.Close()
|
|
_, err = pidFile.WriteString(fmt.Sprintf("%d", pid))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func RemovePidFile(serviceName string) error {
|
|
|
|
pidPath := pidPath()
|
|
|
|
err := os.Remove(filepath.Join(pidPath, fmt.Sprintf("%s.pid", serviceName)))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func pidPath() string {
|
|
var pidPath string
|
|
|
|
// CHeck if a directory exists
|
|
|
|
if os.Getenv("XDG_RUNTIME_DIR") != "" {
|
|
pidPath = os.Getenv("XDG_RUNTIME_DIR")
|
|
} else if dirExists(fmt.Sprintf("%s/%s", os.Getenv("HOME"), "hive")) {
|
|
pidPath = filepath.Join(os.Getenv("HOME"), "hive")
|
|
} else {
|
|
pidPath = os.TempDir()
|
|
}
|
|
|
|
return pidPath
|
|
}
|
|
|
|
func StopProcess(serviceName string) error {
|
|
pid, err := ReadPidFile(serviceName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
err = KillProcess(pid)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Remove PID file
|
|
err = RemovePidFile(serviceName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func KillProcess(pid int) error {
|
|
|
|
process, err := os.FindProcess(pid)
|
|
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Send SIGTERM first (graceful)
|
|
err = process.Signal(syscall.SIGTERM)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Check process terminated
|
|
|
|
checks := 0
|
|
for {
|
|
time.Sleep(1 * time.Second)
|
|
process, err = os.FindProcess(pid)
|
|
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
err = process.Signal(syscall.Signal(0))
|
|
|
|
if err != nil {
|
|
// Process terminated, break
|
|
break
|
|
}
|
|
|
|
checks++
|
|
|
|
// If process is still running after 120 seconds, force kill
|
|
if checks > 120 {
|
|
err = process.Kill() // SIGKILL
|
|
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
break
|
|
}
|
|
}
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
func WaitForPidFileRemoval(instanceID string, timeout time.Duration) error {
|
|
timeoutCh := time.After(timeout)
|
|
ticker := time.NewTicker(100 * time.Millisecond)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-timeoutCh:
|
|
return fmt.Errorf("timeout waiting for PID file to be removed for instance %s", instanceID)
|
|
case <-ticker.C:
|
|
_, err := ReadPidFile(instanceID)
|
|
if err != nil {
|
|
// PID file no longer exists
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func dirExists(path string) bool {
|
|
info, err := os.Stat(path)
|
|
if os.IsNotExist(err) {
|
|
return false
|
|
}
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return info.IsDir()
|
|
}
|
|
|
|
// Convert interface to XML
|
|
func MarshalToXML(payload any) ([]byte, error) {
|
|
|
|
var buf bytes.Buffer
|
|
enc := xml.NewEncoder(&buf)
|
|
// enc.Indent("", " ")
|
|
|
|
if err := xmlutil.BuildXML(payload, enc); err != nil {
|
|
slog.Error("BuildXML failed", "err", err)
|
|
return nil, err
|
|
}
|
|
|
|
if err := enc.Flush(); err != nil {
|
|
slog.Error("Flush failed", "err", err)
|
|
return nil, err
|
|
}
|
|
|
|
return buf.Bytes(), nil
|
|
|
|
}
|
|
|
|
// wrapWithLocation decorates payload with the requested locationName tag.
|
|
func GenerateXMLPayload(locationName string, payload any) any {
|
|
t := reflect.StructOf([]reflect.StructField{
|
|
{
|
|
Name: "Value",
|
|
Type: reflect.TypeOf(payload),
|
|
Tag: reflect.StructTag(`locationName:"` + locationName + `"`),
|
|
},
|
|
})
|
|
|
|
v := reflect.New(t).Elem()
|
|
v.Field(0).Set(reflect.ValueOf(payload))
|
|
return v.Interface()
|
|
}
|
|
|
|
// GenerateIAMXMLPayload wraps IAM output with proper *Result element structure.
|
|
// IAM responses have format: <ActionResponse><ActionResult>content</ActionResult><ResponseMetadata>...</ResponseMetadata></ActionResponse>
|
|
func GenerateIAMXMLPayload(action string, payload any) any {
|
|
// First wrap with ActionResult (e.g., CreateUserResult)
|
|
resultName := action + "Result"
|
|
resultWrapper := reflect.StructOf([]reflect.StructField{
|
|
{
|
|
Name: "Result",
|
|
Type: reflect.TypeOf(payload),
|
|
Tag: reflect.StructTag(`locationName:"` + resultName + `"`),
|
|
},
|
|
})
|
|
resultV := reflect.New(resultWrapper).Elem()
|
|
resultV.Field(0).Set(reflect.ValueOf(payload))
|
|
|
|
// Then wrap with ActionResponse (e.g., CreateUserResponse)
|
|
responseName := action + "Response"
|
|
responseWrapper := reflect.StructOf([]reflect.StructField{
|
|
{
|
|
Name: "Response",
|
|
Type: resultWrapper,
|
|
Tag: reflect.StructTag(`locationName:"` + responseName + `"`),
|
|
},
|
|
})
|
|
responseV := reflect.New(responseWrapper).Elem()
|
|
responseV.Field(0).Set(resultV)
|
|
|
|
return responseV.Interface()
|
|
}
|
|
|
|
// Generate JSON Error Payload
|
|
func GenerateErrorPayload(code string) (jsonResponse []byte) {
|
|
|
|
var responseError ec2.ResponseError
|
|
responseError.Code = aws.String(code)
|
|
|
|
// Return as JSON, to simulate the NATS response
|
|
jsonResponse, err := json.Marshal(responseError)
|
|
if err != nil {
|
|
slog.Error("GenerateErrorPayload could not marshal JSON payload", "err", err)
|
|
return nil
|
|
}
|
|
|
|
return
|
|
|
|
}
|
|
|
|
// Validate the payload is an ec2.ResponseError
|
|
func ValidateErrorPayload(payload []byte) (responseError ec2.ResponseError, err error) {
|
|
|
|
decoder := json.NewDecoder(bytes.NewReader(payload))
|
|
decoder.DisallowUnknownFields()
|
|
|
|
err = decoder.Decode(&responseError)
|
|
|
|
if err == nil && responseError.Code != nil {
|
|
// Successfully decoded as ResponseError AND has a non-nil Code field
|
|
// This is a real error response
|
|
return responseError, errors.New("ResponseError detected")
|
|
}
|
|
|
|
// Either failed to decode (not an error structure) or Code is nil (empty valid response)
|
|
return responseError, nil
|
|
|
|
}
|
|
|
|
// Unmarshal payload
|
|
|
|
func UnmarshalJsonPayload(input any, jsonData []byte) []byte {
|
|
|
|
decoder := json.NewDecoder(bytes.NewReader(jsonData))
|
|
decoder.DisallowUnknownFields()
|
|
|
|
// input is already a pointer, don't take address again
|
|
err := decoder.Decode(input)
|
|
if err != nil {
|
|
// TODO: Move error codes with vars to errors.go
|
|
return GenerateErrorPayload("ValidationError")
|
|
}
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
func MarshalJsonPayload(input any, jsonData []byte) []byte {
|
|
|
|
decoder := json.NewDecoder(bytes.NewReader(jsonData))
|
|
decoder.DisallowUnknownFields()
|
|
|
|
// input is already a pointer, don't take address again
|
|
err := decoder.Decode(input)
|
|
if err != nil {
|
|
// TODO: Move error codes with vars to errors.go
|
|
return GenerateErrorPayload("ValidationError")
|
|
}
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
// ValidateKeyPairName validates that a key pair name only contains allowed characters:
|
|
// - Uppercase A-Z
|
|
// - Lowercase a-z
|
|
// - Digits 0-9
|
|
// - Hyphen (-)
|
|
// - Underscore (_)
|
|
// - Period (.)
|
|
// This prevents path traversal attacks and invalid characters like /etc/passwd, ../../../, etc.
|
|
// Returns ErrorInvalidKeyPairFormat if validation fails
|
|
func ValidateKeyPairName(name string) error {
|
|
if name == "" {
|
|
return errors.New("key name cannot be empty")
|
|
}
|
|
|
|
// Check each character is in the allowed set
|
|
for _, char := range name {
|
|
valid := (char >= 'A' && char <= 'Z') ||
|
|
(char >= 'a' && char <= 'z') ||
|
|
(char >= '0' && char <= '9') ||
|
|
char == '-' ||
|
|
char == '_' ||
|
|
char == '.'
|
|
|
|
if !valid {
|
|
return errors.New(awserrors.ErrorInvalidKeyPairFormat)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// AMI / image extraction utils
|
|
func ExtractDiskImageFromFile(imagepath string, tmpdir string) (diskimage string, err error) {
|
|
|
|
var args []string
|
|
var execCmd string
|
|
|
|
// Confirm file exists
|
|
_, err = os.Stat(imagepath)
|
|
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
// Extract the filepath
|
|
imagefile := filepath.Base(imagepath)
|
|
|
|
// Provide the full path to the specified file
|
|
//imagedir, err := filepath.Abs(filepath.Dir(imagepath))
|
|
|
|
//if err != nil {
|
|
// return
|
|
//}
|
|
|
|
// Already in raw/image formt, confirm the file contains a valid disk image/MBR
|
|
if strings.HasSuffix(imagefile, ".raw") || strings.HasSuffix(imagefile, ".img") || strings.HasSuffix(imagefile, ".qcow2") || strings.HasSuffix(imagefile, ".qcow") {
|
|
|
|
path, err := filepath.Abs(imagepath)
|
|
|
|
if err != nil {
|
|
return path, err
|
|
}
|
|
|
|
// Validate the specified filename is indeed a disk image / MBR
|
|
err = validateDiskImagePath(path)
|
|
|
|
// Check error response
|
|
|
|
if errors.Is(err, ErrQCOWDetected) {
|
|
|
|
//fmt.Println("Extracting raw disk image from qcow2 file", "file", imagepath)
|
|
|
|
extractpath := fmt.Sprintf("%s/%s", tmpdir, imagefile)
|
|
extractpath = strings.TrimSuffix(extractpath, ".qcow2") + ".raw"
|
|
|
|
args = []string{
|
|
"convert",
|
|
"-f",
|
|
"qcow2",
|
|
"-O",
|
|
"raw",
|
|
imagepath,
|
|
"-C",
|
|
extractpath,
|
|
}
|
|
|
|
execCmd = "qemu-img"
|
|
|
|
//fmt.Println("Executing command:", "cmd", execCmd, "args", args)
|
|
|
|
cmd := exec.Command(execCmd, args...)
|
|
_, err = cmd.Output()
|
|
|
|
if err != nil {
|
|
return path, err
|
|
}
|
|
|
|
return extractpath, nil
|
|
|
|
}
|
|
|
|
return path, err
|
|
|
|
} else if strings.HasSuffix(imagefile, ".tar.xz") {
|
|
|
|
args = []string{
|
|
"xfvJ",
|
|
imagepath,
|
|
"-C",
|
|
tmpdir,
|
|
}
|
|
|
|
execCmd = "tar"
|
|
|
|
} else if strings.HasSuffix(imagefile, ".tar.gz") || strings.HasSuffix(imagefile, ".tgz") {
|
|
|
|
args = []string{
|
|
"xfvz",
|
|
imagepath,
|
|
"-C",
|
|
tmpdir,
|
|
}
|
|
|
|
execCmd = "tar"
|
|
|
|
} else if strings.HasSuffix(imagefile, ".tar") {
|
|
|
|
args = []string{
|
|
"xfv",
|
|
imagepath,
|
|
"-C",
|
|
tmpdir,
|
|
}
|
|
|
|
execCmd = "tar"
|
|
|
|
} else if strings.HasSuffix(imagefile, ".xz") {
|
|
|
|
args = []string{
|
|
"-dk",
|
|
imagepath,
|
|
}
|
|
|
|
execCmd = "xz"
|
|
|
|
} else {
|
|
err = errors.New("unsupported filetype")
|
|
return
|
|
}
|
|
|
|
cmd := exec.Command(execCmd, args...)
|
|
output, err := cmd.CombinedOutput()
|
|
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
diskimage, err = extractDiskImagePath(tmpdir, output)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
func extractDiskImagePath(imagedir string, output []byte) (diskimage string, err error) {
|
|
|
|
reader := bytes.NewReader(output)
|
|
|
|
r := bufio.NewReader(reader)
|
|
|
|
for {
|
|
line, err := r.ReadString('\n')
|
|
line = strings.Replace(line, "\n", "", 1)
|
|
|
|
// MacOS tar, filenames begin with `x FILE` (to STDERR)
|
|
if runtime.GOOS == "darwin" && strings.HasPrefix(line, "x ") {
|
|
line = strings.Replace(line, "x ", "", 1)
|
|
|
|
}
|
|
|
|
if strings.HasSuffix(line, ".raw") || strings.HasSuffix(line, ".img") {
|
|
diskimage := fmt.Sprintf("%s/%s", imagedir, line)
|
|
|
|
err = validateDiskImagePath(diskimage)
|
|
|
|
return diskimage, err
|
|
}
|
|
|
|
if err != nil && err.Error() == "EOF" {
|
|
break
|
|
}
|
|
}
|
|
|
|
return
|
|
|
|
}
|
|
|
|
func validateDiskImagePath(diskimage string) (err error) {
|
|
|
|
args := []string{
|
|
diskimage,
|
|
}
|
|
|
|
cmd := exec.Command("file", args...)
|
|
output, _ := cmd.CombinedOutput()
|
|
|
|
filetype := strings.Split(string(output), ":")
|
|
|
|
if len(filetype) > 1 {
|
|
|
|
if strings.Contains(filetype[1], "DOS/MBR boot sector") || strings.Contains(filetype[1], "Linux ") {
|
|
return nil
|
|
} else if strings.Contains(filetype[1], "QEMU QCOW") {
|
|
return ErrQCOWDetected
|
|
}
|
|
|
|
}
|
|
|
|
return errors.New("no valid disk image found")
|
|
|
|
}
|
|
|
|
func CreateS3Client(cfg *config.Config) *s3.S3 {
|
|
|
|
tr := &http.Transport{
|
|
TLSClientConfig: &tls.Config{
|
|
InsecureSkipVerify: true,
|
|
NextProtos: []string{"h2", "http/1.1"},
|
|
},
|
|
ForceAttemptHTTP2: true,
|
|
}
|
|
|
|
// CRITICAL: Configure HTTP/2 support with custom TLS config
|
|
if err := http2.ConfigureTransport(tr); err != nil {
|
|
slog.Warn("Failed to configure HTTP/2", "error", err)
|
|
}
|
|
|
|
httpClient := &http.Client{Transport: tr}
|
|
|
|
sess := session.Must(session.NewSession(&aws.Config{
|
|
Region: aws.String(cfg.Predastore.Region),
|
|
Endpoint: aws.String(fmt.Sprintf("https://%s", cfg.Predastore.Host)),
|
|
Credentials: credentials.NewStaticCredentials(cfg.AccessKey, cfg.SecretKey, ""),
|
|
S3ForcePathStyle: aws.Bool(true),
|
|
DisableSSL: aws.Bool(false),
|
|
HTTPClient: httpClient,
|
|
}))
|
|
|
|
return s3.New(sess)
|
|
|
|
}
|
|
|
|
// Download helper
|
|
|
|
func DownloadFileWithProgress(url string, name string, filename string, timeout time.Duration) (err error) {
|
|
|
|
// Context with optional timeout and Ctrl+C cancel
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
if timeout > 0 {
|
|
ctx, cancel = context.WithTimeout(ctx, timeout)
|
|
}
|
|
defer cancel()
|
|
intCh := make(chan os.Signal, 1)
|
|
signal.Notify(intCh, os.Interrupt)
|
|
go func() {
|
|
<-intCh
|
|
cancel()
|
|
}()
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("request error: %v", err)
|
|
}
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("http error: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
|
return fmt.Errorf("unexpected status: %s", resp.Status)
|
|
}
|
|
|
|
f, err := os.Create(filename)
|
|
if err != nil {
|
|
return fmt.Errorf("file create error: %v", err)
|
|
}
|
|
defer f.Close()
|
|
|
|
cl := resp.ContentLength
|
|
|
|
// Known size: progress bar with total
|
|
if cl > 0 {
|
|
bar, _ := pterm.DefaultProgressbar.
|
|
WithTitle(fmt.Sprintf("Downloading %s", name)).
|
|
WithTotal(int(cl)).
|
|
Start()
|
|
|
|
var written int64
|
|
reader := io.TeeReader(resp.Body, progressWriter(func(n int) {
|
|
written += int64(n)
|
|
// Update progress bar with the number of bytes read in this chunk
|
|
bar.Add(n)
|
|
}))
|
|
|
|
_, err = io.Copy(f, reader)
|
|
_, _ = bar.Stop()
|
|
if err != nil {
|
|
return fmt.Errorf("copy error: %v", err)
|
|
}
|
|
|
|
pterm.Printf("Saved %s (%s)\n", filename, humanBytes(SafeInt64ToUint64(written)))
|
|
return
|
|
|
|
} else {
|
|
|
|
// Unknown size: spinner that shows bytes downloaded
|
|
spin, _ := pterm.DefaultSpinner.
|
|
WithText("Downloading (size unknown)...").
|
|
Start()
|
|
var written int64
|
|
reader := io.TeeReader(resp.Body, progressWriter(func(n int) {
|
|
written += int64(n)
|
|
spin.UpdateText(fmt.Sprintf("Downloading %s (%s) ...", name, humanBytes(SafeInt64ToUint64(written))))
|
|
}))
|
|
_, err = io.Copy(f, reader)
|
|
_ = spin.Stop()
|
|
|
|
if err != nil {
|
|
return fmt.Errorf("copy error: %v", err)
|
|
}
|
|
|
|
pterm.Printf("Saved %s (%s)\n", filename, humanBytes(SafeInt64ToUint64(written)))
|
|
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// progressWriter turns byte counts into a callback for UI updates.
|
|
type progressWriter func(n int)
|
|
|
|
func (pw progressWriter) Write(p []byte) (int, error) {
|
|
pw(len(p))
|
|
return len(p), nil
|
|
}
|
|
|
|
func humanBytes(b uint64) string {
|
|
const unit = 1024
|
|
if b < unit {
|
|
return fmt.Sprintf("%d B", b)
|
|
}
|
|
div, exp := uint64(unit), 0
|
|
for n := b / unit; n >= unit; n /= unit {
|
|
div *= unit
|
|
exp++
|
|
}
|
|
return fmt.Sprintf("%.1f %ciB", float64(b)/float64(div), "KMGTPEZY"[exp])
|
|
}
|