diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a3ed3d7..24ffde0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,23 +26,11 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Install Node.js - uses: actions/setup-node@v4 - with: - node-version: '20' - cache: npm - - name: Install golangci-lint uses: golangci/golangci-lint-action@v4 with: version: latest - - name: Install cspell - run: npm install -g cspell - - - name: Check spelling - run: cspell --config ./.vscode/cspell.json "**" --no-progress - - name: Golang Lint run: golangci-lint run diff --git a/.github/workflows/test-action.yml b/.github/workflows/test-action.yml index 912d766..79a2988 100644 --- a/.github/workflows/test-action.yml +++ b/.github/workflows/test-action.yml @@ -18,13 +18,15 @@ on: push: branches: [dev-v2.0] # Test on pushes to dev branch paths: - - src/** # Only when action code changes + - cmd/** # Only when action code changes + - internal/** # Only when action code changes - action.yml - .github/workflows/test-action.yml pull_request: branches: [dev-v2.0] # Test on PRs to dev branch paths: - - src/** # Only when action code changes + - cmd/** # Only when action code changes + - internal/** # Only when action code changes - action.yml - .github/workflows/test-action.yml diff --git a/.golangci.json b/.golangci.json deleted file mode 100644 index 5f284e9..0000000 --- a/.golangci.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "linters": { - "enable": [ - "gofmt", - "govet", - "staticcheck", - "errcheck", - "ineffassign", - "gocritic" - ] - } -} diff --git a/.golangci.yml b/.golangci.yml index d79c37b..7871831 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -48,6 +48,11 @@ linters: - bidichk # checks for dangerous unicode character sequences - bodyclose # checks whether HTTP response body is closed successfully - canonicalheader # checks whether net/http.Header uses canonical header + - govet # examines Go source code and reports suspicious constructs + - staticcheck # comprehensive checks for Go programs + - errcheck # checks for unchecked errors + - ineffassign # detects ineffective assignments + - gocritic # provides deep code analysis - copyloopvar # detects places where loop variables are copied (Go 1.22+) - cyclop # checks function and package cyclomatic complexity - depguard # checks if package imports are in a list of acceptable packages diff --git a/.trunk/configs/.shellcheckrc b/.trunk/configs/.shellcheckrc deleted file mode 100644 index 8640b04..0000000 --- a/.trunk/configs/.shellcheckrc +++ /dev/null @@ -1,9 +0,0 @@ -shell=bash -enable=all -source-path=SCRIPTDIR -disable=SC2154 -external-sources=true - -# If you're having issues with shellcheck following source, disable the errors via: -# disable=SC1090 -# disable=SC1091 diff --git a/.trunk/configs/cspell.yaml b/.trunk/configs/cspell.yaml deleted file mode 100644 index 3df4ae5..0000000 --- a/.trunk/configs/cspell.yaml +++ /dev/null @@ -1,5 +0,0 @@ -version: "0.2" -# Suggestions can sometimes take longer on CI machines, -# leading to inconsistent results. -suggestionsTimeout: 5000 # ms -enabled: false diff --git a/.trunk/trunk.yaml b/.trunk/trunk.yaml index 0691bb8..9b94d32 100644 --- a/.trunk/trunk.yaml +++ b/.trunk/trunk.yaml @@ -13,8 +13,8 @@ runtimes: - go@1.21.0 lint: disabled: + - cspell enabled: - - cspell@9.2.0 - markdownlint@0.45.0 - actionlint@1.7.7 - gofmt@1.20.4 diff --git a/.vscode/settings.json b/.vscode/settings.json index 910d532..fcff00d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,4 +1,5 @@ { + "cSpell.enabled": false, "[go]": { "editor.defaultFormatter": "trunk.io", "editor.formatOnSave": true @@ -19,10 +20,5 @@ "editor.defaultFormatter": "trunk.io", "editor.formatOnSave": true }, - "cSpell.enabled": false, - "cSpell.caseSensitive": false, - "cSpell.import": [ - ".vscode/cspell.json" - ], "editor.formatOnSave": true } \ No newline at end of file diff --git a/LICENSE b/LICENSE index 7122f41..e75d789 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright 2022 Andrew Walsh +Copyright 2025 Andrew Walsh Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/README.md b/README.md index 40b1a5d..23813bb 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,9 @@ # cache-apt-pkgs-action -[![CI](https://github.com/awalsh128/cache-apt-pkgs-action/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/awalsh128/cache-apt-pkgs-action/actions/workflows/ci.yml) -[![CI (dev-v2.0)](https://github.com/awalsh128/cache-apt-pkgs-action/actions/workflows/ci.yml/badge.svg?branch=dev-v2.0)](https://github.com/awalsh128/cache-apt-pkgs-action/actions/workflows/ci.yml?query=branch%3Adev-v2.0) +[![CI](https://github.com/awalsh128/cache-apt-pkgs-action/actions/workflows/ci.yml/badge.svg?branch=dev-v2.0)](https://github.com/awalsh128/cache-apt-pkgs-action/actions/workflows/ci.yml?query=branch%3Adev-v2.0) [![Go Report Card](https://goreportcard.com/badge/github.com/awalsh128/cache-apt-pkgs-action)](https://goreportcard.com/report/github.com/awalsh128/cache-apt-pkgs-action) [![Go Reference](https://pkg.go.dev/badge/github.com/awalsh128/cache-apt-pkgs-action.svg)](https://pkg.go.dev/github.com/awalsh128/cache-apt-pkgs-action) -[![License](https://img.shields.io/github/license/awalsh128/cache-apt-pkgs-action)](https://github.com/awalsh128/cache-apt-pkgs-action/blob/master/LICENSE) +[![License](https://img.shields.io/github/license/awalsh128/cache-apt-pkgs-action)](https://github.com/awalsh128/cache-apt-pkgs-action/blob/dev-v2.0/LICENSE) [![Release](https://img.shields.io/github/v/release/awalsh128/cache-apt-pkgs-action)](https://github.com/awalsh128/cache-apt-pkgs-action/releases) This action allows caching of Advanced Package Tool (APT) package dependencies to improve workflow execution time instead of installing the packages on every run. @@ -30,7 +29,6 @@ There are three kinds of version labels you can use. - `@v#` - Major only will give you the latest release for that major version only (e.g. `v1`). - Branch - `@master` - Most recent manual and automated tested code. Possibly unstable since it is pre-release. - - `@staging` - Most recent automated tested code and can sometimes contain experimental features. Is pulled from dev stable code. - `@dev` - Very unstable and contains experimental features. Automated testing may not show breaks since CI is also updated based on code in dev. ### Inputs diff --git a/action.yml b/action.yml index 4961155..2168872 100644 --- a/action.yml +++ b/action.yml @@ -41,70 +41,63 @@ outputs: runs: using: "composite" + env: + CACHE_DIR: ~/cache-apt-pkgs + GLOBAL_VERSION: 20250824 steps: + - id: install-aptfast + shell: bash + run: | + if ! apt-fast --version > /dev/null 2>&1; then + "Installing apt-fast for optimized installs and updates" + /bin/bash -c "$(curl -sL https://raw.githubusercontent.com/ilikenwf/apt-fast/master/quick-install.sh)" + fi - id: setup-binary shell: bash run: | - # Map runner architecture to binary name - case "${{ runner.arch }}" in - X64) - BINARY_NAME="cache-apt-pkgs-linux-amd64" - ;; - ARM64) - BINARY_NAME="cache-apt-pkgs-linux-arm64" - ;; - ARM) - BINARY_NAME="cache-apt-pkgs-linux-arm" - ;; - *) - echo "Unsupported architecture: ${{ runner.arch }}" - exit 1 - ;; - esac - - # Use bundled binary from action's dist directory - BINARY_PATH="${{ github.action_path }}/dist/$BINARY_NAME" + BINARY_PATH="${{ github.action_path }}/tools/distribute.sh getbinpath ${{ runner.arch }}" if [ ! -f "$BINARY_PATH" ]; then echo "Error: Binary not found at $BINARY_PATH" echo "Please ensure the action has been properly built and binaries are included in the dist directory" exit 1 fi - - # Create symlink to standardize binary name for other steps - ln -sf "$BINARY_PATH" "${{ github.action_path }}/cache-apt-pkgs" - chmod +x "${{ github.action_path }}/cache-apt-pkgs" - id: create-cache-key - shell: bash + shell: bash run: | ${BINARY_PATH} createkey \ - -cache-dir "$CACHE_DIR" \ + -os-arch ${{ runner.arch }} \ + -plaintext-path "${CACHE_DIR}/cache_key.txt" \ + -ciphertext-path "${CACHE_DIR}/cache_key.md5" \ -version "${{ inputs.version }}" \ - -global-version "4" \ - -exec-install-scripts ${{ inputs.execute_install_scripts }} \ + -global-version "${GLOBAL_VERSION}" \ ${{ inputs.packages }} echo "cache-key=$(cat $CACHE_DIR/cache_key.md5)" >> $GITHUB_OUTPUT - id: load-cache uses: actions/cache/restore@v4 with: - path: ~/cache-apt-pkgs + path: ${CACHE_DIR} key: cache-apt-pkgs_${{ steps.create-cache-key.outputs.cache-key }} - id: post-load-cache + # TODO get this implemented + # -exec-install-scripts ${{ inputs.execute_install_scripts }} \ run: | if [ "$CACHE_HIT" == "true" ]; then - ${BINARY_PATH} restore - -cache-dir "~/cache-apt-pkgs" \ - -exec-install-scripts "$EXEC_INSTALL_SCRIPTS" \ + ${BINARY_PATH} restore \ + -cache-dir "${CACHE_DIR}" \ -restore-root "/" \ "$PACKAGES" else - ${BINARY_PATH} install -cache-dir "~/cache-apt-pkgs" - fi - function create_list { local list=$(cat ~/cache-apt-pkgs/manifest_${1}.log | tr '\n' ','); echo ${list:0:-1}; }; - echo "package-version-list=$(create_list main)" >> $GITHUB_OUTPUT - echo "all-package-version-list=$(create_list all)" >> $GITHUB_OUTPUT + ${BINARY_PATH} install \ + -cache-dir "${CACHE_DIR}" + -version "${{ inputs.version }}" \ + -global-version "${GLOBAL_VERSION}" + "$PACKAGES"" + fi + echo "package-version-list=$(cat "${CACHE_DIR}/pkgs_args.txt")" >> $GITHUB_OUTPUT + echo "all-package-version-list=$(cat "${CACHE_DIR}/pkgs_installed.txt")" >> $GITHUB_OUTPUT shell: bash env: CACHE_HIT: "${{ steps.load-cache.outputs.cache-hit }}" diff --git a/cmd/cache_apt_pkgs/cmdflags.go b/cmd/cache_apt_pkgs/cmdflags.go new file mode 100644 index 0000000..c60c788 --- /dev/null +++ b/cmd/cache_apt_pkgs/cmdflags.go @@ -0,0 +1,175 @@ +package main + +import ( + "flag" + "fmt" + "os" + "path/filepath" + + "awalsh128.com/cache-apt-pkgs-action/internal/logging" + "awalsh128.com/cache-apt-pkgs-action/internal/pkgs" +) + +var ExamplePackages = pkgs.NewPackages( + pkgs.Package{Name: "rolldice"}, + pkgs.Package{Name: "xdot", Version: "1.1-2"}, + pkgs.Package{Name: "libgtk-3-dev"}, +) + +type Cmd struct { + Name string + Description string + Flags *flag.FlagSet + Examples []string // added Examples field for command usage examples + ExamplePackages pkgs.Packages + Run func(cmd *Cmd, pkgArgs pkgs.Packages) error +} + +// StringFlag returns the string value of a flag by name. +func (c *Cmd) StringFlag(name string) string { + return c.Flags.Lookup(name).Value.String() +} + +// binaryName returns the base name of the command without the path +var binaryName = filepath.Base(os.Args[0]) + +type Cmds map[string]*Cmd + +func (c *Cmd) parseFlags() pkgs.Packages { + if len(os.Args) < 3 { + logging.Fatalf("command %q requires arguments", c.Name) + } + // Parse the command line flags + if err := c.Flags.Parse(os.Args[2:]); err != nil { + logging.Fatalf("unable to parse flags for command %q: %v", c.Name, err) + } + + // Check for missing required flags + missingFlagNames := []string{} + c.Flags.VisitAll(func(f *flag.Flag) { + // Consider all flags as required + if f.Value.String() == "" && f.DefValue == "" && f.Name != "help" { + logging.Info("Missing required flag: %s", f.Name) + missingFlagNames = append(missingFlagNames, f.Name) + } + }) + if len(missingFlagNames) > 0 { + logging.Fatalf("missing required flags for command %q: %s", c.Name, missingFlagNames) + } + + // Parse the remaining arguments as package arguments + pkgArgs, err := pkgs.ParsePackageArgs(c.Flags.Args()) + if err != nil { + logging.Fatalf("failed to parse package arguments for command %q: %v", c.Name, err) + } + return pkgArgs +} + +func (c *Cmds) Add(cmd *Cmd) error { + if _, exists := (*c)[cmd.Name]; exists { + return fmt.Errorf("command %q already exists", cmd.Name) + } + (*c)[cmd.Name] = cmd + return nil +} +func (c *Cmds) Get(name string) (*Cmd, bool) { + cmd, ok := (*c)[name] + return cmd, ok +} +func (c *Cmd) getFlagCount() int { + count := 0 + c.Flags.VisitAll(func(f *flag.Flag) { + count++ + }) + return count +} + +func (c *Cmd) help() { + if c.getFlagCount() == 0 { + fmt.Fprintf(os.Stderr, "usage: %s %s [packages]\n\n", binaryName, c.Name) + fmt.Fprintf(os.Stderr, "%s\n\n", c.Description) + } else { + fmt.Fprintf(os.Stderr, "usage: %s %s [flags] [packages]\n\n", binaryName, c.Name) + fmt.Fprintf(os.Stderr, "%s\n\n", c.Description) + fmt.Fprintf(os.Stderr, "Flags:\n") + c.Flags.PrintDefaults() + } + + if c.ExamplePackages == nil && len(c.Examples) == 0 { + return + } + fmt.Fprintf(os.Stderr, "\nExamples:\n") + if len(c.Examples) == 0 { + fmt.Fprintf(os.Stderr, " %s %s %s\n", binaryName, c.Name, c.ExamplePackages.String()) + return + } + for _, example := range c.Examples { + fmt.Fprintf(os.Stderr, " %s %s %s %s\n", binaryName, c.Name, example, c.ExamplePackages.String()) + } +} + +func printUsage(cmds Cmds) { + fmt.Fprintf(os.Stderr, "usage: %s [flags] [packages]\n\n", binaryName) + fmt.Fprintf(os.Stderr, "commands:\n") + + // Get max length for alignment + maxLen := 0 + for name := range cmds { + if len(name) > maxLen { + maxLen = len(name) + } + } + + // Print aligned command descriptions + for name, cmd := range cmds { + fmt.Fprintf(os.Stderr, " %-*s %s\n", maxLen, name, cmd.Description) + } + + fmt.Fprintf(os.Stderr, "\nUse \"%s --help\" for more information about a command\n", binaryName) +} + +func (c *Cmds) Parse() (*Cmd, pkgs.Packages) { + if len(os.Args) < 2 { + fmt.Fprintf(os.Stderr, "error: no command specified\n\n") + printUsage(*c) + os.Exit(1) + } + + cmdName := os.Args[1] + if cmdName == "--help" || cmdName == "-h" { + printUsage(*c) + os.Exit(0) + } + + cmd, ok := c.Get(cmdName) + if !ok { + fmt.Fprintf(os.Stderr, "error: unknown command %q\n\n", binaryName) + printUsage(*c) + os.Exit(1) + } + + // Handle command-specific help + for _, arg := range os.Args[2:] { + if arg == "--help" || arg == "-h" { + cmd.help() + os.Exit(0) + } + } + + pkgArgs := cmd.parseFlags() + if pkgArgs == nil { + fmt.Fprintf(os.Stderr, "error: no package arguments specified for command %q\n\n", cmd.Name) + cmd.help() + os.Exit(1) + } + + return cmd, pkgArgs +} + +func CreateCmds(cmd ...*Cmd) *Cmds { + commands := &Cmds{} + for _, c := range cmd { + commands.Add(c) + } + return commands +} diff --git a/cmd/cache_apt_pkgs/create_key.go b/cmd/cache_apt_pkgs/create_key.go new file mode 100644 index 0000000..5c8a75e --- /dev/null +++ b/cmd/cache_apt_pkgs/create_key.go @@ -0,0 +1,56 @@ +package main + +import ( + "flag" + "fmt" + "path/filepath" + "runtime" + + "awalsh128.com/cache-apt-pkgs-action/internal/cache" + "awalsh128.com/cache-apt-pkgs-action/internal/pkgs" +) + +func createKey(cmd *Cmd, pkgArgs pkgs.Packages) error { + key := cache.Key{ + Packages: pkgArgs, + Version: cmd.StringFlag("version"), + GlobalVersion: cmd.StringFlag("global-version"), + OsArch: cmd.StringFlag("os-arch"), + } + cacheDir := cmd.StringFlag("cache-dir") + + if err := key.Write( + filepath.Join(cacheDir, "cache_key.txt"), + filepath.Join(cacheDir, "cache_key.md5")); err != nil { + return fmt.Errorf("failed to write cache key: %w", err) + } + + return nil +} + +func GetCreateKeyCmd() *Cmd { + cmd := &Cmd{ + Name: "createkey", + Description: "Create a cache key based on the provided options", + Flags: flag.NewFlagSet("createkey", flag.ExitOnError), + Run: createKey, + } + cmd.Flags.String("os-arch", runtime.GOARCH, + "OS architecture to use in the cache key.\n"+ + "Action may be called from different runners in a different OS. This ensures the right one is fetched") + cmd.Flags.String("plaintext-path", "", "Path to the plaintext cache key file") + cmd.Flags.String("ciphertext-path", "", "Path to the hashed cache key file") + cmd.Flags.String("version", "", "Version of the cache key to force cache invalidation") + cmd.Flags.String( + "global-version", + "", + "Unique version to force cache invalidation globally across all action callers\n"+ + "Used to fix corrupted caches or bugs from the action itself", + ) + cmd.Examples = []string{ + "--os-arch amd64 --cache-dir ~/cache_dir --version 1.0.0 --global-version 1", + "--os-arch x86_64 --cache-dir /tmp/cache_dir --version v2 --global-version 2", + } + cmd.ExamplePackages = ExamplePackages + return cmd +} diff --git a/cmd/cache_apt_pkgs/install.go b/cmd/cache_apt_pkgs/install.go new file mode 100644 index 0000000..0eed08f --- /dev/null +++ b/cmd/cache_apt_pkgs/install.go @@ -0,0 +1,84 @@ +package main + +import ( + "flag" + "fmt" + "path/filepath" + "runtime" + "time" + + "awalsh128.com/cache-apt-pkgs-action/internal/cache" + "awalsh128.com/cache-apt-pkgs-action/internal/logging" + "awalsh128.com/cache-apt-pkgs-action/internal/pkgs" +) + +func install(cmd *Cmd, pkgArgs pkgs.Packages) error { + apt, err := pkgs.NewApt() + if err != nil { + return fmt.Errorf("error initializing APT: %v", err) + } + + logging.Info("Installing packages.") + logging.Debug("Package list: %v.", pkgArgs) + + installedPkgs, err := apt.Install(pkgArgs) + if err != nil { + return fmt.Errorf("error installing packages: %v", err) + } + + manifestKey := cache.Key{ + Packages: pkgArgs, + Version: cmd.StringFlag("version"), + GlobalVersion: cmd.StringFlag("global-version"), + OsArch: runtime.GOARCH, + } + + pkgManifests := make([]cache.ManifestPackage, installedPkgs.Len()) + for i := 0; i < installedPkgs.Len(); i++ { + pkg := installedPkgs.Get(i) + files, err := apt.ListInstalledFiles(pkg) + if err != nil { + return err + } + pkgManifests[i] = cache.ManifestPackage{ + Package: *pkg, + Filepaths: files, + } + } + manifest := &cache.Manifest{ + CacheKey: manifestKey, + LastModified: time.Now().UTC(), + InstalledPackages: pkgManifests, + } + + manifestPath := filepath.Join(cmd.StringFlag("cache-dir"), "manifest.json") + if err := cache.Write(manifestPath, manifest); err != nil { + return fmt.Errorf("error writing manifest to %s: %v", manifestPath, err) + } + logging.Info("Writing manifest to %s.", manifestPath) + logging.Info("Completed package installation.") + return nil +} + +func GetInstallCmd() *Cmd { + cmd := &Cmd{ + Name: "install", + Description: "Install packages and saves them to the cache", + Flags: flag.NewFlagSet("install", flag.ExitOnError), + Run: install, + } + cmd.Flags.String("cache-dir", "", "Directory that holds the cached packages, JSON manifest and package lists in text format") + cmd.Flags.String("version", "", "Version of cache to load. Each version will have its own cache. Note, all characters except spaces are allowed.") + cmd.Flags.String( + "global-version", + "", + "Unique version to force cache invalidation globally across all action callers\n"+ + "Used to fix corrupted caches or bugs from the action itself") + cmd.Flags.String("manifest-path", "", "File path that holds the package install manifest in JSON format") + cmd.Examples = []string{ + "--cache-dir ~/cache_dir --version userver1 --global-version 20250812", + "--cache-dir /tmp/cache_dir --version what_ever --global-version whatever_too", + } + cmd.ExamplePackages = ExamplePackages + return cmd +} diff --git a/cmd/cache_apt_pkgs/main.go b/cmd/cache_apt_pkgs/main.go new file mode 100644 index 0000000..8732611 --- /dev/null +++ b/cmd/cache_apt_pkgs/main.go @@ -0,0 +1,21 @@ +package main + +import ( + "awalsh128.com/cache-apt-pkgs-action/internal/logging" +) + +func main() { + logging.Init("cache_apt_pkgs", true) + + commands := CreateCmds( + GetCreateKeyCmd(), + GetInstallCmd(), + GetRestoreCmd(), + GetValidateCmd(), + ) + cmd, pkgArgs := commands.Parse() + err := cmd.Run(cmd, pkgArgs) + if err != nil { + logging.Fatalf("error: %v\n", err) + } +} diff --git a/cmd/cache_apt_pkgs/restore.go b/cmd/cache_apt_pkgs/restore.go new file mode 100644 index 0000000..63b5783 --- /dev/null +++ b/cmd/cache_apt_pkgs/restore.go @@ -0,0 +1,30 @@ +package main + +import ( + "flag" + "fmt" + + "awalsh128.com/cache-apt-pkgs-action/internal/pkgs" +) + +func restore(cmd *Cmd, pkgArgs pkgs.Packages) error { + return fmt.Errorf("restorePackages not implemented") +} + +func GetRestoreCmd() *Cmd { + cmd := &Cmd{ + Name: "restore", + Description: "Restore packages from the cache", + Flags: flag.NewFlagSet("restore", flag.ExitOnError), + Run: restore, + } + cmd.Flags.String("cache-dir", "", "Directory that holds the cached packages, JSON manifest and package lists in text format") + cmd.Flags.String("restore-root", "/", "Root directory to untar the cached packages to") + cmd.Flags.Bool("execute-scripts", false, "Execute APT post-install scripts on restore") + cmd.Examples = []string{ + "--cache-dir ~/cache_dir --restore-root / --execute-scripts true", + "--cache-dir /tmp/cache_dir --restore-root /", + } + cmd.ExamplePackages = ExamplePackages + return cmd +} diff --git a/cmd/cache_apt_pkgs/validate.go b/cmd/cache_apt_pkgs/validate.go new file mode 100644 index 0000000..50ace61 --- /dev/null +++ b/cmd/cache_apt_pkgs/validate.go @@ -0,0 +1,44 @@ +package main + +import ( + "flag" + "fmt" + "strings" + + "awalsh128.com/cache-apt-pkgs-action/internal/logging" + "awalsh128.com/cache-apt-pkgs-action/internal/pkgs" +) + +func validate(cmd *Cmd, pkgArgs pkgs.Packages) error { + apt, err := pkgs.NewApt() + if err != nil { + return fmt.Errorf("error initializing APT: %v", err) + } + + errMsgs := make([]string, 0) + for i := 0; i < pkgArgs.Len(); i++ { + pkg := pkgArgs.Get(i) + if _, err := apt.Validate(pkg); err != nil { + logging.Info("Package %s is invalid: %v.", pkg.String(), err) + errMsgs = append(errMsgs, fmt.Sprintf("%s - %v", pkg.String(), err)) + } else { + logging.Info("Package %s is valid.", pkg.String()) + } + } + + if len(errMsgs) > 0 { + return fmt.Errorf("package validation failed:\n - %s", strings.Join(errMsgs, "\n")) + } + return nil +} + +func GetValidateCmd() *Cmd { + cmd := &Cmd{ + Name: "validate", + Description: "Validate package arguments", + Flags: flag.NewFlagSet("validate", flag.ExitOnError), + Run: validate, + } + cmd.ExamplePackages = ExamplePackages + return cmd +} diff --git a/internal/cache/key.go b/internal/cache/key.go new file mode 100644 index 0000000..e771bad --- /dev/null +++ b/internal/cache/key.go @@ -0,0 +1,58 @@ +// Package cache provides caching functionality for APT packages and their metadata. +package cache + +import ( + "crypto/md5" + "fmt" + "os" + + "awalsh128.com/cache-apt-pkgs-action/internal/logging" + "awalsh128.com/cache-apt-pkgs-action/internal/pkgs" +) + +// Key represents a unique identifier for a package cache entry. +// It combines package information with version and architecture details to create +// a deterministic cache key. +type Key struct { + // Packages is a sorted list of packages to be cached + Packages pkgs.Packages + // Version is the user-specified cache version + Version string + // GlobalVersion is the action's global version, used for cache invalidation + GlobalVersion string + // OsArch is the target architecture (e.g., amd64, arm64) + OsArch string +} + +// PlainText returns a human-readable string representation of the cache key. +// The output format is deterministic since Packages are guaranteed to be sorted. +func (k *Key) PlainText() string { + return fmt.Sprintf("Packages: '%s', Version: '%s', GlobalVersion: '%s', OsArch: '%s'", + k.Packages.String(), k.Version, k.GlobalVersion, k.OsArch) +} + +// Hash generates a deterministic MD5 hash of the key's contents. +// This hash is used as the actual cache key for storage and lookup. +func (k *Key) Hash() [16]byte { + return md5.Sum([]byte(k.PlainText())) +} + +// Write stores both the plaintext and hashed versions of the cache key to files. +// This allows for both human inspection and fast cache lookups. +func (k *Key) Write(plaintextPath string, ciphertextPath string) error { + keyText := k.PlainText() + logging.Info("Writing cache key plaintext to %s.", plaintextPath) + if err := os.WriteFile(plaintextPath, []byte(keyText), 0644); err != nil { + return fmt.Errorf("write failed to %s: %w", plaintextPath, err) + } + logging.Info("Completed writing cache key plaintext.") + + keyHash := k.Hash() + logging.Info("Writing cache key hash to %s.", ciphertextPath) + if err := os.WriteFile(ciphertextPath, keyHash[:], 0644); err != nil { + return fmt.Errorf("write failed to %s: %w", ciphertextPath, err) + } + logging.Info("Completed writing cache key hash.") + + return nil +} diff --git a/internal/cache/key_test.go b/internal/cache/key_test.go new file mode 100644 index 0000000..daebace --- /dev/null +++ b/internal/cache/key_test.go @@ -0,0 +1,123 @@ +package cache + +import ( + "testing" + + "awalsh128.com/cache-apt-pkgs-action/internal/pkgs" +) + +func TestKey_PlainText(t *testing.T) { + tests := []struct { + name string + key Key + expected string + }{ + { + name: "Empty key", + key: Key{ + Packages: pkgs.NewPackages(), + Version: "", + GlobalVersion: "", + OsArch: "", + }, + expected: "Packages: '', Version: '', GlobalVersion: '', OsArch: ''", + }, + { + name: "Single package", + key: Key{ + Packages: pkgs.NewPackagesFromSlice([]string{"xdot=1.3-1"}), + Version: "test", + GlobalVersion: "v2", + OsArch: "amd64", + }, + expected: "Packages: 'xdot=1.3-1', Version: 'test', GlobalVersion: 'v2', OsArch: 'amd64'", + }, + { + name: "Multiple packages", + key: Key{ + Packages: pkgs.NewPackagesFromSlice([]string{"xdot=1.3-1", "rolldice=1.16-1build3"}), + Version: "test", + GlobalVersion: "v2", + OsArch: "amd64", + }, + expected: "Packages: 'xdot=1.3-1,rolldice=1.16-1build3', Version: 'test', GlobalVersion: 'v2', OsArch: 'amd64'", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.key.PlainText() + if result != tt.expected { + t.Errorf("PlainText() = %v, want %v", result, tt.expected) + } + }) + } +} + +func TestKey_Hash(t *testing.T) { + tests := []struct { + name string + key1 Key + key2 Key + wantSame bool + }{ + { + name: "Same keys hash to same value", + key1: Key{ + Packages: pkgs.NewPackagesFromSlice([]string{"xdot=1.3-1"}), + Version: "test", + GlobalVersion: "v2", + OsArch: "amd64", + }, + key2: Key{ + Packages: pkgs.NewPackagesFromSlice([]string{"xdot=1.3-1"}), + Version: "test", + GlobalVersion: "v2", + OsArch: "amd64", + }, + wantSame: true, + }, + { + name: "Different packages hash to different values", + key1: Key{ + Packages: pkgs.NewPackagesFromSlice([]string{"xdot=1.3-1"}), + Version: "test", + GlobalVersion: "v2", + OsArch: "amd64", + }, + key2: Key{ + Packages: pkgs.NewPackagesFromSlice([]string{"rolldice=1.16-1build3"}), + Version: "test", + GlobalVersion: "v2", + OsArch: "amd64", + }, + wantSame: false, + }, + { + name: "Different versions hash to different values", + key1: Key{ + Packages: pkgs.NewPackagesFromSlice([]string{"xdot=1.3-1"}), + Version: "test1", + GlobalVersion: "v2", + OsArch: "amd64", + }, + key2: Key{ + Packages: pkgs.NewPackagesFromSlice([]string{"xdot=1.3-1"}), + Version: "test2", + GlobalVersion: "v2", + OsArch: "amd64", + }, + wantSame: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + hash1 := tt.key1.Hash() + hash2 := tt.key2.Hash() + if (hash1 == hash2) != tt.wantSame { + t.Errorf("Hash equality = %v, want %v", hash1 == hash2, tt.wantSame) + } + }) + } +} diff --git a/internal/cache/manifest.go b/internal/cache/manifest.go new file mode 100644 index 0000000..45740f4 --- /dev/null +++ b/internal/cache/manifest.go @@ -0,0 +1,93 @@ +package cache + +import ( + "fmt" + "os" + "time" + + "awalsh128.com/cache-apt-pkgs-action/internal/cio" + "awalsh128.com/cache-apt-pkgs-action/internal/pkgs" +) + +// ManifestPackage represents a cached package and its installed files. +// It combines package metadata with a list of all files installed by the package. +type ManifestPackage struct { + // Package contains the basic package metadata (name and version) + Package pkgs.Package + // Filepaths is a list of all files installed by this package + Filepaths []string +} + +// Manifest represents the complete state of a cached package set. +// It includes metadata about when the cache was created and what packages +// were installed, along with their files. +type Manifest struct { + // CacheKey uniquely identifies this cache entry + CacheKey Key + // LastModified is when this cache entry was created or last updated + LastModified time.Time + // InstalledPackages lists all packages in the cache and their files + InstalledPackages []ManifestPackage +} + +// Read loads a manifest from a JSON file and validates its contents. +// Returns an error if the file cannot be read or contains invalid data. +func Read(filepath string) (*Manifest, error) { + file, err := os.Open(filepath) + if err != nil { + return nil, fmt.Errorf("failed to open manifest at %s: %w", filepath, err) + } + defer file.Close() + + content, err := os.ReadFile(filepath) + if err != nil { + return nil, fmt.Errorf("failed to read manifest at %s: %w", filepath, err) + } + + manifest := Manifest{} + if err := cio.FromJSON(content, &manifest); err != nil { + return nil, err + } + return &manifest, nil +} + +func Write(filepath string, manifest *Manifest) error { + file, err := os.Create(filepath) + if err != nil { + return fmt.Errorf("failed to create manifest at %s: %w", filepath, err) + } + defer file.Close() + + content, err := cio.ToJSON(manifest) + if err != nil { + return fmt.Errorf("failed to serialize manifest to %s: %v", filepath, err) + } + if _, err := file.Write([]byte(content)); err != nil { + return fmt.Errorf("failed to write manifest to %s: %v", filepath, err) + } + fmt.Printf("Manifest written to %s\n", filepath) + return nil +} + +func WriteGithubOutputs(filepath string, manifest *Manifest) error { + file, err := os.Create(filepath) + if err != nil { + return fmt.Errorf("failed to create GitHub outputs at %s: %w", filepath, err) + } + defer file.Close() + + packageList := "" + for i, pkg := range manifest.InstalledPackages { + if i > 0 { + packageList += "," + } + packageList += fmt.Sprintf("%s-%s", pkg.Package.Name, pkg.Package.Version) + } + + outputLine := fmt.Sprintf("package-version-list=%s\n", packageList) + if _, err := file.WriteString(outputLine); err != nil { + return fmt.Errorf("failed to write to GitHub outputs file at %s: %v", filepath, err) + } + fmt.Printf("GitHub outputs written to %s\n", filepath) + return nil +} diff --git a/internal/cache/manifest_test.go b/internal/cache/manifest_test.go new file mode 100644 index 0000000..306f9bb --- /dev/null +++ b/internal/cache/manifest_test.go @@ -0,0 +1,147 @@ +package cache + +import ( + "os" + "path/filepath" + "testing" + + "awalsh128.com/cache-apt-pkgs-action/internal/pkgs" +) + +func TestNewManifest(t *testing.T) { + // Create a temporary directory for test files + tmpDir, err := os.MkdirTemp("", "manifest-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + tests := []struct { + name string + key Key + wantErr bool + setupFiles []string // Files to create before test + verifyFiles []string // Files to verify after creation + }{ + { + name: "Valid manifest creation", + key: Key{ + Packages: pkgs.NewPackages(), + Version: "test", + GlobalVersion: "v2", + OsArch: "amd64", + }, + wantErr: false, + verifyFiles: []string{ + "manifest.json", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Setup test files + testDir := filepath.Join(tmpDir, tt.name) + err := os.MkdirAll(testDir, 0755) + if err != nil { + t.Fatalf("Failed to create test directory: %v", err) + } + + for _, file := range tt.setupFiles { + path := filepath.Join(testDir, file) + if err := os.WriteFile(path, []byte("test content"), 0644); err != nil { + t.Fatalf("Failed to create test file %s: %v", file, err) + } + } + + // Create manifest + manifest, err := NewManifest(tt.key) + if (err != nil) != tt.wantErr { + t.Errorf("NewManifest() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if err == nil { + // Verify manifest is created correctly + if manifest == nil { + t.Error("NewManifest() returned nil manifest without error") + return + } + + // Verify expected files exist + for _, file := range tt.verifyFiles { + path := filepath.Join(testDir, file) + if _, err := os.Stat(path); os.IsNotExist(err) { + t.Errorf("Expected file %s does not exist", file) + } + } + } + }) + } +} + +func TestManifest_Save(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "manifest-save-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + tests := []struct { + name string + manifest *Manifest + wantErr bool + }{ + { + name: "Save empty manifest", + manifest: &Manifest{ + Key: Key{ + Packages: pkgs.NewPackages(), + Version: "test", + GlobalVersion: "v2", + OsArch: "amd64", + }, + Packages: []ManifestPackage{}, + }, + wantErr: false, + }, + { + name: "Save manifest with packages", + manifest: &Manifest{ + Key: Key{ + Packages: pkgs.NewPackagesFromSlice([]string{"xdot=1.3-1"}), + Version: "test", + GlobalVersion: "v2", + OsArch: "amd64", + }, + Packages: []ManifestPackage{ + { + Name: "xdot", + Version: "1.3-1", + Files: []string{"/usr/bin/xdot", "/usr/share/doc/xdot"}, + }, + }, + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + testDir := filepath.Join(tmpDir, tt.name) + if err := os.MkdirAll(testDir, 0755); err != nil { + t.Fatalf("Failed to create test directory: %v", err) + } + + if err := tt.manifest.Save(testDir); (err != nil) != tt.wantErr { + t.Errorf("Manifest.Save() error = %v, wantErr %v", err, tt.wantErr) + } + + // Verify manifest file was created + manifestPath := filepath.Join(testDir, "manifest.json") + if _, err := os.Stat(manifestPath); os.IsNotExist(err) { + t.Error("Manifest file was not created") + } + }) + } +} diff --git a/internal/cio/serialization.go b/internal/cio/serialization.go new file mode 100644 index 0000000..09996a2 --- /dev/null +++ b/internal/cio/serialization.go @@ -0,0 +1,27 @@ +// Package cio provides common I/O operations for the application. +package cio + +import ( + "encoding/json" + "fmt" +) + +// FromJSON unmarshals JSON data into a value. +// This is a convenience wrapper around json.Unmarshal that maintains consistent +// JSON handling across the application. +func FromJSON(data []byte, v any) error { + if err := json.Unmarshal(data, v); err != nil { + return fmt.Errorf("failed to unmarshal JSON: %w", err) + } + return nil +} + +// ToJSON marshals a value to a JSON string with consistent indentation. +// The output is always indented with two spaces for readability. +func ToJSON(v any) (string, error) { + content, err := json.MarshalIndent(v, "", " ") + if err != nil { + return "", fmt.Errorf("failed to marshal to JSON: %w", err) + } + return string(content), nil +} diff --git a/internal/cio/serialization_test.go b/internal/cio/serialization_test.go new file mode 100644 index 0000000..658ffa9 --- /dev/null +++ b/internal/cio/serialization_test.go @@ -0,0 +1,130 @@ +package cio + +import ( + "os" + "path/filepath" + "testing" +) + +func TestWriteJSON(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "json-write-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + type testStruct struct { + Name string + Value int + } + + tests := []struct { + name string + data interface{} + wantErr bool + validate func([]byte) bool + }{ + { + name: "Write simple struct", + data: testStruct{ + Name: "test", + Value: 42, + }, + wantErr: false, + validate: func(data []byte) bool { + return string(data) == `{"Name":"test","Value":42}`+"\n" + }, + }, + { + name: "Write nil", + data: nil, + wantErr: false, + validate: func(data []byte) bool { + return string(data) == "null\n" + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + filePath := filepath.Join(tmpDir, tt.name+".json") + + err := WriteJSON(filePath, tt.data) + if (err != nil) != tt.wantErr { + t.Errorf("WriteJSON() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if err == nil { + // Read the file back + data, err := os.ReadFile(filePath) + if err != nil { + t.Fatalf("Failed to read test file: %v", err) + } + + // Validate content + if !tt.validate(data) { + t.Errorf("WriteJSON() wrote incorrect data: %s", string(data)) + } + } + }) + } +} + +func TestReadJSON(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "json-read-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + type testStruct struct { + Name string + Value int + } + + tests := []struct { + name string + content string + want testStruct + wantErr bool + }{ + { + name: "Read valid JSON", + content: `{"Name":"test","Value":42}`, + want: testStruct{ + Name: "test", + Value: 42, + }, + wantErr: false, + }, + { + name: "Read invalid JSON", + content: `{"Name":"test","Value":}`, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + filePath := filepath.Join(tmpDir, tt.name+".json") + + // Create test file + err := os.WriteFile(filePath, []byte(tt.content), 0644) + if err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + var got testStruct + err = ReadJSON(filePath, &got) + if (err != nil) != tt.wantErr { + t.Errorf("ReadJSON() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if !tt.wantErr && got != tt.want { + t.Errorf("ReadJSON() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/src/internal/cache/io.go b/internal/cio/tar.go similarity index 86% rename from src/internal/cache/io.go rename to internal/cio/tar.go index a2fdd86..84d718d 100644 --- a/src/internal/cache/io.go +++ b/internal/cio/tar.go @@ -1,4 +1,5 @@ -package cache +// Package cio provides common I/O operations for the application. +package cio import ( "archive/tar" @@ -8,36 +9,9 @@ import ( "path/filepath" ) -func MkDir(dir string) error { - if dir == "" { - return fmt.Errorf("directory path cannot be empty") - } - if err := os.MkdirAll(dir, 0755); err != nil { - return fmt.Errorf("failed to create directory %s: %v", dir, err) - } - return nil -} - -func WriteKey(filepath string, key Key) error { - // Write cache key to file - if err := os.WriteFile(filepath, []byte(key.Hash()), 0644); err != nil { - return fmt.Errorf("failed to write cache key to %s: %w", filepath, err) - } - return nil -} - -// validateTarInputs checks if the input parameters for tar creation are valid -func validateTarInputs(destPath string, files []string) error { - if destPath == "" { - return fmt.Errorf("destination path cannot be empty") - } - if len(files) == 0 { - return fmt.Errorf("no files provided") - } - return nil -} - -// createTarWriter creates a new tar writer for the given destination path +// createTarWriter creates and initializes a new tar archive writer. +// It ensures the parent directory exists and opens the destination file. +// Returns the tar writer, the underlying file (for later closing), and any error encountered. func createTarWriter(destPath string) (*tar.Writer, *os.File, error) { // Create parent directory for destination file if it doesn't exist if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil { @@ -55,7 +29,21 @@ func createTarWriter(destPath string) (*tar.Writer, *os.File, error) { return tw, file, nil } -// validateFileType checks if the file is a regular file or symlink +// validateTarInputs checks if the tar archive parameters are valid. +// Returns an error if the destination path is empty or if no files are provided. +func validateTarInputs(destPath string, files []string) error { + if destPath == "" { + return fmt.Errorf("destination path cannot be empty") + } + if len(files) == 0 { + return fmt.Errorf("no files provided") + } + return nil +} + +// validateFileType ensures the file is a supported type for archiving. +// Currently supports regular files and symbolic links. +// Returns an error for other file types (e.g., directories, devices). func validateFileType(info os.FileInfo, absPath string) error { if !info.Mode().IsRegular() && info.Mode()&os.ModeSymlink == 0 { return fmt.Errorf("file %s is not a regular file or symlink", absPath) @@ -63,7 +51,9 @@ func validateFileType(info os.FileInfo, absPath string) error { return nil } -// createFileHeader creates a tar header for the given file info +// createFileHeader generates a tar header with file metadata. +// The header includes the file's name, size, mode, modification time, +// and other attributes from the filesystem. func createFileHeader(info os.FileInfo, absPath string) (*tar.Header, error) { header, err := tar.FileInfoHeader(info, "") // Empty link name for now if err != nil { diff --git a/internal/logging/logger.go b/internal/logging/logger.go new file mode 100644 index 0000000..5ad3c73 --- /dev/null +++ b/internal/logging/logger.go @@ -0,0 +1,95 @@ +// Package logging provides structured logging functionality for the application. +package logging + +import ( + "io" + "log" + "os" + "path/filepath" + + "awalsh128.com/cache-apt-pkgs-action/internal/cio" +) + +// Logger wraps the standard logger with additional functionality. +// It provides both file and stderr output, with optional debug logging. +type Logger struct { + // wrapped is the underlying standard logger + wrapped *log.Logger + // Filename is the full path to the log file + Filename string + // Debug controls whether debug messages are logged + Debug bool + // file is the log file handle + file *os.File +} + +// Global logger instance used by package-level functions +var logger *Logger + +// LogFilepath is the path where log files will be created +var LogFilepath = os.Args[0] + ".log" + +// Init creates and initializes a new logger. +// It sets up logging to both a file and stderr, and enables debug logging if requested. +// The existing log file is removed to start fresh. +func Init(filename string, debug bool) *Logger { + os.Remove(LogFilepath) + file, err := os.OpenFile(LogFilepath, os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + log.Fatal(err) + } + cwd, _ := os.Getwd() + + logger = &Logger{ + // Logs to both stderr and file. + // Stderr is used to act as a sidechannel of information and stay separate from the actual outputs of the program. + wrapped: log.New(io.MultiWriter(file, os.Stderr), "", log.LstdFlags), + Filename: filepath.Join(cwd, file.Name()), + Debug: debug, + file: file, + } + Debug("Debug log created at %s", logger.Filename) + return logger +} + +// DebugLazy logs a debug message using a lazy evaluation function. +// The message generator function is only called if debug logging is enabled, +// making it efficient for expensive debug message creation. +func DebugLazy(getLine func() string) { + if logger.Debug { + logger.wrapped.Println(getLine()) + } +} + +// Debug logs a formatted debug message if debug logging is enabled. +// Uses fmt.Printf style formatting. +func Debug(format string, a ...any) { + if logger.Debug { + logger.wrapped.Printf(format, a...) + } +} + +func DumpVars(a ...any) { + if logger.Debug { + for _, v := range a { + json, err := cio.ToJSON(v) + if err != nil { + Info("warning: unable to dump variable: %v", err) + continue + } + logger.wrapped.Println(json) + } + } +} + +func Info(format string, a ...any) { + logger.wrapped.Printf(format+"\n", a...) +} + +func Fatal(err error) { + logger.wrapped.Fatal(err) +} + +func Fatalf(format string, a ...any) { + logger.wrapped.Fatalf(format+"\n", a...) +} diff --git a/internal/logging/logger_test.go b/internal/logging/logger_test.go new file mode 100644 index 0000000..2533cbe --- /dev/null +++ b/internal/logging/logger_test.go @@ -0,0 +1,149 @@ +package logging + +import ( + "bytes" + "log" + "os" + "testing" +) + +func TestDebug(t *testing.T) { + // Capture log output + var buf bytes.Buffer + log.SetOutput(&buf) + defer log.SetOutput(os.Stderr) + + tests := []struct { + name string + message string + args []interface{} + enabled bool + wantLog bool + }{ + { + name: "Debug enabled", + message: "test message", + args: []interface{}{}, + enabled: true, + wantLog: true, + }, + { + name: "Debug disabled", + message: "test message", + args: []interface{}{}, + enabled: false, + wantLog: false, + }, + { + name: "Debug with formatting", + message: "test %s %d", + args: []interface{}{"message", 42}, + enabled: true, + wantLog: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + buf.Reset() + SetDebug(tt.enabled) + + Debug(tt.message, tt.args...) + + hasOutput := buf.Len() > 0 + if hasOutput != tt.wantLog { + t.Errorf("Debug() logged = %v, want %v", hasOutput, tt.wantLog) + } + }) + } +} + +func TestDebugLazy(t *testing.T) { + // Capture log output + var buf bytes.Buffer + log.SetOutput(&buf) + defer log.SetOutput(os.Stderr) + + var evaluated bool + messageFunc := func() string { + evaluated = true + return "test message" + } + + tests := []struct { + name string + messageFunc func() string + enabled bool + wantLog bool + wantEvaluate bool + }{ + { + name: "DebugLazy enabled", + messageFunc: messageFunc, + enabled: true, + wantLog: true, + wantEvaluate: true, + }, + { + name: "DebugLazy disabled", + messageFunc: messageFunc, + enabled: false, + wantLog: false, + wantEvaluate: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + buf.Reset() + evaluated = false + SetDebug(tt.enabled) + + DebugLazy(tt.messageFunc) + + hasOutput := buf.Len() > 0 + if hasOutput != tt.wantLog { + t.Errorf("DebugLazy() logged = %v, want %v", hasOutput, tt.wantLog) + } + if evaluated != tt.wantEvaluate { + t.Errorf("DebugLazy() evaluated = %v, want %v", evaluated, tt.wantEvaluate) + } + }) + } +} + +func TestInfo(t *testing.T) { + // Capture log output + var buf bytes.Buffer + log.SetOutput(&buf) + defer log.SetOutput(os.Stderr) + + tests := []struct { + name string + message string + args []interface{} + }{ + { + name: "Simple message", + message: "test message", + args: []interface{}{}, + }, + { + name: "Formatted message", + message: "test %s %d", + args: []interface{}{"message", 42}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + buf.Reset() + + Info(tt.message, tt.args...) + + if buf.Len() == 0 { + t.Error("Info() didn't log anything") + } + }) + } +} diff --git a/internal/pkgs/apt.go b/internal/pkgs/apt.go new file mode 100644 index 0000000..a9b4a35 --- /dev/null +++ b/internal/pkgs/apt.go @@ -0,0 +1,72 @@ +// Package pkgs provides package management functionality using APT. +package pkgs + +import ( + "fmt" + + "awalsh128.com/cache-apt-pkgs-action/internal/logging" + "github.com/bluet/syspkg" + "github.com/bluet/syspkg/manager" +) + +// Apt wraps the APT package manager functionality. +// It provides a simplified interface for installing and querying packages +// using apt-fast for better performance. +type Apt struct { + // manager is the underlying package manager implementation + manager syspkg.PackageManager +} + +// NewApt creates a new APT manager instance configured to use apt-fast. +// Returns an error if the APT package manager is not available or cannot be initialized. +func NewApt() (*Apt, error) { + registry, err := syspkg.New(syspkg.IncludeOptions{AptFast: true}) + if err != nil { + return nil, fmt.Errorf("error initializing SysPkg: %v", err) + } + + // Get APT package manager (if available) + aptManager, err := registry.GetPackageManager("apt-fast") + if err != nil { + return nil, fmt.Errorf("APT package manager not available: %v", err) + } + + return &Apt{ + manager: aptManager, + }, nil +} + +// Install installs a set of packages using apt-fast. +// It returns the list of actually installed packages (which may be different from +// the input if some packages were already installed) and any error encountered. +// The installation is performed with --assume-yes and verbose logging enabled. +func (a *Apt) Install(pkgs Packages) (Packages, error) { + installedPkgs, err := a.manager.Install(pkgs.StringArray(), &manager.Options{AssumeYes: true, Debug: true, Verbose: true}) + if err != nil { + return nil, err + } + logging.Info("Completed installing packages.") + logging.Debug("Installed packages: %v.", installedPkgs) + logging.Info("Skipping packages that are already installed.") + + return NewPackagesFromSyspkg(installedPkgs), nil +} + +// ListInstalledFiles returns a list of all files installed by a package. +// This includes configuration files, binaries, libraries, and any other +// files managed by the package system. +func (a *Apt) ListInstalledFiles(pkg *Package) ([]string, error) { + files, err := a.manager.ListInstalledFiles(pkg.String()) + if err != nil { + return nil, fmt.Errorf("error listing installed files for package %s: %v", pkg.String(), err) + } + return files, nil +} + +func (a *Apt) Validate(pkg *Package) (manager.PackageInfo, error) { + packageInfo, err := a.manager.GetPackageInfo(pkg.String(), &manager.Options{AssumeYes: true}) + if err != nil { + return manager.PackageInfo{}, fmt.Errorf("error getting package info: %v", err) + } + return packageInfo, nil +} diff --git a/internal/pkgs/apt_test.go b/internal/pkgs/apt_test.go new file mode 100644 index 0000000..a7a1def --- /dev/null +++ b/internal/pkgs/apt_test.go @@ -0,0 +1,89 @@ +package pkgs + +import ( + "testing" +) + +func TestNewApt(t *testing.T) { + apt, err := NewApt() + if err != nil { + t.Fatalf("NewApt() error = %v", err) + } + if apt == nil { + t.Error("NewApt() returned nil without error") + } +} + +func TestApt_Install(t *testing.T) { + // Note: These tests require a real system and apt to be available + // They should be run in a controlled environment like a Docker container + tests := []struct { + name string + pkgs []string + wantErr bool + }{ + { + name: "Empty package list", + pkgs: []string{}, + wantErr: false, + }, + { + name: "Invalid package", + pkgs: []string{"nonexistent-package-12345"}, + wantErr: true, + }, + } + + apt, err := NewApt() + if err != nil { + t.Fatalf("Failed to create Apt instance: %v", err) + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + packages := NewPackages() + for _, pkg := range tt.pkgs { + packages.Add(pkg) + } + + _, err := apt.Install(packages) + if (err != nil) != tt.wantErr { + t.Errorf("Apt.Install() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestApt_ListInstalledFiles(t *testing.T) { + // Note: These tests require a real system and apt to be available + apt, err := NewApt() + if err != nil { + t.Fatalf("Failed to create Apt instance: %v", err) + } + + tests := []struct { + name string + pkg string + want []string + wantErr bool + }{ + { + name: "Invalid package", + pkg: "nonexistent-package-12345", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := apt.ListInstalledFiles(tt.pkg) + if (err != nil) != tt.wantErr { + t.Errorf("Apt.ListInstalledFiles() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr && len(got) == 0 { + t.Error("Apt.ListInstalledFiles() returned empty list for valid package") + } + }) + } +} diff --git a/internal/pkgs/package.go b/internal/pkgs/package.go new file mode 100644 index 0000000..ca2ae1c --- /dev/null +++ b/internal/pkgs/package.go @@ -0,0 +1,46 @@ +package pkgs + +import ( + "fmt" + "strings" +) + +// Package represents an APT package with optional version information. +// It follows the APT package specification format of "name" or "name=version". +type Package struct { + // Name is the package name as known to APT + Name string + // Version is the specific version requested, if any + Version string +} + +// NewPackage creates a Package from an APT package specification string. +// The string should be in the format "name" or "name=version". +// Returns an error if the name is empty or if a version separator +// is present but the version is empty. +func NewPackage(aptArgs string) (*Package, error) { + parts := strings.SplitN(aptArgs, "=", 2) + if len(parts) == 1 { + if parts[0] == "" { + return nil, fmt.Errorf("package name cannot be empty") + } + return &Package{Name: parts[0]}, nil + } + if parts[0] == "" { + return nil, fmt.Errorf("package name cannot be empty") + } + if parts[1] == "" { + return nil, fmt.Errorf("package version cannot be empty if specified") + } + return &Package{Name: parts[0], Version: parts[1]}, nil +} + +// String returns the package specification in APT format. +// If Version is empty, returns just the package name. +// If Version is set, returns "name=version". +func (p Package) String() string { + if p.Version != "" { + return fmt.Sprintf("%s=%s", p.Name, p.Version) + } + return p.Name +} diff --git a/internal/pkgs/packages.go b/internal/pkgs/packages.go new file mode 100644 index 0000000..df95795 --- /dev/null +++ b/internal/pkgs/packages.go @@ -0,0 +1,105 @@ +// Package pkgs provides package management functionality using APT. +package pkgs + +import ( + "fmt" + "slices" + "strings" + + "awalsh128.com/cache-apt-pkgs-action/internal/logging" + "github.com/bluet/syspkg/manager" +) + +// packages is an unexported slice type that provides a stable, ordered collection of packages. +// It is unexported to ensure all instances are created through the provided factory functions, +// which maintain the sorting invariant. +type packages []Package + +// Packages represents an ordered collection of software packages. +// The interface provides a safe subset of operations that maintain package ordering +// and prevent direct modification of the underlying collection. +type Packages interface { + // Get returns the package at the specified index. + // Panics if the index is out of bounds. + Get(i int) *Package + // Len returns the number of packages in the collection. + Len() int + // String returns a space-separated string of package specifications. + String() string + // StringArray returns package specifications as a string array. + StringArray() []string +} + +func (p *packages) Get(i int) *Package { + if i < 0 || i >= len(*p) { + logging.Fatalf("index %d out of range 0..%d", i, len(*p)) + } + return &(*p)[i] +} + +func (p *packages) Len() int { + return len(*p) +} + +func (p *packages) StringArray() []string { + result := make([]string, 0, len(*p)) + for _, pkg := range *p { + result = append(result, pkg.String()) + } + return result +} + +// String returns a string representation of Packages +func (p *packages) String() string { + var parts []string + for _, pkg := range *p { + parts = append(parts, pkg.String()) + } + return strings.Join(parts, " ") +} + +func NewPackagesFromSyspkg(pkgs []manager.PackageInfo) Packages { + items := packages{} + for _, pkg := range pkgs { + items = append(items, Package{Name: pkg.Name, Version: pkg.Version}) + } + return NewPackages(items...) +} + +func NewPackages(pkgs ...Package) Packages { + // Create a new slice to avoid modifying the input + result := make(packages, len(pkgs)) + copy(result, pkgs) + + // Sort packages by name and version + slices.SortFunc(result, func(lhs, rhs Package) int { + if lhs.Name != rhs.Name { + if lhs.Name < rhs.Name { + return -1 + } + return 1 + } + if lhs.Version < rhs.Version { + return -1 + } + if lhs.Version > rhs.Version { + return 1 + } + return 0 + }) + + return &result +} + +// ParsePackageArgs parses package arguments and returns a new Packages instance +func ParsePackageArgs(value []string) (Packages, error) { + var pkgs packages + for _, val := range value { + newPkg, err := NewPackage(val) + if err != nil { + return nil, fmt.Errorf("error creating package from arg %q: %v", val, err) + } + pkgs = append(pkgs, *newPkg) + } + return NewPackages(pkgs...), nil +} diff --git a/internal/pkgs/packages_test.go b/internal/pkgs/packages_test.go new file mode 100644 index 0000000..0dfc68e --- /dev/null +++ b/internal/pkgs/packages_test.go @@ -0,0 +1,152 @@ +package pkgs + +import ( + "sort" + "testing" +) + +func TestNewPackages(t *testing.T) { + packages := NewPackages() + if packages == nil { + t.Error("NewPackages() returned nil") + } + if packages.Len() != 0 { + t.Errorf("NewPackages() returned non-empty Packages, got length %d", packages.Len()) + } +} + +func TestPackages_Add(t *testing.T) { + tests := []struct { + name string + initial []string + add string + expected []string + }{ + { + name: "Add to empty", + initial: []string{}, + add: "xdot=1.3-1", + expected: []string{"xdot=1.3-1"}, + }, + { + name: "Add duplicate", + initial: []string{"xdot=1.3-1"}, + add: "xdot=1.3-1", + expected: []string{"xdot=1.3-1"}, + }, + { + name: "Add different version", + initial: []string{"xdot=1.3-1"}, + add: "xdot=1.3-2", + expected: []string{"xdot=1.3-1", "xdot=1.3-2"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + packages := NewPackages() + for _, pkg := range tt.initial { + packages.Add(pkg) + } + packages.Add(tt.add) + + // Convert to slice for comparison + got := make([]string, packages.Len()) + for i := 0; i < packages.Len(); i++ { + got[i] = packages.Get(i) + } + + // Sort both slices for comparison + sort.Strings(got) + sort.Strings(tt.expected) + + if len(got) != len(tt.expected) { + t.Errorf("Packages.Add() resulted in wrong length, got %v, want %v", got, tt.expected) + return + } + + for i := range got { + if got[i] != tt.expected[i] { + t.Errorf("Packages.Add() = %v, want %v", got, tt.expected) + break + } + } + }) + } +} + +func TestPackages_String(t *testing.T) { + tests := []struct { + name string + packages []string + want string + }{ + { + name: "Empty packages", + packages: []string{}, + want: "", + }, + { + name: "Single package", + packages: []string{"xdot=1.3-1"}, + want: "xdot=1.3-1", + }, + { + name: "Multiple packages", + packages: []string{"xdot=1.3-1", "rolldice=1.16-1build3"}, + want: "xdot=1.3-1,rolldice=1.16-1build3", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := NewPackages() + for _, pkg := range tt.packages { + p.Add(pkg) + } + if got := p.String(); got != tt.want { + t.Errorf("Packages.String() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestPackages_Contains(t *testing.T) { + tests := []struct { + name string + packages []string + check string + want bool + }{ + { + name: "Empty packages", + packages: []string{}, + check: "xdot=1.3-1", + want: false, + }, + { + name: "Package exists", + packages: []string{"xdot=1.3-1", "rolldice=1.16-1build3"}, + check: "xdot=1.3-1", + want: true, + }, + { + name: "Package doesn't exist", + packages: []string{"xdot=1.3-1", "rolldice=1.16-1build3"}, + check: "nonexistent=1.0", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := NewPackages() + for _, pkg := range tt.packages { + p.Add(pkg) + } + if got := p.Contains(tt.check); got != tt.want { + t.Errorf("Packages.Contains() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/src/cmd/cache_apt_pkgs/cmdflags.go b/src/cmd/cache_apt_pkgs/cmdflags.go index 2f70dbe..eea2c91 100644 --- a/src/cmd/cache_apt_pkgs/cmdflags.go +++ b/src/cmd/cache_apt_pkgs/cmdflags.go @@ -1,154 +1 @@ -package main - -import ( - "flag" - "fmt" - "os" - "path/filepath" - - "awalsh128.com/cache-apt-pkgs-action/src/internal/pkgs" -) - -type Cmd struct { - Name string - Description string - Flags *flag.FlagSet - Examples []string // added Examples field for command usage examples - ExamplePackages *pkgs.Packages - Run func(cmd *Cmd, pkgArgs *pkgs.Packages) error -} - -// binaryName returns the base name of the command without the path -var binaryName = filepath.Base(os.Args[0]) - -type Cmds map[string]*Cmd - -func (c *Cmd) parseFlags() *pkgs.Packages { - if len(os.Args) < 3 { - fmt.Fprintf(os.Stderr, "error: command %q requires arguments\n", c.Name) - os.Exit(1) - } - // Parse the command line flags - if err := c.Flags.Parse(os.Args[2:]); err != nil { - fmt.Fprintf(os.Stderr, "error: unable to parse flags for command %q: %v\n", c.Name, err) - os.Exit(1) - } - - // Check for missing required flags - missingFlagNames := []string{} - c.Flags.VisitAll(func(f *flag.Flag) { - // Only consider strings as required flags - if f.Value.String() == "" && f.DefValue != "" && f.Name != "help" { - missingFlagNames = append(missingFlagNames, f.Name) - } - }) - if len(missingFlagNames) > 0 { - fmt.Fprintf(os.Stderr, "error: missing required flags for command %q: %s\n", c.Name, missingFlagNames) - os.Exit(1) - } - - // Parse the remaining arguments as package arguments - pkgArgs := pkgs.ParsePackageArgs(c.Flags.Args()) - return pkgArgs -} - -func (c *Cmds) Add(cmd *Cmd) error { - if _, exists := (*c)[cmd.Name]; exists { - return fmt.Errorf("command %q already exists", cmd.Name) - } - (*c)[cmd.Name] = cmd - return nil -} -func (c *Cmds) Get(name string) (*Cmd, bool) { - cmd, ok := (*c)[name] - return cmd, ok -} -func (c *Cmd) getFlagCount() int { - count := 0 - c.Flags.VisitAll(func(f *flag.Flag) { - count++ - }) - return count -} - -func (c *Cmd) help() { - if c.getFlagCount() == 0 { - fmt.Fprintf(os.Stderr, "usage: %s %s [packages]\n\n", binaryName, c.Name) - fmt.Fprintf(os.Stderr, "%s\n\n", c.Description) - } else { - fmt.Fprintf(os.Stderr, "usage: %s %s [flags] [packages]\n\n", binaryName, c.Name) - fmt.Fprintf(os.Stderr, "%s\n\n", c.Description) - fmt.Fprintf(os.Stderr, "Flags:\n") - c.Flags.PrintDefaults() - } - - if c.ExamplePackages == nil && len(c.Examples) == 0 { - return - } - fmt.Fprintf(os.Stderr, "\nExamples:\n") - if len(c.Examples) == 0 { - fmt.Fprintf(os.Stderr, " %s %s %s\n", binaryName, c.Name, c.ExamplePackages.String()) - return - } - for _, example := range c.Examples { - fmt.Fprintf(os.Stderr, " %s %s %s %s\n", binaryName, c.Name, example, c.ExamplePackages.String()) - } -} - -func printUsage(cmds Cmds) { - fmt.Fprintf(os.Stderr, "usage: %s [flags] [packages]\n\n", binaryName) - fmt.Fprintf(os.Stderr, "commands:\n") - - // Get max length for alignment - maxLen := 0 - for name := range cmds { - if len(name) > maxLen { - maxLen = len(name) - } - } - - // Print aligned command descriptions - for name, cmd := range cmds { - fmt.Fprintf(os.Stderr, " %-*s %s\n", maxLen, name, cmd.Description) - } - - fmt.Fprintf(os.Stderr, "\nUse \"%s --help\" for more information about a command\n", binaryName) -} - -func (c *Cmds) Parse() (*Cmd, *pkgs.Packages) { - if len(os.Args) < 2 { - fmt.Fprintf(os.Stderr, "error: no command specified\n\n") - printUsage(*c) - os.Exit(1) - } - - binaryName := os.Args[1] - if binaryName == "--help" || binaryName == "-h" { - printUsage(*c) - os.Exit(0) - } - - cmd, ok := c.Get(binaryName) - if !ok { - fmt.Fprintf(os.Stderr, "error: unknown command %q\n\n", binaryName) - printUsage(*c) - os.Exit(1) - } - - // Handle command-specific help - for _, arg := range os.Args[2:] { - if arg == "--help" || arg == "-h" { - cmd.help() - os.Exit(0) - } - } - - pkgArgs := cmd.parseFlags() - if pkgArgs == nil { - fmt.Fprintf(os.Stderr, "error: no package arguments specified for command %q\n\n", cmd.Name) - cmd.help() - os.Exit(1) - } - - return cmd, pkgArgs -} +package cacheaptpkgs diff --git a/src/cmd/cache_apt_pkgs/cmdflags_test.go b/src/cmd/cache_apt_pkgs/cmdflags_test.go deleted file mode 100644 index 2dfb1e9..0000000 --- a/src/cmd/cache_apt_pkgs/cmdflags_test.go +++ /dev/null @@ -1,210 +0,0 @@ -package main - -import ( - "flag" - "fmt" - "os" - "testing" - - "awalsh128.com/cache-apt-pkgs-action/src/internal/pkgs" - "github.com/stretchr/testify/assert" -) - -const ( - // Packages.Name values - pkgNameNginx = "nginx" - pkgNameRedis = "redis" - pkgNamePostgres = "postgresql" - - // Packages.Version values - pkgVersionNginx = "1.18.0" - pkgVersionPostgres = "13.2" - - // Cmd field values - cmdName = "test" - cmdDescription = "test command" - - // File path values - flagCacheDir = "/path/to/cache" - binaryFullPath = "/usr/local/bin/myapp" - binaryRelPath = "./bin/myapp" - binaryBaseName = "myapp" -) - -// PkgArg tests - -func TestPackagesEmpty(t *testing.T) { - assert := assert.New(t) - pkgArgs := pkgs.Packages{} - assert.Empty(pkgArgs.String(), "empty Packages should return empty string") -} - -func TestPackagesSingleWithoutVersion(t *testing.T) { - assert := assert.New(t) - pkgArgs := pkgs.Packages{pkgs.Package{Name: pkgNameNginx}} - assert.Equal(pkgNameNginx, pkgArgs.String(), "Packages with single package without version") -} - -func TestPackagesSingleWithVersion(t *testing.T) { - assert := assert.New(t) - pkgArgs := pkgs.Packages{pkgs.Package{Name: pkgNameNginx, Version: pkgVersionNginx}} - expected := pkgNameNginx + "=" + pkgVersionNginx - assert.Equal(expected, pkgArgs.String(), "Packages with single package with version") -} - -func TestPackagesMultiple(t *testing.T) { - assert := assert.New(t) - pkgArgs := pkgs.Packages{ - pkgs.Package{Name: pkgNameNginx, Version: pkgVersionNginx}, - pkgs.Package{Name: pkgNameRedis}, - pkgs.Package{Name: pkgNamePostgres, Version: pkgVersionPostgres}, - } - expected := pkgNameNginx + "=" + pkgVersionNginx + " " + pkgNameRedis + " " + pkgNamePostgres + "=" + pkgVersionPostgres - assert.Equal(expected, pkgArgs.String(), "Packages with multiple packages") -} - -// Cmd tests - -func TestCmdName(t *testing.T) { - assert := assert.New(t) - cmd := &Cmd{Name: cmdName} - assert.Equal(cmdName, cmd.Name, "Cmd.Name should match set value") -} - -func TestGetFlagCount(t *testing.T) { - assert := assert.New(t) - - // Test with no flags - cmd := &Cmd{ - Name: cmdName, - Flags: flag.NewFlagSet(cmdName, flag.ExitOnError), - } - assert.Equal(0, cmd.getFlagCount(), "Flag count should be 0 for empty FlagSet") - - // Test with one flag - cmd.Flags.String("test1", "", "test flag 1") - assert.Equal(1, cmd.getFlagCount(), "Flag count should be 1 after adding one flag") - - // Test with multiple flags - cmd.Flags.String("test2", "", "test flag 2") - cmd.Flags.Bool("test3", false, "test flag 3") - assert.Equal(3, cmd.getFlagCount(), "Flag count should match number of added flags") - - // Test with help flag (which is automatically added) - assert.Equal(4, cmd.getFlagCount(), "Flag count should include help flag") -} - -func TestCmdDescription(t *testing.T) { - assert := assert.New(t) - cmd := &Cmd{Description: cmdDescription} - assert.Equal(cmdDescription, cmd.Description, "Cmd.Description should match set value") -} - -func TestCmdExamples(t *testing.T) { - assert := assert.New(t) - examples := []string{"--cache-dir " + flagCacheDir} - cmd := &Cmd{Examples: examples} - assert.Equal(examples, cmd.Examples, "Cmd.Examples should match set value") -} - -func TestCmdExamplePackages(t *testing.T) { - assert := assert.New(t) - examplePkgs := &pkgs.Packages{ - pkgs.Package{Name: pkgNameNginx}, - pkgs.Package{Name: pkgNameRedis}, - } - cmd := &Cmd{ExamplePackages: examplePkgs} - assert.Equal(examplePkgs, cmd.ExamplePackages, "Cmd.ExamplePackages should match set value") -} - -func TestCmdRun(t *testing.T) { - assert := assert.New(t) - runCalled := false - cmd := &Cmd{ - Run: func(cmd *Cmd, pkgArgs *pkgs.Packages) error { - runCalled = true - return nil - }, - } - err := cmd.Run(cmd, nil) - assert.True(runCalled, "Cmd.Run should be called") - assert.NoError(err, "Cmd.Run should not return error") - - // Test error case - cmd = &Cmd{ - Run: func(cmd *Cmd, pkgArgs *pkgs.Packages) error { - return fmt.Errorf("test error") - }, - } - err = cmd.Run(cmd, nil) - assert.Error(err, "Cmd.Run should return error") - assert.Contains(err.Error(), "test error", "Error message should match expected") -} - -// Cmds tests - -func TestCmdsAdd(t *testing.T) { - assert := assert.New(t) - cmds := make(Cmds) - cmd := &Cmd{Name: cmdName} - err := cmds.Add(cmd) - assert.NoError(err, "Add should not return error for new command") - - _, exists := cmds[cmdName] - assert.True(exists, "command should be added to Cmds map") - - // Test duplicate add - err = cmds.Add(cmd) - assert.Error(err, "Add should return error for duplicate command") - assert.Contains(err.Error(), "already exists", "Error should mention duplicate") -} - -func TestCmdsGetExisting(t *testing.T) { - assert := assert.New(t) - cmds := make(Cmds) - cmd := &Cmd{Name: cmdName} - cmds[cmdName] = cmd - - got, ok := cmds.Get(cmdName) - assert.True(ok, "Get should return true for existing command") - assert.Equal(cmd, got, "Get should return correct command") -} - -func TestCmdsGetNonExistent(t *testing.T) { - assert := assert.New(t) - cmds := make(Cmds) - _, ok := cmds.Get("nonexistent") - assert.False(ok, "Get should return false for non-existent command") -} - -// Binary name tests - -func TestBinaryNameSimple(t *testing.T) { - origArgs := os.Args - defer func() { os.Args = origArgs }() - - os.Args = []string{binaryBaseName} - if got := binaryName; got != binaryBaseName { - t.Errorf("binaryName = %q, want %q", got, binaryBaseName) - } -} - -func TestBinaryNameWithPath(t *testing.T) { - origArgs := os.Args - defer func() { os.Args = origArgs }() - - os.Args = []string{binaryFullPath} - if got := binaryName; got != binaryBaseName { - t.Errorf("binaryName = %q, want %q", got, binaryBaseName) - } -} - -func TestBinaryNameWithRelativePath(t *testing.T) { - origArgs := os.Args - defer func() { os.Args = origArgs }() - - os.Args = []string{binaryRelPath} - if got := binaryName; got != binaryBaseName { - t.Errorf("binaryName = %q, want %q", got, binaryBaseName) - } -} // Mock for os.Exit to prevent tests from actually exiting diff --git a/src/cmd/cache_apt_pkgs/create_key.go b/src/cmd/cache_apt_pkgs/create_key.go new file mode 100644 index 0000000..eea2c91 --- /dev/null +++ b/src/cmd/cache_apt_pkgs/create_key.go @@ -0,0 +1 @@ +package cacheaptpkgs diff --git a/src/cmd/cache_apt_pkgs/install.go b/src/cmd/cache_apt_pkgs/install.go new file mode 100644 index 0000000..eea2c91 --- /dev/null +++ b/src/cmd/cache_apt_pkgs/install.go @@ -0,0 +1 @@ +package cacheaptpkgs diff --git a/src/cmd/cache_apt_pkgs/install_packages.go b/src/cmd/cache_apt_pkgs/install_packages.go new file mode 100644 index 0000000..eea2c91 --- /dev/null +++ b/src/cmd/cache_apt_pkgs/install_packages.go @@ -0,0 +1 @@ +package cacheaptpkgs diff --git a/src/cmd/cache_apt_pkgs/main.go b/src/cmd/cache_apt_pkgs/main.go deleted file mode 100644 index 38437bd..0000000 --- a/src/cmd/cache_apt_pkgs/main.go +++ /dev/null @@ -1,161 +0,0 @@ -package main - -import ( - "crypto/md5" - "flag" - "fmt" - "os" - "path/filepath" - "runtime" - - "awalsh128.com/cache-apt-pkgs-action/src/internal/cache" - "awalsh128.com/cache-apt-pkgs-action/src/internal/logging" - "awalsh128.com/cache-apt-pkgs-action/src/internal/pkgs" -) - -func createKey(cmd *Cmd, pkgArgs *pkgs.Packages) error { - key := cache.Key{ - Packages: *pkgArgs, - Version: cmd.Flags.Lookup("version").Value.String(), - GlobalVersion: cmd.Flags.Lookup("global-version").Value.String(), - OsArch: cmd.Flags.Lookup("os-arch").Value.String(), - } - cacheDir := cmd.Flags.Lookup("cache-dir").Value.String() - - keyText := key.Hash() - logging.Info("Created cache key text: %s", keyText) - - keyTextFilepath := filepath.Join(cacheDir, "cache_key.txt") - logging.Info("Writing cache key text '%s' to '%s'", keyText, keyTextFilepath) - if err := os.WriteFile(keyTextFilepath, []byte(keyText), 0644); err != nil { - return fmt.Errorf("failed to write cache key text to %s: %w", keyTextFilepath, err) - } - logging.Info("Cache key text written") - - logging.Info("Creating cache key MD5 hash from key text: %s", keyText) - hash := md5.Sum([]byte(keyText)) - logging.Info("Created cache key with hash: %x", hash) - - keyHashFilepath := filepath.Join(cmd.Flags.Lookup("cache-dir").Value.String(), "cache_key.md5") - logging.Info("Writing cache key hash '%x' to '%s'", hash, keyHashFilepath) - if err := os.WriteFile(keyHashFilepath, hash[:], 0644); err != nil { - return fmt.Errorf("failed to write cache key hash to %s: %w", keyHashFilepath, err) - } - logging.Info("Cache key written") - - return nil -} - -func installPackages(cmd *Cmd, pkgArgs *pkgs.Packages) error { - return fmt.Errorf("installPackages not implemented") -} - -func restorePackages(cmd *Cmd, pkgArgs *pkgs.Packages) error { - return fmt.Errorf("restorePackages not implemented") -} - -func validatePackages(cmd *Cmd, pkgArgs *pkgs.Packages) error { - apt, err := pkgs.New() - if err != nil { - return fmt.Errorf("error initializing APT: %v", err) - } - - for _, pkg := range *pkgArgs { - if _, err := apt.ValidatePackage(&pkg); err != nil { - logging.Info("invalid: %s - %v", pkg.String(), err) - } else { - logging.Info("valid: %s", pkg.String()) - } - } - - return nil -} - -func createCmdFlags( - createKey func(cmd *Cmd, pkgArgs *pkgs.Packages) error, - install func(cmd *Cmd, pkgArgs *pkgs.Packages) error, - restore func(cmd *Cmd, pkgArgs *pkgs.Packages) error) *Cmds { - - examplePackages := &pkgs.Packages{ - pkgs.Package{Name: "rolldice"}, - pkgs.Package{Name: "xdot", Version: "1.1-2"}, - pkgs.Package{Name: "libgtk-3-dev"}, - } - - commands := &Cmds{} - createKeyCmd := &Cmd{ - Name: "createkey", - Description: "Create a cache key based on the provided options", - Flags: flag.NewFlagSet("createkey", flag.ExitOnError), - Run: createKey, - } - createKeyCmd.Flags.String("os-arch", runtime.GOARCH, - "OS architecture to use in the cache key.\n"+ - "Action may be called from different runners in a different OS. This ensures the right one is fetched") - createKeyCmd.Flags.String("cache-dir", "", "Directory that holds the cached packages") - createKeyCmd.Flags.String("version", "", "Version of the cache key to force cache invalidation") - createKeyCmd.Flags.Int( - "global-version", - 0, - "Unique version to force cache invalidation globally across all action callers\n"+ - "Used to fix corrupted caches or bugs from the action itself", - ) - createKeyCmd.Examples = []string{ - "--os-arch amd64 --cache-dir ~/cache_dir --version 1.0.0 --global-version 1", - "--os-arch x86_64 --cache-dir /tmp/cache_dir --version v2 --global-version 2", - } - createKeyCmd.ExamplePackages = examplePackages - commands.Add(createKeyCmd) - - installCmd := &Cmd{ - Name: "install", - Description: "Install packages and saves them to the cache", - Flags: flag.NewFlagSet("install", flag.ExitOnError), - Run: install, - } - installCmd.Flags.String("cache-dir", "", "Directory that holds the cached packages") - installCmd.Examples = []string{ - "--cache-dir ~/cache_dir", - "--cache-dir /tmp/cache_dir", - } - installCmd.ExamplePackages = examplePackages - commands.Add(installCmd) - - restoreCmd := &Cmd{ - Name: "restore", - Description: "Restore packages from the cache", - Flags: flag.NewFlagSet("restore", flag.ExitOnError), - Run: restore, - } - restoreCmd.Flags.String("cache-dir", "", "Directory that holds the cached packages") - restoreCmd.Flags.String("restore-root", "/", "Root directory to untar the cached packages to") - restoreCmd.Flags.Bool("execute-scripts", false, "Execute APT post-install scripts on restore") - restoreCmd.Examples = []string{ - "--cache-dir ~/cache_dir --restore-root / --execute-scripts true", - "--cache-dir /tmp/cache_dir --restore-root /", - } - restoreCmd.ExamplePackages = examplePackages - commands.Add(restoreCmd) - - validatePackagesCmd := &Cmd{ - Name: "validate", - Description: "Validate package arguments", - Flags: flag.NewFlagSet("validate", flag.ExitOnError), - Run: validatePackages, - } - validatePackagesCmd.ExamplePackages = examplePackages - commands.Add(validatePackagesCmd) - - return commands -} - -func main() { - logging.Init("cache_apt_pkgs", true) - - commands := createCmdFlags(createKey, installPackages, restorePackages) - cmd, pkgArgs := commands.Parse() - err := cmd.Run(cmd, pkgArgs) - if err != nil { - logging.Fatalf("error: %v\n", err) - } -} diff --git a/src/cmd/cache_apt_pkgs/validate.go b/src/cmd/cache_apt_pkgs/validate.go new file mode 100644 index 0000000..eea2c91 --- /dev/null +++ b/src/cmd/cache_apt_pkgs/validate.go @@ -0,0 +1 @@ +package cacheaptpkgs diff --git a/src/internal/cache/io_test.go b/src/internal/cache/io_test.go deleted file mode 100644 index 40524cd..0000000 --- a/src/internal/cache/io_test.go +++ /dev/null @@ -1,196 +0,0 @@ -package cache - -import ( - "archive/tar" - "io" - "os" - "path/filepath" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -const ( - content1 = "content 1" - content2 = "content 2" -) - -func setupFiles(t *testing.T) (string, func()) { - assert := assert.New(t) - - // Create temporary directory - tempDir := filepath.Join(os.TempDir(), "tar_files") - err := os.MkdirAll(tempDir, 0755) - assert.NoError(err, "Failed to create temp dir") - - // Create test files - file1Path := filepath.Join(tempDir, "file1.txt") - err = os.WriteFile(file1Path, []byte(content1), 0644) - assert.NoError(err, "Failed to create file 1") - - file2Path := filepath.Join(tempDir, "file2.txt") - err = os.WriteFile(file2Path, []byte(content2), 0644) - assert.NoError(err, "Failed to create file 2") - - subDirPath := filepath.Join(tempDir, "subdir") - err = os.MkdirAll(subDirPath, 0755) - assert.NoError(err, "Failed to create subdir") - - // Create a file in subdir - file3Path := filepath.Join(subDirPath, "file3.txt") - err = os.WriteFile(file3Path, []byte(content2), 0644) // Same content as file2 - assert.NoError(err, "Failed to create file 3") - - // Create symlinks - one relative, one absolute - symlinkPath := filepath.Join(tempDir, "link.txt") - err = os.Symlink("file1.txt", symlinkPath) - assert.NoError(err, "Failed to create relative symlink") - - absSymlinkPath := filepath.Join(tempDir, "abs_link.txt") - err = os.Symlink(file3Path, absSymlinkPath) - assert.NoError(err, "Failed to create absolute symlink") - - cleanup := func() { - os.RemoveAll(tempDir) - } - - return tempDir, cleanup -} - -func TestTarFiles(t *testing.T) { - assert := assert.New(t) - require := require.New(t) - - // Setup files - sourceDir, cleanup := setupFiles(t) - defer cleanup() - - // Create destination for the archive - destPath := filepath.Join(sourceDir, "test.tar") - - // Files to archive (absolute paths) - files := []string{ - filepath.Join(sourceDir, "file1.txt"), - filepath.Join(sourceDir, "file2.txt"), - filepath.Join(sourceDir, "link.txt"), - filepath.Join(sourceDir, "abs_link.txt"), - } - - // Create the archive - err := TarFiles(destPath, files) - require.NoError(err, "TarFiles should succeed") - - // Verify the archive exists - _, err = os.Stat(destPath) - assert.NoError(err, "Archive file should exist") // Open and verify the archive contents - file, err := os.Open(destPath) - require.NoError(err, "Should be able to open archive") - defer file.Close() - - // Create tar reader - tr := tar.NewReader(file) - - // Map to track found files - foundFiles := make(map[string]bool) - foundContent := make(map[string]string) - foundLinks := make(map[string]string) - - // Read all files from the archive - for { - header, err := tr.Next() - if err == io.EOF { - break - } - require.NoError(err, "Should be able to read tar header") - - // When checking files, reconstruct the absolute path - absPath := "/" + header.Name - foundFiles[absPath] = true - - if header.Typeflag == tar.TypeSymlink { - foundLinks[filepath.Base(header.Name)] = header.Linkname - continue - } - - if header.Typeflag == tar.TypeReg { - content, err := io.ReadAll(tr) - require.NoError(err, "Should be able to read file content") - foundContent[filepath.Base(header.Name)] = string(content) - } - } - - // Verify all files were archived - for _, f := range files { - assert.True(foundFiles[f], "Archive should contain %s", f) - } - - // Verify symlink targets are present - file3AbsPath := filepath.Join(sourceDir, "subdir/file3.txt") - assert.True(foundFiles[file3AbsPath], "Archive should contain symlink target %s", file3AbsPath) - - // Get base name of file3 for content check - file3Base := filepath.Base(file3AbsPath) - - // Verify file contents - assert.Equal(content1, foundContent["file1.txt"], "file1.txt should have correct content") - assert.Equal(content2, foundContent["file2.txt"], "file2.txt should have correct content") - assert.Equal(content2, foundContent[file3Base], "file3.txt should have correct content") - - // Verify symlinks - assert.Equal("file1.txt", foundLinks["link.txt"], "link.txt should point to file1.txt") - assert.Equal(file3AbsPath[1:], foundLinks["abs_link.txt"], "abs_link.txt should point to file3.txt with correct path") -} - -func TestTarFilesErrors(t *testing.T) { - assert := assert.New(t) - - // Setup files - sourceDir, cleanup := setupFiles(t) - defer cleanup() - - file1 := filepath.Join(sourceDir, "file1.txt") - - tests := []struct { - name string - destPath string - files []string - wantErr bool - }{ - { - name: "Empty destination path", - destPath: "", - files: []string{file1}, - wantErr: true, - }, - { - name: "Empty files list", - destPath: "test.tar", - files: []string{}, - wantErr: true, - }, - { - name: "Non-existent file", - destPath: "test.tar", - files: []string{filepath.Join(sourceDir, "nonexistent.txt")}, - wantErr: true, - }, - { - name: "Directory in files list", - destPath: "test.tar", - files: []string{sourceDir}, - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := TarFiles(tt.destPath, tt.files) - if tt.wantErr { - assert.Error(err, "TarFiles should return error") - } else { - assert.NoError(err, "TarFiles should not return error") - } - }) - } -} diff --git a/src/internal/cache/key.go b/src/internal/cache/key.go index 4f4a912..08bf029 100644 --- a/src/internal/cache/key.go +++ b/src/internal/cache/key.go @@ -1,30 +1 @@ package cache - -import ( - "fmt" - "sort" - - "awalsh128.com/cache-apt-pkgs-action/src/internal/pkgs" -) - -// Key represents a cache key based on package list and version information -type Key struct { - Packages pkgs.Packages - Version string - GlobalVersion string - OsArch string -} - -// Hash returns an MD5 hash of the key's contents, with packages sorted by name and version -func (k *Key) Hash() string { - // Sort packages in place by Name, then by Version - sort.Slice(k.Packages, func(i, j int) bool { - if k.Packages[i].Name != k.Packages[j].Name { - return k.Packages[i].Name < k.Packages[j].Name - } - return k.Packages[i].Version < k.Packages[j].Version - }) - - // Use the sorted packages to generate the hash input - return fmt.Sprintf("%s @ '%s' '%s' '%s'", k.Packages.String(), k.Version, k.GlobalVersion, k.OsArch) -} diff --git a/src/internal/cache/key_test.go b/src/internal/cache/key_test.go deleted file mode 100644 index 679acd1..0000000 --- a/src/internal/cache/key_test.go +++ /dev/null @@ -1,149 +0,0 @@ -package cache - -import ( - "testing" - - "awalsh128.com/cache-apt-pkgs-action/src/internal/pkgs" - "github.com/stretchr/testify/assert" -) - -const ( - // Package names - pkg1 = "pkg1" - pkg2 = "pkg2" - - // Versions - version1 = "1.0.0" - version2 = "2.0.0" - version3 = "3.0.0" - - // Architectures - archX86 = "amd64" - archArm = "arm64" -) - -func TestKeyHashEmptyKey(t *testing.T) { - assert := assert.New(t) - key := &Key{} - hash := key.Hash() - assert.NotEmpty(hash, "Hash should not be empty even for empty key") -} - -func TestKeyHashWithPackages(t *testing.T) { - assert := assert.New(t) - key := &Key{ - Packages: pkgs.Packages{ - pkgs.Package{Name: pkg1}, - pkgs.Package{Name: pkg2}, - }, - } - hash1 := key.Hash() - - // Same packages in different order should produce same hash - key2 := &Key{ - Packages: pkgs.Packages{ - pkgs.Package{Name: pkg2}, - pkgs.Package{Name: pkg1}, - }, - } - hash2 := key2.Hash() - - assert.Equal(hash1, hash2, "Hash should be same for same packages in different order") - - // Test with versions - key3 := &Key{ - Packages: pkgs.Packages{ - pkgs.Package{Name: pkg1, Version: version2}, - pkgs.Package{Name: pkg1, Version: version1}, - pkgs.Package{Name: pkg2, Version: version1}, - }, - } - hash3 := key3.Hash() - - key4 := &Key{ - Packages: pkgs.Packages{ - pkgs.Package{Name: pkg2, Version: version1}, - pkgs.Package{Name: pkg1, Version: version1}, - pkgs.Package{Name: pkg1, Version: version2}, - }, - } - hash4 := key4.Hash() - - assert.Equal(hash3, hash4, "Hash should be same for same packages and versions in different order") -} - -func TestKeyHashWithVersion(t *testing.T) { - assert := assert.New(t) - key := &Key{ - Packages: pkgs.Packages{pkgs.Package{Name: pkg1}, pkgs.Package{Name: pkg2}}, - Version: version1, - } - hash1 := key.Hash() - - // Same package with different version should produce different hash - key2 := &Key{ - Packages: pkgs.Packages{pkgs.Package{Name: pkg1}}, - Version: version2, - } - hash2 := key2.Hash() - - assert.NotEqual(hash1, hash2, "Hash should be different for different versions") -} - -func TestKeyHashWithGlobalVersion(t *testing.T) { - assert := assert.New(t) - key := &Key{ - Packages: pkgs.Packages{pkgs.Package{Name: pkg1}}, - GlobalVersion: version1, - } - hash1 := key.Hash() - - // Same package with different global version should produce different hash - key2 := &Key{ - Packages: pkgs.Packages{{Name: pkg1}}, - GlobalVersion: version2, - } - hash2 := key2.Hash() - - assert.NotEqual(hash1, hash2, "Hash should be different for different global versions") -} - -func TestKeyHashWithOsArch(t *testing.T) { - assert := assert.New(t) - key := &Key{ - Packages: pkgs.Packages{{Name: pkg1}}, - OsArch: archX86, - } - hash1 := key.Hash() - - // Same package with different OS architecture should produce different hash - key2 := &Key{ - Packages: pkgs.Packages{{Name: pkg1}}, - OsArch: archArm, - } - hash2 := key2.Hash() - - assert.NotEqual(hash1, hash2, "Hash should be different for different OS architectures") -} - -func TestKeyHashWithAll(t *testing.T) { - assert := assert.New(t) - key := &Key{ - Packages: pkgs.Packages{{Name: pkg1}, {Name: pkg2}}, - Version: version1, - GlobalVersion: version2, - OsArch: archX86, - } - hash1 := key.Hash() - - // Same values in different order should produce same hash - key2 := &Key{ - Packages: pkgs.Packages{{Name: pkg2}, {Name: pkg1}}, - Version: version1, - GlobalVersion: version2, - OsArch: archX86, - } - hash2 := key2.Hash() - - assert.Equal(hash1, hash2, "Hash should be same for same values") -} diff --git a/src/internal/cache/manifest.go b/src/internal/cache/manifest.go index 90c84cf..08bf029 100644 --- a/src/internal/cache/manifest.go +++ b/src/internal/cache/manifest.go @@ -1,66 +1 @@ package cache - -import ( - "encoding/json" - "fmt" - "os" - "strings" - "time" -) - -type Manifest struct { - CacheKey Key - FilePaths []string - LastModified time.Time -} - -func (m *Manifest) FilePathsText() string { - var lines []string - lines = append(lines, m.FilePaths...) - return strings.Join(lines, "\n") -} - -func (m *Manifest) Json() (string, error) { - content, err := json.MarshalIndent(m, "", " ") - if err != nil { - return "", fmt.Errorf("failed to marshal manifest to JSON: %w", err) - } - return string(content), nil -} - -func ReadJson(filepath string) (*Manifest, error) { - file, err := os.Open(filepath) - if err != nil { - return nil, fmt.Errorf("failed to open manifest at %s: %w", filepath, err) - } - defer file.Close() - - content, err := os.ReadFile(filepath) - if err != nil { - return nil, fmt.Errorf("failed to read manifest at %s: %w", filepath, err) - } - - var manifest Manifest - if err := json.Unmarshal(content, &manifest); err != nil { - return nil, fmt.Errorf("failed to parse manifest JSON: %w", err) - } - return &manifest, nil -} - -func Write(filepath string, manifest *Manifest) error { - file, err := os.Create(filepath) - if err != nil { - return fmt.Errorf("failed to create manifest at %s: %w", filepath, err) - } - defer file.Close() - - content, err := manifest.Json() - if err != nil { - return fmt.Errorf("failed to serialize manifest to %s: %v", filepath, err) - } - if _, err := file.Write([]byte(content)); err != nil { - return fmt.Errorf("failed to write manifest to %s: %v", filepath, err) - } - fmt.Printf("Manifest written to %s\n", filepath) - return nil -} diff --git a/src/internal/cache/manifest_test.go b/src/internal/cache/manifest_test.go deleted file mode 100644 index 2ebb80e..0000000 --- a/src/internal/cache/manifest_test.go +++ /dev/null @@ -1,65 +0,0 @@ -package cache - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestFilePathsText(t *testing.T) { - tests := []struct { - name string - manifest *Manifest - expected string - description string - }{ - { - name: "empty_paths", - manifest: &Manifest{ - FilePaths: []string{}, - }, - expected: "", - description: "Empty FilePaths should return empty string", - }, - { - name: "single_path", - manifest: &Manifest{ - FilePaths: []string{"/path/to/file1"}, - }, - expected: "/path/to/file1", - description: "Single file path should be returned as is", - }, - { - name: "multiple_paths", - manifest: &Manifest{ - FilePaths: []string{ - "/path/to/file1", - "/path/to/file2", - "/path/to/file3", - }, - }, - expected: "/path/to/file1\n/path/to/file2\n/path/to/file3", - description: "Multiple file paths should be joined with newlines", - }, - { - name: "paths_with_special_chars", - manifest: &Manifest{ - FilePaths: []string{ - "/path with spaces/file1", - "/path/with/tabs\t/file2", - "/path/with/newlines\n/file3", - }, - }, - expected: "/path with spaces/file1\n/path/with/tabs\t/file2\n/path/with/newlines\n/file3", - description: "Paths with special characters should be preserved", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assert := assert.New(t) - result := tt.manifest.FilePathsText() - assert.Equal(tt.expected, result, tt.description) - }) - } -} diff --git a/src/internal/cio/serialization.go b/src/internal/cio/serialization.go new file mode 100644 index 0000000..bfbfb7c --- /dev/null +++ b/src/internal/cio/serialization.go @@ -0,0 +1 @@ +package cio diff --git a/src/internal/cio/tar.go b/src/internal/cio/tar.go new file mode 100644 index 0000000..bfbfb7c --- /dev/null +++ b/src/internal/cio/tar.go @@ -0,0 +1 @@ +package cio diff --git a/src/internal/common/strings.go b/src/internal/common/strings.go index 48805f3..805d0c7 100644 --- a/src/internal/common/strings.go +++ b/src/internal/common/strings.go @@ -1,94 +1 @@ package common - -import ( - "bytes" - "os/exec" - "sort" - "strings" -) - -// ExecCommand executes a command and returns its output -var ExecCommand = func(command string, args ...string) (string, error) { - cmd := exec.Command(command, args...) - var stdout bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stdout - err := cmd.Run() - return strings.TrimSpace(stdout.String()), err -} - -// SortAndJoin sorts a slice of strings and joins them with the specified separator. -func SortAndJoin(strs []string, sep string) string { - if len(strs) == 0 { - return "" - } - sorted := make([]string, len(strs)) - copy(sorted, strs) - sort.Strings(sorted) - return strings.Join(sorted, sep) -} - -// SplitAndTrim splits a string by the given separator and trims whitespace from each part. -// Empty strings are removed from the result. -func SplitAndTrim(s, sep string) []string { - if s == "" { - return nil - } - parts := strings.Split(s, sep) - result := make([]string, 0, len(parts)) - for _, part := range parts { - part = strings.TrimSpace(part) - if part != "" { - result = append(result, part) - } - } - return result -} - -// ParseKeyValue parses a string in the format "key=value" and returns the key and value. -// If no separator is found, returns the entire string as the key and an empty value. -func ParseKeyValue(s, sep string) (key, value string) { - parts := strings.SplitN(s, sep, 2) - key = strings.TrimSpace(parts[0]) - if len(parts) > 1 { - value = strings.TrimSpace(parts[1]) - } - return key, value -} - -// ContainsAny returns true if any of the substrings are found in s. -func ContainsAny(s string, substrings ...string) bool { - for _, sub := range substrings { - if strings.Contains(s, sub) { - return true - } - } - return false -} - -// EqualFold reports whether s and t, interpreted as UTF-8 strings, -// are equal under simple Unicode case-folding, which is a more general -// form of case-insensitivity. -func EqualFold(s, t string) bool { - return strings.EqualFold(s, t) -} - -// TrimPrefixCaseInsensitive removes the provided prefix from s if it exists, -// ignoring case. If s doesn't start with prefix, s is returned unchanged. -func TrimPrefixCaseInsensitive(s, prefix string) string { - if strings.HasPrefix(strings.ToLower(s), strings.ToLower(prefix)) { - return s[len(prefix):] - } - return s -} - -// RemoveEmpty removes empty strings from a slice of strings. -func RemoveEmpty(strs []string) []string { - result := make([]string, 0, len(strs)) - for _, s := range strs { - if s != "" { - result = append(result, s) - } - } - return result -} diff --git a/src/internal/common/strings_test.go b/src/internal/common/strings_test.go index 636d491..805d0c7 100644 --- a/src/internal/common/strings_test.go +++ b/src/internal/common/strings_test.go @@ -1,297 +1 @@ package common - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestExecCommand(t *testing.T) { - oldExecCommand := ExecCommand - defer func() { - ExecCommand = oldExecCommand - }() - - tests := []struct { - name string - command string - args []string - output string - wantErr bool - mockFunc func(string, ...string) (string, error) - }{ - { - name: "simple command", - command: "echo", - args: []string{"hello"}, - output: "hello", - mockFunc: func(cmd string, args ...string) (string, error) { - assert.Equal(t, "echo", cmd) - assert.Equal(t, []string{"hello"}, args) - return "hello", nil - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - ExecCommand = tt.mockFunc - - got, err := ExecCommand(tt.command, tt.args...) - if tt.wantErr { - assert.Error(t, err) - return - } - - assert.NoError(t, err) - assert.Equal(t, tt.output, got) - }) - } -} - -func TestSortAndJoin(t *testing.T) { - tests := []struct { - name string - strs []string - sep string - expected string - }{ - { - name: "empty slice", - strs: []string{}, - sep: ",", - expected: "", - }, - { - name: "single item", - strs: []string{"one"}, - sep: ",", - expected: "one", - }, - { - name: "multiple items", - strs: []string{"c", "a", "b"}, - sep: ",", - expected: "a,b,c", - }, - { - name: "custom separator", - strs: []string{"c", "a", "b"}, - sep: "|", - expected: "a|b|c", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assert := assert.New(t) - result := SortAndJoin(tt.strs, tt.sep) - assert.Equal(tt.expected, result) - }) - } -} - -func TestSplitAndTrim(t *testing.T) { - tests := []struct { - name string - input string - sep string - expected []string - }{ - { - name: "empty string", - input: "", - sep: ",", - expected: nil, - }, - { - name: "single item", - input: "one", - sep: ",", - expected: []string{"one"}, - }, - { - name: "multiple items with whitespace", - input: " a , b , c ", - sep: ",", - expected: []string{"a", "b", "c"}, - }, - { - name: "empty items removed", - input: "a,,b, ,c", - sep: ",", - expected: []string{"a", "b", "c"}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assert := assert.New(t) - result := SplitAndTrim(tt.input, tt.sep) - assert.Equal(tt.expected, result) - }) - } -} - -func TestParseKeyValue(t *testing.T) { - tests := []struct { - name string - input string - sep string - expectedKey string - expectedValue string - }{ - { - name: "no separator", - input: "key", - sep: "=", - expectedKey: "key", - expectedValue: "", - }, - { - name: "with separator", - input: "key=value", - sep: "=", - expectedKey: "key", - expectedValue: "value", - }, - { - name: "with whitespace", - input: " key = value ", - sep: "=", - expectedKey: "key", - expectedValue: "value", - }, - { - name: "custom separator", - input: "key:value", - sep: ":", - expectedKey: "key", - expectedValue: "value", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assert := assert.New(t) - key, value := ParseKeyValue(tt.input, tt.sep) - assert.Equal(tt.expectedKey, key) - assert.Equal(tt.expectedValue, value) - }) - } -} - -func TestContainsAny(t *testing.T) { - tests := []struct { - name string - s string - substrings []string - expected bool - }{ - { - name: "empty string and substrings", - s: "", - substrings: []string{}, - expected: false, - }, - { - name: "no matches", - s: "hello world", - substrings: []string{"foo", "bar"}, - expected: false, - }, - { - name: "single match", - s: "hello world", - substrings: []string{"hello", "foo"}, - expected: true, - }, - { - name: "multiple matches", - s: "hello world", - substrings: []string{"hello", "world"}, - expected: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assert := assert.New(t) - result := ContainsAny(tt.s, tt.substrings...) - assert.Equal(tt.expected, result) - }) - } -} - -func TestTrimPrefixCaseInsensitive(t *testing.T) { - tests := []struct { - name string - s string - prefix string - expected string - }{ - { - name: "exact match", - s: "prefixText", - prefix: "prefix", - expected: "Text", - }, - { - name: "case difference", - s: "PREFIXText", - prefix: "prefix", - expected: "Text", - }, - { - name: "no match", - s: "Text", - prefix: "prefix", - expected: "Text", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assert := assert.New(t) - result := TrimPrefixCaseInsensitive(tt.s, tt.prefix) - assert.Equal(tt.expected, result) - }) - } -} - -func TestRemoveEmpty(t *testing.T) { - tests := []struct { - name string - input []string - expected []string - }{ - { - name: "empty slice", - input: []string{}, - expected: []string{}, - }, - { - name: "no empty strings", - input: []string{"a", "b", "c"}, - expected: []string{"a", "b", "c"}, - }, - { - name: "with empty strings", - input: []string{"a", "", "b", "", "c"}, - expected: []string{"a", "b", "c"}, - }, - { - name: "all empty strings", - input: []string{"", "", ""}, - expected: []string{}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assert := assert.New(t) - result := RemoveEmpty(tt.input) - assert.Equal(tt.expected, result) - }) - } -} diff --git a/src/internal/logging/logger.go b/src/internal/logging/logger.go index 51e5e1a..2b43acc 100644 --- a/src/internal/logging/logger.go +++ b/src/internal/logging/logger.go @@ -1,63 +1 @@ package logging - -import ( - "io" - "log" - "os" - "path/filepath" -) - -type Logger struct { - wrapped *log.Logger - Filename string - Debug bool - file *os.File -} - -var logger *Logger - -var LogFilepath = os.Args[0] + ".log" - -func Init(filename string, debug bool) *Logger { - os.Remove(LogFilepath) - file, err := os.OpenFile(LogFilepath, os.O_CREATE|os.O_WRONLY, 0644) - if err != nil { - log.Fatal(err) - } - cwd, _ := os.Getwd() - - logger = &Logger{ - // Logs to both stderr and file. - // Stderr is used to act as a sidechannel of information and stay separate from the actual outputs of the program. - wrapped: log.New(io.MultiWriter(file, os.Stderr), "", log.LstdFlags), - Filename: filepath.Join(cwd, file.Name()), - Debug: debug, - file: file, - } - Debug("Debug log created at %s", logger.Filename) - return logger -} - -func DebugLazy(getLine func() string) { - if logger.Debug { - logger.wrapped.Println(getLine()) - } -} - -func Debug(format string, a ...any) { - if logger.Debug { - logger.wrapped.Printf(format, a...) - } -} - -func Info(format string, a ...any) { - logger.wrapped.Printf(format+"\n", a...) -} - -func Fatal(err error) { - logger.wrapped.Fatal(err) -} - -func Fatalf(format string, a ...any) { - logger.wrapped.Fatalf(format+"\n", a...) -} diff --git a/src/internal/pkgs/apt.go b/src/internal/pkgs/apt.go new file mode 100644 index 0000000..b275f9b --- /dev/null +++ b/src/internal/pkgs/apt.go @@ -0,0 +1 @@ +package pkgs diff --git a/src/internal/pkgs/package.go b/src/internal/pkgs/package.go new file mode 100644 index 0000000..b275f9b --- /dev/null +++ b/src/internal/pkgs/package.go @@ -0,0 +1 @@ +package pkgs diff --git a/src/internal/pkgs/packages.go b/src/internal/pkgs/packages.go new file mode 100644 index 0000000..b275f9b --- /dev/null +++ b/src/internal/pkgs/packages.go @@ -0,0 +1 @@ +package pkgs diff --git a/src/internal/pkgs/pkgs.go b/src/internal/pkgs/pkgs.go deleted file mode 100644 index 1321f69..0000000 --- a/src/internal/pkgs/pkgs.go +++ /dev/null @@ -1,76 +0,0 @@ -package pkgs - -import ( - "fmt" - "strings" - - "github.com/bluet/syspkg" - "github.com/bluet/syspkg/manager" -) - -// Package represents a package with its version information -type Package struct { - Name string - Version string -} - -// String returns a string representation of a package in the format "name" or "name=version" -func (p Package) String() string { - if p.Version != "" { - return fmt.Sprintf("%s=%s", p.Name, p.Version) - } - return p.Name -} - -type Packages []Package - -func ParsePackageArgs(value []string) *Packages { - var pkgs Packages - for _, val := range value { - parts := strings.SplitN(val, "=", 2) - if len(parts) == 1 { - pkgs = append(pkgs, Package{Name: parts[0]}) - continue - } - pkgs = append(pkgs, Package{Name: parts[0], Version: parts[1]}) - } - return &pkgs -} - -// String returns a string representation of Packages -func (p *Packages) String() string { - var parts []string - for _, arg := range *p { - parts = append(parts, arg.String()) - } - return strings.Join(parts, " ") -} - -type Apt struct { - Manager syspkg.PackageManager -} - -func New() (*Apt, error) { - registry, err := syspkg.New(syspkg.IncludeOptions{AptFast: true}) - if err != nil { - return nil, fmt.Errorf("error initializing SysPkg: %v", err) - } - - // Get APT package manager (if available) - aptManager, err := registry.GetPackageManager("apt-fast") - if err != nil { - return nil, fmt.Errorf("APT package manager not available: %v", err) - } - - return &Apt{ - Manager: aptManager, - }, nil -} - -func (a *Apt) ValidatePackage(pkg *Package) (syspkg.PackageInfo, error) { - packageInfo, err := a.Manager.GetPackageInfo(pkg.String(), &manager.Options{AssumeYes: true}) - if err != nil { - return syspkg.PackageInfo{}, fmt.Errorf("error getting package info: %v", err) - } - return packageInfo, nil -} diff --git a/src/internal/pkgs/pkgs_test.go b/src/internal/pkgs/pkgs_test.go deleted file mode 100644 index 57647e6..0000000 --- a/src/internal/pkgs/pkgs_test.go +++ /dev/null @@ -1,106 +0,0 @@ -package pkgs - -import ( - "testing" -) - -func TestFindInstalledPackages(t *testing.T) { - tests := []struct { - name string - input []Package - wantValid []Package - wantInvalid []string - wantError bool - }{ - { - name: "empty list", - input: []Package{}, - wantValid: []Package{}, - wantInvalid: []string{}, - wantError: false, - }, - // Add more test cases here once we have a way to mock syspkg - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result, err := FindInstalledPackages(tt.input) - if (err != nil) != tt.wantError { - t.Errorf("FindInstalledPackages() error = %v, wantError %v", err, tt.wantError) - return - } - if err != nil { - return - } - - if len(result.Valid) != len(tt.wantValid) { - t.Errorf("FindInstalledPackages() valid = %v, want %v", result.Valid, tt.wantValid) - } - - if len(result.Invalid) != len(tt.wantInvalid) { - t.Errorf("FindInstalledPackages() invalid = %v, want %v", result.Invalid, tt.wantInvalid) - } - }) - } -} - -func TestValidatePackages(t *testing.T) { - tests := []struct { - name string - packageList string - wantError bool - }{ - { - name: "empty string", - packageList: "", - wantError: false, - }, - { - name: "single package", - packageList: "gcc", - wantError: false, - }, - { - name: "multiple packages", - packageList: "gcc,g++,make", - wantError: false, - }, - { - name: "package with version", - packageList: "gcc=4.8.5", - wantError: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - _, err := ValidatePackages(tt.packageList) - if (err != nil) != tt.wantError { - t.Errorf("ValidatePackages() error = %v, wantError %v", err, tt.wantError) - } - }) - } -} - -func TestGetInstalledPackages(t *testing.T) { - tests := []struct { - name string - installLog string - wantError bool - }{ - { - name: "empty log", - installLog: "test_empty.log", - wantError: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - _, err := GetInstalledPackages(tt.installLog) - if (err != nil) != tt.wantError { - t.Errorf("GetInstalledPackages() error = %v, wantError %v", err, tt.wantError) - } - }) - } -} diff --git a/test_distribute.sh b/test_distribute.sh deleted file mode 100644 index 8b13789..0000000 --- a/test_distribute.sh +++ /dev/null @@ -1 +0,0 @@ -