mirror of
https://github.com/awalsh128/cache-apt-pkgs-action.git
synced 2025-12-27 13:51:25 +00:00
Another new draft. Things broken but more streamlined.
This commit is contained in:
parent
1840a3c552
commit
aeeea6da9b
12
.github/workflows/ci.yml
vendored
12
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
6
.github/workflows/test-action.yml
vendored
6
.github/workflows/test-action.yml
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +0,0 @@
|
|||
{
|
||||
"linters": {
|
||||
"enable": [
|
||||
"gofmt",
|
||||
"govet",
|
||||
"staticcheck",
|
||||
"errcheck",
|
||||
"ineffassign",
|
||||
"gocritic"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
version: "0.2"
|
||||
# Suggestions can sometimes take longer on CI machines,
|
||||
# leading to inconsistent results.
|
||||
suggestionsTimeout: 5000 # ms
|
||||
enabled: false
|
||||
|
|
@ -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
|
||||
|
|
|
|||
6
.vscode/settings.json
vendored
6
.vscode/settings.json
vendored
|
|
@ -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
|
||||
}
|
||||
2
LICENSE
2
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.
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
# cache-apt-pkgs-action
|
||||
|
||||
[](https://github.com/awalsh128/cache-apt-pkgs-action/actions/workflows/ci.yml)
|
||||
[](https://github.com/awalsh128/cache-apt-pkgs-action/actions/workflows/ci.yml?query=branch%3Adev-v2.0)
|
||||
[](https://github.com/awalsh128/cache-apt-pkgs-action/actions/workflows/ci.yml?query=branch%3Adev-v2.0)
|
||||
[](https://goreportcard.com/report/github.com/awalsh128/cache-apt-pkgs-action)
|
||||
[](https://pkg.go.dev/github.com/awalsh128/cache-apt-pkgs-action)
|
||||
[](https://github.com/awalsh128/cache-apt-pkgs-action/blob/master/LICENSE)
|
||||
[](https://github.com/awalsh128/cache-apt-pkgs-action/blob/dev-v2.0/LICENSE)
|
||||
[](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
|
||||
|
|
|
|||
65
action.yml
65
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 }}"
|
||||
|
|
|
|||
175
cmd/cache_apt_pkgs/cmdflags.go
Normal file
175
cmd/cache_apt_pkgs/cmdflags.go
Normal file
|
|
@ -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 <command> [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 <command> --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
|
||||
}
|
||||
56
cmd/cache_apt_pkgs/create_key.go
Normal file
56
cmd/cache_apt_pkgs/create_key.go
Normal file
|
|
@ -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
|
||||
}
|
||||
84
cmd/cache_apt_pkgs/install.go
Normal file
84
cmd/cache_apt_pkgs/install.go
Normal file
|
|
@ -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
|
||||
}
|
||||
21
cmd/cache_apt_pkgs/main.go
Normal file
21
cmd/cache_apt_pkgs/main.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
30
cmd/cache_apt_pkgs/restore.go
Normal file
30
cmd/cache_apt_pkgs/restore.go
Normal file
|
|
@ -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
|
||||
}
|
||||
44
cmd/cache_apt_pkgs/validate.go
Normal file
44
cmd/cache_apt_pkgs/validate.go
Normal file
|
|
@ -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
|
||||
}
|
||||
58
internal/cache/key.go
vendored
Normal file
58
internal/cache/key.go
vendored
Normal file
|
|
@ -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
|
||||
}
|
||||
123
internal/cache/key_test.go
vendored
Normal file
123
internal/cache/key_test.go
vendored
Normal file
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
93
internal/cache/manifest.go
vendored
Normal file
93
internal/cache/manifest.go
vendored
Normal file
|
|
@ -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
|
||||
}
|
||||
147
internal/cache/manifest_test.go
vendored
Normal file
147
internal/cache/manifest_test.go
vendored
Normal file
|
|
@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
27
internal/cio/serialization.go
Normal file
27
internal/cio/serialization.go
Normal file
|
|
@ -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
|
||||
}
|
||||
130
internal/cio/serialization_test.go
Normal file
130
internal/cio/serialization_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
95
internal/logging/logger.go
Normal file
95
internal/logging/logger.go
Normal file
|
|
@ -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...)
|
||||
}
|
||||
149
internal/logging/logger_test.go
Normal file
149
internal/logging/logger_test.go
Normal file
|
|
@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
72
internal/pkgs/apt.go
Normal file
72
internal/pkgs/apt.go
Normal file
|
|
@ -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
|
||||
}
|
||||
89
internal/pkgs/apt_test.go
Normal file
89
internal/pkgs/apt_test.go
Normal file
|
|
@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
46
internal/pkgs/package.go
Normal file
46
internal/pkgs/package.go
Normal file
|
|
@ -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
|
||||
}
|
||||
105
internal/pkgs/packages.go
Normal file
105
internal/pkgs/packages.go
Normal file
|
|
@ -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
|
||||
}
|
||||
152
internal/pkgs/packages_test.go
Normal file
152
internal/pkgs/packages_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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 <command> [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 <command> --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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
1
src/cmd/cache_apt_pkgs/create_key.go
Normal file
1
src/cmd/cache_apt_pkgs/create_key.go
Normal file
|
|
@ -0,0 +1 @@
|
|||
package cacheaptpkgs
|
||||
1
src/cmd/cache_apt_pkgs/install.go
Normal file
1
src/cmd/cache_apt_pkgs/install.go
Normal file
|
|
@ -0,0 +1 @@
|
|||
package cacheaptpkgs
|
||||
1
src/cmd/cache_apt_pkgs/install_packages.go
Normal file
1
src/cmd/cache_apt_pkgs/install_packages.go
Normal file
|
|
@ -0,0 +1 @@
|
|||
package cacheaptpkgs
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
1
src/cmd/cache_apt_pkgs/validate.go
Normal file
1
src/cmd/cache_apt_pkgs/validate.go
Normal file
|
|
@ -0,0 +1 @@
|
|||
package cacheaptpkgs
|
||||
196
src/internal/cache/io_test.go
vendored
196
src/internal/cache/io_test.go
vendored
|
|
@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
29
src/internal/cache/key.go
vendored
29
src/internal/cache/key.go
vendored
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
149
src/internal/cache/key_test.go
vendored
149
src/internal/cache/key_test.go
vendored
|
|
@ -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")
|
||||
}
|
||||
65
src/internal/cache/manifest.go
vendored
65
src/internal/cache/manifest.go
vendored
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
65
src/internal/cache/manifest_test.go
vendored
65
src/internal/cache/manifest_test.go
vendored
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
1
src/internal/cio/serialization.go
Normal file
1
src/internal/cio/serialization.go
Normal file
|
|
@ -0,0 +1 @@
|
|||
package cio
|
||||
1
src/internal/cio/tar.go
Normal file
1
src/internal/cio/tar.go
Normal file
|
|
@ -0,0 +1 @@
|
|||
package cio
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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...)
|
||||
}
|
||||
|
|
|
|||
1
src/internal/pkgs/apt.go
Normal file
1
src/internal/pkgs/apt.go
Normal file
|
|
@ -0,0 +1 @@
|
|||
package pkgs
|
||||
1
src/internal/pkgs/package.go
Normal file
1
src/internal/pkgs/package.go
Normal file
|
|
@ -0,0 +1 @@
|
|||
package pkgs
|
||||
1
src/internal/pkgs/packages.go
Normal file
1
src/internal/pkgs/packages.go
Normal file
|
|
@ -0,0 +1 @@
|
|||
package pkgs
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
|
||||
Loading…
Reference in a new issue