Another new draft. Things broken but more streamlined.

This commit is contained in:
awalsh128 2025-08-24 01:40:25 -07:00
parent 1840a3c552
commit aeeea6da9b
54 changed files with 1772 additions and 1783 deletions

View file

@ -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

View file

@ -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

View file

@ -1,12 +0,0 @@
{
"linters": {
"enable": [
"gofmt",
"govet",
"staticcheck",
"errcheck",
"ineffassign",
"gocritic"
]
}
}

View file

@ -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

View file

@ -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

View file

@ -1,5 +0,0 @@
version: "0.2"
# Suggestions can sometimes take longer on CI machines,
# leading to inconsistent results.
suggestionsTimeout: 5000 # ms
enabled: false

View file

@ -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

View file

@ -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
}

View file

@ -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.

View file

@ -1,10 +1,9 @@
# cache-apt-pkgs-action
[![CI](https://github.com/awalsh128/cache-apt-pkgs-action/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/awalsh128/cache-apt-pkgs-action/actions/workflows/ci.yml)
[![CI (dev-v2.0)](https://github.com/awalsh128/cache-apt-pkgs-action/actions/workflows/ci.yml/badge.svg?branch=dev-v2.0)](https://github.com/awalsh128/cache-apt-pkgs-action/actions/workflows/ci.yml?query=branch%3Adev-v2.0)
[![CI](https://github.com/awalsh128/cache-apt-pkgs-action/actions/workflows/ci.yml/badge.svg?branch=dev-v2.0)](https://github.com/awalsh128/cache-apt-pkgs-action/actions/workflows/ci.yml?query=branch%3Adev-v2.0)
[![Go Report Card](https://goreportcard.com/badge/github.com/awalsh128/cache-apt-pkgs-action)](https://goreportcard.com/report/github.com/awalsh128/cache-apt-pkgs-action)
[![Go Reference](https://pkg.go.dev/badge/github.com/awalsh128/cache-apt-pkgs-action.svg)](https://pkg.go.dev/github.com/awalsh128/cache-apt-pkgs-action)
[![License](https://img.shields.io/github/license/awalsh128/cache-apt-pkgs-action)](https://github.com/awalsh128/cache-apt-pkgs-action/blob/master/LICENSE)
[![License](https://img.shields.io/github/license/awalsh128/cache-apt-pkgs-action)](https://github.com/awalsh128/cache-apt-pkgs-action/blob/dev-v2.0/LICENSE)
[![Release](https://img.shields.io/github/v/release/awalsh128/cache-apt-pkgs-action)](https://github.com/awalsh128/cache-apt-pkgs-action/releases)
This action allows caching of Advanced Package Tool (APT) package dependencies to improve workflow execution time instead of installing the packages on every run.
@ -30,7 +29,6 @@ There are three kinds of version labels you can use.
- `@v#` - Major only will give you the latest release for that major version only (e.g. `v1`).
- Branch
- `@master` - Most recent manual and automated tested code. Possibly unstable since it is pre-release.
- `@staging` - Most recent automated tested code and can sometimes contain experimental features. Is pulled from dev stable code.
- `@dev` - Very unstable and contains experimental features. Automated testing may not show breaks since CI is also updated based on code in dev.
### Inputs

View file

@ -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 }}"

View 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
}

View 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
}

View 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
}

View 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)
}
}

View 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
}

View 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
View 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
View 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
View 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
View 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")
}
})
}
}

View 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
}

View 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)
}
})
}
}

View file

@ -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 {

View 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...)
}

View 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
View 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
View 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
View 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
View 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
}

View 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)
}
})
}
}

View file

@ -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

View file

@ -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

View file

@ -0,0 +1 @@
package cacheaptpkgs

View file

@ -0,0 +1 @@
package cacheaptpkgs

View file

@ -0,0 +1 @@
package cacheaptpkgs

View file

@ -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)
}
}

View file

@ -0,0 +1 @@
package cacheaptpkgs

View file

@ -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")
}
})
}
}

View file

@ -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)
}

View file

@ -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")
}

View file

@ -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
}

View file

@ -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)
})
}
}

View file

@ -0,0 +1 @@
package cio

1
src/internal/cio/tar.go Normal file
View file

@ -0,0 +1 @@
package cio

View file

@ -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
}

View file

@ -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)
})
}
}

View file

@ -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
View file

@ -0,0 +1 @@
package pkgs

View file

@ -0,0 +1 @@
package pkgs

View file

@ -0,0 +1 @@
package pkgs

View file

@ -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
}

View file

@ -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)
}
})
}
}

View file

@ -1 +0,0 @@