diff --git a/.gitignore b/.gitignore index fa8aaaf..d438ae5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +bin +y # Binaries for programs and plugins *.exe *.exe~ diff --git a/collections/set/set_suite_test.go b/collections/set/set_suite_test.go index e19482f..e38cb48 100644 --- a/collections/set/set_suite_test.go +++ b/collections/set/set_suite_test.go @@ -10,4 +10,4 @@ import ( func TestSet(t *testing.T) { RegisterFailHandler(Fail) RunSpecs(t, "Set Suite") -} \ No newline at end of file +} diff --git a/context/context.go b/context/context.go index ea7d750..53c3f79 100644 --- a/context/context.go +++ b/context/context.go @@ -11,11 +11,11 @@ // // ctx := context.NewContext(context.Background()) // ctx.Infof("Processing request %s", requestID) -// +// // // Enable debug logging // debugCtx := ctx.WithDebug() // debugCtx.Debugf("Detailed information: %v", details) -// +// // // Start a traced operation // ctx, span := ctx.StartSpan("database-query") // defer span.End() @@ -110,7 +110,7 @@ func WithLogger(log logger.Logger) ContextOptions { // // // Basic context with defaults // ctx := NewContext(context.Background()) -// +// // // Context with custom configuration // ctx := NewContext( // context.Background(), diff --git a/deps/deps.go b/deps/deps.go index 09f2942..8e3453f 100644 --- a/deps/deps.go +++ b/deps/deps.go @@ -2,16 +2,21 @@ package deps import ( "bytes" + "crypto/sha256" "fmt" + "io" "os" "path" + "regexp" "runtime" "strings" + "time" gotemplate "text/template" osExec "os/exec" + "github.com/Masterminds/semver/v3" "github.com/flanksource/commons/exec" "github.com/flanksource/commons/files" "github.com/flanksource/commons/is" @@ -20,20 +25,60 @@ import ( log "github.com/sirupsen/logrus" ) +// VersionCheckMode defines how version checking should be performed +type VersionCheckMode int + +const ( + // VersionCheckNone skips version checking entirely + VersionCheckNone VersionCheckMode = iota + // VersionCheckExact requires an exact version match + VersionCheckExact + // VersionCheckMinimum requires the installed version to be at least the specified version + VersionCheckMinimum + // VersionCheckCompatible allows compatible versions (same major version) + VersionCheckCompatible +) + +// InstallOptions configures the installation behavior +type InstallOptions struct { + BinDir string + Force bool + VersionCheck VersionCheckMode + GOOS string + GOARCH string + Timeout time.Duration + SkipChecksum bool + PreferLocal bool +} + +// InstallOption is a functional option for configuring installation +type InstallOption func(*InstallOptions) + // Dependency is a struct referring to a version and the templated path // to download the dependency on the different OS platforms type Dependency struct { - Version string - Linux string - LinuxARM string - Macosx string - MacosxARM string - Windows string - Go string - Docker string - Template string - BinaryName string - PreInstalled []string + Version string + Linux string + LinuxARM string + Macosx string + MacosxARM string + Windows string + Go string + Docker string + Template string + BinaryName string + PreInstalled []string + VersionCommand string // Command to get version (e.g., "--version") + VersionPattern string // Regex pattern to extract version from output + Checksums map[string]string // Platform -> checksum mapping +} + +type Process struct { + Process *osExec.Cmd +} + +func (p Process) Exec(args ...any) error { + return exec.Execf(p.Process.Path+" "+p.Process.Args[1], args...) } // BinaryFunc is an interface to executing a binary, downloading it necessary @@ -42,6 +87,58 @@ type BinaryFunc func(msg string, args ...any) error // BinaryFuncWithEnv is an interface to executing a binary, downloading it necessary type BinaryFuncWithEnv func(msg string, env map[string]string, args ...any) error +// Option functions for configuring installation + +// WithBinDir sets the binary installation directory +func WithBinDir(dir string) InstallOption { + return func(opts *InstallOptions) { + opts.BinDir = dir + } +} + +// WithForce enables or disables forced reinstallation +func WithForce(force bool) InstallOption { + return func(opts *InstallOptions) { + opts.Force = force + } +} + +// WithVersionCheck sets the version checking mode +func WithVersionCheck(mode VersionCheckMode) InstallOption { + return func(opts *InstallOptions) { + opts.VersionCheck = mode + } +} + +// WithOS overrides the target OS and architecture +func WithOS(goos, goarch string) InstallOption { + return func(opts *InstallOptions) { + opts.GOOS = goos + opts.GOARCH = goarch + } +} + +// WithTimeout sets the download timeout +func WithTimeout(timeout time.Duration) InstallOption { + return func(opts *InstallOptions) { + opts.Timeout = timeout + } +} + +// WithSkipChecksum enables or disables checksum verification +func WithSkipChecksum(skip bool) InstallOption { + return func(opts *InstallOptions) { + opts.SkipChecksum = skip + } +} + +// WithPreferLocal prefers locally installed binaries over downloading +func WithPreferLocal(prefer bool) InstallOption { + return func(opts *InstallOptions) { + opts.PreferLocal = prefer + } +} + func (dependency *Dependency) GetPath(name string, binDir string) (string, error) { data := map[string]string{"os": runtime.GOOS, "platform": runtime.GOARCH, "version": dependency.Version} if dependency.BinaryName != "" { @@ -145,8 +242,10 @@ var dependencies = map[string]Dependency{ Template: "https://amazon-eks.s3-us-west-2.amazonaws.com/{{.version}}/bin/{{.os}}/{{.platform}}/aws-iam-authenticator", }, "kubectl": { - Version: "v1.15.3", - Template: "https://storage.googleapis.com/kubernetes-release/release/{{.version}}/bin/{{.os}}/{{.platform}}/kubectl", + Version: "v1.15.3", + Template: "https://storage.googleapis.com/kubernetes-release/release/{{.version}}/bin/{{.os}}/{{.platform}}/kubectl", + VersionCommand: "version", + VersionPattern: `Client Version:\s*v?(\d+\.\d+\.\d+)`, }, "terraform": { Version: "1.1.7", @@ -207,28 +306,46 @@ var dependencies = map[string]Dependency{ Version: "1.19.2", Template: "https://storage.googleapis.com/kubebuilder-tools/kubebuilder-tools-{{.version}}-{{.os}}-{{.platform}}.tar.gz", }, + "postgrest": { - Version: "v12.2.12", - Linux: "https://github.com/PostgREST/postgrest/releases/download/{{.version}}/postgrest-{{.version}}-linux-static-x64.tar.xz", - LinuxARM: "https://github.com/PostgREST/postgrest/releases/download/{{.version}}/postgrest-{{.version}}-ubuntu-aarch64.tar.xz ", - Windows: "https://github.com/PostgREST/postgrest/releases/download/{{.version}}/postgrest-{{.version}}-windows-x64.zip", - Macosx: "https://github.com/PostgREST/postgrest/releases/download/{{.version}}/postgrest-{{.version}}-macos-x64.tar.xz", - MacosxARM: "https://github.com/PostgREST/postgrest/releases/download/{{.version}}/postgrest-{{.version}}-macos-aarch64.tar.xz", - BinaryName: "postgrest", - }, - - "postgrestV13": { - Version: "v13.0.5", - Linux: "https://github.com/PostgREST/postgrest/releases/download/{{.version}}/postgrest-{{.version}}-linux-static-x86-64.tar.xz", - LinuxARM: "https://github.com/PostgREST/postgrest/releases/download/{{.version}}/postgrest-{{.version}}-ubuntu-aarch64.tar.xz ", - Windows: "https://github.com/PostgREST/postgrest/releases/download/{{.version}}/postgrest-{{.version}}-windows-x86-64.zip", - Macosx: "https://github.com/PostgREST/postgrest/releases/download/{{.version}}/postgrest-{{.version}}-macos-x86-64.tar.xz", - MacosxARM: "https://github.com/PostgREST/postgrest/releases/download/{{.version}}/postgrest-{{.version}}-macos-aarch64.tar.xz", - BinaryName: "postgrest", + Version: "v13.0.5", + Linux: "https://github.com/PostgREST/postgrest/releases/download/{{.version}}/postgrest-{{.version}}-linux-static-x86-64.tar.xz", + LinuxARM: "https://github.com/PostgREST/postgrest/releases/download/{{.version}}/postgrest-{{.version}}-ubuntu-aarch64.tar.xz ", + Windows: "https://github.com/PostgREST/postgrest/releases/download/{{.version}}/postgrest-{{.version}}-windows-x86-64.zip", + Macosx: "https://github.com/PostgREST/postgrest/releases/download/{{.version}}/postgrest-{{.version}}-macos-x86-64.tar.xz", + MacosxARM: "https://github.com/PostgREST/postgrest/releases/download/{{.version}}/postgrest-{{.version}}-macos-aarch64.tar.xz", + BinaryName: "postgrest", + VersionCommand: "--help", + VersionPattern: `PostgREST\s+v?(\d+\.\d+\.\d+)`, + }, + "wal-g": { + Version: "v3.0.5", + Linux: "https://github.com/wal-g/wal-g/releases/download/{{.version}}/wal-g-pg-ubuntu-20.04-amd64.tar.gz", + LinuxARM: "https://github.com/wal-g/wal-g/releases/download/{{.version}}/wal-g-pg-ubuntu-20.04-aarch64.tar.gz", + BinaryName: "wal-g", + VersionCommand: "--version", + VersionPattern: `wal-g\s+version\s+v?(\d+\.\d+\.\d+)`, + // Note: WAL-G does not provide pre-built macOS or Windows binaries + }, + "task": { + Version: "v3.44.1", + Template: "https://github.com/go-task/task/releases/download/{{.version}}/task_{{.os}}_{{.platform}}.tar.gz", + BinaryName: "task", + VersionCommand: "--version", + VersionPattern: `Task\s+version:\s+v?(\d+\.\d+\.\d+)`, + }, + "postgres": { + Version: "16.1.0", + PreInstalled: []string{"postgres"}, + VersionCommand: "--version", + VersionPattern: `postgres\s+\(PostgreSQL\)\s+(\d+\.\d+(?:\.\d+)?)`, + // Note: Uses custom InstallPostgres function due to complex extraction process }, "yq": { - Version: "v4.16.2", - Template: "https://github.com/mikefarah/yq/releases/download/{{.version}}/yq_{{.os}}_{{.platform}}", + Version: "v4.16.2", + Template: "https://github.com/mikefarah/yq/releases/download/{{.version}}/yq_{{.os}}_{{.platform}}", + VersionCommand: "--version", + VersionPattern: `yq\s+.*version\s+v?(\d+\.\d+\.\d+)`, }, "karina": { Version: "v0.61.0", @@ -253,56 +370,116 @@ var dependencies = map[string]Dependency{ }, } -// InstallDependency installs a binary to binDir, if ver is nil then the default version is used -func InstallDependency(name, ver string, binDir string) error { +// Install installs a dependency with configurable options +func Install(name, version string, opts ...InstallOption) error { name = strings.ToLower(name) + + // Set default options + options := &InstallOptions{ + BinDir: "/usr/local/bin", + Force: false, + VersionCheck: VersionCheckNone, + GOOS: runtime.GOOS, + GOARCH: runtime.GOARCH, + Timeout: 5 * time.Minute, + SkipChecksum: false, + PreferLocal: false, + } + + // Apply provided options + for _, opt := range opts { + opt(options) + } + dependency, ok := dependencies[name] if !ok { return fmt.Errorf("dependency %s not found", name) } - if len(strings.TrimSpace(ver)) == 0 { - ver = dependency.Version + + if len(strings.TrimSpace(version)) == 0 { + version = dependency.Version } - bin, err := dependency.GetPath(name, binDir) - if err != nil { - return err + + // Check if binary already exists and meets version requirements + if !options.Force { + bin, err := dependency.GetPathWithOptions(name, options.BinDir, options.GOOS, options.GOARCH) + if err != nil { + return err + } + + if is.File(bin) { + // If version checking is disabled, skip reinstall + if options.VersionCheck == VersionCheckNone { + log.Debugf("%s already exists", bin) + return nil + } + + // Check if installed version meets requirements + if shouldSkipInstall, err := checkVersionRequirement(bin, version, options.VersionCheck, &dependency); err == nil && shouldSkipInstall { + log.Debugf("%s already meets version requirement", bin) + return nil + } + } else if options.PreferLocal && len(dependency.PreInstalled) > 0 { + // Check for pre-installed binaries first + for _, binName := range dependency.PreInstalled { + if Which(binName) { + if options.VersionCheck == VersionCheckNone { + log.Debugf("Using pre-installed %s", binName) + return nil + } + if shouldSkipInstall, err := checkVersionRequirementByCommand(binName, version, options.VersionCheck, &dependency); err == nil && shouldSkipInstall { + log.Debugf("Pre-installed %s meets version requirement", binName) + return nil + } + } + } + } } - if is.File(bin) { - log.Debugf("%s already exists", bin) - return nil + bin, err := dependency.GetPathWithOptions(name, options.BinDir, options.GOOS, options.GOARCH) + if err != nil { + return err } var urlPath string - switch runtime.GOOS { + switch options.GOOS { case "linux": urlPath = dependency.Linux - if strings.HasPrefix(runtime.GOARCH, "arm") && dependency.LinuxARM != "" { + if strings.HasPrefix(options.GOARCH, "arm") && dependency.LinuxARM != "" { urlPath = dependency.LinuxARM } case "darwin": urlPath = dependency.Macosx - if strings.HasPrefix(runtime.GOARCH, "arm") && dependency.MacosxARM != "" { + if strings.HasPrefix(options.GOARCH, "arm") && dependency.MacosxARM != "" { urlPath = dependency.MacosxARM } case "windows": urlPath = dependency.Windows } - data := map[string]string{"os": runtime.GOOS, "platform": runtime.GOARCH, "version": ver} + data := map[string]string{"os": options.GOOS, "platform": options.GOARCH, "version": version} if urlPath == "" && dependency.Template != "" { urlPath, err = template(dependency.Template, data) if err != nil { return err } } + if urlPath != "" { url, err := template(urlPath, data) if err != nil { return err } - log.Infof("Installing %s@%s (from=%s, to:%s)", name, ver, url, bin) - err = download(url, bin) + log.Infof("Installing %s@%s (from=%s, to:%s)", name, version, url, bin) + + // Create bin directory if it doesn't exist + if !files.Exists(options.BinDir) { + if err := os.MkdirAll(options.BinDir, 0755); err != nil { + return fmt.Errorf("failed to create directory %s: %w", options.BinDir, err) + } + } + + err = downloadWithOptions(url, bin, options, &dependency, data) if err != nil { return fmt.Errorf("failed to download %s: %+v", name, err) } @@ -310,9 +487,8 @@ func InstallDependency(name, ver string, binDir string) error { return fmt.Errorf("failed to make %s executable: %w", name, err) } } else if dependency.Go != "" { - //FIXME this only works if the PWD is in the GOPATH url, _ := template(dependency.Go, data) - log.Infof("Installing via go get %s (%s) -> %s", name, ver, url) + log.Infof("Installing via go get %s (%s) -> %s", name, version, url) if err := exec.Execf("GOPATH=$PWD/.go go get %s", url); err != nil { return err } @@ -323,6 +499,11 @@ func InstallDependency(name, ver string, binDir string) error { return nil } +// InstallDependency installs a binary to binDir, if ver is nil then the default version is used +func InstallDependency(name, ver string, binDir string) error { + return Install(name, ver, WithBinDir(binDir)) +} + // Binary returns a function that can be called to execute the binary func Binary(name, ver string, binDir string) BinaryFunc { binDir = absolutePath(binDir) @@ -369,6 +550,67 @@ func Binary(name, ver string, binDir string) BinaryFunc { } +// BinaryWithOptions returns a function that can be called to execute the binary with configurable options +func BinaryWithOptions(name, ver string, opts ...InstallOption) BinaryFunc { + // Set default options + options := &InstallOptions{ + BinDir: "/usr/local/bin", + Force: false, + VersionCheck: VersionCheckNone, + GOOS: runtime.GOOS, + GOARCH: runtime.GOARCH, + Timeout: 5 * time.Minute, + SkipChecksum: false, + PreferLocal: false, + } + + // Apply provided options + for _, opt := range opts { + opt(options) + } + + options.BinDir = absolutePath(options.BinDir) + dependency, ok := dependencies[name] + if !ok { + return func(msg string, args ...any) error { + if Which(name) { + return exec.Execf(name+" "+msg, args...) + } + return fmt.Errorf("cannot find preinstalled dependency: %s", name) + } + } + + if dependency.Docker != "" { + return func(msg string, args ...any) error { + cwd, _ := os.Getwd() + docker := fmt.Sprintf("docker run --rm -v %s:%s -w %s %s:%s ", cwd, cwd, cwd, dependency.Docker, ver) + return exec.Execf(docker+msg, args...) + } + } + + if len(dependency.PreInstalled) > 0 { + return func(msg string, args ...any) error { + for _, bin := range dependency.PreInstalled { + if Which(bin) { + return exec.Execf(bin+" "+msg, args...) + } + } + return fmt.Errorf("cannot find preinstalled dependency: %s", strings.Join(dependency.PreInstalled, ",")) + } + } + + return func(msg string, args ...any) error { + if err := Install(name, ver, opts...); err != nil { + return err + } + bin, err := dependency.GetPathWithOptions(name, options.BinDir, options.GOOS, options.GOARCH) + if err != nil { + return err + } + return exec.Execf(bin+" "+msg, args...) + } +} + // InstallDependencies takes a map of supported dependencies and their version and // installs them to the specified binDir func InstallDependencies(deps map[string]string, binDir string) error { @@ -399,6 +641,200 @@ func Which(cmd string) bool { return err == nil } +// GetPathWithOptions returns the binary path using specified OS and architecture +func (dependency *Dependency) GetPathWithOptions(name string, binDir string, goos, goarch string) (string, error) { + data := map[string]string{"os": goos, "platform": goarch, "version": dependency.Version} + if dependency.BinaryName != "" { + templated, err := template(dependency.BinaryName, data) + if err != nil { + return "", err + } + return path.Join(binDir, templated), nil + } else { + return path.Join(binDir, name), nil + } +} + +// GetInstalledVersion extracts version from binary output +func GetInstalledVersion(binaryPath string, dependency *Dependency) (string, error) { + if dependency.VersionCommand == "" { + dependency.VersionCommand = "--version" + } + + cmd := osExec.Command(binaryPath, dependency.VersionCommand) + output, err := cmd.Output() + if err != nil { + // Try alternative version commands + for _, altCmd := range []string{"-v", "version", "--version"} { + if altCmd != dependency.VersionCommand { + cmd := osExec.Command(binaryPath, altCmd) + if output, err = cmd.Output(); err == nil { + break + } + } + } + if err != nil { + return "", fmt.Errorf("failed to get version from %s: %w", binaryPath, err) + } + } + + return ExtractVersionFromOutput(string(output), dependency.VersionPattern) +} + +// GetInstalledVersionByCommand extracts version by running command directly +func GetInstalledVersionByCommand(command string, dependency *Dependency) (string, error) { + if dependency.VersionCommand == "" { + dependency.VersionCommand = "--version" + } + + cmd := osExec.Command(command, dependency.VersionCommand) + output, err := cmd.Output() + if err != nil { + // Try alternative version commands + for _, altCmd := range []string{"-v", "version", "--version"} { + if altCmd != dependency.VersionCommand { + cmd := osExec.Command(command, altCmd) + if output, err = cmd.Output(); err == nil { + break + } + } + } + if err != nil { + return "", fmt.Errorf("failed to get version from %s: %w", command, err) + } + } + + return ExtractVersionFromOutput(string(output), dependency.VersionPattern) +} + +// ExtractVersionFromOutput extracts version using regex pattern +func ExtractVersionFromOutput(output, pattern string) (string, error) { + if pattern == "" { + // Default pattern for common version formats + pattern = `v?(\d+(?:\.\d+)*(?:\.\d+)*(?:-[a-zA-Z0-9-_.]+)?)` + } + + re, err := regexp.Compile(pattern) + if err != nil { + return "", fmt.Errorf("invalid version pattern: %w", err) + } + + matches := re.FindStringSubmatch(output) + if len(matches) < 2 { + return "", fmt.Errorf("version not found in output") + } + + return strings.TrimPrefix(matches[1], "v"), nil +} + +// CompareVersions compares two version strings using semver +func CompareVersions(installed, required string, mode VersionCheckMode) (bool, error) { + if mode == VersionCheckNone { + return true, nil + } + + // Clean up version strings and ensure they have v prefix for semver parsing + installed = strings.TrimSpace(installed) + required = strings.TrimSpace(required) + + if !strings.HasPrefix(installed, "v") { + installed = "v" + installed + } + if !strings.HasPrefix(required, "v") { + required = "v" + required + } + + if mode == VersionCheckExact { + return strings.TrimPrefix(installed, "v") == strings.TrimPrefix(required, "v"), nil + } + + // Parse versions using semver + installedVer, err := semver.NewVersion(installed) + if err != nil { + return false, fmt.Errorf("failed to parse installed version %s: %w", installed, err) + } + + requiredVer, err := semver.NewVersion(required) + if err != nil { + return false, fmt.Errorf("failed to parse required version %s: %w", required, err) + } + + switch mode { + case VersionCheckMinimum: + return installedVer.GreaterThan(requiredVer) || installedVer.Equal(requiredVer), nil + case VersionCheckCompatible: + // Same major version and installed >= required + return installedVer.Major() == requiredVer.Major() && + (installedVer.GreaterThan(requiredVer) || installedVer.Equal(requiredVer)), nil + } + + return false, fmt.Errorf("unknown version check mode: %d", mode) +} + +// checkVersionRequirement checks if installed version meets requirement +func checkVersionRequirement(binaryPath, requiredVersion string, mode VersionCheckMode, dependency *Dependency) (bool, error) { + installedVersion, err := GetInstalledVersion(binaryPath, dependency) + if err != nil { + return false, err + } + + return CompareVersions(installedVersion, requiredVersion, mode) +} + +// checkVersionRequirementByCommand checks version requirement by command name +func checkVersionRequirementByCommand(command, requiredVersion string, mode VersionCheckMode, dependency *Dependency) (bool, error) { + installedVersion, err := GetInstalledVersionByCommand(command, dependency) + if err != nil { + return false, err + } + + return CompareVersions(installedVersion, requiredVersion, mode) +} + +// downloadWithOptions downloads with checksum verification if enabled +func downloadWithOptions(url, dest string, options *InstallOptions, dependency *Dependency, data map[string]string) error { + if !options.SkipChecksum && dependency.Checksums != nil { + platform := fmt.Sprintf("%s-%s", options.GOOS, options.GOARCH) + if expectedChecksum, ok := dependency.Checksums[platform]; ok { + return downloadWithChecksum(url, dest, expectedChecksum) + } + } + + return download(url, dest) +} + +// downloadWithChecksum downloads and verifies checksum +func downloadWithChecksum(url, dest, expectedChecksum string) error { + if err := net.Download(url, dest); err != nil { + return err + } + + return verifyFileChecksum(dest, expectedChecksum) +} + +// verifyFileChecksum verifies SHA256 checksum of a file +func verifyFileChecksum(filepath, expectedChecksum string) error { + file, err := os.Open(filepath) + if err != nil { + return err + } + defer file.Close() + + hash := sha256.New() + if _, err := io.Copy(hash, file); err != nil { + return err + } + + actualChecksum := fmt.Sprintf("%x", hash.Sum(nil)) + expectedChecksum = strings.TrimSpace(expectedChecksum) + + if actualChecksum != expectedChecksum { + return fmt.Errorf("checksum mismatch: expected %s, got %s", expectedChecksum, actualChecksum) + } + + return nil +} + func template(template string, vars map[string]string) (string, error) { tpl := gotemplate.New("") diff --git a/deps/deps_test.go b/deps/deps_test.go deleted file mode 100644 index af512a8..0000000 --- a/deps/deps_test.go +++ /dev/null @@ -1,42 +0,0 @@ -package deps - -import ( - "fmt" - "os" - - "testing" - - "github.com/flanksource/commons/files" -) - -func XTestInstallDependency(t *testing.T) { - dir, err := os.MkdirTemp("", "commons-test-deps") - fmt.Printf("Created dir %s\n", dir) - if err != nil { - t.Errorf("failed to create temporary directory %v", err) - } - defer os.RemoveAll(dir) - - for name, dependency := range dependencies { - t.Run(name, func(t *testing.T) { - t.Logf("Installing %s", name) - err := InstallDependency(name, dependency.Version, dir) - if err != nil { - t.Errorf("Failed to download %s: %v", name, err) - return - } - if len(dependency.PreInstalled) > 0 || len(dependency.Docker) > 0 { - return - } - - path, err := dependency.GetPath(name, dir) - if err != nil { - t.Errorf("Failed to install %s. ", err) - } - if !files.Exists(path) { - t.Errorf("Failed to install %s. %s does not exist", name, path) - } - }) - - } -} diff --git a/dns/dns.go b/dns/dns.go index a0f3f98..b7fdb49 100644 --- a/dns/dns.go +++ b/dns/dns.go @@ -21,7 +21,7 @@ // for _, ip := range ips { // fmt.Printf("IP: %s\n", ip) // } -// +// // // Direct lookup without caching // ips, err := dns.Lookup("A", "https://example.com") // // Automatically extracts hostname from URL diff --git a/duration/duration.go b/duration/duration.go index dc8ce81..c3b51a8 100644 --- a/duration/duration.go +++ b/duration/duration.go @@ -41,14 +41,14 @@ // log.Fatal(err) // } // fmt.Println(d) // Output: 3d12h30m -// +// // // Create from standard units // d = duration.Day * 7 + duration.Hour * 6 // fmt.Println(d) // Output: 1w6h -// +// // // Convert to standard time.Duration // td := time.Duration(d) -// +// // // Get components // fmt.Printf("Hours: %.2f, Days: %.2f\n", d.Hours(), d.Days()) // @@ -355,7 +355,7 @@ var unitMap = map[string]int64{ // // Valid time units are: // - "ns": nanoseconds -// - "us" (or "µs"/"μs"): microseconds +// - "us" (or "µs"/"μs"): microseconds // - "ms": milliseconds // - "s": seconds // - "m": minutes diff --git a/exec/exec.go b/exec/exec.go index 1e2df6f..d9a8818 100644 --- a/exec/exec.go +++ b/exec/exec.go @@ -10,7 +10,7 @@ import ( log "github.com/sirupsen/logrus" ) -//SafeExec executes the sh script and returns the stdout and stderr, errors will result in a nil return only. +// SafeExec executes the sh script and returns the stdout and stderr, errors will result in a nil return only. func SafeExec(sh string, args ...interface{}) (string, bool) { cmd := exec.Command("bash", "-c", fmt.Sprintf(sh, args...)) data, err := cmd.CombinedOutput() @@ -27,12 +27,12 @@ func SafeExec(sh string, args ...interface{}) (string, bool) { } -//Exec runs the sh script and forwards stderr/stdout to the console +// Exec runs the sh script and forwards stderr/stdout to the console func Exec(sh string) error { return Execf(sh) } -//ExecfWithEnv runs the sh script and forwards stderr/stdout to the console +// ExecfWithEnv runs the sh script and forwards stderr/stdout to the console func ExecfWithEnv(sh string, env map[string]string, args ...interface{}) error { if log.IsLevelEnabled(log.TraceLevel) { envString := "" @@ -67,7 +67,7 @@ func ExecfWithEnv(sh string, env map[string]string, args ...interface{}) error { return nil } -//Execf runs the sh script and forwards stderr/stdout to the console +// Execf runs the sh script and forwards stderr/stdout to the console func Execf(sh string, args ...interface{}) error { return ExecfWithEnv(sh, make(map[string]string), args...) } diff --git a/files/archive_test.go b/files/archive_test.go index ee32f5b..ab667af 100644 --- a/files/archive_test.go +++ b/files/archive_test.go @@ -79,10 +79,21 @@ var _ = Describe("Unarchive", func() { Errors: []error{}, }, }, + { + name: "tar with nodejs-style symlinks", + archivePath: "testdata/archives/nodejs_symlinks.tar", + expectedArchive: &Archive{ + Files: []string{"./lib/node_modules/corepack/dist/corepack.js"}, + Directories: []string{"./", "./bin/", "./lib/", "./lib/node_modules/", "./lib/node_modules/corepack/", "./lib/node_modules/corepack/dist/"}, + Symlinks: []string{"./bin/corepack"}, + Skipped: []string{}, + Errors: []error{}, + }, + }, { name: "tar with dangerous symlinks should fail", archivePath: "testdata/archives/parent_symlinks.tar", - expectedError: "is absolute and not allowed", + expectedError: "escapes extraction directory", }, { name: "simple zip archive", @@ -139,10 +150,21 @@ var _ = Describe("Unarchive", func() { Errors: []error{}, }, }, + { + name: "tar.xz with safe symlinks", + archivePath: "testdata/archives/symlinks.tar.xz", + expectedArchive: &Archive{ + Files: []string{"./bin/postgres", "./lib/libpq.so", "./share/postgresql.conf"}, + Directories: []string{"./", "./bin/", "./lib/", "./share/"}, + Symlinks: []string{"./bin/libpq_link", "./share/postgres.conf"}, + Skipped: []string{}, + Errors: []error{}, + }, + }, { name: "tar.xz with parent symlinks should fail", - archivePath: "testdata/archives/symlinks.tar.xz", - expectedError: "is absolute and not allowed", + archivePath: "testdata/archives/dangerous_symlinks.tar.xz", + expectedError: "escapes extraction directory", }, { name: "empty archive", @@ -188,6 +210,17 @@ var _ = Describe("Unarchive", func() { Errors: []error{}, }, }, + { + name: "unicode characters in tar.gz", + archivePath: "testdata/archives/unicode.tar.gz", + expectedArchive: &Archive{ + Files: []string{"café.txt", "测试.txt", "файл.txt", "日本語.txt", "émojis😀.txt", "simple.txt"}, + Directories: []string{}, + Symlinks: []string{}, + Skipped: []string{}, + Errors: []error{}, + }, + }, { name: "corrupted archive", archivePath: "testdata/archives/corrupted.tar.gz", diff --git a/files/files.go b/files/files.go index 3288cee..2e16c0e 100644 --- a/files/files.go +++ b/files/files.go @@ -121,26 +121,16 @@ func (a *Archive) String() string { return result } -var blacklistedPathSymbols = "${}[]?*:<>|" +var blacklistedPathSymbols = "{}[]?*:<>|" var blockedPrefixes = []string{"/run/", "/proc/", "/etc/", "/var/", "/tmp/", "/dev/"} -func isASCII(s string) bool { - for i := 0; i < len(s); i++ { - - if s[i] > unicode.MaxASCII || unicode.IsControl(rune(s[i])) { - return false - } - } - return true - -} - // ValidatePath validates a single path for security issues func ValidatePath(path string) error { - - // Check for non-ASCII characters - if !isASCII(path) { - return fmt.Errorf("path %s contains non-ASCII characters", path) + // Check for control characters that could be dangerous + for _, r := range path { + if unicode.IsControl(r) && r != '\t' { // Allow tabs, but not other control chars + return fmt.Errorf("path %s contains control characters", path) + } } // Check for illegal characters @@ -149,7 +139,6 @@ func ValidatePath(path string) error { } // Check for path traversal attempts - if strings.Contains(path, "../") || strings.Contains(path, "..\\") { return fmt.Errorf("path %s attempts to access parent directories which is not allowed", path) } @@ -399,7 +388,9 @@ func unzipWithResult(src, dest string, opts *UnarchiveOptions) (*Archive, error) if info.IsDir() { _ = rc.Close() dirMode := info.Mode() & os.ModePerm - if dirMode == 0 { + // Ensure directory has owner write+execute permissions to prevent extraction failures + // Directories need execute bit to be traversable + if dirMode == 0 || dirMode&0300 != 0300 { dirMode = 0755 } if err := root.MkdirAll(path, dirMode); err != nil { @@ -496,7 +487,6 @@ func Unzip(src, dest string) error { return err } - // Untar extracts all files in tarball to the target directory (backwards compatibility wrapper) func Untar(tarball, target string) error { _, err := Unarchive(tarball, target) @@ -606,7 +596,9 @@ func UntarWithFilterAndResult(tarball, target string, filter FileFilter, opts *U if info.IsDir() { dirMode := info.Mode() & os.ModePerm // Extract permission bits only - if dirMode == 0 { + // Ensure directory has owner write+execute permissions to prevent extraction failures + // Directories need execute bit to be traversable + if dirMode == 0 || dirMode&0300 != 0300 { dirMode = 0755 // Default directory permissions } if err = root.MkdirAll(path, dirMode); err != nil { @@ -640,7 +632,9 @@ func UntarWithFilterAndResult(tarball, target string, filter FileFilter, opts *U if fileExists && opts.Overwrite { flags |= os.O_TRUNC } - file, err := root.OpenFile(path, flags, os.FileMode(header.Mode)) + // Extract only permission bits, not file type bits + fileMode := os.FileMode(header.Mode) & os.ModePerm + file, err := root.OpenFile(path, flags, fileMode) if err != nil { return archive, fmt.Errorf("failed to create file %s (mode=%v) %s", path, info.Mode(), err) } @@ -655,11 +649,21 @@ func UntarWithFilterAndResult(tarball, target string, filter FileFilter, opts *U case tar.TypeSymlink: // Validate the symlink target stays within extraction dir linkTarget := header.Linkname - // Only check relative paths; absolute always forbidden - if filepath.IsAbs(linkTarget) || !filepath.IsLocal(linkTarget) { + // Check if symlink target is absolute - always reject + if filepath.IsAbs(linkTarget) { return archive, fmt.Errorf("symlink target %s is absolute and not allowed", linkTarget) } + // For relative targets, resolve them relative to the symlink's directory + symlinkDir := filepath.Dir(path) + resolvedPath := filepath.Join(symlinkDir, linkTarget) + cleanPath := filepath.Clean(resolvedPath) + + // Check if the clean path escapes the extraction directory + if cleanPath == ".." || strings.HasPrefix(cleanPath, "../") { + return archive, fmt.Errorf("symlink target %s escapes extraction directory", linkTarget) + } + if err := root.Symlink(linkTarget, path); err != nil { return archive, fmt.Errorf("failed to create symlink %s -> %s: %w", path, linkTarget, err) } @@ -667,7 +671,9 @@ func UntarWithFilterAndResult(tarball, target string, filter FileFilter, opts *U case tar.TypeDir: dirMode := os.FileMode(header.Mode) & os.ModePerm // Extract permission bits only - if dirMode == 0 { + // Ensure directory has owner write+execute permissions to prevent extraction failures + // Directories need execute bit to be traversable + if dirMode == 0 || dirMode&0300 != 0300 { dirMode = 0755 // Default directory permissions } if err := root.MkdirAll(path, dirMode); err != nil { @@ -699,7 +705,9 @@ func UntarWithFilterAndResult(tarball, target string, filter FileFilter, opts *U if fileExists && opts.Overwrite { flags |= os.O_TRUNC } - file, err := root.OpenFile(path, flags, os.FileMode(header.Mode)) + // Extract only permission bits, not file type bits + fileMode := os.FileMode(header.Mode) & os.ModePerm + file, err := root.OpenFile(path, flags, fileMode) if err != nil { _ = targetFile.Close() return nil, fmt.Errorf("failed to create hard link file %s (mode=%v): %w", path, info.Mode(), err) diff --git a/files/path_validation_test.go b/files/path_validation_test.go new file mode 100644 index 0000000..189cdea --- /dev/null +++ b/files/path_validation_test.go @@ -0,0 +1,123 @@ +package files + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("ValidatePath", func() { + Context("Non-ASCII character support", func() { + It("should allow various non-ASCII characters", func() { + validPaths := []string{ + "café.txt", + "测试.txt", + "файл.txt", + "日本語.txt", + "émojis😀.txt", + "subdir/文件.txt", + "한글/파일.txt", + "العربية/ملف.txt", + "ελληνικά/αρχείο.txt", + } + + for _, path := range validPaths { + err := ValidatePath(path) + Expect(err).NotTo(HaveOccurred(), "Path %s should be valid", path) + } + }) + + It("should still reject dangerous control characters", func() { + invalidPaths := []string{ + "file\x00.txt", // null byte + "file\x01.txt", // control character + "file\x1f.txt", // control character + "file\n.txt", // newline + "file\r.txt", // carriage return + } + + for _, path := range invalidPaths { + err := ValidatePath(path) + Expect(err).To(HaveOccurred(), "Path %s should be invalid", path) + Expect(err.Error()).To(ContainSubstring("control characters")) + } + }) + + It("should allow tabs in filenames", func() { + err := ValidatePath("file\twith\ttabs.txt") + Expect(err).NotTo(HaveOccurred()) + }) + }) + + Context("Existing security validations", func() { + It("should reject path traversal attempts", func() { + invalidPaths := []string{ + "../etc/passwd", + "subdir/../../../etc/passwd", + "..\\windows\\system32", + } + + for _, path := range invalidPaths { + err := ValidatePath(path) + Expect(err).To(HaveOccurred(), "Path %s should be invalid", path) + Expect(err.Error()).To(ContainSubstring("access parent directories")) + } + }) + + It("should reject blocked characters", func() { + blacklistedChars := []string{"{", "}", "[", "]", "?", "*", ":", "<", ">", "|"} + + for _, char := range blacklistedChars { + path := "file" + char + ".txt" + err := ValidatePath(path) + Expect(err).To(HaveOccurred(), "Path %s should be invalid", path) + Expect(err.Error()).To(ContainSubstring("illegal characters")) + } + }) + + It("should allow $ character for Java inner classes", func() { + validPaths := []string{ + "WebConsoleStarter$OsgiUtil.class", + "OuterClass$InnerClass.class", + "Service$1$Helper.class", + "apache-activemq-5.19.0/webapps/admin/WEB-INF/classes/org/apache/activemq/web/WebConsoleStarter$OsgiUtil.class", + } + + for _, path := range validPaths { + err := ValidatePath(path) + Expect(err).NotTo(HaveOccurred(), "Path %s should be valid", path) + } + }) + + It("should reject blocked prefixes", func() { + blockedPaths := []string{ + "/etc/passwd", + "/var/log/system.log", + "/tmp/exploit", + "/proc/version", + "/run/secrets", + "/dev/null", + } + + for _, path := range blockedPaths { + err := ValidatePath(path) + Expect(err).To(HaveOccurred(), "Path %s should be invalid", path) + Expect(err.Error()).To(ContainSubstring("blocked prefix")) + } + }) + + It("should allow valid normal paths", func() { + validPaths := []string{ + "file.txt", + "subdir/file.txt", + "another_dir/subdir/file-name.txt", + "file123.log", + "data.json", + } + + for _, path := range validPaths { + err := ValidatePath(path) + Expect(err).NotTo(HaveOccurred(), "Path %s should be valid", path) + } + }) + }) +}) diff --git a/files/testdata/archives/dangerous_symlinks.tar.xz b/files/testdata/archives/dangerous_symlinks.tar.xz new file mode 100644 index 0000000..e6429f8 Binary files /dev/null and b/files/testdata/archives/dangerous_symlinks.tar.xz differ diff --git a/files/testdata/archives/nodejs_symlinks.tar b/files/testdata/archives/nodejs_symlinks.tar new file mode 100644 index 0000000..b5ac911 Binary files /dev/null and b/files/testdata/archives/nodejs_symlinks.tar differ diff --git a/files/testdata/archives/unicode.tar.gz b/files/testdata/archives/unicode.tar.gz new file mode 100644 index 0000000..f4d0adc Binary files /dev/null and b/files/testdata/archives/unicode.tar.gz differ diff --git a/go.mod b/go.mod index 3e74308..24ce35a 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/flanksource/commons go 1.25.1 require ( + github.com/Masterminds/semver/v3 v3.4.0 github.com/Snawoot/go-http-digest-auth-client v1.1.3 github.com/bsm/gomega v1.27.10 github.com/emirpasic/gods/v2 v2.0.0-alpha diff --git a/go.sum b/go.sum index decbe53..ab86ce2 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Snawoot/go-http-digest-auth-client v1.1.3 h1:Xd/SNBuIUJqotzmxRpbXovBJxmlVZOT19IZZdMdrJ0Q= github.com/Snawoot/go-http-digest-auth-client v1.1.3/go.mod h1:WiwNiPXTRGyjTGpBtSQJlM2wDPRRPpFGhMkMWpV4uqg= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= diff --git a/hash/hash.go b/hash/hash.go index cdfa522..84df28a 100644 --- a/hash/hash.go +++ b/hash/hash.go @@ -7,7 +7,7 @@ // // Key features: // - JSON object hashing with MD5 -// - SHA256 string hashing +// - SHA256 string hashing // - Deterministic UUID generation from any JSON-serializable data // // Basic usage: @@ -23,11 +23,11 @@ // log.Fatal(err) // } // fmt.Printf("MD5: %s\n", hash) -// +// // // Generate SHA256 hash // sha := hash.Sha256Hex("hello world") // fmt.Printf("SHA256: %s\n", sha) -// +// // // Create deterministic UUID // id, err := hash.DeterministicUUID(user) // if err != nil { @@ -109,7 +109,7 @@ func Sha256Hex(in string) string { // } // id, err := DeterministicUUID(userData) // // The same userData will always generate the same UUID -// +// // // Generate UUID from string // id2, err := DeterministicUUID("unique-resource-name") func DeterministicUUID(seed any) (uuid.UUID, error) { diff --git a/http/client.go b/http/client.go index ec26549..b889ab4 100644 --- a/http/client.go +++ b/http/client.go @@ -459,15 +459,16 @@ func (c *Client) TraceToStdout(config TraceConfig) *Client { } // WithHttpLogging enables HTTP request/response logging based on the provided log levels. -// +// // Parameters: // - headerLevel: The minimum log level required to log HTTP headers (e.g., logger.Debug) // - bodyLevel: The minimum log level required to log request/response bodies (e.g., logger.Trace) // // Example: -// client.WithHttpLogging(logger.Debug, logger.Trace) -// -// This will log headers when debug logging is enabled (-v or -v 1) and +// +// client.WithHttpLogging(logger.Debug, logger.Trace) +// +// This will log headers when debug logging is enabled (-v or -v 1) and // bodies when trace logging is enabled (-vv or -v 2 or higher). // // Note: When using with cobra commands, ensure UseCobraFlags is called diff --git a/http/response.go b/http/response.go index 3ef5a14..f3a46ee 100644 --- a/http/response.go +++ b/http/response.go @@ -101,6 +101,29 @@ func (h *Response) HeaderMap() map[string]string { return headers } +func (h *Response) AsMap() (map[string]any, error) { + + m := make(map[string]any) + + m["headers"] = map[string]any{} + for k, v := range logger.StripSecretsFromMap(h.HeaderMap()) { + m["headers"].(map[string]any)[k] = v + } + + if h.Request != nil && h.Request.url != nil { + m["url"] = h.Request.url.String() + } + m["status"] = h.StatusCode + + if h.IsJSON() { + m["body"], _ = h.AsJSON() + } else { + m["body"], _ = h.AsString() + } + + return m, nil +} + func (h *Response) Debug() string { // mimic the response, + add content-type and size var sb strings.Builder diff --git a/logger/buffered.go b/logger/buffered.go index 51e1b70..eea4c34 100644 --- a/logger/buffered.go +++ b/logger/buffered.go @@ -288,42 +288,7 @@ func (b *BufferedLogger) GetLevel() LogLevel { func (b *BufferedLogger) SetLogLevel(level any) { b.mu.Lock() defer b.mu.Unlock() - - oldLevel := b.logLevel - - switch v := level.(type) { - case LogLevel: - b.logLevel = v - case int: - b.logLevel = LogLevel(v) - case string: - // Parse string level - switch v { - case "trace": - b.logLevel = Trace - case "debug": - b.logLevel = Debug - case "info": - b.logLevel = Info - case "warn": - b.logLevel = Warn - case "error": - b.logLevel = Error - case "fatal": - b.logLevel = Fatal - default: - b.logLevel = Info - } - default: - b.logLevel = Info - } - - // Auto-scale retention if log level changed - if oldLevel != b.logLevel { - b.mu.Unlock() // Unlock temporarily for ScaleRetentionByLogLevel - b.ScaleRetentionByLogLevel() - b.mu.Lock() // Re-lock for defer - } + b.logLevel = ParseLevel(b, level) } // SetMinLogLevel sets the minimum log level (same as SetLogLevel for BufferedLogger) diff --git a/logger/default.go b/logger/default.go index 34ecdfe..79d8728 100644 --- a/logger/default.go +++ b/logger/default.go @@ -2,6 +2,7 @@ package logger import ( "fmt" + "io" "os" "regexp" "strings" @@ -231,6 +232,17 @@ func SetLogger(logger Logger) { currentLogger = logger } +// Use configures the logger to write to the specified writer. +// This replaces the current logger with one that outputs to the given writer. +// Useful for integrating with test frameworks like Ginkgo. +// +// Example: +// +// logger.Use(GinkgoWriter) // Route logger output to Ginkgo's test writer +func Use(writer io.Writer) { + currentLogger = NewWithWriter(writer) +} + // StandardLogger returns the current global logger instance. // This is equivalent to GetLogger(). func StandardLogger() Logger { diff --git a/logger/http.go b/logger/http.go index dfc8299..41bf2d2 100644 --- a/logger/http.go +++ b/logger/http.go @@ -16,7 +16,7 @@ var SensitiveHeaders = []string{ // NewHttpLogger creates an HTTP logger that logs at predefined levels. // Deprecated: Use NewHttpLoggerWithLevels for more control over logging levels. -// +// // Default behavior: // - Headers and timing: Requires log level 5 (Trace3) // - Request body: Requires log level 6 (Trace4) @@ -51,8 +51,9 @@ func NewHttpLogger(logger Logger, rt http.RoundTripper) http.RoundTripper { // - bodyLevel: Minimum log level required to log request/response bodies // // Example: -// // Log headers at debug level (-v) and bodies at trace level (-vv) -// transport := NewHttpLoggerWithLevels(logger, http.DefaultTransport, logger.Debug, logger.Trace) +// +// // Log headers at debug level (-v) and bodies at trace level (-vv) +// transport := NewHttpLoggerWithLevels(logger, http.DefaultTransport, logger.Debug, logger.Trace) func NewHttpLoggerWithLevels(logger Logger, rt http.RoundTripper, headerLevel, bodyLevel LogLevel) http.RoundTripper { if !logger.IsLevelEnabled(headerLevel) { return rt diff --git a/logger/slog.go b/logger/slog.go index b908bd9..a4b74bb 100644 --- a/logger/slog.go +++ b/logger/slog.go @@ -3,6 +3,7 @@ package logger import ( "context" "fmt" + "io" "log/slog" "net/http" "os" @@ -135,6 +136,48 @@ func New(prefix string) *SlogLogger { // logger.V(4).Infof("new logger created name=%v flags=%s level=%s", prefix, flags.level, FromSlogLevel(lvl.Level()).String()) return logger } + +// NewWithWriter creates a new SlogLogger that writes to the specified writer. +// This is useful for integrating with test frameworks or custom output destinations. +func NewWithWriter(writer io.Writer) *SlogLogger { + var logger *SlogLogger + var lvl = &slog.LevelVar{} + + reportCaller := properties.On(flags.reportCaller, "log.caller") + logJson := properties.On(flags.jsonLogs, "log.json") + logColor := properties.On(flags.color, "log.color") + + var rootLevel string + if flags.level != "" { + rootLevel = flags.level + } else { + rootLevel = properties.String("info", "log.level") + } + + if logJson { + logger = &SlogLogger{ + Level: lvl, + Logger: slog.New(slog.NewJSONHandler(writer, &slog.HandlerOptions{ + AddSource: reportCaller, + Level: lvl, + })), + } + } else { + logger = &SlogLogger{ + Logger: slog.New(tint.NewHandler(writer, &tint.Options{ + Level: lvl, + NoColor: !logColor, + AddSource: reportCaller, + TimeFormat: properties.String("15:04:05.999", "log.time.format"), + })), + Level: lvl, + } + } + + logger.SetLogLevel(rootLevel) + return logger +} + func UseSlog() { if currentLogger != nil { return diff --git a/properties/properties.go b/properties/properties.go index 217a00c..5fc374a 100644 --- a/properties/properties.go +++ b/properties/properties.go @@ -20,16 +20,16 @@ // if err != nil { // log.Fatal(err) // } -// +// // // Get property values with defaults // host := properties.String("localhost", "server.host") // port := properties.Int(8080, "server.port") // debug := properties.On(false, "debug.enabled", "debug") // timeout := properties.Duration(30*time.Second, "request.timeout") -// +// // // Set properties dynamically // properties.Set("api.key", "secret-key") -// +// // // Register change listener // properties.RegisterListener(func(p *properties.Properties) { // log.Println("Properties updated") @@ -97,12 +97,12 @@ func BindFlags(flags *pflag.FlagSet) { // Properties represents a thread-safe key-value store for application configuration. // It supports loading from files, dynamic updates, file watching, and change notifications. type Properties struct { - m map[string]string // The property map - filename string // Currently loaded file - listeners []func(*Properties) // Change listeners - lock sync.RWMutex // Protects concurrent access - close func() // Cleanup function for file watcher - Reload func() // Function to manually trigger reload + m map[string]string // The property map + filename string // Currently loaded file + listeners []func(*Properties) // Change listeners + lock sync.RWMutex // Protects concurrent access + close func() // Cleanup function for file watcher + Reload func() // Function to manually trigger reload } func (p *Properties) RegisterListener(fn func(*Properties)) { diff --git a/test/colors.go b/test/colors.go index 625e440..8a79ed5 100644 --- a/test/colors.go +++ b/test/colors.go @@ -8,4 +8,4 @@ const ( colorYellow = "\033[93m" colorBlue = "\033[94m" colorBold = "\033[1m" -) \ No newline at end of file +) diff --git a/test/command.go b/test/command.go index 8b81e91..8aaff5a 100644 --- a/test/command.go +++ b/test/command.go @@ -147,4 +147,4 @@ func (c *CommandRunner) Printf(color, style, format string, args ...interface{}) } else { fmt.Printf(format+"\n", args...) } -} \ No newline at end of file +} diff --git a/test/helm.go b/test/helm.go index 5532278..9c04675 100644 --- a/test/helm.go +++ b/test/helm.go @@ -22,11 +22,11 @@ type HelmChart struct { passwordSecret string colorOutput bool dryRun bool - + // Command execution state - runner *CommandRunner - lastResult CommandResult - lastError error + runner *CommandRunner + lastResult CommandResult + lastError error } // NewHelmChart creates a new HelmChart builder @@ -622,9 +622,6 @@ func (h *HelmChart) collectDiagnostics() { h.runner.Printf(colorYellow, colorBold, "=== End of Diagnostics ===") } - - - // Similar runCommand methods for Pod, StatefulSet, etc. func (p *Pod) runCommand(name string, args ...string) CommandResult { return p.helm.runner.RunCommand(name, args...) @@ -711,4 +708,4 @@ func (n *Namespace) MustSucceed() *Namespace { func (n *Namespace) runCommand(name string, args ...string) CommandResult { return n.runner.RunCommand(name, args...) -} \ No newline at end of file +} diff --git a/test/kind.go b/test/kind.go index 649068f..bba8d70 100644 --- a/test/kind.go +++ b/test/kind.go @@ -12,7 +12,7 @@ type Kind struct { Name string `yaml:"name"` UseExisting bool `yaml:"use_existing"` ColorOutput bool `yaml:"color_output"` - + runner *CommandRunner lastResult CommandResult lastError error @@ -47,7 +47,7 @@ func (k *Kind) NoColor() *Kind { // GetOrCreate gets an existing kind cluster or creates a new one func (k *Kind) GetOrCreate() *Kind { k.runner.Printf(colorYellow, colorBold, "=== Kind Cluster: %s ===", k.Name) - + // Check if cluster already exists result := k.runner.RunCommandQuiet("kind", "get", "clusters") if result.Err == nil { @@ -60,25 +60,25 @@ func (k *Kind) GetOrCreate() *Kind { } } } - + // Create new cluster k.runner.Printf(colorBlue, "", "Creating new cluster: %s", k.Name) - + args := []string{"create", "cluster", "--name", k.Name} if k.Version != "" && k.Version != "latest" { args = append(args, "--image", fmt.Sprintf("kindest/node:%s", k.Version)) } - + k.lastResult = k.runner.RunCommand("kind", args...) if k.lastResult.Err != nil { k.lastError = fmt.Errorf("failed to create kind cluster: %s", k.lastResult.String()) return k } - + // Wait for cluster to be ready k.runner.Printf(colorGray, "", "Waiting for cluster to be ready...") k.waitForCluster() - + k.Use() return k } @@ -86,14 +86,14 @@ func (k *Kind) GetOrCreate() *Kind { // Use updates KUBECONFIG to use the kind cluster func (k *Kind) Use() *Kind { k.runner.Printf(colorBlue, "", "Switching to cluster context: kind-%s", k.Name) - + // Export kubeconfig for the kind cluster result := k.runner.RunCommandQuiet("kind", "export", "kubeconfig", "--name", k.Name) if result.Err != nil { k.lastError = fmt.Errorf("failed to export kubeconfig: %s", result.String()) return k } - + // Set the current context contextName := fmt.Sprintf("kind-%s", k.Name) k.lastResult = k.runner.RunCommand("kubectl", "config", "use-context", contextName) @@ -101,7 +101,7 @@ func (k *Kind) Use() *Kind { k.lastError = fmt.Errorf("failed to switch context: %s", k.lastResult.String()) return k } - + // Verify connection k.runner.Printf(colorGray, "", "Verifying cluster connection...") result = k.runner.RunCommandQuiet("kubectl", "cluster-info", "--context", contextName) @@ -109,7 +109,7 @@ func (k *Kind) Use() *Kind { k.lastError = fmt.Errorf("failed to verify cluster connection: %s", result.String()) return k } - + k.runner.Printf(colorGray, "", "Successfully connected to cluster: %s", k.Name) return k } @@ -117,7 +117,7 @@ func (k *Kind) Use() *Kind { // Delete deletes the kind cluster func (k *Kind) Delete() *Kind { k.runner.Printf(colorYellow, colorBold, "=== Deleting Kind Cluster: %s ===", k.Name) - + k.lastResult = k.runner.RunCommand("kind", "delete", "cluster", "--name", k.Name) if k.lastResult.Err != nil { k.lastError = fmt.Errorf("failed to delete kind cluster: %s", k.lastResult.String()) @@ -128,7 +128,7 @@ func (k *Kind) Delete() *Kind { // LoadImage loads a docker image into the kind cluster func (k *Kind) LoadImage(image string) *Kind { k.runner.Printf(colorBlue, "", "Loading image into cluster: %s", image) - + k.lastResult = k.runner.RunCommand("kind", "load", "docker-image", image, "--name", k.Name) if k.lastResult.Err != nil { k.lastError = fmt.Errorf("failed to load image: %s", k.lastResult.String()) @@ -151,7 +151,7 @@ func (k *Kind) Exists() bool { if result.Err != nil { return false } - + clusters := strings.Split(strings.TrimSpace(result.Stdout), "\n") for _, cluster := range clusters { if cluster == k.Name { @@ -198,7 +198,7 @@ func (k *Kind) SetKubeconfig() *Kind { k.lastError = err return k } - + // Write kubeconfig to temp file tempFile := fmt.Sprintf("/tmp/kind-%s-kubeconfig-%d", k.Name, time.Now().UnixNano()) cmd := k.runner.RunCommandQuiet("sh", "-c", fmt.Sprintf("cat > %s << 'EOF'\n%s\nEOF", tempFile, kubeconfig)) @@ -206,10 +206,10 @@ func (k *Kind) SetKubeconfig() *Kind { k.lastError = fmt.Errorf("failed to write kubeconfig: %w", cmd.Err) return k } - + // Set KUBECONFIG environment variable os.Setenv("KUBECONFIG", tempFile) k.runner.Printf(colorGray, "", "KUBECONFIG set to: %s", tempFile) - + return k } diff --git a/test/kind_test.go b/test/kind_test.go index 2878140..3c79050 100644 --- a/test/kind_test.go +++ b/test/kind_test.go @@ -131,4 +131,4 @@ func TestCommandRunner(t *testing.T) { t.Error("Expected non-zero exit code") } }) -} \ No newline at end of file +} diff --git a/text/bytes.go b/text/bytes.go index 6806eef..29e6d0e 100644 --- a/text/bytes.go +++ b/text/bytes.go @@ -38,7 +38,7 @@ as noted in the LICENSE file. // // size := int64(1536) // fmt.Println(text.HumanizeBytes(size)) // "1.5K" -// +// // size = 1073741824 // fmt.Println(text.HumanizeBytes(size)) // "1G" // @@ -46,7 +46,7 @@ as noted in the LICENSE file. // // count := 1500 // fmt.Println(text.HumanizeInt(count)) // "1.5k" -// +// // count = 2000000 // fmt.Println(text.HumanizeInt(count)) // "2m" // @@ -54,7 +54,7 @@ as noted in the LICENSE file. // // d := 3*time.Hour + 30*time.Minute // fmt.Println(text.HumanizeDuration(d)) // "3h30m" -// +// // age := text.Age(time.Now().Add(-24 * time.Hour)) // fmt.Println(age) // "1d" // @@ -84,7 +84,7 @@ const ( EXABYTE ) -// HumanizeBytes returns a human-readable byte string of the form 10M, 12.5K, and so forth. +// HumanizeBytes returns a human-readable byte string of the form 10M, 12.5K, and so forth. // The following units are available: // // E: Exabyte (1024^6 bytes) @@ -171,7 +171,7 @@ const ( // Examples: // // HumanizeInt(1500) // "1.5k" -// HumanizeInt(2000000) // "2m" +// HumanizeInt(2000000) // "2m" // HumanizeInt(1500000000) // "1.5b" // HumanizeInt("1000") // "1k" func HumanizeInt(size interface{}) string { diff --git a/utils/lock.go b/utils/lock.go index 4382559..8d10990 100644 --- a/utils/lock.go +++ b/utils/lock.go @@ -44,7 +44,7 @@ type NamedLock struct { // Example: // // lock := &NamedLock{} -// +// // // Try to acquire lock with 5-second timeout // if unlocker := lock.TryLock("user-123", 5*time.Second); unlocker != nil { // defer unlocker.Release() @@ -64,7 +64,7 @@ type NamedLock struct { // // Work with resource A // } // }() -// +// // go func() { // if u := lock.TryLock("resource-B", time.Second); u != nil { // defer u.Release() diff --git a/utils/time.go b/utils/time.go index 7d11475..38458fb 100644 --- a/utils/time.go +++ b/utils/time.go @@ -21,7 +21,7 @@ import "time" // t2 := utils.ParseTime("2023-12-25 10:30:00") // MySQL format // t3 := utils.ParseTime("2023-12-25") // Date only // t4 := utils.ParseTime("Mon Jan 2 15:04:05 2006") // ANSIC -// +// // if t := utils.ParseTime(userInput); t != nil { // fmt.Printf("Parsed time: %v\n", t) // } else { diff --git a/utils/utils.go b/utils/utils.go index c58f9cc..e4c9e3f 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -18,7 +18,7 @@ // // Create pointer to value // strPtr := utils.Ptr("hello") // intPtr := utils.Ptr(42) -// +// // // Safely dereference (returns zero value if nil) // val := utils.Deref(strPtr) // "hello" // val2 := utils.Deref(nil) // "" (zero value) @@ -33,7 +33,7 @@ // // // Generate random hex key // apiKey := utils.RandomKey(32) -// +// // // Generate random alphanumeric string // sessionID := utils.RandomString(16) // @@ -83,7 +83,7 @@ import ( // // Instead of: // temp := "hello" // ptr := &temp -// +// // // You can write: // ptr := utils.Ptr("hello") func Ptr[T any](value T) *T { @@ -97,7 +97,7 @@ func Ptr[T any](value T) *T { // // var strPtr *string = nil // val := utils.Deref(strPtr) // Returns "" (zero value for string) -// +// // strPtr = utils.Ptr("hello") // val = utils.Deref(strPtr) // Returns "hello" func Deref[T any](v *T) T { @@ -117,10 +117,10 @@ func Deref[T any](v *T) T { // // // String coalescing // name := utils.Coalesce("", "", "John", "Jane") // Returns "John" -// -// // Number coalescing +// +// // Number coalescing // port := utils.Coalesce(0, 0, 8080, 9090) // Returns 8080 -// +// // // With variables // result := utils.Coalesce(config.URL, os.Getenv("API_URL"), "http://localhost") func Coalesce[T comparable](arr ...T) T { @@ -142,7 +142,7 @@ func Coalesce[T comparable](arr ...T) T { // // // Check multiple possible names // dbHost := utils.GetEnvOrDefault("DATABASE_HOST", "DB_HOST", "POSTGRES_HOST") -// +// // // With fallback handling // apiKey := utils.GetEnvOrDefault("API_KEY", "SECRET_KEY") // if apiKey == "" {