diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml new file mode 100644 index 0000000..b44cf5f --- /dev/null +++ b/.github/workflows/pull_request.yml @@ -0,0 +1,29 @@ +name: Pull Request +on: + pull_request: + types: [opened, synchronize] + +permissions: + contents: read + +jobs: + integrate: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v4 + with: + go-version-file: "go.mod" + + - name: Build and test + run: | + go build -v ./... + go test -v ./... + + - name: Lint + uses: golangci/golangci-lint-action@v3 + with: + version: v1.52.2 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cf14f3f --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +src/cmd/apt_query/apt_query* \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..cfa9580 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,12 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Launch Package", + "type": "go", + "request": "launch", + "mode": "auto", + "program": "${fileDirname}", + } + ] +} \ No newline at end of file diff --git a/README.md b/README.md index d51d75d..be10067 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,9 @@ This action allows caching of Advanced Package Tool (APT) package dependencies to improve workflow execution time instead of installing the packages on every run. +> [!IMPORTANT] +> Looking for co-maintainers to help review changes, and investigate issues. I haven't had as much time to stay on top of this action as I would like to and want to make sure it is still responsive and reliable for the community. If you are interested, please reach out. + ## Documentation This action is a composition of [actions/cache](https://github.com/actions/cache/) and the `apt` utility. Some actions require additional APT based packages to be installed in order for other steps to be executed. Packages can be installed when ran but can consume much of the execution workflow time. @@ -20,24 +23,24 @@ Create a workflow `.yml` file in your repositories `.github/workflows` directory There are three kinds of version labels you can use. -* `@latest` - This will give you the latest release. -* `@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. +- `@latest` - This will give you the latest release. +- `@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 -* `packages` - Space delimited list of packages to install. -* `version` - Version of cache to load. Each version will have its own cache. Note, all characters except spaces are allowed. -* `execute_install_scripts` - Execute Debian package pre and post install script upon restore. See [Caveats / Non-file Dependencies](#non-file-dependencies) for more information. +- `packages` - Space delimited list of packages to install. +- `version` - Version of cache to load. Each version will have its own cache. Note, all characters except spaces are allowed. +- `execute_install_scripts` - Execute Debian package pre and post install script upon restore. See [Caveats / Non-file Dependencies](#non-file-dependencies) for more information. ### Outputs -* `cache-hit` - A boolean value to indicate a cache was found for the packages requested. -* `package-version-list` - The main requested packages and versions that are installed. Represented as a comma delimited list with equals delimit on the package version (i.e. \=,\=\,...). -* `all-package-version-list` - All the pulled in packages and versions, including dependencies, that are installed. Represented as a comma delimited list with equals delimit on the package version (i.e. \=,\=\,...). +- `cache-hit` - A boolean value to indicate a cache was found for the packages requested. +- `package-version-list` - The main requested packages and versions that are installed. Represented as a comma delimited list with equals delimit on the package version (i.e. \=,\=\,...). +- `all-package-version-list` - All the pulled in packages and versions, including dependencies, that are installed. Represented as a comma delimited list with equals delimit on the package version (i.e. \=,\=\,...). ### Cache scopes @@ -51,7 +54,6 @@ This was a motivating use case for creating this action. name: Create Documentation on: push jobs: - build_and_deploy_docs: runs-on: ubuntu-latest name: Build Doxygen documentation and deploy @@ -62,7 +64,7 @@ jobs: packages: dia doxygen doxygen-doc doxygen-gui doxygen-latex graphviz mscgen version: 1.0 - - name: Build + - name: Build run: | cmake -B ${{github.workspace}}/build -DCMAKE_BUILD_TYPE=${{env.BUILD_TYPE}} cmake --build ${{github.workspace}}/build --config ${{env.BUILD_TYPE}} @@ -75,15 +77,16 @@ jobs: ``` ```yaml -... - install_doxygen_deps: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - uses: awalsh128/cache-apt-pkgs-action@latest - with: - packages: dia doxygen doxygen-doc doxygen-gui doxygen-latex graphviz mscgen - version: 1.0 + +--- +install_doxygen_deps: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: awalsh128/cache-apt-pkgs-action@latest + with: + packages: dia doxygen doxygen-doc doxygen-gui doxygen-latex graphviz mscgen + version: 1.0 ``` ## Caveats @@ -92,8 +95,8 @@ jobs: This action is based on the principle that most packages can be cached as a fileset. There are situations though where this is not enough. -* Pre and post installation scripts needs to be ran from `/var/lib/dpkg/info/{package name}.[preinst, postinst]`. -* The Debian package database needs to be queried for scripts above (i.e. `dpkg-query`). +- Pre and post installation scripts needs to be ran from `/var/lib/dpkg/info/{package name}.[preinst, postinst]`. +- The Debian package database needs to be queried for scripts above (i.e. `dpkg-query`). The `execute_install_scripts` argument can be used to attempt to execute the install scripts but they are no guaranteed to resolve the issue. @@ -118,4 +121,4 @@ For more context and information see [issue #57](https://github.com/awalsh128/ca ### Cache Limits -A repository can have up to 5GB of caches. Once the 5GB limit is reached, older caches will be evicted based on when the cache was last accessed. Caches that are not accessed within the last week will also be evicted. +A repository can have up to 5GB of caches. Once the 5GB limit is reached, older caches will be evicted based on when the cache was last accessed. Caches that are not accessed within the last week will also be evicted. To get more information on how to access and manage your actions's caches, see [GitHub Actions / Using workflows / Cache dependencies](https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#viewing-cache-entries). diff --git a/action.yml b/action.yml index 035ace6..3e0dd2f 100644 --- a/action.yml +++ b/action.yml @@ -83,6 +83,13 @@ runs: DEBUG: "${{ inputs.debug }}" PACKAGES: "${{ inputs.packages }}" + - id: upload-logs + if: ${{ inputs.debug == 'true' }} + uses: actions/upload-artifact@v3 + with: + name: cache-apt-pkgs-logs_${{ env.CACHE_KEY }} + path: ~/cache-apt-pkgs/*.log + - id: save-cache if: ${{ ! steps.load-cache.outputs.cache-hit }} uses: actions/cache/save@v3 @@ -94,10 +101,3 @@ runs: run: | rm -rf ~/cache-apt-pkgs shell: bash - - - id: upload-logs - if: ${{ inputs.debug == 'true' }} - uses: actions/upload-artifact@v3 - with: - name: cache-apt-pkgs-logs_${{ env.CACHE_KEY }} - path: ~/cache-apt-pkgs/*.log diff --git a/apt_query b/apt_query new file mode 100755 index 0000000..db7287f Binary files /dev/null and b/apt_query differ diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..5cc8b8b --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module awalsh128.com/cache-apt-pkgs-action + +go 1.20 diff --git a/install_and_cache_pkgs.sh b/install_and_cache_pkgs.sh index bf12b74..a35971f 100755 --- a/install_and_cache_pkgs.sh +++ b/install_and_cache_pkgs.sh @@ -18,28 +18,6 @@ cache_dir="${1}" # List of the packages to use. input_packages="${@:3}" -# Trim commas, excess spaces, sort, and version syntax. -# -# NOTE: Unless specified, all APT package listings of name and version use -# colon delimited and not equals delimited syntax (i.e. [:=]). -packages="$(get_normalized_package_list "${input_packages}")" - -package_count=$(wc -w <<< "${packages}") -log "Clean installing and caching ${package_count} package(s)." - -log_empty_line - -manifest_main="" -log "Package list:" -for package in ${packages}; do - read package_name package_ver < <(get_package_name_ver "${package}") - manifest_main="${manifest_main}${package_name}=${package_ver}," - log "- ${package_name} (${package_ver})" -done -write_manifest "main" "${manifest_main}" "${cache_dir}/manifest_main.log" - -log_empty_line - if ! apt-fast --version > /dev/null 2>&1; then log "Installing apt-fast for optimized installs..." # Install apt-fast for optimized installs. @@ -59,6 +37,22 @@ fi log_empty_line +packages="$(get_normalized_package_list "${input_packages}")" +package_count=$(wc -w <<< "${packages}") +log "Clean installing and caching ${package_count} package(s)." + +log_empty_line + +manifest_main="" +log "Package list:" +for package in ${packages}; do + manifest_main="${manifest_main}${package}," + log "- ${package}" +done +write_manifest "main" "${manifest_main}" "${cache_dir}/manifest_main.log" + +log_empty_line + # Strictly contains the requested packages. manifest_main="" # Contains all packages including dependencies. @@ -98,7 +92,8 @@ for installed_package in ${installed_packages}; do & get_install_script_filepath "" "${package_name}" "preinst" \ & get_install_script_filepath "" "${package_name}" "postinst"; } | while IFS= read -r f; do test -f "${f}" -o -L "${f}" && get_tar_relpath "${f}"; done | - xargs -I {} echo \'{}\' | # Single quotes ensure literals like backslash get captured. + # Single quotes ensure literals like backslash get captured. Use \0 to avoid field separation. + awk -F"\0" '{print "\x27"$1"\x27"}' | sudo xargs tar -cf "${cache_filepath}" -C / log " done (compressed size $(du -h "${cache_filepath}" | cut -f1))." diff --git a/lib.sh b/lib.sh index 2144320..6531cc2 100755 --- a/lib.sh +++ b/lib.sh @@ -71,7 +71,7 @@ function get_installed_packages { ############################################################################### # Splits a fully action syntax APT package into the name and version. # Arguments: -# The action syntax colon delimited package pair or just the package name. +# The action syntax equals delimited package pair or just the package name. # Returns: # The package name and version pair. ############################################################################### @@ -81,7 +81,9 @@ function get_package_name_ver { IFS="${ORIG_IFS}" # If version not found in the fully qualified package value. if test -z "${ver}"; then - ver="$(grep "Version:" <<< "$(apt-cache show ${name})" | awk '{print $2}')" + # This is a fallback and should not be used any more as its slow. + log_err "Unexpected version resolution for package '${name}'" + ver="$(apt-cache show ${name} | grep '^Version:' | awk '{print $2}')" fi echo "${name}" "${ver}" } @@ -91,16 +93,17 @@ function get_package_name_ver { # Arguments: # The comma and/or space delimited list of packages. # Returns: -# Sorted list of space delimited packages. +# Sorted list of space delimited package name=version pairs. ############################################################################### function get_normalized_package_list { - # Remove commas, and block scalar folded backslashes. - local stripped=$(echo "${1}" | sed 's/[,\]/ /g') - # Remove extraneous spaces at the middle, beginning, and end. - local trimmed="$(\ - echo "${stripped}" \ - | sed 's/\s\+/ /g; s/^\s\+//g; s/\s\+$//g')" - echo ${trimmed} | tr ' ' '\n' | sort | tr '\n' ' ' + # Remove commas, and block scalar folded backslashes, + # extraneous spaces at the middle, beginning and end + # then sort. + local packages=$(echo "${1}" \ + | sed 's/[,\]/ /g; s/\s\+/ /g; s/^\s\+//g; s/\s\+$//g' \ + | sort -t' ') + local script_dir="$(dirname -- "$(realpath -- "${0}")")" + ${script_dir}/apt_query normalized-list ${packages} } ############################################################################### @@ -120,8 +123,8 @@ function get_tar_relpath { fi } -function log { echo "$(date +%H:%M:%S)" "${@}"; } -function log_err { >&2 echo "$(date +%H:%M:%S)" "${@}"; } +function log { echo "$(date +%T.%3N)" "${@}"; } +function log_err { >&2 echo "$(date +%T.%3N)" "${@}"; } function log_empty_line { echo ""; } diff --git a/pre_cache_action.sh b/pre_cache_action.sh index b487743..703906c 100755 --- a/pre_cache_action.sh +++ b/pre_cache_action.sh @@ -28,7 +28,9 @@ debug="${4}" input_packages="${@:5}" # Trim commas, excess spaces, and sort. +log "Normalizing package list..." packages="$(get_normalized_package_list "${input_packages}")" +log "done" # Create cache directory so artifacts can be saved. mkdir -p ${cache_dir} @@ -53,35 +55,16 @@ log "done" log_empty_line -versioned_packages="" -log "Verifying packages..." -for package in ${packages}; do - if test ! "$(apt-cache show ${package})"; then - echo "aborted" - log "Package '${package}' not found." >&2 - exit 5 - fi - read package_name package_ver < <(get_package_name_ver "${package}") - versioned_packages=""${versioned_packages}" "${package_name}"="${package_ver}"" -done -log "done" - -log_empty_line - # Abort on any failure at this point. set -e log "Creating cache key..." -# TODO Can we prove this will happen again? -normalized_versioned_packages="$(get_normalized_package_list "${versioned_packages}")" -log "- Normalized package list is '${normalized_versioned_packages}'." - # Forces an update in cases where an accidental breaking change was introduced # and a global cache reset is required. force_update_inc="1" -value="${normalized_versioned_packages} @ ${version} ${force_update_inc}" +value="${packages} @ ${version} ${force_update_inc}" log "- Value to hash is '${value}'." key="$(echo "${value}" | md5sum | cut -f1 -d' ')" diff --git a/src/cmd/apt_query/main.go b/src/cmd/apt_query/main.go new file mode 100644 index 0000000..8992a45 --- /dev/null +++ b/src/cmd/apt_query/main.go @@ -0,0 +1,109 @@ +package main + +import ( + "flag" + "fmt" + "os" + "sort" + "strings" + + "awalsh128.com/cache-apt-pkgs-action/src/internal/common" + "awalsh128.com/cache-apt-pkgs-action/src/internal/exec" + "awalsh128.com/cache-apt-pkgs-action/src/internal/logging" +) + +type AptPackage struct { + Name string + Version string +} + +type AptPackages []AptPackage + +func (ps AptPackages) serialize() string { + tokens := []string{} + for _, p := range ps { + tokens = append(tokens, p.Name+"="+p.Version) + } + return strings.Join(tokens, " ") +} + +// Gets the APT based packages as a sorted by name list (normalized). +func getPackages(executor exec.Executor, names []string) AptPackages { + prefixArgs := []string{"--quiet=0", "--no-all-versions", "show"} + execution := executor.Exec("apt-cache", append(prefixArgs, names...)...) + + err := execution.Error() + if err != nil { + logging.Fatal(err) + } + + pkgs := []AptPackage{} + errorMessages := []string{} + + for _, paragraph := range strings.Split(execution.Stdout, "\n\n") { + pkg := AptPackage{} + for _, line := range strings.Split(paragraph, "\n") { + if strings.HasPrefix(line, "Package: ") { + pkg.Name = strings.TrimSpace(strings.SplitN(line, ":", 2)[1]) + } else if strings.HasPrefix(line, "Version: ") { + pkg.Version = strings.TrimSpace(strings.SplitN(line, ":", 2)[1]) + } else if strings.HasPrefix(line, "N: Unable to locate package ") || strings.HasPrefix(line, "E: ") { + if !common.ContainsString(errorMessages, line) { + errorMessages = append(errorMessages, line) + } + } + } + if pkg.Name != "" { + pkgs = append(pkgs, pkg) + } + } + + if len(errorMessages) > 0 { + logging.Fatalf("Errors encountered in apt-cache output (see below):\n%s", strings.Join(errorMessages, "\n")) + } + + sort.Slice(pkgs, func(i, j int) bool { + return pkgs[i].Name < pkgs[j].Name + }) + + return pkgs +} + +func getExecutor(replayFilename string) exec.Executor { + if len(replayFilename) == 0 { + return &exec.BinExecutor{} + } + return exec.NewReplayExecutor(replayFilename) +} + +func main() { + debug := flag.Bool("debug", false, "Log diagnostic information to a file alongside the binary.") + + replayFilename := flag.String("replayfile", "", + "Replay command output from a specified file rather than executing a binary."+ + "The file should be in the same format as the log generated by the debug flag.") + + flag.Parse() + unparsedFlags := flag.Args() + + logging.Init(os.Args[0]+".log", *debug) + + executor := getExecutor(*replayFilename) + + if len(unparsedFlags) < 2 { + logging.Fatalf("Expected at least 2 non-flag arguments but found %d.", len(unparsedFlags)) + return + } + command := unparsedFlags[0] + pkgNames := unparsedFlags[1:] + + switch command { + + case "normalized-list": + pkgs := getPackages(executor, pkgNames) + fmt.Println(pkgs.serialize()) + + default: + logging.Fatalf("Command '%s' not recognized.", command) + } +} diff --git a/src/cmd/apt_query/main_test.go b/src/cmd/apt_query/main_test.go new file mode 100644 index 0000000..744e6fd --- /dev/null +++ b/src/cmd/apt_query/main_test.go @@ -0,0 +1,65 @@ +package main + +import ( + "flag" + "testing" + + "awalsh128.com/cache-apt-pkgs-action/src/internal/cmdtesting" +) + +var createReplayLogs bool = false + +func init() { + flag.BoolVar(&createReplayLogs, "createreplaylogs", false, "Execute the test commands, save the command output for future replay and skip the tests themselves.") +} + +func TestMain(m *testing.M) { + cmdtesting.TestMain(m) +} + +func TestNormalizedList_MultiplePackagesExists_StdoutsAlphaSortedPackageNameVersionPairs(t *testing.T) { + result := cmdtesting.New(t, createReplayLogs).Run("normalized-list", "xdot", "rolldice") + result.ExpectSuccessfulOut("rolldice=1.16-1build1 xdot=1.2-3") +} + +func TestNormalizedList_SamePackagesDifferentOrder_StdoutsMatch(t *testing.T) { + expected := "rolldice=1.16-1build1 xdot=1.2-3" + + ct := cmdtesting.New(t, createReplayLogs) + + result := ct.Run("normalized-list", "rolldice", "xdot") + result.ExpectSuccessfulOut(expected) + + result = ct.Run("normalized-list", "xdot", "rolldice") + result.ExpectSuccessfulOut(expected) +} + +func TestNormalizedList_MultiVersionWarning_StdoutSingleVersion(t *testing.T) { + var result = cmdtesting.New(t, createReplayLogs).Run("normalized-list", "libosmesa6-dev", "libgl1-mesa-dev") + result.ExpectSuccessfulOut("libgl1-mesa-dev=23.0.4-0ubuntu1~23.04.1 libosmesa6-dev=23.0.4-0ubuntu1~23.04.1") +} + +func TestNormalizedList_SinglePackageExists_StdoutsSinglePackageNameVersionPair(t *testing.T) { + var result = cmdtesting.New(t, createReplayLogs).Run("normalized-list", "xdot") + result.ExpectSuccessfulOut("xdot=1.2-3") +} + +func TestNormalizedList_VersionContainsColon_StdoutsEntireVersion(t *testing.T) { + var result = cmdtesting.New(t, createReplayLogs).Run("normalized-list", "default-jre") + result.ExpectSuccessfulOut("default-jre=2:1.17-74") +} + +func TestNormalizedList_NonExistentPackageName_StderrsAptCacheErrors(t *testing.T) { + var result = cmdtesting.New(t, createReplayLogs).Run("normalized-list", "nonexistentpackagename") + result.ExpectError( + `Error encountered running apt-cache --quiet=0 --no-all-versions show nonexistentpackagename +Exited with status code 100; see combined output below: +N: Unable to locate package nonexistentpackagename +N: Unable to locate package nonexistentpackagename +E: No packages found`) +} + +func TestNormalizedList_NoPackagesGiven_StderrsArgMismatch(t *testing.T) { + var result = cmdtesting.New(t, createReplayLogs).Run("normalized-list") + result.ExpectError("Expected at least 2 non-flag arguments but found 1.") +} diff --git a/src/cmd/apt_query/testlogs/testnormalizedlist_multiplepackagesexists_stdoutsalphasortedpackagenameversionpairs.log b/src/cmd/apt_query/testlogs/testnormalizedlist_multiplepackagesexists_stdoutsalphasortedpackagenameversionpairs.log new file mode 100644 index 0000000..ce1d6c0 --- /dev/null +++ b/src/cmd/apt_query/testlogs/testnormalizedlist_multiplepackagesexists_stdoutsalphasortedpackagenameversionpairs.log @@ -0,0 +1,9 @@ +2023/12/10 14:09:15 Debug log created at /home/awalsh128/code/cache-apt-pkgs-action/src/cmd/apt_query/apt_query.log +2023/12/10 14:09:15 EXECUTION-OBJ-START +{ + "Cmd": "apt-cache --quiet=0 --no-all-versions show xdot rolldice", + "Stdout": "Package: xdot\nArchitecture: all\nVersion: 1.2-3\nPriority: optional\nSection: universe/python\nOrigin: Ubuntu\nMaintainer: Ubuntu Developers \u003cubuntu-devel-discuss@lists.ubuntu.com\u003e\nOriginal-Maintainer: Debian Python Team \u003cteam+python@tracker.debian.org\u003e\nBugs: https://bugs.launchpad.net/ubuntu/+filebug\nInstalled-Size: 160\nDepends: gir1.2-gtk-3.0, graphviz, python3-gi, python3-gi-cairo, python3-numpy, python3:any\nFilename: pool/universe/x/xdot/xdot_1.2-3_all.deb\nSize: 28504\nMD5sum: 9fd56a82e8e6dc2b18fad996dd35bcdb\nSHA1: 9dfa42283c7326da18b132e61369fc8d70534f4b\nSHA256: 0dd59c1840b7f2322cf408f9634c83c4f9766a5e609813147cd67e44755bc011\nSHA512: 33d418475bb9977341007528d8622d236b59a5da4f7fb7f6a36aa05256c4789af30868fc2aad7fc17fb726c8814333cdc579a4ae01597f8f5864b4fbfd8dff14\nHomepage: https://github.com/jrfonseca/xdot.py\nDescription-en: interactive viewer for Graphviz dot files\n xdot is an interactive viewer for graphs written in Graphviz's dot language.\n It uses internally the graphviz's xdot output format as an intermediate\n format, and PyGTK and Cairo for rendering. xdot can be used either as a\n standalone application from command line, or as a library embedded in your\n Python 3 application.\n .\n Features:\n * Since it doesn't use bitmaps it is fast and has a small memory footprint.\n * Arbitrary zoom.\n * Keyboard/mouse navigation.\n * Supports events on the nodes with URLs.\n * Animated jumping between nodes.\n * Highlights node/edge under mouse.\nDescription-md5: eb58f25a628b48a744f1b904af3b9282\n\nPackage: rolldice\nArchitecture: amd64\nVersion: 1.16-1build1\nPriority: optional\nSection: universe/games\nOrigin: Ubuntu\nMaintainer: Ubuntu Developers \u003cubuntu-devel-discuss@lists.ubuntu.com\u003e\nOriginal-Maintainer: Thomas Ross \u003cthomasross@thomasross.io\u003e\nBugs: https://bugs.launchpad.net/ubuntu/+filebug\nInstalled-Size: 31\nDepends: libc6 (\u003e= 2.7), libreadline8 (\u003e= 6.0)\nFilename: pool/universe/r/rolldice/rolldice_1.16-1build1_amd64.deb\nSize: 9628\nMD5sum: af6390bf2d5d5b4710d308ac06d4913a\nSHA1: 1d87ccac5b20f4e2a217a0e058408f46cfe5caff\nSHA256: 2e076006200057da0be52060e3cc2f4fc7c51212867173e727590bd7603a0337\nSHA512: a2fa75cfc6f9fc0f1fce3601668bc060f9e10bcf94887025af755ef73db84c55383ed34e4199de53dfbb34377b050c1f9947f29b28d8e527509900f2ec872826\nHomepage: https://github.com/sstrickl/rolldice\nDescription-en: virtual dice roller\n rolldice is a virtual dice roller that takes a string on the command\n line in the format of some fantasy role playing games like Advanced\n Dungeons \u0026 Dragons [1] and returns the result of the dice rolls.\n .\n [1] Advanced Dungeons \u0026 Dragons is a registered trademark of TSR, Inc.\nDescription-md5: fc24e9e12c794a8f92ab0ca6e1058501\n\n", + "Stderr": "", + "ExitCode": 0 +} +EXECUTION-OBJ-END diff --git a/src/cmd/apt_query/testlogs/testnormalizedlist_multiversionwarning_stdoutsingleversion.log b/src/cmd/apt_query/testlogs/testnormalizedlist_multiversionwarning_stdoutsingleversion.log new file mode 100644 index 0000000..32b344d --- /dev/null +++ b/src/cmd/apt_query/testlogs/testnormalizedlist_multiversionwarning_stdoutsingleversion.log @@ -0,0 +1,9 @@ +2023/12/10 14:09:17 Debug log created at /home/awalsh128/code/cache-apt-pkgs-action/src/cmd/apt_query/apt_query.log +2023/12/10 14:09:18 EXECUTION-OBJ-START +{ + "Cmd": "apt-cache --quiet=0 --no-all-versions show libosmesa6-dev libgl1-mesa-dev", + "Stdout": "Package: libosmesa6-dev\nArchitecture: amd64\nVersion: 23.0.4-0ubuntu1~23.04.1\nMulti-Arch: same\nPriority: extra\nSection: devel\nSource: mesa\nOrigin: Ubuntu\nMaintainer: Ubuntu Developers \u003cubuntu-devel-discuss@lists.ubuntu.com\u003e\nOriginal-Maintainer: Debian X Strike Force \u003cdebian-x@lists.debian.org\u003e\nBugs: https://bugs.launchpad.net/ubuntu/+filebug\nInstalled-Size: 51\nProvides: libosmesa-dev\nDepends: libosmesa6 (= 23.0.4-0ubuntu1~23.04.1), mesa-common-dev (= 23.0.4-0ubuntu1~23.04.1) | libgl-dev\nConflicts: libosmesa-dev\nReplaces: libosmesa-dev\nFilename: pool/main/m/mesa/libosmesa6-dev_23.0.4-0ubuntu1~23.04.1_amd64.deb\nSize: 9016\nMD5sum: f2a03da1adaa37afc32493bc52913fe1\nSHA1: 3b0f26ba8438fe6f590cc38d412e0bc1b27f9831\nSHA256: 3fcd3b5c80bde4af1765e588e8f1301ddccae4e052cbfca1a32120d9d8697656\nSHA512: 0a88153218f263511cacffbfe9afd928679f7fa4aacefebd21bafae0517ef1cd3b4bef4fc676e7586728ce1257fbbeb8bd0dda02ddc54fe83c90800f5ce0a660\nHomepage: https://mesa3d.org/\nDescription-en: Mesa Off-screen rendering extension -- development files\n This package provides the required environment for developing programs\n that use the off-screen rendering extension of Mesa.\n .\n For more information on OSmesa see the libosmesa6 package.\nDescription-md5: 9b1d7a0b3e6a2ea021f4443f42dcff4f\n\nPackage: libgl1-mesa-dev\nArchitecture: amd64\nVersion: 23.0.4-0ubuntu1~23.04.1\nMulti-Arch: same\nPriority: extra\nSection: libdevel\nSource: mesa\nOrigin: Ubuntu\nMaintainer: Ubuntu Developers \u003cubuntu-devel-discuss@lists.ubuntu.com\u003e\nOriginal-Maintainer: Debian X Strike Force \u003cdebian-x@lists.debian.org\u003e\nBugs: https://bugs.launchpad.net/ubuntu/+filebug\nInstalled-Size: 33\nDepends: libgl-dev, libglvnd-dev\nFilename: pool/main/m/mesa/libgl1-mesa-dev_23.0.4-0ubuntu1~23.04.1_amd64.deb\nSize: 13908\nMD5sum: af15873cbc37ae8719487216f2465a7f\nSHA1: 0a04c22bd32578c65da6cab759347d22cbe2601f\nSHA256: 7b4b377658aff6aaa42af0cf220a64fe7ccd20b53ed0226b6b96edae5ce65dbe\nSHA512: f5b7c9324e7f1f8764a4a035182514559a17a96ce3d43ba570ebd32746de219bea035de3600850134b4f0ba62f12caf0e281fcf7c5490d93e1bd93ca27bfdd22\nHomepage: https://mesa3d.org/\nDescription-en: transitional dummy package\n This is a transitional dummy package, it can be safely removed.\nDescription-md5: 635a93bcd1440d16621693fe064c2aa9\n\n", + "Stderr": "N: There are 2 additional records. Please use the '-a' switch to see them.\n", + "ExitCode": 0 +} +EXECUTION-OBJ-END diff --git a/src/cmd/apt_query/testlogs/testnormalizedlist_nonexistentpackagename_stderrsaptcacheerrors.log b/src/cmd/apt_query/testlogs/testnormalizedlist_nonexistentpackagename_stderrsaptcacheerrors.log new file mode 100644 index 0000000..7e9e9c2 --- /dev/null +++ b/src/cmd/apt_query/testlogs/testnormalizedlist_nonexistentpackagename_stderrsaptcacheerrors.log @@ -0,0 +1,14 @@ +2023/12/10 14:09:19 Debug log created at /home/awalsh128/code/cache-apt-pkgs-action/src/cmd/apt_query/apt_query.log +2023/12/10 14:09:20 EXECUTION-OBJ-START +{ + "Cmd": "apt-cache --quiet=0 --no-all-versions show nonexistentpackagename", + "Stdout": "", + "Stderr": "N: Unable to locate package nonexistentpackagename\nN: Unable to locate package nonexistentpackagename\nE: No packages found\n", + "ExitCode": 100 +} +EXECUTION-OBJ-END +2023/12/10 14:09:20 Error encountered running apt-cache --quiet=0 --no-all-versions show nonexistentpackagename +Exited with status code 100; see combined output below: +N: Unable to locate package nonexistentpackagename +N: Unable to locate package nonexistentpackagename +E: No packages found diff --git a/src/cmd/apt_query/testlogs/testnormalizedlist_nopackagesgiven_stderrsargmismatch.log b/src/cmd/apt_query/testlogs/testnormalizedlist_nopackagesgiven_stderrsargmismatch.log new file mode 100644 index 0000000..bd9c530 --- /dev/null +++ b/src/cmd/apt_query/testlogs/testnormalizedlist_nopackagesgiven_stderrsargmismatch.log @@ -0,0 +1,2 @@ +2023/12/10 14:09:20 Debug log created at /home/awalsh128/code/cache-apt-pkgs-action/src/cmd/apt_query/apt_query.log +2023/12/10 14:09:20 Expected at least 2 non-flag arguments but found 1. diff --git a/src/cmd/apt_query/testlogs/testnormalizedlist_samepackagesdifferentorder_stdoutsmatch.log b/src/cmd/apt_query/testlogs/testnormalizedlist_samepackagesdifferentorder_stdoutsmatch.log new file mode 100644 index 0000000..c7a9b5b --- /dev/null +++ b/src/cmd/apt_query/testlogs/testnormalizedlist_samepackagesdifferentorder_stdoutsmatch.log @@ -0,0 +1,18 @@ +2023/12/10 14:09:15 Debug log created at /home/awalsh128/code/cache-apt-pkgs-action/src/cmd/apt_query/apt_query.log +2023/12/10 14:09:16 EXECUTION-OBJ-START +{ + "Cmd": "apt-cache --quiet=0 --no-all-versions show rolldice xdot", + "Stdout": "Package: rolldice\nArchitecture: amd64\nVersion: 1.16-1build1\nPriority: optional\nSection: universe/games\nOrigin: Ubuntu\nMaintainer: Ubuntu Developers \u003cubuntu-devel-discuss@lists.ubuntu.com\u003e\nOriginal-Maintainer: Thomas Ross \u003cthomasross@thomasross.io\u003e\nBugs: https://bugs.launchpad.net/ubuntu/+filebug\nInstalled-Size: 31\nDepends: libc6 (\u003e= 2.7), libreadline8 (\u003e= 6.0)\nFilename: pool/universe/r/rolldice/rolldice_1.16-1build1_amd64.deb\nSize: 9628\nMD5sum: af6390bf2d5d5b4710d308ac06d4913a\nSHA1: 1d87ccac5b20f4e2a217a0e058408f46cfe5caff\nSHA256: 2e076006200057da0be52060e3cc2f4fc7c51212867173e727590bd7603a0337\nSHA512: a2fa75cfc6f9fc0f1fce3601668bc060f9e10bcf94887025af755ef73db84c55383ed34e4199de53dfbb34377b050c1f9947f29b28d8e527509900f2ec872826\nHomepage: https://github.com/sstrickl/rolldice\nDescription-en: virtual dice roller\n rolldice is a virtual dice roller that takes a string on the command\n line in the format of some fantasy role playing games like Advanced\n Dungeons \u0026 Dragons [1] and returns the result of the dice rolls.\n .\n [1] Advanced Dungeons \u0026 Dragons is a registered trademark of TSR, Inc.\nDescription-md5: fc24e9e12c794a8f92ab0ca6e1058501\n\nPackage: xdot\nArchitecture: all\nVersion: 1.2-3\nPriority: optional\nSection: universe/python\nOrigin: Ubuntu\nMaintainer: Ubuntu Developers \u003cubuntu-devel-discuss@lists.ubuntu.com\u003e\nOriginal-Maintainer: Debian Python Team \u003cteam+python@tracker.debian.org\u003e\nBugs: https://bugs.launchpad.net/ubuntu/+filebug\nInstalled-Size: 160\nDepends: gir1.2-gtk-3.0, graphviz, python3-gi, python3-gi-cairo, python3-numpy, python3:any\nFilename: pool/universe/x/xdot/xdot_1.2-3_all.deb\nSize: 28504\nMD5sum: 9fd56a82e8e6dc2b18fad996dd35bcdb\nSHA1: 9dfa42283c7326da18b132e61369fc8d70534f4b\nSHA256: 0dd59c1840b7f2322cf408f9634c83c4f9766a5e609813147cd67e44755bc011\nSHA512: 33d418475bb9977341007528d8622d236b59a5da4f7fb7f6a36aa05256c4789af30868fc2aad7fc17fb726c8814333cdc579a4ae01597f8f5864b4fbfd8dff14\nHomepage: https://github.com/jrfonseca/xdot.py\nDescription-en: interactive viewer for Graphviz dot files\n xdot is an interactive viewer for graphs written in Graphviz's dot language.\n It uses internally the graphviz's xdot output format as an intermediate\n format, and PyGTK and Cairo for rendering. xdot can be used either as a\n standalone application from command line, or as a library embedded in your\n Python 3 application.\n .\n Features:\n * Since it doesn't use bitmaps it is fast and has a small memory footprint.\n * Arbitrary zoom.\n * Keyboard/mouse navigation.\n * Supports events on the nodes with URLs.\n * Animated jumping between nodes.\n * Highlights node/edge under mouse.\nDescription-md5: eb58f25a628b48a744f1b904af3b9282\n\n", + "Stderr": "", + "ExitCode": 0 +} +EXECUTION-OBJ-END +2023/12/10 14:09:16 Debug log created at /home/awalsh128/code/cache-apt-pkgs-action/src/cmd/apt_query/apt_query.log +2023/12/10 14:09:17 EXECUTION-OBJ-START +{ + "Cmd": "apt-cache --quiet=0 --no-all-versions show xdot rolldice", + "Stdout": "Package: xdot\nArchitecture: all\nVersion: 1.2-3\nPriority: optional\nSection: universe/python\nOrigin: Ubuntu\nMaintainer: Ubuntu Developers \u003cubuntu-devel-discuss@lists.ubuntu.com\u003e\nOriginal-Maintainer: Debian Python Team \u003cteam+python@tracker.debian.org\u003e\nBugs: https://bugs.launchpad.net/ubuntu/+filebug\nInstalled-Size: 160\nDepends: gir1.2-gtk-3.0, graphviz, python3-gi, python3-gi-cairo, python3-numpy, python3:any\nFilename: pool/universe/x/xdot/xdot_1.2-3_all.deb\nSize: 28504\nMD5sum: 9fd56a82e8e6dc2b18fad996dd35bcdb\nSHA1: 9dfa42283c7326da18b132e61369fc8d70534f4b\nSHA256: 0dd59c1840b7f2322cf408f9634c83c4f9766a5e609813147cd67e44755bc011\nSHA512: 33d418475bb9977341007528d8622d236b59a5da4f7fb7f6a36aa05256c4789af30868fc2aad7fc17fb726c8814333cdc579a4ae01597f8f5864b4fbfd8dff14\nHomepage: https://github.com/jrfonseca/xdot.py\nDescription-en: interactive viewer for Graphviz dot files\n xdot is an interactive viewer for graphs written in Graphviz's dot language.\n It uses internally the graphviz's xdot output format as an intermediate\n format, and PyGTK and Cairo for rendering. xdot can be used either as a\n standalone application from command line, or as a library embedded in your\n Python 3 application.\n .\n Features:\n * Since it doesn't use bitmaps it is fast and has a small memory footprint.\n * Arbitrary zoom.\n * Keyboard/mouse navigation.\n * Supports events on the nodes with URLs.\n * Animated jumping between nodes.\n * Highlights node/edge under mouse.\nDescription-md5: eb58f25a628b48a744f1b904af3b9282\n\nPackage: rolldice\nArchitecture: amd64\nVersion: 1.16-1build1\nPriority: optional\nSection: universe/games\nOrigin: Ubuntu\nMaintainer: Ubuntu Developers \u003cubuntu-devel-discuss@lists.ubuntu.com\u003e\nOriginal-Maintainer: Thomas Ross \u003cthomasross@thomasross.io\u003e\nBugs: https://bugs.launchpad.net/ubuntu/+filebug\nInstalled-Size: 31\nDepends: libc6 (\u003e= 2.7), libreadline8 (\u003e= 6.0)\nFilename: pool/universe/r/rolldice/rolldice_1.16-1build1_amd64.deb\nSize: 9628\nMD5sum: af6390bf2d5d5b4710d308ac06d4913a\nSHA1: 1d87ccac5b20f4e2a217a0e058408f46cfe5caff\nSHA256: 2e076006200057da0be52060e3cc2f4fc7c51212867173e727590bd7603a0337\nSHA512: a2fa75cfc6f9fc0f1fce3601668bc060f9e10bcf94887025af755ef73db84c55383ed34e4199de53dfbb34377b050c1f9947f29b28d8e527509900f2ec872826\nHomepage: https://github.com/sstrickl/rolldice\nDescription-en: virtual dice roller\n rolldice is a virtual dice roller that takes a string on the command\n line in the format of some fantasy role playing games like Advanced\n Dungeons \u0026 Dragons [1] and returns the result of the dice rolls.\n .\n [1] Advanced Dungeons \u0026 Dragons is a registered trademark of TSR, Inc.\nDescription-md5: fc24e9e12c794a8f92ab0ca6e1058501\n\n", + "Stderr": "", + "ExitCode": 0 +} +EXECUTION-OBJ-END diff --git a/src/cmd/apt_query/testlogs/testnormalizedlist_singlepackageexists_stdoutssinglepackagenameversionpair.log b/src/cmd/apt_query/testlogs/testnormalizedlist_singlepackageexists_stdoutssinglepackagenameversionpair.log new file mode 100644 index 0000000..b244b36 --- /dev/null +++ b/src/cmd/apt_query/testlogs/testnormalizedlist_singlepackageexists_stdoutssinglepackagenameversionpair.log @@ -0,0 +1,9 @@ +2023/12/10 14:09:18 Debug log created at /home/awalsh128/code/cache-apt-pkgs-action/src/cmd/apt_query/apt_query.log +2023/12/10 14:09:18 EXECUTION-OBJ-START +{ + "Cmd": "apt-cache --quiet=0 --no-all-versions show xdot", + "Stdout": "Package: xdot\nArchitecture: all\nVersion: 1.2-3\nPriority: optional\nSection: universe/python\nOrigin: Ubuntu\nMaintainer: Ubuntu Developers \u003cubuntu-devel-discuss@lists.ubuntu.com\u003e\nOriginal-Maintainer: Debian Python Team \u003cteam+python@tracker.debian.org\u003e\nBugs: https://bugs.launchpad.net/ubuntu/+filebug\nInstalled-Size: 160\nDepends: gir1.2-gtk-3.0, graphviz, python3-gi, python3-gi-cairo, python3-numpy, python3:any\nFilename: pool/universe/x/xdot/xdot_1.2-3_all.deb\nSize: 28504\nMD5sum: 9fd56a82e8e6dc2b18fad996dd35bcdb\nSHA1: 9dfa42283c7326da18b132e61369fc8d70534f4b\nSHA256: 0dd59c1840b7f2322cf408f9634c83c4f9766a5e609813147cd67e44755bc011\nSHA512: 33d418475bb9977341007528d8622d236b59a5da4f7fb7f6a36aa05256c4789af30868fc2aad7fc17fb726c8814333cdc579a4ae01597f8f5864b4fbfd8dff14\nHomepage: https://github.com/jrfonseca/xdot.py\nDescription-en: interactive viewer for Graphviz dot files\n xdot is an interactive viewer for graphs written in Graphviz's dot language.\n It uses internally the graphviz's xdot output format as an intermediate\n format, and PyGTK and Cairo for rendering. xdot can be used either as a\n standalone application from command line, or as a library embedded in your\n Python 3 application.\n .\n Features:\n * Since it doesn't use bitmaps it is fast and has a small memory footprint.\n * Arbitrary zoom.\n * Keyboard/mouse navigation.\n * Supports events on the nodes with URLs.\n * Animated jumping between nodes.\n * Highlights node/edge under mouse.\nDescription-md5: eb58f25a628b48a744f1b904af3b9282\n\n", + "Stderr": "", + "ExitCode": 0 +} +EXECUTION-OBJ-END diff --git a/src/cmd/apt_query/testlogs/testnormalizedlist_versioncontainscolon_stdoutsentireversion.log b/src/cmd/apt_query/testlogs/testnormalizedlist_versioncontainscolon_stdoutsentireversion.log new file mode 100644 index 0000000..441de8f --- /dev/null +++ b/src/cmd/apt_query/testlogs/testnormalizedlist_versioncontainscolon_stdoutsentireversion.log @@ -0,0 +1,9 @@ +2023/12/10 14:09:18 Debug log created at /home/awalsh128/code/cache-apt-pkgs-action/src/cmd/apt_query/apt_query.log +2023/12/10 14:09:19 EXECUTION-OBJ-START +{ + "Cmd": "apt-cache --quiet=0 --no-all-versions show default-jre", + "Stdout": "Package: default-jre\nArchitecture: amd64\nVersion: 2:1.17-74\nPriority: optional\nSection: interpreters\nSource: java-common (0.74)\nOrigin: Ubuntu\nMaintainer: Ubuntu Developers \u003cubuntu-devel-discuss@lists.ubuntu.com\u003e\nOriginal-Maintainer: Debian Java Maintainers \u003cpkg-java-maintainers@lists.alioth.debian.org\u003e\nBugs: https://bugs.launchpad.net/ubuntu/+filebug\nInstalled-Size: 6\nProvides: java-runtime (= 17), java10-runtime, java11-runtime, java12-runtime, java13-runtime, java14-runtime, java15-runtime, java16-runtime, java17-runtime, java2-runtime, java5-runtime, java6-runtime, java7-runtime, java8-runtime, java9-runtime\nDepends: default-jre-headless (= 2:1.17-74), openjdk-17-jre\nFilename: pool/main/j/java-common/default-jre_1.17-74_amd64.deb\nSize: 912\nMD5sum: e1c24f152396655f96dbaa749bd9cd2e\nSHA1: b1eeca19c6a29448ecc81df249bafdedbe49ee04\nSHA256: 02b1e27de90f05af42d61af927c35f17f7ba1ffe19bf64598ec68216075f623f\nSHA512: 81577c1e4b6920a3fb296a2cdb301131283762efa9fa371e6aa8bc31c7ee1292bbbc442984b2e2ce5d8788507f396eb3e03c1ff8c4dda671f329ac08ec0b2057\nHomepage: https://wiki.debian.org/Java/\nDescription-en: Standard Java or Java compatible Runtime\n This dependency package points to the Java runtime, or Java compatible\n runtime recommended for this architecture, which is\n openjdk-17-jre for amd64.\nDescription-md5: e747dcb24f92ffabcbdfba1db72f26e8\nTask: edubuntu-desktop-gnome\nCnf-Extra-Commands: java,jexec\n\n", + "Stderr": "", + "ExitCode": 0 +} +EXECUTION-OBJ-END diff --git a/src/internal/cmdtesting/cmdtesting.go b/src/internal/cmdtesting/cmdtesting.go new file mode 100644 index 0000000..30519fd --- /dev/null +++ b/src/internal/cmdtesting/cmdtesting.go @@ -0,0 +1,91 @@ +package cmdtesting + +import ( + "os" + "os/exec" + "strings" + "testing" + + "awalsh128.com/cache-apt-pkgs-action/src/internal/common" +) + +const binaryName = "apt_query" + +type CmdTesting struct { + *testing.T + createReplayLogs bool + replayFilename string +} + +func New(t *testing.T, createReplayLogs bool) *CmdTesting { + replayFilename := "testlogs/" + strings.ToLower(t.Name()) + ".log" + if createReplayLogs { + os.Remove(replayFilename) + os.Remove(binaryName + ".log") + } + return &CmdTesting{t, createReplayLogs, replayFilename} +} + +type RunResult struct { + Testing *CmdTesting + Combinedout string + Err error +} + +func TestMain(m *testing.M) { + cmd := exec.Command("go", "build") + out, err := cmd.CombinedOutput() + if err != nil { + panic(string(out)) + } + os.Exit(m.Run()) +} + +func (t *CmdTesting) Run(command string, pkgNames ...string) RunResult { + replayfile := "testlogs/" + strings.ToLower(t.Name()) + ".log" + + flags := []string{"-debug=true"} + if !t.createReplayLogs { + flags = append(flags, "-replayfile="+replayfile) + } + + cmd := exec.Command("./"+binaryName, append(append(flags, command), pkgNames...)...) + combinedout, err := cmd.CombinedOutput() + + if t.createReplayLogs { + err := common.AppendFile(binaryName+".log", t.replayFilename) + if err != nil { + t.T.Fatalf("Error encountered appending log file.\n%s", err.Error()) + } + } + + return RunResult{Testing: t, Combinedout: string(combinedout), Err: err} +} + +func (r *RunResult) ExpectSuccessfulOut(expected string) { + if r.Testing.createReplayLogs { + r.Testing.Log("Skipping test while creating replay logs.") + return + } + + if r.Err != nil { + r.Testing.Errorf("Error running command: %v\n%s", r.Err, r.Combinedout) + return + } + fullExpected := expected + "\n" // Output will always have a end of output newline. + if r.Combinedout != fullExpected { + r.Testing.Errorf("Unexpected combined std[err,out] found.\nExpected:\n'%s'\nActual:\n'%s'", fullExpected, r.Combinedout) + } +} + +func (r *RunResult) ExpectError(expectedCombinedout string) { + if r.Testing.createReplayLogs { + r.Testing.Log("Skipping test while creating replay logs.") + return + } + + fullExpectedCombinedout := expectedCombinedout + "\n" // Output will always have a end of output newline. + if r.Combinedout != fullExpectedCombinedout { + r.Testing.Errorf("Unexpected combined std[err,out] found.\nExpected:\n'%s'\nActual:\n'%s'", fullExpectedCombinedout, r.Combinedout) + } +} diff --git a/src/internal/common/common.go b/src/internal/common/common.go new file mode 100644 index 0000000..1191888 --- /dev/null +++ b/src/internal/common/common.go @@ -0,0 +1,94 @@ +package common + +import ( + "io" + "os" + "path/filepath" +) + +func AppendFile(source string, destination string) error { + err := createDirectoryIfNotPresent(filepath.Dir(destination)) + if err != nil { + return err + } + in, err := os.Open(source) + if err != nil { + return err + } + defer in.Close() + + out, err := os.OpenFile(destination, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0644) + if err != nil { + return err + } + defer out.Close() + + data, err := io.ReadAll(in) + if err != nil { + return err + } + + _, err = out.Write(data) + if err != nil { + return err + } + + return nil +} + +func CopyFile(source string, destination string) error { + err := createDirectoryIfNotPresent(filepath.Dir(destination)) + if err != nil { + return err + } + in, err := os.Open(source) + if err != nil { + return err + } + defer in.Close() + + out, err := os.Create(destination) + if err != nil { + return err + } + defer out.Close() + + data, err := io.ReadAll(in) + if err != nil { + return err + } + + _, err = out.Write(data) + if err != nil { + return err + } + + return nil +} + +func MoveFile(source string, destination string) error { + err := createDirectoryIfNotPresent(filepath.Dir(destination)) + if err != nil { + return err + } + return os.Rename(source, destination) +} + +func ContainsString(arr []string, element string) bool { + for _, x := range arr { + if x == element { + return true + } + } + return false +} + +func createDirectoryIfNotPresent(path string) error { + if _, err := os.Stat(path); os.IsNotExist(err) { + err := os.MkdirAll(path, 0755) + if err != nil { + return err + } + } + return nil +} diff --git a/src/internal/exec/binexecutor.go b/src/internal/exec/binexecutor.go new file mode 100644 index 0000000..d0a442e --- /dev/null +++ b/src/internal/exec/binexecutor.go @@ -0,0 +1,41 @@ +package exec + +import ( + "bytes" + "fmt" + "os/exec" + "strings" + + "awalsh128.com/cache-apt-pkgs-action/src/internal/logging" +) + +// An executor that proxies command executions from the OS. +// +// NOTE: Extra abstraction layer needed for testing and replay. +type BinExecutor struct{} + +func (c *BinExecutor) Exec(name string, arg ...string) *Execution { + cmd := exec.Command(name, arg...) + + var stderr bytes.Buffer + cmd.Stderr = &stderr + + var stdout bytes.Buffer + cmd.Stdout = &stdout + + err := cmd.Run() + execution := &Execution{ + Cmd: name + " " + strings.Join(arg, " "), + Stdout: stdout.String(), + Stderr: stderr.String(), + ExitCode: cmd.ProcessState.ExitCode(), + } + + logging.DebugLazy(func() string { + return fmt.Sprintf("EXECUTION-OBJ-START\n%s\nEXECUTION-OBJ-END", execution.Serialize()) + }) + if err != nil { + logging.Fatal(execution.Error()) + } + return execution +} diff --git a/src/internal/exec/executor.go b/src/internal/exec/executor.go new file mode 100644 index 0000000..160dcc7 --- /dev/null +++ b/src/internal/exec/executor.go @@ -0,0 +1,50 @@ +package exec + +import ( + "encoding/json" + "fmt" + + "awalsh128.com/cache-apt-pkgs-action/src/internal/logging" +) + +type Executor interface { + // Executes a command and either returns the output or exits the programs and writes the output (including error) to STDERR. + Exec(name string, arg ...string) *Execution +} + +type Execution struct { + Cmd string + Stdout string + Stderr string + ExitCode int +} + +// Gets the error, if the command ran with a non-zero exit code. +func (e *Execution) Error() error { + if e.ExitCode == 0 { + return nil + } + return fmt.Errorf( + "Error encountered running %s\nExited with status code %d; see combined output below:\n%s", + e.Cmd, + e.ExitCode, + e.Stdout+e.Stderr, + ) +} + +func DeserializeExecution(payload string) *Execution { + var execution Execution + err := json.Unmarshal([]byte(payload), &execution) + if err != nil { + logging.Fatalf("Error encountered deserializing Execution object.\n%s", err) + } + return &execution +} + +func (e *Execution) Serialize() string { + bytes, err := json.MarshalIndent(e, "", " ") + if err != nil { + logging.Fatalf("Error encountered serializing Execution object.\n%s", err) + } + return string(bytes) +} diff --git a/src/internal/exec/replayexecutor.go b/src/internal/exec/replayexecutor.go new file mode 100644 index 0000000..674c71d --- /dev/null +++ b/src/internal/exec/replayexecutor.go @@ -0,0 +1,74 @@ +package exec + +import ( + "bufio" + "os" + "strings" + + "awalsh128.com/cache-apt-pkgs-action/src/internal/logging" +) + +// An executor that replays execution results from a recorded result. +type ReplayExecutor struct { + logFilepath string + cmdExecs map[string]*Execution +} + +func NewReplayExecutor(logFilepath string) *ReplayExecutor { + file, err := os.Open(logFilepath) + if err != nil { + logging.Fatal(err) + } + defer file.Close() + + scanner := bufio.NewScanner(file) + + cmdExecs := make(map[string]*Execution) + + for scanner.Scan() { + line := scanner.Text() + if strings.Contains(line, "EXECUTION-OBJ-START") { + payload := "" + for scanner.Scan() { + line = scanner.Text() + if strings.Contains(line, "EXECUTION-OBJ-END") { + execution := DeserializeExecution(payload) + cmdExecs[execution.Cmd] = execution + break + } else { + payload += line + "\n" + } + } + } + } + + if err := scanner.Err(); err != nil { + logging.Fatal(err) + } + return &ReplayExecutor{logFilepath, cmdExecs} +} + +func (e *ReplayExecutor) getCmds() []string { + cmds := []string{} + for cmd := range e.cmdExecs { + cmds = append(cmds, cmd) + } + return cmds +} + +func (e *ReplayExecutor) Exec(name string, arg ...string) *Execution { + cmd := name + " " + strings.Join(arg, " ") + value, ok := e.cmdExecs[cmd] + if !ok { + var available string + if len(e.getCmds()) > 0 { + available = "\n" + strings.Join(e.getCmds(), "\n") + } else { + available = " NONE" + } + logging.Fatalf( + "Unable to replay command '%s'.\n"+ + "No command found in the debug log; available commands:%s", cmd, available) + } + return value +} diff --git a/src/internal/logging/logger.go b/src/internal/logging/logger.go new file mode 100644 index 0000000..5b0bcdd --- /dev/null +++ b/src/internal/logging/logger.go @@ -0,0 +1,57 @@ +package logging + +import ( + "fmt" + "log" + "os" + "path/filepath" +) + +type Logger struct { + wrapped *log.Logger + Filename string + Debug bool +} + +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) + os.Exit(2) + } + cwd, _ := os.Getwd() + logger = &Logger{ + wrapped: log.New(file, "", log.LstdFlags), + Filename: filepath.Join(cwd, file.Name()), + Debug: debug, + } + 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 Fatal(err error) { + fmt.Fprintf(os.Stderr, "%s", err.Error()) + logger.wrapped.Fatal(err) +} + +func Fatalf(format string, a ...any) { + fmt.Fprintf(os.Stderr, format+"\n", a...) + logger.wrapped.Fatalf(format, a...) +}